cover thumb with no box for api
[wolnelektury.git] / src / api / handlers.py
1 # -*- coding: utf-8 -*-
2 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
3 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
4 #
5 import json
6
7 from django.contrib.sites.models import Site
8 from django.core.urlresolvers import reverse
9 from django.utils.functional import lazy
10 from django.db import models
11 from piston.handler import AnonymousBaseHandler, BaseHandler
12 from piston.utils import rc
13 from sorl.thumbnail import default
14
15 from catalogue.forms import BookImportForm
16 from catalogue.models import Book, Tag, BookMedia, Fragment, Collection
17 from catalogue.models.tag import prefetch_relations
18 from picture.models import Picture
19 from picture.forms import PictureImportForm
20
21 from stats.utils import piwik_track
22 from wolnelektury.utils import re_escape
23
24 from . import emitters  # Register our emitters
25
26 API_BASE = WL_BASE = MEDIA_BASE = lazy(
27     lambda: u'http://' + Site.objects.get_current().domain, unicode)()
28
29
30 category_singular = {
31     'authors': 'author',
32     'kinds': 'kind',
33     'genres': 'genre',
34     'epochs': 'epoch',
35     'themes': 'theme',
36     'books': 'book',
37 }
38 category_plural = {}
39 for k, v in category_singular.items():
40     category_plural[v] = k
41
42 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
43
44
45 def read_tags(tags, request, allowed):
46     """ Reads a path of filtering tags.
47
48     :param str tags: a path of category and slug pairs, like: authors/an-author/...
49     :returns: list of Tag objects
50     :raises: ValueError when tags can't be found
51     """
52
53     def process(category, slug):
54         if category == 'book':
55             try:
56                 books.append(Book.objects.get(slug=slug))
57             except Book.DoesNotExist:
58                 raise ValueError('Unknown book.')
59         try:
60             real_tags.append(Tag.objects.get(category=category, slug=slug))
61         except Tag.DoesNotExist:
62             raise ValueError('Tag not found')
63
64     if not tags:
65         return [], []
66
67     tags = tags.strip('/').split('/')
68     real_tags = []
69     books = []
70     while tags:
71         category = tags.pop(0)
72         slug = tags.pop(0)
73
74         try:
75             category = category_singular[category]
76         except KeyError:
77             raise ValueError('Unknown category.')
78
79         if category not in allowed:
80             raise ValueError('Category not allowed.')
81         process(category, slug)
82
83     for key in request.GET:
84         if key in category_singular:
85             category = category_singular[key]
86             if category in allowed:
87                 for slug in request.GET.getlist(key):
88                     process(category, slug)
89     return real_tags, books
90
91
92 # RESTful handlers
93
94
95 class BookMediaHandler(BaseHandler):
96     """ Responsible for representing media in Books. """
97
98     model = BookMedia
99     fields = ['name', 'type', 'url', 'artist', 'director']
100
101     @classmethod
102     def url(cls, media):
103         """ Link to media on site. """
104
105         return MEDIA_BASE + media.file.url
106
107     @classmethod
108     def artist(cls, media):
109         return media.extra_info.get('artist_name', '')
110
111     @classmethod
112     def director(cls, media):
113         return media.extra_info.get('director_name', '')
114
115
116 class BookDetails(object):
117     """Custom fields used for representing Books."""
118
119     @classmethod
120     def href(cls, book):
121         """ Returns an URI for a Book in the API. """
122         return API_BASE + reverse("api_book", args=[book.slug])
123
124     @classmethod
125     def url(cls, book):
126         """ Returns Book's URL on the site. """
127         return WL_BASE + book.get_absolute_url()
128
129     @classmethod
130     def children(cls, book):
131         """ Returns all children for a book. """
132         return book.children.order_by('parent_number', 'sort_key')
133
134     @classmethod
135     def media(cls, book):
136         """ Returns all media for a book. """
137         return book.media.all()
138
139     @classmethod
140     def cover(cls, book):
141         return MEDIA_BASE + book.cover.url if book.cover else ''
142
143     @classmethod
144     def cover_thumb(cls, book):
145         return MEDIA_BASE + default.backend.get_thumbnail(
146                     book.cover, "139x193").url if book.cover else ''
147
148     @classmethod
149     def simple_thumb(cls, book):
150         return MEDIA_BASE + book.cover_api_thumb.url if book.cover_api_thumb else ''
151
152     @classmethod
153     def cover_source_image(cls, book):
154         url = book.cover_source()
155         return url.rstrip('/') + '/file/'
156
157
158 class BookDetailHandler(BaseHandler, BookDetails):
159     """ Main handler for Book objects.
160
161     Responsible for single Book details.
162     """
163     allowed_methods = ['GET']
164     fields = ['title', 'parent', 'children'] + Book.formats + [
165         'media', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'fragment_data'] + [
166             category_plural[c] for c in book_tag_categories]
167
168     @piwik_track
169     def read(self, request, book):
170         """ Returns details of a book, identified by a slug and lang. """
171         try:
172             return Book.objects.get(slug=book)
173         except Book.DoesNotExist:
174             return rc.NOT_FOUND
175
176
177 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
178     """ Main handler for Book objects.
179
180     Responsible for lists of Book objects.
181     """
182     allowed_methods = ('GET',)
183     model = Book
184     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb', 'slug', 'simple_thumb']
185
186     @classmethod
187     def genres(cls, book):
188         """ Returns all media for a book. """
189         return book.tags.filter(category='genre')
190
191     @piwik_track
192     def read(self, request, tags=None, top_level=False, audiobooks=False, daisy=False, pk=None,
193              recommended=False, newest=False, books=None,
194              after=None, before=None, count=None):
195         """ Lists all books with given tags.
196
197         :param tags: filtering tags; should be a path of categories
198              and slugs, i.e.: authors/an-author/epoch/an-epoch/
199         :param top_level: if True and a book is included in the results,
200              it's children are aren't. By default all books matching the tags
201              are returned.
202         """
203         if pk is not None:
204             try:
205                 return Book.objects.get(pk=pk)
206             except Book.DoesNotExist:
207                 return rc.NOT_FOUND
208
209         try:
210             tags, _ancestors = read_tags(tags, request, allowed=book_tag_categories)
211         except ValueError:
212             return rc.NOT_FOUND
213
214         if 'after' in request.GET:
215             after = request.GET['after']
216         if 'before' in request.GET:
217             before = request.GET['before']
218         if 'count' in request.GET:
219             count = request.GET['count']
220
221         if tags:
222             if top_level:
223                 books = Book.tagged_top_level(tags)
224                 return books if books else rc.NOT_FOUND
225             else:
226                 books = Book.tagged.with_all(tags)
227         else:
228             books = books if books is not None else Book.objects.all()
229         books = books.order_by('slug')
230
231         if top_level:
232             books = books.filter(parent=None)
233         if audiobooks:
234             books = books.filter(media__type='mp3').distinct()
235         if daisy:
236             books = books.filter(media__type='daisy').distinct()
237         if recommended:
238             books = books.filter(recommended=True)
239         if newest:
240             books = books.order_by('-created_at')
241
242         if after:
243             books = books.filter(slug__gt=after)
244         if before:
245             books = books.filter(slug__lt=before)
246
247         books = books.only('slug', 'title', 'cover', 'cover_thumb')
248         for category in book_tag_categories:
249             books = prefetch_relations(books, category)
250
251         if count:
252             if before:
253                 books = list(reversed(books.order_by('-slug')[:count]))
254             else:
255                 books = books[:count]
256
257         return books
258
259     def create(self, request, *args, **kwargs):
260         return rc.FORBIDDEN
261
262
263 class BooksHandler(BookDetailHandler):
264     allowed_methods = ('GET', 'POST')
265     model = Book
266     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb', 'slug']
267     anonymous = AnonymousBooksHandler
268
269     def create(self, request, *args, **kwargs):
270         if not request.user.has_perm('catalogue.add_book'):
271             return rc.FORBIDDEN
272
273         data = json.loads(request.POST.get('data'))
274         form = BookImportForm(data)
275         if form.is_valid():
276             form.save()
277             return rc.CREATED
278         else:
279             return rc.NOT_FOUND
280
281
282 class EBooksHandler(AnonymousBooksHandler):
283     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
284
285
286 class BookProxy(models.Model):
287     def __init__(self, book, key):
288         self.book = book
289         self.key = key
290
291     def __getattr__(self, item):
292         if item not in ('book', 'key'):
293             return self.book.__getattribute__(item)
294         else:
295             return self.__getattribute__(item)
296
297
298 class QuerySetProxy(models.QuerySet):
299     def __init__(self, l):
300         self.list = l
301
302     def __iter__(self):
303         return iter(self.list)
304
305
306 class FilterBooksHandler(AnonymousBooksHandler):
307     fields = book_tag_categories + [
308         'href', 'title', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'slug', 'key', 'cover_source_image']
309
310     def read(self, request):
311         key_sep = '$'
312         search_string = request.GET.get('search')
313         is_lektura = request.GET.get('lektura')
314         is_audiobook = request.GET.get('audiobook')
315
316         after = request.GET.get('after')
317         count = int(request.GET.get('count', 50))
318         if is_lektura in ('true', 'false'):
319             is_lektura = is_lektura == 'true'
320         else:
321             is_lektura = None
322         if is_audiobook in ('true', 'false'):
323             is_audiobook = is_audiobook == 'true'
324         books = Book.objects.distinct().order_by('slug')
325         if is_lektura is not None:
326             books = books.filter(has_audience=is_lektura)
327         if is_audiobook is not None:
328             if is_audiobook:
329                 books = books.filter(media__type='mp3')
330             else:
331                 books = books.exclude(media__type='mp3')
332         for key in request.GET:
333             if key in category_singular:
334                 category = category_singular[key]
335                 if category in book_tag_categories:
336                     slugs = request.GET[key].split(',')
337                     tags = Tag.objects.filter(category=category, slug__in=slugs)
338                     books = Book.tagged.with_any(tags, books)
339         if (search_string is not None) and len(search_string) < 3:
340             search_string = None
341         if search_string:
342             search_string = re_escape(search_string)
343             books_author = books.filter(cached_author__iregex='\m' + search_string)
344             books_title = books.filter(title__iregex='\m' + search_string)
345             books_title = books_title.exclude(id__in=list(books_author.values_list('id', flat=True)))
346             if after and (key_sep in after):
347                 which, slug = after.split(key_sep, 1)
348                 if which == 'title':
349                     book_lists = [(books_title.filter(slug__gt=slug), 'title')]
350                 else:  # which == 'author'
351                     book_lists = [(books_author.filter(slug__gt=slug), 'author'), (books_title, 'title')]
352             else:
353                 book_lists = [(books_author, 'author'), (books_title, 'title')]
354         else:
355             if after and key_sep in after:
356                 which, slug = after.split(key_sep, 1)
357                 books = books.filter(slug__gt=slug)
358             book_lists = [(books, 'book')]
359
360         filtered_books = []
361         for book_list, label in book_lists:
362             book_list = book_list.only('slug', 'title', 'cover', 'cover_thumb')
363             for category in book_tag_categories:
364                 book_list = prefetch_relations(book_list, category)
365             remaining_count = count - len(filtered_books)
366             new_books = [BookProxy(book, '%s%s%s' % (label, key_sep, book.slug))
367                          for book in book_list[:remaining_count]]
368             filtered_books += new_books
369             if len(filtered_books) == count:
370                 break
371
372         return QuerySetProxy(filtered_books)
373
374
375 # add categorized tags fields for Book
376 def _tags_getter(category):
377     @classmethod
378     def get_tags(cls, book):
379         return book.tags.filter(category=category)
380     return get_tags
381
382
383 def _tag_getter(category):
384     @classmethod
385     def get_tag(cls, book):
386         return book.tag_unicode(category)
387     return get_tag
388
389
390 def add_tag_getters():
391     for plural, singular in category_singular.items():
392         setattr(BookDetails, plural, _tags_getter(singular))
393         setattr(BookDetails, singular, _tag_getter(singular))
394
395 add_tag_getters()
396
397
398 # add fields for files in Book
399 def _file_getter(book_format):
400     field = "%s_file" % book_format
401
402     @classmethod
403     def get_file(cls, book):
404         f = getattr(book, field)
405         if f:
406             return MEDIA_BASE + f.url
407         else:
408             return ''
409     return get_file
410
411
412 def add_file_getters():
413     for book_format in Book.formats:
414         setattr(BookDetails, book_format, _file_getter(book_format))
415
416 add_file_getters()
417
418
419 class CollectionDetails(object):
420     """Custom Collection fields."""
421
422     @classmethod
423     def href(cls, collection):
424         """ Returns URI in the API for the collection. """
425
426         return API_BASE + reverse("api_collection", args=[collection.slug])
427
428     @classmethod
429     def url(cls, collection):
430         """ Returns URL on the site. """
431
432         return WL_BASE + collection.get_absolute_url()
433
434     @classmethod
435     def books(cls, collection):
436         return Book.objects.filter(collection.get_query())
437
438
439 class CollectionDetailHandler(BaseHandler, CollectionDetails):
440     allowed_methods = ('GET',)
441     fields = ['url', 'title', 'description', 'books']
442
443     @piwik_track
444     def read(self, request, slug):
445         """ Returns details of a collection, identified by slug. """
446         try:
447             return Collection.objects.get(slug=slug)
448         except Collection.DoesNotExist:
449             return rc.NOT_FOUND
450
451
452 class CollectionsHandler(BaseHandler, CollectionDetails):
453     allowed_methods = ('GET',)
454     model = Collection
455     fields = ['url', 'href', 'title']
456
457     @piwik_track
458     def read(self, request):
459         """ Returns all collections. """
460         return Collection.objects.all()
461
462
463 class TagDetails(object):
464     """Custom Tag fields."""
465
466     @classmethod
467     def href(cls, tag):
468         """ Returns URI in the API for the tag. """
469
470         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
471
472     @classmethod
473     def url(cls, tag):
474         """ Returns URL on the site. """
475
476         return WL_BASE + tag.get_absolute_url()
477
478
479 class TagDetailHandler(BaseHandler, TagDetails):
480     """ Responsible for details of a single Tag object. """
481
482     fields = ['name', 'url', 'sort_key', 'description']
483
484     @piwik_track
485     def read(self, request, category, slug):
486         """ Returns details of a tag, identified by category and slug. """
487
488         try:
489             category_sng = category_singular[category]
490         except KeyError:
491             return rc.NOT_FOUND
492
493         try:
494             return Tag.objects.get(category=category_sng, slug=slug)
495         except Tag.DoesNotExist:
496             return rc.NOT_FOUND
497
498
499 class TagsHandler(BaseHandler, TagDetails):
500     """ Main handler for Tag objects.
501
502     Responsible for lists of Tag objects
503     and fields used for representing Tags.
504
505     """
506     allowed_methods = ('GET',)
507     model = Tag
508     fields = ['name', 'href', 'url', 'slug']
509
510     @piwik_track
511     def read(self, request, category=None, pk=None):
512         """ Lists all tags in the category (eg. all themes). """
513         if pk is not None:
514             try:
515                 return Tag.objects.exclude(category='set').get(pk=pk)
516             except Book.DoesNotExist:
517                 return rc.NOT_FOUND
518
519         try:
520             category_sng = category_singular[category]
521         except KeyError:
522             return rc.NOT_FOUND
523
524         after = request.GET.get('after')
525         before = request.GET.get('before')
526         count = request.GET.get('count')
527
528         tags = Tag.objects.filter(category=category_sng).exclude(items=None).order_by('slug')
529
530         book_only = request.GET.get('book_only') == 'true'
531         picture_only = request.GET.get('picture_only') == 'true'
532         if book_only:
533             tags = tags.filter(for_books=True)
534         if picture_only:
535             tags = tags.filter(for_pictures=True)
536
537         if after:
538             tags = tags.filter(slug__gt=after)
539         if before:
540             tags = tags.filter(slug__lt=before)
541
542         if count:
543             if before:
544                 tags = list(reversed(tags.order_by('-slug')[:count]))
545             else:
546                 tags = tags[:count]
547
548         return tags
549
550
551 class FragmentDetails(object):
552     """Custom Fragment fields."""
553
554     @classmethod
555     def href(cls, fragment):
556         """ Returns URI in the API for the fragment. """
557
558         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
559
560     @classmethod
561     def url(cls, fragment):
562         """ Returns URL on the site for the fragment. """
563
564         return WL_BASE + fragment.get_absolute_url()
565
566     @classmethod
567     def themes(cls, fragment):
568         """ Returns a list of theme tags for the fragment. """
569
570         return fragment.tags.filter(category='theme')
571
572
573 class FragmentDetailHandler(BaseHandler, FragmentDetails):
574     fields = ['book', 'anchor', 'text', 'url', 'themes']
575
576     @piwik_track
577     def read(self, request, book, anchor):
578         """ Returns details of a fragment, identified by book slug and anchor. """
579         try:
580             return Fragment.objects.get(book__slug=book, anchor=anchor)
581         except Fragment.DoesNotExist:
582             return rc.NOT_FOUND
583
584
585 class FragmentsHandler(BaseHandler, FragmentDetails):
586     """ Main handler for Fragments.
587
588     Responsible for lists of Fragment objects
589     and fields used for representing Fragments.
590
591     """
592     model = Fragment
593     fields = ['book', 'url', 'anchor', 'href']
594     allowed_methods = ('GET',)
595
596     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
597
598     @piwik_track
599     def read(self, request, tags):
600         """ Lists all fragments with given book, tags, themes.
601
602         :param tags: should be a path of categories and slugs, i.e.:
603              books/book-slug/authors/an-author/themes/a-theme/
604
605         """
606         try:
607             tags, ancestors = read_tags(tags, allowed=self.categories)
608         except ValueError:
609             return rc.NOT_FOUND
610         fragments = Fragment.tagged.with_all(tags).select_related('book')
611         if fragments.exists():
612             return fragments
613         else:
614             return rc.NOT_FOUND
615
616
617 class PictureHandler(BaseHandler):
618     model = Picture
619     fields = ('slug', 'title')
620     allowed_methods = ('POST',)
621
622     def create(self, request):
623         if not request.user.has_perm('picture.add_picture'):
624             return rc.FORBIDDEN
625
626         data = json.loads(request.POST.get('data'))
627         form = PictureImportForm(data)
628         if form.is_valid():
629             form.save()
630             return rc.CREATED
631         else:
632             return rc.NOT_FOUND