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