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