270a4fdf618810d8229866943405b3302bc784e4
[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).exclude(book_count=0)
253         if tags.exists():
254             return tags
255         else:
256             return rc.NOT_FOUND
257
258
259     @classmethod
260     def href(cls, tag):
261         """ Returns URI in the API for the tag. """
262
263         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
264
265
266 class FragmentDetailHandler(BaseHandler):
267     fields = ['book', 'anchor', 'text', 'url', 'themes']
268
269     @piwik_track
270     def read(self, request, book, anchor):
271         """ Returns details of a fragment, identified by book slug and anchor. """
272         kwargs = Book.split_urlid(book)
273         if not kwargs:
274             return rc.NOT_FOUND
275
276         fragment_kwargs = {}
277         for field, value in kwargs.items():
278             fragment_kwargs['book__' + field] = value
279
280         try:
281             return Fragment.objects.get(anchor=anchor, **fragment_kwargs)
282         except Fragment.DoesNotExist:
283             return rc.NOT_FOUND
284
285
286 class FragmentsHandler(BaseHandler):
287     """ Main handler for Fragments.
288
289     Responsible for lists of Fragment objects
290     and fields used for representing Fragments.
291
292     """
293     model = Fragment
294     fields = ['book', 'anchor', 'href']
295     allowed_methods = ('GET',)
296
297     categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
298
299     @piwik_track
300     def read(self, request, tags):
301         """ Lists all fragments with given book, tags, themes.
302
303         :param tags: should be a path of categories and slugs, i.e.:
304              books/book-slug/authors/an-author/themes/a-theme/
305
306         """
307         tags = read_tags(tags, allowed=self.categories)
308         fragments = Fragment.tagged.with_all(tags).select_related('book')
309         if fragments.exists():
310             return fragments
311         else:
312             return rc.NOT_FOUND
313
314     @classmethod
315     def href(cls, fragment):
316         """ Returns URI in the API for the fragment. """
317
318         return API_BASE + reverse("api_fragment", args=[fragment.book.urlid(), fragment.anchor])
319
320     @classmethod
321     def url(cls, fragment):
322         """ Returns URL on the site for the fragment. """
323
324         return WL_BASE + fragment.get_absolute_url()
325
326     @classmethod
327     def themes(cls, fragment):
328         """ Returns a list of theme tags for the fragment. """
329
330         return fragment.tags.filter(category='theme')
331
332
333
334
335 # Changes handlers
336
337 class CatalogueHandler(BaseHandler):
338
339     @staticmethod
340     def fields(request, name):
341         fields_str = request.GET.get(name) if request is not None else None
342         return fields_str.split(',') if fields_str is not None else None
343
344     @staticmethod
345     def until(t=None):
346         """ Returns time suitable for use as upper time boundary for check.
347
348             Used to avoid issues with time between setting the change stamp
349             and actually saving the model in database.
350             Cuts the microsecond part to avoid issues with DBs where time has
351             more precision.
352
353             :param datetime t: manually sets the upper boundary
354
355         """
356         # set to five minutes ago, to avoid concurrency issues
357         if t is None:
358             t = datetime.now() - timedelta(seconds=settings.API_WAIT)
359         # set to whole second in case DB supports something smaller
360         return t.replace(microsecond=0)
361
362     @staticmethod
363     def book_dict(book, fields=None):
364         all_fields = ['url', 'title', 'description',
365                       'gazeta_link', 'wiki_link',
366                       ] + Book.formats + BookMedia.formats + [
367                       'parent', 'parent_number',
368                       'tags',
369                       'license', 'license_description', 'source_name',
370                       'technical_editors', 'editors',
371                       'author', 'sort_key',
372                      ]
373         if fields:
374             fields = (f for f in fields if f in all_fields)
375         else:
376             fields = all_fields
377
378         extra_info = book.get_extra_info_value()
379
380         obj = {}
381         for field in fields:
382
383             if field in Book.formats:
384                 f = getattr(book, field+'_file')
385                 if f:
386                     obj[field] = {
387                         'url': f.url,
388                         'size': f.size,
389                     }
390
391             elif field in BookMedia.formats:
392                 media = []
393                 for m in book.media.filter(type=field):
394                     media.append({
395                         'url': m.file.url,
396                         'size': m.file.size,
397                     })
398                 if media:
399                     obj[field] = media
400
401             elif field == 'url':
402                 obj[field] = book.get_absolute_url()
403
404             elif field == 'tags':
405                 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set'))]
406
407             elif field == 'author':
408                 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author'))
409
410             elif field == 'parent':
411                 obj[field] = book.parent_id
412
413             elif field in ('license', 'license_description', 'source_name',
414                       'technical_editors', 'editors'):
415                 f = extra_info.get(field)
416                 if f:
417                     obj[field] = f
418
419             else:
420                 f = getattr(book, field)
421                 if f:
422                     obj[field] = f
423
424         obj['id'] = book.id
425         return obj
426
427     @classmethod
428     def book_changes(cls, request=None, since=0, until=None, fields=None):
429         since = datetime.fromtimestamp(int(since))
430         until = cls.until(until)
431
432         changes = {
433             'time_checked': timestamp(until)
434         }
435
436         if not fields:
437             fields = cls.fields(request, 'book_fields')
438
439         added = []
440         updated = []
441         deleted = []
442
443         last_change = since
444         for book in Book.objects.filter(changed_at__gte=since,
445                     changed_at__lt=until):
446             book_d = cls.book_dict(book, fields)
447             updated.append(book_d)
448         if updated:
449             changes['updated'] = updated
450
451         for book in Deleted.objects.filter(content_type=Book, 
452                     deleted_at__gte=since,
453                     deleted_at__lt=until,
454                     created_at__lt=since):
455             deleted.append(book.id)
456         if deleted:
457             changes['deleted'] = deleted
458
459         return changes
460
461     @staticmethod
462     def tag_dict(tag, fields=None):
463         all_fields = ('name', 'category', 'sort_key', 'description',
464                       'gazeta_link', 'wiki_link',
465                       'url', 'books',
466                      )
467
468         if fields:
469             fields = (f for f in fields if f in all_fields)
470         else:
471             fields = all_fields
472
473         obj = {}
474         for field in fields:
475
476             if field == 'url':
477                 obj[field] = tag.get_absolute_url()
478
479             elif field == 'books':
480                 obj[field] = [b.id for b in Book.tagged_top_level([tag])]
481
482             elif field == 'sort_key':
483                 obj[field] = tag.sort_key
484
485             else:
486                 f = getattr(tag, field)
487                 if f:
488                     obj[field] = f
489
490         obj['id'] = tag.id
491         return obj
492
493     @classmethod
494     def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
495         since = datetime.fromtimestamp(int(since))
496         until = cls.until(until)
497
498         changes = {
499             'time_checked': timestamp(until)
500         }
501
502         if not fields:
503             fields = cls.fields(request, 'tag_fields')
504         if not categories:
505             categories = cls.fields(request, 'tag_categories')
506
507         all_categories = ('author', 'epoch', 'kind', 'genre')
508         if categories:
509             categories = (c for c in categories if c in all_categories)
510         else:
511             categories = all_categories
512
513         updated = []
514         deleted = []
515
516         for tag in Tag.objects.filter(category__in=categories, 
517                     changed_at__gte=since,
518                     changed_at__lt=until):
519             # only serve non-empty tags
520             if tag.book_count:
521                 tag_d = cls.tag_dict(tag, fields)
522                 updated.append(tag_d)
523             elif tag.created_at < since:
524                 deleted.append(tag.id)
525         if updated:
526             changes['updated'] = updated
527
528         for tag in Deleted.objects.filter(category__in=categories,
529                 content_type=Tag, 
530                     deleted_at__gte=since,
531                     deleted_at__lt=until,
532                     created_at__lt=since):
533             deleted.append(tag.id)
534         if deleted:
535             changes['deleted'] = deleted
536
537         return changes
538
539     @classmethod
540     def changes(cls, request=None, since=0, until=None, book_fields=None,
541                 tag_fields=None, tag_categories=None):
542         until = cls.until(until)
543
544         changes = {
545             'time_checked': timestamp(until)
546         }
547
548         changes_by_type = {
549             'books': cls.book_changes(request, since, until, book_fields),
550             'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
551         }
552
553         for model in changes_by_type:
554             for field in changes_by_type[model]:
555                 if field == 'time_checked':
556                     continue
557                 changes.setdefault(field, {})[model] = changes_by_type[model][field]
558         return changes
559
560
561 class BookChangesHandler(CatalogueHandler):
562     allowed_methods = ('GET',)
563
564     @piwik_track
565     def read(self, request, since):
566         return self.book_changes(request, since)
567
568
569 class TagChangesHandler(CatalogueHandler):
570     allowed_methods = ('GET',)
571
572     @piwik_track
573     def read(self, request, since):
574         return self.tag_changes(request, since)
575
576
577 class ChangesHandler(CatalogueHandler):
578     allowed_methods = ('GET',)
579
580     @piwik_track
581     def read(self, request, since):
582         return self.changes(request, since)
583
584
585 class PictureHandler(BaseHandler):
586     model = Picture
587     fields = ('slug', 'title')
588     allowed_methods = ('POST',)
589
590     def create(self, request):
591         if not request.user.has_perm('catalogue.add_book'):
592             return rc.FORBIDDEN
593
594         data = json.loads(request.POST.get('data'))
595         form = BookImportForm(data)
596         if form.is_valid():
597             form.save()
598             return rc.CREATED
599         else:
600             return rc.NOT_FOUND