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