X-Git-Url: https://git.mdrn.pl/wolnelektury.git/blobdiff_plain/5a15f6f4162ddda647b1ca9eec4e36f85f457056..d69fc5f16ed739c02685e7d695abf6de59c2bf5e:/apps/api/handlers.py diff --git a/apps/api/handlers.py b/apps/api/handlers.py index 32a3ce300..5fe931e69 100644 --- a/apps/api/handlers.py +++ b/apps/api/handlers.py @@ -1,23 +1,27 @@ # -*- coding: utf-8 -*- # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later. # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. +# +import json -from datetime import datetime, timedelta - -from django.conf import settings from django.contrib.sites.models import Site from django.core.urlresolvers import reverse -from django.http import Http404 -from django.shortcuts import get_object_or_404 -from piston.handler import BaseHandler +from django.utils.functional import lazy +from piston.handler import AnonymousBaseHandler, BaseHandler from piston.utils import rc +from sorl.thumbnail import default + +from catalogue.forms import BookImportForm +from catalogue.models import Book, Tag, BookMedia, Fragment, Collection +from picture.models import Picture +from picture.forms import PictureImportForm -from api.helpers import timestamp -from api.models import Deleted -from catalogue.models import Book, Tag, BookMedia, Fragment +from stats.utils import piwik_track +from . import emitters # Register our emitters -API_BASE = WL_BASE = MEDIA_BASE = 'http://' + Site.objects.get_current().domain +API_BASE = WL_BASE = MEDIA_BASE = lazy( + lambda: u'http://' + Site.objects.get_current().domain, unicode)() category_singular = { @@ -28,23 +32,27 @@ category_singular = { 'themes': 'theme', 'books': 'book', } -category_plural={} +category_plural = {} for k, v in category_singular.items(): category_plural[v] = k +book_tag_categories = ['author', 'epoch', 'kind', 'genre'] + + def read_tags(tags, allowed): """ Reads a path of filtering tags. :param str tags: a path of category and slug pairs, like: authors/an-author/... :returns: list of Tag objects - :raises: django.http.Http404 + :raises: ValueError when tags can't be found """ if not tags: - return [] + return [], [] tags = tags.strip('/').split('/') real_tags = [] + books = [] while tags: category = tags.pop(0) slug = tags.pop(0) @@ -52,17 +60,22 @@ def read_tags(tags, allowed): try: category = category_singular[category] except KeyError: - raise Http404 + raise ValueError('Unknown category.') if not category in allowed: - raise Http404 + raise ValueError('Category not allowed.') - # !^%@#$^#! if category == 'book': - slug = 'l-' + slug + try: + books.append(Book.objects.get(slug=slug)) + except Book.DoesNotExist: + raise ValueError('Unknown book.') - real_tags.append(get_object_or_404(Tag, category=category, slug=slug)) - return real_tags + try: + real_tags.append(Tag.objects.get(category=category, slug=slug)) + except Tag.DoesNotExist: + raise ValueError('Tag not found') + return real_tags, books # RESTful handlers @@ -72,7 +85,7 @@ class BookMediaHandler(BaseHandler): """ Responsible for representing media in Books. """ model = BookMedia - fields = ['name', 'type', 'url'] + fields = ['name', 'type', 'url', 'artist', 'director'] @classmethod def url(cls, media): @@ -80,50 +93,87 @@ class BookMediaHandler(BaseHandler): return MEDIA_BASE + media.file.url + @classmethod + def artist(cls, media): + return media.extra_info.get('artist_name', '') -class BookDetailHandler(BaseHandler): - """ Main handler for Book objects. + @classmethod + def director(cls, media): + return media.extra_info.get('director_name', '') - Responsible for lists of Book objects - and fields used for representing Books. - """ - allowed_methods = ['GET'] - fields = ['title', 'parent', - 'xml', 'html', 'pdf', 'epub', 'txt', - 'media', 'url'] + category_singular.keys() +class BookDetails(object): + """Custom fields used for representing Books.""" - def read(self, request, slug): - """ Returns details of a book, identified by a slug. """ + @classmethod + def href(cls, book): + """ Returns an URI for a Book in the API. """ + return API_BASE + reverse("api_book", args=[book.slug]) + + @classmethod + def url(cls, book): + """ Returns Book's URL on the site. """ + + return WL_BASE + book.get_absolute_url() - return get_object_or_404(Book, slug=slug) + @classmethod + def children(cls, book): + """ Returns all children for a book. """ + + return book.children.all() + @classmethod + def media(cls, book): + """ Returns all media for a book. """ + return book.media.all() -class BooksHandler(BaseHandler): + @classmethod + def cover(cls, book): + return MEDIA_BASE + book.cover.url if book.cover else '' + + @classmethod + def cover_thumb(cls, book): + return MEDIA_BASE + default.backend.get_thumbnail( + book.cover, "139x193").url if book.cover else '' + + + +class BookDetailHandler(BaseHandler, BookDetails): """ Main handler for Book objects. - Responsible for lists of Book objects - and fields used for representing Books. + Responsible for single Book details. + """ + allowed_methods = ['GET'] + fields = ['title', 'parent', 'children'] + Book.formats + [ + 'media', 'url', 'cover', 'cover_thumb'] + [ + category_plural[c] for c in book_tag_categories] + + @piwik_track + def read(self, request, book): + """ Returns details of a book, identified by a slug and lang. """ + try: + return Book.objects.get(slug=book) + except Book.DoesNotExist: + return rc.NOT_FOUND + +class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails): + """ Main handler for Book objects. + + Responsible for lists of Book objects. """ allowed_methods = ('GET',) model = Book - fields = ['href', 'title'] - - categories = set(['author', 'epoch', 'kind', 'genre']) + fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb'] @classmethod - def href(cls, book): - """ Returns an URI for a Book in the API. """ - return API_BASE + reverse("api_book", args=[book.slug]) + def genres(cls, book): + """ Returns all media for a book. """ + return book.tags.filter(category='genre') - @classmethod - def url(cls, book): - """ Returns Book's URL on the site. """ - - return WL_BASE + book.get_absolute_url() - - def read(self, request, tags, top_level=False): + @piwik_track + def read(self, request, tags=None, top_level=False, + audiobooks=False, daisy=False, pk=None): """ Lists all books with given tags. :param tags: filtering tags; should be a path of categories @@ -132,14 +182,63 @@ class BooksHandler(BaseHandler): it's children are aren't. By default all books matching the tags are returned. """ - tags = read_tags(tags, allowed=self.categories) + if pk is not None: + try: + return Book.objects.get(pk=pk) + except Book.DoesNotExist: + return rc.NOT_FOUND + + try: + tags, _ancestors = read_tags(tags, allowed=book_tag_categories) + except ValueError: + return rc.NOT_FOUND + if tags: if top_level: - return Book.tagged_top_level(tags) + books = Book.tagged_top_level(tags) + return books if books else rc.NOT_FOUND else: - return Book.tagged.with_all(tags) + books = Book.tagged.with_all(tags) + else: + books = Book.objects.all() + + if top_level: + books = books.filter(parent=None) + if audiobooks: + books = books.filter(media__type='mp3').distinct() + if daisy: + books = books.filter(media__type='daisy').distinct() + + if books.exists(): + return books else: - return Book.objects.all() + return rc.NOT_FOUND + + def create(self, request, *args, **kwargs): + return rc.FORBIDDEN + + +class BooksHandler(BookDetailHandler): + allowed_methods = ('GET', 'POST') + model = Book + fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb'] + anonymous = AnonymousBooksHandler + + def create(self, request, *args, **kwargs): + if not request.user.has_perm('catalogue.add_book'): + return rc.FORBIDDEN + + data = json.loads(request.POST.get('data')) + form = BookImportForm(data) + if form.is_valid(): + form.save() + return rc.CREATED + else: + return rc.NOT_FOUND + + +class EBooksHandler(AnonymousBooksHandler): + fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) # add categorized tags fields for Book @@ -148,8 +247,14 @@ def _tags_getter(category): def get_tags(cls, book): return book.tags.filter(category=category) return get_tags +def _tag_getter(category): + @classmethod + def get_tag(cls, book): + return ', '.join(tag.name for tag in book.tags.filter(category=category)) + return get_tag for plural, singular in category_singular.items(): - setattr(BooksHandler, plural, _tags_getter(singular)) + setattr(BookDetails, plural, _tags_getter(singular)) + setattr(BookDetails, singular, _tag_getter(singular)) # add fields for files in Book def _file_getter(format): @@ -162,46 +267,57 @@ def _file_getter(format): else: return '' return get_file -for format in ('xml', 'txt', 'html', 'epub', 'pdf'): - setattr(BooksHandler, format, _file_getter(format)) +for format in Book.formats: + setattr(BookDetails, format, _file_getter(format)) -class TagDetailHandler(BaseHandler): - """ Responsible for details of a single Tag object. """ +class CollectionDetails(object): + """Custom Collection fields.""" - fields = ['name', 'sort_key', 'description'] + @classmethod + def href(cls, collection): + """ Returns URI in the API for the collection. """ - def read(self, request, category, slug): - """ Returns details of a tag, identified by category and slug. """ + return API_BASE + reverse("api_collection", args=[collection.slug]) - try: - category_sng = category_singular[category] - except KeyError, e: - return rc.NOT_FOUND + @classmethod + def url(cls, collection): + """ Returns URL on the site. """ - return get_object_or_404(Tag, category=category_sng, slug=slug) + return WL_BASE + collection.get_absolute_url() + @classmethod + def books(cls, collection): + return Book.objects.filter(collection.get_query()) -class TagsHandler(BaseHandler): - """ Main handler for Tag objects. - Responsible for lists of Tag objects - and fields used for representing Tags. - """ +class CollectionDetailHandler(BaseHandler, CollectionDetails): allowed_methods = ('GET',) - model = Tag - fields = ['name', 'href'] - - def read(self, request, category): - """ Lists all tags in the category (eg. all themes). """ + fields = ['url', 'title', 'description', 'books'] + @piwik_track + def read(self, request, slug): + """ Returns details of a collection, identified by slug. """ try: - category_sng = category_singular[category] - except KeyError, e: + return Collection.objects.get(slug=slug) + except Collection.DoesNotExist: return rc.NOT_FOUND - return Tag.objects.filter(category=category_sng) + +class CollectionsHandler(BaseHandler, CollectionDetails): + allowed_methods = ('GET',) + model = Collection + fields = ['url', 'href', 'title'] + + @piwik_track + def read(self, request): + """ Returns all collections. """ + return Collection.objects.all() + + +class TagDetails(object): + """Custom Tag fields.""" @classmethod def href(cls, tag): @@ -209,44 +325,74 @@ class TagsHandler(BaseHandler): return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug]) + @classmethod + def url(cls, tag): + """ Returns URL on the site. """ -class FragmentDetailHandler(BaseHandler): - fields = ['book', 'anchor', 'text', 'url', 'themes'] + return WL_BASE + tag.get_absolute_url() - def read(self, request, slug, anchor): - """ Returns details of a fragment, identified by book slug and anchor. """ - return get_object_or_404(Fragment, book__slug=slug, anchor=anchor) +class TagDetailHandler(BaseHandler, TagDetails): + """ Responsible for details of a single Tag object. """ + fields = ['name', 'url', 'sort_key', 'description'] -class FragmentsHandler(BaseHandler): - """ Main handler for Fragments. + @piwik_track + def read(self, request, category, slug): + """ Returns details of a tag, identified by category and slug. """ - Responsible for lists of Fragment objects - and fields used for representing Fragments. + try: + category_sng = category_singular[category] + except KeyError, e: + return rc.NOT_FOUND + + try: + return Tag.objects.get(category=category_sng, slug=slug) + except Tag.DoesNotExist: + return rc.NOT_FOUND + + +class TagsHandler(BaseHandler, TagDetails): + """ Main handler for Tag objects. + + Responsible for lists of Tag objects + and fields used for representing Tags. """ - model = Fragment - fields = ['book', 'anchor', 'href'] allowed_methods = ('GET',) + model = Tag + fields = ['name', 'href', 'url'] - categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme']) + @piwik_track + def read(self, request, category=None, pk=None): + """ Lists all tags in the category (eg. all themes). """ + if pk is not None: + try: + return Tag.objects.exclude(category='set').get(pk=pk) + except Book.DoesNotExist: + return rc.NOT_FOUND - def read(self, request, tags): - """ Lists all fragments with given book, tags, themes. + try: + category_sng = category_singular[category] + except KeyError, e: + return rc.NOT_FOUND - :param tags: should be a path of categories and slugs, i.e.: - books/book-slug/authors/an-author/themes/a-theme/ + tags = Tag.objects.filter(category=category_sng).exclude(items=None) + if tags.exists(): + return tags + else: + return rc.NOT_FOUND - """ - tags = read_tags(tags, allowed=self.categories) - return Fragment.tagged.with_all(tags).select_related('book') + +class FragmentDetails(object): + """Custom Fragment fields.""" @classmethod def href(cls, fragment): """ Returns URI in the API for the fragment. """ - return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor]) + return API_BASE + reverse("api_fragment", + args=[fragment.book.slug, fragment.anchor]) @classmethod def url(cls, fragment): @@ -261,251 +407,63 @@ class FragmentsHandler(BaseHandler): return fragment.tags.filter(category='theme') +class FragmentDetailHandler(BaseHandler, FragmentDetails): + fields = ['book', 'anchor', 'text', 'url', 'themes'] + @piwik_track + def read(self, request, book, anchor): + """ Returns details of a fragment, identified by book slug and anchor. """ + try: + return Fragment.objects.get(book__slug=book, anchor=anchor) + except Fragment.DoesNotExist: + return rc.NOT_FOUND -# Changes handlers - -class CatalogueHandler(BaseHandler): - - @staticmethod - def fields(request, name): - fields_str = request.GET.get(name) if request is not None else None - return fields_str.split(',') if fields_str is not None else None - - @staticmethod - def until(t=None): - """ Returns time suitable for use as upper time boundary for check. - - Used to avoid issues with time between setting the change stamp - and actually saving the model in database. - Cuts the microsecond part to avoid issues with DBs where time has - more precision. - - :param datetime t: manually sets the upper boundary - - """ - # set to five minutes ago, to avoid concurrency issues - if t is None: - t = datetime.now() - timedelta(seconds=settings.API_WAIT) - # set to whole second in case DB supports something smaller - return t.replace(microsecond=0) - - @staticmethod - def book_dict(book, fields=None): - all_fields = ('url', 'title', 'description', - 'gazeta_link', 'wiki_link', - 'xml', 'epub', 'txt', 'pdf', 'html', - 'mp3', 'ogg', 'daisy', - 'parent', 'parent_number', - 'tags', - 'license', 'license_description', 'source_name', - 'technical_editors', 'editors', - 'author', 'sort_key', - ) - if fields: - fields = (f for f in fields if f in all_fields) - else: - fields = all_fields - - extra_info = book.get_extra_info_value() - - obj = {} - for field in fields: - - if field in ('xml', 'epub', 'txt', 'pdf', 'html'): - f = getattr(book, field+'_file') - if f: - obj[field] = { - 'url': f.url, - 'size': f.size, - } - - elif field in ('mp3', 'ogg', 'daisy'): - media = [] - for m in book.media.filter(type=field): - media.append({ - 'url': m.file.url, - 'size': m.file.size, - }) - if media: - obj[field] = media - - elif field == 'url': - obj[field] = book.get_absolute_url() - elif field == 'tags': - obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set'))] +class FragmentsHandler(BaseHandler, FragmentDetails): + """ Main handler for Fragments. - elif field == 'author': - obj[field] = ", ".join(t.name for t in book.tags.filter(category='author')) + Responsible for lists of Fragment objects + and fields used for representing Fragments. - elif field == 'parent': - obj[field] = book.parent_id + """ + model = Fragment + fields = ['book', 'url', 'anchor', 'href'] + allowed_methods = ('GET',) - elif field in ('license', 'license_description', 'source_name', - 'technical_editors', 'editors'): - f = extra_info.get(field) - if f: - obj[field] = f + categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme']) - else: - f = getattr(book, field) - if f: - obj[field] = f + @piwik_track + def read(self, request, tags): + """ Lists all fragments with given book, tags, themes. - obj['id'] = book.id - return obj + :param tags: should be a path of categories and slugs, i.e.: + books/book-slug/authors/an-author/themes/a-theme/ - @classmethod - def book_changes(cls, request=None, since=0, until=None, fields=None): - since = datetime.fromtimestamp(int(since)) - until = cls.until(until) - - changes = { - 'time_checked': timestamp(until) - } - - if not fields: - fields = cls.fields(request, 'book_fields') - - added = [] - updated = [] - deleted = [] - - last_change = since - for book in Book.objects.filter(changed_at__gte=since, - changed_at__lt=until): - book_d = cls.book_dict(book, fields) - updated.append(book_d) - if updated: - changes['updated'] = updated - - for book in Deleted.objects.filter(content_type=Book, - deleted_at__gte=since, - deleted_at__lt=until, - created_at__lt=since): - deleted.append(book.id) - if deleted: - changes['deleted'] = deleted - - return changes - - @staticmethod - def tag_dict(tag, fields=None): - all_fields = ('name', 'category', 'sort_key', 'description', - 'gazeta_link', 'wiki_link', - 'url', 'books', - ) - - if fields: - fields = (f for f in fields if f in all_fields) + """ + try: + tags, ancestors = read_tags(tags, allowed=self.categories) + except ValueError: + return rc.NOT_FOUND + fragments = Fragment.tagged.with_all(tags).select_related('book') + if fragments.exists(): + return fragments else: - fields = all_fields - - obj = {} - for field in fields: - - if field == 'url': - obj[field] = tag.get_absolute_url() - - elif field == 'books': - obj[field] = [b.id for b in Book.tagged_top_level([tag])] + return rc.NOT_FOUND - elif field == 'sort_key': - obj[field] = tag.sort_key - else: - f = getattr(tag, field) - if f: - obj[field] = f +class PictureHandler(BaseHandler): + model = Picture + fields = ('slug', 'title') + allowed_methods = ('POST',) - obj['id'] = tag.id - return obj + def create(self, request): + if not request.user.has_perm('picture.add_picture'): + return rc.FORBIDDEN - @classmethod - def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None): - since = datetime.fromtimestamp(int(since)) - until = cls.until(until) - - changes = { - 'time_checked': timestamp(until) - } - - if not fields: - fields = cls.fields(request, 'tag_fields') - if not categories: - categories = cls.fields(request, 'tag_categories') - - all_categories = ('author', 'epoch', 'kind', 'genre') - if categories: - categories = (c for c in categories if c in all_categories) + data = json.loads(request.POST.get('data')) + form = PictureImportForm(data) + if form.is_valid(): + form.save() + return rc.CREATED else: - categories = all_categories - - updated = [] - deleted = [] - - for tag in Tag.objects.filter(category__in=categories, - changed_at__gte=since, - changed_at__lt=until): - # only serve non-empty tags - if tag.get_count(): - tag_d = cls.tag_dict(tag, fields) - updated.append(tag_d) - elif tag.created_at < since: - deleted.append(tag.id) - if updated: - changes['updated'] = updated - - for tag in Deleted.objects.filter(category__in=categories, - content_type=Tag, - deleted_at__gte=since, - deleted_at__lt=until, - created_at__lt=since): - deleted.append(tag.id) - if deleted: - changes['deleted'] = deleted - - return changes - - @classmethod - def changes(cls, request=None, since=0, until=None, book_fields=None, - tag_fields=None, tag_categories=None): - until = cls.until(until) - - changes = { - 'time_checked': timestamp(until) - } - - changes_by_type = { - 'books': cls.book_changes(request, since, until, book_fields), - 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories), - } - - for model in changes_by_type: - for field in changes_by_type[model]: - if field == 'time_checked': - continue - changes.setdefault(field, {})[model] = changes_by_type[model][field] - return changes - - -class BookChangesHandler(CatalogueHandler): - allowed_methods = ('GET',) - - def read(self, request, since): - return self.book_changes(request, since) - - -class TagChangesHandler(CatalogueHandler): - allowed_methods = ('GET',) - - def read(self, request, since): - return self.tag_changes(request, since) - - -class ChangesHandler(CatalogueHandler): - allowed_methods = ('GET',) - - def read(self, request, since): - return self.changes(request, since) + return rc.NOT_FOUND