Files
api-copp/README.md
2026-04-01 14:18:52 +05:00

436 lines
24 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
Веб-приложение для создания, проведения и анализа психологических тестов. Включает API-сервер на FastAPI и пользовательский интерфейс на Flet. Поставляется в Docker и поддерживает готовый тест по методике профессиональной ориентации Голланда.
---
## Содержание
- [Архитектура](#архитектура)
- [Быстрый старт](#быстрый-старт)
- [Переменные окружения](#переменные-окружения)
- [Структура проекта](#структура-проекта)
- [База данных](#база-данных)
- [API](#api)
- [Тест Голланда](#тест-голланда)
- [Паутинная диаграмма](#паутинная-диаграмма)
- [Фронтенд](#фронтенд)
- [Известные ошибки и просчёты](#известные-ошибки-и-просчёты)
---
## Архитектура
```
┌─────────────────────────────────────────────┐
│ Браузер пользователя :80 │
│ (Flet Web App) │
└────────────────────┬────────────────────────┘
│ HTTP / REST
┌────────────────────▼────────────────────────┐
│ FastAPI API :8000 │
│ - аутентификация (JWT) │
│ - управление тестами │
│ - сбор ответов │
│ - расчёт радарных результатов │
│ - генерация SVG-диаграмм │
└──────┬─────────────────────┬────────────────┘
│ │
┌──────▼──────┐ ┌────────▼────────┐
│ PostgreSQL │ │ Redis │
│ :5432 │ │ :6379 │
│ (основная │ │ (сессии / │
│ БД) │ │ кэш) │
└─────────────┘ └─────────────────┘
```
**Сетевая топология Docker:** все три сервиса (api, web, postgres, redis) работают в одной Docker-сети. Фронтенд (`web`) обращается к API по внутреннему имени `http://api:8000`. PostgreSQL и Redis запускаются отдельным compose-файлом `docker-compose_db.yml`.
---
## Быстрый старт
### 1. Инфраструктура (PostgreSQL + Redis)
```bash
docker compose -f docker-compose_db.yml up -d
```
### 2. Приложение (API + Web)
```bash
docker compose up -d --build
```
После запуска:
| Адрес | Что |
|-------|---------------------------|
| `http://localhost` | Веб-интерфейс (Flet) |
| `http://localhost:8000/docs` | Swagger UI (HTTP Basic Auth) |
| `http://localhost:8000/redoc` | ReDoc |
Доступ к документации — логин/пароль из переменных `DOCS_USERNAME` / `DOCS_PASSWORD`.
### 3. Инициализация БД
После первого запуска выполните:
```bash
# Создать таблицы
curl -X POST http://localhost:8000/db/create-tables \
-H "X-Admin-Key: <ADMIN_KEY>"
# Применить миграции (добавляет колонки, если БД уже была создана ранее)
curl -X POST http://localhost:8000/db/migrate \
-H "X-Admin-Key: <ADMIN_KEY>"
```
### 4. Загрузка теста Голланда
```bash
# Создать тест (возвращает poll_id)
curl -X POST http://localhost:8000/polls/holland \
-H "X-Admin-Key: <ADMIN_KEY>"
# Загрузить ключ расшифровки (42 пары → 6 типов × 14 баллов)
curl -X POST http://localhost:8000/polls/holland/<poll_id>/seed-scores \
-H "X-Admin-Key: <ADMIN_KEY>"
```
---
## Переменные окружения
> **Важно:** перед деплоем в продакшн обязательно смените `SECRET_KEY`, `ADMIN_KEY`, `DOCS_PASSWORD` и пароли БД.
| Переменная | Описание | Пример |
|---|---|---|
| `APP_PATH` | Абсолютный путь к директории приложения внутри контейнера | `/home/user/api-copp` |
| `DB_HOST` | Хост PostgreSQL | `postgres` |
| `DB_PORT` | Порт PostgreSQL | `5432` |
| `DB_USER` | Пользователь БД | `postgres` |
| `DB_PASS` | Пароль БД | `postgres` |
| `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-эндпоинтов | — |
| `DOCS_USERNAME` | Логин для доступа к `/docs` и `/redoc` | `admin` |
| `DOCS_PASSWORD` | Пароль для доступа к `/docs` и `/redoc` | — |
| `API_URL` | Внутренний URL API для фронтенда | `http://api:8000` |
Файлы `docker-compose.yml` содержат **демо-значения** — не используйте их в продакшн.
---
## Структура проекта
```
api-copp/
├── main.py # точка входа FastAPI, регистрация роутеров
├── requirements.txt # зависимости API
├── pyproject.toml # метаданные проекта
├── Dockerfile.api # образ API (python:3.14-slim)
├── Dockerfile.web # образ фронтенда (python:3.11-slim + flet)
├── docker-compose.yml # API + Web
├── docker-compose_db.yml # PostgreSQL + Redis
├── docker-compose_web.yml # отдельный запуск только Web
├── bd/ # слой доступа к данным
│ ├── __init__.py # Settings (pydantic), make_engine()
│ └── tables/
│ ├── simple_base.py # общий Base для SQLAlchemy
│ ├── users.py # User
│ ├── organization.py # Organization
│ ├── group.py # Group
│ ├── poll.py # Poll
│ ├── question.py # Question
│ ├── choice.py # Choice
│ ├── response.py # Response, Answer
│ ├── scale.py # ScaleDimension, ChoiceScore
│ └── radar_result.py # RadarResult, RadarResultItem
├── route/ # FastAPI-роутеры
│ ├── auth_utils.py # JWT, bcrypt, require_admin_key
│ ├── auth.py # /auth/register, /auth/login
│ ├── base.py # /health, /version
│ ├── init_data_base.py # /db/create-tables, /db/migrate
│ ├── 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 # генерация теста Голланда + seed-scores
│ ├── scale_crud.py # CRUD осей и расшифровок
│ ├── radar_crud.py # расчёт и чтение радарных результатов
│ └── radar_svg_gen.py # серверная генерация SVG-диаграмм
├── web/ # Flet-фронтенд
│ ├── main.py # точка входа Flet ASGI
│ ├── router.py # маршрутизация страниц
│ ├── api_client.py # httpx-обёртки над API
│ ├── designer.py # компоненты дизайн-системы
│ ├── radar_chart.py # виджет RadarChart (flet.canvas)
│ ├── radar_svg.py # генератор SVG-data-URI (на клиенте)
│ └── views/
│ └── content_users/
│ ├── poll_page.py # прохождение теста + диаграмма сразу после
│ ├── response_page.py # страница результата с диаграммой
│ ├── my_tests_page.py # список пройденных тестов
│ └── ...
└── data/ # смонтированный том Docker (./data:/app/data)
├── postgres/ # данные PostgreSQL
└── radar/ # сохранённые SVG-файлы диаграмм (создаётся автоматически)
```
---
## База данных
### Схема таблиц
```
organizations ──< groups ──< users
polls ──< questions ──< choices ──< choice_scores
│ │
└──< responses ──< answers │
│ │
└──< radar_results ──< radar_result_items
(image_path) └── (dimension_id FK → scale_dimensions)
polls ──< scale_dimensions ──────────────────-─┘
```
### Ключевые таблицы
| Таблица | Описание |
|---|---|
| `users` | Испытуемые: `username`, `hashed_password`, `first_name`, `last_name`, FK на `groups` и `organizations` |
| `polls` | Тест/опрос: `title`, `description` |
| `questions` | Вопросы теста с `type` (`single` / `multiple`) |
| `choices` | Варианты ответов к вопросам |
| `responses` | Один факт прохождения теста (user + poll + timestamp) |
| `answers` | Конкретные ответы внутри прохождения |
| `scale_dimensions` | Оси радарной диаграммы: `name`, `color`, `position`, FK на `polls` |
| `choice_scores` | Расшифровка: какой вариант ответа сколько баллов вносит в какую ось |
| `radar_results` | Итоговый результат по осям + `image_path` (путь к SVG-файлу) |
| `radar_result_items` | Снимок значений по каждой оси на момент расчёта |
### Миграции
Таблицы создаются через `POST /db/create-tables`. Для уже существующих БД применяются дополнительные ALTER-миграции через `POST /db/migrate`. Текущие миграции:
- Добавление колонок `username` / `hashed_password` в `users`
- Добавление колонки `image_path` в `radar_results`
---
## API
### Аутентификация
```
POST /auth/register — регистрация (username ≥ 3 симв., password ≥ 8 симв.)
POST /auth/login — получить JWT-токен (OAuth2 password flow)
```
JWT-токен передаётся заголовком `Authorization: Bearer <token>`. Admin-операции требуют заголовок `X-Admin-Key`.
### Основные эндпоинты
```
# Тесты
GET /polls — список тестов
POST /polls — создать тест
GET /polls/{id} — тест с вопросами и вариантами
DELETE /polls/{id} — удалить тест
# Вопросы / Варианты ответов
POST /polls/{id}/questions
POST /questions/{id}/choices
# Прохождение
POST /polls/{poll_id}/responses — сдать тест (автоматически считает радар)
GET /polls/users/{user_id}/responses — история пользователя
GET /responses/{id} — конкретный результат
# Результаты (радар)
POST /responses/{id}/radar/compute — (пере)считать радарный результат
GET /responses/{id}/radar — данные для диаграммы
GET /responses/{id}/radar/image — SVG-файл диаграммы
GET /users/{user_id}/radar-results — история радарных результатов
# Оси и расшифровки
POST /polls/{poll_id}/dimensions — создать ось
GET /polls/{poll_id}/dimensions — список осей
PUT /dimensions/{id}
DELETE /dimensions/{id}
POST /dimensions/{id}/scores — задать расшифровку варианта ответа
GET /choices/{choice_id}/scores
# Тест Голланда
POST /polls/holland — создать тест с 42 парами
POST /polls/holland/{poll_id}/seed-scores — загрузить ключ расшифровки
# База данных (admin)
POST /db/create-tables
POST /db/migrate
# Служебные
GET /health
GET /version
```
---
## Тест Голланда
Реализована **модификация Г.В. Резапкиной** теста профессиональной ориентации Дж. Голланда. 42 пары профессий, испытуемый выбирает предпочтительную в каждой паре.
### Типы личности
| Код | Название | Цвет |
|---|---|---|
| Р | Реалистичный | `#FF8C00` |
| И | Интеллектуальный | `#4169E1` |
| С | Социальный | `#32CD32` |
| К | Конвенциональный | `#9370DB` |
| П | Предприимчивый | `#DC143C` |
| А | Артистический | `#FF69B4` |
Каждый тип получает ровно 14 вопросов (= 14 максимально возможных баллов). Результат — паутинная диаграмма с 6 осями.
### Процедура загрузки
1. `POST /polls/holland` — создаёт тест с 42 вопросами-парами
2. `POST /polls/holland/{poll_id}/seed-scores` — создаёт 6 `ScaleDimension` и 84 `ChoiceScore` (по 2 на каждую пару: вариант A → тип X, вариант B → тип Y)
---
## Паутинная диаграмма
### Как работает
При сдаче теста (`POST /polls/{id}/responses`) сервер автоматически:
1. Суммирует баллы по каждой оси (`ChoiceScore.score`) для выбранных вариантов
2. Сохраняет результат в `radar_results` + `radar_result_items`
3. Генерирует SVG-файл и сохраняет в `{APP_PATH}/data/radar/{result_id}.svg`
4. Записывает относительный путь `data/radar/{result_id}.svg` в колонку `image_path`
### Скачивание SVG
Фронтенд скачивает SVG с API через внутреннюю сеть (`GET /responses/{id}/radar/image`), кодирует в base64 data URI и открывает в браузере как `data:image/svg+xml;base64,...` — файл сохраняется на устройство пользователя без необходимости публичного URL к серверу.
### Генерация SVG
SVG генерируется чистым Python без зависимостей (`route/radar_svg_gen.py`). Серверный SVG идентичен клиентскому (`web/radar_svg.py`) по разметке, но предназначен для долгосрочного хранения. Параметры:
- Фон `#2F184B`, полигон `rgba(155,114,207,0.55)`
- 5 концентрических сеток
- Подписи с именем типа и баллом
- Размер холста: 520×520 пикселей
---
## Фронтенд
Написан на **Flet** — Python-фреймворке поверх Flutter. Работает как ASGI-приложение через uvicorn, отдаётся браузеру как веб-приложение.
### Маршруты
| Путь | Страница |
|---|---|
| `/login` | Вход |
| `/reg` | Регистрация |
| `/take_test/{url_key}` | Список тестов для прохождения |
| `/poll/{poll_id}` | Прохождение теста |
| `/response/{response_id}` | Результат с диаграммой |
| `/my_tests` | Мои результаты |
| `/profile` | Профиль пользователя |
### Диаграмма после теста
После сдачи теста пользователь сразу видит паутинную диаграмму (виджет `RadarChart`) и кнопку «Сохранить диаграмму», которая загружает SVG с сервера и открывает его в новой вкладке для сохранения.
---
## Известные ошибки и просчёты
### 1. Лишнее дублирование генерации SVG
**Проблема:** SVG-генератор написан дважды — `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент). Кнопка «Сохранить» изначально создавала SVG на клиенте заново вместо использования уже сохранённого серверного файла.
**Решение:** Кнопка «Сохранить» теперь скачивает SVG через `api_client.get_radar_svg_bytes()`, а не регенерирует его. Серверный файл — источник истины.
**Оставшийся долг:** `web/radar_svg.py` больше не используется кнопками сохранения, но файл остался. Можно удалить при следующем рефакторинге.
---
### 2. Несовместимость Python 3.14 и psycopg
**Проблема:** `pyproject.toml` указывает `requires-python = ">=3.14"` и зависимость `psycopg>=3.3.3`, однако `Dockerfile.api` использует `python:3.14-slim`. `psycopg` (асинхронный адаптер) на 3.14 может не иметь собранных wheels. В `requirements.txt` вместо psycopg используется `pg8000==1.29.0` — синхронный pure-Python драйвер.
**Решение:** Все запросы к БД синхронные (`make_engine()` возвращает синхронный `create_engine` с `postgresql+pg8000://...`). Фактически psycopg не используется. `pyproject.toml` содержит ошибочную зависимость `psycopg`, которую следует убрать.
---
### 3. Нет транзакции при сохранении SVG
**Проблема:** После основного `session.commit()` (сохранение `RadarResult` и `RadarResultItem`) идёт отдельное обновление `result.image_path`. Если процесс упадёт между commit и вторым commit — в БД окажется запись без `image_path`, но файл будет создан на диске.
**Последствие:** При повторном прохождении теста пересчёт (`compute_radar`) удаляет старый `RadarResult` и создаёт новый, что восстанавливает корректность. Но исторический файл `.svg` осиротеет на диске.
**Рекомендация:** Объединить записи + генерацию SVG в одну транзакцию с rollback-обработчиком, либо хранить SVG в БД как blob/base64.
---
### 4. Перенос подписей в диаграмме по буквам (устранено)
**Проблема:** В виджете `RadarChart` (`web/radar_chart.py`) подписи осей переносились по буквам из-за слишком малой ширины контейнера (`_W_LBL=90`).
**Решение:** Увеличено до `_W_LBL=130`, добавлен `max_lines=2, no_wrap=False` — перенос только по словам.
---
### 5. Первоначально неверные пары профессий
**Проблема:** В первой версии `holland_crud.py` использовались произвольные пары профессий, не соответствующие оригинальной методике. Ключ расшифровки (`CHOICE_KEY`) не был реализован — загрузка в БД не работала.
**Решение:** Заменены на 42 официальные пары из модификации Резапкиной с корректным `CHOICE_KEY` — каждый из 6 типов (Р/И/С/К/П/А) получает ровно 14 баллов-вопросов.
---
### 6. Браузер не может напрямую обратиться к `http://api:8000`
**Проблема:** Внутреннее Docker-имя `api` недоступно из браузера пользователя. Если бы кнопка «Сохранить» формировала URL вида `http://api:8000/responses/.../radar/image` и передавала его в `page.launch_url()` — браузер получал бы ошибку DNS.
**Решение:** Фронтенд скачивает SVG-байты с API через httpx (внутренняя сеть), конвертирует в base64 data URI и передаёт его в `page.launch_url()`. Браузер открывает data URI напрямую, без обращения к серверу.
---
### 7. `image_path` добавлен после создания таблиц (требует миграции)
**Проблема:** Колонка `image_path` была добавлена в `RadarResult` после того, как таблицы уже создавались через `POST /db/create-tables`. `create_all` не изменяет существующие таблицы.
**Решение:** Добавлена явная миграция в `route/init_data_base.py`:
```sql
ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512);
```
Применяется вызовом `POST /db/migrate`.
---
### 8. Документация API доступна без авторизации по умолчанию у FastAPI (устранено)
**Проблема:** FastAPI по умолчанию открывает `/docs` и `/openapi.json` без каких-либо ограничений.
**Решение:** `docs_url=None, openapi_url=None, redoc_url=None` — стандартные маршруты отключены. Вместо них зарегистрированы собственные `/docs`, `/redoc`, `/openapi.json` с HTTP Basic Auth через `secrets.compare_digest` (защита от timing-атак).