From c3e563338050cf161ed6568d01c8a5f010257c2f Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Tue, 5 Feb 2019 09:52:45 +0100
Subject: [PATCH 01/16] Preserve the weirdness of the API.
---
 src/api/tests/res/responses/books.json |  3 +++
 src/api/tests/res/responses/books.xml  |  3 +--
 src/catalogue/api/serializers.py       | 18 ++++++++----------
 src/catalogue/api/views.py             |  4 ++--
 4 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/src/api/tests/res/responses/books.json b/src/api/tests/res/responses/books.json
index cfd5ee3dd..4b8d11eba 100644
--- a/src/api/tests/res/responses/books.json
+++ b/src/api/tests/res/responses/books.json
@@ -8,6 +8,7 @@
         "has_audio": false, 
         "title": "Child", 
         "cover": "", 
+        "liked": null,
         "epoch": "", 
         "href": "http://testserver/api/books/child/",
         "genre": "Wiersz", 
@@ -24,6 +25,7 @@
         "has_audio": false, 
         "title": "Grandchild", 
         "cover": "", 
+        "liked": null,
         "epoch": "", 
         "href": "http://testserver/api/books/grandchild/",
         "genre": "Sonet", 
@@ -40,6 +42,7 @@
         "has_audio": true, 
         "title": "Parent", 
         "cover": "cover/parent.jpg", 
+        "liked": null,
         "epoch": "Barok", 
         "href": "http://testserver/api/books/parent/",
         "genre": "Sonet", 
diff --git a/src/api/tests/res/responses/books.xml b/src/api/tests/res/responses/books.xml
index 967f8e2ed..f39864480 100644
--- a/src/api/tests/res/responses/books.xml
+++ b/src/api/tests/res/responses/books.xml
@@ -1,3 +1,2 @@
 
-$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
-
+$child$2Childhttp://testserver/katalog/lektura/child/#000000http://testserver/api/books/child/FalseWierszchild$grandchild$3Grandchildhttp://testserver/katalog/lektura/grandchild/#000000http://testserver/api/books/grandchild/FalseSonetgrandchildLirykajohn doe$parent$1Parenthttp://testserver/katalog/lektura/parent/#a6820aJohn Doecover/parent.jpgBarokhttp://testserver/api/books/parent/TrueSonethttp://testserver/media/cover_api_thumb/parent.jpgparentcover_thumb/parent.jpg
diff --git a/src/catalogue/api/serializers.py b/src/catalogue/api/serializers.py
index e8f04ca70..ea48cdba4 100644
--- a/src/catalogue/api/serializers.py
+++ b/src/catalogue/api/serializers.py
@@ -24,16 +24,17 @@ class TagDetailSerializer(serializers.ModelSerializer):
         fields = ['name', 'url', 'sort_key', 'description']
 
 
-class BaseBookSerializer(LegacyMixin, serializers.ModelSerializer):
+class BookSerializer(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')
+    liked = BookLiked()
 
     simple_thumb = serializers.FileField(source='cover_api_thumb')
     href = AbsoluteURLField(view_name='catalogue_api_book', view_args=['slug'])
     url = AbsoluteURLField()
-    cover = serializers.CharField()
+    cover = serializers.FileField()
     cover_thumb = ThumbnailField('139x193', source='cover')
 
     class Meta:
@@ -41,20 +42,17 @@ class BaseBookSerializer(LegacyMixin, serializers.ModelSerializer):
         fields = [
             'kind', 'full_sort_key', 'title', 'url', 'cover_color', 'author',
             'cover', 'epoch', 'href', 'has_audio', 'genre',
-            'simple_thumb', 'slug', 'cover_thumb']
+            'simple_thumb', 'slug', 'cover_thumb', 'liked']
         legacy_non_null_fields = [
             'kind', 'author', 'epoch', 'genre',
             'cover', 'simple_thumb', 'cover_thumb']
 
 
-class BookSerializer(BaseBookSerializer):
-    liked = BookLiked()
-    cover = serializers.FileField()
+class BookListSerializer(BookSerializer):
+    cover = serializers.CharField()
+    cover_thumb = serializers.CharField()
 
-    class Meta:
-        model = Book
-        fields = BaseBookSerializer.Meta.fields + ['liked']
-        legacy_non_null_fields = BaseBookSerializer.Meta.legacy_non_null_fields
+    Meta = BookSerializer.Meta
 
 
 class MediaSerializer(LegacyMixin, serializers.ModelSerializer):
diff --git a/src/catalogue/api/views.py b/src/catalogue/api/views.py
index e21aab707..f3b719309 100644
--- a/src/catalogue/api/views.py
+++ b/src/catalogue/api/views.py
@@ -24,12 +24,12 @@ class CollectionDetail(RetrieveAPIView):
 class BookList(ListAPIView):
     permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
     queryset = Book.objects.none()  # Required for DjangoModelPermissions
-    serializer_class = serializers.BaseBookSerializer
+    serializer_class = serializers.BookListSerializer
 
     def get_queryset(self):
         try:
             tags, ancestors = read_tags(
-                self.kwargs['tags'], self.request,
+                self.kwargs.get('tags', ''), self.request,
                 allowed=('author', 'epoch', 'kind', 'genre')
             )
         except ValueError:
-- 
2.20.1
From ff382f6c37063c0c4c5d21c2834a8759e25c5d02 Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Tue, 5 Feb 2019 19:07:03 +0100
Subject: [PATCH 02/16] 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
From ea221b2bc448ce22dfda4f1ead2968fa7de31771 Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Thu, 7 Feb 2019 00:56:21 +0100
Subject: [PATCH 03/16] Start replacing Piston in OAuth flow with OAuthLib.
---
 requirements/requirements.txt |  1 +
 src/api/drf_auth.py           | 29 +++++++-----
 src/api/request_validator.py  | 85 +++++++++++++++++++++++++++++++++++
 src/api/tests/tests.py        | 27 ++++++-----
 src/api/urls.py               |  2 +-
 src/api/views.py              | 41 ++++++++++++++++-
 6 files changed, 159 insertions(+), 26 deletions(-)
 create mode 100644 src/api/request_validator.py
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index 3b8c95e42..34f4d9a38 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -15,6 +15,7 @@ django-allauth>=0.32,<0.33
 django-extensions
 djangorestframework<3.7
 djangorestframework-xml
+oauthlib>=3.0.1,<3.1
 
 # contact
 pyyaml
diff --git a/src/api/drf_auth.py b/src/api/drf_auth.py
index 26018c61e..ca6a49188 100644
--- a/src/api/drf_auth.py
+++ b/src/api/drf_auth.py
@@ -1,20 +1,29 @@
-"""
-Transitional code: bridge between Piston's OAuth implementation
-and DRF views.
-"""
-from piston.authentication import OAuthAuthentication
+# -*- 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 oauthlib.oauth1 import ResourceEndpoint
 from rest_framework.authentication import BaseAuthentication
+from .request_validator import PistonRequestValidator
 
 
 class PistonOAuthAuthentication(BaseAuthentication):
     def __init__(self):
-        self.piston_auth = OAuthAuthentication()
+        validator = PistonRequestValidator()
+        self.provider = ResourceEndpoint(validator)
 
     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
+        v, r = self.provider.validate_protected_resource_request(
+            request.build_absolute_uri(),
+            http_method=request.method,
+            body=request.body,
+            headers={
+                "Authorization": request.META['HTTP_AUTHORIZATION'],
+                "Content-Type": request.content_type,
+            } if 'HTTP_AUTHORIZATION' in request.META else None
+        )
+        if v:
+            return r.token.user, r.token
diff --git a/src/api/request_validator.py b/src/api/request_validator.py
new file mode 100644
index 000000000..b8554adef
--- /dev/null
+++ b/src/api/request_validator.py
@@ -0,0 +1,85 @@
+# -*- 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 oauthlib.oauth1 import RequestValidator
+from piston.models import Consumer, Nonce, Token
+
+
+class PistonRequestValidator(RequestValidator):
+    dummy_access_token = '!'
+    realms = ['API']
+
+    # Just for the tests.
+    # It'd be a little more kosher to use test client with secure=True.
+    enforce_ssl = False
+
+    # iOS app generates 8-char nonces.
+    nonce_length = 8, 250
+
+    # Because piston.models.Token.key is char(18).
+    access_token_length = 18, 32
+
+    def check_client_key(self, client_key):
+        """We control the keys anyway."""
+        return True
+
+    def get_access_token_secret(self, client_key, token, request):
+        return request.token.secret
+
+    def get_default_realms(self, client_key, request):
+        return ['API']
+
+    def validate_access_token(self, client_key, token, request):
+        try:
+            token = Token.objects.get(
+                token_type=Token.ACCESS,
+                consumer__key=client_key,
+                key=token
+            )
+        except Token.DoesNotExist:
+            return False
+        else:
+            request.token = token
+            return True
+
+    def validate_timestamp_and_nonce(self, client_key, timestamp, nonce,
+                                     request, request_token=None, access_token=None):
+        # TODO: validate the timestamp
+        token = request_token or access_token
+        # Yes, this is what Piston did.
+        if token is None:
+            return True
+
+        nonce, created = Nonce.objects.get_or_create(consumer_key=client_key,
+                                                     token_key=token,
+                                                     key=nonce)
+        return created
+
+    def validate_client_key(self, client_key, request):
+        try:
+            request.oauth_consumer = Consumer.objects.get(key=client_key)
+        except Consumer.DoesNotExist:
+            return False
+        return True
+
+    def validate_realms(self, client_key, token, request, uri=None, realms=None):
+        return True
+
+    def validate_requested_realms(self, *args, **kwargs):
+        return True
+
+    def validate_redirect_uri(self, *args, **kwargs):
+        return True
+
+    def get_client_secret(self, client_key, request):
+        return request.oauth_consumer.secret
+
+    def save_request_token(self, token, request):
+        Token.objects.create(
+            token_type=Token.REQUEST,
+            timestamp=request.timestamp,
+            key=token['oauth_token'],
+            secret=token['oauth_token_secret'],
+            consumer=request.oauth_consumer,
+        )
diff --git a/src/api/tests/tests.py b/src/api/tests/tests.py
index 38f1882bb..ee3d66e6f 100644
--- a/src/api/tests/tests.py
+++ b/src/api/tests/tests.py
@@ -253,7 +253,7 @@ class OAuth1Tests(ApiTest):
         User.objects.all().delete()
 
     def test_create_token(self):
-        base_query = ("oauth_consumer_key=client&oauth_nonce=123&"
+        base_query = ("oauth_consumer_key=client&oauth_nonce=12345678&"
                       "oauth_signature_method=HMAC-SHA1&oauth_timestamp={}&"
                       "oauth_version=1.0".format(int(time())))
         raw = '&'.join([
@@ -274,7 +274,7 @@ class OAuth1Tests(ApiTest):
             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&"
+        base_query = ("oauth_consumer_key=client&oauth_nonce=12345678&"
                       "oauth_signature_method=HMAC-SHA1&oauth_timestamp={}&"
                       "oauth_token={}&oauth_version=1.0".format(
                           int(time()), request_token['oauth_token'][0]))
@@ -330,7 +330,7 @@ class AuthorizedTests(ApiTest):
     def signed(self, url, method='GET', params=None, data=None):
         auth_params = {
             "oauth_consumer_key": self.consumer.key,
-            "oauth_nonce": "%f" % time(),
+            "oauth_nonce": ("%f" % time()).replace('.', ''),
             "oauth_signature_method": "HMAC-SHA1",
             "oauth_timestamp": int(time()),
             "oauth_token": self.token.key,
@@ -358,10 +358,11 @@ class AuthorizedTests(ApiTest):
         if params:
             url = url + '?' + urlencode(params)
         return getattr(self.client, method.lower())(
-                url,
-                data=data,
-                HTTP_AUTHORIZATION=auth
-            )
+            url,
+            data=urlencode(data) if data else None,
+            content_type='application/x-www-form-urlencoded',
+            HTTP_AUTHORIZATION=auth,
+        )
 
     def signed_json(self, url, method='GET', params=None, data=None):
         return json.loads(self.signed(url, method, params, data).content)
@@ -371,10 +372,9 @@ class AuthorizedTests(ApiTest):
             [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'])
+        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/'),
@@ -390,9 +390,8 @@ class AuthorizedTests(ApiTest):
         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'])
+        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/')],
diff --git a/src/api/urls.py b/src/api/urls.py
index 973bf030a..150dc4c0b 100644
--- a/src/api/urls.py
+++ b/src/api/urls.py
@@ -13,7 +13,7 @@ from . import views
 
 
 urlpatterns = [
-    url(r'^oauth/request_token/$', oauth_request_token),
+    url(r'^oauth/request_token/$', views.OAuth1RequestTokenView.as_view()),
     url(r'^oauth/authorize/$', oauth_user_auth, name='oauth_user_auth'),
     url(r'^oauth/access_token/$', csrf_exempt(oauth_access_token)),
 
diff --git a/src/api/views.py b/src/api/views.py
index 377beb681..6d462fa3a 100644
--- a/src/api/views.py
+++ b/src/api/views.py
@@ -2,7 +2,10 @@
 # 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 django.http import Http404, HttpResponse
+from oauthlib.common import urlencode
+from oauthlib.oauth1 import RequestTokenEndpoint
+from piston.models import KEY_SIZE, SECRET_SIZE
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
 from rest_framework.views import APIView
@@ -11,6 +14,42 @@ from migdal.models import Entry
 from catalogue.models import Book
 from .models import BookUserData
 from . import serializers
+from .request_validator import PistonRequestValidator
+
+
+class OAuth1RequestTokenEndpoint(RequestTokenEndpoint):
+    def _create_request(self, *args, **kwargs):
+        r = super(OAuth1RequestTokenEndpoint, self)._create_request(*args, **kwargs)
+        r.redirect_uri = 'oob'
+        return r
+
+    def create_request_token(self, request, credentials):
+        token = {
+            'oauth_token': self.token_generator()[:KEY_SIZE],
+            'oauth_token_secret': self.token_generator()[:SECRET_SIZE],
+        }
+        token.update(credentials)
+        self.request_validator.save_request_token(token, request)
+        return urlencode(token.items())
+
+
+class OAuth1RequestTokenView(APIView):
+    def __init__(self):
+        self.endpoint = OAuth1RequestTokenEndpoint(PistonRequestValidator())
+    def dispatch(self, request):
+        headers, body, status = self.endpoint.create_request_token_response(
+            request.build_absolute_uri(),
+            request.method,
+            request.body,
+            {
+                "Authorization": request.META['HTTP_AUTHORIZATION']
+            } if 'HTTP_AUTHORIZATION' in request.META else None
+        )
+
+        response = HttpResponse(body, status=status)
+        for k, v in headers.items():
+            response[k] = v
+        return response
 
 
 class UserView(RetrieveAPIView):
-- 
2.20.1
From 1434b3dfc0495e885921acb726df6cc06e00c5ed Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Fri, 8 Feb 2019 01:30:34 +0100
Subject: [PATCH 04/16] Move authorize endpoint to OAuthlib.
---
 src/api/drf_auth.py          |   9 +--
 src/api/piston_patch.py      | 125 ++++++++---------------------------
 src/api/request_validator.py |  27 +++++++-
 src/api/urls.py              |   2 +-
 src/api/utils.py             |  33 +++++++++
 src/api/views.py             |  20 ++----
 6 files changed, 98 insertions(+), 118 deletions(-)
 create mode 100644 src/api/utils.py
diff --git a/src/api/drf_auth.py b/src/api/drf_auth.py
index ca6a49188..893f06610 100644
--- a/src/api/drf_auth.py
+++ b/src/api/drf_auth.py
@@ -5,6 +5,7 @@
 from oauthlib.oauth1 import ResourceEndpoint
 from rest_framework.authentication import BaseAuthentication
 from .request_validator import PistonRequestValidator
+from .utils import oauthlib_request
 
 
 class PistonOAuthAuthentication(BaseAuthentication):
@@ -17,13 +18,7 @@ class PistonOAuthAuthentication(BaseAuthentication):
 
     def authenticate(self, request):
         v, r = self.provider.validate_protected_resource_request(
-            request.build_absolute_uri(),
-            http_method=request.method,
-            body=request.body,
-            headers={
-                "Authorization": request.META['HTTP_AUTHORIZATION'],
-                "Content-Type": request.content_type,
-            } if 'HTTP_AUTHORIZATION' in request.META else None
+            **oauthlib_request(request)
         )
         if v:
             return r.token.user, r.token
diff --git a/src/api/piston_patch.py b/src/api/piston_patch.py
index fb3987bde..3c7e50f4f 100644
--- a/src/api/piston_patch.py
+++ b/src/api/piston_patch.py
@@ -1,18 +1,14 @@
 # -*- coding: utf-8 -*-
-
-# modified from django-piston
-import base64
-import hmac
-
-from django import forms
-from django.conf import settings
+# This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
+#
+from oauthlib.oauth1 import AuthorizationEndpoint
 from django.contrib.auth.decorators import login_required
-from django.core.urlresolvers import get_callable
-from django.http import HttpResponseRedirect, HttpResponse
-from django.shortcuts import render_to_response
-from django.template.context import RequestContext
-from piston import oauth
-from piston.authentication import initialize_server_request, INVALID_PARAMS_RESPONSE, send_oauth_error
+from django import forms
+from django.http import HttpResponseRedirect
+from django.shortcuts import render
+from .request_validator import PistonRequestValidator
+from .utils import oauthlib_request, oauthlib_response
 
 
 class HttpResponseAppRedirect(HttpResponseRedirect):
@@ -23,95 +19,32 @@ class OAuthAuthenticationForm(forms.Form):
     oauth_token = forms.CharField(widget=forms.HiddenInput)
     oauth_callback = forms.CharField(widget=forms.HiddenInput)  # changed from URLField - too strict
     # removed authorize_access - redundant
-    csrf_signature = forms.CharField(widget=forms.HiddenInput)
-
-    def __init__(self, *args, **kwargs):
-        forms.Form.__init__(self, *args, **kwargs)
-
-        self.fields['csrf_signature'].initial = self.initial_csrf_signature
-
-    def clean_csrf_signature(self):
-        sig = self.cleaned_data['csrf_signature']
-        token = self.cleaned_data['oauth_token']
-
-        sig1 = OAuthAuthenticationForm.get_csrf_signature(settings.SECRET_KEY, token)
-
-        if sig != sig1:
-            raise forms.ValidationError("CSRF signature is not valid")
-
-        return sig
-
-    def initial_csrf_signature(self):
-        token = self.initial['oauth_token']
-        return OAuthAuthenticationForm.get_csrf_signature(settings.SECRET_KEY, token)
-
-    @staticmethod
-    def get_csrf_signature(key, token):
-        # Check signature...
-        import hashlib  # 2.5
-        hashed = hmac.new(key, token, hashlib.sha1)
-
-        # calculate the digest base 64
-        return base64.b64encode(hashed.digest())
-
-
-# The only thing changed in the views below is the form used
-# and also the Http Redirect class
-
-
-def oauth_auth_view(request, token, callback, params):
-    form = OAuthAuthenticationForm(initial={
-        'oauth_token': token.key,
-        'oauth_callback': callback,
-    })
-
-    return render_to_response('piston/authorize_token.html',
-                              {'form': form}, RequestContext(request))
 
 
 @login_required
 def oauth_user_auth(request):
-    oauth_server, oauth_request = initialize_server_request(request)
-
-    if oauth_request is None:
-        return INVALID_PARAMS_RESPONSE
-
-    try:
-        token = oauth_server.fetch_request_token(oauth_request)
-    except oauth.OAuthError, err:
-        return send_oauth_error(err)
-
-    try:
-        callback = oauth_server.get_callback(oauth_request)
-    except:
-        callback = None
+    endpoint = AuthorizationEndpoint(PistonRequestValidator())
 
     if request.method == "GET":
-        params = oauth_request.get_normalized_parameters()
-
-        oauth_view = getattr(settings, 'OAUTH_AUTH_VIEW', None)
-        if oauth_view is None:
-            return oauth_auth_view(request, token, callback, params)
-        else:
-            return get_callable(oauth_view)(request, token, callback, params)
-    elif request.method == "POST":
-        try:
-            form = OAuthAuthenticationForm(request.POST)
-            if form.is_valid():
-                token = oauth_server.authorize_token(token, request.user)
-                args = '?' + token.to_string(only_key=True)
-            else:
-                args = '?error=%s' % 'Access not granted by user.'
-
-            if not callback:
-                callback = getattr(settings, 'OAUTH_CALLBACK_VIEW')
-                return get_callable(callback)(request, token)
+        # Why not just get oauth_token here?
+        # This is fairly straightforward, in't?
+        realms, credentials = endpoint.get_realms_and_credentials(
+            **oauthlib_request(request))
+        callback = request.GET.get('oauth_callback')
 
-            response = HttpResponseAppRedirect(callback + args)
+        form = OAuthAuthenticationForm(initial={
+            'oauth_token': credentials['resource_owner_key'],
+            'oauth_callback': callback,
+        })
 
-        except oauth.OAuthError, err:
-            response = send_oauth_error(err)
-    else:
-        response = HttpResponse('Action not allowed.')
+        return render(request, 'piston/authorize_token.html', {'form': form})
 
-    return response
+    elif request.method == "POST":
+        response = oauthlib_response(
+            endpoint.create_authorization_response(
+                credentials={"user": request.user},
+                **oauthlib_request(request)
+            )
+        )
+
+        return response
diff --git a/src/api/request_validator.py b/src/api/request_validator.py
index b8554adef..6e3c0c22b 100644
--- a/src/api/request_validator.py
+++ b/src/api/request_validator.py
@@ -2,11 +2,14 @@
 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
 #
+import time
 from oauthlib.oauth1 import RequestValidator
 from piston.models import Consumer, Nonce, Token
 
 
 class PistonRequestValidator(RequestValidator):
+    timestamp_threshold = 300
+
     dummy_access_token = '!'
     realms = ['API']
 
@@ -45,7 +48,8 @@ class PistonRequestValidator(RequestValidator):
 
     def validate_timestamp_and_nonce(self, client_key, timestamp, nonce,
                                      request, request_token=None, access_token=None):
-        # TODO: validate the timestamp
+        if abs(time.time() - int(timestamp)) > self.timestamp_threshold:
+            return False
         token = request_token or access_token
         # Yes, this is what Piston did.
         if token is None:
@@ -83,3 +87,24 @@ class PistonRequestValidator(RequestValidator):
             secret=token['oauth_token_secret'],
             consumer=request.oauth_consumer,
         )
+
+    def verify_request_token(self, token, request):
+        return Token.objects.filter(
+            token_type=Token.REQUEST, key=token, is_approved=False
+        ).exists()
+
+    def get_realms(self, *args, **kwargs):
+        return []
+
+    def save_verifier(self, token, verifier, request):
+        Token.objects.filter(
+            token_type=Token.REQUEST,
+            key=token,
+            is_approved=False
+        ).update(
+            is_approved=True,
+            user=verifier['user']
+        )
+
+    def get_redirect_uri(self, token, request):
+        return request.redirect_uri
diff --git a/src/api/urls.py b/src/api/urls.py
index 150dc4c0b..5936e1b64 100644
--- a/src/api/urls.py
+++ b/src/api/urls.py
@@ -5,7 +5,7 @@
 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 oauth_access_token, oauth_request_token
+from piston.authentication import oauth_access_token
 import catalogue.views
 from api import handlers
 from api.piston_patch import oauth_user_auth
diff --git a/src/api/utils.py b/src/api/utils.py
new file mode 100644
index 000000000..6dc7e4505
--- /dev/null
+++ b/src/api/utils.py
@@ -0,0 +1,33 @@
+# -*- 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 HttpResponse
+from django.utils.encoding import iri_to_uri
+
+
+def oauthlib_request(request):
+    """Creates parameters for OAuthlib's Request from a Django Request."""
+    headers = {}
+    # We don't have request.content_type yet in 2015,
+    # while test client has no META['CONTENT_TYPE'].
+    ct = request.META.get('CONTENT_TYPE', getattr(request, 'content_type', None))
+    if ct:
+        headers["Content-Type"] = ct
+    if 'HTTP_AUTHORIZATION' in request.META:
+        headers["Authorization"] = request.META['HTTP_AUTHORIZATION']
+    return {
+        "uri": request.build_absolute_uri(),
+        "http_method": request.method,
+        "body": request.body,
+        "headers": headers,
+    }
+
+def oauthlib_response((headers, body, status)):
+    """Creates a django.http.HttpResponse from (headers, body, status) tuple from OAuthlib."""
+    response = HttpResponse(body, status=status)
+    for k, v in headers.items():
+        if k == 'Location':
+            v = iri_to_uri(v)
+        response[k] = v
+    return response
diff --git a/src/api/views.py b/src/api/views.py
index 6d462fa3a..3cf957ed9 100644
--- a/src/api/views.py
+++ b/src/api/views.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.http import Http404, HttpResponse
+from django.http import Http404
 from oauthlib.common import urlencode
 from oauthlib.oauth1 import RequestTokenEndpoint
 from piston.models import KEY_SIZE, SECRET_SIZE
@@ -15,6 +15,7 @@ from catalogue.models import Book
 from .models import BookUserData
 from . import serializers
 from .request_validator import PistonRequestValidator
+from .utils import oauthlib_request, oauthlib_response
 
 
 class OAuth1RequestTokenEndpoint(RequestTokenEndpoint):
@@ -36,21 +37,14 @@ class OAuth1RequestTokenEndpoint(RequestTokenEndpoint):
 class OAuth1RequestTokenView(APIView):
     def __init__(self):
         self.endpoint = OAuth1RequestTokenEndpoint(PistonRequestValidator())
+
     def dispatch(self, request):
-        headers, body, status = self.endpoint.create_request_token_response(
-            request.build_absolute_uri(),
-            request.method,
-            request.body,
-            {
-                "Authorization": request.META['HTTP_AUTHORIZATION']
-            } if 'HTTP_AUTHORIZATION' in request.META else None
+        return oauthlib_response(
+            self.endpoint.create_request_token_response(
+                **oauthlib_request(request)
+            )
         )
 
-        response = HttpResponse(body, status=status)
-        for k, v in headers.items():
-            response[k] = v
-        return response
-
 
 class UserView(RetrieveAPIView):
     permission_classes = [IsAuthenticated]
-- 
2.20.1
From 29f694eea94d09bd66976321ddfd80ac30b73d38 Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Fri, 8 Feb 2019 11:18:58 +0100
Subject: [PATCH 05/16] Last Piston view replaced using OAuthlib.
---
 src/api/request_validator.py | 39 ++++++++++++++++++++++++++++++++++++
 src/api/tests/tests.py       |  2 +-
 src/api/urls.py              |  3 +--
 src/api/views.py             | 36 +++++++++++++++++++++++++++++++--
 4 files changed, 75 insertions(+), 5 deletions(-)
diff --git a/src/api/request_validator.py b/src/api/request_validator.py
index 6e3c0c22b..916636b91 100644
--- a/src/api/request_validator.py
+++ b/src/api/request_validator.py
@@ -21,18 +21,37 @@ class PistonRequestValidator(RequestValidator):
     nonce_length = 8, 250
 
     # Because piston.models.Token.key is char(18).
+    request_token_length = 18, 32
     access_token_length = 18, 32
+    # TODO: oauthlib request-access switch.
 
     def check_client_key(self, client_key):
         """We control the keys anyway."""
         return True
 
+    def get_request_token_secret(self, client_key, token, request):
+        return request.token.secret
+
     def get_access_token_secret(self, client_key, token, request):
         return request.token.secret
 
     def get_default_realms(self, client_key, request):
         return ['API']
 
+    def validate_request_token(self, client_key, token, request):
+        try:
+            token = Token.objects.get(
+                token_type=Token.REQUEST,
+                consumer__key=client_key,
+                key=token,
+                is_approved=True,
+            )
+        except Token.DoesNotExist:
+            return False
+        else:
+            request.token = token
+            return True
+
     def validate_access_token(self, client_key, token, request):
         try:
             token = Token.objects.get(
@@ -76,6 +95,9 @@ class PistonRequestValidator(RequestValidator):
     def validate_redirect_uri(self, *args, **kwargs):
         return True
 
+    def validate_verifier(self, client_key, token, verifier, request):
+        return True
+
     def get_client_secret(self, client_key, request):
         return request.oauth_consumer.secret
 
@@ -88,6 +110,16 @@ class PistonRequestValidator(RequestValidator):
             consumer=request.oauth_consumer,
         )
 
+    def save_access_token(self, token, request):
+        Token.objects.create(
+            token_type=Token.ACCESS,
+            timestamp=request.timestamp,
+            key=token['oauth_token'],
+            secret=token['oauth_token_secret'],
+            consumer=request.oauth_consumer,
+            user=request.token.user,
+        )
+
     def verify_request_token(self, token, request):
         return Token.objects.filter(
             token_type=Token.REQUEST, key=token, is_approved=False
@@ -108,3 +140,10 @@ class PistonRequestValidator(RequestValidator):
 
     def get_redirect_uri(self, token, request):
         return request.redirect_uri
+
+    def invalidate_request_token(self, client_key, request_token, request):
+        Token.objects.filter(
+            token_type=Token.REQUEST,
+            key=request_token,
+            consumer__key=client_key,
+        )
diff --git a/src/api/tests/tests.py b/src/api/tests/tests.py
index ee3d66e6f..c8e07d26c 100644
--- a/src/api/tests/tests.py
+++ b/src/api/tests/tests.py
@@ -45,7 +45,7 @@ class ApiTest(TestCase):
         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)
         filename = path.join(path.dirname(__file__), 'res', 'responses', name)
diff --git a/src/api/urls.py b/src/api/urls.py
index 5936e1b64..8f9914113 100644
--- a/src/api/urls.py
+++ b/src/api/urls.py
@@ -5,7 +5,6 @@
 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 oauth_access_token
 import catalogue.views
 from api import handlers
 from api.piston_patch import oauth_user_auth
@@ -15,7 +14,7 @@ from . import views
 urlpatterns = [
     url(r'^oauth/request_token/$', views.OAuth1RequestTokenView.as_view()),
     url(r'^oauth/authorize/$', oauth_user_auth, name='oauth_user_auth'),
-    url(r'^oauth/access_token/$', csrf_exempt(oauth_access_token)),
+    url(r'^oauth/access_token/$', csrf_exempt(views.OAuth1AccessTokenView.as_view())),
 
     url(r'^$', TemplateView.as_view(template_name='api/main.html'), name='api'),
 
diff --git a/src/api/views.py b/src/api/views.py
index 3cf957ed9..518445dd0 100644
--- a/src/api/views.py
+++ b/src/api/views.py
@@ -3,8 +3,9 @@
 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
 #
 from django.http import Http404
+from django.views.generic.base import View
 from oauthlib.common import urlencode
-from oauthlib.oauth1 import RequestTokenEndpoint
+from oauthlib.oauth1 import RequestTokenEndpoint, AccessTokenEndpoint
 from piston.models import KEY_SIZE, SECRET_SIZE
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
@@ -34,7 +35,7 @@ class OAuth1RequestTokenEndpoint(RequestTokenEndpoint):
         return urlencode(token.items())
 
 
-class OAuth1RequestTokenView(APIView):
+class OAuth1RequestTokenView(View):
     def __init__(self):
         self.endpoint = OAuth1RequestTokenEndpoint(PistonRequestValidator())
 
@@ -46,6 +47,37 @@ class OAuth1RequestTokenView(APIView):
         )
 
 
+class OAuth1AccessTokenEndpoint(AccessTokenEndpoint):
+    def _create_request(self, *args, **kwargs):
+        r = super(OAuth1AccessTokenEndpoint, self)._create_request(*args, **kwargs)
+        r.verifier = 'x' * 20
+        return r
+
+    def create_access_token(self, request, credentials):
+        request.realms = self.request_validator.get_realms(
+            request.resource_owner_key, request)
+        token = {
+            'oauth_token': self.token_generator()[:KEY_SIZE],
+            'oauth_token_secret': self.token_generator()[:SECRET_SIZE],
+            'oauth_authorized_realms': ' '.join(request.realms)
+        }
+        token.update(credentials)
+        self.request_validator.save_access_token(token, request)
+        return urlencode(token.items())
+
+
+class OAuth1AccessTokenView(View):
+    def __init__(self):
+        self.endpoint = OAuth1AccessTokenEndpoint(PistonRequestValidator())
+
+    def dispatch(self, request):
+        return oauthlib_response(
+            self.endpoint.create_access_token_response(
+                **oauthlib_request(request)
+            )
+        )
+
+
 class UserView(RetrieveAPIView):
     permission_classes = [IsAuthenticated]
     serializer_class = serializers.UserSerializer
-- 
2.20.1
From 746bfb66224e6a1065e5355ed63f9c4836b0a498 Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Fri, 8 Feb 2019 11:27:01 +0100
Subject: [PATCH 06/16] Summon the annoying banner.
---
 src/wolnelektury/static/css/annoy.css          | 8 ++++----
 src/wolnelektury/static/js/annoy.js            | 6 +++---
 src/wolnelektury/templates/annoy.html          | 7 ++-----
 src/wolnelektury/templates/base/superbase.html | 1 +
 4 files changed, 10 insertions(+), 12 deletions(-)
diff --git a/src/wolnelektury/static/css/annoy.css b/src/wolnelektury/static/css/annoy.css
index 5ee80da35..54dbfba57 100755
--- a/src/wolnelektury/static/css/annoy.css
+++ b/src/wolnelektury/static/css/annoy.css
@@ -6,8 +6,8 @@
     z-index: 200;
     font-family: Arial, sans-serif;
     display: block;
-    padding: 2.6px 13px;
-    /*width: 1em;*/
+    padding: .1em 1em;
+    width: 1em;
     text-align:center;
     border-radius: 0 0 0 1em;
     position: absolute;
@@ -19,9 +19,9 @@
 #annoy {
     font-size: 13px;
     line-height: 1.15em;
-    /*padding: 1em 5em 1em 0;*/
+    padding: 1em 5em 1em 0;
     display: none;
-    /*background: orange;*/
+    background: orange;
     font-family: Arial, sans-serif;
     position: relative;
     z-index: 200;
diff --git a/src/wolnelektury/static/js/annoy.js b/src/wolnelektury/static/js/annoy.js
index 3104097a9..d71f14676 100644
--- a/src/wolnelektury/static/js/annoy.js
+++ b/src/wolnelektury/static/js/annoy.js
@@ -6,18 +6,18 @@ $("#annoy-on").click(function(e) {
     e.preventDefault();
     $("#annoy").slideDown('fast');
     $(this).hide();
-    if (Modernizr.localstorage) localStorage.removeItem("annoyed2013");
+    if (Modernizr.localstorage) localStorage.removeItem("annoyed2019");
 });
 
 $("#annoy-off").click(function() {
     $("#annoy").slideUp('fast');
     $("#annoy-on").show();
-    if (Modernizr.localstorage) localStorage["annoyed2013"] = true;
+    if (Modernizr.localstorage) localStorage["annoyed2019"] = true;
 });
 
 
 if (Modernizr.localstorage) {
-    if (!localStorage["annoyed2013"]) {
+    if (!localStorage["annoyed2019"]) {
         $("#annoy-on").hide();
         $("#annoy").show();
     }
diff --git a/src/wolnelektury/templates/annoy.html b/src/wolnelektury/templates/annoy.html
index ecf1a1f9d..1f0889908 100755
--- a/src/wolnelektury/templates/annoy.html
+++ b/src/wolnelektury/templates/annoy.html
@@ -1,8 +1,7 @@
 {% load static %}
-Misja KsiÄ
żka
+1%
   
-    {% comment %}
-    
+    
       
     
     Droga użytkowniczko, drogi użytkowniku!
@@ -16,8 +15,6 @@
     
 
     
Dowiedz siÄ wiÄcej
-    {% endcomment %}
     
x
-    
     
  
diff --git a/src/wolnelektury/templates/base/superbase.html b/src/wolnelektury/templates/base/superbase.html
index 3ff768f33..f5feb4ed7 100644
--- a/src/wolnelektury/templates/base/superbase.html
+++ b/src/wolnelektury/templates/base/superbase.html
@@ -35,6 +35,7 @@
       {% block bodycontent %}
 
         {% block annoy %}
+          {% include "annoy.html" %}
           {% if not funding_no_show_current %}
             {% cache 120 funding_top_bar LANGUAGE_CODE %}
               {% funding_top_bar %}
-- 
2.20.1
From 6ed1d78c6ca14fd002c7a00867d1964e0c4ea4a8 Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Fri, 8 Feb 2019 11:28:45 +0100
Subject: [PATCH 07/16] Add HotJar on few pages.
---
 src/catalogue/templates/catalogue/book_detail.html    |  8 ++++++++
 .../templates/catalogue/tagged_object_list.html       |  9 +++++++++
 src/wolnelektury/templates/hotjar.html                | 11 +++++++++++
 src/wolnelektury/templates/main_page.html             |  5 +++++
 4 files changed, 33 insertions(+)
 create mode 100644 src/wolnelektury/templates/hotjar.html
diff --git a/src/catalogue/templates/catalogue/book_detail.html b/src/catalogue/templates/catalogue/book_detail.html
index 865aca2bb..ecae89e82 100644
--- a/src/catalogue/templates/catalogue/book_detail.html
+++ b/src/catalogue/templates/catalogue/book_detail.html
@@ -12,6 +12,14 @@
 
 {% block bodyid %}book-detail{% endblock %}
 
+{% block extrahead %}
+{{ block.super }}
+{% if request.path == '/katalog/lektura/pan-tadeusz/' %}
+{% include "hotjar.html" %}
+{% endif %}
+{% endblock %}
+
+
 {% block body %}
   {% include 'catalogue/book_wide.html' %}
 
diff --git a/src/catalogue/templates/catalogue/tagged_object_list.html b/src/catalogue/templates/catalogue/tagged_object_list.html
index f4ba6991c..285af0ac3 100644
--- a/src/catalogue/templates/catalogue/tagged_object_list.html
+++ b/src/catalogue/templates/catalogue/tagged_object_list.html
@@ -5,6 +5,15 @@
 
 {% block titleextra %}{% if tags %}{% title_from_tags tags %}{% elif list_type == 'gallery' %}{% trans "Gallery" %}{% elif list_type == 'audiobooks' %}{% trans "Audiobooks" %}{% else %}{% trans "Literature" %}{% endif %}{% endblock %}
 
+
+{% block extrahead %}
+{{ block.super }}
+{% if request.path == '/katalog/lektury/' or request.path == '/katalog/audiobooki/' %}
+{% include "hotjar.html" %}
+{% endif %}
+{% endblock %}
+
+
 {% block bodyid %}tagged-object-list{% endblock %}
 
 {% block body %}
diff --git a/src/wolnelektury/templates/hotjar.html b/src/wolnelektury/templates/hotjar.html
new file mode 100644
index 000000000..03d8e37ab
--- /dev/null
+++ b/src/wolnelektury/templates/hotjar.html
@@ -0,0 +1,11 @@
+
+
diff --git a/src/wolnelektury/templates/main_page.html b/src/wolnelektury/templates/main_page.html
index 7912c2421..4a627e6c3 100755
--- a/src/wolnelektury/templates/main_page.html
+++ b/src/wolnelektury/templates/main_page.html
@@ -10,6 +10,11 @@
 {% block title %}{% trans "Wolne Lektury internet library" %}{% endblock %}
 {% block ogtitle %}{% trans "Wolne Lektury internet library" %}{% endblock %}
 
+{% block extrahead %}
+{{ block.super }}
+{% include "hotjar.html" %}
+{% endblock %}
+
 {% block body %}
   {% spaceless %}
     {% if cite %}
-- 
2.20.1
From 25cb0dced028c91d9a73d7faedae4d3e73162902 Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Fri, 8 Feb 2019 11:38:57 +0100
Subject: [PATCH 08/16] Deploy in Makefile.
---
 Makefile | 10 ++++++++++
 1 file changed, 10 insertions(+)
diff --git a/Makefile b/Makefile
index 5ef3ba7e4..471127c17 100644
--- a/Makefile
+++ b/Makefile
@@ -1,3 +1,13 @@
+.PHONY: deploy test
+
+
+deploy: src/wolnelektury/localsettings.py
+	git submodule update --init
+	pip install -r requirements/requirements.txt
+	src/manage.py migrate --noinput
+	src/manage.py collectstatic --noinput
+
+
 .ONESHELL:
 test:
 	cd src
-- 
2.20.1
From 832bfbcc54cd38942d6df89e850d4268386d6241 Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Fri, 8 Feb 2019 12:46:25 +0100
Subject: [PATCH 09/16] Bring Piwik for API back.
---
 src/api/urls.py           | 17 +++++++----
 src/catalogue/api/urls.py | 61 +++++++++++++++++++++++++++------------
 src/social/api/urls.py    |  9 ++++--
 src/stats/utils.py        | 13 +++++++++
 4 files changed, 74 insertions(+), 26 deletions(-)
diff --git a/src/api/urls.py b/src/api/urls.py
index 8f9914113..16fdfb749 100644
--- a/src/api/urls.py
+++ b/src/api/urls.py
@@ -6,7 +6,7 @@ from django.conf.urls import url, include
 from django.views.decorators.csrf import csrf_exempt
 from django.views.generic import TemplateView
 import catalogue.views
-from api import handlers
+from stats.utils import piwik_track_view
 from api.piston_patch import oauth_user_auth
 from . import views
 
@@ -23,11 +23,18 @@ urlpatterns = [
     url(r'tag/(?P\d*?)/info\.html$', catalogue.views.tag_info),
 
     # reading data
-    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'),
+    url(r'^reading/(?P[a-z0-9-]+)/$',
+        piwik_track_view(views.BookUserDataView.as_view()),
+        name='api_reading'),
+    url(r'^reading/(?P[a-z0-9-]+)/(?P[a-z]+)/$',
+        piwik_track_view(views.BookUserDataView.as_view()),
+        name='api_reading'),
+    url(r'^username/$',
+        piwik_track_view(views.UserView.as_view()),
+        name='api_username'),
 
-    url(r'^blog/$', views.BlogView.as_view()),
+    url(r'^blog/$',
+        piwik_track_view(views.BlogView.as_view())),
 
     url(r'^pictures/$', include('picture.api.urls')),
     url(r'^', include('social.api.urls')),
diff --git a/src/catalogue/api/urls.py b/src/catalogue/api/urls.py
index 07729aea8..cf73c2cff 100644
--- a/src/catalogue/api/urls.py
+++ b/src/catalogue/api/urls.py
@@ -3,6 +3,7 @@
 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
 #
 from django.conf.urls import include, url
+from stats.utils import piwik_track_view
 from . import views
 
 
@@ -12,45 +13,67 @@ 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/$',
+        piwik_track_view(views.CollectionList.as_view()),
+        name="api_collections"),
     url(r'^collections/(?P[^/]+)/$',
-        views.CollectionDetail.as_view(), name="collection-detail"),
+        piwik_track_view(views.CollectionDetail.as_view()),
+        name="collection-detail"),
 
     url(tags_re + r'books/' + paginate_re,
-        views.BookList.as_view(), name='catalogue_api_book_list'),
+        piwik_track_view(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'),
+        piwik_track_view(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'),
+        piwik_track_view(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'),
+        piwik_track_view(views.BookList.as_view()),
+        {"daisy": True}, name='catalogue_api_daisy_list'),
     url(r'^recommended/' + paginate_re,
-        views.BookList.as_view(),
+        piwik_track_view(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'),
+        piwik_track_view(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'^books/(?P[^/]+)/$',
+        piwik_track_view(views.BookDetail.as_view()),
+        name='catalogue_api_book'),
 
     url(tags_re + r'ebooks/' + paginate_re,
-        views.EbookList.as_view(),
+        piwik_track_view(views.EbookList.as_view()),
         name='catalogue_api_ebook_list'),
     url(tags_re + r'parent_ebooks/' + paginate_re,
-        views.EbookList.as_view(),
+        piwik_track_view(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'^filter-books/$',
+        piwik_track_view(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'^epub/(?P[a-z0-9-]+)/$',
+        piwik_track_view(views.EpubView.as_view()),
+        name='catalogue_api_epub'),
 
-    url(r'^preview/$', views.Preview.as_view(), name='catalogue_api_preview'),
+    url(r'^preview/$',
+        piwik_track_view(views.Preview.as_view()),
+        name='catalogue_api_preview'),
 
-    url(r'^(?P(?:(?:[a-z0-9-]+/){2}){1,6})fragments/$', views.FragmentList.as_view()),
+    url(r'^(?P(?:(?:[a-z0-9-]+/){2}){1,6})fragments/$',
+        piwik_track_view(views.FragmentList.as_view())),
     url(r'^books/(?P[a-z0-9-]+)/fragments/(?P[a-z0-9-]+)/$',
-        views.FragmentView.as_view(), name="catalogue_api_fragment"),
+        piwik_track_view(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"),
+    url(r'^(?P[a-z]+)s/$',
+        piwik_track_view(views.TagCategoryView.as_view()),
+        name='catalogue_api_tag_list'),
+    url(r'^(?P[a-z]+)s/(?P[a-z0-9-]+)/$',
+        piwik_track_view(views.TagView.as_view()),
+        name="catalogue_api_tag"),
 ]
diff --git a/src/social/api/urls.py b/src/social/api/urls.py
index 8ded909b4..5c633d805 100644
--- a/src/social/api/urls.py
+++ b/src/social/api/urls.py
@@ -3,10 +3,15 @@
 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
 #
 from django.conf.urls import include, url
+from stats.utils import piwik_track_view
 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'),
+    url(r'^like/(?P[a-z0-9-]+)/$',
+        piwik_track_view(views.LikeView.as_view()),
+        name='social_api_like'),
+    url(r'^shelf/(?P[a-z]+)/$',
+        piwik_track_view(views.ShelfView.as_view()),
+        name='social_api_shelf'),
 ]
diff --git a/src/stats/utils.py b/src/stats/utils.py
index fc473a6f0..68a01f568 100644
--- a/src/stats/utils.py
+++ b/src/stats/utils.py
@@ -62,3 +62,16 @@ def piwik_track(klass_or_method):
         return klass
     else:
         return wrap
+
+
+def piwik_track_view(view):
+    if not getattr(settings, 'PIWIK_SITE_ID', 0):
+        return view
+
+    def wrap(request, *args, **kwargs):
+        if getattr(request, 'piwik_track', True):
+            track_request.delay(piwik_url(request))
+        return view(self, request, *args, **kwargs)
+
+    update_wrapper(wrap, view)
+    return wrap
-- 
2.20.1
From 2633e1ad9f69bd056dd75e3af8ccf58104272585 Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Mon, 11 Feb 2019 11:51:34 +0100
Subject: [PATCH 10/16] fixup
---
 src/stats/utils.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/stats/utils.py b/src/stats/utils.py
index 68a01f568..86ce54ccd 100644
--- a/src/stats/utils.py
+++ b/src/stats/utils.py
@@ -71,7 +71,7 @@ def piwik_track_view(view):
     def wrap(request, *args, **kwargs):
         if getattr(request, 'piwik_track', True):
             track_request.delay(piwik_url(request))
-        return view(self, request, *args, **kwargs)
+        return view(request, *args, **kwargs)
 
     update_wrapper(wrap, view)
     return wrap
-- 
2.20.1
From 40377c9b3b045814857e1190047ef83334c1131f Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Tue, 12 Feb 2019 22:51:47 +0100
Subject: [PATCH 11/16] App compatibility fix + some error handling.
---
 src/api/piston_patch.py | 38 ++++++++++++++++++++++++++------------
 src/api/tests/tests.py  | 30 ++++++++++++++++++++++--------
 2 files changed, 48 insertions(+), 20 deletions(-)
diff --git a/src/api/piston_patch.py b/src/api/piston_patch.py
index 3c7e50f4f..6a80e15cd 100644
--- a/src/api/piston_patch.py
+++ b/src/api/piston_patch.py
@@ -2,10 +2,10 @@
 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
 #
-from oauthlib.oauth1 import AuthorizationEndpoint
+from oauthlib.oauth1 import AuthorizationEndpoint, OAuth1Error
 from django.contrib.auth.decorators import login_required
 from django import forms
-from django.http import HttpResponseRedirect
+from django.http import HttpResponse, HttpResponseRedirect
 from django.shortcuts import render
 from .request_validator import PistonRequestValidator
 from .utils import oauthlib_request, oauthlib_response
@@ -21,15 +21,26 @@ class OAuthAuthenticationForm(forms.Form):
     # removed authorize_access - redundant
 
 
+class OAuth1AuthorizationEndpoint(AuthorizationEndpoint):
+    def create_verifier(self, request, credentials):
+        verifier = super(OAuth1AuthorizationEndpoint, self).create_verifier(request, credentials)
+        return {
+            'oauth_token': verifier['oauth_token'],
+        }
+
+
 @login_required
 def oauth_user_auth(request):
-    endpoint = AuthorizationEndpoint(PistonRequestValidator())
+    endpoint = OAuth1AuthorizationEndpoint(PistonRequestValidator())
 
     if request.method == "GET":
         # Why not just get oauth_token here?
         # This is fairly straightforward, in't?
-        realms, credentials = endpoint.get_realms_and_credentials(
-            **oauthlib_request(request))
+        try:
+            realms, credentials = endpoint.get_realms_and_credentials(
+                **oauthlib_request(request))
+        except OAuth1Error as e:
+            return HttpResponse(e.message, status=400)
         callback = request.GET.get('oauth_callback')
 
         form = OAuthAuthenticationForm(initial={
@@ -40,11 +51,14 @@ def oauth_user_auth(request):
         return render(request, 'piston/authorize_token.html', {'form': form})
 
     elif request.method == "POST":
-        response = oauthlib_response(
-            endpoint.create_authorization_response(
-                credentials={"user": request.user},
-                **oauthlib_request(request)
+        try:
+            response = oauthlib_response(
+                endpoint.create_authorization_response(
+                    credentials={"user": request.user},
+                    **oauthlib_request(request)
+                )
             )
-        )
-
-        return response
+        except OAuth1Error as e:
+            return HttpResponse(e.message, status=400)
+        else:
+            return response
diff --git a/src/api/tests/tests.py b/src/api/tests/tests.py
index c8e07d26c..298a79433 100644
--- a/src/api/tests/tests.py
+++ b/src/api/tests/tests.py
@@ -242,6 +242,8 @@ class OAuth1Tests(ApiTest):
     @classmethod
     def setUpClass(cls):
         cls.user = User.objects.create(username='test')
+        cls.user.set_password('test')
+        cls.user.save()
         cls.consumer_secret = 'len(quote(consumer secret))>=32'
         Consumer.objects.create(
             key='client',
@@ -253,6 +255,7 @@ class OAuth1Tests(ApiTest):
         User.objects.all().delete()
 
     def test_create_token(self):
+        # Fetch request token.
         base_query = ("oauth_consumer_key=client&oauth_nonce=12345678&"
                       "oauth_signature_method=HMAC-SHA1&oauth_timestamp={}&"
                       "oauth_version=1.0".format(int(time())))
@@ -268,16 +271,26 @@ class OAuth1Tests(ApiTest):
         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)
+        request_token_data = parse_qs(response.content)
+        request_token = request_token_data['oauth_token'][0]
+        request_token_secret = request_token_data['oauth_token_secret'][0]
 
-        Token.objects.filter(
-            key=request_token['oauth_token'][0], token_type=Token.REQUEST
-        ).update(user=self.user, is_approved=True)
+        # Request token authorization.
+        self.client.login(username='test', password='test')
+        response = self.client.get('/api/oauth/authorize/?oauth_token=%s&oauth_callback=test://oauth.callback/' % request_token)
+        post_data = response.context['form'].initial
 
+        response = self.client.post('/api/oauth/authorize/?' + urlencode(post_data))
+        self.assertEqual(
+            response['Location'],
+            'test://oauth.callback/?oauth_token=' + request_token
+        )
+
+        # Fetch access token.
         base_query = ("oauth_consumer_key=client&oauth_nonce=12345678&"
                       "oauth_signature_method=HMAC-SHA1&oauth_timestamp={}&"
                       "oauth_token={}&oauth_version=1.0".format(
-                          int(time()), request_token['oauth_token'][0]))
+                          int(time()), request_token))
         raw = '&'.join([
             'GET',
             quote('http://testserver/api/oauth/access_token/', safe=''),
@@ -285,7 +298,7 @@ class OAuth1Tests(ApiTest):
         ])
         h = hmac.new(
             quote(self.consumer_secret) + '&' +
-            quote(request_token['oauth_token_secret'][0], safe=''),
+            quote(request_token_secret, safe=''),
             raw,
             hashlib.sha1
         ).digest()
@@ -293,11 +306,12 @@ class OAuth1Tests(ApiTest):
         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)
+        access_token_data = parse_qs(response.content)
+        access_token = access_token_data['oauth_token'][0]
 
         self.assertTrue(
             Token.objects.filter(
-                key=access_token['oauth_token'][0],
+                key=access_token,
                 token_type=Token.ACCESS,
                 user=self.user
             ).exists())
-- 
2.20.1
From 32cf6526ef36a9aa0af0de8673f19a94cbf5409b Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Wed, 13 Feb 2019 00:41:37 +0100
Subject: [PATCH 12/16] API links fixes.
---
 src/api/templates/api/main.html | 10 +++++-----
 src/catalogue/api/urls.py       |  2 +-
 2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/api/templates/api/main.html b/src/api/templates/api/main.html
index b7e7b8f95..a2a67cd67 100755
--- a/src/api/templates/api/main.html
+++ b/src/api/templates/api/main.html
@@ -35,12 +35,12 @@
     
 
     
-        - 
+        
- 
           {% url "catalogue_api_book_list" "" %} â {% trans "All books" %}
 
-        - 
-          {% url "api_audiobook_list" "" %} â {% trans "Audiobooks" %}
 
-        - 
-          {% url "api_daisy_list" "" %} â {% trans "DAISY" %}
 
+        - 
+          {% url "catalogue_api_audiobook_list" "" %} â {% trans "Audiobooks" %}
 
+        - 
+          {% url "catalogue_api_daisy_list" "" %} â {% trans "DAISY" %}
 
 
         - 
           {% url "catalogue_api_tag_list" "author" %} â {% trans "List of all authors" %}
 
diff --git a/src/catalogue/api/urls.py b/src/catalogue/api/urls.py
index cf73c2cff..d70338a53 100644
--- a/src/catalogue/api/urls.py
+++ b/src/catalogue/api/urls.py
@@ -15,7 +15,7 @@ urlpatterns = [
     # books by collections
     url(r'^collections/$',
         piwik_track_view(views.CollectionList.as_view()),
-        name="api_collections"),
+        name="catalogue_api_collections"),
     url(r'^collections/(?P[^/]+)/$',
         piwik_track_view(views.CollectionDetail.as_view()),
         name="collection-detail"),
-- 
2.20.1
From e4ddfe1b3a3bfa982386284d9859c64862cb6c08 Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Thu, 14 Feb 2019 22:12:54 +0100
Subject: [PATCH 13/16] Remove piston, keep just the models.
---
 requirements/requirements-dev.txt             |  2 -
 requirements/requirements.txt                 |  1 -
 .../migrations => api}/piston/__init__.py     |  0
 src/api/piston/admin.py                       |  7 +++
 .../piston/migrations}/0001_initial.py        |  0
 src/api/piston/migrations/__init__.py         |  0
 src/api/piston/models.py                      | 56 +++++++++++++++++++
 src/api/request_validator.py                  |  2 +-
 src/api/tests/tests.py                        |  2 +-
 src/api/views.py                              |  2 +-
 src/wolnelektury/settings/apps.py             |  2 +-
 src/wolnelektury/settings/contrib.py          |  1 -
 12 files changed, 67 insertions(+), 8 deletions(-)
 rename src/{wolnelektury/migrations => api}/piston/__init__.py (100%)
 create mode 100644 src/api/piston/admin.py
 rename src/{wolnelektury/migrations/piston => api/piston/migrations}/0001_initial.py (100%)
 create mode 100644 src/api/piston/migrations/__init__.py
 create mode 100644 src/api/piston/models.py
diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt
index 2be42f850..d68937188 100644
--- a/requirements/requirements-dev.txt
+++ b/requirements/requirements-dev.txt
@@ -2,7 +2,5 @@
 
 django-debug-toolbar<1.10
 django-debug-toolbar-template-timings
-Fabric
 sphinx
 pyinotify
-fnpdeploy>=0.2.3
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index 34f4d9a38..7122431cf 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -7,7 +7,6 @@ django-pipeline>=1.6,<1.7
 jsmin
 fnp-django-pagination
 django-maintenancemode==0.10
-django-piston==0.2.2.1.2
 jsonfield>=1.0.3,<1.1
 django-picklefield>=1.0,<1.1
 django-modeltranslation>=0.10,<0.11
diff --git a/src/wolnelektury/migrations/piston/__init__.py b/src/api/piston/__init__.py
similarity index 100%
rename from src/wolnelektury/migrations/piston/__init__.py
rename to src/api/piston/__init__.py
diff --git a/src/api/piston/admin.py b/src/api/piston/admin.py
new file mode 100644
index 000000000..792f19629
--- /dev/null
+++ b/src/api/piston/admin.py
@@ -0,0 +1,7 @@
+from django.contrib import admin
+from . import models
+
+
+admin.site.register(models.Nonce)
+admin.site.register(models.Consumer)
+admin.site.register(models.Token)
diff --git a/src/wolnelektury/migrations/piston/0001_initial.py b/src/api/piston/migrations/0001_initial.py
similarity index 100%
rename from src/wolnelektury/migrations/piston/0001_initial.py
rename to src/api/piston/migrations/0001_initial.py
diff --git a/src/api/piston/migrations/__init__.py b/src/api/piston/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/api/piston/models.py b/src/api/piston/models.py
new file mode 100644
index 000000000..7bb106c81
--- /dev/null
+++ b/src/api/piston/models.py
@@ -0,0 +1,56 @@
+from django.conf import settings
+from django.db import models
+
+
+KEY_SIZE = 18
+SECRET_SIZE = 32
+
+CONSUMER_STATES = (
+    ('pending', 'Pending approval'),
+    ('accepted', 'Accepted'),
+    ('canceled', 'Canceled'),
+)
+
+
+class Nonce(models.Model):
+    token_key = models.CharField(max_length=KEY_SIZE)
+    consumer_key = models.CharField(max_length=KEY_SIZE)
+    key = models.CharField(max_length=255)
+
+    def __unicode__(self):
+        return u"Nonce %s for %s" % (self.key, self.consumer_key)
+
+
+class Resource(models.Model):
+    name = models.CharField(max_length=255)
+    url = models.TextField(max_length=2047)
+    is_readonly = models.BooleanField(default=True)
+
+
+class Consumer(models.Model):
+    name = models.CharField(max_length=255)
+    description = models.TextField()
+    key = models.CharField(max_length=KEY_SIZE)
+    secret = models.CharField(max_length=SECRET_SIZE)
+    status = models.CharField(max_length=16, choices=CONSUMER_STATES, default='pending')
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, related_name='consumers')
+
+    def __unicode__(self):
+        return u"Consumer %s with key %s" % (self.name, self.key)
+
+
+class Token(models.Model):
+    REQUEST = 1
+    ACCESS = 2
+    TOKEN_TYPES = ((REQUEST, u'Request'), (ACCESS, u'Access'))
+
+    key = models.CharField(max_length=KEY_SIZE)
+    secret = models.CharField(max_length=SECRET_SIZE)
+    token_type = models.IntegerField(choices=TOKEN_TYPES)
+    timestamp = models.IntegerField()
+    is_approved = models.BooleanField(default=False)
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, related_name='tokens')
+    consumer = models.ForeignKey(Consumer)
+
+    def __unicode__(self):
+        return u"%s Token %s for %s" % (self.get_token_type_display(), self.key, self.consumer)
diff --git a/src/api/request_validator.py b/src/api/request_validator.py
index 916636b91..55bd6fbdb 100644
--- a/src/api/request_validator.py
+++ b/src/api/request_validator.py
@@ -4,7 +4,7 @@
 #
 import time
 from oauthlib.oauth1 import RequestValidator
-from piston.models import Consumer, Nonce, Token
+from api.piston.models import Consumer, Nonce, Token
 
 
 class PistonRequestValidator(RequestValidator):
diff --git a/src/api/tests/tests.py b/src/api/tests/tests.py
index 298a79433..3b4d4c59e 100644
--- a/src/api/tests/tests.py
+++ b/src/api/tests/tests.py
@@ -17,7 +17,7 @@ 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 api.piston.models import Consumer, Token
 
 from catalogue.models import Book, Tag
 from picture.forms import PictureImportForm
diff --git a/src/api/views.py b/src/api/views.py
index 518445dd0..66c9258e8 100644
--- a/src/api/views.py
+++ b/src/api/views.py
@@ -6,7 +6,7 @@ from django.http import Http404
 from django.views.generic.base import View
 from oauthlib.common import urlencode
 from oauthlib.oauth1 import RequestTokenEndpoint, AccessTokenEndpoint
-from piston.models import KEY_SIZE, SECRET_SIZE
+from api.piston.models import KEY_SIZE, SECRET_SIZE
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
 from rest_framework.views import APIView
diff --git a/src/wolnelektury/settings/apps.py b/src/wolnelektury/settings/apps.py
index c3be29bbe..2c76f67ed 100644
--- a/src/wolnelektury/settings/apps.py
+++ b/src/wolnelektury/settings/apps.py
@@ -50,7 +50,7 @@ INSTALLED_APPS_CONTRIB = [
     'rest_framework',
     'fnp_django_pagination',
     'pipeline',
-    'piston',
+    'api.piston',
     'piwik',
     'sorl.thumbnail',
     'kombu.transport.django',
diff --git a/src/wolnelektury/settings/contrib.py b/src/wolnelektury/settings/contrib.py
index a25ef72d9..f3e7b6ba6 100644
--- a/src/wolnelektury/settings/contrib.py
+++ b/src/wolnelektury/settings/contrib.py
@@ -15,7 +15,6 @@ MODELTRANSLATION_PREPOPULATE_LANGUAGE = 'pl'
 
 MIGRATION_MODULES = {
     'getpaid': 'wolnelektury.migrations.getpaid',
-    'piston': 'wolnelektury.migrations.piston',
 }
 
 GETPAID_ORDER_DESCRIPTION = "{% load funding_tags %}{{ order|sanitize_payment_title }}"
-- 
2.20.1
From b95b09260db87f6665bfcef3fb4f7c19f8acb666 Mon Sep 17 00:00:00 2001
From: Radek Czajka 
Date: Thu, 14 Feb 2019 22:21:19 +0100
Subject: [PATCH 14/16] Django 1.9.
---
 requirements/requirements.txt                 |  19 ++--
 src/api/urls.py                               |   2 +-
 src/catalogue/models/book.py                  |   4 +-
 src/catalogue/models/collection.py            |   1 -
 src/catalogue/urls.py                         | 103 ++++++++----------
 src/catalogue/views.py                        |   1 -
 src/chunks/urls.py                            |  11 +-
 src/contact/admin.py                          |   8 +-
 src/contact/urls.py                           |   8 +-
 src/dictionary/urls.py                        |   7 +-
 src/funding/urls.py                           |  41 ++++---
 src/funding/views.py                          |   4 +-
 src/infopages/urls.py                         |  10 +-
 src/lesmianator/urls.py                       |  19 ++--
 src/libraries/urls.py                         |  14 +--
 src/oai/handlers.py                           |   1 -
 src/oai/urls.py                               |   9 +-
 src/opds/urls.py                              |   7 +-
 src/paypal/tests.py                           |  16 ++-
 src/picture/models.py                         |  28 -----
 src/picture/views.py                          |  21 ----
 src/polls/urls.py                             |  10 +-
 src/push/urls.py                              |   8 +-
 src/reporting/urls.py                         |  14 +--
 src/search/urls.py                            |  13 ++-
 src/social/urls.py                            |  31 ++----
 src/sponsors/urls.py                          |  11 +-
 src/suggest/urls.py                           |   7 +-
 src/waiter/urls.py                            |  10 +-
 src/wolnelektury/settings/apps.py             |   1 -
 src/wolnelektury/settings/basic.py            |   8 +-
 src/wolnelektury/settings/static.py           |   5 -
 .../templates/base/superbase.html             |   2 +-
 src/wolnelektury/templates/openid/login.html  |  18 +--
 .../templates/socialaccount/connections.html  |  12 +-
 35 files changed, 204 insertions(+), 280 deletions(-)
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index 7122431cf..fd98553b0 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -1,15 +1,15 @@
--i https://py.mdrn.pl:8443/simple/
+-i https://py.mdrn.pl/simple/
 
 # django
-Django>=1.8,<1.9
+Django>=1.9,<1.10
 fnpdjango>=0.2.8,<0.3
 django-pipeline>=1.6,<1.7
 jsmin
 fnp-django-pagination
 django-maintenancemode==0.10
-jsonfield>=1.0.3,<1.1
+jsonfield==2.0.2
 django-picklefield>=1.0,<1.1
-django-modeltranslation>=0.10,<0.11
+django-modeltranslation==0.11
 django-allauth>=0.32,<0.33
 django-extensions
 djangorestframework<3.7
@@ -20,12 +20,11 @@ oauthlib>=3.0.1,<3.1
 pyyaml
 
 polib
-django-babel
+django-babel==0.5.0
 
 pytz
 
-django-honeypot
-django-uni-form
+django-honeypot==0.6.0
 
 python-memcached
 django-piwik
@@ -57,10 +56,12 @@ pyoai==2.4.4
 ## egenix-mx-base  # Doesn't play nice with mx in dist-packages.
 sunburnt
 
-django-getpaid>=1.7,<1.8
+#django-getpaid==1.7.4
+-e git+https://github.com/django-getpaid/django-getpaid.git@v1.7.4#egg=django-getpaid
+
 httplib2
 Texml
-django-ssify>=0.2.1,<0.3
+django-ssify>=0.2.6,<0.3
 
 raven
 
diff --git a/src/api/urls.py b/src/api/urls.py
index 16fdfb749..9c8dab26f 100644
--- a/src/api/urls.py
+++ b/src/api/urls.py
@@ -36,7 +36,7 @@ urlpatterns = [
     url(r'^blog/$',
         piwik_track_view(views.BlogView.as_view())),
 
-    url(r'^pictures/$', include('picture.api.urls')),
+    url(r'^pictures/', include('picture.api.urls')),
     url(r'^', include('social.api.urls')),
     url(r'^', include('catalogue.api.urls')),
 ]
diff --git a/src/catalogue/models/book.py b/src/catalogue/models/book.py
index b2289f667..068bae899 100644
--- a/src/catalogue/models/book.py
+++ b/src/catalogue/models/book.py
@@ -205,12 +205,12 @@ class Book(models.Model):
 
     @permalink
     def get_absolute_url(self):
-        return 'catalogue.views.book_detail', [self.slug]
+        return 'book_detail', [self.slug]
 
     @staticmethod
     @permalink
     def create_url(slug):
-        return 'catalogue.views.book_detail', [slug]
+        return 'book_detail', [slug]
 
     def gallery_path(self):
         return gallery_path(self.slug)
diff --git a/src/catalogue/models/collection.py b/src/catalogue/models/collection.py
index e95204341..b765abefe 100644
--- a/src/catalogue/models/collection.py
+++ b/src/catalogue/models/collection.py
@@ -40,7 +40,6 @@ class Collection(models.Model):
     def get_query(self):
         slugs = self.book_slugs.split()
         # allow URIs
-        # WTF
         slugs = [slug.rstrip('/').rsplit('/', 1)[-1] if '/' in slug else slug for slug in slugs]
         return models.Q(slug__in=slugs)
 
diff --git a/src/catalogue/urls.py b/src/catalogue/urls.py
index 50a4a4af9..8b37e0774 100644
--- a/src/catalogue/urls.py
+++ b/src/catalogue/urls.py
@@ -2,54 +2,47 @@
 # 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 patterns, url
+from django.conf.urls import url
 from django.db.models import Max
 from django.views.generic import ListView, RedirectView
 from catalogue.feeds import AudiobookFeed
-from catalogue.views import CustomPDFFormView
 from catalogue.models import Book
+from catalogue import views
+import picture.views
 
 
 SLUG = r'[a-z0-9-]*'
 
-urlpatterns = patterns(
-    'picture.views',
-
-    url(r'^obraz/strona/$', 'picture_page', name='picture_page'),
+urlpatterns = [
+    url(r'^obraz/strona/$', picture.views.picture_page, name='picture_page'),
     # pictures - currently pictures are coupled with catalogue, hence the url is here
-    url(r'^obraz/$', 'picture_list_thumb', name='picture_list_thumb'),
-    url(r'^obraz/(?P%s).html$' % SLUG, 'picture_viewer', name='picture_viewer'),
-    url(r'^obraz/(?P%s)/$' % SLUG, 'picture_detail'),
+    url(r'^obraz/$', picture.views.picture_list_thumb, name='picture_list_thumb'),
+    url(r'^obraz/(?P%s).html$' % SLUG, picture.views.picture_viewer, name='picture_viewer'),
+    url(r'^obraz/(?P%s)/$' % SLUG, picture.views.picture_detail),
 
-    url(r'^p/(?P\d+)/mini\.(?P.+)\.html', 'picture_mini', name='picture_mini'),
-    url(r'^p/(?P\d+)/short\.(?P.+)\.html', 'picture_short', name='picture_short'),
-    url(r'^pa/(?P\d+)/short\.(?P.+)\.html', 'picturearea_short', name='picture_area_short'),
-)
+    url(r'^p/(?P\d+)/mini\.(?P.+)\.html', picture.views.picture_mini, name='picture_mini'),
+    url(r'^p/(?P\d+)/short\.(?P.+)\.html', picture.views.picture_short, name='picture_short'),
+    url(r'^pa/(?P\d+)/short\.(?P.+)\.html', picture.views.picturearea_short, name='picture_area_short'),
 
-urlpatterns += patterns(
-    '',
     # old search page - redirected
     url(r'^szukaj/$', RedirectView.as_view(
             url='/szukaj/', query_string=True, permanent=True)),
-)
 
-urlpatterns += patterns(
-    'catalogue.views',
-    url(r'^$', 'catalogue', name='catalogue'),
+    url(r'^$', views.catalogue, name='catalogue'),
 
-    url(r'^autor/$', 'tag_catalogue', {'category': 'author'}, name='author_catalogue'),
-    url(r'^epoka/$', 'tag_catalogue', {'category': 'epoch'}, name='epoch_catalogue'),
-    url(r'^gatunek/$', 'tag_catalogue', {'category': 'genre'}, name='genre_catalogue'),
-    url(r'^rodzaj/$', 'tag_catalogue', {'category': 'kind'}, name='kind_catalogue'),
-    url(r'^motyw/$', 'tag_catalogue', {'category': 'theme'}, name='theme_catalogue'),
+    url(r'^autor/$', views.tag_catalogue, {'category': 'author'}, name='author_catalogue'),
+    url(r'^epoka/$', views.tag_catalogue, {'category': 'epoch'}, name='epoch_catalogue'),
+    url(r'^gatunek/$', views.tag_catalogue, {'category': 'genre'}, name='genre_catalogue'),
+    url(r'^rodzaj/$', views.tag_catalogue, {'category': 'kind'}, name='kind_catalogue'),
+    url(r'^motyw/$', views.tag_catalogue, {'category': 'theme'}, name='theme_catalogue'),
 
-    url(r'^galeria/$', 'gallery', name='gallery'),
-    url(r'^kolekcje/$', 'collections', name='catalogue_collections'),
+    url(r'^galeria/$', views.gallery, name='gallery'),
+    url(r'^kolekcje/$', views.collections, name='catalogue_collections'),
 
-    url(r'^lektury/$', 'literature', name='book_list'),
-    url(r'^lektury/(?P[a-zA-Z0-9-]+)/$', 'collection', name='collection'),
-    url(r'^audiobooki/$', 'audiobooks', name='audiobook_list'),
-    url(r'^daisy/$', 'daisy_list', name='daisy_list'),
+    url(r'^lektury/$', views.literature, name='book_list'),
+    url(r'^lektury/(?P[a-zA-Z0-9-]+)/$', views.collection, name='collection'),
+    url(r'^audiobooki/$', views.audiobooks, name='audiobook_list'),
+    url(r'^daisy/$', views.daisy_list, name='daisy_list'),
     url(r'^nowe/$', ListView.as_view(
         queryset=Book.objects.filter(parent=None).order_by('-created_at'),
         template_name='catalogue/recent_list.html'), name='recent_list'),
@@ -60,45 +53,45 @@ urlpatterns += patterns(
         queryset=Book.objects.filter(media__type='daisy').annotate(m=Max('media__uploaded_at')).order_by('-m'),
         template_name='catalogue/recent_daisy_list.html'), name='recent_daisy_list'),
 
-    url(r'^custompdf/(?P%s)/$' % SLUG, CustomPDFFormView(), name='custom_pdf_form'),
+    url(r'^custompdf/(?P%s)/$' % SLUG, views.CustomPDFFormView(), name='custom_pdf_form'),
 
     url(r'^audiobooki/(?Pmp3|ogg|daisy|all).xml$', AudiobookFeed(), name='audiobook_feed'),
 
-    url(r'^pobierz/(?P%s).(?P[a-z0-9]*)$' % SLUG, 'embargo_link', name='embargo_link'),
+    url(r'^pobierz/(?P%s).(?P[a-z0-9]*)$' % SLUG, views.embargo_link, name='embargo_link'),
 
     # zip
-    url(r'^zip/pdf\.zip$', 'download_zip', {'format': 'pdf', 'slug': None}, 'download_zip_pdf'),
-    url(r'^zip/epub\.zip$', 'download_zip', {'format': 'epub', 'slug': None}, 'download_zip_epub'),
-    url(r'^zip/mobi\.zip$', 'download_zip', {'format': 'mobi', 'slug': None}, 'download_zip_mobi'),
-    url(r'^zip/mp3/(?P%s)\.zip' % SLUG, 'download_zip', {'format': 'mp3'}, 'download_zip_mp3'),
-    url(r'^zip/ogg/(?P%s)\.zip' % SLUG, 'download_zip', {'format': 'ogg'}, 'download_zip_ogg'),
+    url(r'^zip/pdf\.zip$', views.download_zip, {'format': 'pdf', 'slug': None}, 'download_zip_pdf'),
+    url(r'^zip/epub\.zip$', views.download_zip, {'format': 'epub', 'slug': None}, 'download_zip_epub'),
+    url(r'^zip/mobi\.zip$', views.download_zip, {'format': 'mobi', 'slug': None}, 'download_zip_mobi'),
+    url(r'^zip/mp3/(?P%s)\.zip' % SLUG, views.download_zip, {'format': 'mp3'}, 'download_zip_mp3'),
+    url(r'^zip/ogg/(?P%s)\.zip' % SLUG, views.download_zip, {'format': 'ogg'}, 'download_zip_ogg'),
 
     # Public interface. Do not change this URLs.
-    url(r'^lektura/(?P%s)\.html$' % SLUG, 'book_text', name='book_text'),
-    url(r'^lektura/(?P%s)/audiobook/$' % SLUG, 'player', name='book_player'),
-    url(r'^lektura/(?P%s)/$' % SLUG, 'book_detail', name='book_detail'),
+    url(r'^lektura/(?P%s)\.html$' % SLUG, views.book_text, name='book_text'),
+    url(r'^lektura/(?P%s)/audiobook/$' % SLUG, views.player, name='book_player'),
+    url(r'^lektura/(?P%s)/$' % SLUG, views.book_detail, name='book_detail'),
     url(r'^lektura/(?P%s)/motyw/(?P[a-zA-Z0-9-]+)/$' % SLUG,
-        'book_fragments', name='book_fragments'),
+        views.book_fragments, name='book_fragments'),
 
-    url(r'^okladka-ridero/(?P%s).png$' % SLUG, 'ridero_cover'),
-    url(r'^isbn/(?P(pdf|epub|mobi|txt|html))/(?P%s)/' % SLUG, 'get_isbn'),
+    url(r'^okladka-ridero/(?P%s).png$' % SLUG, views.ridero_cover),
+    url(r'^isbn/(?P(pdf|epub|mobi|txt|html))/(?P%s)/' % SLUG, views.get_isbn),
 
     # Includes.
-    url(r'^b/(?P\d+)/mini\.(?P.+)\.html', 'book_mini', name='catalogue_book_mini'),
-    url(r'^b/(?P\d+)/mini_nolink\.(?P.+)\.html', 'book_mini', {'with_link': False},
+    url(r'^b/(?P\d+)/mini\.(?P.+)\.html', views.book_mini, name='catalogue_book_mini'),
+    url(r'^b/(?P\d+)/mini_nolink\.(?P.+)\.html', views.book_mini, {'with_link': False},
         name='catalogue_book_mini_nolink'),
-    url(r'^b/(?P\d+)/short\.(?P.+)\.html', 'book_short', name='catalogue_book_short'),
-    url(r'^b/(?P\d+)/wide\.(?P.+)\.html', 'book_wide', name='catalogue_book_wide'),
-    url(r'^f/(?P\d+)/promo\.(?P.+)\.html', 'fragment_promo', name='catalogue_fragment_promo'),
-    url(r'^f/(?P\d+)/short\.(?P.+)\.html', 'fragment_short', name='catalogue_fragment_short'),
-    url(r'^t/(?P\d+)/box\.(?P.+)\.html', 'tag_box', name='catalogue_tag_box'),
-    url(r'^c/(?P.+)/box\.(?P.+)\.html', 'collection_box', name='catalogue_collection_box'),
+    url(r'^b/(?P\d+)/short\.(?P.+)\.html', views.book_short, name='catalogue_book_short'),
+    url(r'^b/(?P\d+)/wide\.(?P.+)\.html', views.book_wide, name='catalogue_book_wide'),
+    url(r'^f/(?P\d+)/promo\.(?P.+)\.html', views.fragment_promo, name='catalogue_fragment_promo'),
+    url(r'^f/(?P\d+)/short\.(?P.+)\.html', views.fragment_short, name='catalogue_fragment_short'),
+    url(r'^t/(?P\d+)/box\.(?P.+)\.html', views.tag_box, name='catalogue_tag_box'),
+    url(r'^c/(?P.+)/box\.(?P.+)\.html', views.collection_box, name='catalogue_collection_box'),
 
     # This should be the last pattern.
-    url(r'^galeria/(?P[a-zA-Z0-9-/]*)/$', 'tagged_object_list', {'list_type': 'gallery'},
+    url(r'^galeria/(?P[a-zA-Z0-9-/]*)/$', views.tagged_object_list, {'list_type': 'gallery'},
         name='tagged_object_list_gallery'),
-    url(r'^audiobooki/(?P[a-zA-Z0-9-/]*)/$', 'tagged_object_list', {'list_type': 'audiobooks'},
+    url(r'^audiobooki/(?P[a-zA-Z0-9-/]*)/$', views.tagged_object_list, {'list_type': 'audiobooks'},
         name='tagged_object_list_audiobooks'),
-    url(r'^(?P[a-zA-Z0-9-/]*)/$', 'tagged_object_list', {'list_type': 'books'},
+    url(r'^(?P[a-zA-Z0-9-/]*)/$', views.tagged_object_list, {'list_type': 'books'},
         name='tagged_object_list'),
-)
+]
diff --git a/src/catalogue/views.py b/src/catalogue/views.py
index f6d6cd5eb..2e43bd081 100644
--- a/src/catalogue/views.py
+++ b/src/catalogue/views.py
@@ -52,7 +52,6 @@ def book_list(request, filters=None, template_name='catalogue/book_list.html',
     for tag in books_by_author:
         if books_by_author[tag]:
             books_nav.setdefault(tag.sort_key[0], []).append(tag)
-    # WTF: dlaczego nie include?
     return render_to_response(template_name, {
         'rendered_nav': render_to_string(nav_template_name, {'books_nav': books_nav}),
         'rendered_book_list': render_to_string(list_template_name, {
diff --git a/src/chunks/urls.py b/src/chunks/urls.py
index 89472f961..0536a18b9 100644
--- a/src/chunks/urls.py
+++ b/src/chunks/urls.py
@@ -2,9 +2,10 @@
 # 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 patterns, url
+from django.conf.urls import url
+from . import views
 
-urlpatterns = patterns(
-    'chunks.views',
-    url(r'^chunk/(?P.+)\.(?P.+)\.html$', 'chunk', name='chunk'),
-)
+
+urlpatterns = [
+    url(r'^chunk/(?P.+)\.(?P.+)\.html$', views.chunk, name='chunk'),
+]
diff --git a/src/contact/admin.py b/src/contact/admin.py
index af14c6b02..41095092e 100644
--- a/src/contact/admin.py
+++ b/src/contact/admin.py
@@ -5,7 +5,7 @@ import json
 from django.contrib import admin
 from django.utils.translation import ugettext as _
 from django.utils.safestring import mark_safe
-from django.conf.urls import patterns, url
+from django.conf.urls import url
 from django.http import HttpResponse, Http404
 
 from wolnelektury.utils import UnicodeCSVWriter
@@ -104,12 +104,10 @@ class ContactAdmin(admin.ModelAdmin):
         return super(ContactAdmin, self).changelist_view(request, extra_context=context)
 
     def get_urls(self):
-        # urls = super(ContactAdmin, self).get_urls()
-        return patterns(
-            '',
+        return [
             url(r'^extract/(?P[\w-]+)/(?P[\w-]+)/$',
                 self.admin_site.admin_view(extract_view), name='contact_extract')
-        ) + super(ContactAdmin, self).get_urls()
+        ] + super(ContactAdmin, self).get_urls()
 
 
 def extract_view(request, form_tag, extract_type_slug):
diff --git a/src/contact/urls.py b/src/contact/urls.py
index f2ef9444e..c475cd429 100644
--- a/src/contact/urls.py
+++ b/src/contact/urls.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
-from django.conf.urls import patterns, url
+from django.conf.urls import url
 from . import views
 
-urlpatterns = patterns(
-    'contact.views',
+
+urlpatterns = [
     url(r'^(?P[^/]+)/$', views.form, name='contact_form'),
     url(r'^(?P[^/]+)/thanks/$', views.thanks, name='contact_thanks'),
     url(r'^attachment/(?P\d+)/(?P[^/]+)/$', views.attachment, name='contact_attachment'),
     url(r'^results/(?P\d+)/(?P[0-9a-f]+)/', views.results, name='contact_results'),
-)
+]
diff --git a/src/dictionary/urls.py b/src/dictionary/urls.py
index 6df9a4785..a5582a17a 100755
--- a/src/dictionary/urls.py
+++ b/src/dictionary/urls.py
@@ -2,10 +2,9 @@
 # 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 patterns, url
+from django.conf.urls import url
 from dictionary.views import NotesView
 
-urlpatterns = patterns(
-    'dictionary.views',
+urlpatterns = [
     url(r'^$', NotesView.as_view(), name='dictionary_notes'),
-)
+]
diff --git a/src/funding/urls.py b/src/funding/urls.py
index 1f0d494eb..f8afc66bb 100644
--- a/src/funding/urls.py
+++ b/src/funding/urls.py
@@ -2,33 +2,30 @@
 # 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 patterns, url, include
+from django.conf.urls import url, include
+from . import views
 
-from .views import WLFundView, OfferDetailView, OfferListView, ThanksView, NoThanksView, CurrentView, \
-    DisableNotifications
 
+urlpatterns = [
+    url(r'^$', views.CurrentView.as_view(), name='funding_current'),
+    url(r'^teraz/$', views.CurrentView.as_view()),
+    url(r'^teraz/(?P[^/]+)/$', views.CurrentView.as_view(), name='funding_current'),
+    url(r'^lektura/$', views.OfferListView.as_view(), name='funding'),
+    url(r'^lektura/(?P[^/]+)/$', views.OfferDetailView.as_view(), name='funding_offer'),
+    url(r'^pozostale/$', views.WLFundView.as_view(), name='funding_wlfund'),
 
-urlpatterns = patterns(
-    'funding.views',
-    url(r'^$', CurrentView.as_view(), name='funding_current'),
-    url(r'^teraz/$', CurrentView.as_view()),
-    url(r'^teraz/(?P[^/]+)/$', CurrentView.as_view(), name='funding_current'),
-    url(r'^lektura/$', OfferListView.as_view(), name='funding'),
-    url(r'^lektura/(?P[^/]+)/$', OfferDetailView.as_view(), name='funding_offer'),
-    url(r'^pozostale/$', WLFundView.as_view(), name='funding_wlfund'),
+    url(r'^dziekujemy/$', views.ThanksView.as_view(), name='funding_thanks'),
+    url(r'^niepowodzenie/$', views.NoThanksView.as_view(), name='funding_nothanks'),
 
-    url(r'^dziekujemy/$', ThanksView.as_view(), name='funding_thanks'),
-    url(r'^niepowodzenie/$', NoThanksView.as_view(), name='funding_nothanks'),
-
-    url(r'^wylacz_email/$', DisableNotifications.as_view(), name='funding_disable_notifications'),
+    url(r'^wylacz_email/$', views.DisableNotifications.as_view(), name='funding_disable_notifications'),
 
     url(r'^getpaid/', include('getpaid.urls')),
 
     # Includes
-    url(r'^o/(?P\d+)/top-bar\.(?P.+)\.html$', 'top_bar', name='funding_top_bar'),
-    url(r'^o/(?P\d+)/detail-bar\.(?P.+)\.html$', 'detail_bar', name='funding_detail_bar'),
-    url(r'^o/(?P\d+)/list-bar\.(?P.+)\.html$', 'list_bar', name='funding_list_bar'),
-    url(r'^o/(?P\d+)/status\.(?P.+)\.html$', 'offer_status', name='funding_status'),
-    url(r'^o/(?P\d+)/status-more\.(?P.+)\.html$', 'offer_status_more', name='funding_status_more'),
-    url(r'^o/(?P\d+)/fundings/(?P\d+)\.(?P.+)\.html$', 'offer_fundings', name='funding_fundings'),
-)
+    url(r'^o/(?P\d+)/top-bar\.(?P.+)\.html$', views.top_bar, name='funding_top_bar'),
+    url(r'^o/(?P\d+)/detail-bar\.(?P.+)\.html$', views.detail_bar, name='funding_detail_bar'),
+    url(r'^o/(?P\d+)/list-bar\.(?P.+)\.html$', views.list_bar, name='funding_list_bar'),
+    url(r'^o/(?P\d+)/status\.(?P.+)\.html$', views.offer_status, name='funding_status'),
+    url(r'^o/(?P\d+)/status-more\.(?P.+)\.html$', views.offer_status_more, name='funding_status_more'),
+    url(r'^o/(?P\d+)/fundings/(?P\d+)\.(?P.+)\.html$', views.offer_fundings, name='funding_fundings'),
+]
diff --git a/src/funding/views.py b/src/funding/views.py
index 18000c05d..74530a4af 100644
--- a/src/funding/views.py
+++ b/src/funding/views.py
@@ -88,7 +88,9 @@ class OfferDetailView(FormView):
                     raise Http404
         return super(OfferDetailView, self).dispatch(request, slug)
 
-    def get_form(self, form_class):
+    def get_form(self, form_class=None):
+        if form_class is None:
+            form_class = self.get_form_class()
         if self.request.method == 'POST':
             return form_class(self.object, self.request.POST)
         else:
diff --git a/src/infopages/urls.py b/src/infopages/urls.py
index 37025a1e4..ffae43966 100755
--- a/src/infopages/urls.py
+++ b/src/infopages/urls.py
@@ -2,10 +2,10 @@
 # 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 patterns, url
+from django.conf.urls import url
+from . import views
 
 
-urlpatterns = patterns(
-    'infopages.views',
-    url(r'^(?P[a-zA-Z0-9_-]+)/$', 'infopage', name='infopage'),
-)
+urlpatterns = [
+    url(r'^(?P[a-zA-Z0-9_-]+)/$', views.infopage, name='infopage'),
+]
diff --git a/src/lesmianator/urls.py b/src/lesmianator/urls.py
index 37e363f8c..2685e83e2 100644
--- a/src/lesmianator/urls.py
+++ b/src/lesmianator/urls.py
@@ -2,13 +2,14 @@
 # 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, patterns
+from django.conf.urls import url
+from . import views
 
-urlpatterns = patterns(
-    'lesmianator.views',
-    url(r'^$', 'main_page', name='lesmianator'),
-    url(r'^wiersz/$', 'new_poem', name='new_poem'),
-    url(r'^lektura/(?P[a-z0-9-]+)/$', 'poem_from_book', name='poem_from_book'),
-    url(r'^polka/(?P[a-zA-Z0-9-]+)/$', 'poem_from_set', name='poem_from_set'),
-    url(r'^wiersz/(?P[a-zA-Z0-9-]+)/$', 'get_poem', name='get_poem'),
-)
+
+urlpatterns = [
+    url(r'^$', views.main_page, name='lesmianator'),
+    url(r'^wiersz/$', views.new_poem, name='new_poem'),
+    url(r'^lektura/(?P[a-z0-9-]+)/$', views.poem_from_book, name='poem_from_book'),
+    url(r'^polka/(?P[a-zA-Z0-9-]+)/$', views.poem_from_set, name='poem_from_set'),
+    url(r'^wiersz/(?P[a-zA-Z0-9-]+)/$', views.get_poem, name='get_poem'),
+]
diff --git a/src/libraries/urls.py b/src/libraries/urls.py
index ae1635948..897da151f 100644
--- a/src/libraries/urls.py
+++ b/src/libraries/urls.py
@@ -2,12 +2,12 @@
 # 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 patterns, url
+from django.conf.urls import url
+from . import views
 
 
-urlpatterns = patterns(
-    'libraries.views',
-    url(r'^$', 'main_view', name='libraries_main_view'),
-    url(r'^(?P[a-zA-Z0-9_-]+)$', 'catalog_view', name='libraries_catalog_view'),
-    url(r'^(?P[a-zA-Z0-9_-]+)/(?P[a-zA-Z0-9_-]+)$', 'library_view', name='libraries_library_view'),
-)
+urlpatterns = [
+    url(r'^$', views.main_view, name='libraries_main_view'),
+    url(r'^(?P[a-zA-Z0-9_-]+)$', views.catalog_view, name='libraries_catalog_view'),
+    url(r'^(?P[a-zA-Z0-9_-]+)/(?P[a-zA-Z0-9_-]+)$', views.library_view, name='libraries_library_view'),
+]
diff --git a/src/oai/handlers.py b/src/oai/handlers.py
index 6fbcd4271..0eeea8c0d 100644
--- a/src/oai/handlers.py
+++ b/src/oai/handlers.py
@@ -56,7 +56,6 @@ def nsdcterms(name):
     return '{%s}%s' % (NS_DCTERMS, name)
 
 
-# WTF
 class Catalogue(common.ResumptionOAIPMH):
     TAG_CATEGORIES = ['author', 'epoch', 'kind', 'genre']
 
diff --git a/src/oai/urls.py b/src/oai/urls.py
index 13ea9f12a..205d5e6c9 100644
--- a/src/oai/urls.py
+++ b/src/oai/urls.py
@@ -2,7 +2,10 @@
 # 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 patterns, url
+from django.conf.urls import url
+from . import views
 
-urlpatterns = patterns('oai.views',
-                       url(r'^$', 'oaipmh', name='oaipmh'))
+
+urlpatterns = [
+    url(r'^$', views.oaipmh, name='oaipmh')
+]
diff --git a/src/opds/urls.py b/src/opds/urls.py
index edf928297..8f1059f1e 100644
--- a/src/opds/urls.py
+++ b/src/opds/urls.py
@@ -2,16 +2,15 @@
 # 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 patterns, url
+from django.conf.urls import url
 from opds.views import RootFeed, ByCategoryFeed, ByTagFeed, UserFeed, UserSetFeed, SearchFeed
 
 
-urlpatterns = patterns(
-    'opds.views',
+urlpatterns = [
     url(r'^$', RootFeed(), name="opds_authors"),
     url(r'^search/$', SearchFeed(), name="opds_search"),
     url(r'^user/$', UserFeed(), name="opds_user"),
     url(r'^set/(?P[a-zA-Z0-9-]+)/$', UserSetFeed(), name="opds_user_set"),
     url(r'^(?P[a-zA-Z0-9-]+)/$', ByCategoryFeed(), name="opds_by_category"),
     url(r'^(?P[a-zA-Z0-9-]+)/(?P[a-zA-Z0-9-]+)/$', ByTagFeed(), name="opds_by_tag"),
-)
+]
diff --git a/src/paypal/tests.py b/src/paypal/tests.py
index e38fd8476..a7badbe83 100644
--- a/src/paypal/tests.py
+++ b/src/paypal/tests.py
@@ -167,11 +167,15 @@ class PaypalTests(WLTestCase):
         self.client.login(username='test', password='test')
         BillingPlan.objects.create(amount=100)
         response = self.client.get('/paypal/app-return/?token=secret-token')
-        self.assertRedirects(response, 'wolnelekturyapp://paypal_return')
+        self.assertRedirects(
+            response, 'wolnelekturyapp://paypal_return',
+            fetch_redirect_response=False)
 
         # Repeated returns will not generate further agreements.
         response = self.client.get('/paypal/app-return/?token=secret-token')
-        self.assertRedirects(response, 'wolnelekturyapp://paypal_return')
+        self.assertRedirects(
+            response, 'wolnelekturyapp://paypal_return',
+            fetch_redirect_response=False)
         self.assertEqual(BillingAgreement.objects.all().count(), 1)
 
         self.assertTrue(user_is_subscribed(self.user))
@@ -185,7 +189,9 @@ class PaypalTests(WLTestCase):
                 execute=Mock(return_value=Mock(id=None)))):
             self.client.get('/paypal/app-return/?token=secret-token')
             response = self.client.get('/paypal/app-return/?token=secret-token')
-            self.assertRedirects(response, 'wolnelekturyapp://paypal_error')
+            self.assertRedirects(
+                response, 'wolnelekturyapp://paypal_error',
+                fetch_redirect_response=False)
 
         # No agreement created in our DB if not executed successfully.
         self.assertEqual(BillingAgreement.objects.all().count(), 0)
@@ -195,7 +201,9 @@ class PaypalTests(WLTestCase):
                 execute=BillingAgreementMock.execute,
                 find=Mock(side_effect=ResourceNotFound(None)))):
             response = self.client.get('/paypal/app-return/?token=secret-token')
-            self.assertRedirects(response, 'wolnelekturyapp://paypal_return')
+            self.assertRedirects(
+                response, 'wolnelekturyapp://paypal_return',
+                fetch_redirect_response=False)
 
         # Now the agreement exists in our DB, but is not active.
         self.assertEqual([b.active for b in BillingAgreement.objects.all()], [False])
diff --git a/src/picture/models.py b/src/picture/models.py
index 643149a1a..e20325702 100644
--- a/src/picture/models.py
+++ b/src/picture/models.py
@@ -9,7 +9,6 @@ from sorl.thumbnail import ImageField
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.files.storage import FileSystemStorage
-from django.utils.datastructures import SortedDict
 from slugify import slugify
 from ssify import flush_ssi_includes
 
@@ -326,33 +325,6 @@ class Picture(models.Model):
         annotated.paste(annotation.resize((img.size[0], 40), Image.ANTIALIAS), (0, img.size[1]))
         return annotated
 
-    # WTF/unused
-    @classmethod
-    def picture_list(cls, filter=None):
-        """Generates a hierarchical listing of all pictures
-        Pictures are optionally filtered with a test function.
-        """
-
-        pics = cls.objects.all().order_by('sort_key').only('title', 'slug', 'image_file')
-
-        if filter:
-            pics = pics.filter(filter).distinct()
-
-        pics_by_author = SortedDict()
-        orphans = []
-        for tag in catalogue.models.Tag.objects.filter(category='author'):
-            pics_by_author[tag] = []
-
-        for pic in pics.iterator():
-            authors = list(pic.authors().only('pk'))
-            if authors:
-                for author in authors:
-                    pics_by_author[author].append(pic)
-            else:
-                orphans.append(pic)
-
-        return pics_by_author, orphans
-
     @property
     def info(self):
         if not hasattr(self, '_info'):
diff --git a/src/picture/views.py b/src/picture/views.py
index 6ace13427..6f02007e0 100644
--- a/src/picture/views.py
+++ b/src/picture/views.py
@@ -10,23 +10,6 @@ from picture.models import Picture, PictureArea
 from catalogue.utils import split_tags
 from ssify import ssi_included
 from sponsors.models import Sponsor
-
-
-# WTF/unused
-# # was picture/picture_list.html list (without thumbs)
-# def picture_list(request, filter=None, get_filter=None, template_name='catalogue/picture_list.html',
-#                  cache_key=None, context=None):
-#     """ generates a listing of all books, optionally filtered with a test function """
-#
-#     if get_filter:
-#         filt = get_filter()
-#     pictures_by_author, orphans = Picture.picture_list(filt)
-#     books_nav = OrderedDict()
-#     for tag in pictures_by_author:
-#         if pictures_by_author[tag]:
-#             books_nav.setdefault(tag.sort_key[0], []).append(tag)
-#
-#     return render_to_response(template_name, locals(), context_instance=RequestContext(request))
 from wolnelektury.utils import ajax
 
 
@@ -45,10 +28,6 @@ def picture_detail(request, slug):
 
     theme_things = split_tags(picture.related_themes())
 
-    # categories = SortedDict()
-    # for tag in picture.tags.iterator():
-    #     categories.setdefault(tag.category, []).append(tag)
-
     return render_to_response("picture/picture_detail.html", {
         'picture': picture,
         'themes': theme_things.get('theme', []),
diff --git a/src/polls/urls.py b/src/polls/urls.py
index 0bec684e5..8a5f62136 100644
--- a/src/polls/urls.py
+++ b/src/polls/urls.py
@@ -2,10 +2,10 @@
 # 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 patterns, url
+from django.conf.urls import url
+from . import views
 
 
-urlpatterns = patterns(
-    'polls.views',
-    url(r'^(?P[^/]+)$', 'poll', name='poll'),
-)
+urlpatterns = [
+    url(r'^(?P[^/]+)$', views.poll, name='poll'),
+]
diff --git a/src/push/urls.py b/src/push/urls.py
index 70a8487be..173b2dd18 100644
--- a/src/push/urls.py
+++ b/src/push/urls.py
@@ -2,11 +2,11 @@
 # 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 patterns, url
-
+from django.conf.urls import url
 from push import views
 
-urlpatterns = (
+
+urlpatterns = [
     url(r'^wyslij/$', views.notification_form, name='notification_form'),
     url(r'^wyslane/$', views.notification_sent, name='notification_sent'),
-)
+]
diff --git a/src/reporting/urls.py b/src/reporting/urls.py
index 3b09f1152..f4a65f44b 100755
--- a/src/reporting/urls.py
+++ b/src/reporting/urls.py
@@ -2,12 +2,12 @@
 # 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 patterns, url
+from django.conf.urls import url
+from . import views
 
 
-urlpatterns = patterns(
-    'reporting.views',
-    url(r'^$', 'stats_page', name='reporting_stats'),
-    url(r'^katalog.pdf$', 'catalogue_pdf', name='reporting_catalogue_pdf'),
-    url(r'^katalog.csv$', 'catalogue_csv', name='reporting_catalogue_csv'),
-)
+urlpatterns = [
+    url(r'^$', views.stats_page, name='reporting_stats'),
+    url(r'^katalog.pdf$', views.catalogue_pdf, name='reporting_catalogue_pdf'),
+    url(r'^katalog.csv$', views.catalogue_csv, name='reporting_catalogue_csv'),
+]
diff --git a/src/search/urls.py b/src/search/urls.py
index 5a59422b5..46914d6d2 100644
--- a/src/search/urls.py
+++ b/src/search/urls.py
@@ -2,10 +2,11 @@
 # 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 patterns, url
+from django.conf.urls import url
+from . import views
 
-urlpatterns = patterns(
-    'search.views',
-    url(r'^$', 'main', name='wlsearch'),
-    url(r'^hint/$', 'hint', name='search_hint'),
-)
+
+urlpatterns = [
+    url(r'^$', views.main, name='wlsearch'),
+    url(r'^hint/$', views.hint, name='search_hint'),
+]
diff --git a/src/social/urls.py b/src/social/urls.py
index f9222ce93..395e54ac1 100755
--- a/src/social/urls.py
+++ b/src/social/urls.py
@@ -2,26 +2,19 @@
 # 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 patterns, url
+from django.conf.urls import url
 from django.views.decorators.cache import never_cache
-from social.views import ObjectSetsFormView
+from . import views
 
-urlpatterns = patterns(
-    'social.views',
-    url(r'^lektura/(?P[a-z0-9-]+)/lubie/$', 'like_book', name='social_like_book'),
-    url(r'^lektura/(?P[a-z0-9-]+)/nie_lubie/$', 'unlike_book', name='social_unlike_book'),
-    url(r'^lektura/(?P[a-z0-9-]+)/polki/$', never_cache(ObjectSetsFormView()), name='social_book_sets'),
-    url(r'^polka/$', 'my_shelf', name='social_my_shelf'),
 
-    # Includes
-    url(r'^cite/(?P\d+)\.(?P.+)\.html$', 'cite', name='social_cite'),
-    url(r'^cite_main/(?P\d+)\.(?P.+)\.html$', 'cite', {'main': True}, name='social_cite_main'),
-    url(r'^cite_info/(?P\d+).html$', 'cite_info', name='social_cite_info'),
+urlpatterns = [
+    url(r'^lektura/(?P[a-z0-9-]+)/lubie/$', views.like_book, name='social_like_book'),
+    url(r'^lektura/(?P[a-z0-9-]+)/nie_lubie/$', views.unlike_book, name='social_unlike_book'),
+    url(r'^lektura/(?P[a-z0-9-]+)/polki/$', never_cache(views.ObjectSetsFormView()), name='social_book_sets'),
+    url(r'^polka/$', views.my_shelf, name='social_my_shelf'),
 
-    # url(r'^polki/(?P[a-zA-Z0-9-]+)/formaty/$', 'shelf_book_formats', name='shelf_book_formats'),
-    # url(r'^polki/(?P[a-zA-Z0-9-]+)/(?P%s)/usun$' % SLUG, 'remove_from_shelf', name='remove_from_shelf'),
-    # url(r'^polki/$', 'user_shelves', name='user_shelves'),
-    # url(r'^polki/(?P[a-zA-Z0-9-]+)/usun/$', 'delete_shelf', name='delete_shelf'),
-    # url(r'^polki/(?P[a-zA-Z0-9-]+)\.zip$', 'download_shelf', name='download_shelf'),
-    # url(r'^polki/nowa/$', 'new_set', name='new_set'),
-)
+    # Includes
+    url(r'^cite/(?P\d+)\.(?P.+)\.html$', views.cite, name='social_cite'),
+    url(r'^cite_main/(?P\d+)\.(?P.+)\.html$', views.cite, {'main': True}, name='social_cite_main'),
+    url(r'^cite_info/(?P\d+).html$', views.cite_info, name='social_cite_info'),
+]
diff --git a/src/sponsors/urls.py b/src/sponsors/urls.py
index 7c6bc2ce6..fe3898678 100644
--- a/src/sponsors/urls.py
+++ b/src/sponsors/urls.py
@@ -2,9 +2,10 @@
 # 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 patterns, url
+from django.conf.urls import url
+from . import views
 
-urlpatterns = patterns(
-    'sponsors.views',
-    url(r'^page/(?P.+)\.html$', 'page', name='sponsor_page'),
-)
+
+urlpatterns = [
+    url(r'^page/(?P.+)\.html$', views.page, name='sponsor_page'),
+]
diff --git a/src/suggest/urls.py b/src/suggest/urls.py
index 5e721d45e..167a1a82f 100644
--- a/src/suggest/urls.py
+++ b/src/suggest/urls.py
@@ -2,11 +2,10 @@
 # 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 patterns, url
+from django.conf.urls import url
 from suggest import views
 
-urlpatterns = patterns(
-    '',
+urlpatterns = [
     url(r'^$', views.SuggestionFormView(), name='suggest'),
     url(r'^plan/$', views.PublishingSuggestionFormView(), name='suggest_publishing'),
-)
+]
diff --git a/src/waiter/urls.py b/src/waiter/urls.py
index 69ab03a85..51d5f6e95 100644
--- a/src/waiter/urls.py
+++ b/src/waiter/urls.py
@@ -2,9 +2,9 @@
 # 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 patterns, url
+from django.conf.urls import url
+from waiter import views
 
-urlpatterns = patterns(
-    'waiter.views',
-    url(r'^(?P.*)$', 'wait', name='waiter'),
-)
+urlpatterns = [
+    url(r'^(?P.*)$', views.wait, name='waiter'),
+]
diff --git a/src/wolnelektury/settings/apps.py b/src/wolnelektury/settings/apps.py
index 2c76f67ed..c98944b39 100644
--- a/src/wolnelektury/settings/apps.py
+++ b/src/wolnelektury/settings/apps.py
@@ -68,7 +68,6 @@ INSTALLED_APPS_CONTRIB = [
     'django_gravatar',
 
     # allauth stuff
-    'uni_form',
     'allauth',
     'allauth.account',
     'allauth.socialaccount',
diff --git a/src/wolnelektury/settings/basic.py b/src/wolnelektury/settings/basic.py
index 7990f2535..ebadbb8e0 100644
--- a/src/wolnelektury/settings/basic.py
+++ b/src/wolnelektury/settings/basic.py
@@ -49,10 +49,10 @@ TEMPLATES = [{
         ),
         'context_processors': (
             'django.contrib.auth.context_processors.auth',
-            'django.core.context_processors.debug',
-            'django.core.context_processors.i18n',
-            'django.core.context_processors.media',
-            'django.core.context_processors.request',
+            'django.template.context_processors.debug',
+            'django.template.context_processors.i18n',
+            'django.template.context_processors.media',
+            'django.template.context_processors.request',
             'wolnelektury.context_processors.extra_settings',
             'search.context_processors.search_form',
         ),
diff --git a/src/wolnelektury/settings/static.py b/src/wolnelektury/settings/static.py
index 3c1997d59..146e35bae 100644
--- a/src/wolnelektury/settings/static.py
+++ b/src/wolnelektury/settings/static.py
@@ -33,9 +33,6 @@ PIPELINE = {
 
                 'css/annoy.css',
 
-                'uni_form/uni-form.css',
-                'uni_form/default.uni-form.css',
-
                 'css/ui-lightness/jquery-ui-1.8.16.custom.css',
 
                 'css/tlite.css',
@@ -112,8 +109,6 @@ PIPELINE = {
                 'js/search.js',
                 'funding/funding.js',
 
-                'uni_form/uni-form.js',
-
                 'js/annoy.js',
                 ),
             'output_filename': 'js/base.min.js',
diff --git a/src/wolnelektury/templates/base/superbase.html b/src/wolnelektury/templates/base/superbase.html
index f5feb4ed7..12a6565ea 100644
--- a/src/wolnelektury/templates/base/superbase.html
+++ b/src/wolnelektury/templates/base/superbase.html
@@ -68,7 +68,7 @@