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