update master
authorRadek Czajka <rczajka@rczajka.pl>
Fri, 14 Mar 2025 08:05:15 +0000 (09:05 +0100)
committerRadek Czajka <rczajka@rczajka.pl>
Fri, 14 Mar 2025 08:05:15 +0000 (09:05 +0100)
65 files changed:
requirements/requirements.txt
src/annoy/apps.py
src/annoy/migrations/0017_banner_progress_banner_target_alter_banner_place.py [new file with mode: 0644]
src/annoy/migrations/0018_alter_banner_style.py [new file with mode: 0644]
src/annoy/models.py
src/annoy/places.py
src/annoy/signals.py [new file with mode: 0644]
src/annoy/templates/annoy/banner_crisis.html [new file with mode: 0644]
src/annoy/templates/annoy/banner_top.html [new file with mode: 0644]
src/annoy/templatetags/annoy.py
src/api/drf_auth.py
src/api/fields.py
src/api/migrations/0007_alter_token_consumer.py [new file with mode: 0644]
src/api/migrations/0008_alter_token_token_type.py [new file with mode: 0644]
src/api/models.py
src/api/pagination.py [new file with mode: 0644]
src/api/serializers.py
src/api/tests/res/responses/books.xml
src/api/urls.py
src/api/views.py
src/catalogue/api/serializers.py
src/catalogue/api/urls2.py [new file with mode: 0644]
src/catalogue/api/views.py
src/catalogue/fields.py
src/catalogue/management/commands/load_abstracts.py [new file with mode: 0644]
src/catalogue/management/commands/update_tag_description.py [new file with mode: 0644]
src/catalogue/migrations/0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more.py [new file with mode: 0644]
src/catalogue/migrations/0049_snippet_anchor.py [deleted file]
src/catalogue/models/book.py
src/catalogue/models/snippet.py
src/catalogue/templates/catalogue/book_text.html
src/catalogue/templatetags/catalogue_tags.py
src/catalogue/test_utils.py
src/club/admin.py
src/club/forms.py
src/club/models.py
src/club/templates/admin/club/schedule/change_list.html
src/club/templates/club/donation_step2.html
src/club/templates/club/donation_step_base.html
src/club/urls.py
src/club/views.py
src/dictionary/constants.py
src/pdcounter/templates/pdcounter/author_detail.html
src/pdcounter/templates/pdcounter/book_detail.html
src/references/migrations/0003_occurence_anchor.py [deleted file]
src/references/migrations/0004_update_anchor.py [deleted file]
src/references/models.py
src/references/templates/references/popup.html
src/search/index.py
src/search/templates/search/results.html
src/social/api/views.py
src/social/migrations/0017_userconfirmation.py [new file with mode: 0644]
src/social/models.py
src/social/templates/social/user_confirmation.html [new file with mode: 0644]
src/social/urls.py
src/social/views.py
src/wolnelektury/settings/apps.py
src/wolnelektury/settings/contrib.py
src/wolnelektury/settings/custom.py
src/wolnelektury/static/2022/styles/layout/_annoy.scss
src/wolnelektury/static/2022/styles/layout/_text.scss
src/wolnelektury/static/js/book_text/menu.js
src/wolnelektury/static/js/book_text/references.js
src/wolnelektury/static/js/main.js
src/wolnelektury/templates/header.html

index 54b199b..084f0da 100644 (file)
@@ -3,20 +3,21 @@
 # django
 Django==4.0.8
 fnpdjango==0.6
-docutils==0.16
+docutils==0.20
 
-django-pipeline==2.0.8
-libsasscompiler==0.1.9
+django-pipeline==3.1.0
+libsasscompiler==0.2.0
 jsmin==3.0.1
 
 fnp-django-pagination==2.2.5
-django-modeltranslation==0.18.4
+django-modeltranslation==0.18.12
 django-allauth==0.51
-django-extensions==3.2.1
-djangorestframework==3.13.1
+django-extensions==3.2.3
+djangorestframework==3.15.1
+django-filter==23.5
 djangorestframework-xml==2.0.0
-django-admin-ordering==0.16
-django-countries==7.3.2
+django-admin-ordering==0.18
+django-countries==7.6.1
 
 -e git+https://github.com/rczajka/django-forms-builder@270cc22f80ec4681735b4e01689562fbab79ceda#egg=django-forms-builder
 
@@ -26,36 +27,36 @@ oauthlib>=3.0.1,<3.1
 # contact
 pyyaml==5.4.1
 
-polib==1.1.1
+polib==1.2
 
-django-honeypot==1.0.3
+django-honeypot==1.2.1
 
 python-fb==0.2
 
-Feedparser==6.0.10
+Feedparser==6.0.11
 
-Pillow==9.2.0
-mutagen==1.45.1
-sorl-thumbnail==12.8.0
+Pillow==9.5.0
+mutagen==1.47
+sorl-thumbnail==12.10.0
 
 # home-brewed & dependencies
-librarian==24.9
+librarian==24.5.8
 
 # celery tasks
-celery[redis]==5.2.7
+celery[redis]==5.4.0
 
 # OAI-PMH
 #pyoai==2.5.1
 -e git+https://github.com/infrae/pyoai@5ff2f15e869869e70d8139e4c37b7832854d7049#egg=pyoai
 
-sentry-sdk==0.10.2
+sentry-sdk==2.19.2
 
-requests
+requests==2.32.3
 
 paypalrestsdk
 
-python-slugify
+python-slugify==8.0.4
 
-firebase-admin
-Wikidata==0.7.0
+firebase-admin==6.6.0
+Wikidata==0.8.1
 
index 213a4e9..b2b7eff 100644 (file)
@@ -3,3 +3,6 @@ from django.apps import AppConfig
 
 class AnnoyConfig(AppConfig):
     name = 'annoy'
+
+    def ready(self):
+        from . import signals
diff --git a/src/annoy/migrations/0017_banner_progress_banner_target_alter_banner_place.py b/src/annoy/migrations/0017_banner_progress_banner_target_alter_banner_place.py
new file mode 100644 (file)
index 0000000..62246bf
--- /dev/null
@@ -0,0 +1,28 @@
+# Generated by Django 4.0.8 on 2024-12-04 11:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('annoy', '0016_alter_mediainsertset_file_format'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='banner',
+            name='progress',
+            field=models.IntegerField(blank=True, null=True, verbose_name='postęp'),
+        ),
+        migrations.AddField(
+            model_name='banner',
+            name='target',
+            field=models.IntegerField(blank=True, null=True, verbose_name='cel'),
+        ),
+        migrations.AlterField(
+            model_name='banner',
+            name='place',
+            field=models.SlugField(choices=[('top', 'U góry wszystkich stron'), ('book-page', 'Strona książki'), ('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'),
+        ),
+    ]
diff --git a/src/annoy/migrations/0018_alter_banner_style.py b/src/annoy/migrations/0018_alter_banner_style.py
new file mode 100644 (file)
index 0000000..45ed1e6
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2024-12-06 08:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('annoy', '0017_banner_progress_banner_target_alter_banner_place'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='banner',
+            name='style',
+            field=models.CharField(blank=True, choices=[('blackout_full', 'Blackout — Cały ekran'), ('blackout_upper', 'Blackout — Górna połowa ekranu'), ('crisis_quiet', 'Kryzysowa — Spokojny'), ('crisis_loud', 'Kryzysowa — Ostry')], max_length=255, verbose_name='styl'),
+        ),
+    ]
index e60e3c0..0d88792 100644 (file)
@@ -13,7 +13,6 @@ class Banner(models.Model):
     style = models.CharField(
         'styl', max_length=255, blank=True,
         choices=STYLES,
-        help_text='Dotyczy blackoutu.'
     )
     smallfont = models.BooleanField('mały font', default=False)
     text_color = models.CharField(max_length=10, blank=True)
@@ -33,6 +32,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)
+    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)
     staff_preview = models.BooleanField('podgląd tylko dla zespołu', default=False)
     only_authenticated = models.BooleanField('tylko dla zalogowanych', default=False)
@@ -49,10 +50,10 @@ class Banner(models.Model):
         return Template(self.text).render(Context())
 
     @classmethod
-    def choice(cls, place, request):
+    def choice(cls, place, request, exemptions=True):
         Membership = apps.get_model('club', 'Membership')
 
-        if hasattr(request, 'annoy_banner_exempt'):
+        if exemptions and hasattr(request, 'annoy_banner_exempt'):
             return cls.objects.none()
 
         if settings.DEBUG:
@@ -78,6 +79,33 @@ class Banner(models.Model):
 
         return banners
 
+    @property
+    def progress_percent(self):
+        if not self.target:
+            return 0
+        return (self.progress or 0) / self.target * 100
+
+    @property
+    def progress_percent_pretty(self):
+        return int(self.progress_percent)
+
+    def update_progress(self):
+        # Total of new payments during the action.
+        # This definition will need to change for longer timespans.
+        if not self.since or not self.until or not self.target:
+            return
+        Schedule = apps.get_model('club', 'Schedule')
+        self.progress = Schedule.objects.filter(
+            payed_at__gte=self.since,
+            payed_at__lte=self.until,
+        ).aggregate(c=models.Sum('amount'))['c']
+        self.save(update_fields=['progress'])
+
+    @classmethod
+    def update_all_progress(cls):
+        for obj in cls.objects.exclude(target=None):
+            obj.update_progress()
+
 
 class DynamicTextInsert(models.Model):
     paragraphs = models.IntegerField('akapity')
@@ -146,12 +174,3 @@ class MediaInsertText(models.Model):
 
     class Meta:
         ordering = ('ordering',)
-
-
-from django.db.models.signals import post_save, post_delete
-from django.dispatch import receiver
-
-@receiver(post_delete, sender=MediaInsertText)
-@receiver(post_save, sender=MediaInsertText)
-def update_etag(sender, instance, **kwargs):
-    instance.media_insert_set.update_etag()
index c9f8031..8fa767b 100644 (file)
@@ -8,6 +8,10 @@ PLACE_DEFINITIONS = [
 #        ('centre', 'Środek ekranu'),
         ('upper', 'Górna połowa ekranu'),
     )),
+    ('crisis', 'Kryzysowa', False, (
+        ('quiet', 'Spokojny'),
+        ('loud', 'Ostry'),
+    )),
 ]
 
 PLACE_CHOICES = [p[:2] for p in PLACE_DEFINITIONS]
@@ -22,6 +26,6 @@ STYLES = []
 for p in PLACE_DEFINITIONS:
     if len(p) > 3:
         STYLES.extend([
-            (f'{p[0]}_{s[0]}', s[1])
+            (f'{p[0]}_{s[0]}', f'{p[1]} — {s[1]}')
             for s in p[3]
         ])
diff --git a/src/annoy/signals.py b/src/annoy/signals.py
new file mode 100644 (file)
index 0000000..c985282
--- /dev/null
@@ -0,0 +1,18 @@
+from django.db.models.signals import post_save, post_delete
+from django.dispatch import receiver
+import club.models
+from . import models
+
+
+@receiver(post_delete, sender=models.MediaInsertText)
+@receiver(post_save, sender=models.MediaInsertText)
+def update_etag(sender, instance, **kwargs):
+    instance.media_insert_set.update_etag()
+
+
+@receiver(post_save, sender=club.models.Schedule)
+def update_progress(sender, instance, **kwargs):
+    try:
+        models.Banner.update_all_progress()
+    except:
+        pass
diff --git a/src/annoy/templates/annoy/banner_crisis.html b/src/annoy/templates/annoy/banner_crisis.html
new file mode 100644 (file)
index 0000000..9feffbf
--- /dev/null
@@ -0,0 +1,62 @@
+{% load l10n %}
+{% load time_tags %}
+
+{% if banner %}
+<div class="
+           annoy-banner_crisis-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="progress-box">
+           <div>
+             <div class="l-checkout__support__bar">
+               <span data-label="{{ banner.progress_percent_pretty }}%" style="width: {{ banner.progress_percent|stringformat:".3f" }}%;"></span>
+             </div>
+           </div>
+         </div>
+         <div class="time-box">
+           <strong class="countdown inline" data-until='{{ banner.until|date_to_utc:True|utc_for_js }}'>&nbsp;</strong>
+         </div>
+         <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 %}
+
diff --git a/src/annoy/templates/annoy/banner_top.html b/src/annoy/templates/annoy/banner_top.html
new file mode 100644 (file)
index 0000000..2a62d11
--- /dev/null
@@ -0,0 +1,51 @@
+{% load l10n %}
+
+{% if banner %}
+<div class="
+           annoy-banner_{{ banner.place }}-container
+            annoy-banner-style_{{ banner.style }}
+           ">
+  <div class="
+              annoy-banner
+              annoy-banner_{{ banner.place }}
+              {% if banner.image %}with-image{% endif %}
+              {% if banner.smallfont %}banner-smallfont{% endif %}
+              "
+        id="annoy-banner-{{ banner.id }}"
+       style="
+           {% if banner.text_color %}color: {{ banner.text_color }};{% endif %}
+           {% if banner.background_color %}background-color: {{ banner.background_color }};{% endif %}
+
+        ">
+    <div class="annoy-banner-inner">
+
+      <div class="image-box">
+       {% if banner.image %}
+       <img src="{{ banner.image.url }}">
+       {% endif %}
+      </div>
+
+      <div class="text-box">
+       <div class="text">
+          {{ banner.get_text|safe|linebreaks }}
+       </div>
+
+       <div class="state-box">
+         <div class="action-box">
+           {% if banner.action_label %}
+            <a class="action" href="{{ banner.url }}">
+              {{ banner.action_label }}
+            </a>
+           {% endif %}
+         </div>
+       </div>
+      </div>
+
+
+
+    </div>
+  </div>
+  </div>
+
+{% endif %}
+
index 2bc9314..25293f1 100644 (file)
@@ -22,6 +22,13 @@ def annoy_banner_blackout(context):
         'closable': True,
     }
 
+@register.inclusion_tag('annoy/banner_top.html', takes_context=True)
+def annoy_banner_top(context):
+    banners = Banner.choice('top', request=context['request'])
+    return {
+        'banner': banners.first(),
+        'closable': True,
+    }
 
 @register.inclusion_tag('annoy/banners.html', takes_context=True)
 def annoy_banners(context, place):
@@ -29,3 +36,12 @@ def annoy_banners(context, place):
         'banners': Banner.choice(place, request=context['request']),
         'closable': PLACES.get(place, False),
     }
+
+
+@register.inclusion_tag('annoy/banner_crisis.html', takes_context=True)
+def annoy_banner_crisis(context):
+    banners = Banner.choice('crisis', request=context['request'], exemptions=False)
+    return {
+        'banner': banners.first(),
+        'closable': True,
+    }
index adee780..813d929 100644 (file)
@@ -2,9 +2,10 @@
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
 from oauthlib.oauth1 import ResourceEndpoint
-from rest_framework.authentication import BaseAuthentication
+from rest_framework.authentication import BaseAuthentication, TokenAuthentication
 from .request_validator import PistonRequestValidator
 from .utils import oauthlib_request
+from .models import Token
 
 
 class PistonOAuthAuthentication(BaseAuthentication):
@@ -21,3 +22,7 @@ class PistonOAuthAuthentication(BaseAuthentication):
         )
         if v:
             return r.token.user, r.token
+
+
+class WLTokenAuthentication(TokenAuthentication):
+    model = Token
index cdfcc47..7283234 100644 (file)
@@ -3,7 +3,7 @@
 #
 from rest_framework import serializers
 from sorl.thumbnail import default
-from django.urls import reverse
+from rest_framework.reverse import reverse
 from club.models import Membership
 
 
@@ -20,13 +20,14 @@ class AbsoluteURLField(serializers.ReadOnlyField):
                 self.view_args[fields[0]] = fields[1] if len(fields) > 1 else fields[0]
 
     def to_representation(self, value):
+        request = self.context['request']
         if self.view_name is not None:
             kwargs = {
                 arg: getattr(value, field)
                 for (arg, field) in self.view_args.items()
             }
-            value = reverse(self.view_name, kwargs=kwargs)
-        return self.context['request'].build_absolute_uri(value)
+            return reverse(self.view_name, kwargs=kwargs, request=request)
+        return request.build_absolute_uri(value)
 
 
 class LegacyMixin:
diff --git a/src/api/migrations/0007_alter_token_consumer.py b/src/api/migrations/0007_alter_token_consumer.py
new file mode 100644 (file)
index 0000000..410516a
--- /dev/null
@@ -0,0 +1,19 @@
+# Generated by Django 4.0.8 on 2024-11-08 10:14
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0006_alter_bookuserdata_unique_together'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='token',
+            name='consumer',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.consumer'),
+        ),
+    ]
diff --git a/src/api/migrations/0008_alter_token_token_type.py b/src/api/migrations/0008_alter_token_token_type.py
new file mode 100644 (file)
index 0000000..b2ba7ba
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2025-02-24 15:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0007_alter_token_consumer'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='token',
+            name='token_type',
+            field=models.IntegerField(choices=[(1, 'Request'), (2, 'Access'), (3, 'Refresh')]),
+        ),
+    ]
index 04d08e9..4f05565 100644 (file)
@@ -101,7 +101,12 @@ class Consumer(models.Model):
 class Token(models.Model):
     REQUEST = 1
     ACCESS = 2
-    TOKEN_TYPES = ((REQUEST, 'Request'), (ACCESS, 'Access'))
+    REFRESH = 3
+    TOKEN_TYPES = (
+        (REQUEST, 'Request'),
+        (ACCESS, 'Access'),
+        (REFRESH, 'Refresh')
+    )
 
     key = models.CharField(max_length=KEY_SIZE)
     secret = models.CharField(max_length=SECRET_SIZE)
@@ -112,7 +117,7 @@ class Token(models.Model):
         settings.AUTH_USER_MODEL, models.CASCADE,
         null=True, blank=True, related_name='tokens'
     )
-    consumer = models.ForeignKey(Consumer, models.CASCADE)
+    consumer = models.ForeignKey(Consumer, models.CASCADE, null=True, blank=True)
 
     def __str__(self):
         return "%s Token %s for %s" % (self.get_token_type_display(), self.key, self.consumer)
diff --git a/src/api/pagination.py b/src/api/pagination.py
new file mode 100644 (file)
index 0000000..0436622
--- /dev/null
@@ -0,0 +1,17 @@
+from rest_framework.pagination import LimitOffsetPagination, PageLink
+from rest_framework.response import Response
+
+
+class WLLimitOffsetPagination(LimitOffsetPagination):
+    def get_results(self, data):
+        return data['member']
+
+    def get_paginated_response(self, data):
+        return Response({
+            "member": data,
+            "totalItems": self.count,
+            "view": {
+                "previous": self.get_previous_link(),
+                "next": self.get_next_link(),
+            }
+        })
index e0bf0ee..8c00892 100644 (file)
@@ -15,13 +15,34 @@ 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):
     class Meta:
         model = BookUserData
         fields = ['state']
+
+
+class LoginSerializer(serializers.Serializer):
+    username = serializers.CharField()
+    password = serializers.CharField(style={'input_type': 'password'})
+
+
+
+class RegisterSerializer(serializers.Serializer):
+    email = serializers.CharField()
+    password = serializers.CharField(style={'input_type': 'password'})
+    options = serializers.ListField(child=serializers.IntegerField(), required=False)
+
+
+class RefreshTokenSerializer(serializers.Serializer):
+    refresh_token = serializers.CharField(style={'input_type': 'password'})
+
+
+class RequestConfirmSerializer(serializers.Serializer):
+    email = serializers.CharField()
index f398644..1eb4a97 100644 (file)
@@ -1,2 +1,2 @@
 <?xml version="1.0" encoding="utf-8"?>
-<response><resource><kind></kind><full_sort_key>$child$2</full_sort_key><title>Child</title><url>http://testserver/katalog/lektura/child/</url><cover_color>#000000</cover_color><author></author><cover></cover><epoch></epoch><href>http://testserver/api/books/child/</href><has_audio>False</has_audio><genre>Wiersz</genre><simple_thumb></simple_thumb><slug>child</slug><cover_thumb></cover_thumb><liked></liked></resource><resource><kind></kind><full_sort_key>$grandchild$3</full_sort_key><title>Grandchild</title><url>http://testserver/katalog/lektura/grandchild/</url><cover_color>#000000</cover_color><author></author><cover></cover><epoch></epoch><href>http://testserver/api/books/grandchild/</href><has_audio>False</has_audio><genre>Sonet</genre><simple_thumb></simple_thumb><slug>grandchild</slug><cover_thumb></cover_thumb><liked></liked></resource><resource><kind>Liryka</kind><full_sort_key>john doe$parent$1</full_sort_key><title>Parent</title><url>http://testserver/katalog/lektura/parent/</url><cover_color>#a6820a</cover_color><author>John Doe</author><cover>cover/parent.jpg</cover><epoch>Barok</epoch><href>http://testserver/api/books/parent/</href><has_audio>True</has_audio><genre>Sonet</genre><simple_thumb>http://testserver/media/cover_api_thumb/parent.jpg</simple_thumb><slug>parent</slug><cover_thumb>cover_thumb/parent.jpg</cover_thumb><liked></liked></resource></response>
+<response><resource><kind></kind><full_sort_key>$child$2</full_sort_key><title>Child</title><url>http://testserver/katalog/lektura/child/</url><cover_color>#000000</cover_color><author></author><cover></cover><epoch></epoch><href>http://testserver/api/books/child/?format=xml</href><has_audio>False</has_audio><genre>Wiersz</genre><simple_thumb></simple_thumb><slug>child</slug><cover_thumb></cover_thumb><liked></liked></resource><resource><kind></kind><full_sort_key>$grandchild$3</full_sort_key><title>Grandchild</title><url>http://testserver/katalog/lektura/grandchild/</url><cover_color>#000000</cover_color><author></author><cover></cover><epoch></epoch><href>http://testserver/api/books/grandchild/?format=xml</href><has_audio>False</has_audio><genre>Sonet</genre><simple_thumb></simple_thumb><slug>grandchild</slug><cover_thumb></cover_thumb><liked></liked></resource><resource><kind>Liryka</kind><full_sort_key>john doe$parent$1</full_sort_key><title>Parent</title><url>http://testserver/katalog/lektura/parent/</url><cover_color>#a6820a</cover_color><author>John Doe</author><cover>cover/parent.jpg</cover><epoch>Barok</epoch><href>http://testserver/api/books/parent/?format=xml</href><has_audio>True</has_audio><genre>Sonet</genre><simple_thumb>http://testserver/media/cover_api_thumb/parent.jpg</simple_thumb><slug>parent</slug><cover_thumb>cover_thumb/parent.jpg</cover_thumb><liked></liked></resource></response>
index 1f05e2d..de3dba7 100644 (file)
@@ -9,10 +9,23 @@ from stats.utils import piwik_track_view
 from . import views
 
 
+urlpatterns1 = [
+    path('register/', csrf_exempt(views.RegisterView.as_view())),
+    path('refreshToken/', csrf_exempt(views.RefreshTokenView.as_view())),
+    path('requestConfirm/', csrf_exempt(views.RequestConfirmView.as_view())),
+    path('login/', csrf_exempt(views.Login2View.as_view())),
+    path('me/', views.UserView.as_view()),
+    path('', include('catalogue.api.urls2')),
+]
+
+
 urlpatterns = [
+    path('2/', include((urlpatterns1, 'api'), namespace="v2")),
+
     path('oauth/request_token/', csrf_exempt(views.OAuth1RequestTokenView.as_view())),
     path('oauth/authorize/', views.oauth_user_auth, name='oauth_user_auth'),
     path('oauth/access_token/', csrf_exempt(views.OAuth1AccessTokenView.as_view())),
+    path('login/', csrf_exempt(views.LoginView.as_view())),
 
     path('', TemplateView.as_view(template_name='api/main.html'), name='api'),
 
@@ -36,4 +49,5 @@ urlpatterns = [
 
     path('', include('social.api.urls')),
     path('', include('catalogue.api.urls')),
+
 ]
index 64a7eb0..011161e 100644 (file)
@@ -1,21 +1,26 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # 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 django.shortcuts import render
 from django.views.generic.base import View
-from oauthlib.common import urlencode
+from oauthlib.common import urlencode, generate_token
 from oauthlib.oauth1 import RequestTokenEndpoint, AccessTokenEndpoint
 from oauthlib.oauth1 import AuthorizationEndpoint, OAuth1Error
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
 from rest_framework.views import APIView
-from rest_framework.generics import RetrieveAPIView, get_object_or_404
+from rest_framework.generics import GenericAPIView, RetrieveAPIView, get_object_or_404
 from catalogue.models import Book
-from .models import BookUserData, KEY_SIZE, SECRET_SIZE
+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
@@ -131,6 +136,59 @@ class OAuth1AccessTokenView(View):
         )
 
 
+class LoginView(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."})
+
+        key = generate_token()[:KEY_SIZE]
+        Token.objects.create(
+            key=key,
+            token_type=Token.ACCESS,
+            timestamp=time(),
+            user=user,
+        )
+        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]
@@ -169,3 +227,110 @@ class BookUserDataView(RetrieveAPIView):
 class BlogView(APIView):
     def get(self, request):
         return Response([])
+
+
+
+class RegisterView(GenericAPIView):
+    serializer_class = serializers.RegisterSerializer
+
+    def get(self, request):
+        return Response({
+            "options": [
+                {
+                    "id": 1,
+                    "html": "Chcę otrzymywać newsletter Wolnych Lektur",
+                    "required": False
+                }
+            ],
+            "info": [
+                'Administratorem danych osobowych jest Fundacja Wolne Lektury (ul. Marszałkowska 84/92 lok. 125, 00-514 Warszawa). Podanie danych osobowych jest dobrowolne. Dane są przetwarzane w zakresie niezbędnym do prowadzenia serwisu, a także w celach prowadzenia statystyk, ewaluacji i sprawozdawczości. W przypadku wyrażenia dodatkowej zgody adres e-mail zostanie wykorzystany także w celu przesyłania newslettera Wolnych Lektur. Osobom, których dane są zbierane, przysługuje prawo dostępu do treści swoich danych oraz ich poprawiania. Więcej informacji w <a href="https://fundacja.wolnelektury.pl/prywatnosc/">polityce prywatności</a>.'
+            ]            
+        })
+
+    def post(self, request):
+        if not settings.FEATURE_API_REGISTER:
+            return Response(
+                {
+                    "detail": "Rejestracja aktualnie niedostępna."
+                },
+                status=400
+            )
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        d = serializer.validated_data
+
+        user = User(
+            username=d['email'],
+            email=d['email'],
+            is_active=False
+        )
+        user.set_password(d['password'])
+
+        try:
+            user.save()
+        except:
+            return Response(
+                {
+                    "detail": "Nie można utworzyć konta.",
+                },
+                status=400
+            )
+
+        UserConfirmation.request(user)
+        return Response({})
+
+
+class RefreshTokenView(APIView):
+    serializer_class = serializers.RefreshTokenSerializer
+
+    def post(self, request):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        d = serializer.validated_data
+        
+        t = Token.objects.get(
+            key=d['refresh_token'],
+            token_type=Token.REFRESH
+        )
+        user = t.user
+
+        access_token = generate_token()[:KEY_SIZE]
+        Token.objects.create(
+            key=access_token,
+            token_type=Token.ACCESS,
+            timestamp=time(),
+            user=user,
+        )
+        refresh_token = generate_token()[:KEY_SIZE]
+        Token.objects.create(
+            key=refresh_token,
+            token_type=Token.REFRESH,
+            timestamp=time(),
+            user=user,
+        )
+        return Response({
+            "access_token": access_token,
+            "refresh_token": refresh_token,
+            "expires": 3600,
+        })
+
+
+class RequestConfirmView(APIView):
+    serializer_class = serializers.RequestConfirmSerializer
+
+    def post(self, request):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        d = serializer.validated_data
+
+        try:
+            user = User.objects.get(
+                username=d['email'],
+                is_active=False
+            )
+        except User.DoesNotExist:
+            raise Http404
+
+        UserConfirmation.request(user)
+        return Response({})
+
index daa86fe..60e52a0 100644 (file)
@@ -40,10 +40,155 @@ class TagDetailSerializer(serializers.ModelSerializer):
         ]
 
 
+class AuthorItemSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_author',
+        view_args=('slug',)
+    )
+
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name'
+        ]
+
+class AuthorSerializer(AuthorItemSerializer):
+    photo_thumb = ThumbnailField('139x193', source='photo')
+
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            'genitive', 'photo', 'photo_thumb', 'photo_attribution',
+        ]
+
+class EpochItemSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_epoch',
+        view_args=('slug',)
+    )
+    class Meta:
+        model = Tag
+        fields = ['url', 'href', 'name']
+
+class EpochSerializer(EpochItemSerializer):
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            'adjective_feminine_singular', 'adjective_nonmasculine_plural',
+        ]
+
+class GenreItemSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_genre',
+        view_args=('slug',)
+    )
+    class Meta:
+        model = Tag
+        fields = ['url', 'href', 'name']
+
+class GenreSerializer(GenreItemSerializer):
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            'plural', 'genre_epoch_specific',
+        ]
+
+class KindItemSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_kind',
+        view_args=('slug',)
+    )
+    class Meta:
+        model = Tag
+        fields = ['url', 'href', 'name']
+
+class KindSerializer(KindItemSerializer):
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            'collective_noun',
+        ]
+
+
 class TranslatorSerializer(serializers.Serializer):
     name = serializers.CharField(source='*')
 
 
+class BookSerializer2(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(view_name='catalogue_api_book', view_args=['slug'])
+    xml = EmbargoURLField(source='xml_url')
+    html = EmbargoURLField(source='html_url')
+    txt = EmbargoURLField(source='txt_url')
+    fb2 = EmbargoURLField(source='fb2_url')
+    epub = EmbargoURLField(source='epub_url')
+    mobi = EmbargoURLField(source='mobi_url')
+    pdf = EmbargoURLField(source='pdf_url')
+
+    authors = AuthorItemSerializer(many=True)
+    translators = AuthorItemSerializer(many=True)
+    epochs = EpochItemSerializer(many=True)
+    genres = GenreItemSerializer(many=True)
+    kinds = KindItemSerializer(many=True)
+    parent = serializers.HyperlinkedRelatedField(
+        read_only=True,
+        view_name='catalogue_api_book',
+        lookup_field='slug'
+    )
+
+    class Meta:
+        model = Book
+        fields = [
+            'slug', 'title', 'full_sort_key',
+            'href', 'url', 'language',
+            'authors', 'translators',
+            'epochs', 'genres', 'kinds',
+            #'children',
+            'parent', 'preview',
+            'epub', 'mobi', 'pdf', 'html', 'txt', 'fb2', 'xml',
+            'cover_thumb', 'cover',
+            'isbn_pdf', 'isbn_epub', 'isbn_mobi',
+        ]
+
+class BookSerializer11Labs(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(view_name='catalogue_api_book', view_args=['slug'])
+    html = EmbargoURLField(source='html_nonotes_url')
+
+    authors = AuthorItemSerializer(many=True)
+    translators = AuthorItemSerializer(many=True)
+    epochs = EpochItemSerializer(many=True)
+    genres = GenreItemSerializer(many=True)
+    kinds = KindItemSerializer(many=True)
+    parent = serializers.HyperlinkedRelatedField(
+        read_only=True,
+        view_name='catalogue_api_book',
+        lookup_field='slug'
+    )
+
+    class Meta:
+        model = Book
+        fields = [
+            'slug', 'title', 'full_sort_key',
+            'href', 'url', 'language',
+            'authors', 'translators',
+            'epochs', 'genres', 'kinds',
+            #'children',
+            'parent', 'preview',
+            'html',
+            'cover_thumb', 'cover',
+            'isbn_pdf', 'isbn_epub', 'isbn_mobi',
+        ]
+
+
 class BookSerializer(LegacyMixin, serializers.ModelSerializer):
     author = serializers.CharField(source='author_unicode')
     kind = serializers.CharField(source='kind_unicode')
@@ -205,3 +350,9 @@ class FragmentDetailSerializer(serializers.ModelSerializer):
     class Meta:
         model = Fragment
         fields = ['book', 'anchor', 'text', 'url', 'themes']
+
+
+class FilterTagSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Tag
+        fields = ['id', 'category', 'name']
diff --git a/src/catalogue/api/urls2.py b/src/catalogue/api/urls2.py
new file mode 100644 (file)
index 0000000..b16af66
--- /dev/null
@@ -0,0 +1,52 @@
+# This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
+#
+from django.urls import path, re_path
+from stats.utils import piwik_track_view
+from . import views
+
+
+urlpatterns = [
+    path('books/',
+         piwik_track_view(views.BookList2.as_view()),
+         name='catalogue_api_book_list'
+         ),
+    path('11labs/books/',
+         piwik_track_view(views.BookList11Labs.as_view()),
+         name='catalogue_api_book_list'
+         ),
+    path('books/<slug:slug>/',
+         piwik_track_view(views.BookDetail2.as_view()),
+         name='catalogue_api_book'
+         ),
+
+    path('suggested-tags/',
+         piwik_track_view(views.SuggestedTags.as_view()),
+         name='catalogue_api_suggested_tags'
+         ),
+
+    path('authors/',
+         piwik_track_view(views.AuthorList.as_view()),
+         name="catalogue_api_author_list"),
+    path('authors/<slug:slug>/',
+         piwik_track_view(views.AuthorView.as_view()),
+         name='catalogue_api_author'),
+    path('epochs/',
+         piwik_track_view(views.EpochList.as_view()),
+         name="catalogue_api_epoch_list"),
+    path('epochs/<slug:slug>/',
+         piwik_track_view(views.EpochView.as_view()),
+         name='catalogue_api_epoch'),
+    path('kinds/',
+         piwik_track_view(views.KindList.as_view()),
+         name="catalogue_api_kind_list"),
+    path('kinds/<slug:slug>/',
+         piwik_track_view(views.KindView.as_view()),
+         name='catalogue_api_kind'),
+    path('genres/',
+         piwik_track_view(views.GenreList.as_view()),
+         name="catalogue_api_genre_list"),
+    path('genres/<slug:slug>/',
+         piwik_track_view(views.GenreView.as_view()),
+         name='catalogue_api_genre'),
+]
index c0dc57f..0e758b1 100644 (file)
@@ -9,6 +9,8 @@ from django.core.files.base import ContentFile
 from django.http import Http404, HttpResponse
 from django.utils.decorators import method_decorator
 from django.views.decorators.cache import never_cache
+from django_filters import rest_framework as dfilters
+from rest_framework import filters
 from rest_framework.generics import (ListAPIView, RetrieveAPIView,
                                      RetrieveUpdateAPIView, get_object_or_404)
 from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
@@ -17,6 +19,7 @@ from rest_framework import status
 from api.handlers import read_tags
 from api.utils import vary_on_auth
 from catalogue.forms import BookImportForm
+from catalogue.helpers import get_top_level_related_tags
 from catalogue.models import Book, Collection, Tag, Fragment, BookMedia
 from catalogue.models.tag import prefetch_relations
 from club.models import Membership
@@ -30,6 +33,10 @@ from . import serializers
 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
 
 
+class LegacyListAPIView(ListAPIView):
+    pagination_class = None
+
+
 class CreateOnPutMixin:
     '''
     Creates a new model instance when PUTting a nonexistent resource.
@@ -47,7 +54,7 @@ class CreateOnPutMixin:
                 raise
 
 
-class CollectionList(ListAPIView):
+class CollectionList(LegacyListAPIView):
     queryset = Collection.objects.filter(listed=True)
     serializer_class = serializers.CollectionListSerializer
 
@@ -61,7 +68,7 @@ class CollectionDetail(CreateOnPutMixin, RetrieveUpdateAPIView):
 
 
 @vary_on_auth  # Because of 'liked'.
-class BookList(ListAPIView):
+class BookList(LegacyListAPIView):
     permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
     queryset = Book.objects.none()  # Required for DjangoModelPermissions
     serializer_class = serializers.BookListSerializer
@@ -179,6 +186,53 @@ class BookList(ListAPIView):
         return Response({}, status=status.HTTP_201_CREATED)
 
 
+class BookFilter(dfilters.FilterSet):
+    sort = dfilters.OrderingFilter(
+        fields=(
+            ('sort_key_author', 'alpha'),
+            ('popularity', 'popularity'),
+        )
+    )
+    tag = dfilters.ModelMultipleChoiceFilter(
+        field_name='tag_relations__tag',
+        queryset=Tag.objects.filter(category__in=('author', 'epoch', 'genre', 'kind')),
+        conjoined=True,
+    )
+
+
+class BookList2(ListAPIView):
+    permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
+    queryset = Book.objects.none()  # Required for DjangoModelPermissions
+    serializer_class = serializers.BookSerializer2
+    filter_backends = (
+        dfilters.DjangoFilterBackend,
+        filters.SearchFilter,
+    )
+    filterset_class = BookFilter
+    search_fields = [
+        'title',
+    ]
+
+    def get_queryset(self):
+        books = Book.objects.all()
+        books = books.filter(findable=True)
+        books = order_books(books, True)
+
+        return books
+
+
+class BookList11Labs(BookList2):
+    serializer_class = serializers.BookSerializer11Labs
+
+    def get_queryset(self):
+        books = Book.objects.all()
+        books = books.filter(findable=True)
+        books = books.filter(license='')
+        books = order_books(books, True)
+
+        return books
+
+
 @vary_on_auth  # Because of 'liked'.
 class BookDetail(RetrieveAPIView):
     queryset = Book.objects.all()
@@ -186,13 +240,19 @@ class BookDetail(RetrieveAPIView):
     serializer_class = serializers.BookDetailSerializer
 
 
+class BookDetail2(RetrieveAPIView):
+    queryset = Book.objects.all()
+    lookup_field = 'slug'
+    serializer_class = serializers.BookSerializer2
+
+
 @vary_on_auth  # Because of embargo links.
 class EbookList(BookList):
     serializer_class = serializers.EbookSerializer
 
 
 @method_decorator(never_cache, name='dispatch')
-class Preview(ListAPIView):
+class Preview(LegacyListAPIView):
     #queryset = Book.objects.filter(preview=True)
     serializer_class = serializers.BookPreviewSerializer
 
@@ -205,7 +265,7 @@ class Preview(ListAPIView):
 
 
 @vary_on_auth  # Because of 'liked'.
-class FilterBookList(ListAPIView):
+class FilterBookList(LegacyListAPIView):
     serializer_class = serializers.FilterBookListSerializer
 
     def parse_bool(self, s):
@@ -289,7 +349,7 @@ class EpubView(RetrieveAPIView):
         return HttpResponse(self.get_object().get_media('epub'))
 
 
-class TagCategoryView(ListAPIView):
+class TagCategoryView(LegacyListAPIView):
     serializer_class = serializers.TagSerializer
 
     def get_queryset(self):
@@ -305,6 +365,42 @@ class TagCategoryView(ListAPIView):
 
         return tags
 
+class AuthorList(ListAPIView):
+    serializer_class = serializers.AuthorSerializer
+    queryset = Tag.objects.filter(category='author')
+
+class AuthorView(RetrieveAPIView):
+    serializer_class = serializers.AuthorSerializer
+    queryset = Tag.objects.filter(category='author')
+    lookup_field = 'slug'
+
+class EpochList(ListAPIView):
+    serializer_class = serializers.EpochSerializer
+    queryset = Tag.objects.filter(category='epoch')
+
+class EpochView(RetrieveAPIView):
+    serializer_class = serializers.EpochSerializer
+    queryset = Tag.objects.filter(category='epoch')
+    lookup_field = 'slug'
+
+class GenreList(ListAPIView):
+    serializer_class = serializers.GenreSerializer
+    queryset = Tag.objects.filter(category='genre')
+
+class GenreView(RetrieveAPIView):
+    serializer_class = serializers.GenreSerializer
+    queryset = Tag.objects.filter(category='genre')
+    lookup_field = 'slug'
+
+class KindList(ListAPIView):
+    serializer_class = serializers.KindSerializer
+    queryset = Tag.objects.filter(category='kind')
+
+class KindView(RetrieveAPIView):
+    serializer_class = serializers.KindSerializer
+    queryset = Tag.objects.filter(category='kind')
+    lookup_field = 'slug'
+
 
 class TagView(RetrieveAPIView):
     permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
@@ -373,7 +469,7 @@ class TagView(RetrieveAPIView):
 
 
 @vary_on_auth  # Because of 'liked'.
-class FragmentList(ListAPIView):
+class FragmentList(LegacyListAPIView):
     serializer_class = serializers.FragmentSerializer
 
     def get_queryset(self):
@@ -398,3 +494,13 @@ class FragmentView(RetrieveAPIView):
             book__slug=self.kwargs['book'],
             anchor=self.kwargs['anchor']
         )
+
+
+class SuggestedTags(ListAPIView):
+    serializer_class = serializers.FilterTagSerializer
+
+    def get_queryset(self):
+        tag_ids = self.request.GET.getlist('tag', [])
+        tags = [get_object_or_404(Tag, id=tid) for tid in tag_ids]
+        related_tags = list(t.id for t in get_top_level_related_tags(tags))
+        return Tag.objects.filter(id__in=related_tags)
index ebe5cf4..2d35357 100644 (file)
@@ -229,12 +229,10 @@ class XmlField(EbookField):
 class TxtField(EbookField):
     ext = 'txt'
     for_parents = False
-    librarian2_api = True
 
     @staticmethod
     def transform(wldoc, book):
-        from librarian.builders.txt import TxtBuilder
-        return TxtBuilder().build(wldoc)
+        return wldoc.as_text()
 
 
 class Fb2Field(EbookField):
@@ -301,7 +299,6 @@ class MobiField(EbookField):
 class HtmlField(EbookField):
     ext = 'html'
     for_parents = False
-    librarian2_api = True
 
     def build(self, fieldfile):
         from django.core.files.base import ContentFile
@@ -312,7 +309,7 @@ class HtmlField(EbookField):
 
         book = fieldfile.instance
 
-        html_output = self.transform(book.wldocument2(), book)
+        html_output = self.transform(book.wldocument(parse_dublincore=False), book)
 
         # Delete old fragments, create from scratch if necessary.
         book.fragments.all().delete()
@@ -388,15 +385,37 @@ class HtmlField(EbookField):
 
     @staticmethod
     def transform(wldoc, book):
-        from librarian.builders.html import HtmlBuilder
-        url = wldoc.meta.url
-        if not url:
+        # 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))
+
+
+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:
-            gal_url = gallery_url(slug=url.slug)
-            gal_path = gallery_path(slug=url.slug)
-        return HtmlBuilder(gallery_path=gal_path, gallery_url=gal_url, base_url=absolute_url(gal_url)).build(wldoc)
+            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):
diff --git a/src/catalogue/management/commands/load_abstracts.py b/src/catalogue/management/commands/load_abstracts.py
new file mode 100644 (file)
index 0000000..f9fb2c1
--- /dev/null
@@ -0,0 +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 django.core.management.base import BaseCommand
+
+from catalogue.models import Book
+
+
+class Command(BaseCommand):
+    def handle(self, *args, **options):
+        for b in Book.objects.order_by('slug'):
+            print(b.slug)
+            b.load_abstract()
+            b.save()
diff --git a/src/catalogue/management/commands/update_tag_description.py b/src/catalogue/management/commands/update_tag_description.py
new file mode 100644 (file)
index 0000000..0fb2ffd
--- /dev/null
@@ -0,0 +1,20 @@
+# 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.core.management import BaseCommand
+from catalogue.models import Tag
+
+
+class Command(BaseCommand):
+    help = "Update description for given tag."
+
+    def add_arguments(self, parser):
+        parser.add_argument('category')
+        parser.add_argument('slug')
+        parser.add_argument('description_filename')
+
+    def handle(self, category, slug, description_filename, **options):
+        tag = Tag.objects.get(category=category, slug=slug)
+        description = open(description_filename).read().decode('utf-8')
+        tag.description = description
+        tag.save()
diff --git a/src/catalogue/migrations/0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more.py b/src/catalogue/migrations/0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more.py
new file mode 100644 (file)
index 0000000..bd297d4
--- /dev/null
@@ -0,0 +1,30 @@
+# Generated by Django 4.0.8 on 2025-02-26 14:46
+
+import catalogue.fields
+from django.db import migrations, models
+import fnpdjango.storage
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0048_remove_collection_kind_remove_tag_for_books_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='html_nonotes_file',
+            field=catalogue.fields.HtmlNonotesField(etag_field_name='html_nonotes_file_etag', storage=fnpdjango.storage.BofhFileSystemStorage()),
+        ),
+        migrations.AddField(
+            model_name='book',
+            name='html_nonotes_file_etag',
+            field=models.CharField(db_index=True, default='', editable=False, max_length=255),
+        ),
+        migrations.AddField(
+            model_name='book',
+            name='license',
+            field=models.CharField(blank=True, db_index=True, max_length=255, verbose_name='licencja'),
+        ),
+    ]
diff --git a/src/catalogue/migrations/0049_snippet_anchor.py b/src/catalogue/migrations/0049_snippet_anchor.py
deleted file mode 100644 (file)
index c3e11d1..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-# Generated by Django 4.0.8 on 2024-09-17 14:14
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('catalogue', '0048_remove_collection_kind_remove_tag_for_books_and_more'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='snippet',
-            name='anchor',
-            field=models.CharField(default='', max_length=64),
-            preserve_default=False,
-        ),
-    ]
index 7d36662..c2b9eed 100644 (file)
@@ -19,7 +19,7 @@ from django.utils.translation import gettext_lazy as _, get_language
 from fnpdjango.storage import BofhFileSystemStorage
 from lxml import html
 from librarian.cover import WLCover
-from librarian.builders.html import AbstraktHtmlBuilder
+from librarian.html import transform_abstrakt
 from librarian.builders import builders
 from newtagging import managers
 from catalogue import constants
@@ -43,6 +43,7 @@ class Book(models.Model):
     common_slug = models.SlugField('wspólny slug', max_length=120, db_index=True)
     language = models.CharField('kod języka', max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
     description = models.TextField('opis', blank=True)
+    license = models.CharField('licencja', max_length=255, blank=True, db_index=True)
     abstract = models.TextField('abstrakt', blank=True)
     toc = models.TextField('spis treści', blank=True)
     created_at = models.DateTimeField('data utworzenia', auto_now_add=True, db_index=True)
@@ -62,6 +63,7 @@ class Book(models.Model):
     # files generated during publication
     xml_file = fields.XmlField(storage=bofh_storage, with_etag=False)
     html_file = fields.HtmlField(storage=bofh_storage)
+    html_nonotes_file = fields.HtmlNonotesField(storage=bofh_storage)
     fb2_file = fields.Fb2Field(storage=bofh_storage)
     txt_file = fields.TxtField(storage=bofh_storage)
     epub_file = fields.EpubField(storage=bofh_storage)
@@ -79,7 +81,7 @@ class Book(models.Model):
         'okładka dla Ebookpoint')
 
     ebook_formats = constants.EBOOK_FORMATS
-    formats = ebook_formats + ['html', 'xml']
+    formats = ebook_formats + ['html', 'xml', 'html_nonotes']
 
     parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
     ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
@@ -327,10 +329,7 @@ class Book(models.Model):
         return int(total)
 
     def get_time(self):
-        try:
-            return round(self.xml_file.size / 1000 * 40)
-        except ValueError:
-            return 0
+        return round(self.xml_file.size / 1000 * 40)
     
     def has_media(self, type_):
         if type_ in Book.formats:
@@ -378,6 +377,9 @@ class Book(models.Model):
     def html_url(self):
         return self.media_url('html')
 
+    def html_nonotes_url(self):
+        return self.media_url('html_nonotes')
+
     def pdf_url(self):
         return self.media_url('pdf')
 
@@ -557,8 +559,11 @@ class Book(models.Model):
                 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
 
     def load_abstract(self):
-        self.abstract = AbstraktHtmlBuilder().build(
-            self.wldocument2()).get_bytes().decode('utf-8')
+        abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
+        if abstract is not None:
+            self.abstract = transform_abstrakt(abstract)
+        else:
+            self.abstract = ''
 
     def load_toc(self):
         self.toc = ''
@@ -635,6 +640,7 @@ class Book(models.Model):
         book.findable = findable
         book.language = book_info.language
         book.title = book_info.title
+        book.license = book_info.license or ''
         if book_info.variant_of:
             book.common_slug = book_info.variant_of.slug
         else:
@@ -704,6 +710,7 @@ class Book(models.Model):
         for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
             if format_ not in dont_build:
                 getattr(book, '%s_file' % format_).build_delay()
+        book.html_nonotes_file.build_delay()
 
         if not settings.NO_SEARCH_INDEX and search_index and findable:
             tasks.index_book.delay(book.id)
@@ -717,14 +724,13 @@ class Book(models.Model):
         cls.published.send(sender=cls, instance=book)
         return book
 
-    # TODO TEST
     def update_references(self):
         Entity = apps.get_model('references', 'Entity')
         doc = self.wldocument2()
-        doc.assign_ids()
-
+        doc._compat_assign_section_ids()
+        doc._compat_assign_ordered_ids()
         refs = {}
-        for i, ref_elem in enumerate(doc.references()):
+        for ref_elem in doc.references():
             uri = ref_elem.attrib.get('href', '')
             if not uri:
                 continue
@@ -743,8 +749,10 @@ class Book(models.Model):
                 refs[uri] = ref
                 if not ref_created:
                     ref.occurence_set.all().delete()
-            anchor = ref_elem.get_link()
-
+            sec = ref_elem.get_link()
+            m = re.match(r'sec(\d+)', sec)
+            assert m is not None
+            sec = int(m.group(1))
             snippet = ref_elem.get_snippet()
             b = builders['html-snippet']()
             for s in snippet:
@@ -752,8 +760,7 @@ class Book(models.Model):
             html = b.output().get_bytes().decode('utf-8')
 
             ref.occurence_set.create(
-                section=i,
-                anchor=anchor,
+                section=sec,
                 html=html
             )
         self.reference_set.exclude(entity__uri__in=refs).delete()
index ec7e2f9..4c25b8c 100644 (file)
@@ -7,7 +7,6 @@ from search.utils import UnaccentSearchVector
 class Snippet(models.Model):
     book = models.ForeignKey('Book', models.CASCADE)
     sec = models.IntegerField()
-    anchor = models.CharField(max_length=64)
     text = models.TextField()
     search_vector = SearchVectorField()
 
index dbe9259..18e4bcc 100644 (file)
 
 
   <article id="main-text" {% if book.has_sync_file %}class="has-sync"{% endif %}>
-    {% with next=book.get_next_text prev=book.get_prev_text %}
+<div id="sidebar">
+      {% if book.other_versions.exists %}
+        <div class="box" id="other">
+          <h2>{% trans "Inne wersje tekstu" %}</h2>
+          <a class="other-text-close" href="#">{% trans "Zamknij drugą wersję" %}</a>
+          <ul>
+            {% spaceless %}
+              {% for other_version in book.other_versions %}
+                <li>
+                  <a class="display-other"
+                     data-other="{{ other_version.html_url }}"
+                     href="{% url 'book_text' other_version.slug %}">
+                    {{ other_version.mini_box_nolink }}
+                  </a>
+                </li>
+              {% endfor %}
+            {% endspaceless %}
+          </ul>
+        </div>
+      {% endif %}
+</div>
+
+{% with next=book.get_next_text prev=book.get_prev_text %}
       {% if next %}
         <a class="text_next-book" href="{% url 'book_text' next.slug %}">{{ next.title }}&nbsp;&rarr;</a>
       {% endif %}
             <div class="pointer pointer-top"></div>
         </div>
       </div>
+</div>
 
 
-      {% if book.other_versions.exists %}
-        <div class="box" id="other">
-          <h2>{% trans "Inne wersje utworu" %}</h2>
-          <a class="other-text-close" href="#">{% trans "Zamknij drugą wersję" %}</a>
-          <ul>
-            {% spaceless %}
-              {% for other_version in book.other_versions %}
-                <li>
-                  <a class="display-other"
-                     data-other="{{ other_version.html_url }}"
-                     href="{% url 'book_text' other_version.slug %}">
-                    {{ other_version.mini_box_nolink }}
-                  </a>
-                </li>
-              {% endfor %}
-            {% endspaceless %}
-          </ul>
-        </div>
-      {% endif %}
-
       <div id="annoy-stubs">
         {% annoy_banners 'book-text-intermission' %}
 
index d70ad97..d2298d9 100644 (file)
@@ -307,8 +307,6 @@ def plain_list(context, object_list, with_initials=True, by_author=False, choice
 @register.simple_tag
 def related_books_2022(book=None, limit=4, taken=0):
     limit -= taken
-    if limit < 0:
-        return []
     max_books = limit
 
     books_qs = Book.objects.filter(findable=True)
index f5d98ca..a145522 100644 (file)
@@ -42,6 +42,9 @@ class BookInfoStub:
     # allow single definition for multiple-value fields
     _salias = {
         'authors': 'author',
+        'genres': 'genre',
+        'epochs': 'epoch',
+        'kinds': 'kind',
     }
 
     def __init__(self, **kwargs):
index 8e4380e..4425928 100644 (file)
@@ -10,6 +10,7 @@ from django.utils.html import conditional_escape
 from django.utils.safestring import mark_safe
 from fnpdjango.actions import export_as_csv_action
 from modeltranslation.admin import TranslationAdmin
+import annoy.models
 from wolnelektury.utils import YesNoFilter
 from . import models
 
@@ -67,6 +68,12 @@ class ExpiredFilter(YesNoFilter):
     q = Q(expires_at__isnull=False, expires_at__lt=Now())
 
 
+class ActiveFilter(YesNoFilter):
+    title = 'płatność aktualna'
+    parameter_name = 'active'
+    q = Q(expires_at__gt=Now())
+
+
 class ScheduleForm(forms.ModelForm):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -89,14 +96,35 @@ class SourceFilter(admin.SimpleListFilter):
             (m, m) for m in
             model_admin.model.objects.exclude(source='').values_list('source', flat=True).distinct()[:10]
         ]
-        print(lookups)
         return lookups
 
     def queryset(self, request, queryset):
         return queryset
     
-    #field_name = 'source' # name of the foreign key field
 
+class CrisisFilter(admin.SimpleListFilter):
+    title = 'czas zbiórki kryzysowej'
+    parameter_name = 'crisis'
+
+    def lookups(self, request, model_admin):
+        lookups = [
+            (b.id, '%s — %s' % (b.since, b.until)) for b in
+            annoy.models.Banner.objects.filter(place='crisis')
+        ]
+        return lookups
+
+    def queryset(self, request, queryset):
+        bid = self.value()
+        if not bid:
+            return
+        try:
+            b = annoy.models.Banner.objects.get(id=self.value())
+        except annoy.models.Banner.DoesNotExist:
+            return
+        return queryset.filter(
+            started_at__gte=b.since,
+            started_at__lte=b.until
+        )
 
 
 
@@ -111,8 +139,8 @@ class ScheduleAdmin(admin.ModelAdmin):
     search_fields = ['email', 'source']
     list_filter = [
         'is_cancelled', 'monthly', 'yearly', 'method',
-        PayedFilter, ExpiredFilter,
-        SourceFilter,
+        PayedFilter, ActiveFilter, ExpiredFilter,
+        SourceFilter, CrisisFilter
     ]
     filter_horizontal = ['consent']
     date_hierarchy = 'started_at'
index df92524..b75877d 100644 (file)
@@ -31,7 +31,8 @@ class DonationStep1Form(forms.ModelForm):
             'monthly'
             ]
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, referer=None, **kwargs):
+        self.referer = referer
         super().__init__(*args, **kwargs)
         club = models.Club.objects.first()
         if club is not None:
@@ -48,6 +49,9 @@ class DonationStep1Form(forms.ModelForm):
 
         return state
 
+    def save(self, *args, **kwargs):
+        self.instance.source = self.referer
+        return super().save(*args, **kwargs)
 
 
 class DonationStep2Form(forms.ModelForm, NewsletterForm):
@@ -64,13 +68,11 @@ class DonationStep2Form(forms.ModelForm, NewsletterForm):
             'monthly': forms.HiddenInput,
         }
     
-    def __init__(self, referer=None, **kwargs):
-        self.referer = referer
+    def __init__(self, **kwargs):
         super().__init__(**kwargs)
 
         self.fields['first_name'].required = True
         self.fields['last_name'].required = True
-        self.fields['phone'].required = True
         
         self.consent = []
         for c in models.Consent.objects.filter(active=True).order_by('order'):
@@ -83,11 +85,8 @@ class DonationStep2Form(forms.ModelForm, NewsletterForm):
                 c, key, (lambda k: lambda: self[k])(key)
             ))
 
-
-
     def save(self, *args, **kwargs):
         NewsletterForm.save(self, *args, **kwargs)
-        self.instance.source = self.referer or ''
         instance = super().save(*args, **kwargs)
 
         consents = []
index c09769a..c40428d 100644 (file)
@@ -417,7 +417,8 @@ class PayUOrder(payu_models.Order):
         )            
 
     @classmethod
-    def send_receipt(cls, email, year, resend=False):
+    def generate_receipt(cls, email, year):
+        # TODO: abstract out
         Contact = apps.get_model('messaging', 'Contact')
         Funding = apps.get_model('funding', 'Funding')
         BillingAgreement = apps.get_model('paypal', 'BillingAgreement')
@@ -485,11 +486,8 @@ class PayUOrder(payu_models.Order):
         ctx = {
             "email": email,
             "year": year,
-            "next_year": year + 1,
             "total": sum(x['amount'] for x in payments),
             "payments": payments,
-            "optout": optout,
-            "resend": resend,
         }
         temp = tempfile.NamedTemporaryFile(prefix='receipt-', suffix='.pdf', delete=False)
         temp.close()
@@ -497,15 +495,32 @@ class PayUOrder(payu_models.Order):
             "wl.eps": os.path.join(settings.STATIC_ROOT, "img/wl.eps"),
             })
 
+        with open(temp.name, 'rb') as f:
+            content = f.read()
+        os.unlink(f.name)
+        return content, optout, payments
+
+    @classmethod
+    def send_receipt(cls, email, year, resend=False):
+        receipt = cls.generate_receipt(email, year)
+        if receipt:
+            content, optout, payments = receipt
+        ctx = {
+            "email": email,
+            "year": year,
+            "next_year": year + 1,
+            "total": sum(x['amount'] for x in payments),
+            "payments": payments,
+            "optout": optout,
+            "resend": resend,
+        }
         message = EmailMessage(
                 'Odlicz darowiznę na Wolne Lektury od podatku',
                 template.loader.render_to_string('club/receipt_email.txt', ctx),
                 settings.CLUB_CONTACT_EMAIL, [email]
             )
-        with open(temp.name, 'rb') as f:
-            message.attach('wolnelektury-darowizny.pdf', f.read(), 'application/pdf')
+        message.attach('wolnelektury-darowizny.pdf', content, 'application/pdf')
         message.send()
-        os.unlink(f.name)
 
 
 class PayUCardToken(payu_models.CardToken):
index 1b6de08..3d9cd55 100644 (file)
@@ -2,6 +2,7 @@
 {% load club %}
 
 {% block content %}
+<div style="display: flex; gap:50px;">
   <table class="table">
     <tr>
       <td>Aktywne miesięczne wpłaty cykliczne:</td>
       <td>{% club_active_30day_sum %} zł.</td>
     </tr>
   </table>
+  <div>
+    <form method="post" action="{% url 'club_receipt' %}" style="display: flex; flex-direction: column;">
+      {% csrf_token %}
+      <span>Pobierz zestawienie roczne</span>
+      <input name="email" placeholder="email" required></input>
+      <input name="year" type="number" min="2013" max="2024" value="2024" placeholder="rok" required></input>
+      <button>pobierz zestawienie</button>
+    </form>
+  </div>
+  </div>
   {{ block.super }}
 {% endblock content %}
index 623cd0c..3864ad5 100644 (file)
@@ -48,7 +48,7 @@
               {{ form.email.errors }}
             </div>
             <div class="l-checkout__input">
-              <label for="id_phone"><span>*</span> {% trans "Telefon" %}</label>
+              <label for="id_phone">{% trans "Telefon" %}</label>
               {{ form.phone }}
               {{ form.phone.errors }}
             </div>
index 52256fe..cede1b4 100644 (file)
@@ -14,6 +14,8 @@
 
 
   <main class="l-main">
+    {% comment %}
+    <!-- TODO: hide when crisis banner on -->
     <div class="l-checkout__support">
       <div class="l-checkout__support__bar">
         <span data-label="{% club_count_recurring as c %}{% blocktrans %}Jest nas {{ c }}{% endblocktrans %}" style="width: calc({{ c }}% / 5);"></span>
@@ -23,6 +25,7 @@
         <p>{% blocktrans with c=500 %}Potrzebujemy <strong>{{ c }}</strong> regularnych darczyńców, by Wolne Lektury mogły działać!{% endblocktrans %}</p>
       </div>
     </div>
+    {% endcomment %}
 
     <div class="l-checkout__box">
       <div class="l-checkout__box__header">
index 0267580..87bfa5a 100644 (file)
@@ -28,4 +28,6 @@ urlpatterns = [
     path('notify/<int:pk>/', views.PayUNotifyView.as_view(), name='club_payu_notify'),
 
     path('weryfikacja/', views.member_verify, name='club_member_verify'),
+
+    path('potwierdzenie/', views.receipt, name='club_receipt'),
 ]
index 1ab1eae..b2657e6 100644 (file)
@@ -4,7 +4,7 @@
 from django.conf import settings
 from django.contrib.auth.decorators import login_required, permission_required
 from django.db.models import Sum
-from django.http import HttpResponseRedirect
+from django.http import HttpResponse, HttpResponseRedirect
 from django.shortcuts import get_object_or_404, render
 from django.urls import reverse
 from django.utils.decorators import method_decorator
@@ -99,7 +99,7 @@ class JoinView(CreateView):
 
     def get_form_kwargs(self):
         kwargs = super().get_form_kwargs()
-        #kwargs['referer'] = self.request.META.get('HTTP_REFERER', '')
+        kwargs['referer'] = self.request.META.get('HTTP_REFERER', '')
         return kwargs
 
     def form_valid(self, form):
@@ -249,3 +249,26 @@ def member_verify(request):
             'result': rows
         }
     )
+
+
+@permission_required('club.schedule_view')
+def receipt(request):
+    email = request.POST.get('email')
+    try:
+        year = int(request.POST.get('year'))
+    except:
+        return HttpResponse('no content')
+
+    receipt = models.PayUOrder.generate_receipt(email, year)
+    if receipt:
+        content, optout, payments = receipt
+    else:
+        return HttpResponse('no content')
+    return HttpResponse(
+        content,
+        headers={
+            "Content-Type": "application/pdf",
+            "Content-Disposition": f'attachment; filename="wolnelektury-{year}-{email}.pdf"',
+        }
+    )
+
index 2cef90b..59c8b91 100644 (file)
@@ -9,4 +9,5 @@ FN_TYPES = {
     'pe': _("przypisy redaktorów Wolnych Lektur"),
     'pr': _("przypisy źródła"),
     'pt': _("przypisy tłumacza"),
+    'ptrad': _("przypisy tradycyjne"),
 }
index 8e3153a..2e3adb8 100644 (file)
@@ -25,8 +25,8 @@
     {% if author.alive %}
       <p>
         {% trans "Dzieła tego autora objęte są prawem autorskim." %}
-        {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
-          <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+        {% blocktrans trimmed %}
+          Biblioteki internetowe nie mogą
           udostępniać dzieł tego autora.
         {% endblocktrans %}
       </p>
@@ -46,8 +46,8 @@
           </p>
           <div class='countdown' data-until='{{ pd_counter|date_to_utc|utc_for_js }}'></div>
           <p>
-            {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
-              <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+            {% blocktrans trimmed %}
+              Biblioteki internetowe nie mogą
               udostępniać dzieł tego autora.
             {% endblocktrans %}
           </p>
index 655d7f0..02a099d 100644 (file)
               {% endblocktrans %}</p>
               <div class='countdown' data-until='{{ pd_counter|date_to_utc|utc_for_js }}'></div>
               <p>
-                {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
-                  <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+                {% blocktrans trimmed %}
+                  Biblioteki internetowe nie mogą
                   udostępniać tego utworu.
                 {% endblocktrans %}
               </p>
             {% else %}
               <p>
                 {% trans "Ten utwór objęty jest prawem autorskim." %}
-                {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
-                  <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+                {% blocktrans trimmed %}
+                  Biblioteki internetowe nie mogą
                   udostępniać tego utworu.
                 {% endblocktrans %}
               </p>
diff --git a/src/references/migrations/0003_occurence_anchor.py b/src/references/migrations/0003_occurence_anchor.py
deleted file mode 100644 (file)
index 05e60a4..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-# Generated by Django 4.0.8 on 2024-09-18 11:29
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('references', '0002_remove_reference_first_section_occurence'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='occurence',
-            name='anchor',
-            field=models.CharField(default='', max_length=64),
-            preserve_default=False,
-        ),
-    ]
diff --git a/src/references/migrations/0004_update_anchor.py b/src/references/migrations/0004_update_anchor.py
deleted file mode 100644 (file)
index cd97185..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-# Generated by Django 4.0.8 on 2024-09-18 11:29
-
-from django.db import migrations, models
-from django.db.models.functions import Concat
-
-
-def update_anchor(apps, schema_editor):
-    Occurence = apps.get_model('references', 'Occurence')
-    Occurence.objects.filter(anchor='').update(
-        anchor=Concat(models.Value('sec'), 'section')
-    )
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('references', '0003_occurence_anchor'),
-    ]
-
-    operations = [
-        migrations.RunPython(update_anchor, migrations.RunPython.noop)
-    ]
index f83518d..d4733ff 100644 (file)
@@ -84,7 +84,6 @@ class Reference(models.Model):
 class Occurence(models.Model):
     reference = models.ForeignKey(Reference, models.CASCADE)
     section = models.IntegerField()
-    anchor = models.CharField(max_length=64)
     html = models.TextField()
 
     class Meta:
index 3655309..f8977e6 100644 (file)
@@ -28,7 +28,7 @@
     </div>
 
     {% for occ in ref.occurence_set.all %}
-      <a target="_blank" href="/katalog/lektura/{{ ref.book.slug }}.html#{{ occ.anchor }}" class="c-search-result-fragment-text">
+      <a target="_blank" href="/katalog/lektura/{{ ref.book.slug }}.html#sec{{ occ.section }}" class="c-search-result-fragment-text">
         {{ occ.html|safe }}
       </a>
     {% endfor %}
index 3b0edeb..e0a727c 100644 (file)
@@ -2,8 +2,7 @@
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
 import re
-from librarian.elements.base import WLElement
-from librarian.document import WLDocument
+from librarian.parser import WLDocument
 from lxml import etree
 
 
@@ -32,15 +31,22 @@ class Index:
     skip_header_tags = ['autor_utworu', 'nazwa_utworu', 'dzielo_nadrzedne',
                         '{http://www.w3.org/1999/02/22-rdf-syntax-ns#}RDF']
 
+    @classmethod
+    def get_master(cls, root):
+        """
+        Returns the first master tag from an etree.
+        """
+        for master in root.iter():
+            if master.tag in cls.master_tags:
+                return master
+
     @staticmethod
-    def add_snippet(book, text, position, anchor):
+    def add_snippet(book, text, position):
         book.snippet_set.create(
             sec=position + 1,
-            text=text,
-            anchor=anchor
+            text=text
         )
 
-    # TODO: The section links stuff won't work.
     @classmethod
     def index_book(cls, book):
         """
@@ -51,23 +57,13 @@ class Index:
 
         book.snippet_set.all().delete()
 
-        wld = WLDocument(filename=book.xml_file.path)
-        wld.assign_ids()
+        wld = WLDocument.from_file(book.xml_file.path, parse_dublincore=False)
+        root = wld.edoc.getroot()
 
-        master = wld.tree.getroot().master
+        master = cls.get_master(root)
         if master is None:
             return []
 
-        def get_indexable(element):
-            for child in element:
-                if not isinstance(child, WLElement):
-                    continue
-                if not child.attrib.get('_id'):
-                    for e in get_indexable(child):
-                        yield e
-                else:
-                    yield child
-
         def walker(node):
             if node.tag not in cls.ignore_content_tags:
                 yield node, None, None
@@ -89,14 +85,12 @@ class Index:
 
             return re.sub("(?m)/$", "", text)
 
-        for position, header in enumerate(get_indexable(master)):
+        for position, header in enumerate(master):
             if header.tag in cls.skip_header_tags:
                 continue
             if header.tag is etree.Comment:
                 continue
 
-            el_id = header.attrib['_id']
-
             # section content
             content = []
             footnote = []
@@ -116,7 +110,7 @@ class Index:
                     handle_text.append(collect_footnote)
                 elif end is not None and footnote is not [] and end.tag in cls.footnote_tags:
                     handle_text.pop()
-                    cls.add_snippet(book, ''.join(footnote), position, el_id)
+                    cls.add_snippet(book, ''.join(footnote), position)
                     footnote = []
 
                 if text is not None and handle_text is not []:
@@ -124,4 +118,4 @@ class Index:
                     hdl(text)
 
             # in the end, add a section text.
-            cls.add_snippet(book, fix_format(content), position, el_id)
+            cls.add_snippet(book, fix_format(content), position)
index 2902f26..c71cf45 100644 (file)
               {{ book.title }}
             </a>
             {% for f in snippets %}
-              <a class="c-search-result-fragment-text" href='{% url 'book_text' f.book.slug %}#{{ f.anchor }}'>
+              <a class="c-search-result-fragment-text" href='{% url 'book_text' f.book.slug %}#sec{{ f.sec }}'>
                 {{ f.headline|safe }}
               </a>
             {% endfor %}
index c5506ce..a299304 100644 (file)
@@ -36,6 +36,7 @@ class LikeView(APIView):
 class ShelfView(ListAPIView):
     permission_classes = [IsAuthenticated]
     serializer_class = BookSerializer
+    pagination_class = None
 
     def get_queryset(self):
         state = self.kwargs['state']
diff --git a/src/social/migrations/0017_userconfirmation.py b/src/social/migrations/0017_userconfirmation.py
new file mode 100644 (file)
index 0000000..fe39336
--- /dev/null
@@ -0,0 +1,25 @@
+# Generated by Django 4.0.8 on 2025-02-24 15:19
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('social', '0016_alter_bannergroup_options_alter_carousel_options_and_more'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='UserConfirmation',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('key', models.CharField(max_length=128, unique=True)),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]
index cb1326b..17fe7d0 100644 (file)
@@ -1,10 +1,13 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
+from oauthlib.common import urlencode, generate_token
 from random import randint
 from django.db import models
 from django.conf import settings
+from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
+from django.core.mail import send_mail
 from django.urls import reverse
 from catalogue.models import Book
 from wolnelektury.utils import cached_render, clear_cached_renders
@@ -170,3 +173,30 @@ class CarouselItem(models.Model):
 
     def get_banner(self):
         return self.banner or self.banner_group.get_banner()
+
+
+class UserConfirmation(models.Model):
+    user = models.ForeignKey(User, models.CASCADE)
+    created_at = models.DateTimeField(auto_now_add=True)
+    key = models.CharField(max_length=128, unique=True)
+
+    def send(self):
+        send_mail(
+            'Potwierdź konto w bibliotece Wolne Lektury',
+            f'https://beta.wolnelektury.pl/ludzie/potwierdz/{self.key}/',
+            settings.CONTACT_EMAIL,
+            [self.user.email]
+        )
+
+    def use(self):
+        user = self.user
+        user.is_active = True
+        user.save()
+        self.delete()
+    
+    @classmethod
+    def request(cls, user):
+        cls.objects.create(
+            user=user,
+            key=generate_token()
+        ).send()
diff --git a/src/social/templates/social/user_confirmation.html b/src/social/templates/social/user_confirmation.html
new file mode 100644 (file)
index 0000000..f56d2f2
--- /dev/null
@@ -0,0 +1,12 @@
+{% extends "base_simple.html" %}
+{% load i18n %}
+
+
+{% block body %}
+  <h1>{% trans "Konto potwierdzone" %}</h1>
+
+  <p class="normal-text">
+    {% blocktrans with user=user %}Konto <strong>{{ user }}</strong> zostało potwierdzone. Możesz się teraz zalogować.{% endblocktrans %}
+  </p>
+{% endblock body %}
+
index 299f620..10d9240 100644 (file)
@@ -7,6 +7,7 @@ from . import views
 
 
 urlpatterns = [
+    path('potwierdz/<str:key>/', views.confirm_user, name='social_confirm_user'),
     path('lektura/<slug:slug>/lubie/', views.like_book, name='social_like_book'),
     path('dodaj-tag/', views.AddSetView.as_view(), name='social_add_set_tag'),
     path('usun-tag/', views.RemoveSetView.as_view(), name='social_remove_set_tag'),
index 989771a..3dfcd9e 100644 (file)
@@ -10,7 +10,7 @@ from django.views.generic.edit import FormView
 
 from catalogue.models import Book, Tag
 import catalogue.models.tag
-from social import forms
+from social import forms, models
 from wolnelektury.utils import is_ajax
 
 
@@ -125,3 +125,12 @@ def my_tags(request):
             t.name for t in tags
         ], safe=False
     )
+
+
+def confirm_user(request, key):
+    uc = get_object_or_404(models.UserConfirmation, key=key)
+    user = uc.user
+    uc.use()
+    return render(request, 'social/user_confirmation.html', {
+        'user': user,
+    })
index d4faa8b..b9d507e 100644 (file)
@@ -59,6 +59,7 @@ INSTALLED_APPS_CONTRIB = [
     'django.contrib.postgres',
     'admin_ordering',
     'rest_framework',
+    'django_filters',
     'fnp_django_pagination',
     'pipeline',
     'sorl.thumbnail',
index d03479f..e5b2f60 100644 (file)
@@ -28,9 +28,13 @@ REST_FRAMEWORK = {
         'api.renderers.LegacyXMLRenderer',
     ),
     'DEFAULT_AUTHENTICATION_CLASSES': (
+        'api.drf_auth.WLTokenAuthentication',
         'api.drf_auth.PistonOAuthAuthentication',
         'rest_framework.authentication.SessionAuthentication',
-    )
+    ),
+    'DEFAULT_PAGINATION_CLASS': 'api.pagination.WLLimitOffsetPagination',
+    'PAGE_SIZE': 10,
+    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
 }
 
 
index 16b5e0a..d673772 100644 (file)
@@ -76,3 +76,5 @@ SEARCH_CONFIG_SIMPLE = 'simple'
 SEARCH_USE_UNACCENT = False
 
 FEATURE_SYNCHRO = False
+
+FEATURE_API_REGISTER = False
index f0f0636..70704f3 100644 (file)
@@ -51,7 +51,7 @@
     .annoy-banner-off {
         font-size: 1.2rem !important;
     }
-    
+
     @include rwd($break-flow) {
         .annoy-banner-inner {
             padding: 0;
 
         }
         .annoy-banner-off {
-            
+
         }
     }
 }
+
+
+.annoy-banner_crisis-container,
+.annoy-banner_top-container {
+    position: sticky;
+    top: 0;
+    z-index: 10;
+    box-shadow: 0 0 10px black;
+    display: flex;
+    background: #c32721;
+    color: black;
+    align-items:center;
+    cursor: pointer;
+
+    &.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 (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_top {
+       position: sticky;
+       top: 0;
+       width: 100%;
+
+       .annoy-banner-inner {
+           max-width: 1172px;
+           margin: auto;
+           padding-right: 16px;
+           padding-left: 16px;
+
+           display: flex;
+           gap: 20px;
+           align-items: flex-start;
+
+           .image-box {
+               position: relative;
+               img {
+                   max-height: 159px;
+                   display: block;
+
+                   @media screen and (max-width: 700px) {
+                       max-height: 120px;
+                   }
+               }
+           }
+
+           .text-box {
+               flex-grow: 1;
+               display: flex;
+               flex-direction: column;
+               gap: 10px;
+
+               @media screen and (max-width: 700px) {
+                   p {
+                       font-size: .9em;
+                   }
+               }
+
+               @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;
+               }
+               .state-box {
+                   display: flex;
+                   gap: 10px;
+                   align-items: center;
+                   @media screen and (max-width: 700px) {
+                       flex-direction: column;
+                       align-items: stretch;
+                       text-align: center;
+                   }
+                   .progress-box {
+                       flex-grow: 1;
+
+                       .l-checkout__support__bar span::after {
+                           color: black;
+                       }
+                   }
+                   .time-box {
+                       width: 110px;
+                       margin: auto;
+                   }
+                   .countdown {
+                       margin: 0;
+                   }
+               }
+           }
+
+           p {
+               margin: 0;
+           }
+
+           a.action {
+               background: #edc016;
+               color: black;
+               padding: .8em 1em;
+               border: 3px solid black;
+               border-radius: 10px;
+               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_top-container {
+       background: black;
+       color: white;
+       .annoy-banner-inner {
+           align-items: center;
+
+           .text-box {
+               .text {
+                   background: none;
+                   padding: 0;
+                   border: none;
+                   a {
+                       color: #ffd430;
+                   }
+               }
+           }
+       }
+    }
+}
index d744968..1fde2b9 100644 (file)
@@ -293,7 +293,8 @@ h2 {
 
 h3, .subtitle2 {
     font-size: 1.5em;
-    margin: 1.5em 0 0;
+    margin: 1.5em 0 1em 0;
+    padding-right: 48px;
     font-weight: normal;
     line-height: 1.5em;
 }
@@ -471,6 +472,10 @@ p.paragraph {
     margin: 0;
     text-indent: 1.5em;
 }
+p.paragraph.werset {
+    text-indent: 0;
+    margin-bottom: 1em;
+}
 
 .motto {
     text-align: justify;
@@ -478,6 +483,17 @@ p.paragraph {
     margin: 1.5em 0 0;
 }
 
+.paragraph {
+    &.miejsce_data, &.pozdrowienie, &.podpis {
+       text-align: right;
+    }
+    &.naglowek_listu {
+       text-align: center;
+       margin: .5em 0;
+    }
+}
+
+
 p.motto_podpis {
     font-size: 0.875em;
     text-align: right;
@@ -530,6 +546,9 @@ p.place-and-time {
 em.math, em.foreign-word, em.book-title, em.didaskalia {
     font-style: italic;
 }
+em.foreign-word.foreign-word-protected {
+    font-style: normal;
+}
 
 em.author-emphasis {
     letter-spacing: 0.1em;
@@ -719,7 +738,11 @@ a.reference.interesting:after {
             font-size: 18px;
             font-style: normal;
             font-weight: 400;
-            line-height: 28px; /* 155.556% */ 
+            line-height: 28px; /* 155.556% */
+
+           em.foreign-word.foreign-word-protected {
+               font-style: normal;
+           }
         }
 
         #footnote-link {
@@ -1022,3 +1045,97 @@ background: #fff;
         }
     }
 }
+
+
+
+#sidebar {
+    position: absolute;
+    left: 0;
+    top: 20px;
+    width: 200px;
+
+    h2 {
+       font-size: 20px;
+       margin-bottom: 1em;
+    }
+    
+    .other-text-close {
+       display: none;
+    }
+
+    #other {
+       ul {
+           list-style: none;
+           margin: 0;
+           padding: 0;
+
+           .book-mini-box {
+               position: relative;
+               width: 137px;
+               .language {
+                   position: absolute;
+                   top: 163px;
+                   left: 10px;
+                   color: white;
+                   background: black;
+                   padding: 0 10px;
+                   border-radius: 10px;
+                   font-size: 14px;
+                   line-height: 20px;
+                   
+               }
+               .author, .title {
+                   display: block;
+                   font-size: 14px;
+                   line-height: 18px;
+               }
+           }
+       }
+    }
+}
+.with-other-text {
+    #sidebar {
+       .other-text-close {
+           display: block;
+           background: red;
+           padding: 10px;
+           margin: 10px 0;
+           border-radius: 10px;
+           color: white;
+           text-decoration: none;
+       }
+    }
+    #main-text {
+       #book-text {
+           .other {
+               overflow: hidden;
+               margin: 10px 40px 10px 50px;
+               padding: 10px 20px 0 10px;
+               background: #eee;
+               border-left: 10px double #ddd;
+
+               h3 {
+                   margin: 0;
+                   padding: 0;
+               }
+
+               .paragraph {
+                   padding: 0;
+               }
+           }
+       }
+    }
+}
+
+:lang(pl),
+:lang(en),
+:lang(de),
+:lang(fr),
+:lang(lt),
+:lang(uk)
+{
+    direction: ltr;
+}
+:lang(he) {
+    direction: rtl;
+}
index eff64a0..dde96ed 100644 (file)
@@ -94,33 +94,85 @@ $("#menu a").each(function() {
 $("#menu-other").show();
 
 
+    function insertOtherText(text) {
+       let tree = $(text);
+       let lang = tree.attr('lang') || 'pl';
+       
+       // toc?
+       // themes?
+
+       let cursor = $(".main-text-body #book-text").children().first();
+       // wstawiamy przed kursorem
+       lastTarget = '';
+       tree.children().each((i, e) => {
+           let $e = $(e);
+
+           if ($e.hasClass('anchor')) return;
+           if ($e.hasClass('numeracja')) return;
+           if ($e.attr('id') == 'toc') return;
+           if ($e.attr('id') == 'nota_red') return;
+           if ($e.attr('id') == 'themes') return;
+           if ($e.attr('name') && $e.attr('name').startsWith('sec')) return;
+           
+           if ($e.hasClass('target')) {
+               let target = $e.attr('name');
+
+               while (lastTarget != target) {
+                   let nc = cursor.next();
+                   if (!nc.length) {
+                       break;
+                   }
+                   cursor = nc;
+                   lastTarget = cursor.attr('name');
+               }
+
+               while (true) {
+                   let nc = cursor.next();
+                   if (!nc.length) {
+                       break;
+                   }
+                   cursor = nc;
+                   lastTarget = cursor.attr('name');
+                   if (lastTarget) break;
+               }
+               
+           } else {
+               let d = $('<div class="other">');
+               d.attr('lang', lang);
+               d.append(e);
+               d.insertBefore(cursor);
+           }
+       });
+    }
+    
 /* Load other version of text. */
 $(".display-other").click(function(e) {
     e.preventDefault();
     release_menu();
 
-    $("#other-text").show();
+    $(".other").remove();
     $("body").addClass('with-other-text');
 
     $.ajax($(this).attr('data-other'), {
         success: function(text) {
-            $("#other-text-body").html(text);
+           insertOtherText(text);
             $("#other-text-waiter").hide();
-            $("#other-text-body").show();
-            loaded_text($("#other-text-body"));
+            loaded_text($(".other"));
         }
     });
     _paq.push(['trackEvent', 'html', 'other-text']);
 });
 
 
+
+    
+
 /* Remove other version of text. */
 $(".other-text-close").click(function(e) {
     release_menu();
     e.preventDefault();
-    $("#other-text").hide();
+    $(".other").remove();
     $("body").removeClass('with-other-text');
-    $("#other-text-body").html("");
     _paq.push(['trackEvent', 'html', 'other-text-close']);
 });
 
index 1a284a0..fa4a35a 100644 (file)
 
     $('a.anchor').on('click', function(e) {
         // Workaround for bad TOC markers.
-        if (($this).closest('#toc').length) return;
+        if ($(this).closest('#toc').length) return;
+        if ($(this).closest('#wltoc').length) return;
         e.preventDefault();
 
         let sel = window.getSelection();
         let range = document.createRange();
 
         let $p = $(this).nextAll('.paragraph').first()
-        range.selectNode($p[0]);
+        range.selectNodeContents($p[0]);
         sel.addRange(range);
         
         qbox.showForSelection(sel);
index acc38a9..2feb7b8 100644 (file)
         $(".c-media__settings").toggleClass('active');
     });
 
+    const crisis = document.querySelector(".annoy-banner_crisis-container");
+    const crisisLink = document.querySelector('.annoy-banner_crisis-container a.action');
+    if (crisis) {
+       crisis.addEventListener("click", function() {
+           crisisLink.click();
+       });
+    }
+
 })();
index e7a29b8..3cc4851 100644 (file)
@@ -6,6 +6,9 @@
 {% load latest_blog_posts from blog %}
 {% load preview_ad from catalogue_tags %}
 
+{% annoy_banner_crisis %}
+{% annoy_banner_top %}
+
 {% annoy_banner_blackout %}
 
 <nav class="l-navigation">