cover colors in 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 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 # add categorized tags fields for Book
405 def _tags_getter(category):
406     @classmethod
407     def get_tags(cls, book):
408         return book.tags.filter(category=category)
409     return get_tags
410
411
412 def _tag_getter(category):
413     @classmethod
414     def get_tag(cls, book):
415         return book.tag_unicode(category)
416     return get_tag
417
418
419 def add_tag_getters():
420     for plural, singular in category_singular.items():
421         setattr(BookDetails, plural, _tags_getter(singular))
422         setattr(BookDetails, singular, _tag_getter(singular))
423
424
425 add_tag_getters()
426
427
428 # add fields for files in Book
429 def _file_getter(book_format):
430
431     @staticmethod
432     def get_file(book):
433         f_url = book.media_url(book_format)
434         if f_url:
435             return MEDIA_BASE + f_url
436         else:
437             return ''
438     return get_file
439
440
441 def add_file_getters():
442     for book_format in Book.formats:
443         setattr(BookDetails, book_format, _file_getter(book_format))
444
445
446 add_file_getters()
447
448
449 class CollectionDetails(object):
450     """Custom Collection fields."""
451
452     @classmethod
453     def href(cls, collection):
454         """ Returns URI in the API for the collection. """
455
456         return API_BASE + reverse("api_collection", args=[collection.slug])
457
458     @classmethod
459     def url(cls, collection):
460         """ Returns URL on the site. """
461
462         return WL_BASE + collection.get_absolute_url()
463
464     @classmethod
465     def books(cls, collection):
466         return Book.objects.filter(collection.get_query())
467
468
469 class CollectionDetailHandler(BaseHandler, CollectionDetails):
470     allowed_methods = ('GET',)
471     fields = ['url', 'title', 'description', 'books']
472
473     @piwik_track
474     def read(self, request, slug):
475         """ Returns details of a collection, identified by slug. """
476         try:
477             return Collection.objects.get(slug=slug)
478         except Collection.DoesNotExist:
479             return rc.NOT_FOUND
480
481
482 class CollectionsHandler(BaseHandler, CollectionDetails):
483     allowed_methods = ('GET',)
484     model = Collection
485     fields = ['url', 'href', 'title']
486
487     @piwik_track
488     def read(self, request):
489         """ Returns all collections. """
490         return Collection.objects.all()
491
492
493 class TagDetails(object):
494     """Custom Tag fields."""
495
496     @classmethod
497     def href(cls, tag):
498         """ Returns URI in the API for the tag. """
499
500         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
501
502     @classmethod
503     def url(cls, tag):
504         """ Returns URL on the site. """
505
506         return WL_BASE + tag.get_absolute_url()
507
508
509 class TagDetailHandler(BaseHandler, TagDetails):
510     """ Responsible for details of a single Tag object. """
511
512     fields = ['name', 'url', 'sort_key', 'description']
513
514     @piwik_track
515     def read(self, request, category, slug):
516         """ Returns details of a tag, identified by category and slug. """
517
518         try:
519             category_sng = category_singular[category]
520         except KeyError:
521             return rc.NOT_FOUND
522
523         try:
524             return Tag.objects.get(category=category_sng, slug=slug)
525         except Tag.DoesNotExist:
526             return rc.NOT_FOUND
527
528
529 class TagsHandler(BaseHandler, TagDetails):
530     """ Main handler for Tag objects.
531
532     Responsible for lists of Tag objects
533     and fields used for representing Tags.
534
535     """
536     allowed_methods = ('GET',)
537     model = Tag
538     fields = ['name', 'href', 'url', 'slug']
539
540     @piwik_track
541     def read(self, request, category=None, pk=None):
542         """ Lists all tags in the category (eg. all themes). """
543         if pk is not None:
544             try:
545                 return Tag.objects.exclude(category='set').get(pk=pk)
546             except Book.DoesNotExist:
547                 return rc.NOT_FOUND
548
549         try:
550             category_sng = category_singular[category]
551         except KeyError:
552             return rc.NOT_FOUND
553
554         after = request.GET.get('after')
555         before = request.GET.get('before')
556         count = request.GET.get('count')
557
558         tags = Tag.objects.filter(category=category_sng).exclude(items=None).order_by('slug')
559
560         book_only = request.GET.get('book_only') == 'true'
561         picture_only = request.GET.get('picture_only') == 'true'
562         if book_only:
563             tags = tags.filter(for_books=True)
564         if picture_only:
565             tags = tags.filter(for_pictures=True)
566
567         if after:
568             tags = tags.filter(slug__gt=after)
569         if before:
570             tags = tags.filter(slug__lt=before)
571
572         if count:
573             if before:
574                 tags = list(reversed(tags.order_by('-slug')[:count]))
575             else:
576                 tags = tags[:count]
577
578         return tags
579
580
581 class FragmentDetails(object):
582     """Custom Fragment fields."""
583
584     @classmethod
585     def href(cls, fragment):
586         """ Returns URI in the API for the fragment. """
587
588         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
589
590     @classmethod
591     def url(cls, fragment):
592         """ Returns URL on the site for the fragment. """
593
594         return WL_BASE + fragment.get_absolute_url()
595
596     @classmethod
597     def themes(cls, fragment):
598         """ Returns a list of theme tags for the fragment. """
599
600         return fragment.tags.filter(category='theme')
601
602
603 class FragmentDetailHandler(BaseHandler, FragmentDetails):
604     fields = ['book', 'anchor', 'text', 'url', 'themes']
605
606     @piwik_track
607     def read(self, request, book, anchor):
608         """ Returns details of a fragment, identified by book slug and anchor. """
609         try:
610             return Fragment.objects.get(book__slug=book, anchor=anchor)
611         except Fragment.DoesNotExist:
612             return rc.NOT_FOUND
613
614
615 class FragmentsHandler(BaseHandler, FragmentDetails):
616     """ Main handler for Fragments.
617
618     Responsible for lists of Fragment objects
619     and fields used for representing Fragments.
620
621     """
622     model = Fragment
623     fields = ['book', 'url', 'anchor', 'href']
624     allowed_methods = ('GET',)
625
626     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
627
628     @piwik_track
629     def read(self, request, tags):
630         """ Lists all fragments with given book, tags, themes.
631
632         :param tags: should be a path of categories and slugs, i.e.:
633              books/book-slug/authors/an-author/themes/a-theme/
634
635         """
636         try:
637             tags, ancestors = read_tags(tags, request, allowed=self.categories)
638         except ValueError:
639             return rc.NOT_FOUND
640         fragments = Fragment.tagged.with_all(tags).select_related('book')
641         if fragments.exists():
642             return fragments
643         else:
644             return rc.NOT_FOUND
645
646
647 class PictureHandler(BaseHandler):
648     model = Picture
649     fields = ('slug', 'title')
650     allowed_methods = ('POST',)
651
652     def create(self, request):
653         if not request.user.has_perm('picture.add_picture'):
654             return rc.FORBIDDEN
655
656         data = json.loads(request.POST.get('data'))
657         form = PictureImportForm(data)
658         if form.is_valid():
659             form.save()
660             return rc.CREATED
661         else:
662             return rc.NOT_FOUND
663
664
665 class UserDataHandler(BaseHandler):
666     model = BookUserData
667     fields = ('state', 'username')
668     allowed_methods = ('GET', 'POST')
669
670     def read(self, request, slug=None):
671         if not request.user.is_authenticated():
672             return rc.FORBIDDEN
673         if slug is None:
674             return {'username': request.user.username}
675         try:
676             book = Book.objects.get(slug=slug)
677         except Book.DoesNotExist:
678             return rc.NOT_FOUND
679         try:
680             data = BookUserData.objects.get(book=book, user=request.user)
681         except BookUserData.DoesNotExist:
682             return {'state': 'not_started'}
683         return data
684
685     def create(self, request, slug, state):
686         try:
687             book = Book.objects.get(slug=slug)
688         except Book.DoesNotExist:
689             return rc.NOT_FOUND
690         if not request.user.is_authenticated():
691             return rc.FORBIDDEN
692         if state not in ('reading', 'complete'):
693             return rc.NOT_FOUND
694         data, created = BookUserData.objects.get_or_create(book=book, user=request.user)
695         data.state = state
696         data.save()
697         return data
698
699
700 class UserShelfHandler(BookDetailHandler):
701     fields = book_tag_categories + [
702         'href', 'title', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'slug', 'key']
703
704     def parse_bool(self, s):
705         if s in ('true', 'false'):
706             return s == 'true'
707         else:
708             return None
709
710     def read(self, request, state):
711         if not request.user.is_authenticated():
712             return rc.FORBIDDEN
713         if state not in ('reading', 'complete', 'likes'):
714             return rc.NOT_FOUND
715         after = request.GET.get('after')
716         count = int(request.GET.get('count', 50))
717         if state == 'likes':
718             books = Book.tagged.with_any(request.user.tag_set.all())
719         else:
720             ids = BookUserData.objects.filter(user=request.user, complete=state == 'complete')\
721                 .values_list('book_id', flat=True)
722             books = Book.objects.filter(id__in=list(ids)).distinct().order_by('slug')
723         if after:
724             books = books.filter(slug__gt=after)
725         if count:
726             books = books[:count]
727         return books
728
729
730 class UserLikeHandler(BaseHandler):
731     fields = []
732     allowed_methods = ('GET', 'POST')
733
734     def read(self, request, slug):
735         if not request.user.is_authenticated():
736             return rc.FORBIDDEN
737         try:
738             book = Book.objects.get(slug=slug)
739         except Book.DoesNotExist:
740             return rc.NOT_FOUND
741         return {'likes': likes(request.user, book)}
742
743     def create(self, request, slug, action='like'):
744         if not request.user.is_authenticated():
745             return rc.FORBIDDEN
746         try:
747             book = Book.objects.get(slug=slug)
748         except Book.DoesNotExist:
749             return rc.NOT_FOUND
750         if action == 'like':
751             book.like(request.user)
752         elif action == 'unlike':
753             book.unlike(request.user)
754         return {}
755
756
757 class BlogEntryHandler(BaseHandler):
758     model = Entry
759     fields = ('title', 'lead', 'body', 'place', 'time', 'image_url', 'gallery_urls', 'type', 'key')
760
761     def read(self, request):
762         after = request.GET.get('after')
763         count = int(request.GET.get('count', 20))
764         entries = Entry.published_objects.filter(in_stream=True).order_by('-first_published_at')
765         if after:
766             entries = entries.filter(first_published_at__lt=after)
767         if count:
768             entries = entries[:count]
769         return entries
770
771     @classmethod
772     def image_url(cls, entry):
773         return entry.image.url if entry.image else None
774
775     @classmethod
776     def gallery_urls(cls, entry):
777         return [photo.url() for photo in entry.photo_set.all()]
778
779     @classmethod
780     def key(cls, entry):
781         return entry.first_published_at