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