web-react/: SPA Vite+React+TS, nginx раздаёт статику и проксирует /api на api:8000 по локалке. docker-compose: web(Flet)->web-react на :80. transfer_crud.backup_restore: чистим содержимое data/radar вместо rmtree точки монтирования (EBUSY).
api-copp
Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на Flet. Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов.
Содержание
- Архитектура
- Быстрый старт
- Переменные окружения
- Структура проекта
- База данных
- API
- Перенос данных между серверами
- Паутинная диаграмма
- Redis и кэширование
- Деплой с внешним прокси
- Известные ошибки и решения
Архитектура
Браузер пользователя
│ :80
[ web — Flet ASGI ] ← единственный публичный сервис
│ http://api:8000 (только Docker-сеть)
[ api — FastAPI :8000 ]
│ │
[ PostgreSQL :5432 ] [ Redis :6379 ]
Сервис api не публикует порты наружу — доступен только контейнеру web внутри Docker-сети. Пользователь никогда не обращается к API напрямую.
PostgreSQL и Redis могут запускаться как:
- отдельным compose-файлом
docker-compose_db.yml(локально в Docker) - внешними сервисами (отдельный сервер, managed service) — достаточно прописать нужные
REDIS_HOST/DB_HOSTв переменных окружения
Быстрый старт
1. Инфраструктура (PostgreSQL + Redis)
docker compose -f docker-compose_db.yml up -d
2. Приложение
docker compose up -d --build
| Адрес | Что |
|---|---|
http://localhost |
Веб-интерфейс (Flet) |
http://localhost:8000/docs |
Swagger UI (HTTP Basic Auth) |
http://localhost:8000/redoc |
ReDoc |
http://localhost:8081 |
Дашборд статистики |
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содержит демо-значения. Перед продакшн-деплоем обязательно смените все пароли и ключи.
| Переменная | Описание | Пример |
|---|---|---|
APP_PATH |
Путь к директории приложения внутри контейнера | /app |
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, регистрация роутеров
├── requirements.txt # зависимости API
├── Dockerfile.api # образ API
├── Dockerfile.web # образ фронтенда (Flet)
├── Dockerfile.stats # образ дашборда статистики
├── docker-compose.yml # API + Web + Stats
├── docker-compose_db.yml # PostgreSQL + Redis
│
├── 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/ # Flet-фронтенд (порт 80)
│ ├── main.py
│ ├── router.py
│ ├── api_client.py
│ ├── designer.py # дизайн-система
│ ├── radar_chart.py # виджет RadarChart
│ └── views/content_users/
│
└── 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} — конкретный результат с ответами
Результаты (паутинная диаграмма)
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) сервер автоматически:
- Суммирует баллы по каждой оси (
ChoiceScore.score) для выбранных вариантов - Сохраняет
radar_result+radar_result_items - Генерирует SVG и сохраняет в
{APP_PATH}/data/radar/<тест>/<группа>/<user_id>.svg - Записывает путь в
image_path— всё в одномsession.commit()
SVG-файл
Генерируется чистым Python (route/radar_svg_gen.py):
- Фон
#2F184B, полигонrgba(155,114,207,0.55) - 5 концентрических сеток
- Подписи с именем типа и баллом
- Размер: 520×520 пикселей
Скачивание из браузера
Фронтенд скачивает SVG-байты через внутренний API (httpx, Docker-сеть), кодирует в base64 data URI и открывает в браузере. Браузер не обращается к 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).
Деплой с внешним прокси
Минимальная конфигурация 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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
Заголовки Upgrade / Connection обязательны — Flet использует WebSocket.
В репозитории есть nginx.conf и nginx.production.conf с готовыми примерами.
Известные ошибки и решения
1. api недоступен из браузера ✅
Проблема: Docker-имя api не резолвится у браузера пользователя. Любой URL вида http://api:8000/... в page.launch_url() давал ошибку DNS.
Решение: Фронтенд скачивает SVG-байты через httpx (внутренняя сеть), конвертирует в data:image/svg+xml;base64,... и передаёт готовый data URI в браузер. Прямых обращений к 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. Варианты ответов обрезались ✅
Проблема: ft.Radio(label=...) в Flet не переносит текст метки — длинные варианты обрезались.
Решение: Кастомные кликабельные контейнеры с ft.Text(expand=True, no_wrap=False).
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 (клиент) — два разных генератора. Кнопка «Сохранить» скачивает серверный файл. web/radar_svg.py не используется кнопками, но файл остался в репозитории.