From: Radek Czajka Date: Tue, 1 Jul 2025 10:23:25 +0000 (+0200) Subject: audio fix X-Git-Url: https://git.mdrn.pl/wolnelektury.git/commitdiff_plain/HEAD?hp=ef05330bad3d16d8f478a6cb34e6744cc7565abd audio fix --- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..032024ba7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +FROM python:3.8 AS base + +ARG UID=1000 +ARG GID=1000 + +RUN apt-get update && apt-get install -y \ + git \ + calibre \ + texlive-xetex texlive-lang-polish \ + libespeak-dev + +COPY requirements/requirements.txt requirements.txt + +# numpy -> aeneas +RUN pip install numpy +RUN pip install aeneas + +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir \ + psycopg2-binary \ + django-debug-toolbar==3.2.2 \ + python-bidi + +RUN addgroup --gid $GID app +RUN adduser --gid $GID --home /app --uid $UID app + +RUN apt-get install -y \ + texlive-extra-utils \ + texlive-lang-greek \ + texlive-lang-other \ + texlive-luatex \ + texlive-fonts-extra \ + texlive-fonts-extra-links \ + fonts-noto-core fonts-noto-extra + + +USER app + +# fonts +RUN cp -a /usr/local/lib/python*/site-packages/librarian/fonts /app/.fonts +RUN fc-cache + +WORKDIR /app/src + + +FROM base AS dev + +#RUN pip install --no-cache-dir coverage +USER app + + +FROM base AS prod + +RUN pip install --no-cache-dir gunicorn + +USER app +COPY src /app/src diff --git a/Makefile b/Makefile index d43ff1e6d..77d345df2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,8 @@ -.PHONY: deploy test +.PHONY: deploy test shell + + +UID != id -u +GID != id -g deploy: src/wolnelektury/localsettings.py @@ -17,3 +21,11 @@ test: mv ../htmlcov.new ../htmlcov coverage report rm .coverage + + +shell: + UID=$(UID) GID=$(GID) docker-compose run --rm dev bash + + +build: + UID=$(UID) GID=$(GID) docker-compose build dev diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..89b2216d7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + dev: + build: + context: . + target: dev + args: + - "UID=${UID}" + - "GID=${GID}" + volumes: + - ./src:/app/src + - ./var/media:/app/var/media + - ./var/static:/app/var/static + - ./var/counters/:/app/var/counters + depends_on: + - db + db: + image: postgres + container_name: db + env_file: + - .env + volumes: + - ./var/postgresql-data/:/var/lib/postgresql/data/ diff --git a/manage b/manage new file mode 100755 index 000000000..1077a0396 --- /dev/null +++ b/manage @@ -0,0 +1,14 @@ +#!/bin/sh +export UID=`id -u` +export GID=`id -g` + +if [ "$1" = "runserver" ] +then + PORT="$2" + [ -z "$PORT" ] && PORT=8000 + EXPOSED=127.0.0.1:"$PORT" + echo "expose as: $EXPOSED" + exec docker-compose run --rm -p "$EXPOSED":"$PORT" dev python $PYARGS manage.py runserver 0.0.0.0:"$PORT" +else + exec docker-compose run --rm dev python $PYARGS manage.py "$@" +fi diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 66e456930..0559a74ab 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -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.10 # 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 index 000000000..2a62d1173 --- /dev/null +++ b/src/annoy/templates/annoy/banner_top.html @@ -0,0 +1,51 @@ +{% load l10n %} + +{% if banner %} +
+
+
+ +
+ {% if banner.image %} + + {% endif %} +
+ +
+
+ {{ banner.get_text|safe|linebreaks }} +
+ +
+
+ {% if banner.action_label %} + + {{ banner.action_label }} + + {% endif %} +
+
+
+ + + +
+
+
+ +{% endif %} + diff --git a/src/annoy/templatetags/annoy.py b/src/annoy/templatetags/annoy.py index 40f7511a3..25293f1ca 100644 --- a/src/annoy/templatetags/annoy.py +++ b/src/annoy/templatetags/annoy.py @@ -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): diff --git a/src/api/fields.py b/src/api/fields.py index cdfcc4735..728323473 100644 --- a/src/api/fields.py +++ b/src/api/fields.py @@ -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 index 000000000..b2ba7bab8 --- /dev/null +++ b/src/api/migrations/0008_alter_token_token_type.py @@ -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')]), + ), + ] diff --git a/src/api/models.py b/src/api/models.py index ff1f09a0d..4f0556561 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -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 index 000000000..04366220c --- /dev/null +++ b/src/api/pagination.py @@ -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(), + } + }) diff --git a/src/api/serializers.py b/src/api/serializers.py index dae587c84..8c008926d 100644 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -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() diff --git a/src/api/tests/res/responses/books.xml b/src/api/tests/res/responses/books.xml index f39864480..1eb4a97d6 100644 --- a/src/api/tests/res/responses/books.xml +++ b/src/api/tests/res/responses/books.xml @@ -1,2 +1,2 @@ -$child$2Childhttp://testserver/katalog/lektura/child/#000000http://testserver/api/books/child/FalseWierszchild$grandchild$3Grandchildhttp://testserver/katalog/lektura/grandchild/#000000http://testserver/api/books/grandchild/FalseSonetgrandchildLirykajohn doe$parent$1Parenthttp://testserver/katalog/lektura/parent/#a6820aJohn Doecover/parent.jpgBarokhttp://testserver/api/books/parent/TrueSonethttp://testserver/media/cover_api_thumb/parent.jpgparentcover_thumb/parent.jpg +$child$2Childhttp://testserver/katalog/lektura/child/#000000http://testserver/api/books/child/?format=xmlFalseWierszchild$grandchild$3Grandchildhttp://testserver/katalog/lektura/grandchild/#000000http://testserver/api/books/grandchild/?format=xmlFalseSonetgrandchildLirykajohn doe$parent$1Parenthttp://testserver/katalog/lektura/parent/#a6820aJohn Doecover/parent.jpgBarokhttp://testserver/api/books/parent/?format=xmlTrueSonethttp://testserver/media/cover_api_thumb/parent.jpgparentcover_thumb/parent.jpg diff --git a/src/api/urls.py b/src/api/urls.py index 703283ef7..62d4fa7f7 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -9,7 +9,20 @@ 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')), + path('', include('social.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 +50,5 @@ urlpatterns = [ path('', include('social.api.urls')), path('', include('catalogue.api.urls')), + ] diff --git a/src/api/views.py b/src/api/views.py index 622332f2f..011161e52 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -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 polityce prywatności.' + ] + }) + + 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({}) + diff --git a/src/catalogue/api/serializers.py b/src/catalogue/api/serializers.py index daa86fec8..406cd39aa 100644 --- a/src/catalogue/api/serializers.py +++ b/src/catalogue/api/serializers.py @@ -40,10 +40,157 @@ 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', + 'abstract', + 'has_mp3_file', + ] + +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') @@ -92,6 +239,17 @@ class MediaSerializer(LegacyMixin, serializers.ModelSerializer): legacy_non_null_fields = ['director', 'artist'] +class MediaSerializer2(MediaSerializer): + size = serializers.SerializerMethodField() + + class Meta: + model = BookMedia + fields = ['url', 'director', 'type', 'name', 'part_name', 'artist', 'duration', 'size'] + + def get_size(self, obj): + return obj.file.size + + class BookDetailSerializer(LegacyMixin, serializers.ModelSerializer): url = AbsoluteURLField() @@ -205,3 +363,18 @@ class FragmentDetailSerializer(serializers.ModelSerializer): class Meta: model = Fragment fields = ['book', 'anchor', 'text', 'url', 'themes'] + + +class FragmentSerializer2(serializers.ModelSerializer): + url = AbsoluteURLField() + html = serializers.CharField(source='text') + + class Meta: + model = Fragment + fields = ['anchor', 'html', 'url'] + + +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 index 000000000..e90e13eea --- /dev/null +++ b/src/catalogue/api/urls2.py @@ -0,0 +1,59 @@ +# 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//', + piwik_track_view(views.BookDetail2.as_view()), + name='catalogue_api_book' + ), + path('books//fragment/', + piwik_track_view(views.BookFragmentView.as_view()), + name='catalogue_api_book_fragment' + ), + path('books//media//', + views.BookMediaView.as_view() + ), + + 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//', + 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//', + 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//', + 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//', + piwik_track_view(views.GenreView.as_view()), + name='catalogue_api_genre'), +] diff --git a/src/catalogue/api/views.py b/src/catalogue/api/views.py index c0dc57f76..821b281c4 100644 --- a/src/catalogue/api/views.py +++ b/src/catalogue/api/views.py @@ -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,36 @@ 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', []) + search = self.request.GET.get('search') + 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)) + tags = Tag.objects.filter(id__in=related_tags) + if search: + tags = tags.filter(name__icontains=search) + return tags + + +class BookFragmentView(RetrieveAPIView): + serializer_class = serializers.FragmentSerializer2 + + def get_object(self): + book = get_object_or_404(Book, slug=self.kwargs['slug']) + return book.choose_fragment() + + +class BookMediaView(ListAPIView): + serializer_class = serializers.MediaSerializer2 + pagination_class = None + + def get_queryset(self): + return BookMedia.objects.filter( + book__slug=self.kwargs['slug'], + type=self.kwargs['type'] + ).order_by('index') diff --git a/src/catalogue/fields.py b/src/catalogue/fields.py index 9c5696fa6..2d35357eb 100644 --- a/src/catalogue/fields.py +++ b/src/catalogue/fields.py @@ -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 index 000000000..bd297d4c6 --- /dev/null +++ b/src/catalogue/migrations/0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more.py @@ -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'), + ), + ] diff --git a/src/catalogue/models/book.py b/src/catalogue/models/book.py index 29e375464..ff09853f5 100644 --- a/src/catalogue/models/book.py +++ b/src/catalogue/models/book.py @@ -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') @@ -416,6 +421,67 @@ class Book(models.Model): def has_sync_file(self): return settings.FEATURE_SYNCHRO and self.has_media("sync") + def build_sync_file(self): + from lxml import html + from django.core.files.base import ContentFile + with self.html_file.open('rb') as f: + h = html.fragment_fromstring(f.read().decode('utf-8')) + + durations = [ + m['mp3'].duration + for m in self.get_audiobooks()[0] + ] + if settings.MOCK_DURATIONS: + durations = settings.MOCK_DURATIONS + + sync = [] + ts = None + sid = 1 + dirty = False + for elem in h.iter(): + if elem.get('data-audio-ts'): + part, ts = int(elem.get('data-audio-part')), float(elem.get('data-audio-ts')) + ts = str(round(sum(durations[:part - 1]) + ts, 3)) + # check if inside verse + p = elem.getparent() + while p is not None: + # Workaround for missing ids. + if 'verse' in p.get('class', ''): + if not p.get('id'): + p.set('id', f'syn{sid}') + dirty = True + sid += 1 + sync.append((ts, p.get('id'))) + ts = None + break + p = p.getparent() + elif ts: + cls = elem.get('class', '') + # Workaround for missing ids. + if 'paragraph' in cls or 'verse' in cls or elem.tag in ('h1', 'h2', 'h3', 'h4'): + if not elem.get('id'): + elem.set('id', f'syn{sid}') + dirty = True + sid += 1 + sync.append((ts, elem.get('id'))) + ts = None + if dirty: + htext = html.tostring(h, encoding='utf-8') + with open(self.html_file.path, 'wb') as f: + f.write(htext) + try: + bm = self.media.get(type='sync') + except: + bm = BookMedia(book=self, type='sync') + sync = ( + '27\n' + '\n'.join( + f'{s[0]}\t{sync[i+1][0]}\t{s[1]}' for i, s in enumerate(sync[:-1]) + )).encode('latin1') + bm.file.save( + None, ContentFile(sync) + ) + + def get_sync(self): with self.get_media('sync').first().file.open('r') as f: sync = f.read().split('\n') @@ -635,6 +701,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 +771,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) diff --git a/src/catalogue/models/bookmedia.py b/src/catalogue/models/bookmedia.py index acb1881e5..0a1544fec 100644 --- a/src/catalogue/models/bookmedia.py +++ b/src/catalogue/models/bookmedia.py @@ -70,6 +70,8 @@ class BookMedia(models.Model): return f'{name}.{ext}' def save(self, parts_count=None, *args, **kwargs): + if self.type in ('daisy', 'audio.epub'): + return super().save(*args, **kwargs) from catalogue.utils import ExistingFile, remove_zip if not parts_count: diff --git a/src/catalogue/templates/catalogue/book_detail.html b/src/catalogue/templates/catalogue/book_detail.html index eb1ab8a7d..6c89f1884 100644 --- a/src/catalogue/templates/catalogue/book_detail.html +++ b/src/catalogue/templates/catalogue/book_detail.html @@ -169,6 +169,11 @@
+
+ {% if first_text %} + {% trans + {% endif %} +
{% if first_text %} {% trans "czytaj online" %} diff --git a/src/catalogue/templates/catalogue/book_text.html b/src/catalogue/templates/catalogue/book_text.html index dbe92591a..18e4bcc43 100644 --- a/src/catalogue/templates/catalogue/book_text.html +++ b/src/catalogue/templates/catalogue/book_text.html @@ -176,7 +176,29 @@
+ - {% if book.other_versions.exists %} -
-

{% trans "Inne wersje utworu" %}

- {% trans "Zamknij drugą wersję" %} - -
- {% endif %} -
{% annoy_banners 'book-text-intermission' %} diff --git a/src/catalogue/test_utils.py b/src/catalogue/test_utils.py index f5d98cab5..a14552287 100644 --- a/src/catalogue/test_utils.py +++ b/src/catalogue/test_utils.py @@ -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): diff --git a/src/club/admin.py b/src/club/admin.py index fc715e2cd..442592803 100644 --- a/src/club/admin.py +++ b/src/club/admin.py @@ -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' diff --git a/src/club/civicrm.py b/src/club/civicrm.py index 20b287a28..dffeaa766 100644 --- a/src/club/civicrm.py +++ b/src/club/civicrm.py @@ -27,12 +27,12 @@ class CiviCRM: d = response.json() return d - def create_or_update_contact(self, email, key=None): + def create_or_update_contact(self, email, fields=None): contact_id = self.get_contact_id(email) if contact_id is None: - contact_id = self.create_contact(email, key) - elif key: - self.update_contact(contact_id, key) + contact_id = self.create_contact(email, fields) + elif fields: + self.update_contact(contact_id, fields) return contact_id def get_contact_id(self, email): @@ -49,7 +49,7 @@ class CiviCRM: if result: return result[0]['id'] - def create_contact(self, email, key=None): + def create_contact(self, email, fields): data = { 'values': {}, 'chain': { @@ -65,19 +65,22 @@ class CiviCRM: ] } } - if key: - data['values']['WL.TPWL_key'] = key + if fields: + data['values'].update(fields) result = self.request('Contact', 'create', data) return result['values'][0]['id'] - - def update_contact(self, contact_id, key): + + def update_phone(self, contact_id, phone): + if self.request('Phone', 'get', {'where': [['phone', "=", phone], ['contact_id', "=", contact_id]]})['count']: + return + self.request('Phone', 'create', {'values': {'phone': phone, 'contact_id': contact_id}}) + + def update_contact(self, contact_id, fields): return self.request( 'Contact', 'update', { - 'values': { - 'WL.TPWL_key': key, - }, + 'values': fields, 'where': [ ['id', '=', contact_id] ] @@ -89,7 +92,8 @@ class CiviCRM: if not self.enabled: return - contact_id = self.create_or_update_contact(email, tpwl_key) + fields = {'WL.TPWL_key': tpwl_key} + contact_id = self.create_or_update_contact(email, fields) activity_id = self.get_activity_id(key) if activity_id is None: diff --git a/src/club/models.py b/src/club/models.py index c09769a95..c40428def 100644 --- a/src/club/models.py +++ b/src/club/models.py @@ -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): diff --git a/src/club/templates/admin/club/schedule/change_list.html b/src/club/templates/admin/club/schedule/change_list.html index 1b6de0814..3d9cd55eb 100644 --- a/src/club/templates/admin/club/schedule/change_list.html +++ b/src/club/templates/admin/club/schedule/change_list.html @@ -2,6 +2,7 @@ {% load club %} {% block content %} +
@@ -19,5 +20,15 @@
Aktywne miesięczne wpłaty cykliczne:{% club_active_30day_sum %} zł.
+
+
+ {% csrf_token %} + Pobierz zestawienie roczne + + + +
+
+
{{ block.super }} {% endblock content %} diff --git a/src/club/urls.py b/src/club/urls.py index 026758070..87bfa5a90 100644 --- a/src/club/urls.py +++ b/src/club/urls.py @@ -28,4 +28,6 @@ urlpatterns = [ path('notify//', views.PayUNotifyView.as_view(), name='club_payu_notify'), path('weryfikacja/', views.member_verify, name='club_member_verify'), + + path('potwierdzenie/', views.receipt, name='club_receipt'), ] diff --git a/src/club/views.py b/src/club/views.py index c6136b67f..b2657e62f 100644 --- a/src/club/views.py +++ b/src/club/views.py @@ -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"', + } + ) + diff --git a/src/pdcounter/templates/pdcounter/author_detail.html b/src/pdcounter/templates/pdcounter/author_detail.html index 8e3153a2d..2e3adb86e 100644 --- a/src/pdcounter/templates/pdcounter/author_detail.html +++ b/src/pdcounter/templates/pdcounter/author_detail.html @@ -25,8 +25,8 @@ {% if author.alive %}

{% trans "Dzieła tego autora objęte są prawem autorskim." %} - {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %} - Dowiedz się, dlaczego biblioteki internetowe nie mogą + {% blocktrans trimmed %} + Biblioteki internetowe nie mogą udostępniać dzieł tego autora. {% endblocktrans %}

@@ -46,8 +46,8 @@

- {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %} - Dowiedz się, dlaczego biblioteki internetowe nie mogą + {% blocktrans trimmed %} + Biblioteki internetowe nie mogą udostępniać dzieł tego autora. {% endblocktrans %}

diff --git a/src/pdcounter/templates/pdcounter/book_detail.html b/src/pdcounter/templates/pdcounter/book_detail.html index 655d7f045..02a099da5 100644 --- a/src/pdcounter/templates/pdcounter/book_detail.html +++ b/src/pdcounter/templates/pdcounter/book_detail.html @@ -40,16 +40,16 @@ {% endblocktrans %}

- {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %} - Dowiedz się, dlaczego biblioteki internetowe nie mogą + {% blocktrans trimmed %} + Biblioteki internetowe nie mogą udostępniać tego utworu. {% endblocktrans %}

{% else %}

{% trans "Ten utwór objęty jest prawem autorskim." %} - {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %} - Dowiedz się, dlaczego biblioteki internetowe nie mogą + {% blocktrans trimmed %} + Biblioteki internetowe nie mogą udostępniać tego utworu. {% endblocktrans %}

diff --git a/src/social/api/urls2.py b/src/social/api/urls2.py new file mode 100644 index 000000000..b150e6183 --- /dev/null +++ b/src/social/api/urls2.py @@ -0,0 +1,17 @@ +# 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 +from stats.utils import piwik_track_view +from . import views + + +urlpatterns = [ + path('like//', + piwik_track_view(views.LikeView2.as_view()), + name='social_api_like'), + path('likes/', views.LikesView.as_view()), + path('my-likes/', views.MyLikesView.as_view()), +] + + diff --git a/src/social/api/views.py b/src/social/api/views.py index c5506ce79..22a0e9c52 100644 --- a/src/social/api/views.py +++ b/src/social/api/views.py @@ -11,7 +11,9 @@ from api.utils import vary_on_auth from catalogue.api.helpers import order_books, books_after from catalogue.api.serializers import BookSerializer from catalogue.models import Book +import catalogue.models from social.utils import likes +from social.views import get_sets_for_book_ids @vary_on_auth @@ -32,10 +34,58 @@ class LikeView(APIView): return Response({}) +@vary_on_auth +class LikeView2(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, slug): + book = get_object_or_404(Book, slug=slug) + return Response({"likes": likes(request.user, book)}) + + def put(self, request, slug): + book = get_object_or_404(Book, slug=slug) + book.like(request.user) + return Response({"likes": likes(request.user, book)}) + + def delete(self, request, slug): + book = get_object_or_404(Book, slug=slug) + book.unlike(request.user) + return Response({"likes": likes(request.user, book)}) + + +@vary_on_auth +class LikesView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + slugs = request.GET.getlist('slug') + books = Book.objects.filter(slug__in=slugs) + books = {b.id: b.slug for b in books} + ids = books.keys() + res = get_sets_for_book_ids(ids, request.user) + res = {books[bid]: v for bid, v in res.items()} + return Response(res) + + +@vary_on_auth +class MyLikesView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + ids = catalogue.models.tag.TagRelation.objects.filter(tag__user=request.user).values_list('object_id', flat=True).distinct() + books = Book.objects.filter(id__in=ids) + books = {b.id: b.slug for b in books} + res = get_sets_for_book_ids(ids, request.user) + res = {books[bid]: v for bid, v in res.items()} + return Response(res) + + + @vary_on_auth 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 index 000000000..fe39336e1 --- /dev/null +++ b/src/social/migrations/0017_userconfirmation.py @@ -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)), + ], + ), + ] diff --git a/src/social/models.py b/src/social/models.py index cb1326b15..17fe7d07a 100644 --- a/src/social/models.py +++ b/src/social/models.py @@ -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 index 000000000..f56d2f259 --- /dev/null +++ b/src/social/templates/social/user_confirmation.html @@ -0,0 +1,12 @@ +{% extends "base_simple.html" %} +{% load i18n %} + + +{% block body %} +

{% trans "Konto potwierdzone" %}

+ +

+ {% blocktrans with user=user %}Konto {{ user }} zostało potwierdzone. Możesz się teraz zalogować.{% endblocktrans %} +

+{% endblock body %} + diff --git a/src/social/urls.py b/src/social/urls.py index 299f62058..10d9240d2 100644 --- a/src/social/urls.py +++ b/src/social/urls.py @@ -7,6 +7,7 @@ from . import views urlpatterns = [ + path('potwierdz//', views.confirm_user, name='social_confirm_user'), path('lektura//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'), diff --git a/src/social/views.py b/src/social/views.py index 989771a6c..8f27b8746 100644 --- a/src/social/views.py +++ b/src/social/views.py @@ -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 @@ -19,12 +19,13 @@ from wolnelektury.utils import is_ajax # ==================== -@require_POST +@login_required def like_book(request, slug): - if not request.user.is_authenticated: - return HttpResponseForbidden('Login required.') book = get_object_or_404(Book, slug=slug) + if request.method != 'POST': + return redirect(book) + book.like(request.user) if is_ajax(request): @@ -50,12 +51,13 @@ class RemoveSetView(AddSetView): form_class = forms.RemoveSetForm -@require_POST +@login_required def unlike_book(request, slug): - if not request.user.is_authenticated: - return HttpResponseForbidden('Login required.') book = get_object_or_404(Book, slug=slug) + if request.method != 'POST': + return redirect(book) + book.unlike(request.user) if is_ajax(request): @@ -125,3 +127,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, + }) diff --git a/src/wolnelektury/settings/apps.py b/src/wolnelektury/settings/apps.py index d4faa8bfb..b9d507e86 100644 --- a/src/wolnelektury/settings/apps.py +++ b/src/wolnelektury/settings/apps.py @@ -59,6 +59,7 @@ INSTALLED_APPS_CONTRIB = [ 'django.contrib.postgres', 'admin_ordering', 'rest_framework', + 'django_filters', 'fnp_django_pagination', 'pipeline', 'sorl.thumbnail', diff --git a/src/wolnelektury/settings/contrib.py b/src/wolnelektury/settings/contrib.py index aaef8b2ce..e5b2f60d6 100644 --- a/src/wolnelektury/settings/contrib.py +++ b/src/wolnelektury/settings/contrib.py @@ -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', } diff --git a/src/wolnelektury/settings/custom.py b/src/wolnelektury/settings/custom.py index 16b5e0a4f..d67377268 100644 --- a/src/wolnelektury/settings/custom.py +++ b/src/wolnelektury/settings/custom.py @@ -76,3 +76,5 @@ SEARCH_CONFIG_SIMPLE = 'simple' SEARCH_USE_UNACCENT = False FEATURE_SYNCHRO = False + +FEATURE_API_REGISTER = False diff --git a/src/wolnelektury/static/2022/styles/components/_media.scss b/src/wolnelektury/static/2022/styles/components/_media.scss index c4af4ebd1..9c5f34f94 100644 --- a/src/wolnelektury/static/2022/styles/components/_media.scss +++ b/src/wolnelektury/static/2022/styles/components/_media.scss @@ -10,7 +10,7 @@ .c-media__actions { display: flex; - column-gap: 38px; + column-gap: 20px; row-gap: 10px; } diff --git a/src/wolnelektury/static/2022/styles/layout/_annoy.scss b/src/wolnelektury/static/2022/styles/layout/_annoy.scss index 9d90c65ff..70704f34d 100644 --- a/src/wolnelektury/static/2022/styles/layout/_annoy.scss +++ b/src/wolnelektury/static/2022/styles/layout/_annoy.scss @@ -51,7 +51,7 @@ .annoy-banner-off { font-size: 1.2rem !important; } - + @include rwd($break-flow) { .annoy-banner-inner { padding: 0; @@ -69,16 +69,16 @@ } .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; @@ -87,18 +87,31 @@ 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%; @@ -116,11 +129,11 @@ .image-box { position: relative; img { - height: 159px; + max-height: 159px; display: block; @media screen and (max-width: 700px) { - height: 120px; + max-height: 120px; } } } @@ -137,11 +150,29 @@ } } + @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; } @@ -184,6 +215,13 @@ 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; @@ -191,7 +229,8 @@ } } } - &.annoy-banner-style_crisis_quiet { + &.annoy-banner-style_crisis_quiet, + &.annoy-banner_top-container { background: black; color: white; .annoy-banner-inner { diff --git a/src/wolnelektury/static/2022/styles/layout/_button.scss b/src/wolnelektury/static/2022/styles/layout/_button.scss index 78b33bbae..b1844d896 100644 --- a/src/wolnelektury/static/2022/styles/layout/_button.scss +++ b/src/wolnelektury/static/2022/styles/layout/_button.scss @@ -21,12 +21,16 @@ @include rwd($break-flow) { @include font-size(16px); line-height: 20px; - padding: 19px 20px; + padding: 19px 0; } .icon { font-size: 21px; - margin-right: 15px; + margin-right: 10px; + } + + img { + height: 21px; } &:hover { diff --git a/src/wolnelektury/static/2022/styles/layout/_text.scss b/src/wolnelektury/static/2022/styles/layout/_text.scss index 65bc48aef..c4ff0f355 100644 --- a/src/wolnelektury/static/2022/styles/layout/_text.scss +++ b/src/wolnelektury/static/2022/styles/layout/_text.scss @@ -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,101 @@ background: #fff; } } } + + + +#sidebar { + position: absolute; + left: 0; + top: 20px; + width: 200px; + + h2 { + font-size: 20px; + margin-bottom: 1em; + } + + .other-text-close { + display: none; + } + + #other { + display: none; + @include rwd($break-wide) { + display: block; + } + 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; +} diff --git a/src/wolnelektury/static/2022/styles/reader_player.scss b/src/wolnelektury/static/2022/styles/reader_player.scss index bf0fb2b29..4f85a03ec 100644 --- a/src/wolnelektury/static/2022/styles/reader_player.scss +++ b/src/wolnelektury/static/2022/styles/reader_player.scss @@ -448,7 +448,6 @@ #menu { - padding-bottom: 50px; * { box-sizing: content-box; } diff --git a/src/wolnelektury/static/img/elevenreader-21.png b/src/wolnelektury/static/img/elevenreader-21.png new file mode 100644 index 000000000..86e433d74 Binary files /dev/null and b/src/wolnelektury/static/img/elevenreader-21.png differ diff --git a/src/wolnelektury/static/img/elevenreader.png b/src/wolnelektury/static/img/elevenreader.png new file mode 100644 index 000000000..16dcadead Binary files /dev/null and b/src/wolnelektury/static/img/elevenreader.png differ diff --git a/src/wolnelektury/static/js/book_text/menu.js b/src/wolnelektury/static/js/book_text/menu.js index eff64a0c6..dde96edf9 100644 --- a/src/wolnelektury/static/js/book_text/menu.js +++ b/src/wolnelektury/static/js/book_text/menu.js @@ -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 = $('
'); + 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']); }); diff --git a/src/wolnelektury/static/js/book_text/progress.js b/src/wolnelektury/static/js/book_text/progress.js index ae5b14ac4..723e03ec9 100644 --- a/src/wolnelektury/static/js/book_text/progress.js +++ b/src/wolnelektury/static/js/book_text/progress.js @@ -4,6 +4,8 @@ function upd_t() { $text = $('#main-text #book-text'); + if (!$text.length) return; + texttop = $text.offset().top; $footnotes = $('#footnotes', $text); diff --git a/src/wolnelektury/static/js/book_text/references.js b/src/wolnelektury/static/js/book_text/references.js index fa4a35a69..3f5b7e37f 100644 --- a/src/wolnelektury/static/js/book_text/references.js +++ b/src/wolnelektury/static/js/book_text/references.js @@ -10,10 +10,12 @@ } var map_enabled = false; - var marker = L.circleMarker([0,0]); + var marker = null; var map = null; function enable_map() { + if (!$("#reference-map").length) return; + $("#reference-map").show('slow'); if (map_enabled) return; @@ -22,6 +24,7 @@ L.tileLayer('https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=a8a97f0ae5134403ac38c1a075b03e15', { attribution: 'Maps © Thunderforest, Data © OpenStreetMap contributors' }).addTo(map); + marker = L.circleMarker([0,0]); map_enabled = true; } diff --git a/src/wolnelektury/static/js/main.js b/src/wolnelektury/static/js/main.js index efa442e12..45a41970c 100644 --- a/src/wolnelektury/static/js/main.js +++ b/src/wolnelektury/static/js/main.js @@ -381,24 +381,27 @@ $.post({ url: '/ludzie/lektura/' + $(this).attr('data-book-slug') + '/nie_lubie/', data: {'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()}, + dataType: 'json', success: function() { delete state.liked[$btn.attr('data-book')]; updateLiked($btn); + }, + error: function() { + window.location.href = $('#login-link').attr('href'); } }) } else { $.post({ url: '/ludzie/lektura/' + $(this).attr('data-book-slug') + '/lubie/', data: {'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()}, + dataType: 'json', success: function() { state.liked[$btn.attr('data-book')] = []; updateLiked($btn); }, - error: function(e) { - if (e.status == 403) { - $('#login-link').click(); - } - }, + error: function() { + window.location.href = $('#login-link').attr('href') + } }); } }) @@ -545,8 +548,10 @@ 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(); + }); + } })(); diff --git a/src/wolnelektury/templates/admin/catalogue/book/change_list.html b/src/wolnelektury/templates/admin/catalogue/book/change_list.html index b9574d9c9..db3308460 100644 --- a/src/wolnelektury/templates/admin/catalogue/book/change_list.html +++ b/src/wolnelektury/templates/admin/catalogue/book/change_list.html @@ -4,7 +4,7 @@
{% csrf_token %}

- +

diff --git a/src/wolnelektury/templates/header.html b/src/wolnelektury/templates/header.html index f763f011a..3cc48515a 100644 --- a/src/wolnelektury/templates/header.html +++ b/src/wolnelektury/templates/header.html @@ -7,8 +7,9 @@ {% load preview_ad from catalogue_tags %} {% annoy_banner_crisis %} +{% annoy_banner_top %} -{#% annoy_banner_blackout %#} +{% annoy_banner_blackout %}