From 22bbc3a0a605c317fee460838c9629e3c822c11d Mon Sep 17 00:00:00 2001 From: jze9 Date: Wed, 1 Apr 2026 14:18:52 +0500 Subject: [PATCH] new shems --- README.md | 435 +++++++++++++++++++++++ bd/tables/radar_result.py | 41 +++ bd/tables/scale.py | 37 ++ main.py | 4 +- route/holland_crud.py | 267 +++++++++++--- route/init_data_base.py | 2 + route/radar_crud.py | 258 ++++++++++++++ route/radar_svg_gen.py | 118 ++++++ route/response_crud.py | 22 +- route/scale_crud.py | 227 ++++++++++++ web/api_client.py | 24 ++ web/radar_chart.py | 142 ++++++++ web/radar_svg.py | 105 ++++++ web/views/content_users/poll_page.py | 85 ++++- web/views/content_users/response_page.py | 159 +++++---- 15 files changed, 1793 insertions(+), 133 deletions(-) create mode 100644 bd/tables/radar_result.py create mode 100644 bd/tables/scale.py create mode 100644 route/radar_crud.py create mode 100644 route/radar_svg_gen.py create mode 100644 route/scale_crud.py create mode 100644 web/radar_chart.py create mode 100644 web/radar_svg.py diff --git a/README.md b/README.md index e69de29..3e33600 100644 --- a/README.md +++ b/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: " + +# Применить миграции (добавляет колонки, если БД уже была создана ранее) +curl -X POST http://localhost:8000/db/migrate \ + -H "X-Admin-Key: " +``` + +### 4. Загрузка теста Голланда + +```bash +# Создать тест (возвращает poll_id) +curl -X POST http://localhost:8000/polls/holland \ + -H "X-Admin-Key: " + +# Загрузить ключ расшифровки (42 пары → 6 типов × 14 баллов) +curl -X POST http://localhost:8000/polls/holland//seed-scores \ + -H "X-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 `. 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-атак). diff --git a/bd/tables/radar_result.py b/bd/tables/radar_result.py new file mode 100644 index 0000000..6b8f2c8 --- /dev/null +++ b/bd/tables/radar_result.py @@ -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"] diff --git a/bd/tables/scale.py b/bd/tables/scale.py new file mode 100644 index 0000000..259cbfd --- /dev/null +++ b/bd/tables/scale.py @@ -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"] diff --git a/main.py b/main.py index a0c69f3..7eab724 100644 --- a/main.py +++ b/main.py @@ -6,7 +6,7 @@ from fastapi import Depends, FastAPI, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html 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 import uvicorn @@ -69,6 +69,8 @@ app.include_router(choice_crud.router) app.include_router(response_crud.router) app.include_router(poll_crud.router) app.include_router(holland_crud.router) +app.include_router(scale_crud.router) +app.include_router(radar_crud.router) # --------------------------------------------------------------------------- diff --git a/route/holland_crud.py b/route/holland_crud.py index 9917375..c5d9fcd 100644 --- a/route/holland_crud.py +++ b/route/holland_crud.py @@ -7,53 +7,137 @@ from bd import make_engine from bd.tables.poll import Poll from bd.tables.question import Question from bd.tables.choice import Choice +from bd.tables.scale import ScaleDimension, ChoiceScore router = APIRouter(tags=["polls"], prefix="/polls") +# --------------------------------------------------------------------------- +# Вопросы теста — модификация Г.В. Резапкиной +# Пары профессий: (вариант А, вариант В) +# --------------------------------------------------------------------------- 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): Session = get_session() with Session() as session: - poll = Poll(title="Тест Д. Голланда — определение типа личности", - description="Тест Д. Голланда: выберите из каждой пары предпочитаемую профессию.") - # polls are anonymous; ignore author_id + poll = Poll( + title="Тест Дж. Голланда — определение типа личности", + description=( + "Тест Дж. Голланда (модификация Г.В. Резапкиной): выберите из каждой пары " + "предпочитаемую профессию. Из 42 пар профессий отметьте ту, которая вам больше нравится." + ), + ) session.add(poll) for idx, (a, b) in enumerate(PAIRS, start=1): q = Question(text=f"Вариант {idx}", position=idx, type="single", poll=poll) session.add(q) - ch1 = Choice(question=q, text=f"а) {a}", position=1) - ch2 = Choice(question=q, text=f"б) {b}", position=2) + ch1 = Choice(question=q, text=f"А) {a}", position=1) + ch2 = Choice(question=q, text=f"В) {b}", position=2) session.add(ch1) session.add(ch2) session.commit() @@ -88,3 +176,88 @@ def list_holland_polls(): with Session() as session: rows = session.query(Poll).filter(Poll.title.like("%Голланда%")).all() 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": "Расшифровка теста Голланда успешно загружена в базу данных.", + } diff --git a/route/init_data_base.py b/route/init_data_base.py index efaebec..4e553a2 100644 --- a/route/init_data_base.py +++ b/route/init_data_base.py @@ -103,6 +103,8 @@ async def migrate_tables(): # Добавляем новые колонки в 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 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 = [] try: diff --git a/route/radar_crud.py b/route/radar_crud.py new file mode 100644 index 0000000..6c4e255 --- /dev/null +++ b/route/radar_crud.py @@ -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)) + ], + ) diff --git a/route/radar_svg_gen.py b/route/radar_svg_gen.py new file mode 100644 index 0000000..bb9c828 --- /dev/null +++ b/route/radar_svg_gen.py @@ -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'' + f'' + f'Нет данных' + ) + + 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'', + f'', + ] + + # Концентрические сетки + for lvl in range(1, 6): + pts = [axis_xy(i, lvl / 5) for i in range(n)] + lines.append( + f'' + ) + + # Оси от центра + for i in range(n): + x, y = axis_xy(i) + lines.append( + f'' + ) + + # Полигон данных + data_pts = [axis_xy(i, min(it["value"] / safe_max, 1.0)) for i, it in enumerate(items)] + lines.append( + f'' + ) + + # Точки на вершинах + for x, y in data_pts: + lines.append(f'') + + # Подписи + 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'{name}' + ) + lines.append( + f'{val}' + ) + + lines.append("") + 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}" diff --git a/route/response_crud.py b/route/response_crud.py index fc47c5e..944221e 100644 --- a/route/response_crud.py +++ b/route/response_crud.py @@ -8,6 +8,7 @@ from bd import make_engine from bd.tables.poll import Poll from bd.tables.question import Question from bd.tables.response import Response, Answer +from bd.tables.scale import ScaleDimension router = APIRouter(tags=["responses"], prefix="/polls") @@ -77,7 +78,26 @@ def submit_response(poll_id: str, payload: ResponseIn): session.add(answer) session.commit() 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/") diff --git a/route/scale_crud.py b/route/scale_crud.py new file mode 100644 index 0000000..f1efb3c --- /dev/null +++ b/route/scale_crud.py @@ -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() diff --git a/web/api_client.py b/web/api_client.py index 7faa548..bae4c14 100644 --- a/web/api_client.py +++ b/web/api_client.py @@ -156,6 +156,30 @@ async def get_response(response_id: str) -> dict | 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( poll_id: str, answers: list[dict], diff --git a/web/radar_chart.py b/web/radar_chart.py new file mode 100644 index 0000000..a00766b --- /dev/null +++ b/web/radar_chart.py @@ -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, + ], + ) diff --git a/web/radar_svg.py b/web/radar_svg.py new file mode 100644 index 0000000..d59a810 --- /dev/null +++ b/web/radar_svg.py @@ -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'Нет данных' + + 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'', + # фон + f'', + ] + + # ── концентрические кольца (сетка) ────────────────────────────────────── + for lvl in range(1, 6): + pts = [axis_xy(i, lvl / 5) for i in range(n)] + lines.append( + f'' + ) + + # ── оси ───────────────────────────────────────────────────────────────── + for i in range(n): + x, y = axis_xy(i) + lines.append( + f'' + ) + + # ── полигон данных ─────────────────────────────────────────────────────── + data_pts = [axis_xy(i, min(it["value"] / safe_max, 1.0)) for i, it in enumerate(items)] + lines.append( + f'' + ) + + # ── точки на вершинах ──────────────────────────────────────────────────── + for x, y in data_pts: + lines.append(f'') + + # ── подписи осей ───────────────────────────────────────────────────────── + 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'{name}' + ) + # значение (белый) + lines.append( + f'{val}' + ) + + lines.append("") + 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}" diff --git a/web/views/content_users/poll_page.py b/web/views/content_users/poll_page.py index 11340e0..1c1d32f 100644 --- a/web/views/content_users/poll_page.py +++ b/web/views/content_users/poll_page.py @@ -1,6 +1,8 @@ +import base64 import flet as ft import designer as ds import api_client +from radar_chart import RadarChart class PollPage(ft.Column): @@ -157,27 +159,80 @@ class PollPage(ft.Column): self._error_text.update() else: 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 = [ ft.Container( - alignment=ft.Alignment(0, 0), - padding=40, + padding=20, content=ft.Column( alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=16, - 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}"), - ), - ], + controls=success_controls, ), ) ] diff --git a/web/views/content_users/response_page.py b/web/views/content_users/response_page.py index 94ac4ff..003d288 100644 --- a/web/views/content_users/response_page.py +++ b/web/views/content_users/response_page.py @@ -1,6 +1,8 @@ +import base64 import flet as ft import designer as ds import api_client +from radar_chart import RadarChart class ResponsePage(ft.Column): @@ -14,10 +16,7 @@ class ResponsePage(ft.Column): horizontal_alignment=ft.CrossAxisAlignment.STRETCH, spacing=8, controls=[ - ft.Row( - alignment=ft.MainAxisAlignment.CENTER, - controls=[self._loading], - ) + ft.Row(alignment=ft.MainAxisAlignment.CENTER, controls=[self._loading]) ], ) @@ -34,84 +33,106 @@ class ResponsePage(ft.Column): self.update() return - self._render(response, poll) + radar = await api_client.get_radar_result(self._response_id) + self._render(response, poll, radar) 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", [])} - 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", " ") - 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", []): q = questions_map.get(answer["question_id"]) if not q: continue - selected_text = choices_map.get(answer.get("choice_id") or "") or answer.get("text") or "—" - - # Варианты с подсветкой выбранного choice_rows = [] for c in q.get("choices", []): is_selected = c["id"] == answer.get("choice_id") - choice_rows.append( - ft.Container( - border_radius=8, - padding=ft.padding.symmetric(horizontal=12, vertical=6), - bgcolor=ds.clolors.laer1 if is_selected else ds.clolors.laer3, - content=ft.Row( - spacing=8, - controls=[ - 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), - ], + choice_rows.append(ft.Container( + border_radius=8, + padding=ft.padding.symmetric(horizontal=12, vertical=6), + bgcolor=ds.clolors.laer1 if is_selected else ds.clolors.laer3, + content=ft.Row(spacing=8, controls=[ + 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), + ]), + )) - answer_cards.append( - ft.Container( - border_radius=12, - padding=16, - bgcolor=ds.clolors.laer2, - content=ft.Column( - spacing=8, - controls=[ - ds.CastomText( - text=q.get("text", ""), - size=14, - weight=ft.FontWeight.BOLD, - ), - *choice_rows, - ], - ), - ) - ) + answer_cards.append(ft.Container( + border_radius=12, + padding=16, + bgcolor=ds.clolors.laer2, + content=ft.Column(spacing=8, controls=[ + ds.CastomText(text=q.get("text", ""), size=14, weight=ft.FontWeight.BOLD), + *choice_rows, + ]), + )) - self.controls = [ - 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), - ], - ), - ), - *answer_cards, - ] + if answer_cards: + controls.append(ds.CastomText(text="Ответы", size=16, weight=ft.FontWeight.BOLD)) + controls.extend(answer_cards) + + self.controls = controls