# api-copp Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. Включает REST API на FastAPI и пользовательский интерфейс на Flet. Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. --- ## Содержание - [Архитектура](#архитектура) - [Быстрый старт](#быстрый-старт) - [Переменные окружения](#переменные-окружения) - [Структура проекта](#структура-проекта) - [База данных](#база-данных) - [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 │ ├── glomshtok_crud.py # генерация теста Гломштока │ ├── klimov_crud.py # генерация теста Климова │ ├── 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 # дизайн-система: colors, компоненты │ ├── theme_manager.py # менеджер тем, сохранение в client_storage │ ├── radar_chart.py # виджет RadarChart (flet.canvas) │ ├── radar_svg.py # генератор SVG (устаревший, не используется) │ └── 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 # Готовые тесты (admin) POST /polls/holland — создать тест Голланда (42 пары) POST /polls/holland/{poll_id}/seed-scores — загрузить ключ расшифровки Голланда POST /polls/glomshtok — создать тест Гломштока POST /polls/klimov — создать тест Климова # База данных (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 из браузера. ### Генерация SVG SVG генерируется чистым Python без зависимостей (`route/radar_svg_gen.py`): - 5 концентрических сеток - Подписи с именем типа и баллом - Размер холста: 520×520 пикселей --- ## Фронтенд Написан на **Flet** — Python-фреймворке поверх Flutter. Работает как ASGI-приложение через uvicorn на порту 80. ### Маршруты | Путь | Страница | |---|---| | `/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`. ### Варианты ответов в тесте Вместо стандартного `ft.RadioGroup` / `ft.Radio` (лейбл которого не переносится) используются кастомные кликабельные контейнеры (`poll_page.py`). При выборе варианта: - фон меняется с `surface_alt` → `primary_light` - иконка меняется с `RADIO_BUTTON_UNCHECKED` → `CHECK_CIRCLE` --- ## Дизайн-система Все UI-компоненты сосредоточены в `web/designer.py`. Управление темой — в `web/theme_manager.py`. ### Глобальный масштаб ```python SCALE: float = 1.7 # 1.0 = 100%, меняйте только эту константу s(14) # → round(14 * 1.7) = 24px ``` ### Цветовая палитра (стиль copp74.ru) | Имя | HEX | Применение | |---|---|---| | `colors.primary` | `#F85A40` | Кнопки, акценты, логотип | | `colors.primary_dark` | `#D94530` | Hover-состояние кнопок | | `colors.primary_light` | `#FFEAB7` | Выбранный вариант ответа | | `colors.background` | `#F4F6F8` | Фон страниц | | `colors.surface` | `#FFFFFF` | Карточки, формы | | `colors.surface_alt` | `#F0F2F5` | Невыбранные варианты, значки | | `colors.accent` | `#FFC845` | Жёлтый акцент | | `colors.info` | `#007FBD` | Синие кнопки скачивания | | `colors.success` | `#85C446` | Зелёный (успех) | | `colors.text_primary` | `#1E2A3A` | Основной текст | | `colors.text_secondary` | `#5A6779` | Вторичный текст, плейсхолдеры | | `colors.text_on_primary` | `#FFFFFF` | Текст на оранжевых кнопках | | `colors.border` | `#DDE3EA` | Границы полей | ### Компоненты | Класс | Наследует | Описание | |---|---|---| | `CastomText` | `ft.Text` | Текст с масштабированием | | `CastomTextField_input` | `ft.TextField` | Поле ввода, белый фон, оранжевый фокус | | `CastomButton` | `ft.Button` | Оранжевая кнопка с белым текстом | | `CastomIconButton` | `ft.Button` | Синяя кнопка с иконкой | | `CastomDropdown` | `ft.Dropdown` | Выпадающий список | | `CastomSwitch` | `ft.Switch` | Переключатель | | `AlterDiaalogApproval` | `ft.AlertDialog` | Диалог пользовательского соглашения | ### Темы интерфейса Пользователь может выбрать тему в профиле. Тема сохраняется в `client_storage` браузера и восстанавливается при следующем входе. | ID | Название | Фон | |---|---|---| | `default` | ЦОПП (orange) | `#F4F6F8` | | `light` | Светлая | `#FFFFFF` | | `dark_blue` | Синяя | `#E8F4FD` | | `green` | Зелёная | `#EDF7E7` | --- ## Деплой с внешним прокси В репозитории есть готовый файл `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. Ошибка `module 'designer' has no attribute 'clolors'` ✅ исправлено **Проблема:** Класс цветов был назван `clolors` с опечаткой. После переименования в `colors` все ссылки в 10+ файлах указывали на старое имя. **Решение:** Класс переименован в `colors`, все ссылки во всех view-файлах обновлены. --- --- ## Содержание - [Архитектура](#архитектура) - [Быстрый старт](#быстрый-старт) - [Переменные окружения](#переменные-окружения) - [Структура проекта](#структура-проекта) - [База данных](#база-данных) - [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`. --- ## Содержание - [Архитектура](#архитектура) - [Быстрый старт](#быстрый-старт) - [Переменные окружения](#переменные-окружения) - [Структура проекта](#структура-проекта) - [База данных](#база-данных) - [API](#api) - [Тест Голланда](#тест-голланда) - [Паутинная диаграмма](#паутинная-диаграмма) - [Фронтенд](#фронтенд) - [Известные ошибки и просчёты](#известные-ошибки-и-просчёты) --- ## Архитектура ``` ┌─────────────────────────────────────────────┐ │ Браузер пользователя :80 │ │ (Flet Web App) │ └────────────────────┬────────────────────────┘ │ HTTP / REST ┌────────────────────▼────────────────────────┐ │ FastAPI API :8000 │ │ - аутентификация (JWT) │ │ - управление тестами │ │ - сбор ответов │ │ - расчёт радарных результатов │ │ - генерация SVG-диаграмм │ └──────┬─────────────────────┬────────────────┘ │ │ ┌──────▼──────┐ ┌────────▼────────┐ │ PostgreSQL │ │ Redis │ │ :5432 │ │ :6379 │ │ (основная │ │ (сессии / │ │ БД) │ │ кэш) │ └─────────────┘ └─────────────────┘ ``` **Сетевая топология Docker:** все три сервиса (api, web, postgres, redis) работают в одной Docker-сети. Фронтенд (`web`) обращается к API по внутреннему имени `http://api:8000`. PostgreSQL и Redis запускаются отдельным compose-файлом `docker-compose_db.yml`. --- ## Быстрый старт ### 1. Инфраструктура (PostgreSQL + Redis) ```bash docker compose -f docker-compose_db.yml up -d ``` ### 2. Приложение (API + Web) ```bash docker compose up -d --build ``` После запуска: | Адрес | Что | |-------|---------------------------| | `http://localhost` | Веб-интерфейс (Flet) | | `http://localhost:8000/docs` | Swagger UI (HTTP Basic Auth) | | `http://localhost:8000/redoc` | ReDoc | Доступ к документации — логин/пароль из переменных `DOCS_USERNAME` / `DOCS_PASSWORD`. ### 3. Инициализация БД После первого запуска выполните: ```bash # Создать таблицы curl -X POST http://localhost:8000/db/create-tables \ -H "X-Admin-Key: " # Применить миграции (добавляет колонки, если БД уже была создана ранее) curl -X POST http://localhost:8000/db/migrate \ -H "X-Admin-Key: " ``` ### 4. Загрузка теста Голланда ```bash # Создать тест (возвращает poll_id) curl -X POST http://localhost:8000/polls/holland \ -H "X-Admin-Key: " # Загрузить ключ расшифровки (42 пары → 6 типов × 14 баллов) curl -X POST http://localhost:8000/polls/holland//seed-scores \ -H "X-Admin-Key: " ``` --- ## Переменные окружения > **Важно:** перед деплоем в продакшн обязательно смените `SECRET_KEY`, `ADMIN_KEY`, `DOCS_PASSWORD` и пароли БД. | Переменная | Описание | Пример | |---|---|---| | `APP_PATH` | Абсолютный путь к директории приложения внутри контейнера | `/home/user/api-copp` | | `DB_HOST` | Хост PostgreSQL | `postgres` | | `DB_PORT` | Порт PostgreSQL | `5432` | | `DB_USER` | Пользователь БД | `postgres` | | `DB_PASS` | Пароль БД | `postgres` | | `DB_NAME` | Имя базы данных | `profi` | | `REDIS_HOST` | Хост Redis | `redis` | | `REDIS_PORT` | Порт Redis | `6379` | | `REDIS_DB` | Номер БД Redis | `0` | | `REDIS_PASSWORD` | Пароль Redis | — | | `SECRET_KEY` | Секрет для подписи JWT-токенов (min 32 символа) | `python -c "import secrets; print(secrets.token_hex(32))"` | | `ADMIN_KEY` | Ключ для защищённых admin-эндпоинтов | — | | `DOCS_USERNAME` | Логин для доступа к `/docs` и `/redoc` | `admin` | | `DOCS_PASSWORD` | Пароль для доступа к `/docs` и `/redoc` | — | | `API_URL` | Внутренний URL API для фронтенда | `http://api:8000` | Файлы `docker-compose.yml` содержат **демо-значения** — не используйте их в продакшн. --- ## Структура проекта ``` api-copp/ ├── main.py # точка входа FastAPI, регистрация роутеров ├── requirements.txt # зависимости API ├── pyproject.toml # метаданные проекта ├── Dockerfile.api # образ API (python:3.14-slim) ├── Dockerfile.web # образ фронтенда (python:3.11-slim + flet) ├── docker-compose.yml # API + Web ├── docker-compose_db.yml # PostgreSQL + Redis ├── docker-compose_web.yml # отдельный запуск только Web │ ├── bd/ # слой доступа к данным │ ├── __init__.py # Settings (pydantic), make_engine() │ └── tables/ │ ├── simple_base.py # общий Base для SQLAlchemy │ ├── users.py # User │ ├── organization.py # Organization │ ├── group.py # Group │ ├── poll.py # Poll │ ├── question.py # Question │ ├── choice.py # Choice │ ├── response.py # Response, Answer │ ├── scale.py # ScaleDimension, ChoiceScore │ └── radar_result.py # RadarResult, RadarResultItem │ ├── route/ # FastAPI-роутеры │ ├── auth_utils.py # JWT, bcrypt, require_admin_key │ ├── auth.py # /auth/register, /auth/login │ ├── base.py # /health, /version │ ├── init_data_base.py # /db/create-tables, /db/migrate │ ├── users_crud.py # CRUD пользователей │ ├── groups_crud.py # CRUD групп │ ├── organizations_crud.py# CRUD организаций │ ├── poll_crud.py # CRUD опросов │ ├── question_crud.py # CRUD вопросов │ ├── choice_crud.py # CRUD вариантов ответов │ ├── response_crud.py # приём ответов + авто-расчёт радара │ ├── holland_crud.py # генерация теста Голланда + seed-scores │ ├── scale_crud.py # CRUD осей и расшифровок │ ├── radar_crud.py # расчёт и чтение радарных результатов │ └── radar_svg_gen.py # серверная генерация SVG-диаграмм │ ├── web/ # Flet-фронтенд │ ├── main.py # точка входа Flet ASGI │ ├── router.py # маршрутизация страниц │ ├── api_client.py # httpx-обёртки над API │ ├── designer.py # компоненты дизайн-системы │ ├── radar_chart.py # виджет RadarChart (flet.canvas) │ ├── radar_svg.py # генератор SVG-data-URI (на клиенте) │ └── views/ │ └── content_users/ │ ├── poll_page.py # прохождение теста + диаграмма сразу после │ ├── response_page.py # страница результата с диаграммой │ ├── my_tests_page.py # список пройденных тестов │ └── ... │ └── data/ # смонтированный том Docker (./data:/app/data) ├── postgres/ # данные PostgreSQL └── radar/ # сохранённые SVG-файлы диаграмм (создаётся автоматически) ``` --- ## База данных ### Схема таблиц ``` organizations ──< groups ──< users │ polls ──< questions ──< choices ──< choice_scores │ │ └──< responses ──< answers │ │ │ └──< radar_results ──< radar_result_items (image_path) └── (dimension_id FK → scale_dimensions) ↑ polls ──< scale_dimensions ──────────────────-─┘ ``` ### Ключевые таблицы | Таблица | Описание | |---|---| | `users` | Испытуемые: `username`, `hashed_password`, `first_name`, `last_name`, FK на `groups` и `organizations` | | `polls` | Тест/опрос: `title`, `description` | | `questions` | Вопросы теста с `type` (`single` / `multiple`) | | `choices` | Варианты ответов к вопросам | | `responses` | Один факт прохождения теста (user + poll + timestamp) | | `answers` | Конкретные ответы внутри прохождения | | `scale_dimensions` | Оси радарной диаграммы: `name`, `color`, `position`, FK на `polls` | | `choice_scores` | Расшифровка: какой вариант ответа сколько баллов вносит в какую ось | | `radar_results` | Итоговый результат по осям + `image_path` (путь к SVG-файлу) | | `radar_result_items` | Снимок значений по каждой оси на момент расчёта | ### Миграции Таблицы создаются через `POST /db/create-tables`. Для уже существующих БД применяются дополнительные ALTER-миграции через `POST /db/migrate`. Текущие миграции: - Добавление колонок `username` / `hashed_password` в `users` - Добавление колонки `image_path` в `radar_results` --- ## API ### Аутентификация ``` POST /auth/register — регистрация (username ≥ 3 симв., password ≥ 8 симв.) POST /auth/login — получить JWT-токен (OAuth2 password flow) ``` JWT-токен передаётся заголовком `Authorization: Bearer `. Admin-операции требуют заголовок `X-Admin-Key`. ### Основные эндпоинты ``` # Тесты GET /polls — список тестов POST /polls — создать тест GET /polls/{id} — тест с вопросами и вариантами DELETE /polls/{id} — удалить тест # Вопросы / Варианты ответов POST /polls/{id}/questions POST /questions/{id}/choices # Прохождение POST /polls/{poll_id}/responses — сдать тест (автоматически считает радар) GET /polls/users/{user_id}/responses — история пользователя GET /responses/{id} — конкретный результат # Результаты (радар) POST /responses/{id}/radar/compute — (пере)считать радарный результат GET /responses/{id}/radar — данные для диаграммы GET /responses/{id}/radar/image — SVG-файл диаграммы GET /users/{user_id}/radar-results — история радарных результатов # Оси и расшифровки POST /polls/{poll_id}/dimensions — создать ось GET /polls/{poll_id}/dimensions — список осей PUT /dimensions/{id} DELETE /dimensions/{id} POST /dimensions/{id}/scores — задать расшифровку варианта ответа GET /choices/{choice_id}/scores # Тест Голланда POST /polls/holland — создать тест с 42 парами POST /polls/holland/{poll_id}/seed-scores — загрузить ключ расшифровки # База данных (admin) POST /db/create-tables POST /db/migrate # Служебные GET /health GET /version ``` --- ## Тест Голланда Реализована **модификация Г.В. Резапкиной** теста профессиональной ориентации Дж. Голланда. 42 пары профессий, испытуемый выбирает предпочтительную в каждой паре. ### Типы личности | Код | Название | Цвет | |---|---|---| | Р | Реалистичный | `#FF8C00` | | И | Интеллектуальный | `#4169E1` | | С | Социальный | `#32CD32` | | К | Конвенциональный | `#9370DB` | | П | Предприимчивый | `#DC143C` | | А | Артистический | `#FF69B4` | Каждый тип получает ровно 14 вопросов (= 14 максимально возможных баллов). Результат — паутинная диаграмма с 6 осями. ### Процедура загрузки 1. `POST /polls/holland` — создаёт тест с 42 вопросами-парами 2. `POST /polls/holland/{poll_id}/seed-scores` — создаёт 6 `ScaleDimension` и 84 `ChoiceScore` (по 2 на каждую пару: вариант A → тип X, вариант B → тип Y) --- ## Паутинная диаграмма ### Как работает При сдаче теста (`POST /polls/{id}/responses`) сервер автоматически: 1. Суммирует баллы по каждой оси (`ChoiceScore.score`) для выбранных вариантов 2. Сохраняет результат в `radar_results` + `radar_result_items` 3. Генерирует SVG-файл и сохраняет в `{APP_PATH}/data/radar/{result_id}.svg` 4. Записывает относительный путь `data/radar/{result_id}.svg` в колонку `image_path` ### Скачивание SVG Фронтенд скачивает SVG с API через внутреннюю сеть (`GET /responses/{id}/radar/image`), кодирует в base64 data URI и открывает в браузере как `data:image/svg+xml;base64,...` — файл сохраняется на устройство пользователя без необходимости публичного URL к серверу. ### Генерация SVG SVG генерируется чистым Python без зависимостей (`route/radar_svg_gen.py`). Серверный SVG идентичен клиентскому (`web/radar_svg.py`) по разметке, но предназначен для долгосрочного хранения. Параметры: - Фон `#2F184B`, полигон `rgba(155,114,207,0.55)` - 5 концентрических сеток - Подписи с именем типа и баллом - Размер холста: 520×520 пикселей --- ## Фронтенд Написан на **Flet** — Python-фреймворке поверх Flutter. Работает как ASGI-приложение через uvicorn, отдаётся браузеру как веб-приложение. ### Маршруты | Путь | Страница | |---|---| | `/login` | Вход | | `/reg` | Регистрация | | `/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`. ### Диаграмма после теста После сдачи теста пользователь сразу видит паутинную диаграмму (виджет `RadarChart`) и кнопку «Сохранить диаграмму», которая загружает SVG с сервера и открывает его в новой вкладке для сохранения. --- ## Известные ошибки и просчёты ### 1. Лишнее дублирование генерации SVG **Проблема:** SVG-генератор написан дважды — `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент). Кнопка «Сохранить» изначально создавала SVG на клиенте заново вместо использования уже сохранённого серверного файла. **Решение:** Кнопка «Сохранить» теперь скачивает SVG через `api_client.get_radar_svg_bytes()`, а не регенерирует его. Серверный файл — источник истины. **Оставшийся долг:** `web/radar_svg.py` больше не используется кнопками сохранения, но файл остался. Можно удалить при следующем рефакторинге. --- ### 2. Несовместимость Python 3.14 и psycopg ✅ исправлено **Проблема:** `pyproject.toml` указывал `requires-python = ">=3.14"` и зависимость `psycopg>=3.3.3`, однако `Dockerfile.api` использует `python:3.14-slim`. `psycopg` (асинхронный адаптер) на 3.14 может не иметь собранных wheels. В `requirements.txt` вместо psycopg используется `pg8000==1.29.0` — синхронный pure-Python драйвер. **Решение:** Все запросы к БД синхронные (`make_engine()` возвращает синхронный `create_engine` с `postgresql+pg8000://...`). Фактически psycopg не используется. Ошибочная зависимость `psycopg` заменена на `pg8000>=1.29.0` в `pyproject.toml`. --- ### 3. Нет транзакции при сохранении SVG ✅ исправлено **Проблема:** После основного `session.commit()` (сохранение `RadarResult` и `RadarResultItem`) шло отдельное обновление `result.image_path`. Если процесс падал между первым и вторым commit — в БД оставалась запись без `image_path`, хотя файл уже существовал на диске. **Последствие:** При повторном прохождении теста пересчёт (`compute_radar`) удалял старый `RadarResult` и создавал новый, что восстанавливало корректность. Но исторический файл `.svg` «осиротевал» на диске. **Решение:** `items_data` теперь строится непосредственно из `scores_map`/`dimensions` (данные уже в памяти после `flush()`), SVG генерируется до коммита, `result.image_path` устанавливается до коммита. Итого один `session.commit()` атомарно сохраняет `RadarResult`, `RadarResultItem` и путь к SVG. --- ### 4. Перенос подписей в диаграмме по буквам (устранено) **Проблема:** В виджете `RadarChart` (`web/radar_chart.py`) подписи осей переносились по буквам из-за слишком малой ширины контейнера (`_W_LBL=90`). **Решение:** Увеличено до `_W_LBL=130`, добавлен `max_lines=2, no_wrap=False` — перенос только по словам. --- ### 5. Первоначально неверные пары профессий **Проблема:** В первой версии `holland_crud.py` использовались произвольные пары профессий, не соответствующие оригинальной методике. Ключ расшифровки (`CHOICE_KEY`) не был реализован — загрузка в БД не работала. **Решение:** Заменены на 42 официальные пары из модификации Резапкиной с корректным `CHOICE_KEY` — каждый из 6 типов (Р/И/С/К/П/А) получает ровно 14 баллов-вопросов. --- ### 6. Браузер не может напрямую обратиться к `http://api:8000` **Проблема:** Внутреннее Docker-имя `api` недоступно из браузера пользователя. Если бы кнопка «Сохранить» формировала URL вида `http://api:8000/responses/.../radar/image` и передавала его в `page.launch_url()` — браузер получал бы ошибку DNS. **Решение:** Фронтенд скачивает SVG-байты с API через httpx (внутренняя сеть), конвертирует в base64 data URI и передаёт его в `page.launch_url()`. Браузер открывает data URI напрямую, без обращения к серверу. --- ### 7. `image_path` добавлен после создания таблиц (требует миграции) **Проблема:** Колонка `image_path` была добавлена в `RadarResult` после того, как таблицы уже создавались через `POST /db/create-tables`. `create_all` не изменяет существующие таблицы. **Решение:** Добавлена явная миграция в `route/init_data_base.py`: ```sql ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512); ``` Применяется вызовом `POST /db/migrate`. --- ### 8. Документация API доступна без авторизации по умолчанию у FastAPI (устранено) **Проблема:** FastAPI по умолчанию открывает `/docs` и `/openapi.json` без каких-либо ограничений. **Решение:** `docs_url=None, openapi_url=None, redoc_url=None` — стандартные маршруты отключены. Вместо них зарегистрированы собственные `/docs`, `/redoc`, `/openapi.json` с HTTP Basic Auth через `secrets.compare_digest` (защита от timing-атак).