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