Add Book.ancestor m2m.
[wolnelektury.git] / apps / 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 from datetime import datetime, timedelta
6 import json
7
8 from django.conf import settings
9 from django.contrib.sites.models import Site
10 from django.core.cache import get_cache
11 from django.core.urlresolvers import reverse
12 from django.utils.functional import lazy
13 from django.utils.timezone import utc
14 from piston.handler import AnonymousBaseHandler, BaseHandler
15 from piston.utils import rc
16 from sorl.thumbnail import default
17
18 from api.helpers import timestamp
19 from api.models import Deleted
20 from catalogue.forms import BookImportForm
21 from catalogue.models import Book, Tag, BookMedia, Fragment, Collection
22 from picture.models import Picture
23 from picture.forms import PictureImportForm
24 from wolnelektury.utils import tz
25
26 from stats.utils import piwik_track
27
28 API_BASE = WL_BASE = MEDIA_BASE = lazy(
29     lambda: u'http://' + Site.objects.get_current().domain, unicode)()
30
31
32 category_singular = {
33     'authors': 'author',
34     'kinds': 'kind',
35     'genres': 'genre',
36     'epochs': 'epoch',
37     'themes': 'theme',
38     'books': 'book',
39 }
40 category_plural = {}
41 for k, v in category_singular.items():
42     category_plural[v] = k
43
44 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
45
46
47
48 def read_tags(tags, allowed):
49     """ Reads a path of filtering tags.
50
51     :param str tags: a path of category and slug pairs, like: authors/an-author/...
52     :returns: list of Tag objects
53     :raises: ValueError when tags can't be found
54     """
55     if not tags:
56         return [], []
57
58     tags = tags.strip('/').split('/')
59     real_tags = []
60     books = []
61     while tags:
62         category = tags.pop(0)
63         slug = tags.pop(0)
64
65         try:
66             category = category_singular[category]
67         except KeyError:
68             raise ValueError('Unknown category.')
69
70         if not category in allowed:
71             raise ValueError('Category not allowed.')
72
73         if category == 'book':
74             books.append(Book.objects.get(slug=slug))
75
76         try:
77             real_tags.append(Tag.objects.get(category=category, slug=slug))
78         except Tag.DoesNotExist:
79             raise ValueError('Tag not found')
80     return real_tags, books
81
82
83 # RESTful handlers
84
85
86 class BookMediaHandler(BaseHandler):
87     """ Responsible for representing media in Books. """
88
89     model = BookMedia
90     fields = ['name', 'type', 'url', 'artist', 'director']
91
92     @classmethod
93     def url(cls, media):
94         """ Link to media on site. """
95
96         return MEDIA_BASE + media.file.url
97
98     @classmethod
99     def artist(cls, media):
100         return media.extra_info.get('artist_name', '')
101
102     @classmethod
103     def director(cls, media):
104         return media.extra_info.get('director_name', '')
105
106
107 class BookDetails(object):
108     """Custom fields used for representing Books."""
109
110     @classmethod
111     def href(cls, book):
112         """ Returns an URI for a Book in the API. """
113         return API_BASE + reverse("api_book", args=[book.slug])
114
115     @classmethod
116     def url(cls, book):
117         """ Returns Book's URL on the site. """
118
119         return WL_BASE + book.get_absolute_url()
120
121     @classmethod
122     def children(cls, book):
123         """ Returns all children for a book. """
124
125         return book.children.all()
126
127     @classmethod
128     def media(cls, book):
129         """ Returns all media for a book. """
130         return book.media.all()
131
132     @classmethod
133     def cover(cls, book):
134         return MEDIA_BASE + book.cover.url if book.cover else ''
135
136     @classmethod
137     def cover_thumb(cls, book):
138         return MEDIA_BASE + default.backend.get_thumbnail(
139                     book.cover, "139x193").url if book.cover else ''
140
141
142
143 class BookDetailHandler(BaseHandler, BookDetails):
144     """ Main handler for Book objects.
145
146     Responsible for single Book details.
147     """
148     allowed_methods = ['GET']
149     fields = ['title', 'parent', 'children'] + Book.formats + [
150         'media', 'url', 'cover', 'cover_thumb'] + [
151             category_plural[c] for c in book_tag_categories]
152
153     @piwik_track
154     def read(self, request, book):
155         """ Returns details of a book, identified by a slug and lang. """
156         try:
157             return Book.objects.get(slug=book)
158         except Book.DoesNotExist:
159             return rc.NOT_FOUND
160
161
162 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
163     """ Main handler for Book objects.
164
165     Responsible for lists of Book objects.
166     """
167     allowed_methods = ('GET',)
168     model = Book
169     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
170
171     @classmethod
172     def genres(cls, book):
173         """ Returns all media for a book. """
174         return book.tags.filter(category='genre')
175
176     @piwik_track
177     def read(self, request, tags, top_level=False,
178                 audiobooks=False, daisy=False):
179         """ Lists all books with given tags.
180
181         :param tags: filtering tags; should be a path of categories
182              and slugs, i.e.: authors/an-author/epoch/an-epoch/
183         :param top_level: if True and a book is included in the results,
184              it's children are aren't. By default all books matching the tags
185              are returned.
186         """
187         try:
188             tags, ancestors_ = read_tags(tags, allowed=book_tag_categories)
189         except ValueError:
190             return rc.NOT_FOUND
191
192         if tags:
193             if top_level:
194                 books = Book.tagged_top_level(tags)
195                 return books if books else rc.NOT_FOUND
196             else:
197                 books = Book.tagged.with_all(tags)
198         else:
199             books = Book.objects.all()
200
201         if top_level:
202             books = books.filter(parent=None)
203         if audiobooks:
204             books = books.filter(media__type='mp3').distinct()
205         if daisy:
206             books = books.filter(media__type='daisy').distinct()
207
208         if books.exists():
209             return books
210         else:
211             return rc.NOT_FOUND
212
213     def create(self, request, *args, **kwargs):
214         return rc.FORBIDDEN
215
216
217 class BooksHandler(BookDetailHandler):
218     allowed_methods = ('GET', 'POST')
219     model = Book
220     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
221     anonymous = AnonymousBooksHandler
222
223     def create(self, request, *args, **kwargs):
224         if not request.user.has_perm('catalogue.add_book'):
225             return rc.FORBIDDEN
226
227         data = json.loads(request.POST.get('data'))
228         form = BookImportForm(data)
229         if form.is_valid():
230             form.save()
231             return rc.CREATED
232         else:
233             return rc.NOT_FOUND
234
235
236 class EBooksHandler(AnonymousBooksHandler):
237     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats)
238
239
240 # add categorized tags fields for Book
241 def _tags_getter(category):
242     @classmethod
243     def get_tags(cls, book):
244         return book.tags.filter(category=category)
245     return get_tags
246 def _tag_getter(category):
247     @classmethod
248     def get_tag(cls, book):
249         return ', '.join(tag.name for tag in book.tags.filter(category=category))
250     return get_tag
251 for plural, singular in category_singular.items():
252     setattr(BookDetails, plural, _tags_getter(singular))
253     setattr(BookDetails, singular, _tag_getter(singular))
254
255 # add fields for files in Book
256 def _file_getter(format):
257     field = "%s_file" % format
258     @classmethod
259     def get_file(cls, book):
260         f = getattr(book, field)
261         if f:
262             return MEDIA_BASE + f.url
263         else:
264             return ''
265     return get_file
266 for format in Book.formats:
267     setattr(BookDetails, format, _file_getter(format))
268
269
270 class CollectionDetails(object):
271     """Custom Collection fields."""
272
273     @classmethod
274     def href(cls, collection):
275         """ Returns URI in the API for the collection. """
276
277         return API_BASE + reverse("api_collection", args=[collection.slug])
278
279     @classmethod
280     def url(cls, collection):
281         """ Returns URL on the site. """
282
283         return WL_BASE + collection.get_absolute_url()
284
285     @classmethod
286     def books(cls, collection):
287         return Book.objects.filter(collection.get_query())
288
289
290
291 class CollectionDetailHandler(BaseHandler, CollectionDetails):
292     allowed_methods = ('GET',)
293     fields = ['url', 'title', 'description', 'books']
294
295     @piwik_track
296     def read(self, request, slug):
297         """ Returns details of a collection, identified by slug. """
298         try:
299             return Collection.objects.get(slug=slug)
300         except Collection.DoesNotExist:
301             return rc.NOT_FOUND
302
303
304 class CollectionsHandler(BaseHandler, CollectionDetails):
305     allowed_methods = ('GET',)
306     model = Collection
307     fields = ['url', 'href', 'title']
308
309     @piwik_track
310     def read(self, request):
311         """ Returns all collections. """
312         return Collection.objects.all()
313
314
315 class TagDetails(object):
316     """Custom Tag fields."""
317
318     @classmethod
319     def href(cls, tag):
320         """ Returns URI in the API for the tag. """
321
322         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
323
324     @classmethod
325     def url(cls, tag):
326         """ Returns URL on the site. """
327
328         return WL_BASE + tag.get_absolute_url()
329
330
331 class TagDetailHandler(BaseHandler, TagDetails):
332     """ Responsible for details of a single Tag object. """
333
334     fields = ['name', 'url', 'sort_key', 'description']
335
336     @piwik_track
337     def read(self, request, category, slug):
338         """ Returns details of a tag, identified by category and slug. """
339
340         try:
341             category_sng = category_singular[category]
342         except KeyError, e:
343             return rc.NOT_FOUND
344
345         try:
346             return Tag.objects.get(category=category_sng, slug=slug)
347         except Tag.DoesNotExist:
348             return rc.NOT_FOUND
349
350
351 class TagsHandler(BaseHandler, TagDetails):
352     """ Main handler for Tag objects.
353
354     Responsible for lists of Tag objects
355     and fields used for representing Tags.
356
357     """
358     allowed_methods = ('GET',)
359     model = Tag
360     fields = ['name', 'href', 'url']
361
362     @piwik_track
363     def read(self, request, category):
364         """ Lists all tags in the category (eg. all themes). """
365
366         try:
367             category_sng = category_singular[category]
368         except KeyError, e:
369             return rc.NOT_FOUND
370
371         tags = Tag.objects.filter(category=category_sng).exclude(items=None)
372         if tags.exists():
373             return tags
374         else:
375             return rc.NOT_FOUND
376
377
378 class FragmentDetails(object):
379     """Custom Fragment fields."""
380
381     @classmethod
382     def href(cls, fragment):
383         """ Returns URI in the API for the fragment. """
384
385         return API_BASE + reverse("api_fragment",
386             args=[fragment.book.slug, fragment.anchor])
387
388     @classmethod
389     def url(cls, fragment):
390         """ Returns URL on the site for the fragment. """
391
392         return WL_BASE + fragment.get_absolute_url()
393
394     @classmethod
395     def themes(cls, fragment):
396         """ Returns a list of theme tags for the fragment. """
397
398         return fragment.tags.filter(category='theme')
399
400
401 class FragmentDetailHandler(BaseHandler, FragmentDetails):
402     fields = ['book', 'anchor', 'text', 'url', 'themes']
403
404     @piwik_track
405     def read(self, request, book, anchor):
406         """ Returns details of a fragment, identified by book slug and anchor. """
407         try:
408             return Fragment.objects.get(book__slug=book, anchor=anchor)
409         except Fragment.DoesNotExist:
410             return rc.NOT_FOUND
411
412
413 class FragmentsHandler(BaseHandler, FragmentDetails):
414     """ Main handler for Fragments.
415
416     Responsible for lists of Fragment objects
417     and fields used for representing Fragments.
418
419     """
420     model = Fragment
421     fields = ['book', 'url', 'anchor', 'href']
422     allowed_methods = ('GET',)
423
424     categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
425
426     @piwik_track
427     def read(self, request, tags):
428         """ Lists all fragments with given book, tags, themes.
429
430         :param tags: should be a path of categories and slugs, i.e.:
431              books/book-slug/authors/an-author/themes/a-theme/
432
433         """
434         try:
435             tags, ancestors = read_tags(tags, allowed=self.categories)
436         except ValueError:
437             return rc.NOT_FOUND
438         fragments = Fragment.tagged.with_all(tags).select_related('book')
439         if fragments.exists():
440             return fragments
441         else:
442             return rc.NOT_FOUND
443
444
445
446 # Changes handlers
447
448 class CatalogueHandler(BaseHandler):
449
450     @staticmethod
451     def fields(request, name):
452         fields_str = request.GET.get(name) if request is not None else None
453         return fields_str.split(',') if fields_str is not None else None
454
455     @staticmethod
456     def until(t=None):
457         """ Returns time suitable for use as upper time boundary for check.
458
459             Used to avoid issues with time between setting the change stamp
460             and actually saving the model in database.
461             Cuts the microsecond part to avoid issues with DBs where time has
462             more precision.
463
464             :param datetime t: manually sets the upper boundary
465
466         """
467         # set to five minutes ago, to avoid concurrency issues
468         if t is None:
469             t = datetime.utcnow().replace(tzinfo=utc) - timedelta(seconds=settings.API_WAIT)
470         # set to whole second in case DB supports something smaller
471         return t.replace(microsecond=0)
472
473     @staticmethod
474     def book_dict(book, fields=None):
475         all_fields = ['url', 'title', 'description',
476                       'gazeta_link', 'wiki_link',
477                       ] + Book.formats + BookMedia.formats.keys() + [
478                       'parent', 'parent_number',
479                       'tags',
480                       'license', 'license_description', 'source_name',
481                       'technical_editors', 'editors',
482                       'author', 'sort_key',
483                      ]
484         if fields:
485             fields = (f for f in fields if f in all_fields)
486         else:
487             fields = all_fields
488
489         extra_info = book.extra_info
490
491         obj = {}
492         for field in fields:
493
494             if field in Book.formats:
495                 f = getattr(book, field+'_file')
496                 if f:
497                     obj[field] = {
498                         'url': f.url,
499                         'size': f.size,
500                     }
501
502             elif field in BookMedia.formats:
503                 media = []
504                 for m in book.media.filter(type=field).iterator():
505                     media.append({
506                         'url': m.file.url,
507                         'size': m.file.size,
508                     })
509                 if media:
510                     obj[field] = media
511
512             elif field == 'url':
513                 obj[field] = book.get_absolute_url()
514
515             elif field == 'tags':
516                 obj[field] = [t.id for t in book.tags.exclude(category='set').iterator()]
517
518             elif field == 'author':
519                 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
520
521             elif field == 'parent':
522                 obj[field] = book.parent_id
523
524             elif field in ('license', 'license_description', 'source_name',
525                       'technical_editors', 'editors'):
526                 f = extra_info.get(field)
527                 if f:
528                     obj[field] = f
529
530             else:
531                 f = getattr(book, field)
532                 if f:
533                     obj[field] = f
534
535         obj['id'] = book.id
536         return obj
537
538     @classmethod
539     def book_changes(cls, request=None, since=0, until=None, fields=None):
540         since = datetime.fromtimestamp(int(since), tz)
541         until = cls.until(until)
542
543         changes = {
544             'time_checked': timestamp(until)
545         }
546
547         if not fields:
548             fields = cls.fields(request, 'book_fields')
549
550         added = []
551         updated = []
552         deleted = []
553
554         last_change = since
555         for book in Book.objects.filter(changed_at__gte=since,
556                     changed_at__lt=until).iterator():
557             book_d = cls.book_dict(book, fields)
558             updated.append(book_d)
559         if updated:
560             changes['updated'] = updated
561
562         for book in Deleted.objects.filter(content_type=Book,
563                     deleted_at__gte=since,
564                     deleted_at__lt=until,
565                     created_at__lt=since).iterator():
566             deleted.append(book.id)
567         if deleted:
568             changes['deleted'] = deleted
569
570         return changes
571
572     @staticmethod
573     def tag_dict(tag, fields=None):
574         all_fields = ('name', 'category', 'sort_key', 'description',
575                       'gazeta_link', 'wiki_link',
576                       'url', 'books',
577                      )
578
579         if fields:
580             fields = (f for f in fields if f in all_fields)
581         else:
582             fields = all_fields
583
584         obj = {}
585         for field in fields:
586
587             if field == 'url':
588                 obj[field] = tag.get_absolute_url()
589
590             elif field == 'books':
591                 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
592
593             elif field == 'sort_key':
594                 obj[field] = tag.sort_key
595
596             else:
597                 f = getattr(tag, field)
598                 if f:
599                     obj[field] = f
600
601         obj['id'] = tag.id
602         return obj
603
604     @classmethod
605     def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
606         since = datetime.fromtimestamp(int(since), tz)
607         until = cls.until(until)
608
609         changes = {
610             'time_checked': timestamp(until)
611         }
612
613         if not fields:
614             fields = cls.fields(request, 'tag_fields')
615         if not categories:
616             categories = cls.fields(request, 'tag_categories')
617
618         all_categories = ('author', 'epoch', 'kind', 'genre')
619         if categories:
620             categories = (c for c in categories if c in all_categories)
621         else:
622             categories = all_categories
623
624         updated = []
625         deleted = []
626
627         for tag in Tag.objects.filter(category__in=categories,
628                     changed_at__gte=since,
629                     changed_at__lt=until
630                     ).exclude(items=None).iterator():
631             tag_d = cls.tag_dict(tag, fields)
632             updated.append(tag_d)
633         for tag in Tag.objects.filter(category__in=categories,
634                     created_at__lt=since,
635                     changed_at__gte=since,
636                     changed_at__lt=until,
637                     items=None).iterator():
638             deleted.append(tag.id)
639         if updated:
640             changes['updated'] = updated
641
642         for tag in Deleted.objects.filter(category__in=categories,
643                 content_type=Tag,
644                     deleted_at__gte=since,
645                     deleted_at__lt=until,
646                     created_at__lt=since).iterator():
647             deleted.append(tag.id)
648         if deleted:
649             changes['deleted'] = deleted
650
651         return changes
652
653     @classmethod
654     def changes(cls, request=None, since=0, until=None, book_fields=None,
655                 tag_fields=None, tag_categories=None):
656         until = cls.until(until)
657         since = int(since)
658
659         if not since:
660             cache = get_cache('api')
661             key = hash((book_fields, tag_fields, tag_categories,
662                     tuple(sorted(request.GET.items()))
663                   ))
664             value = cache.get(key)
665             if value is not None:
666                 return value
667
668         changes = {
669             'time_checked': timestamp(until)
670         }
671
672         changes_by_type = {
673             'books': cls.book_changes(request, since, until, book_fields),
674             'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
675         }
676
677         for model in changes_by_type:
678             for field in changes_by_type[model]:
679                 if field == 'time_checked':
680                     continue
681                 changes.setdefault(field, {})[model] = changes_by_type[model][field]
682
683         if not since:
684             cache.set(key, changes)
685
686         return changes
687
688
689 class BookChangesHandler(CatalogueHandler):
690     allowed_methods = ('GET',)
691
692     @piwik_track
693     def read(self, request, since):
694         return self.book_changes(request, since)
695
696
697 class TagChangesHandler(CatalogueHandler):
698     allowed_methods = ('GET',)
699
700     @piwik_track
701     def read(self, request, since):
702         return self.tag_changes(request, since)
703
704
705 class ChangesHandler(CatalogueHandler):
706     allowed_methods = ('GET',)
707
708     @piwik_track
709     def read(self, request, since):
710         return self.changes(request, since)
711
712
713 class PictureHandler(BaseHandler):
714     model = Picture
715     fields = ('slug', 'title')
716     allowed_methods = ('POST',)
717
718     def create(self, request):
719         if not request.user.has_perm('picture.add_picture'):
720             return rc.FORBIDDEN
721
722         data = json.loads(request.POST.get('data'))
723         form = PictureImportForm(data)
724         if form.is_valid():
725             form.save()
726             return rc.CREATED
727         else:
728             return rc.NOT_FOUND