new shems
This commit is contained in:
435
README.md
435
README.md
@@ -0,0 +1,435 @@
|
|||||||
|
# api-copp
|
||||||
|
|
||||||
|
Веб-приложение для создания, проведения и анализа психологических тестов. Включает API-сервер на FastAPI и пользовательский интерфейс на Flet. Поставляется в Docker и поддерживает готовый тест по методике профессиональной ориентации Голланда.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
- [Архитектура](#архитектура)
|
||||||
|
- [Быстрый старт](#быстрый-старт)
|
||||||
|
- [Переменные окружения](#переменные-окружения)
|
||||||
|
- [Структура проекта](#структура-проекта)
|
||||||
|
- [База данных](#база-данных)
|
||||||
|
- [API](#api)
|
||||||
|
- [Тест Голланда](#тест-голланда)
|
||||||
|
- [Паутинная диаграмма](#паутинная-диаграмма)
|
||||||
|
- [Фронтенд](#фронтенд)
|
||||||
|
- [Известные ошибки и просчёты](#известные-ошибки-и-просчёты)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Браузер пользователя :80 │
|
||||||
|
│ (Flet Web App) │
|
||||||
|
└────────────────────┬────────────────────────┘
|
||||||
|
│ HTTP / REST
|
||||||
|
┌────────────────────▼────────────────────────┐
|
||||||
|
│ FastAPI API :8000 │
|
||||||
|
│ - аутентификация (JWT) │
|
||||||
|
│ - управление тестами │
|
||||||
|
│ - сбор ответов │
|
||||||
|
│ - расчёт радарных результатов │
|
||||||
|
│ - генерация SVG-диаграмм │
|
||||||
|
└──────┬─────────────────────┬────────────────┘
|
||||||
|
│ │
|
||||||
|
┌──────▼──────┐ ┌────────▼────────┐
|
||||||
|
│ PostgreSQL │ │ Redis │
|
||||||
|
│ :5432 │ │ :6379 │
|
||||||
|
│ (основная │ │ (сессии / │
|
||||||
|
│ БД) │ │ кэш) │
|
||||||
|
└─────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Сетевая топология Docker:** все три сервиса (api, web, postgres, redis) работают в одной Docker-сети. Фронтенд (`web`) обращается к API по внутреннему имени `http://api:8000`. PostgreSQL и Redis запускаются отдельным compose-файлом `docker-compose_db.yml`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### 1. Инфраструктура (PostgreSQL + Redis)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose_db.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Приложение (API + Web)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
После запуска:
|
||||||
|
|
||||||
|
| Адрес | Что |
|
||||||
|
|-------|---------------------------|
|
||||||
|
| `http://localhost` | Веб-интерфейс (Flet) |
|
||||||
|
| `http://localhost:8000/docs` | Swagger UI (HTTP Basic Auth) |
|
||||||
|
| `http://localhost:8000/redoc` | ReDoc |
|
||||||
|
|
||||||
|
Доступ к документации — логин/пароль из переменных `DOCS_USERNAME` / `DOCS_PASSWORD`.
|
||||||
|
|
||||||
|
### 3. Инициализация БД
|
||||||
|
|
||||||
|
После первого запуска выполните:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать таблицы
|
||||||
|
curl -X POST http://localhost:8000/db/create-tables \
|
||||||
|
-H "X-Admin-Key: <ADMIN_KEY>"
|
||||||
|
|
||||||
|
# Применить миграции (добавляет колонки, если БД уже была создана ранее)
|
||||||
|
curl -X POST http://localhost:8000/db/migrate \
|
||||||
|
-H "X-Admin-Key: <ADMIN_KEY>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Загрузка теста Голланда
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать тест (возвращает poll_id)
|
||||||
|
curl -X POST http://localhost:8000/polls/holland \
|
||||||
|
-H "X-Admin-Key: <ADMIN_KEY>"
|
||||||
|
|
||||||
|
# Загрузить ключ расшифровки (42 пары → 6 типов × 14 баллов)
|
||||||
|
curl -X POST http://localhost:8000/polls/holland/<poll_id>/seed-scores \
|
||||||
|
-H "X-Admin-Key: <ADMIN_KEY>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
> **Важно:** перед деплоем в продакшн обязательно смените `SECRET_KEY`, `ADMIN_KEY`, `DOCS_PASSWORD` и пароли БД.
|
||||||
|
|
||||||
|
| Переменная | Описание | Пример |
|
||||||
|
|---|---|---|
|
||||||
|
| `APP_PATH` | Абсолютный путь к директории приложения внутри контейнера | `/home/user/api-copp` |
|
||||||
|
| `DB_HOST` | Хост PostgreSQL | `postgres` |
|
||||||
|
| `DB_PORT` | Порт PostgreSQL | `5432` |
|
||||||
|
| `DB_USER` | Пользователь БД | `postgres` |
|
||||||
|
| `DB_PASS` | Пароль БД | `postgres` |
|
||||||
|
| `DB_NAME` | Имя базы данных | `profi` |
|
||||||
|
| `REDIS_HOST` | Хост Redis | `redis` |
|
||||||
|
| `REDIS_PORT` | Порт Redis | `6379` |
|
||||||
|
| `REDIS_DB` | Номер БД Redis | `0` |
|
||||||
|
| `REDIS_PASSWORD` | Пароль Redis | — |
|
||||||
|
| `SECRET_KEY` | Секрет для подписи JWT-токенов (min 32 символа) | `python -c "import secrets; print(secrets.token_hex(32))"` |
|
||||||
|
| `ADMIN_KEY` | Ключ для защищённых admin-эндпоинтов | — |
|
||||||
|
| `DOCS_USERNAME` | Логин для доступа к `/docs` и `/redoc` | `admin` |
|
||||||
|
| `DOCS_PASSWORD` | Пароль для доступа к `/docs` и `/redoc` | — |
|
||||||
|
| `API_URL` | Внутренний URL API для фронтенда | `http://api:8000` |
|
||||||
|
|
||||||
|
Файлы `docker-compose.yml` содержат **демо-значения** — не используйте их в продакшн.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
api-copp/
|
||||||
|
├── main.py # точка входа FastAPI, регистрация роутеров
|
||||||
|
├── requirements.txt # зависимости API
|
||||||
|
├── pyproject.toml # метаданные проекта
|
||||||
|
├── Dockerfile.api # образ API (python:3.14-slim)
|
||||||
|
├── Dockerfile.web # образ фронтенда (python:3.11-slim + flet)
|
||||||
|
├── docker-compose.yml # API + Web
|
||||||
|
├── docker-compose_db.yml # PostgreSQL + Redis
|
||||||
|
├── docker-compose_web.yml # отдельный запуск только Web
|
||||||
|
│
|
||||||
|
├── bd/ # слой доступа к данным
|
||||||
|
│ ├── __init__.py # Settings (pydantic), make_engine()
|
||||||
|
│ └── tables/
|
||||||
|
│ ├── simple_base.py # общий Base для SQLAlchemy
|
||||||
|
│ ├── users.py # User
|
||||||
|
│ ├── organization.py # Organization
|
||||||
|
│ ├── group.py # Group
|
||||||
|
│ ├── poll.py # Poll
|
||||||
|
│ ├── question.py # Question
|
||||||
|
│ ├── choice.py # Choice
|
||||||
|
│ ├── response.py # Response, Answer
|
||||||
|
│ ├── scale.py # ScaleDimension, ChoiceScore
|
||||||
|
│ └── radar_result.py # RadarResult, RadarResultItem
|
||||||
|
│
|
||||||
|
├── route/ # FastAPI-роутеры
|
||||||
|
│ ├── auth_utils.py # JWT, bcrypt, require_admin_key
|
||||||
|
│ ├── auth.py # /auth/register, /auth/login
|
||||||
|
│ ├── base.py # /health, /version
|
||||||
|
│ ├── init_data_base.py # /db/create-tables, /db/migrate
|
||||||
|
│ ├── users_crud.py # CRUD пользователей
|
||||||
|
│ ├── groups_crud.py # CRUD групп
|
||||||
|
│ ├── organizations_crud.py# CRUD организаций
|
||||||
|
│ ├── poll_crud.py # CRUD опросов
|
||||||
|
│ ├── question_crud.py # CRUD вопросов
|
||||||
|
│ ├── choice_crud.py # CRUD вариантов ответов
|
||||||
|
│ ├── response_crud.py # приём ответов + авто-расчёт радара
|
||||||
|
│ ├── holland_crud.py # генерация теста Голланда + seed-scores
|
||||||
|
│ ├── scale_crud.py # CRUD осей и расшифровок
|
||||||
|
│ ├── radar_crud.py # расчёт и чтение радарных результатов
|
||||||
|
│ └── radar_svg_gen.py # серверная генерация SVG-диаграмм
|
||||||
|
│
|
||||||
|
├── web/ # Flet-фронтенд
|
||||||
|
│ ├── main.py # точка входа Flet ASGI
|
||||||
|
│ ├── router.py # маршрутизация страниц
|
||||||
|
│ ├── api_client.py # httpx-обёртки над API
|
||||||
|
│ ├── designer.py # компоненты дизайн-системы
|
||||||
|
│ ├── radar_chart.py # виджет RadarChart (flet.canvas)
|
||||||
|
│ ├── radar_svg.py # генератор SVG-data-URI (на клиенте)
|
||||||
|
│ └── views/
|
||||||
|
│ └── content_users/
|
||||||
|
│ ├── poll_page.py # прохождение теста + диаграмма сразу после
|
||||||
|
│ ├── response_page.py # страница результата с диаграммой
|
||||||
|
│ ├── my_tests_page.py # список пройденных тестов
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
└── data/ # смонтированный том Docker (./data:/app/data)
|
||||||
|
├── postgres/ # данные PostgreSQL
|
||||||
|
└── radar/ # сохранённые SVG-файлы диаграмм (создаётся автоматически)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## База данных
|
||||||
|
|
||||||
|
### Схема таблиц
|
||||||
|
|
||||||
|
```
|
||||||
|
organizations ──< groups ──< users
|
||||||
|
│
|
||||||
|
polls ──< questions ──< choices ──< choice_scores
|
||||||
|
│ │
|
||||||
|
└──< responses ──< answers │
|
||||||
|
│ │
|
||||||
|
└──< radar_results ──< radar_result_items
|
||||||
|
(image_path) └── (dimension_id FK → scale_dimensions)
|
||||||
|
↑
|
||||||
|
polls ──< scale_dimensions ──────────────────-─┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые таблицы
|
||||||
|
|
||||||
|
| Таблица | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `users` | Испытуемые: `username`, `hashed_password`, `first_name`, `last_name`, FK на `groups` и `organizations` |
|
||||||
|
| `polls` | Тест/опрос: `title`, `description` |
|
||||||
|
| `questions` | Вопросы теста с `type` (`single` / `multiple`) |
|
||||||
|
| `choices` | Варианты ответов к вопросам |
|
||||||
|
| `responses` | Один факт прохождения теста (user + poll + timestamp) |
|
||||||
|
| `answers` | Конкретные ответы внутри прохождения |
|
||||||
|
| `scale_dimensions` | Оси радарной диаграммы: `name`, `color`, `position`, FK на `polls` |
|
||||||
|
| `choice_scores` | Расшифровка: какой вариант ответа сколько баллов вносит в какую ось |
|
||||||
|
| `radar_results` | Итоговый результат по осям + `image_path` (путь к SVG-файлу) |
|
||||||
|
| `radar_result_items` | Снимок значений по каждой оси на момент расчёта |
|
||||||
|
|
||||||
|
### Миграции
|
||||||
|
|
||||||
|
Таблицы создаются через `POST /db/create-tables`. Для уже существующих БД применяются дополнительные ALTER-миграции через `POST /db/migrate`. Текущие миграции:
|
||||||
|
|
||||||
|
- Добавление колонок `username` / `hashed_password` в `users`
|
||||||
|
- Добавление колонки `image_path` в `radar_results`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Аутентификация
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /auth/register — регистрация (username ≥ 3 симв., password ≥ 8 симв.)
|
||||||
|
POST /auth/login — получить JWT-токен (OAuth2 password flow)
|
||||||
|
```
|
||||||
|
|
||||||
|
JWT-токен передаётся заголовком `Authorization: Bearer <token>`. Admin-операции требуют заголовок `X-Admin-Key`.
|
||||||
|
|
||||||
|
### Основные эндпоинты
|
||||||
|
|
||||||
|
```
|
||||||
|
# Тесты
|
||||||
|
GET /polls — список тестов
|
||||||
|
POST /polls — создать тест
|
||||||
|
GET /polls/{id} — тест с вопросами и вариантами
|
||||||
|
DELETE /polls/{id} — удалить тест
|
||||||
|
|
||||||
|
# Вопросы / Варианты ответов
|
||||||
|
POST /polls/{id}/questions
|
||||||
|
POST /questions/{id}/choices
|
||||||
|
|
||||||
|
# Прохождение
|
||||||
|
POST /polls/{poll_id}/responses — сдать тест (автоматически считает радар)
|
||||||
|
GET /polls/users/{user_id}/responses — история пользователя
|
||||||
|
GET /responses/{id} — конкретный результат
|
||||||
|
|
||||||
|
# Результаты (радар)
|
||||||
|
POST /responses/{id}/radar/compute — (пере)считать радарный результат
|
||||||
|
GET /responses/{id}/radar — данные для диаграммы
|
||||||
|
GET /responses/{id}/radar/image — SVG-файл диаграммы
|
||||||
|
GET /users/{user_id}/radar-results — история радарных результатов
|
||||||
|
|
||||||
|
# Оси и расшифровки
|
||||||
|
POST /polls/{poll_id}/dimensions — создать ось
|
||||||
|
GET /polls/{poll_id}/dimensions — список осей
|
||||||
|
PUT /dimensions/{id}
|
||||||
|
DELETE /dimensions/{id}
|
||||||
|
POST /dimensions/{id}/scores — задать расшифровку варианта ответа
|
||||||
|
GET /choices/{choice_id}/scores
|
||||||
|
|
||||||
|
# Тест Голланда
|
||||||
|
POST /polls/holland — создать тест с 42 парами
|
||||||
|
POST /polls/holland/{poll_id}/seed-scores — загрузить ключ расшифровки
|
||||||
|
|
||||||
|
# База данных (admin)
|
||||||
|
POST /db/create-tables
|
||||||
|
POST /db/migrate
|
||||||
|
|
||||||
|
# Служебные
|
||||||
|
GET /health
|
||||||
|
GET /version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Тест Голланда
|
||||||
|
|
||||||
|
Реализована **модификация Г.В. Резапкиной** теста профессиональной ориентации Дж. Голланда. 42 пары профессий, испытуемый выбирает предпочтительную в каждой паре.
|
||||||
|
|
||||||
|
### Типы личности
|
||||||
|
|
||||||
|
| Код | Название | Цвет |
|
||||||
|
|---|---|---|
|
||||||
|
| Р | Реалистичный | `#FF8C00` |
|
||||||
|
| И | Интеллектуальный | `#4169E1` |
|
||||||
|
| С | Социальный | `#32CD32` |
|
||||||
|
| К | Конвенциональный | `#9370DB` |
|
||||||
|
| П | Предприимчивый | `#DC143C` |
|
||||||
|
| А | Артистический | `#FF69B4` |
|
||||||
|
|
||||||
|
Каждый тип получает ровно 14 вопросов (= 14 максимально возможных баллов). Результат — паутинная диаграмма с 6 осями.
|
||||||
|
|
||||||
|
### Процедура загрузки
|
||||||
|
|
||||||
|
1. `POST /polls/holland` — создаёт тест с 42 вопросами-парами
|
||||||
|
2. `POST /polls/holland/{poll_id}/seed-scores` — создаёт 6 `ScaleDimension` и 84 `ChoiceScore` (по 2 на каждую пару: вариант A → тип X, вариант B → тип Y)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Паутинная диаграмма
|
||||||
|
|
||||||
|
### Как работает
|
||||||
|
|
||||||
|
При сдаче теста (`POST /polls/{id}/responses`) сервер автоматически:
|
||||||
|
|
||||||
|
1. Суммирует баллы по каждой оси (`ChoiceScore.score`) для выбранных вариантов
|
||||||
|
2. Сохраняет результат в `radar_results` + `radar_result_items`
|
||||||
|
3. Генерирует SVG-файл и сохраняет в `{APP_PATH}/data/radar/{result_id}.svg`
|
||||||
|
4. Записывает относительный путь `data/radar/{result_id}.svg` в колонку `image_path`
|
||||||
|
|
||||||
|
### Скачивание SVG
|
||||||
|
|
||||||
|
Фронтенд скачивает SVG с API через внутреннюю сеть (`GET /responses/{id}/radar/image`), кодирует в base64 data URI и открывает в браузере как `data:image/svg+xml;base64,...` — файл сохраняется на устройство пользователя без необходимости публичного URL к серверу.
|
||||||
|
|
||||||
|
### Генерация SVG
|
||||||
|
|
||||||
|
SVG генерируется чистым Python без зависимостей (`route/radar_svg_gen.py`). Серверный SVG идентичен клиентскому (`web/radar_svg.py`) по разметке, но предназначен для долгосрочного хранения. Параметры:
|
||||||
|
|
||||||
|
- Фон `#2F184B`, полигон `rgba(155,114,207,0.55)`
|
||||||
|
- 5 концентрических сеток
|
||||||
|
- Подписи с именем типа и баллом
|
||||||
|
- Размер холста: 520×520 пикселей
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фронтенд
|
||||||
|
|
||||||
|
Написан на **Flet** — Python-фреймворке поверх Flutter. Работает как ASGI-приложение через uvicorn, отдаётся браузеру как веб-приложение.
|
||||||
|
|
||||||
|
### Маршруты
|
||||||
|
|
||||||
|
| Путь | Страница |
|
||||||
|
|---|---|
|
||||||
|
| `/login` | Вход |
|
||||||
|
| `/reg` | Регистрация |
|
||||||
|
| `/take_test/{url_key}` | Список тестов для прохождения |
|
||||||
|
| `/poll/{poll_id}` | Прохождение теста |
|
||||||
|
| `/response/{response_id}` | Результат с диаграммой |
|
||||||
|
| `/my_tests` | Мои результаты |
|
||||||
|
| `/profile` | Профиль пользователя |
|
||||||
|
|
||||||
|
### Диаграмма после теста
|
||||||
|
|
||||||
|
После сдачи теста пользователь сразу видит паутинную диаграмму (виджет `RadarChart`) и кнопку «Сохранить диаграмму», которая загружает SVG с сервера и открывает его в новой вкладке для сохранения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Известные ошибки и просчёты
|
||||||
|
|
||||||
|
### 1. Лишнее дублирование генерации SVG
|
||||||
|
|
||||||
|
**Проблема:** SVG-генератор написан дважды — `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент). Кнопка «Сохранить» изначально создавала SVG на клиенте заново вместо использования уже сохранённого серверного файла.
|
||||||
|
|
||||||
|
**Решение:** Кнопка «Сохранить» теперь скачивает SVG через `api_client.get_radar_svg_bytes()`, а не регенерирует его. Серверный файл — источник истины.
|
||||||
|
|
||||||
|
**Оставшийся долг:** `web/radar_svg.py` больше не используется кнопками сохранения, но файл остался. Можно удалить при следующем рефакторинге.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Несовместимость Python 3.14 и psycopg
|
||||||
|
|
||||||
|
**Проблема:** `pyproject.toml` указывает `requires-python = ">=3.14"` и зависимость `psycopg>=3.3.3`, однако `Dockerfile.api` использует `python:3.14-slim`. `psycopg` (асинхронный адаптер) на 3.14 может не иметь собранных wheels. В `requirements.txt` вместо psycopg используется `pg8000==1.29.0` — синхронный pure-Python драйвер.
|
||||||
|
|
||||||
|
**Решение:** Все запросы к БД синхронные (`make_engine()` возвращает синхронный `create_engine` с `postgresql+pg8000://...`). Фактически psycopg не используется. `pyproject.toml` содержит ошибочную зависимость `psycopg`, которую следует убрать.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Нет транзакции при сохранении SVG
|
||||||
|
|
||||||
|
**Проблема:** После основного `session.commit()` (сохранение `RadarResult` и `RadarResultItem`) идёт отдельное обновление `result.image_path`. Если процесс упадёт между commit и вторым commit — в БД окажется запись без `image_path`, но файл будет создан на диске.
|
||||||
|
|
||||||
|
**Последствие:** При повторном прохождении теста пересчёт (`compute_radar`) удаляет старый `RadarResult` и создаёт новый, что восстанавливает корректность. Но исторический файл `.svg` осиротеет на диске.
|
||||||
|
|
||||||
|
**Рекомендация:** Объединить записи + генерацию SVG в одну транзакцию с rollback-обработчиком, либо хранить SVG в БД как blob/base64.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Перенос подписей в диаграмме по буквам (устранено)
|
||||||
|
|
||||||
|
**Проблема:** В виджете `RadarChart` (`web/radar_chart.py`) подписи осей переносились по буквам из-за слишком малой ширины контейнера (`_W_LBL=90`).
|
||||||
|
|
||||||
|
**Решение:** Увеличено до `_W_LBL=130`, добавлен `max_lines=2, no_wrap=False` — перенос только по словам.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Первоначально неверные пары профессий
|
||||||
|
|
||||||
|
**Проблема:** В первой версии `holland_crud.py` использовались произвольные пары профессий, не соответствующие оригинальной методике. Ключ расшифровки (`CHOICE_KEY`) не был реализован — загрузка в БД не работала.
|
||||||
|
|
||||||
|
**Решение:** Заменены на 42 официальные пары из модификации Резапкиной с корректным `CHOICE_KEY` — каждый из 6 типов (Р/И/С/К/П/А) получает ровно 14 баллов-вопросов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Браузер не может напрямую обратиться к `http://api:8000`
|
||||||
|
|
||||||
|
**Проблема:** Внутреннее Docker-имя `api` недоступно из браузера пользователя. Если бы кнопка «Сохранить» формировала URL вида `http://api:8000/responses/.../radar/image` и передавала его в `page.launch_url()` — браузер получал бы ошибку DNS.
|
||||||
|
|
||||||
|
**Решение:** Фронтенд скачивает SVG-байты с API через httpx (внутренняя сеть), конвертирует в base64 data URI и передаёт его в `page.launch_url()`. Браузер открывает data URI напрямую, без обращения к серверу.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. `image_path` добавлен после создания таблиц (требует миграции)
|
||||||
|
|
||||||
|
**Проблема:** Колонка `image_path` была добавлена в `RadarResult` после того, как таблицы уже создавались через `POST /db/create-tables`. `create_all` не изменяет существующие таблицы.
|
||||||
|
|
||||||
|
**Решение:** Добавлена явная миграция в `route/init_data_base.py`:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512);
|
||||||
|
```
|
||||||
|
Применяется вызовом `POST /db/migrate`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Документация API доступна без авторизации по умолчанию у FastAPI (устранено)
|
||||||
|
|
||||||
|
**Проблема:** FastAPI по умолчанию открывает `/docs` и `/openapi.json` без каких-либо ограничений.
|
||||||
|
|
||||||
|
**Решение:** `docs_url=None, openapi_url=None, redoc_url=None` — стандартные маршруты отключены. Вместо них зарегистрированы собственные `/docs`, `/redoc`, `/openapi.json` с HTTP Basic Auth через `secrets.compare_digest` (защита от timing-атак).
|
||||||
|
|||||||
41
bd/tables/radar_result.py
Normal file
41
bd/tables/radar_result.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
RadarResult — сохранённый результат прохождения теста в виде значений по осям.
|
||||||
|
RadarResultItem — отдельная ось с итоговым значением внутри результата.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Float, String, DateTime, ForeignKey, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from .simple_base import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class RadarResult(Base):
|
||||||
|
"""Итоговый результат (набор баллов по осям) за одно прохождение теста."""
|
||||||
|
__tablename__ = "radar_results"
|
||||||
|
__table_args__ = (UniqueConstraint("response_id", name="uq_radar_response"),)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
response_id = Column(UUID(as_uuid=True), ForeignKey("responses.id", ondelete="CASCADE"), nullable=False, unique=True)
|
||||||
|
computed_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
# Путь к сохранённому SVG-файлу (относительно APP_PATH/data/radar/)
|
||||||
|
image_path = Column(String(512), nullable=True)
|
||||||
|
|
||||||
|
items = relationship("RadarResultItem", backref="result", cascade="all, delete-orphan", passive_deletes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RadarResultItem(Base):
|
||||||
|
"""Значение по одной оси в рамках результата."""
|
||||||
|
__tablename__ = "radar_result_items"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
result_id = Column(UUID(as_uuid=True), ForeignKey("radar_results.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
dimension_id = Column(UUID(as_uuid=True), ForeignKey("scale_dimensions.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
dimension_name = Column(String(255), nullable=False) # снимок имени на момент расчёта
|
||||||
|
dimension_color = Column(String(32), nullable=True) # снимок цвета
|
||||||
|
dimension_position = Column(Float, nullable=True) # порядок на диаграмме
|
||||||
|
value = Column(Float, nullable=False, default=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Base", "RadarResult", "RadarResultItem"]
|
||||||
37
bd/tables/scale.py
Normal file
37
bd/tables/scale.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
ScaleDimension — ось/сфера радарной диаграммы, привязанная к тесту.
|
||||||
|
ChoiceScore — расшифровка варианта ответа: сколько баллов в какую ось он вносит.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Float, Integer, ForeignKey, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from .simple_base import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class ScaleDimension(Base):
|
||||||
|
"""Ось/категория радарной диаграммы для конкретного теста."""
|
||||||
|
__tablename__ = "scale_dimensions"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
poll_id = Column(UUID(as_uuid=True), ForeignKey("polls.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
name = Column(String(255), nullable=False) # напр. "ЗДОРОВЬЕ"
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
color = Column(String(32), nullable=True) # напр. "#FF8C00"
|
||||||
|
position = Column(Integer, nullable=True) # порядок на диаграмме
|
||||||
|
|
||||||
|
scores = relationship("ChoiceScore", backref="dimension", cascade="all, delete-orphan", passive_deletes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ChoiceScore(Base):
|
||||||
|
"""Расшифровка: вклад конкретного варианта ответа в конкретную ось."""
|
||||||
|
__tablename__ = "choice_scores"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
choice_id = Column(UUID(as_uuid=True), ForeignKey("choices.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
dimension_id = Column(UUID(as_uuid=True), ForeignKey("scale_dimensions.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
score = Column(Float, nullable=False, default=1.0) # значение, которое добавляется при выборе
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Base", "ScaleDimension", "ChoiceScore"]
|
||||||
4
main.py
4
main.py
@@ -6,7 +6,7 @@ from fastapi import Depends, FastAPI, HTTPException, status
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
from route import base, holland_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud
|
from route import base, holland_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud, scale_crud, radar_crud
|
||||||
from route import auth
|
from route import auth
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
@@ -69,6 +69,8 @@ app.include_router(choice_crud.router)
|
|||||||
app.include_router(response_crud.router)
|
app.include_router(response_crud.router)
|
||||||
app.include_router(poll_crud.router)
|
app.include_router(poll_crud.router)
|
||||||
app.include_router(holland_crud.router)
|
app.include_router(holland_crud.router)
|
||||||
|
app.include_router(scale_crud.router)
|
||||||
|
app.include_router(radar_crud.router)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -7,53 +7,137 @@ from bd import make_engine
|
|||||||
from bd.tables.poll import Poll
|
from bd.tables.poll import Poll
|
||||||
from bd.tables.question import Question
|
from bd.tables.question import Question
|
||||||
from bd.tables.choice import Choice
|
from bd.tables.choice import Choice
|
||||||
|
from bd.tables.scale import ScaleDimension, ChoiceScore
|
||||||
|
|
||||||
router = APIRouter(tags=["polls"], prefix="/polls")
|
router = APIRouter(tags=["polls"], prefix="/polls")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Вопросы теста — модификация Г.В. Резапкиной
|
||||||
|
# Пары профессий: (вариант А, вариант В)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
PAIRS = [
|
PAIRS = [
|
||||||
("инженер-техник", "инженер-контролер"),
|
("инженер-технолог", "инженер-конструктор"), # 1
|
||||||
("вязальщик", "санитарный врач"),
|
("электрорадиотехник", "врач-терапевт"), # 2
|
||||||
("повар", "наборщик"),
|
("оператор станков с числовым программным управлением", "кодировщик (обработка информации)"), # 3
|
||||||
("фотограф", "зав. магазином"),
|
("фотограф", "коммерсант"), # 4
|
||||||
("чертежник", "дизайнер"),
|
("спасатель МЧС", "дизайнер"), # 5
|
||||||
("философ", "психиатр"),
|
("политолог", "психиатр"), # 6
|
||||||
("ученый-химик", "бухгалтер"),
|
("ученый химик", "бухгалтер"), # 7
|
||||||
("редактор научного журнала", "адвокат"),
|
("философ", "частный предприниматель"), # 8
|
||||||
("лингвист", "переводчик художественной литературы"),
|
("лингвист", "модельер"), # 9
|
||||||
("педиатр", "статистик"),
|
("инспектор службы занятости населения", "статист"), # 10
|
||||||
("организатор воспитательной работы", "председатель профсоюза"),
|
("социальный педагог", "биржевой маклер"), # 11
|
||||||
("спортивный врач", "фельетонист"),
|
("тренер", "искусствовед"), # 12
|
||||||
("нотариус", "снабженец"),
|
("нотариус", "менеджер"), # 13
|
||||||
("перфоратор", "карикатурист"),
|
("перфораторщик", "художник"), # 14
|
||||||
("политический деятель", "писатель"),
|
("лидер политической партии, общего движения", "писатель"), # 15
|
||||||
("садовник", "метеоролог"),
|
("закройщик", "метеоролог"), # 16
|
||||||
("водитель", "медсестра"),
|
("водитель", "работник пресс-службы"), # 17
|
||||||
("инженер-электрик", "секретарь-машинистка"),
|
("чертежник", "риэлтер"), # 18
|
||||||
("маляр", "художник по металлу"),
|
("специалист по ремонту компьютеров и оргтехники", "секретарь-референт"), # 19
|
||||||
("биолог", "главный врач"),
|
("микробиолог", "психолог"), # 20
|
||||||
("телеоператор", "режиссер"),
|
("видеооператор", "режиссер"), # 21
|
||||||
("гидролог", "ревизор"),
|
("экономист", "провизор"), # 22
|
||||||
("зоолог", "зоотехник"),
|
("зоолог", "главный инженер"), # 23
|
||||||
("математик", "архитектор"),
|
("программист", "архитектор"), # 24
|
||||||
("работник ИДН", "счетовод"),
|
("работник инспекции по делам несовершеннолетних", "коммивояжер (сетевой маркетинг)"), # 25
|
||||||
("учитель", "милиционер"),
|
("преподаватель", "биржевой маклер"), # 26
|
||||||
("воспитатель", "художник по керамике"),
|
("воспитатель", "декоратор"), # 27
|
||||||
("экономист", "заведующий отделом"),
|
("реставратор", "зав. отделом предприятия"), # 28
|
||||||
("корректор", "критик"),
|
("корректор", "литератор и кинокритик"), # 29
|
||||||
("завхоз", "директор"),
|
("фермер", "визажист"), # 30
|
||||||
("радиоинженер", "специалист по ядерной физике"),
|
("парикмахер", "социолог"), # 31
|
||||||
("водопроводчик", "наборщик"),
|
("экспедитор", "редактор"), # 32
|
||||||
("агроном", "председатель сельхозкооператива"),
|
("ветеринар", "директор (финансовый)"), # 33
|
||||||
("закройщик-модельер", "декоратор"),
|
("автомеханик", "стилист"), # 34
|
||||||
("археолог", "эксперт"),
|
("археолог", "эксперт"), # 35
|
||||||
("работник музея", "консультант"),
|
("библиограф", "корреспондент"), # 36
|
||||||
("ученый", "актер"),
|
("эколог", "актер"), # 37
|
||||||
("логопед", "стенографист"),
|
("логопед", "контролер"), # 38
|
||||||
("врач", "дипломат"),
|
("адвокат", "директор (глава АО)"), # 39
|
||||||
("главный бухгалтер", "директор"),
|
("кассир", "продюсер"), # 40
|
||||||
("поэт", "психолог"),
|
("поэт, писатель", "продавец"), # 41
|
||||||
("архивариус", "скульптор"),
|
("криминалист (баллистик)", "композитор"), # 42
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ключ расшифровки: номер пары → (тип выбора А, тип выбора В)
|
||||||
|
# Типы: Р=Реалистический, И=Интеллектуальный, С=Социальный,
|
||||||
|
# К=Конвенциональный, П=Предприимчивый, А=Художественный
|
||||||
|
# Источник: Тест Дж. Голланда, модификация Г.В. Резапкиной
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
CHOICE_KEY: dict[int, tuple[str, str]] = {
|
||||||
|
1: ("Р", "И"), 2: ("Р", "С"), 3: ("Р", "К"), 4: ("Р", "П"), 5: ("Р", "А"),
|
||||||
|
6: ("И", "С"), 7: ("И", "К"), 8: ("И", "П"), 9: ("И", "А"), 10: ("С", "К"),
|
||||||
|
11: ("С", "П"), 12: ("С", "А"), 13: ("К", "П"), 14: ("К", "А"), 15: ("П", "А"),
|
||||||
|
16: ("Р", "И"), 17: ("Р", "С"), 18: ("К", "П"), 19: ("Р", "К"), 20: ("И", "С"),
|
||||||
|
21: ("Р", "А"), 22: ("И", "К"), 23: ("И", "П"), 24: ("И", "А"), 25: ("С", "П"),
|
||||||
|
26: ("С", "П"), 27: ("С", "А"), 28: ("Р", "П"), 29: ("К", "А"), 30: ("П", "А"),
|
||||||
|
31: ("Р", "И"), 32: ("Р", "К"), 33: ("Р", "П"), 34: ("Р", "А"), 35: ("И", "К"),
|
||||||
|
36: ("И", "С"), 37: ("И", "А"), 38: ("С", "К"), 39: ("С", "П"), 40: ("К", "П"),
|
||||||
|
41: ("А", "С"), 42: ("К", "А"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Описания шести типов личности по Голланду
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DIMENSIONS = [
|
||||||
|
{
|
||||||
|
"code": "Р", "name": "Реалистический", "position": 1, "color": "#6B8E5E",
|
||||||
|
"description": (
|
||||||
|
"Предпочитает работать с вещами, а не с людьми. Несоциальный, эмоционально-стабильный тип. "
|
||||||
|
"Ориентирован на настоящее, определённое. Занимается конкретными объектами и их использованием "
|
||||||
|
"(вещи, инструменты, техника). Трудолюбив, пластичен. Преобладают невербальные и математические "
|
||||||
|
"способности. Охотнее делает, чем говорит; настойчив, уверен в себе. "
|
||||||
|
"Рекомендуемые профессии: оператор ПК, техник, шофёр, ювелир, автомеханик, фермер, инженер."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "И", "name": "Интеллектуальный", "position": 2, "color": "#4A7FA5",
|
||||||
|
"description": (
|
||||||
|
"Ориентирован на труд с идеями и объектами. Любознательный, методичный, любит работать в одиночку. "
|
||||||
|
"Аналитические способности, рационализм, независимость и оригинальность мышления. "
|
||||||
|
"Нередко выбирает научную и исследовательскую работу. Мир идей для него важнее, чем общение с людьми. "
|
||||||
|
"Рекомендуемые профессии: метеоролог, научный работник, физик, химик, хирург, биолог."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "С", "name": "Социальный", "position": 3, "color": "#D4A84B",
|
||||||
|
"description": (
|
||||||
|
"Ориентирован на общение и взаимодействие с людьми. Ответственен, терпелив, эмпатичен. "
|
||||||
|
"Развитые вербальные способности. Предпочитает деятельность, связанную с обучением, воспитанием, "
|
||||||
|
"лечением, консультированием. Гуманен, чувствителен, активен. "
|
||||||
|
"Рекомендуемые профессии: учитель, психолог, логопед, врач, продавец."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "К", "name": "Конвенциональный", "position": 4, "color": "#8B7355",
|
||||||
|
"description": (
|
||||||
|
"Отдаёт предпочтение чётко структурированной деятельности. Консервативен, аккуратен, пунктуален. "
|
||||||
|
"Хорошо работает с информацией в виде цифр, формул, текстов. Прекрасный исполнитель. "
|
||||||
|
"Склонен к канцелярской и конторской работе. "
|
||||||
|
"Рекомендуемые профессии: экономист, бухгалтер, нотариус, кассир, архивариус, налоговый инспектор."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "П", "name": "Предприимчивый", "position": 5, "color": "#C0392B",
|
||||||
|
"description": (
|
||||||
|
"Выбирает цели и задачи, которые позволяют проявить энергию и энтузиазм. Самоуверен, настойчив. "
|
||||||
|
"Находчив, практичен, быстро ориентируется в сложной обстановке. Социально активен, готов рисковать. "
|
||||||
|
"Предпочитает деятельность, связанную с руководством и влиянием на людей. "
|
||||||
|
"Рекомендуемые профессии: руководитель, директор, судья, адвокат, брокер, предприниматель, риэлтор."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "А", "name": "Художественный", "position": 6, "color": "#8E44AD",
|
||||||
|
"description": (
|
||||||
|
"Сложный взгляд на жизнь, гибкость и независимость в принятии решений. Богатое воображение, "
|
||||||
|
"склонность к творческой деятельности. Оригинален, эмоционален, не терпит жёсткой регламентации. "
|
||||||
|
"Преобладают вербальные способности. "
|
||||||
|
"Рекомендуемые профессии: писатель, фотограф, музыкант, художник, журналист, архитектор, дизайнер."
|
||||||
|
),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -66,15 +150,19 @@ def get_session():
|
|||||||
def create_holland_poll(author_id: Optional[str] = None):
|
def create_holland_poll(author_id: Optional[str] = None):
|
||||||
Session = get_session()
|
Session = get_session()
|
||||||
with Session() as session:
|
with Session() as session:
|
||||||
poll = Poll(title="Тест Д. Голланда — определение типа личности",
|
poll = Poll(
|
||||||
description="Тест Д. Голланда: выберите из каждой пары предпочитаемую профессию.")
|
title="Тест Дж. Голланда — определение типа личности",
|
||||||
# polls are anonymous; ignore author_id
|
description=(
|
||||||
|
"Тест Дж. Голланда (модификация Г.В. Резапкиной): выберите из каждой пары "
|
||||||
|
"предпочитаемую профессию. Из 42 пар профессий отметьте ту, которая вам больше нравится."
|
||||||
|
),
|
||||||
|
)
|
||||||
session.add(poll)
|
session.add(poll)
|
||||||
for idx, (a, b) in enumerate(PAIRS, start=1):
|
for idx, (a, b) in enumerate(PAIRS, start=1):
|
||||||
q = Question(text=f"Вариант {idx}", position=idx, type="single", poll=poll)
|
q = Question(text=f"Вариант {idx}", position=idx, type="single", poll=poll)
|
||||||
session.add(q)
|
session.add(q)
|
||||||
ch1 = Choice(question=q, text=f"а) {a}", position=1)
|
ch1 = Choice(question=q, text=f"А) {a}", position=1)
|
||||||
ch2 = Choice(question=q, text=f"б) {b}", position=2)
|
ch2 = Choice(question=q, text=f"В) {b}", position=2)
|
||||||
session.add(ch1)
|
session.add(ch1)
|
||||||
session.add(ch2)
|
session.add(ch2)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -88,3 +176,88 @@ def list_holland_polls():
|
|||||||
with Session() as session:
|
with Session() as session:
|
||||||
rows = session.query(Poll).filter(Poll.title.like("%Голланда%")).all()
|
rows = session.query(Poll).filter(Poll.title.like("%Голланда%")).all()
|
||||||
return [{"id": str(p.id), "title": p.title, "description": p.description} for p in rows]
|
return [{"id": str(p.id), "title": p.title, "description": p.description} for p in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/holland/{poll_id}/seed-scores", status_code=201)
|
||||||
|
def seed_holland_scores(poll_id: str):
|
||||||
|
"""
|
||||||
|
Загружает расшифровку теста Голланда (модификация Резапкиной) в базу данных:
|
||||||
|
- создаёт 6 осей диаграммы (ScaleDimension) для указанного теста
|
||||||
|
- для каждого из 42 вопросов привязывает оба варианта ответа к своей оси (ChoiceScore)
|
||||||
|
|
||||||
|
При повторном вызове старые оси и расшифровки пересоздаются.
|
||||||
|
"""
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
pid = uuid.UUID(poll_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid poll UUID")
|
||||||
|
|
||||||
|
with Session() as session:
|
||||||
|
poll = session.get(Poll, pid)
|
||||||
|
if not poll:
|
||||||
|
raise HTTPException(status_code=404, detail="Poll not found")
|
||||||
|
|
||||||
|
# Получаем вопросы упорядоченно по позиции
|
||||||
|
questions = (
|
||||||
|
session.query(Question)
|
||||||
|
.filter(Question.poll_id == pid)
|
||||||
|
.order_by(Question.position.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(questions) != 42:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Ожидается 42 вопроса, найдено {len(questions)}. Убедитесь, что тест создан через POST /polls/holland.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Удаляем старые оси (и ChoiceScore через cascade)
|
||||||
|
old_dims = session.query(ScaleDimension).filter(ScaleDimension.poll_id == pid).all()
|
||||||
|
for d in old_dims:
|
||||||
|
session.delete(d)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# Создаём 6 осей: код → объект
|
||||||
|
dim_map: dict[str, ScaleDimension] = {}
|
||||||
|
for d_data in DIMENSIONS:
|
||||||
|
dim = ScaleDimension(
|
||||||
|
poll_id=pid,
|
||||||
|
name=d_data["name"],
|
||||||
|
description=d_data["description"],
|
||||||
|
color=d_data["color"],
|
||||||
|
position=d_data["position"],
|
||||||
|
)
|
||||||
|
session.add(dim)
|
||||||
|
session.flush()
|
||||||
|
dim_map[d_data["code"]] = dim
|
||||||
|
|
||||||
|
# Для каждого вопроса привязываем варианты к осям
|
||||||
|
scores_created = 0
|
||||||
|
for q in questions:
|
||||||
|
pair_num = q.position # 1..42
|
||||||
|
if pair_num not in CHOICE_KEY:
|
||||||
|
continue
|
||||||
|
type_a, type_b = CHOICE_KEY[pair_num]
|
||||||
|
|
||||||
|
choices = (
|
||||||
|
session.query(Choice)
|
||||||
|
.filter(Choice.question_id == q.id)
|
||||||
|
.order_by(Choice.position.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(choices) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
choice_a, choice_b = choices[0], choices[1]
|
||||||
|
session.add(ChoiceScore(choice_id=choice_a.id, dimension_id=dim_map[type_a].id, score=1.0))
|
||||||
|
session.add(ChoiceScore(choice_id=choice_b.id, dimension_id=dim_map[type_b].id, score=1.0))
|
||||||
|
scores_created += 2
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"dimensions": len(DIMENSIONS),
|
||||||
|
"choice_scores": scores_created,
|
||||||
|
"message": "Расшифровка теста Голланда успешно загружена в базу данных.",
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ async def migrate_tables():
|
|||||||
# Добавляем новые колонки в users
|
# Добавляем новые колонки в users
|
||||||
'ALTER TABLE users ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES "group"(id) ON DELETE SET NULL',
|
'ALTER TABLE users ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES "group"(id) ON DELETE SET NULL',
|
||||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization(id) ON DELETE SET NULL",
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization(id) ON DELETE SET NULL",
|
||||||
|
# Добавляем путь к SVG-файлу диаграммы в radar_results
|
||||||
|
"ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512)",
|
||||||
]
|
]
|
||||||
applied = []
|
applied = []
|
||||||
try:
|
try:
|
||||||
|
|||||||
258
route/radar_crud.py
Normal file
258
route/radar_crud.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""
|
||||||
|
Эндпоинты для работы с радарными результатами тестов.
|
||||||
|
|
||||||
|
POST /responses/{response_id}/radar/compute
|
||||||
|
— (пере)считать и сохранить результат как многогранную диаграмму.
|
||||||
|
Вызывается автоматически из response_crud при сдаче теста,
|
||||||
|
а также может быть вызван вручную (пересчёт).
|
||||||
|
|
||||||
|
GET /responses/{response_id}/radar
|
||||||
|
— получить сохранённый результат (данные для отрисовки диаграммы).
|
||||||
|
|
||||||
|
GET /users/{user_id}/radar-results
|
||||||
|
— история всех результатов пользователя (для всех тестов).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session as SASession
|
||||||
|
|
||||||
|
from bd import make_engine
|
||||||
|
from bd.tables.response import Response, Answer
|
||||||
|
from bd.tables.scale import ScaleDimension, ChoiceScore
|
||||||
|
from bd.tables.radar_result import RadarResult, RadarResultItem
|
||||||
|
from route.radar_svg_gen import save_radar_svg
|
||||||
|
|
||||||
|
router = APIRouter(tags=["radar"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
return sessionmaker(bind=make_engine(), future=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Схемы ответов
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RadarItemOut(BaseModel):
|
||||||
|
dimension_id: Optional[str]
|
||||||
|
dimension_name: str
|
||||||
|
dimension_color: Optional[str]
|
||||||
|
dimension_position: Optional[float]
|
||||||
|
value: float
|
||||||
|
|
||||||
|
|
||||||
|
class RadarResultOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
response_id: str
|
||||||
|
computed_at: str
|
||||||
|
image_url: Optional[str] # URL для скачивания SVG с сервера
|
||||||
|
items: List[RadarItemOut]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Вспомогательная функция расчёта — используется и внутри пакета
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compute_radar(response_id: uuid.UUID, session: SASession) -> RadarResult:
|
||||||
|
"""
|
||||||
|
Считает сумму баллов по каждой оси теста для данного ответа.
|
||||||
|
Если результат уже существует — пересоздаёт его.
|
||||||
|
Возвращает сохранённый объект RadarResult.
|
||||||
|
"""
|
||||||
|
resp: Response = session.get(Response, response_id)
|
||||||
|
if not resp:
|
||||||
|
raise HTTPException(status_code=404, detail="Response not found")
|
||||||
|
|
||||||
|
# Получаем все оси теста
|
||||||
|
dimensions = (
|
||||||
|
session.query(ScaleDimension)
|
||||||
|
.filter(ScaleDimension.poll_id == resp.poll_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not dimensions:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Poll has no scale dimensions defined. Add dimensions and choice scores first.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Собираем set выбранных вариантов
|
||||||
|
chosen_choice_ids = {
|
||||||
|
a.choice_id for a in resp.answers if a.choice_id is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Считаем баллы по каждой оси
|
||||||
|
scores_map: dict[uuid.UUID, float] = {d.id: 0.0 for d in dimensions}
|
||||||
|
if chosen_choice_ids:
|
||||||
|
choice_scores = (
|
||||||
|
session.query(ChoiceScore)
|
||||||
|
.filter(ChoiceScore.choice_id.in_(chosen_choice_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for cs in choice_scores:
|
||||||
|
if cs.dimension_id in scores_map:
|
||||||
|
scores_map[cs.dimension_id] += cs.score
|
||||||
|
|
||||||
|
# Удаляем старый результат, если есть
|
||||||
|
old = session.query(RadarResult).filter(RadarResult.response_id == response_id).first()
|
||||||
|
if old:
|
||||||
|
session.delete(old)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# Создаём новый
|
||||||
|
result = RadarResult(response_id=response_id)
|
||||||
|
session.add(result)
|
||||||
|
session.flush() # чтобы получить result.id до добавления items
|
||||||
|
|
||||||
|
for dim in sorted(dimensions, key=lambda d: (d.position is None, d.position)):
|
||||||
|
item = RadarResultItem(
|
||||||
|
result_id=result.id,
|
||||||
|
dimension_id=dim.id,
|
||||||
|
dimension_name=dim.name,
|
||||||
|
dimension_color=dim.color,
|
||||||
|
dimension_position=float(dim.position) if dim.position is not None else None,
|
||||||
|
value=scores_map[dim.id],
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.refresh(result)
|
||||||
|
|
||||||
|
# Сохраняем SVG-файл на сервере
|
||||||
|
items_data = [
|
||||||
|
{
|
||||||
|
"dimension_name": item.dimension_name,
|
||||||
|
"dimension_color": item.dimension_color,
|
||||||
|
"value": item.value,
|
||||||
|
}
|
||||||
|
for item in sorted(result.items, key=lambda i: (i.dimension_position is None, i.dimension_position))
|
||||||
|
]
|
||||||
|
max_val = max((it["value"] for it in items_data), default=1.0)
|
||||||
|
max_val = max(max_val, 14.0)
|
||||||
|
try:
|
||||||
|
rel_path = save_radar_svg(str(result.id), items_data, max_val)
|
||||||
|
with session.begin_nested():
|
||||||
|
result.image_path = rel_path
|
||||||
|
session.commit()
|
||||||
|
session.refresh(result)
|
||||||
|
except Exception:
|
||||||
|
pass # не ломаем сдачу, если запись файла не удалась
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Эндпоинты
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/responses/{response_id}/radar/compute", response_model=RadarResultOut,
|
||||||
|
status_code=200)
|
||||||
|
def compute_radar_endpoint(response_id: str):
|
||||||
|
"""Вычисляет (или пересчитывает) многогранный результат по прохождению теста."""
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
rid = uuid.UUID(response_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid UUID")
|
||||||
|
with Session() as session:
|
||||||
|
result = compute_radar(rid, session)
|
||||||
|
return _result_to_out(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/responses/{response_id}/radar", response_model=RadarResultOut)
|
||||||
|
def get_radar_result(response_id: str):
|
||||||
|
"""Возвращает сохранённый радарный результат для отображения диаграммы."""
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
rid = uuid.UUID(response_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid UUID")
|
||||||
|
with Session() as session:
|
||||||
|
result = (
|
||||||
|
session.query(RadarResult)
|
||||||
|
.filter(RadarResult.response_id == rid)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Radar result not found. Submit the response first.")
|
||||||
|
return _result_to_out(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/responses/{response_id}/radar/image")
|
||||||
|
def get_radar_image(response_id: str):
|
||||||
|
"""Отдаёт SVG-файл диаграммы для скачивания."""
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
rid = uuid.UUID(response_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid UUID")
|
||||||
|
with Session() as session:
|
||||||
|
result = (
|
||||||
|
session.query(RadarResult)
|
||||||
|
.filter(RadarResult.response_id == rid)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not result or not result.image_path:
|
||||||
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
|
app_path = os.getenv("APP_PATH", "/app")
|
||||||
|
full_path = os.path.join(app_path, result.image_path)
|
||||||
|
if not os.path.isfile(full_path):
|
||||||
|
raise HTTPException(status_code=404, detail="Image file missing on server")
|
||||||
|
return FileResponse(
|
||||||
|
full_path,
|
||||||
|
media_type="image/svg+xml",
|
||||||
|
filename=f"radar_{response_id}.svg",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/radar-results", response_model=List[RadarResultOut])
|
||||||
|
def get_user_radar_history(user_id: str):
|
||||||
|
"""История радарных результатов пользователя по всем тестам."""
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(user_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid user UUID")
|
||||||
|
with Session() as session:
|
||||||
|
responses = (
|
||||||
|
session.query(Response)
|
||||||
|
.filter(Response.user_id == uid)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
response_ids = [r.id for r in responses]
|
||||||
|
if not response_ids:
|
||||||
|
return []
|
||||||
|
results = (
|
||||||
|
session.query(RadarResult)
|
||||||
|
.filter(RadarResult.response_id.in_(response_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_result_to_out(r) for r in results]
|
||||||
|
|
||||||
|
|
||||||
|
def _result_to_out(result: RadarResult) -> RadarResultOut:
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
if result.image_path:
|
||||||
|
image_url = f"/responses/{result.response_id}/radar/image"
|
||||||
|
return RadarResultOut(
|
||||||
|
id=str(result.id),
|
||||||
|
response_id=str(result.response_id),
|
||||||
|
computed_at=result.computed_at.isoformat(),
|
||||||
|
image_url=image_url,
|
||||||
|
items=[
|
||||||
|
RadarItemOut(
|
||||||
|
dimension_id=str(item.dimension_id) if item.dimension_id else None,
|
||||||
|
dimension_name=item.dimension_name,
|
||||||
|
dimension_color=item.dimension_color,
|
||||||
|
dimension_position=item.dimension_position,
|
||||||
|
value=item.value,
|
||||||
|
)
|
||||||
|
for item in sorted(result.items, key=lambda i: (i.dimension_position is None, i.dimension_position))
|
||||||
|
],
|
||||||
|
)
|
||||||
118
route/radar_svg_gen.py
Normal file
118
route/radar_svg_gen.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
radar_svg_gen.py — серверная генерация SVG паутинной диаграммы.
|
||||||
|
Используется при сохранении результата: файл пишется в APP_PATH/data/radar/.
|
||||||
|
Не требует сторонних зависимостей.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
|
def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520) -> str:
|
||||||
|
"""
|
||||||
|
Возвращает строку SVG паутинной диаграммы.
|
||||||
|
|
||||||
|
items — список осей: [{dimension_name, dimension_color, value}, ...]
|
||||||
|
max_val — максимум шкалы (14 для теста Голланда)
|
||||||
|
size — размер холста в пикселях
|
||||||
|
"""
|
||||||
|
n = len(items)
|
||||||
|
if n < 3:
|
||||||
|
return (
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">'
|
||||||
|
f'<rect width="{size}" height="{size}" fill="#2F184B" rx="16"/>'
|
||||||
|
f'<text x="{size//2}" y="{size//2}" text-anchor="middle" fill="white" '
|
||||||
|
f'font-family="Arial,sans-serif" font-size="16">Нет данных</text></svg>'
|
||||||
|
)
|
||||||
|
|
||||||
|
cx = cy = size / 2
|
||||||
|
R = size * 0.27
|
||||||
|
lbl_r = R + size * 0.13
|
||||||
|
safe_max = max_val if max_val > 0 else 1.0
|
||||||
|
|
||||||
|
def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]:
|
||||||
|
a = math.radians(-90.0 + 360.0 * i / n)
|
||||||
|
return cx + R * frac * math.cos(a), cy + R * frac * math.sin(a)
|
||||||
|
|
||||||
|
def poly_pts(pts: list[tuple]) -> str:
|
||||||
|
return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
|
||||||
|
|
||||||
|
lines: list[str] = [
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">',
|
||||||
|
f'<rect width="{size}" height="{size}" fill="#2F184B" rx="16"/>',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Концентрические сетки
|
||||||
|
for lvl in range(1, 6):
|
||||||
|
pts = [axis_xy(i, lvl / 5) for i in range(n)]
|
||||||
|
lines.append(
|
||||||
|
f'<polygon points="{poly_pts(pts)}" '
|
||||||
|
f'fill="none" stroke="rgba(244,239,250,0.18)" stroke-width="1"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Оси от центра
|
||||||
|
for i in range(n):
|
||||||
|
x, y = axis_xy(i)
|
||||||
|
lines.append(
|
||||||
|
f'<line x1="{cx:.2f}" y1="{cy:.2f}" x2="{x:.2f}" y2="{y:.2f}" '
|
||||||
|
f'stroke="rgba(244,239,250,0.22)" stroke-width="1"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Полигон данных
|
||||||
|
data_pts = [axis_xy(i, min(it["value"] / safe_max, 1.0)) for i, it in enumerate(items)]
|
||||||
|
lines.append(
|
||||||
|
f'<polygon points="{poly_pts(data_pts)}" '
|
||||||
|
f'fill="rgba(155,114,207,0.55)" stroke="#F4EFFA" stroke-width="2.5"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Точки на вершинах
|
||||||
|
for x, y in data_pts:
|
||||||
|
lines.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="5" fill="#F4EFFA"/>')
|
||||||
|
|
||||||
|
# Подписи
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
a = math.radians(-90.0 + 360.0 * i / n)
|
||||||
|
lx = cx + lbl_r * math.cos(a)
|
||||||
|
ly = cy + lbl_r * math.sin(a)
|
||||||
|
col = item.get("dimension_color") or "#F4EFFA"
|
||||||
|
name = _xml_escape(item["dimension_name"])
|
||||||
|
val = int(item["value"])
|
||||||
|
|
||||||
|
lines.append(
|
||||||
|
f'<text x="{lx:.2f}" y="{ly:.2f}" text-anchor="middle" '
|
||||||
|
f'font-family="Arial,Helvetica,sans-serif" font-size="14" '
|
||||||
|
f'font-weight="bold" fill="{col}">{name}</text>'
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f'<text x="{lx:.2f}" y="{ly + 20:.2f}" text-anchor="middle" '
|
||||||
|
f'font-family="Arial,Helvetica,sans-serif" font-size="13" '
|
||||||
|
f'fill="#F4EFFA">{val}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append("</svg>")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _xml_escape(s: str) -> str:
|
||||||
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
def save_radar_svg(result_id: str, items: list[dict], max_val: float = 14.0) -> str:
|
||||||
|
"""
|
||||||
|
Генерирует SVG и сохраняет его на диск.
|
||||||
|
Возвращает относительный путь к файлу (относительно APP_PATH).
|
||||||
|
|
||||||
|
Файл пишется в: {APP_PATH}/data/radar/{result_id}.svg
|
||||||
|
Возвращает: data/radar/{result_id}.svg
|
||||||
|
"""
|
||||||
|
app_path = os.getenv("APP_PATH", "/app")
|
||||||
|
radar_dir = os.path.join(app_path, "data", "radar")
|
||||||
|
os.makedirs(radar_dir, exist_ok=True)
|
||||||
|
|
||||||
|
filename = f"{result_id}.svg"
|
||||||
|
full_path = os.path.join(radar_dir, filename)
|
||||||
|
svg = generate_radar_svg(items, max_val)
|
||||||
|
with open(full_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(svg)
|
||||||
|
|
||||||
|
return f"data/radar/{filename}"
|
||||||
@@ -8,6 +8,7 @@ from bd import make_engine
|
|||||||
from bd.tables.poll import Poll
|
from bd.tables.poll import Poll
|
||||||
from bd.tables.question import Question
|
from bd.tables.question import Question
|
||||||
from bd.tables.response import Response, Answer
|
from bd.tables.response import Response, Answer
|
||||||
|
from bd.tables.scale import ScaleDimension
|
||||||
|
|
||||||
router = APIRouter(tags=["responses"], prefix="/polls")
|
router = APIRouter(tags=["responses"], prefix="/polls")
|
||||||
|
|
||||||
@@ -77,7 +78,26 @@ def submit_response(poll_id: str, payload: ResponseIn):
|
|||||||
session.add(answer)
|
session.add(answer)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(resp)
|
session.refresh(resp)
|
||||||
return {"id": str(resp.id)}
|
|
||||||
|
# Автоматически считаем радарный результат, если у теста есть оси
|
||||||
|
has_dimensions = (
|
||||||
|
session.query(ScaleDimension)
|
||||||
|
.filter(ScaleDimension.poll_id == pid)
|
||||||
|
.first()
|
||||||
|
) is not None
|
||||||
|
radar_id: Optional[str] = None
|
||||||
|
if has_dimensions:
|
||||||
|
try:
|
||||||
|
from route.radar_crud import compute_radar
|
||||||
|
radar = compute_radar(resp.id, session)
|
||||||
|
radar_id = str(radar.id)
|
||||||
|
except Exception:
|
||||||
|
pass # не ломаем сдачу, если расчёт не удался
|
||||||
|
|
||||||
|
result: dict = {"id": str(resp.id)}
|
||||||
|
if radar_id:
|
||||||
|
result["radar_result_id"] = radar_id
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/responses/")
|
@router.get("/responses/")
|
||||||
|
|||||||
227
route/scale_crud.py
Normal file
227
route/scale_crud.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""
|
||||||
|
CRUD для осей (ScaleDimension) и расшифровок вариантов ответов (ChoiceScore).
|
||||||
|
|
||||||
|
Эндпоинты:
|
||||||
|
POST /polls/{poll_id}/dimensions — создать ось для теста
|
||||||
|
GET /polls/{poll_id}/dimensions — список осей теста
|
||||||
|
PUT /dimensions/{dimension_id} — изменить ось
|
||||||
|
DELETE /dimensions/{dimension_id} — удалить ось
|
||||||
|
|
||||||
|
POST /dimensions/{dimension_id}/scores — задать расшифровку для варианта ответа
|
||||||
|
GET /dimensions/{dimension_id}/scores — список расшифровок по оси
|
||||||
|
GET /choices/{choice_id}/scores — расшифровки конкретного варианта
|
||||||
|
DELETE /choice-scores/{score_id} — удалить расшифровку
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from bd import make_engine
|
||||||
|
from bd.tables.poll import Poll
|
||||||
|
from bd.tables.choice import Choice
|
||||||
|
from bd.tables.scale import ScaleDimension, ChoiceScore
|
||||||
|
|
||||||
|
router = APIRouter(tags=["scale"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
return sessionmaker(bind=make_engine(), future=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Схемы
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DimensionIn(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
position: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DimensionOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
poll_id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
color: Optional[str]
|
||||||
|
position: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
class ChoiceScoreIn(BaseModel):
|
||||||
|
choice_id: str
|
||||||
|
score: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class ChoiceScoreOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
choice_id: str
|
||||||
|
dimension_id: str
|
||||||
|
score: float
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Оси (ScaleDimension)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/polls/{poll_id}/dimensions", response_model=DimensionOut, status_code=201,
|
||||||
|
tags=["scale"])
|
||||||
|
def create_dimension(poll_id: str, payload: DimensionIn):
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
pid = uuid.UUID(poll_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid poll UUID")
|
||||||
|
with Session() as session:
|
||||||
|
if not session.get(Poll, pid):
|
||||||
|
raise HTTPException(status_code=404, detail="Poll not found")
|
||||||
|
dim = ScaleDimension(
|
||||||
|
poll_id=pid,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
color=payload.color,
|
||||||
|
position=payload.position,
|
||||||
|
)
|
||||||
|
session.add(dim)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(dim)
|
||||||
|
return DimensionOut(
|
||||||
|
id=str(dim.id), poll_id=str(dim.poll_id),
|
||||||
|
name=dim.name, description=dim.description,
|
||||||
|
color=dim.color, position=dim.position,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/polls/{poll_id}/dimensions", response_model=List[DimensionOut],
|
||||||
|
tags=["scale"])
|
||||||
|
def list_dimensions(poll_id: str):
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
pid = uuid.UUID(poll_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid poll UUID")
|
||||||
|
with Session() as session:
|
||||||
|
dims = (
|
||||||
|
session.query(ScaleDimension)
|
||||||
|
.filter(ScaleDimension.poll_id == pid)
|
||||||
|
.order_by(ScaleDimension.position.asc().nullslast())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
DimensionOut(id=str(d.id), poll_id=str(d.poll_id),
|
||||||
|
name=d.name, description=d.description,
|
||||||
|
color=d.color, position=d.position)
|
||||||
|
for d in dims
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/dimensions/{dimension_id}", response_model=DimensionOut, tags=["scale"])
|
||||||
|
def update_dimension(dimension_id: str, payload: DimensionIn):
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
did = uuid.UUID(dimension_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid dimension UUID")
|
||||||
|
with Session() as session:
|
||||||
|
dim = session.get(ScaleDimension, did)
|
||||||
|
if not dim:
|
||||||
|
raise HTTPException(status_code=404, detail="Dimension not found")
|
||||||
|
dim.name = payload.name
|
||||||
|
dim.description = payload.description
|
||||||
|
dim.color = payload.color
|
||||||
|
dim.position = payload.position
|
||||||
|
session.commit()
|
||||||
|
session.refresh(dim)
|
||||||
|
return DimensionOut(id=str(dim.id), poll_id=str(dim.poll_id),
|
||||||
|
name=dim.name, description=dim.description,
|
||||||
|
color=dim.color, position=dim.position)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/dimensions/{dimension_id}", status_code=204, tags=["scale"])
|
||||||
|
def delete_dimension(dimension_id: str):
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
did = uuid.UUID(dimension_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid dimension UUID")
|
||||||
|
with Session() as session:
|
||||||
|
dim = session.get(ScaleDimension, did)
|
||||||
|
if not dim:
|
||||||
|
raise HTTPException(status_code=404, detail="Dimension not found")
|
||||||
|
session.delete(dim)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Расшифровки вариантов ответов (ChoiceScore)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/dimensions/{dimension_id}/scores", response_model=ChoiceScoreOut,
|
||||||
|
status_code=201, tags=["scale"])
|
||||||
|
def create_choice_score(dimension_id: str, payload: ChoiceScoreIn):
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
did = uuid.UUID(dimension_id)
|
||||||
|
cid = uuid.UUID(payload.choice_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid UUID")
|
||||||
|
with Session() as session:
|
||||||
|
if not session.get(ScaleDimension, did):
|
||||||
|
raise HTTPException(status_code=404, detail="Dimension not found")
|
||||||
|
if not session.get(Choice, cid):
|
||||||
|
raise HTTPException(status_code=404, detail="Choice not found")
|
||||||
|
cs = ChoiceScore(choice_id=cid, dimension_id=did, score=payload.score)
|
||||||
|
session.add(cs)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(cs)
|
||||||
|
return ChoiceScoreOut(id=str(cs.id), choice_id=str(cs.choice_id),
|
||||||
|
dimension_id=str(cs.dimension_id), score=cs.score)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dimensions/{dimension_id}/scores", response_model=List[ChoiceScoreOut],
|
||||||
|
tags=["scale"])
|
||||||
|
def list_scores_by_dimension(dimension_id: str):
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
did = uuid.UUID(dimension_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid dimension UUID")
|
||||||
|
with Session() as session:
|
||||||
|
rows = session.query(ChoiceScore).filter(ChoiceScore.dimension_id == did).all()
|
||||||
|
return [ChoiceScoreOut(id=str(r.id), choice_id=str(r.choice_id),
|
||||||
|
dimension_id=str(r.dimension_id), score=r.score)
|
||||||
|
for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/choices/{choice_id}/scores", response_model=List[ChoiceScoreOut],
|
||||||
|
tags=["scale"])
|
||||||
|
def list_scores_by_choice(choice_id: str):
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
cid = uuid.UUID(choice_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid choice UUID")
|
||||||
|
with Session() as session:
|
||||||
|
rows = session.query(ChoiceScore).filter(ChoiceScore.choice_id == cid).all()
|
||||||
|
return [ChoiceScoreOut(id=str(r.id), choice_id=str(r.choice_id),
|
||||||
|
dimension_id=str(r.dimension_id), score=r.score)
|
||||||
|
for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/choice-scores/{score_id}", status_code=204, tags=["scale"])
|
||||||
|
def delete_choice_score(score_id: str):
|
||||||
|
Session = get_session()
|
||||||
|
try:
|
||||||
|
sid = uuid.UUID(score_id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid UUID")
|
||||||
|
with Session() as session:
|
||||||
|
cs = session.get(ChoiceScore, sid)
|
||||||
|
if not cs:
|
||||||
|
raise HTTPException(status_code=404, detail="Score not found")
|
||||||
|
session.delete(cs)
|
||||||
|
session.commit()
|
||||||
@@ -156,6 +156,30 @@ async def get_response(response_id: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_radar_result(response_id: str) -> dict | None:
|
||||||
|
"""GET /responses/{response_id}/radar — данные для радарной диаграммы."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||||
|
resp = await client.get(f"/responses/{response_id}/radar")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_radar_svg_bytes(response_id: str) -> bytes | None:
|
||||||
|
"""GET /responses/{response_id}/radar/image — SVG-файл с диаграммой."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||||
|
resp = await client.get(f"/responses/{response_id}/radar/image")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.content
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def submit_response(
|
async def submit_response(
|
||||||
poll_id: str,
|
poll_id: str,
|
||||||
answers: list[dict],
|
answers: list[dict],
|
||||||
|
|||||||
142
web/radar_chart.py
Normal file
142
web/radar_chart.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
RadarChart — переиспользуемый виджет паутинной диаграммы (ft.Stack).
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
items — список осей из API /responses/{id}/radar:
|
||||||
|
[{dimension_name, dimension_color, dimension_position, value}, ...]
|
||||||
|
max_val — максимум на шкале (default 14 — для теста Голланда)
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import flet as ft
|
||||||
|
import flet.canvas as cv
|
||||||
|
import designer as ds
|
||||||
|
|
||||||
|
# ── размеры ─────────────────────────────────────────────────────────────────
|
||||||
|
_CS = 260 # размер canvas (×2 = итоговая зона графика)
|
||||||
|
_CX = _CS // 2
|
||||||
|
_CY = _CS // 2
|
||||||
|
_R = 80 # радиус внешнего кольца
|
||||||
|
_PAD = 95 # отступ вокруг canvas — оставляем место для подписей
|
||||||
|
_LBL_R = _R + 50 # расстояние от центра до центра подписи (в координатах Stack)
|
||||||
|
_W_LBL = 130 # ширина контейнера подписи (достаточно для «Конвенциональный»)
|
||||||
|
|
||||||
|
|
||||||
|
def _axis_xy(n: int, i: int, frac: float = 1.0) -> tuple[float, float]:
|
||||||
|
a = math.radians(-90.0 + 360.0 * i / n)
|
||||||
|
return _CX + _R * frac * math.cos(a), _CY + _R * frac * math.sin(a)
|
||||||
|
|
||||||
|
|
||||||
|
def _closed_path(pts: list[tuple]) -> list:
|
||||||
|
els = [cv.Path.MoveTo(*pts[0])]
|
||||||
|
for p in pts[1:]:
|
||||||
|
els.append(cv.Path.LineTo(*p))
|
||||||
|
els.append(cv.Path.Close())
|
||||||
|
return els
|
||||||
|
|
||||||
|
|
||||||
|
class RadarChart(ft.Stack):
|
||||||
|
"""Паутинная диаграмма типов/сфер по данным radar API."""
|
||||||
|
|
||||||
|
def __init__(self, items: list[dict], max_val: float = 14.0):
|
||||||
|
n = len(items)
|
||||||
|
shapes: list = []
|
||||||
|
|
||||||
|
# ── сетка (5 уровней) ────────────────────────────────────────────────
|
||||||
|
for lvl in range(1, 6):
|
||||||
|
ring = [_axis_xy(n, i, lvl / 5) for i in range(n)]
|
||||||
|
shapes.append(cv.Path(
|
||||||
|
_closed_path(ring),
|
||||||
|
paint=ft.Paint(
|
||||||
|
style=ft.PaintingStyle.STROKE,
|
||||||
|
stroke_width=1,
|
||||||
|
color="#30F4EFFA",
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── оси от центра ────────────────────────────────────────────────────
|
||||||
|
for i in range(n):
|
||||||
|
x, y = _axis_xy(n, i)
|
||||||
|
shapes.append(cv.Path(
|
||||||
|
[cv.Path.MoveTo(_CX, _CY), cv.Path.LineTo(x, y)],
|
||||||
|
paint=ft.Paint(stroke_width=1, color="#40F4EFFA"),
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── полигон данных ───────────────────────────────────────────────────
|
||||||
|
safe_max = max_val if max_val > 0 else 1.0
|
||||||
|
data_pts = [
|
||||||
|
_axis_xy(n, i, min(it["value"] / safe_max, 1.0))
|
||||||
|
for i, it in enumerate(items)
|
||||||
|
]
|
||||||
|
shapes.append(cv.Path(
|
||||||
|
_closed_path(data_pts),
|
||||||
|
paint=ft.Paint(style=ft.PaintingStyle.FILL, color="#609B72CF"),
|
||||||
|
))
|
||||||
|
shapes.append(cv.Path(
|
||||||
|
_closed_path(data_pts),
|
||||||
|
paint=ft.Paint(
|
||||||
|
style=ft.PaintingStyle.STROKE,
|
||||||
|
stroke_width=2.5,
|
||||||
|
color="#F4EFFA",
|
||||||
|
),
|
||||||
|
))
|
||||||
|
for px, py in data_pts:
|
||||||
|
shapes.append(cv.Circle(px, py, 4, paint=ft.Paint(color="#F4EFFA")))
|
||||||
|
|
||||||
|
canvas = cv.Canvas(shapes=shapes, width=_CS, height=_CS)
|
||||||
|
|
||||||
|
# ── подписи оси в Stack-координатах ──────────────────────────────────
|
||||||
|
sw = _CS + 2 * _PAD
|
||||||
|
sh = _CS + 2 * _PAD
|
||||||
|
ccx = _PAD + _CX # центр графика в Stack
|
||||||
|
ccy = _PAD + _CY
|
||||||
|
|
||||||
|
labels: list[ft.Control] = []
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
a = math.radians(-90.0 + 360.0 * i / n)
|
||||||
|
lx = ccx + _LBL_R * math.cos(a)
|
||||||
|
ly = ccy + _LBL_R * math.sin(a)
|
||||||
|
col = item.get("dimension_color") or ds.clolors.ferst
|
||||||
|
|
||||||
|
labels.append(ft.Container(
|
||||||
|
left=lx - _W_LBL / 2,
|
||||||
|
top=ly - 24,
|
||||||
|
width=_W_LBL,
|
||||||
|
content=ft.Column(
|
||||||
|
spacing=1,
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
controls=[
|
||||||
|
ft.Text(
|
||||||
|
item["dimension_name"],
|
||||||
|
size=10,
|
||||||
|
color=col,
|
||||||
|
text_align=ft.TextAlign.CENTER,
|
||||||
|
weight=ft.FontWeight.BOLD,
|
||||||
|
# разрешаем перенос только по словам (не по символам)
|
||||||
|
overflow=ft.TextOverflow.FADE,
|
||||||
|
max_lines=2,
|
||||||
|
no_wrap=False,
|
||||||
|
),
|
||||||
|
ft.Text(
|
||||||
|
str(int(item["value"])),
|
||||||
|
size=11,
|
||||||
|
color=ds.clolors.ferst,
|
||||||
|
text_align=ft.TextAlign.CENTER,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
width=sw,
|
||||||
|
height=sh,
|
||||||
|
controls=[
|
||||||
|
ft.Container(
|
||||||
|
left=_PAD,
|
||||||
|
top=_PAD,
|
||||||
|
width=_CS,
|
||||||
|
height=_CS,
|
||||||
|
content=canvas,
|
||||||
|
),
|
||||||
|
*labels,
|
||||||
|
],
|
||||||
|
)
|
||||||
105
web/radar_svg.py
Normal file
105
web/radar_svg.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""
|
||||||
|
radar_svg.py — генерация SVG паутинной диаграммы для сохранения на устройство.
|
||||||
|
|
||||||
|
Не требует сторонних зависимостей (чистый Python).
|
||||||
|
SVG содержит корректные шрифты и цвета, может быть открыт/сохранён в браузере.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
|
def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520) -> str:
|
||||||
|
"""
|
||||||
|
Возвращает SVG-строку паутинной диаграммы.
|
||||||
|
|
||||||
|
items — список осей (dimension_name, dimension_color, value)
|
||||||
|
max_val — максимум шкалы
|
||||||
|
size — размер холста в пикселях
|
||||||
|
"""
|
||||||
|
n = len(items)
|
||||||
|
if n < 3:
|
||||||
|
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}"><text x="10" y="20" fill="white">Нет данных</text></svg>'
|
||||||
|
|
||||||
|
cx = cy = size / 2
|
||||||
|
R = size * 0.27 # радиус внешнего кольца
|
||||||
|
lbl_r = R + size * 0.12 # расстояние до подписей от центра
|
||||||
|
safe_max = max_val if max_val > 0 else 1.0
|
||||||
|
|
||||||
|
def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]:
|
||||||
|
a = math.radians(-90.0 + 360.0 * i / n)
|
||||||
|
return cx + R * frac * math.cos(a), cy + R * frac * math.sin(a)
|
||||||
|
|
||||||
|
def poly_pts(pts: list[tuple]) -> str:
|
||||||
|
return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
|
||||||
|
|
||||||
|
lines: list[str] = [
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">',
|
||||||
|
# фон
|
||||||
|
f'<rect width="{size}" height="{size}" fill="#2F184B" rx="16"/>',
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── концентрические кольца (сетка) ──────────────────────────────────────
|
||||||
|
for lvl in range(1, 6):
|
||||||
|
pts = [axis_xy(i, lvl / 5) for i in range(n)]
|
||||||
|
lines.append(
|
||||||
|
f'<polygon points="{poly_pts(pts)}" '
|
||||||
|
f'fill="none" stroke="rgba(244,239,250,0.18)" stroke-width="1"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── оси ─────────────────────────────────────────────────────────────────
|
||||||
|
for i in range(n):
|
||||||
|
x, y = axis_xy(i)
|
||||||
|
lines.append(
|
||||||
|
f'<line x1="{cx:.2f}" y1="{cy:.2f}" x2="{x:.2f}" y2="{y:.2f}" '
|
||||||
|
f'stroke="rgba(244,239,250,0.22)" stroke-width="1"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── полигон данных ───────────────────────────────────────────────────────
|
||||||
|
data_pts = [axis_xy(i, min(it["value"] / safe_max, 1.0)) for i, it in enumerate(items)]
|
||||||
|
lines.append(
|
||||||
|
f'<polygon points="{poly_pts(data_pts)}" '
|
||||||
|
f'fill="rgba(155,114,207,0.55)" stroke="#F4EFFA" stroke-width="2.5"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── точки на вершинах ────────────────────────────────────────────────────
|
||||||
|
for x, y in data_pts:
|
||||||
|
lines.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="5" fill="#F4EFFA"/>')
|
||||||
|
|
||||||
|
# ── подписи осей ─────────────────────────────────────────────────────────
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
a = math.radians(-90.0 + 360.0 * i / n)
|
||||||
|
lx = cx + lbl_r * math.cos(a)
|
||||||
|
ly = cy + lbl_r * math.sin(a)
|
||||||
|
col = item.get("dimension_color") or "#F4EFFA"
|
||||||
|
name = _xml_escape(item["dimension_name"])
|
||||||
|
val = int(item["value"])
|
||||||
|
|
||||||
|
# название типа (жирный, цветной)
|
||||||
|
lines.append(
|
||||||
|
f'<text x="{lx:.2f}" y="{ly:.2f}" text-anchor="middle" '
|
||||||
|
f'font-family="Arial,Helvetica,sans-serif" font-size="14" '
|
||||||
|
f'font-weight="bold" fill="{col}">{name}</text>'
|
||||||
|
)
|
||||||
|
# значение (белый)
|
||||||
|
lines.append(
|
||||||
|
f'<text x="{lx:.2f}" y="{ly + 20:.2f}" text-anchor="middle" '
|
||||||
|
f'font-family="Arial,Helvetica,sans-serif" font-size="13" '
|
||||||
|
f'fill="#F4EFFA">{val}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append("</svg>")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _xml_escape(s: str) -> str:
|
||||||
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
def get_radar_data_uri(items: list[dict], max_val: float = 14.0) -> str:
|
||||||
|
"""
|
||||||
|
Возвращает data URI вида «data:image/svg+xml;base64,…».
|
||||||
|
Передайте в page.launch_url(...) чтобы открыть/сохранить изображение.
|
||||||
|
"""
|
||||||
|
svg = generate_radar_svg(items, max_val)
|
||||||
|
b64 = base64.b64encode(svg.encode("utf-8")).decode()
|
||||||
|
return f"data:image/svg+xml;base64,{b64}"
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import base64
|
||||||
import flet as ft
|
import flet as ft
|
||||||
import designer as ds
|
import designer as ds
|
||||||
import api_client
|
import api_client
|
||||||
|
from radar_chart import RadarChart
|
||||||
|
|
||||||
|
|
||||||
class PollPage(ft.Column):
|
class PollPage(ft.Column):
|
||||||
@@ -157,27 +159,80 @@ class PollPage(ft.Column):
|
|||||||
self._error_text.update()
|
self._error_text.update()
|
||||||
else:
|
else:
|
||||||
url_key = self._page.session.store.get("url_key") or ""
|
url_key = self._page.session.store.get("url_key") or ""
|
||||||
# Заменяем контент на сообщение об успехе
|
|
||||||
|
# Показываем лоадер пока грузим радар
|
||||||
|
self.controls = [
|
||||||
|
ft.Row(
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
controls=[ft.ProgressRing(width=40, height=40, color=ds.clolors.laer1)],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
radar = await api_client.get_radar_result(response_id)
|
||||||
|
|
||||||
|
success_controls: list[ft.Control] = [
|
||||||
|
ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=48, color=ds.clolors.laer1),
|
||||||
|
ds.CastomText(text="Тест пройден!", size=20, weight=ft.FontWeight.BOLD),
|
||||||
|
]
|
||||||
|
|
||||||
|
if radar and radar.get("items"):
|
||||||
|
items = sorted(
|
||||||
|
radar["items"],
|
||||||
|
key=lambda x: (
|
||||||
|
x.get("dimension_position") is None,
|
||||||
|
x.get("dimension_position", 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
actual_max = max((it["value"] for it in items), default=1.0)
|
||||||
|
max_val = max(actual_max, 14.0)
|
||||||
|
|
||||||
|
success_controls.append(
|
||||||
|
ft.Row(
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
controls=[RadarChart(items, max_val)],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _download(_evt, _rid=response_id):
|
||||||
|
svg_bytes = await api_client.get_radar_svg_bytes(_rid)
|
||||||
|
if svg_bytes:
|
||||||
|
b64 = base64.b64encode(svg_bytes).decode()
|
||||||
|
uri = f"data:image/svg+xml;base64,{b64}"
|
||||||
|
self._page.launch_url(uri, web_window_name="_blank")
|
||||||
|
|
||||||
|
success_controls.append(
|
||||||
|
ft.Row(
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
controls=[
|
||||||
|
ft.ElevatedButton(
|
||||||
|
text="Сохранить диаграмму",
|
||||||
|
icon=ft.Icons.DOWNLOAD,
|
||||||
|
on_click=_download,
|
||||||
|
style=ft.ButtonStyle(
|
||||||
|
color=ds.clolors.ferst,
|
||||||
|
bgcolor=ds.clolors.laer2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
success_controls.append(
|
||||||
|
ds.CastomButton(
|
||||||
|
text="Вернуться к тестам",
|
||||||
|
on_click=lambda _: self._page.go(f"/take_test/{url_key}"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
self.controls = [
|
self.controls = [
|
||||||
ft.Container(
|
ft.Container(
|
||||||
alignment=ft.Alignment(0, 0),
|
padding=20,
|
||||||
padding=40,
|
|
||||||
content=ft.Column(
|
content=ft.Column(
|
||||||
alignment=ft.MainAxisAlignment.CENTER,
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
spacing=16,
|
spacing=16,
|
||||||
controls=[
|
controls=success_controls,
|
||||||
ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=64, color=ds.clolors.laer1),
|
|
||||||
ds.CastomText(
|
|
||||||
text="Ответы отправлены!",
|
|
||||||
size=20,
|
|
||||||
weight=ft.FontWeight.BOLD,
|
|
||||||
),
|
|
||||||
ds.CastomButton(
|
|
||||||
text="Вернуться к тестам",
|
|
||||||
on_click=lambda _: self._page.go(f"/take_test/{url_key}"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import base64
|
||||||
import flet as ft
|
import flet as ft
|
||||||
import designer as ds
|
import designer as ds
|
||||||
import api_client
|
import api_client
|
||||||
|
from radar_chart import RadarChart
|
||||||
|
|
||||||
|
|
||||||
class ResponsePage(ft.Column):
|
class ResponsePage(ft.Column):
|
||||||
@@ -14,10 +16,7 @@ class ResponsePage(ft.Column):
|
|||||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||||
spacing=8,
|
spacing=8,
|
||||||
controls=[
|
controls=[
|
||||||
ft.Row(
|
ft.Row(alignment=ft.MainAxisAlignment.CENTER, controls=[self._loading])
|
||||||
alignment=ft.MainAxisAlignment.CENTER,
|
|
||||||
controls=[self._loading],
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,84 +33,106 @@ class ResponsePage(ft.Column):
|
|||||||
self.update()
|
self.update()
|
||||||
return
|
return
|
||||||
|
|
||||||
self._render(response, poll)
|
radar = await api_client.get_radar_result(self._response_id)
|
||||||
|
self._render(response, poll, radar)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _render(self, response: dict, poll: dict):
|
def _render(self, response: dict, poll: dict, radar: dict | None):
|
||||||
# Строим индексы для быстрого поиска
|
|
||||||
questions_map = {q["id"]: q for q in poll.get("questions", [])}
|
questions_map = {q["id"]: q for q in poll.get("questions", [])}
|
||||||
choices_map = {
|
|
||||||
c["id"]: c["text"]
|
|
||||||
for q in poll.get("questions", [])
|
|
||||||
for c in q.get("choices", [])
|
|
||||||
}
|
|
||||||
|
|
||||||
submitted_at = response["submitted_at"][:19].replace("T", " ")
|
submitted_at = response["submitted_at"][:19].replace("T", " ")
|
||||||
answer_cards = []
|
|
||||||
|
|
||||||
|
controls: list[ft.Control] = [
|
||||||
|
ft.Container(
|
||||||
|
padding=ft.padding.only(bottom=8),
|
||||||
|
content=ft.Column(spacing=4, controls=[
|
||||||
|
ds.CastomText(text=poll.get("title", ""), size=20, weight=ft.FontWeight.BOLD),
|
||||||
|
ds.CastomText(text=f"Пройден: {submitted_at}", size=12),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Паутинная диаграмма ──────────────────────────────────────────────
|
||||||
|
if radar and radar.get("items"):
|
||||||
|
items = sorted(
|
||||||
|
radar["items"],
|
||||||
|
key=lambda x: (x.get("dimension_position") is None, x.get("dimension_position", 0)),
|
||||||
|
)
|
||||||
|
actual_max = max((it["value"] for it in items), default=1.0)
|
||||||
|
max_val = max(actual_max, 14.0)
|
||||||
|
|
||||||
|
async def _download(_evt, _rid=self._response_id):
|
||||||
|
svg_bytes = await api_client.get_radar_svg_bytes(_rid)
|
||||||
|
if svg_bytes:
|
||||||
|
b64 = base64.b64encode(svg_bytes).decode()
|
||||||
|
uri = f"data:image/svg+xml;base64,{b64}"
|
||||||
|
self._page.launch_url(uri, web_window_name="_blank")
|
||||||
|
|
||||||
|
controls.append(ft.Container(
|
||||||
|
border_radius=16,
|
||||||
|
padding=12,
|
||||||
|
bgcolor=ds.clolors.laer3,
|
||||||
|
content=ft.Column(
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=10,
|
||||||
|
controls=[
|
||||||
|
ds.CastomText(text="Результат", size=16, weight=ft.FontWeight.BOLD),
|
||||||
|
ft.Row(
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
controls=[RadarChart(items, max_val)],
|
||||||
|
),
|
||||||
|
ft.Row(
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
controls=[
|
||||||
|
ft.ElevatedButton(
|
||||||
|
text="Сохранить диаграмму",
|
||||||
|
icon=ft.Icons.DOWNLOAD,
|
||||||
|
on_click=_download,
|
||||||
|
style=ft.ButtonStyle(
|
||||||
|
color=ds.clolors.ferst,
|
||||||
|
bgcolor=ds.clolors.laer2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── Ответы ───────────────────────────────────────────────────────────
|
||||||
|
answer_cards: list[ft.Control] = []
|
||||||
for answer in response.get("answers", []):
|
for answer in response.get("answers", []):
|
||||||
q = questions_map.get(answer["question_id"])
|
q = questions_map.get(answer["question_id"])
|
||||||
if not q:
|
if not q:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
selected_text = choices_map.get(answer.get("choice_id") or "") or answer.get("text") or "—"
|
|
||||||
|
|
||||||
# Варианты с подсветкой выбранного
|
|
||||||
choice_rows = []
|
choice_rows = []
|
||||||
for c in q.get("choices", []):
|
for c in q.get("choices", []):
|
||||||
is_selected = c["id"] == answer.get("choice_id")
|
is_selected = c["id"] == answer.get("choice_id")
|
||||||
choice_rows.append(
|
choice_rows.append(ft.Container(
|
||||||
ft.Container(
|
border_radius=8,
|
||||||
border_radius=8,
|
padding=ft.padding.symmetric(horizontal=12, vertical=6),
|
||||||
padding=ft.padding.symmetric(horizontal=12, vertical=6),
|
bgcolor=ds.clolors.laer1 if is_selected else ds.clolors.laer3,
|
||||||
bgcolor=ds.clolors.laer1 if is_selected else ds.clolors.laer3,
|
content=ft.Row(spacing=8, controls=[
|
||||||
content=ft.Row(
|
ft.Icon(
|
||||||
spacing=8,
|
ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED,
|
||||||
controls=[
|
color=ds.clolors.ferst, size=16,
|
||||||
ft.Icon(
|
|
||||||
ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED,
|
|
||||||
color=ds.clolors.ferst,
|
|
||||||
size=16,
|
|
||||||
),
|
|
||||||
ds.CastomText(text=c["text"], size=13),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
)
|
ds.CastomText(text=c["text"], size=13),
|
||||||
)
|
]),
|
||||||
|
))
|
||||||
|
|
||||||
answer_cards.append(
|
answer_cards.append(ft.Container(
|
||||||
ft.Container(
|
border_radius=12,
|
||||||
border_radius=12,
|
padding=16,
|
||||||
padding=16,
|
bgcolor=ds.clolors.laer2,
|
||||||
bgcolor=ds.clolors.laer2,
|
content=ft.Column(spacing=8, controls=[
|
||||||
content=ft.Column(
|
ds.CastomText(text=q.get("text", ""), size=14, weight=ft.FontWeight.BOLD),
|
||||||
spacing=8,
|
*choice_rows,
|
||||||
controls=[
|
]),
|
||||||
ds.CastomText(
|
))
|
||||||
text=q.get("text", ""),
|
|
||||||
size=14,
|
|
||||||
weight=ft.FontWeight.BOLD,
|
|
||||||
),
|
|
||||||
*choice_rows,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.controls = [
|
if answer_cards:
|
||||||
ft.Container(
|
controls.append(ds.CastomText(text="Ответы", size=16, weight=ft.FontWeight.BOLD))
|
||||||
padding=ft.padding.only(bottom=8),
|
controls.extend(answer_cards)
|
||||||
content=ft.Column(
|
|
||||||
spacing=4,
|
self.controls = controls
|
||||||
controls=[
|
|
||||||
ds.CastomText(
|
|
||||||
text=poll.get("title", ""),
|
|
||||||
size=20,
|
|
||||||
weight=ft.FontWeight.BOLD,
|
|
||||||
),
|
|
||||||
ds.CastomText(text=f"Пройден: {submitted_at}", size=12),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
*answer_cards,
|
|
||||||
]
|
|
||||||
|
|||||||
Reference in New Issue
Block a user