From 8a189e310b19f2730e65c62c19bbaf4434b7f52b Mon Sep 17 00:00:00 2001 From: jze9 Date: Thu, 2 Apr 2026 12:00:11 +0500 Subject: [PATCH] fix install diogram --- README.md | 493 ++++++++++++++++++++++- bd/__init__.py | 28 +- docker-compose.yml | 8 +- docker-compose_db.yml | 8 +- main.py | 5 +- nginx.conf | 35 ++ pyproject.toml | 2 +- route/klimov_crud.py | 293 ++++++++++++++ route/radar_crud.py | 64 +-- route/radar_svg_gen.py | 155 +++++-- web/api_client.py | 13 + web/designer.py | 26 +- web/main.py | 33 +- web/views/content_users/main_page.py | 21 +- web/views/content_users/my_tests_page.py | 1 + web/views/content_users/poll_page.py | 130 +++--- web/views/content_users/response_page.py | 46 +-- web/views/login_view.py | 11 +- web/views/poll_view.py | 1 + web/views/reg_view.py | 15 +- web/views/response_view.py | 1 + web/views/take_test_view.py | 1 + web/views/user_view.py | 11 +- 23 files changed, 1212 insertions(+), 189 deletions(-) create mode 100644 nginx.conf create mode 100644 route/klimov_crud.py diff --git a/README.md b/README.md index 3e33600..8b7ad8f 100644 --- a/README.md +++ b/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: " + +# Применить миграции (добавляет колонки, если БД уже была создана ранее) +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` | Абсолютный путь к директории приложения внутри контейнера | `/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 `. 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://: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. --- diff --git a/bd/__init__.py b/bd/__init__.py index c2bd00b..ba28834 100644 --- a/bd/__init__.py +++ b/bd/__init__.py @@ -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.") diff --git a/docker-compose.yml b/docker-compose.yml index 471bf47..d49cf7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker-compose_db.yml b/docker-compose_db.yml index ce25aa2..6f1379a 100644 --- a/docker-compose_db.yml +++ b/docker-compose_db.yml @@ -22,4 +22,10 @@ services: - POSTGRES_PASSWORD=postgres - PGDATA=/var/lib/postgresql/data/pgdata volumes: - - ./data/postgres:/var/lib/postgresql/data \ No newline at end of file + - ./data/postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s \ No newline at end of file diff --git a/main.py b/main.py index 7eab724..69453e3 100644 --- a/main.py +++ b/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) diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..423f705 --- /dev/null +++ b/nginx.conf @@ -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; + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 7ee223a..5326d4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/route/klimov_crud.py b/route/klimov_crud.py new file mode 100644 index 0000000..0d8bd3b --- /dev/null +++ b/route/klimov_crud.py @@ -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///.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///.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/." + ), + } diff --git a/route/radar_crud.py b/route/radar_crud.py index 6c4e255..efc7feb 100644 --- a/route/radar_crud.py +++ b/route/radar_crud.py @@ -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), diff --git a/route/radar_svg_gen.py b/route/radar_svg_gen.py index bb9c828..9fef4a6 100644 --- a/route/radar_svg_gen.py +++ b/route/radar_svg_gen.py @@ -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'' - f'' - f'Нет данных' ) + # ---- масштаб ---- + 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'', - f'', ] # Концентрические сетки - 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'' + 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'' + 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'{tick_val:.0f}' + ) + # Полигон данных data_pts = [axis_xy(i, min(it["value"] / safe_max, 1.0)) for i, it in enumerate(items)] lines.append( f'' + f'fill="rgba(180,140,240,0.35)" stroke="#A070D0" stroke-width="3.5"/>' ) # Точки на вершинах for x, y in data_pts: - lines.append(f'') + lines.append(f'') - # Подписи + # Подписи осей (с переносом длинных строк по ' — ' или ' ') + 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'{_xml_escape(part)}' + ) lines.append( - f'{name}' - ) - lines.append( - f'{val}' + f'{val}' ) lines.append("") @@ -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) diff --git a/web/api_client.py b/web/api_client.py index bae4c14..787bcfd 100644 --- a/web/api_client.py +++ b/web/api_client.py @@ -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 # --------------------------------------------------------------------------- diff --git a/web/designer.py b/web/designer.py index 5db05d1..5e85f93 100644 --- a/web/designer.py +++ b/web/designer.py @@ -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__( diff --git a/web/main.py b/web/main.py index 55e1491..e97fcfb 100644 --- a/web/main.py +++ b/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), + ] +) diff --git a/web/views/content_users/main_page.py b/web/views/content_users/main_page.py index 3c734d0..41e2980 100644 --- a/web/views/content_users/main_page.py +++ b/web/views/content_users/main_page.py @@ -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, diff --git a/web/views/content_users/my_tests_page.py b/web/views/content_users/my_tests_page.py index eadad22..2479614 100644 --- a/web/views/content_users/my_tests_page.py +++ b/web/views/content_users/my_tests_page.py @@ -64,6 +64,7 @@ class MyTestsPage(ft.Column): controls=[ ft.Column( spacing=4, + expand=True, controls=[ ds.CastomText( text=title, diff --git a/web/views/content_users/poll_page.py b/web/views/content_users/poll_page.py index 1c1d32f..91c1e3a 100644 --- a/web/views/content_users/poll_page.py +++ b/web/views/content_users/poll_page.py @@ -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, diff --git a/web/views/content_users/response_page.py b/web/views/content_users/response_page.py index 003d288..2db936f 100644 --- a/web/views/content_users/response_page.py +++ b/web/views/content_users/response_page.py @@ -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)), ]), )) diff --git a/web/views/login_view.py b/web/views/login_view.py index 553bd30..a35ca70 100644 --- a/web/views/login_view.py +++ b/web/views/login_view.py @@ -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, diff --git a/web/views/poll_view.py b/web/views/poll_view.py index 334b273..82c1264 100644 --- a/web/views/poll_view.py +++ b/web/views/poll_view.py @@ -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, diff --git a/web/views/reg_view.py b/web/views/reg_view.py index 26474e4..d50344d 100644 --- a/web/views/reg_view.py +++ b/web/views/reg_view.py @@ -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, diff --git a/web/views/response_view.py b/web/views/response_view.py index 84dc258..050eb58 100644 --- a/web/views/response_view.py +++ b/web/views/response_view.py @@ -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, diff --git a/web/views/take_test_view.py b/web/views/take_test_view.py index a251555..6b78654 100644 --- a/web/views/take_test_view.py +++ b/web/views/take_test_view.py @@ -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=[ diff --git a/web/views/user_view.py b/web/views/user_view.py index 1f51c2e..3ba300f 100644 --- a/web/views/user_view.py +++ b/web/views/user_view.py @@ -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, ] ),