From ff382f6c37063c0c4c5d21c2834a8759e25c5d02 Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Tue, 5 Feb 2019 19:07:03 +0100 Subject: [PATCH] All views migrated from Piston, except for OAuth. --- src/api/fields.py | 17 + src/api/handlers.py | 438 +----------------- src/api/helpers.py | 12 - src/api/serializers.py | 34 +- src/api/tests/res/responses/ebooks.json | 22 +- src/api/tests/res/responses/filter-books.json | 23 +- src/api/tests/tests.py | 47 +- src/api/urls.py | 56 +-- src/api/views.py | 21 +- src/catalogue/api/fields.py | 17 +- src/catalogue/api/serializers.py | 26 +- src/catalogue/api/urls.py | 10 + src/catalogue/api/views.py | 88 +++- src/picture/api/__init__.py | 0 src/picture/api/urls.py | 11 + src/picture/api/views.py | 26 ++ 16 files changed, 302 insertions(+), 546 deletions(-) delete mode 100644 src/api/helpers.py create mode 100644 src/picture/api/__init__.py create mode 100644 src/picture/api/urls.py create mode 100644 src/picture/api/views.py diff --git a/src/api/fields.py b/src/api/fields.py index 1ce83cc3e..66fd7d2e9 100644 --- a/src/api/fields.py +++ b/src/api/fields.py @@ -1,4 +1,9 @@ +# -*- 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 rest_framework import serializers +from sorl.thumbnail import default from django.core.urlresolvers import reverse from paypal.rest import user_is_subscribed @@ -41,3 +46,15 @@ class UserPremiumField(serializers.ReadOnlyField): def to_representation(self, value): return user_is_subscribed(value) + + +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/api/handlers.py b/src/api/handlers.py index 7178b9f9e..d08812f41 100644 --- a/src/api/handlers.py +++ b/src/api/handlers.py @@ -2,34 +2,12 @@ # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later. # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. # -import json - from django.contrib.sites.models import Site -from django.core.urlresolvers import reverse -from django.db.models import Q -from django.http.response import HttpResponse from django.utils.functional import lazy -from django.db import models -from migdal.models import Entry -from piston.handler import AnonymousBaseHandler, BaseHandler -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 -from paypal.rest import user_is_subscribed -from picture.models import Picture -from picture.forms import PictureImportForm -from social.utils import likes +from catalogue.models import Book, Tag -from stats.utils import piwik_track -from wolnelektury.utils import re_escape - -API_BASE = WL_BASE = MEDIA_BASE = lazy( +WL_BASE = lazy( lambda: u'https://' + Site.objects.get_current().domain, unicode)() category_singular = { @@ -40,14 +18,6 @@ category_singular = { 'themes': 'theme', 'books': 'book', } -category_plural = {} -for k, v in category_singular.items(): - category_plural[v] = k - -book_tag_categories = ['author', 'epoch', 'kind', 'genre'] - -book_list_fields = book_tag_categories + [ - 'href', 'title', 'url', 'cover', 'cover_thumb', 'slug', 'simple_thumb', 'has_audio', 'cover_color', 'full_sort_key'] def read_tags(tags, request, allowed): @@ -96,407 +66,3 @@ def read_tags(tags, request, allowed): for slug in request.GET.getlist(key): process(category, slug) return real_tags, books - - -# RESTful handlers - - - - - -class BookDetails(object): - """Custom fields used for representing Books.""" - - @classmethod - def href(cls, book): - """ Returns an URI for a Book in the API. """ - return API_BASE + reverse("catalogue_api_book", args=[book.slug]) - - @classmethod - def url(cls, book): - """ Returns Book's URL on the site. """ - return WL_BASE + book.get_absolute_url() - - @classmethod - def children(cls, book): - """ Returns all children for a book. """ - return book.children.order_by('parent_number', 'sort_key') - - @classmethod - def media(cls, book): - """ Returns all media for a book. """ - return book.media.all() - - @classmethod - def cover(cls, book): - return MEDIA_BASE + book.cover.url if book.cover else '' - - @classmethod - def cover_thumb(cls, book): - return MEDIA_BASE + default.backend.get_thumbnail( - book.cover, "139x193").url if book.cover else '' - - @classmethod - def simple_thumb(cls, book): - return MEDIA_BASE + book.cover_api_thumb.url if book.cover_api_thumb else '' - - @classmethod - def simple_cover(cls, book): - return MEDIA_BASE + book.simple_cover.url if book.simple_cover else '' - - -class BookDetailHandler(BaseHandler, BookDetails): - """ Main handler for Book objects. - - Responsible for single Book details. - """ - allowed_methods = ['GET'] - fields = ['title', 'parent', 'children'] + Book.formats + [ - 'media', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'simple_cover', 'fragment_data', 'audio_length', - 'preview', 'cover_color'] + [ - category_plural[c] for c in book_tag_categories] - - @piwik_track - def read(self, request, book): - """ Returns details of a book, identified by a slug and lang. """ - try: - return Book.objects.get(slug=book) - except Book.DoesNotExist: - return rc.NOT_FOUND - - -class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails): - """ Main handler for Book objects. - - Responsible for lists of Book objects. - """ - allowed_methods = ('GET',) - model = Book - fields = book_list_fields - - # FIXME: Unused? - @classmethod - def genres(cls, book): - """ Returns all media for a book. """ - return book.tags.filter(category='genre') - - @piwik_track - def read(self, request, tags=None, top_level=False, audiobooks=False, daisy=False, pk=None, - recommended=False, newest=False, books=None, - after=None, count=None): - """ Lists all books with given tags. - - :param tags: filtering tags; should be a path of categories - and slugs, i.e.: authors/an-author/epoch/an-epoch/ - :param top_level: if True and a book is included in the results, - it's children are aren't. By default all books matching the tags - are returned. - """ - if pk is not None: - # FIXME: Unused? - try: - return Book.objects.get(pk=pk) - except Book.DoesNotExist: - return rc.NOT_FOUND - - try: - tags, _ancestors = read_tags(tags, request, allowed=book_tag_categories) - except ValueError: - return rc.NOT_FOUND - - new_api = request.GET.get('new_api') - if 'after' in request.GET: - after = request.GET['after'] - if 'count' in request.GET: - count = request.GET['count'] - - if tags: - if top_level: - books = Book.tagged_top_level(tags) - return books if books else rc.NOT_FOUND - else: - books = Book.tagged.with_all(tags) - else: - books = books if books is not None else Book.objects.all() - books = order_books(books, new_api) - - if top_level: - books = books.filter(parent=None) - if audiobooks: - books = books.filter(media__type='mp3').distinct() - if daisy: - books = books.filter(media__type='daisy').distinct() - if recommended: - books = books.filter(recommended=True) - if newest: - books = books.order_by('-created_at') - - if after: - books = books_after(books, after, new_api) - - if new_api: - books = books.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key', 'sort_key_author') - else: - books = books.only('slug', 'title', 'cover', 'cover_thumb') - for category in book_tag_categories: - books = prefetch_relations(books, category) - - if count: - books = books[:count] - - return books - - def create(self, request, *args, **kwargs): - return rc.FORBIDDEN - - -class BooksHandler(BookDetailHandler): - allowed_methods = ('GET', 'POST') - model = Book - fields = book_list_fields + ['liked'] - anonymous = AnonymousBooksHandler - - # hack, because piston is stupid - @classmethod - def liked(cls, book): - return getattr(book, 'liked', None) - - def read(self, request, **kwargs): - books = AnonymousBooksHandler().read(request, **kwargs) - likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True)) - - new_books = [ - BookProxy(book).set('liked', book.id in likes) - for book in books] - return QuerySetProxy(new_books) - - def create(self, request, *args, **kwargs): - if not request.user.has_perm('catalogue.add_book'): - return rc.FORBIDDEN - - data = json.loads(request.POST.get('data')) - form = BookImportForm(data) - if form.is_valid(): - form.save() - return rc.CREATED - else: - return rc.NOT_FOUND - - -class EBooksHandler(AnonymousBooksHandler): - fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',) - - -class BookProxy(models.Model): - class Meta: - managed = False - - def __init__(self, book, key=None): - self.book = book - self.key = key - - def set(self, attr, value): - self.__setattr__(attr, value) - return self - - def __getattr__(self, item): - return self.book.__getattribute__(item) - - -class QuerySetProxy(models.QuerySet): - def __init__(self, l): - self.list = l - - def __iter__(self): - return iter(self.list) - - -class AnonFilterBooksHandler(AnonymousBooksHandler): - fields = book_list_fields + ['key'] - - def parse_bool(self, s): - if s in ('true', 'false'): - return s == 'true' - else: - return None - - def read(self, request): - key_sep = '$' - search_string = request.GET.get('search') - is_lektura = self.parse_bool(request.GET.get('lektura')) - is_audiobook = self.parse_bool(request.GET.get('audiobook')) - preview = self.parse_bool(request.GET.get('preview')) - - new_api = request.GET.get('new_api') - after = request.GET.get('after') - count = int(request.GET.get('count', 50)) - 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: - if is_audiobook: - books = books.filter(media__type='mp3') - else: - books = books.exclude(media__type='mp3') - if preview is not None: - books = books.filter(preview=preview) - for key in request.GET: - if key in category_singular: - category = category_singular[key] - if category in book_tag_categories: - slugs = request.GET[key].split(',') - tags = Tag.objects.filter(category=category, slug__in=slugs) - books = Book.tagged.with_any(tags, books) - if (search_string is not None) and len(search_string) < 3: - search_string = None - if search_string: - search_string = re_escape(search_string) - books_author = books.filter(cached_author__iregex='\m' + search_string) - books_title = books.filter(title__iregex='\m' + search_string) - books_title = books_title.exclude(id__in=list(books_author.values_list('id', flat=True))) - if after and (key_sep in after): - which, key = after.split(key_sep, 1) - if which == 'title': - book_lists = [(books_after(books_title, key, new_api), 'title')] - else: # which == 'author' - 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 = books_after(books, key, new_api) - book_lists = [(books, 'book')] - - filtered_books = [] - for book_list, label in book_lists: - book_list = book_list.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key_author', 'sort_key') - for category in book_tag_categories: - book_list = prefetch_relations(book_list, category) - remaining_count = count - len(filtered_books) - new_books = [ - BookProxy(book, '%s%s%s' % ( - 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: - break - - return QuerySetProxy(filtered_books) - - -class FilterBooksHandler(BooksHandler): - anonymous = AnonFilterBooksHandler - fields = book_list_fields + ['key', 'liked'] - - # hack, because piston is stupid - @classmethod - def liked(cls, book): - return getattr(book, 'liked', None) - - def read(self, request): - qsp = AnonFilterBooksHandler().read(request) - likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True)) - for book in qsp.list: - book.set('liked', book.id in likes) - return qsp - - -# add categorized tags fields for Book -def _tags_getter(category): - @classmethod - def get_tags(cls, book): - return book.tags.filter(category=category) - return get_tags - - -def _tag_getter(category): - @classmethod - def get_tag(cls, book): - return book.tag_unicode(category) - return get_tag - - -def add_tag_getters(): - for plural, singular in category_singular.items(): - setattr(BookDetails, plural, _tags_getter(singular)) - setattr(BookDetails, singular, _tag_getter(singular)) - - -add_tag_getters() - - -# add fields for files in Book -def _file_getter(book_format): - - @staticmethod - def get_file(book): - f_url = book.media_url(book_format) - if f_url: - return MEDIA_BASE + f_url - else: - return '' - return get_file - - -def add_file_getters(): - for book_format in Book.formats: - setattr(BookDetails, book_format, _file_getter(book_format)) - - -add_file_getters() - - -class PictureHandler(BaseHandler): - model = Picture - fields = ('slug', 'title') - allowed_methods = ('POST',) - - def create(self, request): - if not request.user.has_perm('picture.add_picture'): - return rc.FORBIDDEN - - data = json.loads(request.POST.get('data')) - form = PictureImportForm(data) - if form.is_valid(): - form.save() - return rc.CREATED - else: - return rc.NOT_FOUND - - -class BlogEntryHandler(BaseHandler): - model = Entry - fields = ( - 'title', 'lead', 'body', 'place', 'time', 'image_url', 'image_thumb', 'gallery_urls', 'type', 'key', 'url') - - def read(self, request): - after = request.GET.get('after') - count = int(request.GET.get('count', 20)) - entries = Entry.published_objects.filter(in_stream=True).order_by('-first_published_at') - if after: - entries = entries.filter(first_published_at__lt=after) - if count: - entries = entries[:count] - return entries - - @classmethod - def image_url(cls, entry): - return (WL_BASE + entry.image.url) if entry.image else None - - @classmethod - def image_thumb(cls, entry): - return MEDIA_BASE + default.backend.get_thumbnail( - entry.image, "193x193").url if entry.image else '' - - @classmethod - def gallery_urls(cls, entry): - return [WL_BASE + photo.url() for photo in entry.photo_set.all()] - - @classmethod - def key(cls, entry): - return entry.first_published_at - - @classmethod - def url(cls, entry): - return WL_BASE + entry.get_absolute_url() diff --git a/src/api/helpers.py b/src/api/helpers.py deleted file mode 100644 index ffe92846f..000000000 --- a/src/api/helpers.py +++ /dev/null @@ -1,12 +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. -# -from piston.resource import Resource - - -class CsrfExemptResource(Resource): - """A Custom Resource that is csrf exempt""" - def __init__(self, handler, authentication=None): - super(CsrfExemptResource, self).__init__(handler, authentication) - self.csrf_exempt = getattr(self.handler, 'csrf_exempt', True) diff --git a/src/api/serializers.py b/src/api/serializers.py index a876387e9..f2c22d4d4 100644 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -1,7 +1,18 @@ +# -*- 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.contrib.auth.models import User from rest_framework import serializers -from .fields import UserPremiumField +from .fields import UserPremiumField, AbsoluteURLField, ThumbnailField from .models import BookUserData +from migdal.models import Entry, Photo + + +class PlainSerializer(serializers.ModelSerializer): + def to_representation(self, value): + value = super(PlainSerializer, self).to_representation(value) + return value.values()[0] class UserSerializer(serializers.ModelSerializer): @@ -16,3 +27,24 @@ class BookUserDataSerializer(serializers.ModelSerializer): class Meta: model = BookUserData fields = ['state'] + + +class BlogGalleryUrlSerializer(PlainSerializer): + class Meta: + model = Photo + fields = ['image'] + + +class BlogSerializer(serializers.ModelSerializer): + url = AbsoluteURLField() + image_url = serializers.FileField(source='image') + image_thumb = ThumbnailField('193x193', source='image') + key = serializers.DateTimeField(source='first_published_at') + gallery_urls = BlogGalleryUrlSerializer(many=True, source='photo_set') + body = serializers.CharField() + lead = serializers.CharField() + + class Meta: + model = Entry + fields = ['title', 'lead', 'body', 'place', 'time', 'image_url', 'image_thumb', + 'gallery_urls', 'type', 'key', 'url'] diff --git a/src/api/tests/res/responses/ebooks.json b/src/api/tests/res/responses/ebooks.json index 9c4659a1b..719faac17 100644 --- a/src/api/tests/res/responses/ebooks.json +++ b/src/api/tests/res/responses/ebooks.json @@ -5,34 +5,34 @@ "title": "Child", "author": "", "cover": "", - "href": "https://example.com/api/books/child/", + "href": "http://testserver/api/books/child/", "pdf": "", "txt": "", "slug": "child", "epub": "" }, { - "fb2": "https://example.com/katalog/pobierz/grandchild.fb2", - "mobi": "https://example.com/katalog/pobierz/grandchild.mobi", + "fb2": "http://testserver/katalog/pobierz/grandchild.fb2", + "mobi": "http://testserver/katalog/pobierz/grandchild.mobi", "title": "Grandchild", "author": "", "cover": "", - "href": "https://example.com/api/books/grandchild/", - "pdf": "https://example.com/katalog/pobierz/grandchild.pdf", - "txt": "https://example.com/katalog/pobierz/grandchild.txt", + "href": "http://testserver/api/books/grandchild/", + "pdf": "http://testserver/katalog/pobierz/grandchild.pdf", + "txt": "http://testserver/katalog/pobierz/grandchild.txt", "slug": "grandchild", - "epub": "https://example.com/katalog/pobierz/grandchild.epub" + "epub": "http://testserver/katalog/pobierz/grandchild.epub" }, { "fb2": "", - "mobi": "https://example.com/media/mobi/parent.mobi", + "mobi": "http://testserver/media/mobi/parent.mobi", "title": "Parent", "author": "John Doe", "cover": "cover/parent.jpg", - "href": "https://example.com/api/books/parent/", - "pdf": "https://example.com/media/pdf/parent.pdf", + "href": "http://testserver/api/books/parent/", + "pdf": "http://testserver/media/pdf/parent.pdf", "txt": "", "slug": "parent", - "epub": "https://example.com/media/epub/parent.epub" + "epub": "http://testserver/media/epub/parent.epub" } ] diff --git a/src/api/tests/res/responses/filter-books.json b/src/api/tests/res/responses/filter-books.json index 60ff4d3a7..49f3dea18 100644 --- a/src/api/tests/res/responses/filter-books.json +++ b/src/api/tests/res/responses/filter-books.json @@ -4,12 +4,13 @@ "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": "", + "cover": "", + "liked": null, "epoch": "", - "href": "https://example.com/api/books/child/", + "href": "http://testserver/api/books/child/", "key": "book$child", "genre": "Wiersz", "simple_thumb": "", @@ -21,12 +22,13 @@ "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": "", + "cover": "", + "liked": null, "epoch": "", - "href": "https://example.com/api/books/grandchild/", + "href": "http://testserver/api/books/grandchild/", "key": "book$grandchild", "genre": "Sonet", "simple_thumb": "", @@ -38,15 +40,16 @@ "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", + "cover": "cover/parent.jpg", + "liked": null, "epoch": "Barok", - "href": "https://example.com/api/books/parent/", + "href": "http://testserver/api/books/parent/", "key": "book$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/tests.py b/src/api/tests/tests.py index 6be34ed6b..38f1882bb 100644 --- a/src/api/tests/tests.py +++ b/src/api/tests/tests.py @@ -327,7 +327,7 @@ class AuthorizedTests(ApiTest): cls.consumer.delete() super(AuthorizedTests, cls).tearDownClass() - def signed(self, url, method='GET', params=None): + def signed(self, url, method='GET', params=None, data=None): auth_params = { "oauth_consumer_key": self.consumer.key, "oauth_nonce": "%f" % time(), @@ -340,12 +340,14 @@ class AuthorizedTests(ApiTest): sign_params = {} if params: sign_params.update(params) + if data: + sign_params.update(data) sign_params.update(auth_params) raw = "&".join([ method.upper(), quote('http://testserver' + url, safe=''), quote("&".join( - quote(str(k)) + "=" + quote(str(v)) + quote(str(k), safe='') + "=" + quote(str(v), safe='') for (k, v) in sorted(sign_params.items()))) ]) auth_params["oauth_signature"] = quote(b64encode(hmac.new( @@ -357,11 +359,12 @@ class AuthorizedTests(ApiTest): url = url + '?' + urlencode(params) return getattr(self.client, method.lower())( url, + data=data, HTTP_AUTHORIZATION=auth ) - def signed_json(self, url, method='GET', params=None): - return json.loads(self.signed(url, method, params).content) + def signed_json(self, url, method='GET', params=None, data=None): + return json.loads(self.signed(url, method, params, data).content) def test_books(self): self.assertEqual( @@ -435,3 +438,39 @@ class AuthorizedTests(ApiTest): self.assertEqual( self.signed('/api/epub/grandchild/').content, "") + + def test_publish(self): + response = self.signed('/api/books/', + method='POST', + data={"data": json.dumps({})}) + self.assertEqual(response.status_code, 403) + + response = self.signed('/api/pictures/', + method='POST', + data={"data": json.dumps({})}) + self.assertEqual(response.status_code, 403) + + self.user.is_superuser = True + self.user.save() + + with patch('catalogue.models.Book.from_xml_file') as mock: + response = self.signed('/api/books/', + method='POST', + data={"data": json.dumps({ + "book_xml": "" + })}) + self.assertTrue(mock.called) + self.assertEqual(response.status_code, 201) + + with patch('picture.models.Picture.from_xml_file') as mock: + response = self.signed('/api/pictures/', + method='POST', + data={"data": json.dumps({ + "picture_xml": "", + "picture_image_data": "Kg==", + })}) + self.assertTrue(mock.called) + self.assertEqual(response.status_code, 201) + + self.user.is_superuser = False + self.user.save() diff --git a/src/api/urls.py b/src/api/urls.py index d93bcce00..973bf030a 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -5,53 +5,12 @@ 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 -from piston.resource import Resource +from piston.authentication import oauth_access_token, oauth_request_token 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") - - -class DjangoAuthentication(object): - """ - Authentication handler that always returns - True, so no authentication is needed, nor - initiated (`challenge` is missing.) - """ - def is_authenticated(self, request): - return request.user.is_authenticated() - - def challenge(self): - from django.http import HttpResponse - resp = HttpResponse("Authorization Required") - resp.status_code = 401 - return resp - - -def auth_resource(handler): - from django.conf import settings - if settings.DEBUG: - django_auth = DjangoAuthentication() - return CsrfExemptResource(handler=handler, authentication=django_auth) - return CsrfExemptResource(handler=handler, authentication=auth) - - -book_list_resource = auth_resource(handler=handlers.BooksHandler) -ebook_list_resource = Resource(handler=handlers.EBooksHandler) -filter_book_resource = auth_resource(handler=handlers.FilterBooksHandler) - -picture_resource = auth_resource(handler=handlers.PictureHandler) - -blog_resource = Resource(handler=handlers.BlogEntryHandler) - - -tags_re = r'^(?P(?:(?:[a-z0-9-]+/){2}){0,6})' -paginate_re = r'(?:after/(?P[a-z0-9-]+)/)?(?:count/(?P[0-9]+)/)?$' - urlpatterns = [ url(r'^oauth/request_token/$', oauth_request_token), @@ -69,18 +28,9 @@ urlpatterns = [ url(r'^reading/(?P[a-z0-9-]+)/(?P[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'ebooks/' + paginate_re, - ebook_list_resource, name='api_ebook_list'), - url(tags_re + r'parent_ebooks/' + paginate_re, - ebook_list_resource, {"top_level": True}, name='api_parent_ebook_list'), - - url(r'^filter-books/$', filter_book_resource, name='api_filter_books'), - - url(r'^pictures/$', picture_resource), - - url(r'^blog/$', blog_resource), + url(r'^blog/$', views.BlogView.as_view()), + url(r'^pictures/$', include('picture.api.urls')), url(r'^', include('social.api.urls')), url(r'^', include('catalogue.api.urls')), ] diff --git a/src/api/views.py b/src/api/views.py index 812be832c..377beb681 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -1,8 +1,13 @@ +# -*- 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.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 rest_framework.generics import ListAPIView, RetrieveAPIView, get_object_or_404 +from migdal.models import Entry from catalogue.models import Book from .models import BookUserData from . import serializers @@ -39,3 +44,17 @@ class BookUserDataView(RetrieveAPIView): instance = BookUserData.update(book, request.user, state) serializer = self.get_serializer(instance) return Response(serializer.data) + + +class BlogView(ListAPIView): + serializer_class = serializers.BlogSerializer + + def get_queryset(self): + after = self.request.query_params.get('after') + count = int(self.request.query_params.get('count', 20)) + entries = Entry.published_objects.filter(in_stream=True).order_by('-first_published_at') + if after: + entries = entries.filter(first_published_at__lt=after) + if count: + entries = entries[:count] + return entries diff --git a/src/catalogue/api/fields.py b/src/catalogue/api/fields.py index 145780e2b..cab947592 100644 --- a/src/catalogue/api/fields.py +++ b/src/catalogue/api/fields.py @@ -1,5 +1,8 @@ +# -*- 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 rest_framework import serializers -from sorl.thumbnail import default from catalogue.models import Book @@ -16,15 +19,3 @@ class BookLiked(serializers.ReadOnlyField): 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/serializers.py b/src/catalogue/api/serializers.py index ea48cdba4..a5069e3bd 100644 --- a/src/catalogue/api/serializers.py +++ b/src/catalogue/api/serializers.py @@ -1,7 +1,11 @@ +# -*- 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 rest_framework import serializers -from api.fields import AbsoluteURLField, LegacyMixin +from api.fields import AbsoluteURLField, LegacyMixin, ThumbnailField from catalogue.models import Book, Collection, Tag, BookMedia, Fragment -from .fields import BookLiked, ThumbnailField +from .fields import BookLiked class TagSerializer(serializers.ModelSerializer): @@ -34,7 +38,6 @@ 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() - cover = serializers.FileField() cover_thumb = ThumbnailField('139x193', source='cover') class Meta: @@ -55,6 +58,15 @@ class BookListSerializer(BookSerializer): Meta = BookSerializer.Meta +class FilterBookListSerializer(BookListSerializer): + key = serializers.CharField() + + class Meta: + model = Book + fields = BookListSerializer.Meta.fields + ['key'] + legacy_non_null_fields = BookListSerializer.Meta.legacy_non_null_fields + + class MediaSerializer(LegacyMixin, serializers.ModelSerializer): url = serializers.FileField(source='file') @@ -107,7 +119,13 @@ class BookPreviewSerializer(BookDetailSerializer): legacy_non_null_fields = BookDetailSerializer.Meta.legacy_non_null_fields -class EbookSerializer(BookSerializer): +class EbookSerializer(BookListSerializer): + 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') + class Meta: model = Book fields = ['author', 'href', 'title', 'cover', 'slug'] + Book.ebook_formats diff --git a/src/catalogue/api/urls.py b/src/catalogue/api/urls.py index 13cebead5..07729aea8 100644 --- a/src/catalogue/api/urls.py +++ b/src/catalogue/api/urls.py @@ -33,6 +33,16 @@ urlpatterns = [ url(r'^books/(?P[^/]+)/$', views.BookDetail.as_view(), name='catalogue_api_book'), + url(tags_re + r'ebooks/' + paginate_re, + views.EbookList.as_view(), + name='catalogue_api_ebook_list'), + url(tags_re + r'parent_ebooks/' + paginate_re, + views.EbookList.as_view(), + {"top_level": True}, + name='catalogue_api_parent_ebook_list'), + + url(r'^filter-books/$', views.FilterBookList.as_view(), name='catalogue_api_filter_books'), + url(r'^epub/(?P[a-z0-9-]+)/$', views.EpubView.as_view(), name='catalogue_api_epub'), url(r'^preview/$', views.Preview.as_view(), name='catalogue_api_preview'), diff --git a/src/catalogue/api/views.py b/src/catalogue/api/views.py index f3b719309..ee345ba68 100644 --- a/src/catalogue/api/views.py +++ b/src/catalogue/api/views.py @@ -1,13 +1,24 @@ +# -*- 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. +# +import json 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.response import Response 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.forms import BookImportForm from catalogue.models import Book, Collection, Tag, Fragment from catalogue.models.tag import prefetch_relations +from wolnelektury.utils import re_escape + + +book_tag_categories = ['author', 'epoch', 'kind', 'genre'] class CollectionList(ListAPIView): @@ -75,7 +86,7 @@ class BookList(ListAPIView): return books - def post(self, request): + def post(self, request, **kwargs): # Permission needed. data = json.loads(request.POST.get('data')) form = BookImportForm(data) @@ -92,11 +103,86 @@ class BookDetail(RetrieveAPIView): serializer_class = serializers.BookDetailSerializer +class EbookList(BookList): + serializer_class = serializers.EbookSerializer + + class Preview(ListAPIView): queryset = Book.objects.filter(preview=True) serializer_class = serializers.BookPreviewSerializer +class FilterBookList(ListAPIView): + serializer_class = serializers.FilterBookListSerializer + + def parse_bool(self, s): + if s in ('true', 'false'): + return s == 'true' + else: + return None + + def get_queryset(self): + key_sep = '$' + search_string = self.request.query_params.get('search') + is_lektura = self.parse_bool(self.request.query_params.get('lektura')) + is_audiobook = self.parse_bool(self.request.query_params.get('audiobook')) + preview = self.parse_bool(self.request.query_params.get('preview')) + + 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)) + 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: + if is_audiobook: + books = books.filter(media__type='mp3') + else: + books = books.exclude(media__type='mp3') + if preview is not None: + books = books.filter(preview=preview) + for category in book_tag_categories: + category_plural = category + 's' + if category_plural in self.request.query_params: + slugs = self.request.query_params[category_plural].split(',') + tags = Tag.objects.filter(category=category, slug__in=slugs) + books = Book.tagged.with_any(tags, books) + if (search_string is not None) and len(search_string) < 3: + search_string = None + if search_string: + search_string = re_escape(search_string) + books_author = books.filter(cached_author__iregex=r'\m' + search_string) + books_title = books.filter(title__iregex=r'\m' + search_string) + books_title = books_title.exclude(id__in=list(books_author.values_list('id', flat=True))) + if after and (key_sep in after): + which, key = after.split(key_sep, 1) + if which == 'title': + book_lists = [(books_after(books_title, key, new_api), 'title')] + else: # which == 'author' + 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 = books_after(books, key, new_api) + book_lists = [(books, 'book')] + + filtered_books = [] + for book_list, label in book_lists: + for category in book_tag_categories: + book_list = prefetch_relations(book_list, category) + remaining_count = count - len(filtered_books) + for book in book_list[:remaining_count]: + book.key = '%s%s%s' % ( + label, key_sep, book.slug if not new_api else book.full_sort_key()) + filtered_books.append(book) + if len(filtered_books) == count: + break + + return filtered_books + + class EpubView(RetrieveAPIView): queryset = Book.objects.all() lookup_field = 'slug' diff --git a/src/picture/api/__init__.py b/src/picture/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/picture/api/urls.py b/src/picture/api/urls.py new file mode 100644 index 000000000..e8dc14618 --- /dev/null +++ b/src/picture/api/urls.py @@ -0,0 +1,11 @@ +# -*- 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 url +from . import views + + +urlpatterns = [ + url(r'^$', views.PicturesView.as_view()), +] diff --git a/src/picture/api/views.py b/src/picture/api/views.py new file mode 100644 index 000000000..fddc86c17 --- /dev/null +++ b/src/picture/api/views.py @@ -0,0 +1,26 @@ +# -*- 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. +# +import json +from django.http import Http404 +from rest_framework.permissions import DjangoModelPermissions +from rest_framework.response import Response +from rest_framework import status +from rest_framework.views import APIView +from picture.forms import PictureImportForm +from picture.models import Picture + + +class PicturesView(APIView): + permission_classes = [DjangoModelPermissions] + queryset = Picture.objects.none() # Required for DjangoModelPermissions + + def post(self, request): + data = json.loads(request.POST.get('data')) + form = PictureImportForm(data) + if form.is_valid(): + form.save() + return Response({}, status=status.HTTP_201_CREATED) + else: + raise Http404 -- 2.20.1