1411 lines
71 KiB
Markdown
1411 lines
71 KiB
Markdown
# 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-атак).
|