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