Merge commit 'afb3cc28'
[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.api.helpers import books_after, order_books
20 from catalogue.forms import BookImportForm
21 from catalogue.models import Book, Tag, BookMedia
22 from catalogue.models.tag import prefetch_relations
23 from paypal.rest import user_is_subscribed
24 from picture.models import Picture
25 from picture.forms import PictureImportForm
26 from social.utils import likes
27
28 from stats.utils import piwik_track
29 from wolnelektury.utils import re_escape
30
31
32 API_BASE = WL_BASE = MEDIA_BASE = lazy(
33     lambda: u'https://' + Site.objects.get_current().domain, unicode)()
34
35 category_singular = {
36     'authors': 'author',
37     'kinds': 'kind',
38     'genres': 'genre',
39     'epochs': 'epoch',
40     'themes': 'theme',
41     'books': 'book',
42 }
43 category_plural = {}
44 for k, v in category_singular.items():
45     category_plural[v] = k
46
47 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
48
49 book_list_fields = book_tag_categories + [
50     'href', 'title', 'url', 'cover', 'cover_thumb', 'slug', 'simple_thumb', 'has_audio', 'cover_color', 'full_sort_key']
51
52
53 def read_tags(tags, request, allowed):
54     """ Reads a path of filtering tags.
55
56     :param str tags: a path of category and slug pairs, like: authors/an-author/...
57     :returns: list of Tag objects
58     :raises: ValueError when tags can't be found
59     """
60
61     def process(category, slug):
62         if category == 'book':
63             # FIXME: Unused?
64             try:
65                 books.append(Book.objects.get(slug=slug))
66             except Book.DoesNotExist:
67                 raise ValueError('Unknown book.')
68         try:
69             real_tags.append(Tag.objects.get(category=category, slug=slug))
70         except Tag.DoesNotExist:
71             raise ValueError('Tag not found')
72
73     if not tags:
74         return [], []
75
76     tags = tags.strip('/').split('/')
77     real_tags = []
78     books = []
79     while tags:
80         category = tags.pop(0)
81         slug = tags.pop(0)
82
83         try:
84             category = category_singular[category]
85         except KeyError:
86             raise ValueError('Unknown category.')
87
88         if category not in allowed:
89             raise ValueError('Category not allowed.')
90         process(category, slug)
91
92     for key in request.GET:
93         if key in category_singular:
94             category = category_singular[key]
95             if category in allowed:
96                 for slug in request.GET.getlist(key):
97                     process(category, slug)
98     return real_tags, books
99
100
101 # RESTful handlers
102
103
104
105
106
107 class BookDetails(object):
108     """Custom fields used for representing Books."""
109
110     @classmethod
111     def href(cls, book):
112         """ Returns an URI for a Book in the API. """
113         return API_BASE + reverse("catalogue_api_book", args=[book.slug])
114
115     @classmethod
116     def url(cls, book):
117         """ Returns Book's URL on the site. """
118         return WL_BASE + book.get_absolute_url()
119
120     @classmethod
121     def children(cls, book):
122         """ Returns all children for a book. """
123         return book.children.order_by('parent_number', 'sort_key')
124
125     @classmethod
126     def media(cls, book):
127         """ Returns all media for a book. """
128         return book.media.all()
129
130     @classmethod
131     def cover(cls, book):
132         return MEDIA_BASE + book.cover.url if book.cover else ''
133
134     @classmethod
135     def cover_thumb(cls, book):
136         return MEDIA_BASE + default.backend.get_thumbnail(
137                     book.cover, "139x193").url if book.cover else ''
138
139     @classmethod
140     def simple_thumb(cls, book):
141         return MEDIA_BASE + book.cover_api_thumb.url if book.cover_api_thumb else ''
142
143     @classmethod
144     def simple_cover(cls, book):
145         return MEDIA_BASE + book.simple_cover.url if book.simple_cover else ''
146
147
148 class BookDetailHandler(BaseHandler, BookDetails):
149     """ Main handler for Book objects.
150
151     Responsible for single Book details.
152     """
153     allowed_methods = ['GET']
154     fields = ['title', 'parent', 'children'] + Book.formats + [
155         'media', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'simple_cover', 'fragment_data', 'audio_length',
156         'preview', 'cover_color'] + [
157             category_plural[c] for c in book_tag_categories]
158
159     @piwik_track
160     def read(self, request, book):
161         """ Returns details of a book, identified by a slug and lang. """
162         try:
163             return Book.objects.get(slug=book)
164         except Book.DoesNotExist:
165             return rc.NOT_FOUND
166
167
168 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
169     """ Main handler for Book objects.
170
171     Responsible for lists of Book objects.
172     """
173     allowed_methods = ('GET',)
174     model = Book
175     fields = book_list_fields
176
177     # FIXME: Unused?
178     @classmethod
179     def genres(cls, book):
180         """ Returns all media for a book. """
181         return book.tags.filter(category='genre')
182
183     @piwik_track
184     def read(self, request, tags=None, top_level=False, audiobooks=False, daisy=False, pk=None,
185              recommended=False, newest=False, books=None,
186              after=None, count=None):
187         """ Lists all books with given tags.
188
189         :param tags: filtering tags; should be a path of categories
190              and slugs, i.e.: authors/an-author/epoch/an-epoch/
191         :param top_level: if True and a book is included in the results,
192              it's children are aren't. By default all books matching the tags
193              are returned.
194         """
195         if pk is not None:
196             # FIXME: Unused?
197             try:
198                 return Book.objects.get(pk=pk)
199             except Book.DoesNotExist:
200                 return rc.NOT_FOUND
201
202         try:
203             tags, _ancestors = read_tags(tags, request, allowed=book_tag_categories)
204         except ValueError:
205             return rc.NOT_FOUND
206
207         new_api = request.GET.get('new_api')
208         if 'after' in request.GET:
209             after = request.GET['after']
210         if 'count' in request.GET:
211             count = request.GET['count']
212
213         if tags:
214             if top_level:
215                 books = Book.tagged_top_level(tags)
216                 return books if books else rc.NOT_FOUND
217             else:
218                 books = Book.tagged.with_all(tags)
219         else:
220             books = books if books is not None else Book.objects.all()
221         books = order_books(books, new_api)
222
223         if top_level:
224             books = books.filter(parent=None)
225         if audiobooks:
226             books = books.filter(media__type='mp3').distinct()
227         if daisy:
228             books = books.filter(media__type='daisy').distinct()
229         if recommended:
230             books = books.filter(recommended=True)
231         if newest:
232             books = books.order_by('-created_at')
233
234         if after:
235             books = books_after(books, after, new_api)
236
237         if new_api:
238             books = books.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key', 'sort_key_author')
239         else:
240             books = books.only('slug', 'title', 'cover', 'cover_thumb')
241         for category in book_tag_categories:
242             books = prefetch_relations(books, category)
243
244         if count:
245             books = books[:count]
246
247         return books
248
249     def create(self, request, *args, **kwargs):
250         return rc.FORBIDDEN
251
252
253 class BooksHandler(BookDetailHandler):
254     allowed_methods = ('GET', 'POST')
255     model = Book
256     fields = book_list_fields + ['liked']
257     anonymous = AnonymousBooksHandler
258
259     # hack, because piston is stupid
260     @classmethod
261     def liked(cls, book):
262         return getattr(book, 'liked', None)
263
264     def read(self, request, **kwargs):
265         books = AnonymousBooksHandler().read(request, **kwargs)
266         likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
267
268         new_books = [
269             BookProxy(book).set('liked', book.id in likes)
270             for book in books]
271         return QuerySetProxy(new_books)
272
273     def create(self, request, *args, **kwargs):
274         if not request.user.has_perm('catalogue.add_book'):
275             return rc.FORBIDDEN
276
277         data = json.loads(request.POST.get('data'))
278         form = BookImportForm(data)
279         if form.is_valid():
280             form.save()
281             return rc.CREATED
282         else:
283             return rc.NOT_FOUND
284
285
286 class EBooksHandler(AnonymousBooksHandler):
287     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
288
289
290 class BookProxy(models.Model):
291     class Meta:
292         managed = False
293
294     def __init__(self, book, key=None):
295         self.book = book
296         self.key = key
297
298     def set(self, attr, value):
299         self.__setattr__(attr, value)
300         return self
301
302     def __getattr__(self, item):
303         return self.book.__getattribute__(item)
304
305
306 class QuerySetProxy(models.QuerySet):
307     def __init__(self, l):
308         self.list = l
309
310     def __iter__(self):
311         return iter(self.list)
312
313
314 class AnonFilterBooksHandler(AnonymousBooksHandler):
315     fields = book_list_fields + ['key']
316
317     def parse_bool(self, s):
318         if s in ('true', 'false'):
319             return s == 'true'
320         else:
321             return None
322
323     def read(self, request):
324         key_sep = '$'
325         search_string = request.GET.get('search')
326         is_lektura = self.parse_bool(request.GET.get('lektura'))
327         is_audiobook = self.parse_bool(request.GET.get('audiobook'))
328         preview = self.parse_bool(request.GET.get('preview'))
329
330         new_api = request.GET.get('new_api')
331         after = request.GET.get('after')
332         count = int(request.GET.get('count', 50))
333         books = order_books(Book.objects.distinct(), new_api)
334         if is_lektura is not None:
335             books = books.filter(has_audience=is_lektura)
336         if is_audiobook is not None:
337             if is_audiobook:
338                 books = books.filter(media__type='mp3')
339             else:
340                 books = books.exclude(media__type='mp3')
341         if preview is not None:
342             books = books.filter(preview=preview)
343         for key in request.GET:
344             if key in category_singular:
345                 category = category_singular[key]
346                 if category in book_tag_categories:
347                     slugs = request.GET[key].split(',')
348                     tags = Tag.objects.filter(category=category, slug__in=slugs)
349                     books = Book.tagged.with_any(tags, books)
350         if (search_string is not None) and len(search_string) < 3:
351             search_string = None
352         if search_string:
353             search_string = re_escape(search_string)
354             books_author = books.filter(cached_author__iregex='\m' + search_string)
355             books_title = books.filter(title__iregex='\m' + search_string)
356             books_title = books_title.exclude(id__in=list(books_author.values_list('id', flat=True)))
357             if after and (key_sep in after):
358                 which, key = after.split(key_sep, 1)
359                 if which == 'title':
360                     book_lists = [(books_after(books_title, key, new_api), 'title')]
361                 else:  # which == 'author'
362                     book_lists = [(books_after(books_author, key, new_api), 'author'), (books_title, 'title')]
363             else:
364                 book_lists = [(books_author, 'author'), (books_title, 'title')]
365         else:
366             if after and key_sep in after:
367                 which, key = after.split(key_sep, 1)
368                 books = books_after(books, key, new_api)
369             book_lists = [(books, 'book')]
370
371         filtered_books = []
372         for book_list, label in book_lists:
373             book_list = book_list.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key_author', 'sort_key')
374             for category in book_tag_categories:
375                 book_list = prefetch_relations(book_list, category)
376             remaining_count = count - len(filtered_books)
377             new_books = [
378                 BookProxy(book, '%s%s%s' % (
379                     label, key_sep, book.slug if not new_api else book.full_sort_key()))
380                 for book in book_list[:remaining_count]]
381             filtered_books += new_books
382             if len(filtered_books) == count:
383                 break
384
385         return QuerySetProxy(filtered_books)
386
387
388 class FilterBooksHandler(BooksHandler):
389     anonymous = AnonFilterBooksHandler
390     fields = book_list_fields + ['key', 'liked']
391
392     # hack, because piston is stupid
393     @classmethod
394     def liked(cls, book):
395         return getattr(book, 'liked', None)
396
397     def read(self, request):
398         qsp = AnonFilterBooksHandler().read(request)
399         likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
400         for book in qsp.list:
401             book.set('liked', book.id in likes)
402         return qsp
403
404
405 # add categorized tags fields for Book
406 def _tags_getter(category):
407     @classmethod
408     def get_tags(cls, book):
409         return book.tags.filter(category=category)
410     return get_tags
411
412
413 def _tag_getter(category):
414     @classmethod
415     def get_tag(cls, book):
416         return book.tag_unicode(category)
417     return get_tag
418
419
420 def add_tag_getters():
421     for plural, singular in category_singular.items():
422         setattr(BookDetails, plural, _tags_getter(singular))
423         setattr(BookDetails, singular, _tag_getter(singular))
424
425
426 add_tag_getters()
427
428
429 # add fields for files in Book
430 def _file_getter(book_format):
431
432     @staticmethod
433     def get_file(book):
434         f_url = book.media_url(book_format)
435         if f_url:
436             return MEDIA_BASE + f_url
437         else:
438             return ''
439     return get_file
440
441
442 def add_file_getters():
443     for book_format in Book.formats:
444         setattr(BookDetails, book_format, _file_getter(book_format))
445
446
447 add_file_getters()
448
449
450 class PictureHandler(BaseHandler):
451     model = Picture
452     fields = ('slug', 'title')
453     allowed_methods = ('POST',)
454
455     def create(self, request):
456         if not request.user.has_perm('picture.add_picture'):
457             return rc.FORBIDDEN
458
459         data = json.loads(request.POST.get('data'))
460         form = PictureImportForm(data)
461         if form.is_valid():
462             form.save()
463             return rc.CREATED
464         else:
465             return rc.NOT_FOUND
466
467
468 class BlogEntryHandler(BaseHandler):
469     model = Entry
470     fields = (
471         'title', 'lead', 'body', 'place', 'time', 'image_url', 'image_thumb', 'gallery_urls', 'type', 'key', 'url')
472
473     def read(self, request):
474         after = request.GET.get('after')
475         count = int(request.GET.get('count', 20))
476         entries = Entry.published_objects.filter(in_stream=True).order_by('-first_published_at')
477         if after:
478             entries = entries.filter(first_published_at__lt=after)
479         if count:
480             entries = entries[:count]
481         return entries
482
483     @classmethod
484     def image_url(cls, entry):
485         return (WL_BASE + entry.image.url) if entry.image else None
486
487     @classmethod
488     def image_thumb(cls, entry):
489         return MEDIA_BASE + default.backend.get_thumbnail(
490             entry.image, "193x193").url if entry.image else ''
491
492     @classmethod
493     def gallery_urls(cls, entry):
494         return [WL_BASE + photo.url() for photo in entry.photo_set.all()]
495
496     @classmethod
497     def key(cls, entry):
498         return entry.first_published_at
499
500     @classmethod
501     def url(cls, entry):
502         return WL_BASE + entry.get_absolute_url()