add to api: keyset pagination, recommended, newest
[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', 'slug']
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              recommended=False, newest=False,
176              after=None, before=None, count=None):
177         """ Lists all books with given tags.
178
179         :param tags: filtering tags; should be a path of categories
180              and slugs, i.e.: authors/an-author/epoch/an-epoch/
181         :param top_level: if True and a book is included in the results,
182              it's children are aren't. By default all books matching the tags
183              are returned.
184         """
185         if pk is not None:
186             try:
187                 return Book.objects.get(pk=pk)
188             except Book.DoesNotExist:
189                 return rc.NOT_FOUND
190
191         try:
192             tags, _ancestors = read_tags(tags, allowed=book_tag_categories)
193         except ValueError:
194             return rc.NOT_FOUND
195
196         if tags:
197             if top_level:
198                 books = Book.tagged_top_level(tags)
199                 return books if books else rc.NOT_FOUND
200             else:
201                 books = Book.tagged.with_all(tags)
202         else:
203             books = Book.objects.all()
204         books = books.order_by('slug')
205
206         if top_level:
207             books = books.filter(parent=None)
208         if audiobooks:
209             books = books.filter(media__type='mp3').distinct()
210         if daisy:
211             books = books.filter(media__type='daisy').distinct()
212         if recommended:
213             books = books.filter(recommended=True)
214         if newest:
215             books = books.order_by('-created_at')
216
217         if after:
218             books = books.filter(slug__gt=after)
219         if before:
220             books = books.filter(slug__lt=before)
221
222         books = books.only('slug', 'title', 'cover', 'cover_thumb')
223         for category in book_tag_categories:
224             books = prefetch_relations(books, category)
225
226         if count:
227             if before:
228                 books = list(reversed(books.order_by('-slug')[:count]))
229             else:
230                 books = books[:count]
231
232         if books:
233             return books
234         else:
235             return rc.NOT_FOUND
236
237     def create(self, request, *args, **kwargs):
238         return rc.FORBIDDEN
239
240
241 class BooksHandler(BookDetailHandler):
242     allowed_methods = ('GET', 'POST')
243     model = Book
244     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb', 'slug']
245     anonymous = AnonymousBooksHandler
246
247     def create(self, request, *args, **kwargs):
248         if not request.user.has_perm('catalogue.add_book'):
249             return rc.FORBIDDEN
250
251         data = json.loads(request.POST.get('data'))
252         form = BookImportForm(data)
253         if form.is_valid():
254             form.save()
255             return rc.CREATED
256         else:
257             return rc.NOT_FOUND
258
259
260 class EBooksHandler(AnonymousBooksHandler):
261     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
262
263
264 # add categorized tags fields for Book
265 def _tags_getter(category):
266     @classmethod
267     def get_tags(cls, book):
268         return book.tags.filter(category=category)
269     return get_tags
270
271
272 def _tag_getter(category):
273     @classmethod
274     def get_tag(cls, book):
275         return book.tag_unicode(category)
276     return get_tag
277
278
279 def add_tag_getters():
280     for plural, singular in category_singular.items():
281         setattr(BookDetails, plural, _tags_getter(singular))
282         setattr(BookDetails, singular, _tag_getter(singular))
283
284 add_tag_getters()
285
286
287 # add fields for files in Book
288 def _file_getter(book_format):
289     field = "%s_file" % book_format
290
291     @classmethod
292     def get_file(cls, book):
293         f = getattr(book, field)
294         if f:
295             return MEDIA_BASE + f.url
296         else:
297             return ''
298     return get_file
299
300
301 def add_file_getters():
302     for book_format in Book.formats:
303         setattr(BookDetails, book_format, _file_getter(book_format))
304
305 add_file_getters()
306
307
308 class CollectionDetails(object):
309     """Custom Collection fields."""
310
311     @classmethod
312     def href(cls, collection):
313         """ Returns URI in the API for the collection. """
314
315         return API_BASE + reverse("api_collection", args=[collection.slug])
316
317     @classmethod
318     def url(cls, collection):
319         """ Returns URL on the site. """
320
321         return WL_BASE + collection.get_absolute_url()
322
323     @classmethod
324     def books(cls, collection):
325         return Book.objects.filter(collection.get_query())
326
327
328 class CollectionDetailHandler(BaseHandler, CollectionDetails):
329     allowed_methods = ('GET',)
330     fields = ['url', 'title', 'description', 'books']
331
332     @piwik_track
333     def read(self, request, slug):
334         """ Returns details of a collection, identified by slug. """
335         try:
336             return Collection.objects.get(slug=slug)
337         except Collection.DoesNotExist:
338             return rc.NOT_FOUND
339
340
341 class CollectionsHandler(BaseHandler, CollectionDetails):
342     allowed_methods = ('GET',)
343     model = Collection
344     fields = ['url', 'href', 'title']
345
346     @piwik_track
347     def read(self, request):
348         """ Returns all collections. """
349         return Collection.objects.all()
350
351
352 class TagDetails(object):
353     """Custom Tag fields."""
354
355     @classmethod
356     def href(cls, tag):
357         """ Returns URI in the API for the tag. """
358
359         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
360
361     @classmethod
362     def url(cls, tag):
363         """ Returns URL on the site. """
364
365         return WL_BASE + tag.get_absolute_url()
366
367
368 class TagDetailHandler(BaseHandler, TagDetails):
369     """ Responsible for details of a single Tag object. """
370
371     fields = ['name', 'url', 'sort_key', 'description']
372
373     @piwik_track
374     def read(self, request, category, slug):
375         """ Returns details of a tag, identified by category and slug. """
376
377         try:
378             category_sng = category_singular[category]
379         except KeyError:
380             return rc.NOT_FOUND
381
382         try:
383             return Tag.objects.get(category=category_sng, slug=slug)
384         except Tag.DoesNotExist:
385             return rc.NOT_FOUND
386
387
388 class TagsHandler(BaseHandler, TagDetails):
389     """ Main handler for Tag objects.
390
391     Responsible for lists of Tag objects
392     and fields used for representing Tags.
393
394     """
395     allowed_methods = ('GET',)
396     model = Tag
397     fields = ['name', 'href', 'url']
398
399     @piwik_track
400     def read(self, request, category=None, pk=None):
401         """ Lists all tags in the category (eg. all themes). """
402         if pk is not None:
403             try:
404                 return Tag.objects.exclude(category='set').get(pk=pk)
405             except Book.DoesNotExist:
406                 return rc.NOT_FOUND
407
408         try:
409             category_sng = category_singular[category]
410         except KeyError:
411             return rc.NOT_FOUND
412
413         tags = Tag.objects.filter(category=category_sng).exclude(items=None)
414         if tags.exists():
415             return tags
416         else:
417             return rc.NOT_FOUND
418
419
420 class FragmentDetails(object):
421     """Custom Fragment fields."""
422
423     @classmethod
424     def href(cls, fragment):
425         """ Returns URI in the API for the fragment. """
426
427         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
428
429     @classmethod
430     def url(cls, fragment):
431         """ Returns URL on the site for the fragment. """
432
433         return WL_BASE + fragment.get_absolute_url()
434
435     @classmethod
436     def themes(cls, fragment):
437         """ Returns a list of theme tags for the fragment. """
438
439         return fragment.tags.filter(category='theme')
440
441
442 class FragmentDetailHandler(BaseHandler, FragmentDetails):
443     fields = ['book', 'anchor', 'text', 'url', 'themes']
444
445     @piwik_track
446     def read(self, request, book, anchor):
447         """ Returns details of a fragment, identified by book slug and anchor. """
448         try:
449             return Fragment.objects.get(book__slug=book, anchor=anchor)
450         except Fragment.DoesNotExist:
451             return rc.NOT_FOUND
452
453
454 class FragmentsHandler(BaseHandler, FragmentDetails):
455     """ Main handler for Fragments.
456
457     Responsible for lists of Fragment objects
458     and fields used for representing Fragments.
459
460     """
461     model = Fragment
462     fields = ['book', 'url', 'anchor', 'href']
463     allowed_methods = ('GET',)
464
465     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
466
467     @piwik_track
468     def read(self, request, tags):
469         """ Lists all fragments with given book, tags, themes.
470
471         :param tags: should be a path of categories and slugs, i.e.:
472              books/book-slug/authors/an-author/themes/a-theme/
473
474         """
475         try:
476             tags, ancestors = read_tags(tags, allowed=self.categories)
477         except ValueError:
478             return rc.NOT_FOUND
479         fragments = Fragment.tagged.with_all(tags).select_related('book')
480         if fragments.exists():
481             return fragments
482         else:
483             return rc.NOT_FOUND
484
485
486 class PictureHandler(BaseHandler):
487     model = Picture
488     fields = ('slug', 'title')
489     allowed_methods = ('POST',)
490
491     def create(self, request):
492         if not request.user.has_perm('picture.add_picture'):
493             return rc.FORBIDDEN
494
495         data = json.loads(request.POST.get('data'))
496         form = PictureImportForm(data)
497         if form.is_valid():
498             form.save()
499             return rc.CREATED
500         else:
501             return rc.NOT_FOUND