c118d95df8a05a3a8a9e2f2c87218d09a6ab6ce1
[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 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 = 'http://' + Site.objects.get_current().domain
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     while tags:
61         category = tags.pop(0)
62         slug = tags.pop(0)
63
64         try:
65             category = category_singular[category]
66         except KeyError:
67             raise ValueError('Unknown category.')
68
69         if not category in allowed:
70             raise ValueError('Category not allowed.')
71
72         # !^%@#$^#!
73         if category == 'book':
74             slug = 'l-' + 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
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
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         print slug
299         """ Returns details of a collection, identified by slug. """
300         try:
301             return Collection.objects.get(slug=slug)
302         except Collection.DoesNotExist:
303             return rc.NOT_FOUND
304
305
306 class CollectionsHandler(BaseHandler, CollectionDetails):
307     allowed_methods = ('GET',)
308     model = Collection
309     fields = ['url', 'href', 'title']
310
311     @piwik_track
312     def read(self, request):
313         """ Returns all collections. """
314         return Collection.objects.all()
315
316
317 class TagDetails(object):
318     """Custom Tag fields."""
319
320     @classmethod
321     def href(cls, tag):
322         """ Returns URI in the API for the tag. """
323
324         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
325
326     @classmethod
327     def url(cls, tag):
328         """ Returns URL on the site. """
329
330         return WL_BASE + tag.get_absolute_url()
331
332
333 class TagDetailHandler(BaseHandler, TagDetails):
334     """ Responsible for details of a single Tag object. """
335
336     fields = ['name', 'url', 'sort_key', 'description']
337
338     @piwik_track
339     def read(self, request, category, slug):
340         """ Returns details of a tag, identified by category and slug. """
341
342         try:
343             category_sng = category_singular[category]
344         except KeyError, e:
345             return rc.NOT_FOUND
346
347         try:
348             return Tag.objects.get(category=category_sng, slug=slug)
349         except Tag.DoesNotExist:
350             return rc.NOT_FOUND
351
352
353 class TagsHandler(BaseHandler, TagDetails):
354     """ Main handler for Tag objects.
355
356     Responsible for lists of Tag objects
357     and fields used for representing Tags.
358
359     """
360     allowed_methods = ('GET',)
361     model = Tag
362     fields = ['name', 'href', 'url']
363
364     @piwik_track
365     def read(self, request, category):
366         """ Lists all tags in the category (eg. all themes). """
367
368         try:
369             category_sng = category_singular[category]
370         except KeyError, e:
371             return rc.NOT_FOUND
372
373         tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
374         if tags.exists():
375             return tags
376         else:
377             return rc.NOT_FOUND
378
379
380 class FragmentDetails(object):
381     """Custom Fragment fields."""
382
383     @classmethod
384     def href(cls, fragment):
385         """ Returns URI in the API for the fragment. """
386
387         return API_BASE + reverse("api_fragment", 
388             args=[fragment.book.slug, fragment.anchor])
389
390     @classmethod
391     def url(cls, fragment):
392         """ Returns URL on the site for the fragment. """
393
394         return WL_BASE + fragment.get_absolute_url()
395
396     @classmethod
397     def themes(cls, fragment):
398         """ Returns a list of theme tags for the fragment. """
399
400         return fragment.tags.filter(category='theme')
401
402
403 class FragmentDetailHandler(BaseHandler, FragmentDetails):
404     fields = ['book', 'anchor', 'text', 'url', 'themes']
405
406     @piwik_track
407     def read(self, request, book, anchor):
408         """ Returns details of a fragment, identified by book slug and anchor. """
409         try:
410             return Fragment.objects.get(book__slug=book, anchor=anchor)
411         except Fragment.DoesNotExist:
412             return rc.NOT_FOUND
413
414
415 class FragmentsHandler(BaseHandler, FragmentDetails):
416     """ Main handler for Fragments.
417
418     Responsible for lists of Fragment objects
419     and fields used for representing Fragments.
420
421     """
422     model = Fragment
423     fields = ['book', 'url', 'anchor', 'href']
424     allowed_methods = ('GET',)
425
426     categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
427
428     @piwik_track
429     def read(self, request, tags):
430         """ Lists all fragments with given book, tags, themes.
431
432         :param tags: should be a path of categories and slugs, i.e.:
433              books/book-slug/authors/an-author/themes/a-theme/
434
435         """
436         try:
437             tags = read_tags(tags, allowed=self.categories)
438         except ValueError:
439             return rc.NOT_FOUND
440         fragments = Fragment.tagged.with_all(tags).select_related('book')
441         if fragments.exists():
442             return fragments
443         else:
444             return rc.NOT_FOUND
445
446
447
448 # Changes handlers
449
450 class CatalogueHandler(BaseHandler):
451
452     @staticmethod
453     def fields(request, name):
454         fields_str = request.GET.get(name) if request is not None else None
455         return fields_str.split(',') if fields_str is not None else None
456
457     @staticmethod
458     def until(t=None):
459         """ Returns time suitable for use as upper time boundary for check.
460
461             Used to avoid issues with time between setting the change stamp
462             and actually saving the model in database.
463             Cuts the microsecond part to avoid issues with DBs where time has
464             more precision.
465
466             :param datetime t: manually sets the upper boundary
467
468         """
469         # set to five minutes ago, to avoid concurrency issues
470         if t is None:
471             t = datetime.utcnow().replace(tzinfo=utc) - timedelta(seconds=settings.API_WAIT)
472         # set to whole second in case DB supports something smaller
473         return t.replace(microsecond=0)
474
475     @staticmethod
476     def book_dict(book, fields=None):
477         all_fields = ['url', 'title', 'description',
478                       'gazeta_link', 'wiki_link',
479                       ] + Book.formats + BookMedia.formats.keys() + [
480                       'parent', 'parent_number',
481                       'tags',
482                       'license', 'license_description', 'source_name',
483                       'technical_editors', 'editors',
484                       'author', 'sort_key',
485                      ]
486         if fields:
487             fields = (f for f in fields if f in all_fields)
488         else:
489             fields = all_fields
490
491         extra_info = book.extra_info
492
493         obj = {}
494         for field in fields:
495
496             if field in Book.formats:
497                 f = getattr(book, field+'_file')
498                 if f:
499                     obj[field] = {
500                         'url': f.url,
501                         'size': f.size,
502                     }
503
504             elif field in BookMedia.formats:
505                 media = []
506                 for m in book.media.filter(type=field).iterator():
507                     media.append({
508                         'url': m.file.url,
509                         'size': m.file.size,
510                     })
511                 if media:
512                     obj[field] = media
513
514             elif field == 'url':
515                 obj[field] = book.get_absolute_url()
516
517             elif field == 'tags':
518                 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set')).iterator()]
519
520             elif field == 'author':
521                 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
522
523             elif field == 'parent':
524                 obj[field] = book.parent_id
525
526             elif field in ('license', 'license_description', 'source_name',
527                       'technical_editors', 'editors'):
528                 f = extra_info.get(field)
529                 if f:
530                     obj[field] = f
531
532             else:
533                 f = getattr(book, field)
534                 if f:
535                     obj[field] = f
536
537         obj['id'] = book.id
538         return obj
539
540     @classmethod
541     def book_changes(cls, request=None, since=0, until=None, fields=None):
542         since = datetime.fromtimestamp(int(since), tz)
543         until = cls.until(until)
544
545         changes = {
546             'time_checked': timestamp(until)
547         }
548
549         if not fields:
550             fields = cls.fields(request, 'book_fields')
551
552         added = []
553         updated = []
554         deleted = []
555
556         last_change = since
557         for book in Book.objects.filter(changed_at__gte=since,
558                     changed_at__lt=until).iterator():
559             book_d = cls.book_dict(book, fields)
560             updated.append(book_d)
561         if updated:
562             changes['updated'] = updated
563
564         for book in Deleted.objects.filter(content_type=Book, 
565                     deleted_at__gte=since,
566                     deleted_at__lt=until,
567                     created_at__lt=since).iterator():
568             deleted.append(book.id)
569         if deleted:
570             changes['deleted'] = deleted
571
572         return changes
573
574     @staticmethod
575     def tag_dict(tag, fields=None):
576         all_fields = ('name', 'category', 'sort_key', 'description',
577                       'gazeta_link', 'wiki_link',
578                       'url', 'books',
579                      )
580
581         if fields:
582             fields = (f for f in fields if f in all_fields)
583         else:
584             fields = all_fields
585
586         obj = {}
587         for field in fields:
588
589             if field == 'url':
590                 obj[field] = tag.get_absolute_url()
591
592             elif field == 'books':
593                 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
594
595             elif field == 'sort_key':
596                 obj[field] = tag.sort_key
597
598             else:
599                 f = getattr(tag, field)
600                 if f:
601                     obj[field] = f
602
603         obj['id'] = tag.id
604         return obj
605
606     @classmethod
607     def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
608         since = datetime.fromtimestamp(int(since), tz)
609         until = cls.until(until)
610
611         changes = {
612             'time_checked': timestamp(until)
613         }
614
615         if not fields:
616             fields = cls.fields(request, 'tag_fields')
617         if not categories:
618             categories = cls.fields(request, 'tag_categories')
619
620         all_categories = ('author', 'epoch', 'kind', 'genre')
621         if categories:
622             categories = (c for c in categories if c in all_categories)
623         else:
624             categories = all_categories
625
626         updated = []
627         deleted = []
628
629         for tag in Tag.objects.filter(category__in=categories, 
630                     changed_at__gte=since,
631                     changed_at__lt=until).iterator():
632             # only serve non-empty tags
633             if tag.book_count:
634                 tag_d = cls.tag_dict(tag, fields)
635                 updated.append(tag_d)
636             elif tag.created_at < since:
637                 deleted.append(tag.id)
638         if updated:
639             changes['updated'] = updated
640
641         for tag in Deleted.objects.filter(category__in=categories,
642                 content_type=Tag, 
643                     deleted_at__gte=since,
644                     deleted_at__lt=until,
645                     created_at__lt=since).iterator():
646             deleted.append(tag.id)
647         if deleted:
648             changes['deleted'] = deleted
649
650         return changes
651
652     @classmethod
653     def changes(cls, request=None, since=0, until=None, book_fields=None,
654                 tag_fields=None, tag_categories=None):
655         until = cls.until(until)
656         since = int(since)
657
658         if not since:
659             cache = get_cache('api')
660             key = hash((book_fields, tag_fields, tag_categories,
661                     tuple(sorted(request.GET.items()))
662                   ))
663             value = cache.get(key)
664             if value is not None:
665                 return value
666
667         changes = {
668             'time_checked': timestamp(until)
669         }
670
671         changes_by_type = {
672             'books': cls.book_changes(request, since, until, book_fields),
673             'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
674         }
675
676         for model in changes_by_type:
677             for field in changes_by_type[model]:
678                 if field == 'time_checked':
679                     continue
680                 changes.setdefault(field, {})[model] = changes_by_type[model][field]
681
682         if not since:
683             cache.set(key, changes)
684
685         return changes
686
687
688 class BookChangesHandler(CatalogueHandler):
689     allowed_methods = ('GET',)
690
691     @piwik_track
692     def read(self, request, since):
693         return self.book_changes(request, since)
694
695
696 class TagChangesHandler(CatalogueHandler):
697     allowed_methods = ('GET',)
698
699     @piwik_track
700     def read(self, request, since):
701         return self.tag_changes(request, since)
702
703
704 class ChangesHandler(CatalogueHandler):
705     allowed_methods = ('GET',)
706
707     @piwik_track
708     def read(self, request, since):
709         return self.changes(request, since)
710
711
712 class PictureHandler(BaseHandler):
713     model = Picture
714     fields = ('slug', 'title')
715     allowed_methods = ('POST',)
716
717     def create(self, request):
718         if not request.user.has_perm('picture.add_picture'):
719             return rc.FORBIDDEN
720
721         data = json.loads(request.POST.get('data'))
722         form = PictureImportForm(data)
723         if form.is_valid():
724             form.save()
725             return rc.CREATED
726         else:
727             return rc.NOT_FOUND