From: Radek Czajka Date: Mon, 4 Feb 2019 21:17:08 +0000 (+0100) Subject: Merge commit 'afb3cc28' X-Git-Url: https://git.mdrn.pl/wolnelektury.git/commitdiff_plain/540e76dd8e04d7bab431da696565e826788bd218?hp=afb3cc283074103e1137e79589d15390c825ecf1 Merge commit 'afb3cc28' --- diff --git a/.gitignore b/.gitignore index a1c1ad15a..cf605b2b4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,10 @@ thumbs.db # PyCharm .idea +# Emacs +\#*\# +.\#* + # Tags file TAGS diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 3037f5cd2..3b8c95e42 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -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 index 000000000..26018c61e --- /dev/null +++ b/src/api/drf_auth.py @@ -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 index 50a2d4101..000000000 --- a/src/api/emitters.py +++ /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 index 000000000..1ce83cc3e --- /dev/null +++ b/src/api/fields.py @@ -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) diff --git a/src/api/handlers.py b/src/api/handlers.py index eb18e0502..7178b9f9e 100644 --- a/src/api/handlers.py +++ b/src/api/handlers.py @@ -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 = ( diff --git a/src/api/models.py b/src/api/models.py index 2481010be..d2716b922 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -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 index 000000000..b5b63c6fd --- /dev/null +++ b/src/api/renderers.py @@ -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 index 000000000..a876387e9 --- /dev/null +++ b/src/api/serializers.py @@ -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'] diff --git a/src/api/templates/api/main.html b/src/api/templates/api/main.html index 67ff426fe..b7e7b8f95 100755 --- a/src/api/templates/api/main.html +++ b/src/api/templates/api/main.html @@ -36,30 +36,30 @@

- {% 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.: diff --git a/src/api/tests/res/responses/books-child.json b/src/api/tests/res/responses/books-child.json index 4c5c13450..27aaee713 100644 --- a/src/api/tests/res/responses/books-child.json +++ b/src/api/tests/res/responses/books-child.json @@ -10,25 +10,25 @@ "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" } @@ -43,18 +43,18 @@ "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": "" diff --git a/src/api/tests/res/responses/books-grandchild.json b/src/api/tests/res/responses/books-grandchild.json index faf8c783e..b7dd9daaf 100644 --- a/src/api/tests/res/responses/books-grandchild.json +++ b/src/api/tests/res/responses/books-grandchild.json @@ -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, @@ -42,11 +42,11 @@ "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": "" } diff --git a/src/api/tests/res/responses/books-parent.json b/src/api/tests/res/responses/books-parent.json index ade38cc8f..6dc75a5bd 100644 --- a/src/api/tests/res/responses/books-parent.json +++ b/src/api/tests/res/responses/books-parent.json @@ -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" } @@ -14,25 +14,25 @@ "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" } @@ -40,14 +40,14 @@ "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", @@ -59,29 +59,29 @@ "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" } diff --git a/src/api/tests/res/responses/books.json b/src/api/tests/res/responses/books.json index 80414b5e1..cfd5ee3dd 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,14 +36,14 @@ "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 index 000000000..967f8e2ed --- /dev/null +++ b/src/api/tests/res/responses/books.xml @@ -0,0 +1,3 @@ + +$child$2Childhttps://example.com/katalog/lektura/child/Falsechildhttps://example.com/api/books/child/Wiersz#000000$grandchild$3Grandchildhttps://example.com/katalog/lektura/grandchild/Falsegrandchildhttps://example.com/api/books/grandchild/Sonet#000000Lirykajohn doe$parent$1Parenthttps://example.com/katalog/lektura/parent/TrueJohn Doecover/parent.jpgparentBarokhttps://example.com/api/books/parent/Sonethttps://example.com/media/cover_api_thumb/parent.jpg#a6820ahttps://example.com/media/cover/parent.jpg-139x193 + diff --git a/src/api/tests/res/responses/collection.json b/src/api/tests/res/responses/collection.json index 866abedbd..299246112 100644 --- a/src/api/tests/res/responses/collection.json +++ b/src/api/tests/res/responses/collection.json @@ -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", diff --git a/src/api/tests/res/responses/collections.json b/src/api/tests/res/responses/collections.json index a0852cdb0..d76ff2e90 100644 --- a/src/api/tests/res/responses/collections.json +++ b/src/api/tests/res/responses/collections.json @@ -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" } ] diff --git a/src/api/tests/res/responses/ebooks.json b/src/api/tests/res/responses/ebooks.json index 171c153a9..9c4659a1b 100644 --- a/src/api/tests/res/responses/ebooks.json +++ b/src/api/tests/res/responses/ebooks.json @@ -12,16 +12,16 @@ "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": "", diff --git a/src/api/tests/res/responses/fragment.json b/src/api/tests/res/responses/fragment.json index 40db3f4f3..e579e506c 100644 --- a/src/api/tests/res/responses/fragment.json +++ b/src/api/tests/res/responses/fragment.json @@ -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" } diff --git a/src/api/tests/res/responses/fragments.json b/src/api/tests/res/responses/fragments.json index 1733ba171..e464557a4 100644 --- a/src/api/tests/res/responses/fragments.json +++ b/src/api/tests/res/responses/fragments.json @@ -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/" } ] diff --git a/src/api/tests/res/responses/tags.json b/src/api/tests/res/responses/tags.json index 4729bebab..0232c9089 100644 --- a/src/api/tests/res/responses/tags.json +++ b/src/api/tests/res/responses/tags.json @@ -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" } diff --git a/src/api/tests/tests.py b/src/api/tests/tests.py index 5486b5f53..6be34ed6b 100644 --- a/src/api/tests/tests.py +++ b/src/api/tests/tests.py @@ -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("")): + self.assertEqual( + self.signed('/api/epub/grandchild/').content, + "") diff --git a/src/api/urls.py b/src/api/urls.py index bd832fe25..d93bcce00 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -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\d*?)/info\.html$', catalogue.views.book_info), url(r'tag/(?P\d*?)/info\.html$', catalogue.views.tag_info), - # books by collections - url(r'^collections/$', collection_list_resource, name="api_collections"), - url(r'^collections/(?P[^/]+)/$', collection_resource, name="api_collection"), - - # epub preview - url(r'^epub/(?P[a-z0-9-]+)/$', epub_resource, name='api_epub'), - # reading data - url(r'^reading/(?P[a-z0-9-]+)/$', reading_resource, name='api_reading'), - url(r'^reading/(?P[a-z0-9-]+)/(?P[a-z]+)/$', reading_resource, name='api_reading'), - url(r'^shelf/(?P[a-z]+)/$', shelf_resource, name='api_shelf'), - url(r'^username/$', reading_resource, name='api_username'), - - url(r'^like/(?P[a-z0-9-]+)/$', like_resource, name='api_like'), - - # objects details - url(r'^books/(?P[a-z0-9-]+)/$', book_resource, name="api_book"), - url(r'^(?P[a-z0-9-]+)/(?P[a-z0-9-]+)/$', - tag_resource, name="api_tag"), - url(r'^books/(?P[a-z0-9-]+)/fragments/(?P[a-z0-9-]+)/$', - fragment_resource, name="api_fragment"), + url(r'^reading/(?P[a-z0-9-]+)/$', views.BookUserDataView.as_view(), name='api_reading'), + 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'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(?:(?:[a-z0-9-]+/){2}){1,6})fragments/$', fragment_list_resource), - - # tags by category - url(r'^(?P[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 index 000000000..812be832c --- /dev/null +++ b/src/api/views.py @@ -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 index 000000000..e69de29bb diff --git a/src/catalogue/api/fields.py b/src/catalogue/api/fields.py new file mode 100644 index 000000000..145780e2b --- /dev/null +++ b/src/catalogue/api/fields.py @@ -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 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 new file mode 100644 index 000000000..e8f04ca70 --- /dev/null +++ b/src/catalogue/api/serializers.py @@ -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 index 000000000..13cebead5 --- /dev/null +++ b/src/catalogue/api/urls.py @@ -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(?:(?:[a-z0-9-]+/){2}){0,6})' +paginate_re = r'(?:after/(?P[a-z0-9-]+)/)?(?:count/(?P[0-9]+)/)?$' + + +urlpatterns = [ + # books by collections + url(r'^collections/$', views.CollectionList.as_view(), name="api_collections"), + url(r'^collections/(?P[^/]+)/$', + 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[^/]+)/$', views.BookDetail.as_view(), name='catalogue_api_book'), + + 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'), + + url(r'^(?P(?:(?:[a-z0-9-]+/){2}){1,6})fragments/$', views.FragmentList.as_view()), + url(r'^books/(?P[a-z0-9-]+)/fragments/(?P[a-z0-9-]+)/$', + views.FragmentView.as_view(), name="catalogue_api_fragment"), + + url(r'^(?P[a-z]+)s/$', views.TagCategoryView.as_view(), name='catalogue_api_tag_list'), + url(r'^(?P[a-z]+)s/(?P[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 index 000000000..e21aab707 --- /dev/null +++ b/src/catalogue/api/views.py @@ -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'] + ) diff --git a/src/catalogue/fixtures/test-books.yaml b/src/catalogue/fixtures/test-books.yaml index 4c607758a..42eefd761 100644 --- a/src/catalogue/fixtures/test-books.yaml +++ b/src/catalogue/fixtures/test-books.yaml @@ -1,3 +1,4 @@ + - model: catalogue.book pk: 1 fields: @@ -31,11 +32,16 @@ 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" @@ -134,7 +140,7 @@ uploaded_at: "1970-01-03 0:0Z" - model: catalogue.fragment - id: 1 + pk: 1 fields: short_text: "Fragment" text: "A fragment" diff --git a/src/catalogue/models/book.py b/src/catalogue/models/book.py index 6e6c50f8f..b2289f667 100644 --- a/src/catalogue/models/book.py +++ b/src/catalogue/models/book.py @@ -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: diff --git a/src/catalogue/models/bookmedia.py b/src/catalogue/models/bookmedia.py index 377dbc43c..407c41969 100644 --- a/src/catalogue/models/bookmedia.py +++ b/src/catalogue/models/bookmedia.py @@ -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) diff --git a/src/catalogue/models/fragment.py b/src/catalogue/models/fragment.py index a3dbdea54..dc159a59f 100644 --- a/src/catalogue/models/fragment.py +++ b/src/catalogue/models/fragment.py @@ -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 diff --git a/src/catalogue/models/tag.py b/src/catalogue/models/tag.py index 7e15636da..c6b6f2663 100644 --- a/src/catalogue/models/tag.py +++ b/src/catalogue/models/tag.py @@ -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 index 000000000..9d0865b0e --- /dev/null +++ b/src/paypal/permissions.py @@ -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 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[a-z0-9-]+)/$', views.LikeView.as_view(), name='social_api_like'), + url(r'^shelf/(?P[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 + diff --git a/src/wolnelektury/settings/apps.py b/src/wolnelektury/settings/apps.py index 87286d56a..c3be29bbe 100644 --- a/src/wolnelektury/settings/apps.py +++ b/src/wolnelektury/settings/apps.py @@ -47,6 +47,7 @@ INSTALLED_APPS_CONTRIB = [ 'django.contrib.admin', 'django.contrib.admindocs', 'django.contrib.staticfiles', + 'rest_framework', 'fnp_django_pagination', 'pipeline', 'piston', diff --git a/src/wolnelektury/settings/contrib.py b/src/wolnelektury/settings/contrib.py index de64990bc..a25ef72d9 100644 --- a/src/wolnelektury/settings/contrib.py +++ b/src/wolnelektury/settings/contrib.py @@ -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', + ) +} diff --git a/src/wolnelektury/settings/test.py b/src/wolnelektury/settings/test.py index 0ad0e6988..25bd82caf 100644 --- a/src/wolnelektury/settings/test.py +++ b/src/wolnelektury/settings/test.py @@ -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/'