+# Base system and requirements.
+
FROM python:3.9-trixie AS base
-ARG UID=1000
-ARG GID=1000
+ARG UID=8100
+ARG GID=8100
RUN apt-get update && apt-get install -y \
git \
+# dev
+# Assumes code will be mounted.
FROM base AS dev
#RUN pip install --no-cache-dir coverage
+# prod-base
+# Installs code and production requirements.
-
-FROM base AS prod
+FROM base AS prod-base
USER root
COPY src /app/src
+# static-builder
+# Static files are collected here. Statics are then copied into an Nginx image,
+# and staticfiles.json manifest file is copied back into production image.
-
-FROM prod AS static-builder
+FROM prod-base AS static-builder
RUN python manage.py collectstatic --noinput
+# nginx
+# Contains static files and nginx config.
FROM nginx:alpine AS nginx
+ARG UID=8100
+ARG GID=8100
+
# COPY nginx.conf conf.d/...
+RUN addgroup -g $GID app && addgroup nginx app
COPY --from=static-builder /app/var/static /app/var/static
+
+
+
+
+# Postgres with Polish hunspell dictionary withoud diacritics
+# to allow full text search without diacritics.
+
+# --- Stage 1: prepare dictionary files ---
+FROM python:3.13-slim-trixie AS polish-dict
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends hunspell-pl \
+ && rm -rf /var/lib/apt/lists/*
+
+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
+
+# --- Stage 2: actual Postgres image with the dictionary ---
+FROM postgres:18 AS postgres
+
+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 scripts/postgres-polish.sql /docker-entrypoint-initdb.d/10-postgres-polish.sql
+
+
+
+# prod
+# That's the production image with staticfiles.json manifest file added.
+
+FROM prod-base AS prod
+
+COPY --from=static-builder /app/var/static/staticfiles.json /app/var/static/staticfiles.json
services:
web:
<<: *app-base
- ports:
- - "8000:8000"
command: ./manage.py runserver 0.0.0.0:8000
celery:
command: celery -A wolnelektury worker -l info
db:
- image: postgres:18
+ build:
+ context: .
+ target: postgres
env_file:
- .env
volumes:
memcached:
image: memcached:1.6-alpine
+ 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:
--- /dev/null
+#!/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])
--- /dev/null
+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' 'GE, 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;
+ }
+}
--- /dev/null
+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;
+
+