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