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