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