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