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 catalogue.utils import related_tag_name
22 from picture.models import Picture
23 from picture.forms import PictureImportForm
25 from stats.utils import piwik_track
27 API_BASE = WL_BASE = MEDIA_BASE = 'http://' + Site.objects.get_current().domain
39 for k, v in category_singular.items():
40 category_plural[v] = k
42 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
46 def read_tags(tags, allowed):
47 """ Reads a path of filtering tags.
49 :param str tags: a path of category and slug pairs, like: authors/an-author/...
50 :returns: list of Tag objects
51 :raises: ValueError when tags can't be found
56 tags = tags.strip('/').split('/')
59 category = tags.pop(0)
63 category = category_singular[category]
65 raise ValueError('Unknown category.')
67 if not category in allowed:
68 raise ValueError('Category not allowed.')
71 if category == 'book':
75 real_tags.append(Tag.objects.get(category=category, slug=slug))
76 except Tag.DoesNotExist:
77 raise ValueError('Tag not found')
84 class BookMediaHandler(BaseHandler):
85 """ Responsible for representing media in Books. """
88 fields = ['name', 'type', 'url', 'artist', 'director']
92 """ Link to media on site. """
94 return MEDIA_BASE + media.file.url
97 def artist(cls, media):
98 return media.extra_info.get('artist_name', '')
101 def director(cls, media):
102 return media.extra_info.get('director_name', '')
106 class BookDetails(object):
107 """Custom fields used for representing Books."""
111 """ Returns an URI for a Book in the API. """
112 return API_BASE + reverse("api_book", args=[book.slug])
116 """ Returns Book's URL on the site. """
118 return WL_BASE + book.get_absolute_url()
121 def children(cls, book):
122 """ Returns all children for a book. """
124 return book.children.all()
127 def media(cls, book):
128 """ Returns all media for a book. """
129 return book.media.all()
132 def cover(cls, book):
133 return MEDIA_BASE + book.cover.url if book.cover else ''
136 def cover_thumb(cls, book):
137 return MEDIA_BASE + default.backend.get_thumbnail(
138 book.cover, "139x193").url if book.cover else ''
142 class BookDetailHandler(BaseHandler, BookDetails):
143 """ Main handler for Book objects.
145 Responsible for single Book details.
147 allowed_methods = ['GET']
148 fields = ['title', 'parent', 'children'] + Book.formats + [
149 'media', 'url', 'cover', 'cover_thumb'] + [
150 category_plural[c] for c in book_tag_categories]
153 def read(self, request, book):
154 """ Returns details of a book, identified by a slug and lang. """
156 return Book.objects.get(slug=book)
157 except Book.DoesNotExist:
161 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
162 """ Main handler for Book objects.
164 Responsible for lists of Book objects.
166 allowed_methods = ('GET',)
168 fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
171 def genres(cls, book):
172 """ Returns all media for a book. """
173 return book.tags.filter(category='genre')
176 def read(self, request, tags, top_level=False,
177 audiobooks=False, daisy=False):
178 """ Lists all books with given tags.
180 :param tags: filtering tags; should be a path of categories
181 and slugs, i.e.: authors/an-author/epoch/an-epoch/
182 :param top_level: if True and a book is included in the results,
183 it's children are aren't. By default all books matching the tags
187 tags = read_tags(tags, allowed=book_tag_categories)
193 books = Book.tagged_top_level(tags)
194 return books if books else rc.NOT_FOUND
196 books = Book.tagged.with_all(tags)
198 books = Book.objects.all()
201 books = books.filter(parent=None)
203 books = books.filter(media__type='mp3').distinct()
205 books = books.filter(media__type='daisy').distinct()
212 def create(self, request, *args, **kwargs):
216 class BooksHandler(BookDetailHandler):
217 allowed_methods = ('GET', 'POST')
219 fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
220 anonymous = AnonymousBooksHandler
222 def create(self, request, *args, **kwargs):
223 if not request.user.has_perm('catalogue.add_book'):
226 data = json.loads(request.POST.get('data'))
227 form = BookImportForm(data)
235 class EBooksHandler(AnonymousBooksHandler):
236 fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats)
239 # add categorized tags fields for Book
240 def _tags_getter(category):
242 def get_tags(cls, book):
243 return book.tags.filter(category=category)
245 def _tag_getter(category):
247 def get_tag(cls, book):
248 return ", ".join(related_tag_name(t) for t in book.related_info()['tags'].get(category, []))
250 for plural, singular in category_singular.items():
251 setattr(BookDetails, plural, _tags_getter(singular))
252 setattr(BookDetails, singular, _tag_getter(singular))
254 # add fields for files in Book
255 def _file_getter(format):
256 field = "%s_file" % format
258 def get_file(cls, book):
259 f = getattr(book, field)
261 return MEDIA_BASE + f.url
265 for format in Book.formats:
266 setattr(BookDetails, format, _file_getter(format))
269 class CollectionDetails(object):
270 """Custom Collection fields."""
273 def href(cls, collection):
274 """ Returns URI in the API for the collection. """
276 return API_BASE + reverse("api_collection", args=[collection.slug])
279 def url(cls, collection):
280 """ Returns URL on the site. """
282 return WL_BASE + collection.get_absolute_url()
285 def books(cls, collection):
286 return Book.objects.filter(collection.get_query())
290 class CollectionDetailHandler(BaseHandler, CollectionDetails):
291 allowed_methods = ('GET',)
292 fields = ['url', 'title', 'description', 'books']
295 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(book_count=0)
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 = 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.now() - 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__in=('book', '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))
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))
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,
629 changed_at__lt=until).iterator():
630 # only serve non-empty tags
632 tag_d = cls.tag_dict(tag, fields)
633 updated.append(tag_d)
634 elif tag.created_at < since:
635 deleted.append(tag.id)
637 changes['updated'] = updated
639 for tag in Deleted.objects.filter(category__in=categories,
641 deleted_at__gte=since,
642 deleted_at__lt=until,
643 created_at__lt=since).iterator():
644 deleted.append(tag.id)
646 changes['deleted'] = deleted
651 def changes(cls, request=None, since=0, until=None, book_fields=None,
652 tag_fields=None, tag_categories=None):
653 until = cls.until(until)
657 cache = get_cache('api')
658 key = hash((book_fields, tag_fields, tag_categories,
659 tuple(sorted(request.GET.items()))
661 value = cache.get(key)
662 if value is not None:
666 'time_checked': timestamp(until)
670 'books': cls.book_changes(request, since, until, book_fields),
671 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
674 for model in changes_by_type:
675 for field in changes_by_type[model]:
676 if field == 'time_checked':
678 changes.setdefault(field, {})[model] = changes_by_type[model][field]
681 cache.set(key, changes)
686 class BookChangesHandler(CatalogueHandler):
687 allowed_methods = ('GET',)
690 def read(self, request, since):
691 return self.book_changes(request, since)
694 class TagChangesHandler(CatalogueHandler):
695 allowed_methods = ('GET',)
698 def read(self, request, since):
699 return self.tag_changes(request, since)
702 class ChangesHandler(CatalogueHandler):
703 allowed_methods = ('GET',)
706 def read(self, request, since):
707 return self.changes(request, since)
710 class PictureHandler(BaseHandler):
712 fields = ('slug', 'title')
713 allowed_methods = ('POST',)
715 def create(self, request):
716 if not request.user.has_perm('picture.add_picture'):
719 data = json.loads(request.POST.get('data'))
720 form = PictureImportForm(data)