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