From: Radek Czajka Date: Tue, 11 Oct 2011 10:33:07 +0000 (+0200) Subject: Merge branch 'redakcja-api' X-Git-Url: https://git.mdrn.pl/wolnelektury.git/commitdiff_plain/60b06883b6d5a336ef47c01103ec1ce25aafae69 Merge branch 'redakcja-api' Conflicts: apps/api/handlers.py apps/api/urls.py --- 60b06883b6d5a336ef47c01103ec1ce25aafae69 diff --cc apps/api/handlers.py index 685dfafa2,09370fd36..9bb89005c --- a/apps/api/handlers.py +++ b/apps/api/handlers.py @@@ -1,543 -1,41 +1,565 @@@ # -*- 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. -# -from functools import wraps -from django.shortcuts import get_object_or_404 +from datetime import datetime, timedelta ++import json + +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.urlresolvers import reverse - from piston.handler import BaseHandler + from piston.handler import AnonymousBaseHandler, BaseHandler -from piston.utils import rc, validate -from catalogue.models import Book +from piston.utils import rc + +from api.helpers import timestamp +from api.models import Deleted + from catalogue.forms import BookImportForm +from catalogue.models import Book, Tag, BookMedia, Fragment + + +API_BASE = WL_BASE = MEDIA_BASE = 'http://' + Site.objects.get_current().domain + + +category_singular = { + 'authors': 'author', + 'kinds': 'kind', + 'genres': 'genre', + 'epochs': 'epoch', + 'themes': 'theme', + 'books': 'book', +} +category_plural={} +for k, v in category_singular.items(): + category_plural[v] = k + + +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: ValueError when tags can't be found + """ + if not tags: + return [] + + tags = tags.strip('/').split('/') + real_tags = [] + while tags: + category = tags.pop(0) + slug = tags.pop(0) + + try: + category = category_singular[category] + except KeyError: + raise ValueError('Unknown category.') + + if not category in allowed: + raise ValueError('Category not allowed.') + + # !^%@#$^#! + if category == 'book': + slug = 'l-' + slug + + try: + real_tags.append(Tag.objects.get(category=category, slug=slug)) + except Tag.DoesNotExist: + raise ValueError('Tag not found') + return real_tags + + +# RESTful handlers + + +class BookMediaHandler(BaseHandler): + """ Responsible for representing media in Books. """ + model = BookMedia + fields = ['name', 'type', 'url'] + + @classmethod + def url(cls, media): + """ Link to media on site. """ + + return MEDIA_BASE + media.file.url + + +class BookDetailHandler(BaseHandler): + """ Main handler for Book objects. + + 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() + + def read(self, request, slug): + """ Returns details of a book, identified by a slug. """ + + try: + return Book.objects.get(slug=slug) + except Book.DoesNotExist: + return rc.NOT_FOUND - class BooksHandler(BaseHandler): + class AnonymousBooksHandler(AnonymousBaseHandler): + """ Main handler for Book objects. + + Responsible for lists of Book objects + and fields used for representing Books. + + """ + allowed_methods = ('GET',) model = Book - fields = ('slug', 'title') + fields = ['href', 'title'] + + categories = set(['author', 'epoch', 'kind', 'genre']) + + @classmethod + def href(cls, book): + """ Returns an URI for a Book in the API. """ + return API_BASE + reverse("api_book", args=[book.slug]) - def read(self, request, slug=None): - if slug: - return get_object_or_404(Book, slug=slug) + @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): + """ Lists all books with given tags. + + :param tags: filtering tags; should be a path of categories + and slugs, i.e.: authors/an-author/epoch/an-epoch/ + :param top_level: if True and a book is included in the results, + it's children are aren't. By default all books matching the tags + are returned. + """ + tags = read_tags(tags, allowed=self.categories) + if tags: + if top_level: + books = Book.tagged_top_level(tags) + return books if books else rc.NOT_FOUND + else: + books = Book.tagged.with_all(tags) else: - return Book.objects.all() + books = Book.objects.all() + + if books.exists(): + return books + else: + return rc.NOT_FOUND + ++ def create(self, request, tags, top_level=False): ++ return 'aaa' ++ + @classmethod + def media(self, book): + """ Returns all media for a book. """ + + return book.media.all() + class BooksHandler(BaseHandler): + model = Book + fields = ('slug', 'title') + anonymous = AnonymousBooksHandler + - def create(self, request): ++ def create(self, request, tags, top_level=False): + if not request.user.has_perm('catalogue.add_book'): + return rc.FORBIDDEN + - form = BookImportForm(request.POST, request.FILES) ++ data = json.loads(request.POST.get('data')) ++ form = BookImportForm(data) + if form.is_valid(): + form.save() + return rc.CREATED + else: - return rc.BAD_REQUEST ++ return rc.NOT_FOUND ++ +# add categorized tags fields for Book +def _tags_getter(category): + @classmethod + def get_tags(cls, book): + return book.tags.filter(category=category) + return get_tags +for plural, singular in category_singular.items(): + setattr(BooksHandler, plural, _tags_getter(singular)) + +# add fields for files in Book +def _file_getter(format): + field = "%s_file" % format + @classmethod + def get_file(cls, book): + f = getattr(book, field) + if f: + return MEDIA_BASE + f.url + else: + return '' + return get_file +for format in ('xml', 'txt', 'html', 'epub', 'pdf'): + setattr(BooksHandler, format, _file_getter(format)) + + +class TagDetailHandler(BaseHandler): + """ Responsible for details of a single Tag object. """ + + fields = ['name', 'sort_key', 'description'] + + def read(self, request, category, slug): + """ Returns details of a tag, identified by category and slug. """ + + 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): + """ Main handler for Tag objects. + + Responsible for lists of Tag objects + and fields used for representing Tags. + + """ + allowed_methods = ('GET',) + model = Tag + fields = ['name', 'href'] + + def read(self, request, category): + """ Lists all tags in the category (eg. all themes). """ + + try: + category_sng = category_singular[category] + except KeyError, e: + return rc.NOT_FOUND + + tags = Tag.objects.filter(category=category_sng) + tags = [t for t in tags if t.get_count() > 0] + if tags: + return tags + else: + return rc.NOT_FOUND + + + @classmethod + def href(cls, tag): + """ Returns URI in the API for the tag. """ + + return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug]) + + +class FragmentDetailHandler(BaseHandler): + fields = ['book', 'anchor', 'text', 'url', 'themes'] + + def read(self, request, slug, anchor): + """ Returns details of a fragment, identified by book slug and anchor. """ + + try: + return Fragment.objects.get(book__slug=slug, anchor=anchor) + except Fragment.DoesNotExist: + return rc.NOT_FOUND + + +class FragmentsHandler(BaseHandler): + """ Main handler for Fragments. + + Responsible for lists of Fragment objects + and fields used for representing Fragments. + + """ + model = Fragment + fields = ['book', 'anchor', 'href'] + allowed_methods = ('GET',) + + categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme']) + + def read(self, request, tags): + """ Lists all fragments with given book, tags, themes. + + :param tags: should be a path of categories and slugs, i.e.: + books/book-slug/authors/an-author/themes/a-theme/ + + """ + tags = read_tags(tags, allowed=self.categories) + fragments = Fragment.tagged.with_all(tags).select_related('book') + if fragments.exists(): + return fragments + else: + return rc.NOT_FOUND + + @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]) + + @classmethod + def url(cls, fragment): + """ Returns URL on the site for the fragment. """ + + return WL_BASE + fragment.get_absolute_url() + + @classmethod + def themes(cls, fragment): + """ Returns a list of theme tags for the fragment. """ + + return fragment.tags.filter(category='theme') + + + + +# 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'))] + + elif field == 'author': + obj[field] = ", ".join(t.name for t in book.tags.filter(category='author')) + + elif field == 'parent': + obj[field] = book.parent_id + + elif field in ('license', 'license_description', 'source_name', + 'technical_editors', 'editors'): + f = extra_info.get(field) + if f: + obj[field] = f + + else: + f = getattr(book, field) + if f: + obj[field] = f + + obj['id'] = book.id + return obj + + @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) + 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])] + + elif field == 'sort_key': + obj[field] = tag.sort_key + + else: + f = getattr(tag, field) + if f: + obj[field] = f + + obj['id'] = tag.id + return obj + + @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) + 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) diff --cc apps/api/urls.py index 89bb6001c,c060322fd..2d92eba46 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@@ -1,55 -1,22 +1,65 @@@ # -*- coding: utf-8 -*- from django.conf.urls.defaults import * -from piston.resource import Resource + from piston.authentication import OAuthAuthentication +from piston.resource import Resource -from api.handlers import BooksHandler +from api import handlers + auth = OAuthAuthentication(realm="Wolne Lektury") -book_resource = Resource(handler=BooksHandler, authentication=auth) + +book_changes_resource = Resource(handler=handlers.BookChangesHandler) +tag_changes_resource = Resource(handler=handlers.TagChangesHandler) +changes_resource = Resource(handler=handlers.ChangesHandler) + - book_list_resource = Resource(handler=handlers.BooksHandler) ++book_list_resource = Resource(handler=handlers.BooksHandler, authentication=auth) ++#book_list_resource = Resource(handler=handlers.BooksHandler) +book_resource = Resource(handler=handlers.BookDetailHandler) + +tag_list_resource = Resource(handler=handlers.TagsHandler) +tag_resource = Resource(handler=handlers.TagDetailHandler) -urlpatterns = patterns('', - url(r'^books/(?P[^/]+)\.(?Pxml|json|yaml)$', book_resource), - url(r'^books\.(?Pxml|json|yaml)$', book_resource), +fragment_resource = Resource(handler=handlers.FragmentDetailHandler) +fragment_list_resource = Resource(handler=handlers.FragmentsHandler) -) + patterns( + - urlpatterns = patterns('', ++urlpatterns = patterns( + 'piston.authentication', - url(r'^oauth/request_token/$','oauth_request_token'), - url(r'^oauth/authorize/$','oauth_user_auth'), - url(r'^oauth/access_token/$','oauth_access_token'), ++ url(r'^oauth/request_token/$', 'oauth_request_token'), ++ url(r'^oauth/authorize/$', 'oauth_user_auth'), ++ url(r'^oauth/access_token/$', 'oauth_access_token'), ++ ++) + patterns('', + url(r'^$', 'django.views.generic.simple.direct_to_template', + {'template': 'api/main.html'}), + + + # changes handlers + url(r'^book_changes/(?P\d*?)\.(?Pxml|json|yaml)$', book_changes_resource), + url(r'^tag_changes/(?P\d*?)\.(?Pxml|json|yaml)$', tag_changes_resource), + # used by mobile app + url(r'^changes/(?P\d*?)\.(?Pxml|json|yaml)$', changes_resource), + + # info boxes (used by mobile app) + url(r'book/(?P\d*?)/info\.html$', 'catalogue.views.book_info'), + url(r'tag/(?P\d*?)/info\.html$', 'catalogue.views.tag_info'), + + + # objects details + url(r'^books/(?P[a-z0-9-]+)/$', book_resource, name="api_book"), + url(r'^(?P[a-z0-9-]+)/(?P[a-z0-9-]+)/$', + tag_resource, name="api_tag"), + url(r'^books/(?P[a-z0-9-]+)/fragments/(?P[a-z0-9-]+)/$', + fragment_resource, name="api_fragment"), + + # books by tags + url(r'^(?P(?:(?:[a-z0-9-]+/){2}){0,6})books/$', book_list_resource), + url(r'^(?P(?:(?:[a-z0-9-]+/){2}){0,6})parent_books/$', book_list_resource, {"top_level": True}), + + # fragments by book, tags, themes + # this should be paged + url(r'^(?P(?:(?:[a-z0-9-]+/){2}){1,6})fragments/$', fragment_list_resource), + + # tags by category + url(r'^(?P[a-z0-9-]+)/$', tag_list_resource), )