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