fix install diogram
This commit is contained in:
493
README.md
493
README.md
@@ -2,6 +2,474 @@
|
||||
|
||||
Веб-приложение для создания, проведения и анализа психологических тестов. Включает API-сервер на FastAPI и пользовательский интерфейс на Flet. Поставляется в Docker и поддерживает готовый тест по методике профессиональной ориентации Голланда.
|
||||
|
||||
---
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Архитектура](#архитектура)
|
||||
- [Быстрый старт](#быстрый-старт)
|
||||
- [Переменные окружения](#переменные-окружения)
|
||||
- [Структура проекта](#структура-проекта)
|
||||
- [База данных](#база-данных)
|
||||
- [API](#api)
|
||||
- [Тест Голланда](#тест-голланда)
|
||||
- [Паутинная диаграмма](#паутинная-диаграмма)
|
||||
- [Фронтенд](#фронтенд)
|
||||
- [Деплой с внешним прокси](#деплой-с-внешним-прокси)
|
||||
- [Известные ошибки и просчёты](#известные-ошибки-и-просчёты)
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Пользователь (браузер)
|
||||
│
|
||||
│ :80
|
||||
[ Внешний прокси / nginx ] ← необязателен для локальной сети
|
||||
│
|
||||
│ :80
|
||||
[ web — Flet ASGI ] ← единственный публичный сервис
|
||||
│
|
||||
│ http://api:8000 (только внутри Docker-сети)
|
||||
[ api — FastAPI ]
|
||||
│ │
|
||||
[ PostgreSQL :5432 ] [ Redis :6379 ]
|
||||
```
|
||||
|
||||
**Важно:** сервис `api` не публикует порты наружу — он доступен только контейнеру `web` внутри Docker-сети. Пользователь никогда не обращается к API напрямую.
|
||||
|
||||
**Сетевая топология Docker:** PostgreSQL и Redis запускаются отдельным compose-файлом `docker-compose_db.yml` и подключаются к той же Docker network.
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Инфраструктура (PostgreSQL + Redis)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose_db.yml up -d
|
||||
```
|
||||
|
||||
### 2. Приложение (API + Web)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
После запуска:
|
||||
|
||||
| Адрес | Что |
|
||||
|---|---|
|
||||
| `http://localhost` | Веб-интерфейс (Flet) |
|
||||
| `http://localhost:8000/docs` | Swagger UI — **только с хоста сервера**, порт не опубликован |
|
||||
| `http://localhost:8000/redoc` | ReDoc |
|
||||
|
||||
> В продакшне API недоступен снаружи. Для работы с `/docs` используйте SSH-туннель:
|
||||
> ```bash
|
||||
> ssh -L 8000:localhost:8000 user@your-server
|
||||
> ```
|
||||
|
||||
Доступ к документации — логин/пароль из переменных `DOCS_USERNAME` / `DOCS_PASSWORD`.
|
||||
|
||||
### 3. Инициализация БД
|
||||
|
||||
После первого запуска выполните (с хоста сервера или через туннель):
|
||||
|
||||
```bash
|
||||
# Создать таблицы
|
||||
curl -X POST http://localhost:8000/db/create-tables \
|
||||
-H "X-Admin-Key: <ADMIN_KEY>"
|
||||
|
||||
# Применить миграции (добавляет колонки, если БД уже была создана ранее)
|
||||
curl -X POST http://localhost:8000/db/migrate \
|
||||
-H "X-Admin-Key: <ADMIN_KEY>"
|
||||
```
|
||||
|
||||
### 4. Загрузка теста Голланда
|
||||
|
||||
```bash
|
||||
# Создать тест (возвращает poll_id)
|
||||
curl -X POST http://localhost:8000/polls/holland \
|
||||
-H "X-Admin-Key: <ADMIN_KEY>"
|
||||
|
||||
# Загрузить ключ расшифровки (42 пары → 6 типов × 14 баллов)
|
||||
curl -X POST http://localhost:8000/polls/holland/<poll_id>/seed-scores \
|
||||
-H "X-Admin-Key: <ADMIN_KEY>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
> **Важно:** перед деплоем в продакшн обязательно смените `SECRET_KEY`, `ADMIN_KEY`, `DOCS_PASSWORD` и пароли БД.
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
|---|---|---|
|
||||
| `APP_PATH` | Абсолютный путь к директории приложения внутри контейнера | `/app` |
|
||||
| `DB_HOST` | Хост PostgreSQL | `postgres` |
|
||||
| `DB_PORT` | Порт PostgreSQL | `5432` |
|
||||
| `DB_USER` | Пользователь БД | `postgres` |
|
||||
| `DB_PASS` | Пароль БД | `postgres` |
|
||||
| `DB_NAME` | Имя базы данных | `profi` |
|
||||
| `REDIS_HOST` | Хост Redis | `redis` |
|
||||
| `REDIS_PORT` | Порт Redis | `6379` |
|
||||
| `REDIS_DB` | Номер БД Redis | `0` |
|
||||
| `REDIS_PASSWORD` | Пароль Redis | — |
|
||||
| `SECRET_KEY` | Секрет для подписи JWT-токенов (min 32 символа) | `python -c "import secrets; print(secrets.token_hex(32))"` |
|
||||
| `ADMIN_KEY` | Ключ для защищённых admin-эндпоинтов | — |
|
||||
| `DOCS_USERNAME` | Логин для доступа к `/docs` и `/redoc` | `admin` |
|
||||
| `DOCS_PASSWORD` | Пароль для доступа к `/docs` и `/redoc` | — |
|
||||
| `API_URL` | Внутренний URL API для фронтенда (только Docker-сеть) | `http://api:8000` |
|
||||
|
||||
Файл `docker-compose.yml` содержит **демо-значения** — не используйте их в продакшн.
|
||||
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
api-copp/
|
||||
├── main.py # точка входа FastAPI, регистрация роутеров
|
||||
├── requirements.txt # зависимости API
|
||||
├── pyproject.toml # метаданные проекта
|
||||
├── Dockerfile.api # образ API (python:3.14-slim)
|
||||
├── Dockerfile.web # образ фронтенда (python:3.11-slim + flet), порт 80
|
||||
├── docker-compose.yml # API (без портов) + Web (порт 80)
|
||||
├── docker-compose_db.yml # PostgreSQL + Redis
|
||||
├── nginx.conf # пример конфига nginx (для внешнего прокси)
|
||||
│
|
||||
├── bd/ # слой доступа к данным
|
||||
│ ├── __init__.py # Settings (pydantic), make_engine()
|
||||
│ └── tables/
|
||||
│ ├── simple_base.py # общий Base для SQLAlchemy
|
||||
│ ├── users.py # User
|
||||
│ ├── organization.py # Organization
|
||||
│ ├── group.py # Group
|
||||
│ ├── poll.py # Poll
|
||||
│ ├── question.py # Question
|
||||
│ ├── choice.py # Choice
|
||||
│ ├── response.py # Response, Answer
|
||||
│ ├── scale.py # ScaleDimension, ChoiceScore
|
||||
│ └── radar_result.py # RadarResult, RadarResultItem
|
||||
│
|
||||
├── route/ # FastAPI-роутеры
|
||||
│ ├── auth_utils.py # JWT, bcrypt, require_admin_key
|
||||
│ ├── auth.py # /auth/register, /auth/login
|
||||
│ ├── base.py # /health, /version
|
||||
│ ├── init_data_base.py # /db/create-tables, /db/migrate
|
||||
│ ├── users_crud.py # CRUD пользователей
|
||||
│ ├── groups_crud.py # CRUD групп
|
||||
│ ├── organizations_crud.py# CRUD организаций
|
||||
│ ├── poll_crud.py # CRUD опросов
|
||||
│ ├── question_crud.py # CRUD вопросов
|
||||
│ ├── choice_crud.py # CRUD вариантов ответов
|
||||
│ ├── response_crud.py # приём ответов + авто-расчёт радара
|
||||
│ ├── holland_crud.py # генерация теста Голланда + seed-scores
|
||||
│ ├── scale_crud.py # CRUD осей и расшифровок
|
||||
│ ├── radar_crud.py # расчёт и чтение радарных результатов
|
||||
│ └── radar_svg_gen.py # серверная генерация SVG-диаграмм
|
||||
│
|
||||
├── web/ # Flet-фронтенд
|
||||
│ ├── main.py # точка входа Flet ASGI
|
||||
│ ├── router.py # маршрутизация страниц
|
||||
│ ├── api_client.py # httpx-обёртки над API (API_URL из env)
|
||||
│ ├── designer.py # компоненты дизайн-системы
|
||||
│ ├── radar_chart.py # виджет RadarChart (flet.canvas)
|
||||
│ ├── radar_svg.py # генератор SVG-data-URI (на клиенте, не используется)
|
||||
│ └── views/
|
||||
│ ├── login_view.py
|
||||
│ ├── reg_view.py
|
||||
│ ├── user_view.py
|
||||
│ ├── profile_view.py
|
||||
│ ├── poll_view.py
|
||||
│ ├── response_view.py
|
||||
│ ├── my_tests_view.py
|
||||
│ ├── take_test_view.py
|
||||
│ └── content_users/
|
||||
│ ├── main_page.py # главная: «Все тесты» / «Мои тесты»
|
||||
│ ├── poll_page.py # прохождение теста (кастомные кнопки выбора)
|
||||
│ ├── response_page.py # результат с паутинной диаграммой
|
||||
│ ├── my_tests_page.py # список пройденных тестов
|
||||
│ ├── take_test_page.py # список доступных тестов
|
||||
│ └── profile_page.py # редактирование профиля
|
||||
│
|
||||
└── data/ # смонтированный том Docker (./data:/app/data)
|
||||
├── postgres/ # данные PostgreSQL
|
||||
└── radar/ # сохранённые SVG-файлы диаграмм
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## База данных
|
||||
|
||||
### Схема таблиц
|
||||
|
||||
```
|
||||
organizations ──< groups ──< users
|
||||
│
|
||||
polls ──< questions ──< choices ──< choice_scores
|
||||
│ │
|
||||
└──< responses ──< answers │
|
||||
│ │
|
||||
└──< radar_results ──< radar_result_items
|
||||
(image_path) └── (dimension_id FK → scale_dimensions)
|
||||
↑
|
||||
polls ──< scale_dimensions ──────────────────-─┘
|
||||
```
|
||||
|
||||
### Ключевые таблицы
|
||||
|
||||
| Таблица | Описание |
|
||||
|---|---|
|
||||
| `users` | Испытуемые: `username`, `hashed_password`, `first_name`, `last_name`, FK на `groups` и `organizations` |
|
||||
| `polls` | Тест/опрос: `title`, `description` |
|
||||
| `questions` | Вопросы теста с `type` (`single` / `multiple`) |
|
||||
| `choices` | Варианты ответов к вопросам |
|
||||
| `responses` | Один факт прохождения теста (user + poll + timestamp) |
|
||||
| `answers` | Конкретные ответы внутри прохождения |
|
||||
| `scale_dimensions` | Оси радарной диаграммы: `name`, `color`, `position`, FK на `polls` |
|
||||
| `choice_scores` | Расшифровка: какой вариант ответа сколько баллов вносит в какую ось |
|
||||
| `radar_results` | Итоговый результат по осям + `image_path` (путь к SVG-файлу) |
|
||||
| `radar_result_items` | Снимок значений по каждой оси на момент расчёта |
|
||||
|
||||
### Миграции
|
||||
|
||||
Таблицы создаются через `POST /db/create-tables`. Для уже существующих БД применяются дополнительные ALTER-миграции через `POST /db/migrate`. Текущие миграции:
|
||||
|
||||
- Добавление колонок `username` / `hashed_password` в `users`
|
||||
- Добавление колонки `image_path` в `radar_results`
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### Аутентификация
|
||||
|
||||
```
|
||||
POST /auth/register — регистрация (username ≥ 3 симв., password ≥ 8 симв.)
|
||||
POST /auth/login — получить JWT-токен (OAuth2 password flow)
|
||||
```
|
||||
|
||||
JWT-токен передаётся заголовком `Authorization: Bearer <token>`. Admin-операции требуют заголовок `X-Admin-Key`.
|
||||
|
||||
### Основные эндпоинты
|
||||
|
||||
```
|
||||
# Тесты
|
||||
GET /polls — список тестов
|
||||
POST /polls — создать тест
|
||||
GET /polls/{id} — тест с вопросами и вариантами
|
||||
DELETE /polls/{id} — удалить тест
|
||||
|
||||
# Вопросы / Варианты ответов
|
||||
POST /polls/{id}/questions
|
||||
POST /questions/{id}/choices
|
||||
|
||||
# Прохождение
|
||||
POST /polls/{poll_id}/responses — сдать тест (автоматически считает радар)
|
||||
GET /polls/users/{user_id}/responses — история пользователя
|
||||
GET /responses/{id} — конкретный результат
|
||||
|
||||
# Результаты (радар)
|
||||
POST /responses/{id}/radar/compute — (пере)считать радарный результат
|
||||
GET /responses/{id}/radar — данные для диаграммы
|
||||
GET /responses/{id}/radar/image — SVG-файл диаграммы
|
||||
GET /users/{user_id}/radar-results — история радарных результатов
|
||||
|
||||
# Оси и расшифровки
|
||||
POST /polls/{poll_id}/dimensions — создать ось
|
||||
GET /polls/{poll_id}/dimensions — список осей
|
||||
PUT /dimensions/{id}
|
||||
DELETE /dimensions/{id}
|
||||
POST /dimensions/{id}/scores — задать расшифровку варианта ответа
|
||||
GET /choices/{choice_id}/scores
|
||||
|
||||
# Тест Голланда
|
||||
POST /polls/holland — создать тест с 42 парами
|
||||
POST /polls/holland/{poll_id}/seed-scores — загрузить ключ расшифровки
|
||||
|
||||
# База данных (admin)
|
||||
POST /db/create-tables
|
||||
POST /db/migrate
|
||||
|
||||
# Служебные
|
||||
GET /health
|
||||
GET /version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Тест Голланда
|
||||
|
||||
Реализована **модификация Г.В. Резапкиной** теста профессиональной ориентации Дж. Голланда. 42 пары профессий, испытуемый выбирает предпочтительную в каждой паре.
|
||||
|
||||
### Типы личности
|
||||
|
||||
| Код | Название | Цвет |
|
||||
|---|---|---|
|
||||
| Р | Реалистичный | `#FF8C00` |
|
||||
| И | Интеллектуальный | `#4169E1` |
|
||||
| С | Социальный | `#32CD32` |
|
||||
| К | Конвенциональный | `#9370DB` |
|
||||
| П | Предприимчивый | `#DC143C` |
|
||||
| А | Артистический | `#FF69B4` |
|
||||
|
||||
Каждый тип получает ровно 14 вопросов (= 14 максимально возможных баллов). Результат — паутинная диаграмма с 6 осями.
|
||||
|
||||
### Процедура загрузки
|
||||
|
||||
1. `POST /polls/holland` — создаёт тест с 42 вопросами-парами
|
||||
2. `POST /polls/holland/{poll_id}/seed-scores` — создаёт 6 `ScaleDimension` и 84 `ChoiceScore`
|
||||
|
||||
---
|
||||
|
||||
## Паутинная диаграмма
|
||||
|
||||
### Как работает
|
||||
|
||||
При сдаче теста (`POST /polls/{id}/responses`) сервер автоматически:
|
||||
|
||||
1. Суммирует баллы по каждой оси (`ChoiceScore.score`) для выбранных вариантов
|
||||
2. Сохраняет результат в `radar_results` + `radar_result_items`
|
||||
3. Генерирует SVG-файл и сохраняет в `{APP_PATH}/data/radar/{result_id}.svg`
|
||||
4. Записывает относительный путь в `image_path` — всё в одном `commit()`
|
||||
|
||||
### Скачивание SVG
|
||||
|
||||
Фронтенд получает SVG-байты с API через внутреннюю Docker-сеть (`api_client.get_radar_svg_bytes()`), кодирует в `data:application/octet-stream;base64,...` и передаёт в `page.launch_url()`. Браузер скачивает файл **напрямую из data URI** — никаких запросов к API из браузера, никакой зависимости от публичности API.
|
||||
|
||||
### Генерация SVG
|
||||
|
||||
SVG генерируется чистым Python без зависимостей (`route/radar_svg_gen.py`):
|
||||
|
||||
- Фон `#2F184B`, полигон `rgba(155,114,207,0.55)`
|
||||
- 5 концентрических сеток
|
||||
- Подписи с именем типа и баллом
|
||||
- Размер холста: 520×520 пикселей
|
||||
|
||||
---
|
||||
|
||||
## Фронтенд
|
||||
|
||||
Написан на **Flet** — Python-фреймворке поверх Flutter. Работает как ASGI-приложение через uvicorn на порту 80.
|
||||
|
||||
### Адаптация под мобильные устройства
|
||||
|
||||
Интерфейс адаптирован для экранов с малым горизонтальным разрешением:
|
||||
|
||||
- Формы входа и регистрации растягиваются на всю ширину экрана (без фиксированной ширины `360px`)
|
||||
- Карточки навигации («Все тесты» / «Мои тесты») переносятся на следующую строку при нехватке места (`ft.Row(wrap=True)`)
|
||||
- Варианты ответов в тесте реализованы кастомными контейнерами вместо `ft.Radio` — текст длинных вариантов переносится на следующую строку
|
||||
- Заголовок страницы пользователя не выталкивает кнопку профиля
|
||||
|
||||
### Варианты ответов в тесте
|
||||
|
||||
Вместо стандартного `ft.RadioGroup` / `ft.Radio` (лейбл которого не переносится) используются кастомные кликабельные контейнеры (`poll_page.py: _build_choice_group`). При выборе варианта:
|
||||
- фон меняется с `laer3` → `laer1`
|
||||
- появляется белая рамка
|
||||
- иконка меняется с `RADIO_BUTTON_UNCHECKED` → `CHECK_CIRCLE`
|
||||
|
||||
### Маршруты
|
||||
|
||||
| Путь | Страница |
|
||||
|---|---|
|
||||
| `/login` | Вход |
|
||||
| `/reg` | Регистрация |
|
||||
| `/user/{url_key}` | Главная страница пользователя |
|
||||
| `/take_test/{url_key}` | Список тестов для прохождения |
|
||||
| `/poll/{url_key}/{poll_id}` | Прохождение теста |
|
||||
| `/response/{url_key}/{response_id}` | Результат с диаграммой |
|
||||
| `/my_tests/{url_key}` | Мои результаты |
|
||||
| `/profile/{url_key}` | Профиль пользователя |
|
||||
|
||||
`{url_key}` — одноразовый 32-символьный hex-ключ, генерируется при успешном входе (`secrets.token_hex(16)`). Обращение к защищённому маршруту с чужим ключом перенаправляет на `/login`.
|
||||
|
||||
---
|
||||
|
||||
## Деплой с внешним прокси
|
||||
|
||||
В репозитории есть готовый файл `nginx.conf` для случая, когда nginx развёрнут на отдельном сервере или уже управляет несколькими сайтами.
|
||||
|
||||
Минимальная конфигурация upstream в вашем nginx:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://<server-ip>:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
}
|
||||
```
|
||||
|
||||
Заголовки `Upgrade` / `Connection` обязательны — Flet использует WebSocket.
|
||||
|
||||
---
|
||||
|
||||
## Известные ошибки и просчёты
|
||||
|
||||
### 1. Лишнее дублирование генерации SVG
|
||||
|
||||
**Проблема:** SVG-генератор написан дважды — `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент).
|
||||
|
||||
**Решение:** Кнопка «Сохранить» скачивает SVG через `api_client.get_radar_svg_bytes()`. `web/radar_svg.py` больше не используется, но файл остался — можно удалить при следующем рефакторинге.
|
||||
|
||||
---
|
||||
|
||||
### 2. Несовместимость Python 3.14 и psycopg ✅ исправлено
|
||||
|
||||
**Проблема:** `pyproject.toml` указывал `psycopg>=3.3.3`, но реально используется синхронный `pg8000`.
|
||||
|
||||
**Решение:** Зависимость заменена на `pg8000>=1.29.0`.
|
||||
|
||||
---
|
||||
|
||||
### 3. Нет транзакции при сохранении SVG ✅ исправлено
|
||||
|
||||
**Проблема:** `image_path` обновлялся отдельным `commit()` после основного — при падении между ними запись оставалась без пути к файлу.
|
||||
|
||||
**Решение:** SVG генерируется до коммита, `image_path` устанавливается до коммита. Один атомарный `session.commit()`.
|
||||
|
||||
---
|
||||
|
||||
### 4. Перенос подписей в диаграмме по буквам ✅ исправлено
|
||||
|
||||
**Решение:** `_W_LBL` увеличен до 130, добавлен `max_lines=2, no_wrap=False`.
|
||||
|
||||
---
|
||||
|
||||
### 5. Текст вариантов ответов обрезался ✅ исправлено
|
||||
|
||||
**Проблема:** `ft.Radio(label=...)` в Flet не переносит текст метки — длинные варианты ответов обрезались.
|
||||
|
||||
**Решение:** Реализованы кастомные кликабельные контейнеры с `ft.Text(expand=True, no_wrap=False)`.
|
||||
|
||||
---
|
||||
|
||||
### 6. Браузер не может обратиться к `http://api:8000` ✅ исправлено
|
||||
|
||||
**Проблема:** Внутреннее Docker-имя `api` недоступно из браузера пользователя.
|
||||
|
||||
**Решение:** Фронтенд скачивает SVG через httpx (внутри Docker-сети) и передаёт браузеру как `data:application/octet-stream;base64,...`.
|
||||
|
||||
---
|
||||
|
||||
### 7. `image_path` добавлен после создания таблиц ✅ исправлено
|
||||
|
||||
**Решение:** Добавлена явная миграция:
|
||||
```sql
|
||||
ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Документация API доступна без авторизации ✅ исправлено
|
||||
|
||||
**Решение:** Стандартные маршруты `/docs`, `/redoc`, `/openapi.json` отключены. Зарегистрированы собственные с HTTP Basic Auth через `secrets.compare_digest`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Содержание
|
||||
@@ -350,11 +818,14 @@ SVG генерируется чистым Python без зависимостей
|
||||
|---|---|
|
||||
| `/login` | Вход |
|
||||
| `/reg` | Регистрация |
|
||||
| `/user/{url_key}` | Главная страница пользователя |
|
||||
| `/take_test/{url_key}` | Список тестов для прохождения |
|
||||
| `/poll/{poll_id}` | Прохождение теста |
|
||||
| `/response/{response_id}` | Результат с диаграммой |
|
||||
| `/my_tests` | Мои результаты |
|
||||
| `/profile` | Профиль пользователя |
|
||||
| `/poll/{url_key}/{poll_id}` | Прохождение теста |
|
||||
| `/response/{url_key}/{response_id}` | Результат с диаграммой |
|
||||
| `/my_tests/{url_key}` | Мои результаты |
|
||||
| `/profile/{url_key}` | Профиль пользователя |
|
||||
|
||||
`{url_key}` — одноразовый 32-символьный hex-ключ, генерируется при успешном входе (`secrets.token_hex(16)`) и сохраняется в сессии. Обращение к защищённому маршруту с чужим или отсутствующим ключом автоматически сбрасывает сессию и перенаправляет на `/login`.
|
||||
|
||||
### Диаграмма после теста
|
||||
|
||||
@@ -374,21 +845,21 @@ SVG генерируется чистым Python без зависимостей
|
||||
|
||||
---
|
||||
|
||||
### 2. Несовместимость Python 3.14 и psycopg
|
||||
### 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 драйвер.
|
||||
**Проблема:** `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`, которую следует убрать.
|
||||
**Решение:** Все запросы к БД синхронные (`make_engine()` возвращает синхронный `create_engine` с `postgresql+pg8000://...`). Фактически psycopg не используется. Ошибочная зависимость `psycopg` заменена на `pg8000>=1.29.0` в `pyproject.toml`.
|
||||
|
||||
---
|
||||
|
||||
### 3. Нет транзакции при сохранении SVG
|
||||
### 3. Нет транзакции при сохранении SVG ✅ исправлено
|
||||
|
||||
**Проблема:** После основного `session.commit()` (сохранение `RadarResult` и `RadarResultItem`) идёт отдельное обновление `result.image_path`. Если процесс упадёт между commit и вторым commit — в БД окажется запись без `image_path`, но файл будет создан на диске.
|
||||
**Проблема:** После основного `session.commit()` (сохранение `RadarResult` и `RadarResultItem`) шло отдельное обновление `result.image_path`. Если процесс падал между первым и вторым commit — в БД оставалась запись без `image_path`, хотя файл уже существовал на диске.
|
||||
|
||||
**Последствие:** При повторном прохождении теста пересчёт (`compute_radar`) удаляет старый `RadarResult` и создаёт новый, что восстанавливает корректность. Но исторический файл `.svg` осиротеет на диске.
|
||||
**Последствие:** При повторном прохождении теста пересчёт (`compute_radar`) удалял старый `RadarResult` и создавал новый, что восстанавливало корректность. Но исторический файл `.svg` «осиротевал» на диске.
|
||||
|
||||
**Рекомендация:** Объединить записи + генерацию SVG в одну транзакцию с rollback-обработчиком, либо хранить SVG в БД как blob/base64.
|
||||
**Решение:** `items_data` теперь строится непосредственно из `scores_map`/`dimensions` (данные уже в памяти после `flush()`), SVG генерируется до коммита, `result.image_path` устанавливается до коммита. Итого один `session.commit()` атомарно сохраняет `RadarResult`, `RadarResultItem` и путь к SVG.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -43,4 +43,30 @@ def make_engine():
|
||||
"""Создаёт SQLAlchemy engine с правильными параметрами для pg8000."""
|
||||
from sqlalchemy import create_engine
|
||||
s = Settings()
|
||||
return create_engine(s.DATABASE_URL_syncpg, connect_args=s.PG8000_CONNECT_ARGS, future=True)
|
||||
return create_engine(
|
||||
s.DATABASE_URL_syncpg,
|
||||
connect_args=s.PG8000_CONNECT_ARGS,
|
||||
future=True,
|
||||
pool_pre_ping=True, # проверяет соединение перед каждым запросом
|
||||
)
|
||||
|
||||
|
||||
def wait_for_db(retries: int = 15, delay: float = 2.0) -> None:
|
||||
"""Ждёт, пока PostgreSQL станет доступен. Вызывается при старте приложения."""
|
||||
import time
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger("bd")
|
||||
engine = make_engine()
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
logger.info("PostgreSQL is ready.")
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("DB not ready (attempt %d/%d): %s", attempt, retries, exc)
|
||||
if attempt < retries:
|
||||
time.sleep(delay)
|
||||
raise RuntimeError("PostgreSQL did not become ready in time.")
|
||||
|
||||
@@ -3,10 +3,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# ports НЕ публикуются — API доступен только внутри Docker-сети
|
||||
environment:
|
||||
- APP_PATH=/home/user/api-copp
|
||||
- APP_PATH=/app
|
||||
- DEBUG=True
|
||||
|
||||
- DB_HOST=postgres
|
||||
@@ -28,7 +27,8 @@ services:
|
||||
- DOCS_USERNAME=admin
|
||||
- DOCS_PASSWORD=2cbae90b3d9f6710cf51354f
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./data/radar:/app/data/radar
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
|
||||
@@ -22,4 +22,10 @@ services:
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
5
main.py
5
main.py
@@ -6,8 +6,9 @@ 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, scale_crud, radar_crud
|
||||
from route import base, holland_crud, klimov_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 bd import wait_for_db
|
||||
import uvicorn
|
||||
|
||||
# Читаем версию из pyproject.toml
|
||||
@@ -69,6 +70,7 @@ 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(klimov_crud.router)
|
||||
app.include_router(scale_crud.router)
|
||||
app.include_router(radar_crud.router)
|
||||
|
||||
@@ -92,6 +94,7 @@ def get_openapi_schema(_: None = Depends(_verify_docs)):
|
||||
|
||||
|
||||
print(f"Application path: {APP_PATH}")
|
||||
wait_for_db()
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
35
nginx.conf
Normal file
35
nginx.conf
Normal file
@@ -0,0 +1,35 @@
|
||||
events {}
|
||||
|
||||
http {
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# API — проксируем /api/* → api:8000 (убираем префикс /api)
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Flet WebSocket
|
||||
location /ws {
|
||||
proxy_pass http://web:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# Всё остальное → веб-приложение
|
||||
location / {
|
||||
proxy_pass http://web:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ dependencies = [
|
||||
"python-dotenv>=1.2.1",
|
||||
"redis>=7.1.0",
|
||||
"sqlalchemy>=2.0.47",
|
||||
"psycopg>=3.3.3",
|
||||
"pg8000>=1.29.0",
|
||||
"flet>=0.82.2",
|
||||
"jose>=1.0.0",
|
||||
"passlib>=1.7.4",
|
||||
|
||||
293
route/klimov_crud.py
Normal file
293
route/klimov_crud.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Эндпоинты для работы с тестом ДДО Е.А. Климова.
|
||||
|
||||
POST /polls/klimov — создать опрос в БД (42 пары → нет, 20 пар)
|
||||
POST /polls/klimov/{id}/seed-scores — загрузить ключ расшифровки (5 типов)
|
||||
GET /polls/klimov — список тестов Климова
|
||||
|
||||
После прохождения (POST /polls/{id}/responses) диаграмма строится автоматически
|
||||
через response_crud → compute_radar и сохраняется в data/radar/<poll_title>/<user_id>/<result_id>.svg.
|
||||
Путь к файлу хранится в таблице radar_results.image_path.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
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")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 20 пар вопросов ДДО Е.А. Климова
|
||||
# Каждая пара: (вариант А, вариант Б)
|
||||
# ---------------------------------------------------------------------------
|
||||
PAIRS: list[tuple[str, str]] = [
|
||||
("Ухаживать за животными.", # 1а
|
||||
"Обслуживать машины, приборы (следить, регулировать)."), # 1б
|
||||
("Помогать больным людям, лечить их.", # 2а
|
||||
"Составлять таблицы, схемы, программы для вычислительных машин."), # 2б
|
||||
("Следить за качеством книжных иллюстраций, плакатов, художественных открыток, грампластинок.", # 3а
|
||||
"Следить за состоянием, развитием растений."), # 3б
|
||||
("Обрабатывать материалы (дерево, ткань, пластмассу, металл и т.п.).", # 4а
|
||||
"Доводить товары до потребителя (рекламировать, продавать)."), # 4б
|
||||
("Обсуждать научно-популярные книги, статьи.", # 5а
|
||||
"Обсуждать художественные книги (или пьесы, концерты)."), # 5б
|
||||
("Выращивать молодняк (животных какой-либо породы).", # 6а
|
||||
"Тренировать товарищей (или младших) в выполнении каких-либо действий (трудовых, учебных, спортивных)."), # 6б
|
||||
("Копировать рисунки, изображения (или настраивать музыкальные инструменты).", # 7а
|
||||
"Управлять каким-либо грузовым (подъёмным или транспортным) средством."), # 7б
|
||||
("Сообщать, разъяснять людям нужные им сведения (в справочном бюро, на экскурсии и т.д.).", # 8а
|
||||
"Художественно оформлять выставки, витрины (или участвовать в подготовке пьес, концертов)."), # 8б
|
||||
("Ремонтировать вещи, изделия (одежду, технику), жилище.", # 9а
|
||||
"Искать и исправлять ошибки в текстах, таблицах, рисунках."), # 9б
|
||||
("Лечить животных.", # 10а
|
||||
"Выполнять вычисления, расчёты."), # 10б
|
||||
("Выводить новые сорта растений.", # 11а
|
||||
"Конструировать, проектировать новые виды промышленных изделий (машины или одежду, дома, продукты питания и т.п.)."), # 11б
|
||||
("Разбирать споры, ссоры между людьми, убеждать, разъяснять, поощрять, наказывать.", # 12а
|
||||
"Разбираться в чертежах, схемах, таблицах (проверять, уточнять, приводить в порядок)."), # 12б
|
||||
("Наблюдать, изучать работу кружков художественной самодеятельности.", # 13а
|
||||
"Наблюдать, изучать жизнь микробов."), # 13б
|
||||
("Обсуждать, налаживать медицинские приборы, аппараты.", # 14а
|
||||
"Оказывать людям медицинскую помощь при ранениях, ушибах, ожогах и т.п."), # 14б
|
||||
("Составлять точные описания, отчёты о наблюдаемых явлениях, событиях, измеряемых объектах.", # 15а
|
||||
"Художественно описывать, изображать события (наблюдаемые или представляемые)."), # 15б
|
||||
("Делать лабораторные анализы в больнице.", # 16а
|
||||
"Принимать, осматривать больных, беседовать с ними, назначать лечение."), # 16б
|
||||
("Красить или расписывать стены помещений, поверхность изделий.", # 17а
|
||||
"Осуществлять монтаж зданий или сборку машин, приборов."), # 17б
|
||||
("Организовывать культпоходы сверстников или младших в театры, музеи, экскурсии, туристические походы и т.п.", # 18а
|
||||
"Играть на сцене, принимать участие в концертах."), # 18б
|
||||
("Изготовлять по чертежам детали, изделия (машины, одежду), строить здания.", # 19а
|
||||
"Заниматься черчением, копированием чертежей, карт."), # 19б
|
||||
("Вести борьбу с болезнями растений, с вредителями леса, сада.", # 20а
|
||||
"Работать на клавишных машинах."), # 20б
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ключ расшифровки: номер пары → (тип варианта А, тип варианта Б)
|
||||
# Типы: П — природа, Т — техника, Ч — человек, Х — худ. образ, З — знаки
|
||||
# Источник: лист ответов ДДО Е.А. Климова
|
||||
# ---------------------------------------------------------------------------
|
||||
CHOICE_KEY: dict[int, tuple[str, str]] = {
|
||||
1: ("П", "Т"),
|
||||
2: ("Ч", "З"),
|
||||
3: ("З", "П"), # 3а — знаковая, 3б — природа
|
||||
4: ("Т", "Ч"),
|
||||
5: ("З", "Х"),
|
||||
6: ("П", "Ч"),
|
||||
7: ("Х", "Т"),
|
||||
8: ("Ч", "Х"),
|
||||
9: ("Т", "З"),
|
||||
10: ("П", "З"),
|
||||
11: ("П", "Т"),
|
||||
12: ("Ч", "З"),
|
||||
13: ("Х", "П"),
|
||||
14: ("Т", "Ч"),
|
||||
15: ("З", "Х"),
|
||||
16: ("П", "Ч"),
|
||||
17: ("Х", "Т"),
|
||||
18: ("Ч", "Х"),
|
||||
19: ("Т", "З"),
|
||||
20: ("П", "З"),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5 типов профессий по Климову
|
||||
# ---------------------------------------------------------------------------
|
||||
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": "#8E44AD",
|
||||
"description": (
|
||||
"Творческие профессии. Требуются воображение, образное мышление, талант, трудолюбие. "
|
||||
"Примеры: художник, музыкант, актёр, архитектор, фотограф, дизайнер, писатель."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_session():
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
return sessionmaker(bind=make_engine(), future=True)
|
||||
|
||||
|
||||
@router.post("/klimov", status_code=201)
|
||||
def create_klimov_poll():
|
||||
"""
|
||||
Создаёт тест ДДО Е.А. Климова в базе данных (20 пар вопросов, 2 варианта каждый).
|
||||
После создания вызовите POST /polls/klimov/{id}/seed-scores для загрузки ключа.
|
||||
"""
|
||||
Session = get_session()
|
||||
with Session() as session:
|
||||
poll = Poll(
|
||||
title="ДДО Е.А. Климова — определение типа профессии",
|
||||
description=(
|
||||
"Дифференциально-диагностический опросник Е.А. Климова: выберите из каждой пары "
|
||||
"предпочтительный вид занятий. 20 пар — выявляет ориентацию на 5 типов профессий: "
|
||||
"«Человек — природа», «Человек — техника», «Человек — человек», "
|
||||
"«Человек — знаковая система», «Человек — художественный образ»."
|
||||
),
|
||||
)
|
||||
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)
|
||||
session.add(ch1)
|
||||
session.add(ch2)
|
||||
session.commit()
|
||||
session.refresh(poll)
|
||||
return {"id": str(poll.id), "questions": len(poll.questions)}
|
||||
|
||||
|
||||
@router.get("/klimov")
|
||||
def list_klimov_polls():
|
||||
"""Возвращает список тестов ДДО Климова."""
|
||||
Session = get_session()
|
||||
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("/klimov/{poll_id}/seed-scores", status_code=201)
|
||||
def seed_klimov_scores(poll_id: str):
|
||||
"""
|
||||
Загружает расшифровку ДДО Климова в базу данных:
|
||||
- создаёт 5 осей диаграммы (ScaleDimension) для указанного теста
|
||||
- для каждого из 20 вопросов привязывает оба варианта ответа к своей оси (ChoiceScore)
|
||||
|
||||
При повторном вызове оси и расшифровки пересоздаются.
|
||||
Диаграмма строится автоматически после прохождения теста (POST /polls/{id}/responses)
|
||||
и сохраняется в data/radar/<poll_title>/<user_id>/<result_id>.svg.
|
||||
"""
|
||||
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) != 20:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Ожидается 20 вопросов, найдено {len(questions)}. Убедитесь, что тест создан через POST /polls/klimov.",
|
||||
)
|
||||
|
||||
# Удаляем старые оси (и ChoiceScore через cascade)
|
||||
old_dims = session.query(ScaleDimension).filter(ScaleDimension.poll_id == pid).all()
|
||||
for d in old_dims:
|
||||
session.delete(d)
|
||||
session.flush()
|
||||
|
||||
# Создаём 5 осей: код → объект
|
||||
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..20
|
||||
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": (
|
||||
"Расшифровка ДДО Климова успешно загружена. "
|
||||
"После прохождения теста диаграмма строится автоматически и "
|
||||
"сохраняется в data/radar/."
|
||||
),
|
||||
}
|
||||
@@ -24,6 +24,7 @@ from sqlalchemy.orm import Session as SASession
|
||||
|
||||
from bd import make_engine
|
||||
from bd.tables.response import Response, Answer
|
||||
from bd.tables.poll import Poll
|
||||
from bd.tables.scale import ScaleDimension, ChoiceScore
|
||||
from bd.tables.radar_result import RadarResult, RadarResultItem
|
||||
from route.radar_svg_gen import save_radar_svg
|
||||
@@ -110,7 +111,8 @@ def compute_radar(response_id: uuid.UUID, session: SASession) -> RadarResult:
|
||||
session.add(result)
|
||||
session.flush() # чтобы получить result.id до добавления items
|
||||
|
||||
for dim in sorted(dimensions, key=lambda d: (d.position is None, d.position)):
|
||||
dims_sorted = sorted(dimensions, key=lambda d: (d.position is None, d.position))
|
||||
for dim in dims_sorted:
|
||||
item = RadarResultItem(
|
||||
result_id=result.id,
|
||||
dimension_id=dim.id,
|
||||
@@ -121,29 +123,35 @@ def compute_radar(response_id: uuid.UUID, session: SASession) -> RadarResult:
|
||||
)
|
||||
session.add(item)
|
||||
|
||||
session.commit()
|
||||
session.refresh(result)
|
||||
|
||||
# Сохраняем SVG-файл на сервере
|
||||
# Генерируем SVG до коммита — result.id уже известен после flush(),
|
||||
# items_data строим из scores_map/dimensions (они в памяти, не из БД).
|
||||
# Так result + items + image_path фиксируются в одной транзакции.
|
||||
items_data = [
|
||||
{
|
||||
"dimension_name": item.dimension_name,
|
||||
"dimension_color": item.dimension_color,
|
||||
"value": item.value,
|
||||
"dimension_name": dim.name,
|
||||
"dimension_color": dim.color,
|
||||
"value": scores_map[dim.id],
|
||||
}
|
||||
for item in sorted(result.items, key=lambda i: (i.dimension_position is None, i.dimension_position))
|
||||
for dim in dims_sorted
|
||||
]
|
||||
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)
|
||||
poll = session.get(Poll, resp.poll_id)
|
||||
poll_title = poll.title if poll else "unknown"
|
||||
user_id_str = str(resp.user_id) if resp.user_id else "anonymous"
|
||||
rel_path = save_radar_svg(
|
||||
str(result.id), items_data,
|
||||
max_val=0.0, # автомасштаб: шкала строится от данных
|
||||
poll_title=poll_title,
|
||||
user_id=user_id_str,
|
||||
scale_padding=0.2, # +20% запаса над максимумом
|
||||
min_scale=5.0, # шкала не менее 5 даже для малых значений
|
||||
)
|
||||
result.image_path = rel_path
|
||||
except Exception:
|
||||
pass # не ломаем сдачу, если запись файла не удалась
|
||||
pass # не ломаем сдачу, если генерация файла не удалась
|
||||
|
||||
session.commit() # единственный коммит: result + items + image_path
|
||||
session.refresh(result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -186,7 +194,7 @@ def get_radar_result(response_id: str):
|
||||
|
||||
@router.get("/responses/{response_id}/radar/image")
|
||||
def get_radar_image(response_id: str):
|
||||
"""Отдаёт SVG-файл диаграммы для скачивания."""
|
||||
"""Отдаёт SVG-файл диаграммы. Если файл отсутствует — пересчитывает автоматически."""
|
||||
Session = get_session()
|
||||
try:
|
||||
rid = uuid.UUID(response_id)
|
||||
@@ -198,16 +206,21 @@ def get_radar_image(response_id: str):
|
||||
.filter(RadarResult.response_id == rid)
|
||||
.first()
|
||||
)
|
||||
if not result or not result.image_path:
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Radar result not found. Submit the response first.")
|
||||
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")
|
||||
full_path = os.path.join(app_path, result.image_path) if result.image_path else None
|
||||
# Файл удалён или отсутствует — пересчитываем с актуальным алгоритмом
|
||||
if not full_path or not os.path.isfile(full_path):
|
||||
result = compute_radar(rid, session)
|
||||
full_path = os.path.join(app_path, result.image_path) if result.image_path else None
|
||||
if not full_path or not os.path.isfile(full_path):
|
||||
raise HTTPException(status_code=500, detail="Failed to generate radar image")
|
||||
return FileResponse(
|
||||
full_path,
|
||||
media_type="image/svg+xml",
|
||||
filename=f"radar_{response_id}.svg",
|
||||
headers={"Content-Disposition": f'attachment; filename="radar_{response_id}.svg"'},
|
||||
)
|
||||
|
||||
|
||||
@@ -237,9 +250,8 @@ def get_user_radar_history(user_id: str):
|
||||
|
||||
|
||||
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"
|
||||
# image_url возвращаем всегда — endpoint пересоздаст SVG если файл отсутствует
|
||||
image_url = f"/responses/{result.response_id}/radar/image"
|
||||
return RadarResultOut(
|
||||
id=str(result.id),
|
||||
response_id=str(result.response_id),
|
||||
|
||||
@@ -8,27 +8,46 @@ import os
|
||||
import base64
|
||||
|
||||
|
||||
def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520) -> str:
|
||||
def generate_radar_svg(
|
||||
items: list[dict],
|
||||
max_val: float = 0.0,
|
||||
size: int = 520,
|
||||
scale_padding: float = 0.2,
|
||||
min_scale: float = 5.0,
|
||||
grid_levels: int = 5,
|
||||
show_scale_labels: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Возвращает строку SVG паутинной диаграммы.
|
||||
|
||||
items — список осей: [{dimension_name, dimension_color, value}, ...]
|
||||
max_val — максимум шкалы (14 для теста Голланда)
|
||||
size — размер холста в пикселях
|
||||
items — список осей: [{dimension_name, dimension_color, value}, ...]
|
||||
max_val — максимум шкалы; 0 = авто (вычисляется из items)
|
||||
size — размер холста в пикселях
|
||||
scale_padding — запас над максимальным значением (доля, 0.2 = +20%)
|
||||
min_scale — минимальное значение шкалы (чтобы маленькие значения не выглядели огромными)
|
||||
grid_levels — количество концентрических колец
|
||||
show_scale_labels — рисовать ли подписи значений на одной из осей
|
||||
"""
|
||||
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'<text x="{size//2}" y="{size//2}" text-anchor="middle" fill="#333" '
|
||||
f'font-family="Arial,sans-serif" font-size="16">Нет данных</text></svg>'
|
||||
)
|
||||
|
||||
# ---- масштаб ----
|
||||
data_max = max((it["value"] for it in items), default=1.0)
|
||||
if max_val <= 0:
|
||||
max_val = data_max
|
||||
safe_max = max(max_val * (1.0 + scale_padding), min_scale, 1.0)
|
||||
|
||||
# ---- геометрия ----
|
||||
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
|
||||
# Радиус сетки: оставляем место для подписей со всех сторон
|
||||
lbl_margin = size * 0.25 # запас под подписи
|
||||
R = (size / 2) - lbl_margin # сетка заполняет максимум пространства
|
||||
lbl_r = R + size * 0.06 # подписи чуть дальше от сетки
|
||||
|
||||
def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]:
|
||||
a = math.radians(-90.0 + 360.0 * i / n)
|
||||
@@ -39,15 +58,16 @@ def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520
|
||||
|
||||
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)]
|
||||
for lvl in range(1, grid_levels + 1):
|
||||
frac = lvl / grid_levels
|
||||
pts = [axis_xy(i, frac) for i in range(n)]
|
||||
alpha = 0.28 + 0.14 * (lvl / grid_levels) # внешние кольца чуть ярче
|
||||
lines.append(
|
||||
f'<polygon points="{poly_pts(pts)}" '
|
||||
f'fill="none" stroke="rgba(244,239,250,0.18)" stroke-width="1"/>'
|
||||
f'fill="none" stroke="rgba(170,130,230,{alpha:.2f})" stroke-width="1.2"/>'
|
||||
)
|
||||
|
||||
# Оси от центра
|
||||
@@ -55,38 +75,57 @@ def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520
|
||||
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"/>'
|
||||
f'stroke="rgba(170,130,230,0.45)" stroke-width="1.2"/>'
|
||||
)
|
||||
|
||||
# Подписи значений на первой оси (вертикально вверх)
|
||||
if show_scale_labels:
|
||||
for lvl in range(1, grid_levels + 1):
|
||||
frac = lvl / grid_levels
|
||||
tick_val = safe_max * frac
|
||||
tx, ty = axis_xy(0, frac)
|
||||
lines.append(
|
||||
f'<text x="{tx + 4:.2f}" y="{ty:.2f}" '
|
||||
f'font-family="Arial,sans-serif" font-size="10" '
|
||||
f'fill="#A070D0">{tick_val:.0f}</text>'
|
||||
)
|
||||
|
||||
# Полигон данных
|
||||
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"/>'
|
||||
f'fill="rgba(180,140,240,0.35)" stroke="#A070D0" stroke-width="3.5"/>'
|
||||
)
|
||||
|
||||
# Точки на вершинах
|
||||
for x, y in data_pts:
|
||||
lines.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="5" fill="#F4EFFA"/>')
|
||||
lines.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="6" fill="#A070D0"/>')
|
||||
|
||||
# Подписи
|
||||
# Подписи осей (с переносом длинных строк по ' — ' или ' ')
|
||||
font_size = max(11, min(14, int(size / 40)))
|
||||
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"])
|
||||
col = item.get("dimension_color") or "#2F184B"
|
||||
val = int(item["value"])
|
||||
|
||||
word_lines = _wrap_label(item["dimension_name"])
|
||||
# Вертикальное центрирование: сдвигаем вверх на половину суммарной высоты
|
||||
line_h = font_size + 3
|
||||
total_h = line_h * len(word_lines) + (font_size + 2) # +строка со значением
|
||||
y0 = ly - total_h / 2 + line_h
|
||||
|
||||
for k, part in enumerate(word_lines):
|
||||
lines.append(
|
||||
f'<text x="{lx:.2f}" y="{y0 + k * line_h:.2f}" text-anchor="middle" '
|
||||
f'font-family="Arial,Helvetica,sans-serif" font-size="{font_size}" '
|
||||
f'font-weight="bold" fill="{col}">{_xml_escape(part)}</text>'
|
||||
)
|
||||
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>'
|
||||
f'<text x="{lx:.2f}" y="{y0 + len(word_lines) * line_h:.2f}" '
|
||||
f'text-anchor="middle" font-family="Arial,Helvetica,sans-serif" '
|
||||
f'font-size="{font_size + 2}" fill="#A070D0">{val}</text>'
|
||||
)
|
||||
|
||||
lines.append("</svg>")
|
||||
@@ -97,22 +136,66 @@ 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:
|
||||
def _wrap_label(text: str, max_chars: int = 14) -> list[str]:
|
||||
"""
|
||||
Разбивает длинные подписи осей на строки.
|
||||
Сначала пробует разрезать по ' — ', затем по пробелам.
|
||||
"""
|
||||
if len(text) <= max_chars:
|
||||
return [text]
|
||||
# разрезаем по длинному тире
|
||||
if " — " in text:
|
||||
parts = text.split(" — ", 1)
|
||||
return [parts[0] + " —", parts[1]]
|
||||
# разрезаем по последнему пробелу ≤ max_chars
|
||||
idx = text.rfind(" ", 0, max_chars)
|
||||
if idx == -1:
|
||||
return [text]
|
||||
return [text[:idx], text[idx + 1:]]
|
||||
|
||||
|
||||
def save_radar_svg(
|
||||
result_id: str,
|
||||
items: list[dict],
|
||||
max_val: float = 0.0,
|
||||
poll_title: str = "unknown",
|
||||
user_id: str = "anonymous",
|
||||
size: int = 520,
|
||||
scale_padding: float = 0.2,
|
||||
min_scale: float = 5.0,
|
||||
) -> str:
|
||||
"""
|
||||
Генерирует SVG и сохраняет его на диск.
|
||||
Возвращает относительный путь к файлу (относительно APP_PATH).
|
||||
|
||||
Файл пишется в: {APP_PATH}/data/radar/{result_id}.svg
|
||||
Возвращает: data/radar/{result_id}.svg
|
||||
Файл пишется в: {APP_PATH}/data/radar/{poll_title}/{user_id}/{result_id}.svg
|
||||
Возвращает: data/radar/{poll_title}/{user_id}/{result_id}.svg
|
||||
|
||||
max_val=0 — автомасштаб (рекомендуется)
|
||||
scale_padding — запас над максимумом (0.2 = +20%)
|
||||
min_scale — нижняя граница шкалы
|
||||
"""
|
||||
import re
|
||||
|
||||
def _safe(s: str) -> str:
|
||||
s = re.sub(r"[^\w\-. ]", "_", s, flags=re.UNICODE)
|
||||
return s.strip("_. ").replace(" ", "_") or "_"
|
||||
|
||||
app_path = os.getenv("APP_PATH", "/app")
|
||||
radar_dir = os.path.join(app_path, "data", "radar")
|
||||
os.makedirs(radar_dir, exist_ok=True)
|
||||
rel_dir = os.path.join("data", "radar", _safe(poll_title), str(user_id))
|
||||
full_dir = os.path.join(app_path, rel_dir)
|
||||
os.makedirs(full_dir, exist_ok=True)
|
||||
|
||||
filename = f"{result_id}.svg"
|
||||
full_path = os.path.join(radar_dir, filename)
|
||||
svg = generate_radar_svg(items, max_val)
|
||||
full_path = os.path.join(full_dir, filename)
|
||||
svg = generate_radar_svg(
|
||||
items,
|
||||
max_val=max_val,
|
||||
size=size,
|
||||
scale_padding=scale_padding,
|
||||
min_scale=min_scale,
|
||||
)
|
||||
with open(full_path, "w", encoding="utf-8") as f:
|
||||
f.write(svg)
|
||||
|
||||
return f"data/radar/{filename}"
|
||||
return os.path.join(rel_dir, filename)
|
||||
|
||||
@@ -4,6 +4,7 @@ api_client.py — единое место для всех HTTP-запросов
|
||||
"""
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -11,6 +12,18 @@ API_URL = os.getenv("API_URL", "http://api:8000")
|
||||
_TIMEOUT = 10
|
||||
|
||||
|
||||
def build_radar_download_url(page_url: str, response_id: str) -> str:
|
||||
"""
|
||||
Строит URL для скачивания SVG-диаграммы через веб-сервер (/radar-image/{id}).
|
||||
page_url — значение page.url из Flet (может быть ws:// или wss://).
|
||||
"""
|
||||
parsed = urlparse(page_url)
|
||||
scheme = "https" if parsed.scheme in ("wss", "https") else "http"
|
||||
port = parsed.port
|
||||
port_str = f":{port}" if port and port not in (80, 443) else ""
|
||||
return f"{scheme}://{parsed.hostname}{port_str}/radar-image/{response_id}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -13,7 +13,8 @@ class CastomText(ft.Text):
|
||||
value=text,
|
||||
color=color,
|
||||
size=size,
|
||||
weight=weight
|
||||
weight=weight,
|
||||
max_lines=99,
|
||||
)
|
||||
|
||||
|
||||
@@ -48,7 +49,7 @@ class CastomTextField_input(ft.TextField):
|
||||
class CastomButton(ft.Button):
|
||||
def __init__(self, text, on_click):
|
||||
super().__init__(
|
||||
text,
|
||||
content=text,
|
||||
style=ft.ButtonStyle(
|
||||
color=clolors.ferst,
|
||||
bgcolor=clolors.laer1,
|
||||
@@ -59,6 +60,27 @@ class CastomButton(ft.Button):
|
||||
on_click=on_click
|
||||
)
|
||||
|
||||
|
||||
class CastomIconButton(ft.Button):
|
||||
"""Кнопка с иконкой и текстом."""
|
||||
def __init__(self, text, icon, on_click):
|
||||
super().__init__(
|
||||
content=ft.Row(
|
||||
spacing=8,
|
||||
tight=True,
|
||||
controls=[
|
||||
ft.Icon(icon, color=clolors.ferst, size=18),
|
||||
ft.Text(text, color=clolors.ferst, size=14),
|
||||
],
|
||||
),
|
||||
style=ft.ButtonStyle(
|
||||
color=clolors.ferst,
|
||||
bgcolor=clolors.laer2,
|
||||
text_style=ft.TextStyle(size=14),
|
||||
),
|
||||
on_click=on_click,
|
||||
)
|
||||
|
||||
class CastomDropdown(ft.Dropdown):
|
||||
def __init__(self, label="", hint_text="", options=None, on_change=None):
|
||||
super().__init__(
|
||||
|
||||
33
web/main.py
33
web/main.py
@@ -1,6 +1,30 @@
|
||||
import os
|
||||
import flet as ft
|
||||
import httpx
|
||||
import designer as ds
|
||||
from router import setup_router
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route, Mount
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
_API_URL = os.getenv("API_URL", "http://api:8000")
|
||||
|
||||
|
||||
async def _radar_image(request):
|
||||
"""Проксирует SVG-диаграмму с API — браузер скачивает с собственного хоста."""
|
||||
response_id = request.path_params["response_id"]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(f"{_API_URL}/responses/{response_id}/radar/image")
|
||||
if r.status_code == 200:
|
||||
return StarletteResponse(
|
||||
content=r.content,
|
||||
media_type="image/svg+xml",
|
||||
headers={"Content-Disposition": 'attachment; filename="diagram.svg"'},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return StarletteResponse(status_code=404)
|
||||
|
||||
|
||||
async def main(page: ft.Page):
|
||||
@@ -11,4 +35,11 @@ async def main(page: ft.Page):
|
||||
await page.push_route(initial)
|
||||
|
||||
|
||||
app = ft.run(main, export_asgi_app=True, assets_dir="assets")
|
||||
_flet_app = ft.run(main, export_asgi_app=True, assets_dir="assets")
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/radar-image/{response_id}", _radar_image),
|
||||
Mount("/", _flet_app),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -9,16 +9,19 @@ class MainPageUser(ft.Column):
|
||||
|
||||
super().__init__(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
spacing=self.spacing_groop,
|
||||
controls=[
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
spacing=self.spacing_groop,
|
||||
run_spacing=self.spacing_groop,
|
||||
wrap=True,
|
||||
controls=[
|
||||
ft.Container(
|
||||
border_radius=self.border_radius,
|
||||
height=200,
|
||||
width=200,
|
||||
height=160,
|
||||
width=160,
|
||||
alignment=ft.Alignment(0.0, 0.0),
|
||||
content=ft.Column(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
@@ -28,8 +31,8 @@ class MainPageUser(ft.Column):
|
||||
controls=[
|
||||
ft.Image(
|
||||
src="icons/clipboard-question_16542596.svg",
|
||||
width=120,
|
||||
height=120,
|
||||
width=100,
|
||||
height=100,
|
||||
)
|
||||
]
|
||||
),
|
||||
@@ -47,8 +50,8 @@ class MainPageUser(ft.Column):
|
||||
),
|
||||
ft.Container(
|
||||
border_radius=self.border_radius,
|
||||
height=200,
|
||||
width=200,
|
||||
height=160,
|
||||
width=160,
|
||||
alignment=ft.Alignment(0.0, 0.0),
|
||||
content=ft.Column(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
@@ -58,10 +61,10 @@ class MainPageUser(ft.Column):
|
||||
controls=[
|
||||
ft.Image(
|
||||
src="icons/leaderboard-trophy_14227576.svg",
|
||||
width=120,
|
||||
height=120,
|
||||
width=100,
|
||||
height=100,
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
|
||||
@@ -64,6 +64,7 @@ class MyTestsPage(ft.Column):
|
||||
controls=[
|
||||
ft.Column(
|
||||
spacing=4,
|
||||
expand=True,
|
||||
controls=[
|
||||
ds.CastomText(
|
||||
text=title,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import base64
|
||||
import flet as ft
|
||||
import base64
|
||||
import designer as ds
|
||||
import api_client
|
||||
from radar_chart import RadarChart
|
||||
|
||||
|
||||
class PollPage(ft.Column):
|
||||
@@ -38,43 +37,64 @@ class PollPage(ft.Column):
|
||||
self.controls = [ds.CastomText(text="Тест не найден", size=16, color="#FF6B6B")]
|
||||
self.update()
|
||||
|
||||
def _build_choice_group(self, choices: list, qid: str) -> ft.Column:
|
||||
"""Кастомные кнопки выбора с переносом текста вместо ft.Radio."""
|
||||
containers: list[ft.Container] = []
|
||||
|
||||
def make_tile(choice_id: str, choice_text: str) -> ft.Container:
|
||||
return ft.Container(
|
||||
border_radius=8,
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=10),
|
||||
bgcolor=ds.clolors.laer3,
|
||||
border=ft.border.all(2, ds.clolors.laer3),
|
||||
content=ft.Row(
|
||||
spacing=10,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
controls=[
|
||||
ft.Icon(
|
||||
ft.Icons.RADIO_BUTTON_UNCHECKED,
|
||||
color=ds.clolors.ferst,
|
||||
size=20,
|
||||
),
|
||||
ft.Text(
|
||||
value=choice_text,
|
||||
color=ds.clolors.ferst,
|
||||
size=14,
|
||||
expand=True,
|
||||
no_wrap=False,
|
||||
),
|
||||
],
|
||||
),
|
||||
data=choice_id,
|
||||
)
|
||||
|
||||
def on_tile_click(e, _qid=qid, _containers=containers):
|
||||
selected_id = e.control.data
|
||||
self._answers[_qid] = selected_id
|
||||
for tile in _containers:
|
||||
is_selected = tile.data == selected_id
|
||||
tile.bgcolor = ds.clolors.laer1 if is_selected else ds.clolors.laer3
|
||||
tile.border = ft.border.all(2, ds.clolors.ferst if is_selected else ds.clolors.laer3)
|
||||
row = tile.content
|
||||
row.controls[0].name = ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED
|
||||
tile.update()
|
||||
|
||||
for c in choices:
|
||||
tile = make_tile(c["id"], c["text"])
|
||||
tile.on_click = on_tile_click
|
||||
tile.ink = True
|
||||
containers.append(tile)
|
||||
|
||||
return ft.Column(controls=containers, spacing=6)
|
||||
|
||||
def _render_poll(self, poll: dict):
|
||||
self._poll = poll
|
||||
question_cards = []
|
||||
|
||||
for q in poll.get("questions", []):
|
||||
choices = q.get("choices", [])
|
||||
q_type = q.get("type", "single")
|
||||
|
||||
if q_type == "single":
|
||||
radio_controls = [
|
||||
ft.Radio(
|
||||
value=c["id"],
|
||||
label=c["text"],
|
||||
fill_color=ds.clolors.laer1,
|
||||
label_style=ft.TextStyle(color=ds.clolors.ferst, size=14),
|
||||
)
|
||||
for c in choices
|
||||
]
|
||||
input_widget = ft.RadioGroup(
|
||||
content=ft.Column(controls=radio_controls, spacing=4),
|
||||
on_change=lambda e, qid=q["id"]: self._on_single_answer(qid, e.control.value),
|
||||
)
|
||||
else:
|
||||
# multiple / text — показываем как single если нет multiple в API
|
||||
radio_controls = [
|
||||
ft.Radio(
|
||||
value=c["id"],
|
||||
label=c["text"],
|
||||
fill_color=ds.clolors.laer1,
|
||||
label_style=ft.TextStyle(color=ds.clolors.ferst, size=14),
|
||||
)
|
||||
for c in choices
|
||||
]
|
||||
input_widget = ft.RadioGroup(
|
||||
content=ft.Column(controls=radio_controls, spacing=4),
|
||||
on_change=lambda e, qid=q["id"]: self._on_single_answer(qid, e.control.value),
|
||||
)
|
||||
input_widget = self._build_choice_group(choices, q["id"])
|
||||
|
||||
card = ft.Container(
|
||||
border_radius=12,
|
||||
@@ -122,9 +142,6 @@ class PollPage(ft.Column):
|
||||
]
|
||||
self._submit_btn.visible = True
|
||||
|
||||
def _on_single_answer(self, question_id: str, choice_id: str):
|
||||
self._answers[question_id] = choice_id
|
||||
|
||||
async def _submit(self, e):
|
||||
poll = getattr(self, "_poll", None)
|
||||
if not poll:
|
||||
@@ -170,49 +187,41 @@ class PollPage(ft.Column):
|
||||
self.update()
|
||||
|
||||
radar = await api_client.get_radar_result(response_id)
|
||||
svg_bytes = await api_client.get_radar_svg_bytes(response_id) if radar and radar.get("image_url") else None
|
||||
|
||||
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)
|
||||
if svg_bytes and radar and radar.get("image_url"):
|
||||
b64 = base64.b64encode(svg_bytes).decode()
|
||||
data_uri = f"data:image/svg+xml;base64,{b64}"
|
||||
download_url = api_client.build_radar_download_url(self._page.url, response_id)
|
||||
|
||||
success_controls.append(
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
controls=[RadarChart(items, max_val)],
|
||||
ft.Container(
|
||||
expand=True,
|
||||
content=ft.Image(
|
||||
src=data_uri,
|
||||
expand=True,
|
||||
height=380,
|
||||
fit=ft.BoxFit.CONTAIN,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
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")
|
||||
async def _download(_evt, _url=download_url):
|
||||
await self._page.launch_url(_url)
|
||||
|
||||
success_controls.append(
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
controls=[
|
||||
ft.ElevatedButton(
|
||||
ds.CastomIconButton(
|
||||
text="Сохранить диаграмму",
|
||||
icon=ft.Icons.DOWNLOAD,
|
||||
on_click=_download,
|
||||
style=ft.ButtonStyle(
|
||||
color=ds.clolors.ferst,
|
||||
bgcolor=ds.clolors.laer2,
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -228,6 +237,9 @@ class PollPage(ft.Column):
|
||||
self.controls = [
|
||||
ft.Container(
|
||||
padding=20,
|
||||
bgcolor=ds.clolors.laer3,
|
||||
border_radius=16,
|
||||
expand=True,
|
||||
content=ft.Column(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import base64
|
||||
import flet as ft
|
||||
import base64
|
||||
import designer as ds
|
||||
import api_client
|
||||
from radar_chart import RadarChart
|
||||
|
||||
|
||||
class ResponsePage(ft.Column):
|
||||
@@ -34,10 +33,11 @@ class ResponsePage(ft.Column):
|
||||
return
|
||||
|
||||
radar = await api_client.get_radar_result(self._response_id)
|
||||
self._render(response, poll, radar)
|
||||
svg_bytes = await api_client.get_radar_svg_bytes(self._response_id) if radar and radar.get("image_url") else None
|
||||
self._render(response, poll, radar, svg_bytes)
|
||||
self.update()
|
||||
|
||||
def _render(self, response: dict, poll: dict, radar: dict | None):
|
||||
def _render(self, response: dict, poll: dict, radar: dict | None, svg_bytes: bytes | None = None):
|
||||
questions_map = {q["id"]: q for q in poll.get("questions", [])}
|
||||
submitted_at = response["submitted_at"][:19].replace("T", " ")
|
||||
|
||||
@@ -52,20 +52,13 @@ class ResponsePage(ft.Column):
|
||||
]
|
||||
|
||||
# ── Паутинная диаграмма ──────────────────────────────────────────────
|
||||
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)
|
||||
if svg_bytes and radar and radar.get("image_url"):
|
||||
b64 = base64.b64encode(svg_bytes).decode()
|
||||
data_uri = f"data:image/svg+xml;base64,{b64}"
|
||||
download_url = api_client.build_radar_download_url(self._page.url, self._response_id)
|
||||
|
||||
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")
|
||||
async def _download(_evt, _url=download_url):
|
||||
await self._page.launch_url(_url)
|
||||
|
||||
controls.append(ft.Container(
|
||||
border_radius=16,
|
||||
@@ -76,21 +69,22 @@ class ResponsePage(ft.Column):
|
||||
spacing=10,
|
||||
controls=[
|
||||
ds.CastomText(text="Результат", size=16, weight=ft.FontWeight.BOLD),
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
controls=[RadarChart(items, max_val)],
|
||||
ft.Container(
|
||||
expand=True,
|
||||
content=ft.Image(
|
||||
src=data_uri,
|
||||
expand=True,
|
||||
height=380,
|
||||
fit=ft.BoxFit.CONTAIN,
|
||||
),
|
||||
),
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
controls=[
|
||||
ft.ElevatedButton(
|
||||
ds.CastomIconButton(
|
||||
text="Сохранить диаграмму",
|
||||
icon=ft.Icons.DOWNLOAD,
|
||||
on_click=_download,
|
||||
style=ft.ButtonStyle(
|
||||
color=ds.clolors.ferst,
|
||||
bgcolor=ds.clolors.laer2,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -117,7 +111,7 @@ class ResponsePage(ft.Column):
|
||||
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),
|
||||
ft.Container(expand=True, content=ds.CastomText(text=c["text"], size=13)),
|
||||
]),
|
||||
))
|
||||
|
||||
|
||||
@@ -37,16 +37,19 @@ class LoginView(ft.View):
|
||||
super().__init__(
|
||||
route="/login",
|
||||
bgcolor=ds.clolors.background,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||
vertical_alignment=ft.MainAxisAlignment.CENTER,
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=24),
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
)
|
||||
self.controls = [
|
||||
ft.Text("COPP", color=ds.clolors.ferst, size=30, weight=ft.FontWeight.BOLD),
|
||||
ft.Text("COPP", color=ds.clolors.ferst, size=30, weight=ft.FontWeight.BOLD,
|
||||
text_align=ft.TextAlign.CENTER),
|
||||
ft.Container(height=8),
|
||||
ft.Text("Вход в систему", color=ds.clolors.ferst, size=16),
|
||||
ft.Text("Вход в систему", color=ds.clolors.ferst, size=16,
|
||||
text_align=ft.TextAlign.CENTER),
|
||||
ft.Container(height=24),
|
||||
ft.Container(
|
||||
width=360,
|
||||
padding=ft.padding.all(24),
|
||||
bgcolor=ds.clolors.laer3,
|
||||
border_radius=16,
|
||||
|
||||
@@ -18,6 +18,7 @@ class PollView(ft.View):
|
||||
super().__init__(
|
||||
route=f"/poll/{url_key}/{poll_id}",
|
||||
bgcolor=ds.clolors.background,
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=8),
|
||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||
vertical_alignment=ft.MainAxisAlignment.START,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
|
||||
@@ -61,22 +61,24 @@ class RegView(ft.View):
|
||||
super().__init__(
|
||||
route="/reg",
|
||||
bgcolor=ds.clolors.background,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||
vertical_alignment=ft.MainAxisAlignment.CENTER,
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=24),
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
)
|
||||
self.controls = [
|
||||
ft.Text("COPP", color=ds.clolors.ferst, size=30, weight=ft.FontWeight.BOLD),
|
||||
ft.Text("COPP", color=ds.clolors.ferst, size=30, weight=ft.FontWeight.BOLD,
|
||||
text_align=ft.TextAlign.CENTER),
|
||||
ft.Container(height=8),
|
||||
ft.Text("Регистрация в системе", color=ds.clolors.ferst, size=16),
|
||||
ft.Text("Регистрация в системе", color=ds.clolors.ferst, size=16,
|
||||
text_align=ft.TextAlign.CENTER),
|
||||
ft.Container(height=24),
|
||||
ft.Container(
|
||||
width=360,
|
||||
padding=ft.padding.all(24),
|
||||
bgcolor=ds.clolors.laer3,
|
||||
border_radius=16,
|
||||
content=ft.Column(
|
||||
spacing=16,
|
||||
scroll="auto",
|
||||
controls=[
|
||||
self._first_name,
|
||||
self._last_name,
|
||||
@@ -86,6 +88,9 @@ class RegView(ft.View):
|
||||
self._group_dropdown,
|
||||
self._error,
|
||||
ft.Row(
|
||||
wrap=True,
|
||||
spacing=8,
|
||||
run_spacing=8,
|
||||
controls=[
|
||||
self._btn_cansel,
|
||||
self._btn,
|
||||
|
||||
@@ -18,6 +18,7 @@ class ResponseView(ft.View):
|
||||
super().__init__(
|
||||
route=f"/response/{url_key}/{response_id}",
|
||||
bgcolor=ds.clolors.background,
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=8),
|
||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||
vertical_alignment=ft.MainAxisAlignment.START,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
|
||||
@@ -18,6 +18,7 @@ class TakeTestView(ft.View):
|
||||
super().__init__(
|
||||
route=f"/take_test/{url_key}",
|
||||
bgcolor=ds.clolors.background,
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=8),
|
||||
horizontal_alignment=ft.CrossAxisAlignment.START,
|
||||
vertical_alignment=ft.MainAxisAlignment.START,
|
||||
controls=[
|
||||
|
||||
@@ -20,8 +20,9 @@ class UserView(ft.View):
|
||||
super().__init__(
|
||||
route=f"/user/{url_key}",
|
||||
bgcolor=ds.clolors.background,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.START,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||
vertical_alignment=ft.MainAxisAlignment.START,
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=8),
|
||||
)
|
||||
self.controls = [
|
||||
ft.Column(
|
||||
@@ -31,7 +32,13 @@ class UserView(ft.View):
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
controls=[
|
||||
ds.CastomText(text="Добро пожаловать в COPP!", size=24, weight=ft.FontWeight.BOLD),
|
||||
ft.Text(
|
||||
"Добро пожаловать в COPP!",
|
||||
color=ds.clolors.ferst,
|
||||
size=20,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
expand=True,
|
||||
),
|
||||
profile_btn,
|
||||
]
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user