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