merge search into pretty branch
[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
20 from stats.utils import piwik_track
21
22 API_BASE = WL_BASE = MEDIA_BASE = 'http://' + Site.objects.get_current().domain
23
24
25 category_singular = {
26     'authors': 'author',
27     'kinds': 'kind',
28     'genres': 'genre',
29     'epochs': 'epoch',
30     'themes': 'theme',
31     'books': 'book',
32 }
33 category_plural={}
34 for k, v in category_singular.items():
35     category_plural[v] = k
36
37
38 def read_tags(tags, allowed):
39     """ Reads a path of filtering tags.
40
41     :param str tags: a path of category and slug pairs, like: authors/an-author/...
42     :returns: list of Tag objects
43     :raises: ValueError when tags can't be found
44     """
45     if not tags:
46         return []
47
48     tags = tags.strip('/').split('/')
49     real_tags = []
50     while tags:
51         category = tags.pop(0)
52         slug = tags.pop(0)
53
54         try:
55             category = category_singular[category]
56         except KeyError:
57             raise ValueError('Unknown category.')
58
59         if not category in allowed:
60             raise ValueError('Category not allowed.')
61
62         # !^%@#$^#!
63         if category == 'book':
64             slug = 'l-' + slug
65
66         try:
67             real_tags.append(Tag.objects.get(category=category, slug=slug))
68         except Tag.DoesNotExist:
69             raise ValueError('Tag not found')
70     return real_tags
71
72
73 # RESTful handlers
74
75
76 class BookMediaHandler(BaseHandler):
77     """ Responsible for representing media in Books. """
78
79     model = BookMedia
80     fields = ['name', 'type', 'url']
81
82     @classmethod
83     def url(cls, media):
84         """ Link to media on site. """
85
86         return MEDIA_BASE + media.file.url
87
88
89 class BookDetailHandler(BaseHandler):
90     """ Main handler for Book objects.
91
92     Responsible for lists of Book objects
93     and fields used for representing Books.
94
95     """
96     allowed_methods = ['GET']
97     fields = ['title', 'parent'] + Book.formats + [
98         'media', 'url'] + category_singular.keys()
99
100     @piwik_track
101     def read(self, request, book):
102         """ Returns details of a book, identified by a slug and lang. """
103         kwargs = Book.split_urlid(book)
104         if not kwargs:
105             return rc.NOT_FOUND
106
107         try:
108             return Book.objects.get(**kwargs)
109         except Book.DoesNotExist:
110             return rc.NOT_FOUND
111
112
113 class AnonymousBooksHandler(AnonymousBaseHandler):
114     """ Main handler for Book objects.
115
116     Responsible for lists of Book objects
117     and fields used for representing Books.
118
119     """
120     allowed_methods = ('GET',)
121     model = Book
122     fields = ['href', 'title']
123
124     categories = set(['author', 'epoch', 'kind', 'genre'])
125
126     @classmethod
127     def href(cls, book):
128         """ Returns an URI for a Book in the API. """
129         return API_BASE + reverse("api_book", args=[book.urlid()])
130
131     @classmethod
132     def url(cls, book):
133         """ Returns Book's URL on the site. """
134
135         return WL_BASE + book.get_absolute_url()
136
137     @piwik_track
138     def read(self, request, tags, top_level=False):
139         """ Lists all books with given tags.
140
141         :param tags: filtering tags; should be a path of categories
142              and slugs, i.e.: authors/an-author/epoch/an-epoch/
143         :param top_level: if True and a book is included in the results,
144              it's children are aren't. By default all books matching the tags
145              are returned.
146         """
147         tags = read_tags(tags, allowed=self.categories)
148         if tags:
149             if top_level:
150                 books = Book.tagged_top_level(tags)
151                 return books if books else rc.NOT_FOUND
152             else:
153                 books = Book.tagged.with_all(tags)
154         else:
155             books = Book.objects.all()
156
157         if books.exists():
158             return books
159         else:
160             return rc.NOT_FOUND
161
162     def create(self, request, tags, top_level=False):
163         return 'aaa'
164
165     @classmethod
166     def media(self, book):
167         """ Returns all media for a book. """
168
169         return book.media.all()
170
171
172 class BooksHandler(BaseHandler):
173     model = Book
174     fields = ('slug', 'title')
175     anonymous = AnonymousBooksHandler
176
177     def create(self, request, tags, top_level=False):
178         if not request.user.has_perm('catalogue.add_book'):
179             return rc.FORBIDDEN
180
181         data = json.loads(request.POST.get('data'))
182         form = BookImportForm(data)
183         if form.is_valid():
184             form.save()
185             return rc.CREATED
186         else:
187             return rc.NOT_FOUND
188
189 # add categorized tags fields for Book
190 def _tags_getter(category):
191     @classmethod
192     def get_tags(cls, book):
193         return book.tags.filter(category=category)
194     return get_tags
195 for plural, singular in category_singular.items():
196     setattr(BooksHandler, plural, _tags_getter(singular))
197
198 # add fields for files in Book
199 def _file_getter(format):
200     field = "%s_file" % format
201     @classmethod
202     def get_file(cls, book):
203         f = getattr(book, field)
204         if f:
205             return MEDIA_BASE + f.url
206         else:
207             return ''
208     return get_file
209 for format in Book.formats:
210     setattr(BooksHandler, format, _file_getter(format))
211
212
213 class TagDetailHandler(BaseHandler):
214     """ Responsible for details of a single Tag object. """
215
216     fields = ['name', 'sort_key', 'description']
217
218     @piwik_track
219     def read(self, request, category, slug):
220         """ Returns details of a tag, identified by category and slug. """
221
222         try:
223             category_sng = category_singular[category]
224         except KeyError, e:
225             return rc.NOT_FOUND
226
227         try:
228             return Tag.objects.get(category=category_sng, slug=slug)
229         except Tag.DoesNotExist:
230             return rc.NOT_FOUND
231
232
233 class TagsHandler(BaseHandler):
234     """ Main handler for Tag objects.
235
236     Responsible for lists of Tag objects
237     and fields used for representing Tags.
238
239     """
240     allowed_methods = ('GET',)
241     model = Tag
242     fields = ['name', 'href']
243
244     @piwik_track
245     def read(self, request, category):
246         """ Lists all tags in the category (eg. all themes). """
247
248         try:
249             category_sng = category_singular[category]
250         except KeyError, e:
251             return rc.NOT_FOUND
252
253         tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
254         if tags.exists():
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.book_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)
584
585
586 class PictureHandler(BaseHandler):
587     model = Picture
588     fields = ('slug', 'title')
589     allowed_methods = ('POST',)
590
591     def create(self, request):
592         if not request.user.has_perm('catalogue.add_book'):
593             return rc.FORBIDDEN
594
595         data = json.loads(request.POST.get('data'))
596         form = BookImportForm(data)
597         if form.is_valid():
598             form.save()
599             return rc.CREATED
600         else:
601             return rc.NOT_FOUND