From bf99d8859b9b576381cde347bd8abd4544f09d38 Mon Sep 17 00:00:00 2001 From: Radek Czajka <rczajka@rczajka.pl> Date: Mon, 4 Feb 2019 15:41:25 +0100 Subject: [PATCH] More views in DRF. --- src/api/handlers.py | 108 ++----------------------- src/api/templates/api/main.html | 2 +- src/api/tests/res/responses/books.json | 16 ++-- src/api/urls.py | 23 +----- src/catalogue/api/helpers.py | 22 +++++ src/catalogue/api/serializers.py | 30 ++++++- src/catalogue/api/urls.py | 21 +++++ src/catalogue/api/views.py | 76 ++++++++++++++++- src/social/api/__init__.py | 0 src/social/api/urls.py | 12 +++ src/social/api/views.py | 58 +++++++++++++ 11 files changed, 232 insertions(+), 136 deletions(-) create mode 100644 src/catalogue/api/helpers.py create mode 100644 src/social/api/__init__.py create mode 100644 src/social/api/urls.py create mode 100644 src/social/api/views.py diff --git a/src/api/handlers.py b/src/api/handlers.py index 4296817e8..7178b9f9e 100644 --- a/src/api/handlers.py +++ b/src/api/handlers.py @@ -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 = ( diff --git a/src/api/templates/api/main.html b/src/api/templates/api/main.html index 14dd04578..b7e7b8f95 100755 --- a/src/api/templates/api/main.html +++ b/src/api/templates/api/main.html @@ -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" "" %}'> diff --git a/src/api/tests/res/responses/books.json b/src/api/tests/res/responses/books.json index f83e74e42..fb3d39abd 100644 --- a/src/api/tests/res/responses/books.json +++ b/src/api/tests/res/responses/books.json @@ -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", @@ -20,12 +20,12 @@ "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", @@ -36,15 +36,15 @@ "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" } ] diff --git a/src/api/urls.py b/src/api/urls.py index 19e63dd16..d93bcce00 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -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 index 000000000..d872dde17 --- /dev/null +++ b/src/catalogue/api/helpers.py @@ -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 index 7c792d586..e8f04ca70 100644 --- a/src/catalogue/api/serializers.py +++ b/src/catalogue/api/serializers.py @@ -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']) diff --git a/src/catalogue/api/urls.py b/src/catalogue/api/urls.py index 723526797..13cebead5 100644 --- a/src/catalogue/api/urls.py +++ b/src/catalogue/api/urls.py @@ -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"), diff --git a/src/catalogue/api/views.py b/src/catalogue/api/views.py index aba794d05..e21aab707 100644 --- a/src/catalogue/api/views.py +++ b/src/catalogue/api/views.py @@ -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 index 000000000..e69de29bb diff --git a/src/social/api/urls.py b/src/social/api/urls.py new file mode 100644 index 000000000..8ded909b4 --- /dev/null +++ b/src/social/api/urls.py @@ -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 index 000000000..555fd8433 --- /dev/null +++ b/src/social/api/views.py @@ -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 + -- 2.20.1