update master
authorRadek Czajka <rczajka@rczajka.pl>
Fri, 14 Mar 2025 08:05:15 +0000 (09:05 +0100)
committerRadek Czajka <rczajka@rczajka.pl>
Fri, 14 Mar 2025 08:05:15 +0000 (09:05 +0100)
40 files changed:
requirements/requirements.txt
src/annoy/templates/annoy/banner_top.html [new file with mode: 0644]
src/annoy/templatetags/annoy.py
src/api/fields.py
src/api/migrations/0008_alter_token_token_type.py [new file with mode: 0644]
src/api/models.py
src/api/pagination.py [new file with mode: 0644]
src/api/serializers.py
src/api/tests/res/responses/books.xml
src/api/urls.py
src/api/views.py
src/catalogue/api/serializers.py
src/catalogue/api/urls2.py [new file with mode: 0644]
src/catalogue/api/views.py
src/catalogue/fields.py
src/catalogue/migrations/0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more.py [new file with mode: 0644]
src/catalogue/models/book.py
src/catalogue/templates/catalogue/book_text.html
src/catalogue/test_utils.py
src/club/admin.py
src/club/models.py
src/club/templates/admin/club/schedule/change_list.html
src/club/urls.py
src/club/views.py
src/pdcounter/templates/pdcounter/author_detail.html
src/pdcounter/templates/pdcounter/book_detail.html
src/social/api/views.py
src/social/migrations/0017_userconfirmation.py [new file with mode: 0644]
src/social/models.py
src/social/templates/social/user_confirmation.html [new file with mode: 0644]
src/social/urls.py
src/social/views.py
src/wolnelektury/settings/apps.py
src/wolnelektury/settings/contrib.py
src/wolnelektury/settings/custom.py
src/wolnelektury/static/2022/styles/layout/_annoy.scss
src/wolnelektury/static/2022/styles/layout/_text.scss
src/wolnelektury/static/js/book_text/menu.js
src/wolnelektury/static/js/main.js
src/wolnelektury/templates/header.html

index 66e4569..084f0da 100644 (file)
@@ -3,20 +3,21 @@
 # django
 Django==4.0.8
 fnpdjango==0.6
-docutils==0.16
+docutils==0.20
 
-django-pipeline==2.0.8
-libsasscompiler==0.1.9
+django-pipeline==3.1.0
+libsasscompiler==0.2.0
 jsmin==3.0.1
 
 fnp-django-pagination==2.2.5
-django-modeltranslation==0.18.4
+django-modeltranslation==0.18.12
 django-allauth==0.51
-django-extensions==3.2.1
-djangorestframework==3.13.1
+django-extensions==3.2.3
+djangorestframework==3.15.1
+django-filter==23.5
 djangorestframework-xml==2.0.0
-django-admin-ordering==0.16
-django-countries==7.3.2
+django-admin-ordering==0.18
+django-countries==7.6.1
 
 -e git+https://github.com/rczajka/django-forms-builder@270cc22f80ec4681735b4e01689562fbab79ceda#egg=django-forms-builder
 
@@ -26,36 +27,36 @@ oauthlib>=3.0.1,<3.1
 # contact
 pyyaml==5.4.1
 
-polib==1.1.1
+polib==1.2
 
-django-honeypot==1.0.3
+django-honeypot==1.2.1
 
 python-fb==0.2
 
-Feedparser==6.0.10
+Feedparser==6.0.11
 
-Pillow==9.2.0
-mutagen==1.45.1
-sorl-thumbnail==12.8.0
+Pillow==9.5.0
+mutagen==1.47
+sorl-thumbnail==12.10.0
 
 # home-brewed & dependencies
-librarian==24.5.4
+librarian==24.5.8
 
 # celery tasks
-celery[redis]==5.2.7
+celery[redis]==5.4.0
 
 # OAI-PMH
 #pyoai==2.5.1
 -e git+https://github.com/infrae/pyoai@5ff2f15e869869e70d8139e4c37b7832854d7049#egg=pyoai
 
-sentry-sdk==0.10.2
+sentry-sdk==2.19.2
 
-requests
+requests==2.32.3
 
 paypalrestsdk
 
-python-slugify
+python-slugify==8.0.4
 
-firebase-admin
-Wikidata==0.7.0
+firebase-admin==6.6.0
+Wikidata==0.8.1
 
diff --git a/src/annoy/templates/annoy/banner_top.html b/src/annoy/templates/annoy/banner_top.html
new file mode 100644 (file)
index 0000000..2a62d11
--- /dev/null
@@ -0,0 +1,51 @@
+{% load l10n %}
+
+{% if banner %}
+<div class="
+           annoy-banner_{{ banner.place }}-container
+            annoy-banner-style_{{ banner.style }}
+           ">
+  <div class="
+              annoy-banner
+              annoy-banner_{{ banner.place }}
+              {% if banner.image %}with-image{% endif %}
+              {% if banner.smallfont %}banner-smallfont{% endif %}
+              "
+        id="annoy-banner-{{ banner.id }}"
+       style="
+           {% if banner.text_color %}color: {{ banner.text_color }};{% endif %}
+           {% if banner.background_color %}background-color: {{ banner.background_color }};{% endif %}
+
+        ">
+    <div class="annoy-banner-inner">
+
+      <div class="image-box">
+       {% if banner.image %}
+       <img src="{{ banner.image.url }}">
+       {% endif %}
+      </div>
+
+      <div class="text-box">
+       <div class="text">
+          {{ banner.get_text|safe|linebreaks }}
+       </div>
+
+       <div class="state-box">
+         <div class="action-box">
+           {% if banner.action_label %}
+            <a class="action" href="{{ banner.url }}">
+              {{ banner.action_label }}
+            </a>
+           {% endif %}
+         </div>
+       </div>
+      </div>
+
+
+
+    </div>
+  </div>
+  </div>
+
+{% endif %}
+
index 40f7511..25293f1 100644 (file)
@@ -22,6 +22,13 @@ def annoy_banner_blackout(context):
         'closable': True,
     }
 
+@register.inclusion_tag('annoy/banner_top.html', takes_context=True)
+def annoy_banner_top(context):
+    banners = Banner.choice('top', request=context['request'])
+    return {
+        'banner': banners.first(),
+        'closable': True,
+    }
 
 @register.inclusion_tag('annoy/banners.html', takes_context=True)
 def annoy_banners(context, place):
index cdfcc47..7283234 100644 (file)
@@ -3,7 +3,7 @@
 #
 from rest_framework import serializers
 from sorl.thumbnail import default
-from django.urls import reverse
+from rest_framework.reverse import reverse
 from club.models import Membership
 
 
@@ -20,13 +20,14 @@ class AbsoluteURLField(serializers.ReadOnlyField):
                 self.view_args[fields[0]] = fields[1] if len(fields) > 1 else fields[0]
 
     def to_representation(self, value):
+        request = self.context['request']
         if self.view_name is not None:
             kwargs = {
                 arg: getattr(value, field)
                 for (arg, field) in self.view_args.items()
             }
-            value = reverse(self.view_name, kwargs=kwargs)
-        return self.context['request'].build_absolute_uri(value)
+            return reverse(self.view_name, kwargs=kwargs, request=request)
+        return request.build_absolute_uri(value)
 
 
 class LegacyMixin:
diff --git a/src/api/migrations/0008_alter_token_token_type.py b/src/api/migrations/0008_alter_token_token_type.py
new file mode 100644 (file)
index 0000000..b2ba7ba
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2025-02-24 15:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0007_alter_token_consumer'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='token',
+            name='token_type',
+            field=models.IntegerField(choices=[(1, 'Request'), (2, 'Access'), (3, 'Refresh')]),
+        ),
+    ]
index ff1f09a..4f05565 100644 (file)
@@ -101,7 +101,12 @@ class Consumer(models.Model):
 class Token(models.Model):
     REQUEST = 1
     ACCESS = 2
-    TOKEN_TYPES = ((REQUEST, 'Request'), (ACCESS, 'Access'))
+    REFRESH = 3
+    TOKEN_TYPES = (
+        (REQUEST, 'Request'),
+        (ACCESS, 'Access'),
+        (REFRESH, 'Refresh')
+    )
 
     key = models.CharField(max_length=KEY_SIZE)
     secret = models.CharField(max_length=SECRET_SIZE)
diff --git a/src/api/pagination.py b/src/api/pagination.py
new file mode 100644 (file)
index 0000000..0436622
--- /dev/null
@@ -0,0 +1,17 @@
+from rest_framework.pagination import LimitOffsetPagination, PageLink
+from rest_framework.response import Response
+
+
+class WLLimitOffsetPagination(LimitOffsetPagination):
+    def get_results(self, data):
+        return data['member']
+
+    def get_paginated_response(self, data):
+        return Response({
+            "member": data,
+            "totalItems": self.count,
+            "view": {
+                "previous": self.get_previous_link(),
+                "next": self.get_next_link(),
+            }
+        })
index dae587c..8c00892 100644 (file)
@@ -15,10 +15,11 @@ class PlainSerializer(serializers.ModelSerializer):
 
 class UserSerializer(serializers.ModelSerializer):
     premium = UserPremiumField()
+    confirmed = serializers.BooleanField(source='is_active')
 
     class Meta:
         model = User
-        fields = ['username', 'premium']
+        fields = ['username', 'premium', 'confirmed']
 
 
 class BookUserDataSerializer(serializers.ModelSerializer):
@@ -30,3 +31,18 @@ class BookUserDataSerializer(serializers.ModelSerializer):
 class LoginSerializer(serializers.Serializer):
     username = serializers.CharField()
     password = serializers.CharField(style={'input_type': 'password'})
+
+
+
+class RegisterSerializer(serializers.Serializer):
+    email = serializers.CharField()
+    password = serializers.CharField(style={'input_type': 'password'})
+    options = serializers.ListField(child=serializers.IntegerField(), required=False)
+
+
+class RefreshTokenSerializer(serializers.Serializer):
+    refresh_token = serializers.CharField(style={'input_type': 'password'})
+
+
+class RequestConfirmSerializer(serializers.Serializer):
+    email = serializers.CharField()
index f398644..1eb4a97 100644 (file)
@@ -1,2 +1,2 @@
 <?xml version="1.0" encoding="utf-8"?>
-<response><resource><kind></kind><full_sort_key>$child$2</full_sort_key><title>Child</title><url>http://testserver/katalog/lektura/child/</url><cover_color>#000000</cover_color><author></author><cover></cover><epoch></epoch><href>http://testserver/api/books/child/</href><has_audio>False</has_audio><genre>Wiersz</genre><simple_thumb></simple_thumb><slug>child</slug><cover_thumb></cover_thumb><liked></liked></resource><resource><kind></kind><full_sort_key>$grandchild$3</full_sort_key><title>Grandchild</title><url>http://testserver/katalog/lektura/grandchild/</url><cover_color>#000000</cover_color><author></author><cover></cover><epoch></epoch><href>http://testserver/api/books/grandchild/</href><has_audio>False</has_audio><genre>Sonet</genre><simple_thumb></simple_thumb><slug>grandchild</slug><cover_thumb></cover_thumb><liked></liked></resource><resource><kind>Liryka</kind><full_sort_key>john doe$parent$1</full_sort_key><title>Parent</title><url>http://testserver/katalog/lektura/parent/</url><cover_color>#a6820a</cover_color><author>John Doe</author><cover>cover/parent.jpg</cover><epoch>Barok</epoch><href>http://testserver/api/books/parent/</href><has_audio>True</has_audio><genre>Sonet</genre><simple_thumb>http://testserver/media/cover_api_thumb/parent.jpg</simple_thumb><slug>parent</slug><cover_thumb>cover_thumb/parent.jpg</cover_thumb><liked></liked></resource></response>
+<response><resource><kind></kind><full_sort_key>$child$2</full_sort_key><title>Child</title><url>http://testserver/katalog/lektura/child/</url><cover_color>#000000</cover_color><author></author><cover></cover><epoch></epoch><href>http://testserver/api/books/child/?format=xml</href><has_audio>False</has_audio><genre>Wiersz</genre><simple_thumb></simple_thumb><slug>child</slug><cover_thumb></cover_thumb><liked></liked></resource><resource><kind></kind><full_sort_key>$grandchild$3</full_sort_key><title>Grandchild</title><url>http://testserver/katalog/lektura/grandchild/</url><cover_color>#000000</cover_color><author></author><cover></cover><epoch></epoch><href>http://testserver/api/books/grandchild/?format=xml</href><has_audio>False</has_audio><genre>Sonet</genre><simple_thumb></simple_thumb><slug>grandchild</slug><cover_thumb></cover_thumb><liked></liked></resource><resource><kind>Liryka</kind><full_sort_key>john doe$parent$1</full_sort_key><title>Parent</title><url>http://testserver/katalog/lektura/parent/</url><cover_color>#a6820a</cover_color><author>John Doe</author><cover>cover/parent.jpg</cover><epoch>Barok</epoch><href>http://testserver/api/books/parent/?format=xml</href><has_audio>True</has_audio><genre>Sonet</genre><simple_thumb>http://testserver/media/cover_api_thumb/parent.jpg</simple_thumb><slug>parent</slug><cover_thumb>cover_thumb/parent.jpg</cover_thumb><liked></liked></resource></response>
index 703283e..de3dba7 100644 (file)
@@ -9,7 +9,19 @@ from stats.utils import piwik_track_view
 from . import views
 
 
+urlpatterns1 = [
+    path('register/', csrf_exempt(views.RegisterView.as_view())),
+    path('refreshToken/', csrf_exempt(views.RefreshTokenView.as_view())),
+    path('requestConfirm/', csrf_exempt(views.RequestConfirmView.as_view())),
+    path('login/', csrf_exempt(views.Login2View.as_view())),
+    path('me/', views.UserView.as_view()),
+    path('', include('catalogue.api.urls2')),
+]
+
+
 urlpatterns = [
+    path('2/', include((urlpatterns1, 'api'), namespace="v2")),
+
     path('oauth/request_token/', csrf_exempt(views.OAuth1RequestTokenView.as_view())),
     path('oauth/authorize/', views.oauth_user_auth, name='oauth_user_auth'),
     path('oauth/access_token/', csrf_exempt(views.OAuth1AccessTokenView.as_view())),
@@ -37,4 +49,5 @@ urlpatterns = [
 
     path('', include('social.api.urls')),
     path('', include('catalogue.api.urls')),
+
 ]
index 622332f..011161e 100644 (file)
@@ -2,8 +2,10 @@
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
 from time import time
+from django.conf import settings
 from django.contrib.auth import authenticate
 from django.contrib.auth.decorators import login_required
+from django.contrib.auth.models import User
 from django import forms
 from django.http import HttpResponse
 from django.http import Http404
@@ -18,6 +20,7 @@ from rest_framework.views import APIView
 from rest_framework.generics import GenericAPIView, RetrieveAPIView, get_object_or_404
 from catalogue.models import Book
 from .models import BookUserData, KEY_SIZE, SECRET_SIZE, Token
+from social.models import UserConfirmation
 from . import serializers
 from .request_validator import PistonRequestValidator
 from .utils import oauthlib_request, oauthlib_response, vary_on_auth
@@ -154,6 +157,38 @@ class LoginView(GenericAPIView):
         return Response({"access_token": key})
 
 
+class Login2View(GenericAPIView):
+    serializer_class = serializers.LoginSerializer
+
+    def post(self, request):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        d = serializer.validated_data
+        user = authenticate(username=d['username'], password=d['password'])
+        if user is None:
+            return Response({"detail": "Invalid credentials."})
+
+        access_token = generate_token()[:KEY_SIZE]
+        Token.objects.create(
+            key=access_token,
+            token_type=Token.ACCESS,
+            timestamp=time(),
+            user=user,
+        )
+        refresh_token = generate_token()[:KEY_SIZE]
+        Token.objects.create(
+            key=refresh_token,
+            token_type=Token.REFRESH,
+            timestamp=time(),
+            user=user,
+        )
+        return Response({
+            "access_token": access_token,
+            "refresh_token": refresh_token,
+            "expires": 3600,
+        })
+
+
 @vary_on_auth
 class UserView(RetrieveAPIView):
     permission_classes = [IsAuthenticated]
@@ -192,3 +227,110 @@ class BookUserDataView(RetrieveAPIView):
 class BlogView(APIView):
     def get(self, request):
         return Response([])
+
+
+
+class RegisterView(GenericAPIView):
+    serializer_class = serializers.RegisterSerializer
+
+    def get(self, request):
+        return Response({
+            "options": [
+                {
+                    "id": 1,
+                    "html": "Chcę otrzymywać newsletter Wolnych Lektur",
+                    "required": False
+                }
+            ],
+            "info": [
+                'Administratorem danych osobowych jest Fundacja Wolne Lektury (ul. Marszałkowska 84/92 lok. 125, 00-514 Warszawa). Podanie danych osobowych jest dobrowolne. Dane są przetwarzane w zakresie niezbędnym do prowadzenia serwisu, a także w celach prowadzenia statystyk, ewaluacji i sprawozdawczości. W przypadku wyrażenia dodatkowej zgody adres e-mail zostanie wykorzystany także w celu przesyłania newslettera Wolnych Lektur. Osobom, których dane są zbierane, przysługuje prawo dostępu do treści swoich danych oraz ich poprawiania. Więcej informacji w <a href="https://fundacja.wolnelektury.pl/prywatnosc/">polityce prywatności</a>.'
+            ]            
+        })
+
+    def post(self, request):
+        if not settings.FEATURE_API_REGISTER:
+            return Response(
+                {
+                    "detail": "Rejestracja aktualnie niedostępna."
+                },
+                status=400
+            )
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        d = serializer.validated_data
+
+        user = User(
+            username=d['email'],
+            email=d['email'],
+            is_active=False
+        )
+        user.set_password(d['password'])
+
+        try:
+            user.save()
+        except:
+            return Response(
+                {
+                    "detail": "Nie można utworzyć konta.",
+                },
+                status=400
+            )
+
+        UserConfirmation.request(user)
+        return Response({})
+
+
+class RefreshTokenView(APIView):
+    serializer_class = serializers.RefreshTokenSerializer
+
+    def post(self, request):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        d = serializer.validated_data
+        
+        t = Token.objects.get(
+            key=d['refresh_token'],
+            token_type=Token.REFRESH
+        )
+        user = t.user
+
+        access_token = generate_token()[:KEY_SIZE]
+        Token.objects.create(
+            key=access_token,
+            token_type=Token.ACCESS,
+            timestamp=time(),
+            user=user,
+        )
+        refresh_token = generate_token()[:KEY_SIZE]
+        Token.objects.create(
+            key=refresh_token,
+            token_type=Token.REFRESH,
+            timestamp=time(),
+            user=user,
+        )
+        return Response({
+            "access_token": access_token,
+            "refresh_token": refresh_token,
+            "expires": 3600,
+        })
+
+
+class RequestConfirmView(APIView):
+    serializer_class = serializers.RequestConfirmSerializer
+
+    def post(self, request):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        d = serializer.validated_data
+
+        try:
+            user = User.objects.get(
+                username=d['email'],
+                is_active=False
+            )
+        except User.DoesNotExist:
+            raise Http404
+
+        UserConfirmation.request(user)
+        return Response({})
+
index daa86fe..60e52a0 100644 (file)
@@ -40,10 +40,155 @@ class TagDetailSerializer(serializers.ModelSerializer):
         ]
 
 
+class AuthorItemSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_author',
+        view_args=('slug',)
+    )
+
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name'
+        ]
+
+class AuthorSerializer(AuthorItemSerializer):
+    photo_thumb = ThumbnailField('139x193', source='photo')
+
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            'genitive', 'photo', 'photo_thumb', 'photo_attribution',
+        ]
+
+class EpochItemSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_epoch',
+        view_args=('slug',)
+    )
+    class Meta:
+        model = Tag
+        fields = ['url', 'href', 'name']
+
+class EpochSerializer(EpochItemSerializer):
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            'adjective_feminine_singular', 'adjective_nonmasculine_plural',
+        ]
+
+class GenreItemSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_genre',
+        view_args=('slug',)
+    )
+    class Meta:
+        model = Tag
+        fields = ['url', 'href', 'name']
+
+class GenreSerializer(GenreItemSerializer):
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            'plural', 'genre_epoch_specific',
+        ]
+
+class KindItemSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_kind',
+        view_args=('slug',)
+    )
+    class Meta:
+        model = Tag
+        fields = ['url', 'href', 'name']
+
+class KindSerializer(KindItemSerializer):
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            'collective_noun',
+        ]
+
+
 class TranslatorSerializer(serializers.Serializer):
     name = serializers.CharField(source='*')
 
 
+class BookSerializer2(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(view_name='catalogue_api_book', view_args=['slug'])
+    xml = EmbargoURLField(source='xml_url')
+    html = EmbargoURLField(source='html_url')
+    txt = EmbargoURLField(source='txt_url')
+    fb2 = EmbargoURLField(source='fb2_url')
+    epub = EmbargoURLField(source='epub_url')
+    mobi = EmbargoURLField(source='mobi_url')
+    pdf = EmbargoURLField(source='pdf_url')
+
+    authors = AuthorItemSerializer(many=True)
+    translators = AuthorItemSerializer(many=True)
+    epochs = EpochItemSerializer(many=True)
+    genres = GenreItemSerializer(many=True)
+    kinds = KindItemSerializer(many=True)
+    parent = serializers.HyperlinkedRelatedField(
+        read_only=True,
+        view_name='catalogue_api_book',
+        lookup_field='slug'
+    )
+
+    class Meta:
+        model = Book
+        fields = [
+            'slug', 'title', 'full_sort_key',
+            'href', 'url', 'language',
+            'authors', 'translators',
+            'epochs', 'genres', 'kinds',
+            #'children',
+            'parent', 'preview',
+            'epub', 'mobi', 'pdf', 'html', 'txt', 'fb2', 'xml',
+            'cover_thumb', 'cover',
+            'isbn_pdf', 'isbn_epub', 'isbn_mobi',
+        ]
+
+class BookSerializer11Labs(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(view_name='catalogue_api_book', view_args=['slug'])
+    html = EmbargoURLField(source='html_nonotes_url')
+
+    authors = AuthorItemSerializer(many=True)
+    translators = AuthorItemSerializer(many=True)
+    epochs = EpochItemSerializer(many=True)
+    genres = GenreItemSerializer(many=True)
+    kinds = KindItemSerializer(many=True)
+    parent = serializers.HyperlinkedRelatedField(
+        read_only=True,
+        view_name='catalogue_api_book',
+        lookup_field='slug'
+    )
+
+    class Meta:
+        model = Book
+        fields = [
+            'slug', 'title', 'full_sort_key',
+            'href', 'url', 'language',
+            'authors', 'translators',
+            'epochs', 'genres', 'kinds',
+            #'children',
+            'parent', 'preview',
+            'html',
+            'cover_thumb', 'cover',
+            'isbn_pdf', 'isbn_epub', 'isbn_mobi',
+        ]
+
+
 class BookSerializer(LegacyMixin, serializers.ModelSerializer):
     author = serializers.CharField(source='author_unicode')
     kind = serializers.CharField(source='kind_unicode')
@@ -205,3 +350,9 @@ class FragmentDetailSerializer(serializers.ModelSerializer):
     class Meta:
         model = Fragment
         fields = ['book', 'anchor', 'text', 'url', 'themes']
+
+
+class FilterTagSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Tag
+        fields = ['id', 'category', 'name']
diff --git a/src/catalogue/api/urls2.py b/src/catalogue/api/urls2.py
new file mode 100644 (file)
index 0000000..b16af66
--- /dev/null
@@ -0,0 +1,52 @@
+# This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
+#
+from django.urls import path, re_path
+from stats.utils import piwik_track_view
+from . import views
+
+
+urlpatterns = [
+    path('books/',
+         piwik_track_view(views.BookList2.as_view()),
+         name='catalogue_api_book_list'
+         ),
+    path('11labs/books/',
+         piwik_track_view(views.BookList11Labs.as_view()),
+         name='catalogue_api_book_list'
+         ),
+    path('books/<slug:slug>/',
+         piwik_track_view(views.BookDetail2.as_view()),
+         name='catalogue_api_book'
+         ),
+
+    path('suggested-tags/',
+         piwik_track_view(views.SuggestedTags.as_view()),
+         name='catalogue_api_suggested_tags'
+         ),
+
+    path('authors/',
+         piwik_track_view(views.AuthorList.as_view()),
+         name="catalogue_api_author_list"),
+    path('authors/<slug:slug>/',
+         piwik_track_view(views.AuthorView.as_view()),
+         name='catalogue_api_author'),
+    path('epochs/',
+         piwik_track_view(views.EpochList.as_view()),
+         name="catalogue_api_epoch_list"),
+    path('epochs/<slug:slug>/',
+         piwik_track_view(views.EpochView.as_view()),
+         name='catalogue_api_epoch'),
+    path('kinds/',
+         piwik_track_view(views.KindList.as_view()),
+         name="catalogue_api_kind_list"),
+    path('kinds/<slug:slug>/',
+         piwik_track_view(views.KindView.as_view()),
+         name='catalogue_api_kind'),
+    path('genres/',
+         piwik_track_view(views.GenreList.as_view()),
+         name="catalogue_api_genre_list"),
+    path('genres/<slug:slug>/',
+         piwik_track_view(views.GenreView.as_view()),
+         name='catalogue_api_genre'),
+]
index c0dc57f..0e758b1 100644 (file)
@@ -9,6 +9,8 @@ from django.core.files.base import ContentFile
 from django.http import Http404, HttpResponse
 from django.utils.decorators import method_decorator
 from django.views.decorators.cache import never_cache
+from django_filters import rest_framework as dfilters
+from rest_framework import filters
 from rest_framework.generics import (ListAPIView, RetrieveAPIView,
                                      RetrieveUpdateAPIView, get_object_or_404)
 from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
@@ -17,6 +19,7 @@ from rest_framework import status
 from api.handlers import read_tags
 from api.utils import vary_on_auth
 from catalogue.forms import BookImportForm
+from catalogue.helpers import get_top_level_related_tags
 from catalogue.models import Book, Collection, Tag, Fragment, BookMedia
 from catalogue.models.tag import prefetch_relations
 from club.models import Membership
@@ -30,6 +33,10 @@ from . import serializers
 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
 
 
+class LegacyListAPIView(ListAPIView):
+    pagination_class = None
+
+
 class CreateOnPutMixin:
     '''
     Creates a new model instance when PUTting a nonexistent resource.
@@ -47,7 +54,7 @@ class CreateOnPutMixin:
                 raise
 
 
-class CollectionList(ListAPIView):
+class CollectionList(LegacyListAPIView):
     queryset = Collection.objects.filter(listed=True)
     serializer_class = serializers.CollectionListSerializer
 
@@ -61,7 +68,7 @@ class CollectionDetail(CreateOnPutMixin, RetrieveUpdateAPIView):
 
 
 @vary_on_auth  # Because of 'liked'.
-class BookList(ListAPIView):
+class BookList(LegacyListAPIView):
     permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
     queryset = Book.objects.none()  # Required for DjangoModelPermissions
     serializer_class = serializers.BookListSerializer
@@ -179,6 +186,53 @@ class BookList(ListAPIView):
         return Response({}, status=status.HTTP_201_CREATED)
 
 
+class BookFilter(dfilters.FilterSet):
+    sort = dfilters.OrderingFilter(
+        fields=(
+            ('sort_key_author', 'alpha'),
+            ('popularity', 'popularity'),
+        )
+    )
+    tag = dfilters.ModelMultipleChoiceFilter(
+        field_name='tag_relations__tag',
+        queryset=Tag.objects.filter(category__in=('author', 'epoch', 'genre', 'kind')),
+        conjoined=True,
+    )
+
+
+class BookList2(ListAPIView):
+    permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
+    queryset = Book.objects.none()  # Required for DjangoModelPermissions
+    serializer_class = serializers.BookSerializer2
+    filter_backends = (
+        dfilters.DjangoFilterBackend,
+        filters.SearchFilter,
+    )
+    filterset_class = BookFilter
+    search_fields = [
+        'title',
+    ]
+
+    def get_queryset(self):
+        books = Book.objects.all()
+        books = books.filter(findable=True)
+        books = order_books(books, True)
+
+        return books
+
+
+class BookList11Labs(BookList2):
+    serializer_class = serializers.BookSerializer11Labs
+
+    def get_queryset(self):
+        books = Book.objects.all()
+        books = books.filter(findable=True)
+        books = books.filter(license='')
+        books = order_books(books, True)
+
+        return books
+
+
 @vary_on_auth  # Because of 'liked'.
 class BookDetail(RetrieveAPIView):
     queryset = Book.objects.all()
@@ -186,13 +240,19 @@ class BookDetail(RetrieveAPIView):
     serializer_class = serializers.BookDetailSerializer
 
 
+class BookDetail2(RetrieveAPIView):
+    queryset = Book.objects.all()
+    lookup_field = 'slug'
+    serializer_class = serializers.BookSerializer2
+
+
 @vary_on_auth  # Because of embargo links.
 class EbookList(BookList):
     serializer_class = serializers.EbookSerializer
 
 
 @method_decorator(never_cache, name='dispatch')
-class Preview(ListAPIView):
+class Preview(LegacyListAPIView):
     #queryset = Book.objects.filter(preview=True)
     serializer_class = serializers.BookPreviewSerializer
 
@@ -205,7 +265,7 @@ class Preview(ListAPIView):
 
 
 @vary_on_auth  # Because of 'liked'.
-class FilterBookList(ListAPIView):
+class FilterBookList(LegacyListAPIView):
     serializer_class = serializers.FilterBookListSerializer
 
     def parse_bool(self, s):
@@ -289,7 +349,7 @@ class EpubView(RetrieveAPIView):
         return HttpResponse(self.get_object().get_media('epub'))
 
 
-class TagCategoryView(ListAPIView):
+class TagCategoryView(LegacyListAPIView):
     serializer_class = serializers.TagSerializer
 
     def get_queryset(self):
@@ -305,6 +365,42 @@ class TagCategoryView(ListAPIView):
 
         return tags
 
+class AuthorList(ListAPIView):
+    serializer_class = serializers.AuthorSerializer
+    queryset = Tag.objects.filter(category='author')
+
+class AuthorView(RetrieveAPIView):
+    serializer_class = serializers.AuthorSerializer
+    queryset = Tag.objects.filter(category='author')
+    lookup_field = 'slug'
+
+class EpochList(ListAPIView):
+    serializer_class = serializers.EpochSerializer
+    queryset = Tag.objects.filter(category='epoch')
+
+class EpochView(RetrieveAPIView):
+    serializer_class = serializers.EpochSerializer
+    queryset = Tag.objects.filter(category='epoch')
+    lookup_field = 'slug'
+
+class GenreList(ListAPIView):
+    serializer_class = serializers.GenreSerializer
+    queryset = Tag.objects.filter(category='genre')
+
+class GenreView(RetrieveAPIView):
+    serializer_class = serializers.GenreSerializer
+    queryset = Tag.objects.filter(category='genre')
+    lookup_field = 'slug'
+
+class KindList(ListAPIView):
+    serializer_class = serializers.KindSerializer
+    queryset = Tag.objects.filter(category='kind')
+
+class KindView(RetrieveAPIView):
+    serializer_class = serializers.KindSerializer
+    queryset = Tag.objects.filter(category='kind')
+    lookup_field = 'slug'
+
 
 class TagView(RetrieveAPIView):
     permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
@@ -373,7 +469,7 @@ class TagView(RetrieveAPIView):
 
 
 @vary_on_auth  # Because of 'liked'.
-class FragmentList(ListAPIView):
+class FragmentList(LegacyListAPIView):
     serializer_class = serializers.FragmentSerializer
 
     def get_queryset(self):
@@ -398,3 +494,13 @@ class FragmentView(RetrieveAPIView):
             book__slug=self.kwargs['book'],
             anchor=self.kwargs['anchor']
         )
+
+
+class SuggestedTags(ListAPIView):
+    serializer_class = serializers.FilterTagSerializer
+
+    def get_queryset(self):
+        tag_ids = self.request.GET.getlist('tag', [])
+        tags = [get_object_or_404(Tag, id=tid) for tid in tag_ids]
+        related_tags = list(t.id for t in get_top_level_related_tags(tags))
+        return Tag.objects.filter(id__in=related_tags)
index 9c5696f..2d35357 100644 (file)
@@ -398,6 +398,26 @@ class HtmlField(EbookField):
         return wldoc.as_html(gallery_path=gal_path, gallery_url=gal_url, base_url=absolute_url(gal_url))
 
 
+class HtmlNonotesField(EbookField):
+    ext = 'html'
+    for_parents = False
+    directory = 'html_nonotes'
+
+    @staticmethod
+    def transform(wldoc, book):
+        # ugly, but we can't use wldoc.book_info here
+        from librarian import DCNS
+        url_elem = wldoc.edoc.getroot().find('.//' + DCNS('identifier.url'))
+        if url_elem is None:
+            gal_url = ''
+            gal_path = ''
+        else:
+            slug = url_elem.text.rstrip('/').rsplit('/', 1)[1]
+            gal_url = gallery_url(slug=slug)
+            gal_path = gallery_path(slug=slug)
+        return wldoc.as_html(gallery_path=gal_path, gallery_url=gal_url, base_url=absolute_url(gal_url), flags=['nonotes'])
+
+
 class CoverField(EbookField):
     ext = 'jpg'
     directory = 'cover'
diff --git a/src/catalogue/migrations/0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more.py b/src/catalogue/migrations/0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more.py
new file mode 100644 (file)
index 0000000..bd297d4
--- /dev/null
@@ -0,0 +1,30 @@
+# Generated by Django 4.0.8 on 2025-02-26 14:46
+
+import catalogue.fields
+from django.db import migrations, models
+import fnpdjango.storage
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0048_remove_collection_kind_remove_tag_for_books_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='html_nonotes_file',
+            field=catalogue.fields.HtmlNonotesField(etag_field_name='html_nonotes_file_etag', storage=fnpdjango.storage.BofhFileSystemStorage()),
+        ),
+        migrations.AddField(
+            model_name='book',
+            name='html_nonotes_file_etag',
+            field=models.CharField(db_index=True, default='', editable=False, max_length=255),
+        ),
+        migrations.AddField(
+            model_name='book',
+            name='license',
+            field=models.CharField(blank=True, db_index=True, max_length=255, verbose_name='licencja'),
+        ),
+    ]
index 29e3754..c2b9eed 100644 (file)
@@ -43,6 +43,7 @@ class Book(models.Model):
     common_slug = models.SlugField('wspólny slug', max_length=120, db_index=True)
     language = models.CharField('kod języka', max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
     description = models.TextField('opis', blank=True)
+    license = models.CharField('licencja', max_length=255, blank=True, db_index=True)
     abstract = models.TextField('abstrakt', blank=True)
     toc = models.TextField('spis treści', blank=True)
     created_at = models.DateTimeField('data utworzenia', auto_now_add=True, db_index=True)
@@ -62,6 +63,7 @@ class Book(models.Model):
     # files generated during publication
     xml_file = fields.XmlField(storage=bofh_storage, with_etag=False)
     html_file = fields.HtmlField(storage=bofh_storage)
+    html_nonotes_file = fields.HtmlNonotesField(storage=bofh_storage)
     fb2_file = fields.Fb2Field(storage=bofh_storage)
     txt_file = fields.TxtField(storage=bofh_storage)
     epub_file = fields.EpubField(storage=bofh_storage)
@@ -79,7 +81,7 @@ class Book(models.Model):
         'okładka dla Ebookpoint')
 
     ebook_formats = constants.EBOOK_FORMATS
-    formats = ebook_formats + ['html', 'xml']
+    formats = ebook_formats + ['html', 'xml', 'html_nonotes']
 
     parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
     ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
@@ -375,6 +377,9 @@ class Book(models.Model):
     def html_url(self):
         return self.media_url('html')
 
+    def html_nonotes_url(self):
+        return self.media_url('html_nonotes')
+
     def pdf_url(self):
         return self.media_url('pdf')
 
@@ -635,6 +640,7 @@ class Book(models.Model):
         book.findable = findable
         book.language = book_info.language
         book.title = book_info.title
+        book.license = book_info.license or ''
         if book_info.variant_of:
             book.common_slug = book_info.variant_of.slug
         else:
@@ -704,6 +710,7 @@ class Book(models.Model):
         for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
             if format_ not in dont_build:
                 getattr(book, '%s_file' % format_).build_delay()
+        book.html_nonotes_file.build_delay()
 
         if not settings.NO_SEARCH_INDEX and search_index and findable:
             tasks.index_book.delay(book.id)
index dbe9259..18e4bcc 100644 (file)
 
 
   <article id="main-text" {% if book.has_sync_file %}class="has-sync"{% endif %}>
-    {% with next=book.get_next_text prev=book.get_prev_text %}
+<div id="sidebar">
+      {% if book.other_versions.exists %}
+        <div class="box" id="other">
+          <h2>{% trans "Inne wersje tekstu" %}</h2>
+          <a class="other-text-close" href="#">{% trans "Zamknij drugą wersję" %}</a>
+          <ul>
+            {% spaceless %}
+              {% for other_version in book.other_versions %}
+                <li>
+                  <a class="display-other"
+                     data-other="{{ other_version.html_url }}"
+                     href="{% url 'book_text' other_version.slug %}">
+                    {{ other_version.mini_box_nolink }}
+                  </a>
+                </li>
+              {% endfor %}
+            {% endspaceless %}
+          </ul>
+        </div>
+      {% endif %}
+</div>
+
+{% with next=book.get_next_text prev=book.get_prev_text %}
       {% if next %}
         <a class="text_next-book" href="{% url 'book_text' next.slug %}">{{ next.title }}&nbsp;&rarr;</a>
       {% endif %}
             <div class="pointer pointer-top"></div>
         </div>
       </div>
+</div>
 
 
-      {% if book.other_versions.exists %}
-        <div class="box" id="other">
-          <h2>{% trans "Inne wersje utworu" %}</h2>
-          <a class="other-text-close" href="#">{% trans "Zamknij drugą wersję" %}</a>
-          <ul>
-            {% spaceless %}
-              {% for other_version in book.other_versions %}
-                <li>
-                  <a class="display-other"
-                     data-other="{{ other_version.html_url }}"
-                     href="{% url 'book_text' other_version.slug %}">
-                    {{ other_version.mini_box_nolink }}
-                  </a>
-                </li>
-              {% endfor %}
-            {% endspaceless %}
-          </ul>
-        </div>
-      {% endif %}
-
       <div id="annoy-stubs">
         {% annoy_banners 'book-text-intermission' %}
 
index f5d98ca..a145522 100644 (file)
@@ -42,6 +42,9 @@ class BookInfoStub:
     # allow single definition for multiple-value fields
     _salias = {
         'authors': 'author',
+        'genres': 'genre',
+        'epochs': 'epoch',
+        'kinds': 'kind',
     }
 
     def __init__(self, **kwargs):
index fc715e2..4425928 100644 (file)
@@ -10,6 +10,7 @@ from django.utils.html import conditional_escape
 from django.utils.safestring import mark_safe
 from fnpdjango.actions import export_as_csv_action
 from modeltranslation.admin import TranslationAdmin
+import annoy.models
 from wolnelektury.utils import YesNoFilter
 from . import models
 
@@ -95,14 +96,35 @@ class SourceFilter(admin.SimpleListFilter):
             (m, m) for m in
             model_admin.model.objects.exclude(source='').values_list('source', flat=True).distinct()[:10]
         ]
-        print(lookups)
         return lookups
 
     def queryset(self, request, queryset):
         return queryset
     
-    #field_name = 'source' # name of the foreign key field
 
+class CrisisFilter(admin.SimpleListFilter):
+    title = 'czas zbiórki kryzysowej'
+    parameter_name = 'crisis'
+
+    def lookups(self, request, model_admin):
+        lookups = [
+            (b.id, '%s — %s' % (b.since, b.until)) for b in
+            annoy.models.Banner.objects.filter(place='crisis')
+        ]
+        return lookups
+
+    def queryset(self, request, queryset):
+        bid = self.value()
+        if not bid:
+            return
+        try:
+            b = annoy.models.Banner.objects.get(id=self.value())
+        except annoy.models.Banner.DoesNotExist:
+            return
+        return queryset.filter(
+            started_at__gte=b.since,
+            started_at__lte=b.until
+        )
 
 
 
@@ -118,7 +140,7 @@ class ScheduleAdmin(admin.ModelAdmin):
     list_filter = [
         'is_cancelled', 'monthly', 'yearly', 'method',
         PayedFilter, ActiveFilter, ExpiredFilter,
-        SourceFilter,
+        SourceFilter, CrisisFilter
     ]
     filter_horizontal = ['consent']
     date_hierarchy = 'started_at'
index c09769a..c40428d 100644 (file)
@@ -417,7 +417,8 @@ class PayUOrder(payu_models.Order):
         )            
 
     @classmethod
-    def send_receipt(cls, email, year, resend=False):
+    def generate_receipt(cls, email, year):
+        # TODO: abstract out
         Contact = apps.get_model('messaging', 'Contact')
         Funding = apps.get_model('funding', 'Funding')
         BillingAgreement = apps.get_model('paypal', 'BillingAgreement')
@@ -485,11 +486,8 @@ class PayUOrder(payu_models.Order):
         ctx = {
             "email": email,
             "year": year,
-            "next_year": year + 1,
             "total": sum(x['amount'] for x in payments),
             "payments": payments,
-            "optout": optout,
-            "resend": resend,
         }
         temp = tempfile.NamedTemporaryFile(prefix='receipt-', suffix='.pdf', delete=False)
         temp.close()
@@ -497,15 +495,32 @@ class PayUOrder(payu_models.Order):
             "wl.eps": os.path.join(settings.STATIC_ROOT, "img/wl.eps"),
             })
 
+        with open(temp.name, 'rb') as f:
+            content = f.read()
+        os.unlink(f.name)
+        return content, optout, payments
+
+    @classmethod
+    def send_receipt(cls, email, year, resend=False):
+        receipt = cls.generate_receipt(email, year)
+        if receipt:
+            content, optout, payments = receipt
+        ctx = {
+            "email": email,
+            "year": year,
+            "next_year": year + 1,
+            "total": sum(x['amount'] for x in payments),
+            "payments": payments,
+            "optout": optout,
+            "resend": resend,
+        }
         message = EmailMessage(
                 'Odlicz darowiznę na Wolne Lektury od podatku',
                 template.loader.render_to_string('club/receipt_email.txt', ctx),
                 settings.CLUB_CONTACT_EMAIL, [email]
             )
-        with open(temp.name, 'rb') as f:
-            message.attach('wolnelektury-darowizny.pdf', f.read(), 'application/pdf')
+        message.attach('wolnelektury-darowizny.pdf', content, 'application/pdf')
         message.send()
-        os.unlink(f.name)
 
 
 class PayUCardToken(payu_models.CardToken):
index 1b6de08..3d9cd55 100644 (file)
@@ -2,6 +2,7 @@
 {% load club %}
 
 {% block content %}
+<div style="display: flex; gap:50px;">
   <table class="table">
     <tr>
       <td>Aktywne miesięczne wpłaty cykliczne:</td>
       <td>{% club_active_30day_sum %} zł.</td>
     </tr>
   </table>
+  <div>
+    <form method="post" action="{% url 'club_receipt' %}" style="display: flex; flex-direction: column;">
+      {% csrf_token %}
+      <span>Pobierz zestawienie roczne</span>
+      <input name="email" placeholder="email" required></input>
+      <input name="year" type="number" min="2013" max="2024" value="2024" placeholder="rok" required></input>
+      <button>pobierz zestawienie</button>
+    </form>
+  </div>
+  </div>
   {{ block.super }}
 {% endblock content %}
index 0267580..87bfa5a 100644 (file)
@@ -28,4 +28,6 @@ urlpatterns = [
     path('notify/<int:pk>/', views.PayUNotifyView.as_view(), name='club_payu_notify'),
 
     path('weryfikacja/', views.member_verify, name='club_member_verify'),
+
+    path('potwierdzenie/', views.receipt, name='club_receipt'),
 ]
index c6136b6..b2657e6 100644 (file)
@@ -4,7 +4,7 @@
 from django.conf import settings
 from django.contrib.auth.decorators import login_required, permission_required
 from django.db.models import Sum
-from django.http import HttpResponseRedirect
+from django.http import HttpResponse, HttpResponseRedirect
 from django.shortcuts import get_object_or_404, render
 from django.urls import reverse
 from django.utils.decorators import method_decorator
@@ -249,3 +249,26 @@ def member_verify(request):
             'result': rows
         }
     )
+
+
+@permission_required('club.schedule_view')
+def receipt(request):
+    email = request.POST.get('email')
+    try:
+        year = int(request.POST.get('year'))
+    except:
+        return HttpResponse('no content')
+
+    receipt = models.PayUOrder.generate_receipt(email, year)
+    if receipt:
+        content, optout, payments = receipt
+    else:
+        return HttpResponse('no content')
+    return HttpResponse(
+        content,
+        headers={
+            "Content-Type": "application/pdf",
+            "Content-Disposition": f'attachment; filename="wolnelektury-{year}-{email}.pdf"',
+        }
+    )
+
index 8e3153a..2e3adb8 100644 (file)
@@ -25,8 +25,8 @@
     {% if author.alive %}
       <p>
         {% trans "Dzieła tego autora objęte są prawem autorskim." %}
-        {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
-          <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+        {% blocktrans trimmed %}
+          Biblioteki internetowe nie mogą
           udostępniać dzieł tego autora.
         {% endblocktrans %}
       </p>
@@ -46,8 +46,8 @@
           </p>
           <div class='countdown' data-until='{{ pd_counter|date_to_utc|utc_for_js }}'></div>
           <p>
-            {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
-              <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+            {% blocktrans trimmed %}
+              Biblioteki internetowe nie mogą
               udostępniać dzieł tego autora.
             {% endblocktrans %}
           </p>
index 655d7f0..02a099d 100644 (file)
               {% endblocktrans %}</p>
               <div class='countdown' data-until='{{ pd_counter|date_to_utc|utc_for_js }}'></div>
               <p>
-                {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
-                  <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+                {% blocktrans trimmed %}
+                  Biblioteki internetowe nie mogą
                   udostępniać tego utworu.
                 {% endblocktrans %}
               </p>
             {% else %}
               <p>
                 {% trans "Ten utwór objęty jest prawem autorskim." %}
-                {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
-                  <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+                {% blocktrans trimmed %}
+                  Biblioteki internetowe nie mogą
                   udostępniać tego utworu.
                 {% endblocktrans %}
               </p>
index c5506ce..a299304 100644 (file)
@@ -36,6 +36,7 @@ class LikeView(APIView):
 class ShelfView(ListAPIView):
     permission_classes = [IsAuthenticated]
     serializer_class = BookSerializer
+    pagination_class = None
 
     def get_queryset(self):
         state = self.kwargs['state']
diff --git a/src/social/migrations/0017_userconfirmation.py b/src/social/migrations/0017_userconfirmation.py
new file mode 100644 (file)
index 0000000..fe39336
--- /dev/null
@@ -0,0 +1,25 @@
+# Generated by Django 4.0.8 on 2025-02-24 15:19
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('social', '0016_alter_bannergroup_options_alter_carousel_options_and_more'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='UserConfirmation',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('key', models.CharField(max_length=128, unique=True)),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]
index cb1326b..17fe7d0 100644 (file)
@@ -1,10 +1,13 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
+from oauthlib.common import urlencode, generate_token
 from random import randint
 from django.db import models
 from django.conf import settings
+from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
+from django.core.mail import send_mail
 from django.urls import reverse
 from catalogue.models import Book
 from wolnelektury.utils import cached_render, clear_cached_renders
@@ -170,3 +173,30 @@ class CarouselItem(models.Model):
 
     def get_banner(self):
         return self.banner or self.banner_group.get_banner()
+
+
+class UserConfirmation(models.Model):
+    user = models.ForeignKey(User, models.CASCADE)
+    created_at = models.DateTimeField(auto_now_add=True)
+    key = models.CharField(max_length=128, unique=True)
+
+    def send(self):
+        send_mail(
+            'Potwierdź konto w bibliotece Wolne Lektury',
+            f'https://beta.wolnelektury.pl/ludzie/potwierdz/{self.key}/',
+            settings.CONTACT_EMAIL,
+            [self.user.email]
+        )
+
+    def use(self):
+        user = self.user
+        user.is_active = True
+        user.save()
+        self.delete()
+    
+    @classmethod
+    def request(cls, user):
+        cls.objects.create(
+            user=user,
+            key=generate_token()
+        ).send()
diff --git a/src/social/templates/social/user_confirmation.html b/src/social/templates/social/user_confirmation.html
new file mode 100644 (file)
index 0000000..f56d2f2
--- /dev/null
@@ -0,0 +1,12 @@
+{% extends "base_simple.html" %}
+{% load i18n %}
+
+
+{% block body %}
+  <h1>{% trans "Konto potwierdzone" %}</h1>
+
+  <p class="normal-text">
+    {% blocktrans with user=user %}Konto <strong>{{ user }}</strong> zostało potwierdzone. Możesz się teraz zalogować.{% endblocktrans %}
+  </p>
+{% endblock body %}
+
index 299f620..10d9240 100644 (file)
@@ -7,6 +7,7 @@ from . import views
 
 
 urlpatterns = [
+    path('potwierdz/<str:key>/', views.confirm_user, name='social_confirm_user'),
     path('lektura/<slug:slug>/lubie/', views.like_book, name='social_like_book'),
     path('dodaj-tag/', views.AddSetView.as_view(), name='social_add_set_tag'),
     path('usun-tag/', views.RemoveSetView.as_view(), name='social_remove_set_tag'),
index 989771a..3dfcd9e 100644 (file)
@@ -10,7 +10,7 @@ from django.views.generic.edit import FormView
 
 from catalogue.models import Book, Tag
 import catalogue.models.tag
-from social import forms
+from social import forms, models
 from wolnelektury.utils import is_ajax
 
 
@@ -125,3 +125,12 @@ def my_tags(request):
             t.name for t in tags
         ], safe=False
     )
+
+
+def confirm_user(request, key):
+    uc = get_object_or_404(models.UserConfirmation, key=key)
+    user = uc.user
+    uc.use()
+    return render(request, 'social/user_confirmation.html', {
+        'user': user,
+    })
index d4faa8b..b9d507e 100644 (file)
@@ -59,6 +59,7 @@ INSTALLED_APPS_CONTRIB = [
     'django.contrib.postgres',
     'admin_ordering',
     'rest_framework',
+    'django_filters',
     'fnp_django_pagination',
     'pipeline',
     'sorl.thumbnail',
index aaef8b2..e5b2f60 100644 (file)
@@ -31,7 +31,10 @@ REST_FRAMEWORK = {
         'api.drf_auth.WLTokenAuthentication',
         'api.drf_auth.PistonOAuthAuthentication',
         'rest_framework.authentication.SessionAuthentication',
-    )
+    ),
+    'DEFAULT_PAGINATION_CLASS': 'api.pagination.WLLimitOffsetPagination',
+    'PAGE_SIZE': 10,
+    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
 }
 
 
index 16b5e0a..d673772 100644 (file)
@@ -76,3 +76,5 @@ SEARCH_CONFIG_SIMPLE = 'simple'
 SEARCH_USE_UNACCENT = False
 
 FEATURE_SYNCHRO = False
+
+FEATURE_API_REGISTER = False
index 9d90c65..70704f3 100644 (file)
@@ -51,7 +51,7 @@
     .annoy-banner-off {
         font-size: 1.2rem !important;
     }
-    
+
     @include rwd($break-flow) {
         .annoy-banner-inner {
             padding: 0;
 
         }
         .annoy-banner-off {
-            
+
         }
     }
 }
 
 
-.annoy-banner_crisis-container {
+.annoy-banner_crisis-container,
+.annoy-banner_top-container {
     position: sticky;
     top: 0;
-    height: 160px;
     z-index: 10;
     box-shadow: 0 0 10px black;
     display: flex;
     align-items:center;
     cursor: pointer;
 
-    @media screen and (min-height: 480px) {
-       height: 33vh;
-       top: calc(-33vh + 160px);
+    &.annoy-banner_top-container {
+       padding: 16px 0;
+
+       @media screen and (max-height: 700px) {
+           padding: 5px 0;
+       }
     }
+    &.annoy-banner_crisis-container {
+       height: 160px;
 
-    @media screen and (max-width: 940px) {
-       padding: 10px 0;
-       height: auto;
-       top: 0;
+       @media screen and (min-height: 480px) {
+           height: 33vh;
+           top: calc(-33vh + 160px);
+       }
+
+       @media screen and (max-width: 940px) {
+           padding: 10px 0;
+           height: auto;
+           top: 0;
+       }
     }
 
-    .annoy-banner_crisis {
+
+    .annoy-banner_crisis,
+    .annoy-banner_top {
        position: sticky;
        top: 0;
        width: 100%;
            .image-box {
                position: relative;
                img {
-                   height: 159px;
+                   max-height: 159px;
                    display: block;
 
                    @media screen and (max-width: 700px) {
-                       height: 120px;
+                       max-height: 120px;
                    }
                }
            }
                    }
                }
 
+               @media screen and (max-height: 700px) {
+                   flex-direction: row;
+                   justify-content: space-between;
+                   p {
+                       font-size: .9em;
+                   }
+               }
+
+
                .text {
                    background: #edc016;
                    padding: 1em;
                    border: 3px solid black;
                }
+
+               @media screen and (max-height: 400px) {
+                   align-items: center;
+                   .text {
+                       height: 1.2em;
+                       overflow: hidden;
+                   }
+               }
+
                a {
                    color: #c32721;
                }
                display: block;
                transition: background-color .2s;
 
+               @media screen and (max-height: 700px) {
+                   font-size: .9em;
+               }
+               @media screen and (max-height: 400px) {
+                   white-space: nowrap;
+               }
+
                &:hover {
                    background: #ffd430;
                    text-decoration: none;
            }
        }
     }
-    &.annoy-banner-style_crisis_quiet {
+    &.annoy-banner-style_crisis_quiet,
+    &.annoy-banner_top-container {
        background: black;
        color: white;
        .annoy-banner-inner {
index 65bc48a..1fde2b9 100644 (file)
@@ -294,6 +294,7 @@ h2 {
 h3, .subtitle2 {
     font-size: 1.5em;
     margin: 1.5em 0 1em 0;
+    padding-right: 48px;
     font-weight: normal;
     line-height: 1.5em;
 }
@@ -1044,3 +1045,97 @@ background: #fff;
         }
     }
 }
+
+
+
+#sidebar {
+    position: absolute;
+    left: 0;
+    top: 20px;
+    width: 200px;
+
+    h2 {
+       font-size: 20px;
+       margin-bottom: 1em;
+    }
+    
+    .other-text-close {
+       display: none;
+    }
+
+    #other {
+       ul {
+           list-style: none;
+           margin: 0;
+           padding: 0;
+
+           .book-mini-box {
+               position: relative;
+               width: 137px;
+               .language {
+                   position: absolute;
+                   top: 163px;
+                   left: 10px;
+                   color: white;
+                   background: black;
+                   padding: 0 10px;
+                   border-radius: 10px;
+                   font-size: 14px;
+                   line-height: 20px;
+                   
+               }
+               .author, .title {
+                   display: block;
+                   font-size: 14px;
+                   line-height: 18px;
+               }
+           }
+       }
+    }
+}
+.with-other-text {
+    #sidebar {
+       .other-text-close {
+           display: block;
+           background: red;
+           padding: 10px;
+           margin: 10px 0;
+           border-radius: 10px;
+           color: white;
+           text-decoration: none;
+       }
+    }
+    #main-text {
+       #book-text {
+           .other {
+               overflow: hidden;
+               margin: 10px 40px 10px 50px;
+               padding: 10px 20px 0 10px;
+               background: #eee;
+               border-left: 10px double #ddd;
+
+               h3 {
+                   margin: 0;
+                   padding: 0;
+               }
+
+               .paragraph {
+                   padding: 0;
+               }
+           }
+       }
+    }
+}
+
+:lang(pl),
+:lang(en),
+:lang(de),
+:lang(fr),
+:lang(lt),
+:lang(uk)
+{
+    direction: ltr;
+}
+:lang(he) {
+    direction: rtl;
+}
index eff64a0..dde96ed 100644 (file)
@@ -94,33 +94,85 @@ $("#menu a").each(function() {
 $("#menu-other").show();
 
 
+    function insertOtherText(text) {
+       let tree = $(text);
+       let lang = tree.attr('lang') || 'pl';
+       
+       // toc?
+       // themes?
+
+       let cursor = $(".main-text-body #book-text").children().first();
+       // wstawiamy przed kursorem
+       lastTarget = '';
+       tree.children().each((i, e) => {
+           let $e = $(e);
+
+           if ($e.hasClass('anchor')) return;
+           if ($e.hasClass('numeracja')) return;
+           if ($e.attr('id') == 'toc') return;
+           if ($e.attr('id') == 'nota_red') return;
+           if ($e.attr('id') == 'themes') return;
+           if ($e.attr('name') && $e.attr('name').startsWith('sec')) return;
+           
+           if ($e.hasClass('target')) {
+               let target = $e.attr('name');
+
+               while (lastTarget != target) {
+                   let nc = cursor.next();
+                   if (!nc.length) {
+                       break;
+                   }
+                   cursor = nc;
+                   lastTarget = cursor.attr('name');
+               }
+
+               while (true) {
+                   let nc = cursor.next();
+                   if (!nc.length) {
+                       break;
+                   }
+                   cursor = nc;
+                   lastTarget = cursor.attr('name');
+                   if (lastTarget) break;
+               }
+               
+           } else {
+               let d = $('<div class="other">');
+               d.attr('lang', lang);
+               d.append(e);
+               d.insertBefore(cursor);
+           }
+       });
+    }
+    
 /* Load other version of text. */
 $(".display-other").click(function(e) {
     e.preventDefault();
     release_menu();
 
-    $("#other-text").show();
+    $(".other").remove();
     $("body").addClass('with-other-text');
 
     $.ajax($(this).attr('data-other'), {
         success: function(text) {
-            $("#other-text-body").html(text);
+           insertOtherText(text);
             $("#other-text-waiter").hide();
-            $("#other-text-body").show();
-            loaded_text($("#other-text-body"));
+            loaded_text($(".other"));
         }
     });
     _paq.push(['trackEvent', 'html', 'other-text']);
 });
 
 
+
+    
+
 /* Remove other version of text. */
 $(".other-text-close").click(function(e) {
     release_menu();
     e.preventDefault();
-    $("#other-text").hide();
+    $(".other").remove();
     $("body").removeClass('with-other-text');
-    $("#other-text-body").html("");
     _paq.push(['trackEvent', 'html', 'other-text-close']);
 });
 
index efa442e..2feb7b8 100644 (file)
 
     const crisis = document.querySelector(".annoy-banner_crisis-container");
     const crisisLink = document.querySelector('.annoy-banner_crisis-container a.action');
-    crisis.addEventListener("click", function() {
-       crisisLink.click();
-    });
+    if (crisis) {
+       crisis.addEventListener("click", function() {
+           crisisLink.click();
+       });
+    }
 
 })();
index f763f01..3cc4851 100644 (file)
@@ -7,8 +7,9 @@
 {% load preview_ad from catalogue_tags %}
 
 {% annoy_banner_crisis %}
+{% annoy_banner_top %}
 
-{#% annoy_banner_blackout %#}
+{% annoy_banner_blackout %}
 
 <nav class="l-navigation">
   <div class="l-container">