--- /dev/null
+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
-.PHONY: deploy test
+.PHONY: deploy test shell
+
+
+UID != id -u
+GID != id -g
deploy: src/wolnelektury/localsettings.py
mv ../htmlcov.new ../htmlcov
coverage report
rm .coverage
+
+
+shell:
+ UID=$(UID) GID=$(GID) docker compose run --rm dev bash
+
+
+build:
+ docker compose build dev
--- /dev/null
+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/
--- /dev/null
+#!/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
fnpdjango==0.6
docutils==0.20
+python-memcached==1.59
+
django-pipeline==3.1.0
libsasscompiler==0.2.0
jsmin==3.0.1
django-allauth==0.51
django-extensions==3.2.3
djangorestframework==3.15.1
+django-filter==23.5
djangorestframework-xml==2.0.0
django-admin-ordering==0.18
django-countries==7.6.1
Feedparser==6.0.11
-Pillow==10.4
+Pillow==9.5.0
mutagen==1.47
sorl-thumbnail==12.10.0
# home-brewed & dependencies
-librarian==24.5.4
+librarian==24.5.10
# celery tasks
celery[redis]==5.4.0
from . import models
+admin.site.register(models.Campaign)
+
+
class BannerAdmin(TranslationAdmin):
list_display = [
'place', 'text',
--- /dev/null
+# 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'),
+ ),
+ ]
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,
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)
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'):
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)
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, (
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 }}"
{% if banner.background_color %}background-color: {{ banner.background_color }};{% endif %}
">
{% if not banner.action_label %}
- <a href="{{ banner.url }}">
+ <a
+ {% if banner.is_external %}target="_blank"{% endif %}
+ href="{{ banner.url }}">
{% endif %}
<div class="annoy-banner-inner">
- {% if banner.image %}
- <img src="{{ banner.image.url }}">
+ {% if banner.get_image %}
+ <div>
+ <img src="{{ banner.get_image.url }}">
+ </div>
{% endif %}
<div class="text">
{{ banner.get_text|safe|linebreaks }}
</div>
{% if banner.action_label %}
- <a class="action" href="{{ banner.url }}">
+ <a class="action"
+ {% if banner.is_external %}target="_blank"{% endif %}
+ href="{{ banner.url }}">
{{ banner.action_label }}
</a>
{% endif %}
--- /dev/null
+{% load l10n %}
+
+{% if banner %}
+<div class="
+ annoy-banner_{{ banner.place }}-container
+ annoy-banner-style_{{ banner.style }}
+ ">
+ <div class="
+ annoy-banner
+ annoy-banner_{{ banner.place }}
+ {% if banner.image %}with-image{% endif %}
+ {% if banner.smallfont %}banner-smallfont{% endif %}
+ "
+ id="annoy-banner-{{ banner.id }}"
+ style="
+ {% if banner.text_color %}color: {{ banner.text_color }};{% endif %}
+ {% if banner.background_color %}background-color: {{ banner.background_color }};{% endif %}
+
+ ">
+ <div class="annoy-banner-inner">
+
+ <div class="image-box">
+ {% if banner.image %}
+ <img src="{{ banner.image.url }}">
+ {% endif %}
+ </div>
+
+ <div class="text-box">
+ <div class="text">
+ {{ banner.get_text|safe|linebreaks }}
+ </div>
+
+ <div class="state-box">
+ <div class="action-box">
+ {% if banner.action_label %}
+ <a class="action" href="{{ banner.url }}">
+ {{ banner.action_label }}
+ </a>
+ {% endif %}
+ </div>
+ </div>
+ </div>
+
+
+
+ </div>
+ </div>
+ </div>
+
+{% endif %}
+
@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),
'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):
--- /dev/null
+# 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')]),
+ ),
+ ]
class WLLimitOffsetPagination(LimitOffsetPagination):
+ def get_results(self, data):
+ return data['member']
+
def get_paginated_response(self, data):
return Response({
"member": data,
class RegisterSerializer(serializers.Serializer):
email = serializers.CharField()
password = serializers.CharField(style={'input_type': 'password'})
- options = serializers.ListField(child=serializers.IntegerField())
+ 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()
path('login/', csrf_exempt(views.Login2View.as_view())),
path('me/', views.UserView.as_view()),
path('', include('catalogue.api.urls2')),
+ path('', include('social.api.urls2')),
]
# Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
#
from time import time
+from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth.decorators import login_required
+from django.contrib.auth.models import User
from django import forms
from django.http import HttpResponse
from django.http import Http404
from rest_framework.generics import GenericAPIView, RetrieveAPIView, get_object_or_404
from catalogue.models import Book
from .models import BookUserData, KEY_SIZE, SECRET_SIZE, Token
+from social.models import UserConfirmation
from . import serializers
from .request_validator import PistonRequestValidator
from .utils import oauthlib_request, oauthlib_response, vary_on_auth
-class RegisterView(APIView):
+class RegisterView(GenericAPIView):
serializer_class = serializers.RegisterSerializer
def get(self, request):
})
def post(self, request):
- pass
-
+ if not settings.FEATURE_API_REGISTER:
+ return Response(
+ {
+ "detail": "Rejestracja aktualnie niedostępna."
+ },
+ status=400
+ )
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ d = serializer.validated_data
+
+ user = User(
+ username=d['email'],
+ email=d['email'],
+ is_active=False
+ )
+ user.set_password(d['password'])
+
+ try:
+ user.save()
+ except:
+ return Response(
+ {
+ "detail": "Nie można utworzyć konta.",
+ },
+ status=400
+ )
+
+ UserConfirmation.request(user)
+ return Response({})
+
class RefreshTokenView(APIView):
serializer_class = serializers.RefreshTokenSerializer
class RequestConfirmView(APIView):
- pass
+ 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 AuthorItemSerializer(serializers.ModelSerializer):
+ url = AbsoluteURLField()
+ href = AbsoluteURLField(
+ view_name='catalogue_api_author',
+ view_args=('slug',)
+ )
+
+ class Meta:
+ model = Tag
+ fields = [
+ 'url', 'href', 'name'
+ ]
+
+class AuthorSerializer(AuthorItemSerializer):
+ photo_thumb = ThumbnailField('139x193', source='photo')
+
+ class Meta:
+ model = Tag
+ fields = [
+ 'url', 'href', 'name', 'slug', 'sort_key', 'description',
+ 'genitive', 'photo', 'photo_thumb', 'photo_attribution',
+ ]
+
+class EpochItemSerializer(serializers.ModelSerializer):
+ url = AbsoluteURLField()
+ href = AbsoluteURLField(
+ view_name='catalogue_api_epoch',
+ view_args=('slug',)
+ )
+ class Meta:
+ model = Tag
+ fields = ['url', 'href', 'name']
+
+class EpochSerializer(EpochItemSerializer):
+ class Meta:
+ model = Tag
+ fields = [
+ 'url', 'href', 'name', 'slug', 'sort_key', 'description',
+ 'adjective_feminine_singular', 'adjective_nonmasculine_plural',
+ ]
+
+class GenreItemSerializer(serializers.ModelSerializer):
+ url = AbsoluteURLField()
+ href = AbsoluteURLField(
+ view_name='catalogue_api_genre',
+ view_args=('slug',)
+ )
+ class Meta:
+ model = Tag
+ fields = ['url', 'href', 'name']
+
+class GenreSerializer(GenreItemSerializer):
+ class Meta:
+ model = Tag
+ fields = [
+ 'url', 'href', 'name', 'slug', 'sort_key', 'description',
+ 'plural', 'genre_epoch_specific',
+ ]
+
+class KindItemSerializer(serializers.ModelSerializer):
+ url = AbsoluteURLField()
+ href = AbsoluteURLField(
+ view_name='catalogue_api_kind',
+ view_args=('slug',)
+ )
+ class Meta:
+ model = Tag
+ fields = ['url', 'href', 'name']
+
+class KindSerializer(KindItemSerializer):
+ class Meta:
+ model = Tag
+ fields = [
+ 'url', 'href', 'name', 'slug', 'sort_key', 'description',
+ 'collective_noun',
+ ]
+
+
class TranslatorSerializer(serializers.Serializer):
name = serializers.CharField(source='*')
mobi = EmbargoURLField(source='mobi_url')
pdf = EmbargoURLField(source='pdf_url')
+ authors = AuthorItemSerializer(many=True)
+ translators = AuthorItemSerializer(many=True)
+ epochs = EpochItemSerializer(many=True)
+ genres = GenreItemSerializer(many=True)
+ kinds = KindItemSerializer(many=True)
+ parent = serializers.HyperlinkedRelatedField(
+ read_only=True,
+ view_name='catalogue_api_book',
+ lookup_field='slug'
+ )
+
class Meta:
model = Book
fields = [
- 'full_sort_key', 'title',
+ 'slug', 'title', 'full_sort_key',
'href', 'url', 'language',
- #'epochs', 'genres', 'kinds', 'authors', 'translators',
+ 'authors', 'translators',
+ 'epochs', 'genres', 'kinds',
#'children',
'parent', 'preview',
'epub', 'mobi', 'pdf', 'html', 'txt', 'fb2', 'xml',
'cover_thumb', 'cover',
'isbn_pdf', 'isbn_epub', 'isbn_mobi',
+ 'abstract',
+ 'has_mp3_file',
+ ]
+
+class BookSerializer11Labs(serializers.ModelSerializer):
+ url = AbsoluteURLField()
+ href = AbsoluteURLField(view_name='catalogue_api_book', view_args=['slug'])
+ html = EmbargoURLField(source='html_nonotes_url')
+
+ authors = AuthorItemSerializer(many=True)
+ translators = AuthorItemSerializer(many=True)
+ epochs = EpochItemSerializer(many=True)
+ genres = GenreItemSerializer(many=True)
+ kinds = KindItemSerializer(many=True)
+ parent = serializers.HyperlinkedRelatedField(
+ read_only=True,
+ view_name='catalogue_api_book',
+ lookup_field='slug'
+ )
+
+ class Meta:
+ model = Book
+ fields = [
+ 'slug', 'title', 'full_sort_key',
+ 'href', 'url', 'language',
+ 'authors', 'translators',
+ 'epochs', 'genres', 'kinds',
+ #'children',
+ 'parent', 'preview',
+ 'html',
+ 'cover_thumb', 'cover',
+ 'isbn_pdf', 'isbn_epub', 'isbn_mobi',
]
+
class BookSerializer(LegacyMixin, serializers.ModelSerializer):
author = serializers.CharField(source='author_unicode')
kind = serializers.CharField(source='kind_unicode')
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()
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']
piwik_track_view(views.BookList2.as_view()),
name='catalogue_api_book_list'
),
+ path('11labs/books/',
+ piwik_track_view(views.BookList11Labs.as_view()),
+ name='catalogue_api_book_list'
+ ),
path('books/<slug:slug>/',
piwik_track_view(views.BookDetail2.as_view()),
name='catalogue_api_book'
),
+ path('books/<slug:slug>/fragment/',
+ piwik_track_view(views.BookFragmentView.as_view()),
+ name='catalogue_api_book_fragment'
+ ),
+ path('books/<slug:slug>/media/<slug:type>/',
+ views.BookMediaView.as_view()
+ ),
+
+ path('suggested-tags/',
+ piwik_track_view(views.SuggestedTags.as_view()),
+ name='catalogue_api_suggested_tags'
+ ),
+
+ path('authors/',
+ piwik_track_view(views.AuthorList.as_view()),
+ name="catalogue_api_author_list"),
+ path('authors/<slug:slug>/',
+ piwik_track_view(views.AuthorView.as_view()),
+ name='catalogue_api_author'),
+ path('epochs/',
+ piwik_track_view(views.EpochList.as_view()),
+ name="catalogue_api_epoch_list"),
+ path('epochs/<slug:slug>/',
+ piwik_track_view(views.EpochView.as_view()),
+ name='catalogue_api_epoch'),
+ path('kinds/',
+ piwik_track_view(views.KindList.as_view()),
+ name="catalogue_api_kind_list"),
+ path('kinds/<slug:slug>/',
+ piwik_track_view(views.KindView.as_view()),
+ name='catalogue_api_kind'),
+ path('genres/',
+ piwik_track_view(views.GenreList.as_view()),
+ name="catalogue_api_genre_list"),
+ path('genres/<slug:slug>/',
+ piwik_track_view(views.GenreView.as_view()),
+ name='catalogue_api_genre'),
]
from django.http import Http404, HttpResponse
from django.utils.decorators import method_decorator
from django.views.decorators.cache import never_cache
+from django_filters import rest_framework as dfilters
+from rest_framework import filters
from rest_framework.generics import (ListAPIView, RetrieveAPIView,
RetrieveUpdateAPIView, get_object_or_404)
from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
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
return Response({}, status=status.HTTP_201_CREATED)
+class BookFilter(dfilters.FilterSet):
+ sort = dfilters.OrderingFilter(
+ fields=(
+ ('sort_key_author', 'alpha'),
+ ('popularity', 'popularity'),
+ )
+ )
+ tag = dfilters.ModelMultipleChoiceFilter(
+ field_name='tag_relations__tag',
+ queryset=Tag.objects.filter(category__in=('author', 'epoch', 'genre', 'kind')),
+ conjoined=True,
+ )
+
+
class BookList2(ListAPIView):
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
queryset = Book.objects.none() # Required for DjangoModelPermissions
serializer_class = serializers.BookSerializer2
+ filter_backends = (
+ dfilters.DjangoFilterBackend,
+ filters.SearchFilter,
+ )
+ filterset_class = BookFilter
+ search_fields = [
+ 'title',
+ ]
def get_queryset(self):
books = Book.objects.all()
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()
return tags
+class AuthorList(ListAPIView):
+ serializer_class = serializers.AuthorSerializer
+ queryset = Tag.objects.filter(category='author')
+
+class AuthorView(RetrieveAPIView):
+ serializer_class = serializers.AuthorSerializer
+ queryset = Tag.objects.filter(category='author')
+ lookup_field = 'slug'
+
+class EpochList(ListAPIView):
+ serializer_class = serializers.EpochSerializer
+ queryset = Tag.objects.filter(category='epoch')
+
+class EpochView(RetrieveAPIView):
+ serializer_class = serializers.EpochSerializer
+ queryset = Tag.objects.filter(category='epoch')
+ lookup_field = 'slug'
+
+class GenreList(ListAPIView):
+ serializer_class = serializers.GenreSerializer
+ queryset = Tag.objects.filter(category='genre')
+
+class GenreView(RetrieveAPIView):
+ serializer_class = serializers.GenreSerializer
+ queryset = Tag.objects.filter(category='genre')
+ lookup_field = 'slug'
+
+class KindList(ListAPIView):
+ serializer_class = serializers.KindSerializer
+ queryset = Tag.objects.filter(category='kind')
+
+class KindView(RetrieveAPIView):
+ serializer_class = serializers.KindSerializer
+ queryset = Tag.objects.filter(category='kind')
+ lookup_field = 'slug'
+
class TagView(RetrieveAPIView):
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
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')
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'
--- /dev/null
+# 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'),
+ ),
+ ]
--- /dev/null
+# 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'),
+ ),
+ ]
--- /dev/null
+# 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),
+ ),
+ ]
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
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)
# 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)
'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)
tags = managers.TagDescriptor(Tag)
tag_relations = GenericRelation(Tag.intermediary_table_model)
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()
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 []
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:
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')
def has_sync_file(self):
return settings.FEATURE_SYNCHRO and self.has_media("sync")
+ def build_sync_file(self):
+ from lxml import html
+ from django.core.files.base import ContentFile
+ with self.html_file.open('rb') as f:
+ h = html.fragment_fromstring(f.read().decode('utf-8'))
+
+ durations = [
+ m['mp3'].duration
+ for m in self.get_audiobooks()[0]
+ ]
+ if settings.MOCK_DURATIONS:
+ durations = settings.MOCK_DURATIONS
+
+ sync = []
+ ts = None
+ sid = 1
+ dirty = False
+ for elem in h.iter():
+ if elem.get('data-audio-ts'):
+ part, ts = int(elem.get('data-audio-part')), float(elem.get('data-audio-ts'))
+ ts = str(round(sum(durations[:part - 1]) + ts, 3))
+ # check if inside verse
+ p = elem.getparent()
+ while p is not None:
+ # Workaround for missing ids.
+ if 'verse' in p.get('class', ''):
+ if not p.get('id'):
+ p.set('id', f'syn{sid}')
+ dirty = True
+ sid += 1
+ sync.append((ts, p.get('id')))
+ ts = None
+ break
+ p = p.getparent()
+ elif ts:
+ cls = elem.get('class', '')
+ # Workaround for missing ids.
+ if 'paragraph' in cls or 'verse' in cls or elem.tag in ('h1', 'h2', 'h3', 'h4'):
+ if not elem.get('id'):
+ elem.set('id', f'syn{sid}')
+ dirty = True
+ sid += 1
+ sync.append((ts, elem.get('id')))
+ ts = None
+ if dirty:
+ htext = html.tostring(h, encoding='utf-8')
+ with open(self.html_file.path, 'wb') as f:
+ f.write(htext)
+ try:
+ bm = self.media.get(type='sync')
+ except:
+ bm = BookMedia(book=self, type='sync')
+ sync = (
+ '27\n' + '\n'.join(
+ f'{s[0]}\t{sync[i+1][0]}\t{s[1]}' for i, s in enumerate(sync[:-1])
+ )).encode('latin1')
+ bm.file.save(
+ None, ContentFile(sync)
+ )
+
+
def get_sync(self):
with self.get_media('sync').first().file.open('r') as f:
sync = f.read().split('\n')
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
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
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:
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)
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):
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:
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:
}
$("#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;
});
$(".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;
};
// TODO: if snap then roll
locator.removeClass('up').removeClass('down');
if (locator.hasClass('snap')) {
- console.log('SCROLL!');
scrollTo();
} else {
if (y < miny) {
});
$('.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']);
_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'];
initialTime = last[2];
}
}
- console.log('READY 5!', initialElem, initialTime);
setMedia($(initialElem), initialTime);
- console.log('READY 6!');
},
timeupdate: function(event) {
ended: function(event) {
- let p = $currentMedia.next();
+ let p = $currentMedia.nextAll('li');
if (p.length) {
setMedia(p).jPlayer("play");
}
{% if book.is_book %}
<span class="icon icon-book-alt" title="{% trans 'książka' %}"></span>
{% endif %}
- {% if book.has_mp3_file %}
+ {% if book.has_audio %}
<span class="icon icon-audio" title="{% trans 'audiobook' %}"></span>
{% endif %}
{% if book.is_picture %}
{% load choose_cites from social_tags %}
{% load catalogue_tags %}
{% load likes_book from social_tags %}
+{% load annoy %}
{% block global-content %}
+<div class="l-container">
+ {% annoy_banner 'book-page' %}
+ </div>
+
<div class="l-container">
<div class="l-breadcrumb">
<a href="/"><span>{% trans "Strona główna" %}</span></a>
<div class="c-media__btn">
<button class="l-button l-button--media" id="ebook"><i class="icon icon-book"></i> {% trans "pobierz książkę" %}</button>
</div>
+ <div class="c-media__btn">
+ {% if first_text %}
+ <a href="https://elevenreader.io/audiobooks/wolnelektury:{{ first_text.slug }}" target="_blank" class="l-button l-button--media"><img src="{% static 'img/elevenreader-21.png' %}" title="{% trans "Posłuchaj w ElevenReader" %}" alt="{% trans "Posłuchaj w ElevenReader" %}"></a>
+ {% endif %}
+ </div>
<div class="c-media__btn">
{% if first_text %}
<a href="{% url 'book_text' first_text.slug %}" class="l-button l-button--media l-button--media--full"><i class="icon icon-eye"></i> {% trans "czytaj online" %}</a>
</div>
-
- {% if book.has_mp3_file %}
+ {% if book.has_audio %}
{% include 'catalogue/snippets/jplayer.html' %}
- {% else %}
- {% with ch=book.get_child_audiobook %}
- {% if ch %}
- {% include 'catalogue/snippets/jplayer_link.html' with book=ch %}
- {% endif %}
- {% endwith %}
-
{% endif %}
-
-
<div class="c-media__popup" data-popup="ebook">
<div class="c-media__popup__box">
<div class="c-media__popup__box__lead">
<button class="l-article__read-more" aria-label="{% trans 'Kliknij aby rozwinąć' %}" data-label="{% trans 'Czytaj więcej' %}" data-action="{% trans 'Zwiń tekst' %}">{% trans 'Czytaj więcej' %}</button>
</article>
{% if accessible %}
- <div class="c-support">
- <div>
- <h2>
- {% blocktrans trimmed %}
- Ta książka jest dostępna dla tysięcy dzieciaków dzięki
- <span>darowiznom</span> od osób takich jak <span>Ty</span>!
- {% endblocktrans %}
- </h2>
- <a href="{% url 'club_join' %}?pk_campaign=layout">{% trans "Dorzuć się!" %}</a>
- </div>
- <div class="bg">
- <!-- img src="{% static '2022/images/dziecko.jpeg' %}" alt="Dorzuć się!" -->
- </div>
- </div>
+ {% annoy_banner 'book-page-center' book=book %}
{% endif %}
</div>
{% endwith %}
</div>
{% endif %}
+ {% if narrated %}
+ <div class="l-section l-section--col">
+ <h2 class="header">Audiobooki</h2>
+ <div class="l-books__grid" id="book-list">
+ {% for book in narrated %}
+ {% include "catalogue/book_box.html" %}
+ {% endfor %}
+ </div>
+ </div>
+ {% endif %}
+
{% if main_tag %}
<section class="l-section">
<div class="l-author">
<article id="main-text" {% if book.has_sync_file %}class="has-sync"{% endif %}>
- {% with next=book.get_next_text prev=book.get_prev_text %}
+<div id="sidebar">
+ {% if book.other_versions.exists %}
+ <div class="box" id="other">
+ <h2>{% trans "Inne wersje tekstu" %}</h2>
+ <a class="other-text-close" href="#">{% trans "Zamknij drugą wersję" %}</a>
+ <ul>
+ {% spaceless %}
+ {% for other_version in book.other_versions %}
+ <li>
+ <a class="display-other"
+ data-other="{{ other_version.html_url }}"
+ href="{% url 'book_text' other_version.slug %}">
+ {{ other_version.mini_box_nolink }}
+ </a>
+ </li>
+ {% endfor %}
+ {% endspaceless %}
+ </ul>
+ </div>
+ {% endif %}
+</div>
+
+{% with next=book.get_next_text prev=book.get_prev_text %}
{% if next %}
<a class="text_next-book" href="{% url 'book_text' next.slug %}">{{ next.title }} →</a>
{% endif %}
<div class="pointer pointer-top"></div>
</div>
</div>
+</div>
- {% if book.other_versions.exists %}
- <div class="box" id="other">
- <h2>{% trans "Inne wersje utworu" %}</h2>
- <a class="other-text-close" href="#">{% trans "Zamknij drugą wersję" %}</a>
- <ul>
- {% spaceless %}
- {% for other_version in book.other_versions %}
- <li>
- <a class="display-other"
- data-other="{{ other_version.html_url }}"
- href="{% url 'book_text' other_version.slug %}">
- {{ other_version.mini_box_nolink }}
- </a>
- </li>
- {% endfor %}
- {% endspaceless %}
- </ul>
- </div>
- {% endif %}
-
<div id="annoy-stubs">
{% annoy_banners 'book-text-intermission' %}
{% load i18n l10n catalogue_tags %}
-{% with audiobooks=book.get_audiobooks %}
+{% with audiobooks=book.get_audiobooks_with_children %}
<div class="c-media__player" id="jp_container_{{ book.pk }}" data-book-slug="{{ book.slug }}">
<div class="jp-jplayer" data-player="jp_container_{{ book.pk }}"
data-supplied="oga,mp3"></div>
<ul class="jp-playlist">
{% localize off %}
{% for i in audiobooks.0 %}
+ {% if i.part %}
+ <div class="title"><strong>{{ i.part.title }}</strong></div>
+ {% else %}
<li
data-mp3='{{ i.mp3.file.url }}'
data-ogg='{{ i.ogg.file.url }}'
</span>
{% endwith %}
</li>
+ {% endif %}
{% endfor %}
{% endlocalize %}
</ul>
+++ /dev/null
-{% load i18n catalogue_tags %}
-
-<div class="c-media__player" id="jp_container_{{ book.pk }}">
- <div class="c-player__head">
- <span> </span>
- </div>
-
- <div class="c-player">
- <div class="c-player__btns">
- <button disabled class="play-prev"><i class="icon icon-prev"></i></button>
- <button disabled class="c-player__btn--md"><i class="icon icon-play"></i></button>
- <form action='{{ book.get_absolute_url }}'>
- <button class="play-next"><i class="icon icon-next"></i></button>
- </form>
- </div>
-
- <div class="c-player__timeline">
- <div class="c-player__info">{{ book.pretty_title }}</div>
- </div>
-
- </div>
- <div class="c-media__caption">
- </div>
-</div>
]
if len(self.ctx['tags']) == 1 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)
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
)
+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):
form = ScheduleForm
search_fields = ['email', 'source']
list_filter = [
'is_cancelled', 'monthly', 'yearly', 'method',
+ 'consent', OptOutFilter,
PayedFilter, ActiveFilter, ExpiredFilter,
SourceFilter, CrisisFilter
]
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):
if result:
return result[0]['id']
- def create_contact(self, email, key=None):
+ def create_contact(self, email, fields):
data = {
'values': {},
'chain': {
]
}
}
- 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]
]
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:
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
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)
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)
)
@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')
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()
"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):
{% load club %}
{% block content %}
+<div style="display: flex; gap:50px;">
<table class="table">
<tr>
<td>Aktywne miesięczne wpłaty cykliczne:</td>
<td>{% club_active_30day_sum %} zł.</td>
</tr>
</table>
+ <div>
+ <form method="post" action="{% url 'club_receipt' %}" style="display: flex; flex-direction: column;">
+ {% csrf_token %}
+ <span>Pobierz zestawienie roczne</span>
+ <input name="email" placeholder="email" required></input>
+ <input name="year" type="number" min="2013" max="2024" value="2024" placeholder="rok" required></input>
+ <button>pobierz zestawienie</button>
+ </form>
+ </div>
+ </div>
{{ block.super }}
{% endblock content %}
--- /dev/null
+<div class="checkout-infobar">
+ <div class="if-monthly">
+ Dziękujemy, że decydujesz się wspierać nas co miesiąc.<br/>
+ Jeśli to pomyłka, możesz zmienić darowiznę na <a class="donation-mod-monthly" data-url="{% url 'donation_set_monthly' schedule.key %}" data-monthly="false" href="{% url 'donation_step1' schedule.key %}">jednorazową</a>.
+ </div>
+ <div class="if-not-monthly">
+ Wolę wspierać co miesiąc!
+ <a class="donation-mod-monthly" data-url="{% url 'donation_set_monthly' schedule.key %}" data-monthly="true" href="{% url 'donation_step1' schedule.key %}">Zmień na comiesięczną wpłatę.</a>
+ </div>
+</div>
{% load static %}
{% load i18n %}
-<form method="post" action="{% url 'club_join' %}">
+<form method="post" action="{% if schedule %}{% url 'donation_step1' key=schedule.key %}{% else %}{% url 'club_join' %}{% endif %}">
{% csrf_token %}
{{ form.errors }}
<input type="radio" name="switch" id="switch-once" value="single" class="toggle-input" {% if schedule and not schedule.monthly %}checked{% endif %}>
{% with amounts=club.get_amounts %}
<div class="l-checkout__payments payments-once wide-spot-{{ amounts.single_wide_spot }}">
{% for amount in amounts.single %}
- <div class="l-checkout__payments__box once{% if not schedule.monthly and schedule.amount == amount.amount or not schedule and club.default_single_amount == amount.amount %} is-active{% endif %}{% if amount.wide %} l-checkout__payments__box--special{% endif %} l-checkout__payments__box--{{ amount.box_variant }}">
+ <div class="l-checkout__payments__box once{% if not schedule.monthly and schedule.amount == amount.amount or not schedule and club.default_single_amount == amount.amount %} is-active initial-active{% endif %}{% if amount.wide %} l-checkout__payments__box--special{% endif %} l-checkout__payments__box--{{ amount.box_variant }}">
- <h3>{{ amount.amount }} zł</h3>
<div class="l-checkout__payments__box__btn-wrp">
- {% if amount.description %}
- <p>{{ amount.description|safe }}</p>
- {% endif %}
- <button name="single_amount" value="{{ amount.amount }}">{% trans "Wybierz" %}</button>
+ <button name="single_amount" value="{{ amount.amount }}">{{ amount.amount }} zł</button>
</div>
</div>
{% endfor %}
<div class="l-checkout__payments payments-recurring wide-spot-{{ amounts.monthly_wide_spot }}">
{% for amount in amounts.monthly %}
- <div class="l-checkout__payments__box{% if schedule.monthly and schedule.amount == amount.amount or not schedule and amount.amount == club.default_monthly_amount %} is-active{% endif %}{% if amount.wide %} l-checkout__payments__box--special{% endif %} l-checkout__payments__box--{{ amount.box_variant }}">
- <h3>{{ amount.amount }} zł <span>{% trans "/mies." context "kwota na miesiąc" %}</span></h3>
+ <div class="l-checkout__payments__box{% if schedule.monthly and schedule.amount == amount.amount or not schedule and amount.amount == club.default_monthly_amount %} is-active initial-active{% endif %}{% if amount.wide %} l-checkout__payments__box--special{% endif %} l-checkout__payments__box--{{ amount.box_variant }}">
<div class="l-checkout__payments__box__btn-wrp">
- {% if amount.description %}
- <p>{{ amount.description|safe }}</p>
- {% endif %}
- <button name="monthly_amount" value="{{ amount.amount }}">{% trans "Wybierz" %}</button>
+ <button name="monthly_amount" value="{{ amount.amount }}">{{ amount.amount }} zł <span> /mies.</span></button>
</div>
</div>
{% endfor %}
<div class="l-checkout__amount">
<div class="l-checkout__input">
- <label for="kwota">{% trans "Inna kwota" %}</label>
+ <label for="id_custom_amount">{% trans "Inna kwota" %}</label>
{{ form.custom_amount }}
</div>
<button>{% trans "Dalej" %}</button>
{% block donation-step-content %}
- <div class="l-checkout__cols">
+ <div class="l-checkout__cols q-is-monthly {% if schedule.monthly %}is-monthly{% endif %}">
<div class="l-checkout__col">
<div class="l-checkout__payments__box is-active">
<h3>
{{ schedule.amount|floatformat }} zł
- {% if schedule.monthly %}
- <span>{% trans "/mies." context "kwota na miesiąc" %}</span>
- {% endif %}
+ <span class="if-monthly">{% trans "miesięcznie" %}</span>
+ <span class="if-not-monthly">{% trans "jednorazowo" %}</span>
</h3>
<img src="{% static '2022/images/checkout-img-3.jpg' %}" alt="">
+ {% if schedule.get_description %}
<p>{{ schedule.get_description }}</p>
+ {% endif %}
</div>
</div>
<div class="l-checkout__col">
+ {% include "club/donation_infobox.html" %}
+
<form method='post'>
{% csrf_token %}
{{ form.errors }}
- {{ form.amount }}
- {{ form.monthly }}
<div class="l-checkout__form">
<div class="l-checkout__form__row">
<div class="l-checkout__input">
{% block donation-step-content %}
- <div class="l-checkout__cols">
+ <div class="l-checkout__cols q-reload-is-monthly {% if schedule.monthly %}is-monthly{% endif %}">
<div class="l-checkout__col">
<div class="l-checkout__payments__box is-active">
<h3>
{{ schedule.amount|floatformat }} zł
{% if schedule.monthly %}
- <span>{% trans "/mies." context "kwota na miesiąc" %}</span>
+ <span>{% trans "miesięcznie" %}</span>
+ {% else %}
+ <span>{% trans "jednorazowo" %}</span>
{% endif %}</h3>
<img src="{% static '2022/images/checkout-img-3.jpg' %}" alt="">
</div>
</div>
<div class="l-checkout__col">
+ {% include "club/donation_infobox.html" %}
+
+ {% if schedule.monthly %}
+ <h3>Darowizna będzie pobierana automatycznie co miesiąc.</h3>
+ <p>Możesz z niej zrezygnować w dowolnej chwili, korzystając z linku który dostaniesz mailem.</p>
+ {% endif %}
+
<div class="l-checkout__form">
<div class="l-checkout__form__row full">
- <div class="iframe">
+
+
+ <div class="iframe">
+
{% for method in schedule.get_payment_methods %}
{% invite_payment method schedule %}
{% endfor %}
<div class="l-checkout__box">
<div class="l-checkout__box__header">
- <img src="{% block donation-jumbo-image %}{% static '2022/images/checkout-img-1.jpg' %}{% endblock %}" alt="Wspieraj Wolne Lektury">
+ <div class="l-checkout__box__header__img"
+ style="background-image: url({% block donation-jumbo-image %}{% static '2022/images/checkout-img-1.jpg' %}{% endblock %}">
+ </div>
<div class="l-checkout__box__header__content">
- <h1>{% trans "Wspieraj Wolne Lektury" %}</h1>
- <p>{% trans "Dziękujemy, że chcesz razem z nami uwalniać książki!" %}</p>
- <p>{% trans "Wspieraj Wolne Lektury stałą wpłatą – nawet niewielka ma wielką moc! Możesz też wesprzeć Wolne Lektury jednorazowo." %}</p>
+ {% chunk "donate-top" %}
</div>
</div>
<div class="l-checkout__steps">
{% endif %}
<div class="{% if view.step == 1 %}is-current{% else %}is-completed{% endif %}">
<span>1</span>
- <p>{% trans "Rodzaj wsparcia" %}</p>
+ <p>{% trans "Kwota wsparcia" %}</p>
</div>
{% if view.step > 1 and view.step != 4 %}
</a>
<div class="l-checkout__footer">
<div class="l-checkout__footer__content">
+ {% chunk 'donate-bottom' %}
+
<div class="l-checkout__footer__content__item">
<h3>{% trans "Transparentność jest dla nas bardzo ważna." %}</h3>
<div>
{% load i18n static %}
-<h3>{% trans "Wolisz wpłacić przez PayPal?" %}</h3>
+<h3>
+ {% if schedule.monthly %}
+ {% trans "Wolisz ustawić comiesięczną darowiznę przez PayPal?" %}
+ {% else %}
+ {% trans "Wolisz wpłacić przez PayPal?" %}
+ {% endif %}
+</h3>
<a href="{% url 'paypal_init' schedule.key %}">
<div class="iframe">
<img src="{% static 'club/paypal.png' %}" alt="PayPal">
{% load i18n %}
-<h3>{% trans "Podaj dane karty płatniczej" %}</h3>
+<h3>{% trans "Podaj dane karty płatniczej do comiesięcznej darowizny" %}</h3>
<div class="iframe">
<form id="theform" method='POST' action='{% url "club_payu_rec_payment" schedule.key %}'>
{% csrf_token %}
path('plan/<key>/zestawienie/<int:year>/', banner_exempt(views.YearSummaryView.as_view()), name='club_year_summary'),
path('plan/<key>/rodzaj/', banner_exempt(views.DonationStep1.as_view()), name='donation_step1'),
path('plan/<key>/dane/', banner_exempt(views.DonationStep2.as_view()), name='donation_step2'),
+ path('plan/<key>/ustaw-miesiecznie/', views.set_monthly, name='donation_set_monthly'),
path('przylacz/<key>/', views.claim, name='club_claim'),
path('anuluj/<key>/', views.cancel, name='club_cancel'),
path('notify/<int:pk>/', views.PayUNotifyView.as_view(), name='club_payu_notify'),
path('weryfikacja/', views.member_verify, name='club_member_verify'),
+
+ path('potwierdzenie/', views.receipt, name='club_receipt'),
]
from django.conf import settings
from django.contrib.auth.decorators import login_required, permission_required
from django.db.models import Sum
-from django.http import HttpResponseRedirect
+from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils.decorators import method_decorator
+@method_decorator(never_cache, name='dispatch')
class DonationStep1(UpdateView):
queryset = models.Schedule.objects.filter(payed_at=None)
form_class = forms.DonationStep1Form
return reverse('donation_step2', args=[self.object.key])
+@method_decorator(never_cache, name='dispatch')
class DonationStep2(UpdateView):
queryset = models.Schedule.objects.filter(payed_at=None)
form_class = forms.DonationStep2Form
return c
+def set_monthly(request, key):
+ schedule = get_object_or_404(models.Schedule, payed_at=None, key=key)
+ if request.POST:
+ schedule.monthly = request.POST.get('monthly') == 'true'
+ schedule.save(update_fields=['monthly'])
+ return JsonResponse({
+ "amount": schedule.amount,
+ "monthly": schedule.monthly,
+ })
+
+
class JoinView(CreateView):
form_class = forms.DonationStep1Form
template_name = 'club/donation_step1.html'
'result': rows
}
)
+
+
+@permission_required('club.schedule_view')
+def receipt(request):
+ email = request.POST.get('email')
+ try:
+ year = int(request.POST.get('year'))
+ except:
+ return HttpResponse('no content')
+
+ receipt = models.PayUOrder.generate_receipt(email, year)
+ if receipt:
+ content, optout, payments = receipt
+ else:
+ return HttpResponse('no content')
+ return HttpResponse(
+ content,
+ headers={
+ "Content-Type": "application/pdf",
+ "Content-Disposition": f'attachment; filename="wolnelektury-{year}-{email}.pdf"',
+ }
+ )
+
{% if author.alive %}
<p>
{% trans "Dzieła tego autora objęte są prawem autorskim." %}
- {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
- <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+ {% blocktrans trimmed %}
+ Biblioteki internetowe nie mogą
udostępniać dzieł tego autora.
{% endblocktrans %}
</p>
</p>
<div class='countdown' data-until='{{ pd_counter|date_to_utc|utc_for_js }}'></div>
<p>
- {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
- <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+ {% blocktrans trimmed %}
+ Biblioteki internetowe nie mogą
udostępniać dzieł tego autora.
{% endblocktrans %}
</p>
{% endblocktrans %}</p>
<div class='countdown' data-until='{{ pd_counter|date_to_utc|utc_for_js }}'></div>
<p>
- {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
- <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+ {% blocktrans trimmed %}
+ Biblioteki internetowe nie mogą
udostępniać tego utworu.
{% endblocktrans %}
</p>
{% else %}
<p>
{% trans "Ten utwór objęty jest prawem autorskim." %}
- {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
- <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+ {% blocktrans trimmed %}
+ Biblioteki internetowe nie mogą
udostępniać tego utworu.
{% endblocktrans %}
</p>
def populate_from_wikidata(self, wikidata_id):
client = Client()
+ client.opener.addheaders = [(
+ 'User-Agent', 'Wolne Lektury / https://wolnelektury.pl / Python-wikidata'
+ )]
entity = client.get(wikidata_id)
self.label = entity.label.get('pl', entity.label) or ''
--- /dev/null
+# This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
+#
+from django.urls import path
+from stats.utils import piwik_track_view
+from . import views
+
+
+urlpatterns = [
+ path('like/<slug:slug>/',
+ piwik_track_view(views.LikeView2.as_view()),
+ name='social_api_like'),
+ path('likes/', views.LikesView.as_view()),
+ path('my-likes/', views.MyLikesView.as_view()),
+]
+
+
from catalogue.api.helpers import order_books, books_after
from catalogue.api.serializers import BookSerializer
from catalogue.models import Book
+import catalogue.models
from social.utils import likes
+from social.views import get_sets_for_book_ids
@vary_on_auth
return Response({})
+@vary_on_auth
+class LikeView2(APIView):
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request, slug):
+ book = get_object_or_404(Book, slug=slug)
+ return Response({"likes": likes(request.user, book)})
+
+ def put(self, request, slug):
+ book = get_object_or_404(Book, slug=slug)
+ book.like(request.user)
+ return Response({"likes": likes(request.user, book)})
+
+ def delete(self, request, slug):
+ book = get_object_or_404(Book, slug=slug)
+ book.unlike(request.user)
+ return Response({"likes": likes(request.user, book)})
+
+
+@vary_on_auth
+class LikesView(APIView):
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ slugs = request.GET.getlist('slug')
+ books = Book.objects.filter(slug__in=slugs)
+ books = {b.id: b.slug for b in books}
+ ids = books.keys()
+ res = get_sets_for_book_ids(ids, request.user)
+ res = {books[bid]: v for bid, v in res.items()}
+ return Response(res)
+
+
+@vary_on_auth
+class MyLikesView(APIView):
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ ids = catalogue.models.tag.TagRelation.objects.filter(tag__user=request.user).values_list('object_id', flat=True).distinct()
+ books = Book.objects.filter(id__in=ids)
+ books = {b.id: b.slug for b in books}
+ res = get_sets_for_book_ids(ids, request.user)
+ res = {books[bid]: v for bid, v in res.items()}
+ return Response(res)
+
+
+
@vary_on_auth
class ShelfView(ListAPIView):
permission_classes = [IsAuthenticated]
--- /dev/null
+# Generated by Django 4.0.8 on 2025-02-24 15:19
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('social', '0016_alter_bannergroup_options_alter_carousel_options_and_more'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UserConfirmation',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('key', models.CharField(max_length=128, unique=True)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
# This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
# Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
#
+from oauthlib.common import urlencode, generate_token
from random import randint
from django.db import models
from django.conf import settings
+from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
+from django.core.mail import send_mail
from django.urls import reverse
from catalogue.models import Book
from wolnelektury.utils import cached_render, clear_cached_renders
def get_banner(self):
return self.banner or self.banner_group.get_banner()
+
+
+class UserConfirmation(models.Model):
+ user = models.ForeignKey(User, models.CASCADE)
+ created_at = models.DateTimeField(auto_now_add=True)
+ key = models.CharField(max_length=128, unique=True)
+
+ def send(self):
+ send_mail(
+ 'Potwierdź konto w bibliotece Wolne Lektury',
+ f'https://beta.wolnelektury.pl/ludzie/potwierdz/{self.key}/',
+ settings.CONTACT_EMAIL,
+ [self.user.email]
+ )
+
+ def use(self):
+ user = self.user
+ user.is_active = True
+ user.save()
+ self.delete()
+
+ @classmethod
+ def request(cls, user):
+ cls.objects.create(
+ user=user,
+ key=generate_token()
+ ).send()
--- /dev/null
+{% extends "base_simple.html" %}
+{% load i18n %}
+
+
+{% block body %}
+ <h1>{% trans "Konto potwierdzone" %}</h1>
+
+ <p class="normal-text">
+ {% blocktrans with user=user %}Konto <strong>{{ user }}</strong> zostało potwierdzone. Możesz się teraz zalogować.{% endblocktrans %}
+ </p>
+{% endblock body %}
+
urlpatterns = [
+ path('potwierdz/<str:key>/', views.confirm_user, name='social_confirm_user'),
path('lektura/<slug:slug>/lubie/', views.like_book, name='social_like_book'),
path('dodaj-tag/', views.AddSetView.as_view(), name='social_add_set_tag'),
path('usun-tag/', views.RemoveSetView.as_view(), name='social_remove_set_tag'),
from catalogue.models import Book, Tag
import catalogue.models.tag
-from social import forms
+from social import forms, models
from wolnelektury.utils import is_ajax
# ====================
-@require_POST
+@login_required
def like_book(request, slug):
- if not request.user.is_authenticated:
- return HttpResponseForbidden('Login required.')
book = get_object_or_404(Book, slug=slug)
+ if request.method != 'POST':
+ return redirect(book)
+
book.like(request.user)
if is_ajax(request):
form_class = forms.RemoveSetForm
-@require_POST
+@login_required
def unlike_book(request, slug):
- if not request.user.is_authenticated:
- return HttpResponseForbidden('Login required.')
book = get_object_or_404(Book, slug=slug)
+ if request.method != 'POST':
+ return redirect(book)
+
book.unlike(request.user)
if is_ajax(request):
t.name for t in tags
], safe=False
)
+
+
+def confirm_user(request, key):
+ uc = get_object_or_404(models.UserConfirmation, key=key)
+ user = uc.user
+ uc.use()
+ return render(request, 'social/user_confirmation.html', {
+ 'user': user,
+ })
'django.contrib.postgres',
'admin_ordering',
'rest_framework',
+ 'django_filters',
'fnp_django_pagination',
'pipeline',
'sorl.thumbnail',
# This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
# Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
#
-from os import path
-from .paths import PROJECT_DIR
+import os
DEBUG = True
CONTACT_EMAIL = 'fundacja@wolnelektury.pl'
+ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split()
+
CACHE_MIDDLEWARE_SECONDS = 3 * 60
DATABASES = {
'default': {
- 'ENGINE': 'django.db.backends.sqlite3', # 'postgresql_psycopg2'
- 'NAME': path.join(PROJECT_DIR, 'dev.db'),
- 'USER': '', # Not used with sqlite3.
- 'PASSWORD': '', # Not used with sqlite3.
- 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
+ 'ENGINE': 'django.db.backends.postgresql',
+ 'HOST': 'db',
+ 'USER': os.environ.get('POSTGRES_USER'),
+ 'PASSWORD': os.environ.get('POSTGRES_PASSWORD'),
+ 'NAME': os.environ.get('POSTGRES_USER'),
}
}
}
-HAYSTACK_CONNECTIONS = {
- 'default': {
- 'ENGINE': 'haystack.backends.simple_backend.SimpleEngine',
- },
-}
-
-
FORMS_BUILDER_USE_SITES = False
FORMS_BUILDER_EDITABLE_FIELD_MAX_LENGTH = True
FORMS_BUILDER_EDITABLE_SLUGS = True
LATEST_BLOG_POSTS = "https://fundacja.wolnelektury.pl/feed/?cat=-135"
-CATALOGUE_COUNTERS_FILE = os.path.join(VAR_DIR, 'catalogue_counters.p')
+CATALOGUE_COUNTERS_FILE = os.path.join(VAR_DIR, 'counters/catalogue_counters.p')
+LESMIANATOR_PICKLE = os.path.join(VAR_DIR, 'counters/lesmianator.p')
+
+NO_SEARCH_INDEX = False
CATALOGUE_MIN_INITIALS = 60
VARIANTS = {
}
-EPUB_FUNDRAISING = []
-
CIVICRM_BASE = None
CIVICRM_KEY = None
SEARCH_USE_UNACCENT = False
FEATURE_SYNCHRO = False
+
+FEATURE_API_REGISTER = False
.c-media__actions {
display: flex;
- column-gap: 38px;
+ column-gap: 20px;
row-gap: 10px;
}
display: inline;
}
}
+ div.title {
+ font-size: 12px;
+ line-height: 140%;
+ letter-spacing: 0.05em;
+ list-style-type: decimal;
+ margin: 5px 0;
+ color: white;
+ cursor: pointer;
+ }
}
}
.annoy-banner-off {
font-size: 1.2rem !important;
}
-
+
@include rwd($break-flow) {
.annoy-banner-inner {
padding: 0;
}
.annoy-banner-off {
-
+
}
}
}
-.annoy-banner_crisis-container {
+.annoy-banner_crisis-container,
+.annoy-banner_top-container {
position: sticky;
top: 0;
- height: 160px;
z-index: 10;
box-shadow: 0 0 10px black;
display: flex;
align-items:center;
cursor: pointer;
- @media screen and (min-height: 480px) {
- height: 33vh;
- top: calc(-33vh + 160px);
+ &.annoy-banner_top-container {
+ padding: 16px 0;
+
+ @media screen and (max-height: 700px) {
+ padding: 5px 0;
+ }
}
+ &.annoy-banner_crisis-container {
+ height: 160px;
- @media screen and (max-width: 940px) {
- padding: 10px 0;
- height: auto;
- top: 0;
+ @media screen and (min-height: 480px) {
+ height: 33vh;
+ top: calc(-33vh + 160px);
+ }
+
+ @media screen and (max-width: 940px) {
+ padding: 10px 0;
+ height: auto;
+ top: 0;
+ }
}
- .annoy-banner_crisis {
+
+ .annoy-banner_crisis,
+ .annoy-banner_top {
position: sticky;
top: 0;
width: 100%;
.image-box {
position: relative;
img {
- height: 159px;
+ max-height: 159px;
display: block;
@media screen and (max-width: 700px) {
- height: 120px;
+ max-height: 120px;
}
}
}
}
}
+ @media screen and (max-height: 700px) {
+ flex-direction: row;
+ justify-content: space-between;
+ p {
+ font-size: .9em;
+ }
+ }
+
+
.text {
background: #edc016;
padding: 1em;
border: 3px solid black;
}
+
+ @media screen and (max-height: 400px) {
+ align-items: center;
+ .text {
+ height: 1.2em;
+ overflow: hidden;
+ }
+ }
+
a {
color: #c32721;
}
display: block;
transition: background-color .2s;
+ @media screen and (max-height: 700px) {
+ font-size: .9em;
+ }
+ @media screen and (max-height: 400px) {
+ white-space: nowrap;
+ }
+
&:hover {
background: #ffd430;
text-decoration: none;
}
}
}
- &.annoy-banner-style_crisis_quiet {
+ &.annoy-banner-style_crisis_quiet,
+ &.annoy-banner_top-container {
background: black;
color: white;
.annoy-banner-inner {
}
}
}
+
+
+
+.annoy-banner_book-page {
+ background-color: #ffd430;
+ color: #083F4D;
+ border-radius: 10px;
+ padding: 15px 20px;
+ margin-top: 20px;
+
+ .annoy-banner-inner {
+ display: flex;
+ flex-direction: row;
+ gap: 20px;
+ align-items: flex-start;
+ justify-content: space-between;
+
+ p {
+ margin: 0;
+ }
+ a {
+ line-height: 1.35;
+ color: #c32721;
+ white-space: nowrap;
+ border: solid #c32721;
+ border-width: 0 0 1px;
+
+ &:hover {
+ text-decoration: none;
+ border-bottom-width: 2px;
+ }
+ }
+ }
+}
+
+.annoy-banner_book-page-center {
+ background: white;
+ margin-top: 50px;
+ padding: 20px;
+ border: 1px solid #018189;
+ border-radius: 15px;
+ color:#018189;
+
+ .annoy-banner-inner {
+ display: flex;
+ gap: 15px;
+ align-items: center;
+
+ img {
+ width: 150px;
+ }
+ p {
+ margin: 0;
+ }
+ }
+}
@include rwd($break-flow) {
@include font-size(16px);
line-height: 20px;
- padding: 19px 20px;
+ padding: 19px 0;
}
.icon {
font-size: 21px;
- margin-right: 15px;
+ margin-right: 10px;
+ }
+
+ img {
+ height: 21px;
}
&:hover {
display: flex;
background: #083F4D;
- img {
+ .l-checkout__box__header__img {
display: none;
+ background-position: center;
+ background-size: cover;
@include rwd($break-flow) {
display: block;
+ width: 50%;
}
}
}
}
}
.l-checkout__payments__box__btn-wrp {
- padding: 0 20px 20px 20px;
+ padding: 20px;
margin-bottom: 0;
margin-top: auto;
- @include rwd($break-flow) {
- padding-top: 20px;
- }
}
p {
margin-top: 0;
}
}
button {
- height: 56px;
+ margin: 0;
+ font-family: "Source Sans Pro",sans-serif;
+ font-weight: bold;
+ font-size: 44px;
+ letter-spacing: -0.01em;
+ height: 90px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all $ease-out 250ms;
+
background: #FFFFFF;
border: 1px solid #92BD39;
border-radius: 3px;
width: 100%;
outline: 0;
cursor: pointer;
- font-weight: 600;
- font-size: 16px;
- line-height: 24px;
- display: flex;
- align-items: center;
- justify-content: center;
text-align: center;
color: #083F4D;
- transition: background $ease-out 250ms;
+ flex-direction: column;
@include rwd($break-flow) {
- font-size: 20px;
- line-height: 25px;
+ flex-direction: row;
+ line-height: 130%;
+ }
+
+ span {
+ font-weight: 600;
+ font-size: 25px;
+ letter-spacing: -0.01em;
+ color: #92BD39;
+ margin-left: 10px;
+ transition: opacity $ease-out 250ms;
}
&:hover {
&:hover {
background: #83AD2B;
}
+ span {
+ color: white;
+ }
}
}
}
}
}
+
+
+.if-monthly { display: none; }
+.is-monthly {
+ .if-monthly {
+ display: block;
+ }
+ .if-not-monthly {
+ display: none;
+ }
+}
+
+
+.checkout-infobar {
+ margin: 0 0 20px;
+ padding: 20px;
+ border-radius: 10px;
+ border: 1px solid #edc016;
+ background: #edc016;
+}
color: white;
}
}
+
+ .menubar-donate {
+ color: #fff;
+ background: #c92834;
+ padding: 9px 20px 11px;
+ font-weight: 600;
+ margin-right: 20px;
+ border-radius: 15px;
+ }
}
.user {
display: block;
left: 16px;
}
+
+ .menubar-donate {
+ display: none;
+ }
}
}
}
h3, .subtitle2 {
font-size: 1.5em;
margin: 1.5em 0 1em 0;
+ padding-right: 48px;
font-weight: normal;
line-height: 1.5em;
}
}
}
}
+
+
+
+#sidebar {
+ position: absolute;
+ left: 0;
+ top: 20px;
+ width: 200px;
+
+ h2 {
+ font-size: 20px;
+ margin-bottom: 1em;
+ }
+
+ .other-text-close {
+ display: none;
+ }
+
+ #other {
+ display: none;
+ @include rwd($break-wide) {
+ display: block;
+ }
+ ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+
+ .book-mini-box {
+ position: relative;
+ width: 137px;
+ .language {
+ position: absolute;
+ top: 163px;
+ left: 10px;
+ color: white;
+ background: black;
+ padding: 0 10px;
+ border-radius: 10px;
+ font-size: 14px;
+ line-height: 20px;
+
+ }
+ .author, .title {
+ display: block;
+ font-size: 14px;
+ line-height: 18px;
+ }
+ }
+ }
+ }
+}
+.with-other-text {
+ #sidebar {
+ .other-text-close {
+ display: block;
+ background: red;
+ padding: 10px;
+ margin: 10px 0;
+ border-radius: 10px;
+ color: white;
+ text-decoration: none;
+ }
+ }
+ #main-text {
+ #book-text {
+ .other {
+ overflow: hidden;
+ margin: 10px 40px 10px 50px;
+ padding: 10px 20px 0 10px;
+ background: #eee;
+ border-left: 10px double #ddd;
+
+ h3 {
+ margin: 0;
+ padding: 0;
+ }
+
+ .paragraph {
+ padding: 0;
+ }
+ }
+ }
+ }
+}
+
+:lang(pl),
+:lang(en),
+:lang(de),
+:lang(fr),
+:lang(lt),
+:lang(uk)
+{
+ direction: ltr;
+}
+:lang(he) {
+ direction: rtl;
+}
#menu {
- padding-bottom: 50px;
* {
box-sizing: content-box;
}
$("#menu-other").show();
+ function insertOtherText(text) {
+ let tree = $(text);
+ let lang = tree.attr('lang') || 'pl';
+
+ // toc?
+ // themes?
+
+ let cursor = $(".main-text-body #book-text").children().first();
+ // wstawiamy przed kursorem
+ lastTarget = '';
+ tree.children().each((i, e) => {
+ let $e = $(e);
+
+ if ($e.hasClass('anchor')) return;
+ if ($e.hasClass('numeracja')) return;
+ if ($e.attr('id') == 'toc') return;
+ if ($e.attr('id') == 'nota_red') return;
+ if ($e.attr('id') == 'themes') return;
+ if ($e.attr('name') && $e.attr('name').startsWith('sec')) return;
+
+ if ($e.hasClass('target')) {
+ let target = $e.attr('name');
+
+ while (lastTarget != target) {
+ let nc = cursor.next();
+ if (!nc.length) {
+ break;
+ }
+ cursor = nc;
+ lastTarget = cursor.attr('name');
+ }
+
+ while (true) {
+ let nc = cursor.next();
+ if (!nc.length) {
+ break;
+ }
+ cursor = nc;
+ lastTarget = cursor.attr('name');
+ if (lastTarget) break;
+ }
+
+ } else {
+ let d = $('<div class="other">');
+ d.attr('lang', lang);
+ d.append(e);
+ d.insertBefore(cursor);
+ }
+ });
+ }
+
/* Load other version of text. */
$(".display-other").click(function(e) {
e.preventDefault();
release_menu();
- $("#other-text").show();
+ $(".other").remove();
$("body").addClass('with-other-text');
$.ajax($(this).attr('data-other'), {
success: function(text) {
- $("#other-text-body").html(text);
+ insertOtherText(text);
$("#other-text-waiter").hide();
- $("#other-text-body").show();
- loaded_text($("#other-text-body"));
+ loaded_text($(".other"));
}
});
_paq.push(['trackEvent', 'html', 'other-text']);
});
+
+
+
/* Remove other version of text. */
$(".other-text-close").click(function(e) {
release_menu();
e.preventDefault();
- $("#other-text").hide();
+ $(".other").remove();
$("body").removeClass('with-other-text');
- $("#other-text-body").html("");
_paq.push(['trackEvent', 'html', 'other-text-close']);
});
function upd_t() {
$text = $('#main-text #book-text');
+ if (!$text.length) return;
+
texttop = $text.offset().top;
$footnotes = $('#footnotes', $text);
}
var map_enabled = false;
- var marker = L.circleMarker([0,0]);
+ var marker = null;
var map = null;
function enable_map() {
+ if (!$("#reference-map").length) return;
+
$("#reference-map").show('slow');
if (map_enabled) return;
L.tileLayer('https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=a8a97f0ae5134403ac38c1a075b03e15', {
attribution: 'Maps © <a href="http://www.thunderforest.com">Thunderforest</a>, Data © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>'
}).addTo(map);
+ marker = L.circleMarker([0,0]);
map_enabled = true;
}
$('input', container).val($(this).val());
$('.is-active', container).removeClass('is-active');
$(this).closest('.l-checkout__payments__box').addClass('is-active');
- $('#kwota').val('');
- return false;
+ $('#id_custom_amount').val('');
+ });
+
+ $('#id_custom_amount').on('input', function() {
+ if ($(this).val() > 0) {
+ $('.l-checkout__payments__box.is-active').removeClass('is-active');
+ } else {
+ $('.l-checkout__payments__box.initial-active').addClass('is-active');
+ }
+ });
+
+ $('.donation-mod-monthly').on('click', function() {
+ $.ajax({
+ method: 'POST',
+ data: {
+ csrfmiddlewaretoken: $("[name=csrfmiddlewaretoken]").val(),
+ monthly: $(this).data('monthly'),
+ },
+ url: $(this).data('url'),
+ success: function(data) {
+ if ($(".q-reload-is-monthly").length) {
+ window.location.reload()
+ } else {
+ $(".q-is-monthly").toggleClass('is-monthly', data.monthly);
+ }
+ }
+ });
+ return false;
});
})();
$.post({
url: '/ludzie/lektura/' + $(this).attr('data-book-slug') + '/nie_lubie/',
data: {'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()},
+ dataType: 'json',
success: function() {
delete state.liked[$btn.attr('data-book')];
updateLiked($btn);
+ },
+ error: function() {
+ window.location.href = $('#login-link').attr('href');
}
})
} else {
$.post({
url: '/ludzie/lektura/' + $(this).attr('data-book-slug') + '/lubie/',
data: {'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()},
+ dataType: 'json',
success: function() {
state.liked[$btn.attr('data-book')] = [];
updateLiked($btn);
},
- error: function(e) {
- if (e.status == 403) {
- $('#login-link').click();
- }
- },
+ error: function() {
+ window.location.href = $('#login-link').attr('href')
+ }
});
}
})
<form action="{% url 'import_book' %}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
- <input type="file" id="id_book_xml_file" name="book_xml_file"/>
+ <input type="file" id="id_book_xml_file" name="book_xml_file" required/>
<input type="submit" value="Import książki" />
</p>
</form>
{% load preview_ad from catalogue_tags %}
{% annoy_banner_crisis %}
+{% annoy_banner_top %}
{% annoy_banner_blackout %}
</ul>
</div>
<a href="{% url 'user_settings' %}" class="user">
- {% if request.user.is_staffs %}
+ {% if request.user.is_staff %}
<img src="{% static '2022/images/icons/user-staff.svg' %}">
{% elif request.user.membership %}
<img src="{% static '2022/images/icons/user-vip.svg' %}">
{% else %}
<img src="{% static '2022/images/icons/user.svg' %}">
{% endif %}
- </a>
+ </a>
{% else %}
<div class="l-navigation__login">
<a id="login-link" href='{% url 'login' %}?next={{ request.path }}'>{% trans "Zaloguj się" %}</a>
<a href='{% url 'register' %}?next={{ request.path }}'>{% trans "Załóż konto" %}</a>
</div>
{% endif %}
+
+<a href="/pomagam/?pk_campaign=menubar" class="menubar-donate">Wspieram!</a>
+