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