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