Files
api-copp/README.md
2026-04-06 15:40:06 +05:00

1411 lines
71 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)
- [Тест Голланда](#тест-голланда)
- [Паутинная диаграмма](#паутинная-диаграмма)
- [Фронтенд](#фронтенд)
- [Дизайн-система](#дизайн-система)
- [Деплой с внешним прокси](#деплой-с-внешним-прокси)
- [Известные ошибки и просчёты](#известные-ошибки-и-просчёты)
---
## Архитектура
```
Пользователь (браузер)
│ :80
[ Внешний прокси / nginx ] ← необязателен для локальной сети
│ :80
[ web — Flet ASGI ] ← единственный публичный сервис
│ http://api:8000 (только внутри Docker-сети)
[ api — FastAPI ]
│ │
[ PostgreSQL :5432 ] [ Redis :6379 ]
```
**Важно:** сервис `api` не публикует порты наружу — он доступен только контейнеру `web` внутри Docker-сети. Пользователь никогда не обращается к API напрямую.
**Сетевая топология Docker:** PostgreSQL и Redis запускаются отдельным compose-файлом `docker-compose_db.yml` и подключаются к той же Docker network.
---
## Быстрый старт
### 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://localhost:8000/redoc` | ReDoc |
> В продакшне API недоступен снаружи. Для работы с `/docs` используйте SSH-туннель:
> ```bash
> ssh -L 8000:localhost:8000 user@your-server
> ```
Доступ к документации — логин/пароль из переменных `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` | Абсолютный путь к директории приложения внутри контейнера | `/app` |
| `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 для фронтенда (только Docker-сеть) | `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), порт 80
├── docker-compose.yml # API (без портов) + Web (порт 80)
├── docker-compose_db.yml # PostgreSQL + Redis
├── nginx.conf # пример конфига nginx (для внешнего прокси)
├── 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
│ ├── glomshtok_crud.py # генерация теста Гломштока
│ ├── klimov_crud.py # генерация теста Климова
│ ├── 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 (API_URL из env)
│ ├── designer.py # дизайн-система: colors, компоненты
│ ├── theme_manager.py # менеджер тем, сохранение в client_storage
│ ├── radar_chart.py # виджет RadarChart (flet.canvas)
│ ├── radar_svg.py # генератор SVG (устаревший, не используется)
│ └── views/
│ ├── login_view.py
│ ├── reg_view.py
│ ├── user_view.py
│ ├── profile_view.py
│ ├── poll_view.py
│ ├── response_view.py
│ ├── my_tests_view.py
│ ├── take_test_view.py
│ └── content_users/
│ ├── main_page.py # главная: «Все тесты» / «Мои тесты»
│ ├── poll_page.py # прохождение теста
│ ├── response_page.py # результат с паутинной диаграммой
│ ├── my_tests_page.py # список пройденных тестов
│ ├── take_test_page.py # список доступных тестов
│ └── profile_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
# Готовые тесты (admin)
POST /polls/holland — создать тест Голланда (42 пары)
POST /polls/holland/{poll_id}/seed-scores — загрузить ключ расшифровки Голланда
POST /polls/glomshtok — создать тест Гломштока
POST /polls/klimov — создать тест Климова
# База данных (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`
---
## Паутинная диаграмма
### Как работает
При сдаче теста (`POST /polls/{id}/responses`) сервер автоматически:
1. Суммирует баллы по каждой оси (`ChoiceScore.score`) для выбранных вариантов
2. Сохраняет результат в `radar_results` + `radar_result_items`
3. Генерирует SVG-файл и сохраняет в `{APP_PATH}/data/radar/{result_id}.svg`
4. Записывает относительный путь в `image_path` — всё в одном `commit()`
### Скачивание SVG
Фронтенд получает SVG-байты с API через внутреннюю Docker-сеть (`api_client.get_radar_svg_bytes()`), кодирует в `data:application/octet-stream;base64,...` и передаёт в `page.launch_url()`. Браузер скачивает файл **напрямую из data URI** — никаких запросов к API из браузера.
### Генерация SVG
SVG генерируется чистым Python без зависимостей (`route/radar_svg_gen.py`):
- 5 концентрических сеток
- Подписи с именем типа и баллом
- Размер холста: 520×520 пикселей
---
## Фронтенд
Написан на **Flet** — Python-фреймворке поверх Flutter. Работает как ASGI-приложение через uvicorn на порту 80.
### Маршруты
| Путь | Страница |
|---|---|
| `/login` | Вход |
| `/reg` | Регистрация |
| `/user/{url_key}` | Главная страница пользователя |
| `/take_test/{url_key}` | Список тестов для прохождения |
| `/poll/{url_key}/{poll_id}` | Прохождение теста |
| `/response/{url_key}/{response_id}` | Результат с диаграммой |
| `/my_tests/{url_key}` | Мои результаты |
| `/profile/{url_key}` | Профиль пользователя + выбор темы |
`{url_key}` — одноразовый 32-символьный hex-ключ, генерируется при успешном входе (`secrets.token_hex(16)`). Обращение к защищённому маршруту с чужим ключом перенаправляет на `/login`.
### Варианты ответов в тесте
Вместо стандартного `ft.RadioGroup` / `ft.Radio` (лейбл которого не переносится) используются кастомные кликабельные контейнеры (`poll_page.py`). При выборе варианта:
- фон меняется с `surface_alt``primary_light`
- иконка меняется с `RADIO_BUTTON_UNCHECKED``CHECK_CIRCLE`
---
## Дизайн-система
Все UI-компоненты сосредоточены в `web/designer.py`. Управление темой — в `web/theme_manager.py`.
### Глобальный масштаб
```python
SCALE: float = 1.7 # 1.0 = 100%, меняйте только эту константу
s(14) # → round(14 * 1.7) = 24px
```
### Цветовая палитра (стиль copp74.ru)
| Имя | HEX | Применение |
|---|---|---|
| `colors.primary` | `#F85A40` | Кнопки, акценты, логотип |
| `colors.primary_dark` | `#D94530` | Hover-состояние кнопок |
| `colors.primary_light` | `#FFEAB7` | Выбранный вариант ответа |
| `colors.background` | `#F4F6F8` | Фон страниц |
| `colors.surface` | `#FFFFFF` | Карточки, формы |
| `colors.surface_alt` | `#F0F2F5` | Невыбранные варианты, значки |
| `colors.accent` | `#FFC845` | Жёлтый акцент |
| `colors.info` | `#007FBD` | Синие кнопки скачивания |
| `colors.success` | `#85C446` | Зелёный (успех) |
| `colors.text_primary` | `#1E2A3A` | Основной текст |
| `colors.text_secondary` | `#5A6779` | Вторичный текст, плейсхолдеры |
| `colors.text_on_primary` | `#FFFFFF` | Текст на оранжевых кнопках |
| `colors.border` | `#DDE3EA` | Границы полей |
### Компоненты
| Класс | Наследует | Описание |
|---|---|---|
| `CastomText` | `ft.Text` | Текст с масштабированием |
| `CastomTextField_input` | `ft.TextField` | Поле ввода, белый фон, оранжевый фокус |
| `CastomButton` | `ft.Button` | Оранжевая кнопка с белым текстом |
| `CastomIconButton` | `ft.Button` | Синяя кнопка с иконкой |
| `CastomDropdown` | `ft.Dropdown` | Выпадающий список |
| `CastomSwitch` | `ft.Switch` | Переключатель |
| `AlterDiaalogApproval` | `ft.AlertDialog` | Диалог пользовательского соглашения |
### Темы интерфейса
Пользователь может выбрать тему в профиле. Тема сохраняется в `client_storage` браузера и восстанавливается при следующем входе.
| ID | Название | Фон |
|---|---|---|
| `default` | ЦОПП (orange) | `#F4F6F8` |
| `light` | Светлая | `#FFFFFF` |
| `dark_blue` | Синяя | `#E8F4FD` |
| `green` | Зелёная | `#EDF7E7` |
---
## Деплой с внешним прокси
В репозитории есть готовый файл `nginx.conf` для случая, когда nginx развёрнут на отдельном сервере или уже управляет несколькими сайтами.
Минимальная конфигурация upstream в вашем 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.
---
## Известные ошибки и просчёты
### 1. Лишнее дублирование генерации SVG
**Проблема:** SVG-генератор написан дважды — `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент).
**Решение:** Кнопка «Сохранить» скачивает SVG через `api_client.get_radar_svg_bytes()`. `web/radar_svg.py` больше не используется, но файл остался — можно удалить при следующем рефакторинге.
---
### 2. Несовместимость Python 3.14 и psycopg ✅ исправлено
**Проблема:** `pyproject.toml` указывал `psycopg>=3.3.3`, но реально используется синхронный `pg8000`.
**Решение:** Зависимость заменена на `pg8000>=1.29.0`.
---
### 3. Нет транзакции при сохранении SVG ✅ исправлено
**Проблема:** `image_path` обновлялся отдельным `commit()` после основного — при падении между ними запись оставалась без пути к файлу.
**Решение:** SVG генерируется до коммита, `image_path` устанавливается до коммита. Один атомарный `session.commit()`.
---
### 4. Перенос подписей в диаграмме по буквам ✅ исправлено
**Решение:** `_W_LBL` увеличен до 130, добавлен `max_lines=2, no_wrap=False`.
---
### 5. Текст вариантов ответов обрезался ✅ исправлено
**Проблема:** `ft.Radio(label=...)` в Flet не переносит текст метки — длинные варианты ответов обрезались.
**Решение:** Реализованы кастомные кликабельные контейнеры с `ft.Text(expand=True, no_wrap=False)`.
---
### 6. Ошибка `module 'designer' has no attribute 'clolors'` ✅ исправлено
**Проблема:** Класс цветов был назван `clolors` с опечаткой. После переименования в `colors` все ссылки в 10+ файлах указывали на старое имя.
**Решение:** Класс переименован в `colors`, все ссылки во всех view-файлах обновлены.
---
---
## Содержание
- [Архитектура](#архитектура)
- [Быстрый старт](#быстрый-старт)
- [Переменные окружения](#переменные-окружения)
- [Структура проекта](#структура-проекта)
- [База данных](#база-данных)
- [API](#api)
- [Тест Голланда](#тест-голланда)
- [Паутинная диаграмма](#паутинная-диаграмма)
- [Фронтенд](#фронтенд)
- [Деплой с внешним прокси](#деплой-с-внешним-прокси)
- [Известные ошибки и просчёты](#известные-ошибки-и-просчёты)
---
## Архитектура
```
Пользователь (браузер)
│ :80
[ Внешний прокси / nginx ] ← необязателен для локальной сети
│ :80
[ web — Flet ASGI ] ← единственный публичный сервис
│ http://api:8000 (только внутри Docker-сети)
[ api — FastAPI ]
│ │
[ PostgreSQL :5432 ] [ Redis :6379 ]
```
**Важно:** сервис `api` не публикует порты наружу — он доступен только контейнеру `web` внутри Docker-сети. Пользователь никогда не обращается к API напрямую.
**Сетевая топология Docker:** PostgreSQL и Redis запускаются отдельным compose-файлом `docker-compose_db.yml` и подключаются к той же Docker network.
---
## Быстрый старт
### 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://localhost:8000/redoc` | ReDoc |
> В продакшне API недоступен снаружи. Для работы с `/docs` используйте SSH-туннель:
> ```bash
> ssh -L 8000:localhost:8000 user@your-server
> ```
Доступ к документации — логин/пароль из переменных `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` | Абсолютный путь к директории приложения внутри контейнера | `/app` |
| `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 для фронтенда (только Docker-сеть) | `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), порт 80
├── docker-compose.yml # API (без портов) + Web (порт 80)
├── docker-compose_db.yml # PostgreSQL + Redis
├── nginx.conf # пример конфига nginx (для внешнего прокси)
├── 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 (API_URL из env)
│ ├── designer.py # компоненты дизайн-системы
│ ├── radar_chart.py # виджет RadarChart (flet.canvas)
│ ├── radar_svg.py # генератор SVG-data-URI (на клиенте, не используется)
│ └── views/
│ ├── login_view.py
│ ├── reg_view.py
│ ├── user_view.py
│ ├── profile_view.py
│ ├── poll_view.py
│ ├── response_view.py
│ ├── my_tests_view.py
│ ├── take_test_view.py
│ └── content_users/
│ ├── main_page.py # главная: «Все тесты» / «Мои тесты»
│ ├── poll_page.py # прохождение теста (кастомные кнопки выбора)
│ ├── response_page.py # результат с паутинной диаграммой
│ ├── my_tests_page.py # список пройденных тестов
│ ├── take_test_page.py # список доступных тестов
│ └── profile_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`
---
## Паутинная диаграмма
### Как работает
При сдаче теста (`POST /polls/{id}/responses`) сервер автоматически:
1. Суммирует баллы по каждой оси (`ChoiceScore.score`) для выбранных вариантов
2. Сохраняет результат в `radar_results` + `radar_result_items`
3. Генерирует SVG-файл и сохраняет в `{APP_PATH}/data/radar/{result_id}.svg`
4. Записывает относительный путь в `image_path` — всё в одном `commit()`
### Скачивание SVG
Фронтенд получает SVG-байты с API через внутреннюю Docker-сеть (`api_client.get_radar_svg_bytes()`), кодирует в `data:application/octet-stream;base64,...` и передаёт в `page.launch_url()`. Браузер скачивает файл **напрямую из data URI** — никаких запросов к API из браузера, никакой зависимости от публичности API.
### Генерация SVG
SVG генерируется чистым Python без зависимостей (`route/radar_svg_gen.py`):
- Фон `#2F184B`, полигон `rgba(155,114,207,0.55)`
- 5 концентрических сеток
- Подписи с именем типа и баллом
- Размер холста: 520×520 пикселей
---
## Фронтенд
Написан на **Flet** — Python-фреймворке поверх Flutter. Работает как ASGI-приложение через uvicorn на порту 80.
### Адаптация под мобильные устройства
Интерфейс адаптирован для экранов с малым горизонтальным разрешением:
- Формы входа и регистрации растягиваются на всю ширину экрана (без фиксированной ширины `360px`)
- Карточки навигации («Все тесты» / «Мои тесты») переносятся на следующую строку при нехватке места (`ft.Row(wrap=True)`)
- Варианты ответов в тесте реализованы кастомными контейнерами вместо `ft.Radio` — текст длинных вариантов переносится на следующую строку
- Заголовок страницы пользователя не выталкивает кнопку профиля
### Варианты ответов в тесте
Вместо стандартного `ft.RadioGroup` / `ft.Radio` (лейбл которого не переносится) используются кастомные кликабельные контейнеры (`poll_page.py: _build_choice_group`). При выборе варианта:
- фон меняется с `laer3``laer1`
- появляется белая рамка
- иконка меняется с `RADIO_BUTTON_UNCHECKED``CHECK_CIRCLE`
### Маршруты
| Путь | Страница |
|---|---|
| `/login` | Вход |
| `/reg` | Регистрация |
| `/user/{url_key}` | Главная страница пользователя |
| `/take_test/{url_key}` | Список тестов для прохождения |
| `/poll/{url_key}/{poll_id}` | Прохождение теста |
| `/response/{url_key}/{response_id}` | Результат с диаграммой |
| `/my_tests/{url_key}` | Мои результаты |
| `/profile/{url_key}` | Профиль пользователя |
`{url_key}` — одноразовый 32-символьный hex-ключ, генерируется при успешном входе (`secrets.token_hex(16)`). Обращение к защищённому маршруту с чужим ключом перенаправляет на `/login`.
---
## Деплой с внешним прокси
В репозитории есть готовый файл `nginx.conf` для случая, когда nginx развёрнут на отдельном сервере или уже управляет несколькими сайтами.
Минимальная конфигурация upstream в вашем 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.
---
## Известные ошибки и просчёты
### 1. Лишнее дублирование генерации SVG
**Проблема:** SVG-генератор написан дважды — `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент).
**Решение:** Кнопка «Сохранить» скачивает SVG через `api_client.get_radar_svg_bytes()`. `web/radar_svg.py` больше не используется, но файл остался — можно удалить при следующем рефакторинге.
---
### 2. Несовместимость Python 3.14 и psycopg ✅ исправлено
**Проблема:** `pyproject.toml` указывал `psycopg>=3.3.3`, но реально используется синхронный `pg8000`.
**Решение:** Зависимость заменена на `pg8000>=1.29.0`.
---
### 3. Нет транзакции при сохранении SVG ✅ исправлено
**Проблема:** `image_path` обновлялся отдельным `commit()` после основного — при падении между ними запись оставалась без пути к файлу.
**Решение:** SVG генерируется до коммита, `image_path` устанавливается до коммита. Один атомарный `session.commit()`.
---
### 4. Перенос подписей в диаграмме по буквам ✅ исправлено
**Решение:** `_W_LBL` увеличен до 130, добавлен `max_lines=2, no_wrap=False`.
---
### 5. Текст вариантов ответов обрезался ✅ исправлено
**Проблема:** `ft.Radio(label=...)` в Flet не переносит текст метки — длинные варианты ответов обрезались.
**Решение:** Реализованы кастомные кликабельные контейнеры с `ft.Text(expand=True, no_wrap=False)`.
---
### 6. Браузер не может обратиться к `http://api:8000` ✅ исправлено
**Проблема:** Внутреннее Docker-имя `api` недоступно из браузера пользователя.
**Решение:** Фронтенд скачивает SVG через httpx (внутри Docker-сети) и передаёт браузеру как `data:application/octet-stream;base64,...`.
---
### 7. `image_path` добавлен после создания таблиц ✅ исправлено
**Решение:** Добавлена явная миграция:
```sql
ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512);
```
---
### 8. Документация API доступна без авторизации ✅ исправлено
**Решение:** Стандартные маршруты `/docs`, `/redoc`, `/openapi.json` отключены. Зарегистрированы собственные с HTTP Basic Auth через `secrets.compare_digest`.
---
## Содержание
- [Архитектура](#архитектура)
- [Быстрый старт](#быстрый-старт)
- [Переменные окружения](#переменные-окружения)
- [Структура проекта](#структура-проекта)
- [База данных](#база-данных)
- [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` | Регистрация |
| `/user/{url_key}` | Главная страница пользователя |
| `/take_test/{url_key}` | Список тестов для прохождения |
| `/poll/{url_key}/{poll_id}` | Прохождение теста |
| `/response/{url_key}/{response_id}` | Результат с диаграммой |
| `/my_tests/{url_key}` | Мои результаты |
| `/profile/{url_key}` | Профиль пользователя |
`{url_key}` — одноразовый 32-символьный hex-ключ, генерируется при успешном входе (`secrets.token_hex(16)`) и сохраняется в сессии. Обращение к защищённому маршруту с чужим или отсутствующим ключом автоматически сбрасывает сессию и перенаправляет на `/login`.
### Диаграмма после теста
После сдачи теста пользователь сразу видит паутинную диаграмму (виджет `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 не используется. Ошибочная зависимость `psycopg` заменена на `pg8000>=1.29.0` в `pyproject.toml`.
---
### 3. Нет транзакции при сохранении SVG ✅ исправлено
**Проблема:** После основного `session.commit()` (сохранение `RadarResult` и `RadarResultItem`) шло отдельное обновление `result.image_path`. Если процесс падал между первым и вторым commit — в БД оставалась запись без `image_path`, хотя файл уже существовал на диске.
**Последствие:** При повторном прохождении теста пересчёт (`compute_radar`) удалял старый `RadarResult` и создавал новый, что восстанавливало корректность. Но исторический файл `.svg` «осиротевал» на диске.
**Решение:** `items_data` теперь строится непосредственно из `scores_map`/`dimensions` (данные уже в памяти после `flush()`), SVG генерируется до коммита, `result.image_path` устанавливается до коммита. Итого один `session.commit()` атомарно сохраняет `RadarResult`, `RadarResultItem` и путь к SVG.
---
### 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-атак).