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