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