update librarian
[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', 'audio_length',
168         'preview'] + [
169             category_plural[c] for c in book_tag_categories]
170
171     @piwik_track
172     def read(self, request, book):
173         """ Returns details of a book, identified by a slug and lang. """
174         try:
175             return Book.objects.get(slug=book)
176         except Book.DoesNotExist:
177             return rc.NOT_FOUND
178
179
180 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
181     """ Main handler for Book objects.
182
183     Responsible for lists of Book objects.
184     """
185     allowed_methods = ('GET',)
186     model = Book
187     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb', 'slug', 'simple_thumb', 'has_audio']
188
189     @classmethod
190     def genres(cls, book):
191         """ Returns all media for a book. """
192         return book.tags.filter(category='genre')
193
194     @piwik_track
195     def read(self, request, tags=None, top_level=False, audiobooks=False, daisy=False, pk=None,
196              recommended=False, newest=False, books=None,
197              after=None, before=None, count=None):
198         """ Lists all books with given tags.
199
200         :param tags: filtering tags; should be a path of categories
201              and slugs, i.e.: authors/an-author/epoch/an-epoch/
202         :param top_level: if True and a book is included in the results,
203              it's children are aren't. By default all books matching the tags
204              are returned.
205         """
206         if pk is not None:
207             try:
208                 return Book.objects.get(pk=pk)
209             except Book.DoesNotExist:
210                 return rc.NOT_FOUND
211
212         try:
213             tags, _ancestors = read_tags(tags, request, allowed=book_tag_categories)
214         except ValueError:
215             return rc.NOT_FOUND
216
217         if 'after' in request.GET:
218             after = request.GET['after']
219         if 'before' in request.GET:
220             before = request.GET['before']
221         if 'count' in request.GET:
222             count = request.GET['count']
223
224         if tags:
225             if top_level:
226                 books = Book.tagged_top_level(tags)
227                 return books if books else rc.NOT_FOUND
228             else:
229                 books = Book.tagged.with_all(tags)
230         else:
231             books = books if books is not None else Book.objects.all()
232         books = books.order_by('slug')
233
234         if top_level:
235             books = books.filter(parent=None)
236         if audiobooks:
237             books = books.filter(media__type='mp3').distinct()
238         if daisy:
239             books = books.filter(media__type='daisy').distinct()
240         if recommended:
241             books = books.filter(recommended=True)
242         if newest:
243             books = books.order_by('-created_at')
244
245         if after:
246             books = books.filter(slug__gt=after)
247         if before:
248             books = books.filter(slug__lt=before)
249
250         books = books.only('slug', 'title', 'cover', 'cover_thumb')
251         for category in book_tag_categories:
252             books = prefetch_relations(books, category)
253
254         if count:
255             if before:
256                 books = list(reversed(books.order_by('-slug')[:count]))
257             else:
258                 books = books[:count]
259
260         return books
261
262     def create(self, request, *args, **kwargs):
263         return rc.FORBIDDEN
264
265
266 class BooksHandler(BookDetailHandler):
267     allowed_methods = ('GET', 'POST')
268     model = Book
269     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb', 'slug']
270     anonymous = AnonymousBooksHandler
271
272     def create(self, request, *args, **kwargs):
273         if not request.user.has_perm('catalogue.add_book'):
274             return rc.FORBIDDEN
275
276         data = json.loads(request.POST.get('data'))
277         form = BookImportForm(data)
278         if form.is_valid():
279             form.save()
280             return rc.CREATED
281         else:
282             return rc.NOT_FOUND
283
284
285 class EpubHandler(BookDetailHandler):
286     def read(self, request, slug):
287         if not is_subscribed(request.user):
288             return rc.FORBIDDEN
289         try:
290             book = Book.objects.get(slug=slug)
291         except Book.DoesNotExist:
292             return rc.NOT_FOUND
293         response = HttpResponse(book.get_media('epub'))
294         return response
295
296
297 class EBooksHandler(AnonymousBooksHandler):
298     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
299
300
301 class BookProxy(models.Model):
302     class Meta:
303         managed = False
304
305     def __init__(self, book, key):
306         self.book = book
307         self.key = key
308
309     def __getattr__(self, item):
310         if item not in ('book', 'key'):
311             return self.book.__getattribute__(item)
312         else:
313             return self.__getattribute__(item)
314
315
316 class QuerySetProxy(models.QuerySet):
317     def __init__(self, l):
318         self.list = l
319
320     def __iter__(self):
321         return iter(self.list)
322
323
324 class FilterBooksHandler(AnonymousBooksHandler):
325     fields = book_tag_categories + [
326         'href', 'title', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'has_audio', 'slug', 'key']
327
328     def parse_bool(self, s):
329         if s in ('true', 'false'):
330             return s == 'true'
331         else:
332             return None
333
334     def read(self, request):
335         key_sep = '$'
336         search_string = request.GET.get('search')
337         is_lektura = self.parse_bool(request.GET.get('lektura'))
338         is_audiobook = self.parse_bool(request.GET.get('audiobook'))
339         preview = self.parse_bool(request.GET.get('preview'))
340
341         after = request.GET.get('after')
342         count = int(request.GET.get('count', 50))
343         books = Book.objects.distinct().order_by('slug')
344         if is_lektura is not None:
345             books = books.filter(has_audience=is_lektura)
346         if is_audiobook is not None:
347             if is_audiobook:
348                 books = books.filter(media__type='mp3')
349             else:
350                 books = books.exclude(media__type='mp3')
351         if preview is not None:
352             books = books.filter(preview=preview)
353         for key in request.GET:
354             if key in category_singular:
355                 category = category_singular[key]
356                 if category in book_tag_categories:
357                     slugs = request.GET[key].split(',')
358                     tags = Tag.objects.filter(category=category, slug__in=slugs)
359                     books = Book.tagged.with_any(tags, books)
360         if (search_string is not None) and len(search_string) < 3:
361             search_string = None
362         if search_string:
363             search_string = re_escape(search_string)
364             books_author = books.filter(cached_author__iregex='\m' + search_string)
365             books_title = books.filter(title__iregex='\m' + search_string)
366             books_title = books_title.exclude(id__in=list(books_author.values_list('id', flat=True)))
367             if after and (key_sep in after):
368                 which, slug = after.split(key_sep, 1)
369                 if which == 'title':
370                     book_lists = [(books_title.filter(slug__gt=slug), 'title')]
371                 else:  # which == 'author'
372                     book_lists = [(books_author.filter(slug__gt=slug), 'author'), (books_title, 'title')]
373             else:
374                 book_lists = [(books_author, 'author'), (books_title, 'title')]
375         else:
376             if after and key_sep in after:
377                 which, slug = after.split(key_sep, 1)
378                 books = books.filter(slug__gt=slug)
379             book_lists = [(books, 'book')]
380
381         filtered_books = []
382         for book_list, label in book_lists:
383             book_list = book_list.only('slug', 'title', 'cover', 'cover_thumb')
384             for category in book_tag_categories:
385                 book_list = prefetch_relations(book_list, category)
386             remaining_count = count - len(filtered_books)
387             new_books = [BookProxy(book, '%s%s%s' % (label, key_sep, book.slug))
388                          for book in book_list[:remaining_count]]
389             filtered_books += new_books
390             if len(filtered_books) == count:
391                 break
392
393         return QuerySetProxy(filtered_books)
394
395
396 # add categorized tags fields for Book
397 def _tags_getter(category):
398     @classmethod
399     def get_tags(cls, book):
400         return book.tags.filter(category=category)
401     return get_tags
402
403
404 def _tag_getter(category):
405     @classmethod
406     def get_tag(cls, book):
407         return book.tag_unicode(category)
408     return get_tag
409
410
411 def add_tag_getters():
412     for plural, singular in category_singular.items():
413         setattr(BookDetails, plural, _tags_getter(singular))
414         setattr(BookDetails, singular, _tag_getter(singular))
415
416
417 add_tag_getters()
418
419
420 # add fields for files in Book
421 def _file_getter(book_format):
422
423     @staticmethod
424     def get_file(book):
425         f_url = book.media_url(book_format)
426         if f_url:
427             return MEDIA_BASE + f_url
428         else:
429             return ''
430     return get_file
431
432
433 def add_file_getters():
434     for book_format in Book.formats:
435         setattr(BookDetails, book_format, _file_getter(book_format))
436
437
438 add_file_getters()
439
440
441 class CollectionDetails(object):
442     """Custom Collection fields."""
443
444     @classmethod
445     def href(cls, collection):
446         """ Returns URI in the API for the collection. """
447
448         return API_BASE + reverse("api_collection", args=[collection.slug])
449
450     @classmethod
451     def url(cls, collection):
452         """ Returns URL on the site. """
453
454         return WL_BASE + collection.get_absolute_url()
455
456     @classmethod
457     def books(cls, collection):
458         return Book.objects.filter(collection.get_query())
459
460
461 class CollectionDetailHandler(BaseHandler, CollectionDetails):
462     allowed_methods = ('GET',)
463     fields = ['url', 'title', 'description', 'books']
464
465     @piwik_track
466     def read(self, request, slug):
467         """ Returns details of a collection, identified by slug. """
468         try:
469             return Collection.objects.get(slug=slug)
470         except Collection.DoesNotExist:
471             return rc.NOT_FOUND
472
473
474 class CollectionsHandler(BaseHandler, CollectionDetails):
475     allowed_methods = ('GET',)
476     model = Collection
477     fields = ['url', 'href', 'title']
478
479     @piwik_track
480     def read(self, request):
481         """ Returns all collections. """
482         return Collection.objects.all()
483
484
485 class TagDetails(object):
486     """Custom Tag fields."""
487
488     @classmethod
489     def href(cls, tag):
490         """ Returns URI in the API for the tag. """
491
492         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
493
494     @classmethod
495     def url(cls, tag):
496         """ Returns URL on the site. """
497
498         return WL_BASE + tag.get_absolute_url()
499
500
501 class TagDetailHandler(BaseHandler, TagDetails):
502     """ Responsible for details of a single Tag object. """
503
504     fields = ['name', 'url', 'sort_key', 'description']
505
506     @piwik_track
507     def read(self, request, category, slug):
508         """ Returns details of a tag, identified by category and slug. """
509
510         try:
511             category_sng = category_singular[category]
512         except KeyError:
513             return rc.NOT_FOUND
514
515         try:
516             return Tag.objects.get(category=category_sng, slug=slug)
517         except Tag.DoesNotExist:
518             return rc.NOT_FOUND
519
520
521 class TagsHandler(BaseHandler, TagDetails):
522     """ Main handler for Tag objects.
523
524     Responsible for lists of Tag objects
525     and fields used for representing Tags.
526
527     """
528     allowed_methods = ('GET',)
529     model = Tag
530     fields = ['name', 'href', 'url', 'slug']
531
532     @piwik_track
533     def read(self, request, category=None, pk=None):
534         """ Lists all tags in the category (eg. all themes). """
535         if pk is not None:
536             try:
537                 return Tag.objects.exclude(category='set').get(pk=pk)
538             except Book.DoesNotExist:
539                 return rc.NOT_FOUND
540
541         try:
542             category_sng = category_singular[category]
543         except KeyError:
544             return rc.NOT_FOUND
545
546         after = request.GET.get('after')
547         before = request.GET.get('before')
548         count = request.GET.get('count')
549
550         tags = Tag.objects.filter(category=category_sng).exclude(items=None).order_by('slug')
551
552         book_only = request.GET.get('book_only') == 'true'
553         picture_only = request.GET.get('picture_only') == 'true'
554         if book_only:
555             tags = tags.filter(for_books=True)
556         if picture_only:
557             tags = tags.filter(for_pictures=True)
558
559         if after:
560             tags = tags.filter(slug__gt=after)
561         if before:
562             tags = tags.filter(slug__lt=before)
563
564         if count:
565             if before:
566                 tags = list(reversed(tags.order_by('-slug')[:count]))
567             else:
568                 tags = tags[:count]
569
570         return tags
571
572
573 class FragmentDetails(object):
574     """Custom Fragment fields."""
575
576     @classmethod
577     def href(cls, fragment):
578         """ Returns URI in the API for the fragment. """
579
580         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
581
582     @classmethod
583     def url(cls, fragment):
584         """ Returns URL on the site for the fragment. """
585
586         return WL_BASE + fragment.get_absolute_url()
587
588     @classmethod
589     def themes(cls, fragment):
590         """ Returns a list of theme tags for the fragment. """
591
592         return fragment.tags.filter(category='theme')
593
594
595 class FragmentDetailHandler(BaseHandler, FragmentDetails):
596     fields = ['book', 'anchor', 'text', 'url', 'themes']
597
598     @piwik_track
599     def read(self, request, book, anchor):
600         """ Returns details of a fragment, identified by book slug and anchor. """
601         try:
602             return Fragment.objects.get(book__slug=book, anchor=anchor)
603         except Fragment.DoesNotExist:
604             return rc.NOT_FOUND
605
606
607 class FragmentsHandler(BaseHandler, FragmentDetails):
608     """ Main handler for Fragments.
609
610     Responsible for lists of Fragment objects
611     and fields used for representing Fragments.
612
613     """
614     model = Fragment
615     fields = ['book', 'url', 'anchor', 'href']
616     allowed_methods = ('GET',)
617
618     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
619
620     @piwik_track
621     def read(self, request, tags):
622         """ Lists all fragments with given book, tags, themes.
623
624         :param tags: should be a path of categories and slugs, i.e.:
625              books/book-slug/authors/an-author/themes/a-theme/
626
627         """
628         try:
629             tags, ancestors = read_tags(tags, request, allowed=self.categories)
630         except ValueError:
631             return rc.NOT_FOUND
632         fragments = Fragment.tagged.with_all(tags).select_related('book')
633         if fragments.exists():
634             return fragments
635         else:
636             return rc.NOT_FOUND
637
638
639 class PictureHandler(BaseHandler):
640     model = Picture
641     fields = ('slug', 'title')
642     allowed_methods = ('POST',)
643
644     def create(self, request):
645         if not request.user.has_perm('picture.add_picture'):
646             return rc.FORBIDDEN
647
648         data = json.loads(request.POST.get('data'))
649         form = PictureImportForm(data)
650         if form.is_valid():
651             form.save()
652             return rc.CREATED
653         else:
654             return rc.NOT_FOUND
655
656
657 class UserDataHandler(BaseHandler):
658     model = BookUserData
659     fields = ('state',)
660     allowed_methods = ('GET', 'POST')
661
662     def read(self, request, slug):
663         try:
664             book = Book.objects.get(slug=slug)
665         except Book.DoesNotExist:
666             return rc.NOT_FOUND
667         if not request.user.is_authenticated():
668             return rc.FORBIDDEN
669         try:
670             data = BookUserData.objects.get(book=book, user=request.user)
671         except BookUserData.DoesNotExist:
672             return {'state': 'not_started'}
673         return data
674
675     def create(self, request, slug, state):
676         try:
677             book = Book.objects.get(slug=slug)
678         except Book.DoesNotExist:
679             return rc.NOT_FOUND
680         if not request.user.is_authenticated():
681             return rc.FORBIDDEN
682         if state not in ('reading', 'complete'):
683             return rc.NOT_FOUND
684         data, created = BookUserData.objects.get_or_create(book=book, user=request.user)
685         data.state = state
686         data.save()
687         return data
688
689
690 class UserShelfHandler(BookDetailHandler):
691     fields = book_tag_categories + [
692         'href', 'title', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'slug', 'key']
693
694     def parse_bool(self, s):
695         if s in ('true', 'false'):
696             return s == 'true'
697         else:
698             return None
699
700     def read(self, request, state):
701         if not request.user.is_authenticated():
702             return rc.FORBIDDEN
703         if state not in ('reading', 'complete'):
704             return rc.NOT_FOUND
705         after = request.GET.get('after')
706         count = int(request.GET.get('count', 50))
707         ids = BookUserData.objects.filter(user=request.user, complete=state == 'complete')\
708             .values_list('book_id', flat=True)
709         books = Book.objects.filter(id__in=list(ids)).distinct().order_by('slug')
710         if after:
711             books = books.filter(slug__gt=after)
712         if count:
713             books = books[:count]
714         return books