new shems

This commit is contained in:
2026-04-01 14:18:52 +05:00
parent 4e9b89fbe5
commit 22bbc3a0a6
15 changed files with 1793 additions and 133 deletions

435
README.md
View File

@@ -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
View 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
View 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"]

View File

@@ -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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -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": "Расшифровка теста Голланда успешно загружена в базу данных.",
}

View File

@@ -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
View 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
View 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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}"

View File

@@ -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
View 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()

View File

@@ -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
View 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
View 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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}"

View File

@@ -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}"),
),
],
), ),
) )
] ]

View File

@@ -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( content=ft.Row(spacing=8, controls=[
spacing=8,
controls=[
ft.Icon( ft.Icon(
ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED, ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED,
color=ds.clolors.ferst, color=ds.clolors.ferst, size=16,
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( content=ft.Column(spacing=8, controls=[
spacing=8, ds.CastomText(text=q.get("text", ""), size=14, weight=ft.FontWeight.BOLD),
controls=[
ds.CastomText(
text=q.get("text", ""),
size=14,
weight=ft.FontWeight.BOLD,
),
*choice_rows, *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,
]