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