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