From 162b0b772d94f0f6b12a63ea2778c1cc0c104027 Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Fri, 17 Apr 2026 11:28:26 +0200 Subject: [PATCH 01/16] Separate list item read-only serializer. --- src/social/api/serializers.py | 24 ++++++++++++++++++++++++ src/social/api/views.py | 14 ++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/social/api/serializers.py b/src/social/api/serializers.py index 3351c0720..cbaa3bb6a 100644 --- a/src/social/api/serializers.py +++ b/src/social/api/serializers.py @@ -2,7 +2,9 @@ # Copyright © Fundacja Wolne Lektury. See NOTICE for more information. # from rest_framework import serializers +import bookmarks.api.views import bookmarks.models +import catalogue.api.serializers import catalogue.models from social import models @@ -128,6 +130,28 @@ class UserListItemSerializer(serializers.ModelSerializer): } +class UserListItemReadSerializer(UserListItemSerializer): + book = catalogue.api.serializers.BookSerializer2() + bookmark = bookmarks.api.views.BookmarkSerializer() + class Meta: + model = models.UserListItem + fields = [ + 'client_id', + 'uuid', + 'order', + 'list_slug', + 'timestamp', + 'favorites', + 'deleted', + + 'book', + 'fragment', + 'quote', + 'bookmark', + 'note', + ] + + class UserListSerializerV3(serializers.ModelSerializer): client_id = serializers.CharField(write_only=True, required=False) timestamp = serializers.IntegerField(required=False) diff --git a/src/social/api/views.py b/src/social/api/views.py index 2c2383591..6cae38ae4 100644 --- a/src/social/api/views.py +++ b/src/social/api/views.py @@ -187,12 +187,17 @@ class ListItemViewV2(APIView): @never_cache class ListItemListViewV3(ListCreateAPIView): permission_classes = [IsAuthenticatedOrReadOnly] - serializer_class = serializers.UserListItemSerializer def get_queryset(self): lst = get_userlist(self.kwargs['slug'], self.request) return lst.userlistitem_set.all().order_by('order') + def get_serializer_class(self): + if self.request.method == 'GET': + return serializers.UserListItemReadSerializer + else: + return serializers.UserListItemSerializer + def get_serializer(self, *args, **kwargs): serializer_class = self.get_serializer_class() kwargs.setdefault('context', self.get_serializer_context()) @@ -210,7 +215,6 @@ class ListItemListViewV3(ListCreateAPIView): @never_cache class ListItemViewV3(RetrieveUpdateDestroyAPIView): permission_classes = [IsAuthenticated] - serializer_class = serializers.UserListItemSerializer lookup_field = 'uuid' def get_queryset(self): @@ -218,6 +222,12 @@ class ListItemViewV3(RetrieveUpdateDestroyAPIView): list__user=self.request.user ) + def get_serializer_class(self): + if self.request.method == 'GET': + return serializers.UserListItemReadSerializer + else: + return serializers.UserListItemSerializer + @vary_on_auth class ShelfView(ListAPIView): -- 2.20.1 From 72252d692444e68a5857bd3b3040d5fbf9881b69 Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Mon, 20 Apr 2026 10:50:21 +0200 Subject: [PATCH 02/16] Fix for kafkaesque abstract --- requirements/requirements.txt | 4 ++-- src/catalogue/templates/catalogue/book_detail.html | 2 +- .../static/2022/styles/layout/_article.scss | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 1fba04c93..ad5eb4e64 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -27,7 +27,7 @@ django-countries==7.6.1 oauthlib>=3.0.1,<3.1 # contact -pyyaml==5.4.1 +pyyaml==6.0.3 polib==1.2 @@ -42,7 +42,7 @@ mutagen==1.47 sorl-thumbnail==12.10.0 # home-brewed & dependencies -librarian==26.2 +librarian==26.4.1 # celery tasks celery[redis]==5.4.0 diff --git a/src/catalogue/templates/catalogue/book_detail.html b/src/catalogue/templates/catalogue/book_detail.html index 2f51787eb..7074a81aa 100644 --- a/src/catalogue/templates/catalogue/book_detail.html +++ b/src/catalogue/templates/catalogue/book_detail.html @@ -335,7 +335,7 @@ {% endif %} -
+
{% content_warning book %} {{ book.abstract|safe }} diff --git a/src/wolnelektury/static/2022/styles/layout/_article.scss b/src/wolnelektury/static/2022/styles/layout/_article.scss index ba7e14546..cb913a36e 100644 --- a/src/wolnelektury/static/2022/styles/layout/_article.scss +++ b/src/wolnelektury/static/2022/styles/layout/_article.scss @@ -226,3 +226,17 @@ margin-bottom: .7em; } } + + +.abstract { + h2 { + font-size: 1.1em; + margin-top: 1.5em; + margin-bottom: 0; + font-weight: bold; + } + em.author-emphasis { + font-weight: bold; + font-style: normal; + } +} -- 2.20.1 From 439fc561d2a4929fc82b58bc706a49cc95826eb6 Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Fri, 24 Apr 2026 11:48:19 +0200 Subject: [PATCH 03/16] Protect dictionary from bots. --- src/dictionary/urls.py | 3 ++- src/wolnelektury/static/root/robots.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/dictionary/urls.py b/src/dictionary/urls.py index 8f11ac8ec..541344576 100644 --- a/src/dictionary/urls.py +++ b/src/dictionary/urls.py @@ -1,9 +1,10 @@ # 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.contrib.auth.decorators import login_required from django.urls import path from dictionary.views import NotesView urlpatterns = [ - path('', NotesView.as_view(), name='dictionary_notes'), + path('', login_required(NotesView.as_view()), name='dictionary_notes'), ] diff --git a/src/wolnelektury/static/root/robots.txt b/src/wolnelektury/static/root/robots.txt index 913cfaae8..a67966478 100644 --- a/src/wolnelektury/static/root/robots.txt +++ b/src/wolnelektury/static/root/robots.txt @@ -7,4 +7,4 @@ Disallow: /szukaj/ Disallow: /uzytkownik/ Disallow: /zegar/ Disallow: /katalog/custompdf/ - +Disallow: /przypisy/ -- 2.20.1 From 54b3d528298bc6387e88dd48b634c94d350f1c7b Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Fri, 24 Apr 2026 11:57:41 +0200 Subject: [PATCH 04/16] Allow bulk list item delete --- src/social/api/urls2.py | 1 + src/social/api/views.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/social/api/urls2.py b/src/social/api/urls2.py index 7f6ab6542..f66223e1b 100644 --- a/src/social/api/urls2.py +++ b/src/social/api/urls2.py @@ -18,6 +18,7 @@ urlpatterns = [ path('lists/', views.ListsView.as_view()), path('lists//', views.ListView.as_view()), path('lists//items/', views.ListItemListViewV3.as_view()), + path('list-items/', views.ListItemsView.as_view()), path('list-items//', views.ListItemViewV3.as_view()), path('lists///', views.ListItemViewV2.as_view()), diff --git a/src/social/api/views.py b/src/social/api/views.py index 6cae38ae4..0ea769c4e 100644 --- a/src/social/api/views.py +++ b/src/social/api/views.py @@ -212,6 +212,19 @@ class ListItemListViewV3(ListCreateAPIView): serializer.save(list=lst) +class ListItemsView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request): + if not isinstance(self.request.data, list): + return Response({"error": "no data"}, status=400) + models.UserListItem.objects.filter( + list__user=self.request.user, + uuid__in=self.request.data + ).delete() + return Response({}) + + @never_cache class ListItemViewV3(RetrieveUpdateDestroyAPIView): permission_classes = [IsAuthenticated] -- 2.20.1 From 5cb736f7ca55179d06965cc9874d0b059299d933 Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Fri, 24 Apr 2026 12:50:48 +0200 Subject: [PATCH 05/16] Serve partner audiobooks with isbns --- src/catalogue/forms.py | 6 +- .../migrations/0056_book_isbn_mp3.py | 18 ++++++ src/catalogue/models/book.py | 5 +- src/partners/admin.py | 4 ++ src/partners/api/urls.py | 2 + src/partners/api/views.py | 59 +++++++++++++++++++ .../migrations/0003_audiopricelevel.py | 26 ++++++++ src/partners/models.py | 17 ++++++ 8 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 src/catalogue/migrations/0056_book_isbn_mp3.py create mode 100644 src/partners/migrations/0003_audiopricelevel.py diff --git a/src/catalogue/forms.py b/src/catalogue/forms.py index 7e543df29..c9c3edc23 100644 --- a/src/catalogue/forms.py +++ b/src/catalogue/forms.py @@ -21,6 +21,7 @@ class BookImportForm(forms.Form): logo_mono = forms.CharField(required=False) logo_alt = forms.CharField(required=False) can_sell = forms.BooleanField(required=False) + isbn_mp3 = forms.CharField(required=False) def clean(self): from django.core.files.base import ContentFile @@ -41,8 +42,9 @@ class BookImportForm(forms.Form): logo=self.cleaned_data['logo'], logo_mono=self.cleaned_data['logo_mono'], logo_alt=self.cleaned_data['logo_alt'], - can_sell=self.cleaned_data['can_sell'], - **kwargs) + can_sell=self.cleaned_data['can_sell'], + isbn_mp3=self.cleaned_data['isbn_mp3'], + **kwargs) FORMATS = [(f, f.upper()) for f in Book.ebook_formats] diff --git a/src/catalogue/migrations/0056_book_isbn_mp3.py b/src/catalogue/migrations/0056_book_isbn_mp3.py new file mode 100644 index 000000000..bb24a7e93 --- /dev/null +++ b/src/catalogue/migrations/0056_book_isbn_mp3.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.8 on 2026-04-24 10:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalogue', '0055_book_can_sell'), + ] + + operations = [ + migrations.AddField( + model_name='book', + name='isbn_mp3', + field=models.CharField(blank=True, max_length=32, verbose_name='ISBN audiobooka'), + ), + ] diff --git a/src/catalogue/models/book.py b/src/catalogue/models/book.py index 8597eac75..fddce429c 100644 --- a/src/catalogue/models/book.py +++ b/src/catalogue/models/book.py @@ -62,6 +62,7 @@ class Book(models.Model): preview_key = models.CharField(max_length=32, blank=True, null=True) findable = models.BooleanField('wyszukiwalna', default=True, db_index=True) can_sell = models.BooleanField('do sprzedaży', default=True) + isbn_mp3 = models.CharField('ISBN audiobooka', max_length=32, blank=True) # files generated during publication xml_file = fields.XmlField(storage=bofh_storage, with_etag=False) @@ -684,7 +685,7 @@ class Book(models.Model): @classmethod def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True, - remote_gallery_url=None, days=0, findable=True, logo=None, logo_mono=None, logo_alt=None, can_sell=None): + remote_gallery_url=None, days=0, findable=True, logo=None, logo_mono=None, logo_alt=None, can_sell=None, isbn_mp3=None): from catalogue import tasks if dont_build is None: @@ -741,6 +742,8 @@ class Book(models.Model): extra['logo_alt'] = logo_alt if can_sell is not None: book.can_sell = can_sell + if isbn_mp3 is not None: + book.isbn_mp3 = isbn_mp3 book.extra_info = json.dumps(extra) book.load_abstract() book.load_toc() diff --git a/src/partners/admin.py b/src/partners/admin.py index 3ad6775ce..5083f6058 100644 --- a/src/partners/admin.py +++ b/src/partners/admin.py @@ -6,10 +6,14 @@ class PriceLevelInline(admin.TabularInline): model = models.PriceLevel extra = 0 +class AudioPriceLevelInline(admin.TabularInline): + model = models.AudioPriceLevel + extra = 0 @admin.register(models.Partner) class PartnerAdmin(admin.ModelAdmin): inlines = [ PriceLevelInline, + AudioPriceLevelInline, ] diff --git a/src/partners/api/urls.py b/src/partners/api/urls.py index df250fdd6..f88eedff1 100644 --- a/src/partners/api/urls.py +++ b/src/partners/api/urls.py @@ -8,4 +8,6 @@ from . import views urlpatterns = [ path('/books/', views.PartnerBooksView.as_view()), + path('/audiobooks/', + views.PartnerAudiobooksView.as_view()), ] diff --git a/src/partners/api/views.py b/src/partners/api/views.py index ccae25ebd..a2e1de597 100644 --- a/src/partners/api/views.py +++ b/src/partners/api/views.py @@ -39,6 +39,52 @@ class PartnerBookSerializer(BookSerializer2): return self.context['partner'].get_price(obj.pages) +class PartnerAudiobookSerializer(BookSerializer2): + price = serializers.SerializerMethodField() + duration = serializers.SerializerMethodField() + files = serializers.SerializerMethodField() + + class Meta: + model = Book + fields = [ + 'slug', 'title', + 'href', 'url', 'language', + 'authors', 'translators', + 'epochs', 'genres', 'kinds', + 'files', + 'cover', + 'isbn_mp3', + 'abstract', + 'content_warnings', 'audiences', + 'changed_at', 'duration', + 'price', + ] + + def get_duration(self, obj): + return obj.get_audiobooks(True)[2] + + def get_files(self, obj): + def get_for_single(b): + fs = [] + for m in b.media.filter(type='mp3'): + fs.append({ + "name": m.name, + "part_name": m.part_name, + "url": 'https://wolnelektury.pl' + m.file.url, + }) + for c in b.get_children(): + fs.extend(get_for_single(c)) + return fs + return get_for_single(b) + + def get_price(self, obj): + duration = obj.get_audiobooks(True)[2] + if not duration: + return None + duration /= 60 + return self.context['partner'].get_audio_price(obj.pages) + + @method_decorator(never_cache, name='dispatch') class PartnerBooksView(ListAPIView): serializer_class = PartnerBookSerializer @@ -50,3 +96,16 @@ class PartnerBooksView(ListAPIView): def get_queryset(self): return Book.objects.filter(parent=None).filter(can_sell=True).exclude(pages=None) + + +@method_decorator(never_cache, name='dispatch') +class PartnerAudiobooksView(ListAPIView): + serializer_class = PartnerAudiobookSerializer + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['partner'] = get_object_or_404(models.Partner, key=self.kwargs['key']) + return ctx + + def get_queryset(self): + return Book.objects.exclude(isbn_mp3='') diff --git a/src/partners/migrations/0003_audiopricelevel.py b/src/partners/migrations/0003_audiopricelevel.py new file mode 100644 index 000000000..9bd1f2148 --- /dev/null +++ b/src/partners/migrations/0003_audiopricelevel.py @@ -0,0 +1,26 @@ +# Generated by Django 4.0.8 on 2026-04-24 10:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('partners', '0002_alter_pricelevel_price'), + ] + + operations = [ + migrations.CreateModel( + name='AudioPriceLevel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('min_minutes', models.IntegerField(blank=True, null=True)), + ('price', models.DecimalField(decimal_places=2, max_digits=10)), + ('partner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partners.partner')), + ], + options={ + 'ordering': ('price',), + }, + ), + ] diff --git a/src/partners/models.py b/src/partners/models.py index 32e8a1bb2..583c34c79 100644 --- a/src/partners/models.py +++ b/src/partners/models.py @@ -16,6 +16,14 @@ class Partner(models.Model): return None return price_obj.price + def get_audio_price(self, minutes): + price_obj = self.audiopricelevel_set.exclude( + min_minutes__gt=minutes + ).order_by('-price').first() + if price_obj is None: + return None + return price_obj.price + class PriceLevel(models.Model): partner = models.ForeignKey(Partner, models.CASCADE) @@ -24,3 +32,12 @@ class PriceLevel(models.Model): class Meta: ordering = ('price',) + + +class AudioPriceLevel(models.Model): + partner = models.ForeignKey(Partner, models.CASCADE) + min_minutes = models.IntegerField(null=True, blank=True) + price = models.DecimalField(max_digits=10, decimal_places=2) + + class Meta: + ordering = ('price',) -- 2.20.1 From 7a3626e47b968d2a56c7992ac6c6d292e91f987a Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Fri, 24 Apr 2026 13:44:08 +0200 Subject: [PATCH 06/16] Add sellable flag for audiobooks. --- .../migrations/0057_book_can_sell_mp3.py | 18 ++++++++++++++++++ src/catalogue/models/book.py | 14 ++++++++++++++ src/partners/api/views.py | 6 +++--- 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 src/catalogue/migrations/0057_book_can_sell_mp3.py diff --git a/src/catalogue/migrations/0057_book_can_sell_mp3.py b/src/catalogue/migrations/0057_book_can_sell_mp3.py new file mode 100644 index 000000000..900053d55 --- /dev/null +++ b/src/catalogue/migrations/0057_book_can_sell_mp3.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.8 on 2026-04-24 11:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalogue', '0056_book_isbn_mp3'), + ] + + operations = [ + migrations.AddField( + model_name='book', + name='can_sell_mp3', + field=models.BooleanField(default=True, verbose_name='do sprzedaży mp3'), + ), + ] diff --git a/src/catalogue/models/book.py b/src/catalogue/models/book.py index fddce429c..d0487cd5f 100644 --- a/src/catalogue/models/book.py +++ b/src/catalogue/models/book.py @@ -7,6 +7,7 @@ from datetime import date, timedelta from random import randint import os.path import re +import requests from slugify import slugify from sortify import sortify from urllib.request import urlretrieve @@ -62,6 +63,7 @@ class Book(models.Model): preview_key = models.CharField(max_length=32, blank=True, null=True) findable = models.BooleanField('wyszukiwalna', default=True, db_index=True) can_sell = models.BooleanField('do sprzedaży', default=True) + can_sell_mp3 = models.BooleanField('do sprzedaży mp3', default=True) isbn_mp3 = models.CharField('ISBN audiobooka', max_length=32, blank=True) # files generated during publication @@ -909,6 +911,18 @@ class Book(models.Model): narrators.append(t) self.narrators.set(narrators) + def update_can_sell_mp3(self): + ret = True + for child in self.get_children(): + child.update_can_sell_mp3() + if not child.can_sell_mp3: + ret = False + if self.has_mp3_file(): + audio_items = requests.get(f'https://audio.wolnelektury.pl/archive/book/{self.slug}.json').json()['items'] + if not all(x['project']['can_sell'] for x in audio_items): + ret = False + self.can_sell_audio = ret + @classmethod @transaction.atomic def repopulate_ancestors(cls): diff --git a/src/partners/api/views.py b/src/partners/api/views.py index a2e1de597..530938021 100644 --- a/src/partners/api/views.py +++ b/src/partners/api/views.py @@ -75,10 +75,10 @@ class PartnerAudiobookSerializer(BookSerializer2): for c in b.get_children(): fs.extend(get_for_single(c)) return fs - return get_for_single(b) + return get_for_single(obj) def get_price(self, obj): - duration = obj.get_audiobooks(True)[2] + duration = obj.get_audiobooks(True, True)[2] if not duration: return None duration /= 60 @@ -108,4 +108,4 @@ class PartnerAudiobooksView(ListAPIView): return ctx def get_queryset(self): - return Book.objects.exclude(isbn_mp3='') + return Book.objects.filter(parent=None, can_sell_mp3=True).exclude(isbn_mp3='') -- 2.20.1 From 5e286cda5a6aa1c6299b285aa9c6a7f4ed3e654c Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Fri, 24 Apr 2026 13:49:03 +0200 Subject: [PATCH 07/16] fix --- src/catalogue/models/book.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/catalogue/models/book.py b/src/catalogue/models/book.py index d0487cd5f..7ed0b4cda 100644 --- a/src/catalogue/models/book.py +++ b/src/catalogue/models/book.py @@ -63,7 +63,7 @@ class Book(models.Model): preview_key = models.CharField(max_length=32, blank=True, null=True) findable = models.BooleanField('wyszukiwalna', default=True, db_index=True) can_sell = models.BooleanField('do sprzedaży', default=True) - can_sell_mp3 = models.BooleanField('do sprzedaży mp3', default=True) + can_sell_mp3 = models.BooleanField('do sprzedaży mp3', default=False) isbn_mp3 = models.CharField('ISBN audiobooka', max_length=32, blank=True) # files generated during publication @@ -921,7 +921,8 @@ class Book(models.Model): audio_items = requests.get(f'https://audio.wolnelektury.pl/archive/book/{self.slug}.json').json()['items'] if not all(x['project']['can_sell'] for x in audio_items): ret = False - self.can_sell_audio = ret + self.can_sell_mp3 = ret + self.save(update_fields=['can_sell_mp3']) @classmethod @transaction.atomic -- 2.20.1 From 8b021af276a2eb583fe962098b28459764f84b88 Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Tue, 28 Apr 2026 15:21:48 +0200 Subject: [PATCH 08/16] Query lists for book. --- src/social/api/urls2.py | 1 + src/social/api/views.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/social/api/urls2.py b/src/social/api/urls2.py index f66223e1b..8f1dc150c 100644 --- a/src/social/api/urls2.py +++ b/src/social/api/urls2.py @@ -19,6 +19,7 @@ urlpatterns = [ path('lists//', views.ListView.as_view()), path('lists//items/', views.ListItemListViewV3.as_view()), path('list-items/', views.ListItemsView.as_view()), + path('list-items/book//', views.ListItemsForBook.as_view()), path('list-items//', views.ListItemViewV3.as_view()), path('lists///', views.ListItemViewV2.as_view()), diff --git a/src/social/api/views.py b/src/social/api/views.py index 0ea769c4e..2a57ad3d4 100644 --- a/src/social/api/views.py +++ b/src/social/api/views.py @@ -212,6 +212,19 @@ class ListItemListViewV3(ListCreateAPIView): serializer.save(list=lst) +@never_cache +class ListItemsForBook(ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = serializers.UserListItemReadSerializer + + def get_queryset(self): + book = get_object_or_404(catalogue.models.Book, slug=self.kwargs['book']) + return models.UserListItem.objects.filter( + list__user=self.request.user, + book=book, + ) + + class ListItemsView(APIView): permission_classes = [IsAuthenticated] -- 2.20.1 From d00a4bcb2ee6a3ec0c65600b854a86524dfa4d4b Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Fri, 8 May 2026 15:16:33 +0200 Subject: [PATCH 09/16] Seasonal overlays --- .../0023_banner_video_alter_banner_place.py | 23 ++++++ src/annoy/models.py | 1 + src/annoy/places.py | 1 + .../annoy/banner_seasonal_overlay.html | 41 ++++++++++ .../templates/annoy/checkout_header.html | 9 ++ src/annoy/templatetags/annoy.py | 20 +++++ .../0058_alter_book_can_sell_mp3.py | 18 ++++ src/club/templates/club/donation_step1.html | 10 +++ .../templates/club/donation_step_base.html | 2 + .../static/2022/styles/layout/_annoy.scss | 82 +++++++++++++++++++ .../static/2022/styles/layout/_checkout.scss | 78 +++++++++++------- src/wolnelektury/templates/base.html | 1 + 12 files changed, 257 insertions(+), 29 deletions(-) create mode 100644 src/annoy/migrations/0023_banner_video_alter_banner_place.py create mode 100644 src/annoy/templates/annoy/banner_seasonal_overlay.html create mode 100644 src/annoy/templates/annoy/checkout_header.html create mode 100644 src/catalogue/migrations/0058_alter_book_can_sell_mp3.py diff --git a/src/annoy/migrations/0023_banner_video_alter_banner_place.py b/src/annoy/migrations/0023_banner_video_alter_banner_place.py new file mode 100644 index 000000000..7ba8961eb --- /dev/null +++ b/src/annoy/migrations/0023_banner_video_alter_banner_place.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.8 on 2026-04-29 11:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('annoy', '0022_remove_banner_progress_remove_banner_target'), + ] + + operations = [ + migrations.AddField( + model_name='banner', + name='video', + field=models.FileField(blank=True, upload_to='annoy/banners/', verbose_name='video'), + ), + migrations.AlterField( + model_name='banner', + name='place', + field=models.SlugField(choices=[('top', 'U góry wszystkich stron'), ('book-page', 'Strona książki'), ('book-page-center', 'Strona książki, środek'), ('book-text-intermission', 'Przerwa w treści książki'), ('book-fragment-list', 'Obok listy fragmentów książki'), ('blackout', 'Blackout'), ('crisis', 'Kryzysowa'), ('seasonal', 'Sezonowa'), ('seasonal-overlay', 'Sezonowa-overlay')], verbose_name='miejsce'), + ), + ] diff --git a/src/annoy/models.py b/src/annoy/models.py index 7ef119ec2..c4f4ff0b3 100644 --- a/src/annoy/models.py +++ b/src/annoy/models.py @@ -87,6 +87,7 @@ class Banner(models.Model): close_label = models.CharField('etykieta zamykania', max_length=255, blank=True) text = models.TextField('tekst') image = models.FileField('obraz', upload_to='annoy/banners/', blank=True) + video = models.FileField('video', upload_to='annoy/banners/', blank=True) url = models.CharField('URL', max_length=1024) priority = models.PositiveSmallIntegerField( 'priorytet', default=0, diff --git a/src/annoy/places.py b/src/annoy/places.py index 2fbd34ca3..ae7fe4479 100644 --- a/src/annoy/places.py +++ b/src/annoy/places.py @@ -14,6 +14,7 @@ PLACE_DEFINITIONS = [ ('loud', 'Ostry'), )), ('seasonal', 'Sezonowa', False), + ('seasonal-overlay', 'Sezonowa-overlay', True), ] PLACE_CHOICES = [p[:2] for p in PLACE_DEFINITIONS] diff --git a/src/annoy/templates/annoy/banner_seasonal_overlay.html b/src/annoy/templates/annoy/banner_seasonal_overlay.html new file mode 100644 index 000000000..7ab86b2ff --- /dev/null +++ b/src/annoy/templates/annoy/banner_seasonal_overlay.html @@ -0,0 +1,41 @@ +{% if banner %} + +
+
+
+ + {% if banner.get_image %} +
+ +
+ {% endif %} + {% if banner.video %} + + {% endif %} +
+ {{ banner.get_text|safe }} + {{ banner.action_label }} +
+ + + + + + +
+ +
+
+{% endif %} diff --git a/src/annoy/templates/annoy/checkout_header.html b/src/annoy/templates/annoy/checkout_header.html new file mode 100644 index 000000000..225d3b509 --- /dev/null +++ b/src/annoy/templates/annoy/checkout_header.html @@ -0,0 +1,9 @@ + +
+ {% if banner.video %} + + {% endif %} +
+
+ {{ banner.text|safe }} +
diff --git a/src/annoy/templatetags/annoy.py b/src/annoy/templatetags/annoy.py index 602fb3cef..ac1b6283d 100644 --- a/src/annoy/templatetags/annoy.py +++ b/src/annoy/templatetags/annoy.py @@ -53,3 +53,23 @@ def annoy_banner_seasonal(context): 'banner': banners.first(), 'closable': False, } + +@register.inclusion_tag('annoy/banner_seasonal_overlay.html', takes_context=True) +def annoy_banner_seasonal_overlay(context): + banners = Banner.choice('seasonal-overlay', request=context['request']) + return { + 'banner': banners.first(), + } + + +@register.simple_tag(takes_context=True) +def seasonal_overlay_exists(context): + return Banner.choice('seasonal-overlay', request=context['request'], exemptions=False).exists() + + +@register.inclusion_tag('annoy/checkout_header.html', takes_context=True) +def annoy_checkout_header(context): + banners = Banner.choice('seasonal-overlay', request=context['request'], exemptions=False) + return { + 'banner': banners.first(), + } diff --git a/src/catalogue/migrations/0058_alter_book_can_sell_mp3.py b/src/catalogue/migrations/0058_alter_book_can_sell_mp3.py new file mode 100644 index 000000000..bd3204194 --- /dev/null +++ b/src/catalogue/migrations/0058_alter_book_can_sell_mp3.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.8 on 2026-04-29 11:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalogue', '0057_book_can_sell_mp3'), + ] + + operations = [ + migrations.AlterField( + model_name='book', + name='can_sell_mp3', + field=models.BooleanField(default=False, verbose_name='do sprzedaży mp3'), + ), + ] diff --git a/src/club/templates/club/donation_step1.html b/src/club/templates/club/donation_step1.html index d01e69364..4c7292f8c 100644 --- a/src/club/templates/club/donation_step1.html +++ b/src/club/templates/club/donation_step1.html @@ -1,6 +1,16 @@ {% extends 'club/donation_step_base.html' %} +{% load annoy %} {% load chunks %} +{% block donation-form-header %} +{% seasonal_overlay_exists as e %} +{% if e %} +{% annoy_checkout_header %} +{% else %} +{{ block.super }} +{% endif %} +{% endblock %} + {% block donation-step-content %} {% include "club/donation_step1_form.html" %} {% endblock %} diff --git a/src/club/templates/club/donation_step_base.html b/src/club/templates/club/donation_step_base.html index 0c99d3ca9..e2cec7ac2 100644 --- a/src/club/templates/club/donation_step_base.html +++ b/src/club/templates/club/donation_step_base.html @@ -29,12 +29,14 @@
+ {% block donation-form-header %}
{% chunk "donate-top" %}
+ {% endblock %}
diff --git a/src/wolnelektury/static/2022/styles/layout/_annoy.scss b/src/wolnelektury/static/2022/styles/layout/_annoy.scss index f6b619a76..18c8df847 100644 --- a/src/wolnelektury/static/2022/styles/layout/_annoy.scss +++ b/src/wolnelektury/static/2022/styles/layout/_annoy.scss @@ -362,3 +362,85 @@ justify-content: space-between; } } + + +.t-relative { + position: relative; +} +.annoy-banner_seasonal-overlay { + display: none; + + background-color: #ffd430; + color: #083F4D; + border-radius: 0 0 10px 10px; + position: absolute; + z-index: 1000; + left: 16px; + right: 16px; + padding: 15px 20px; + margin-top: -10px; + + &:before { + background: rgba(0,0,0,0); + height: 10px; + display: block; + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + border-radius: 0 0 10px 10px; + box-shadow: 0px 10px 10px black; + z-index: 999; + } + + .annoy-banner-inner { + display: flex; + flex-direction: column; + gap: 20px; + align-items: flex-start; + justify-content: space-between; + + @include rwd($break-flow) { + flex-direction: row; + } + + .annoy-banner-off { + background: none; + border-color: transparent; + + position: absolute; + top: 16px; + right: 16px; + padding: 12px 4px; + } + + video { + height: 240px; + width: 100%; + @include rwd($break-flow) { + height: 360px; + width: auto; + } + } + + .text { + margin-right: 60px; + h1 { + font-size: 1.5em; + margin: 0 0 1em; + font-weight: bold; + } + + p { + margin: 1em 0; + } + .l-button { + display: inline-block; + margin-top: 1em; + padding: 12px 20px; + } + } + + } +} diff --git a/src/wolnelektury/static/2022/styles/layout/_checkout.scss b/src/wolnelektury/static/2022/styles/layout/_checkout.scss index 3b1ce0634..135c65310 100644 --- a/src/wolnelektury/static/2022/styles/layout/_checkout.scss +++ b/src/wolnelektury/static/2022/styles/layout/_checkout.scss @@ -64,51 +64,71 @@ } .l-checkout__box__header { - display: flex; + display: flex; + flex-direction: column; background: #083F4D; + @include rwd($break-flow) { + flex-direction: row; + } + .l-checkout__box__header__img { display: none; + background: black; background-position: center; background-size: cover; @include rwd($break-flow) { display: block; width: 50%; } - } -} -.l-checkout__box__header__content h1 { - font-style: italic; - font-weight: 300; - font-size: 48px; - line-height: 55px; - letter-spacing: -0.02em; - color: #92BD39; - margin: 0; + &.l-checkout__box__header__real { + display: block; + width: auto; + + video { + height: 200px; + width: 100%; + display: block; + @include rwd($break-flow) { + height: 360px; + width: auto; + } + } + } + } } -.l-checkout__box__header__content p:first-of-type { - font-style: normal; - font-weight: 600; - font-size: 21.5px; - line-height: 140%; - letter-spacing: -0.01em; - color: #FFFFFF; - margin-top: 23px; -} +.l-checkout__box__header__content { + color: white; + padding: 32px 45px; + + h1 { + font-style: italic; + font-weight: 300; + font-size: 48px; + line-height: 55px; + letter-spacing: -0.02em; + color: #92BD39; + margin: 0; + } -.l-checkout__box__header__content p { - font-weight: normal; - font-size: 18px; - line-height: 150%; - color: #FFFFFF; - margin-top: 8px; + p { + font-weight: normal; + font-size: 18px; + line-height: 150%; + margin-top: 8px; + + &:first-of-type { + font-weight: 600; + font-size: 21.5px; + line-height: 140%; + letter-spacing: -0.01em; + margin-top: 23px; + } + } } -.l-checkout__box__header__content { - padding: 32px 45px; -} //Steps .l-checkout__steps { diff --git a/src/wolnelektury/templates/base.html b/src/wolnelektury/templates/base.html index cf921fbe3..2f7b99f4d 100644 --- a/src/wolnelektury/templates/base.html +++ b/src/wolnelektury/templates/base.html @@ -40,6 +40,7 @@ {% endif %} {% annoy_banner_seasonal %} + {% annoy_banner_seasonal_overlay %} {% block global-content %}