e10a4b5a22aab07f58f5b27996f7cd6e6a8e4974
[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.formats + [
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.formats:
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.formats + BookMedia.formats + [
368                       'parent', 'parent_number',
369                       'tags',
370                       'license', 'license_description', 'source_name',
371                       'technical_editors', 'editors',
372                       'author', 'sort_key',
373                      ]
374         if fields:
375             fields = (f for f in fields if f in all_fields)
376         else:
377             fields = all_fields
378
379         extra_info = book.get_extra_info_value()
380
381         obj = {}
382         for field in fields:
383
384             if field in Book.formats:
385                 f = getattr(book, field+'_file')
386                 if f:
387                     obj[field] = {
388                         'url': f.url,
389                         'size': f.size,
390                     }
391
392             elif field in BookMedia.formats:
393                 media = []
394                 for m in book.media.filter(type=field):
395                     media.append({
396                         'url': m.file.url,
397                         'size': m.file.size,
398                     })
399                 if media:
400                     obj[field] = media
401
402             elif field == 'url':
403                 obj[field] = book.get_absolute_url()
404
405             elif field == 'tags':
406                 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set'))]
407
408             elif field == 'author':
409                 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author'))
410
411             elif field == 'parent':
412                 obj[field] = book.parent_id
413
414             elif field in ('license', 'license_description', 'source_name',
415                       'technical_editors', 'editors'):
416                 f = extra_info.get(field)
417                 if f:
418                     obj[field] = f
419
420             else:
421                 f = getattr(book, field)
422                 if f:
423                     obj[field] = f
424
425         obj['id'] = book.id
426         return obj
427
428     @classmethod
429     def book_changes(cls, request=None, since=0, until=None, fields=None):
430         since = datetime.fromtimestamp(int(since))
431         until = cls.until(until)
432
433         changes = {
434             'time_checked': timestamp(until)
435         }
436
437         if not fields:
438             fields = cls.fields(request, 'book_fields')
439
440         added = []
441         updated = []
442         deleted = []
443
444         last_change = since
445         for book in Book.objects.filter(changed_at__gte=since,
446                     changed_at__lt=until):
447             book_d = cls.book_dict(book, fields)
448             updated.append(book_d)
449         if updated:
450             changes['updated'] = updated
451
452         for book in Deleted.objects.filter(content_type=Book, 
453                     deleted_at__gte=since,
454                     deleted_at__lt=until,
455                     created_at__lt=since):
456             deleted.append(book.id)
457         if deleted:
458             changes['deleted'] = deleted
459
460         return changes
461
462     @staticmethod
463     def tag_dict(tag, fields=None):
464         all_fields = ('name', 'category', 'sort_key', 'description',
465                       'gazeta_link', 'wiki_link',
466                       'url', 'books',
467                      )
468
469         if fields:
470             fields = (f for f in fields if f in all_fields)
471         else:
472             fields = all_fields
473
474         obj = {}
475         for field in fields:
476
477             if field == 'url':
478                 obj[field] = tag.get_absolute_url()
479
480             elif field == 'books':
481                 obj[field] = [b.id for b in Book.tagged_top_level([tag])]
482
483             elif field == 'sort_key':
484                 obj[field] = tag.sort_key
485
486             else:
487                 f = getattr(tag, field)
488                 if f:
489                     obj[field] = f
490
491         obj['id'] = tag.id
492         return obj
493
494     @classmethod
495     def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
496         since = datetime.fromtimestamp(int(since))
497         until = cls.until(until)
498
499         changes = {
500             'time_checked': timestamp(until)
501         }
502
503         if not fields:
504             fields = cls.fields(request, 'tag_fields')
505         if not categories:
506             categories = cls.fields(request, 'tag_categories')
507
508         all_categories = ('author', 'epoch', 'kind', 'genre')
509         if categories:
510             categories = (c for c in categories if c in all_categories)
511         else:
512             categories = all_categories
513
514         updated = []
515         deleted = []
516
517         for tag in Tag.objects.filter(category__in=categories, 
518                     changed_at__gte=since,
519                     changed_at__lt=until):
520             # only serve non-empty tags
521             if tag.get_count():
522                 tag_d = cls.tag_dict(tag, fields)
523                 updated.append(tag_d)
524             elif tag.created_at < since:
525                 deleted.append(tag.id)
526         if updated:
527             changes['updated'] = updated
528
529         for tag in Deleted.objects.filter(category__in=categories,
530                 content_type=Tag, 
531                     deleted_at__gte=since,
532                     deleted_at__lt=until,
533                     created_at__lt=since):
534             deleted.append(tag.id)
535         if deleted:
536             changes['deleted'] = deleted
537
538         return changes
539
540     @classmethod
541     def changes(cls, request=None, since=0, until=None, book_fields=None,
542                 tag_fields=None, tag_categories=None):
543         until = cls.until(until)
544
545         changes = {
546             'time_checked': timestamp(until)
547         }
548
549         changes_by_type = {
550             'books': cls.book_changes(request, since, until, book_fields),
551             'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
552         }
553
554         for model in changes_by_type:
555             for field in changes_by_type[model]:
556                 if field == 'time_checked':
557                     continue
558                 changes.setdefault(field, {})[model] = changes_by_type[model][field]
559         return changes
560
561
562 class BookChangesHandler(CatalogueHandler):
563     allowed_methods = ('GET',)
564
565     @piwik_track
566     def read(self, request, since):
567         return self.book_changes(request, since)
568
569
570 class TagChangesHandler(CatalogueHandler):
571     allowed_methods = ('GET',)
572
573     @piwik_track
574     def read(self, request, since):
575         return self.tag_changes(request, since)
576
577
578 class ChangesHandler(CatalogueHandler):
579     allowed_methods = ('GET',)
580
581     @piwik_track
582     def read(self, request, since):
583         return self.changes(request, since)