api optimization (book lists)
[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 Prefetch
10 from django.utils.functional import lazy
11 from piston.handler import AnonymousBaseHandler, BaseHandler
12 from piston.utils import rc
13 from sorl.thumbnail import default
14
15 from catalogue.forms import BookImportForm
16 from catalogue.models import Book, Tag, BookMedia, Fragment, Collection
17 from catalogue.models.tag import TagRelation
18 from picture.models import Picture
19 from picture.forms import PictureImportForm
20
21 from stats.utils import piwik_track
22
23 from . import emitters  # Register our emitters
24
25 API_BASE = WL_BASE = MEDIA_BASE = lazy(
26     lambda: u'http://' + Site.objects.get_current().domain, unicode)()
27
28
29 category_singular = {
30     'authors': 'author',
31     'kinds': 'kind',
32     'genres': 'genre',
33     'epochs': 'epoch',
34     'themes': 'theme',
35     'books': 'book',
36 }
37 category_plural = {}
38 for k, v in category_singular.items():
39     category_plural[v] = k
40
41 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
42
43
44 def read_tags(tags, allowed):
45     """ Reads a path of filtering tags.
46
47     :param str tags: a path of category and slug pairs, like: authors/an-author/...
48     :returns: list of Tag objects
49     :raises: ValueError when tags can't be found
50     """
51     if not tags:
52         return [], []
53
54     tags = tags.strip('/').split('/')
55     real_tags = []
56     books = []
57     while tags:
58         category = tags.pop(0)
59         slug = tags.pop(0)
60
61         try:
62             category = category_singular[category]
63         except KeyError:
64             raise ValueError('Unknown category.')
65
66         if category not in allowed:
67             raise ValueError('Category not allowed.')
68
69         if category == 'book':
70             try:
71                 books.append(Book.objects.get(slug=slug))
72             except Book.DoesNotExist:
73                 raise ValueError('Unknown book.')
74
75         try:
76             real_tags.append(Tag.objects.get(category=category, slug=slug))
77         except Tag.DoesNotExist:
78             raise ValueError('Tag not found')
79     return real_tags, books
80
81
82 # RESTful handlers
83
84
85 class BookMediaHandler(BaseHandler):
86     """ Responsible for representing media in Books. """
87
88     model = BookMedia
89     fields = ['name', 'type', 'url', 'artist', 'director']
90
91     @classmethod
92     def url(cls, media):
93         """ Link to media on site. """
94
95         return MEDIA_BASE + media.file.url
96
97     @classmethod
98     def artist(cls, media):
99         return media.extra_info.get('artist_name', '')
100
101     @classmethod
102     def director(cls, media):
103         return media.extra_info.get('director_name', '')
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("api_book", args=[book.slug])
113
114     @classmethod
115     def url(cls, book):
116         """ Returns Book's URL on the site. """
117
118         return WL_BASE + book.get_absolute_url()
119
120     @classmethod
121     def children(cls, book):
122         """ Returns all children for a book. """
123
124         return book.children.all()
125
126     @classmethod
127     def media(cls, book):
128         """ Returns all media for a book. """
129         return book.media.all()
130
131     @classmethod
132     def cover(cls, book):
133         return MEDIA_BASE + book.cover.url if book.cover else ''
134
135     @classmethod
136     def cover_thumb(cls, book):
137         return MEDIA_BASE + default.backend.get_thumbnail(
138                     book.cover, "139x193").url if book.cover else ''
139
140
141 class BookDetailHandler(BaseHandler, BookDetails):
142     """ Main handler for Book objects.
143
144     Responsible for single Book details.
145     """
146     allowed_methods = ['GET']
147     fields = ['title', 'parent', 'children'] + Book.formats + [
148         'media', 'url', 'cover', 'cover_thumb'] + [
149             category_plural[c] for c in book_tag_categories]
150
151     @piwik_track
152     def read(self, request, book):
153         """ Returns details of a book, identified by a slug and lang. """
154         try:
155             return Book.objects.get(slug=book)
156         except Book.DoesNotExist:
157             return rc.NOT_FOUND
158
159
160 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
161     """ Main handler for Book objects.
162
163     Responsible for lists of Book objects.
164     """
165     allowed_methods = ('GET',)
166     model = Book
167     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
168
169     @classmethod
170     def genres(cls, book):
171         """ Returns all media for a book. """
172         return book.tags.filter(category='genre')
173
174     @piwik_track
175     def read(self, request, tags=None, top_level=False, audiobooks=False, daisy=False, pk=None):
176         """ Lists all books with given tags.
177
178         :param tags: filtering tags; should be a path of categories
179              and slugs, i.e.: authors/an-author/epoch/an-epoch/
180         :param top_level: if True and a book is included in the results,
181              it's children are aren't. By default all books matching the tags
182              are returned.
183         """
184         if pk is not None:
185             try:
186                 return Book.objects.get(pk=pk)
187             except Book.DoesNotExist:
188                 return rc.NOT_FOUND
189
190         try:
191             tags, _ancestors = read_tags(tags, allowed=book_tag_categories)
192         except ValueError:
193             return rc.NOT_FOUND
194
195         if tags:
196             if top_level:
197                 books = Book.tagged_top_level(tags)
198                 return books if books else rc.NOT_FOUND
199             else:
200                 books = Book.tagged.with_all(tags)
201         else:
202             books = Book.objects.all()
203
204         if top_level:
205             books = books.filter(parent=None)
206         if audiobooks:
207             books = books.filter(media__type='mp3').distinct()
208         if daisy:
209             books = books.filter(media__type='daisy').distinct()
210
211         books = books.only('slug', 'title', 'cover', 'cover_thumb')
212         for category in book_tag_categories:
213             books = books.prefetch_related(
214                 Prefetch(
215                     'tag_relations',
216                     queryset=TagRelation.objects.filter(tag__category=category)
217                         .select_related('tag').only('tag__name_pl', 'object_id'),
218                     to_attr='%s_relations' % category))
219
220         if books:
221             return books
222         else:
223             return rc.NOT_FOUND
224
225     def create(self, request, *args, **kwargs):
226         return rc.FORBIDDEN
227
228
229 class BooksHandler(BookDetailHandler):
230     allowed_methods = ('GET', 'POST')
231     model = Book
232     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
233     anonymous = AnonymousBooksHandler
234
235     def create(self, request, *args, **kwargs):
236         if not request.user.has_perm('catalogue.add_book'):
237             return rc.FORBIDDEN
238
239         data = json.loads(request.POST.get('data'))
240         form = BookImportForm(data)
241         if form.is_valid():
242             form.save()
243             return rc.CREATED
244         else:
245             return rc.NOT_FOUND
246
247
248 class EBooksHandler(AnonymousBooksHandler):
249     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats)
250
251
252 # add categorized tags fields for Book
253 def _tags_getter(category):
254     @classmethod
255     def get_tags(cls, book):
256         return book.tags.filter(category=category)
257     return get_tags
258
259
260 def _tag_getter(category):
261     @classmethod
262     def get_tag(cls, book):
263         if hasattr(book, '%s_relations' % category):
264             return ', '.join(rel.tag.name for rel in getattr(book, '%s_relations' % category))
265         return ', '.join(book.tags.filter(category=category).values_list('name', flat=True))
266     return get_tag
267
268
269 def add_tag_getters():
270     for plural, singular in category_singular.items():
271         setattr(BookDetails, plural, _tags_getter(singular))
272         setattr(BookDetails, singular, _tag_getter(singular))
273
274 add_tag_getters()
275
276
277 # add fields for files in Book
278 def _file_getter(book_format):
279     field = "%s_file" % book_format
280
281     @classmethod
282     def get_file(cls, book):
283         f = getattr(book, field)
284         if f:
285             return MEDIA_BASE + f.url
286         else:
287             return ''
288     return get_file
289
290
291 def add_file_getters():
292     for book_format in Book.formats:
293         setattr(BookDetails, book_format, _file_getter(book_format))
294
295 add_file_getters()
296
297
298 class CollectionDetails(object):
299     """Custom Collection fields."""
300
301     @classmethod
302     def href(cls, collection):
303         """ Returns URI in the API for the collection. """
304
305         return API_BASE + reverse("api_collection", args=[collection.slug])
306
307     @classmethod
308     def url(cls, collection):
309         """ Returns URL on the site. """
310
311         return WL_BASE + collection.get_absolute_url()
312
313     @classmethod
314     def books(cls, collection):
315         return Book.objects.filter(collection.get_query())
316
317
318 class CollectionDetailHandler(BaseHandler, CollectionDetails):
319     allowed_methods = ('GET',)
320     fields = ['url', 'title', 'description', 'books']
321
322     @piwik_track
323     def read(self, request, slug):
324         """ Returns details of a collection, identified by slug. """
325         try:
326             return Collection.objects.get(slug=slug)
327         except Collection.DoesNotExist:
328             return rc.NOT_FOUND
329
330
331 class CollectionsHandler(BaseHandler, CollectionDetails):
332     allowed_methods = ('GET',)
333     model = Collection
334     fields = ['url', 'href', 'title']
335
336     @piwik_track
337     def read(self, request):
338         """ Returns all collections. """
339         return Collection.objects.all()
340
341
342 class TagDetails(object):
343     """Custom Tag fields."""
344
345     @classmethod
346     def href(cls, tag):
347         """ Returns URI in the API for the tag. """
348
349         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
350
351     @classmethod
352     def url(cls, tag):
353         """ Returns URL on the site. """
354
355         return WL_BASE + tag.get_absolute_url()
356
357
358 class TagDetailHandler(BaseHandler, TagDetails):
359     """ Responsible for details of a single Tag object. """
360
361     fields = ['name', 'url', 'sort_key', 'description']
362
363     @piwik_track
364     def read(self, request, category, slug):
365         """ Returns details of a tag, identified by category and slug. """
366
367         try:
368             category_sng = category_singular[category]
369         except KeyError:
370             return rc.NOT_FOUND
371
372         try:
373             return Tag.objects.get(category=category_sng, slug=slug)
374         except Tag.DoesNotExist:
375             return rc.NOT_FOUND
376
377
378 class TagsHandler(BaseHandler, TagDetails):
379     """ Main handler for Tag objects.
380
381     Responsible for lists of Tag objects
382     and fields used for representing Tags.
383
384     """
385     allowed_methods = ('GET',)
386     model = Tag
387     fields = ['name', 'href', 'url']
388
389     @piwik_track
390     def read(self, request, category=None, pk=None):
391         """ Lists all tags in the category (eg. all themes). """
392         if pk is not None:
393             try:
394                 return Tag.objects.exclude(category='set').get(pk=pk)
395             except Book.DoesNotExist:
396                 return rc.NOT_FOUND
397
398         try:
399             category_sng = category_singular[category]
400         except KeyError:
401             return rc.NOT_FOUND
402
403         tags = Tag.objects.filter(category=category_sng).exclude(items=None)
404         if tags.exists():
405             return tags
406         else:
407             return rc.NOT_FOUND
408
409
410 class FragmentDetails(object):
411     """Custom Fragment fields."""
412
413     @classmethod
414     def href(cls, fragment):
415         """ Returns URI in the API for the fragment. """
416
417         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
418
419     @classmethod
420     def url(cls, fragment):
421         """ Returns URL on the site for the fragment. """
422
423         return WL_BASE + fragment.get_absolute_url()
424
425     @classmethod
426     def themes(cls, fragment):
427         """ Returns a list of theme tags for the fragment. """
428
429         return fragment.tags.filter(category='theme')
430
431
432 class FragmentDetailHandler(BaseHandler, FragmentDetails):
433     fields = ['book', 'anchor', 'text', 'url', 'themes']
434
435     @piwik_track
436     def read(self, request, book, anchor):
437         """ Returns details of a fragment, identified by book slug and anchor. """
438         try:
439             return Fragment.objects.get(book__slug=book, anchor=anchor)
440         except Fragment.DoesNotExist:
441             return rc.NOT_FOUND
442
443
444 class FragmentsHandler(BaseHandler, FragmentDetails):
445     """ Main handler for Fragments.
446
447     Responsible for lists of Fragment objects
448     and fields used for representing Fragments.
449
450     """
451     model = Fragment
452     fields = ['book', 'url', 'anchor', 'href']
453     allowed_methods = ('GET',)
454
455     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
456
457     @piwik_track
458     def read(self, request, tags):
459         """ Lists all fragments with given book, tags, themes.
460
461         :param tags: should be a path of categories and slugs, i.e.:
462              books/book-slug/authors/an-author/themes/a-theme/
463
464         """
465         try:
466             tags, ancestors = read_tags(tags, allowed=self.categories)
467         except ValueError:
468             return rc.NOT_FOUND
469         fragments = Fragment.tagged.with_all(tags).select_related('book')
470         if fragments.exists():
471             return fragments
472         else:
473             return rc.NOT_FOUND
474
475
476 class PictureHandler(BaseHandler):
477     model = Picture
478     fields = ('slug', 'title')
479     allowed_methods = ('POST',)
480
481     def create(self, request):
482         if not request.user.has_perm('picture.add_picture'):
483             return rc.FORBIDDEN
484
485         data = json.loads(request.POST.get('data'))
486         form = PictureImportForm(data)
487         if form.is_valid():
488             form.save()
489             return rc.CREATED
490         else:
491             return rc.NOT_FOUND