Merge commit 'afb3cc28'
authorRadek Czajka <rczajka@rczajka.pl>
Mon, 4 Feb 2019 21:17:08 +0000 (22:17 +0100)
committerRadek Czajka <rczajka@rczajka.pl>
Mon, 4 Feb 2019 21:17:08 +0000 (22:17 +0100)
42 files changed:
.gitignore
requirements/requirements.txt
src/api/drf_auth.py [new file with mode: 0644]
src/api/emitters.py [deleted file]
src/api/fields.py [new file with mode: 0644]
src/api/handlers.py
src/api/models.py
src/api/renderers.py [new file with mode: 0644]
src/api/serializers.py [new file with mode: 0644]
src/api/templates/api/main.html
src/api/tests/res/responses/books-child.json
src/api/tests/res/responses/books-grandchild.json
src/api/tests/res/responses/books-parent.json
src/api/tests/res/responses/books.json
src/api/tests/res/responses/books.xml [new file with mode: 0644]
src/api/tests/res/responses/collection.json
src/api/tests/res/responses/collections.json
src/api/tests/res/responses/ebooks.json
src/api/tests/res/responses/fragment.json
src/api/tests/res/responses/fragments.json
src/api/tests/res/responses/tags.json
src/api/tests/tests.py
src/api/urls.py
src/api/views.py [new file with mode: 0644]
src/catalogue/api/__init__.py [new file with mode: 0644]
src/catalogue/api/fields.py [new file with mode: 0644]
src/catalogue/api/helpers.py [new file with mode: 0644]
src/catalogue/api/serializers.py [new file with mode: 0644]
src/catalogue/api/urls.py [new file with mode: 0644]
src/catalogue/api/views.py [new file with mode: 0644]
src/catalogue/fixtures/test-books.yaml
src/catalogue/models/book.py
src/catalogue/models/bookmedia.py
src/catalogue/models/fragment.py
src/catalogue/models/tag.py
src/paypal/permissions.py [new file with mode: 0644]
src/social/api/__init__.py [new file with mode: 0644]
src/social/api/urls.py [new file with mode: 0644]
src/social/api/views.py [new file with mode: 0644]
src/wolnelektury/settings/apps.py
src/wolnelektury/settings/contrib.py
src/wolnelektury/settings/test.py

index a1c1ad1..cf605b2 100644 (file)
@@ -38,6 +38,10 @@ thumbs.db
 # PyCharm
 .idea
 
+# Emacs
+\#*\#
+.\#*
+
 # Tags file
 TAGS
 
index 3037f5c..3b8c95e 100644 (file)
@@ -13,6 +13,8 @@ django-picklefield>=1.0,<1.1
 django-modeltranslation>=0.10,<0.11
 django-allauth>=0.32,<0.33
 django-extensions
+djangorestframework<3.7
+djangorestframework-xml
 
 # contact
 pyyaml
diff --git a/src/api/drf_auth.py b/src/api/drf_auth.py
new file mode 100644 (file)
index 0000000..26018c6
--- /dev/null
@@ -0,0 +1,20 @@
+"""
+Transitional code: bridge between Piston's OAuth implementation
+and DRF views.
+"""
+from piston.authentication import OAuthAuthentication
+from rest_framework.authentication import BaseAuthentication
+
+
+class PistonOAuthAuthentication(BaseAuthentication):
+    def __init__(self):
+        self.piston_auth = OAuthAuthentication()
+
+    def authenticate_header(self, request):
+        return 'OAuth realm="API"'
+
+    def authenticate(self, request):
+        if self.piston_auth.is_valid_request(request):
+            consumer, token, parameters = self.piston_auth.validate_token(request)
+            if consumer and token:
+                return token.user, token
diff --git a/src/api/emitters.py b/src/api/emitters.py
deleted file mode 100644 (file)
index 50a2d41..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-# -*- 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.
-#
-"""
-Wrappers for piston Emitter classes.
-"""
-from piston.emitters import Emitter
-
-
-# hack
-class EpubEmitter(Emitter):
-    def render(self, request):
-        return self.data
-
-Emitter.register('epub', EpubEmitter, 'application/epub+zip')
diff --git a/src/api/fields.py b/src/api/fields.py
new file mode 100644 (file)
index 0000000..1ce83cc
--- /dev/null
@@ -0,0 +1,43 @@
+from rest_framework import serializers
+from django.core.urlresolvers import reverse
+from paypal.rest import user_is_subscribed
+
+
+class AbsoluteURLField(serializers.ReadOnlyField):
+    def __init__(self, view_name=None, view_args=None, source='get_absolute_url', *args, **kwargs):
+        if view_name is not None:
+            source = '*'
+        super(AbsoluteURLField, self).__init__(*args, source=source, **kwargs)
+        self.view_name = view_name
+        self.view_args = {}
+        if view_args:
+            for v in view_args:
+                fields = v.split(':', 1)
+                self.view_args[fields[0]] = fields[1] if len(fields)>1 else fields[0]
+
+    def to_representation(self, value):
+        if self.view_name is not None:
+            kwargs = {
+                arg: getattr(value, field)
+                for (arg, field) in self.view_args.items()
+            }
+            value = reverse(self.view_name, kwargs=kwargs)
+        return self.context['request'].build_absolute_uri(value)
+
+
+class LegacyMixin(object):
+    def to_representation(self, value):
+        value = super(LegacyMixin, self).to_representation(value)
+        non_null_fields = getattr(getattr(self, 'Meta', None), 'legacy_non_null_fields', [])
+        for field in non_null_fields:
+            if field in value and value[field] is None:
+                value[field] = ''
+        return value
+
+
+class UserPremiumField(serializers.ReadOnlyField):
+    def __init__(self, *args, **kwargs):
+        super(UserPremiumField, self).__init__(*args, source='*', **kwargs)
+
+    def to_representation(self, value):
+        return user_is_subscribed(value)
index eb18e05..7178b9f 100644 (file)
@@ -16,10 +16,10 @@ from piston.utils import rc
 from sorl.thumbnail import default
 
 from api.models import BookUserData
+from catalogue.api.helpers import books_after, order_books
 from catalogue.forms import BookImportForm
-from catalogue.models import Book, Tag, BookMedia, Fragment, Collection
+from catalogue.models import Book, Tag, BookMedia
 from catalogue.models.tag import prefetch_relations
-from librarian.cover import WLCover
 from paypal.rest import user_is_subscribed
 from picture.models import Picture
 from picture.forms import PictureImportForm
@@ -28,13 +28,10 @@ from social.utils import likes
 from stats.utils import piwik_track
 from wolnelektury.utils import re_escape
 
-from . import emitters  # Register our emitters
 
 API_BASE = WL_BASE = MEDIA_BASE = lazy(
     lambda: u'https://' + Site.objects.get_current().domain, unicode)()
 
-SORT_KEY_SEP = '$'
-
 category_singular = {
     'authors': 'author',
     'kinds': 'kind',
@@ -63,6 +60,7 @@ def read_tags(tags, request, allowed):
 
     def process(category, slug):
         if category == 'book':
+            # FIXME: Unused?
             try:
                 books.append(Book.objects.get(slug=slug))
             except Book.DoesNotExist:
@@ -103,25 +101,7 @@ def read_tags(tags, request, allowed):
 # RESTful handlers
 
 
-class BookMediaHandler(BaseHandler):
-    """ Responsible for representing media in Books. """
-
-    model = BookMedia
-    fields = ['name', 'type', 'url', 'artist', 'director']
-
-    @classmethod
-    def url(cls, media):
-        """ Link to media on site. """
-
-        return MEDIA_BASE + media.file.url
-
-    @classmethod
-    def artist(cls, media):
-        return media.extra_info.get('artist_name', '')
 
-    @classmethod
-    def director(cls, media):
-        return media.extra_info.get('director_name', '')
 
 
 class BookDetails(object):
@@ -130,7 +110,7 @@ class BookDetails(object):
     @classmethod
     def href(cls, book):
         """ Returns an URI for a Book in the API. """
-        return API_BASE + reverse("api_book", args=[book.slug])
+        return API_BASE + reverse("catalogue_api_book", args=[book.slug])
 
     @classmethod
     def url(cls, book):
@@ -164,33 +144,6 @@ class BookDetails(object):
     def simple_cover(cls, book):
         return MEDIA_BASE + book.simple_cover.url if book.simple_cover else ''
 
-    @classmethod
-    def cover_color(cls, book):
-        return WLCover.epoch_colors.get(book.extra_info.get('epoch'), '#000000')
-
-    @classmethod
-    def full_sort_key(cls, book):
-        return '%s%s%s%s%s' % (book.sort_key_author, SORT_KEY_SEP, book.sort_key, SORT_KEY_SEP, book.id)
-
-    @staticmethod
-    def books_after(books, after, new_api):
-        if not new_api:
-            return books.filter(slug__gt=after)
-        try:
-            author, title, book_id = after.split(SORT_KEY_SEP)
-        except ValueError:
-            return Book.objects.none()
-        return books.filter(Q(sort_key_author__gt=author)
-                            | (Q(sort_key_author=author) & Q(sort_key__gt=title))
-                            | (Q(sort_key_author=author) & Q(sort_key=title) & Q(id__gt=int(book_id))))
-
-    @staticmethod
-    def order_books(books, new_api):
-        if new_api:
-            return books.order_by('sort_key_author', 'sort_key', 'id')
-        else:
-            return books.order_by('slug')
-
 
 class BookDetailHandler(BaseHandler, BookDetails):
     """ Main handler for Book objects.
@@ -221,6 +174,7 @@ class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
     model = Book
     fields = book_list_fields
 
+    # FIXME: Unused?
     @classmethod
     def genres(cls, book):
         """ Returns all media for a book. """
@@ -239,6 +193,7 @@ class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
              are returned.
         """
         if pk is not None:
+            # FIXME: Unused?
             try:
                 return Book.objects.get(pk=pk)
             except Book.DoesNotExist:
@@ -263,7 +218,7 @@ class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
                 books = Book.tagged.with_all(tags)
         else:
             books = books if books is not None else Book.objects.all()
-        books = self.order_books(books, new_api)
+        books = order_books(books, new_api)
 
         if top_level:
             books = books.filter(parent=None)
@@ -277,7 +232,7 @@ class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
             books = books.order_by('-created_at')
 
         if after:
-            books = self.books_after(books, after, new_api)
+            books = books_after(books, after, new_api)
 
         if new_api:
             books = books.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key', 'sort_key_author')
@@ -328,18 +283,6 @@ class BooksHandler(BookDetailHandler):
             return rc.NOT_FOUND
 
 
-class EpubHandler(BookDetailHandler):
-    def read(self, request, slug):
-        if not user_is_subscribed(request.user):
-            return rc.FORBIDDEN
-        try:
-            book = Book.objects.get(slug=slug)
-        except Book.DoesNotExist:
-            return rc.NOT_FOUND
-        response = HttpResponse(book.get_media('epub'))
-        return response
-
-
 class EBooksHandler(AnonymousBooksHandler):
     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
 
@@ -387,7 +330,7 @@ class AnonFilterBooksHandler(AnonymousBooksHandler):
         new_api = request.GET.get('new_api')
         after = request.GET.get('after')
         count = int(request.GET.get('count', 50))
-        books = self.order_books(Book.objects.distinct(), new_api)
+        books = order_books(Book.objects.distinct(), new_api)
         if is_lektura is not None:
             books = books.filter(has_audience=is_lektura)
         if is_audiobook is not None:
@@ -414,15 +357,15 @@ class AnonFilterBooksHandler(AnonymousBooksHandler):
             if after and (key_sep in after):
                 which, key = after.split(key_sep, 1)
                 if which == 'title':
-                    book_lists = [(self.books_after(books_title, key, new_api), 'title')]
+                    book_lists = [(books_after(books_title, key, new_api), 'title')]
                 else:  # which == 'author'
-                    book_lists = [(self.books_after(books_author, key, new_api), 'author'), (books_title, 'title')]
+                    book_lists = [(books_after(books_author, key, new_api), 'author'), (books_title, 'title')]
             else:
                 book_lists = [(books_author, 'author'), (books_title, 'title')]
         else:
             if after and key_sep in after:
                 which, key = after.split(key_sep, 1)
-                books = self.books_after(books, key, new_api)
+                books = books_after(books, key, new_api)
             book_lists = [(books, 'book')]
 
         filtered_books = []
@@ -433,7 +376,7 @@ class AnonFilterBooksHandler(AnonymousBooksHandler):
             remaining_count = count - len(filtered_books)
             new_books = [
                 BookProxy(book, '%s%s%s' % (
-                    label, key_sep, book.slug if not new_api else self.full_sort_key(book)))
+                    label, key_sep, book.slug if not new_api else book.full_sort_key()))
                 for book in book_list[:remaining_count]]
             filtered_books += new_books
             if len(filtered_books) == count:
@@ -459,13 +402,6 @@ class FilterBooksHandler(BooksHandler):
         return qsp
 
 
-class BookPreviewHandler(BookDetailHandler):
-    fields = BookDetailHandler.fields + ['slug']
-
-    def read(self, request):
-        return Book.objects.filter(preview=True)
-
-
 # add categorized tags fields for Book
 def _tags_getter(category):
     @classmethod
@@ -511,198 +447,6 @@ def add_file_getters():
 add_file_getters()
 
 
-class CollectionDetails(object):
-    """Custom Collection fields."""
-
-    @classmethod
-    def href(cls, collection):
-        """ Returns URI in the API for the collection. """
-
-        return API_BASE + reverse("api_collection", args=[collection.slug])
-
-    @classmethod
-    def url(cls, collection):
-        """ Returns URL on the site. """
-
-        return WL_BASE + collection.get_absolute_url()
-
-    @classmethod
-    def books(cls, collection):
-        return Book.objects.filter(collection.get_query())
-
-
-class CollectionDetailHandler(BaseHandler, CollectionDetails):
-    allowed_methods = ('GET',)
-    fields = ['url', 'title', 'description', 'books']
-
-    @piwik_track
-    def read(self, request, slug):
-        """ Returns details of a collection, identified by slug. """
-        try:
-            return Collection.objects.get(slug=slug)
-        except Collection.DoesNotExist:
-            return rc.NOT_FOUND
-
-
-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):
-        """ Returns URI in the API for the tag. """
-
-        return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
-
-    @classmethod
-    def url(cls, tag):
-        """ Returns URL on the site. """
-
-        return WL_BASE + tag.get_absolute_url()
-
-
-class TagDetailHandler(BaseHandler, TagDetails):
-    """ Responsible for details of a single Tag object. """
-
-    fields = ['name', 'url', 'sort_key', 'description']
-
-    @piwik_track
-    def read(self, request, category, slug):
-        """ Returns details of a tag, identified by category and slug. """
-
-        try:
-            category_sng = category_singular[category]
-        except KeyError:
-            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.
-
-    """
-    allowed_methods = ('GET',)
-    model = Tag
-    fields = ['name', 'href', 'url', 'slug']
-
-    @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
-
-        try:
-            category_sng = category_singular[category]
-        except KeyError:
-            return rc.NOT_FOUND
-
-        after = request.GET.get('after')
-        count = request.GET.get('count')
-
-        tags = Tag.objects.filter(category=category_sng).exclude(items=None).order_by('slug')
-
-        book_only = request.GET.get('book_only') == 'true'
-        picture_only = request.GET.get('picture_only') == 'true'
-        if book_only:
-            tags = tags.filter(for_books=True)
-        if picture_only:
-            tags = tags.filter(for_pictures=True)
-
-        if after:
-            tags = tags.filter(slug__gt=after)
-
-        if count:
-            tags = tags[:count]
-
-        return tags
-
-
-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])
-
-    @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')
-
-
-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
-
-
-class FragmentsHandler(BaseHandler, FragmentDetails):
-    """ Main handler for Fragments.
-
-    Responsible for lists of Fragment objects
-    and fields used for representing Fragments.
-
-    """
-    model = Fragment
-    fields = ['book', 'url', 'anchor', 'href']
-    allowed_methods = ('GET',)
-
-    categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
-
-    @piwik_track
-    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/
-
-        """
-        try:
-            tags, ancestors = read_tags(tags, request, allowed=self.categories)
-        except ValueError:
-            return rc.NOT_FOUND
-        fragments = Fragment.tagged.with_all(tags).select_related('book')
-        if fragments.exists():
-            return fragments
-        else:
-            return rc.NOT_FOUND
-
-
 class PictureHandler(BaseHandler):
     model = Picture
     fields = ('slug', 'title')
@@ -721,109 +465,6 @@ class PictureHandler(BaseHandler):
             return rc.NOT_FOUND
 
 
-class UserDataHandler(BaseHandler):
-    model = BookUserData
-    fields = ('state', 'username', 'premium')
-    allowed_methods = ('GET', 'POST')
-
-    def read(self, request, slug=None):
-        if not request.user.is_authenticated():
-            return rc.FORBIDDEN
-        if slug is None:
-            return {'username': request.user.username, 'premium': user_is_subscribed(request.user)}
-        try:
-            book = Book.objects.get(slug=slug)
-        except Book.DoesNotExist:
-            return rc.NOT_FOUND
-        try:
-            data = BookUserData.objects.get(book=book, user=request.user)
-        except BookUserData.DoesNotExist:
-            return {'state': 'not_started'}
-        return data
-
-    def create(self, request, slug, state):
-        try:
-            book = Book.objects.get(slug=slug)
-        except Book.DoesNotExist:
-            return rc.NOT_FOUND
-        if not request.user.is_authenticated():
-            return rc.FORBIDDEN
-        if state not in ('reading', 'complete'):
-            return rc.NOT_FOUND
-        data, created = BookUserData.objects.get_or_create(book=book, user=request.user)
-        data.state = state
-        data.save()
-        return data
-
-
-class UserShelfHandler(BookDetailHandler):
-    fields = book_list_fields + ['liked']
-
-    def parse_bool(self, s):
-        if s in ('true', 'false'):
-            return s == 'true'
-        else:
-            return None
-
-    # hack, because piston is stupid
-    @classmethod
-    def liked(cls, book):
-        return getattr(book, 'liked', None)
-
-    def read(self, request, state):
-        if not request.user.is_authenticated():
-            return rc.FORBIDDEN
-        likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
-        if state not in ('reading', 'complete', 'likes'):
-            return rc.NOT_FOUND
-        new_api = request.GET.get('new_api')
-        after = request.GET.get('after')
-        count = int(request.GET.get('count', 50))
-        if state == 'likes':
-            books = Book.tagged.with_any(request.user.tag_set.all())
-        else:
-            ids = BookUserData.objects.filter(user=request.user, complete=state == 'complete')\
-                .values_list('book_id', flat=True)
-            books = Book.objects.filter(id__in=list(ids)).distinct()
-            books = self.order_books(books, new_api)
-        if after:
-            books = self.books_after(books, after, new_api)
-        if count:
-            books = books[:count]
-        new_books = []
-        for book in books:
-            new_books.append(BookProxy(book).set('liked', book.id in likes))
-        return QuerySetProxy(new_books)
-
-
-class UserLikeHandler(BaseHandler):
-    fields = []
-    allowed_methods = ('GET', 'POST')
-
-    def read(self, request, slug):
-        if not request.user.is_authenticated():
-            return rc.FORBIDDEN
-        try:
-            book = Book.objects.get(slug=slug)
-        except Book.DoesNotExist:
-            return rc.NOT_FOUND
-        return {'likes': likes(request.user, book)}
-
-    def create(self, request, slug):
-        if not request.user.is_authenticated():
-            return rc.FORBIDDEN
-        try:
-            book = Book.objects.get(slug=slug)
-        except Book.DoesNotExist:
-            return rc.NOT_FOUND
-        action = request.GET.get('action', 'like')
-        if action == 'like':
-            book.like(request.user)
-        elif action == 'unlike':
-            book.unlike(request.user)
-        return {}
-
-
 class BlogEntryHandler(BaseHandler):
     model = Entry
     fields = (
index 2481010..d2716b9 100644 (file)
@@ -47,10 +47,13 @@ class BookUserData(models.Model):
     complete = models.BooleanField(default=False)
     last_changed = models.DateTimeField(auto_now=True)
 
-    def get_state(self):
+    @property
+    def state(self):
         return 'complete' if self.complete else 'reading'
 
-    def set_state(self, state):
-        self.complete = state == 'complete'
-
-    state = property(get_state, set_state)
+    @classmethod
+    def update(cls, book, user, state):
+        instance, created = cls.objects.get_or_create(book=book, user=user)
+        instance.complete = state == 'complete'
+        instance.save()
+        return instance
diff --git a/src/api/renderers.py b/src/api/renderers.py
new file mode 100644 (file)
index 0000000..b5b63c6
--- /dev/null
@@ -0,0 +1,11 @@
+from rest_framework_xml.renderers import XMLRenderer
+
+
+class LegacyXMLRenderer(XMLRenderer):
+    """
+    Renderer which serializes to XML.
+    """
+
+    item_tag_name = 'resource'
+    root_tag_name = 'response'
+
diff --git a/src/api/serializers.py b/src/api/serializers.py
new file mode 100644 (file)
index 0000000..a876387
--- /dev/null
@@ -0,0 +1,18 @@
+from django.contrib.auth.models import User
+from rest_framework import serializers
+from .fields import UserPremiumField
+from .models import BookUserData
+
+
+class UserSerializer(serializers.ModelSerializer):
+    premium = UserPremiumField()
+
+    class Meta:
+        model = User
+        fields = ['username', 'premium']
+
+
+class BookUserDataSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = BookUserData
+        fields = ['state']
index 67ff426..b7e7b8f 100755 (executable)
 
     <ul>
         <li><a href='{% url "api_book_list" "" %}'>
-          {% url "api_book_list" "" %}</a> – {% trans "All books" %}</li>
+          {% url "catalogue_api_book_list" "" %}</a> – {% trans "All books" %}</li>
         <li><a href='{% url "api_audiobook_list" "" %}'>
           {% url "api_audiobook_list" "" %}</a> – {% trans "Audiobooks" %}</li>
         <li><a href='{% url "api_daisy_list" "" %}'>
           {% url "api_daisy_list" "" %}</a> – {% trans "DAISY" %}</li>
 
-        <li><a href='{% url "api_tag_list" "authors" %}'>
-          {% url "api_tag_list" "authors" %}</a> – {% trans "List of all authors" %}</li>
-        <li><a href='{% url "api_tag_list" "epochs" %}'>
-          {% url "api_tag_list" "epochs" %}</a> – {% trans "List of all epochs" %}</li>
-        <li><a href='{% url "api_tag_list" "genres" %}'>
-          {% url "api_tag_list" "genres" %}</a> – {% trans "List of all genres" %}</li>
-        <li><a href='{% url "api_tag_list" "kinds" %}'>
-          {% url "api_tag_list" "kinds" %}</a> – {% trans "List of all kinds" %}</li>
+        <li><a href='{% url "catalogue_api_tag_list" "author" %}'>
+          {% url "catalogue_api_tag_list" "author" %}</a> – {% trans "List of all authors" %}</li>
+        <li><a href='{% url "catalogue_api_tag_list" "epoch" %}'>
+          {% url "catalogue_api_tag_list" "epoch" %}</a> – {% trans "List of all epochs" %}</li>
+        <li><a href='{% url "catalogue_api_tag_list" "genre" %}'>
+          {% url "catalogue_api_tag_list" "genre" %}</a> – {% trans "List of all genres" %}</li>
+        <li><a href='{% url "catalogue_api_tag_list" "kind" %}'>
+          {% url "catalogue_api_tag_list" "kind" %}</a> – {% trans "List of all kinds" %}</li>
 
-        <li><a href='{% url "api_tag_list" "themes" %}'>
-          {% url "api_tag_list" "themes" %}</a> – {% trans "List of all themes" %}</li>
-        <li><a href='{% url "api_collections" %}'>
-          {% url "api_collections" %}</a> – {% trans "Collections" %}</li>
+        <li><a href='{% url "catalogue_api_tag_list" "theme" %}'>
+          {% url "catalogue_api_tag_list" "theme" %}</a> – {% trans "List of all themes" %}</li>
+        <li><a href='{% url "catalogue_api_collections" %}'>
+          {% url "catalogue_api_collections" %}</a> – {% trans "Collections" %}</li>
     </ul>
 
     <p>
-      {% url "api_book" "studnia-i-wahadlo" as e1 %}
-      {% url "api_tag" "authors" "edgar-allan-poe" as e2 %}
+      {% url "catalogue_api_book" "studnia-i-wahadlo" as e1 %}
+      {% url "catalogue_api_tag" "author" "edgar-allan-poe" as e2 %}
       {% blocktrans %}
         Each element of those lists contains a link (in a "href") attibute
         which points to individual resource's details, i.e.:
index 4c5c134..27aaee7 100644 (file)
             "kind": "", 
             "full_sort_key": "$grandchild$3", 
             "author": "", 
-            "url": "https://example.com/katalog/lektura/grandchild/", 
+            "url": "http://testserver/katalog/lektura/grandchild/",
             "cover_color": "#000000", 
             "title": "Grandchild", 
             "cover": "", 
             "liked": null, 
             "slug": "grandchild", 
             "epoch": "", 
-            "href": "https://example.com/api/books/grandchild/", 
+            "href": "http://testserver/api/books/grandchild/",
             "genre": "Sonet", 
             "simple_thumb": "", 
             "has_audio": false, 
             "cover_thumb": ""
         }
     ], 
-    "xml": "", 
+    "xml": null,
     "genres": [
         {
-            "url": "https://example.com/katalog/gatunek/wiersz/", 
-            "href": "https://example.com/api/genres/wiersz/", 
+            "url": "http://testserver/katalog/gatunek/wiersz/",
+            "href": "http://testserver/api/genres/wiersz/",
             "name": "Wiersz", 
             "slug": "wiersz"
         }
         "kind": "Liryka", 
         "full_sort_key": "john doe$parent$1", 
         "author": "John Doe", 
-        "url": "https://example.com/katalog/lektura/parent/", 
+        "url": "http://testserver/katalog/lektura/parent/",
         "cover_color": "#a6820a", 
         "title": "Parent", 
-        "cover": "https://example.com/media/cover/parent.jpg", 
+        "cover": "http://testserver/media/cover/parent.jpg",
         "liked": null, 
         "slug": "parent", 
         "epoch": "Barok", 
-        "href": "https://example.com/api/books/parent/", 
+        "href": "http://testserver/api/books/parent/",
         "genre": "Sonet", 
-        "simple_thumb": "https://example.com/media/cover_api_thumb/parent.jpg", 
+        "simple_thumb": "http://testserver/media/cover_api_thumb/parent.jpg",
         "has_audio": true, 
-        "cover_thumb": "https://example.com/media/cover/parent.jpg-139x193"
+        "cover_thumb": "http://testserver/media/cover/parent.jpg-139x193"
     }, 
     "cover_color": "#000000", 
     "simple_cover": "", 
@@ -63,7 +63,7 @@
     "epub": "", 
     "cover_thumb": "", 
     "mobi": "", 
-    "url": "https://example.com/katalog/lektura/child/", 
+    "url": "http://testserver/katalog/lektura/child/",
     "cover": "", 
     "pdf": "", 
     "simple_thumb": ""
index faf8c78..b7dd9da 100644 (file)
@@ -4,35 +4,35 @@
         "html": "Fragment", 
         "title": "Parent, Child"
     }, 
-    "txt": "https://example.com/media/txt/grandchild.txt", 
+    "txt": "http://testserver/katalog/pobierz/grandchild.txt",
     "children": [], 
-    "xml": "", 
+    "xml": "http://testserver/katalog/pobierz/grandchild.xml",
     "genres": [
         {
-            "url": "https://example.com/katalog/gatunek/sonet/", 
-            "href": "https://example.com/api/genres/sonet/", 
+            "url": "http://testserver/katalog/gatunek/sonet/",
+            "href": "http://testserver/api/genres/sonet/",
             "name": "Sonet", 
             "slug": "sonet"
         }
     ], 
     "title": "Grandchild", 
     "media": [], 
-    "html": "https://example.com/media/html/grandchild.html", 
-    "preview": false, 
-    "fb2": "https://example.com/media/fb2/grandchild.fb2", 
+    "html": "http://testserver/katalog/pobierz/grandchild.html",
+    "preview": true, 
+    "fb2": "http://testserver/katalog/pobierz/grandchild.fb2",
     "kinds": [], 
     "parent": {
         "kind": "", 
         "full_sort_key": "$child$2", 
         "author": "", 
-        "url": "https://example.com/katalog/lektura/child/", 
+        "url": "http://testserver/katalog/lektura/child/",
         "cover_color": "#000000", 
         "title": "Child", 
         "cover": "", 
         "liked": null, 
         "slug": "child", 
         "epoch": "", 
-        "href": "https://example.com/api/books/child/", 
+        "href": "http://testserver/api/books/child/",
         "genre": "Wiersz", 
         "simple_thumb": "", 
         "has_audio": false, 
     "simple_cover": "", 
     "authors": [], 
     "audio_length": "", 
-    "epub": "", 
+    "epub": "http://testserver/katalog/pobierz/grandchild.epub",
     "cover_thumb": "", 
-    "mobi": "", 
-    "url": "https://example.com/katalog/lektura/grandchild/", 
+    "mobi": "http://testserver/katalog/pobierz/grandchild.mobi",
+    "url": "http://testserver/katalog/lektura/grandchild/",
     "cover": "", 
-    "pdf": "", 
+    "pdf": "http://testserver/katalog/pobierz/grandchild.pdf",
     "simple_thumb": ""
 }
index ade38cc..6dc75a5 100644 (file)
@@ -1,8 +1,8 @@
 {
     "epochs": [
         {
-            "url": "https://example.com/katalog/epoka/barok/", 
-            "href": "https://example.com/api/epochs/barok/", 
+            "url": "http://testserver/katalog/epoka/barok/",
+            "href": "http://testserver/api/epochs/barok/",
             "name": "Barok", 
             "slug": "barok"
         }
             "kind": "", 
             "full_sort_key": "$child$2", 
             "author": "", 
-            "url": "https://example.com/katalog/lektura/child/", 
+            "url": "http://testserver/katalog/lektura/child/",
             "cover_color": "#000000", 
             "title": "Child", 
             "cover": "", 
             "liked": null, 
             "slug": "child", 
             "epoch": "", 
-            "href": "https://example.com/api/books/child/", 
+            "href": "http://testserver/api/books/child/",
             "genre": "Wiersz", 
             "simple_thumb": "", 
             "has_audio": false, 
             "cover_thumb": ""
         }
     ], 
-    "xml": "https://example.com/media/xml/parent.xml", 
+    "xml": "http://testserver/media/xml/parent.xml",
     "genres": [
         {
-            "url": "https://example.com/katalog/gatunek/sonet/", 
-            "href": "https://example.com/api/genres/sonet/", 
+            "url": "http://testserver/katalog/gatunek/sonet/",
+            "href": "http://testserver/api/genres/sonet/",
             "name": "Sonet", 
             "slug": "sonet"
         }
     "title": "Parent", 
     "media": [
         {
-            "url": "https://example.com/media/daisy/parent.daisy", 
+            "url": "http://testserver/media/daisy/parent.daisy",
             "director": "", 
             "type": "daisy", 
             "name": "Parent DAISY", 
             "artist": ""
         }, 
         {
-            "url": "https://example.com/media/mp3/parent.mp3", 
+            "url": "http://testserver/media/mp3/parent.mp3",
             "director": "Director", 
             "type": "mp3", 
             "name": "Parent Audiobook", 
     "fb2": "", 
     "kinds": [
         {
-            "url": "https://example.com/katalog/rodzaj/liryka/", 
-            "href": "https://example.com/api/kinds/liryka/", 
+            "url": "http://testserver/katalog/rodzaj/liryka/",
+            "href": "http://testserver/api/kinds/liryka/",
             "name": "Liryka", 
             "slug": "liryka"
         }
     ], 
     "parent": null, 
     "cover_color": "#a6820a", 
-    "simple_cover": "https://example.com/media/simple_cover/parent.jpg", 
+    "simple_cover": "http://testserver/media/simple_cover/parent.jpg",
     "authors": [
         {
-            "url": "https://example.com/katalog/autor/john-doe/", 
-            "href": "https://example.com/api/authors/john-doe/", 
+            "url": "http://testserver/katalog/autor/john-doe/",
+            "href": "http://testserver/api/authors/john-doe/",
             "name": "John Doe", 
             "slug": "john-doe"
         }
     ], 
     "audio_length": "1:00", 
-    "epub": "https://example.com/media/epub/parent.epub", 
-    "cover_thumb": "https://example.com/media/cover/parent.jpg-139x193", 
-    "mobi": "https://example.com/media/mobi/parent.mobi", 
-    "url": "https://example.com/katalog/lektura/parent/", 
-    "cover": "https://example.com/media/cover/parent.jpg", 
-    "pdf": "https://example.com/media/pdf/parent.pdf", 
-    "simple_thumb": "https://example.com/media/cover_api_thumb/parent.jpg"
+    "epub": "http://testserver/media/epub/parent.epub",
+    "cover_thumb": "http://testserver/media/cover/parent.jpg-139x193",
+    "mobi": "http://testserver/media/mobi/parent.mobi",
+    "url": "http://testserver/katalog/lektura/parent/",
+    "cover": "http://testserver/media/cover/parent.jpg",
+    "pdf": "http://testserver/media/pdf/parent.pdf",
+    "simple_thumb": "http://testserver/media/cover_api_thumb/parent.jpg"
 }
index 80414b5..cfd5ee3 100644 (file)
@@ -4,12 +4,12 @@
         "full_sort_key": "$child$2", 
         "cover_color": "#000000", 
         "author": "", 
-        "url": "https://example.com/katalog/lektura/child/", 
+        "url": "http://testserver/katalog/lektura/child/",
         "has_audio": false, 
         "title": "Child", 
         "cover": "", 
         "epoch": "", 
-        "href": "https://example.com/api/books/child/", 
+        "href": "http://testserver/api/books/child/",
         "genre": "Wiersz", 
         "simple_thumb": "", 
         "slug": "child", 
         "full_sort_key": "$grandchild$3", 
         "cover_color": "#000000", 
         "author": "", 
-        "url": "https://example.com/katalog/lektura/grandchild/", 
+        "url": "http://testserver/katalog/lektura/grandchild/",
         "has_audio": false, 
         "title": "Grandchild", 
         "cover": "", 
         "epoch": "", 
-        "href": "https://example.com/api/books/grandchild/", 
+        "href": "http://testserver/api/books/grandchild/",
         "genre": "Sonet", 
         "simple_thumb": "", 
         "slug": "grandchild", 
         "full_sort_key": "john doe$parent$1", 
         "cover_color": "#a6820a", 
         "author": "John Doe", 
-        "url": "https://example.com/katalog/lektura/parent/", 
+        "url": "http://testserver/katalog/lektura/parent/",
         "has_audio": true, 
         "title": "Parent", 
         "cover": "cover/parent.jpg", 
         "epoch": "Barok", 
-        "href": "https://example.com/api/books/parent/", 
+        "href": "http://testserver/api/books/parent/",
         "genre": "Sonet", 
-        "simple_thumb": "https://example.com/media/cover_api_thumb/parent.jpg", 
+        "simple_thumb": "http://testserver/media/cover_api_thumb/parent.jpg",
         "slug": "parent", 
         "cover_thumb": "cover_thumb/parent.jpg"
     }
diff --git a/src/api/tests/res/responses/books.xml b/src/api/tests/res/responses/books.xml
new file mode 100644 (file)
index 0000000..967f8e2
--- /dev/null
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="utf-8"?>
+<response><resource><kind></kind><full_sort_key>$child$2</full_sort_key><title>Child</title><url>https://example.com/katalog/lektura/child/</url><has_audio>False</has_audio><author></author><cover></cover><slug>child</slug><epoch></epoch><href>https://example.com/api/books/child/</href><genre>Wiersz</genre><simple_thumb></simple_thumb><cover_color>#000000</cover_color><cover_thumb></cover_thumb></resource><resource><kind></kind><full_sort_key>$grandchild$3</full_sort_key><title>Grandchild</title><url>https://example.com/katalog/lektura/grandchild/</url><has_audio>False</has_audio><author></author><cover></cover><slug>grandchild</slug><epoch></epoch><href>https://example.com/api/books/grandchild/</href><genre>Sonet</genre><simple_thumb></simple_thumb><cover_color>#000000</cover_color><cover_thumb></cover_thumb></resource><resource><kind>Liryka</kind><full_sort_key>john doe$parent$1</full_sort_key><title>Parent</title><url>https://example.com/katalog/lektura/parent/</url><has_audio>True</has_audio><author>John Doe</author><cover>cover/parent.jpg</cover><slug>parent</slug><epoch>Barok</epoch><href>https://example.com/api/books/parent/</href><genre>Sonet</genre><simple_thumb>https://example.com/media/cover_api_thumb/parent.jpg</simple_thumb><cover_color>#a6820a</cover_color><cover_thumb>https://example.com/media/cover/parent.jpg-139x193</cover_thumb></resource></response>
+
index 866abed..2992461 100644 (file)
@@ -1,22 +1,22 @@
 {
-    "url": "https://example.com/katalog/lektury/a-collection/", 
+    "url": "http://testserver/katalog/lektury/a-collection/",
     "books": [
         {
             "kind": "Liryka", 
             "full_sort_key": "john doe$parent$1", 
             "author": "John Doe", 
-            "url": "https://example.com/katalog/lektura/parent/", 
+            "url": "http://testserver/katalog/lektura/parent/",
             "cover_color": "#a6820a", 
             "title": "Parent", 
-            "cover": "https://example.com/media/cover/parent.jpg", 
+            "cover": "http://testserver/media/cover/parent.jpg",
             "liked": null, 
             "slug": "parent", 
             "epoch": "Barok", 
-            "href": "https://example.com/api/books/parent/", 
+            "href": "http://testserver/api/books/parent/",
             "genre": "Sonet", 
-            "simple_thumb": "https://example.com/media/cover_api_thumb/parent.jpg", 
+            "simple_thumb": "http://testserver/media/cover_api_thumb/parent.jpg",
             "has_audio": true, 
-            "cover_thumb": "https://example.com/media/cover/parent.jpg-139x193"
+            "cover_thumb": "http://testserver/media/cover/parent.jpg-139x193"
         }
     ], 
     "description": "Description", 
index a0852cd..d76ff2e 100644 (file)
@@ -1,7 +1,7 @@
 [
     {
-        "url": "https://example.com/katalog/lektury/a-collection/", 
-        "href": "https://example.com/dapi/collections/a-collection/", 
+        "url": "http://testserver/katalog/lektury/a-collection/",
+        "href": "http://testserver/api/collections/a-collection/",
         "title": "A Collection"
     }
 ]
index 171c153..9c4659a 100644 (file)
         "epub": ""
     }, 
     {
-        "fb2": "https://example.com/media/fb2/grandchild.fb2", 
-        "mobi": "", 
+        "fb2": "https://example.com/katalog/pobierz/grandchild.fb2", 
+        "mobi": "https://example.com/katalog/pobierz/grandchild.mobi", 
         "title": "Grandchild",
         "author": "", 
         "cover": "", 
         "href": "https://example.com/api/books/grandchild/", 
-        "pdf": "", 
-        "txt": "https://example.com/media/txt/grandchild.txt", 
+        "pdf": "https://example.com/katalog/pobierz/grandchild.pdf", 
+        "txt": "https://example.com/katalog/pobierz/grandchild.txt", 
         "slug": "grandchild", 
-        "epub": ""
+        "epub": "https://example.com/katalog/pobierz/grandchild.epub"
     }, 
     {
         "fb2": "", 
index 40db3f4..e579e50 100644 (file)
@@ -1,18 +1,18 @@
 {
-    "url": "https://example.com/katalog/lektura/child.html#man-anchor", 
+    "url": "http://testserver/katalog/lektura/child.html#man-anchor",
     "text": "A fragment", 
     "book": {
         "kind": "", 
         "full_sort_key": "$child$2", 
         "author": "", 
-        "url": "https://example.com/katalog/lektura/child/", 
+        "url": "http://testserver/katalog/lektura/child/",
         "cover_color": "#000000", 
         "title": "Child", 
         "cover": "", 
         "liked": null, 
         "slug": "child", 
         "epoch": "", 
-        "href": "https://example.com/api/books/child/", 
+        "href": "http://testserver/api/books/child/",
         "genre": "Wiersz", 
         "simple_thumb": "", 
         "has_audio": false, 
@@ -21,8 +21,8 @@
     "anchor": "an-anchor", 
     "themes": [
         {
-            "url": "https://example.com/katalog/motyw/koniec/", 
-            "href": "https://example.com/api/themes/koniec/", 
+            "url": "http://testserver/katalog/motyw/koniec/",
+            "href": "http://testserver/api/themes/koniec/",
             "name": "Koniec", 
             "slug": "koniec"
         }
index 1733ba1..e464557 100644 (file)
@@ -1,24 +1,24 @@
 [
     {
-        "url": "https://example.com/katalog/lektura/child.html#man-anchor", 
+        "url": "http://testserver/katalog/lektura/child.html#man-anchor",
         "book": {
             "kind": "", 
             "full_sort_key": "$child$2", 
             "author": "", 
-            "url": "https://example.com/katalog/lektura/child/", 
+            "url": "http://testserver/katalog/lektura/child/",
             "cover_color": "#000000", 
             "title": "Child", 
             "cover": "", 
             "liked": null, 
             "slug": "child", 
             "epoch": "", 
-            "href": "https://example.com/api/books/child/", 
+            "href": "http://testserver/api/books/child/",
             "genre": "Wiersz", 
             "simple_thumb": "", 
             "has_audio": false, 
             "cover_thumb": ""
         }, 
         "anchor": "an-anchor", 
-        "href": "https://example.com/api/books/child/fragments/an-anchor/"
+        "href": "http://testserver/api/books/child/fragments/an-anchor/"
     }
 ]
index 4729beb..0232c90 100644 (file)
@@ -1,13 +1,13 @@
 [
     {
-        "url": "https://example.com/katalog/gatunek/sonet/", 
-        "href": "https://example.com/api/genres/sonet/", 
+        "url": "http://testserver/katalog/gatunek/sonet/",
+        "href": "http://testserver/api/genres/sonet/",
         "name": "Sonet", 
         "slug": "sonet"
     }, 
     {
-        "url": "https://example.com/katalog/gatunek/wiersz/", 
-        "href": "https://example.com/api/genres/wiersz/", 
+        "url": "http://testserver/katalog/gatunek/wiersz/",
+        "href": "http://testserver/api/genres/wiersz/",
         "name": "Wiersz", 
         "slug": "wiersz"
     }
index 5486b5f..6be34ed 100644 (file)
@@ -2,12 +2,22 @@
 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
 #
+from base64 import b64encode
 from os import path
+import hashlib
+import hmac
 import json
+from StringIO import StringIO
+from time import time
+from urllib import quote, urlencode
+from urlparse import parse_qs
 
+from django.contrib.auth.models import User
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test import TestCase
 from django.test.utils import override_settings
+from mock import patch
+from piston.models import Consumer, Token
 
 from catalogue.models import Book, Tag
 from picture.forms import PictureImportForm
@@ -29,9 +39,17 @@ class ApiTest(TestCase):
             self.fail('No JSON could be decoded: %s' % content)
         return data
 
+    def assert_response(self, url, name):
+        content = self.client.get(url).content.rstrip()
+        filename = path.join(path.dirname(__file__), 'res', 'responses', name)
+        with open(filename) as f:
+            good_content = f.read().rstrip()
+        self.assertEqual(content, good_content, content)
+    
     def assert_json_response(self, url, name):
         data = self.load_json(url)
-        with open(path.join(path.dirname(__file__), 'res', 'responses', name)) as f:
+        filename = path.join(path.dirname(__file__), 'res', 'responses', name)
+        with open(filename) as f:
             good_data = json.load(f)
         self.assertEqual(data, good_data, json.dumps(data, indent=4))
 
@@ -45,7 +63,8 @@ class BookTests(ApiTest):
     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 = Book.objects.create(
+            title='Tagged Book', slug='tagged-book')
         self.book_tagged.tags = [self.tag]
         self.book_tagged.save()
 
@@ -69,7 +88,8 @@ class BookTests(ApiTest):
 class TagTests(ApiTest):
 
     def setUp(self):
-        self.tag = Tag.objects.create(category='author', slug='joe', name='Joe')
+        self.tag = Tag.objects.create(
+            category='author', slug='joe', name='Joe')
         self.book = Book.objects.create(title='A Book', slug='a-book')
         self.book.tags = [self.tag]
         self.book.save()
@@ -89,9 +109,15 @@ class PictureTests(ApiTest):
     def test_publish(self):
         slug = "kandinsky-composition-viii"
         xml = SimpleUploadedFile(
-            'composition8.xml', open(path.join(picture.tests.__path__[0], "files", slug + ".xml")).read())
+            'composition8.xml',
+            open(path.join(
+                picture.tests.__path__[0], "files", slug + ".xml"
+            )).read())
         img = SimpleUploadedFile(
-            'kompozycja-8.png', open(path.join(picture.tests.__path__[0], "files", slug + ".png")).read())
+            'kompozycja-8.png',
+            open(path.join(
+                picture.tests.__path__[0], "files", slug + ".png"
+            )).read())
 
         import_form = PictureImportForm({}, {
             'picture_xml_file': xml,
@@ -109,8 +135,9 @@ class BooksTests(ApiTest):
     fixtures = ['test-books.yaml']
 
     def test_books(self):
-       self.assert_json_response('/api/books/', 'books.json')
-       self.assert_json_response('/api/books/?new_api=true', 'books.json')
+        self.assert_json_response('/api/books/', 'books.json')
+        self.assert_json_response('/api/books/?new_api=true', 'books.json')
+        self.assert_response('/api/books/?format=xml', 'books.xml')
 
         self.assert_slugs('/api/audiobooks/', ['parent'])
         self.assert_slugs('/api/daisy/', ['parent'])
@@ -120,18 +147,25 @@ class BooksTests(ApiTest):
 
         # Book paging.
         self.assert_slugs('/api/books/after/grandchild/count/1/', ['parent'])
-        self.assert_slugs('/api/books/?new_api=true&after=$grandchild$3&count=1', ['parent'])
+        self.assert_slugs(
+            '/api/books/?new_api=true&after=$grandchild$3&count=1', ['parent'])
 
         # By tag.
-       self.assert_slugs('/api/authors/john-doe/books/', ['parent'])
-       self.assert_slugs('/api/genres/sonet/books/?authors=john-doe', ['parent'])
+        self.assert_slugs('/api/authors/john-doe/books/', ['parent'])
+        self.assert_slugs(
+            '/api/genres/sonet/books/?authors=john-doe',
+            ['parent'])
         # It is probably a mistake that this doesn't filter:
-       self.assert_slugs('/api/books/?authors=john-doe', ['child', 'grandchild', 'parent'])
+        self.assert_slugs(
+            '/api/books/?authors=john-doe',
+            ['child', 'grandchild', 'parent'])
 
-       # Parent books by tag.
+        # Parent books by tag.
         # Notice this contains a grandchild, if a child doesn't have the tag.
         # This probably isn't really intended behavior and should be redefined.
-       self.assert_slugs('/api/genres/sonet/parent_books/', ['grandchild', 'parent'])
+        self.assert_slugs(
+            '/api/genres/sonet/parent_books/',
+            ['grandchild', 'parent'])
 
     def test_ebooks(self):
         self.assert_json_response('/api/ebooks/', 'ebooks.json')
@@ -139,18 +173,25 @@ class BooksTests(ApiTest):
     def test_filter_books(self):
         self.assert_json_response('/api/filter-books/', 'filter-books.json')
         self.assert_slugs(
-            '/api/filter-books/?lektura=false&preview=false',
+            '/api/filter-books/?lektura=false',
             ['child', 'grandchild', 'parent'])
         self.assert_slugs(
             '/api/filter-books/?lektura=true',
             [])
 
-        Book.objects.filter(slug='child').update(preview=True)
-        self.assert_slugs('/api/filter-books/?preview=true', ['child'])
-        self.assert_slugs('/api/filter-books/?preview=false', ['grandchild', 'parent'])
+        self.assert_slugs(
+            '/api/filter-books/?preview=true',
+            ['grandchild'])
+        self.assert_slugs(
+            '/api/filter-books/?preview=false',
+            ['child', 'parent'])
 
-        self.assert_slugs('/api/filter-books/?audiobook=true', ['parent'])
-        self.assert_slugs('/api/filter-books/?audiobook=false', ['child', 'grandchild'])
+        self.assert_slugs(
+            '/api/filter-books/?audiobook=true',
+            ['parent'])
+        self.assert_slugs(
+            '/api/filter-books/?audiobook=false',
+            ['child', 'grandchild'])
 
         self.assert_slugs('/api/filter-books/?genres=wiersz', ['child'])
 
@@ -158,25 +199,33 @@ class BooksTests(ApiTest):
 
     def test_collections(self):
         self.assert_json_response('/api/collections/', 'collections.json')
-        self.assert_json_response('/api/collections/a-collection/', 'collection.json')
+        self.assert_json_response(
+            '/api/collections/a-collection/',
+            'collection.json')
 
     def test_book(self):
-       self.assert_json_response('/api/books/parent/', 'books-parent.json')
-       self.assert_json_response('/api/books/child/', 'books-child.json')
-       self.assert_json_response('/api/books/grandchild/', 'books-grandchild.json')
+        self.assert_json_response('/api/books/parent/', 'books-parent.json')
+        self.assert_json_response('/api/books/child/', 'books-child.json')
+        self.assert_json_response(
+            '/api/books/grandchild/',
+            'books-grandchild.json')
 
     def test_tags(self):
-       # List of tags by category.
-       self.assert_json_response('/api/genres/', 'tags.json')
+        # List of tags by category.
+        self.assert_json_response('/api/genres/', 'tags.json')
 
     def test_fragments(self):
         # This is not supported, though it probably should be.
-       #self.assert_json_response('/api/books/child/fragments/', 'fragments.json')
+        # self.assert_json_response(
+        #     '/api/books/child/fragments/',
+        #     'fragments.json')
 
-       self.assert_json_response('/api/genres/wiersz/fragments/', 'fragments.json')
-       self.assert_json_response('/api/genres/wiersz/fragments/', 'fragments.json')
-
-        self.assert_json_response('/api/books/child/fragments/an-anchor/', 'fragment.json')
+        self.assert_json_response(
+            '/api/genres/wiersz/fragments/',
+            'fragments.json')
+        self.assert_json_response(
+            '/api/books/child/fragments/an-anchor/',
+            'fragment.json')
 
 
 class BlogTests(ApiTest):
@@ -189,3 +238,200 @@ class PreviewTests(ApiTest):
         self.assert_json_response('/api/preview/', 'preview.json')
 
 
+class OAuth1Tests(ApiTest):
+    @classmethod
+    def setUpClass(cls):
+        cls.user = User.objects.create(username='test')
+        cls.consumer_secret = 'len(quote(consumer secret))>=32'
+        Consumer.objects.create(
+            key='client',
+            secret=cls.consumer_secret
+        )
+
+    @classmethod
+    def tearDownClass(cls):
+        User.objects.all().delete()
+
+    def test_create_token(self):
+        base_query = ("oauth_consumer_key=client&oauth_nonce=123&"
+                      "oauth_signature_method=HMAC-SHA1&oauth_timestamp={}&"
+                      "oauth_version=1.0".format(int(time())))
+        raw = '&'.join([
+            'GET',
+            quote('http://testserver/api/oauth/request_token/', safe=''),
+            quote(base_query, safe='')
+        ])
+        h = hmac.new(
+            quote(self.consumer_secret) + '&', raw, hashlib.sha1
+        ).digest()
+        h = b64encode(h).rstrip('\n')
+        sign = quote(h)
+        query = "{}&oauth_signature={}".format(base_query, sign)
+        response = self.client.get('/api/oauth/request_token/?' + query)
+        request_token = parse_qs(response.content)
+
+        Token.objects.filter(
+            key=request_token['oauth_token'][0], token_type=Token.REQUEST
+        ).update(user=self.user, is_approved=True)
+
+        base_query = ("oauth_consumer_key=client&oauth_nonce=123&"
+                      "oauth_signature_method=HMAC-SHA1&oauth_timestamp={}&"
+                      "oauth_token={}&oauth_version=1.0".format(
+                          int(time()), request_token['oauth_token'][0]))
+        raw = '&'.join([
+            'GET',
+            quote('http://testserver/api/oauth/access_token/', safe=''),
+            quote(base_query, safe='')
+        ])
+        h = hmac.new(
+            quote(self.consumer_secret) + '&' +
+            quote(request_token['oauth_token_secret'][0], safe=''),
+            raw,
+            hashlib.sha1
+        ).digest()
+        h = b64encode(h).rstrip('\n')
+        sign = quote(h)
+        query = u"{}&oauth_signature={}".format(base_query, sign)
+        response = self.client.get(u'/api/oauth/access_token/?' + query)
+        access_token = parse_qs(response.content)
+
+        self.assertTrue(
+            Token.objects.filter(
+                key=access_token['oauth_token'][0],
+                token_type=Token.ACCESS,
+                user=self.user
+            ).exists())
+
+
+class AuthorizedTests(ApiTest):
+    fixtures = ['test-books.yaml']
+
+    @classmethod
+    def setUpClass(cls):
+        super(AuthorizedTests, cls).setUpClass()
+        cls.user = User.objects.create(username='test')
+        cls.consumer = Consumer.objects.create(
+            key='client', secret='12345678901234567890123456789012')
+        cls.token = Token.objects.create(
+            key='123456789012345678',
+            secret='12345678901234567890123456789012',
+            user=cls.user,
+            consumer=cls.consumer,
+            token_type=Token.ACCESS,
+            timestamp=time())
+        cls.key = cls.consumer.secret + '&' + cls.token.secret
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.user.delete()
+        cls.consumer.delete()
+        super(AuthorizedTests, cls).tearDownClass()
+
+    def signed(self, url, method='GET', params=None):
+        auth_params = {
+            "oauth_consumer_key": self.consumer.key,
+            "oauth_nonce": "%f" % time(),
+            "oauth_signature_method": "HMAC-SHA1",
+            "oauth_timestamp": int(time()),
+            "oauth_token": self.token.key,
+            "oauth_version": "1.0",
+        }
+
+        sign_params = {}
+        if params:
+            sign_params.update(params)
+        sign_params.update(auth_params)
+        raw = "&".join([
+            method.upper(),
+            quote('http://testserver' + url, safe=''),
+            quote("&".join(
+                quote(str(k)) + "=" + quote(str(v))
+                for (k, v) in sorted(sign_params.items())))
+        ])
+        auth_params["oauth_signature"] = quote(b64encode(hmac.new(
+            self.key, raw, hashlib.sha1).digest()).rstrip('\n'))
+        auth = 'OAuth realm="API", ' + ', '.join(
+            '{}="{}"'.format(k, v) for (k, v) in auth_params.items())
+
+        if params:
+            url = url + '?' + urlencode(params)
+        return getattr(self.client, method.lower())(
+                url,
+                HTTP_AUTHORIZATION=auth
+            )
+
+    def signed_json(self, url, method='GET', params=None):
+        return json.loads(self.signed(url, method, params).content)
+
+    def test_books(self):
+        self.assertEqual(
+            [b['liked'] for b in self.signed_json('/api/books/')],
+            [False, False, False]
+        )
+        # This one fails in the legacy implementation
+        # data = self.signed_json('/api/books/child/')
+        # self.assertFalse(data['parent']['liked'])
+        # self.assertFalse(data['children'][0]['liked'])
+
+        self.assertEqual(
+            self.signed_json('/api/like/parent/'),
+            {"likes": False}
+        )
+        self.signed('/api/like/parent/', 'POST')
+        self.assertEqual(
+            self.signed_json('/api/like/parent/'),
+            {"likes": True}
+        )
+        # There are several endpoints where 'liked' appears.
+        self.assertTrue(self.signed_json('/api/parent_books/')[0]['liked'])
+        self.assertTrue(self.signed_json(
+            '/api/filter-books/', params={"search": "parent"})[0]['liked'])
+
+        # This one fails in the legacy implementation.
+        #self.assertTrue(self.signed_json(
+        #    '/api/books/child/')['parent']['liked'])
+        # Liked books go on shelf.
+        self.assertEqual(
+            [x['slug'] for x in self.signed_json('/api/shelf/likes/')],
+            ['parent'])
+
+        self.signed('/api/like/parent/', 'POST', {"action": "unlike"})
+        self.assertEqual(
+            self.signed_json('/api/like/parent/'),
+            {"likes": False}
+        )
+        self.assertFalse(self.signed_json('/api/parent_books/')[0]['liked'])
+
+    def test_reading(self):
+        self.assertEqual(
+            self.signed_json('/api/reading/parent/'),
+            {"state": "not_started"}
+        )
+        self.signed('/api/reading/parent/reading/', 'post')
+        self.assertEqual(
+            self.signed_json('/api/reading/parent/'),
+            {"state": "reading"}
+        )
+        self.assertEqual(
+            [x['slug'] for x in self.signed_json('/api/shelf/reading/')],
+            ['parent'])
+
+    def test_subscription(self):
+        self.assert_slugs('/api/preview/', ['grandchild'])
+        self.assertEqual(
+            self.signed_json('/api/username/'),
+            {"username": "test", "premium": False})
+        self.assertEqual(
+            self.signed('/api/epub/grandchild/').status_code,
+            403)
+
+        with patch('api.fields.user_is_subscribed', return_value=True):
+            self.assertEqual(
+                self.signed_json('/api/username/'),
+                {"username": "test", "premium": True})
+        with patch('paypal.permissions.user_is_subscribed', return_value=True):
+            with patch('django.core.files.storage.Storage.open',
+                       return_value=StringIO("<epub>")):
+                self.assertEqual(
+                    self.signed('/api/epub/grandchild/').content,
+                    "<epub>")
index bd832fe..d93bcce 100644 (file)
@@ -2,7 +2,7 @@
 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
 #
-from django.conf.urls import url
+from django.conf.urls import url, include
 from django.views.decorators.csrf import csrf_exempt
 from django.views.generic import TemplateView
 from piston.authentication import OAuthAuthentication, oauth_access_token, oauth_request_token
@@ -11,6 +11,7 @@ import catalogue.views
 from api import handlers
 from api.helpers import CsrfExemptResource
 from api.piston_patch import oauth_user_auth
+from . import views
 
 auth = OAuthAuthentication(realm="Wolne Lektury")
 
@@ -41,26 +42,7 @@ def auth_resource(handler):
 
 book_list_resource = auth_resource(handler=handlers.BooksHandler)
 ebook_list_resource = Resource(handler=handlers.EBooksHandler)
-# book_list_resource = Resource(handler=handlers.BooksHandler)
-book_resource = Resource(handler=handlers.BookDetailHandler)
 filter_book_resource = auth_resource(handler=handlers.FilterBooksHandler)
-epub_resource = auth_resource(handler=handlers.EpubHandler)
-
-preview_resource = Resource(handler=handlers.BookPreviewHandler)
-
-reading_resource = auth_resource(handler=handlers.UserDataHandler)
-shelf_resource = auth_resource(handler=handlers.UserShelfHandler)
-
-like_resource = auth_resource(handler=handlers.UserLikeHandler)
-
-collection_resource = Resource(handler=handlers.CollectionDetailHandler)
-collection_list_resource = Resource(handler=handlers.CollectionsHandler)
-
-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)
 
 picture_resource = auth_resource(handler=handlers.PictureHandler)
 
@@ -82,56 +64,23 @@ urlpatterns = [
     url(r'book/(?P<book_id>\d*?)/info\.html$', catalogue.views.book_info),
     url(r'tag/(?P<tag_id>\d*?)/info\.html$', catalogue.views.tag_info),
 
-    # books by collections
-    url(r'^collections/$', collection_list_resource, name="api_collections"),
-    url(r'^collections/(?P<slug>[^/]+)/$', collection_resource, name="api_collection"),
-
-    # epub preview
-    url(r'^epub/(?P<slug>[a-z0-9-]+)/$', epub_resource, name='api_epub'),
-
     # reading data
-    url(r'^reading/(?P<slug>[a-z0-9-]+)/$', reading_resource, name='api_reading'),
-    url(r'^reading/(?P<slug>[a-z0-9-]+)/(?P<state>[a-z]+)/$', reading_resource, name='api_reading'),
-    url(r'^shelf/(?P<state>[a-z]+)/$', shelf_resource, name='api_shelf'),
-    url(r'^username/$', reading_resource, name='api_username'),
-
-    url(r'^like/(?P<slug>[a-z0-9-]+)/$', like_resource, name='api_like'),
-
-    # objects details
-    url(r'^books/(?P<book>[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<book>[a-z0-9-]+)/fragments/(?P<anchor>[a-z0-9-]+)/$',
-        fragment_resource, name="api_fragment"),
+    url(r'^reading/(?P<slug>[a-z0-9-]+)/$', views.BookUserDataView.as_view(), name='api_reading'),
+    url(r'^reading/(?P<slug>[a-z0-9-]+)/(?P<state>[a-z]+)/$', views.BookUserDataView.as_view(), name='api_reading'),
+    url(r'^username/$', views.UserView.as_view(), name='api_username'),
 
     # books by tags
-    url(tags_re + r'books/' + paginate_re,
-        book_list_resource, name='api_book_list'),
     url(tags_re + r'ebooks/' + paginate_re,
         ebook_list_resource, name='api_ebook_list'),
-    url(tags_re + r'parent_books/' + paginate_re,
-        book_list_resource, {"top_level": True}, name='api_parent_book_list'),
     url(tags_re + r'parent_ebooks/' + paginate_re,
         ebook_list_resource, {"top_level": True}, name='api_parent_ebook_list'),
-    url(tags_re + r'audiobooks/' + paginate_re,
-        book_list_resource, {"audiobooks": True}, name='api_audiobook_list'),
-    url(tags_re + r'daisy/' + paginate_re,
-        book_list_resource, {"daisy": True}, name='api_daisy_list'),
 
-    url(r'^recommended/' + paginate_re, book_list_resource, {"recommended": True}, name='api_recommended_list'),
-    url(r'^newest/$', book_list_resource, {"newest": True, "top_level": True, "count": 20}, name='api_newest_list'),
     url(r'^filter-books/$', filter_book_resource, name='api_filter_books'),
 
-    url(r'^preview/$', preview_resource, name='api_preview'),
-
     url(r'^pictures/$', picture_resource),
 
     url(r'^blog/$', blog_resource),
 
-    # 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, name='api_tag_list'),
+    url(r'^', include('social.api.urls')),
+    url(r'^', include('catalogue.api.urls')),
 ]
diff --git a/src/api/views.py b/src/api/views.py
new file mode 100644 (file)
index 0000000..812be83
--- /dev/null
@@ -0,0 +1,41 @@
+from django.http import Http404
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from rest_framework.generics import RetrieveAPIView, get_object_or_404
+from catalogue.models import Book
+from .models import BookUserData
+from . import serializers
+
+
+class UserView(RetrieveAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = serializers.UserSerializer
+
+    def get_object(self):
+        return self.request.user
+
+
+class BookUserDataView(RetrieveAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = serializers.BookUserDataSerializer
+    lookup_field = 'book__slug'
+    lookup_url_kwarg = 'slug'
+
+    def get_queryset(self):
+        return BookUserData.objects.filter(user=self.request.user)
+
+    def get(self, *args, **kwargs):
+        try:
+            return super(BookUserDataView, self).get(*args, **kwargs)
+        except Http404:
+            return Response({"state": "not_started"})
+
+    def post(self, request, slug, state):
+        if state not in ('reading', 'complete'):
+            raise Http404
+
+        book = get_object_or_404(Book, slug=slug)
+        instance = BookUserData.update(book, request.user, state)
+        serializer = self.get_serializer(instance)
+        return Response(serializer.data)
diff --git a/src/catalogue/api/__init__.py b/src/catalogue/api/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/catalogue/api/fields.py b/src/catalogue/api/fields.py
new file mode 100644 (file)
index 0000000..145780e
--- /dev/null
@@ -0,0 +1,30 @@
+from rest_framework import serializers
+from sorl.thumbnail import default
+from catalogue.models import Book
+
+
+class BookLiked(serializers.ReadOnlyField):
+    def __init__(self, source='pk', **kwargs):
+        super(BookLiked, self).__init__(source=source, **kwargs)
+
+    def to_representation(self, value):
+        request = self.context['request']
+        if not hasattr(request, 'liked_books'):
+            if request.user.is_authenticated():
+                request.liked_books = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
+            else:
+                request.liked_books = None
+        if request.liked_books is not None:
+            return value in request.liked_books
+
+
+class ThumbnailField(serializers.FileField):
+    def __init__(self, geometry, *args, **kwargs):
+        self.geometry = geometry
+        super(ThumbnailField, self).__init__(*args, **kwargs)
+        
+    def to_representation(self, value):
+        if value:
+            return super(ThumbnailField, self).to_representation(
+                default.backend.get_thumbnail(value, self.geometry)
+            )
diff --git a/src/catalogue/api/helpers.py b/src/catalogue/api/helpers.py
new file mode 100644 (file)
index 0000000..d872dde
--- /dev/null
@@ -0,0 +1,22 @@
+from django.db.models import Q
+from catalogue.models import Book
+
+
+def books_after(books, after, new_api):
+    if not new_api:
+        return books.filter(slug__gt=after)
+    try:
+        author, title, book_id = after.split(Book.SORT_KEY_SEP)
+    except ValueError:
+        return Book.objects.none()
+    return books.filter(Q(sort_key_author__gt=author)
+                        | (Q(sort_key_author=author) & Q(sort_key__gt=title))
+                        | (Q(sort_key_author=author) & Q(sort_key=title) & Q(id__gt=int(book_id))))
+
+
+def order_books(books, new_api):
+    if new_api:
+        return books.order_by('sort_key_author', 'sort_key', 'id')
+    else:
+        return books.order_by('slug')
+
diff --git a/src/catalogue/api/serializers.py b/src/catalogue/api/serializers.py
new file mode 100644 (file)
index 0000000..e8f04ca
--- /dev/null
@@ -0,0 +1,154 @@
+from rest_framework import serializers
+from api.fields import AbsoluteURLField, LegacyMixin
+from catalogue.models import Book, Collection, Tag, BookMedia, Fragment
+from .fields import BookLiked, ThumbnailField
+
+
+class TagSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_tag',
+        view_args=('category', 'slug')
+    )
+
+    class Meta:
+        model = Tag
+        fields = ['url', 'href', 'name', 'slug']
+
+
+class TagDetailSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+
+    class Meta:
+        model = Tag
+        fields = ['name', 'url', 'sort_key', 'description']
+
+
+class BaseBookSerializer(LegacyMixin, serializers.ModelSerializer):
+    author = serializers.CharField(source='author_unicode')
+    kind = serializers.CharField(source='kind_unicode')
+    epoch = serializers.CharField(source='epoch_unicode')
+    genre = serializers.CharField(source='genre_unicode')
+
+    simple_thumb = serializers.FileField(source='cover_api_thumb')
+    href = AbsoluteURLField(view_name='catalogue_api_book', view_args=['slug'])
+    url = AbsoluteURLField()
+    cover = serializers.CharField()
+    cover_thumb = ThumbnailField('139x193', source='cover')
+
+    class Meta:
+        model = Book
+        fields = [
+            'kind', 'full_sort_key', 'title', 'url', 'cover_color', 'author',
+            'cover', 'epoch', 'href', 'has_audio', 'genre',
+            'simple_thumb', 'slug', 'cover_thumb']
+        legacy_non_null_fields = [
+            'kind', 'author', 'epoch', 'genre',
+            'cover', 'simple_thumb', 'cover_thumb']
+
+
+class BookSerializer(BaseBookSerializer):
+    liked = BookLiked()
+    cover = serializers.FileField()
+
+    class Meta:
+        model = Book
+        fields = BaseBookSerializer.Meta.fields + ['liked']
+        legacy_non_null_fields = BaseBookSerializer.Meta.legacy_non_null_fields
+
+
+class MediaSerializer(LegacyMixin, serializers.ModelSerializer):
+    url = serializers.FileField(source='file')
+
+    class Meta:
+        model = BookMedia
+        fields = ['url', 'director', 'type', 'name', 'artist']
+        legacy_non_null_fields = ['director', 'artist']
+
+
+class BookDetailSerializer(LegacyMixin, serializers.ModelSerializer):
+    url = AbsoluteURLField()
+
+    authors = TagSerializer(many=True)
+    epochs = TagSerializer(many=True)
+    genres = TagSerializer(many=True)
+    kinds = TagSerializer(many=True)
+
+    fragment_data = serializers.DictField()
+    parent = BookSerializer()
+    children = BookSerializer(many=True)
+
+    xml = AbsoluteURLField(source='xml_url')
+    html = AbsoluteURLField(source='html_url')
+    txt = AbsoluteURLField(source='txt_url')
+    fb2 = AbsoluteURLField(source='fb2_url')
+    epub = AbsoluteURLField(source='epub_url')
+    mobi = AbsoluteURLField(source='mobi_url')
+    pdf = AbsoluteURLField(source='pdf_url')
+    media = MediaSerializer(many=True)
+    cover_thumb = ThumbnailField('139x193', source='cover')
+    simple_thumb = serializers.FileField(source='cover_api_thumb')
+
+    class Meta:
+        model = Book
+        fields = [
+            'title', 'url',
+            'epochs', 'genres', 'kinds', 'authors',
+            'fragment_data', 'children', 'parent', 'preview',
+            'epub', 'mobi', 'pdf', 'html', 'txt', 'fb2', 'xml', 'media', 'audio_length',
+            'cover_color', 'simple_cover', 'cover_thumb', 'cover', 'simple_thumb'
+        ]
+        legacy_non_null_fields = ['html', 'txt', 'fb2', 'epub', 'mobi', 'pdf',
+                                  'cover', 'simple_cover', 'cover_thumb', 'simple_thumb']
+
+
+class BookPreviewSerializer(BookDetailSerializer):
+    class Meta:
+        model = Book
+        fields = BookDetailSerializer.Meta.fields + ['slug']
+        legacy_non_null_fields = BookDetailSerializer.Meta.legacy_non_null_fields
+
+
+class EbookSerializer(BookSerializer):
+    class Meta:
+        model = Book
+        fields = ['author', 'href', 'title', 'cover', 'slug'] + Book.ebook_formats
+        legacy_non_null_fields = ['author', 'cover'] + Book.ebook_formats
+
+
+class CollectionListSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(view_name='collection-detail', view_args=['slug'])
+
+    class Meta:
+        model = Collection
+        fields = ['url', 'href', 'title']
+
+
+class CollectionSerializer(serializers.ModelSerializer):
+    books = BookSerializer(many=True, source='get_books')
+    url = AbsoluteURLField()
+
+    class Meta:
+        model = Collection
+        fields = ['url', 'books', 'description', 'title']
+
+
+class FragmentSerializer(serializers.ModelSerializer):
+    book = BookSerializer()
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(source='get_api_url')
+
+    class Meta:
+        model = Fragment
+        fields = ['book', 'url', 'anchor', 'href']
+
+
+class FragmentDetailSerializer(serializers.ModelSerializer):
+    book = BookSerializer()
+    url = AbsoluteURLField()
+    themes = TagSerializer(many=True)
+
+    class Meta:
+        model = Fragment
+        fields = ['book', 'anchor', 'text', 'url', 'themes']
diff --git a/src/catalogue/api/urls.py b/src/catalogue/api/urls.py
new file mode 100644 (file)
index 0000000..13cebea
--- /dev/null
@@ -0,0 +1,46 @@
+# -*- 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 django.conf.urls import include, url
+from . import views
+
+
+tags_re = r'^(?P<tags>(?:(?:[a-z0-9-]+/){2}){0,6})'
+paginate_re = r'(?:after/(?P<after>[a-z0-9-]+)/)?(?:count/(?P<count>[0-9]+)/)?$'
+
+
+urlpatterns = [
+    # books by collections
+    url(r'^collections/$', views.CollectionList.as_view(), name="api_collections"),
+    url(r'^collections/(?P<slug>[^/]+)/$',
+        views.CollectionDetail.as_view(), name="collection-detail"),
+
+    url(tags_re + r'books/' + paginate_re,
+        views.BookList.as_view(), name='catalogue_api_book_list'),
+    url(tags_re + r'parent_books/' + paginate_re,
+        views.BookList.as_view(), {"top_level": True}, name='catalogue_api_parent_book_list'),
+    url(tags_re + r'audiobooks/' + paginate_re,
+        views.BookList.as_view(), {"audiobooks": True}, name='catalogue_api_audiobook_list'),
+    url(tags_re + r'daisy/' + paginate_re,
+        views.BookList.as_view(), {"daisy": True}, name='catalogue_api_daisy_list'),
+    url(r'^recommended/' + paginate_re,
+        views.BookList.as_view(),
+        {"recommended": True}, name='catalogue_api_recommended_list'),
+    url(r'^newest/$',
+        views.BookList.as_view(),
+        {"newest": True, "top_level": True, "count": 20}, name='catalogue_api_newest_list'),
+
+    url(r'^books/(?P<slug>[^/]+)/$', views.BookDetail.as_view(), name='catalogue_api_book'),
+
+    url(r'^epub/(?P<slug>[a-z0-9-]+)/$', views.EpubView.as_view(), name='catalogue_api_epub'),
+
+    url(r'^preview/$', views.Preview.as_view(), name='catalogue_api_preview'),
+
+    url(r'^(?P<tags>(?:(?:[a-z0-9-]+/){2}){1,6})fragments/$', views.FragmentList.as_view()),
+    url(r'^books/(?P<book>[a-z0-9-]+)/fragments/(?P<anchor>[a-z0-9-]+)/$',
+        views.FragmentView.as_view(), name="catalogue_api_fragment"),
+
+    url(r'^(?P<category>[a-z]+)s/$', views.TagCategoryView.as_view(), name='catalogue_api_tag_list'),
+    url(r'^(?P<category>[a-z]+)s/(?P<slug>[a-z0-9-]+)/$', views.TagView.as_view(), name="catalogue_api_tag"),
+]
diff --git a/src/catalogue/api/views.py b/src/catalogue/api/views.py
new file mode 100644 (file)
index 0000000..e21aab7
--- /dev/null
@@ -0,0 +1,164 @@
+from django.http import Http404, HttpResponse
+from rest_framework.generics import ListAPIView, RetrieveAPIView, get_object_or_404
+from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
+from rest_framework import status
+from paypal.permissions import IsSubscribed
+from api.handlers import read_tags
+from .helpers import books_after, order_books
+from . import serializers
+from catalogue.models import Book, Collection, Tag, Fragment
+from catalogue.models.tag import prefetch_relations
+
+
+class CollectionList(ListAPIView):
+    queryset = Collection.objects.all()
+    serializer_class = serializers.CollectionListSerializer
+
+
+class CollectionDetail(RetrieveAPIView):
+    queryset = Collection.objects.all()
+    lookup_field = 'slug'
+    serializer_class = serializers.CollectionSerializer
+
+
+class BookList(ListAPIView):
+    permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
+    queryset = Book.objects.none()  # Required for DjangoModelPermissions
+    serializer_class = serializers.BaseBookSerializer
+
+    def get_queryset(self):
+        try:
+            tags, ancestors = read_tags(
+                self.kwargs['tags'], self.request,
+                allowed=('author', 'epoch', 'kind', 'genre')
+            )
+        except ValueError:
+            raise Http404
+
+        new_api = self.request.query_params.get('new_api')
+        after = self.request.query_params.get('after', self.kwargs.get('after'))
+        count = self.request.query_params.get('count', self.kwargs.get('count'))
+
+        if tags:
+            if self.kwargs.get('top_level'):
+                books = Book.tagged_top_level(tags)
+                if not books:
+                    raise Http404
+                return books
+            else:
+                books = Book.tagged.with_all(tags)
+        else:
+            books = Book.objects.all()
+        books = order_books(books, new_api)
+
+        if self.kwargs.get('top_level'):
+            books = books.filter(parent=None)
+        if self.kwargs.get('audiobooks'):
+            books = books.filter(media__type='mp3').distinct()
+        if self.kwargs.get('daisy'):
+            books = books.filter(media__type='daisy').distinct()
+        if self.kwargs.get('recommended'):
+            books = books.filter(recommended=True)
+        if self.kwargs.get('newest'):
+            books = books.order_by('-created_at')
+
+        if after:
+            books = books_after(books, after, new_api)
+
+        prefetch_relations(books, 'author')
+        prefetch_relations(books, 'genre')
+        prefetch_relations(books, 'kind')
+        prefetch_relations(books, 'epoch')
+
+        if count:
+            books = books[:count]
+
+        return books
+
+    def post(self, request):
+        # Permission needed.
+        data = json.loads(request.POST.get('data'))
+        form = BookImportForm(data)
+        if form.is_valid():
+            form.save()
+            return Response({}, status=status.HTTP_201_CREATED)
+        else:
+            raise Http404
+
+
+class BookDetail(RetrieveAPIView):
+    queryset = Book.objects.all()
+    lookup_field = 'slug'
+    serializer_class = serializers.BookDetailSerializer
+
+
+class Preview(ListAPIView):
+    queryset = Book.objects.filter(preview=True)
+    serializer_class = serializers.BookPreviewSerializer
+
+
+class EpubView(RetrieveAPIView):
+    queryset = Book.objects.all()
+    lookup_field = 'slug'
+    permission_classes = [IsSubscribed]
+
+    def get(self, *args, **kwargs):
+        return HttpResponse(self.get_object().get_media('epub'))
+
+
+class TagCategoryView(ListAPIView):
+    serializer_class = serializers.TagSerializer
+
+    def get_queryset(self):
+        category = self.kwargs['category']
+        tags = Tag.objects.filter(category=category).exclude(items=None).order_by('slug')
+        if self.request.query_params.get('book_only') == 'true':
+            tags = tags.filter(for_books=True)
+        if self.request.GET.get('picture_only') == 'true':
+            tags = filter(for_pictures=True)
+
+        after = self.request.query_params.get('after')
+        count = self.request.query_params.get('count')
+        if after:
+            tags = tags.filter(slug__gt=after)
+        if count:
+            tags = tags[:count]
+
+        return tags
+
+
+class TagView(RetrieveAPIView):
+    serializer_class = serializers.TagDetailSerializer
+
+    def get_object(self):
+        return get_object_or_404(
+            Tag,
+            category=self.kwargs['category'],
+            slug=self.kwargs['slug']
+        )
+
+
+class FragmentList(ListAPIView):
+    serializer_class = serializers.FragmentSerializer
+
+    def get_queryset(self):
+        try:
+            tags, ancestors = read_tags(
+                self.kwargs['tags'],
+                self.request,
+                allowed={'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
+            )
+        except ValueError:
+            raise Http404
+        return Fragment.tagged.with_all(tags).select_related('book')
+
+
+class FragmentView(RetrieveAPIView):
+    serializer_class = serializers.FragmentDetailSerializer
+
+    def get_object(self):
+        return get_object_or_404(
+            Fragment,
+            book__slug=self.kwargs['book'],
+            anchor=self.kwargs['anchor']
+        )
index 4c60775..42eefd7 100644 (file)
@@ -1,3 +1,4 @@
+
 - model: catalogue.book
   pk: 1
   fields:
   fields:
     slug: grandchild
     title: Grandchild
+    preview: true
     sort_key: grandchild
     parent: 2
-    txt_file: txt/grandchild.txt
-    html_file: html/grandchild.html
-    fb2_file: fb2/grandchild.fb2
+    xml_file: secret/grandchild.xml
+    txt_file: secret/grandchild.txt
+    html_file: secret/grandchild.html
+    epub_file: secret/grandchild.epub
+    mobi_file: secret/grandchild.mobi
+    pdf_file: secret/grandchild.pdf
+    fb2_file: secret/grandchild.fb2
     created_at: "1970-01-01 0:0Z"
     changed_at: "1970-01-01 0:0Z"
 
     uploaded_at: "1970-01-03 0:0Z"
 
 - model: catalogue.fragment
-  id: 1
+  pk: 1
   fields:
     short_text: "Fragment"
     text: "A fragment"
index 6e6c50f..b2289f6 100644 (file)
@@ -20,6 +20,7 @@ import jsonfield
 from fnpdjango.storage import BofhFileSystemStorage
 from ssify import flush_ssi_includes
 
+from librarian.cover import WLCover
 from librarian.html import transform_abstrakt
 from newtagging import managers
 from catalogue import constants
@@ -115,6 +116,8 @@ class Book(models.Model):
     html_built = django.dispatch.Signal()
     published = django.dispatch.Signal()
 
+    SORT_KEY_SEP = '$'
+
     class AlreadyExists(Exception):
         pass
 
@@ -136,6 +139,15 @@ class Book(models.Model):
     def authors(self):
         return self.tags.filter(category='author')
 
+    def epochs(self):
+        return self.tags.filter(category='epoch')
+
+    def genres(self):
+        return self.tags.filter(category='genre')
+
+    def kinds(self):
+        return self.tags.filter(category='kind')
+
     def tag_unicode(self, category):
         relations = prefetched_relations(self, category)
         if relations:
@@ -149,6 +161,15 @@ class Book(models.Model):
     def author_unicode(self):
         return self.cached_author
 
+    def kind_unicode(self):
+        return self.tag_unicode('kind')
+
+    def epoch_unicode(self):
+        return self.tag_unicode('epoch')
+
+    def genre_unicode(self):
+        return self.tag_unicode('genre')
+
     def translator(self):
         translators = self.extra_info.get('translators')
         if not translators:
@@ -780,6 +801,12 @@ class Book(models.Model):
         if likes(user, self):
             set_sets(user, self, [])
 
+    def full_sort_key(self):
+        return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
+
+    def cover_color(self):
+        return WLCover.epoch_colors.get(self.extra_info.get('epoch'), '#000000')
+
 
 def add_file_fields():
     for format_ in Book.formats:
index 377dbc4..407c419 100644 (file)
@@ -146,3 +146,11 @@ class BookMedia(models.Model):
                 return None
         else:
             return None
+
+    @property
+    def director(self):
+        return self.extra_info.get('director_name', None)
+
+    @property
+    def artist(self):
+        return self.extra_info.get('artist_name', None)
index a3dbdea..dc159a5 100644 (file)
@@ -35,10 +35,17 @@ class Fragment(models.Model):
     def get_absolute_url(self):
         return '%s#m%s' % (reverse('book_text', args=[self.book.slug]), self.anchor)
 
+    def get_api_url(self):
+        return reverse('catalogue_api_fragment', args=[self.book.slug, self.anchor])
+
     def get_short_text(self):
         """Returns short version of the fragment."""
         return self.short_text if self.short_text else self.text
 
+    @property
+    def themes(self):
+        return self.tags.filter(category='theme')
+
     def flush_includes(self, languages=True):
         if not languages:
             return
index 7e15636..c6b6f26 100644 (file)
@@ -166,6 +166,10 @@ class Tag(TagBase):
         else:
             return ''
 
+    @property
+    def category_plural(self):
+        return self.category + 's'
+
     @permalink
     def get_absolute_url(self):
         return 'tagged_object_list', [self.url_chunk]
diff --git a/src/paypal/permissions.py b/src/paypal/permissions.py
new file mode 100644 (file)
index 0000000..9d0865b
--- /dev/null
@@ -0,0 +1,7 @@
+from rest_framework.permissions import BasePermission
+from .rest import user_is_subscribed
+
+
+class IsSubscribed(BasePermission):
+    def has_permission(self, request, view):
+        return request.user.is_authenticated and user_is_subscribed(request.user)
diff --git a/src/social/api/__init__.py b/src/social/api/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/social/api/urls.py b/src/social/api/urls.py
new file mode 100644 (file)
index 0000000..8ded909
--- /dev/null
@@ -0,0 +1,12 @@
+# -*- 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 django.conf.urls import include, url
+from . import views
+
+
+urlpatterns = [
+    url(r'^like/(?P<slug>[a-z0-9-]+)/$', views.LikeView.as_view(), name='social_api_like'),
+    url(r'^shelf/(?P<state>[a-z]+)/$', views.ShelfView.as_view(), name='social_api_shelf'),
+]
diff --git a/src/social/api/views.py b/src/social/api/views.py
new file mode 100644 (file)
index 0000000..555fd84
--- /dev/null
@@ -0,0 +1,58 @@
+# -*- 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 django.http import Http404
+from rest_framework.generics import ListAPIView, get_object_or_404
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from api.models import BookUserData
+from catalogue.api.helpers import order_books, books_after
+from catalogue.api.serializers import BookSerializer
+from catalogue.models import Book
+from social.utils import likes
+
+
+class LikeView(APIView):
+    permission_classes = [IsAuthenticated]
+
+    def get(self, request, slug):
+        book = get_object_or_404(Book, slug=slug)
+        return Response({"likes": likes(request.user, book)})
+
+    def post(self, request, slug):
+        book = get_object_or_404(Book, slug=slug)
+        action = request.query_params.get('action', 'like')
+        if action == 'like':
+            book.like(request.user)
+        elif action == 'unlike':
+            book.unlike(request.user)
+        return Response({})
+
+
+class ShelfView(ListAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = BookSerializer
+
+    def get_queryset(self):
+        state = self.kwargs['state']
+        if state not in ('reading', 'complete', 'likes'):
+            raise Http404
+        new_api = self.request.query_params.get('new_api')
+        after = self.request.query_params.get('after')
+        count = int(self.request.query_params.get('count', 50))
+        if state == 'likes':
+            books = Book.tagged.with_any(self.request.user.tag_set.all())
+        else:
+            ids = BookUserData.objects.filter(user=self.request.user, complete=state == 'complete')\
+                .values_list('book_id', flat=True)
+            books = Book.objects.filter(id__in=list(ids)).distinct()
+            books = order_books(books, new_api)
+        if after:
+            books = books_after(books, after, new_api)
+        if count:
+            books = books[:count]
+
+        return books
+
index 87286d5..c3be29b 100644 (file)
@@ -47,6 +47,7 @@ INSTALLED_APPS_CONTRIB = [
     'django.contrib.admin',
     'django.contrib.admindocs',
     'django.contrib.staticfiles',
+    'rest_framework',
     'fnp_django_pagination',
     'pipeline',
     'piston',
index de64990..a25ef72 100644 (file)
@@ -44,3 +44,14 @@ MIGDAL_TYPES = (
     EntryType('info', _('info'), commentable=False),
     EntryType('event', _('events'), commentable=False),
 )
+
+REST_FRAMEWORK = {
+    "DEFAULT_RENDERER_CLASSES": (
+        'rest_framework.renderers.JSONRenderer',
+        'rest_framework.renderers.BrowsableAPIRenderer',
+        'api.renderers.LegacyXMLRenderer',
+    ),
+    'DEFAULT_AUTHENTICATION_CLASSES': (
+        'api.drf_auth.PistonOAuthAuthentication',
+    )
+}
index 0ad0e69..25bd82c 100644 (file)
@@ -2,3 +2,4 @@ from wolnelektury.settings import *
 
 THUMBNAIL_BACKEND = 'wolnelektury.test_utils.DummyThumbnailBackend'
 CATALOGUE_GET_MP3_LENGTH = 'catalogue.test_utils.get_mp3_length'
+MEDIA_URL = '/media/'