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