Merge branch 'master' into appdev
authorRadek Czajka <rczajka@rczajka.pl>
Wed, 8 Oct 2025 12:43:07 +0000 (14:43 +0200)
committerRadek Czajka <rczajka@rczajka.pl>
Wed, 8 Oct 2025 12:43:07 +0000 (14:43 +0200)
37 files changed:
Dockerfile [new file with mode: 0644]
Makefile
docker-compose.yml [new file with mode: 0644]
manage [new file with mode: 0755]
requirements/requirements.txt
src/catalogue/api/serializers.py
src/catalogue/api/urls2.py
src/catalogue/api/views.py
src/catalogue/migrations/0050_book_narrators.py [new file with mode: 0644]
src/catalogue/migrations/0051_book_has_audio.py [new file with mode: 0644]
src/catalogue/models/book.py
src/catalogue/models/bookmedia.py
src/catalogue/static/book/filter.js
src/catalogue/static/player/player.js
src/catalogue/templates/catalogue/book_box.html
src/catalogue/templates/catalogue/book_detail.html
src/catalogue/templates/catalogue/book_list.html
src/catalogue/templates/catalogue/snippets/jplayer.html
src/catalogue/templates/catalogue/snippets/jplayer_link.html [deleted file]
src/catalogue/views.py
src/club/civicrm.py
src/references/models.py
src/social/views.py
src/wolnelektury/settings/basic.py
src/wolnelektury/settings/contrib.py
src/wolnelektury/settings/custom.py
src/wolnelektury/static/2022/styles/components/_media.scss
src/wolnelektury/static/2022/styles/components/_player.scss
src/wolnelektury/static/2022/styles/layout/_button.scss
src/wolnelektury/static/2022/styles/layout/_text.scss
src/wolnelektury/static/2022/styles/reader_player.scss
src/wolnelektury/static/img/elevenreader-21.png [new file with mode: 0644]
src/wolnelektury/static/img/elevenreader.png [new file with mode: 0644]
src/wolnelektury/static/js/book_text/progress.js
src/wolnelektury/static/js/book_text/references.js
src/wolnelektury/static/js/main.js
src/wolnelektury/templates/admin/catalogue/book/change_list.html

diff --git a/Dockerfile b/Dockerfile
new file mode 100644 (file)
index 0000000..032024b
--- /dev/null
@@ -0,0 +1,57 @@
+FROM python:3.8 AS base
+
+ARG UID=1000
+ARG GID=1000
+
+RUN     apt-get update && apt-get install -y \
+       git \
+       calibre \
+       texlive-xetex texlive-lang-polish \
+       libespeak-dev
+
+COPY requirements/requirements.txt requirements.txt
+
+# numpy -> aeneas
+RUN pip install numpy
+RUN pip install aeneas
+
+RUN pip install --no-cache-dir -r requirements.txt
+RUN pip install --no-cache-dir \
+    psycopg2-binary \
+    django-debug-toolbar==3.2.2 \
+    python-bidi
+
+RUN addgroup --gid $GID app
+RUN adduser --gid $GID --home /app --uid $UID app
+
+RUN     apt-get install -y \
+       texlive-extra-utils \
+       texlive-lang-greek \
+       texlive-lang-other \
+       texlive-luatex \
+       texlive-fonts-extra \
+       texlive-fonts-extra-links \
+       fonts-noto-core fonts-noto-extra
+
+
+USER app
+
+# fonts
+RUN cp -a /usr/local/lib/python*/site-packages/librarian/fonts /app/.fonts
+RUN fc-cache
+
+WORKDIR /app/src
+
+
+FROM base AS dev
+
+#RUN pip install --no-cache-dir coverage
+USER app
+
+
+FROM base AS prod
+
+RUN pip install --no-cache-dir gunicorn
+
+USER app
+COPY src /app/src
index d43ff1e..77d345d 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,8 @@
-.PHONY: deploy test
+.PHONY: deploy test shell
+
+
+UID != id -u
+GID != id -g
 
 
 deploy: src/wolnelektury/localsettings.py
@@ -17,3 +21,11 @@ test:
        mv ../htmlcov.new ../htmlcov
        coverage report
        rm .coverage
+
+
+shell:
+       UID=$(UID) GID=$(GID) docker-compose run --rm dev bash
+
+
+build:
+       UID=$(UID) GID=$(GID) docker-compose build dev
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644 (file)
index 0000000..89b2216
--- /dev/null
@@ -0,0 +1,22 @@
+services:
+  dev:
+    build:
+      context: .
+      target: dev
+      args:
+        - "UID=${UID}"
+        - "GID=${GID}"
+    volumes:
+      - ./src:/app/src
+      - ./var/media:/app/var/media
+      - ./var/static:/app/var/static
+      - ./var/counters/:/app/var/counters
+    depends_on:
+      - db
+  db:
+    image: postgres
+    container_name: db
+    env_file:
+      - .env
+    volumes:
+      - ./var/postgresql-data/:/var/lib/postgresql/data/
diff --git a/manage b/manage
new file mode 100755 (executable)
index 0000000..1077a03
--- /dev/null
+++ b/manage
@@ -0,0 +1,14 @@
+#!/bin/sh
+export UID=`id -u`
+export GID=`id -g`
+
+if [ "$1" = "runserver" ]
+then
+    PORT="$2"
+    [ -z "$PORT" ] && PORT=8000
+    EXPOSED=127.0.0.1:"$PORT"
+    echo "expose as: $EXPOSED"
+    exec docker-compose run --rm -p "$EXPOSED":"$PORT" dev python $PYARGS manage.py runserver 0.0.0.0:"$PORT"
+else
+    exec docker-compose run --rm dev python $PYARGS manage.py "$@"
+fi
index 084f0da..0559a74 100644 (file)
@@ -40,7 +40,7 @@ mutagen==1.47
 sorl-thumbnail==12.10.0
 
 # home-brewed & dependencies
-librarian==24.5.8
+librarian==24.5.10
 
 # celery tasks
 celery[redis]==5.4.0
index 9c70a9f..609fa74 100644 (file)
@@ -261,6 +261,7 @@ class MediaSerializer2(MediaSerializer):
     def get_size(self, obj):
         return obj.file.size
 
+
 class BookDetailSerializer(LegacyMixin, serializers.ModelSerializer):
     url = AbsoluteURLField()
 
index 29d4a3a..ad96d7a 100644 (file)
@@ -23,7 +23,9 @@ urlpatterns = [
          piwik_track_view(views.BookFragmentView.as_view()),
          name='catalogue_api_book_fragment'
          ),
-    path('books/<slug:slug>/media/<slug:type>/', views.BookMediaView.as_view()),
+    path('books/<slug:slug>/media/<slug:type>/',
+         views.BookMediaView.as_view()
+         ),
     path('books/<slug:slug>.json',
         views.BookJsonView.as_view()),
 
index 198f87f..ed5d10f 100644 (file)
@@ -537,7 +537,10 @@ class BookMediaView(ListAPIView):
     pagination_class = None
 
     def get_queryset(self):
-        return BookMedia.objects.filter(book__slug=self.kwargs['slug'], type=self.kwargs['type']).order_by('index')
+        return BookMedia.objects.filter(
+            book__slug=self.kwargs['slug'],
+            type=self.kwargs['type']
+        ).order_by('index')
 
 
 from .tojson import conv
@@ -548,4 +551,3 @@ class BookJsonView(APIView):
         book = get_object_or_404(Book, slug=slug)
         js = conv(etree.parse(book.xml_file.path))
         return JsonResponse(js, json_dumps_params={'ensure_ascii': False})
-
diff --git a/src/catalogue/migrations/0050_book_narrators.py b/src/catalogue/migrations/0050_book_narrators.py
new file mode 100644 (file)
index 0000000..388f150
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2025-08-12 09:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='narrators',
+            field=models.ManyToManyField(blank=True, related_name='narrated', to='catalogue.tag'),
+        ),
+    ]
diff --git a/src/catalogue/migrations/0051_book_has_audio.py b/src/catalogue/migrations/0051_book_has_audio.py
new file mode 100644 (file)
index 0000000..e333bff
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2025-08-12 10:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0050_book_narrators'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='has_audio',
+            field=models.BooleanField(default=False),
+        ),
+    ]
index 0f53baf..b2148e6 100644 (file)
@@ -7,6 +7,8 @@ from datetime import date, timedelta
 from random import randint
 import os.path
 import re
+from slugify import slugify
+from sortify import sortify
 from urllib.request import urlretrieve
 from django.apps import apps
 from django.conf import settings
@@ -94,6 +96,8 @@ class Book(models.Model):
     tags = managers.TagDescriptor(Tag)
     tag_relations = GenericRelation(Tag.intermediary_table_model, related_query_name='tagged_book')
     translators = models.ManyToManyField(Tag, blank=True)
+    narrators = models.ManyToManyField(Tag, blank=True, related_name='narrated')
+    has_audio = models.BooleanField(default=False)
 
     html_built = django.dispatch.Signal()
     published = django.dispatch.Signal()
@@ -266,17 +270,6 @@ class Book(models.Model):
             return sibling.get_first_text()
         return self.parent.get_next_text(inside=False)
 
-    def get_child_audiobook(self):
-        BookMedia = apps.get_model('catalogue', 'BookMedia')
-        if not BookMedia.objects.filter(book__ancestor=self).exists():
-            return None
-        for child in self.children.order_by('parent_number').all():
-            if child.has_mp3_file():
-                return child
-            child_sub = child.get_child_audiobook()
-            if child_sub is not None:
-                return child_sub
-
     def get_siblings(self):
         if not self.parent:
             return []
@@ -337,9 +330,6 @@ class Book(models.Model):
         else:
             return self.media.filter(type=type_).exists()
 
-    def has_audio(self):
-        return self.has_media('mp3')
-
     def get_media(self, type_):
         if self.has_media(type_):
             if type_ in Book.formats:
@@ -421,6 +411,67 @@ class Book(models.Model):
     def has_sync_file(self):
         return settings.FEATURE_SYNCHRO and self.has_media("sync")
 
+    def build_sync_file(self):
+        from lxml import html
+        from django.core.files.base import ContentFile
+        with self.html_file.open('rb') as f:
+            h = html.fragment_fromstring(f.read().decode('utf-8'))
+
+        durations = [
+            m['mp3'].duration
+            for m in self.get_audiobooks()[0]
+        ]
+        if settings.MOCK_DURATIONS:
+            durations = settings.MOCK_DURATIONS
+
+        sync = []
+        ts = None
+        sid = 1
+        dirty = False
+        for elem in h.iter():
+            if elem.get('data-audio-ts'):
+                part, ts = int(elem.get('data-audio-part')), float(elem.get('data-audio-ts'))
+                ts = str(round(sum(durations[:part - 1]) + ts, 3))
+                # check if inside verse
+                p = elem.getparent()
+                while p is not None:
+                    # Workaround for missing ids.
+                    if 'verse' in p.get('class', ''):
+                        if not p.get('id'):
+                            p.set('id', f'syn{sid}')
+                            dirty = True
+                            sid += 1
+                        sync.append((ts, p.get('id')))
+                        ts = None
+                        break
+                    p = p.getparent()
+            elif ts:
+                cls = elem.get('class', '')
+                # Workaround for missing ids.
+                if 'paragraph' in cls or 'verse' in cls or elem.tag in ('h1', 'h2', 'h3', 'h4'):
+                    if not elem.get('id'):
+                        elem.set('id', f'syn{sid}')
+                        dirty = True
+                        sid += 1
+                    sync.append((ts, elem.get('id')))
+                    ts = None
+        if dirty:
+            htext = html.tostring(h, encoding='utf-8')
+            with open(self.html_file.path, 'wb') as f:
+                f.write(htext)
+        try:
+            bm = self.media.get(type='sync')
+        except:
+            bm = BookMedia(book=self, type='sync')
+        sync = (
+            '27\n' + '\n'.join(
+                f'{s[0]}\t{sync[i+1][0]}\t{s[1]}' for i, s in enumerate(sync[:-1])
+            )).encode('latin1')
+        bm.file.save(
+            None, ContentFile(sync)
+            )
+
+    
     def get_sync(self):
         with self.get_media('sync').first().file.open('r') as f:
             sync = f.read().split('\n')
@@ -444,7 +495,7 @@ class Book(models.Model):
     def media_audio_epub(self):
         return self.get_media('audio.epub')
 
-    def get_audiobooks(self):
+    def get_audiobooks(self, with_children=False, processing=False):
         ogg_files = {}
         for m in self.media.filter(type='ogg').order_by().iterator():
             ogg_files[m.name] = m
@@ -470,13 +521,27 @@ class Book(models.Model):
                 media['ogg'] = ogg
             audiobooks.append(media)
 
-        projects = sorted(projects)
-        total_duration = '%d:%02d' % (
-            total_duration // 60,
-            total_duration % 60
-        )
+        if with_children:
+            for child in self.get_children():
+                ch_audiobooks, ch_projects, ch_duration = child.get_audiobooks(
+                    with_children=True, processing=True)
+                audiobooks.append({'part': child})
+                audiobooks += ch_audiobooks
+                projects.update(ch_projects)
+                total_duration += ch_duration
+
+        if not processing:
+            projects = sorted(projects)
+            total_duration = '%d:%02d' % (
+                total_duration // 60,
+                total_duration % 60
+            )
+
         return audiobooks, projects, total_duration
 
+    def get_audiobooks_with_children(self):
+        return self.get_audiobooks(with_children=True)
+    
     def wldocument(self, parse_dublincore=True, inherit=True):
         from catalogue.import_utils import ORMDocProvider
         from librarian.parser import WLDocument
@@ -769,6 +834,42 @@ class Book(models.Model):
     def references(self):
         return self.reference_set.all().select_related('entity')
 
+    def update_has_audio(self):
+        self.has_audio = False
+        if self.media.filter(type='mp3').exists():
+            self.has_audio = True
+        if self.descendant.filter(has_audio=True).exists():
+            self.has_audio = True
+        self.save(update_fields=['has_audio'])
+        if self.parent is not None:
+            self.parent.update_has_audio()
+
+    def update_narrators(self):
+        narrator_names = set()
+        for bm in self.media.filter(type='mp3'):
+            narrator_names.update(set(
+                a.strip() for a in re.split(r',|\si\s', bm.artist)
+            ))
+        narrators = []
+
+        for name in narrator_names:
+            if not name: continue
+            slug = slugify(name)
+            try:
+                t = Tag.objects.get(category='author', slug=slug)
+            except Tag.DoesNotExist:
+                sort_key = sortify(
+                    ' '.join(name.rsplit(' ', 1)[::-1]).lower()
+                )
+                t = Tag.objects.create(
+                    category='author',
+                    name_pl=name,
+                    slug=slug,
+                    sort_key=sort_key,
+                )
+            narrators.append(t)
+        self.narrators.set(narrators)
+
     @classmethod
     @transaction.atomic
     def repopulate_ancestors(cls):
index fd0dd56..a5df657 100644 (file)
@@ -70,6 +70,8 @@ class BookMedia(models.Model):
         return f'{name}.{ext}'
 
     def save(self, parts_count=None, *args, **kwargs):
+        if self.type in ('daisy', 'audio.epub'):
+            return super().save(*args, **kwargs)
         from catalogue.utils import ExistingFile, remove_zip
 
         if not parts_count:
@@ -99,7 +101,9 @@ class BookMedia(models.Model):
         self.extra_info = json.dumps(extra_info)
         self.source_sha1 = self.read_source_sha1(self.file.path, self.type)
         self.duration = self.read_duration()
-        return super(BookMedia, self).save(*args, **kwargs)
+        super(BookMedia, self).save(*args, **kwargs)
+        self.book.update_narrators()
+        self.book.update_has_audio()
 
     def read_duration(self):
         try:
index 2ff4262..37613bf 100644 (file)
     }
 
     $("#paginator").on('click', 'a', function() {
-        get_page_by_url(url=$(this).attr('href'));
+        get_page_by_url(url=$(this).attr('href'), () => {
+            $("html").animate({
+                scrollTop: $("#book-list").offset().top - 50
+            });
+        });
         return false;
     });
 
index cbf9026..4857087 100644 (file)
                 $(".c-media__caption .license", $root).html($(".license", elem).html());
                 $(".c-media__caption .project-logo", $root).html($(".project-icon", elem).html());
 
-                console.log('sm 1');
                 doesUpdateSynchro = false;
                 if (!$currentMedia || $currentMedia[0] != elem[0]) {
-                    console.log('set', player.jPlayer("setMedia", media))
+                    player.jPlayer("setMedia", media);
                     player.jPlayer("option", "playbackRate", speed);
                 }
                 doesUpdateSynchro = true;
                 player.jPlayer(cmd, time);
 
                 $currentMedia = elem;
-                $(".play-next", $root).prop("disabled", !elem.next().length);
+                $(".play-next", $root).prop("disabled", !elem.nextAll('li').length);
 
                 let du = parseFloat(elem.data('duration'));
                 currentDuration = du;
-                elem.nextAll().each(function() {
+                elem.nextAll('li').each(function() {
                     du += parseFloat($(this).data('duration'));
                 });
                 totalDurationLeft = du;
 
                 let pdu = 0;
-                elem.prevAll().each(function() {
+                elem.prevAll('li').each(function() {
                     pdu += parseFloat($(this).data('duration'));
                 });
                 totalDurationBefore = pdu;
-                console.log('sm 3', du, pdu);
 
                 return player;
             };
                     // TODO: if snap then roll
                     locator.removeClass('up').removeClass('down');
                     if (locator.hasClass('snap')) {
-                        console.log('SCROLL!');
                         scrollTo();
                     } else {
                         if (y < miny) {
                 });
 
                 $('.play-next', $root).click(function() {
-                    let p = $currentMedia.next();
+                    let p = $currentMedia.nextAll('li').first();
                     if (p.length) {
                         setMedia(p).jPlayer("play");
                         _paq.push(['trackEvent', 'audiobook', 'next']);
                     }
                 });
                 $('.play-prev', $root).click(function() {
-                    let p = $currentMedia.prev();
+                    let p = $currentMedia.prevAll('li').first();
                     if (p.length) {
                         setMedia(p).jPlayer("play");
                         _paq.push(['trackEvent', 'audiobook', 'prev']);
                     _paq.push(['trackEvent', 'audiobook', 'chapter']);
                 });
 
-                console.log('READY 3!');
                 var initialElem = $('.jp-playlist li', $root).first();
                 var initialTime = 0;
-                console.log('READY 4!');
                 if (true || Modernizr.localstorage) {
                     try {
                         let speedStr = localStorage['audiobook-speed'];
                         initialTime = last[2];
                     }
                 }
-                console.log('READY 5!', initialElem, initialTime);
                 setMedia($(initialElem), initialTime);
-                console.log('READY 6!');
             },
 
             timeupdate: function(event) {
 
 
             ended: function(event) {
-                let p = $currentMedia.next();
+                let p = $currentMedia.nextAll('li');
                 if (p.length) {
                     setMedia(p).jPlayer("play");
                 }
index 4458b5a..b82d926 100644 (file)
@@ -34,7 +34,7 @@
     {% if book.is_book %}
       <span class="icon icon-book-alt" title="{% trans 'książka' %}"></span>
     {% endif %}
-    {% if book.has_mp3_file %}
+    {% if book.has_audio %}
       <span class="icon icon-audio" title="{% trans 'audiobook' %}"></span>
     {% endif %}
     {% if book.is_picture %}
index eb1ab8a..32a10ad 100644 (file)
                         <div class="c-media__btn">
                           <button class="l-button l-button--media" id="ebook"><i class="icon icon-book"></i> {% trans "pobierz książkę" %}</button>
                         </div>
+                        <div class="c-media__btn">
+                          {% if first_text %}
+                            <a href="https://elevenreader.io/audiobooks/wolnelektury:{{ first_text.slug }}" target="_blank" class="l-button l-button--media"><img src="{% static 'img/elevenreader-21.png' %}" title="{% trans "Posłuchaj w ElevenReader" %}" alt="{% trans "Posłuchaj w ElevenReader" %}"></a>
+                          {% endif %}
+                        </div>
                         <div class="c-media__btn">
                           {% if first_text %}
                             <a href="{% url 'book_text' first_text.slug %}" class="l-button l-button--media l-button--media--full"><i class="icon icon-eye"></i> {% trans "czytaj online" %}</a>
                   </div>
 
 
-
-                  {% if book.has_mp3_file %}
+                  {% if book.has_audio %}
                     {% include 'catalogue/snippets/jplayer.html' %}
-                  {% else %}
-                    {% with ch=book.get_child_audiobook %}
-                      {% if ch %}
-                        {% include 'catalogue/snippets/jplayer_link.html' with book=ch %}
-                      {% endif %}
-                    {% endwith %}
-
                   {% endif %}
 
 
-
-
                   <div class="c-media__popup" data-popup="ebook">
                     <div class="c-media__popup__box">
                       <div class="c-media__popup__box__lead">
index c57bdd3..2a3db14 100644 (file)
     </div>
   {% endif %}
 
+  {% if narrated %}
+    <div class="l-section l-section--col">
+      <h2 class="header">Audiobooki</h2>
+      <div class="l-books__grid" id="book-list">
+        {% for book in narrated %}
+          {% include "catalogue/book_box.html" %}
+        {% endfor %}
+      </div>
+    </div>
+  {% endif %}
+
   {% if main_tag %}
     <section class="l-section">
       <div class="l-author">
index e0893fa..2399a09 100644 (file)
@@ -1,6 +1,6 @@
 {% load i18n l10n catalogue_tags %}
 
-{% with audiobooks=book.get_audiobooks %}
+{% with audiobooks=book.get_audiobooks_with_children %}
   <div class="c-media__player" id="jp_container_{{ book.pk }}" data-book-slug="{{ book.slug }}">
     <div class="jp-jplayer" data-player="jp_container_{{ book.pk }}"
          data-supplied="oga,mp3"></div>
@@ -38,6 +38,9 @@
           <ul class="jp-playlist">
             {% localize off %}
             {% for i in audiobooks.0 %}
+            {% if i.part %}
+              <div class="title"><strong>{{ i.part.title }}</strong></div>
+            {% else %}
               <li
                   data-mp3='{{ i.mp3.file.url }}'
                   data-ogg='{{ i.ogg.file.url }}'
@@ -67,6 +70,7 @@
                   </span>
                {% endwith %}
               </li>
+              {% endif %}
             {% endfor %}
             {% endlocalize %}
           </ul>
diff --git a/src/catalogue/templates/catalogue/snippets/jplayer_link.html b/src/catalogue/templates/catalogue/snippets/jplayer_link.html
deleted file mode 100644 (file)
index 7bbddcd..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-{% load i18n catalogue_tags %}
-
-<div class="c-media__player" id="jp_container_{{ book.pk }}">
-  <div class="c-player__head">
-    <span>&nbsp;</span>
-  </div>
-
-  <div class="c-player">
-    <div class="c-player__btns">
-      <button disabled class="play-prev"><i class="icon icon-prev"></i></button>
-      <button disabled class="c-player__btn--md"><i class="icon icon-play"></i></button>
-      <form action='{{ book.get_absolute_url }}'>
-        <button class="play-next"><i class="icon icon-next"></i></button>
-      </form>
-    </div>
-
-    <div class="c-player__timeline">
-      <div class="c-player__info">{{ book.pretty_title }}</div>
-    </div>
-
-  </div>
-  <div class="c-media__caption">
-  </div>
-</div>
index a56286d..1ac6c08 100644 (file)
@@ -240,6 +240,7 @@ class TaggedObjectList(BookList):
         ]
         if len(self.ctx['tags']) == 1 and self.ctx['main_tag'] is not None and self.ctx['main_tag'].category == 'author':
             self.ctx['translation_list'] = self.ctx['main_tag'].book_set.all()
+            self.ctx['narrated'] = self.ctx['main_tag'].narrated.all()
 
     def get_queryset(self):
         if self.ctx['work_tags']:
index 20b287a..dffeaa7 100644 (file)
@@ -27,12 +27,12 @@ class CiviCRM:
         d = response.json()
         return d
 
-    def create_or_update_contact(self, email, key=None):
+    def create_or_update_contact(self, email, fields=None):
         contact_id = self.get_contact_id(email)
         if contact_id is None:
-            contact_id = self.create_contact(email, key)
-        elif key:
-            self.update_contact(contact_id, key)
+            contact_id = self.create_contact(email, fields)
+        elif fields:
+            self.update_contact(contact_id, fields)
         return contact_id
 
     def get_contact_id(self, email):
@@ -49,7 +49,7 @@ class CiviCRM:
         if result:
             return result[0]['id']
 
-    def create_contact(self, email, key=None):
+    def create_contact(self, email, fields):
         data = {
             'values': {},
             'chain': {
@@ -65,19 +65,22 @@ class CiviCRM:
                 ]
             }
         }
-        if key:
-            data['values']['WL.TPWL_key'] =  key
+        if fields:
+            data['values'].update(fields)
         result = self.request('Contact', 'create', data)
         return result['values'][0]['id']
-    
-    def update_contact(self, contact_id, key):
+
+    def update_phone(self, contact_id, phone):
+        if self.request('Phone', 'get', {'where': [['phone', "=", phone], ['contact_id', "=",  contact_id]]})['count']:
+            return
+        self.request('Phone', 'create', {'values': {'phone': phone, 'contact_id': contact_id}})
+
+    def update_contact(self, contact_id, fields):
         return self.request(
             'Contact',
             'update',
             {
-                'values': {
-                    'WL.TPWL_key': key,
-                },
+                'values': fields,
                 'where': [
                     ['id', '=', contact_id]
                 ]
@@ -89,7 +92,8 @@ class CiviCRM:
         if not self.enabled:
             return
 
-        contact_id = self.create_or_update_contact(email, tpwl_key)
+        fields = {'WL.TPWL_key': tpwl_key}
+        contact_id = self.create_or_update_contact(email, fields)
 
         activity_id = self.get_activity_id(key)
         if activity_id is None:
index d4733ff..24a6dad 100644 (file)
@@ -31,6 +31,9 @@ class Entity(models.Model):
 
     def populate_from_wikidata(self, wikidata_id):
         client = Client()
+        client.opener.addheaders = [(
+            'User-Agent', 'Wolne Lektury / https://wolnelektury.pl / Python-wikidata'
+        )]
         entity = client.get(wikidata_id)
 
         self.label = entity.label.get('pl', entity.label) or ''
index e3d60ca..0ff0771 100644 (file)
@@ -18,12 +18,13 @@ from wolnelektury.utils import is_ajax
 # ====================
 
 
-@require_POST
+@login_required
 def like_book(request, slug):
-    if not request.user.is_authenticated:
-        return HttpResponseForbidden('Login required.')
     book = get_object_or_404(Book, slug=slug)
 
+    if request.method != 'POST':
+        return redirect(book)
+
     models.UserList.like(request.user, book)
 
     if is_ajax(request):
@@ -49,12 +50,13 @@ class RemoveSetView(AddSetView):
     form_class = forms.RemoveSetForm
 
 
-@require_POST
+@login_required
 def unlike_book(request, slug):
-    if not request.user.is_authenticated:
-        return HttpResponseForbidden('Login required.')
     book = get_object_or_404(Book, slug=slug)
 
+    if request.method != 'POST':
+        return redirect(book)
+
     models.UserList.unlike(request.user, book)
 
     if is_ajax(request):
index aa50736..918bed9 100644 (file)
@@ -1,8 +1,7 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
-from os import path
-from .paths import PROJECT_DIR
+import os
 
 DEBUG = True
 
@@ -14,15 +13,17 @@ MANAGERS = ADMINS
 
 CONTACT_EMAIL = 'fundacja@wolnelektury.pl'
 
+ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split()
+
 CACHE_MIDDLEWARE_SECONDS = 3 * 60
 
 DATABASES = {
     'default': {
-        'ENGINE': 'django.db.backends.sqlite3',  # 'postgresql_psycopg2'
-        'NAME': path.join(PROJECT_DIR, 'dev.db'),
-        'USER': '',                      # Not used with sqlite3.
-        'PASSWORD': '',                  # Not used with sqlite3.
-        'HOST': '',                      # Set to empty string for localhost. Not used with sqlite3.
+        'ENGINE': 'django.db.backends.postgresql',
+        'HOST': 'db',
+        'USER': os.environ.get('POSTGRES_USER'),
+        'PASSWORD': os.environ.get('POSTGRES_PASSWORD'),
+        'NAME': os.environ.get('POSTGRES_USER'),
     }
 }
 
index e5b2f60..bd40547 100644 (file)
@@ -43,13 +43,6 @@ DEBUG_TOOLBAR_CONFIG = {
 }
 
 
-HAYSTACK_CONNECTIONS = {
-    'default': {
-        'ENGINE': 'haystack.backends.simple_backend.SimpleEngine',
-    },
-}
-
-
 FORMS_BUILDER_USE_SITES = False
 FORMS_BUILDER_EDITABLE_FIELD_MAX_LENGTH = True
 FORMS_BUILDER_EDITABLE_SLUGS = True
index d673772..4400319 100644 (file)
@@ -22,7 +22,10 @@ LIBRARIAN_PDF_MOREFLOATS = None
 
 LATEST_BLOG_POSTS = "https://fundacja.wolnelektury.pl/feed/?cat=-135"
 
-CATALOGUE_COUNTERS_FILE = os.path.join(VAR_DIR, 'catalogue_counters.p')
+CATALOGUE_COUNTERS_FILE = os.path.join(VAR_DIR, 'counters/catalogue_counters.p')
+LESMIANATOR_PICKLE = os.path.join(VAR_DIR, 'counters/lesmianator.p')
+
+NO_SEARCH_INDEX = False
 
 CATALOGUE_MIN_INITIALS = 60
 
@@ -55,8 +58,6 @@ NEWSLETTER_PHPLIST_SUBSCRIBE_URL = None
 VARIANTS = {
 }
 
-EPUB_FUNDRAISING = []
-
 CIVICRM_BASE = None
 CIVICRM_KEY = None
 
index c4af4eb..9c5f34f 100644 (file)
@@ -10,7 +10,7 @@
 
 .c-media__actions {
     display: flex;
-    column-gap: 38px;
+    column-gap: 20px;
     row-gap: 10px;
 }
 
index 52729f9..d1fb096 100644 (file)
           display: inline;
       }
     }
+    div.title {
+      font-size: 12px;
+      line-height: 140%;
+      letter-spacing: 0.05em;
+      list-style-type: decimal;
+      margin: 5px 0;
+      color: white;
+      cursor: pointer;
+    }
   }
 }
 
index 78b33bb..b1844d8 100644 (file)
     @include rwd($break-flow) {
         @include font-size(16px);
         line-height: 20px;
-        padding: 19px 20px;
+        padding: 19px 0;
     }
     
     .icon {
       font-size: 21px;
-      margin-right: 15px;
+      margin-right: 10px;
+    }
+
+    img {
+        height: 21px;
     }
 
     &:hover {
index 1fde2b9..c4ff0f3 100644 (file)
@@ -1064,6 +1064,10 @@ background: #fff;
     }
 
     #other {
+        display: none;
+        @include rwd($break-wide) {
+            display: block;
+        }
        ul {
            list-style: none;
            margin: 0;
index bf0fb2b..4f85a03 100644 (file)
 
 
 #menu {
-    padding-bottom: 50px;
     * {
         box-sizing: content-box;
     }
diff --git a/src/wolnelektury/static/img/elevenreader-21.png b/src/wolnelektury/static/img/elevenreader-21.png
new file mode 100644 (file)
index 0000000..86e433d
Binary files /dev/null and b/src/wolnelektury/static/img/elevenreader-21.png differ
diff --git a/src/wolnelektury/static/img/elevenreader.png b/src/wolnelektury/static/img/elevenreader.png
new file mode 100644 (file)
index 0000000..16dcade
Binary files /dev/null and b/src/wolnelektury/static/img/elevenreader.png differ
index ae5b14a..723e03e 100644 (file)
@@ -4,6 +4,8 @@
 
     function upd_t() {
         $text = $('#main-text #book-text');
+        if (!$text.length) return;
+
         texttop = $text.offset().top;
 
         $footnotes = $('#footnotes', $text);
index fa4a35a..3f5b7e3 100644 (file)
     }
 
     var map_enabled = false;
-    var marker = L.circleMarker([0,0]);
+    var marker = null;
     var map = null;
 
     function enable_map() {
+        if (!$("#reference-map").length) return;
+
         $("#reference-map").show('slow');
 
         if (map_enabled) return;
@@ -22,6 +24,7 @@
         L.tileLayer('https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=a8a97f0ae5134403ac38c1a075b03e15', {
             attribution: 'Maps © <a href="http://www.thunderforest.com">Thunderforest</a>, Data © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>'
         }).addTo(map);
+        marker = L.circleMarker([0,0]);
 
         map_enabled = true;
     }
index 2feb7b8..45a4197 100644 (file)
             $.post({
                 url: '/ludzie/lektura/' + $(this).attr('data-book-slug') + '/nie_lubie/',
                 data: {'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()},
+                dataType: 'json',
                 success: function() {
                     delete state.liked[$btn.attr('data-book')];
                     updateLiked($btn);
+                },
+                error: function() {
+                    window.location.href = $('#login-link').attr('href');
                 }
             })
         } else {
             $.post({
                 url: '/ludzie/lektura/' + $(this).attr('data-book-slug') + '/lubie/',
                 data: {'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()},
+                dataType: 'json',
                 success: function() {
                     state.liked[$btn.attr('data-book')] = [];
                     updateLiked($btn);
                 },
-                error: function(e) {
-                    if (e.status == 403) {
-                        $('#login-link').click();
-                    }
-                },
+                error: function() {
+                    window.location.href = $('#login-link').attr('href')
+                }
             });
         }
     })
index b9574d9..db33084 100644 (file)
@@ -4,7 +4,7 @@
   <form action="{% url 'import_book' %}" method="post" enctype="multipart/form-data">
     {% csrf_token %}
     <p>
-      <input type="file" id="id_book_xml_file" name="book_xml_file"/>
+      <input type="file" id="id_book_xml_file" name="book_xml_file" required/>
       <input type="submit" value="Import książki" />
     </p>
   </form>