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