34 Commits

Author SHA1 Message Date
8abcfd1f5e Fix CI: Add container with docker pre-installed
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 4m47s
2026-02-20 15:32:23 +03:00
566cb31430 feat: Docker CI/CD setup, cleanup and release prep v1.0.0
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-02-20 15:03:35 +03:00
a159a128b1 mod: имя контейнера (для однообразия) и порт (чтобы не было конфликта с другими проектами) 2026-02-20 14:52:44 +03:00
4393ff3dad mod: nginx-конфиг хоста для работы через docker 2026-02-20 13:10:07 +03:00
b95fb628b1 add: вся для запуска в docker 2026-02-20 13:09:00 +03:00
7c62b49396 mod: порядок отдачи следующей случайной цитаты чтоб не возникало петель 2026-02-20 13:07:31 +03:00
880f7f117d mod: Пример .env (окружение проекта) 2026-02-20 02:14:50 +03:00
5b0c4d84cb del: 2026-02-20 02:12:51 +03:00
51a6cdaadf add: страницы ошибок для внешнего-хостового nginx (на случай если django, gunicorn или весь контейнер упали или недоступны). 2026-02-20 01:52:16 +03:00
e4e156458c add: описание для media-каталога и .gitignore чтоб в него не пушилось. 2026-02-20 01:49:43 +03:00
17328bcc83 del: удалили мусор (картинки из media и requirement*) 2026-02-20 01:42:46 +03:00
e41245804f ---: minor 2026-02-19 20:00:41 +03:00
17ce89d9c0 add: nginx-кокфил для nginx на хосте. 2026-02-19 14:04:48 +03:00
27ba9cba17 mod: Настройка gunicorn для "корневой статики" 2026-02-19 13:09:31 +03:00
c487dca798 add: Описание проекта для LLMJ и GEO. 2026-02-19 13:08:58 +03:00
df924872de add: файлы подтверждения владением для поисковиков. 2026-02-19 12:34:07 +03:00
e26e97add7 mod: Улучшены SEO и LLMO 2026-02-19 12:28:45 +03:00
e4dcfdbfed add: библиотека tqdm для прогресс-бара в Management Command. 2026-02-19 02:32:49 +03:00
7a16fb04ec mod: Management Command улучшен (немного) 2026-02-19 02:26:07 +03:00
3357f01c40 add: Management Command для массового типографирования всех записей. Использовать через python dicquo/manage.py reprocess_typography. 2026-02-19 02:01:14 +03:00
b66d804a71 add: В админку добавлен типограф etpgrf. Удалены ненужные поля из модели. 2026-02-19 01:36:21 +03:00
4b9e102887 mod: усилена медитативность 2026-02-18 19:44:06 +03:00
b7321220c2 mod: цвет фона определяется на фронтенде в JS. 2026-02-18 19:18:43 +03:00
dda71c9dc9 add: страницы ошибок 2026-02-18 19:02:08 +03:00
d1eb218986 mod: Шаблоны в современном ситиле + schema.org 2026-02-18 18:52:22 +03:00
7e33997260 fix: Исправлено образование петель.
mod: современный стиль для вьюх.
2026-02-18 17:38:35 +03:00
65feb36f77 mod: современный способ создания sitemap.xml 2026-02-18 16:00:52 +03:00
33fa2d04a9 mod: отключены внешние типографы 2026-02-18 15:07:35 +03:00
b94e31dc59 mod: добавлен gunicorn (cgi) и whitenoise (отдача статики через gunicorn) 2026-02-18 02:07:35 +03:00
f7e5ff8269 add: каталог для базы SQLite 2026-02-18 01:31:52 +03:00
49fa53b5f0 mod: в соответствии с Django 6 2026-02-18 01:31:16 +03:00
67e3cbe83c mod: использование секретов через .env 2026-02-18 01:30:18 +03:00
53a5bce1dc mod: add django-environ for use .env for manage environment 2026-02-15 15:33:29 +03:00
b5e9e85476 glb: use poetry to new release dicquo-app 2026-02-15 15:06:25 +03:00
65 changed files with 2509 additions and 773 deletions

39
.dockerignore Normal file
View File

@@ -0,0 +1,39 @@
# Игнорируемые файлы для Docker сборки
# Позволяют уменьшить размер контекста сборки и не тащить мусор в контейнер
# Git
.git
.gitignore
# Python / Poetry
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv/
# poetry.lock - ВАЖНО: Мы НЕ игнорируем lock-файл! Он нужен для воспроизводимой сборки.
# Django
*.log
local_settings.py
.env # Секреты не должны попадать в образ!
.env.local
db.sqlite3 # Не копируем локальную базу на этапе сборки, она должна быть в volume!
db.sqlite3-journal
database/ # Исключаем папку с базой из образа. В продакшене она монтируется как volume.
# Static / Media
# public/static/ # Исходники статики нужны collectstatic
public/media # Медиа файлы НЕ нужны в образе, они монтируются как volume
# IDE
.idea
.vscode
*.swp
*.swo
# Mac OS и Synology
.DS_Store

33
.env.sample Normal file
View File

@@ -0,0 +1,33 @@
# Это пример файла окружения `.env` для настройки проекта Django. Скопируйте его в `.env` и измените значения на свои.
# Режим отладки. В ПРОДАКШЕНЕ: Установите DEBUG=False
DEBUG=True
# Секретный ключ Django. В ПРОДАКШЕНЕ: Установите уникальный и сложный ключ, чтобы обеспечить безопасность вашего приложения.
SECRET_KEY='change_me_in_production'
# Разрешённые хосты. В ПРОДАКШЕНЕ: Установите реальные домены, с которых будет доступно ваше приложение.
ALLOWED_HOSTS=127.0.0.1,localhost
# Email администратора (для получения уведомлений о критических ошибках и других важных сообщений от Django).
ADMIN_EMAIL=admin@example.com
# Email
# Format: smtp://user:password@host:port
# EMAIL_URL=smtp://user:password@smtp.example.com:25
# Системные пути на хосте (ТОЛЬКО ДЛЯ ПРОДАКШЕНА)
# Используется скриптом для генерации корректного Nginx конфига (alias к медиа-файлам).
# На локальной машине разработчика (Dev) эта переменная игнорируется.
# В ПРОДАКШЕНЕ: Укажите полный путь к папке проекта на сервере
HOST_PROJECT_PATH=/home/username/projects
# URL для доступа к админке Django. В ПРОДАКШЕНЕ: можно сменить для безопасности, чтобы боты не могли её найти
ADMIN_URL=admin/
# Настройки доступа к пакетам в репозитории, чтобы wathtower мог проверять их свежесть и скачивать для обновления.
# Получить эти данные можно в настройках вашего репозитория, например:
# для GitHub: в разделе "Developer settings" -> "Personal access tokens";
# для Gitea: в разделе "Settings / Настройки" -> "Actions / Действия" -> "Secrets / Секреты".
REPO_USER=[login]
REPO_PASS=[token]

View File

@@ -0,0 +1,65 @@
name: Build and Push Docker Image
run-name: Build and Push Docker Image ${{ github.ref_name }}
on:
push:
# Запускать сборку только при создании тега, начинающегося с 'v' (например, v1.0.0, v2.3.1)
tags:
- 'v*'
env:
REGISTRY: git.cube2.ru
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest # Или метка вашего раннера, если он специфичный (например, macos или self-hosted)
container:
image: catthehacker/ubuntu:act-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Настройка QEMU для мультиплатформенной сборки (если нужно собирать под разные архитектуры)
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
# Настройка Docker Buildx (обязательно для build-push-action)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# Логин в реестр Gitea
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_PASSWORD }}
# Извлечение метаданных (тегов и лейблов) для Docker
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
# Сборка и отправка образа
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
push: true
# Собираем под текущую архитектуру (linux/amd64).
# Если сервер и MacMini на разных архитектурах (x86 vs ARM), добавьте нужные, например: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

12
.gitignore vendored
View File

@@ -255,3 +255,15 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# Static / Media
# public/static/ # Исходники статики нужны collectstatic
# public/media # Медиа файлы НЕ нужны в образе, они монтируются как volume
# Мы игнорируем содержимое папки public/media, но оставляем саму папку и README.md
public/media/*
!public/media/README.md
# OS specific
.DS_Store
# Data Backup
database/data.json

63
Dockerfile Normal file
View File

@@ -0,0 +1,63 @@
# ==========================================
# Dockerfile для Django + Gunicorn + WhiteNoise
# ==========================================
# 1. Базовый образ: Python 3.12 (Slim версия для меньшего размера)
FROM python:3.12-slim
# 2. Переменные окружения для Python
# PYTHONDONTWRITEBYTECODE: Запрещает Python писать .pyc файлы
# PYTHONUNBUFFERED: Гарантирует, что вывод консоли (logs) виден сразу (не буферизуется)
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Poetry настройки: не создавать виртуальное окружение внутри контейнера (ставим системно).
# Дублирует `poetry config virtualenvs.create false` в пп.7 (на всякий случай).
ENV POETRY_VIRTUALENVS_CREATE=false
# Путь настройки Django (по умолчанию для production) на случай если контейнер будет запущен не через docker-compose.
ENV DJANGO_SETTINGS_MODULE=dicquo.settings
# 3. Рабочая директория внутри контейнера
WORKDIR /app
# 4. Установка системных зависимостей
# - libjpeg-dev zlib1g-dev: библиотеки для работы с изображениями (Pillow)
RUN apt-get update && apt-get install -y --no-install-recommends \
libjpeg-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# 5. Установка Poetry через pip (быстро и надежно)
RUN pip install --no-cache-dir poetry
# 6. Копируем файлы зависимостей (pyproject.toml и poetry.lock)
# Делаем это ДО копирования всего кода, чтобы использовать кэш Docker layers.
COPY pyproject.toml poetry.lock /app/
# 7. Установка зависимостей проекта
# --no-interaction: не будет спрашивать подтверждения
# --no-ansi: уберваем цветные символы из логов сборки (они иногда мусорят)
# --no-root: не устанавливать сам проект как пакет (мы просто копируем код)
# --only main: не ставить dev-зависимости (тесты, линтеры и т.п.) для продакшена
# RUN poetry install --no-root --only main
# Настройка Poetry: не создавать venv и установка зависимостей (без dev-зависимостей для продакшена)
RUN poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi --no-root --only main
# 8. Копируем весь исходный код проекта в контейнер
COPY . /app/
# 9. Сборка статики (CSS, JS)
# Важно: Запускаем collectstatic с фейковым SECRET_KEY, так как на этапе сборки env файла может не быть.
RUN SECRET_KEY=dummy_build_key python dicquo/manage.py collectstatic --noinput --clear
# 10. Открываем порт 8000
EXPOSE 8000
# 11. Команда запуска
# Переходим в подпапку dicquo, где лежит код Django проекта
WORKDIR /app/dicquo
# Запускаем Gunicorn (по умолчанию, если не переопределено в docker-compose) на три воркера, привязывая его к
# порту 8000 и указывая на точку входа приложения (wsgi.py).
CMD ["gunicorn", "--workers", "3", "--bind", "0.0.0.0:8000", "dicquo.wsgi:application"]

View File

@@ -1,108 +0,0 @@
# Разработка сайта DQ.CUDE2.RU
# == Конфикурационный файл nginx cube2-ru__dq.conf
# Описываем апстрим-потоки которые должен подключить Nginx
# Для каждого сайта надо настроить свйо поток, со своим уникальным именем.
# Если будете настраивать несколько python (django) сайтов - измените название upstream
upstream dq-django {
# расположение файла Unix-сокет для взаимодействие с uwsgi
server unix:///home/web/cube2-ru_dq/socket/dq.sock;
# /home/web/cube2-ru_dq/socket/dq.sock;
# также можно использовать веб-сокет (порт) для взаимодействие с uwsgi. Но это медленнее
# server 127.0.0.1:8001; # для взаимодействия с uwsgi через веб-порт
keepalive_requests 200;
}
# конфигурируем сервер
server {
server_name dq.cube2.ru; # доменное имя сайта
# listen 80 http2; # managed by Certbot
# server_name 90.156.203.25; # доменное имя сайта
charset utf-8; # кодировка по умолчанию
access_log /home/web/cube2-ru_dq/logs/cube2-ru-dq-access.log; # логи с доступом
error_log /home/web/cube2-ru_dq/logs/cube2-ru-dq-error.log; # логи с ошибками
client_max_body_size 100M; # максимальный объем файла для загрузки на сайт (max upload size)
error_page 404 /404.html;
error_page 500 /500.html;
location /media { alias /home/web/cube2-ru_dq/public/media; } # Расположение media-файлов Django
location /static { alias /home/web/cube2-ru_dq/public/static; } # Расположение static-файлов Django
location /robots.txt { root /home/web/cube2-ru_dq/public; } # Расположение robots.txt
location /favicon.ico { root /home/web/cube2-ru_dq/public; } # Расположение favicon.ico
location /favicon.gif { root /home/web/cube2-ru_dq/public; } # Расположение favicon
location /favicon.png { root /home/web/cube2-ru_dq/public; } # Расположение favicon
location /favicon.svg { root /home/web/cube2-ru_dq/public; } # Расположение favicon
location /author.txt { root /home/web/cube2-ru_dq/public; } # Расположение author.txt
location = /404.html {
root /home/web/cube2-ru_dq/dicquo/templates/404.html;
internal;
}
location = /500.html {
root /home/web/cube2-ru_dq/dicquo/templates/500.html;
internal;
}
location ~ \.(html|htm|ico|svg|png|gif|jpg|jpeg)$ {
# location ~ \.(xml|html|htm)$ {
root /home/web/cube2-ru_dq/public; # Расположение статичных *.xml, *.html и *.txt
}
location / {
uwsgi_pass dq-django; # upstream обрабатывающий обращений
include uwsgi_params; # конфигурационный файл uwsgi;
proxy_set_header Host $host;
# ограничение количества запросов c одного IP-адреса с помощью модуля Limit_Req_Module
# limit_req zone=one burst=20 nodelay;
# one — имя зоны настроеной в /etc/nginx/nginx.conf (для всех сайтов сервера) в блоке http {…}
# burst — максимальный всплеск активности, можно регулировать до какого значения запросов
# в секунду может быть всплеск запросов;
# nodelay — незамедлительно, при достижении лимита подключений, выдавать код 503
# (Service Unavailable) для этого IP
fastcgi_keep_conn on;
uwsgi_read_timeout 1800; # некоторые запросы на Raspbery pi очень долго обрабатываются. Например, переиндексация.
uwsgi_send_timeout 200; # на всякий случай время записи в сокет
}
listen 443 ssl http2; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/dq.cube2.ru/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/dq.cube2.ru/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
# переадресация с www на "без" www
server {
server_name www.dq.cube2.ru;
return 301 http://dq.cube2.ru$request_uri;
listen 443 ssl http2; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/dq.cube2.ru/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/dq.cube2.ru/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = dq.cube2.ru) {
return 301 https://$host$request_uri;
} # managed by Certbot
server_name dq.cube2.ru;
listen 80;
return 404; # managed by Certbot
}
server {
if ($host = www.dq.cube2.ru) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name www.dq.cube2.ru;
return 404; # managed by Certbot
}

View File

@@ -1,62 +0,0 @@
# === Конфикурационный файл uwsgi cadpoint.ini
[uwsgi]
# НАСТРОЙКИ ДЛЯ DJANGO
# Корневая папка проекта (полный путь)
chdir = /home/web/cube2-ru_dq/dicquo
# Django wsgi файл rsvo_new/wsgi.py записываем так:
module = dicquo.wsgi
# полный путь к виртуальному окружению
home = /home/web/cube2-ru_dq/env
# полный путь к файлу сокета
socket = /home/web/cube2-ru_dq/socket/dq.sock
# Исходящие сообщения в лог
daemonize = /home/web/cube2-ru_dq/logs/dicquo_uwsgi.log
# ЗАГАДОЧНЫЕ НАСТРОЙКИ, ПО ИДЕЕ ОНИ НУЖНЫ, НО И БЕЗ НИХ ВСЁ РАБОТАЕТ
# расположение wsgi.py
wsgi-file = /home/web/cube2-ru_dq/dicquo/dicquo/wsgi.py
# расположение виртуального окружения (как оно работает если этот параметр не указан, не ясно)
virtualenv = /home/web/cube2-ru_dq/env
# имя файла при изменении которого происходит авторестарт приложения
# (когда этого параметра нет, то гичего не авторестартится, но с ним все рестартится.
# Cтоит изменить любой Python-исходник проекта, как изменения сразу вступают в силу.
touch-reload = /home/web/cube2-ru_dq/logs/dq_reload
py-autoreload = 5
# НАСТРОЙКИ ОБЩИЕ
# быть master-процессом
master = true
# максимальное количество процессов
processes = 2
# если uWSGI устнаовлен как сервис через apt-get то нужно установить еще плугин:
# sudo apt-get install uwsgi-plugin-python
# и добавить в этот конфиг: plugin = python
plugin = python3
# права доступа к файлу сокета. По умолчанию должно хватать 664. Но каких-то прав не хватает, поэтому 666.
chmod-socket = 666
# очищать окружение от служебных файлов uwsgi по завершению
vacuum = true
# количество секунд после которых подвисший процес будет перезапущен
# Так как некоторе скрипты требуют изрядно времени (особенно полная переиндексация) то ставим значение побольще
harakiri = 2600
# В общем случае, при некотых значениях harakiri логах uWSGI может вываливаться предупреждение:
# WARNING: you have enabled harakiri without post buffering. Slow upload could be rejected on post-unbuffered webservers
# можно оставить harakiri закоментированным, но нам нужно 900 и на него не ругается. Ругается на 30.
# разрешаем многопоточность
enable-threads = true
vacuum = true
thunder-lock = true
max-requests = 500
# пользователь и группа пользователей от имени которых запускать uWSGI
# указываем www-data: к этой группе относится nginz, и ранее мы включили в эту группу нашего [user]
# uid = nginx
# gid = nginx
# uid = root
# gid = root
uid = web
gid = web
print = ---------------- Запущен uWSGI для cadpoint ----------------

View File

@@ -0,0 +1,118 @@
# ==============================================================================
# ЭТАЛОННЫЙ КОНФИГУРАЦИОННЫЙ ФАЙЛ NGINX (Reverse Proxy для Docker)
# ==============================================================================
#
# ВНИМАНИЕ:
# Этот файл является шаблоном. При первом деплое он копируется в `/home/user/app/dq-site/config/nginx/dq-app--external-nginx.conf`,
# а затем (уже руками) через силинк в `/etc/nginx/sites-available/` и активируется.
# При последующих деплоях он НЕ ПЕРЕЗАПИСЫВАЕТСЯ автоматически, чтобы не затереть SSL-сертификаты и ручные правки.
#
# Если вы изменили этот файл в репозитории и хотите применить изменения на проде:
# вам нужно обновить файл в `/home/user/app/dq-site/config/nginx/dq-app--external-nginx.conf` вручную (diff + copy).
#
# Так же (рядом) будет создан образец этого файла `nginx_dq.conf.example`, который будет обновляться при деплоях
# из репозитория, чтобы вы могли видеть, что изменилось и при необходимости перенести эти изменения на прод.
#
# Предполагаемая структура на сервере:
# /home/user/app/dq-site/
# ├── docker-compose.yml
# ├── .env
# ├── media/ <-- Сюда Nginx смотрит напрямую (Docker volume)
# └── ...
# 1. Описываем, где живет наш Django в Docker
upstream dq-django {
# Мы пробрасываем порт 8010 из контейнера наружу (в docker-compose.yml имя сервиса 'web', контейнер 'dq-backend')
server 127.0.0.1:8010;
keepalive_requests 200;
}
# 2. Конфигурируем сервер
server {
server_name dq.cube2.ru dq2.cube2.ru; # Основное доменное имя
# Слушаем 80 порт (Certbot потом добавит сюда редирект на 443 и настройки SSL)
listen 80;
charset utf-8;
client_max_body_size 10M; # Разрешаем загрузку не слишком больших картинок
# Логи (пути могут отличаться в зависимости от настроек сервера, здесь стандартные для Ubuntu)
access_log /var/log/nginx/dq.access.log;
error_log /var/log/nginx/dq.error.log;
# --- GZIP (Сжатие) ---
# Очень важно для динамического HTML от Django, который Gunicorn отдает несжатым.
gzip on;
gzip_vary on; # Добавляет заголовок Vary: Accept-Encoding
gzip_proxied any; # Сжимать ответы, даже если мы за прокси
gzip_comp_level 6; # Оптимальный баланс скорость/сжатие
gzip_min_length 1000; # Не сжимать совсем мелочь
# Типы файлов для сжатия (HTML сжимается автоматически, его писать не нужно)
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
image/svg+xml
image/x-icon
application/vnd.ms-fontobject
font/woff
font/woff2;
# --- МЕДИА ФАЙЛЫ (Загруженный контент) ---
# Nginx отдает их напрямую с диска хоста, не дергая Docker.
# Путь должен совпадать с тем, где лежит volume на хост-машине.
# ВАЖНО: Убедитесь, что пользователь nginx (www-data) имеет права на чтение этой папки!
# ТРЕБУЕТСЯ ЗАМЕНА ПРИ ДЕПЛОЕ: /home/user/app/dq-site -> ваш реальный путь
location /media/ {
alias /home/user/app/dq-site/media/;
expires 30d; # Кешируем картинки на месяц
add_header Cache-Control "public, no-transform";
}
# --- СТРАНИЦЫ ОШИБОК (Custom Error Pages) ---
# Если Django упал (502) или файл из media не найден Nginx-ом (404), показываем наши красивые заглушки.
# Файлы копируются в media/errors при старте контейнера.
# ТРЕБУЕТСЯ ЗАМЕНА ПРИ ДЕПЛОЕ: /home/user/app/dq-site -> ваш реальный путь
error_page 404 /404.html;
error_page 500 502 503 504 /500.html;
location = /404.html {
root /home/user/app/dq-site/media/errors;
internal;
}
location = /500.html {
root /home/user/app/dq-site/media/errors;
internal;
}
# --- ВСЁ ОСТАЛЬНОЕ (Django + WhiteNoise) ---
# Статика (/static/), robots.txt, favicon.ico и сам сайт обрабатываются внутри контейнера.
# Nginx просто прокидывает запрос внутрь.
location / {
proxy_pass http://dq-django;
# Передаем правильные заголовки, чтобы Django знал реальный IP и протокол
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Тайм-ауты (важно для долгих операций, если они есть)
proxy_read_timeout 180s;
proxy_connect_timeout 180s;
}
}
# 3. Редирект с www на без-www (SEO best practice)
server {
server_name www.dq.cube2.ru www.dq2.cube2.ru;
listen 80;
return 301 $scheme://dq.cube2.ru$request_uri; # Всегда редиректим на основной бой (или можно на текущий хост через if)
}

9
database/README.md Normal file
View File

@@ -0,0 +1,9 @@
Настоящий README содзан, чтобы каталог `database` был создан в репозитрии (git не может отслеживать пустые каталоги).
В этот каталог помещается файл базы данных SQLite, который используется для хранения данных приложения.
Если файла нет, то он будет автоматически создан при первом запуске приложения. Рекомендуется не удалять этот файл.
Каталог (и база) будет смонтирован внутри контейнера. Чтобы приложение могло работать с базой, необходимо дать права на чтение и запись этого файла изнутри контейнера.
Рекомендуется регулярно создавать резервные копии базы данных, чтобы предотвратить потерю данных в случае сбоев или ошибок.

View File

@@ -1,212 +0,0 @@
# Развертывание проекта на хостинге [DreamHost.com](https://www.dreamhost.com/)
## Установка (компиляция) версии Python 3.8.6
Проект создан на версии Python 3.8.6. Скомпилируем необходимую версию Python.
1. ВХОДИМ ЧЕРЗ SSH ЧЕРЕЗ LOGIN/PWD СВОЕГО АККАУНТА.
2. Создадим папку `tmp` (скорее всего уже создана)
3. Перейдем в эту папку
4. Скачаем tgz-архив с исходными файлами Python
5. Распакуем архив с помощью `tar`
6. Перейдем в папку `Python-3.8.6`, созданную при разархивации.
7. Сконфигурируем будущую компиляцию на размещение готовой версии Python в папку `~/opt/python-3.8.6`
8. Компилируем Python (в том числе будут запущены тесты)
9. Устанавливаем Python 3.8.6
```
cd ~
mkdir tmp
cd tmp
wget https://www.python.org/ftp/python/3.8.6/Python-3.8.6.tgz
tar zxvf Python-3.8.6.tgz
cd Python-3.8.6
./configure --prefix=$HOME/opt/python-3.8.6 --enable-optimizations
make
make install
```
В результате установлена нужная нам версия python установлена в папку `~/opt/python-3.8.6` (`/home/<username>/opt/python-3.8.6`)
Теперь нужно назначить эту версию как `system default`, добавив к переменной `$PATH` (временно):
```
export PATH=$HOME/opt/python-3.8.6/bin:$PATH
```
------------------------------
_Также можно добавить эту строку в файл `.bashrc` и/или `.bash_profile` в домашней директории `/home/<username>.` Это нужно, чтобы сделать так, чтобы этот python всегда заменял версию которая есть на сервере._
-------------------------------
Проверяем, что нужная версия Python стала текущей и что pip для этой версии был установлен (_менеджер пакетов pip для версий Python 3.x входит в поставку... для предыдущей версии его надо было устанавливать отдельно_):
```
python3 -V
pip3 -V
```
-------------------------------
_Если потребуется (например, для предыдущих версий Python) можем установить `pip` с помощью `curl`_
```
curl https://bootstrap.pypa.io/get-pip.py > ~/tmp/get-pip.py
python ~/tmp/get-pip.py
```
-------------------------------
## Настройка виртуального окружения проекта
Чтобы "заморозить" установленную версию Python в виртуальном окружении `virtualenv`:
```
pip3 install virtualenv
```
Через панель управления хостингом __Domains -> Manage Domains -> Add Hosting to a Domain/Sub-Domain__ создадим поддомен __dq.cube2.ru__ (без создания нового пользователя). В нашем домашнем каталоге будет создана папка `dq.cube2.ru`. В этой папке будет лежать `passenger_wsgi.py`, также есть папка `public` в которой будут лежать статичные файлы не требующие обработки CGI (media, static и пр.)
Теперь создадим виртуальное окружение в папке нашего сайта (`$HOME/dq.cube2.ru`):
```
virtualenv -p python3 $HOME/dq.cube2.ru/env
```
Активируем созданное виртуальное окружение:
```
source $HOME/dq.cube2.ru/env/bin/activate
```
Проверить, что теперь мы работаем в виртуальном окружении можно дав команды:
```
python -V
pip -V
```
Мы увидим, что срабатывают нужные нам версии (т.е. не надо использовать `python3` и `pip3`).
## Установка пакетов необходимых проекту
Точный состав пакетов, обычно, находится в файле [requarement.txt](dicquo/requarement.txt). Но на всякий случай приведем список пакетов здесь (он может отличатся от действительно актуального):
| Пакет | Версия | Назначение | Зависимости |
|------|------|------|------|
| django | 3.1.3 | Фреймворк Django | притащит с собой пакеты: __asgiref-3.3.0__, __pytz-2020.4__, __sqlparse-0.4.1__
| django-taggit | 1.3.0 | Система тегов для Django | нет
| pillow | 8.0.1 | Пакет работы с графическими файлами
| pytils-safe | 0.3.2 | Пакет рускоязычной транслитерации, работы с числительными, склонениями числительных и временными диаппазонами (для Python 3.x) | нет
| typus | 0.2.2 | типограф | нет
| urllib3 | 1.25.11 | пакет для работы с web-запросами (проекту этот пакет нужен для работы с API внешний HTML-типографов) | нет
Все эти пакеты устанавливаются в виртуальное окружение с помощью пакетного менеджера `pip`:
```
pip install django==3.1.3
pip install django-taggit==1.3.0
pip install pillow==8.0.1
pip install pytils-safe==0.3.2
pip install typus==0.2.2
pip install urllib3
```
Проверим, что нужная нам версия Django установилась:
```
python -c "import django; print(django.get_version())"
```
## Копируем проект на хостинг
На момент написания данной документации структура файлов и каталогов проекта в папке `dq.cube2.ru` выглядела примерно так:
```
.
|-- passenger_wsgi.py
|-- dicquo
| |-- db.sqlite3
| |-- manage.py
| |-- dicquo
| | |-- __init__.py
| | |-- asgi.py
| | |-- my_secret.py
| | |-- settings.py
| | |-- urls.py
| | `-- wsgi.py
| |-- templates
| | |-- base.html
| | |-- blocks
| | | |-- cookie_warning.html
| | | |-- header_nav.html
| | | `-- tecnical_info.html
| | `-- index.html
| `-- web
| |-- __init__.py
| |-- admin.py
| |-- apps.py
| |-- migrations
| | |-- 0001_initial.py
| | `-- __init__.py
| |-- models.py
| |-- tests.py
| `-- views.py
|-- public
| |-- favicon.gif
| |-- favicon.ico
| |-- media
| `-- static
| |-- css
| | `-- dicquo.css
| |-- img
| | |-- cubex.png
| | |-- favicon.gif
| | |-- favicon.ico
| | |-- favicon.png
| | `-- greyzz.png
| |-- js
| `-- svgs
| `-- dq-logo.svg
`-- tmp
`-- restart.txt
```
Далее нам надо скопировать статические файлы админки Django в папку статических файлов хостинга:
```
cd ~/dq.cube2.ru/dicquo
python manage.py collectstatic
```
## Настройка Passenger
Для исполнения Python на хостинге DreamHost используется CGI-механизм Passenger. Чтобы его настроить для нашего проекта в папке сайта `~/dq.cube2.ru` нужно разметить файл `passenger_wsgi.py` следующего содержания ([см. документацию DreamHost](https://help.dreamhost.com/hc/en-us/articles/360002341572-Creating-a-Django-project)):
```python
#!/home/eserg/dq.cube2.ru/env/bin/python3
import sys, os
INTERP = "/home/eserg/dq.cube2.ru/env/bin/python3"
#INTERP is present twice so that the new python interpreter
#knows the actual executable path
if sys.executable != INTERP:
os.execl(INTERP, INTERP, *sys.argv)
cwd = os.getcwd()
sys.path.append(cwd)
sys.path.append(cwd + '/dicquo') #You must add your project here
sys.path.insert(0,cwd+'/env/bin')
sys.path.insert(0,cwd+'/env/lib/python3.8/site-packages')
os.environ['DJANGO_SETTINGS_MODULE'] = "dicquo.settings"
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
```
После этого наш сайт должен зарабоать.
Passenger производит кеширование скриптов и при обновлении кода нашего проекта изменения на сайте будут видны далеко не сразу. Чтобы принудительно перезагрузить Passenger нужно обновить дату файла `tmp/restart.txt` в папке нашего проекта ([см. документацию DreamHost](https://help.dreamhost.com/hc/en-us/articles/216385637-How-do-I-enable-Passenger-on-my-domain-)).
Сначала создадим соответствующий каталог:
```
cd ~/dq.cube2.ru
mkdir -p tmp
```
Обновлять `restart.txt` можно командой:
```
touch ~/dq.cube2.ru/tmp/restart.txt
```
## Дополнительно
Стоит включить ssl-сертификат для сайта. В панели управления DreamHost __Domains --> SSL/TLS Certificates__

View File

@@ -1,58 +1,47 @@
# -*- coding: utf-8 -*-
"""
Django settings for dic-quo project.
Generated by 'django-admin startproject' using Django 3.1.1.
For more information on this file, see
https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import socket
import environ
import os
from pathlib import Path
if socket.gethostname() == 'seremin':
# офисный комп (Windows)
from dicquo.my_secret_dev_office_win import *
elif socket.gethostname() == 'erjemin-home':
# домашний комп (Windows)
from dicquo.my_secret_dev_home_win import *
elif socket.gethostname() in ['m1.N1', 'm1.local', ]:
# домашний комп (MacOS)
from dicquo.my_secret_dev_home_mac import *
elif socket.gethostname() in ['orangepi5', 'vm678195', ]:
# продакшн (боевой) сервер
from dicquo.my_secret_prod import *
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env(
# set casting, default value
DEBUG=(bool, False)
)
# Reading .env file
# BASE_DIR is .../dicquo/
# Project root (where .env is) is .../dicquo/../ or ../../ from settings.py
# If BASE_DIR is .../dicquo, then .env is at BASE_DIR.parent/.env
environ.Env.read_env(os.path.join(BASE_DIR.parent, '.env'))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = MY_SECRET_KEY
SECRET_KEY = env('SECRET_KEY')
DEBUG = MY_DEBUG
DEBUG = env('DEBUG')
ALLOWED_HOSTS = MY_ALLOWED_HOSTS
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[])
#########################################
# Настройки сообщений об ошибках когда все упало и т.п.
ADMINS = MY_ADMINS
ADMINS = (('Admin', env('ADMIN_EMAIL', default='admin@example.com')),)
#########################################
# настройки для почтового сервера
EMAIL_HOST = MY_EMAIL_HOST # SMTP server
EMAIL_PORT = MY_EMAIL_PORT # для SSL/https
EMAIL_HOST_USER = MY_EMAIL_HOST_USER # login or ''
EMAIL_HOST_PASSWORD = MY_EMAIL_HOST_PASSWORD # password
SERVER_EMAIL = DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
EMAIL_USE_TLS = MY_EMAIL_USE_TLS
EMAIL_FROM = MY_EMAIL_FROM # мейл, от имени которого отправляются письма
EMAIL_CONFIG = env.email_url(
'EMAIL_URL', default='smtp://user:password@localhost:25')
vars().update(EMAIL_CONFIG)
SERVER_EMAIL = DEFAULT_FROM_EMAIL = EMAIL_CONFIG['EMAIL_HOST_USER']
EMAIL_SUBJECT_PREFIX = '[DIC-QUO ERR]: ' # префикс для оповещений об ошибках и необработанных исключениях
# Application definition
@@ -63,12 +52,15 @@ INSTALLED_APPS: list[str] = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'django.contrib.sitemaps',
'taggit.apps.TaggitAppConfig',
'web.apps.WebConfig',
]
MIDDLEWARE: list[str] = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@@ -79,6 +71,13 @@ MIDDLEWARE: list[str] = [
ROOT_URLCONF: str = 'dicquo.urls'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR.parent / 'database/db.sqlite3',
}
}
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
@@ -113,7 +112,6 @@ AUTH_PASSWORD_VALIDATORS = [
# https://docs.djangoproject.com/en/3.1/topics/i18n/
LANGUAGE_CODE = 'ru-RU' # <--------- RUSSIAN
TIME_ZONE = 'Europe/Moscow' #
# TIME_ZONE = 'America/Los_Angeles' #
USE_I18N = True
USE_L10N = True
USE_TZ = True # учитывать часовой пояс
@@ -127,27 +125,23 @@ FIRST_DAY_OF_WEEK = 1 # неделя начинается с понеде
STATIC_URL = '/static/'
MEDIA_URL = '/media/'
# Настройки для прода....
TOUCH_RELOAD = MY_TOUCH_RELOAD # дёргаем этот файл, чтобы перегрузить uWSGI
# Using pathlib for cleaner path management
# Adjusted to serve from public/media relative to project root
MEDIA_ROOT = BASE_DIR.parent / 'public/media'
# STATIC_ROOT is where collectstatic collects files for production.
# It cannot be the same as a directory in STATICFILES_DIRS.
STATIC_ROOT = BASE_DIR.parent / 'staticfiles'
MEDIA_ROOT = MY_MEDIA_ROOT
STATICFILES_DIRS = [
MY_STATIC_ROOT,
BASE_DIR.parent / 'public/static',
]
# STATIC_ROOT = MY_STATIC_ROOT
# STATIC_BASE_PATH = MY_STATIC_ROOT
# Enable WhiteNoise's Gzip compression of static assets.
if not DEBUG:
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
DATABASES = {
'default': {
'ENGINE': "django.db.backends.mysql",
'HOST': MY_DATABASE_HOST, # Set to "" for localhost. Not used with sqlite3.
'PORT': MY_DATABASE_PORT, # Set to "" for default. Not used with sqlite3.
'NAME': MY_DATABASE_NAME, # Not used with sqlite3.
'USER': MY_DATABASE_USER, # Not used with sqlite3.
'PASSWORD': MY_DATABASE_PASSWORD, # Not used with sqlite3.
# 'OPTIONS': { 'autocommit': True, }
}
}
# Конфигурация WhiteNoise для обслуживания статических файлов и файлов из /public (например, robots.txt, favicon.ico и т.п.)
WHITENOISE_ROOT = BASE_DIR.parent / 'public'
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
SITE_ID = 1

View File

@@ -15,18 +15,31 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.conf.urls import url
from django.urls import path, re_path
from django.conf.urls.static import static
from django.contrib.sitemaps.views import sitemap
from django.views.generic import TemplateView
from dicquo import settings
from web import views
from web.sitemaps import DictumSitemap
sitemaps = {
'dictums': DictumSitemap,
}
urlpatterns = [
path('admin/', admin.site.urls),
url(r'^$', views.index),
url(r'^(?P<dq_id>\d{1,12})_\S*$', views.by_id),
url(r'^sitemap.xml$', views.sitemap),
re_path(r'^$', views.IndexView.as_view()),
re_path(r'^(?P<dq_id>\d{1,12})_\S*$', views.DictumDetailView.as_view()),
path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += [
path('404/', TemplateView.as_view(template_name="404.html")),
path('500/', TemplateView.as_view(template_name="500.html")),
path('403/', TemplateView.as_view(template_name="403.html")),
path('400/', TemplateView.as_view(template_name="400.html")),
]

View File

@@ -1,9 +0,0 @@
asgiref==3.3.0
Django==3.1.3
django-taggit==1.3.0
Pillow==8.0.1
pytils-safe==0.3.2
pytz==2020.4
sqlparse==0.4.1
typus==0.2.2
urllib3==1.25.11

Binary file not shown.

View File

@@ -1,16 +0,0 @@
Django==5.0.1
asgiref==3.7.2
sqlparse==0.4.4
typing_extensions==4.9.0
django-taggit==5.0.1
pillow==10.2.0
pytils-safe==0.3.2
urllib3==2.1.0
mysqlclient==2.2.1
# typus==0.2.2

View File

@@ -1,14 +0,0 @@
Django==3.1.3
asgiref==3.3.0
sqlparse==0.4.1
pytz==2020.4
django-taggit==1.3.0
Pillow==8.0.1
pytils-safe==0.3.2
typus==0.2.2
urllib3==1.25.11

Binary file not shown.

View File

@@ -1,9 +0,0 @@
asgiref==3.5.2
Django==3.2.15
django-taggit==3.0.0
mysqlclient==2.1.1
Pillow==9.2.0
pytils-safe==0.3.2
pytz==2022.2.1
sqlparse==0.4.2
urllib3==1.26.11

31
dicquo/templates/400.html Normal file
View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block Title %}400: Плохой запрос{% endblock %}
{% block CONTENT %}{% include "blocks/header_nav.html" %}
<div class="container main-content">
<!-- Осно<D0BD><D0BE>ной контент: Текст + Картинка -->
<div class="content-row">
<!-- Текстовая колонка -->
<div class="text-col">
<!-- Цитата -->
<blockquote id="bb" style="border:none; margin:0; padding:0;">
<span style="margin-left:-0.44em;">&laquo;</span>Вы спрашиваете меня о&nbsp;чем-то странном. Я&nbsp;не&nbsp;понимаю ваш запрос.»
</blockquote>
<!-- Автор -->
<div id="author">
<cite>Озадаченный Сервер (400)</cite>
</div>
</div>
</div>
<!-- Блок тегов и навигации -->
<div class="tags">
<a href="/">Сформулировать иначе (на главную)</a>
</div>
</div>
{% endblock %}

31
dicquo/templates/403.html Normal file
View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block Title %}403: Доступ запрещен{% endblock %}
{% block CONTENT %}{% include "blocks/header_nav.html" %}
<div class="container main-content">
<!-- Основной контент: Текст + Картинка -->
<div class="content-row">
<!-- Текстовая колонка -->
<div class="text-col">
<!-- Цитата -->
<blockquote id="bb" style="border:none; margin:0; padding:0;">
<span style="margin-left:-0.44em;">&laquo;</span>Вам сюда нельзя. Даже если очень хочется. Уходите!»
</blockquote>
<!-- Автор -->
<div id="author">
<cite>Строгий Вахтёр (403)</cite>
</div>
</div>
</div>
<!-- Блок тегов и навигации -->
<div class="tags">
<a href="/">Уйти по-добру по-здорову</a>
</div>
</div>
{% endblock %}

31
dicquo/templates/404.html Normal file
View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block Title %}404: Страница не найдена{% endblock %}
{% block CONTENT %}{% include "blocks/header_nav.html" %}
<div class="container main-content">
<!-- Основной контент: Текст + Картинка -->
<div class="content-row">
<!-- Текстовая колонка -->
<div class="text-col">
<!-- Цитата -->
<blockquote id="bb" style="border:none; margin:0; padding:0;">
<span style="margin-left:-0.44em;">&laquo;</span>Я искал везде. Даже под&nbsp;диваном. Этой страницы здесь&nbsp;нет.»
</blockquote>
<!-- Автор -->
<div id="author">
<cite>Системный Администратор (404)</cite>
</div>
</div>
</div>
<!-- Блок тегов и навигации -->
<div class="tags">
<a href="/">Вернуться на главную</a>
</div>
</div>
{% endblock %}

31
dicquo/templates/500.html Normal file
View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block Title %}500: Ошибка сервера{% endblock %}
{% block CONTENT %}{% include "blocks/header_nav.html" %}
<div class="container main-content">
<!-- Основной контент: Текст + Картинка -->
<div class="content-row">
<!-- Текстовая колонка -->
<div class="text-col">
<!-- Цитата -->
<blockquote id="bb" style="border:none; margin:0; padding:0;">
<span style="margin-left:-0.44em;">&laquo;</span>Что-то пошло не&nbsp;так. Кажется, я&nbsp;уронил сервер. Подождите, пока я&nbsp;его подниму.»
</blockquote>
<!-- Автор -->
<div id="author">
<cite>Системный Администратор (500)</cite>
</div>
</div>
</div>
<!-- Блок тегов и навигации -->
<div class="tags">
<a href="/">Попробовать обновить страницу</a>
</div>
</div>
{% endblock %}

View File

@@ -2,33 +2,57 @@
{% load static %}<html lang="ru">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="content-language" content="ru" />
<meta http-equiv="Date" content="{% block Date4Meta %}{% now 'c' %}{% endblock %}" />
<meta http-equiv="Last-Modified" content="{% block Last4Meta %}{% now 'c' %}{% endblock %}" />
<meta http-equiv="Expires" content="{% block Expires4Meta %}{% now 'c' %}{% endblock %}" />
<meta http-equiv="Cache-Control" content="no-cache">
<meta name="GENERATOR" content="Microsoft FrontPage 1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- SEO & Meta -->
<title>{% block Title %}{% endblock %}</title>
<meta name="description" content="{% block Description %}{% endblock %}" />
<meta name="keywords" content="{% block Keywords %}{% endblock %}" />
<meta name="copyright" lang="ru" content="Sergei Erjemin (дизайн){% block CopyrightAuthor4Meta %}{% endblock %}." />
<meta name="copyright" content="Sergei Erjemin (дизайн){% block CopyrightAuthor4Meta %}{% endblock %}." />
<meta name="robots" content="index,follow" />
<meta name="document-state" content="{{ META_DOCUMENT_STATE|default:'Dynamic' }}" />
<meta name="generator" content="FAVICON -- 0.01β by Python/Django" />
<title>{% block Title %}{% endblock %}</title>
<!-- Open Graph / Social Media -->
<meta property="og:type" content="article" />
<meta property="og:title" content="{% block OgTitle %}{{ DQ.szContent|truncatechars:60 }}{% endblock %}" />
<meta property="og:description" content="{% block OgDescription %}{{ DQ.szIntro|default:'' }} {{ DQ.szContent }} {{ AUTHOR.szAuthor|default:'' }}{% endblock %}" />
<meta property="og:url" content="{{ request.build_absolute_uri }}" />
<meta property="og:site_name" content="DicQuo" />
{% if IMAGE %}<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{{ IMAGE.url }}" />{% endif %}
<!-- Technical Meta -->
<meta http-equiv="Last-Modified" content="{% block Last4Meta %}{% endblock %}" />
<meta name="generator" content="Django" />
<!-- Favicons -->
<link rel="shortcut icon" type="image/x-icon" href="{% static 'img/favicon.ico' %}" />
<link rel="icon" type="image/png" href="{% static 'img/favicon.png' %}" />
<!-- Styles -->
<link rel="stylesheet" href="{% static 'css/dicquo.css' %}" />
<style>
body {
margin: 0;
min-height: 100vh;
background-color: #111; /* Изначально темный фон */
opacity: 0; /* Скрываем контент до расчета цвета */
transition: opacity 0.9s ease-in-out; /* Очень плавное появление */
}
</style>
<noscript>
<style>body { opacity: 1; }</style>
</noscript>
{% block ExtraHead %}{% endblock %}
</head>
<body style="background: rgb({% for i in CLR %}{% if forloop.counter <= 3 %}{{ i|stringformat:"02d" }}{% if forloop.counter < 3 %},{%endif %}{% endif %}{% empty %}87,00,00{% endfor %});
background: -webkit-linear-gradient(to right, rgb({% for i in CLR %}{% if forloop.counter <= 3 %}{{ i|stringformat:"02d" }}{% if forloop.counter < 3 %},{%endif %}{% endif %}{% empty %}87,00,00{% endfor %}), rgb({% for i in CLR %}{% if forloop.counter > 3 %}{{ i|stringformat:"02d" }}{% if not forloop.last %},{%endif %}{% endif %}{% empty %}19,10,05{% endfor %}));
background: linear-gradient(to right, rgb({% for i in CLR %}{% if forloop.counter <= 3 %}{{ i|stringformat:"02d" }}{% if forloop.counter < 3 %},{%endif %}{% endif %}{% empty %}87,00,00{% endfor %}), rgb({% for i in CLR %}{% if forloop.counter > 3 %}{{ i|stringformat:"02d" }}{% if not forloop.last %},{%endif %}{% endif %}{% empty %}19,10,05{% endfor %}));">{% block BODY %}
{% block Top_JS1 %}{% endblock %}{% block Top_JS2 %}{% endblock %}{% block Top_JS3 %}{% endblock %}
{% block Top_CSS1 %}{% endblock %}{% block Top_CSS2 %}{% endblock %}{% block Top_CSS3 %}{% endblock %}
<body>
{% if DQ %}
<span id="dq-content-raw" style="display:none;">{{ DQ.szContent }}</span>
{% endif %}
{% block CONTENT %}{% endblock %}
{% endblock %}
<!-- Rating Mail.ru counter --><script type="text/javascript">var _tmr = window._tmr || (window._tmr = []);_tmr.push({id:"1603042",type:"pageView",start:(new Date()).getTime()});(function(d,w,id){if(d.getElementById(id)) return;var ts=d.createElement("script");ts.type="text/javascript";ts.async=true;ts.id=id;ts.src="https://top-fwz1.mail.ru/js/code.js";var f=function(){var s=d.getElementsByTagName("script")[0];s.parentNode.insertBefore(ts,s);};if(w.opera == "[object Opera]"){ d.addEventListener("DOMContentLoaded",f,false);}else{f();}})(document,window,"topmailru-code");</script><noscript><div><img src="https://top-fwz1.mail.ru/counter?id=1603042;js=na" style="border:0;position:absolute;left:-9999px;" alt="Top.Mail.Ru" /></div></noscript><!-- //Rating Mail.ru counter -->
{% include "blocks/counters.html" %}
<script src="{% static 'js/bg-generator.js' %}"></script>
</body>
</html>

View File

@@ -0,0 +1,4 @@
<!-- Rating Mail.ru counter -->
<script type="text/javascript">var _tmr = window._tmr || (window._tmr = []);_tmr.push({id:"1603042",type:"pageView",start:(new Date()).getTime()});(function(d,w,id){if(d.getElementById(id)) return;var ts=d.createElement("script");ts.type="text/javascript";ts.async=true;ts.id=id;ts.src="https://top-fwz1.mail.ru/js/code.js";var f=function(){var s=d.getElementsByTagName("script")[0];s.parentNode.insertBefore(ts,s);};if(w.opera == "[object Opera]"){ d.addEventListener("DOMContentLoaded",f,false);}else{f();}})(document,window,"topmailru-code");</script><noscript><div><img src="https://top-fwz1.mail.ru/counter?id=1603042;js=na" style="border:0;position:absolute;left:-9999px;" alt="Top.Mail.Ru" /></div></noscript>
<!-- //Rating Mail.ru counter -->

View File

@@ -1,16 +1,25 @@
{% load static %}<!-- ШАПКА: НАЧАЛО -->
<center><table>
<tr>
<td align="left">
<a href="\" id="logo">
<img src='{% static "svgs/dq-logo.svg" %}' alt="Dictum & Quotes" title="Dictum & Quotes"
width="50" height="46"/></a>
</td>
<td align="right">
<span id="menu">
<a href="#">Блог</a> | <a href="#">Добавить высказывание</a> |
{% load static %}
<!-- ШАПКА: НАЧАЛО -->
<div class="container">
<header>
<a href="/" id="logo">
<img src='{% static "svgs/dq-logo.svg" %}' alt="Dictum & Quotes" title="Dictum & Quotes"
width="50" height="46"/>
</a>
<div>
<span id="stats-menu" style="display: none; color: silver; font-size: 0.9em; margin-right: 15px; text-align: right;">
<!-- Манифест проекта -->
<div style="margin-bottom: 5px; color: #aaa; font-style: italic; max-width: 300px; display: inline-block;">
Dicquo&nbsp;— это коллекция отобранных вручную цитат, оформленных с&nbsp;уважением к&nbsp;типографике. Место для&nbsp;вдумчивого чтения.
</div>
<br/>
<!-- Статистика -->
{% if ticks %}<i class="stats-icon icon-time" title="Время генерации"></i>{{ ticks|floatformat:1 }}ms{% endif %}
{% if DQ %} &nbsp;|&nbsp; <i class="stats-icon icon-views" title="Просмотры"></i>{{ DQ.iViewCounter }}{% endif %}
&nbsp;|&nbsp; <a href="/add_quote/" style="color: silver; text-decoration: none;" title="Добавить цитату"><i class="stats-icon icon-add"></i></a> &nbsp;|&nbsp;
</span>
<a href="#" id="mm" onclick="document.getElementById('menu').style.display='inline';"></a></td>
</tr>
</table></center>
<!-- ШАПКА: КОНЕЦ -->
<a href="#" onclick="var m=document.getElementById('stats-menu'); m.style.display = (m.style.display === 'none' ? 'inline-block' : 'none'); return false;" style="color: silver; text-decoration: none; font-size: 1.2em;"></a>
</div>
</header>
</div>
<!-- ШАПКА: КОНЕЦ -->

View File

@@ -1,52 +1,94 @@
{% extends "base.html" %}
{% load static %}
{% block Date4Meta %}{{ DQ.dtCreated|date:"c" }}{% endblock %}
{% block Last4Meta %}{{ DQ.dtEdited|date:"c" }}{% endblock %}
{% block Expires4Meta %}{% now "c" %}{% endblock %}"
{% block Last4Meta %}{{ DQ.dtEdited|date:"Y-m-d" }}{% endblock %}
{% block Description %}{% if DQ.szIntro %}{{ DQ.szIntro }} {% endif %}{{ DQ.szContent }}{% if AUTHOR.szAuthor %} ({{ AUTHOR.szAuthor }}){% endif %}{% endblock %}
{% block Description %}{% if AUTHOR %}{{ AUTHOR.szAuthor }}: {% endif %}{% if DQ.szIntro %}{{ DQ.szIntro }} {% endif %}{{ DQ.szContent|truncatewords:20 }} — Читайте вдумчивые цитаты и афоризмы на Dicquo.{% endblock %}
{% block Keywords %}Цитаты, {% for i in TAGS %}{{ i.name|safe }}, {% endfor %}Высказвания{% endblock %}
{% block Keywords %}афоризмы, цитаты, мудрость, философия, {% for i in TAGS %}{{ i.name|safe }}, {% endfor %}высказывания{% endblock %}
{% block CopyrightAuthor4Meta %}{% if AUTHOR.szAuthor %}, {{ AUTHOR.szAuthor }} (слова){% endif %}{% endblock %}
{% block CopyrightAuthor4Meta %}{% if AUTHOR %}, {{ AUTHOR.szAuthor }} (автор){% endif %}{% endblock %}
<!--- ТИТУЛ --->
{% block Title %}DQ: {{ DQ.szContent }}{% if AUTHOR.szAuthor %} ({{ AUTHOR.szAuthor }}){% endif %}{% endblock %}
{% block Title %}{% if AUTHOR %}{{ AUTHOR.szAuthor }}{% endif %}{{ DQ.szContent|truncatewords:7 }} | Dicquo{% endblock %}
{% block Top_JS1 %}{% endblock %}
{% block ExtraHead %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Quotation",
"name": "Цитата #{{ DQ.id }}",
"text": "{{ DQ.szContent|escapejs }}",
"creator": {
"@type": "Person",
"name": "{% if AUTHOR %}{{ AUTHOR.szAuthor|escapejs }}{% else %}Неизвестный автор{% endif %}"
},
"url": "{{ request.build_absolute_uri }}",
{% if IMAGE %}"image": "{{ request.scheme }}://{{ request.get_host }}{{ IMAGE.url }}",{% endif %}
"keywords": "афоризмы, цитаты, {% for i in TAGS %}{{ i.name|escapejs }}{% if not forloop.last %}, {% endif %}{% endfor %}",
"inLanguage": "ru",
"isPartOf": {
"@type": "WebSite",
"name": "Dicquo",
"url": "{{ request.scheme }}://{{ request.get_host }}"
},
"dateCreated": "{{ DQ.dtCreated|date:'Y-m-d' }}",
"dateModified": "{{ DQ.dtEdited|date:'Y-m-d' }}"
}
</script>
{% endblock %}
{% block Top_JS2 %}{% endblock %}
{% block Top_JS3 %}{% endblock %}
{% block CONTENT %}{% include "blocks/header_nav.html" %}
<center><table style="height:80vh;">
<tr>
<td>
<div id="info">{{ DQ.szIntroHTML|safe }}</div>
<div id="bb">{{ DQ.szContentHTML|safe }}</div>
<div id="author">{{ AUTHOR.szAuthorHTML|safe }}</div>
</td>{% if IMAGE %}<td id="image">
<center><div style="background:rgba({% for i in CLR %}{% if forloop.counter <= 3 %}{{ i|stringformat:"02d" }}{% if forloop.counter < 3 %},{%endif %}{% endif %}{% empty %}87,00,00{% endfor %},0.7);">
<div><img src="{{IMAGE.url}}" alt="{{ AUTHOR.szAuthor }}" title="{{ AUTHOR.szAuthor }}" /></div>
</div></center>
</td></tr><tr><td colspan="2">{% else %}</tr><tr><td>{% endif %}
<div class="tags">
{% for i in TAGS %}<a href="/?tag={{ i.slug }}">{{ i.name|safe }}</a> {% endfor %}
<div id="next"><a href="/{{ NEXT }}_{{ NEXT_TXT }}">&rightarrow;</a></div>
</div>
</td>
</tr>
</table></center>
<div class="container main-content">
<!-- Основной контент: Текст + Картинка -->
<div class="content-row">
<!-- Текстовая колонка -->
<div class="text-col">
<!-- Интро/Вступление -->
{% if DQ.szIntroHTML %}
<div id="info">{{ DQ.szIntroHTML|safe }}</div>
{% endif %}
<!-- Цитата: Семантический blockquote -->
<blockquote id="bb" style="border:none; margin:0; padding:0;">
{{ DQ.szContentHTML|safe }}
</blockquote>
<!-- Автор: Семантический cite -->
<div id="author">
<cite>
{% if AUTHOR %}
{{ AUTHOR.szAuthorHTML|default:AUTHOR.szAuthor|safe }}
{% endif %}
</cite>
</div>
</div>
<!-- Колонка с картинкой (если есть) -->
{% if IMAGE %}
<div class="image-col" id="image">
<center>
<div style="background:rgba(87,0,0,0.7);">
<div><img src="{{IMAGE.url}}" alt="{% if AUTHOR %}{{ AUTHOR.szAuthor }}{% else %}Dictum & Quotes{% endif %}" title="{% if AUTHOR %}{{ AUTHOR.szAuthor }}{% else %}Dictum & Quotes{% endif %}" /></div>
</div>
</center>
</div>
{% endif %}
</div>
<!-- Блок тегов и навигации -->
<div class="tags">
{% for i in TAGS %}<a href="/?tag={{ i.slug }}">{{ i.name|safe }}</a> {% endfor %}
<div id="next"><a href="/{{ NEXT }}_{{ NEXT_TXT }}{% if CURRENT_TAG %}?tag={{ CURRENT_TAG }}{% endif %}">&rightarrow;</a></div>
</div>
</div>
<script type="text/javascript">
setTimeout('location.replace("/{{ NEXT }}_{{ NEXT_TXT }}")', 15000);
/*Изменить текущий адрес страницы через 3 секунды (3000 миллисекунд)*/
</script>
<noscript>
<meta http-equiv="refresh" content="15; url=/{{ NEXT}}_{{ NEXT_TXT }}">
<meta http-equiv="refresh" content="15; url=/{{ NEXT}}_{{ NEXT_TXT }}{% if CURRENT_TAG %}?tag={{ CURRENT_TAG }}{% endif %}">
</noscript>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://dq.cube2.ru/</loc><priority>0.1</priority></url>{% for I in DATA %}
<url><loc>https://dq.cube2.ru/{{ I.ID }}_{{ I.SLUG }}</loc><priority>1</priority></url>{% endfor %}
</urlset>

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
</html>
</body>
</div>
<a href="/">Вернуться на главную</a>
<cite>Системный Администратор (404)</cite>
</blockquote>
<span>«</span>Я искал везде. Даже под&nbsp;диваном. Этой страницы здесь&nbsp;нет.»
<blockquote>
<div class="container">
<body>
</head>
</style>
a:hover { color: #999; border-bottom: 1px solid #999; }
a { color: #555; text-decoration: none; border-bottom: 1px dotted #555; transition: color 0.3s; font-size: 0.8em; margin-top: 30px; display: inline-block;}
cite { display: block; font-size: 0.9em; color: #777; margin-top: 15px; font-style: normal;}
blockquote span { margin-left: -0.44em; }
blockquote { font-size: 2em; margin: 0 0 20px 0; font-style: italic; line-height: 1.4; }
.container { max-width: 600px; padding: 20px; }
body { background-color: #111; color: #ccc; font-family: Georgia, serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; text-align: center; }
<style>
<title>404: Страница не найдена</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8">
<head>
<html lang="ru">

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>500: Ошибка сервера</title>
<style>
body { background-color: #111; color: #ccc; font-family: Georgia, serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; text-align: center; }
.container { max-width: 600px; padding: 20px; }
blockquote { font-size: 2em; margin: 0 0 20px 0; font-style: italic; line-height: 1.4; }
blockquote span { margin-left: -0.44em; }
cite { display: block; font-size: 0.9em; color: #777; margin-top: 15px; font-style: normal; }
a { color: #555; text-decoration: none; border-bottom: 1px dotted #555; transition: color 0.3s; font-size: 0.8em; margin-top: 30px; display: inline-block;}
a:hover { color: #999; border-bottom: 1px solid #999; }
</style>
</head>
<body>
<div class="container">
<blockquote>
<span>«</span>Что-то пошло не&nbsp;так. Кажется, я&nbsp;уронил сервер, контейнер или &nbsp;случилось что-то ещё. Подождите, пока я&nbsp;его подниму.»
</blockquote>
<cite>Системный Администратор (5xx)</cite>
<a href="#" onclick="window.location.reload(); return false;">Попробовать обновить через 5&nbsp;минут.</a>
</div>
</body>
</html>

View File

@@ -1,10 +1,72 @@
# -*- coding: utf-8 -*-
from django.contrib import admin
from django import forms
from web.models import TbDictumAndQuotes, TbAuthor, TbImages, TbOrigin
try:
from etpgrf.typograph import Typographer
from etpgrf.layout import LayoutProcessor
from etpgrf.hyphenation import Hyphenator
except ImportError:
# Заглушка, если библиотека не установлена
class Typographer:
def __init__(self, **kwargs): pass
def process(self, text): return text
class LayoutProcessor:
def __init__(self, **kwargs): pass
class Hyphenator:
def __init__(self, **kwargs): pass
class DictumAdminForm(forms.ModelForm):
# Виртуальные поля для настройки типографа
etp_language = forms.ChoiceField(
label="Язык типографики",
choices=[('ru', 'Русский'), ('en', 'English'), ('ru,en', 'Ru + En')],
initial='ru',
required=False
)
etp_quotes = forms.BooleanField(
label="Обработка кавычек",
initial=True,
required=False,
help_text="Заменять прямые кавычки на «ёлочки» или “лапки”"
)
etp_hanging_punctuation = forms.ChoiceField(
label="Висячая пунктуация",
choices=[('no', 'Нет'), ('left', 'Слева'), ('right', 'Справа'), ('both', 'Обе стороны')],
initial='left',
required=False,
help_text="Выносить кавычки за границу текстового блока"
)
etp_hyphenation = forms.BooleanField(
label="Переносы",
initial=True,
required=False,
help_text="Расставлять мягкие переносы (&amp;shy;)"
)
etp_sanitize = forms.BooleanField(
label="Санитайзер (HTML)",
initial=False,
required=False,
help_text="Очищать HTML теги перед обработкой"
)
etp_mode = forms.ChoiceField(
label="Режим вывода",
choices=[('mixed', 'Смешанный (Mixed)'), ('unicode', 'Юникод (Unicode)'), ('mnemonic', 'Мнемоники')],
initial='mixed',
required=False,
help_text="Формат спецсимволов"
)
class Meta:
model = TbDictumAndQuotes
fields = '__all__'
# Register your models here.
class AdmDictumAndQuotesAdmin(admin.ModelAdmin):
form = DictumAdminForm
search_fields = ['id', 'szIntro', 'szContent', ]
list_display = ('id', 'szIntro', 'szContent', 'tag_list', 'iViewCounter', 'dtEdited', )
list_display_links = ('id', 'szIntro', 'szContent', )
@@ -13,12 +75,109 @@ class AdmDictumAndQuotesAdmin(admin.ModelAdmin):
actions_on_top = False
actions_on_bottom = True
actions_selection_counter = True
# погасить кнопку "Добавить" в интерфейсе админки
# def has_add_permission(self, request):
# return False
# fieldsets = (
# (None, {'fields': ('szIntro', 'iViewCounter', 'tags',)}),
# )
fieldsets = (
(None, {
'fields': ('szIntro', 'szContent', 'kAuthor', 'kOrigin', 'kImages', 'tags', 'bIsChecked')
}),
('Настройки типографа (Etpgrf)', {
'classes': ('collapse',),
'fields': (
('etp_language', 'etp_mode'),
('etp_quotes', 'etp_sanitize'),
('etp_hyphenation', 'etp_hanging_punctuation'),
),
'description': 'Настройки применяются при сохранении. Результат записывается в скрытые HTML-поля.'
}),
('HTML Результат (ReadOnly)', {
'classes': ('collapse',),
'fields': ('szIntroHTML', 'szContentHTML'),
}),
('Служебное', {
'classes': ('collapse',),
'fields': ('iViewCounter', 'imFileOG', 'bTypograph') # bTypograph kept for compatibility
})
)
readonly_fields = ('szIntroHTML', 'szContentHTML', 'iViewCounter')
def save_model(self, request, obj, form, change):
# 1. Читаем базовые настройки
langs = form.cleaned_data.get('etp_language', 'ru').split(',')
# 2. Собираем LayoutProcessor
layout_option = False
# Включаем layout по умолчанию с базовыми настройками (инициалы, юниты)
layout_option = LayoutProcessor(
langs=langs,
process_initials_and_acronyms=True,
process_units=True
)
# 3. Собираем Hyphenator
hyphenation_enabled = form.cleaned_data.get('etp_hyphenation', True)
hyphenation_option = False
if hyphenation_enabled:
hyphenation_option = Hyphenator(
langs=langs,
max_unhyphenated_len=12
)
# 4. Читаем Sanitizer
sanitizer_enabled = form.cleaned_data.get('etp_sanitize', False)
sanitizer_option = None
if sanitizer_enabled:
sanitizer_option = 'etp'
# 5. Читаем Hanging Punctuation
hanging_val = form.cleaned_data.get('etp_hanging_punctuation', 'no')
hanging_option = None
if hanging_val != 'no':
hanging_option = hanging_val
# 6. Собираем общие опции
options = {
'langs': langs,
'process_html': True,
'quotes': form.cleaned_data.get('etp_quotes', True),
'layout': layout_option,
'unbreakables': True,
'hyphenation': hyphenation_option,
'symbols': True,
'hanging_punctuation': hanging_option,
'mode': form.cleaned_data.get('etp_mode', 'mixed'),
'sanitizer': sanitizer_option,
}
# Инициализируем типограф с настройками из формы
try:
# DEBUG: Проверка, какой класс используется
if Typographer.__module__ == __name__: # Если класс определен в этом же файле (заглушка)
self.message_user(request, "ВНИМАНИЕ: Используется заглушка Typographer! Библиотека etpgrf не найдена.", level='WARNING')
t = Typographer(**options)
# Обрабатываем контент
if obj.szContent:
# В онлайн-типографе используется .process(text)
old_html = obj.szContentHTML or ""
processed = t.process(obj.szContent)
obj.szContentHTML = processed
# DEBUG: Проверка изменений
if processed != old_html and processed != obj.szContent:
self.message_user(request, f"Типограф: szContentHTML обновлен (len changed: {len(old_html)} -> {len(processed)})", level='INFO')
# Обрабатываем интро
if obj.szIntro:
obj.szIntroHTML = t.process(obj.szIntro)
except Exception as e:
# Fallback if processing fails
self.message_user(request, f"Ошибка типографа: {e}", level='ERROR')
if not obj.szContentHTML: obj.szContentHTML = obj.szContent
if not obj.szIntroHTML: obj.szIntroHTML = obj.szIntro
super().save_model(request, obj, form, change)
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('tags')
@@ -64,3 +223,4 @@ admin.site.register(TbDictumAndQuotes, AdmDictumAndQuotesAdmin)
admin.site.register(TbOrigin, AdmOrigin)
admin.site.register(TbImages, AdmImages)
admin.site.register(TbAuthor, AdmAuthor)

View File

View File

@@ -0,0 +1,115 @@
from django.core.management.base import BaseCommand
from web.models import TbDictumAndQuotes
try:
from etpgrf.typograph import Typographer
from etpgrf.layout import LayoutProcessor
from etpgrf.hyphenation import Hyphenator
from etpgrf.sanitizer import SanitizerProcessor
except ImportError:
print("Ошибка: библиотека etpgrf не найдена. Пожалуйста, установите её через 'poetry add etpgrf'")
Typographer = None
class Command(BaseCommand):
help = 'Переобрабатывает все цитаты через etpgrf с "санитайзером" и "висячей пунктуацией: слева"'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Запустить без сохранения изменений в БД',
)
parser.add_argument(
'--limit',
type=int,
help='Ограничить количество обрабатываемых записей',
)
parser.add_argument(
'--offset',
type=int,
default=0,
help='Пропустить первые N записей (использовать вместе с limit)',
)
def handle(self, *args, **options):
if not Typographer:
self.stdout.write(self.style.ERROR('Библиотека Etpgrf отсутствует.'))
return
# Настройки типографа
settings = {
'langs': ['ru'],
'process_html': True, # Обрабатываем как HTML
'quotes': True,
'layout': LayoutProcessor(langs=['ru'], process_initials_and_acronyms=True, process_units=True),
'unbreakables': True,
'hyphenation': Hyphenator(langs=['ru'], max_unhyphenated_len=12),
'symbols': True,
'hanging_punctuation': 'left', # ВАЖНО: Слева
'mode': 'mixed',
'sanitizer': SanitizerProcessor(mode='etp'), # ВАЖНО: Санитайзинг включен (очистит старую разметку)
}
self.stdout.write(f"Настройка Типографа с параметрами: {settings}")
typographer = Typographer(**settings)
qs = TbDictumAndQuotes.objects.all().order_by('id')
start_index = options['offset']
end_index = None
if options['limit']:
end_index = start_index + options['limit']
if end_index:
qs = qs[start_index:end_index]
else:
qs = qs[start_index:]
count = qs.count()
self.stdout.write(f"Найдено {count} цитат для обработки (сдвиг {start_index})...")
# Попытка импортировать tqdm для красоты, если нет - обычный счетчик
try:
from tqdm import tqdm
iterator = tqdm(qs, total=count)
except ImportError:
iterator = qs
processed_count = 0
for dq in iterator:
try:
# Берем исходный текст.
# Если в szContent уже лежит старый HTML (Муравьев), санитайзер 'etp' его вычистит.
source_text = dq.szContent
if not source_text:
continue
new_html = typographer.process(source_text)
# Обрабатываем intro если есть
new_intro_html = ""
if dq.szIntro:
new_intro_html = typographer.process(dq.szIntro)
if options['dry_run']:
self.stdout.write(f"[{dq.id}] Будет обновлено. Предпросмотр: {new_html[:50]}...")
else:
dq.szContentHTML = new_html
if new_intro_html:
dq.szIntroHTML = new_intro_html
# Сохраняем в обход метода save(), чтобы не триггерить ничего лишнего,
# или вызываем save(), если там теперь пусто (в нашей новой моделе save пустой).
# Используем update_fields для скорости.
dq.save(update_fields=['szContentHTML', 'szIntroHTML'])
processed_count += 1
if not isinstance(iterator, qs.__class__): # Если это не tqdm
if processed_count % 10 == 0:
self.stdout.write(f"Обработано {processed_count}/{count}...", ending='\r')
except Exception as e:
self.stdout.write(self.style.ERROR(f"Ошибка обработки id={dq.id}: {e}"))
self.stdout.write(self.style.SUCCESS(f"\nГотово! Обработано {processed_count} цитат."))

View File

@@ -1,8 +1,8 @@
# Generated by Django 3.1.2 on 2020-10-05 06:52
# Generated by Django 6.0.2 on 2026-02-17 22:08
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
from django.db import migrations, models
class Migration(migrations.Migration):
@@ -10,21 +10,21 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('taggit', '0003_taggeditem_add_unique_index'),
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
]
operations = [
migrations.CreateModel(
name='TbAuthor',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('szAuthor', models.CharField(db_index=True, default='', help_text='Автор и, если необходимо, краткая справка', max_length=128, unique=True, verbose_name='Автор')),
('szAuthorHTML', models.TextField(default='', help_text='Автор и, если необходимо, краткая справка<br />Свертано в HTML по правилам типографики <small>(рекламные URL вставляются тут)</small>', null=True, verbose_name='Автор HTML')),
('bIsChecked', models.BooleanField(db_index=True, default=True, help_text='Есть доступ для сканирования.', verbose_name='Проверен')),
('iViewCounter', models.PositiveIntegerField(default=0, help_text='Число просмотров картинки.', verbose_name='Просмотры')),
('szAuthorHTML', models.TextField(blank=True, default='', help_text='Автор и, если необходимо, краткая справка<br />Свертано в HTML по правилам типографики <small>(рекламные URL вставляются тут)</small>', verbose_name='Автор HTML')),
('bTypograph', models.BooleanField(db_index=True, default=True, help_text='Применять типографику к этому автору?', verbose_name='Типографить')),
('bIsChecked', models.BooleanField(db_index=True, default=True, help_text='Автор проверен.', verbose_name='Проверен')),
('iViewCounter', models.PositiveIntegerField(default=0, help_text='Число просмотров Автора.', verbose_name='')),
('dtCreated', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата создания')),
('dtEdited', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Дата последнего редактирования')),
('tags', taggit.managers.TaggableManager(help_text='Теги через запятую… Регистр не чувствителен… <b>Теги нужны для подстановки картинок и навигации<b>', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Теги')),
('dtEdited', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Дата редактирования')),
],
options={
'verbose_name': 'АВТОР',
@@ -35,10 +35,10 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='TbOrigin',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('szOrigin', models.CharField(db_index=True, default='', help_text='Ссылка или указание источника: книга, URL, просто что-то…', max_length=256, unique=True, verbose_name='Источник')),
('dtCreated', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата создания')),
('dtEdited', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Дата последнего редактирования')),
('dtEdited', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Дата редактирования')),
],
options={
'verbose_name': 'ИСТОЧНИК',
@@ -46,17 +46,39 @@ class Migration(migrations.Migration):
'ordering': ['id'],
},
),
migrations.CreateModel(
name='RuTaggedItem',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('taggit.taggeditem',),
),
migrations.CreateModel(
name='RuTag',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('taggit.tag',),
),
migrations.CreateModel(
name='TbImages',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('imFile', models.ImageField(db_index=True, default='', help_text='Файл с картинкой (gif, jpeg, png, bmp).', max_length=136, unique=True, upload_to='img2', verbose_name='Картинка')),
('szCaption', models.CharField(db_index=True, default='', help_text='Название, подпись, описание что изображено…', max_length=128, unique=True, verbose_name='Название')),
('bIsChecked', models.BooleanField(db_index=True, default=True, help_text='Есть доступ для сканирования.', verbose_name='Проверен')),
('iViewCounter', models.PositiveIntegerField(default=0, help_text='Число просмотров картинки.', verbose_name='Просмотры')),
('bIsChecked', models.BooleanField(db_index=True, default=True, help_text='Картинку проверили.', verbose_name='Проверен')),
('iViewCounter', models.PositiveIntegerField(default=0, help_text='Число просмотров картинки.', verbose_name='')),
('dtCreated', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата создания')),
('dtEdited', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Дата последнего редактирования')),
('tags', taggit.managers.TaggableManager(help_text='Теги через запятую… Регистр не чувствителен… <b>Теги нужны для подстановки картинок и навигации<b>', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Теги')),
('dtEdited', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Дата редактирования')),
('tags', taggit.managers.TaggableManager(blank=True, help_text='Теги через запятую… Регистр не чувствителен… <b>Теги нужны для подстановки картинок и навигации<b>', through='web.RuTaggedItem', to='taggit.Tag', verbose_name='Теги')),
],
options={
'verbose_name': 'КАРТИНКА',
@@ -67,19 +89,20 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='TbDictumAndQuotes',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('szIntro', models.CharField(default=None, help_text='Не обязательно. Вступление перед цитатой.', max_length=256, null=True, verbose_name='Вступление')),
('szIntroHTML', models.TextField(default='', help_text='Автор и, если необходимо, краткая справка<br />Вступление перед цитатой, в HTML по правилам типографики</small>', verbose_name='Вступление HTML')),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('szIntro', models.CharField(blank=True, default=None, help_text='Не обязательно. Вступление перед цитатой.', max_length=256, verbose_name='Вступление')),
('szIntroHTML', models.TextField(blank=True, default='', help_text='Автор и, если необходимо, краткая справка<br />Вступление перед цитатой, в HTML по правилам типографики</small>', verbose_name='Вступление HTML')),
('szContent', models.TextField(default='', help_text='Не обязательно. Вступление перед цитатой.', max_length=256, verbose_name='Высказывание')),
('szContentHTML', models.TextField(default='', help_text='<b>Высказывание Крылатое</b> -- крылатое, пародоксальное и все такое', verbose_name='Высказывание HTML')),
('imFileOG', models.ImageField(default='', help_text='Картинка для социальной сети <b>(будет создана автоматически)</b>.<br /><small>Файл с картинкой (png).<small>', max_length=136, upload_to='img2og', verbose_name='OG-image</b>')),
('iViewCounter', models.PositiveIntegerField(db_index=True, default=0, help_text='Число сканирований хоста.', verbose_name='Просмотры')),
('szContentHTML', models.TextField(blank=True, default='', help_text='Содержание цитаты, афоризма, высказывания…<br />Свертано в HTML по правилам типографики', verbose_name='Изречение HTML')),
('bTypograph', models.BooleanField(db_index=True, default=True, help_text='Применять типографику?', verbose_name='Типографить')),
('imFileOG', models.ImageField(blank=True, default='', help_text='Картинка для социальной сети <b>(будет создана автоматически)</b>.<br /><small>Файл с картинкой (png).<small>', max_length=136, upload_to='img2og', verbose_name='OG-image')),
('iViewCounter', models.PositiveIntegerField(db_index=True, default=0, help_text='Число просмотров высказывания.', verbose_name='')),
('dtCreated', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата создания')),
('dtEdited', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Дата последнего редактирования')),
('kAuthor', models.ForeignKey(default=None, help_text='Автор изречения или цитаты <b>(не обязательно, но желательно)</b>', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='web.tbauthor', verbose_name='Автор')),
('kImages', models.ForeignKey(default=None, help_text='Ссылка на картинку, в табличке картинок <b>(не обязательно)</b><br /><small>если нужна именно данная картинка, а не выбранная автоматически</small>', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='web.tbimages', verbose_name='Картинка')),
('kOrigin', models.ForeignKey(default=None, help_text='Откуда взята циатата, высказывание, изречение <b>(не обязательно, но желательно)</b>', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='web.tborigin', verbose_name='Источник')),
('tags', taggit.managers.TaggableManager(help_text='Теги через запятую… Регистр не чувствителен… <b>Теги нужны для подстановки картинок и навигации<b>', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Теги')),
('dtEdited', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Дата редактирования')),
('kAuthor', models.ForeignKey(blank=True, default=None, help_text='Автор изречения или цитаты <b>(не обязательно, но желательно)</b>', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='web.tbauthor', verbose_name='Автор')),
('kImages', models.ForeignKey(blank=True, default=None, help_text='Ссылка на картинку, в табличке картинок <b>(не обязательно)</b><br /><small>если нужна именно данная картинка, а не выбранная автоматически</small>', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='web.tbimages', verbose_name='Картинка')),
('kOrigin', models.ForeignKey(blank=True, default=None, help_text='Откуда взята циатата, высказывание, изречение <b>(не обязательно, но желательно)</b>', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='web.tborigin', verbose_name='Источник')),
('tags', taggit.managers.TaggableManager(blank=True, help_text='Теги через запятую… Регистр не чувствителен… <b>Теги нужны для подстановки картинок и навигации<b>', through='web.RuTaggedItem', to='taggit.Tag', verbose_name='Теги')),
],
options={
'verbose_name': 'ВЫСКАЗЫВАНИЕ',
@@ -87,4 +110,9 @@ class Migration(migrations.Migration):
'ordering': ['id'],
},
),
migrations.AddField(
model_name='tbauthor',
name='tags',
field=taggit.managers.TaggableManager(blank=True, help_text='Теги через запятую… Регистр не чувствителен… <b>Теги нужны для подстановки картинок и навигации<b>', through='web.RuTaggedItem', to='taggit.Tag', verbose_name='Теги'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.2 on 2026-02-18 12:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('web', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='tbdictumandquotes',
name='bIsChecked',
field=models.BooleanField(db_index=True, default=True, help_text='Цитата проверена.', verbose_name='Проверен'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0.2 on 2026-02-18 19:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('web', '0002_tbdictumandquotes_bischecked'),
]
operations = [
migrations.AlterField(
model_name='tbdictumandquotes',
name='bTypograph',
field=models.BooleanField(db_index=True, default=True, help_text='Применять типографику?', verbose_name='Типографировать'),
),
migrations.AlterField(
model_name='tbdictumandquotes',
name='szContent',
field=models.TextField(default='', help_text='Не обязательно.', max_length=640, verbose_name='Изречение'),
),
migrations.AlterField(
model_name='tbdictumandquotes',
name='szContentHTML',
field=models.TextField(blank=True, default='', help_text='Содержание цитаты, афоризма, высказывания…<br /> Свёрстано в HTML по правилам типографики', verbose_name='Изречение HTML'),
),
migrations.AlterField(
model_name='tbdictumandquotes',
name='szIntroHTML',
field=models.TextField(blank=True, default='', help_text='Автор и, если необходимо, краткая справка<br /> Вступление перед цитатой, в HTML по правилам типографики</small>', verbose_name='Вступление HTML'),
),
]

View File

@@ -2,10 +2,12 @@
from django.db import models
from taggit.managers import TaggableManager
from taggit.models import Tag, TaggedItem
from typus import en_typus, ru_typus
try:
from typus import en_typus, ru_typus
except ImportError:
def en_typus(text): return text
def ru_typus(text): return text
from pathlib import Path
import urllib3
import json
import pytils
@@ -190,6 +192,12 @@ class TbAuthor(models.Model):
help_text=u"Автор и, если необходимо, краткая справка<br />"
u"Свертано в HTML по правилам типографики <small>(рекламные URL вставляются тут)</small>"
)
bTypograph = models.BooleanField(
default=True,
db_index=True,
verbose_name=u"Типографить",
help_text=u"Применять типографику к этому автору?"
)
bIsChecked = models.BooleanField(
default=True,
db_index=True,
@@ -233,22 +241,11 @@ class TbAuthor(models.Model):
return self.__str__()
def save(self, *args, **kwargs):
http = urllib3.PoolManager()
# последовательно
# Используем типограф typus (https://github.com/byashimov/typus)
# Используем типограф Eugene Spearance (http://www.typograf.ru/)
# Используем типограф Муравьева (http://mdash.ru/api.v1.php)
self.szAuthor = ru_typus(self.szAuthor)
resp = http.request("POST",
"http://www.typograf.ru/webservice/",
fields={"text": self.szAuthor.encode('cp1251')})
self.szAuthorHTML = resp.data.decode('cp1251')
# print(self.szContentHTML)
resp = http.request("POST",
"http://mdash.ru/api.v1.php",
fields={"text": self.szAuthorHTML.encode('utf-8')})
self.szAuthorHTML = json.loads(resp.data)["result"]
# print(self.szContentHTML)
# Типографирование перенесено в админку (через библиотеку etpgrf)
# Здесь оставляем только базовое сохранение
if not self.szAuthorHTML and self.szAuthor:
# Если HTML пуст, временно заполняем его оригиналом (или можно вызвать etpgrf с дефолтами)
self.szAuthorHTML = self.szAuthor
super(TbAuthor, self).save(*args, **kwargs)
class Meta:
@@ -284,24 +281,38 @@ class TbDictumAndQuotes(models.Model):
blank=True,
verbose_name=u"Вступление HTML",
help_text=u"Автор и, если необходимо, краткая справка<br />"
u"Вступление перед цитатой, в HTML по правилам типографики</small>"
u" Вступление перед цитатой, в HTML по правилам типографики</small>"
)
szContent = models.TextField(
max_length=256,
max_length=640,
default="",
verbose_name=u"Высказывание",
help_text=u"Не обязательно. Вступление перед цитатой."
verbose_name=u"Изречение",
help_text=u"Не обязательно."
)
szContentHTML = models.TextField(
default="",
blank=True,
verbose_name=u"Высказывание HTML",
help_text=u"<b>Высказывание Крылатое</b> -- крылатое, пародоксальное и все такое"
verbose_name=u"Изречение HTML",
help_text=u"Содержание цитаты, афоризма, высказывания…<br />"
u" Свёрстано в HTML по правилам типографики"
)
bTypograph = models.BooleanField(
default=True,
db_index=True,
verbose_name=u"Типографировать",
help_text=u"Применять типографику?"
)
bIsChecked = models.BooleanField(
default=True,
db_index=True,
verbose_name=u"Проверен",
help_text=u"Цитата проверена."
)
kAuthor = models.ForeignKey(
TbAuthor,
default=None,
blank=True,
null=True,
on_delete=models.DO_NOTHING,
verbose_name=u"Автор",
help_text=u"Автор изречения или цитаты <b>(не обязательно, но желательно)</b>"
@@ -310,6 +321,7 @@ class TbDictumAndQuotes(models.Model):
TbOrigin,
default=None,
blank=True,
null=True,
on_delete=models.DO_NOTHING,
verbose_name=u"Источник",
help_text=u"Откуда взята циатата, высказывание, изречение <b>(не обязательно, но желательно)</b>"
@@ -318,6 +330,7 @@ class TbDictumAndQuotes(models.Model):
TbImages,
default=None,
blank=True,
null=True,
on_delete=models.DO_NOTHING,
verbose_name=u"Картинка",
help_text=u"Ссылка на картинку, в табличке картинок <b>(не обязательно)</b><br />"
@@ -373,44 +386,15 @@ class TbDictumAndQuotes(models.Model):
return self.__str__()
def save(self, *args, **kwargs):
http = urllib3.PoolManager()
# последовательно
# Используем типограф typus (https://github.com/byashimov/typus)
# Используем типограф Eugene Spearance (http://www.typograf.ru/)
# Используем типограф Муравьева (http://mdash.ru/api.v1.php)
if self.szIntro != "" and self.szIntro != ru_typus(self.szIntro):
# сравнение self.szIntro != ru_typus(self.szIntro) нужно для избежания повторных обращений
# к типографам при обновлении щетчиков просмотра
self.szIntro = ru_typus(self.szIntro)
resp = http.request("POST",
"http://www.typograf.ru/webservice/",
fields={"text": self.szIntro.replace("\u202f", " ").replace("\u2009", " ").encode('cp1251')})
self.szIntroHTML = resp.data.decode('cp1251')
# print(self.szIntroHTML)
resp = http.request("POST",
"http://mdash.ru/api.v1.php",
fields={"text": self.szIntroHTML.encode('utf-8')})
self.szIntroHTML = json.loads(resp.data)["result"]
# print(self.szIntroHTML)
else:
self.szIntroHTML = ""
if self.szContent != ru_typus(self.szContent):
# self.szContent != ru_typus(self.szContent) нужно для избежания повторных обращений
# к типографам при обновлении щетчиков просмотра
self.szContent = ru_typus(self.szContent)
resp = http.request("POST",
"http://www.typograf.ru/webservice/",
fields={"text": self.szContent.replace("\u202f", " ").replace("\u2009", " ").encode('cp1251')})
self.szContentHTML = resp.data.decode('cp1251')
print(self.szContentHTML)
resp = http.request("POST",
"http://mdash.ru/api.v1.php",
fields={"text": self.szContentHTML.encode('utf-8')})
self.szContentHTML = json.loads(resp.data)["result"]
# print(self.szContentHTML)
# Типографирование (szContent -> szContentHTML, szIntro -> szIntroHTML)
# перенесено в админку для управления параметрами (язык, переносы и т.д.)
if not self.szContentHTML and self.szContent:
self.szContentHTML = self.szContent
if not self.szIntroHTML and self.szIntro:
self.szIntroHTML = self.szIntro
super(TbDictumAndQuotes, self).save(*args, **kwargs)
class Meta:
verbose_name = u"ВЫСКАЗЫВАНИЕ"
verbose_name_plural = u"ВЫСКАЗЫВАНИЯ"
ordering = ['id', ]
ordering = ['-id', ]

20
dicquo/web/sitemaps.py Normal file
View File

@@ -0,0 +1,20 @@
from django.contrib.sitemaps import Sitemap
from web.models import TbDictumAndQuotes
import pytils
class DictumSitemap(Sitemap):
changefreq = "weekly" # Как часто меняются страницы
priority = 0.9 # Приоритет (от 0.0 до 1.0)
def items(self):
# Only show checked items in sitemap
return TbDictumAndQuotes.objects.filter(bIsChecked=True).order_by('-id')
def lastmod(self, obj):
return obj.dtEdited
def location(self, obj):
# Generates URL in format: /123_slug
slug = pytils.translit.slugify(obj.szContent.lower())[:120]
return f"/{obj.id}_{slug}"

View File

@@ -1,104 +1,227 @@
# -*- coding: utf-8 -*-
__author__ = "Sergei Erjemin"
__copyright__ = "Copyright 2020, Sergei Erjemin"
__copyright__ = "Copyright 2020-2026, Sergei Erjemin"
__credits__ = ["Sergei Erjemin", ]
__license__ = "GPL"
__version__ = "0.0.1"
__version__ = "0.3.0"
__maintainer__ = "Sergei Erjemin"
__email__ = "erjemin@gmail.com"
__status__ = "in progress"
from django.shortcuts import render
from django.core.exceptions import ObjectDoesNotExist
from django.views.generic import DetailView, TemplateView
import time
import hashlib
import random
import pytils
from taggit.models import Tag
from web.models import TbOrigin, TbDictumAndQuotes, TbImages, TbAuthor
from web.models import TbDictumAndQuotes, TbImages, TbAuthor
# Create your views here.
def for_dq(dq):
to_template = {}
num = int(hashlib.blake2s(dq.szContent.encode("utf-8"), digest_size=1).hexdigest(), 16)
clr = sorted([num / 2, num / 3, num / 5, num / 7, num / 11, num / 1.5], key=lambda A: random.random())
to_template.update({'CLR': clr})
to_template.update({'DQ': dq})
try:
au = TbAuthor.objects.get(id=dq.kAuthor_id)
to_template.update({'AUTHOR': au})
tags = au.tags.names()
except ObjectDoesNotExist:
tags = dq.tags.names()
tag_and_slug = []
for i in tags:
tag_and_slug.append({"name": i, "slug": pytils.translit.slugify(i.lower())[:120]})
to_template.update({'TAGS': sorted(tag_and_slug, key=lambda x: x["name"])}) # tag_and_slug
if dq.kImages_id is None:
if len(tags) != 0:
try:
# tagged_image = TbImages.objects.filter(tags__name__in=tags).order_by('?').first()
tagged_image = TbImages.objects.filter(tags__name__in=tags)
random.shuffle(list(tagged_image))
to_template.update({'IMAGE': tagged_image[0].imFile})
except IndexError:
pass
else:
to_template.update({'IMAGE': dq.kImages.imFile})
dq.iViewCounter += 1
dq.save()
# dq_next = TbDictumAndQuotes.objects.exclude(id=dq.id).order_by('?').first()
dq_next = TbDictumAndQuotes.objects.exclude(id=dq.id)
random.shuffle(list(dq_next))
to_template.update({"NEXT": dq_next[0].id})
to_template.update({"NEXT_TXT": pytils.translit.slugify(dq_next[0].szContent.lower()[:120])})
return to_template
class CommonContextMixin:
"""
Общий миксин для представлений:
- Логика "одной цитаты" (получение контекста цитаты)
- Общий контекст (куки, тайминги)
"""
def dispatch(self, request, *args, **kwargs):
# Засекаем время в самом начале обработки запроса
self.t_start = time.process_time()
return super().dispatch(request, *args, **kwargs)
def get_filtered_queryset(self):
"""
Возвращает (queryset, tag_slug) на основе GET-параметров запроса.
Если тега нет или он не найден, возвращает (None, None).
"""
tag_slug = self.request.GET.get('tag')
if not tag_slug:
return None, None
dq_qs = TbDictumAndQuotes.objects.all()
# 1. Пробуем найти цитаты, где АВТОР имеет этот тег
author_tag_qs = dq_qs.filter(kAuthor__tags__slug__in=[tag_slug])
if author_tag_qs.exists():
return author_tag_qs, tag_slug
# 2. Если авторов нет, ищем цитаты с этим тегом
quote_tag_qs = dq_qs.filter(tags__slug__in=[tag_slug])
if quote_tag_qs.exists():
return quote_tag_qs, tag_slug
return None, None
def get_dictum_context(self, request, dq, queryset=None):
"""
Получение контекста для цитаты dq. Если queryset передан, используется для логики "следующей цитаты"
и фильтрации по тегу.
"""
context = {}
# Если queryset не передан, используем все объекты
if queryset is None:
queryset = TbDictumAndQuotes.objects.all()
# --- 1. ЛОГИКА ИСТОРИИ СЕССИИ (Предотвращение петель) ---
seen_ids = request.session.get('seen_ids', [])
# Если мы переключили контекст (например, выбрали другой тег), имеет смысл сбросить историю?
# Или можно оставить, так как уникальность ID глобальна.
# Проблема: если seen_ids забит цитатами, а мы выбрали тег, где всего 2 цитаты,
# и они обе случайно оказались в seen_ids (потому что мы их видели раньше без тега),
# то exclude исключит всё.
# Решение: принудительно добавить текущую цитату, если её нет
if dq.id not in seen_ids:
seen_ids.append(dq.id)
if len(seen_ids) > 100:
seen_ids.pop(0)
request.session['seen_ids'] = seen_ids
context.update({'DQ': dq})
# --- 3. АВТОР И ТЕГИ ---
try:
au = TbAuthor.objects.get(id=dq.kAuthor_id)
context.update({'AUTHOR': au})
tags = au.tags.names()
except ObjectDoesNotExist:
tags = dq.tags.names()
tag_and_slug = []
for i in tags:
tag_and_slug.append({"name": i, "slug": pytils.translit.slugify(i.lower())[:120]})
context.update({'TAGS': sorted(tag_and_slug, key=lambda x: x["name"])})
# --- 4. ВЫБОР КАРТИНКИ ---
if dq.kImages_id is None:
if len(tags) != 0:
tagged_image = TbImages.objects.filter(tags__name__in=tags).order_by('?').first()
if tagged_image:
context.update({'IMAGE': tagged_image.imFile})
else:
context.update({'IMAGE': dq.kImages.imFile})
# --- 5. СЧЕТЧИК ---
dq.iViewCounter += 1
dq.save(update_fields=['iViewCounter'])
# --- 6. ВЫБОР СЛЕДУЮЩЕЙ ЦИТАТЫ ---
# Сначала пробуем найти следующую цитату, которую мы еще не видели
dq_next = queryset.exclude(id__in=seen_ids).order_by('?').first()
# Если таких нет (мы посмотрели все цитаты в этом контексте/теге)
if dq_next is None:
# СБРОС ИСТОРИИ!
# Мы посмотрели всё, что было по этому фильтру. Начинаем круг заново.
# Но удалять ВСЮ историю сессии опасно (вдруг мы вернемся в общий список).
# Лучше локально для выбора следующей цитаты игнорировать историю,
# но возможно стоит очистить сессию, чтобы цикл начался чисто.
# Вариант: Очистить seen_ids, чтобы в следующий раз (на некст странице) список был пуст?
# Или просто выбрать любую КРОМЕ текущей?
dq_next = queryset.exclude(id=dq.id).order_by('?').first()
# Если мы действительно прошли весь цикл по тегу, логично сбросить seen_ids,
# чтобы пользователь мог заново проходить этот список случайно, а не "застревать" на последних.
# Однако, очистка seen_ids здесь повлияет на глобальную сессию.
# Если тег "red" (2 цитаты), мы их посмотрели. seen_ids=[1,2].
# queryset=[1,2]. exclude -> []. dq_next=None.
# Fallback: exclude(current) -> [1] (если cur=2). dq_next=1.
# User goes to 1. seen_ids=[1,2] (set logic handles dupes/order? No, list appends).
# seen_ids=[1,2,1].
# Next request (dq=1). queryset=[1,2]. exclude([1,2,1]) -> []. dq_next=2.
# It loops 1-2-1-2.
# Чтобы разорвать этот малый круг и сделать его снова "случайным" (если там >2 элементов, но меньше 100),
# нужно очистить seen_ids, если мы уткнулись в конец списка.
# Но удалять нужно только те ID, которые принадлежат этому queryset? Сложно.
# Проще очистить всё, так как пользователь явно "наелся" текущим контекстом и пошел по второму кругу.
request.session['seen_ids'] = []
if dq_next:
context.update({"NEXT": dq_next.id})
context.update({"NEXT_TXT": pytils.translit.slugify(dq_next.szContent.lower()[:120])})
# Если мы в режиме фильтрации (tag), передаем текущий тег в контекст
if request.GET.get('tag'):
context.update({"CURRENT_TAG": request.GET.get('tag')})
return context
def finalize_context(self, context):
"""
Добавляет общие данные: проверки куки и время выполнения.
"""
if self.request.COOKIES.get('cookie_accept'):
context['cookie_accept'] = 1
# Считаем время от self.t_start, заданного в dispatch
total_time = 0.0
if hasattr(self, 't_start'):
total_time = float(time.process_time() - self.t_start)
context['ticks'] = total_time * 1000
return context
def by_id(request, dq_id):
t_start = time.process_time()
template = "index.html" # шаблон
dq = TbDictumAndQuotes.objects.get(id=dq_id)
to_template = for_dq(dq)
# пероверка, что посетитель согласился со сбором даных через cookies
if request.COOKIES.get('cookie_accept'):
to_template.update({'cookie_accept': 1})
to_template.update({'ticks': float(time.process_time() - t_start)})
response = render(request, template, to_template)
return response
class DictumDetailView(CommonContextMixin, DetailView):
model = TbDictumAndQuotes
template_name = "index.html"
pk_url_kwarg = 'dq_id'
context_object_name = 'DQ'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Определяем контекст фильтрации (если есть тег в URL)
active_qs, _ = self.get_filtered_queryset()
# Используем миксин логики цитаты с учетом фильтра
extras = self.get_dictum_context(self.request, self.object, queryset=active_qs)
context.update(extras)
# Финализируем контекст (куки, тайминги)
return self.finalize_context(context)
def index(request):
t_start = time.process_time()
# проверка на аутентификацию
# if not request.user.is_authenticated():
# return HttpResponseRedirect("/access")
template = "index.html" # шаблон
dq_ = TbDictumAndQuotes.objects
if request.GET.get('tag'):
dq = dq_.filter(kAuthor__tags__slug__in=[request.GET['tag']]).order_by('?').first()
class IndexView(CommonContextMixin, TemplateView):
template_name = "index.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
active_qs, _ = self.get_filtered_queryset()
dq = None
seen_ids = self.request.session.get('seen_ids', [])
if active_qs is not None:
# Если мы в режиме фильтрации, тоже стараемся не показывать то, что уже видели
dq = active_qs.exclude(id__in=seen_ids).order_by('?').first()
# Если после фильтрации ничего не осталось (мы просмотрели все цитаты тега)
if dq is None:
# Сбрасываем историю и берем любую
self.request.session['seen_ids'] = []
dq = active_qs.order_by('?').first()
if dq is None:
dq = dq_.filter(tags__slug__in=[request.GET['tag']]).order_by('?').first()
# Если тег не задан, или по тегу ничего не нашлось совсем
# Сбрасываем active_qs на "все", так как специфический контекст пуст
active_qs = TbDictumAndQuotes.objects.all()
# Случайная цитата (с учетом истории, чтобы главная страница тоже не зацикливалась)
dq = active_qs.exclude(id__in=seen_ids).order_by('?').first()
if dq is None:
dq = dq_.order_by('?').first()
else:
dq = dq_.first()
to_template = for_dq(dq)
# пероверка, что посетитель согласился со сбором даных через cookies
if request.COOKIES.get('cookie_accept'):
to_template.update({'cookie_accept': 1})
to_template.update({'ticks': float(time.process_time() - t_start)})
response = render(request, template, to_template)
return response
self.request.session['seen_ids'] = []
dq = active_qs.order_by('?').first()
if dq:
# Используем миксин, ОБЯЗАТЕЛЬНО передаем active_qs
extras = self.get_dictum_context(self.request, dq, queryset=active_qs)
context.update(extras)
def sitemap(request):
template = "sitemap.xml" # шаблон
to_template = []
dq = TbDictumAndQuotes.objects.order_by('id').all()
for i in dq:
to_template.append({"ID": i.id,
"SLUG": pytils.translit.slugify(i.szContent.lower()[:120])})
response = render(request, template, {"DATA": to_template})
return response
# Финализируем контекст (куки, тайминги)
return self.finalize_context(context)

114
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,114 @@
# ==============================================================================
# Docker Compose для PRODUCTION
# Этот файл запускается на боевом сервере.
# Вариант 1 (если переименовали в docker-compose.yml): docker compose up -d
# Вариант 2 (если оставили имя): docker compose -f docker-compose.prod.yml up -d
# ==============================================================================
version: '3.8'
services:
# --- ОCНОВНОЙ СЕРВИС: DJANGO + GUNICORN + WHITENOISE ---
web:
# Имя контейнера
container_name: dq-backend
# 1. ОБРАЗ
# В продакшене мы используем готовый, собранный образ из реестра (Gitea)
# image: git.cube2.ru/e-serg/dicquo:latest
# Но пока, для первого деплоя или если реестра нет, можно собирать локально:
build: .
restart: always
# 2. Метки для Watchtower (авто-обновление)
labels:
- "com.centurylinklabs.watchtower.scope=dq-scope"
# 3. КОМАНДА ЗАПУСКА (Замена entrypoint.sh)
# Выполняем цепочку команд внутри контейнера при запуске:
# 1. Миграции
# 2. Collectstatic
# 3. Создаем папку nginx в примонтированном томе конфигов (если нет)
# 4. Копирование конфига Nginx с авто-заменой путей через sed (замену реального пути на хосте получаем через переменную окружения HOST_PROJECT_PATH)
# 5. Инициализация боевого конфига (если нет)
# 6. Создаем папку для ошибок и копируем туда статические страницы 404/500
# 7. Запуск Gunicorn
command: >
sh -c "python manage.py migrate --noinput &&
python manage.py collectstatic --noinput &&
mkdir -p /nginx_configs_host/nginx &&
sed \"s|/home/user/app/dq-site|${HOST_PROJECT_PATH:-/home/default_user/projects/dq-site}|g\" /app/configs/nginx/dq-app--external-nginx.conf > /nginx_configs_host/nginx/nginx_dq.conf.example &&
if [ ! -f /nginx_configs_host/nginx/dq-app--external-nginx.conf ]; then
cp /nginx_configs_host/nginx/nginx_dq.conf.example /nginx_configs_host/nginx/dq-app--external-nginx.conf;
echo 'INIT: Created new nginx config with correct paths';
fi &&
mkdir -p /app/public/media/errors &&
cp /app/dicquo/templates/static_404.html /app/public/media/errors/404.html &&
cp /app/dicquo/templates/static_500.html /app/public/media/errors/500.html &&
gunicorn --workers 3 --bind 0.0.0.0:8000 dicquo.wsgi:application"
# 4. Проброс портов (Внешний Nginx -> localhost:8010)
ports:
- "8010:8000"
# 5. Тома (Volumes)
volumes:
# База данных
# Монтируем папку database с хоста в папку с базой внутри контейнера.
# Путь в контейнере: /app/database (так как Django ищет базу в BASE_DIR.parent/database)
- ./database:/app/database
# Медиа (папка media должна быть рядом с docker-compose.yml)
- ./media:/app/public/media
# Конфиги (Монтируем папку ./config с хоста в /nginx_configs_host внутри контейнера)
# Это нужно, чтобы скрипт запуска мог положить туда .example конфиг и прочитать боевой конфиг.
- ./config:/nginx_configs_host
# 6. Переменные окружения
env_file:
- .env
environment:
- DJANGO_SETTINGS_MODULE=dicquo.settings
- PYTHONUNBUFFERED=1
# Передаем переменную с путем на хосте внутрь контейнера, чтобы sed мог её использовать
- HOST_PROJECT_PATH=${HOST_PROJECT_PATH:-/home/default_user/projects/dq-site}
# 7. Логирование (Ротация)
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# 8. Ресурсы
deploy:
resources:
limits:
cpus: '0.40'
memory: 256M
mem_limit: 256m
# --- WATCHTOWER: АВТО-ОБНОВЛЕНИЕ ОБРАЗОВ ---
# Следит за реестром Gitea и обновляет контейнер web, если появился новый image
watchtower:
image: containrrr/watchtower
container_name: dq_watchtower
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
# Токен/Логин для вашего приватного реестра (нужно добавить в .env!)
# REPO_USER и REPO_PASS должны быть в .env файле на сервере
- REPO_USER=${REPO_USER}
- REPO_PASS=${REPO_PASS}
- WATCHTOWER_SCOPE=dq-scope
- WATCHTOWER_CLEANUP=true # Удалять старые образы после обновления
- WATCHTOWER_POLL_INTERVAL=1800 # Проверять каждые 30 минут
command: --scope dq-scope
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

61
docker-compose.yml Normal file
View File

@@ -0,0 +1,61 @@
# ==============================================================================
# Docker Compose для РАЗРАБОТКИ (Local Development)
# Этот файл содержит настройки для локальной работы (live reload, debug).
# Запуск: docker compose up --build
# ==============================================================================
services:
web:
# Имя контейнера для удобства
container_name: dq-backend-dev
# Сборка из текущей директории
build: .
# Проброс портов (чтобы сайт был доступен на localhost:8010)
ports:
- "8010:8000"
# 1. КОМАНДА ЗАПУСКА (Dev режим)
# Используем --reload для авто-перезагрузки при изменении кода.
# Уменьшаем число воркеров до 2 (экономия ресурсов dev-машины).
# Убираем collectstatic (в dev Django сам может отдавать статику или она нам не так важна сжатой)
# Но миграции оставляем, чтобы база была актуальной.
command: >
sh -c "python manage.py migrate --noinput &&
gunicorn --workers 2 --bind 0.0.0.0:8000 --reload dicquo.wsgi:application"
# 2. МОНТИРОВАНИЕ КОДА (Live Reload)
# Подключаем локальные папки внутрь контейнера, чтобы Gunicorn видел изменения без пересборки образа.
volumes:
# Монтируем основной код проекта.
# Так как web, templates и manage.py лежат внутри dicquo/, одного этого маунта достаточно.
- ./dicquo:/app/dicquo
# Монтируем всю папку public (Static + Media)
# Это нужно, чтобы:
# 1. Изменения в CSS/JS (public/static) сразу были видны (Live Reload).
# 2. Загруженные картинки (public/media) сохранялись на диске.
- ./public:/app/public
# Монтируем базу данных (чтобы данные сохранялись при пересоздании контейнера)
# Используем ту же папку database, что и на проде, для единообразия.
# ВАЖНО: Django ищет базу в BASE_DIR.parent / 'database/db.sqlite3'
# В контейнере BASE_DIR=/app/dicquo, значит путь к базе: /app/database/db.sqlite3
- ./database:/app/database
# 3. ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ
environment:
- DEBUG=True
- DJANGO_LOG_LEVEL=DEBUG
# В dev нам не нужно ограничивать буферизацию так строго, но не помешает.
# 4. РЕСУРСЫ (Без лимитов)
# Удаляем секцию ограничений, чтобы локально использовать все доступные ресурсы хоста.
# deploy:
# resources:
# limits:
# cpus: ...
# memory: ...
# mem_limit: ...

View File

@@ -1,37 +0,0 @@
#!/home/eserg/dq.cube2.ru/env/bin/python3
import sys, os
INTERP = "/home/eserg/dq.cube2.ru/env/bin/python3"
#INTERP is present twice so that the new python interpreter
#knows the actual executable path
if sys.executable != INTERP:
os.execl(INTERP, INTERP, *sys.argv)
cwd = os.getcwd()
sys.path.append(cwd)
sys.path.append(cwd + '/dicquo') #You must add your project here
sys.path.insert(0,cwd+'/env/bin')
sys.path.insert(0,cwd+'/env/lib/python3.8/site-packages')
os.environ['DJANGO_SETTINGS_MODULE'] = "dicquo.settings"
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
##!/usr/bin/env python
#import sys, os
#cwd = os.getcwd()
#sys.path.append(cwd)
#sys.path.append(cwd + '/dicquo')
##Switch to new python
##if sys.version < "2.7.8": os.execl(cwd+"/env/bin/python", "python2.7", *sys.argv)
#sys.path.insert(0,cwd+'/env/bin')
#sys.path.insert(0,cwd+'/env/lib/python3.8/site-packages/django')
#sys.path.insert(0,cwd+'/env/lib/python3.8/site-packages')
#os.environ['DJANGO_SETTINGS_MODULE'] = "dicquo.settings"
#from django.core.wsgi import get_wsgi_application
#application = get_wsgi_application()

649
poetry.lock generated Normal file
View File

@@ -0,0 +1,649 @@
# This file is automatically @generated by Poetry 1.8.0 and should not be changed by hand.
[[package]]
name = "asgiref"
version = "3.11.1"
description = "ASGI specs, helper code, and adapters"
optional = false
python-versions = ">=3.9"
files = [
{file = "asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"},
{file = "asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce"},
]
[package.extras]
tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"},
{file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"},
]
[package.dependencies]
soupsieve = ">=1.6.1"
typing-extensions = ">=4.0.0"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "django"
version = "6.0.2"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
optional = false
python-versions = ">=3.12"
files = [
{file = "django-6.0.2-py3-none-any.whl", hash = "sha256:610dd3b13d15ec3f1e1d257caedd751db8033c5ad8ea0e2d1219a8acf446ecc6"},
{file = "django-6.0.2.tar.gz", hash = "sha256:3046a53b0e40d4b676c3b774c73411d7184ae2745fe8ce5e45c0f33d3ddb71a7"},
]
[package.dependencies]
asgiref = ">=3.9.1"
sqlparse = ">=0.5.0"
tzdata = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
argon2 = ["argon2-cffi (>=23.1.0)"]
bcrypt = ["bcrypt (>=4.1.1)"]
[[package]]
name = "django-environ"
version = "0.12.1"
description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application."
optional = false
python-versions = "<4,>=3.9"
files = [
{file = "django_environ-0.12.1-py2.py3-none-any.whl", hash = "sha256:064ba2d5082f833e6d7fe4def4928bde1eedc0248a417575da7db147aeec1c20"},
{file = "django_environ-0.12.1.tar.gz", hash = "sha256:22859c6e905ab7637fa3348d1787543bb4492f38d761104a3ce0519b7b752845"},
]
[package.extras]
develop = ["coverage[toml] (>=5.0a4)", "furo (>=2024.8.6)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)", "sphinx (>=5.0)", "sphinx-notfound-page"]
docs = ["furo (>=2024.8.6)", "sphinx (>=5.0)", "sphinx-notfound-page"]
testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)"]
[[package]]
name = "django-taggit"
version = "6.1.0"
description = "django-taggit is a reusable Django application for simple tagging."
optional = false
python-versions = ">=3.8"
files = [
{file = "django_taggit-6.1.0-py3-none-any.whl", hash = "sha256:ab776264bbc76cb3d7e49e1bf9054962457831bd21c3a42db9138b41956e4cf0"},
{file = "django_taggit-6.1.0.tar.gz", hash = "sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3"},
]
[package.dependencies]
Django = ">=4.1"
[[package]]
name = "etpgrf"
version = "0.1.4"
description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ."
optional = false
python-versions = ">=3.10"
files = [
{file = "etpgrf-0.1.4-py3-none-any.whl", hash = "sha256:62d4371e1b5fab06b99f79bd351767aed8baf7d041cae7e5d4eb63f7c9545114"},
{file = "etpgrf-0.1.4.tar.gz", hash = "sha256:c699382c292e3110915331dd5539e7dde0c961e4f4ca65cf8db0e01e84dab72f"},
]
[package.dependencies]
beautifulsoup4 = ">=4.10.0"
lxml = ">=4.9.0"
regex = ">=2022.1.18"
[[package]]
name = "gunicorn"
version = "25.1.0"
description = "WSGI HTTP Server for UNIX"
optional = false
python-versions = ">=3.10"
files = [
{file = "gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b"},
{file = "gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616"},
]
[package.dependencies]
packaging = "*"
[package.extras]
eventlet = ["eventlet (>=0.40.3)"]
gevent = ["gevent (>=24.10.1)"]
http2 = ["h2 (>=4.1.0)"]
setproctitle = ["setproctitle"]
testing = ["coverage", "eventlet (>=0.40.3)", "gevent (>=24.10.1)", "h2 (>=4.1.0)", "httpx[http2]", "pytest", "pytest-asyncio", "pytest-cov", "uvloop (>=0.19.0)"]
tornado = ["tornado (>=6.5.0)"]
[[package]]
name = "lxml"
version = "6.0.2"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
optional = false
python-versions = ">=3.8"
files = [
{file = "lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388"},
{file = "lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153"},
{file = "lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31"},
{file = "lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9"},
{file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8"},
{file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba"},
{file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c"},
{file = "lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c"},
{file = "lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321"},
{file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1"},
{file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34"},
{file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a"},
{file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c"},
{file = "lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b"},
{file = "lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0"},
{file = "lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5"},
{file = "lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607"},
{file = "lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938"},
{file = "lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d"},
{file = "lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438"},
{file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964"},
{file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d"},
{file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7"},
{file = "lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178"},
{file = "lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553"},
{file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb"},
{file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a"},
{file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c"},
{file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7"},
{file = "lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46"},
{file = "lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078"},
{file = "lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285"},
{file = "lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456"},
{file = "lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924"},
{file = "lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f"},
{file = "lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534"},
{file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564"},
{file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f"},
{file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0"},
{file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192"},
{file = "lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0"},
{file = "lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092"},
{file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f"},
{file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8"},
{file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f"},
{file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6"},
{file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322"},
{file = "lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849"},
{file = "lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f"},
{file = "lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6"},
{file = "lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77"},
{file = "lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f"},
{file = "lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452"},
{file = "lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048"},
{file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df"},
{file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1"},
{file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916"},
{file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd"},
{file = "lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6"},
{file = "lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a"},
{file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679"},
{file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659"},
{file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484"},
{file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2"},
{file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314"},
{file = "lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2"},
{file = "lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7"},
{file = "lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf"},
{file = "lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe"},
{file = "lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d"},
{file = "lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d"},
{file = "lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5"},
{file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0"},
{file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba"},
{file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0"},
{file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d"},
{file = "lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37"},
{file = "lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9"},
{file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917"},
{file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f"},
{file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8"},
{file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a"},
{file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c"},
{file = "lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b"},
{file = "lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed"},
{file = "lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8"},
{file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d"},
{file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d"},
{file = "lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9"},
{file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e"},
{file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d"},
{file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec"},
{file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272"},
{file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f"},
{file = "lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312"},
{file = "lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca"},
{file = "lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c"},
{file = "lxml-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a656ca105115f6b766bba324f23a67914d9c728dafec57638e2b92a9dcd76c62"},
{file = "lxml-6.0.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c54d83a2188a10ebdba573f16bd97135d06c9ef60c3dc495315c7a28c80a263f"},
{file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:1ea99340b3c729beea786f78c38f60f4795622f36e305d9c9be402201efdc3b7"},
{file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af85529ae8d2a453feee4c780d9406a5e3b17cee0dd75c18bd31adcd584debc3"},
{file = "lxml-6.0.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fe659f6b5d10fb5a17f00a50eb903eb277a71ee35df4615db573c069bcf967ac"},
{file = "lxml-6.0.2-cp38-cp38-win32.whl", hash = "sha256:5921d924aa5468c939d95c9814fa9f9b5935a6ff4e679e26aaf2951f74043512"},
{file = "lxml-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:0aa7070978f893954008ab73bb9e3c24a7c56c054e00566a21b553dc18105fca"},
{file = "lxml-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2c8458c2cdd29589a8367c09c8f030f1d202be673f0ca224ec18590b3b9fb694"},
{file = "lxml-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3fee0851639d06276e6b387f1c190eb9d7f06f7f53514e966b26bae46481ec90"},
{file = "lxml-6.0.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b2142a376b40b6736dfc214fd2902409e9e3857eff554fed2d3c60f097e62a62"},
{file = "lxml-6.0.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6b5b39cc7e2998f968f05309e666103b53e2edd01df8dc51b90d734c0825444"},
{file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4aec24d6b72ee457ec665344a29acb2d35937d5192faebe429ea02633151aad"},
{file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:b42f4d86b451c2f9d06ffb4f8bbc776e04df3ba070b9fe2657804b1b40277c48"},
{file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cdaefac66e8b8f30e37a9b4768a391e1f8a16a7526d5bc77a7928408ef68e93"},
{file = "lxml-6.0.2-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:b738f7e648735714bbb82bdfd030203360cfeab7f6e8a34772b3c8c8b820568c"},
{file = "lxml-6.0.2-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daf42de090d59db025af61ce6bdb2521f0f102ea0e6ea310f13c17610a97da4c"},
{file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:66328dabea70b5ba7e53d94aa774b733cf66686535f3bc9250a7aab53a91caaf"},
{file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:e237b807d68a61fc3b1e845407e27e5eb8ef69bc93fe8505337c1acb4ee300b6"},
{file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:ac02dc29fd397608f8eb15ac1610ae2f2f0154b03f631e6d724d9e2ad4ee2c84"},
{file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:817ef43a0c0b4a77bd166dc9a09a555394105ff3374777ad41f453526e37f9cb"},
{file = "lxml-6.0.2-cp39-cp39-win32.whl", hash = "sha256:bc532422ff26b304cfb62b328826bd995c96154ffd2bac4544f37dbb95ecaa8f"},
{file = "lxml-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:995e783eb0374c120f528f807443ad5a83a656a8624c467ea73781fc5f8a8304"},
{file = "lxml-6.0.2-cp39-cp39-win_arm64.whl", hash = "sha256:08b9d5e803c2e4725ae9e8559ee880e5328ed61aa0935244e0515d7d9dbec0aa"},
{file = "lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6"},
{file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba"},
{file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5"},
{file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4"},
{file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d"},
{file = "lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d"},
{file = "lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700"},
{file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee"},
{file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f"},
{file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9"},
{file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a"},
{file = "lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e"},
{file = "lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62"},
]
[package.extras]
cssselect = ["cssselect (>=0.7)"]
html-clean = ["lxml_html_clean"]
html5 = ["html5lib"]
htmlsoup = ["BeautifulSoup4"]
[[package]]
name = "packaging"
version = "26.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
{file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
]
[[package]]
name = "pillow"
version = "12.1.1"
description = "Python Imaging Library (fork)"
optional = false
python-versions = ">=3.10"
files = [
{file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"},
{file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"},
{file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"},
{file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"},
{file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"},
{file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"},
{file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"},
{file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"},
{file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"},
{file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"},
{file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"},
{file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"},
{file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"},
{file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"},
{file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"},
{file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"},
{file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"},
{file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"},
{file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"},
{file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"},
{file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"},
{file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"},
{file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"},
{file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"},
{file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"},
{file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"},
{file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"},
{file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"},
{file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"},
{file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"},
{file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"},
{file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"},
{file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"},
{file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"},
{file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"},
{file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"},
{file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"},
{file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"},
{file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"},
{file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"},
{file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"},
{file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"},
{file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"},
{file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"},
{file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"},
{file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"},
{file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"},
{file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"},
{file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"},
{file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"},
{file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"},
{file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"},
{file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"},
{file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"},
{file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"},
{file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"},
{file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"},
{file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"},
{file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"},
{file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"},
{file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"},
{file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"},
{file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"},
{file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"},
{file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"},
{file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"},
{file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"},
{file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"},
{file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"},
{file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"},
{file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"},
{file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"},
{file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"},
{file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"},
{file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"},
{file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"},
{file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"},
{file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"},
{file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"},
{file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"},
{file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"},
{file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"},
{file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"},
{file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
xmp = ["defusedxml"]
[[package]]
name = "pytils"
version = "0.4.4"
description = "Russian-specific string utils"
optional = false
python-versions = "*"
files = [
{file = "pytils-0.4.4-py3-none-any.whl", hash = "sha256:e54c16465a5fdb65d414e2da8045e6cc6de79889acda6143dcef2e1e86a1a840"},
{file = "pytils-0.4.4.tar.gz", hash = "sha256:9992a96caad57daa211584df1da4fd825f11e836d3ed93011785f1d02ab6f0ca"},
]
[[package]]
name = "regex"
version = "2026.1.15"
description = "Alternative regular expression module, to replace re."
optional = false
python-versions = ">=3.9"
files = [
{file = "regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e"},
{file = "regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f"},
{file = "regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b"},
{file = "regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c"},
{file = "regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9"},
{file = "regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c"},
{file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106"},
{file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618"},
{file = "regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4"},
{file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79"},
{file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9"},
{file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220"},
{file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13"},
{file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3"},
{file = "regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218"},
{file = "regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a"},
{file = "regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3"},
{file = "regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a"},
{file = "regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f"},
{file = "regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1"},
{file = "regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b"},
{file = "regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8"},
{file = "regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413"},
{file = "regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026"},
{file = "regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785"},
{file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e"},
{file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763"},
{file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb"},
{file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2"},
{file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1"},
{file = "regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569"},
{file = "regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7"},
{file = "regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec"},
{file = "regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1"},
{file = "regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681"},
{file = "regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f"},
{file = "regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa"},
{file = "regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804"},
{file = "regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c"},
{file = "regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5"},
{file = "regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3"},
{file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb"},
{file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410"},
{file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4"},
{file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d"},
{file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22"},
{file = "regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913"},
{file = "regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a"},
{file = "regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056"},
{file = "regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e"},
{file = "regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10"},
{file = "regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc"},
{file = "regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599"},
{file = "regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae"},
{file = "regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5"},
{file = "regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6"},
{file = "regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788"},
{file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714"},
{file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d"},
{file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3"},
{file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31"},
{file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3"},
{file = "regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f"},
{file = "regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e"},
{file = "regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337"},
{file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be"},
{file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8"},
{file = "regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd"},
{file = "regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a"},
{file = "regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93"},
{file = "regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af"},
{file = "regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09"},
{file = "regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5"},
{file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794"},
{file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a"},
{file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80"},
{file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2"},
{file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60"},
{file = "regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952"},
{file = "regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10"},
{file = "regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829"},
{file = "regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac"},
{file = "regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6"},
{file = "regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2"},
{file = "regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846"},
{file = "regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b"},
{file = "regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e"},
{file = "regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde"},
{file = "regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5"},
{file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34"},
{file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75"},
{file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e"},
{file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160"},
{file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1"},
{file = "regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1"},
{file = "regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903"},
{file = "regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705"},
{file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8"},
{file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf"},
{file = "regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d"},
{file = "regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84"},
{file = "regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df"},
{file = "regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434"},
{file = "regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a"},
{file = "regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10"},
{file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac"},
{file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea"},
{file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e"},
{file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521"},
{file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db"},
{file = "regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e"},
{file = "regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf"},
{file = "regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70"},
{file = "regex-2026.1.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:55b4ea996a8e4458dd7b584a2f89863b1655dd3d17b88b46cbb9becc495a0ec5"},
{file = "regex-2026.1.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e1e28be779884189cdd57735e997f282b64fd7ccf6e2eef3e16e57d7a34a815"},
{file = "regex-2026.1.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0057de9eaef45783ff69fa94ae9f0fd906d629d0bd4c3217048f46d1daa32e9b"},
{file = "regex-2026.1.15-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7cd0b2be0f0269283a45c0d8b2c35e149d1319dcb4a43c9c3689fa935c1ee6"},
{file = "regex-2026.1.15-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8db052bbd981e1666f09e957f3790ed74080c2229007c1dd67afdbf0b469c48b"},
{file = "regex-2026.1.15-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:343db82cb3712c31ddf720f097ef17c11dab2f67f7a3e7be976c4f82eba4e6df"},
{file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e9d0118d97794367309635df398bdfd7c33b93e2fdfa0b239661cd74b4c14e"},
{file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:008b185f235acd1e53787333e5690082e4f156c44c87d894f880056089e9bc7c"},
{file = "regex-2026.1.15-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fd65af65e2aaf9474e468f9e571bd7b189e1df3a61caa59dcbabd0000e4ea839"},
{file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f42e68301ff4afee63e365a5fc302b81bb8ba31af625a671d7acb19d10168a8c"},
{file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f7792f27d3ee6e0244ea4697d92b825f9a329ab5230a78c1a68bd274e64b5077"},
{file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:dbaf3c3c37ef190439981648ccbf0c02ed99ae066087dd117fcb616d80b010a4"},
{file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:adc97a9077c2696501443d8ad3fa1b4fc6d131fc8fd7dfefd1a723f89071cf0a"},
{file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:069f56a7bf71d286a6ff932a9e6fb878f151c998ebb2519a9f6d1cee4bffdba3"},
{file = "regex-2026.1.15-cp39-cp39-win32.whl", hash = "sha256:ea4e6b3566127fda5e007e90a8fd5a4169f0cf0619506ed426db647f19c8454a"},
{file = "regex-2026.1.15-cp39-cp39-win_amd64.whl", hash = "sha256:cda1ed70d2b264952e88adaa52eea653a33a1b98ac907ae2f86508eb44f65cdc"},
{file = "regex-2026.1.15-cp39-cp39-win_arm64.whl", hash = "sha256:b325d4714c3c48277bfea1accd94e193ad6ed42b4bad79ad64f3b8f8a31260a5"},
{file = "regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5"},
]
[[package]]
name = "soupsieve"
version = "2.8.3"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.9"
files = [
{file = "soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95"},
{file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"},
]
[[package]]
name = "sqlparse"
version = "0.5.5"
description = "A non-validating SQL parser."
optional = false
python-versions = ">=3.8"
files = [
{file = "sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba"},
{file = "sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e"},
]
[package.extras]
dev = ["build"]
doc = ["sphinx"]
[[package]]
name = "tqdm"
version = "4.67.3"
description = "Fast, Extensible Progress Meter"
optional = false
python-versions = ">=3.7"
files = [
{file = "tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf"},
{file = "tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras]
dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"]
discord = ["requests"]
notebook = ["ipywidgets (>=6)"]
slack = ["slack-sdk"]
telegram = ["requests"]
[[package]]
name = "typing-extensions"
version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "tzdata"
version = "2025.3"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
files = [
{file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"},
{file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"},
]
[[package]]
name = "whitenoise"
version = "6.11.0"
description = "Radically simplified static file serving for WSGI applications"
optional = false
python-versions = ">=3.9"
files = [
{file = "whitenoise-6.11.0-py3-none-any.whl", hash = "sha256:b2aeb45950597236f53b5342b3121c5de69c8da0109362aee506ce88e022d258"},
{file = "whitenoise-6.11.0.tar.gz", hash = "sha256:0f5bfce6061ae6611cd9396a8231e088722e4fc67bc13a111be74c738d99375f"},
]
[package.extras]
brotli = ["brotli"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "3d7a7f2fe8ec78993616e707e29e96503f134bd1cec48cac7f6dd47814863f4f"

2
poetry.toml Normal file
View File

@@ -0,0 +1,2 @@
[virtualenvs]
in-project = true

4
public/BingSiteAuth.xml Executable file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0"?>
<users>
<user>D3BB27DEC758800CCB5E391674DF4212</user>
</users>

View File

@@ -0,0 +1 @@
google-site-verification: googleea5d443319529114.html

View File

@@ -0,0 +1 @@
google-site-verification: googleacb673eb0c31969f.html

28
public/llms.txt Normal file
View File

@@ -0,0 +1,28 @@
# Dicquo (Dictum & Quotes)
> Dicquo — это коллекция отобранных вручную цитат, оформленных с уважением к типографике. Место для вдумчивого чтения.
## О проекте
Этот сайт представляет собой минималистичную коллекцию афоризмов, цитат и высказываний известных людей мира музыки.
Основной акцент сделан на качестве текста (типографика) и визуальной подаче ("медитативный" дизайн).
## Структура контента
Каждая страница содержит одну цитату.
Основные сущности:
- **Цитата (Dictum)**: Текст высказывания.
- **Автор (Author)**: Имя автора.
- **Теги (Tags)**: Ключевые слова.
- **Интро (Intro)**: Контекст или вступление к цитате (опционально).
## Доступ к данным
- **Sitemap**: /sitemap.xml — полная карта сайта со всеми цитатами.
- **Главная**: / — показывает случайную цитату (или последнюю, в зависимости от логики).
## Лицензия
Контент (цитаты) принадлежит их авторам.
Оформление и подборка © Sergei Erjemin.

27
public/media/README.md Normal file
View File

@@ -0,0 +1,27 @@
# Папка для медиа-файлов (Media)
Эта директория предназначена для хранения загружаемого контента (изображений, документов), который генерируется или загружается пользователями в процессе работы Django-приложения.
## Как это работает с Docker
В `docker-compose.prod.yml` настроено монтирование этой папки как Docker Volume:
```yaml
volumes:
- ./media:/app/public/media
```
* **На хосте (сервере):** Файлы физически хранятся в папке `media` рядом с `docker-compose.yml`.
* **В контейнере:** Django видит их по пути `/app/public/media` (настройка `MEDIA_ROOT`).
* **Nginx:** Внешний Nginx настроен на прямую отдачу файлов из этой папки хоста, минуя Django.
## Содержимое репозитория
В репозитории эта папка содержит только этот `README.md`.
При развертывании контейнера через docker-compose (с volume `./media:/app/public/media`), содержимое этой папки на хосте становится доступным приложению.
**Автоматически создаваемые файлы:**
При старте контейнера в этой папке автоматически создается каталог `errors/`, куда копируются статические HTML-файлы для отображения ошибок (404.html, 500.html), чтобы внешний Nginx мог их отдавать, даже если Django, gunicorn или весь контейнер недоступен.
> **Важно:** Убедитесь, что у пользователя, под которым работает Nginx (обычно `www-data`), есть права на чтение этой директории и файлов в ней.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

View File

@@ -1,7 +1,26 @@
# DicQuo
User-Agent: *
Allow: /
Disallow:
Disallow: /admin/
Disallow: /*?tag=
Disallow: /*?
# Optimize for Yandex
Clean-param: tag /
# AI and LLM bots settings
# OpenAI GPT
# User-agent: GPTBot
# Disallow:
# Common Crawl (used by many AI models)
# User-agent: CCBot
# Disallow:
# Google Bard/Gemini
# User-agent: Google-Extended
# Disallow:
Host: dq.cube2.ru
Sitemap: https://dq.cube2.ru/sitemap.xml

View File

@@ -132,3 +132,93 @@ table { width: 80%; }
#next { float: right; }
#next a { border-bottom: none; }
/* --- NEW STYLES for FLEXBOX LAYOUT --- */
.container {
width: 90%;
max-width: 1200px;
margin: 0 auto;
}
/* Header */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1vh 0;
}
/* Main Content Area */
.main-content {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 80vh;
}
.content-row {
display: flex;
align-items: center;
justify-content: center;
gap: 2vw;
}
.text-col {
flex: 1;
}
.image-col {
flex: 0 0 30vw;
display: flex;
justify-content: center;
}
/* --- Icons for Header Stats (SVG in Base64) --- */
.stats-icon {
display: inline-block;
width: 0.9em;
height: 0.9em;
vertical-align: middle;
background-size: contain;
background-repeat: no-repeat;
margin-right: 0.2em;
opacity: 0.7; /* Slight transparency for subtle look */
}
/* Clock Icon (Time) */
.icon-time {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' stroke='silver' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");
}
/* Eye Icon (Views) */
.icon-views {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' stroke='silver' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'%3E%3C/path%3E%3Ccircle cx='12' cy='12' r='3'%3E%3C/circle%3E%3C/svg%3E");
}
/* Responsive: on mobile stack columns */
@media (max-width: 768px) {
.content-row {
flex-direction: column-reverse;
}
.image-col {
flex: 0 0 auto;
margin-bottom: 2vh;
}
}
/* --- ВЫРАВНИВАНИЕ СИМВОЛОВ ВИСЯЧЕЙ ПУНКТУАЦИИ (Hanging Punctuation) ТИПОГРАФА ETPGRF --- */
/* --- ЛЕВЫЕ ВИСЯЧИЕ СИМВОЛЫ (выравнивание по левому краю) --- */
.etp-laquo { margin-left: -0.44em; } /* « */
.etp-ldquo, .etp-bdquo { margin-left: -0.4em; } /* “ „ */
.etp-lsquo { margin-left: -0.22em; } /* */
.etp-lpar, .etp-lsqb, .etp-lcub { margin-left: -0.25em; } /* ( [ { */
/* --- ПРАВЫЕ ВИСЯЧИЕ СИМВОЛЫ (выравнивание по правому краю) --- */
/* Общая механика: "вырываем" символ из потока для идеального выравнивания текста */
[class^="etp-r"], [class*=" etp-r"] { position: absolute; }
/* Точечная настройка смещения для каждого символа */
.etp-raquo { right: -0.44em; } /* » */
.etp-rdquo { right: -0.4em; } /* ” */
.etp-rsquo { right: -0.22em; } /* */
.etp-rpar, .etp-rsqb, .etp-rcub { right: -0.25em; } /* ) ] } */
.etp-r-dot, .etp-r-comma, .etp-r-colon { right: -0.15em; } /* . , : */

View File

@@ -0,0 +1,98 @@
// bg-generator.js: Generates a unique gradient background based on text content
document.addEventListener("DOMContentLoaded", function() {
// 1. Get the text to hash (from hidden span in base.html)
const rawSpan = document.getElementById('dq-content-raw');
let text = rawSpan ? rawSpan.innerText.trim() : "";
if (!text) {
text = "DictumAndQuotesDefault" + Math.random(); // Fallback random if no text
}
// 2. Hash function (DJB2)
let hash = 5381;
for (let i = 0; i < text.length; i++) {
// Force 32-bit integer arithmetic
hash = ((hash << 5) + hash) + text.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
}
// 3. Generate 6 color components deterministically from the hash
// We need 6 numbers between 0 and 255.
// Let's use pseudo-random generator seeded by hash
function Mulberry32(a) {
return function() {
var t = a += 0x6D2B79F5;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
}
const rand = Mulberry32(hash); // Seeded random generator
// Generate 6 color components with darker range for "meditative" feel
let colors = [];
for(let i=0; i<6; i++) {
// Generate number between 10 and 80 (dark colors)
colors.push(Math.floor(rand() * 70) + 10);
}
// Shuffle slightly based on random to allow variation on refresh (optional)
// colors.sort(() => Math.random() - 0.5);
const rgb1 = `rgb(${colors[0]}, ${colors[1]}, ${colors[2]})`;
const rgb2 = `rgb(${colors[3]}, ${colors[4]}, ${colors[5]})`;
console.log("DQ BG Generator:", text.substring(0, 20) + "...", hash, rgb1, rgb2);
// 4. Apply to body
// Using linear-gradient to right with standard syntax
const bgString = `linear-gradient(90deg, ${rgb1} 0%, ${rgb2} 100%)`;
document.body.style.background = bgString;
// 5. Apply to image background container (if exists on index page)
const imgBgContainer = document.querySelector('.image-col center > div');
if (imgBgContainer) {
// Use the first color of the gradient with opacity 0.7
imgBgContainer.style.background = `rgba(${colors[0]}, ${colors[1]}, ${colors[2]}, 0.7)`;
}
// 6. Reveal content (Fade In effect)
setTimeout(() => {
document.body.style.opacity = 1;
}, 50);
// 7. Handle Fade Out on link clicks
document.body.addEventListener('click', function(e) {
// Find if a link was clicked (bubble up)
const link = e.target.closest('a');
if (link && link.href && link.target !== '_blank') {
const hrefAttr = link.getAttribute('href');
if (hrefAttr && !hrefAttr.startsWith('#') && !link.href.includes('javascript:')) {
// Check if it is an internal link (same domain)
if (new URL(link.href).origin === window.location.origin) {
e.preventDefault(); // Stop immediate navigation
document.body.style.opacity = 0; // Start Fade Out
// Wait for transition (matches CSS transition time 1.5s)
setTimeout(() => {
window.location.href = link.href;
}, 900);
}
}
}
});
// 8. Auto-redirect ("meditative" slideshow)
// Find the NEXT link and simulate a click on it after 15 seconds
const nextLink = document.querySelector('#next a');
if (nextLink) {
setTimeout(() => {
// Trigger the click event on the link so our handler above (step 7) catches it
// and performs the smooth fade out animation.
nextLink.click();
}, 15000);
}
});

View File

@@ -0,0 +1,6 @@
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>Verification: e5c0008fb741485c</body>
</html>

24
pyproject.toml Normal file
View File

@@ -0,0 +1,24 @@
[tool.poetry]
name = "dicquo"
version = "3.0.0"
description = "Веб-приложение для коллекции цитат и афоризмов."
authors = ["Sergei Erjemin <erjemin@gmail.com>"]
license = "MIT"
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.12"
django = "^6.0.2"
django-taggit = "^6.1.0"
pillow = "^12.1.1"
pytils = "^0.4.4"
etpgrf = "^0.1.4"
django-environ = "^0.12.1"
whitenoise = "^6.11.0"
gunicorn = "^25.1.0"
tqdm = "^4.67.3"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"