From: Radek Czajka Date: Mon, 12 Sep 2011 01:54:01 +0000 (+0200) Subject: books, tags, fragments api X-Git-Url: https://git.mdrn.pl/wolnelektury.git/commitdiff_plain/5a15f6f4162ddda647b1ca9eec4e36f85f457056?ds=inline;hp=--cc books, tags, fragments api --- 5a15f6f4162ddda647b1ca9eec4e36f85f457056 diff --git a/apps/api/handlers.py b/apps/api/handlers.py index 706e0cdac..32a3ce300 100644 --- a/apps/api/handlers.py +++ b/apps/api/handlers.py @@ -3,13 +3,267 @@ # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. from datetime import datetime, timedelta -from piston.handler import BaseHandler + 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 piston.utils import rc from api.helpers import timestamp from api.models import Deleted -from catalogue.models import Book, Tag +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: django.http.Http404 + """ + 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 Http404 + + if not category in allowed: + raise Http404 + + # !^%@#$^#! + if category == 'book': + slug = 'l-' + slug + + real_tags.append(get_object_or_404(Tag, category=category, slug=slug)) + 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. """ + + return get_object_or_404(Book, slug=slug) + + +class BooksHandler(BaseHandler): + """ Main handler for Book objects. + + Responsible for lists of Book objects + and fields used for representing Books. + + """ + allowed_methods = ('GET',) + model = Book + 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]) + + @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: + return Book.tagged_top_level(tags) + else: + return Book.tagged.with_all(tags) + else: + return Book.objects.all() + + +# 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 + + return get_object_or_404(Tag, category=category_sng, slug=slug) + + +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 + + return Tag.objects.filter(category=category_sng) + + @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. """ + + return get_object_or_404(Fragment, book__slug=slug, anchor=anchor) + + +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) + return Fragment.tagged.with_all(tags).select_related('book') + + @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): @@ -21,12 +275,14 @@ class CatalogueHandler(BaseHandler): @staticmethod def until(t=None): """ Returns time suitable for use as upper time boundary for check. - - Defaults to 'five minutes ago' to avoid issues with time between - change stamp set and model save. + + 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: diff --git a/apps/api/tests.py b/apps/api/tests.py index 41df4c6b0..12c71260b 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -60,7 +60,6 @@ class BookChangesTests(ApiTest): def test_shelf(self): changed_at = self.book.changed_at - print changed_at # putting on a shelf should not update changed_at shelf = Tag.objects.create(category='set', slug='shelf') @@ -90,3 +89,46 @@ class TagChangesTests(ApiTest): changes = json.loads(self.client.get('/api/tag_changes/0.json').content) self.assertEqual(len(changes), 1, 'Empty or deleted tag should disappear.') + + + +class BookTests(TestCase): + + def setUp(self): + self.tag = Tag.objects.create(category='author', slug='joe') + self.book = Book.objects.create(title='A Book', slug='a-book') + self.book_tagged = Book.objects.create(title='Tagged Book', slug='tagged-book') + self.book_tagged.tags = [self.tag] + self.book_tagged.save() + + def test_book_list(self): + books = json.loads(self.client.get('/api/books/').content) + self.assertEqual(len(books), 2, + 'Wrong book list.') + + def test_tagged_books(self): + books = json.loads(self.client.get('/api/authors/joe/books/').content) + + self.assertEqual([b['title'] for b in books], [self.book_tagged.title], + 'Wrong tagged book list.') + + def test_detail(self): + book = json.loads(self.client.get('/api/books/a-book/').content) + self.assertEqual(book['title'], self.book.title, + 'Wrong book details.') + + +class TagTests(TestCase): + + def setUp(self): + self.tag = Tag.objects.create(category='author', slug='joe', name='Joe') + + def test_tag_list(self): + tags = json.loads(self.client.get('/api/authors/').content) + self.assertEqual(len(tags), 1, + 'Wrong tag list.') + + def test_tag_detail(self): + tag = json.loads(self.client.get('/api/authors/joe/').content) + self.assertEqual(tag['name'], self.tag.name, + 'Wrong tag details.') diff --git a/apps/api/urls.py b/apps/api/urls.py index 536454f2f..ec2c2e74a 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -9,12 +9,43 @@ 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_resource = Resource(handler=handlers.BookDetailHandler) + +tag_list_resource = Resource(handler=handlers.TagsHandler) +tag_resource = Resource(handler=handlers.TagDetailHandler) + +fragment_resource = Resource(handler=handlers.FragmentDetailHandler) +fragment_list_resource = Resource(handler=handlers.FragmentsHandler) + + urlpatterns = patterns('', + # 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), )