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