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