# 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: " # Применить все миграции (безопасно запускать повторно — IF NOT EXISTS) curl -X POST http://localhost:8000/db/migrate \ -H "X-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: " ``` > **Важно:** `db/migrate-cascade-user` нужно выполнить **один раз** на каждом сервере с уже существующей БД (созданной до апреля 2026). На новых базах — уже включено автоматически через `create-tables`. ### 4. Загрузка тестов ```bash # Голланд (42 пары профессий) curl -X POST http://localhost:8000/polls/holland -H "X-Admin-Key: " curl -X POST http://localhost:8000/polls/holland//seed-scores -H "X-Admin-Key: " # Климов (ДДО) curl -X POST http://localhost:8000/polls/klimov -H "X-Admin-Key: " curl -X POST http://localhost:8000/polls/klimov//seed-scores -H "X-Admin-Key: " # Гломшток (Карта интересов) curl -X POST http://localhost:8000/polls/glomshtok -H "X-Admin-Key: " curl -X POST http://localhost:8000/polls/glomshtok//seed-scores -H "X-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-файлы диаграмм (структура: <тест>/<группа>/.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 ` Admin-операции: `X-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: " \ http://server-a:8000/transfer/export -o export.zip # Сервер B — на новой БД сначала создать схему (один раз) curl -X POST -H "X-Admin-Key: " http://server-b:8000/db/create-tables curl -X POST -H "X-Admin-Key: " http://server-b:8000/db/migrate curl -X POST -H "X-Admin-Key: " http://server-b:8000/db/migrate-cascade-user # Сервер B — импортировать (добавляет только новое, дубли по UUID пропускаются) curl -X POST -H "X-Admin-Key: " \ -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: " \ http://localhost:8000/backup/export -o backup_20260410.zip # Восстановить из резервной копии (УДАЛЯЕТ все текущие данные, вставляет из архива) curl -X POST -H "X-Admin-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/<тест>/<группа>/.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: ``, 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:` | Отозванные JWT-токены | До истечения токена | | `cache:` | Кэш ответов `/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://: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-соединением — та же архитектура, что подвела основной фронтенд (см. записи №1–4 выше). Плавающая версия `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` эта проверка проваливалась по нескольким пунктам.