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