508659e75fd4c3155ecac908c60ba3f8d864d531
[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     @classmethod
151     def media(self, book):
152         """ Returns all media for a book. """
153
154         return book.media.all()
155
156
157 # add categorized tags fields for Book
158 def _tags_getter(category):
159     @classmethod
160     def get_tags(cls, book):
161         return book.tags.filter(category=category)
162     return get_tags
163 for plural, singular in category_singular.items():
164     setattr(BooksHandler, plural, _tags_getter(singular))
165
166 # add fields for files in Book
167 def _file_getter(format):
168     field = "%s_file" % format
169     @classmethod
170     def get_file(cls, book):
171         f = getattr(book, field)
172         if f:
173             return MEDIA_BASE + f.url
174         else:
175             return ''
176     return get_file
177 for format in ('xml', 'txt', 'html', 'epub', 'pdf'):
178     setattr(BooksHandler, format, _file_getter(format))
179
180
181 class TagDetailHandler(BaseHandler):
182     """ Responsible for details of a single Tag object. """
183
184     fields = ['name', 'sort_key', 'description']
185
186     def read(self, request, category, slug):
187         """ Returns details of a tag, identified by category and slug. """
188
189         try:
190             category_sng = category_singular[category]
191         except KeyError, e:
192             return rc.NOT_FOUND
193
194         return get_object_or_404(Tag, category=category_sng, slug=slug)
195
196
197 class TagsHandler(BaseHandler):
198     """ Main handler for Tag objects.
199
200     Responsible for lists of Tag objects
201     and fields used for representing Tags.
202
203     """
204     allowed_methods = ('GET',)
205     model = Tag
206     fields = ['name', 'href']
207
208     def read(self, request, category):
209         """ Lists all tags in the category (eg. all themes). """
210
211         try:
212             category_sng = category_singular[category]
213         except KeyError, e:
214             return rc.NOT_FOUND
215
216         tags = Tag.objects.filter(category=category_sng)
217         tags = [t for t in tags if t.get_count() > 0]
218         if tags:
219             return tags
220         else:
221             return rc.NOT_FOUND
222
223
224     @classmethod
225     def href(cls, tag):
226         """ Returns URI in the API for the tag. """
227
228         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
229
230
231 class FragmentDetailHandler(BaseHandler):
232     fields = ['book', 'anchor', 'text', 'url', 'themes']
233
234     def read(self, request, slug, anchor):
235         """ Returns details of a fragment, identified by book slug and anchor. """
236
237         return get_object_or_404(Fragment, book__slug=slug, anchor=anchor)
238
239
240 class FragmentsHandler(BaseHandler):
241     """ Main handler for Fragments.
242
243     Responsible for lists of Fragment objects
244     and fields used for representing Fragments.
245
246     """
247     model = Fragment
248     fields = ['book', 'anchor', 'href']
249     allowed_methods = ('GET',)
250
251     categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
252
253     def read(self, request, tags):
254         """ Lists all fragments with given book, tags, themes.
255
256         :param tags: should be a path of categories and slugs, i.e.:
257              books/book-slug/authors/an-author/themes/a-theme/
258
259         """
260         tags = read_tags(tags, allowed=self.categories)
261         fragments = Fragment.tagged.with_all(tags).select_related('book')
262         if fragments.exists():
263             return fragments
264         else:
265             return rc.NOT_FOUND
266
267     @classmethod
268     def href(cls, fragment):
269         """ Returns URI in the API for the fragment. """
270
271         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
272
273     @classmethod
274     def url(cls, fragment):
275         """ Returns URL on the site for the fragment. """
276
277         return WL_BASE + fragment.get_absolute_url()
278
279     @classmethod
280     def themes(cls, fragment):
281         """ Returns a list of theme tags for the fragment. """
282
283         return fragment.tags.filter(category='theme')
284
285
286
287
288 # Changes handlers
289
290 class CatalogueHandler(BaseHandler):
291
292     @staticmethod
293     def fields(request, name):
294         fields_str = request.GET.get(name) if request is not None else None
295         return fields_str.split(',') if fields_str is not None else None
296
297     @staticmethod
298     def until(t=None):
299         """ Returns time suitable for use as upper time boundary for check.
300
301             Used to avoid issues with time between setting the change stamp
302             and actually saving the model in database.
303             Cuts the microsecond part to avoid issues with DBs where time has
304             more precision.
305
306             :param datetime t: manually sets the upper boundary
307
308         """
309         # set to five minutes ago, to avoid concurrency issues
310         if t is None:
311             t = datetime.now() - timedelta(seconds=settings.API_WAIT)
312         # set to whole second in case DB supports something smaller
313         return t.replace(microsecond=0)
314
315     @staticmethod
316     def book_dict(book, fields=None):
317         all_fields = ('url', 'title', 'description',
318                       'gazeta_link', 'wiki_link',
319                       'xml', 'epub', 'txt', 'pdf', 'html',
320                       'mp3', 'ogg', 'daisy',
321                       'parent', 'parent_number',
322                       'tags',
323                       'license', 'license_description', 'source_name',
324                       'technical_editors', 'editors',
325                       'author', 'sort_key',
326                      )
327         if fields:
328             fields = (f for f in fields if f in all_fields)
329         else:
330             fields = all_fields
331
332         extra_info = book.get_extra_info_value()
333
334         obj = {}
335         for field in fields:
336
337             if field in ('xml', 'epub', 'txt', 'pdf', 'html'):
338                 f = getattr(book, field+'_file')
339                 if f:
340                     obj[field] = {
341                         'url': f.url,
342                         'size': f.size,
343                     }
344
345             elif field in ('mp3', 'ogg', 'daisy'):
346                 media = []
347                 for m in book.media.filter(type=field):
348                     media.append({
349                         'url': m.file.url,
350                         'size': m.file.size,
351                     })
352                 if media:
353                     obj[field] = media
354
355             elif field == 'url':
356                 obj[field] = book.get_absolute_url()
357
358             elif field == 'tags':
359                 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set'))]
360
361             elif field == 'author':
362                 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author'))
363
364             elif field == 'parent':
365                 obj[field] = book.parent_id
366
367             elif field in ('license', 'license_description', 'source_name',
368                       'technical_editors', 'editors'):
369                 f = extra_info.get(field)
370                 if f:
371                     obj[field] = f
372
373             else:
374                 f = getattr(book, field)
375                 if f:
376                     obj[field] = f
377
378         obj['id'] = book.id
379         return obj
380
381     @classmethod
382     def book_changes(cls, request=None, since=0, until=None, fields=None):
383         since = datetime.fromtimestamp(int(since))
384         until = cls.until(until)
385
386         changes = {
387             'time_checked': timestamp(until)
388         }
389
390         if not fields:
391             fields = cls.fields(request, 'book_fields')
392
393         added = []
394         updated = []
395         deleted = []
396
397         last_change = since
398         for book in Book.objects.filter(changed_at__gte=since,
399                     changed_at__lt=until):
400             book_d = cls.book_dict(book, fields)
401             updated.append(book_d)
402         if updated:
403             changes['updated'] = updated
404
405         for book in Deleted.objects.filter(content_type=Book, 
406                     deleted_at__gte=since,
407                     deleted_at__lt=until,
408                     created_at__lt=since):
409             deleted.append(book.id)
410         if deleted:
411             changes['deleted'] = deleted
412
413         return changes
414
415     @staticmethod
416     def tag_dict(tag, fields=None):
417         all_fields = ('name', 'category', 'sort_key', 'description',
418                       'gazeta_link', 'wiki_link',
419                       'url', 'books',
420                      )
421
422         if fields:
423             fields = (f for f in fields if f in all_fields)
424         else:
425             fields = all_fields
426
427         obj = {}
428         for field in fields:
429
430             if field == 'url':
431                 obj[field] = tag.get_absolute_url()
432
433             elif field == 'books':
434                 obj[field] = [b.id for b in Book.tagged_top_level([tag])]
435
436             elif field == 'sort_key':
437                 obj[field] = tag.sort_key
438
439             else:
440                 f = getattr(tag, field)
441                 if f:
442                     obj[field] = f
443
444         obj['id'] = tag.id
445         return obj
446
447     @classmethod
448     def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
449         since = datetime.fromtimestamp(int(since))
450         until = cls.until(until)
451
452         changes = {
453             'time_checked': timestamp(until)
454         }
455
456         if not fields:
457             fields = cls.fields(request, 'tag_fields')
458         if not categories:
459             categories = cls.fields(request, 'tag_categories')
460
461         all_categories = ('author', 'epoch', 'kind', 'genre')
462         if categories:
463             categories = (c for c in categories if c in all_categories)
464         else:
465             categories = all_categories
466
467         updated = []
468         deleted = []
469
470         for tag in Tag.objects.filter(category__in=categories, 
471                     changed_at__gte=since,
472                     changed_at__lt=until):
473             # only serve non-empty tags
474             if tag.get_count():
475                 tag_d = cls.tag_dict(tag, fields)
476                 updated.append(tag_d)
477             elif tag.created_at < since:
478                 deleted.append(tag.id)
479         if updated:
480             changes['updated'] = updated
481
482         for tag in Deleted.objects.filter(category__in=categories,
483                 content_type=Tag, 
484                     deleted_at__gte=since,
485                     deleted_at__lt=until,
486                     created_at__lt=since):
487             deleted.append(tag.id)
488         if deleted:
489             changes['deleted'] = deleted
490
491         return changes
492
493     @classmethod
494     def changes(cls, request=None, since=0, until=None, book_fields=None,
495                 tag_fields=None, tag_categories=None):
496         until = cls.until(until)
497
498         changes = {
499             'time_checked': timestamp(until)
500         }
501
502         changes_by_type = {
503             'books': cls.book_changes(request, since, until, book_fields),
504             'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
505         }
506
507         for model in changes_by_type:
508             for field in changes_by_type[model]:
509                 if field == 'time_checked':
510                     continue
511                 changes.setdefault(field, {})[model] = changes_by_type[model][field]
512         return changes
513
514
515 class BookChangesHandler(CatalogueHandler):
516     allowed_methods = ('GET',)
517
518     def read(self, request, since):
519         return self.book_changes(request, since)
520
521
522 class TagChangesHandler(CatalogueHandler):
523     allowed_methods = ('GET',)
524
525     def read(self, request, since):
526         return self.tag_changes(request, since)
527
528
529 class ChangesHandler(CatalogueHandler):
530     allowed_methods = ('GET',)
531
532     def read(self, request, since):
533         return self.changes(request, since)