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