query strings in API + minor changes
[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, request, 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
51     def process(category, slug):
52         if category == 'book':
53             try:
54                 books.append(Book.objects.get(slug=slug))
55             except Book.DoesNotExist:
56                 raise ValueError('Unknown book.')
57         try:
58             real_tags.append(Tag.objects.get(category=category, slug=slug))
59         except Tag.DoesNotExist:
60             raise ValueError('Tag not found')
61
62     if not tags:
63         return [], []
64
65     tags = tags.strip('/').split('/')
66     real_tags = []
67     books = []
68     while tags:
69         category = tags.pop(0)
70         slug = tags.pop(0)
71
72         try:
73             category = category_singular[category]
74         except KeyError:
75             raise ValueError('Unknown category.')
76
77         if category not in allowed:
78             raise ValueError('Category not allowed.')
79         process(category, slug)
80
81     for key in request.GET:
82         if key in category_singular:
83             category = category_singular[key]
84             if category in allowed:
85                 for slug in request.GET.getlist(key):
86                     process(category, slug)
87     return real_tags, books
88
89
90 # RESTful handlers
91
92
93 class BookMediaHandler(BaseHandler):
94     """ Responsible for representing media in Books. """
95
96     model = BookMedia
97     fields = ['name', 'type', 'url', 'artist', 'director']
98
99     @classmethod
100     def url(cls, media):
101         """ Link to media on site. """
102
103         return MEDIA_BASE + media.file.url
104
105     @classmethod
106     def artist(cls, media):
107         return media.extra_info.get('artist_name', '')
108
109     @classmethod
110     def director(cls, media):
111         return media.extra_info.get('director_name', '')
112
113
114 class BookDetails(object):
115     """Custom fields used for representing Books."""
116
117     @classmethod
118     def href(cls, book):
119         """ Returns an URI for a Book in the API. """
120         return API_BASE + reverse("api_book", args=[book.slug])
121
122     @classmethod
123     def url(cls, book):
124         """ Returns Book's URL on the site. """
125
126         return WL_BASE + book.get_absolute_url()
127
128     @classmethod
129     def children(cls, book):
130         """ Returns all children for a book. """
131
132         return book.children.all()
133
134     @classmethod
135     def media(cls, book):
136         """ Returns all media for a book. """
137         return book.media.all()
138
139     @classmethod
140     def cover(cls, book):
141         return MEDIA_BASE + book.cover.url if book.cover else ''
142
143     @classmethod
144     def cover_thumb(cls, book):
145         return MEDIA_BASE + default.backend.get_thumbnail(
146                     book.cover, "139x193").url if book.cover else ''
147
148     @classmethod
149     def cover_source_image(cls, book):
150         url = book.cover_source()
151         return url.rstrip('/') + '/file/'
152
153
154 class BookDetailHandler(BaseHandler, BookDetails):
155     """ Main handler for Book objects.
156
157     Responsible for single Book details.
158     """
159     allowed_methods = ['GET']
160     fields = ['title', 'parent', 'children'] + Book.formats + [
161         'media', 'url', 'cover', 'cover_thumb'] + [
162             category_plural[c] for c in book_tag_categories]
163
164     @piwik_track
165     def read(self, request, book):
166         """ Returns details of a book, identified by a slug and lang. """
167         try:
168             return Book.objects.get(slug=book)
169         except Book.DoesNotExist:
170             return rc.NOT_FOUND
171
172
173 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
174     """ Main handler for Book objects.
175
176     Responsible for lists of Book objects.
177     """
178     allowed_methods = ('GET',)
179     model = Book
180     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb', 'slug']
181
182     @classmethod
183     def genres(cls, book):
184         """ Returns all media for a book. """
185         return book.tags.filter(category='genre')
186
187     @piwik_track
188     def read(self, request, tags=None, top_level=False, audiobooks=False, daisy=False, pk=None,
189              recommended=False, newest=False, books=None,
190              after=None, before=None, count=None):
191         """ Lists all books with given tags.
192
193         :param tags: filtering tags; should be a path of categories
194              and slugs, i.e.: authors/an-author/epoch/an-epoch/
195         :param top_level: if True and a book is included in the results,
196              it's children are aren't. By default all books matching the tags
197              are returned.
198         """
199         if pk is not None:
200             try:
201                 return Book.objects.get(pk=pk)
202             except Book.DoesNotExist:
203                 return rc.NOT_FOUND
204
205         try:
206             tags, _ancestors = read_tags(tags, request, allowed=book_tag_categories)
207         except ValueError:
208             return rc.NOT_FOUND
209
210         if 'after' in request.GET:
211             after = request.GET['after']
212         if 'before' in request.GET:
213             before = request.GET['before']
214         if 'count' in request.GET:
215             count = request.GET['count']
216
217         if tags:
218             if top_level:
219                 books = Book.tagged_top_level(tags)
220                 return books if books else rc.NOT_FOUND
221             else:
222                 books = Book.tagged.with_all(tags)
223         else:
224             books = books if books is not None else Book.objects.all()
225         books = books.order_by('slug')
226
227         if top_level:
228             books = books.filter(parent=None)
229         if audiobooks:
230             books = books.filter(media__type='mp3').distinct()
231         if daisy:
232             books = books.filter(media__type='daisy').distinct()
233         if recommended:
234             books = books.filter(recommended=True)
235         if newest:
236             books = books.order_by('-created_at')
237
238         if after:
239             books = books.filter(slug__gt=after)
240         if before:
241             books = books.filter(slug__lt=before)
242
243         books = books.only('slug', 'title', 'cover', 'cover_thumb')
244         for category in book_tag_categories:
245             books = prefetch_relations(books, category)
246
247         if count:
248             if before:
249                 books = list(reversed(books.order_by('-slug')[:count]))
250             else:
251                 books = books[:count]
252
253         return books
254
255     def create(self, request, *args, **kwargs):
256         return rc.FORBIDDEN
257
258
259 class BooksHandler(BookDetailHandler):
260     allowed_methods = ('GET', 'POST')
261     model = Book
262     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb', 'slug']
263     anonymous = AnonymousBooksHandler
264
265     def create(self, request, *args, **kwargs):
266         if not request.user.has_perm('catalogue.add_book'):
267             return rc.FORBIDDEN
268
269         data = json.loads(request.POST.get('data'))
270         form = BookImportForm(data)
271         if form.is_valid():
272             form.save()
273             return rc.CREATED
274         else:
275             return rc.NOT_FOUND
276
277
278 class EBooksHandler(AnonymousBooksHandler):
279     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
280
281
282 class FilterBooksHandler(AnonymousBooksHandler):
283     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb', 'slug', 'cover_source_image']
284
285     def read(self, request, title_part=None, author_part=None, is_lektura=None, is_audiobook=None,
286              after=None, before=None, count=None):
287         if 'title_part' in request.GET:
288             title_part = request.GET['title_part']
289         if 'author_part' in request.GET:
290             author_part = request.GET['author_part']
291         if 'is_lektura' in request.GET:
292             is_lektura = request.GET['is_lektura']
293         if 'is_audiobook' in request.GET:
294             is_audiobook = request.GET['is_audiobook']
295
296         if count is None:
297             count = 50
298         if is_lektura in ('true', 'false'):
299             is_lektura = is_lektura == 'true'
300         else:
301             is_lektura = None
302         if is_audiobook in ('true', 'false'):
303             is_audiobook = is_audiobook == 'true'
304         books = Book.objects.distinct()
305         if title_part:
306             books = books.filter(title__iregex='\m' + title_part)
307         if author_part is not None:
308             books = books.filter(cached_author__iregex='\m' + author_part)
309         if is_lektura is not None:
310             books = books.filter(has_audience=is_lektura)
311         if is_audiobook is not None:
312             if is_audiobook:
313                 books = books.filter(media__type='mp3')
314             else:
315                 books = books.exclude(media__type='mp3')
316         return super(FilterBooksHandler, self).read(request, books=books, after=after, before=before, count=count)
317
318
319 # add categorized tags fields for Book
320 def _tags_getter(category):
321     @classmethod
322     def get_tags(cls, book):
323         return book.tags.filter(category=category)
324     return get_tags
325
326
327 def _tag_getter(category):
328     @classmethod
329     def get_tag(cls, book):
330         return book.tag_unicode(category)
331     return get_tag
332
333
334 def add_tag_getters():
335     for plural, singular in category_singular.items():
336         setattr(BookDetails, plural, _tags_getter(singular))
337         setattr(BookDetails, singular, _tag_getter(singular))
338
339 add_tag_getters()
340
341
342 # add fields for files in Book
343 def _file_getter(book_format):
344     field = "%s_file" % book_format
345
346     @classmethod
347     def get_file(cls, book):
348         f = getattr(book, field)
349         if f:
350             return MEDIA_BASE + f.url
351         else:
352             return ''
353     return get_file
354
355
356 def add_file_getters():
357     for book_format in Book.formats:
358         setattr(BookDetails, book_format, _file_getter(book_format))
359
360 add_file_getters()
361
362
363 class CollectionDetails(object):
364     """Custom Collection fields."""
365
366     @classmethod
367     def href(cls, collection):
368         """ Returns URI in the API for the collection. """
369
370         return API_BASE + reverse("api_collection", args=[collection.slug])
371
372     @classmethod
373     def url(cls, collection):
374         """ Returns URL on the site. """
375
376         return WL_BASE + collection.get_absolute_url()
377
378     @classmethod
379     def books(cls, collection):
380         return Book.objects.filter(collection.get_query())
381
382
383 class CollectionDetailHandler(BaseHandler, CollectionDetails):
384     allowed_methods = ('GET',)
385     fields = ['url', 'title', 'description', 'books']
386
387     @piwik_track
388     def read(self, request, slug):
389         """ Returns details of a collection, identified by slug. """
390         try:
391             return Collection.objects.get(slug=slug)
392         except Collection.DoesNotExist:
393             return rc.NOT_FOUND
394
395
396 class CollectionsHandler(BaseHandler, CollectionDetails):
397     allowed_methods = ('GET',)
398     model = Collection
399     fields = ['url', 'href', 'title']
400
401     @piwik_track
402     def read(self, request):
403         """ Returns all collections. """
404         return Collection.objects.all()
405
406
407 class TagDetails(object):
408     """Custom Tag fields."""
409
410     @classmethod
411     def href(cls, tag):
412         """ Returns URI in the API for the tag. """
413
414         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
415
416     @classmethod
417     def url(cls, tag):
418         """ Returns URL on the site. """
419
420         return WL_BASE + tag.get_absolute_url()
421
422
423 class TagDetailHandler(BaseHandler, TagDetails):
424     """ Responsible for details of a single Tag object. """
425
426     fields = ['name', 'url', 'sort_key', 'description']
427
428     @piwik_track
429     def read(self, request, category, slug):
430         """ Returns details of a tag, identified by category and slug. """
431
432         try:
433             category_sng = category_singular[category]
434         except KeyError:
435             return rc.NOT_FOUND
436
437         try:
438             return Tag.objects.get(category=category_sng, slug=slug)
439         except Tag.DoesNotExist:
440             return rc.NOT_FOUND
441
442
443 class TagsHandler(BaseHandler, TagDetails):
444     """ Main handler for Tag objects.
445
446     Responsible for lists of Tag objects
447     and fields used for representing Tags.
448
449     """
450     allowed_methods = ('GET',)
451     model = Tag
452     fields = ['name', 'href', 'url', 'slug']
453
454     @piwik_track
455     def read(self, request, category=None, pk=None):
456         """ Lists all tags in the category (eg. all themes). """
457         if pk is not None:
458             try:
459                 return Tag.objects.exclude(category='set').get(pk=pk)
460             except Book.DoesNotExist:
461                 return rc.NOT_FOUND
462
463         try:
464             category_sng = category_singular[category]
465         except KeyError:
466             return rc.NOT_FOUND
467
468         after = request.GET.get('after')
469         before = request.GET.get('before')
470         count = request.GET.get('count')
471
472         tags = Tag.objects.filter(category=category_sng).exclude(items=None).order_by('slug')
473
474         if after:
475             tags = tags.filter(slug__gt=after)
476         if before:
477             tags = tags.filter(slug__lt=before)
478
479         if count:
480             if before:
481                 tags = list(reversed(tags.order_by('-slug')[:count]))
482             else:
483                 tags = tags[:count]
484
485         return tags
486
487
488 class FragmentDetails(object):
489     """Custom Fragment fields."""
490
491     @classmethod
492     def href(cls, fragment):
493         """ Returns URI in the API for the fragment. """
494
495         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
496
497     @classmethod
498     def url(cls, fragment):
499         """ Returns URL on the site for the fragment. """
500
501         return WL_BASE + fragment.get_absolute_url()
502
503     @classmethod
504     def themes(cls, fragment):
505         """ Returns a list of theme tags for the fragment. """
506
507         return fragment.tags.filter(category='theme')
508
509
510 class FragmentDetailHandler(BaseHandler, FragmentDetails):
511     fields = ['book', 'anchor', 'text', 'url', 'themes']
512
513     @piwik_track
514     def read(self, request, book, anchor):
515         """ Returns details of a fragment, identified by book slug and anchor. """
516         try:
517             return Fragment.objects.get(book__slug=book, anchor=anchor)
518         except Fragment.DoesNotExist:
519             return rc.NOT_FOUND
520
521
522 class FragmentsHandler(BaseHandler, FragmentDetails):
523     """ Main handler for Fragments.
524
525     Responsible for lists of Fragment objects
526     and fields used for representing Fragments.
527
528     """
529     model = Fragment
530     fields = ['book', 'url', 'anchor', 'href']
531     allowed_methods = ('GET',)
532
533     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
534
535     @piwik_track
536     def read(self, request, tags):
537         """ Lists all fragments with given book, tags, themes.
538
539         :param tags: should be a path of categories and slugs, i.e.:
540              books/book-slug/authors/an-author/themes/a-theme/
541
542         """
543         try:
544             tags, ancestors = read_tags(tags, allowed=self.categories)
545         except ValueError:
546             return rc.NOT_FOUND
547         fragments = Fragment.tagged.with_all(tags).select_related('book')
548         if fragments.exists():
549             return fragments
550         else:
551             return rc.NOT_FOUND
552
553
554 class PictureHandler(BaseHandler):
555     model = Picture
556     fields = ('slug', 'title')
557     allowed_methods = ('POST',)
558
559     def create(self, request):
560         if not request.user.has_perm('picture.add_picture'):
561             return rc.FORBIDDEN
562
563         data = json.loads(request.POST.get('data'))
564         form = PictureImportForm(data)
565         if form.is_valid():
566             form.save()
567             return rc.CREATED
568         else:
569             return rc.NOT_FOUND