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.
5 from datetime import datetime, timedelta
7 from urlparse import urljoin
9 from django.conf import settings
10 from django.contrib.sites.models import Site
11 from django.core.cache import get_cache
12 from django.core.urlresolvers import reverse
13 from piston.handler import AnonymousBaseHandler, BaseHandler
14 from piston.utils import rc
15 from sorl.thumbnail import default
17 from api.helpers import timestamp
18 from api.models import Deleted
19 from catalogue.forms import BookImportForm
20 from catalogue.models import Book, Tag, BookMedia, Fragment
21 from picture.models import Picture
22 from picture.forms import PictureImportForm
24 from stats.utils import piwik_track
26 API_BASE = WL_BASE = MEDIA_BASE = 'http://' + Site.objects.get_current().domain
38 for k, v in category_singular.items():
39 category_plural[v] = k
41 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
45 def read_tags(tags, allowed):
46 """ Reads a path of filtering tags.
48 :param str tags: a path of category and slug pairs, like: authors/an-author/...
49 :returns: list of Tag objects
50 :raises: ValueError when tags can't be found
55 tags = tags.strip('/').split('/')
58 category = tags.pop(0)
62 category = category_singular[category]
64 raise ValueError('Unknown category.')
66 if not category in allowed:
67 raise ValueError('Category not allowed.')
70 if category == 'book':
74 real_tags.append(Tag.objects.get(category=category, slug=slug))
75 except Tag.DoesNotExist:
76 raise ValueError('Tag not found')
83 class BookMediaHandler(BaseHandler):
84 """ Responsible for representing media in Books. """
87 fields = ['name', 'type', 'url', 'artist', 'director']
91 """ Link to media on site. """
93 return MEDIA_BASE + media.file.url
96 def artist(cls, media):
97 return media.extra_info.get('artist_name', '')
100 def director(cls, media):
101 return media.extra_info.get('director_name', '')
105 class BookDetails(object):
106 """Custom fields used for representing Books."""
109 def author(cls, book):
110 return ",".join(t[0] for t in book.related_info()['tags'].get('author', []))
114 """ Returns an URI for a Book in the API. """
115 return API_BASE + reverse("api_book", args=[book.slug])
119 """ Returns Book's URL on the site. """
121 return WL_BASE + book.get_absolute_url()
124 def children(cls, book):
125 """ Returns all children for a book. """
127 return book.children.all()
130 def media(cls, book):
131 """ Returns all media for a book. """
132 return book.media.all()
135 def cover(cls, book):
136 return MEDIA_BASE + book.cover.url if book.cover else ''
139 def cover_thumb(cls, book):
140 return MEDIA_BASE + default.backend.get_thumbnail(
141 book.cover, "139x193").url
145 class BookDetailHandler(BaseHandler, BookDetails):
146 """ Main handler for Book objects.
148 Responsible for single Book details.
150 allowed_methods = ['GET']
151 fields = ['title', 'parent', 'children'] + Book.formats + [
152 'media', 'url', 'cover', 'cover_thumb'] + [
153 category_plural[c] for c in book_tag_categories]
156 def read(self, request, book):
157 """ Returns details of a book, identified by a slug and lang. """
159 return Book.objects.get(slug=book)
160 except Book.DoesNotExist:
164 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
165 """ Main handler for Book objects.
167 Responsible for lists of Book objects.
169 allowed_methods = ('GET',)
171 fields = ['author', 'href', 'title', 'url', 'cover']
174 def read(self, request, tags, top_level=False,
175 audiobooks=False, daisy=False):
176 """ Lists all books with given tags.
178 :param tags: filtering tags; should be a path of categories
179 and slugs, i.e.: authors/an-author/epoch/an-epoch/
180 :param top_level: if True and a book is included in the results,
181 it's children are aren't. By default all books matching the tags
185 tags = read_tags(tags, allowed=book_tag_categories)
191 books = Book.tagged_top_level(tags)
192 return books if books else rc.NOT_FOUND
194 books = Book.tagged.with_all(tags)
196 books = Book.objects.all()
199 books = books.filter(parent=None)
201 books = books.filter(media__type='mp3').distinct()
203 books = books.filter(media__type='daisy').distinct()
210 def create(self, request, *args, **kwargs):
214 class BooksHandler(BookDetailHandler):
215 allowed_methods = ('GET', 'POST')
217 fields = ['author', 'href', 'title', 'url']
218 anonymous = AnonymousBooksHandler
220 def create(self, request, *args, **kwargs):
221 if not request.user.has_perm('catalogue.add_book'):
224 data = json.loads(request.POST.get('data'))
225 form = BookImportForm(data)
233 # add categorized tags fields for Book
234 def _tags_getter(category):
236 def get_tags(cls, book):
237 return book.tags.filter(category=category)
239 for plural, singular in category_singular.items():
240 setattr(BookDetails, plural, _tags_getter(singular))
242 # add fields for files in Book
243 def _file_getter(format):
244 field = "%s_file" % format
246 def get_file(cls, book):
247 f = getattr(book, field)
249 return MEDIA_BASE + f.url
253 for format in Book.formats:
254 setattr(BookDetails, format, _file_getter(format))
257 class TagDetails(object):
258 """Custom Tag fields."""
262 """ Returns URI in the API for the tag. """
264 return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
268 """ Returns URL on the site. """
270 return WL_BASE + tag.get_absolute_url()
273 class TagDetailHandler(BaseHandler, TagDetails):
274 """ Responsible for details of a single Tag object. """
276 fields = ['name', 'url', 'sort_key', 'description']
279 def read(self, request, category, slug):
280 """ Returns details of a tag, identified by category and slug. """
283 category_sng = category_singular[category]
288 return Tag.objects.get(category=category_sng, slug=slug)
289 except Tag.DoesNotExist:
293 class TagsHandler(BaseHandler, TagDetails):
294 """ Main handler for Tag objects.
296 Responsible for lists of Tag objects
297 and fields used for representing Tags.
300 allowed_methods = ('GET',)
302 fields = ['name', 'href', 'url']
305 def read(self, request, category):
306 """ Lists all tags in the category (eg. all themes). """
309 category_sng = category_singular[category]
313 tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
320 class FragmentDetails(object):
321 """Custom Fragment fields."""
324 def href(cls, fragment):
325 """ Returns URI in the API for the fragment. """
327 return API_BASE + reverse("api_fragment",
328 args=[fragment.book.slug, fragment.anchor])
331 def url(cls, fragment):
332 """ Returns URL on the site for the fragment. """
334 return WL_BASE + fragment.get_absolute_url()
337 def themes(cls, fragment):
338 """ Returns a list of theme tags for the fragment. """
340 return fragment.tags.filter(category='theme')
343 class FragmentDetailHandler(BaseHandler, FragmentDetails):
344 fields = ['book', 'anchor', 'text', 'url', 'themes']
347 def read(self, request, book, anchor):
348 """ Returns details of a fragment, identified by book slug and anchor. """
350 return Fragment.objects.get(book__slug=book, anchor=anchor)
351 except Fragment.DoesNotExist:
355 class FragmentsHandler(BaseHandler, FragmentDetails):
356 """ Main handler for Fragments.
358 Responsible for lists of Fragment objects
359 and fields used for representing Fragments.
363 fields = ['book', 'url', 'anchor', 'href']
364 allowed_methods = ('GET',)
366 categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
369 def read(self, request, tags):
370 """ Lists all fragments with given book, tags, themes.
372 :param tags: should be a path of categories and slugs, i.e.:
373 books/book-slug/authors/an-author/themes/a-theme/
377 tags = read_tags(tags, allowed=self.categories)
380 fragments = Fragment.tagged.with_all(tags).select_related('book')
381 if fragments.exists():
390 class CatalogueHandler(BaseHandler):
393 def fields(request, name):
394 fields_str = request.GET.get(name) if request is not None else None
395 return fields_str.split(',') if fields_str is not None else None
399 """ Returns time suitable for use as upper time boundary for check.
401 Used to avoid issues with time between setting the change stamp
402 and actually saving the model in database.
403 Cuts the microsecond part to avoid issues with DBs where time has
406 :param datetime t: manually sets the upper boundary
409 # set to five minutes ago, to avoid concurrency issues
411 t = datetime.now() - timedelta(seconds=settings.API_WAIT)
412 # set to whole second in case DB supports something smaller
413 return t.replace(microsecond=0)
416 def book_dict(book, fields=None):
417 all_fields = ['url', 'title', 'description',
418 'gazeta_link', 'wiki_link',
419 ] + Book.formats + BookMedia.formats.keys() + [
420 'parent', 'parent_number',
422 'license', 'license_description', 'source_name',
423 'technical_editors', 'editors',
424 'author', 'sort_key',
427 fields = (f for f in fields if f in all_fields)
431 extra_info = book.extra_info
436 if field in Book.formats:
437 f = getattr(book, field+'_file')
444 elif field in BookMedia.formats:
446 for m in book.media.filter(type=field).iterator():
455 obj[field] = book.get_absolute_url()
457 elif field == 'tags':
458 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set')).iterator()]
460 elif field == 'author':
461 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
463 elif field == 'parent':
464 obj[field] = book.parent_id
466 elif field in ('license', 'license_description', 'source_name',
467 'technical_editors', 'editors'):
468 f = extra_info.get(field)
473 f = getattr(book, field)
481 def book_changes(cls, request=None, since=0, until=None, fields=None):
482 since = datetime.fromtimestamp(int(since))
483 until = cls.until(until)
486 'time_checked': timestamp(until)
490 fields = cls.fields(request, 'book_fields')
497 for book in Book.objects.filter(changed_at__gte=since,
498 changed_at__lt=until).iterator():
499 book_d = cls.book_dict(book, fields)
500 updated.append(book_d)
502 changes['updated'] = updated
504 for book in Deleted.objects.filter(content_type=Book,
505 deleted_at__gte=since,
506 deleted_at__lt=until,
507 created_at__lt=since).iterator():
508 deleted.append(book.id)
510 changes['deleted'] = deleted
515 def tag_dict(tag, fields=None):
516 all_fields = ('name', 'category', 'sort_key', 'description',
517 'gazeta_link', 'wiki_link',
522 fields = (f for f in fields if f in all_fields)
530 obj[field] = tag.get_absolute_url()
532 elif field == 'books':
533 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
535 elif field == 'sort_key':
536 obj[field] = tag.sort_key
539 f = getattr(tag, field)
547 def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
548 since = datetime.fromtimestamp(int(since))
549 until = cls.until(until)
552 'time_checked': timestamp(until)
556 fields = cls.fields(request, 'tag_fields')
558 categories = cls.fields(request, 'tag_categories')
560 all_categories = ('author', 'epoch', 'kind', 'genre')
562 categories = (c for c in categories if c in all_categories)
564 categories = all_categories
569 for tag in Tag.objects.filter(category__in=categories,
570 changed_at__gte=since,
571 changed_at__lt=until).iterator():
572 # only serve non-empty tags
574 tag_d = cls.tag_dict(tag, fields)
575 updated.append(tag_d)
576 elif tag.created_at < since:
577 deleted.append(tag.id)
579 changes['updated'] = updated
581 for tag in Deleted.objects.filter(category__in=categories,
583 deleted_at__gte=since,
584 deleted_at__lt=until,
585 created_at__lt=since).iterator():
586 deleted.append(tag.id)
588 changes['deleted'] = deleted
593 def changes(cls, request=None, since=0, until=None, book_fields=None,
594 tag_fields=None, tag_categories=None):
595 until = cls.until(until)
599 cache = get_cache('api')
600 key = hash((book_fields, tag_fields, tag_categories,
601 tuple(sorted(request.GET.items()))
603 value = cache.get(key)
604 if value is not None:
608 'time_checked': timestamp(until)
612 'books': cls.book_changes(request, since, until, book_fields),
613 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
616 for model in changes_by_type:
617 for field in changes_by_type[model]:
618 if field == 'time_checked':
620 changes.setdefault(field, {})[model] = changes_by_type[model][field]
623 cache.set(key, changes)
628 class BookChangesHandler(CatalogueHandler):
629 allowed_methods = ('GET',)
632 def read(self, request, since):
633 return self.book_changes(request, since)
636 class TagChangesHandler(CatalogueHandler):
637 allowed_methods = ('GET',)
640 def read(self, request, since):
641 return self.tag_changes(request, since)
644 class ChangesHandler(CatalogueHandler):
645 allowed_methods = ('GET',)
648 def read(self, request, since):
649 return self.changes(request, since)
652 class PictureHandler(BaseHandler):
654 fields = ('slug', 'title')
655 allowed_methods = ('POST',)
657 def create(self, request):
658 if not request.user.has_perm('picture.add_picture'):
661 data = json.loads(request.POST.get('data'))
662 form = PictureImportForm(data)