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