From: Radek Czajka Date: Mon, 22 Dec 2025 10:18:16 +0000 (+0100) Subject: fixes X-Git-Url: https://git.mdrn.pl/wolnelektury.git/commitdiff_plain/HEAD?hp=ef05330bad3d16d8f478a6cb34e6744cc7565abd fixes --- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..f916be0b7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +FROM python:3.8 AS base + +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 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 + + +# fonts +RUN cp -a /usr/local/lib/python*/site-packages/librarian/fonts /usr/local/share/fonts +RUN fc-cache + +WORKDIR /app/src + + +FROM base AS dev + +#RUN pip install --no-cache-dir coverage + + +FROM base AS prod + +RUN pip install --no-cache-dir gunicorn + +COPY src /app/src diff --git a/Makefile b/Makefile index d43ff1e6d..2d7d105be 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: + docker compose build dev diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..87612505f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + dev: + build: + context: . + target: dev + user: "${UID}:${GID}" + volumes: + - ./src:/app/src + - ./var/media:/app/var/media + - ./var/static:/app/var/static + - ./var/counters/:/app/var/counters + depends_on: + - db + env_file: + - .env + db: + image: postgres:18.0 + container_name: db + env_file: + - .env + volumes: + - ./var/postgresql-data/:/var/lib/postgresql/ diff --git a/manage b/manage new file mode 100755 index 000000000..b70a319d4 --- /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..5f7496ec6 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -3,20 +3,23 @@ # django Django==4.0.8 fnpdjango==0.6 -docutils==0.16 +docutils==0.20 -django-pipeline==2.0.8 -libsasscompiler==0.1.9 +python-memcached==1.59 + +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 +29,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==25.12 # 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/admin.py b/src/annoy/admin.py index ab7be6aca..ed651b2c5 100644 --- a/src/annoy/admin.py +++ b/src/annoy/admin.py @@ -5,6 +5,9 @@ from modeltranslation.admin import TranslationAdmin from . import models +admin.site.register(models.Campaign) + + class BannerAdmin(TranslationAdmin): list_display = [ 'place', 'text', diff --git a/src/annoy/migrations/0019_campaign_banner_books_alter_banner_place_and_more.py b/src/annoy/migrations/0019_campaign_banner_books_alter_banner_place_and_more.py new file mode 100644 index 000000000..51e33392f --- /dev/null +++ b/src/annoy/migrations/0019_campaign_banner_books_alter_banner_place_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.0.8 on 2025-11-25 15:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalogue', '0051_book_has_audio'), + ('annoy', '0018_alter_banner_style'), + ] + + operations = [ + migrations.CreateModel( + name='Campaign', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Dla zespołu', max_length=255)), + ('image', models.FileField(blank=True, upload_to='annoy/banners/', verbose_name='obraz')), + ], + ), + migrations.AddField( + model_name='banner', + name='books', + field=models.ManyToManyField(blank=True, to='catalogue.book'), + ), + 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')], verbose_name='miejsce'), + ), + migrations.AddField( + model_name='banner', + name='campaign', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='annoy.campaign'), + ), + ] diff --git a/src/annoy/models.py b/src/annoy/models.py index 0d887920d..6af1cc045 100644 --- a/src/annoy/models.py +++ b/src/annoy/models.py @@ -8,8 +8,18 @@ from django.utils.timezone import now from .places import PLACES, PLACE_CHOICES, STYLES +class Campaign(models.Model): + name = models.CharField(max_length=255, help_text='Dla zespołu') + image = models.FileField('obraz', upload_to='annoy/banners/', blank=True) + + def __str__(self): + return self.name + + class Banner(models.Model): place = models.SlugField('miejsce', choices=PLACE_CHOICES) + campaign = models.ForeignKey(Campaign, models.PROTECT, null=True, blank=True) + style = models.CharField( 'styl', max_length=255, blank=True, choices=STYLES, @@ -32,6 +42,8 @@ class Banner(models.Model): help_text='Bannery z wyższym priorytetem mają pierwszeństwo.') since = models.DateTimeField('od', null=True, blank=True) until = models.DateTimeField('do', null=True, blank=True) + books = models.ManyToManyField('catalogue.Book', blank=True) + target = models.IntegerField('cel', null=True, blank=True) progress = models.IntegerField('postęp', null=True, blank=True) show_members = models.BooleanField('widoczny dla członków klubu', default=False) @@ -49,8 +61,20 @@ class Banner(models.Model): def get_text(self): return Template(self.text).render(Context()) + def get_image(self): + if self.campaign and self.campaign.image: + return self.campaign.image + else: + return self.image + + def is_external(self): + return (self.url and + not self.url.startswith('/') and + not self.url.startswith('https://wolnelektury.pl/') + ) + @classmethod - def choice(cls, place, request, exemptions=True): + def choice(cls, place, request, exemptions=True, book=None): Membership = apps.get_model('club', 'Membership') if exemptions and hasattr(request, 'annoy_banner_exempt'): @@ -68,6 +92,12 @@ class Banner(models.Model): until__lt=n ).order_by('-priority', '?') + if book is None: + banners = banners.filter(books=None) + else: + banners = banners.filter(models.Q(books=None) | models.Q(books=book)) + + if not request.user.is_authenticated: banners = banners.filter(only_authenticated=False) diff --git a/src/annoy/places.py b/src/annoy/places.py index 8fa767bba..0de0483b9 100644 --- a/src/annoy/places.py +++ b/src/annoy/places.py @@ -1,6 +1,7 @@ PLACE_DEFINITIONS = [ ('top', 'U góry wszystkich stron', True), ('book-page', 'Strona książki', False), + ('book-page-center', 'Strona książki, środek', False), ('book-text-intermission', 'Przerwa w treści książki', False), ('book-fragment-list', 'Obok listy fragmentów książki', False), ('blackout', 'Blackout', True, ( diff --git a/src/annoy/templates/annoy/banner.html b/src/annoy/templates/annoy/banner.html index 4b2bdcfb8..9a67fcdcb 100644 --- a/src/annoy/templates/annoy/banner.html +++ b/src/annoy/templates/annoy/banner.html @@ -11,7 +11,7 @@ annoy-banner annoy-banner_{{ banner.place }} annoy-banner-style_{{ banner.style }} - {% if banner.image %}with-image{% endif %} + {% if banner.get_image %}with-image{% endif %} {% if banner.smallfont %}banner-smallfont{% endif %} " id="annoy-banner-{{ banner.id }}" @@ -20,19 +20,25 @@ {% if banner.background_color %}background-color: {{ banner.background_color }};{% endif %} "> {% if not banner.action_label %} - + {% endif %}
- {% if banner.image %} - + {% if banner.get_image %} +
+ +
{% endif %}
{{ banner.get_text|safe|linebreaks }}
{% if banner.action_label %} -
+ {{ banner.action_label }} {% endif %} 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..cdf4dbc5d 100644 --- a/src/annoy/templatetags/annoy.py +++ b/src/annoy/templatetags/annoy.py @@ -7,8 +7,8 @@ register = template.Library() @register.inclusion_tag('annoy/banner.html', takes_context=True) -def annoy_banner(context, place): - banners = Banner.choice(place, request=context['request']) +def annoy_banner(context, place, **kwargs): + banners = Banner.choice(place, request=context['request'], **kwargs) return { 'banner': banners.first(), 'closable': PLACES.get(place, False), @@ -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/migrations/0009_sessiontransfertoken.py b/src/api/migrations/0009_sessiontransfertoken.py new file mode 100644 index 000000000..a277811aa --- /dev/null +++ b/src/api/migrations/0009_sessiontransfertoken.py @@ -0,0 +1,27 @@ +# Generated by Django 4.0.8 on 2025-11-19 13:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0008_alter_token_token_type'), + ] + + operations = [ + migrations.CreateModel( + name='SessionTransferToken', + fields=[ + ('token', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField()), + ('used', models.BooleanField(default=False)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/api/models.py b/src/api/models.py index ff1f09a0d..eade03754 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -1,12 +1,14 @@ # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later. # Copyright © Fundacja Wolne Lektury. See NOTICE for more information. # +from datetime import timedelta +import uuid from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models.signals import pre_delete - +from django.utils import timezone from catalogue.models import Book, Tag @@ -101,7 +103,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) @@ -116,3 +123,26 @@ class Token(models.Model): def __str__(self): return "%s Token %s for %s" % (self.get_token_type_display(), self.key, self.consumer) + + +class SessionTransferToken(models.Model): + token = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + used = models.BooleanField(default=False) + + @classmethod + def create_for_user(cls, user, lifetime_seconds=30): + return cls.objects.create( + user=user, + expires_at=timezone.now() + timedelta(seconds=lifetime_seconds) + ) + + def is_valid(self): + if self.used: + return False + if timezone.now() > self.expires_at: + return False + return True 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..8d160b0e4 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,49 @@ 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() + + +class DeleteAccountSerializer(serializers.Serializer): + password =serializers.CharField( + style={'input_type': 'password'} + ) + + def validate_password(self, value): + u = self.context['user'] + if not u.check_password(value): + raise serializers.ValidationError("Password incorrect.") + return value + + +class PasswordSerializer(serializers.Serializer): + old_password = serializers.CharField( + style={'input_type': 'password'} + ) + new_password = serializers.CharField( + style={'input_type': 'password'} + ) + + def validate_old_password(self, value): + u = self.context['user'] + if not u.check_password(value): + raise serializers.ValidationError("Password incorrect.") + return value + + +class ResetPasswordSerializer(serializers.Serializer): + email = serializers.EmailField() 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..182e0dd6f 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -9,7 +9,29 @@ 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('deleteAccount/', views.DeleteAccountView.as_view()), + path('password/', views.PasswordView.as_view()), + path('password-reset/', views.ResetPasswordView.as_view()), + path("session-transfer/", views.SessionTransferTokenView.as_view()), + path("session-transfer/continue/", views.ConsumeSessionTransferTokenView.as_view(), name="api-session-continue"), + + path('', include('catalogue.api.urls2')), + path('', include('social.api.urls2')), + path('', include('bookmarks.api.urls')), + path('', include('search.api.urls')), + path('', include('push.api.urls')), +] + + 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 +59,5 @@ urlpatterns = [ path('', include('social.api.urls')), path('', include('catalogue.api.urls')), + ] diff --git a/src/api/utils.py b/src/api/utils.py index 3b23246b9..26e0778d9 100644 --- a/src/api/utils.py +++ b/src/api/utils.py @@ -5,6 +5,7 @@ from django.http import HttpResponse, HttpResponseRedirect from django.utils.decorators import method_decorator from django.utils.encoding import iri_to_uri from django.views.decorators.vary import vary_on_headers +import django.views.decorators.cache def oauthlib_request(request): @@ -36,6 +37,7 @@ def oauthlib_response(response_tuple): vary_on_auth = method_decorator(vary_on_headers('Authorization'), 'dispatch') +never_cache = method_decorator(django.views.decorators.cache.never_cache, 'dispatch') class HttpResponseAppRedirect(HttpResponseRedirect): diff --git a/src/api/views.py b/src/api/views.py index 622332f2f..5d526780a 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -2,12 +2,16 @@ # Copyright © Fundacja Wolne Lektury. See NOTICE for more information. # from time import time -from django.contrib.auth import authenticate +from allauth.account.forms import ResetPasswordForm +from allauth.account.utils import filter_users_by_email +from django.conf import settings +from django.contrib.auth import authenticate, login 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 HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.http import Http404 -from django.shortcuts import render +from django.shortcuts import redirect, render from django.views.generic.base import View from oauthlib.common import urlencode, generate_token from oauthlib.oauth1 import RequestTokenEndpoint, AccessTokenEndpoint @@ -17,7 +21,8 @@ from rest_framework.response import Response 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 .models import BookUserData, KEY_SIZE, SECRET_SIZE, Token, SessionTransferToken +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 +159,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 +229,200 @@ 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 + + email = d['email'] + + user = User( + username=email, + email=email, + is_active=True + ) + user.set_password(d['password']) + + if settings.FEATURE_CONFIRM_USER: + user.is_active = False + + try: + assert not filter_users_by_email(email) + user.save() + except: + return Response( + { + "detail": "Nie można utworzyć konta.", + }, + status=400 + ) + + if settings.FEATURE_CONFIRM_USER: + UserConfirmation.request(user) + return Response({ + 'emailConfirmationRequired': settings.FEATURE_CONFIRM_USER, + }) + + +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({}) + + +class DeleteAccountView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = serializers.DeleteAccountSerializer + + def post(self, request): + u = request.user + serializer = self.get_serializer( + data=request.data, + context={'user': u} + ) + serializer.is_valid(raise_exception=True) + d = serializer.validated_data + u.is_active = False + u.save() + return Response({}) + + +class PasswordView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = serializers.PasswordSerializer + + def post(self, request): + u = request.user + serializer = self.get_serializer( + data=request.data, + context={'user': u} + ) + serializer.is_valid(raise_exception=True) + d = serializer.validated_data + u.set_password(d['new_password']) + u.save() + return Response({}) + + +class ResetPasswordView(GenericAPIView): + serializer_class = serializers.ResetPasswordSerializer + + def post(self, request): + serializer = serializers.ResetPasswordSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + form = ResetPasswordForm({"email": serializer.validated_data['email']}) + form.is_valid() + form.save(request) + return Response({}) + + +class SessionTransferTokenView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + ott = SessionTransferToken.create_for_user(request.user) + return Response({ + "token": str(ott.token) + }) + + +class ConsumeSessionTransferTokenView(View): + def get(self, request): + token_str = request.GET.get("token") + next_url = request.GET.get("next", "/") #TODO: validate + + if not token_str: + return HttpResponseBadRequest("Missing token") + + try: + ott = SessionTransferToken.objects.get(token=token_str) + except SessionTransferToken.DoesNotExist: + return HttpResponseBadRequest("Invalid token") + + if not ott.is_valid(): + return HttpResponseForbidden("Token expired or already used") + + # Mark token as used + ott.used = True + ott.save(update_fields=["used"]) + + # Log in the user via Django session + login(request, ott.user) + + return redirect(next_url) diff --git a/src/bookmarks/api/urls.py b/src/bookmarks/api/urls.py new file mode 100644 index 000000000..f61b3263f --- /dev/null +++ b/src/bookmarks/api/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + + +urlpatterns = [ + path('bookmarks/', views.BookmarksView.as_view()), + path('bookmarks/book//', views.BookBookmarksView.as_view()), + path('bookmarks//', views.BookmarkView.as_view(), name='api_bookmark'), +] diff --git a/src/bookmarks/api/views.py b/src/bookmarks/api/views.py new file mode 100644 index 000000000..b500a6654 --- /dev/null +++ b/src/bookmarks/api/views.py @@ -0,0 +1,69 @@ +from api.utils import never_cache + +from django.db.models import Q +from django.http import Http404, JsonResponse +from django.shortcuts import render, get_object_or_404 +from django.views.decorators import cache +import catalogue.models +from wolnelektury.utils import is_ajax +from bookmarks import models +from lxml import html +import re +from rest_framework.generics import ListAPIView, ListCreateAPIView, RetrieveUpdateDestroyAPIView +from rest_framework import serializers +from rest_framework.permissions import SAFE_METHODS, IsAuthenticated, IsAuthenticatedOrReadOnly +from api.fields import AbsoluteURLField + + +class BookmarkSerializer(serializers.ModelSerializer): + book = serializers.SlugRelatedField( + queryset=catalogue.models.Book.objects.all(), slug_field='slug', + required=False + ) + href = AbsoluteURLField(view_name='api_bookmark', view_args=['uuid']) + timestamp = serializers.IntegerField(required=False) + location = serializers.CharField(required=False) + + class Meta: + model = models.Bookmark + fields = ['book', 'anchor', 'audio_timestamp', 'mode', 'note', 'href', 'uuid', 'location', 'timestamp', 'deleted'] + read_only_fields = ['uuid', 'mode'] + + + +@never_cache +class BookmarksView(ListCreateAPIView): + permission_classes = [IsAuthenticated] + serializer_class = BookmarkSerializer + + def get_queryset(self): + return self.request.user.bookmark_set.all() + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +@never_cache +class BookBookmarksView(ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = BookmarkSerializer + pagination_class = None + + def get_queryset(self): + return self.request.user.bookmark_set.filter(book__slug=self.kwargs['book']) + + +@never_cache +class BookmarkView(RetrieveUpdateDestroyAPIView): + permission_classes = [IsAuthenticatedOrReadOnly] + serializer_class = BookmarkSerializer + lookup_field = 'uuid' + + def get_queryset(self): + if self.request.method in SAFE_METHODS: + q = Q(deleted=False) + if self.request.user.is_authenticated: + q |= Q(user=self.request.user) + return models.Bookmark.objects.filter(q) + else: + return self.request.user.bookmark_set.all() diff --git a/src/bookmarks/migrations/0003_bookmark_deleted_bookmark_reported_timestamp_and_more.py b/src/bookmarks/migrations/0003_bookmark_deleted_bookmark_reported_timestamp_and_more.py new file mode 100644 index 000000000..8e9f32c3e --- /dev/null +++ b/src/bookmarks/migrations/0003_bookmark_deleted_bookmark_reported_timestamp_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.0.8 on 2025-08-01 14:35 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookmarks', '0002_quote'), + ] + + operations = [ + migrations.AddField( + model_name='bookmark', + name='deleted', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='bookmark', + name='reported_timestamp', + field=models.DateTimeField(default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='bookmark', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/src/bookmarks/migrations/0004_bookmark_audio_timestamp_bookmark_mode_and_more.py b/src/bookmarks/migrations/0004_bookmark_audio_timestamp_bookmark_mode_and_more.py new file mode 100644 index 000000000..87dc168b7 --- /dev/null +++ b/src/bookmarks/migrations/0004_bookmark_audio_timestamp_bookmark_mode_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.0.8 on 2025-08-22 14:52 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookmarks', '0003_bookmark_deleted_bookmark_reported_timestamp_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='bookmark', + name='audio_timestamp', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='bookmark', + name='mode', + field=models.CharField(choices=[('text', 'text'), ('audio', 'audio')], default='text', max_length=64), + ), + migrations.AlterField( + model_name='bookmark', + name='reported_timestamp', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/src/bookmarks/models.py b/src/bookmarks/models.py index 67a4fa5e6..098a8b004 100644 --- a/src/bookmarks/models.py +++ b/src/bookmarks/models.py @@ -1,17 +1,110 @@ import uuid +from django.apps import apps from django.db import models +from django.utils.timezone import now +from social.syncable import Syncable -class Bookmark(models.Model): +class Bookmark(Syncable, models.Model): uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) user = models.ForeignKey('auth.User', models.CASCADE) book = models.ForeignKey('catalogue.Book', models.CASCADE) anchor = models.CharField(max_length=100, blank=True) + audio_timestamp = models.IntegerField(null=True, blank=True) + mode = models.CharField(max_length=64, choices=[ + ('text', 'text'), + ('audio', 'audio'), + ], default='text') created_at = models.DateTimeField(auto_now_add=True) note = models.TextField(blank=True) + updated_at = models.DateTimeField(auto_now=True) + reported_timestamp = models.DateTimeField(default=now) + deleted = models.BooleanField(default=False) + + syncable_fields = [ + 'deleted', 'note', + ] def __str__(self): return str(self.uuid) + + def save(self, *args, **kwargs): + # TODO: placeholder. + try: + audio_l = self.book.get_audio_length() + except: + audio_l = 60 + + if self.anchor: + self.mode = 'text' + self.audio_timestamp = self.book.sync_elid(self.anchor) + if self.audio_timestamp: + self.mode = 'audio' + self.anchor = self.book.sync_ts(self.audio_timestamp) or '' + return super().save(*args, **kwargs) + + @classmethod + def create_from_data(cls, user, data): + if data.get('location'): + return cls.get_by_location(user, data['location'], create=True) + elif data.get('book') and data.get('anchor'): + return cls.objects.create(user=user, book=data['book'], anchor=data['anchor']) + elif data.get('book') and data.get('audio_timestamp'): + return cls.objects.create(user=user, book=data['book'], audio_timestamp=data['audio_timestamp']) + + @property + def timestamp(self): + return self.updated_at.timestamp() + + def location(self): + if self.mode == 'text': + return f'{self.book.slug}/{self.anchor}' + else: + return f'{self.book.slug}/audio/{self.audio_timestamp}' + + @classmethod + def get_by_location(cls, user, location, create=False): + Book = apps.get_model('catalogue', 'Book') + try: + slug, anchor = location.split('/', 1) + except: + return None + if '/' in anchor: + try: + mode, audio_timestamp = anchor.split('/', 1) + assert mode == 'audio' + audio_timestamp = int(audio_timestamp) + except: + return None + anchor = '' + instance = cls.objects.filter( + user=user, + book__slug=slug, + mode=mode, + audio_timestamp=audio_timestamp, + ).first() + else: + mode = 'text' + audio_timestamp = None + instance = cls.objects.filter( + user=user, + book__slug=slug, + mode='text', + anchor=anchor, + ).first() + if instance is None and create: + try: + book = Book.objects.get(slug=slug) + except Book.DoesNotExist: + return None + instance = cls.objects.create( + user=user, + book=book, + mode=mode, + anchor=anchor, + audio_timestamp=audio_timestamp, + ) + return instance def get_for_json(self): return { diff --git a/src/bookmarks/views.py b/src/bookmarks/views.py index 0ff5104de..50390df16 100644 --- a/src/bookmarks/views.py +++ b/src/bookmarks/views.py @@ -62,6 +62,8 @@ def bookmark_delete(request, uuid): @cache.never_cache def quotes(request): + if not request.user.is_authenticated: + return JsonResponse({}) try: slug = request.headers['Referer'].rsplit('.', 1)[0].rsplit('/', 1)[-1] except: diff --git a/src/catalogue/api/serializers.py b/src/catalogue/api/serializers.py index daa86fec8..3d6341ed8 100644 --- a/src/catalogue/api/serializers.py +++ b/src/catalogue/api/serializers.py @@ -40,10 +40,171 @@ 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 = [ + 'id', 'url', 'href', 'name', 'slug' + ] + +class AuthorSerializer(AuthorItemSerializer): + photo_thumb = ThumbnailField('139x193', source='photo') + + class Meta: + model = Tag + fields = [ + 'id', '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 = ['id', 'url', 'href', 'name', 'slug'] + +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 = ['id', 'url', 'href', 'name', 'slug'] + +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 = ['id', 'url', 'href', 'name', 'slug'] + +class KindSerializer(KindItemSerializer): + class Meta: + model = Tag + fields = [ + 'url', 'href', 'name', 'slug', 'sort_key', 'description', + 'collective_noun', + ] + +class ThemeSerializer(serializers.ModelSerializer): + url = AbsoluteURLField() + href = AbsoluteURLField( + view_name='catalogue_api_theme', + view_args=('slug',) + ) + class Meta: + model = Tag + fields = [ + 'url', 'href', 'name', 'slug', 'sort_key', 'description', + ] + + 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' + ) + audiences = serializers.ListField(source='audiences_pl') + + 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', 'has_sync_file', + 'elevenreader_link', 'content_warnings', 'audiences', + ] + +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 +253,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 +377,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', 'slug'] diff --git a/src/catalogue/api/tojson.py b/src/catalogue/api/tojson.py new file mode 100644 index 000000000..3ff257a60 --- /dev/null +++ b/src/catalogue/api/tojson.py @@ -0,0 +1,228 @@ +from collections import defaultdict +import json +import re +from sys import argv +from lxml import etree + +tags = { + 'utwor': ('_pass', False, None, None, None), + '{http://www.w3.org/1999/02/22-rdf-syntax-ns#}RDF': ('_ignore', False, None, None, None), + 'abstrakt': ('_ignore', False, None, None, None), + 'uwaga': ('_ignore', False, None, None, None), + 'extra': ('_ignore', False, None, None, None), + 'nota_red': ('_ignore', False, None, None, None), + 'numeracja': ('_ignore', False, None, None, None), + + 'powiesc': ('master', False, None, None, None), + 'opowiadanie': ('master', False, None, None, None), + 'liryka_lp': ('master', False, None, None, None), + 'liryka_l': ('master', False, None, None, None), + 'dramat_wspolczesny': ('master', False, None, None, None), + 'dramat_wierszowany_lp': ('master', False, None, None, None), + 'dramat_wierszowany_l': ('master', False, None, None, None), + + 'dlugi_cytat': ('blockquote', False, None, None, None), + 'poezja_cyt': ('blockquote', False, None, None, None), + 'dlugi_cyt': ('blockquote', False, None, None, None), + 'ramka': ('blockquote', False, {'class': 'ramka'}, None, None), + + 'blok': ('div', False, None, None, None), + + 'strofa': ('div', True, {'class': 'stanza'}, None, None), + 'wers': ('div', True, {'class': 'verse'}, None, None), + 'wers_wciety': ('div', True, {'class': 'wers_wciety'}, None, None), + 'wers_cd': ('div', True, {'class': 'wers_cd'}, None, None), + 'wers_akap': ('div', True, {'class': 'wers_akap'}, None, None), + 'zastepnik_wersu': ('div', True, {'class': 'zastepnik_wersu'}, None, None), + 'wers_do_prawej': ('div', True, {'class': 'wers_do_prawej'}, None, None), + 'wers_srodek': ('div', True, {'class': 'wers_srodek'}, None, None), + + 'autor_utworu': ('div', True, {'class': 'author'}, None, None), + 'dzielo_nadrzedne': ('div', True, {'class': 'dzielo_nadrzedne'}, None, None), + 'nazwa_utworu': ('div', True, {'class': 'title'}, None, None), + 'podtytul': ('div', True, {'class': 'podtytul'}, None, None), + + 'motto': ('div', False, {'class': 'motto'}, None, None), + 'motto_podpis': ('div', True, {'class': 'motto_podpis'}, None, None), + 'dedykacja': ('div', True, {'class': 'dedykacja'}, None, None), + 'miejsce_czas': ('div', True, {'class': 'miejsce_czas'}, None, None), + + 'lista_osob': ('div', False, {'class': 'lista_osob'}, None, None), + 'naglowek_listy': ('div', True, {'class': 'naglowek_listy'}, None, None), + 'lista_osoba': ('div', True, {'class': 'lista_osoba'}, None, None), + 'naglowek_osoba': ('div', True, {'class': 'naglowek_osoba'}, None, None), + 'osoba': ('em', True, {'class': 'osoba'}, None, None), + 'didaskalia': ('div', True, {'class': 'didaskalia'}, None, None), + 'kwestia': ('div', False, {'class': 'kwestia'}, None, None), + 'didask_tekst': ('em', False, {'class': 'didask_tekst'}, None, None), + + 'naglowek_czesc': ('h2', True, None, None, None), + 'naglowek_akt': ('h2', True, None, None, None), + 'naglowek_scena': ('h3', True, None, None, None), + 'naglowek_rozdzial': ('h3', True, None, None, None), + 'naglowek_podrozdzial': ('h4', True, None, None, None), + 'srodtytul': ('h5', True, None, None, None), + + 'nota': ('div', True, {'class': 'note'}, None, False), + + 'akap': ('p', True, {'class': 'paragraph'}, None, True), + 'akap_dialog': ('p', True, {'class': 'paragraph'}, None, True), + 'akap_cd': ('p', True, {'class': 'paragraph'}, None, True), + + 'sekcja_asterysk': ('p', True, {'class': 'spacer-asterisk'}, None, True), + 'sekcja_swiatlo': ('p', True, {'class': 'sekcja_swiatlo'}, None, True), + 'separator_linia': ('p', True, {'class': 'separator_linia'}, None, True), + + 'tytul_dziela': ('em', True, {'class': 'book-title'}, None, False), + 'slowo_obce': ('em', True, {'class': 'foreign-word'}, None, False), + 'wyroznienie': ('em', True, {'class': 'author-emphasis'}, None, False), + 'wieksze_odstepy': ('em', True, {'class': 'wieksze_odstepy'}, None, False), + + 'ref': ('a', True, {'class': 'reference'}, {'data-uri': 'href'}, False), + + 'begin': ('_ignore', True, {'class': 'reference'}, {'data-uri': 'href'}, False), + 'end': ('_ignore', True, {'class': 'reference'}, {'data-uri': 'href'}, False), + 'motyw': ('_ignore', True, {'class': 'theme'}, None, False), + + 'pa': ('a', True, {'class': 'footnote footnote-pa'}, None, False), + 'pe': ('a', True, {'class': 'footnote footnote-pe'}, None, False), + 'pr': ('a', True, {'class': 'footnote footnote-pr'}, None, False), + 'pt': ('a', True, {'class': 'footnote footnote-pt'}, None, False), + 'ptrad': ('a', True, {'class': 'footnote footnote-ptrad'}, None, False), + + 'werset': ('p', True, {'class': 'werset'}, None, True), + 'br': ('br', False, None, None, None), + 'indeks_dolny': ('em', True, {'class': 'indeks_dolny'}, None, False), + 'mat': ('span', True, {'class': 'mat'}, None, False), +} + +id_prefixes = { + 'pa': 'fn', + 'pe': 'fn', + 'pr': 'fn', + 'pt': 'fn', + 'ptrad': 'fn', + 'wers': 'f', + 'wers_wciety': 'f', + 'wers_cd': 'f', + 'wers_akap': 'f', + 'zastepnik_wersu': 'f', + 'wers_do_prawej': 'f', + 'wers_srodek': 'f', + 'akap': 'f', + 'akap_cd': 'f', + 'akap_dialog': 'f', +} + + +#tree = etree.parse(argv[1]) + +front1 = set([ + 'dzielo_nadrzedne', + 'nazwa_utworu', + 'podtytul', + ]) +front2 = set(['autor_utworu']) + + +def norm(text): + text = text.replace('---', '—').replace('--', '–').replace('...', '…').replace(',,', '„').replace('"', '”').replace('\n', ' ') + return text + + +def toj(elem, S): + if elem.tag is etree.Comment: return [] + tag, hastext, attrs, attr_map, num = tags[elem.tag] + contents = [] + if tag == '_pass': + output = contents + elif tag == '_ignore': + return [] + else: + output = { + 'tag': tag, + } + if num: + S['index'] += 1 + output['paragraphIndex'] = S['index'] + if 'dlugi_cytat' not in S['stack'] and 'poezja_cyt' not in S['stack']: + S['vindex'] += 1 + output['visibleNumber'] = S['vindex'] + id_prefix = id_prefixes.get(elem.tag, 'i') + S['id'][id_prefix] += 1 + output['id'] = id_prefix + str(S['id'][id_prefix]) + if elem.attrib.get('id'): + output['id'] = 'wl-' + elem.attrib.get('id') + if attrs: + output['attr'] = attrs.copy() + if attr_map: + output.setdefault('attr', {}) + for k, v in attr_map.items(): + output['attr'][k] = elem.attrib[v] + output['contents'] = contents + output = [output] + if elem.tag == 'strofa': + verses = [etree.Element('wers')] + if elem.text: + vparts = re.split(r'/\s+', elem.text) + for i, v in enumerate(vparts): + if i: + verses.append(etree.Element('wers')) + verses[-1].text = (verses[-1].text or '') + v + for child in elem: + vparts = re.split(r'/\s+', child.tail or '') + child.tail = vparts[0] + verses[-1].append(child) + for v in vparts[1:]: + verses.append(etree.Element('wers')) + verses[-1].text = v + + if not(len(verses[-1]) or (verses[-1].text or '').strip()): + verses.pop() + + elem.clear(keep_tail=True) + for verse in verses: + if len(verse) == 1 and (verse[0].tag.startswith('wers') or verse[0].tag == 'zastepnik_wersu') and not (verse[0].tail or '').strip(): + elem.append(verse[0]) + else: + elem.append(verse) + + #if not len(elem): + # for v in re.split(r'/\s+', elem.text): + # etree.SubElement(elem, 'wers').text = v + # elem.text = None + + if hastext and elem.text: + contents.append(norm(elem.text)) + for c in elem: + S['stack'].append(elem.tag) + contents += toj(c, S) + if hastext and c.tail: + contents.append(norm(c.tail)) + S['stack'].pop() + + if elem.tag in front1: + S['front1'] += output + return [] + if elem.tag in front2: + S['front2'] += output + return [] + return output + +def conv(tree): + S = { + 'index': 0, + 'vindex': 0, + 'id': defaultdict(lambda: 0), + 'stack': [], + 'front1': [], + 'front2': [], + } + output = toj(tree.getroot(), S) + if not len(output): return {} + jt = output[0] + jt['front1'] = S['front1'] + jt['front2'] = S['front2'] + return jt + +#print(json.dumps(jt, indent=2, ensure_ascii=False)) diff --git a/src/catalogue/api/urls2.py b/src/catalogue/api/urls2.py new file mode 100644 index 000000000..46f2a36b5 --- /dev/null +++ b/src/catalogue/api/urls2.py @@ -0,0 +1,70 @@ +# 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('books/.json', + views.BookJsonView.as_view()), + path('books//sync/', + views.BookSyncView.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'), + path('themes/', + piwik_track_view(views.ThemeList.as_view()), + name="catalogue_api_theme_list"), + path('themes//', + piwik_track_view(views.ThemeView.as_view()), + name='catalogue_api_theme'), +] diff --git a/src/catalogue/api/views.py b/src/catalogue/api/views.py index c0dc57f76..0f3a95fa2 100644 --- a/src/catalogue/api/views.py +++ b/src/catalogue/api/views.py @@ -6,9 +6,11 @@ import os.path from urllib.request import urlopen from django.conf import settings from django.core.files.base import ContentFile -from django.http import Http404, HttpResponse +from django.http import Http404, HttpResponse, JsonResponse 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,58 @@ 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, + ) + translator = dfilters.ModelMultipleChoiceFilter( + field_name='translators', + queryset=Tag.objects.filter(category='author'), + 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 +245,31 @@ class BookDetail(RetrieveAPIView): serializer_class = serializers.BookDetailSerializer +class BookDetail2(RetrieveAPIView): + queryset = Book.objects.all() + lookup_field = 'slug' + serializer_class = serializers.BookSerializer2 + + +class BookSyncView(RetrieveAPIView): + queryset = Book.objects.all() + lookup_field = 'slug' + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + return Response([ + {"id": i, "timestamp": ts} + for (i, ts) in instance.get_sync() + ]) + + @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 +282,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 +366,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 +382,51 @@ 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 ThemeList(ListAPIView): + serializer_class = serializers.ThemeSerializer + queryset = Tag.objects.filter(category='theme') + +class ThemeView(RetrieveAPIView): + serializer_class = serializers.ThemeSerializer + queryset = Tag.objects.filter(category='theme') + lookup_field = 'slug' + class TagView(RetrieveAPIView): permission_classes = [DjangoModelPermissionsOrAnonReadOnly] @@ -373,7 +495,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 +520,47 @@ 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') + + +from .tojson import conv +from lxml import etree +from rest_framework.views import APIView + +class BookJsonView(APIView): + def get(self, request, slug): + book = get_object_or_404(Book, slug=slug) + js = conv(etree.parse(book.xml_file.path)) + return JsonResponse(js, json_dumps_params={'ensure_ascii': False}) 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/migrations/0050_book_narrators.py b/src/catalogue/migrations/0050_book_narrators.py new file mode 100644 index 000000000..388f150e9 --- /dev/null +++ b/src/catalogue/migrations/0050_book_narrators.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.8 on 2025-08-12 09:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalogue', '0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='book', + name='narrators', + field=models.ManyToManyField(blank=True, related_name='narrated', to='catalogue.tag'), + ), + ] diff --git a/src/catalogue/migrations/0051_book_has_audio.py b/src/catalogue/migrations/0051_book_has_audio.py new file mode 100644 index 000000000..e333bff6c --- /dev/null +++ b/src/catalogue/migrations/0051_book_has_audio.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.8 on 2025-08-12 10:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalogue', '0050_book_narrators'), + ] + + operations = [ + migrations.AddField( + model_name='book', + name='has_audio', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/catalogue/models/book.py b/src/catalogue/models/book.py index 29e375464..ee3aecb02 100644 --- a/src/catalogue/models/book.py +++ b/src/catalogue/models/book.py @@ -7,6 +7,8 @@ from datetime import date, timedelta from random import randint import os.path import re +from slugify import slugify +from sortify import sortify from urllib.request import urlretrieve from django.apps import apps from django.conf import settings @@ -43,6 +45,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 +65,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 +83,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) @@ -90,8 +94,10 @@ class Book(models.Model): objects = models.Manager() tagged = managers.ModelTaggedItemManager(Tag) tags = managers.TagDescriptor(Tag) - tag_relations = GenericRelation(Tag.intermediary_table_model) + tag_relations = GenericRelation(Tag.intermediary_table_model, related_query_name='tagged_book') translators = models.ManyToManyField(Tag, blank=True) + narrators = models.ManyToManyField(Tag, blank=True, related_name='narrated') + has_audio = models.BooleanField(default=False) html_built = django.dispatch.Signal() published = django.dispatch.Signal() @@ -264,17 +270,6 @@ class Book(models.Model): return sibling.get_first_text() return self.parent.get_next_text(inside=False) - def get_child_audiobook(self): - BookMedia = apps.get_model('catalogue', 'BookMedia') - if not BookMedia.objects.filter(book__ancestor=self).exists(): - return None - for child in self.children.order_by('parent_number').all(): - if child.has_mp3_file(): - return child - child_sub = child.get_child_audiobook() - if child_sub is not None: - return child_sub - def get_siblings(self): if not self.parent: return [] @@ -335,9 +330,6 @@ class Book(models.Model): else: return self.media.filter(type=type_).exists() - def has_audio(self): - return self.has_media('mp3') - def get_media(self, type_): if self.has_media(type_): if type_ in Book.formats: @@ -375,6 +367,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,7 +411,69 @@ 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): + if not self.has_sync_file(): + return [] with self.get_media('sync').first().file.open('r') as f: sync = f.read().split('\n') offset = float(sync[0]) @@ -426,8 +483,22 @@ class Book(models.Model): continue start, end, elid = line.split() items.append([elid, float(start) + offset]) - return json.dumps(items) - + return items + + def sync_ts(self, ts): + elid = None + for cur_id, t in self.get_sync(): + if ts >= t: + elid = cur_id + else: + break + return elid + + def sync_elid(self, elid): + for cur_id, t in self.get_sync(): + if cur_id == elid: + return t + def has_audio_epub_file(self): return self.has_media("audio.epub") @@ -439,7 +510,7 @@ class Book(models.Model): def media_audio_epub(self): return self.get_media('audio.epub') - def get_audiobooks(self): + def get_audiobooks(self, with_children=False, processing=False): ogg_files = {} for m in self.media.filter(type='ogg').order_by().iterator(): ogg_files[m.name] = m @@ -465,13 +536,27 @@ class Book(models.Model): media['ogg'] = ogg audiobooks.append(media) - projects = sorted(projects) - total_duration = '%d:%02d' % ( - total_duration // 60, - total_duration % 60 - ) + if with_children: + for child in self.get_children(): + ch_audiobooks, ch_projects, ch_duration = child.get_audiobooks( + with_children=True, processing=True) + audiobooks.append({'part': child}) + audiobooks += ch_audiobooks + projects.update(ch_projects) + total_duration += ch_duration + + if not processing: + projects = sorted(projects) + total_duration = '%d:%02d' % ( + total_duration // 60, + total_duration % 60 + ) + return audiobooks, projects, total_duration + def get_audiobooks_with_children(self): + return self.get_audiobooks(with_children=True) + def wldocument(self, parse_dublincore=True, inherit=True): from catalogue.import_utils import ORMDocProvider from librarian.parser import WLDocument @@ -635,6 +720,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 +790,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) @@ -762,6 +849,42 @@ class Book(models.Model): def references(self): return self.reference_set.all().select_related('entity') + def update_has_audio(self): + self.has_audio = False + if self.media.filter(type='mp3').exists(): + self.has_audio = True + if self.descendant.filter(has_audio=True).exists(): + self.has_audio = True + self.save(update_fields=['has_audio']) + if self.parent is not None: + self.parent.update_has_audio() + + def update_narrators(self): + narrator_names = set() + for bm in self.media.filter(type='mp3'): + narrator_names.update(set( + a.strip() for a in re.split(r',|\si\s', bm.artist) + )) + narrators = [] + + for name in narrator_names: + if not name: continue + slug = slugify(name) + try: + t = Tag.objects.get(category='author', slug=slug) + except Tag.DoesNotExist: + sort_key = sortify( + ' '.join(name.rsplit(' ', 1)[::-1]).lower() + ) + t = Tag.objects.create( + category='author', + name_pl=name, + slug=slug, + sort_key=sort_key, + ) + narrators.append(t) + self.narrators.set(narrators) + @classmethod @transaction.atomic def repopulate_ancestors(cls): @@ -986,7 +1109,7 @@ class Book(models.Model): return None def update_popularity(self): - count = self.tags.filter(category='set').values('user').order_by('user').distinct().count() + count = self.userlistitem_set.values('list__user').order_by('list__user').distinct().count() try: pop = self.popularity pop.count = count @@ -997,16 +1120,23 @@ class Book(models.Model): def ridero_link(self): return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_')) - def like(self, user): - from social.utils import likes, get_set, set_sets - if not likes(user, self): - tag = get_set(user, '') - set_sets(user, self, [tag]) + def elevenreader_link(self): + first_text = self.get_first_text() + if first_text is None: + return None + return 'https://elevenreader.io/audiobooks/wolnelektury:' + first_text.slug - def unlike(self, user): - from social.utils import likes, set_sets - if likes(user, self): - set_sets(user, self, []) + def content_warnings(self): + warnings_def = { + 'wulgaryzmy': _('wulgaryzmy'), + } + warnings = self.get_extra_info_json().get('content_warnings', []) + warnings = [ + warnings_def.get(w, w) + for w in warnings + ] + warnings.sort() + return warnings def full_sort_key(self): return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id))) diff --git a/src/catalogue/models/bookmedia.py b/src/catalogue/models/bookmedia.py index acb1881e5..a5df65740 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: @@ -87,7 +89,7 @@ class BookMedia(models.Model): except BookMedia.DoesNotExist: old = None - super(BookMedia, self).save(*args, **kwargs) + #super(BookMedia, self).save(*args, **kwargs) # remove the zip package for book with modified media if old: @@ -99,7 +101,9 @@ class BookMedia(models.Model): self.extra_info = json.dumps(extra_info) self.source_sha1 = self.read_source_sha1(self.file.path, self.type) self.duration = self.read_duration() - return super(BookMedia, self).save(*args, **kwargs) + super(BookMedia, self).save(*args, **kwargs) + self.book.update_narrators() + self.book.update_has_audio() def read_duration(self): try: diff --git a/src/catalogue/models/tag.py b/src/catalogue/models/tag.py index 13d8a6403..fb90a7b89 100644 --- a/src/catalogue/models/tag.py +++ b/src/catalogue/models/tag.py @@ -161,6 +161,8 @@ class Tag(models.Model): @staticmethod def get_tag_list(tag_str): + from social.models import UserList + if not tag_str: return [] tags = [] @@ -170,7 +172,10 @@ class Tag(models.Model): tags_splitted = tag_str.split('/') for name in tags_splitted: if category: - tags.append(Tag.objects.get(slug=name, category=category)) + if category == 'set': + tags.append(UserList.objects.get(slug=name, deleted=False)) + else: + tags.append(Tag.objects.get(slug=name, category=category)) category = None elif name in Tag.categories_rev: category = Tag.categories_rev[name] @@ -242,6 +247,11 @@ class Tag(models.Model): meta_tags.append((tag, relationship)) return meta_tags +# def get_books(self): +# """ Only useful for sets. """ +# return + + TagRelation.tag_model = Tag diff --git a/src/catalogue/static/book/filter.js b/src/catalogue/static/book/filter.js index 2ff426234..37613bfc1 100644 --- a/src/catalogue/static/book/filter.js +++ b/src/catalogue/static/book/filter.js @@ -41,7 +41,11 @@ } $("#paginator").on('click', 'a', function() { - get_page_by_url(url=$(this).attr('href')); + get_page_by_url(url=$(this).attr('href'), () => { + $("html").animate({ + scrollTop: $("#book-list").offset().top - 50 + }); + }); return false; }); diff --git a/src/catalogue/static/player/player.js b/src/catalogue/static/player/player.js index cbf90261b..485708764 100644 --- a/src/catalogue/static/player/player.js +++ b/src/catalogue/static/player/player.js @@ -114,31 +114,29 @@ $(".c-media__caption .license", $root).html($(".license", elem).html()); $(".c-media__caption .project-logo", $root).html($(".project-icon", elem).html()); - console.log('sm 1'); doesUpdateSynchro = false; if (!$currentMedia || $currentMedia[0] != elem[0]) { - console.log('set', player.jPlayer("setMedia", media)) + player.jPlayer("setMedia", media); player.jPlayer("option", "playbackRate", speed); } doesUpdateSynchro = true; player.jPlayer(cmd, time); $currentMedia = elem; - $(".play-next", $root).prop("disabled", !elem.next().length); + $(".play-next", $root).prop("disabled", !elem.nextAll('li').length); let du = parseFloat(elem.data('duration')); currentDuration = du; - elem.nextAll().each(function() { + elem.nextAll('li').each(function() { du += parseFloat($(this).data('duration')); }); totalDurationLeft = du; let pdu = 0; - elem.prevAll().each(function() { + elem.prevAll('li').each(function() { pdu += parseFloat($(this).data('duration')); }); totalDurationBefore = pdu; - console.log('sm 3', du, pdu); return player; }; @@ -166,7 +164,6 @@ // TODO: if snap then roll locator.removeClass('up').removeClass('down'); if (locator.hasClass('snap')) { - console.log('SCROLL!'); scrollTo(); } else { if (y < miny) { @@ -212,14 +209,14 @@ }); $('.play-next', $root).click(function() { - let p = $currentMedia.next(); + let p = $currentMedia.nextAll('li').first(); if (p.length) { setMedia(p).jPlayer("play"); _paq.push(['trackEvent', 'audiobook', 'next']); } }); $('.play-prev', $root).click(function() { - let p = $currentMedia.prev(); + let p = $currentMedia.prevAll('li').first(); if (p.length) { setMedia(p).jPlayer("play"); _paq.push(['trackEvent', 'audiobook', 'prev']); @@ -236,10 +233,8 @@ _paq.push(['trackEvent', 'audiobook', 'chapter']); }); - console.log('READY 3!'); var initialElem = $('.jp-playlist li', $root).first(); var initialTime = 0; - console.log('READY 4!'); if (true || Modernizr.localstorage) { try { let speedStr = localStorage['audiobook-speed']; @@ -266,9 +261,7 @@ initialTime = last[2]; } } - console.log('READY 5!', initialElem, initialTime); setMedia($(initialElem), initialTime); - console.log('READY 6!'); }, timeupdate: function(event) { @@ -310,7 +303,7 @@ ended: function(event) { - let p = $currentMedia.next(); + let p = $currentMedia.nextAll('li'); if (p.length) { setMedia(p).jPlayer("play"); } diff --git a/src/catalogue/templates/catalogue/book_box.html b/src/catalogue/templates/catalogue/book_box.html index 4458b5a22..b82d926e8 100644 --- a/src/catalogue/templates/catalogue/book_box.html +++ b/src/catalogue/templates/catalogue/book_box.html @@ -34,7 +34,7 @@ {% if book.is_book %} {% endif %} - {% if book.has_mp3_file %} + {% if book.has_audio %} {% endif %} {% if book.is_picture %} diff --git a/src/catalogue/templates/catalogue/book_detail.html b/src/catalogue/templates/catalogue/book_detail.html index eb1ab8a7d..ccce18bf8 100644 --- a/src/catalogue/templates/catalogue/book_detail.html +++ b/src/catalogue/templates/catalogue/book_detail.html @@ -5,9 +5,14 @@ {% load choose_cites from social_tags %} {% load catalogue_tags %} {% load likes_book from social_tags %} +{% load annoy %} {% block global-content %} +
+ {% annoy_banner 'book-page' %} +
+
- {% if book.other_versions.exists %} -
-

{% trans "Inne wersje utworu" %}

- {% trans "Zamknij drugą wersję" %} - -
- {% endif %} -
{% annoy_banners 'book-text-intermission' %} @@ -319,9 +322,7 @@
{% include 'catalogue/snippets/jplayer_reader.html' %}
- + {{ book.get_sync|json_script:"smil" }} {% endif %} diff --git a/src/catalogue/templates/catalogue/snippets/jplayer.html b/src/catalogue/templates/catalogue/snippets/jplayer.html index e0893facb..2399a09f0 100644 --- a/src/catalogue/templates/catalogue/snippets/jplayer.html +++ b/src/catalogue/templates/catalogue/snippets/jplayer.html @@ -1,6 +1,6 @@ {% load i18n l10n catalogue_tags %} -{% with audiobooks=book.get_audiobooks %} +{% with audiobooks=book.get_audiobooks_with_children %}
@@ -38,6 +38,9 @@ diff --git a/src/catalogue/templates/catalogue/snippets/jplayer_link.html b/src/catalogue/templates/catalogue/snippets/jplayer_link.html deleted file mode 100644 index 7bbddcdda..000000000 --- a/src/catalogue/templates/catalogue/snippets/jplayer_link.html +++ /dev/null @@ -1,24 +0,0 @@ -{% load i18n catalogue_tags %} - -
-
-   -
- -
-
- - -
- -
-
- -
-
{{ book.pretty_title }}
-
- -
-
-
-
diff --git a/src/catalogue/templatetags/catalogue_tags.py b/src/catalogue/templatetags/catalogue_tags.py index d2298d9f5..e79563cb3 100644 --- a/src/catalogue/templatetags/catalogue_tags.py +++ b/src/catalogue/templatetags/catalogue_tags.py @@ -17,6 +17,7 @@ from catalogue.helpers import get_audiobook_tags from catalogue.models import Book, BookMedia, Fragment, Tag, Source from catalogue.constants import LICENSES from club.models import Membership +from social.models import UserList register = template.Library() @@ -73,7 +74,10 @@ def nice_title_from_tags(tags, related_tags): def split_tags(tags): result = {} for tag in tags: - result.setdefault(tag.category, []).append(tag) + if isinstance(tag, UserList): + result.setdefault('userlist', []).append(tag) + else: + result.setdefault(tag.category, []).append(tag) return result self = split_tags(tags) @@ -420,16 +424,8 @@ def status(book, user): @register.inclusion_tag('catalogue/snippets/content_warning.html') def content_warning(book): - warnings_def = { - 'wulgaryzmy': _('wulgaryzmy'), - } - warnings = book.get_extra_info_json().get('content_warnings', []) - warnings = sorted( - warnings_def.get(w, w) - for w in warnings - ) return { - "warnings": warnings + "warnings": book.content_warnings(), } 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/catalogue/views.py b/src/catalogue/views.py index 3c4a55fe9..e754e05ca 100644 --- a/src/catalogue/views.py +++ b/src/catalogue/views.py @@ -23,6 +23,7 @@ from club.forms import DonationStep1Form from club.models import Club from annoy.models import DynamicTextInsert from pdcounter import views as pdcounter_views +from social.models import UserList from wolnelektury.utils import is_ajax from catalogue import constants from catalogue import forms @@ -211,25 +212,45 @@ class AudiobooksView(LiteratureView): class TaggedObjectList(BookList): def analyse(self): super().analyse() + self.ctx['tags'] = analyse_tags(self.request, self.kwargs['tags']) - self.ctx['fragment_tags'] = [t for t in self.ctx['tags'] if t.category in ('theme', 'object')] - self.ctx['work_tags'] = [t for t in self.ctx['tags'] if t not in self.ctx['fragment_tags']] + if len(self.ctx['tags']) > 4: + raise Http404 + self.ctx.update({ + 'fragment_tags': [], + 'work_tags': [], + 'user_lists': [], + }) + for tag in self.ctx['tags']: + if isinstance(tag, UserList): + self.ctx['user_lists'].append(tag) + elif tag.category == 'theme': + self.ctx['fragment_tags'].append(tag) + else: + self.ctx['work_tags'].append(tag) + self.is_themed = self.ctx['has_theme'] = bool(self.ctx['fragment_tags']) if self.is_themed: self.ctx['main_tag'] = self.ctx['fragment_tags'][0] - elif self.ctx['tags']: - self.ctx['main_tag'] = self.ctx['tags'][0] + elif self.ctx['work_tags']: + self.ctx['main_tag'] = self.ctx['work_tags'][0] else: self.ctx['main_tag'] = None self.ctx['filtering_tags'] = [ t for t in self.ctx['tags'] if t is not self.ctx['main_tag'] ] - if len(self.ctx['tags']) == 1 and self.ctx['main_tag'].category == 'author': + if len(self.ctx['tags']) == 1 and self.ctx['main_tag'] is not None and self.ctx['main_tag'].category == 'author': self.ctx['translation_list'] = self.ctx['main_tag'].book_set.all() + self.ctx['narrated'] = self.ctx['main_tag'].narrated.all() def get_queryset(self): - qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True) + if self.ctx['work_tags']: + qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True) + else: + qs = Book.objects.filter(findable=True) + for ul in self.ctx['user_lists']: + qs = qs.filter(id__in=[i.id for i in ul.get_books()]) qs = qs.exclude(ancestor__in=qs) if self.is_themed: fqs = Fragment.tagged.with_all(self.ctx['fragment_tags']) @@ -241,6 +262,9 @@ class TaggedObjectList(BookList): return qs def get_suggested_tags(self, queryset): + if self.ctx['user_lists']: + # TODO + return [] tag_ids = [t.id for t in self.ctx['tags']] if self.is_themed: related_tags = [] @@ -252,6 +276,7 @@ class TaggedObjectList(BookList): containing_books, ).exclude(category='set').exclude(pk__in=tag_ids) )) + ### FIXME: These won't be tags if self.request.user.is_authenticated: related_tags.extend(list( Tag.objects.usage_for_queryset( @@ -292,6 +317,7 @@ def object_list(request, objects, list_type='books'): Tag.objects.usage_for_queryset( objects, counts=True ).exclude(category='set')) + ### FIXME: these won't be tags if request.user.is_authenticated: related_tag_lists.append( Tag.objects.usage_for_queryset( @@ -309,7 +335,7 @@ def object_list(request, objects, list_type='books'): .only('name', 'sort_key', 'category', 'slug')) if isinstance(objects, QuerySet): objects = prefetch_relations(objects, 'author') - + categories = split_tags(*related_tag_lists) suggest = [] for c in ['set', 'author', 'epoch', 'kind', 'genre']: @@ -324,7 +350,7 @@ def object_list(request, objects, list_type='books'): } template = 'catalogue/author_detail.html' - + return render( request, template, result, ) diff --git a/src/club/admin.py b/src/club/admin.py index fc715e2cd..8620e7407 100644 --- a/src/club/admin.py +++ b/src/club/admin.py @@ -10,6 +10,8 @@ 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 messaging.models import Contact, Level from wolnelektury.utils import YesNoFilter from . import models @@ -95,15 +97,41 @@ 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 + ) + + +class OptOutFilter(YesNoFilter): + title = 'opt out' + parameter_name = 'optout' + q = Q(email__in=Contact.objects.filter(level=Level.OPT_OUT).values_list('email', flat=True)) class ScheduleAdmin(admin.ModelAdmin): @@ -117,8 +145,9 @@ class ScheduleAdmin(admin.ModelAdmin): search_fields = ['email', 'source'] list_filter = [ 'is_cancelled', 'monthly', 'yearly', 'method', + 'consent', OptOutFilter, 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/forms.py b/src/club/forms.py index b75877d49..e0963036a 100644 --- a/src/club/forms.py +++ b/src/club/forms.py @@ -35,6 +35,8 @@ class DonationStep1Form(forms.ModelForm): self.referer = referer super().__init__(*args, **kwargs) club = models.Club.objects.first() + if self.instance.is_custom_amount(): + self.fields['custom_amount'].initial = int(self.instance.amount) if club is not None: self.fields['custom_amount'].widget.attrs['min'] = club.min_amount @@ -50,7 +52,8 @@ class DonationStep1Form(forms.ModelForm): return state def save(self, *args, **kwargs): - self.instance.source = self.referer + if self.referer is not None: + self.instance.source = self.referer return super().save(*args, **kwargs) diff --git a/src/club/models.py b/src/club/models.py index c09769a95..77154954b 100644 --- a/src/club/models.py +++ b/src/club/models.py @@ -161,6 +161,15 @@ class Schedule(models.Model): club = Club.objects.first() return club.get_description_for_amount(self.amount, self.monthly) + def is_custom_amount(self): + club = Club.objects.first() + if not self.amount: + return False + if self.monthly: + return not club.monthlyamount_set.filter(amount=self.amount).exists() + else: + return not club.singleamount_set.filter(amount=self.amount).exists() + def initiate_payment(self, request): return self.get_payment_method().initiate(request, self) @@ -417,7 +426,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 +495,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 +504,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/templates/club/donation_infobox.html b/src/club/templates/club/donation_infobox.html new file mode 100644 index 000000000..093480b73 --- /dev/null +++ b/src/club/templates/club/donation_infobox.html @@ -0,0 +1,10 @@ +
+
+ Dziękujemy, że decydujesz się wspierać nas co miesiąc.
+ Jeśli to pomyłka, możesz zmienić darowiznę na jednorazową. +
+
+ Wolę wspierać co miesiąc! + Zmień na comiesięczną wpłatę. +
+
diff --git a/src/club/templates/club/donation_step1_form.html b/src/club/templates/club/donation_step1_form.html index 033b0ea3f..49805559b 100644 --- a/src/club/templates/club/donation_step1_form.html +++ b/src/club/templates/club/donation_step1_form.html @@ -1,7 +1,7 @@ {% load static %} {% load i18n %} -
+ {% csrf_token %} {{ form.errors }} @@ -21,14 +21,10 @@ {% with amounts=club.get_amounts %}
{% for amount in amounts.single %} -
+
-

{{ amount.amount }} zł

- {% if amount.description %} -

{{ amount.description|safe }}

- {% endif %} - +
{% endfor %} @@ -41,13 +37,9 @@
{% for amount in amounts.monthly %} -
-

{{ amount.amount }} zł {% trans "/mies." context "kwota na miesiąc" %}

+
- {% if amount.description %} -

{{ amount.description|safe }}

- {% endif %} - +
{% endfor %} @@ -58,7 +50,7 @@
- + {{ form.custom_amount }}
diff --git a/src/club/templates/club/donation_step2.html b/src/club/templates/club/donation_step2.html index 3864ad5a5..5d602135d 100644 --- a/src/club/templates/club/donation_step2.html +++ b/src/club/templates/club/donation_step2.html @@ -8,26 +8,27 @@ {% block donation-step-content %} -
+

{{ schedule.amount|floatformat }} zł - {% if schedule.monthly %} - {% trans "/mies." context "kwota na miesiąc" %} - {% endif %} + {% trans "miesięcznie" %} + {% trans "jednorazowo" %}

+ {% if schedule.get_description %}

{{ schedule.get_description }}

+ {% endif %}
+ {% include "club/donation_infobox.html" %} + {% csrf_token %} {{ form.errors }} - {{ form.amount }} - {{ form.monthly }}
diff --git a/src/club/templates/club/donation_step3.html b/src/club/templates/club/donation_step3.html index baeee9f80..999c70872 100644 --- a/src/club/templates/club/donation_step3.html +++ b/src/club/templates/club/donation_step3.html @@ -8,21 +8,33 @@ {% block donation-step-content %} -
+

{{ schedule.amount|floatformat }} zł {% if schedule.monthly %} - {% trans "/mies." context "kwota na miesiąc" %} + {% trans "miesięcznie" %} + {% else %} + {% trans "jednorazowo" %} {% endif %}

+ {% include "club/donation_infobox.html" %} + + {% if schedule.monthly %} +

Darowizna będzie pobierana automatycznie co miesiąc.

+

Możesz z niej zrezygnować w dowolnej chwili, korzystając z linku który dostaniesz mailem.

+ {% endif %} +
-
+ + +
+ {% for method in schedule.get_payment_methods %} {% invite_payment method schedule %} {% endfor %} diff --git a/src/club/templates/club/donation_step_base.html b/src/club/templates/club/donation_step_base.html index cede1b4ca..388932488 100644 --- a/src/club/templates/club/donation_step_base.html +++ b/src/club/templates/club/donation_step_base.html @@ -29,11 +29,11 @@
- Wspieraj Wolne Lektury +
+
-

{% trans "Wspieraj Wolne Lektury" %}

-

{% trans "Dziękujemy, że chcesz razem z nami uwalniać książki!" %}

-

{% trans "Wspieraj Wolne Lektury stałą wpłatą – nawet niewielka ma wielką moc! Możesz też wesprzeć Wolne Lektury jednorazowo." %}

+ {% chunk "donate-top" %}
@@ -43,7 +43,7 @@ {% endif %}
1 -

{% trans "Rodzaj wsparcia" %}

+

{% trans "Kwota wsparcia" %}

{% if view.step > 1 and view.step != 4 %} @@ -84,6 +84,8 @@