More views in DRF.
authorRadek Czajka <rczajka@rczajka.pl>
Mon, 4 Feb 2019 14:41:25 +0000 (15:41 +0100)
committerRadek Czajka <rczajka@rczajka.pl>
Mon, 4 Feb 2019 14:41:25 +0000 (15:41 +0100)
src/api/handlers.py
src/api/templates/api/main.html
src/api/tests/res/responses/books.json
src/api/urls.py
src/catalogue/api/helpers.py [new file with mode: 0644]
src/catalogue/api/serializers.py
src/catalogue/api/urls.py
src/catalogue/api/views.py
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]

index 4296817..7178b9f 100644 (file)
@@ -16,6 +16,7 @@ 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
 from catalogue.models.tag import prefetch_relations
@@ -143,25 +144,6 @@ class BookDetails(object):
     def simple_cover(cls, book):
         return MEDIA_BASE + book.simple_cover.url if book.simple_cover else ''
 
-    @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(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))))
-
-    @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.
@@ -236,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)
@@ -250,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')
@@ -348,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:
@@ -375,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 = []
@@ -420,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
@@ -490,75 +465,6 @@ class PictureHandler(BaseHandler):
             return rc.NOT_FOUND
 
 
-class UserShelfHandler(BookDetailHandler):
-    fields = book_list_fields + ['liked']
-
-    # FIXME: Unused?
-    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 14dd045..b7e7b8f 100755 (executable)
@@ -36,7 +36,7 @@
 
     <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" "" %}'>
index f83e74e..fb3d39a 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": "https://example.com/media/cover/parent.jpg-139x193"
+        "cover_thumb": "http://testserver/media/cover/parent.jpg-139x193"
     }
 ]
index 19e63dd..d93bcce 100644 (file)
@@ -42,15 +42,8 @@ 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)
 filter_book_resource = auth_resource(handler=handlers.FilterBooksHandler)
 
-preview_resource = Resource(handler=handlers.BookPreviewHandler)
-
-shelf_resource = auth_resource(handler=handlers.UserShelfHandler)
-
-like_resource = auth_resource(handler=handlers.UserLikeHandler)
-
 picture_resource = auth_resource(handler=handlers.PictureHandler)
 
 blog_resource = Resource(handler=handlers.BlogEntryHandler)
@@ -74,34 +67,20 @@ urlpatterns = [
     # reading data
     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'^shelf/(?P<state>[a-z]+)/$', shelf_resource, name='api_shelf'),
     url(r'^username/$', views.UserView.as_view(), name='api_username'),
 
-    url(r'^like/(?P<slug>[a-z0-9-]+)/$', like_resource, name='api_like'),
-
     # 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),
 
+    url(r'^', include('social.api.urls')),
     url(r'^', include('catalogue.api.urls')),
 ]
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')
+
index 7c792d5..e8f04ca 100644 (file)
@@ -24,7 +24,7 @@ class TagDetailSerializer(serializers.ModelSerializer):
         fields = ['name', 'url', 'sort_key', 'description']
 
 
-class BookSerializer(LegacyMixin, serializers.ModelSerializer):
+class BaseBookSerializer(LegacyMixin, serializers.ModelSerializer):
     author = serializers.CharField(source='author_unicode')
     kind = serializers.CharField(source='kind_unicode')
     epoch = serializers.CharField(source='epoch_unicode')
@@ -33,20 +33,30 @@ class BookSerializer(LegacyMixin, serializers.ModelSerializer):
     simple_thumb = serializers.FileField(source='cover_api_thumb')
     href = AbsoluteURLField(view_name='catalogue_api_book', view_args=['slug'])
     url = AbsoluteURLField()
-    liked = BookLiked()
+    cover = serializers.CharField()
     cover_thumb = ThumbnailField('139x193', source='cover')
 
     class Meta:
         model = Book
         fields = [
             'kind', 'full_sort_key', 'title', 'url', 'cover_color', 'author',
-            'cover', 'liked', 'epoch', 'href', 'has_audio', 'genre',
+            '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')
 
@@ -92,6 +102,20 @@ class BookDetailSerializer(LegacyMixin, serializers.ModelSerializer):
                                   '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'])
index 7235267..13cebea 100644 (file)
@@ -6,16 +6,37 @@ 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"),
index aba794d..e21aab7 100644 (file)
@@ -1,9 +1,13 @@
-from django.http import HttpResponse
+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):
@@ -17,12 +21,82 @@ class CollectionDetail(RetrieveAPIView):
     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'
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
+