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