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