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."""
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 if book.cover else ''
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 class EBooksHandler(AnonymousBooksHandler):
234 fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats)
237 # add categorized tags fields for Book
238 def _tags_getter(category):
240 def get_tags(cls, book):
241 return book.tags.filter(category=category)
243 for plural, singular in category_singular.items():
244 setattr(BookDetails, plural, _tags_getter(singular))
246 # add fields for files in Book
247 def _file_getter(format):
248 field = "%s_file" % format
250 def get_file(cls, book):
251 f = getattr(book, field)
253 return MEDIA_BASE + f.url
257 for format in Book.formats:
258 setattr(BookDetails, format, _file_getter(format))
261 class CollectionDetails(object):
262 """Custom Collection fields."""
265 def href(cls, collection):
266 """ Returns URI in the API for the collection. """
268 return API_BASE + reverse("api_collection", args=[collection.slug])
271 def url(cls, collection):
272 """ Returns URL on the site. """
274 return WL_BASE + collection.get_absolute_url()
277 def books(cls, collection):
278 return Book.objects.filter(collection.get_query())
282 class CollectionDetailHandler(BaseHandler, CollectionDetails):
283 allowed_methods = ('GET',)
284 fields = ['url', 'title', 'description', 'books']
287 def read(self, request, slug):
289 """ Returns details of a collection, identified by slug. """
291 return Collection.objects.get(slug=slug)
292 except Collection.DoesNotExist:
296 class CollectionsHandler(BaseHandler, CollectionDetails):
297 allowed_methods = ('GET',)
299 fields = ['url', 'href', 'title']
302 def read(self, request):
303 """ Returns all collections. """
304 return Collection.objects.all()
307 class TagDetails(object):
308 """Custom Tag fields."""
312 """ Returns URI in the API for the tag. """
314 return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
318 """ Returns URL on the site. """
320 return WL_BASE + tag.get_absolute_url()
323 class TagDetailHandler(BaseHandler, TagDetails):
324 """ Responsible for details of a single Tag object. """
326 fields = ['name', 'url', 'sort_key', 'description']
329 def read(self, request, category, slug):
330 """ Returns details of a tag, identified by category and slug. """
333 category_sng = category_singular[category]
338 return Tag.objects.get(category=category_sng, slug=slug)
339 except Tag.DoesNotExist:
343 class TagsHandler(BaseHandler, TagDetails):
344 """ Main handler for Tag objects.
346 Responsible for lists of Tag objects
347 and fields used for representing Tags.
350 allowed_methods = ('GET',)
352 fields = ['name', 'href', 'url']
355 def read(self, request, category):
356 """ Lists all tags in the category (eg. all themes). """
359 category_sng = category_singular[category]
363 tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
370 class FragmentDetails(object):
371 """Custom Fragment fields."""
374 def href(cls, fragment):
375 """ Returns URI in the API for the fragment. """
377 return API_BASE + reverse("api_fragment",
378 args=[fragment.book.slug, fragment.anchor])
381 def url(cls, fragment):
382 """ Returns URL on the site for the fragment. """
384 return WL_BASE + fragment.get_absolute_url()
387 def themes(cls, fragment):
388 """ Returns a list of theme tags for the fragment. """
390 return fragment.tags.filter(category='theme')
393 class FragmentDetailHandler(BaseHandler, FragmentDetails):
394 fields = ['book', 'anchor', 'text', 'url', 'themes']
397 def read(self, request, book, anchor):
398 """ Returns details of a fragment, identified by book slug and anchor. """
400 return Fragment.objects.get(book__slug=book, anchor=anchor)
401 except Fragment.DoesNotExist:
405 class FragmentsHandler(BaseHandler, FragmentDetails):
406 """ Main handler for Fragments.
408 Responsible for lists of Fragment objects
409 and fields used for representing Fragments.
413 fields = ['book', 'url', 'anchor', 'href']
414 allowed_methods = ('GET',)
416 categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
419 def read(self, request, tags):
420 """ Lists all fragments with given book, tags, themes.
422 :param tags: should be a path of categories and slugs, i.e.:
423 books/book-slug/authors/an-author/themes/a-theme/
427 tags = read_tags(tags, allowed=self.categories)
430 fragments = Fragment.tagged.with_all(tags).select_related('book')
431 if fragments.exists():
440 class CatalogueHandler(BaseHandler):
443 def fields(request, name):
444 fields_str = request.GET.get(name) if request is not None else None
445 return fields_str.split(',') if fields_str is not None else None
449 """ Returns time suitable for use as upper time boundary for check.
451 Used to avoid issues with time between setting the change stamp
452 and actually saving the model in database.
453 Cuts the microsecond part to avoid issues with DBs where time has
456 :param datetime t: manually sets the upper boundary
459 # set to five minutes ago, to avoid concurrency issues
461 t = datetime.now() - timedelta(seconds=settings.API_WAIT)
462 # set to whole second in case DB supports something smaller
463 return t.replace(microsecond=0)
466 def book_dict(book, fields=None):
467 all_fields = ['url', 'title', 'description',
468 'gazeta_link', 'wiki_link',
469 ] + Book.formats + BookMedia.formats.keys() + [
470 'parent', 'parent_number',
472 'license', 'license_description', 'source_name',
473 'technical_editors', 'editors',
474 'author', 'sort_key',
477 fields = (f for f in fields if f in all_fields)
481 extra_info = book.extra_info
486 if field in Book.formats:
487 f = getattr(book, field+'_file')
494 elif field in BookMedia.formats:
496 for m in book.media.filter(type=field).iterator():
505 obj[field] = book.get_absolute_url()
507 elif field == 'tags':
508 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set')).iterator()]
510 elif field == 'author':
511 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
513 elif field == 'parent':
514 obj[field] = book.parent_id
516 elif field in ('license', 'license_description', 'source_name',
517 'technical_editors', 'editors'):
518 f = extra_info.get(field)
523 f = getattr(book, field)
531 def book_changes(cls, request=None, since=0, until=None, fields=None):
532 since = datetime.fromtimestamp(int(since))
533 until = cls.until(until)
536 'time_checked': timestamp(until)
540 fields = cls.fields(request, 'book_fields')
547 for book in Book.objects.filter(changed_at__gte=since,
548 changed_at__lt=until).iterator():
549 book_d = cls.book_dict(book, fields)
550 updated.append(book_d)
552 changes['updated'] = updated
554 for book in Deleted.objects.filter(content_type=Book,
555 deleted_at__gte=since,
556 deleted_at__lt=until,
557 created_at__lt=since).iterator():
558 deleted.append(book.id)
560 changes['deleted'] = deleted
565 def tag_dict(tag, fields=None):
566 all_fields = ('name', 'category', 'sort_key', 'description',
567 'gazeta_link', 'wiki_link',
572 fields = (f for f in fields if f in all_fields)
580 obj[field] = tag.get_absolute_url()
582 elif field == 'books':
583 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
585 elif field == 'sort_key':
586 obj[field] = tag.sort_key
589 f = getattr(tag, field)
597 def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
598 since = datetime.fromtimestamp(int(since))
599 until = cls.until(until)
602 'time_checked': timestamp(until)
606 fields = cls.fields(request, 'tag_fields')
608 categories = cls.fields(request, 'tag_categories')
610 all_categories = ('author', 'epoch', 'kind', 'genre')
612 categories = (c for c in categories if c in all_categories)
614 categories = all_categories
619 for tag in Tag.objects.filter(category__in=categories,
620 changed_at__gte=since,
621 changed_at__lt=until).iterator():
622 # only serve non-empty tags
624 tag_d = cls.tag_dict(tag, fields)
625 updated.append(tag_d)
626 elif tag.created_at < since:
627 deleted.append(tag.id)
629 changes['updated'] = updated
631 for tag in Deleted.objects.filter(category__in=categories,
633 deleted_at__gte=since,
634 deleted_at__lt=until,
635 created_at__lt=since).iterator():
636 deleted.append(tag.id)
638 changes['deleted'] = deleted
643 def changes(cls, request=None, since=0, until=None, book_fields=None,
644 tag_fields=None, tag_categories=None):
645 until = cls.until(until)
649 cache = get_cache('api')
650 key = hash((book_fields, tag_fields, tag_categories,
651 tuple(sorted(request.GET.items()))
653 value = cache.get(key)
654 if value is not None:
658 'time_checked': timestamp(until)
662 'books': cls.book_changes(request, since, until, book_fields),
663 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
666 for model in changes_by_type:
667 for field in changes_by_type[model]:
668 if field == 'time_checked':
670 changes.setdefault(field, {})[model] = changes_by_type[model][field]
673 cache.set(key, changes)
678 class BookChangesHandler(CatalogueHandler):
679 allowed_methods = ('GET',)
682 def read(self, request, since):
683 return self.book_changes(request, since)
686 class TagChangesHandler(CatalogueHandler):
687 allowed_methods = ('GET',)
690 def read(self, request, since):
691 return self.tag_changes(request, since)
694 class ChangesHandler(CatalogueHandler):
695 allowed_methods = ('GET',)
698 def read(self, request, since):
699 return self.changes(request, since)
702 class PictureHandler(BaseHandler):
704 fields = ('slug', 'title')
705 allowed_methods = ('POST',)
707 def create(self, request):
708 if not request.user.has_perm('picture.add_picture'):
711 data = json.loads(request.POST.get('data'))
712 form = PictureImportForm(data)