More API views migrated.
[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
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
104
105
106 class BookDetails(object):
107     """Custom fields used for representing Books."""
108
109     @classmethod
110     def href(cls, book):
111         """ Returns an URI for a Book in the API. """
112         return API_BASE + reverse("catalogue_api_book", args=[book.slug])
113
114     @classmethod
115     def url(cls, book):
116         """ Returns Book's URL on the site. """
117         return WL_BASE + book.get_absolute_url()
118
119     @classmethod
120     def children(cls, book):
121         """ Returns all children for a book. """
122         return book.children.order_by('parent_number', 'sort_key')
123
124     @classmethod
125     def media(cls, book):
126         """ Returns all media for a book. """
127         return book.media.all()
128
129     @classmethod
130     def cover(cls, book):
131         return MEDIA_BASE + book.cover.url if book.cover else ''
132
133     @classmethod
134     def cover_thumb(cls, book):
135         return MEDIA_BASE + default.backend.get_thumbnail(
136                     book.cover, "139x193").url if book.cover else ''
137
138     @classmethod
139     def simple_thumb(cls, book):
140         return MEDIA_BASE + book.cover_api_thumb.url if book.cover_api_thumb else ''
141
142     @classmethod
143     def simple_cover(cls, book):
144         return MEDIA_BASE + book.simple_cover.url if book.simple_cover else ''
145
146     @staticmethod
147     def books_after(books, after, new_api):
148         if not new_api:
149             return books.filter(slug__gt=after)
150         try:
151             author, title, book_id = after.split(Book.SORT_KEY_SEP)
152         except ValueError:
153             return Book.objects.none()
154         return books.filter(Q(sort_key_author__gt=author)
155                             | (Q(sort_key_author=author) & Q(sort_key__gt=title))
156                             | (Q(sort_key_author=author) & Q(sort_key=title) & Q(id__gt=int(book_id))))
157
158     @staticmethod
159     def order_books(books, new_api):
160         if new_api:
161             return books.order_by('sort_key_author', 'sort_key', 'id')
162         else:
163             return books.order_by('slug')
164
165
166 class BookDetailHandler(BaseHandler, BookDetails):
167     """ Main handler for Book objects.
168
169     Responsible for single Book details.
170     """
171     allowed_methods = ['GET']
172     fields = ['title', 'parent', 'children'] + Book.formats + [
173         'media', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'simple_cover', 'fragment_data', 'audio_length',
174         'preview', 'cover_color'] + [
175             category_plural[c] for c in book_tag_categories]
176
177     @piwik_track
178     def read(self, request, book):
179         """ Returns details of a book, identified by a slug and lang. """
180         try:
181             return Book.objects.get(slug=book)
182         except Book.DoesNotExist:
183             return rc.NOT_FOUND
184
185
186 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
187     """ Main handler for Book objects.
188
189     Responsible for lists of Book objects.
190     """
191     allowed_methods = ('GET',)
192     model = Book
193     fields = book_list_fields
194
195     # FIXME: Unused?
196     @classmethod
197     def genres(cls, book):
198         """ Returns all media for a book. """
199         return book.tags.filter(category='genre')
200
201     @piwik_track
202     def read(self, request, tags=None, top_level=False, audiobooks=False, daisy=False, pk=None,
203              recommended=False, newest=False, books=None,
204              after=None, count=None):
205         """ Lists all books with given tags.
206
207         :param tags: filtering tags; should be a path of categories
208              and slugs, i.e.: authors/an-author/epoch/an-epoch/
209         :param top_level: if True and a book is included in the results,
210              it's children are aren't. By default all books matching the tags
211              are returned.
212         """
213         if pk is not None:
214             # FIXME: Unused?
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         new_api = request.GET.get('new_api')
226         if 'after' in request.GET:
227             after = request.GET['after']
228         if 'count' in request.GET:
229             count = request.GET['count']
230
231         if tags:
232             if top_level:
233                 books = Book.tagged_top_level(tags)
234                 return books if books else rc.NOT_FOUND
235             else:
236                 books = Book.tagged.with_all(tags)
237         else:
238             books = books if books is not None else Book.objects.all()
239         books = self.order_books(books, new_api)
240
241         if top_level:
242             books = books.filter(parent=None)
243         if audiobooks:
244             books = books.filter(media__type='mp3').distinct()
245         if daisy:
246             books = books.filter(media__type='daisy').distinct()
247         if recommended:
248             books = books.filter(recommended=True)
249         if newest:
250             books = books.order_by('-created_at')
251
252         if after:
253             books = self.books_after(books, after, new_api)
254
255         if new_api:
256             books = books.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key', 'sort_key_author')
257         else:
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             books = books[:count]
264
265         return books
266
267     def create(self, request, *args, **kwargs):
268         return rc.FORBIDDEN
269
270
271 class BooksHandler(BookDetailHandler):
272     allowed_methods = ('GET', 'POST')
273     model = Book
274     fields = book_list_fields + ['liked']
275     anonymous = AnonymousBooksHandler
276
277     # hack, because piston is stupid
278     @classmethod
279     def liked(cls, book):
280         return getattr(book, 'liked', None)
281
282     def read(self, request, **kwargs):
283         books = AnonymousBooksHandler().read(request, **kwargs)
284         likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
285
286         new_books = [
287             BookProxy(book).set('liked', book.id in likes)
288             for book in books]
289         return QuerySetProxy(new_books)
290
291     def create(self, request, *args, **kwargs):
292         if not request.user.has_perm('catalogue.add_book'):
293             return rc.FORBIDDEN
294
295         data = json.loads(request.POST.get('data'))
296         form = BookImportForm(data)
297         if form.is_valid():
298             form.save()
299             return rc.CREATED
300         else:
301             return rc.NOT_FOUND
302
303
304 class EBooksHandler(AnonymousBooksHandler):
305     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
306
307
308 class BookProxy(models.Model):
309     class Meta:
310         managed = False
311
312     def __init__(self, book, key=None):
313         self.book = book
314         self.key = key
315
316     def set(self, attr, value):
317         self.__setattr__(attr, value)
318         return self
319
320     def __getattr__(self, item):
321         return self.book.__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 AnonFilterBooksHandler(AnonymousBooksHandler):
333     fields = book_list_fields + ['key']
334
335     def parse_bool(self, s):
336         if s in ('true', 'false'):
337             return s == 'true'
338         else:
339             return None
340
341     def read(self, request):
342         key_sep = '$'
343         search_string = request.GET.get('search')
344         is_lektura = self.parse_bool(request.GET.get('lektura'))
345         is_audiobook = self.parse_bool(request.GET.get('audiobook'))
346         preview = self.parse_bool(request.GET.get('preview'))
347
348         new_api = request.GET.get('new_api')
349         after = request.GET.get('after')
350         count = int(request.GET.get('count', 50))
351         books = self.order_books(Book.objects.distinct(), new_api)
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, key = after.split(key_sep, 1)
377                 if which == 'title':
378                     book_lists = [(self.books_after(books_title, key, new_api), 'title')]
379                 else:  # which == 'author'
380                     book_lists = [(self.books_after(books_author, key, new_api), '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, key = after.split(key_sep, 1)
386                 books = self.books_after(books, key, new_api)
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', 'sort_key_author', 'sort_key')
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 = [
396                 BookProxy(book, '%s%s%s' % (
397                     label, key_sep, book.slug if not new_api else book.full_sort_key()))
398                 for book in book_list[:remaining_count]]
399             filtered_books += new_books
400             if len(filtered_books) == count:
401                 break
402
403         return QuerySetProxy(filtered_books)
404
405
406 class FilterBooksHandler(BooksHandler):
407     anonymous = AnonFilterBooksHandler
408     fields = book_list_fields + ['key', 'liked']
409
410     # hack, because piston is stupid
411     @classmethod
412     def liked(cls, book):
413         return getattr(book, 'liked', None)
414
415     def read(self, request):
416         qsp = AnonFilterBooksHandler().read(request)
417         likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
418         for book in qsp.list:
419             book.set('liked', book.id in likes)
420         return qsp
421
422
423 class BookPreviewHandler(BookDetailHandler):
424     fields = BookDetailHandler.fields + ['slug']
425
426     def read(self, request):
427         return Book.objects.filter(preview=True)
428
429
430 # add categorized tags fields for Book
431 def _tags_getter(category):
432     @classmethod
433     def get_tags(cls, book):
434         return book.tags.filter(category=category)
435     return get_tags
436
437
438 def _tag_getter(category):
439     @classmethod
440     def get_tag(cls, book):
441         return book.tag_unicode(category)
442     return get_tag
443
444
445 def add_tag_getters():
446     for plural, singular in category_singular.items():
447         setattr(BookDetails, plural, _tags_getter(singular))
448         setattr(BookDetails, singular, _tag_getter(singular))
449
450
451 add_tag_getters()
452
453
454 # add fields for files in Book
455 def _file_getter(book_format):
456
457     @staticmethod
458     def get_file(book):
459         f_url = book.media_url(book_format)
460         if f_url:
461             return MEDIA_BASE + f_url
462         else:
463             return ''
464     return get_file
465
466
467 def add_file_getters():
468     for book_format in Book.formats:
469         setattr(BookDetails, book_format, _file_getter(book_format))
470
471
472 add_file_getters()
473
474
475 class PictureHandler(BaseHandler):
476     model = Picture
477     fields = ('slug', 'title')
478     allowed_methods = ('POST',)
479
480     def create(self, request):
481         if not request.user.has_perm('picture.add_picture'):
482             return rc.FORBIDDEN
483
484         data = json.loads(request.POST.get('data'))
485         form = PictureImportForm(data)
486         if form.is_valid():
487             form.save()
488             return rc.CREATED
489         else:
490             return rc.NOT_FOUND
491
492
493 class UserShelfHandler(BookDetailHandler):
494     fields = book_list_fields + ['liked']
495
496     # FIXME: Unused?
497     def parse_bool(self, s):
498         if s in ('true', 'false'):
499             return s == 'true'
500         else:
501             return None
502
503     # hack, because piston is stupid
504     @classmethod
505     def liked(cls, book):
506         return getattr(book, 'liked', None)
507
508     def read(self, request, state):
509         if not request.user.is_authenticated():
510             return rc.FORBIDDEN
511         likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
512         if state not in ('reading', 'complete', 'likes'):
513             return rc.NOT_FOUND
514         new_api = request.GET.get('new_api')
515         after = request.GET.get('after')
516         count = int(request.GET.get('count', 50))
517         if state == 'likes':
518             books = Book.tagged.with_any(request.user.tag_set.all())
519         else:
520             ids = BookUserData.objects.filter(user=request.user, complete=state == 'complete')\
521                 .values_list('book_id', flat=True)
522             books = Book.objects.filter(id__in=list(ids)).distinct()
523             books = self.order_books(books, new_api)
524         if after:
525             books = self.books_after(books, after, new_api)
526         if count:
527             books = books[:count]
528         new_books = []
529         for book in books:
530             new_books.append(BookProxy(book).set('liked', book.id in likes))
531         return QuerySetProxy(new_books)
532
533
534 class UserLikeHandler(BaseHandler):
535     fields = []
536     allowed_methods = ('GET', 'POST')
537
538     def read(self, request, slug):
539         if not request.user.is_authenticated():
540             return rc.FORBIDDEN
541         try:
542             book = Book.objects.get(slug=slug)
543         except Book.DoesNotExist:
544             return rc.NOT_FOUND
545         return {'likes': likes(request.user, book)}
546
547     def create(self, request, slug):
548         if not request.user.is_authenticated():
549             return rc.FORBIDDEN
550         try:
551             book = Book.objects.get(slug=slug)
552         except Book.DoesNotExist:
553             return rc.NOT_FOUND
554         action = request.GET.get('action', 'like')
555         if action == 'like':
556             book.like(request.user)
557         elif action == 'unlike':
558             book.unlike(request.user)
559         return {}
560
561
562 class BlogEntryHandler(BaseHandler):
563     model = Entry
564     fields = (
565         'title', 'lead', 'body', 'place', 'time', 'image_url', 'image_thumb', 'gallery_urls', 'type', 'key', 'url')
566
567     def read(self, request):
568         after = request.GET.get('after')
569         count = int(request.GET.get('count', 20))
570         entries = Entry.published_objects.filter(in_stream=True).order_by('-first_published_at')
571         if after:
572             entries = entries.filter(first_published_at__lt=after)
573         if count:
574             entries = entries[:count]
575         return entries
576
577     @classmethod
578     def image_url(cls, entry):
579         return (WL_BASE + entry.image.url) if entry.image else None
580
581     @classmethod
582     def image_thumb(cls, entry):
583         return MEDIA_BASE + default.backend.get_thumbnail(
584             entry.image, "193x193").url if entry.image else ''
585
586     @classmethod
587     def gallery_urls(cls, entry):
588         return [WL_BASE + photo.url() for photo in entry.photo_set.all()]
589
590     @classmethod
591     def key(cls, entry):
592         return entry.first_published_at
593
594     @classmethod
595     def url(cls, entry):
596         return WL_BASE + entry.get_absolute_url()