better related books
[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
40 def read_tags(tags, allowed):
41     """ Reads a path of filtering tags.
42
43     :param str tags: a path of category and slug pairs, like: authors/an-author/...
44     :returns: list of Tag objects
45     :raises: ValueError when tags can't be found
46     """
47     if not tags:
48         return []
49
50     tags = tags.strip('/').split('/')
51     real_tags = []
52     while tags:
53         category = tags.pop(0)
54         slug = tags.pop(0)
55
56         try:
57             category = category_singular[category]
58         except KeyError:
59             raise ValueError('Unknown category.')
60
61         if not category in allowed:
62             raise ValueError('Category not allowed.')
63
64         # !^%@#$^#!
65         if category == 'book':
66             slug = 'l-' + slug
67
68         try:
69             real_tags.append(Tag.objects.get(category=category, slug=slug))
70         except Tag.DoesNotExist:
71             raise ValueError('Tag not found')
72     return real_tags
73
74
75 # RESTful handlers
76
77
78 class BookMediaHandler(BaseHandler):
79     """ Responsible for representing media in Books. """
80
81     model = BookMedia
82     fields = ['name', 'type', 'url']
83
84     @classmethod
85     def url(cls, media):
86         """ Link to media on site. """
87
88         return MEDIA_BASE + media.file.url
89
90
91 class BookDetailHandler(BaseHandler):
92     """ Main handler for Book objects.
93
94     Responsible for lists of Book objects
95     and fields used for representing Books.
96
97     """
98     allowed_methods = ['GET']
99     fields = ['title', 'parent'] + Book.formats + [
100         'media', 'url'] + category_singular.keys()
101
102     @piwik_track
103     def read(self, request, slug):
104         """ Returns details of a book, identified by a slug and lang. """
105         try:
106             return Book.objects.get(slug=slug)
107         except Book.DoesNotExist:
108             return rc.NOT_FOUND
109
110
111 class AnonymousBooksHandler(AnonymousBaseHandler):
112     """ Main handler for Book objects.
113
114     Responsible for lists of Book objects
115     and fields used for representing Books.
116
117     """
118     allowed_methods = ('GET',)
119     model = Book
120     fields = ['href', 'title']
121
122     categories = set(['author', 'epoch', 'kind', 'genre'])
123
124     @classmethod
125     def href(cls, book):
126         """ Returns an URI for a Book in the API. """
127         return API_BASE + reverse("api_book", args=[book.slug])
128
129     @classmethod
130     def url(cls, book):
131         """ Returns Book's URL on the site. """
132
133         return WL_BASE + book.get_absolute_url()
134
135     @piwik_track
136     def read(self, request, tags, top_level=False):
137         """ Lists all books with given tags.
138
139         :param tags: filtering tags; should be a path of categories
140              and slugs, i.e.: authors/an-author/epoch/an-epoch/
141         :param top_level: if True and a book is included in the results,
142              it's children are aren't. By default all books matching the tags
143              are returned.
144         """
145         tags = read_tags(tags, allowed=self.categories)
146         if tags:
147             if top_level:
148                 books = Book.tagged_top_level(tags)
149                 return books if books else rc.NOT_FOUND
150             else:
151                 books = Book.tagged.with_all(tags)
152         else:
153             books = Book.objects.all()
154
155         if books.exists():
156             return books
157         else:
158             return rc.NOT_FOUND
159
160     def create(self, request, tags, top_level=False):
161         return 'aaa'
162
163     @classmethod
164     def media(self, book):
165         """ Returns all media for a book. """
166
167         return book.media.all()
168
169
170 class BooksHandler(BaseHandler):
171     model = Book
172     fields = ('slug', 'title')
173     anonymous = AnonymousBooksHandler
174
175     def create(self, request, tags, top_level=False):
176         if not request.user.has_perm('catalogue.add_book'):
177             return rc.FORBIDDEN
178
179         data = json.loads(request.POST.get('data'))
180         form = BookImportForm(data)
181         if form.is_valid():
182             form.save()
183             return rc.CREATED
184         else:
185             return rc.NOT_FOUND
186
187 # add categorized tags fields for Book
188 def _tags_getter(category):
189     @classmethod
190     def get_tags(cls, book):
191         return book.tags.filter(category=category)
192     return get_tags
193 for plural, singular in category_singular.items():
194     setattr(BooksHandler, plural, _tags_getter(singular))
195
196 # add fields for files in Book
197 def _file_getter(format):
198     field = "%s_file" % format
199     @classmethod
200     def get_file(cls, book):
201         f = getattr(book, field)
202         if f:
203             return MEDIA_BASE + f.url
204         else:
205             return ''
206     return get_file
207 for format in Book.formats:
208     setattr(BooksHandler, format, _file_getter(format))
209
210
211 class TagDetailHandler(BaseHandler):
212     """ Responsible for details of a single Tag object. """
213
214     fields = ['name', 'sort_key', 'description']
215
216     @piwik_track
217     def read(self, request, category, slug):
218         """ Returns details of a tag, identified by category and slug. """
219
220         try:
221             category_sng = category_singular[category]
222         except KeyError, e:
223             return rc.NOT_FOUND
224
225         try:
226             return Tag.objects.get(category=category_sng, slug=slug)
227         except Tag.DoesNotExist:
228             return rc.NOT_FOUND
229
230
231 class TagsHandler(BaseHandler):
232     """ Main handler for Tag objects.
233
234     Responsible for lists of Tag objects
235     and fields used for representing Tags.
236
237     """
238     allowed_methods = ('GET',)
239     model = Tag
240     fields = ['name', 'href']
241
242     @piwik_track
243     def read(self, request, category):
244         """ Lists all tags in the category (eg. all themes). """
245
246         try:
247             category_sng = category_singular[category]
248         except KeyError, e:
249             return rc.NOT_FOUND
250
251         tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
252         if tags.exists():
253             return tags
254         else:
255             return rc.NOT_FOUND
256
257
258     @classmethod
259     def href(cls, tag):
260         """ Returns URI in the API for the tag. """
261
262         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
263
264
265 class FragmentDetailHandler(BaseHandler):
266     fields = ['book', 'anchor', 'text', 'url', 'themes']
267
268     @piwik_track
269     def read(self, request, slug, anchor):
270         """ Returns details of a fragment, identified by book slug and anchor. """
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", 
310             args=[fragment.book.slug, fragment.anchor])
311
312     @classmethod
313     def url(cls, fragment):
314         """ Returns URL on the site for the fragment. """
315
316         return WL_BASE + fragment.get_absolute_url()
317
318     @classmethod
319     def themes(cls, fragment):
320         """ Returns a list of theme tags for the fragment. """
321
322         return fragment.tags.filter(category='theme')
323
324
325
326
327 # Changes handlers
328
329 class CatalogueHandler(BaseHandler):
330
331     @staticmethod
332     def fields(request, name):
333         fields_str = request.GET.get(name) if request is not None else None
334         return fields_str.split(',') if fields_str is not None else None
335
336     @staticmethod
337     def until(t=None):
338         """ Returns time suitable for use as upper time boundary for check.
339
340             Used to avoid issues with time between setting the change stamp
341             and actually saving the model in database.
342             Cuts the microsecond part to avoid issues with DBs where time has
343             more precision.
344
345             :param datetime t: manually sets the upper boundary
346
347         """
348         # set to five minutes ago, to avoid concurrency issues
349         if t is None:
350             t = datetime.now() - timedelta(seconds=settings.API_WAIT)
351         # set to whole second in case DB supports something smaller
352         return t.replace(microsecond=0)
353
354     @staticmethod
355     def book_dict(book, fields=None):
356         all_fields = ['url', 'title', 'description',
357                       'gazeta_link', 'wiki_link',
358                       ] + Book.formats + BookMedia.formats + [
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.formats:
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 BookMedia.formats:
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.book_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         since = int(since)
536
537         if not since:
538             cache = get_cache('api')
539             key = hash((book_fields, tag_fields, tag_categories,
540                     tuple(sorted(request.GET.items()))
541                   ))
542             value = cache.get(key)
543             if value is not None:
544                 return value
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
561         if not since:
562             cache.set(key, changes)
563
564         return changes
565
566
567 class BookChangesHandler(CatalogueHandler):
568     allowed_methods = ('GET',)
569
570     @piwik_track
571     def read(self, request, since):
572         return self.book_changes(request, since)
573
574
575 class TagChangesHandler(CatalogueHandler):
576     allowed_methods = ('GET',)
577
578     @piwik_track
579     def read(self, request, since):
580         return self.tag_changes(request, since)
581
582
583 class ChangesHandler(CatalogueHandler):
584     allowed_methods = ('GET',)
585
586     @piwik_track
587     def read(self, request, since):
588         return self.changes(request, since)
589
590
591 class PictureHandler(BaseHandler):
592     model = Picture
593     fields = ('slug', 'title')
594     allowed_methods = ('POST',)
595
596     def create(self, request):
597         if not request.user.has_perm('picture.add_picture'):
598             return rc.FORBIDDEN
599
600         data = json.loads(request.POST.get('data'))
601         form = PictureImportForm(data)
602         if form.is_valid():
603             form.save()
604             return rc.CREATED
605         else:
606             return rc.NOT_FOUND