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
8 from django.conf import settings
9 from django.contrib.sites.models import Site
10 from django.core.cache import get_cache
11 from django.core.urlresolvers import reverse
12 from django.utils.functional import lazy
13 from django.utils.timezone import utc
14 from piston.handler import AnonymousBaseHandler, BaseHandler
15 from piston.utils import rc
16 from sorl.thumbnail import default
18 from api.helpers import timestamp
19 from api.models import Deleted
20 from catalogue.forms import BookImportForm
21 from catalogue.models import Book, Tag, BookMedia, Fragment, Collection
22 from picture.models import Picture
23 from picture.forms import PictureImportForm
24 from wolnelektury.utils import tz
26 from stats.utils import piwik_track
28 API_BASE = WL_BASE = MEDIA_BASE = lazy(
29 lambda: u'http://' + Site.objects.get_current().domain, unicode)()
41 for k, v in category_singular.items():
42 category_plural[v] = k
44 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
48 def read_tags(tags, allowed):
49 """ Reads a path of filtering tags.
51 :param str tags: a path of category and slug pairs, like: authors/an-author/...
52 :returns: list of Tag objects
53 :raises: ValueError when tags can't be found
58 tags = tags.strip('/').split('/')
62 category = tags.pop(0)
66 category = category_singular[category]
68 raise ValueError('Unknown category.')
70 if not category in allowed:
71 raise ValueError('Category not allowed.')
73 if category == 'book':
74 books.append(Book.objects.get(slug=slug))
77 real_tags.append(Tag.objects.get(category=category, slug=slug))
78 except Tag.DoesNotExist:
79 raise ValueError('Tag not found')
80 return real_tags, books
86 class BookMediaHandler(BaseHandler):
87 """ Responsible for representing media in Books. """
90 fields = ['name', 'type', 'url', 'artist', 'director']
94 """ Link to media on site. """
96 return MEDIA_BASE + media.file.url
99 def artist(cls, media):
100 return media.extra_info.get('artist_name', '')
103 def director(cls, media):
104 return media.extra_info.get('director_name', '')
107 class BookDetails(object):
108 """Custom fields used for representing Books."""
112 """ Returns an URI for a Book in the API. """
113 return API_BASE + reverse("api_book", args=[book.slug])
117 """ Returns Book's URL on the site. """
119 return WL_BASE + book.get_absolute_url()
122 def children(cls, book):
123 """ Returns all children for a book. """
125 return book.children.all()
128 def media(cls, book):
129 """ Returns all media for a book. """
130 return book.media.all()
133 def cover(cls, book):
134 return MEDIA_BASE + book.cover.url if book.cover else ''
137 def cover_thumb(cls, book):
138 return MEDIA_BASE + default.backend.get_thumbnail(
139 book.cover, "139x193").url if book.cover else ''
143 class BookDetailHandler(BaseHandler, BookDetails):
144 """ Main handler for Book objects.
146 Responsible for single Book details.
148 allowed_methods = ['GET']
149 fields = ['title', 'parent', 'children'] + Book.formats + [
150 'media', 'url', 'cover', 'cover_thumb'] + [
151 category_plural[c] for c in book_tag_categories]
154 def read(self, request, book):
155 """ Returns details of a book, identified by a slug and lang. """
157 return Book.objects.get(slug=book)
158 except Book.DoesNotExist:
162 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
163 """ Main handler for Book objects.
165 Responsible for lists of Book objects.
167 allowed_methods = ('GET',)
169 fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
172 def genres(cls, book):
173 """ Returns all media for a book. """
174 return book.tags.filter(category='genre')
177 def read(self, request, tags, top_level=False,
178 audiobooks=False, daisy=False):
179 """ Lists all books with given tags.
181 :param tags: filtering tags; should be a path of categories
182 and slugs, i.e.: authors/an-author/epoch/an-epoch/
183 :param top_level: if True and a book is included in the results,
184 it's children are aren't. By default all books matching the tags
188 tags, ancestors_ = read_tags(tags, allowed=book_tag_categories)
194 books = Book.tagged_top_level(tags)
195 return books if books else rc.NOT_FOUND
197 books = Book.tagged.with_all(tags)
199 books = Book.objects.all()
202 books = books.filter(parent=None)
204 books = books.filter(media__type='mp3').distinct()
206 books = books.filter(media__type='daisy').distinct()
213 def create(self, request, *args, **kwargs):
217 class BooksHandler(BookDetailHandler):
218 allowed_methods = ('GET', 'POST')
220 fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
221 anonymous = AnonymousBooksHandler
223 def create(self, request, *args, **kwargs):
224 if not request.user.has_perm('catalogue.add_book'):
227 data = json.loads(request.POST.get('data'))
228 form = BookImportForm(data)
236 class EBooksHandler(AnonymousBooksHandler):
237 fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats)
240 # add categorized tags fields for Book
241 def _tags_getter(category):
243 def get_tags(cls, book):
244 return book.tags.filter(category=category)
246 def _tag_getter(category):
248 def get_tag(cls, book):
249 return ', '.join(tag.name for tag in book.tags.filter(category=category))
251 for plural, singular in category_singular.items():
252 setattr(BookDetails, plural, _tags_getter(singular))
253 setattr(BookDetails, singular, _tag_getter(singular))
255 # add fields for files in Book
256 def _file_getter(format):
257 field = "%s_file" % format
259 def get_file(cls, book):
260 f = getattr(book, field)
262 return MEDIA_BASE + f.url
266 for format in Book.formats:
267 setattr(BookDetails, format, _file_getter(format))
270 class CollectionDetails(object):
271 """Custom Collection fields."""
274 def href(cls, collection):
275 """ Returns URI in the API for the collection. """
277 return API_BASE + reverse("api_collection", args=[collection.slug])
280 def url(cls, collection):
281 """ Returns URL on the site. """
283 return WL_BASE + collection.get_absolute_url()
286 def books(cls, collection):
287 return Book.objects.filter(collection.get_query())
291 class CollectionDetailHandler(BaseHandler, CollectionDetails):
292 allowed_methods = ('GET',)
293 fields = ['url', 'title', 'description', 'books']
296 def read(self, request, slug):
297 """ Returns details of a collection, identified by slug. """
299 return Collection.objects.get(slug=slug)
300 except Collection.DoesNotExist:
304 class CollectionsHandler(BaseHandler, CollectionDetails):
305 allowed_methods = ('GET',)
307 fields = ['url', 'href', 'title']
310 def read(self, request):
311 """ Returns all collections. """
312 return Collection.objects.all()
315 class TagDetails(object):
316 """Custom Tag fields."""
320 """ Returns URI in the API for the tag. """
322 return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
326 """ Returns URL on the site. """
328 return WL_BASE + tag.get_absolute_url()
331 class TagDetailHandler(BaseHandler, TagDetails):
332 """ Responsible for details of a single Tag object. """
334 fields = ['name', 'url', 'sort_key', 'description']
337 def read(self, request, category, slug):
338 """ Returns details of a tag, identified by category and slug. """
341 category_sng = category_singular[category]
346 return Tag.objects.get(category=category_sng, slug=slug)
347 except Tag.DoesNotExist:
351 class TagsHandler(BaseHandler, TagDetails):
352 """ Main handler for Tag objects.
354 Responsible for lists of Tag objects
355 and fields used for representing Tags.
358 allowed_methods = ('GET',)
360 fields = ['name', 'href', 'url']
363 def read(self, request, category):
364 """ Lists all tags in the category (eg. all themes). """
367 category_sng = category_singular[category]
371 tags = Tag.objects.filter(category=category_sng).exclude(items=None)
378 class FragmentDetails(object):
379 """Custom Fragment fields."""
382 def href(cls, fragment):
383 """ Returns URI in the API for the fragment. """
385 return API_BASE + reverse("api_fragment",
386 args=[fragment.book.slug, fragment.anchor])
389 def url(cls, fragment):
390 """ Returns URL on the site for the fragment. """
392 return WL_BASE + fragment.get_absolute_url()
395 def themes(cls, fragment):
396 """ Returns a list of theme tags for the fragment. """
398 return fragment.tags.filter(category='theme')
401 class FragmentDetailHandler(BaseHandler, FragmentDetails):
402 fields = ['book', 'anchor', 'text', 'url', 'themes']
405 def read(self, request, book, anchor):
406 """ Returns details of a fragment, identified by book slug and anchor. """
408 return Fragment.objects.get(book__slug=book, anchor=anchor)
409 except Fragment.DoesNotExist:
413 class FragmentsHandler(BaseHandler, FragmentDetails):
414 """ Main handler for Fragments.
416 Responsible for lists of Fragment objects
417 and fields used for representing Fragments.
421 fields = ['book', 'url', 'anchor', 'href']
422 allowed_methods = ('GET',)
424 categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
427 def read(self, request, tags):
428 """ Lists all fragments with given book, tags, themes.
430 :param tags: should be a path of categories and slugs, i.e.:
431 books/book-slug/authors/an-author/themes/a-theme/
435 tags, ancestors = read_tags(tags, allowed=self.categories)
438 fragments = Fragment.tagged.with_all(tags).select_related('book')
439 if fragments.exists():
448 class CatalogueHandler(BaseHandler):
451 def fields(request, name):
452 fields_str = request.GET.get(name) if request is not None else None
453 return fields_str.split(',') if fields_str is not None else None
457 """ Returns time suitable for use as upper time boundary for check.
459 Used to avoid issues with time between setting the change stamp
460 and actually saving the model in database.
461 Cuts the microsecond part to avoid issues with DBs where time has
464 :param datetime t: manually sets the upper boundary
467 # set to five minutes ago, to avoid concurrency issues
469 t = datetime.utcnow().replace(tzinfo=utc) - timedelta(seconds=settings.API_WAIT)
470 # set to whole second in case DB supports something smaller
471 return t.replace(microsecond=0)
474 def book_dict(book, fields=None):
475 all_fields = ['url', 'title', 'description',
476 'gazeta_link', 'wiki_link',
477 ] + Book.formats + BookMedia.formats.keys() + [
478 'parent', 'parent_number',
480 'license', 'license_description', 'source_name',
481 'technical_editors', 'editors',
482 'author', 'sort_key',
485 fields = (f for f in fields if f in all_fields)
489 extra_info = book.extra_info
494 if field in Book.formats:
495 f = getattr(book, field+'_file')
502 elif field in BookMedia.formats:
504 for m in book.media.filter(type=field).iterator():
513 obj[field] = book.get_absolute_url()
515 elif field == 'tags':
516 obj[field] = [t.id for t in book.tags.exclude(category='set').iterator()]
518 elif field == 'author':
519 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
521 elif field == 'parent':
522 obj[field] = book.parent_id
524 elif field in ('license', 'license_description', 'source_name',
525 'technical_editors', 'editors'):
526 f = extra_info.get(field)
531 f = getattr(book, field)
539 def book_changes(cls, request=None, since=0, until=None, fields=None):
540 since = datetime.fromtimestamp(int(since), tz)
541 until = cls.until(until)
544 'time_checked': timestamp(until)
548 fields = cls.fields(request, 'book_fields')
555 for book in Book.objects.filter(changed_at__gte=since,
556 changed_at__lt=until).iterator():
557 book_d = cls.book_dict(book, fields)
558 updated.append(book_d)
560 changes['updated'] = updated
562 for book in Deleted.objects.filter(content_type=Book,
563 deleted_at__gte=since,
564 deleted_at__lt=until,
565 created_at__lt=since).iterator():
566 deleted.append(book.id)
568 changes['deleted'] = deleted
573 def tag_dict(tag, fields=None):
574 all_fields = ('name', 'category', 'sort_key', 'description',
575 'gazeta_link', 'wiki_link',
580 fields = (f for f in fields if f in all_fields)
588 obj[field] = tag.get_absolute_url()
590 elif field == 'books':
591 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
593 elif field == 'sort_key':
594 obj[field] = tag.sort_key
597 f = getattr(tag, field)
605 def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
606 since = datetime.fromtimestamp(int(since), tz)
607 until = cls.until(until)
610 'time_checked': timestamp(until)
614 fields = cls.fields(request, 'tag_fields')
616 categories = cls.fields(request, 'tag_categories')
618 all_categories = ('author', 'epoch', 'kind', 'genre')
620 categories = (c for c in categories if c in all_categories)
622 categories = all_categories
627 for tag in Tag.objects.filter(category__in=categories,
628 changed_at__gte=since,
630 ).exclude(items=None).iterator():
631 tag_d = cls.tag_dict(tag, fields)
632 updated.append(tag_d)
633 for tag in Tag.objects.filter(category__in=categories,
634 created_at__lt=since,
635 changed_at__gte=since,
636 changed_at__lt=until,
637 items=None).iterator():
638 deleted.append(tag.id)
640 changes['updated'] = updated
642 for tag in Deleted.objects.filter(category__in=categories,
644 deleted_at__gte=since,
645 deleted_at__lt=until,
646 created_at__lt=since).iterator():
647 deleted.append(tag.id)
649 changes['deleted'] = deleted
654 def changes(cls, request=None, since=0, until=None, book_fields=None,
655 tag_fields=None, tag_categories=None):
656 until = cls.until(until)
660 cache = get_cache('api')
661 key = hash((book_fields, tag_fields, tag_categories,
662 tuple(sorted(request.GET.items()))
664 value = cache.get(key)
665 if value is not None:
669 'time_checked': timestamp(until)
673 'books': cls.book_changes(request, since, until, book_fields),
674 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
677 for model in changes_by_type:
678 for field in changes_by_type[model]:
679 if field == 'time_checked':
681 changes.setdefault(field, {})[model] = changes_by_type[model][field]
684 cache.set(key, changes)
689 class BookChangesHandler(CatalogueHandler):
690 allowed_methods = ('GET',)
693 def read(self, request, since):
694 return self.book_changes(request, since)
697 class TagChangesHandler(CatalogueHandler):
698 allowed_methods = ('GET',)
701 def read(self, request, since):
702 return self.tag_changes(request, since)
705 class ChangesHandler(CatalogueHandler):
706 allowed_methods = ('GET',)
709 def read(self, request, since):
710 return self.changes(request, since)
713 class PictureHandler(BaseHandler):
715 fields = ('slug', 'title')
716 allowed_methods = ('POST',)
718 def create(self, request):
719 if not request.user.has_perm('picture.add_picture'):
722 data = json.loads(request.POST.get('data'))
723 form = PictureImportForm(data)