Files
api-copp/README.md
2026-04-10 01:39:47 +05:00

598 lines
27 KiB
Markdown
Raw 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 + веб-интерфейс на Flet. Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов.
---
## Содержание
- [Архитектура](#архитектура)
- [Быстрый старт](#быстрый-старт)
- [Переменные окружения](#переменные-окружения)
- [Структура проекта](#структура-проекта)
- [База данных](#база-данных)
- [API](#api)
- [Перенос данных между серверами](#перенос-данных-между-серверами)
- [Паутинная диаграмма](#паутинная-диаграмма)
- [Redis и кэширование](#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)
```bash
docker compose -f docker-compose_db.yml up -d
```
### 2. Приложение
```bash
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. Инициализация БД (первый запуск)
```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` содержит **демо-значения**. Перед продакшн-деплоем обязательно смените все пароли и ключи.
| Переменная | Описание | Пример |
|---|---|---|
| `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.
```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 пикселей
### Скачивание из браузера
Фронтенд скачивает 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:
```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` требует миграции на старых БД ✅
**Решение:**
```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` (клиент) — два разных генератора. Кнопка «Сохранить» скачивает серверный файл. `web/radar_svg.py` не используется кнопками, но файл остался в репозитории.