add to api: filter for app
[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, books=None,
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 = books if books is not None else 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 class FilterBooksHandler(AnonymousBooksHandler):
265     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb', 'slug']
266
267     def read(self, request, title_part=None, author_part=None, is_lektura=None, is_audiobook=None,
268              after=None, before=None, count=None):
269         if count is None:
270             count = 50
271         if is_lektura in ('true', 'false'):
272             is_lektura = is_lektura == 'true'
273         else:
274             is_lektura = None
275         if is_audiobook in ('true', 'false'):
276             is_audiobook = is_audiobook == 'true'
277         books = Book.objects.distinct()
278         if title_part:
279             books = books.filter(title__icontains=title_part)
280         if author_part is not None:
281             books = books.filter(cached_author__icontains=author_part)
282         if is_lektura is not None:
283             books = books.filter(has_audience=is_lektura)
284         if is_audiobook is not None:
285             if is_audiobook:
286                 books = books.filter(media__type='mp3')
287             else:
288                 books = books.exclude(media__type='mp3')
289         return super(FilterBooksHandler, self).read(request, books=books, after=after, before=before, count=count)
290
291
292 # add categorized tags fields for Book
293 def _tags_getter(category):
294     @classmethod
295     def get_tags(cls, book):
296         return book.tags.filter(category=category)
297     return get_tags
298
299
300 def _tag_getter(category):
301     @classmethod
302     def get_tag(cls, book):
303         return book.tag_unicode(category)
304     return get_tag
305
306
307 def add_tag_getters():
308     for plural, singular in category_singular.items():
309         setattr(BookDetails, plural, _tags_getter(singular))
310         setattr(BookDetails, singular, _tag_getter(singular))
311
312 add_tag_getters()
313
314
315 # add fields for files in Book
316 def _file_getter(book_format):
317     field = "%s_file" % book_format
318
319     @classmethod
320     def get_file(cls, book):
321         f = getattr(book, field)
322         if f:
323             return MEDIA_BASE + f.url
324         else:
325             return ''
326     return get_file
327
328
329 def add_file_getters():
330     for book_format in Book.formats:
331         setattr(BookDetails, book_format, _file_getter(book_format))
332
333 add_file_getters()
334
335
336 class CollectionDetails(object):
337     """Custom Collection fields."""
338
339     @classmethod
340     def href(cls, collection):
341         """ Returns URI in the API for the collection. """
342
343         return API_BASE + reverse("api_collection", args=[collection.slug])
344
345     @classmethod
346     def url(cls, collection):
347         """ Returns URL on the site. """
348
349         return WL_BASE + collection.get_absolute_url()
350
351     @classmethod
352     def books(cls, collection):
353         return Book.objects.filter(collection.get_query())
354
355
356 class CollectionDetailHandler(BaseHandler, CollectionDetails):
357     allowed_methods = ('GET',)
358     fields = ['url', 'title', 'description', 'books']
359
360     @piwik_track
361     def read(self, request, slug):
362         """ Returns details of a collection, identified by slug. """
363         try:
364             return Collection.objects.get(slug=slug)
365         except Collection.DoesNotExist:
366             return rc.NOT_FOUND
367
368
369 class CollectionsHandler(BaseHandler, CollectionDetails):
370     allowed_methods = ('GET',)
371     model = Collection
372     fields = ['url', 'href', 'title']
373
374     @piwik_track
375     def read(self, request):
376         """ Returns all collections. """
377         return Collection.objects.all()
378
379
380 class TagDetails(object):
381     """Custom Tag fields."""
382
383     @classmethod
384     def href(cls, tag):
385         """ Returns URI in the API for the tag. """
386
387         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
388
389     @classmethod
390     def url(cls, tag):
391         """ Returns URL on the site. """
392
393         return WL_BASE + tag.get_absolute_url()
394
395
396 class TagDetailHandler(BaseHandler, TagDetails):
397     """ Responsible for details of a single Tag object. """
398
399     fields = ['name', 'url', 'sort_key', 'description']
400
401     @piwik_track
402     def read(self, request, category, slug):
403         """ Returns details of a tag, identified by category and slug. """
404
405         try:
406             category_sng = category_singular[category]
407         except KeyError:
408             return rc.NOT_FOUND
409
410         try:
411             return Tag.objects.get(category=category_sng, slug=slug)
412         except Tag.DoesNotExist:
413             return rc.NOT_FOUND
414
415
416 class TagsHandler(BaseHandler, TagDetails):
417     """ Main handler for Tag objects.
418
419     Responsible for lists of Tag objects
420     and fields used for representing Tags.
421
422     """
423     allowed_methods = ('GET',)
424     model = Tag
425     fields = ['name', 'href', 'url']
426
427     @piwik_track
428     def read(self, request, category=None, pk=None):
429         """ Lists all tags in the category (eg. all themes). """
430         if pk is not None:
431             try:
432                 return Tag.objects.exclude(category='set').get(pk=pk)
433             except Book.DoesNotExist:
434                 return rc.NOT_FOUND
435
436         try:
437             category_sng = category_singular[category]
438         except KeyError:
439             return rc.NOT_FOUND
440
441         tags = Tag.objects.filter(category=category_sng).exclude(items=None)
442         if tags.exists():
443             return tags
444         else:
445             return rc.NOT_FOUND
446
447
448 class FragmentDetails(object):
449     """Custom Fragment fields."""
450
451     @classmethod
452     def href(cls, fragment):
453         """ Returns URI in the API for the fragment. """
454
455         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
456
457     @classmethod
458     def url(cls, fragment):
459         """ Returns URL on the site for the fragment. """
460
461         return WL_BASE + fragment.get_absolute_url()
462
463     @classmethod
464     def themes(cls, fragment):
465         """ Returns a list of theme tags for the fragment. """
466
467         return fragment.tags.filter(category='theme')
468
469
470 class FragmentDetailHandler(BaseHandler, FragmentDetails):
471     fields = ['book', 'anchor', 'text', 'url', 'themes']
472
473     @piwik_track
474     def read(self, request, book, anchor):
475         """ Returns details of a fragment, identified by book slug and anchor. """
476         try:
477             return Fragment.objects.get(book__slug=book, anchor=anchor)
478         except Fragment.DoesNotExist:
479             return rc.NOT_FOUND
480
481
482 class FragmentsHandler(BaseHandler, FragmentDetails):
483     """ Main handler for Fragments.
484
485     Responsible for lists of Fragment objects
486     and fields used for representing Fragments.
487
488     """
489     model = Fragment
490     fields = ['book', 'url', 'anchor', 'href']
491     allowed_methods = ('GET',)
492
493     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
494
495     @piwik_track
496     def read(self, request, tags):
497         """ Lists all fragments with given book, tags, themes.
498
499         :param tags: should be a path of categories and slugs, i.e.:
500              books/book-slug/authors/an-author/themes/a-theme/
501
502         """
503         try:
504             tags, ancestors = read_tags(tags, allowed=self.categories)
505         except ValueError:
506             return rc.NOT_FOUND
507         fragments = Fragment.tagged.with_all(tags).select_related('book')
508         if fragments.exists():
509             return fragments
510         else:
511             return rc.NOT_FOUND
512
513
514 class PictureHandler(BaseHandler):
515     model = Picture
516     fields = ('slug', 'title')
517     allowed_methods = ('POST',)
518
519     def create(self, request):
520         if not request.user.has_perm('picture.add_picture'):
521             return rc.FORBIDDEN
522
523         data = json.loads(request.POST.get('data'))
524         form = PictureImportForm(data)
525         if form.is_valid():
526             form.save()
527             return rc.CREATED
528         else:
529             return rc.NOT_FOUND