books, tags, fragments api
authorRadek Czajka <radoslaw.czajka@nowoczesnapolska.org.pl>
Mon, 12 Sep 2011 01:54:01 +0000 (03:54 +0200)
committerRadek Czajka <radoslaw.czajka@nowoczesnapolska.org.pl>
Mon, 12 Sep 2011 01:54:01 +0000 (03:54 +0200)
apps/api/handlers.py
apps/api/tests.py
apps/api/urls.py

index 706e0cd..32a3ce3 100644 (file)
 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
 
 from datetime import datetime, timedelta
 # 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.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 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):
 
 
 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.
     @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.
 
             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:
         """
         # set to five minutes ago, to avoid concurrency issues
         if t is None:
index 41df4c6..12c7126 100644 (file)
@@ -60,7 +60,6 @@ class BookChangesTests(ApiTest):
 
     def test_shelf(self):
         changed_at = self.book.changed_at
 
     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')
 
         # 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.')
         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.')
index 536454f..ec2c2e7 100644 (file)
@@ -9,12 +9,43 @@ book_changes_resource = Resource(handler=handlers.BookChangesHandler)
 tag_changes_resource = Resource(handler=handlers.TagChangesHandler)
 changes_resource = Resource(handler=handlers.ChangesHandler)
 
 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('',
 urlpatterns = patterns('',
+    # changes handlers
     url(r'^book_changes/(?P<since>\d*?)\.(?P<emitter_format>xml|json|yaml)$', book_changes_resource),
     url(r'^tag_changes/(?P<since>\d*?)\.(?P<emitter_format>xml|json|yaml)$', tag_changes_resource),
     url(r'^book_changes/(?P<since>\d*?)\.(?P<emitter_format>xml|json|yaml)$', book_changes_resource),
     url(r'^tag_changes/(?P<since>\d*?)\.(?P<emitter_format>xml|json|yaml)$', tag_changes_resource),
+    # used by mobile app
     url(r'^changes/(?P<since>\d*?)\.(?P<emitter_format>xml|json|yaml)$', changes_resource),
 
     url(r'^changes/(?P<since>\d*?)\.(?P<emitter_format>xml|json|yaml)$', changes_resource),
 
-
+    # info boxes (used by mobile app)
     url(r'book/(?P<id>\d*?)/info\.html$', 'catalogue.views.book_info'),
     url(r'tag/(?P<id>\d*?)/info\.html$', 'catalogue.views.tag_info'),
     url(r'book/(?P<id>\d*?)/info\.html$', 'catalogue.views.book_info'),
     url(r'tag/(?P<id>\d*?)/info\.html$', 'catalogue.views.tag_info'),
+
+
+    # objects details
+    url(r'^books/(?P<slug>[a-z0-9-]+)/$', book_resource, name="api_book"),
+    url(r'^(?P<category>[a-z0-9-]+)/(?P<slug>[a-z0-9-]+)/$',
+        tag_resource, name="api_tag"),
+    url(r'^books/(?P<slug>[a-z0-9-]+)/fragments/(?P<anchor>[a-z0-9-]+)/$',
+        fragment_resource, name="api_fragment"),
+
+    # books by tags
+    url(r'^(?P<tags>(?:(?:[a-z0-9-]+/){2}){0,6})books/$', book_list_resource),
+    url(r'^(?P<tags>(?:(?:[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<tags>(?:(?:[a-z0-9-]+/){2}){1,6})fragments/$', fragment_list_resource),
+
+    # tags by category
+    url(r'^(?P<category>[a-z0-9-]+)/$', tag_list_resource),
 )
 )