Files
api-copp/README.md
jze9 eb110f677c feat(stats): переписать дашборд статистики с Flet на React
stats/ (Flet) генерировал графики через matplotlib в PNG и отдавал их
через Flutter web с WebSocket — та же архитектура, что подводила
основной фронтенд (см. известные ошибки в README). Плавающая версия
flet>=0.82.2 недавно подтянула 0.86.0 с несовместимым протоколом
фреймирования, и дашборд перестал открываться в браузере.

stats-react/ — SPA по тому же паттерну, что и web-react (Vite+React+TS,
свой Dockerfile+nginx, same-origin прокси /api вместо WebSocket).
Функциональность 1:1: 4 summary-карточки, общие фильтры (тест/
организация/группа/год), два таба (прохождения / результаты тестов),
8 графиков, топ-10 таблица, фильтр по оси + сравнение по организациям/
группам. Круговая диаграмма заменена на 100%-stacked bar (dataviz:
"part-to-whole rides on the stacked bar chart; donut stays
deprioritized") с фолдингом длинного хвоста категорий в "Другое" —
у оригинала было 32 категории на 8 цветов, из-за чего сегменты
становились неразличимы.

Категориальная палитра графиков провалидирована на CVD-безопасность
(scripts/validate_palette.js, light+dark) — 8-цветная палитра из
исходного designer.py не проходила проверку (hard fail по
normal-vision floor). Бренд-оранжевый сохранён в палитре, остальные
7 слотов — из референсного набора skill'а.

stats/ и Dockerfile.stats удалены, docker-compose.yml переключён на
stats-react. Проверено сборкой всех трёх образов, полным compose up
и визуально (headless Chrome, light+dark, обе вкладки, реальные
данные с прод-сервера).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 14:11:06 +05:00

36 KiB
Raw Permalink Blame History

api-copp

Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на React (SPA). Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. Тест можно пройти анонимно — достаточно выбрать школу и класс, аккаунт не нужен.


Содержание


Архитектура

Браузер пользователя
        │ :80
  [ web-react — nginx (SPA + reverse-proxy /api) ]   ← единственный публичный сервис
        │ http://api:8000 (только Docker-сеть)
  [ api — FastAPI :8000 ]
        │                 │
 [ PostgreSQL :5432 ]  [ Redis :6379 ]

Сервис api не публикует порты наружу — доступен только контейнеру web-react внутри Docker-сети. Браузер обращается к /api/*, nginx внутри web-react перенаписывает и проксирует запрос на api:8000 (см. web-react/nginx.conf). Пользователь никогда не обращается к API напрямую.

PostgreSQL и Redis могут запускаться как:

  • сервисами postgres/redis в том же docker-compose.yml, за профилем local-db (локально в Docker)
  • внешними сервисами (отдельный сервер, managed service) — достаточно прописать нужные REDIS_HOST/DB_HOST в .env, профиль local-db не поднимать

Быстрый старт

Один файл docker-compose.yml на всё. По умолчанию поднимает только приложение (api + web-react + stats) — БД и Redis ожидаются внешними (DB_HOST/REDIS_HOST в .env, см. «Переменные окружения»).

# Только приложение (внешние БД/Redis из .env)
docker compose up -d --build

# + локальные PostgreSQL и Redis в Docker (профиль local-db)
docker compose --profile local-db up -d --build
Адрес Что
http://localhost Веб-интерфейс (React SPA)
http://localhost:8000/docs Swagger UI (HTTP Basic Auth)
http://localhost:8000/redoc ReDoc
http://localhost:8081 Дашборд статистики

Реальные значения переменных окружения (пароли, ключи) держите в .env в корне репозитория — docker compose подставляет их автоматически. Шаблон без секретов — в .env.example. Файл .env в .gitignore, в репозиторий не попадает.

3. Инициализация БД (первый запуск)

# Создать таблицы
curl -X POST http://localhost:8000/db/create-tables \
     -H "X-Admin-Key: <ADMIN_KEY>"

# Применить все миграции (безопасно запускать повторно — IF NOT EXISTS)
curl -X POST http://localhost:8000/db/migrate \
     -H "X-Admin-Key: <ADMIN_KEY>"

# Миграция FK: responses.user_id → users.id ON DELETE CASCADE
curl -X POST http://localhost:8000/db/migrate-cascade-user \
     -H "X-Admin-Key: <ADMIN_KEY>"

Важно: db/migrate-cascade-user нужно выполнить один раз на каждом сервере с уже существующей БД (созданной до апреля 2026). На новых базах — уже включено автоматически через create-tables.

4. Загрузка тестов

# Голланд (42 пары профессий)
curl -X POST http://localhost:8000/polls/holland -H "X-Admin-Key: <ADMIN_KEY>"
curl -X POST http://localhost:8000/polls/holland/<poll_id>/seed-scores -H "X-Admin-Key: <ADMIN_KEY>"

# Климов (ДДО)
curl -X POST http://localhost:8000/polls/klimov -H "X-Admin-Key: <ADMIN_KEY>"
curl -X POST http://localhost:8000/polls/klimov/<poll_id>/seed-scores -H "X-Admin-Key: <ADMIN_KEY>"

# Гломшток (Карта интересов)
curl -X POST http://localhost:8000/polls/glomshtok -H "X-Admin-Key: <ADMIN_KEY>"
curl -X POST http://localhost:8000/polls/glomshtok/<poll_id>/seed-scores -H "X-Admin-Key: <ADMIN_KEY>"

Переменные окружения

docker-compose.yml читает значения из .env (не коммитится, см. .env.example за шаблоном). Перед продакшн-деплоем обязательно смените все пароли и ключи в своём .env.

Переменная Описание Пример
APP_PATH Путь к директории приложения внутри контейнера /app
DEBUG Режим отладки API False
DB_HOST Хост PostgreSQL postgres
DB_PORT Порт PostgreSQL 5432
DB_USER Пользователь БД postgres
DB_PASS Пароль БД
DB_NAME Имя базы данных profi
REDIS_HOST Хост Redis redis
REDIS_PORT Порт Redis 6379
REDIS_DB Номер БД Redis 0
REDIS_PASSWORD Пароль Redis
SECRET_KEY Секрет JWT (min 32 символа) python -c "import secrets; print(secrets.token_hex(32))"
ADMIN_KEY Ключ для admin-эндпоинтов (X-Admin-Key)
DOCS_USERNAME Логин для /docs и /redoc admin
DOCS_PASSWORD Пароль для /docs и /redoc
API_URL URL API для фронтенда (только Docker-сеть) http://api:8000

Структура проекта

api-copp/
├── main.py                   # точка входа FastAPI, регистрация роутеров
├── pyproject.toml / uv.lock  # зависимости API (единственный источник — читает Dockerfile.api через uv)
├── .dockerignore              # что не попадает в build context образов
├── .env.example               # шаблон переменных окружения без секретов
├── Dockerfile.api             # образ API
├── docker-compose.yml         # api + web-react + stats-react (+ postgres/redis за профилем local-db)
│
├── bd/
│   ├── __init__.py           # Settings (pydantic), make_engine()
│   └── tables/
│       ├── simple_base.py
│       ├── users.py          # User (username, hashed_password, group_id, org_id)
│       ├── organization.py   # Organization
│       ├── group.py          # Group
│       ├── poll.py           # Poll (title, description, is_active)
│       ├── question.py       # Question (type: single/multiple)
│       ├── choice.py         # Choice (варианты ответов)
│       ├── response.py       # Response, Answer
│       ├── scale.py          # ScaleDimension, ChoiceScore
│       └── radar_result.py   # RadarResult, RadarResultItem
│
├── route/
│   ├── auth_utils.py         # JWT, bcrypt, require_admin_key
│   ├── auth.py               # /auth/register, /auth/login, /auth/logout
│   ├── base.py               # /health, /version
│   ├── init_data_base.py     # /db/create-tables, /db/migrate, /db/migrate-cascade-user
│   ├── users_crud.py         # CRUD пользователей
│   ├── groups_crud.py        # CRUD групп
│   ├── organizations_crud.py # CRUD организаций
│   ├── poll_crud.py          # CRUD опросов
│   ├── question_crud.py      # CRUD вопросов
│   ├── choice_crud.py        # CRUD вариантов ответов
│   ├── response_crud.py      # приём ответов + авто-расчёт радара
│   ├── holland_crud.py       # тест Голланда
│   ├── klimov_crud.py        # тест Климова (ДДО)
│   ├── glomshtok_crud.py     # тест Гломштока (Карта интересов)
│   ├── scale_crud.py         # CRUD осей и расшифровок
│   ├── radar_crud.py         # расчёт и чтение радарных результатов
│   ├── radar_svg_gen.py      # серверная генерация SVG-диаграмм
│   ├── stats_crud.py         # статистика (кэшируется в Redis, TTL 5 мин)
│   └── transfer_crud.py      # экспорт/импорт/бэкап/восстановление БД
│
├── web-react/                 # React SPA-фронтенд (порт 80, свой Dockerfile+nginx)
│   ├── src/api/client.ts      # единая точка HTTP-запросов к API
│   ├── src/auth/AuthContext.tsx  # токен-логин + анонимная сессия (школа+класс без аккаунта)
│   ├── src/pages/              # страницы (Login, AnonymousEntry, Poll, MyTests, ...)
│   └── nginx.conf              # раздаёт статику SPA, проксирует /api → api:8000
│
├── stats-react/                # дашборд статистики, React SPA (порт 8081, свой Dockerfile+nginx)
│   ├── src/api/client.ts       # HTTP-клиент к /stats/*, /polls/, /organizations/, /groups/
│   ├── src/components/charts/  # BarChart, LineChart, PartToWholeBar — свои SVG/HTML, без сторонних libs
│   └── src/App.tsx             # фильтры + два таба (прохождения / результаты тестов)
│
└── data/                     # том Docker (./data:/app/data)
    ├── postgres/             # данные PostgreSQL
    └── radar/                # SVG-файлы диаграмм (структура: <тест>/<группа>/<user_id>.svg)

База данных

Схема связей

organizations ──< groups ──< users
                                │ CASCADE DELETE
polls ──< questions ──< choices ──< choice_scores
  │
  └──< responses ──< answers               (CASCADE: poll→responses, responses→answers)
         │
         └──< radar_results ──< radar_result_items   (CASCADE)
                   │
             image_path → data/radar/...svg

polls ──< scale_dimensions ──< choice_scores
                 │
                 └── radar_result_items.dimension_id (SET NULL при удалении оси)

Каскадное удаление

При удалении пользователя автоматически удаляется:

  • все его responses → все answers внутри → все radar_results → все radar_result_items

organization и group, к которым был привязан пользователь, не удаляются — только обнуляются ссылки (SET NULL).

При удалении теста (poll):

  • все questions → все choices → все choice_scores
  • все responses → все answers → все radar_results → все radar_result_items
  • все scale_dimensions → все choice_scores

Миграции

Применяются через admin-эндпоинты (безопасно запускать повторно — используют IF NOT EXISTS/IF EXISTS):

Эндпоинт Что делает
POST /db/create-tables Создаёт все таблицы по моделям SQLAlchemy
POST /db/migrate Добавляет колонки username/hashed_password в users, image_path в radar_results
POST /db/migrate-cascade-user Добавляет FK responses.user_id → users(id) ON DELETE CASCADE

API

Аутентификация

POST /auth/register      — регистрация (username ≥ 3 симв., password ≥ 8 симв.)
POST /auth/login         — JWT-токен (OAuth2 password flow)
POST /auth/logout        — отзыв токена (добавляет в Redis-блэклист)

Токен: Authorization: Bearer <token>
Admin-операции: X-Admin-Key: <ADMIN_KEY>

Тесты и вопросы

GET    /polls                           — список тестов
POST   /polls                           — создать тест  [admin]
GET    /polls/{id}                      — тест с вопросами и вариантами
DELETE /polls/{id}                      — удалить тест  [admin]
POST   /polls/{id}/questions            — добавить вопрос  [admin]
POST   /questions/{id}/choices          — добавить вариант ответа  [admin]

Прохождение теста

POST /polls/{poll_id}/responses         — сдать тест (автоматически считает радар + генерирует SVG)
GET  /polls/users/{user_id}/responses   — история пользователя
GET  /responses/{id}                    — конкретный результат с ответами

POST /polls/{poll_id}/responses не требует авторизации: user_id необязателен. web-react использует это для анонимного прохождения — школьник на /anonymous выбирает только школу и класс (без пароля и аккаунта), выбор кладётся в group_id/organization_id тела запроса вместо user_id. Результат сохраняется в базе как снимок школы/класса без привязки к пользователю; истории результатов после закрытия вкладки у анонимной сессии нет — только у настоящих аккаунтов (/my-tests).

Результаты (паутинная диаграмма)

GET  /responses/{id}/radar              — данные диаграммы (оси + баллы)
GET  /responses/{id}/radar/image        — SVG-файл диаграммы
POST /responses/{id}/radar/compute      — пересчитать диаграмму  [admin]
GET  /users/{user_id}/radar-results     — история всех радарных результатов пользователя

Оси и расшифровки

POST   /polls/{poll_id}/dimensions      — создать ось  [admin]
GET    /polls/{poll_id}/dimensions      — список осей
PUT    /dimensions/{id}                 [admin]
DELETE /dimensions/{id}                 [admin]
POST   /dimensions/{id}/scores          — задать расшифровку варианта ответа  [admin]
GET    /choices/{choice_id}/scores

Готовые тесты

POST /polls/holland                           — создать тест Голланда (42 пары)  [admin]
POST /polls/holland/{poll_id}/seed-scores     — загрузить ключ расшифровки  [admin]
POST /polls/klimov                            — создать тест Климова (ДДО)  [admin]
POST /polls/klimov/{poll_id}/seed-scores      [admin]
POST /polls/glomshtok                         — создать тест Гломштока  [admin]
POST /polls/glomshtok/{poll_id}/seed-scores   [admin]

Пользователи / Группы / Организации

POST   /users/                          — создать пользователя  [admin]
GET    /users/                          — список пользователей  [admin]
DELETE /users/{id}                      — удалить пользователя (каскадно удаляет все данные)
GET    /users/me                        — текущий пользователь
PUT    /users/me                        — обновить профиль

GET    /groups/                         — список групп
POST   /groups/                         [admin]
...

GET    /organizations/                  — список организаций
POST   /organizations/                  [admin]
...

Статистика

GET /stats/summary                      — общая сводка (total_responses, total_polls, ...)
GET /stats/responses?group_by=poll      — ответы по тестам/организациям/группам/месяцам
GET /stats/results/avg-by-dimension     — средний балл по осям
GET /stats/results/distribution         — распределение баллов
GET /stats/years                        — список лет с активностью

Фильтры: poll_id, org_id, group_id, year. Все ответы кэшируются в Redis (TTL 5 минут). Кэш автоматически сбрасывается при удалении пользователя.

База данных

POST /db/create-tables                  [admin]
POST /db/migrate                        [admin]
POST /db/migrate-cascade-user           [admin]

Служебные

GET /health
GET /version

Перенос данных между серверами

Два сценария использования:

Сценарий 1: Дополнение (transfer)

Ученики прошли тест на сервере A — нужно перенести их результаты на сервер B, не удаляя существующие данные на B.

# Сервер A — скачать архив (все таблицы + SVG-диаграммы)
curl -H "X-Admin-Key: <KEY_A>" \
     http://server-a:8000/transfer/export -o export.zip

# Сервер B — на новой БД сначала создать схему (один раз)
curl -X POST -H "X-Admin-Key: <KEY_B>" http://server-b:8000/db/create-tables
curl -X POST -H "X-Admin-Key: <KEY_B>" http://server-b:8000/db/migrate
curl -X POST -H "X-Admin-Key: <KEY_B>" http://server-b:8000/db/migrate-cascade-user

# Сервер B — импортировать (добавляет только новое, дубли по UUID пропускаются)
curl -X POST -H "X-Admin-Key: <KEY_B>" \
     -F "file=@export.zip" \
     http://server-b:8000/transfer/import

Ответ импорта:

{
  "status": "ok",
  "result": {
    "inserted": {"organizations": 107, "users": 4, "responses": 34, "radar_results": 34, "svg_files": 23, ...},
    "skipped":  {"organizations": 0,   "users": 0, "responses": 0,  "radar_results": 0, ...}
  }
}

При повторном импорте того же архива все записи попадают в skippedдублей не создаётся.

Сценарий 2: Резервное копирование и восстановление (backup)

Полный снимок БД с возможностью полного восстановления.

# Создать резервную копию (имя файла начинается с backup_)
curl -H "X-Admin-Key: <KEY>" \
     http://localhost:8000/backup/export -o backup_20260410.zip

# Восстановить из резервной копии (УДАЛЯЕТ все текущие данные, вставляет из архива)
curl -X POST -H "X-Admin-Key: <KEY>" \
     -F "file=@backup_20260410.zip" \
     http://localhost:8000/backup/restore

Внимание: /backup/restore полностью очищает БД (в обратном FK-порядке) перед вставкой данных. SVG-папка тоже очищается и восстанавливается. Используйте только для полного восстановления.

Ответ восстановления:

{
  "status": "ok",
  "deleted":  {"radar_result_items": 332, "users": 4, "organizations": 107, ...},
  "inserted": {"organizations": 107, "users": 4, "radar_result_items": 332, "svg_files": 23, ...}
}

Что входит в архив

Оба архива содержат одинаковый набор данных (12 таблиц в FK-порядке + SVG):

manifest.json          ← версия формата, дата, количество записей
organizations.json
groups.json
polls.json
questions.json
choices.json
scale_dimensions.json
choice_scores.json
users.json
responses.json
answers.json
radar_results.json
radar_result_items.json
radar_svgs/            ← все SVG-файлы (структура папок сохраняется)

Все UUID сохраняются без изменений — FK-связи не рвутся.


Паутинная диаграмма

Как работает

При сдаче теста (POST /polls/{id}/responses) сервер автоматически:

  1. Суммирует баллы по каждой оси (ChoiceScore.score) для выбранных вариантов
  2. Сохраняет radar_result + radar_result_items
  3. Генерирует SVG и сохраняет в {APP_PATH}/data/radar/<тест>/<группа>/<user_id>.svg
  4. Записывает путь в image_path — всё в одном session.commit()

SVG-файл

Генерируется чистым Python (route/radar_svg_gen.py):

  • Фон #2F184B, полигон rgba(155,114,207,0.55)
  • 5 концентрических сеток
  • Подписи с именем типа и баллом
  • Размер: 520×520 пикселей

Скачивание из браузера

web-react отдаёт SVG через тот же same-origin прокси, что и остальной API: <img src="/api/responses/{id}/radar/image">, nginx внутри контейнера web-react перенаправляет запрос на api:8000 (см. web-react/nginx.conf). Браузер по-прежнему не обращается к api напрямую — api не публикует порты.


Redis и кэширование

Настройка Redis

Используется connection pool (max_connections=20). При Redis с отключённой командой CONFIG (например, Debian redis-server по умолчанию) — настройки меняются только через /etc/redis/redis.conf + systemctl restart redis.

Рекомендуемые настройки для продакшна:

maxmemory 2048mb
maxmemory-policy volatile-lru
appendfsync everysec
timeout 300
tcp-keepalive 60

Назначение ключей

Префикс Что хранит TTL
token_blacklist:<jti> Отозванные JWT-токены До истечения токена
cache:<md5> Кэш ответов /stats/* 5 минут

Сброс кэша

Кэш статистики (cache:*) автоматически сбрасывается при удалении пользователя — чтобы счётчики обновились немедленно, а не через 5 минут.


Тест Голланда

Модификация Г.В. Резапкиной теста профессиональной ориентации Дж. Голланда. 42 пары профессий, испытуемый выбирает предпочтительную в каждой паре.

Тип Название Цвет
Р Реалистичный #FF8C00
И Интеллектуальный #4169E1
С Социальный #32CD32
К Конвенциональный #9370DB
П Предприимчивый #DC143C
А Артистический #FF69B4

Каждый тип — 14 вопросов (14 максимальных баллов). seed-scores создаёт 6 осей (ScaleDimension) и 84 записи расшифровки (ChoiceScore).


Деплой с внешним прокси

web-react — статический SPA + reverse-proxy внутри своего же контейнера (web-react/nginx.conf), долгоживущих WebSocket-соединений (как было у Flet) больше нет. Минимальная конфигурация внешнего nginx перед контейнером:

location / {
    proxy_pass         http://<server-ip>:80;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Real-IP         $remote_addr;
    proxy_http_version 1.1;
}

nginx.conf и nginx.production.conf в репозитории всё ещё написаны под старый Flet-фронтенд (upstream flet_app, отдельный location /ws для WebSocket, location /radar-image/). После перехода на web-react эти файлы не актуальны и не обновлялись — nginx.production.conf, судя по всему, конфиг боевого внешнего nginx для test.cloud-copp74.ru, поэтому его правка не входит в эту уборку без отдельного подтверждения (ошибка здесь ломает публичный домен). Если используете внешний прокси в проде — сверьте его с примером выше перед следующим деплоем.


Известные ошибки и решения

1. api недоступен из браузера (относилось к старому Flet-фронтенду)

Проблема: Docker-имя api не резолвится у браузера пользователя. Любой URL вида http://api:8000/... в page.launch_url() (Flet) давал ошибку DNS.

Решение в web-react: same-origin прокси /api/* в web-react/nginx.conf — браузер всегда обращается к тому же origin, что и отдал SPA, прямых обращений к api из браузера нет в принципе (см. «Скачивание из браузера» выше).


2. SVG записывался без image_path при сбое

Проблема: image_path обновлялся вторым session.commit() — при падении между первым и вторым записи оставались без пути к файлу.

Решение: SVG генерируется до коммита, image_path устанавливается до коммита. Один атомарный commit().


3. /docs был открыт без авторизации

Решение: docs_url=None, openapi_url=None, redoc_url=None — стандартные маршруты отключены. Вместо них собственные маршруты с HTTP Basic Auth (secrets.compare_digest против timing-атак).


4. Варианты ответов обрезались (относилось к старому Flet-фронтенду)

Проблема: ft.Radio(label=...) в Flet не переносит текст метки — длинные варианты обрезались.

Решение: актуально не для этого проекта — в web-react варианты ответа рендерятся обычной HTML-разметкой (PollPage.tsx), перенос текста работает из коробки.


5. image_path требует миграции на старых БД

Решение:

ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512);

Применяется через POST /db/migrate.


6. При удалении пользователя оставались его ответы

Проблема: responses.user_id был простым UUID без FK-ограничения. При удалении пользователя его ответы, answers и radar_results оставались в БД (висящие строки).

Решение: Добавлен FK responses.user_id → users(id) ON DELETE CASCADE. Теперь удаление пользователя каскадно удаляет все его данные.

Применяется через POST /db/migrate-cascade-user.


7. Статистика не обновлялась после удаления пользователя

Проблема: После удаления пользователя кэш /stats/* в Redis ещё 5 минут отдавал старые данные (включая удалённые ответы).

Решение: delete_user вызывает cache_flush() — удаляет все ключи cache:* через scan_iter. Следующий запрос к статистике сразу идёт в БД.


8. Redis на Debian отключает команду CONFIG

Проблема: /etc/redis/redis.conf содержал rename-command CONFIG "" — команда CONFIG SET возвращала ошибку ERR unknown command.

Решение: Настройки меняются напрямую в /etc/redis/redis.conf + systemctl restart redis. Строку rename-command CONFIG "" при необходимости удалить или закомментировать.


9. pg8000 вместо psycopg

Проблема: pyproject.toml указывал psycopg>=3.3.3, реально использовался pg8000.

Решение: Зависимость заменена на pg8000>=1.29.0. Все запросы к БД синхронные.


10. Дублирование SVG-генератора

Было: route/radar_svg_gen.py (сервер) и web/radar_svg.py (клиент, Flet) — два разных генератора, второй нигде не вызывался.

Решение: старый Flet-фронтенд web/ целиком удалён вместе с Dockerfile.web (заменён на web-react в коммите eac6192). Единственный генератор SVG теперь — route/radar_svg_gen.py.


11. Реальные секреты в docker-compose.yml

Проблема: пароли БД/Redis, SECRET_KEY, ADMIN_KEY были прописаны в открытом виде прямо в docker-compose.yml (тогда ещё вместе с отдельным docker-compose_db.yml), закоммичены в git.

Решение: значения вынесены в .env.gitignore, не коммитится), compose-файлы читают их через ${VAR}. Шаблон без секретов — .env.example. Важно: старые значения уже есть в истории git (коммит 71887b0) — сама по себе эта правка их не отзывает, реальные пароли на серверах БД/Redis нужно ротировать отдельно.


12. Зависимости API расходились между pyproject.toml и requirements.txt

Проблема: Dockerfile.api ставил зависимости из requirements.txt, а не из pyproject.toml/uv.lock — версии успели разойтись (redis==5.0.0 vs >=7.1.0, sqlalchemy==2.0.32 vs >=2.0.47).

Решение: Dockerfile.api теперь ставит зависимости через uv sync --frozen напрямую из pyproject.toml/uv.lock. requirements.txt удалён — единственный источник правды. При переходе обнаружилась ещё одна расхождение: python-multipart (нужен FastAPI для UploadFile/Form, используется в organizations_crud.py при импорте .docx) был в requirements.txt, но отсутствовал в pyproject.toml — без него API падал при старте с RuntimeError. Добавлен в зависимости.


13. Деплой требовал двух отдельных compose-файлов

Было: docker-compose.yml (api + web-react + stats) и docker-compose_db.yml (postgres + redis) — два отдельных файла и две команды на разворачивание.

Решение: postgres/redis перенесены в тот же docker-compose.yml, за профилем local-db — не поднимаются командой docker compose up -d по умолчанию (приложение по умолчанию ходит на внешние DB_HOST/REDIS_HOST из .env), но доступны через docker compose --profile local-db up -d, когда нужна локальная БД. docker-compose_db.yml удалён.


14. Дашборд статистики был на Flet — переписан на React

Проблема: stats/ (Flet, Dockerfile.stats) генерировал графики через matplotlib в PNG на сервере и отдавал их клиенту через Flutter web c долгоживущим WebSocket-соединением — та же архитектура, что подвела основной фронтенд (см. записи №14 выше). Плавающая версия flet>=0.82.2 в какой-то момент подтянула мажорный 0.86.0 с несовместимым протоколом фреймирования, и дашборд перестал открываться в браузере.

Решение: stats/ и Dockerfile.stats удалены, дашборд переписан на React SPA (stats-react/, тот же паттерн, что и web-react/) — same-origin прокси /api/* вместо WebSocket, графики — свой SVG/HTML без сторонних библиотек (bar/line/part-to-whole с hover-тултипами), функциональность 1:1 с оригиналом (2 таба, 4+4 графика, топ-10 таблица, все фильтры). Категориальная палитра графиков провалидирована на CVD-безопасность (node scripts/validate_palette.js) — у исходной 8-цветной палитры из designer.py эта проверка проваливалась по нескольким пунктам.