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