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