88fa1c3dbba8d7e5d04759457eededa38bed657b
[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     def __init__(self, book, key):
287         self.book = book
288         self.key = key
289
290     def __getattr__(self, item):
291         if item not in ('book', 'key'):
292             return self.book.__getattribute__(item)
293         else:
294             return self.__getattribute__(item)
295
296
297 class QuerySetProxy(models.QuerySet):
298     def __init__(self, l):
299         self.list = l
300
301     def __iter__(self):
302         return iter(self.list)
303
304
305 class FilterBooksHandler(AnonymousBooksHandler):
306     fields = book_tag_categories + [
307         'href', 'title', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'slug', 'key']
308
309     def read(self, request):
310         key_sep = '$'
311         search_string = request.GET.get('search')
312         is_lektura = request.GET.get('lektura')
313         is_audiobook = request.GET.get('audiobook')
314
315         after = request.GET.get('after')
316         count = int(request.GET.get('count', 50))
317         if is_lektura in ('true', 'false'):
318             is_lektura = is_lektura == 'true'
319         else:
320             is_lektura = None
321         if is_audiobook in ('true', 'false'):
322             is_audiobook = is_audiobook == 'true'
323         books = Book.objects.distinct().order_by('slug')
324         if is_lektura is not None:
325             books = books.filter(has_audience=is_lektura)
326         if is_audiobook is not None:
327             if is_audiobook:
328                 books = books.filter(media__type='mp3')
329             else:
330                 books = books.exclude(media__type='mp3')
331         for key in request.GET:
332             if key in category_singular:
333                 category = category_singular[key]
334                 if category in book_tag_categories:
335                     slugs = request.GET[key].split(',')
336                     tags = Tag.objects.filter(category=category, slug__in=slugs)
337                     books = Book.tagged.with_any(tags, books)
338         if (search_string is not None) and len(search_string) < 3:
339             search_string = None
340         if search_string:
341             search_string = re_escape(search_string)
342             books_author = books.filter(cached_author__iregex='\m' + search_string)
343             books_title = books.filter(title__iregex='\m' + search_string)
344             books_title = books_title.exclude(id__in=list(books_author.values_list('id', flat=True)))
345             if after and (key_sep in after):
346                 which, slug = after.split(key_sep, 1)
347                 if which == 'title':
348                     book_lists = [(books_title.filter(slug__gt=slug), 'title')]
349                 else:  # which == 'author'
350                     book_lists = [(books_author.filter(slug__gt=slug), 'author'), (books_title, 'title')]
351             else:
352                 book_lists = [(books_author, 'author'), (books_title, 'title')]
353         else:
354             if after and key_sep in after:
355                 which, slug = after.split(key_sep, 1)
356                 books = books.filter(slug__gt=slug)
357             book_lists = [(books, 'book')]
358
359         filtered_books = []
360         for book_list, label in book_lists:
361             book_list = book_list.only('slug', 'title', 'cover', 'cover_thumb')
362             for category in book_tag_categories:
363                 book_list = prefetch_relations(book_list, category)
364             remaining_count = count - len(filtered_books)
365             new_books = [BookProxy(book, '%s%s%s' % (label, key_sep, book.slug))
366                          for book in book_list[:remaining_count]]
367             filtered_books += new_books
368             if len(filtered_books) == count:
369                 break
370
371         return QuerySetProxy(filtered_books)
372
373
374 # add categorized tags fields for Book
375 def _tags_getter(category):
376     @classmethod
377     def get_tags(cls, book):
378         return book.tags.filter(category=category)
379     return get_tags
380
381
382 def _tag_getter(category):
383     @classmethod
384     def get_tag(cls, book):
385         return book.tag_unicode(category)
386     return get_tag
387
388
389 def add_tag_getters():
390     for plural, singular in category_singular.items():
391         setattr(BookDetails, plural, _tags_getter(singular))
392         setattr(BookDetails, singular, _tag_getter(singular))
393
394 add_tag_getters()
395
396
397 # add fields for files in Book
398 def _file_getter(book_format):
399     field = "%s_file" % book_format
400
401     @classmethod
402     def get_file(cls, book):
403         f = getattr(book, field)
404         if f:
405             return MEDIA_BASE + f.url
406         else:
407             return ''
408     return get_file
409
410
411 def add_file_getters():
412     for book_format in Book.formats:
413         setattr(BookDetails, book_format, _file_getter(book_format))
414
415 add_file_getters()
416
417
418 class CollectionDetails(object):
419     """Custom Collection fields."""
420
421     @classmethod
422     def href(cls, collection):
423         """ Returns URI in the API for the collection. """
424
425         return API_BASE + reverse("api_collection", args=[collection.slug])
426
427     @classmethod
428     def url(cls, collection):
429         """ Returns URL on the site. """
430
431         return WL_BASE + collection.get_absolute_url()
432
433     @classmethod
434     def books(cls, collection):
435         return Book.objects.filter(collection.get_query())
436
437
438 class CollectionDetailHandler(BaseHandler, CollectionDetails):
439     allowed_methods = ('GET',)
440     fields = ['url', 'title', 'description', 'books']
441
442     @piwik_track
443     def read(self, request, slug):
444         """ Returns details of a collection, identified by slug. """
445         try:
446             return Collection.objects.get(slug=slug)
447         except Collection.DoesNotExist:
448             return rc.NOT_FOUND
449
450
451 class CollectionsHandler(BaseHandler, CollectionDetails):
452     allowed_methods = ('GET',)
453     model = Collection
454     fields = ['url', 'href', 'title']
455
456     @piwik_track
457     def read(self, request):
458         """ Returns all collections. """
459         return Collection.objects.all()
460
461
462 class TagDetails(object):
463     """Custom Tag fields."""
464
465     @classmethod
466     def href(cls, tag):
467         """ Returns URI in the API for the tag. """
468
469         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
470
471     @classmethod
472     def url(cls, tag):
473         """ Returns URL on the site. """
474
475         return WL_BASE + tag.get_absolute_url()
476
477
478 class TagDetailHandler(BaseHandler, TagDetails):
479     """ Responsible for details of a single Tag object. """
480
481     fields = ['name', 'url', 'sort_key', 'description']
482
483     @piwik_track
484     def read(self, request, category, slug):
485         """ Returns details of a tag, identified by category and slug. """
486
487         try:
488             category_sng = category_singular[category]
489         except KeyError:
490             return rc.NOT_FOUND
491
492         try:
493             return Tag.objects.get(category=category_sng, slug=slug)
494         except Tag.DoesNotExist:
495             return rc.NOT_FOUND
496
497
498 class TagsHandler(BaseHandler, TagDetails):
499     """ Main handler for Tag objects.
500
501     Responsible for lists of Tag objects
502     and fields used for representing Tags.
503
504     """
505     allowed_methods = ('GET',)
506     model = Tag
507     fields = ['name', 'href', 'url', 'slug']
508
509     @piwik_track
510     def read(self, request, category=None, pk=None):
511         """ Lists all tags in the category (eg. all themes). """
512         if pk is not None:
513             try:
514                 return Tag.objects.exclude(category='set').get(pk=pk)
515             except Book.DoesNotExist:
516                 return rc.NOT_FOUND
517
518         try:
519             category_sng = category_singular[category]
520         except KeyError:
521             return rc.NOT_FOUND
522
523         after = request.GET.get('after')
524         before = request.GET.get('before')
525         count = request.GET.get('count')
526
527         tags = Tag.objects.filter(category=category_sng).exclude(items=None).order_by('slug')
528
529         book_only = request.GET.get('book_only') == 'true'
530         picture_only = request.GET.get('picture_only') == 'true'
531         if book_only:
532             tags = tags.filter(for_books=True)
533         if picture_only:
534             tags = tags.filter(for_pictures=True)
535
536         if after:
537             tags = tags.filter(slug__gt=after)
538         if before:
539             tags = tags.filter(slug__lt=before)
540
541         if count:
542             if before:
543                 tags = list(reversed(tags.order_by('-slug')[:count]))
544             else:
545                 tags = tags[:count]
546
547         return tags
548
549
550 class FragmentDetails(object):
551     """Custom Fragment fields."""
552
553     @classmethod
554     def href(cls, fragment):
555         """ Returns URI in the API for the fragment. """
556
557         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
558
559     @classmethod
560     def url(cls, fragment):
561         """ Returns URL on the site for the fragment. """
562
563         return WL_BASE + fragment.get_absolute_url()
564
565     @classmethod
566     def themes(cls, fragment):
567         """ Returns a list of theme tags for the fragment. """
568
569         return fragment.tags.filter(category='theme')
570
571
572 class FragmentDetailHandler(BaseHandler, FragmentDetails):
573     fields = ['book', 'anchor', 'text', 'url', 'themes']
574
575     @piwik_track
576     def read(self, request, book, anchor):
577         """ Returns details of a fragment, identified by book slug and anchor. """
578         try:
579             return Fragment.objects.get(book__slug=book, anchor=anchor)
580         except Fragment.DoesNotExist:
581             return rc.NOT_FOUND
582
583
584 class FragmentsHandler(BaseHandler, FragmentDetails):
585     """ Main handler for Fragments.
586
587     Responsible for lists of Fragment objects
588     and fields used for representing Fragments.
589
590     """
591     model = Fragment
592     fields = ['book', 'url', 'anchor', 'href']
593     allowed_methods = ('GET',)
594
595     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
596
597     @piwik_track
598     def read(self, request, tags):
599         """ Lists all fragments with given book, tags, themes.
600
601         :param tags: should be a path of categories and slugs, i.e.:
602              books/book-slug/authors/an-author/themes/a-theme/
603
604         """
605         try:
606             tags, ancestors = read_tags(tags, allowed=self.categories)
607         except ValueError:
608             return rc.NOT_FOUND
609         fragments = Fragment.tagged.with_all(tags).select_related('book')
610         if fragments.exists():
611             return fragments
612         else:
613             return rc.NOT_FOUND
614
615
616 class PictureHandler(BaseHandler):
617     model = Picture
618     fields = ('slug', 'title')
619     allowed_methods = ('POST',)
620
621     def create(self, request):
622         if not request.user.has_perm('picture.add_picture'):
623             return rc.FORBIDDEN
624
625         data = json.loads(request.POST.get('data'))
626         form = PictureImportForm(data)
627         if form.is_valid():
628             form.save()
629             return rc.CREATED
630         else:
631             return rc.NOT_FOUND