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