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