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