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, Collection
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."""
110 """ Returns an URI for a Book in the API. """
111 return API_BASE + reverse("api_book", args=[book.slug])
115 """ Returns Book's URL on the site. """
117 return WL_BASE + book.get_absolute_url()
120 def children(cls, book):
121 """ Returns all children for a book. """
123 return book.children.all()
126 def media(cls, book):
127 """ Returns all media for a book. """
128 return book.media.all()
131 def cover(cls, book):
132 return MEDIA_BASE + book.cover.url if book.cover else ''
135 def cover_thumb(cls, book):
136 return MEDIA_BASE + default.backend.get_thumbnail(
137 book.cover, "139x193").url if book.cover else ''
141 class BookDetailHandler(BaseHandler, BookDetails):
142 """ Main handler for Book objects.
144 Responsible for single Book details.
146 allowed_methods = ['GET']
147 fields = ['title', 'parent', 'children'] + Book.formats + [
148 'media', 'url', 'cover', 'cover_thumb'] + [
149 category_plural[c] for c in book_tag_categories]
152 def read(self, request, book):
153 """ Returns details of a book, identified by a slug and lang. """
155 return Book.objects.get(slug=book)
156 except Book.DoesNotExist:
160 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
161 """ Main handler for Book objects.
163 Responsible for lists of Book objects.
165 allowed_methods = ('GET',)
167 fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
170 def genres(cls, book):
171 """ Returns all media for a book. """
172 return book.tags.filter(category='genre')
175 def read(self, request, tags, top_level=False,
176 audiobooks=False, daisy=False):
177 """ Lists all books with given tags.
179 :param tags: filtering tags; should be a path of categories
180 and slugs, i.e.: authors/an-author/epoch/an-epoch/
181 :param top_level: if True and a book is included in the results,
182 it's children are aren't. By default all books matching the tags
186 tags = read_tags(tags, allowed=book_tag_categories)
192 books = Book.tagged_top_level(tags)
193 return books if books else rc.NOT_FOUND
195 books = Book.tagged.with_all(tags)
197 books = Book.objects.all()
200 books = books.filter(parent=None)
202 books = books.filter(media__type='mp3').distinct()
204 books = books.filter(media__type='daisy').distinct()
211 def create(self, request, *args, **kwargs):
215 class BooksHandler(BookDetailHandler):
216 allowed_methods = ('GET', 'POST')
218 fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
219 anonymous = AnonymousBooksHandler
221 def create(self, request, *args, **kwargs):
222 if not request.user.has_perm('catalogue.add_book'):
225 data = json.loads(request.POST.get('data'))
226 form = BookImportForm(data)
234 class EBooksHandler(AnonymousBooksHandler):
235 fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats)
238 # add categorized tags fields for Book
239 def _tags_getter(category):
241 def get_tags(cls, book):
242 return book.tags.filter(category=category)
244 def _tag_getter(category):
246 def get_tag(cls, book):
247 return ", ".join(t[0] for t in book.related_info()['tags'].get(category, []))
249 for plural, singular in category_singular.items():
250 setattr(BookDetails, plural, _tags_getter(singular))
251 setattr(BookDetails, singular, _tag_getter(singular))
253 # add fields for files in Book
254 def _file_getter(format):
255 field = "%s_file" % format
257 def get_file(cls, book):
258 f = getattr(book, field)
260 return MEDIA_BASE + f.url
264 for format in Book.formats:
265 setattr(BookDetails, format, _file_getter(format))
268 class CollectionDetails(object):
269 """Custom Collection fields."""
272 def href(cls, collection):
273 """ Returns URI in the API for the collection. """
275 return API_BASE + reverse("api_collection", args=[collection.slug])
278 def url(cls, collection):
279 """ Returns URL on the site. """
281 return WL_BASE + collection.get_absolute_url()
284 def books(cls, collection):
285 return Book.objects.filter(collection.get_query())
289 class CollectionDetailHandler(BaseHandler, CollectionDetails):
290 allowed_methods = ('GET',)
291 fields = ['url', 'title', 'description', 'books']
294 def read(self, request, slug):
296 """ Returns details of a collection, identified by slug. """
298 return Collection.objects.get(slug=slug)
299 except Collection.DoesNotExist:
303 class CollectionsHandler(BaseHandler, CollectionDetails):
304 allowed_methods = ('GET',)
306 fields = ['url', 'href', 'title']
309 def read(self, request):
310 """ Returns all collections. """
311 return Collection.objects.all()
314 class TagDetails(object):
315 """Custom Tag fields."""
319 """ Returns URI in the API for the tag. """
321 return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
325 """ Returns URL on the site. """
327 return WL_BASE + tag.get_absolute_url()
330 class TagDetailHandler(BaseHandler, TagDetails):
331 """ Responsible for details of a single Tag object. """
333 fields = ['name', 'url', 'sort_key', 'description']
336 def read(self, request, category, slug):
337 """ Returns details of a tag, identified by category and slug. """
340 category_sng = category_singular[category]
345 return Tag.objects.get(category=category_sng, slug=slug)
346 except Tag.DoesNotExist:
350 class TagsHandler(BaseHandler, TagDetails):
351 """ Main handler for Tag objects.
353 Responsible for lists of Tag objects
354 and fields used for representing Tags.
357 allowed_methods = ('GET',)
359 fields = ['name', 'href', 'url']
362 def read(self, request, category):
363 """ Lists all tags in the category (eg. all themes). """
366 category_sng = category_singular[category]
370 tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
377 class FragmentDetails(object):
378 """Custom Fragment fields."""
381 def href(cls, fragment):
382 """ Returns URI in the API for the fragment. """
384 return API_BASE + reverse("api_fragment",
385 args=[fragment.book.slug, fragment.anchor])
388 def url(cls, fragment):
389 """ Returns URL on the site for the fragment. """
391 return WL_BASE + fragment.get_absolute_url()
394 def themes(cls, fragment):
395 """ Returns a list of theme tags for the fragment. """
397 return fragment.tags.filter(category='theme')
400 class FragmentDetailHandler(BaseHandler, FragmentDetails):
401 fields = ['book', 'anchor', 'text', 'url', 'themes']
404 def read(self, request, book, anchor):
405 """ Returns details of a fragment, identified by book slug and anchor. """
407 return Fragment.objects.get(book__slug=book, anchor=anchor)
408 except Fragment.DoesNotExist:
412 class FragmentsHandler(BaseHandler, FragmentDetails):
413 """ Main handler for Fragments.
415 Responsible for lists of Fragment objects
416 and fields used for representing Fragments.
420 fields = ['book', 'url', 'anchor', 'href']
421 allowed_methods = ('GET',)
423 categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
426 def read(self, request, tags):
427 """ Lists all fragments with given book, tags, themes.
429 :param tags: should be a path of categories and slugs, i.e.:
430 books/book-slug/authors/an-author/themes/a-theme/
434 tags = read_tags(tags, allowed=self.categories)
437 fragments = Fragment.tagged.with_all(tags).select_related('book')
438 if fragments.exists():
447 class CatalogueHandler(BaseHandler):
450 def fields(request, name):
451 fields_str = request.GET.get(name) if request is not None else None
452 return fields_str.split(',') if fields_str is not None else None
456 """ Returns time suitable for use as upper time boundary for check.
458 Used to avoid issues with time between setting the change stamp
459 and actually saving the model in database.
460 Cuts the microsecond part to avoid issues with DBs where time has
463 :param datetime t: manually sets the upper boundary
466 # set to five minutes ago, to avoid concurrency issues
468 t = datetime.now() - timedelta(seconds=settings.API_WAIT)
469 # set to whole second in case DB supports something smaller
470 return t.replace(microsecond=0)
473 def book_dict(book, fields=None):
474 all_fields = ['url', 'title', 'description',
475 'gazeta_link', 'wiki_link',
476 ] + Book.formats + BookMedia.formats.keys() + [
477 'parent', 'parent_number',
479 'license', 'license_description', 'source_name',
480 'technical_editors', 'editors',
481 'author', 'sort_key',
484 fields = (f for f in fields if f in all_fields)
488 extra_info = book.extra_info
493 if field in Book.formats:
494 f = getattr(book, field+'_file')
501 elif field in BookMedia.formats:
503 for m in book.media.filter(type=field).iterator():
512 obj[field] = book.get_absolute_url()
514 elif field == 'tags':
515 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set')).iterator()]
517 elif field == 'author':
518 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
520 elif field == 'parent':
521 obj[field] = book.parent_id
523 elif field in ('license', 'license_description', 'source_name',
524 'technical_editors', 'editors'):
525 f = extra_info.get(field)
530 f = getattr(book, field)
538 def book_changes(cls, request=None, since=0, until=None, fields=None):
539 since = datetime.fromtimestamp(int(since))
540 until = cls.until(until)
543 'time_checked': timestamp(until)
547 fields = cls.fields(request, 'book_fields')
554 for book in Book.objects.filter(changed_at__gte=since,
555 changed_at__lt=until).iterator():
556 book_d = cls.book_dict(book, fields)
557 updated.append(book_d)
559 changes['updated'] = updated
561 for book in Deleted.objects.filter(content_type=Book,
562 deleted_at__gte=since,
563 deleted_at__lt=until,
564 created_at__lt=since).iterator():
565 deleted.append(book.id)
567 changes['deleted'] = deleted
572 def tag_dict(tag, fields=None):
573 all_fields = ('name', 'category', 'sort_key', 'description',
574 'gazeta_link', 'wiki_link',
579 fields = (f for f in fields if f in all_fields)
587 obj[field] = tag.get_absolute_url()
589 elif field == 'books':
590 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
592 elif field == 'sort_key':
593 obj[field] = tag.sort_key
596 f = getattr(tag, field)
604 def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
605 since = datetime.fromtimestamp(int(since))
606 until = cls.until(until)
609 'time_checked': timestamp(until)
613 fields = cls.fields(request, 'tag_fields')
615 categories = cls.fields(request, 'tag_categories')
617 all_categories = ('author', 'epoch', 'kind', 'genre')
619 categories = (c for c in categories if c in all_categories)
621 categories = all_categories
626 for tag in Tag.objects.filter(category__in=categories,
627 changed_at__gte=since,
628 changed_at__lt=until).iterator():
629 # only serve non-empty tags
631 tag_d = cls.tag_dict(tag, fields)
632 updated.append(tag_d)
633 elif tag.created_at < since:
634 deleted.append(tag.id)
636 changes['updated'] = updated
638 for tag in Deleted.objects.filter(category__in=categories,
640 deleted_at__gte=since,
641 deleted_at__lt=until,
642 created_at__lt=since).iterator():
643 deleted.append(tag.id)
645 changes['deleted'] = deleted
650 def changes(cls, request=None, since=0, until=None, book_fields=None,
651 tag_fields=None, tag_categories=None):
652 until = cls.until(until)
656 cache = get_cache('api')
657 key = hash((book_fields, tag_fields, tag_categories,
658 tuple(sorted(request.GET.items()))
660 value = cache.get(key)
661 if value is not None:
665 'time_checked': timestamp(until)
669 'books': cls.book_changes(request, since, until, book_fields),
670 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
673 for model in changes_by_type:
674 for field in changes_by_type[model]:
675 if field == 'time_checked':
677 changes.setdefault(field, {})[model] = changes_by_type[model][field]
680 cache.set(key, changes)
685 class BookChangesHandler(CatalogueHandler):
686 allowed_methods = ('GET',)
689 def read(self, request, since):
690 return self.book_changes(request, since)
693 class TagChangesHandler(CatalogueHandler):
694 allowed_methods = ('GET',)
697 def read(self, request, since):
698 return self.tag_changes(request, since)
701 class ChangesHandler(CatalogueHandler):
702 allowed_methods = ('GET',)
705 def read(self, request, since):
706 return self.changes(request, since)
709 class PictureHandler(BaseHandler):
711 fields = ('slug', 'title')
712 allowed_methods = ('POST',)
714 def create(self, request):
715 if not request.user.has_perm('picture.add_picture'):
718 data = json.loads(request.POST.get('data'))
719 form = PictureImportForm(data)