add option to generate covers with non-standard aspect ratio
[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 simple_cover(cls, book):
154         return MEDIA_BASE + book.simple_cover.url if book.simple_cover else ''
155
156
157 class BookDetailHandler(BaseHandler, BookDetails):
158     """ Main handler for Book objects.
159
160     Responsible for single Book details.
161     """
162     allowed_methods = ['GET']
163     fields = ['title', 'parent', 'children'] + Book.formats + [
164         'media', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'simple_cover', 'fragment_data'] + [
165             category_plural[c] for c in book_tag_categories]
166
167     @piwik_track
168     def read(self, request, book):
169         """ Returns details of a book, identified by a slug and lang. """
170         try:
171             return Book.objects.get(slug=book)
172         except Book.DoesNotExist:
173             return rc.NOT_FOUND
174
175
176 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
177     """ Main handler for Book objects.
178
179     Responsible for lists of Book objects.
180     """
181     allowed_methods = ('GET',)
182     model = Book
183     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb', 'slug', 'simple_thumb']
184
185     @classmethod
186     def genres(cls, book):
187         """ Returns all media for a book. """
188         return book.tags.filter(category='genre')
189
190     @piwik_track
191     def read(self, request, tags=None, top_level=False, audiobooks=False, daisy=False, pk=None,
192              recommended=False, newest=False, books=None,
193              after=None, before=None, count=None):
194         """ Lists all books with given tags.
195
196         :param tags: filtering tags; should be a path of categories
197              and slugs, i.e.: authors/an-author/epoch/an-epoch/
198         :param top_level: if True and a book is included in the results,
199              it's children are aren't. By default all books matching the tags
200              are returned.
201         """
202         if pk is not None:
203             try:
204                 return Book.objects.get(pk=pk)
205             except Book.DoesNotExist:
206                 return rc.NOT_FOUND
207
208         try:
209             tags, _ancestors = read_tags(tags, request, allowed=book_tag_categories)
210         except ValueError:
211             return rc.NOT_FOUND
212
213         if 'after' in request.GET:
214             after = request.GET['after']
215         if 'before' in request.GET:
216             before = request.GET['before']
217         if 'count' in request.GET:
218             count = request.GET['count']
219
220         if tags:
221             if top_level:
222                 books = Book.tagged_top_level(tags)
223                 return books if books else rc.NOT_FOUND
224             else:
225                 books = Book.tagged.with_all(tags)
226         else:
227             books = books if books is not None else Book.objects.all()
228         books = books.order_by('slug')
229
230         if top_level:
231             books = books.filter(parent=None)
232         if audiobooks:
233             books = books.filter(media__type='mp3').distinct()
234         if daisy:
235             books = books.filter(media__type='daisy').distinct()
236         if recommended:
237             books = books.filter(recommended=True)
238         if newest:
239             books = books.order_by('-created_at')
240
241         if after:
242             books = books.filter(slug__gt=after)
243         if before:
244             books = books.filter(slug__lt=before)
245
246         books = books.only('slug', 'title', 'cover', 'cover_thumb')
247         for category in book_tag_categories:
248             books = prefetch_relations(books, category)
249
250         if count:
251             if before:
252                 books = list(reversed(books.order_by('-slug')[:count]))
253             else:
254                 books = books[:count]
255
256         return books
257
258     def create(self, request, *args, **kwargs):
259         return rc.FORBIDDEN
260
261
262 class BooksHandler(BookDetailHandler):
263     allowed_methods = ('GET', 'POST')
264     model = Book
265     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb', 'slug']
266     anonymous = AnonymousBooksHandler
267
268     def create(self, request, *args, **kwargs):
269         if not request.user.has_perm('catalogue.add_book'):
270             return rc.FORBIDDEN
271
272         data = json.loads(request.POST.get('data'))
273         form = BookImportForm(data)
274         if form.is_valid():
275             form.save()
276             return rc.CREATED
277         else:
278             return rc.NOT_FOUND
279
280
281 class EBooksHandler(AnonymousBooksHandler):
282     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
283
284
285 class BookProxy(models.Model):
286     class Meta:
287         managed = False
288
289     def __init__(self, book, key):
290         self.book = book
291         self.key = key
292
293     def __getattr__(self, item):
294         if item not in ('book', 'key'):
295             return self.book.__getattribute__(item)
296         else:
297             return self.__getattribute__(item)
298
299
300 class QuerySetProxy(models.QuerySet):
301     def __init__(self, l):
302         self.list = l
303
304     def __iter__(self):
305         return iter(self.list)
306
307
308 class FilterBooksHandler(AnonymousBooksHandler):
309     fields = book_tag_categories + [
310         'href', 'title', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'slug', 'key']
311
312     def read(self, request):
313         key_sep = '$'
314         search_string = request.GET.get('search')
315         is_lektura = request.GET.get('lektura')
316         is_audiobook = request.GET.get('audiobook')
317
318         after = request.GET.get('after')
319         count = int(request.GET.get('count', 50))
320         if is_lektura in ('true', 'false'):
321             is_lektura = is_lektura == 'true'
322         else:
323             is_lektura = None
324         if is_audiobook in ('true', 'false'):
325             is_audiobook = is_audiobook == 'true'
326         books = Book.objects.distinct().order_by('slug')
327         if is_lektura is not None:
328             books = books.filter(has_audience=is_lektura)
329         if is_audiobook is not None:
330             if is_audiobook:
331                 books = books.filter(media__type='mp3')
332             else:
333                 books = books.exclude(media__type='mp3')
334         for key in request.GET:
335             if key in category_singular:
336                 category = category_singular[key]
337                 if category in book_tag_categories:
338                     slugs = request.GET[key].split(',')
339                     tags = Tag.objects.filter(category=category, slug__in=slugs)
340                     books = Book.tagged.with_any(tags, books)
341         if (search_string is not None) and len(search_string) < 3:
342             search_string = None
343         if search_string:
344             search_string = re_escape(search_string)
345             books_author = books.filter(cached_author__iregex='\m' + search_string)
346             books_title = books.filter(title__iregex='\m' + search_string)
347             books_title = books_title.exclude(id__in=list(books_author.values_list('id', flat=True)))
348             if after and (key_sep in after):
349                 which, slug = after.split(key_sep, 1)
350                 if which == 'title':
351                     book_lists = [(books_title.filter(slug__gt=slug), 'title')]
352                 else:  # which == 'author'
353                     book_lists = [(books_author.filter(slug__gt=slug), 'author'), (books_title, 'title')]
354             else:
355                 book_lists = [(books_author, 'author'), (books_title, 'title')]
356         else:
357             if after and key_sep in after:
358                 which, slug = after.split(key_sep, 1)
359                 books = books.filter(slug__gt=slug)
360             book_lists = [(books, 'book')]
361
362         filtered_books = []
363         for book_list, label in book_lists:
364             book_list = book_list.only('slug', 'title', 'cover', 'cover_thumb')
365             for category in book_tag_categories:
366                 book_list = prefetch_relations(book_list, category)
367             remaining_count = count - len(filtered_books)
368             new_books = [BookProxy(book, '%s%s%s' % (label, key_sep, book.slug))
369                          for book in book_list[:remaining_count]]
370             filtered_books += new_books
371             if len(filtered_books) == count:
372                 break
373
374         return QuerySetProxy(filtered_books)
375
376
377 # add categorized tags fields for Book
378 def _tags_getter(category):
379     @classmethod
380     def get_tags(cls, book):
381         return book.tags.filter(category=category)
382     return get_tags
383
384
385 def _tag_getter(category):
386     @classmethod
387     def get_tag(cls, book):
388         return book.tag_unicode(category)
389     return get_tag
390
391
392 def add_tag_getters():
393     for plural, singular in category_singular.items():
394         setattr(BookDetails, plural, _tags_getter(singular))
395         setattr(BookDetails, singular, _tag_getter(singular))
396
397 add_tag_getters()
398
399
400 # add fields for files in Book
401 def _file_getter(book_format):
402     field = "%s_file" % book_format
403
404     @classmethod
405     def get_file(cls, book):
406         f = getattr(book, field)
407         if f:
408             return MEDIA_BASE + f.url
409         else:
410             return ''
411     return get_file
412
413
414 def add_file_getters():
415     for book_format in Book.formats:
416         setattr(BookDetails, book_format, _file_getter(book_format))
417
418 add_file_getters()
419
420
421 class CollectionDetails(object):
422     """Custom Collection fields."""
423
424     @classmethod
425     def href(cls, collection):
426         """ Returns URI in the API for the collection. """
427
428         return API_BASE + reverse("api_collection", args=[collection.slug])
429
430     @classmethod
431     def url(cls, collection):
432         """ Returns URL on the site. """
433
434         return WL_BASE + collection.get_absolute_url()
435
436     @classmethod
437     def books(cls, collection):
438         return Book.objects.filter(collection.get_query())
439
440
441 class CollectionDetailHandler(BaseHandler, CollectionDetails):
442     allowed_methods = ('GET',)
443     fields = ['url', 'title', 'description', 'books']
444
445     @piwik_track
446     def read(self, request, slug):
447         """ Returns details of a collection, identified by slug. """
448         try:
449             return Collection.objects.get(slug=slug)
450         except Collection.DoesNotExist:
451             return rc.NOT_FOUND
452
453
454 class CollectionsHandler(BaseHandler, CollectionDetails):
455     allowed_methods = ('GET',)
456     model = Collection
457     fields = ['url', 'href', 'title']
458
459     @piwik_track
460     def read(self, request):
461         """ Returns all collections. """
462         return Collection.objects.all()
463
464
465 class TagDetails(object):
466     """Custom Tag fields."""
467
468     @classmethod
469     def href(cls, tag):
470         """ Returns URI in the API for the tag. """
471
472         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
473
474     @classmethod
475     def url(cls, tag):
476         """ Returns URL on the site. """
477
478         return WL_BASE + tag.get_absolute_url()
479
480
481 class TagDetailHandler(BaseHandler, TagDetails):
482     """ Responsible for details of a single Tag object. """
483
484     fields = ['name', 'url', 'sort_key', 'description']
485
486     @piwik_track
487     def read(self, request, category, slug):
488         """ Returns details of a tag, identified by category and slug. """
489
490         try:
491             category_sng = category_singular[category]
492         except KeyError:
493             return rc.NOT_FOUND
494
495         try:
496             return Tag.objects.get(category=category_sng, slug=slug)
497         except Tag.DoesNotExist:
498             return rc.NOT_FOUND
499
500
501 class TagsHandler(BaseHandler, TagDetails):
502     """ Main handler for Tag objects.
503
504     Responsible for lists of Tag objects
505     and fields used for representing Tags.
506
507     """
508     allowed_methods = ('GET',)
509     model = Tag
510     fields = ['name', 'href', 'url', 'slug']
511
512     @piwik_track
513     def read(self, request, category=None, pk=None):
514         """ Lists all tags in the category (eg. all themes). """
515         if pk is not None:
516             try:
517                 return Tag.objects.exclude(category='set').get(pk=pk)
518             except Book.DoesNotExist:
519                 return rc.NOT_FOUND
520
521         try:
522             category_sng = category_singular[category]
523         except KeyError:
524             return rc.NOT_FOUND
525
526         after = request.GET.get('after')
527         before = request.GET.get('before')
528         count = request.GET.get('count')
529
530         tags = Tag.objects.filter(category=category_sng).exclude(items=None).order_by('slug')
531
532         book_only = request.GET.get('book_only') == 'true'
533         picture_only = request.GET.get('picture_only') == 'true'
534         if book_only:
535             tags = tags.filter(for_books=True)
536         if picture_only:
537             tags = tags.filter(for_pictures=True)
538
539         if after:
540             tags = tags.filter(slug__gt=after)
541         if before:
542             tags = tags.filter(slug__lt=before)
543
544         if count:
545             if before:
546                 tags = list(reversed(tags.order_by('-slug')[:count]))
547             else:
548                 tags = tags[:count]
549
550         return tags
551
552
553 class FragmentDetails(object):
554     """Custom Fragment fields."""
555
556     @classmethod
557     def href(cls, fragment):
558         """ Returns URI in the API for the fragment. """
559
560         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
561
562     @classmethod
563     def url(cls, fragment):
564         """ Returns URL on the site for the fragment. """
565
566         return WL_BASE + fragment.get_absolute_url()
567
568     @classmethod
569     def themes(cls, fragment):
570         """ Returns a list of theme tags for the fragment. """
571
572         return fragment.tags.filter(category='theme')
573
574
575 class FragmentDetailHandler(BaseHandler, FragmentDetails):
576     fields = ['book', 'anchor', 'text', 'url', 'themes']
577
578     @piwik_track
579     def read(self, request, book, anchor):
580         """ Returns details of a fragment, identified by book slug and anchor. """
581         try:
582             return Fragment.objects.get(book__slug=book, anchor=anchor)
583         except Fragment.DoesNotExist:
584             return rc.NOT_FOUND
585
586
587 class FragmentsHandler(BaseHandler, FragmentDetails):
588     """ Main handler for Fragments.
589
590     Responsible for lists of Fragment objects
591     and fields used for representing Fragments.
592
593     """
594     model = Fragment
595     fields = ['book', 'url', 'anchor', 'href']
596     allowed_methods = ('GET',)
597
598     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
599
600     @piwik_track
601     def read(self, request, tags):
602         """ Lists all fragments with given book, tags, themes.
603
604         :param tags: should be a path of categories and slugs, i.e.:
605              books/book-slug/authors/an-author/themes/a-theme/
606
607         """
608         try:
609             tags, ancestors = read_tags(tags, allowed=self.categories)
610         except ValueError:
611             return rc.NOT_FOUND
612         fragments = Fragment.tagged.with_all(tags).select_related('book')
613         if fragments.exists():
614             return fragments
615         else:
616             return rc.NOT_FOUND
617
618
619 class PictureHandler(BaseHandler):
620     model = Picture
621     fields = ('slug', 'title')
622     allowed_methods = ('POST',)
623
624     def create(self, request):
625         if not request.user.has_perm('picture.add_picture'):
626             return rc.FORBIDDEN
627
628         data = json.loads(request.POST.get('data'))
629         form = PictureImportForm(data)
630         if form.is_valid():
631             form.save()
632             return rc.CREATED
633         else:
634             return rc.NOT_FOUND