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

633 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# api-copp
Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на React (SPA). Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. Тест можно пройти анонимно — достаточно выбрать школу и класс, аккаунт не нужен.
---
## Содержание
- [Архитектура](#архитектура)
- [Быстрый старт](#быстрый-старт)
- [Переменные окружения](#переменные-окружения)
- [Структура проекта](#структура-проекта)
- [База данных](#база-данных)
- [API](#api)
- [Перенос данных между серверами](#перенос-данных-между-серверами)
- [Паутинная диаграмма](#паутинная-диаграмма)
- [Redis и кэширование](#redis-и-кэширование)
- [Деплой с внешним прокси](#деплой-с-внешним-прокси)
- [Известные ошибки и решения](#известные-ошибки-и-решения)
---
## Архитектура
```
Браузер пользователя
│ :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`, см. «Переменные окружения»).
```bash
# Только приложение (внешние БД/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. Инициализация БД (первый запуск)
```bash
# Создать таблицы
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. Загрузка тестов
```bash
# Голланд (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.
```bash
# Сервер 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
```
**Ответ импорта:**
```json
{
"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)
Полный снимок БД с возможностью полного восстановления.
```bash
# Создать резервную копию (имя файла начинается с 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-папка тоже очищается и восстанавливается. Используйте только для полного восстановления.
**Ответ восстановления:**
```json
{
"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 перед контейнером:
```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` требует миграции на старых БД ✅
**Решение:**
```sql
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` эта проверка проваливалась по нескольким пунктам.