Docker and testing changes master
authorRadek Czajka <radekczajka@wolnelektury.pl>
Fri, 6 Nov 2026 23:47:06 +0000 (00:47 +0100)
committerRadek Czajka <radekczajka@wolnelektury.pl>
Fri, 6 Nov 2026 23:47:06 +0000 (00:47 +0100)
30 files changed:
.dockerignore [new file with mode: 0644]
.gitignore
Dockerfile
Makefile
docker-compose.test.yml [new file with mode: 0644]
docker-compose.yml
requirements/requirements.txt
scripts/fold_hunspell.py [new file with mode: 0644]
scripts/nginx.conf [new file with mode: 0644]
scripts/postgres-polish.sql [new file with mode: 0644]
src/catalogue/models/snippet.py
src/catalogue/templates/catalogue/book_detail.html
src/catalogue/templates/catalogue/book_text.html
src/catalogue/templatetags/catalogue_tags.py
src/catalogue/test_utils.py
src/search/utils.py
src/search/views.py
src/social/api/serializers.py
src/stats/models.py
src/wolnelektury/settings/basic.py
src/wolnelektury/settings/test.py
src/wolnelektury/static/2022/styles/components/_media.scss
src/wolnelektury/static/2022/styles/layout/_books.scss
src/wolnelektury/static/2022/styles/layout/_footer.scss
src/wolnelektury/static/2022/styles/layout/_navigation.scss
src/wolnelektury/static/2022/styles/layout/_quotes.scss
src/wolnelektury/static/2022/styles/layout/_your-books.scss
src/wolnelektury/static/js/main.js
src/wolnelektury/templates/header.html
src/wolnelektury/templates/user_actions.html

diff --git a/.dockerignore b/.dockerignore
new file mode 100644 (file)
index 0000000..6d8372f
--- /dev/null
@@ -0,0 +1,7 @@
+**/__pycache__
+**/*~
+**/#*#
+**/.#*
+src/wolnelektury/localsettings.py
+src/wolnelektury/dev.db
+
index 3d746f9..6c61236 100644 (file)
@@ -1,4 +1,5 @@
 localsettings.py
+.env
 dev.sqlite
 *.db
 *.db-journal
index b23530b..7d4c875 100644 (file)
@@ -1,7 +1,32 @@
-FROM python:3.9-trixie AS base
+# Wolne Lektury images.
+#
+# Stages marked with ##### are build targets:
+#   deps      dev image (docker-compose.yml, code mounted)
+#   test      docker-compose.test.yml
+#   prod      production app image (default, last stage)
+#   nginx     production nginx with static files
+#   postgres  Postgres with Polish dictionary
+# Stages marked with ===== are intermediate.
 
-ARG UID=1000
-ARG GID=1000
+
+# Global build arguments. Stages using UID/GID must redeclare them
+# (bare `ARG UID`) to bring them into scope; the defaults are set here.
+
+ARG PYTHON_IMAGE=python:3.9-trixie
+ARG UID=8100
+ARG GID=8100
+
+
+##########
+#  deps  #############################################################
+#
+#  Base system and requirements.
+#  Used directly as the dev image, with code mounted.
+#
+FROM ${PYTHON_IMAGE} AS deps
+
+ARG UID
+ARG GID
 
 RUN     apt-get update && apt-get install -y \
        git \
@@ -38,36 +63,111 @@ WORKDIR /app/src
 RUN mkdir /app/.ipython
 
 
+#=======#
+#  app  #=============================================================
+#
+#  Code and production requirements. Not production yet: needs
+#  staticfiles.json manifest from static-builder.
+#
+FROM deps AS app
 
+USER root
 
-FROM base AS dev
+RUN pip install --no-cache-dir gunicorn
 
-#RUN pip install --no-cache-dir coverage
+USER app
 
+COPY src /app/src
 
 
+#==================#
+#  static-builder  #==================================================
+#
+#  Static files are collected here. Statics are then copied into
+#  the nginx image, and staticfiles.json manifest is copied back
+#  into the test and prod images.
+#
+FROM app AS static-builder
 
-FROM base AS prod
+RUN python manage.py collectstatic --noinput
 
-USER root
 
-RUN pip install --no-cache-dir gunicorn
+###########
+#  nginx  ############################################################
+#
+#  Static files for production nginx. Config is mounted.
+#
+FROM nginx:alpine AS nginx
 
-USER app
+ARG UID
+ARG GID
 
-COPY src /app/src
+# COPY nginx.conf conf.d/...
 
+RUN addgroup -g $GID app && addgroup nginx app
+COPY --from=static-builder /app/var/static /app/var/static
 
 
+#===============#
+#  polish-dict  #=====================================================
+#
+#  Polish hunspell dictionary without diacritics, to allow
+#  full text search without diacritics.
+#
+FROM ${PYTHON_IMAGE} AS polish-dict
 
-FROM prod AS static-builder
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends hunspell-pl \
+ && rm -rf /var/lib/apt/lists/*
 
-RUN python manage.py collectstatic --noinput
+COPY scripts/fold_hunspell.py /usr/local/bin/fold_hunspell.py
 
+RUN set -eu; \
+    mkdir /out; cd /tmp; \
+    enc="$(sed -n 's/^SET[[:space:]]*//p' /usr/share/hunspell/pl_PL.aff | tr -d '\r')"; \
+    iconv -f "${enc:-UTF-8}" -t UTF-8 /usr/share/hunspell/pl_PL.dic > pl.dict; \
+    iconv -f "${enc:-UTF-8}" -t UTF-8 /usr/share/hunspell/pl_PL.aff > pl.affix; \
+    python3 /usr/local/bin/fold_hunspell.py pl.dict pl.affix \
+        /out/polish.dict /out/polish.affix; \
+    dpkg-query -W -f='hunspell-pl ${Version}\n' hunspell-pl > /out/polish.version
 
 
-FROM nginx:alpine AS nginx
+##############
+#  postgres  #########################################################
+#
+#  Postgres with the dictionary from polish-dict.
+#
+FROM postgres:18 AS postgres
 
-# COPY nginx.conf conf.d/...
+COPY --from=polish-dict /out/ /tmp/polish-dict/
+RUN mv /tmp/polish-dict/* "/usr/share/postgresql/$PG_MAJOR/tsearch_data/" \
+ && rmdir /tmp/polish-dict
 
-COPY --from=static-builder /app/var/static /app/var/static
+COPY scripts/postgres-polish.sql /docker-entrypoint-initdb.d/10-postgres-polish.sql
+
+
+##########
+#  test  #############################################################
+#
+#  Production image plus test requirements. Defined before `prod`,
+#  so that `prod` stays the default (last) stage.
+#
+FROM app AS test
+
+USER root
+COPY requirements/requirements-test.txt /tmp/
+RUN pip install --no-cache-dir -r /tmp/requirements-test.txt
+USER app
+RUN mkdir -p /app/var/counters
+COPY --from=static-builder /app/var/static/staticfiles.json /app/var/static/staticfiles.json
+CMD ["./manage.py", "test"]
+
+
+##########
+#  prod  #############################################################
+#
+#  Production image: app + staticfiles.json manifest.
+#
+FROM app AS prod
+
+COPY --from=static-builder /app/var/static/staticfiles.json /app/var/static/staticfiles.json
index 59e222a..3f1b3c4 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -12,15 +12,14 @@ deploy: src/wolnelektury/localsettings.py
        src/manage.py collectstatic --noinput
 
 
-.ONESHELL:
+TEST_COMPOSE := docker compose -f docker-compose.test.yml
+
 test:
-       cd src
-       python -Wall -m coverage run --branch --source='.' ./manage.py test; true
-       coverage html -d ../htmlcov.new
-       rm -rf ../htmlcov
-       mv ../htmlcov.new ../htmlcov
-       coverage report
-       rm .coverage
+       mkdir -p htmlcov
+       UID=$(UID) GID=$(GID) $(TEST_COMPOSE) run --rm --build test sh -c '\
+         python -Wall -m coverage run --branch --source=. ./manage.py test $(ARGS); s=$$?; \
+         coverage report --skip-covered; coverage html -d /app/htmlcov; exit $$s'; \
+       status=$$?; $(TEST_COMPOSE) down; exit $$status
 
 
 up:
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
new file mode 100644 (file)
index 0000000..11c6697
--- /dev/null
@@ -0,0 +1,31 @@
+#   make test
+#   make test ARGS=catalogue
+
+name: wolnelektury-test
+
+x-env: &env
+  POSTGRES_USER: wl
+  POSTGRES_PASSWORD: wl
+
+services:
+  test:
+    build:
+      context: .
+      target: test
+      args:
+        UID: ${UID:-1000}
+        GID: ${GID:-1000}
+    environment:
+      <<: *env
+      SECRET_KEY: test
+      ALLOWED_HOSTS: "*"
+      COVERAGE_FILE: /tmp/.coverage
+    volumes:
+      - ./htmlcov:/app/htmlcov
+    depends_on: [db]
+
+  db:
+    build: {context: ., target: postgres}
+    environment: *env
+    tmpfs: [/var/lib/postgresql]
+
index bda15bb..55a09d8 100644 (file)
@@ -1,7 +1,7 @@
 x-app-base: &app-base
   build:
     context: .
-    target: dev
+    target: deps
     args:
       UID: ${UID:-1000}
       GID: ${GID:-1000}
@@ -26,16 +26,19 @@ x-app-base: &app-base
 services:
   web:
     <<: *app-base
-    ports:
-     - "8000:8000"
     command: ./manage.py runserver 0.0.0.0:8000
+    ports:
+      # Dev: use http://localhost:8000/ directly
+      - "127.0.0.1:8000:8000"
 
   celery:
     <<: *app-base
     command: celery -A wolnelektury worker -l info
 
   db:
-    image: postgres:18
+    build:
+      context: .
+      target: postgres
     env_file:
       - .env
     volumes:
@@ -47,6 +50,24 @@ services:
   memcached:
     image: memcached:1.6-alpine
 
+  nginx:
+    # Just for testing production-like environment.
+    # docker compose --profile nginx up
+    profiles: ["nginx"]
+    build:
+      context: .
+      target: nginx
+      args:
+        UID: ${UID:-1000}
+        GID: ${GID:-1000}
+    ports:
+      - "127.0.0.1:8101:80"
+    volumes:
+      - ./var/media:/app/var/media:ro
+      - ./scripts/nginx.conf:/etc/nginx/conf.d/default.conf:ro
+    depends_on:
+      - web
+
 
 volumes:
-  ipython:
\ No newline at end of file
+  ipython:
index 600ec63..853c411 100644 (file)
@@ -21,7 +21,7 @@ djangorestframework-xml==2.0.0
 django-admin-ordering==0.18
 django-countries==7.6.1
 
--e git+https://github.com/rczajka/django-forms-builder@270cc22f80ec4681735b4e01689562fbab79ceda#egg=django-forms-builder
+-e git+https://github.com/rczajka/django-forms-builder@6533f9442f004d44ed362202b6bdab82b73dd5e9#egg=django-forms-builder
 
 
 oauthlib>=3.0.1,<3.1
diff --git a/scripts/fold_hunspell.py b/scripts/fold_hunspell.py
new file mode 100644 (file)
index 0000000..bc63e78
--- /dev/null
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+"""
+Tworzy wersję słownika hunspell bez polskich znaków dla PostgreSQL (szablon ispell).
+
+Użycie:
+    fold_hunspell.py polish.dict polish.affix polish_ascii.dict polish_ascii.affix
+
+Pliki wejściowe muszą być w UTF-8 (po iconv z ISO-8859-2).
+
+Samo usunięcie znaków psuje warunki reguł z klasami negowanymi: np.
+`SFX H ć ło [^ąeśź]ć` (wszystko poza ą, e, ś, ź przed ć) po zamianie
+stałoby się `[^aesz]c` i przestało pasować do czasowników na -ać.
+Dlatego litera zostaje w klasie negowanej tylko wtedy, gdy oryginał
+wykluczał wszystkie litery, które się na nią składają; w przeciwnym
+razie jest usuwana, a pusta klasa zamienia się w `.`. Reguły stają się
+przez to nieco zbyt liberalne (nadmiarowe formy), ale żadna forma nie ginie.
+"""
+import re
+import sys
+import unicodedata
+
+
+def fold(text):
+    text = text.replace('ł', 'l').replace('Ł', 'L')
+    return ''.join(
+        c for c in unicodedata.normalize('NFKD', text)
+        if not unicodedata.combining(c)
+    )
+
+
+LETTERS = 'aąbcćdeęfghijklłmnńoóprsśtuvwxyzźż'
+GROUPS = {}
+for letter in LETTERS:
+    GROUPS.setdefault(fold(letter), set()).add(letter)
+
+
+def fold_negated_class(match):
+    excluded = set(match.group(1))
+    keep = []
+    for c in match.group(1):
+        folded = fold(c)
+        if GROUPS.get(folded, {c}) <= excluded and folded not in keep:
+            keep.append(folded)
+    return '[^' + ''.join(keep) + ']' if keep else '.'
+
+
+def fold_affix_line(line):
+    parts = line.split()
+    if len(parts) >= 5 and parts[0] in ('SFX', 'PFX') and '[^' in parts[4]:
+        condition = re.sub(r'\[\^([^\]]*)\]', fold_negated_class, parts[4])
+        line = line.replace(parts[4], condition, 1)
+    if line.startswith('SET '):
+        return 'SET UTF-8\n'
+    return fold(line)
+
+
+def main(dict_in, affix_in, dict_out, affix_out):
+    with open(dict_in, encoding='utf-8') as src, open(dict_out, 'w', encoding='utf-8') as dst:
+        dst.write(fold(src.read()))
+    with open(affix_in, encoding='utf-8') as src, open(affix_out, 'w', encoding='utf-8') as dst:
+        for line in src:
+            dst.write(fold_affix_line(line))
+
+
+if __name__ == '__main__':
+    main(*sys.argv[1:5])
diff --git a/scripts/nginx.conf b/scripts/nginx.conf
new file mode 100644 (file)
index 0000000..561a5d4
--- /dev/null
@@ -0,0 +1,60 @@
+server {
+    listen 80;
+
+    access_log off;
+    error_log /dev/stderr warn;
+
+    client_max_body_size 4G;
+    send_timeout 300s;
+    proxy_buffers 16 16k;
+    proxy_buffer_size 32k;
+
+    location /static/ {
+        root /app/var;
+        expires 30d;
+    }
+
+    location /media/ {
+        root /app/var;
+        expires 30d;
+    }
+    location ~ ^/media/.*\.(mp3|ogg|daisy\.zip)$ {
+        add_header Content-Disposition "attachment";
+    }
+    location ~ ^/media/.*\.txt$ {
+        add_header Content-Type "text/plain; charset=utf-8";
+    }
+    location ~ ^/media/book/(pdf|epub|mobi|fb2|cover|xml)/ {
+        # kill cache
+        add_header Last-Modified $date_gmt;
+        add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
+        if_modified_since off;
+        expires off;
+        etag off;
+    }
+
+    location /api/ {
+        add_header 'Access-Control-Allow-Origin' '*' always;
+        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE' always;
+        add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
+        add_header 'Access-Control-Expose-Headers' 'Link' always;
+
+        if ($request_method = OPTIONS) {
+            return 204;
+        }
+
+        proxy_set_header Accept-Language "";
+        proxy_set_header Host $host;
+        proxy_pass http://web:8000;
+    }
+    location / {
+       root /app/var/static/root;
+       try_files $uri @backend;
+    }
+
+    location @backend {
+        proxy_set_header Accept-Language "";
+        proxy_set_header Host $host;
+        proxy_pass http://web:8000;
+    }
+}
diff --git a/scripts/postgres-polish.sql b/scripts/postgres-polish.sql
new file mode 100644 (file)
index 0000000..100764a
--- /dev/null
@@ -0,0 +1,27 @@
+CREATE EXTENSION IF NOT EXISTS unaccent;
+
+DO $$
+  BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_ts_dict
+               WHERE dictname = 'polish_hunspell'
+                 AND dictnamespace = 'public'::regnamespace) THEN
+    CREATE TEXT SEARCH DICTIONARY public.polish_hunspell (
+        TEMPLATE = ispell, DictFile = polish, AffFile = polish
+    );
+  END IF;
+  IF NOT EXISTS (SELECT 1 FROM pg_ts_config
+                      WHERE cfgname = 'polish'
+               AND cfgnamespace = 'public'::regnamespace) THEN
+    CREATE TEXT SEARCH CONFIGURATION public.polish (COPY = pg_catalog.simple);
+  END IF;
+END $$;
+
+ALTER TEXT SEARCH DICTIONARY public.polish_hunspell (
+  DictFile = polish, AffFile = polish, StopWords
+);
+
+ALTER TEXT SEARCH CONFIGURATION public.polish
+  ALTER MAPPING FOR asciiword, asciihword, hword_asciipart, word, hword, hword_part
+  WITH unaccent, polish_hunspell, simple;
+
+
index 4c25b8c..24b2d85 100644 (file)
@@ -26,4 +26,4 @@ class Snippet(models.Model):
 
     @classmethod
     def update_all(cls):
-        cls.objects.all().update(search_vector = build_search_vector('text'))
+        cls.objects.all().update(search_vector=UnaccentSearchVector('text'))
index 4852c9a..f87041a 100644 (file)
@@ -8,6 +8,12 @@
 {% load annoy %}
 
 
+{% block settings %}
+{% load title %}
+{% title book.pretty_title %}
+{% endblock %}
+
+
 {% block global-content %}
 <div class="l-container">
   {% annoy_banner 'book-page' %}
                       <div class="c-media__actions lay-col lay-l-row">
                         <div class="c-media__btn">
                           {% if book.has_audio %}
-                            <button class="l-button l-button--media" id="audiobook"><i class="icon icon-audio"></i> {% trans "pobierz audiobook" %}</button>
+                            <button class="l-button l-button--media l-button--dialog" id="audiobook"><i class="icon icon-audio"></i> {% trans "pobierz audiobook" %}</button>
                           {% endif %}
                         </div>
                         <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>
+                          <button class="l-button l-button--media l-button--dialog" id="ebook"><i class="icon icon-book"></i> {% trans "pobierz książkę" %}</button>
                         </div>
                         <div class="c-media__btn">
                           {% if first_text %}
                   {% endif %}
 
 
-                  <div class="c-media__popup" data-popup="ebook">
-                    <div class="c-media__popup__box">
+                    <dialog class="c-media__popup__box" data-popup="ebook" closedby="any" aria-labelledby="popup-dl-ebook-title">
                       <div class="c-media__popup__box__lead">
-                        <h2>{% trans "Pobieranie e-booka" %}</h2>
+                        <h2 id="popup-dl-ebook-title">{% trans "Pobieranie e-booka" %}</h2>
                         <p>{% trans "Wybierz wersję dla siebie" %}:</p>
                       </div>
                       <div class="c-media__popup__box__items">
                       <button class="c-media__popup__close">
                         <img src="{% static '2022/images/close.svg' %}" alt="{% trans 'Zamknij' %}">
                       </button>
-                    </div>
-                  </div>
+                  </dialog>
                   {% if book.has_audio %}
-                    <div class="c-media__popup" data-popup="audiobook">
-                      <div class="c-media__popup__box">
+                    <dialog class="c-media__popup__box" data-popup="audiobook" closedby="any" aria-labelledby="popup-dl-audio-title">
                         <div class="c-media__popup__box__lead">
-                          <h2>{% trans "Pobieranie audiobooka" %}</h2>
+                          <h2 id="popup-dl-audio-title">{% trans "Pobieranie audiobooka" %}</h2>
                           <p>{% trans "Wybierz wersję dla siebie" %}:</p>
                         </div>
                         <div class="c-media__popup__box__items">
                         <button class="c-media__popup__close">
                           <img src="{% static '2022/images/close.svg' %}" alt="{% trans 'Zamknij' %}">
                         </button>
-                      </div>
-                    </div>
+                    </dialog>
                   {% endif %}
                 </div>
               {% else %}
index 45805c4..ad0089a 100644 (file)
@@ -9,7 +9,10 @@
 {% load annoy %}
 
 
-{% block title %}{{ book.pretty_title }}{% endblock %}
+{% block settings %}
+{% load title %}
+{% title book.pretty_title %}
+{% endblock %}
 
 
 {% block extrahead %}
index e50ab58..2901975 100644 (file)
@@ -164,7 +164,7 @@ def book_tree_texml(book_list, books_by_parent, depth=1):
             %(children)s
             """ % {
                 "depth": depth,
-                "title": book.title,
+                "title": book.title.replace('&', '&amp;').replace('<', '&lt;'),
                 "audiences": ", ".join(book.audiences_pl()),
                 "audiobook": "audiobook" if book.has_media('mp3') else "",
                 "children": book_tree_texml(books_by_parent.get(book.id, ()), books_by_parent, depth + 1)
index a145522..6ad67c7 100644 (file)
@@ -38,7 +38,7 @@ class PersonStub:
 
 
 class BookInfoStub:
-    _empty_fields = ['cover_url', 'variant_of']
+    _empty_fields = ['cover_url', 'variant_of', 'license']
     # allow single definition for multiple-value fields
     _salias = {
         'authors': 'author',
index 77ff1ae..69263e7 100644 (file)
@@ -1,31 +1,10 @@
 from django.conf import settings
-from django.db.models import Func
-from django.contrib.postgres.search import SearchQuery, SearchVectorField
+from django.contrib.postgres.search import SearchQuery, SearchVector
 
 
-class UnaccentSearchQuery(SearchQuery):
-    '''
-    The idea is to run unaccent *after* the query is already passed through the language dictionary.
-    '''
-    def as_sql(self, *args, **kwargs):
-        sql, params = super().as_sql(*args, **kwargs)
-        if settings.SEARCH_USE_UNACCENT:
-            sql = f'unaccent({sql}::text)::tsquery'
-        return sql, params
+# Deprecated
+UnaccentSearchQuery = SearchQuery
 
 
-class UnaccentSearchVector(Func):
-    '''
-    We do the indexing twice, to account for non-diacritic versions.
-    For example: user enters 'róże' -> stem to 'róża' -> unaccent to 'roza'.
-    But user enters 'roze' -> stem leaves it as is, so we need original form in the vector.
-    '''
-    function='to_tsvector'
-    if settings.SEARCH_USE_UNACCENT:
-        template = f'''unaccent(
-        %(function)s('{settings.SEARCH_CONFIG}', %(expressions)s)::text)::tsvector ||
-        to_tsvector(
-        '{settings.SEARCH_CONFIG_SIMPLE}', 
-        unaccent(%(expressions)s)
-        )'''
-    output_field = SearchVectorField()
+def UnaccentSearchVector(expression):
+    return SearchVector(expression, config=settings.SEARCH_CONFIG)
index 6066cd9..6a9e1ea 100644 (file)
@@ -29,7 +29,7 @@ def get_hints(prefix, user=None, limit=10):
     data = []
     if len(data) < limit:
         authors = catalogue.models.Tag.objects.filter(
-            category='author', name_pl__iregex='\m' + prefix).only('name', 'id', 'slug', 'category')
+            category='author', name_pl__iregex=r'\m' + prefix).only('name', 'id', 'slug', 'category')
         data.extend([
             {
                 'type': 'author',
@@ -44,7 +44,7 @@ def get_hints(prefix, user=None, limit=10):
     
     if user is not None and user.is_authenticated and len(data) < limit:
         tags = social.models.UserList.objects.filter(
-            user=user, name__iregex='\m' + prefix).only('name', 'id', 'slug')
+            user=user, name__iregex=r'\m' + prefix).only('name', 'id', 'slug')
         data.extend([
             {
                 'type': 'userlist',
@@ -56,7 +56,7 @@ def get_hints(prefix, user=None, limit=10):
         ])
     if len(data) < limit:
         tags = catalogue.models.Tag.objects.filter(
-            category__in=('theme', 'genre', 'epoch', 'kind'), name_pl__iregex='\m' + prefix).only('name', 'id', 'slug', 'category')
+            category__in=('theme', 'genre', 'epoch', 'kind'), name_pl__iregex=r'\m' + prefix).only('name', 'id', 'slug', 'category')
         data.extend([
             {
                 'type': tag.category,
@@ -69,7 +69,7 @@ def get_hints(prefix, user=None, limit=10):
         ])
     if len(data) < limit:
         collections = catalogue.models.Collection.objects.filter(
-            title_pl__iregex='\m' + prefix).only('title', 'slug')
+            title_pl__iregex=r'\m' + prefix).only('title', 'slug')
         data.extend([
             {
                 'type': 'collection',
@@ -80,7 +80,7 @@ def get_hints(prefix, user=None, limit=10):
             for collection in collections[:limit - len(data)]
         ])
     if len(data) < limit:
-        for b in catalogue.models.Book.objects.filter(findable=True, title__iregex='\m' + prefix)[:limit-len(data)]:
+        for b in catalogue.models.Book.objects.filter(findable=True, title__iregex=r'\m' + prefix)[:limit-len(data)]:
             author_str = b.author_unicode()
             translator = b.translator()
             if translator:
@@ -99,7 +99,7 @@ def get_hints(prefix, user=None, limit=10):
         infos = infopages.models.InfoPage.objects.filter(
             published=True,
             findable=True,
-            title_pl__iregex='\m' + prefix).only('title', 'id', 'slug')
+            title_pl__iregex=r'\m' + prefix).only('title', 'id', 'slug')
         data.extend([
             {
                 'type': 'info',
index 155c0e6..3b581a0 100644 (file)
@@ -154,6 +154,8 @@ class UserListItemReadSerializerV3(UserListItemSerializer):
 class UserListItemReadSerializer(UserListItemSerializer):
     book = catalogue.api.serializers.BookSerializer2()
     bookmark = bookmarks.api.views.BookmarkSerializer()
+    quote = bookmarks.api.views.QuoteSerializer()
+
     class Meta:
         model = models.UserListItem
         fields = [
index ca0b55d..5dcd551 100644 (file)
@@ -23,7 +23,7 @@ class VisitsBase(models.Model):
         data = urlopen(url).read().decode('utf-16')
         lines = data.split('\n')[1:]
         for line in lines:
-            m = re.match('^/katalog/lektura/([^,]+)\.html,', line)
+            m = re.match(r'^/katalog/lektura/([^,]+)\.html,', line)
             if m is not None:
                 slug = m.group(1)
                 _url, uviews, views, _rest = line.split(',', 3)
index 84503ed..50e0edd 100644 (file)
@@ -4,6 +4,7 @@
 import os
 
 DEBUG = os.environ.get('DEBUG', '')
+SECRET_KEY = os.environ.get('SECRET_KEY', None)
 
 ADMINS = [
     # ('Your Name', 'your_email@domain.com'),
index f5d70b2..42a453e 100644 (file)
@@ -10,3 +10,11 @@ MEDIA_URL = '/media/'
 SEARCH_CONFIG = 'english'
 SEARCH_CONFIG_SIMPLE = 'simple'
 SEARCH_USE_UNACCENT = False
+
+# Infrastructure: tests don't need Redis or Memcached.
+CELERY_BROKER_URL = 'memory://'
+CELERY_TASK_ALWAYS_EAGER = True
+CACHES = {
+    'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'},
+    'template_fragments': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'},
+}
index 6b15f80..714638e 100644 (file)
   }
 }
 
-.c-media__popup {
-  position: fixed;
-  z-index: 10;
-  top: 0;
-  left: 0;
-  height: 100%;
-  align-items: flex-start;
-  justify-content: center;
-  background-color: rgba($color-black, 0.35);
-  display: none;
-  overflow-y: scroll;
-  padding: 10vh 16px 10px 10px;
-  width: 100%;
-
-  @include rwd($break-flow) {
-      padding: 10vh 40px 40px 40px;
-  }
-  
-  &.is-open {
-    display: flex;
-  }
-}
-
 .c-media__popup__box {
-  background-color: #fff;
-  position: relative;
-  border-radius: 10px;
-  overflow: hidden;
+    padding: 0;
+    border: 0;
+    background: none;
+    border-radius: 10px;
 
-  @include rwd($break-flow) {
-      min-width: 640px;
-  }
+    @include rwd($break-flow) {
+        min-width: 640px;
+    }
+
+    &::backdrop {
+        background-color: rgba($color-black, 0.35);
+    }
 }
 
 .c-media__popup__box__lead {
 }
 
 .c-media__popup__box__items {
+    background: white;
     padding: 16px;
   @include rwd($break-flow) {
       padding: 40px;
 }
 
 .c-media__popup__close {
+    --focus-ring: #fff;
   position: absolute;
   padding: 0;
   border: 0;
index e14adbf..74106ff 100644 (file)
@@ -28,7 +28,6 @@
   padding: 21px;
   box-shadow: 0 4px 10px rgba(0, 0, 0, 0);
   transition: all $ease-dynamic 350ms;
-  outline: 0;
   margin: 5px;
 
   &.l-books__item--placeholder {
     visibility: hidden;
   }
 
-  & * {
-    outline: 0;
-  }
-
   &:hover {
     box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
   }
@@ -55,6 +50,7 @@
     line-height: 140%;
     letter-spacing: -0.01em;
     color: #FFFFFF;
+    --focus-ring: #fff;
 
     display: flex;
     flex-direction: column;
   font-size: 0;
   margin: 0 0 11px;
 
-  img {
-      width: 100%;
+  a {
+      display: block;
+      img {
+          width: 100%;
+      }
   }
 }
 
     border-radius: 52px;
     border: 1px solid #D9D9D9;
     width: 100%;
-    outline: 0;
     transition: border $ease-out 350ms;
 
     @input rwd($break-flow) {
       padding: 0;
       margin: 0 0 0 30px;
       background-color: transparent;
-      outline: 0;
       font-weight: $regular;
       @include font-size(18px);
       line-height: 150%;
index fd4a53a..eddcf33 100644 (file)
@@ -1,4 +1,5 @@
 .l-footer {
+    --focus-ring: #fff;
   color: #FFFFFF;
   background: #083F4D;
   margin: 0 auto;
index 9e3aafc..ece84d5 100644 (file)
@@ -29,24 +29,6 @@ body {
     align-items: center;
     justify-content: space-between;
   }
-
-  &.is-open {
-    position: fixed;
-    top: 0; left: 0;
-    width: 100%;
-    z-index: $master-layer;
-
-    .l-navigation__logo {
-        width: unset;
-        overflow: unset;
-    }
-
-    .l-navigation__menu {
-      opacity: 1;
-      visibility: visible;
-      pointer-events: auto;
-    }
-  }
 }
 
 .l-navigation__logo {
@@ -342,12 +324,8 @@ body:has(dialog[open]) {
       gap: 3%;
   }
 
-  ul {
-    margin: 0;
-    padding: 0;
-    list-style: none;
-    //max-width: 212px;
-    width: 45%;
+  .l-navigation__menu__group {
+      width: 45%;
 
     @include rwd($break-menu) {
       width: 100%;
@@ -355,6 +333,19 @@ body:has(dialog[open]) {
       margin-bottom: 20px;
     }
 
+    h2 {
+        margin: 16px 0 0 0;
+        color: #92BD39;
+        font-weight: $semibold;
+        @include font-size(21px);
+        line-height: 140%;
+        letter-spacing: -0.01em;
+    }
+
+    ul {
+        margin: 0;
+        padding: 0;
+        list-style: none;
     li {
       font-weight: $regular;
       font-size: 16px;
@@ -366,14 +357,6 @@ body:has(dialog[open]) {
           line-height: 115%;
       }
 
-      strong {
-        color: #92BD39;
-        font-weight: $semibold;
-        @include font-size(21px);
-        line-height: 140%;
-        letter-spacing: -0.01em;
-      }
-
       a {
         color: $color-white;
         &:hover {
@@ -395,6 +378,7 @@ body:has(dialog[open]) {
         border: 0;
       }
     }
+    }
   }
 }
 
index fe38f2a..e98c691 100644 (file)
@@ -19,7 +19,6 @@
 }
 
 .l-quotes__item {
-  outline: 0;
   background: #E1F1F2;
   border-radius: 10px;
   width: 558px;
index 98570d4..bab0d0a 100644 (file)
@@ -30,7 +30,6 @@
   }
 
   button {
-    outline: 0;
     width: 40px;
     height: 40px;
     background: transparent;
index b646bf4..5b7f897 100644 (file)
 
 // Ebook/Audiobook Btns
 (function() {
-  let button = $('.c-media__btn button:not(.l-button--media--full)');
-  let popupLayer = $('.c-media__popup');
-  let closeButton = $('.c-media__popup__close');
+    let dialogs = $(".l-button--dialog");
+    dialogs.on('click', function () {
+        let target = $(this).attr('id');
+        $('[data-popup=' + target)[0].showModal();
+    });
+
+    $('.c-media__popup__close').on('click', function() {
+        $(this).closest('dialog')[0].close();
+    });
+})();
+
+
+// Player
+(function() {
   let playButton = $('.c-player__btn--md');
   let chaptersButton = $('.c-player__chapters span');
   let select = $('.c-select');
   let selectItem = $('.c-select li');
   let volumeButton = $('.icon-volume');
 
-  button.on('click', function () {
-    let target = $(this).attr('id');
-    $('[data-popup=' + target).addClass('is-open');
-    $('body').addClass('popup-open');
-  });
-
-  closeButton.on('click', function() {
-    $(this).closest('.c-media__popup').removeClass('is-open');
-    $('body').removeClass('popup-open');
-  });
-
-  popupLayer.on('click', function(e) {
-    let _this = $(this);
-    if($(e.target).is(popupLayer)) {
-      _this.removeClass('is-open');
-      $('body').removeClass('popup-open');
-    }
-  });
-
   chaptersButton.on('click', function() {
     $(this).parent().toggleClass('is-active');
   });
     selectItem.removeClass('is-active');
     $(this).addClass('is-active');
   });
-
-  $(document).keyup(function(e) {
-    if (e.keyCode === 27) {
-      $('.c-media__popup').removeClass('is-open');
-    }
-  });
 })();
 
 // Homepage books sliders
                     updateLiked($btn);
                 },
                 error: function() {
-                    window.location.href = $('#login-link').attr('href');
+                    window.location.href = $('.login-link').attr('href');
                 }
             })
         } else {
                     updateLiked($btn);
                 },
                 error: function() {
-                    window.location.href = $('#login-link').attr('href')
+                    window.location.href = $('.login-link').attr('href')
                 }
             });
         }
index 7b257b3..9bb3b15 100644 (file)
@@ -17,8 +17,8 @@
       <img src="{% static "2022/images/logo.svg" %}" alt="WolneLektury.pl">
     </a>
     <div class="l-naviagion__search">
-      <form action="/szukaj/">
-        <input id="search" name="q" type="text" placeholder="szukaj tytułu, autora, motywów…" autocomplete="off" data-source="/szukaj/hint/?max=10">
+      <form action="/szukaj/" role="search">
+        <input id="search" name="q" type="text" placeholder="szukaj tytułu, autora, motywów…" autocomplete="off" data-source="/szukaj/hint/?max=10" aria-label="szukaj">
       </form>
     </div>
     <div class="l-navigation__actions">
           <div class="l-navigation__menu__blocks">
             <div class="l-navigation__menu__links">
               {% cache 30 menu LANGUAGE_CODE %}
-                <ul>
-                  <li><strong>{% trans "Katalog" %}</strong></li>
+              <div class="l-navigation__menu__group">
+                <h2 id="menu-group-1">{% trans "Katalog" %}</h2>
+                <ul aria-labelledby="menu-group-1">
                   {% menu 'Katalog' %}
                 </ul>
-                <ul>
-                  <li><strong>{% trans "Włącz się" %}</strong></li>
+              </div>
+              <div class="l-navigation__menu__group">
+                <h2 id="menu-group-2">{% trans "Włącz się" %}</h2>
+                <ul aria-labelledby="menu-group-2">
                   {% menu 'Włącz się' %}
                 </ul>
-                <ul>
-                  <li><strong>{% trans "Aktualności" %}</strong></li>
+              </div>
+              <div class="l-navigation__menu__group">
+                <h2 id="menu-group-3">{% trans "Aktualności" %}</h2>
+                <ul aria-labelledby="menu-group-3">
                   {% latest_blog_posts %}
                   {% menu 'Aktualności' %}
                 </ul>
-                <ul>
-                  <li><strong>{% trans "Narzędzia" %}</strong></li>
+              </div>
+              <div class="l-navigation__menu__group">
+                <h2 id="menu-group-4">{% trans "Narzędzia" %}</h2>
+                <ul aria-labelledby="menu-group-4">
                   {% menu 'Narzędzia' %}
                 </ul>
-                <ul>
-                  <li><strong>{% trans "O nas" %}</strong></li>
+              </div>
+              <div class="l-navigation__menu__group">
+                <h2 id="menu-group-5">{% trans "O nas" %}</h2>
+                <ul aria-labelledby="menu-group-5">
                   {% menu 'O nas' %}
                 </ul>
+              </div>
               {% endcache %}
             </div>
 
               </div>
 
               <div class="l-navigation__menu__social">
-                <ul>
+                <ul aria-label="{% trans "Media społecznościowe" %}">
                   <li>
                     <a href="https://www.facebook.com/wolnelektury/"
                        title="Facebook" target="_blank">
index 86d6eed..fb05a98 100644 (file)
               aria-label="{% trans "Menu konta" %}">
     {% endif %}
     {% if request.user.is_staff %}
-      <img src="{% static '2022/images/icons/user-staff.svg' %}">
+      <img src="{% static '2022/images/icons/user-staff.svg' %}" alt="Użytkownik">
     {% elif request.user.membership %}
-      <img src="{% static '2022/images/icons/user-vip.svg' %}">
+      <img src="{% static '2022/images/icons/user-vip.svg' %}" alt="Użytkownik">
     {% else %}
-      <img src="{% static '2022/images/icons/user.svg' %}">
+      <img src="{% static '2022/images/icons/user.svg' %}" alt="Użytkownik">
       {% endif %}
   {% if hide_menu %}
     </a>
@@ -45,7 +45,7 @@
   {% endif %}
 {% else %}
   <div class="l-navigation__login">
-    <a id="login-link" href='{% url 'login' %}?next={{ request.path }}'>{% trans "Zaloguj się" %}</a>
+    <a class="login-link" href='{% url 'login' %}?next={{ request.path }}'>{% trans "Zaloguj się" %}</a>
     /
     <a href='{% url 'register' %}?next={{ request.path }}'>{% trans "Załóż konto" %}</a>
   </div>