From 7d907379860abfa31d6843e01f69f71a4fc7e833 Mon Sep 17 00:00:00 2001 From: jze9 Date: Fri, 10 Apr 2026 01:39:47 +0500 Subject: [PATCH] new docs --- README.md | 1603 +++++++++++++---------------------------------------- 1 file changed, 395 insertions(+), 1208 deletions(-) diff --git a/README.md b/README.md index 43cd06b..5f14e92 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # api-copp -Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. Включает REST API на FastAPI и пользовательский интерфейс на Flet. Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. +Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на Flet. Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. --- @@ -12,35 +12,31 @@ - [Структура проекта](#структура-проекта) - [База данных](#база-данных) - [API](#api) -- [Тест Голланда](#тест-голланда) +- [Перенос данных между серверами](#перенос-данных-между-серверами) - [Паутинная диаграмма](#паутинная-диаграмма) -- [Фронтенд](#фронтенд) -- [Дизайн-система](#дизайн-система) +- [Redis и кэширование](#redis-и-кэширование) - [Деплой с внешним прокси](#деплой-с-внешним-прокси) -- [Известные ошибки и просчёты](#известные-ошибки-и-просчёты) +- [Известные ошибки и решения](#известные-ошибки-и-решения) --- ## Архитектура ``` -Пользователь (браузер) - │ - │ :80 - [ Внешний прокси / nginx ] ← необязателен для локальной сети - │ - │ :80 - [ web — Flet ASGI ] ← единственный публичный сервис - │ - │ http://api:8000 (только внутри Docker-сети) - [ api — FastAPI ] - │ │ - [ PostgreSQL :5432 ] [ Redis :6379 ] +Браузер пользователя + │ :80 + [ web — Flet ASGI ] ← единственный публичный сервис + │ http://api:8000 (только Docker-сеть) + [ api — FastAPI :8000 ] + │ │ + [ PostgreSQL :5432 ] [ Redis :6379 ] ``` -**Важно:** сервис `api` не публикует порты наружу — он доступен только контейнеру `web` внутри Docker-сети. Пользователь никогда не обращается к API напрямую. +Сервис `api` **не публикует порты наружу** — доступен только контейнеру `web` внутри Docker-сети. Пользователь никогда не обращается к API напрямую. -**Сетевая топология Docker:** PostgreSQL и Redis запускаются отдельным compose-файлом `docker-compose_db.yml` и подключаются к той же Docker network. +PostgreSQL и Redis могут запускаться как: +- отдельным compose-файлом `docker-compose_db.yml` (локально в Docker) +- внешними сервисами (отдельный сервер, managed service) — достаточно прописать нужные `REDIS_HOST`/`DB_HOST` в переменных окружения --- @@ -52,1049 +48,76 @@ docker compose -f docker-compose_db.yml up -d ``` -### 2. Приложение (API + Web) +### 2. Приложение ```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 | +| `http://localhost:8081` | Дашборд статистики | -Доступ к документации — логин/пароль из переменных `DOCS_USERNAME` / `DOCS_PASSWORD`. - -### 3. Инициализация БД - -После первого запуска выполните: +### 3. Инициализация БД (первый запуск) ```bash # Создать таблицы curl -X POST http://localhost:8000/db/create-tables \ - -H "X-Admin-Key: " + -H "X-Admin-Key: " -# Применить миграции (добавляет колонки, если БД уже была создана ранее) +# Применить все миграции (безопасно запускать повторно — IF NOT EXISTS) curl -X POST http://localhost:8000/db/migrate \ - -H "X-Admin-Key: " + -H "X-Admin-Key: " + +# Миграция FK: responses.user_id → users.id ON DELETE CASCADE +curl -X POST http://localhost:8000/db/migrate-cascade-user \ + -H "X-Admin-Key: " ``` -### 4. Загрузка теста Голланда +> **Важно:** `db/migrate-cascade-user` нужно выполнить **один раз** на каждом сервере с уже существующей БД (созданной до апреля 2026). На новых базах — уже включено автоматически через `create-tables`. + +### 4. Загрузка тестов ```bash -# Создать тест (возвращает poll_id) -curl -X POST http://localhost:8000/polls/holland \ - -H "X-Admin-Key: " +# Голланд (42 пары профессий) +curl -X POST http://localhost:8000/polls/holland -H "X-Admin-Key: " +curl -X POST http://localhost:8000/polls/holland//seed-scores -H "X-Admin-Key: " -# Загрузить ключ расшифровки (42 пары → 6 типов × 14 баллов) -curl -X POST http://localhost:8000/polls/holland//seed-scores \ - -H "X-Admin-Key: " +# Климов (ДДО) +curl -X POST http://localhost:8000/polls/klimov -H "X-Admin-Key: " +curl -X POST http://localhost:8000/polls/klimov//seed-scores -H "X-Admin-Key: " + +# Гломшток (Карта интересов) +curl -X POST http://localhost:8000/polls/glomshtok -H "X-Admin-Key: " +curl -X POST http://localhost:8000/polls/glomshtok//seed-scores -H "X-Admin-Key: " ``` --- ## Переменные окружения -> **Важно:** перед деплоем в продакшн обязательно смените `SECRET_KEY`, `ADMIN_KEY`, `DOCS_PASSWORD` и пароли БД. +> Файл `docker-compose.yml` содержит **демо-значения**. Перед продакшн-деплоем обязательно смените все пароли и ключи. | Переменная | Описание | Пример | |---|---|---| -| `APP_PATH` | Абсолютный путь к директории приложения внутри контейнера | `/home/user/api-copp` | +| `APP_PATH` | Путь к директории приложения внутри контейнера | `/app` | | `DB_HOST` | Хост PostgreSQL | `postgres` | | `DB_PORT` | Порт PostgreSQL | `5432` | | `DB_USER` | Пользователь БД | `postgres` | -| `DB_PASS` | Пароль БД | `postgres` | +| `DB_PASS` | Пароль БД | — | | `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` содержат **демо-значения** — не используйте их в продакшн. +| `SECRET_KEY` | Секрет JWT (min 32 символа) | `python -c "import secrets; print(secrets.token_hex(32))"` | +| `ADMIN_KEY` | Ключ для admin-эндпоинтов (`X-Admin-Key`) | — | +| `DOCS_USERNAME` | Логин для `/docs` и `/redoc` | `admin` | +| `DOCS_PASSWORD` | Пароль для `/docs` и `/redoc` | — | +| `API_URL` | URL API для фронтенда (только Docker-сеть) | `http://api:8000` | --- @@ -1102,105 +125,105 @@ curl -X POST http://localhost:8000/polls/holland//seed-scores \ ``` 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 +├── main.py # точка входа FastAPI, регистрация роутеров +├── requirements.txt # зависимости API +├── Dockerfile.api # образ API +├── Dockerfile.web # образ фронтенда (Flet) +├── Dockerfile.stats # образ дашборда статистики +├── docker-compose.yml # API + Web + Stats +├── docker-compose_db.yml # PostgreSQL + Redis │ -├── bd/ # слой доступа к данным -│ ├── __init__.py # Settings (pydantic), make_engine() +├── 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 +│ ├── simple_base.py +│ ├── users.py # User (username, hashed_password, group_id, org_id) +│ ├── organization.py # Organization +│ ├── group.py # Group +│ ├── poll.py # Poll (title, description, is_active) +│ ├── question.py # Question (type: single/multiple) +│ ├── 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-диаграмм +├── route/ +│ ├── auth_utils.py # JWT, bcrypt, require_admin_key +│ ├── auth.py # /auth/register, /auth/login, /auth/logout +│ ├── base.py # /health, /version +│ ├── init_data_base.py # /db/create-tables, /db/migrate, /db/migrate-cascade-user +│ ├── 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 # тест Голланда +│ ├── klimov_crud.py # тест Климова (ДДО) +│ ├── glomshtok_crud.py # тест Гломштока (Карта интересов) +│ ├── scale_crud.py # CRUD осей и расшифровок +│ ├── radar_crud.py # расчёт и чтение радарных результатов +│ ├── radar_svg_gen.py # серверная генерация SVG-диаграмм +│ ├── stats_crud.py # статистика (кэшируется в Redis, TTL 5 мин) +│ └── transfer_crud.py # экспорт/импорт/бэкап/восстановление БД │ -├── 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 # список пройденных тестов -│ └── ... +├── web/ # Flet-фронтенд (порт 80) +│ ├── main.py +│ ├── router.py +│ ├── api_client.py +│ ├── designer.py # дизайн-система +│ ├── radar_chart.py # виджет RadarChart +│ └── views/content_users/ │ -└── data/ # смонтированный том Docker (./data:/app/data) - ├── postgres/ # данные PostgreSQL - └── radar/ # сохранённые SVG-файлы диаграмм (создаётся автоматически) +└── data/ # том Docker (./data:/app/data) + ├── postgres/ # данные PostgreSQL + └── radar/ # SVG-файлы диаграмм (структура: <тест>/<группа>/.svg) ``` --- ## База данных -### Схема таблиц +### Схема связей ``` organizations ──< groups ──< users - │ + │ CASCADE DELETE polls ──< questions ──< choices ──< choice_scores - │ │ - └──< responses ──< answers │ - │ │ - └──< radar_results ──< radar_result_items - (image_path) └── (dimension_id FK → scale_dimensions) - ↑ -polls ──< scale_dimensions ──────────────────-─┘ + │ + └──< responses ──< answers (CASCADE: poll→responses, responses→answers) + │ + └──< radar_results ──< radar_result_items (CASCADE) + │ + image_path → data/radar/...svg + +polls ──< scale_dimensions ──< choice_scores + │ + └── radar_result_items.dimension_id (SET NULL при удалении оси) ``` -### Ключевые таблицы +### Каскадное удаление -| Таблица | Описание | -|---|---| -| `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` | Снимок значений по каждой оси на момент расчёта | +При удалении **пользователя** автоматически удаляется: +- все его `responses` → все `answers` внутри → все `radar_results` → все `radar_result_items` + +`organization` и `group`, к которым был привязан пользователь, **не удаляются** — только обнуляются ссылки (`SET NULL`). + +При удалении **теста (poll)**: +- все `questions` → все `choices` → все `choice_scores` +- все `responses` → все `answers` → все `radar_results` → все `radar_result_items` +- все `scale_dimensions` → все `choice_scores` ### Миграции -Таблицы создаются через `POST /db/create-tables`. Для уже существующих БД применяются дополнительные ALTER-миграции через `POST /db/migrate`. Текущие миграции: +Применяются через admin-эндпоинты (безопасно запускать повторно — используют `IF NOT EXISTS`/`IF EXISTS`): -- Добавление колонок `username` / `hashed_password` в `users` -- Добавление колонки `image_path` в `radar_results` +| Эндпоинт | Что делает | +|---|---| +| `POST /db/create-tables` | Создаёт все таблицы по моделям SQLAlchemy | +| `POST /db/migrate` | Добавляет колонки `username`/`hashed_password` в users, `image_path` в radar_results | +| `POST /db/migrate-cascade-user` | Добавляет FK `responses.user_id → users(id) ON DELETE CASCADE` | --- @@ -1210,79 +233,195 @@ polls ──< scale_dimensions ──────────────── ``` POST /auth/register — регистрация (username ≥ 3 симв., password ≥ 8 симв.) -POST /auth/login — получить JWT-токен (OAuth2 password flow) +POST /auth/login — JWT-токен (OAuth2 password flow) +POST /auth/logout — отзыв токена (добавляет в Redis-блэклист) ``` -JWT-токен передаётся заголовком `Authorization: Bearer `. Admin-операции требуют заголовок `X-Admin-Key`. +Токен: `Authorization: Bearer ` +Admin-операции: `X-Admin-Key: ` -### Основные эндпоинты +### Тесты и вопросы ``` -# Тесты -GET /polls — список тестов -POST /polls — создать тест -GET /polls/{id} — тест с вопросами и вариантами -DELETE /polls/{id} — удалить тест +GET /polls — список тестов +POST /polls — создать тест [admin] +GET /polls/{id} — тест с вопросами и вариантами +DELETE /polls/{id} — удалить тест [admin] +POST /polls/{id}/questions — добавить вопрос [admin] +POST /questions/{id}/choices — добавить вариант ответа [admin] +``` -# Вопросы / Варианты ответов -POST /polls/{id}/questions -POST /questions/{id}/choices +### Прохождение теста -# Прохождение -POST /polls/{poll_id}/responses — сдать тест (автоматически считает радар) -GET /polls/users/{user_id}/responses — история пользователя -GET /responses/{id} — конкретный результат +``` +POST /polls/{poll_id}/responses — сдать тест (автоматически считает радар + генерирует SVG) +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 /responses/{id}/radar — данные диаграммы (оси + баллы) +GET /responses/{id}/radar/image — SVG-файл диаграммы +POST /responses/{id}/radar/compute — пересчитать диаграмму [admin] +GET /users/{user_id}/radar-results — история всех радарных результатов пользователя +``` + +### Оси и расшифровки + +``` +POST /polls/{poll_id}/dimensions — создать ось [admin] +GET /polls/{poll_id}/dimensions — список осей +PUT /dimensions/{id} [admin] +DELETE /dimensions/{id} [admin] +POST /dimensions/{id}/scores — задать расшифровку варианта ответа [admin] GET /choices/{choice_id}/scores +``` -# Тест Голланда -POST /polls/holland — создать тест с 42 парами -POST /polls/holland/{poll_id}/seed-scores — загрузить ключ расшифровки +### Готовые тесты -# База данных (admin) -POST /db/create-tables -POST /db/migrate +``` +POST /polls/holland — создать тест Голланда (42 пары) [admin] +POST /polls/holland/{poll_id}/seed-scores — загрузить ключ расшифровки [admin] +POST /polls/klimov — создать тест Климова (ДДО) [admin] +POST /polls/klimov/{poll_id}/seed-scores [admin] +POST /polls/glomshtok — создать тест Гломштока [admin] +POST /polls/glomshtok/{poll_id}/seed-scores [admin] +``` -# Служебные -GET /health -GET /version +### Пользователи / Группы / Организации + +``` +POST /users/ — создать пользователя [admin] +GET /users/ — список пользователей [admin] +DELETE /users/{id} — удалить пользователя (каскадно удаляет все данные) +GET /users/me — текущий пользователь +PUT /users/me — обновить профиль + +GET /groups/ — список групп +POST /groups/ [admin] +... + +GET /organizations/ — список организаций +POST /organizations/ [admin] +... +``` + +### Статистика + +``` +GET /stats/summary — общая сводка (total_responses, total_polls, ...) +GET /stats/responses?group_by=poll — ответы по тестам/организациям/группам/месяцам +GET /stats/results/avg-by-dimension — средний балл по осям +GET /stats/results/distribution — распределение баллов +GET /stats/years — список лет с активностью +``` + +Фильтры: `poll_id`, `org_id`, `group_id`, `year`. Все ответы кэшируются в Redis (TTL 5 минут). Кэш автоматически сбрасывается при удалении пользователя. + +### База данных + +``` +POST /db/create-tables [admin] +POST /db/migrate [admin] +POST /db/migrate-cascade-user [admin] +``` + +### Служебные + +``` +GET /health +GET /version ``` --- -## Тест Голланда +## Перенос данных между серверами -Реализована **модификация Г.В. Резапкиной** теста профессиональной ориентации Дж. Голланда. 42 пары профессий, испытуемый выбирает предпочтительную в каждой паре. +Два сценария использования: -### Типы личности +### Сценарий 1: Дополнение (transfer) -| Код | Название | Цвет | -|---|---|---| -| Р | Реалистичный | `#FF8C00` | -| И | Интеллектуальный | `#4169E1` | -| С | Социальный | `#32CD32` | -| К | Конвенциональный | `#9370DB` | -| П | Предприимчивый | `#DC143C` | -| А | Артистический | `#FF69B4` | +Ученики прошли тест на сервере A — нужно перенести их результаты на сервер B, не удаляя существующие данные на B. -Каждый тип получает ровно 14 вопросов (= 14 максимально возможных баллов). Результат — паутинная диаграмма с 6 осями. +```bash +# Сервер A — скачать архив (все таблицы + SVG-диаграммы) +curl -H "X-Admin-Key: " \ + http://server-a:8000/transfer/export -o export.zip -### Процедура загрузки +# Сервер B — на новой БД сначала создать схему (один раз) +curl -X POST -H "X-Admin-Key: " http://server-b:8000/db/create-tables +curl -X POST -H "X-Admin-Key: " http://server-b:8000/db/migrate +curl -X POST -H "X-Admin-Key: " http://server-b:8000/db/migrate-cascade-user -1. `POST /polls/holland` — создаёт тест с 42 вопросами-парами -2. `POST /polls/holland/{poll_id}/seed-scores` — создаёт 6 `ScaleDimension` и 84 `ChoiceScore` (по 2 на каждую пару: вариант A → тип X, вариант B → тип Y) +# Сервер B — импортировать (добавляет только новое, дубли по UUID пропускаются) +curl -X POST -H "X-Admin-Key: " \ + -F "file=@export.zip" \ + http://server-b:8000/transfer/import +``` + +**Ответ импорта:** +```json +{ + "status": "ok", + "result": { + "inserted": {"organizations": 107, "users": 4, "responses": 34, "radar_results": 34, "svg_files": 23, ...}, + "skipped": {"organizations": 0, "users": 0, "responses": 0, "radar_results": 0, ...} + } +} +``` + +При повторном импорте того же архива все записи попадают в `skipped` — **дублей не создаётся**. + +### Сценарий 2: Резервное копирование и восстановление (backup) + +Полный снимок БД с возможностью полного восстановления. + +```bash +# Создать резервную копию (имя файла начинается с backup_) +curl -H "X-Admin-Key: " \ + http://localhost:8000/backup/export -o backup_20260410.zip + +# Восстановить из резервной копии (УДАЛЯЕТ все текущие данные, вставляет из архива) +curl -X POST -H "X-Admin-Key: " \ + -F "file=@backup_20260410.zip" \ + http://localhost:8000/backup/restore +``` + +> **Внимание:** `/backup/restore` полностью очищает БД (в обратном FK-порядке) перед вставкой данных. SVG-папка тоже очищается и восстанавливается. Используйте только для полного восстановления. + +**Ответ восстановления:** +```json +{ + "status": "ok", + "deleted": {"radar_result_items": 332, "users": 4, "organizations": 107, ...}, + "inserted": {"organizations": 107, "users": 4, "radar_result_items": 332, "svg_files": 23, ...} +} +``` + +### Что входит в архив + +Оба архива содержат одинаковый набор данных (12 таблиц в FK-порядке + SVG): + +``` +manifest.json ← версия формата, дата, количество записей +organizations.json +groups.json +polls.json +questions.json +choices.json +scale_dimensions.json +choice_scores.json +users.json +responses.json +answers.json +radar_results.json +radar_result_items.json +radar_svgs/ ← все SVG-файлы (структура папок сохраняется) +``` + +Все UUID сохраняются **без изменений** — FK-связи не рвутся. --- @@ -1293,118 +432,166 @@ GET /version При сдаче теста (`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` +2. Сохраняет `radar_result` + `radar_result_items` +3. Генерирует SVG и сохраняет в `{APP_PATH}/data/radar/<тест>/<группа>/.svg` +4. Записывает путь в `image_path` — всё в **одном** `session.commit()` -### Скачивание 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`) по разметке, но предназначен для долгосрочного хранения. Параметры: +### SVG-файл +Генерируется чистым Python (`route/radar_svg_gen.py`): - Фон `#2F184B`, полигон `rgba(155,114,207,0.55)` - 5 концентрических сеток - Подписи с именем типа и баллом -- Размер холста: 520×520 пикселей +- Размер: 520×520 пикселей + +### Скачивание из браузера + +Фронтенд скачивает SVG-байты через внутренний API (httpx, Docker-сеть), кодирует в base64 data URI и открывает в браузере. Браузер не обращается к API напрямую — это важно, так как `api` не имеет публичного адреса. --- -## Фронтенд +## Redis и кэширование -Написан на **Flet** — Python-фреймворке поверх Flutter. Работает как ASGI-приложение через uvicorn, отдаётся браузеру как веб-приложение. +### Настройка Redis -### Маршруты +Используется connection pool (`max_connections=20`). При Redis с отключённой командой `CONFIG` (например, Debian `redis-server` по умолчанию) — настройки меняются только через `/etc/redis/redis.conf` + `systemctl restart redis`. -| Путь | Страница | -|---|---| -| `/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}` | Профиль пользователя | +Рекомендуемые настройки для продакшна: +``` +maxmemory 2048mb +maxmemory-policy volatile-lru +appendfsync everysec +timeout 300 +tcp-keepalive 60 +``` -`{url_key}` — одноразовый 32-символьный hex-ключ, генерируется при успешном входе (`secrets.token_hex(16)`) и сохраняется в сессии. Обращение к защищённому маршруту с чужим или отсутствующим ключом автоматически сбрасывает сессию и перенаправляет на `/login`. +### Назначение ключей -### Диаграмма после теста +| Префикс | Что хранит | TTL | +|---|---|---| +| `token_blacklist:` | Отозванные JWT-токены | До истечения токена | +| `cache:` | Кэш ответов `/stats/*` | 5 минут | -После сдачи теста пользователь сразу видит паутинную диаграмму (виджет `RadarChart`) и кнопку «Сохранить диаграмму», которая загружает SVG с сервера и открывает его в новой вкладке для сохранения. +### Сброс кэша + +Кэш статистики (`cache:*`) автоматически сбрасывается при удалении пользователя — чтобы счётчики обновились немедленно, а не через 5 минут. --- -## Известные ошибки и просчёты +## Тест Голланда -### 1. Лишнее дублирование генерации SVG +Модификация Г.В. Резапкиной теста профессиональной ориентации Дж. Голланда. 42 пары профессий, испытуемый выбирает предпочтительную в каждой паре. -**Проблема:** SVG-генератор написан дважды — `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент). Кнопка «Сохранить» изначально создавала SVG на клиенте заново вместо использования уже сохранённого серверного файла. +| Тип | Название | Цвет | +|---|---|---| +| Р | Реалистичный | `#FF8C00` | +| И | Интеллектуальный | `#4169E1` | +| С | Социальный | `#32CD32` | +| К | Конвенциональный | `#9370DB` | +| П | Предприимчивый | `#DC143C` | +| А | Артистический | `#FF69B4` | -**Решение:** Кнопка «Сохранить» теперь скачивает SVG через `api_client.get_radar_svg_bytes()`, а не регенерирует его. Серверный файл — источник истины. - -**Оставшийся долг:** `web/radar_svg.py` больше не используется кнопками сохранения, но файл остался. Можно удалить при следующем рефакторинге. +Каждый тип — 14 вопросов (14 максимальных баллов). `seed-scores` создаёт 6 осей (`ScaleDimension`) и 84 записи расшифровки (`ChoiceScore`). --- -### 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 драйвер. +Минимальная конфигурация nginx: -**Решение:** Все запросы к БД синхронные (`make_engine()` возвращает синхронный `create_engine` с `postgresql+pg8000://...`). Фактически psycopg не используется. Ошибочная зависимость `psycopg` заменена на `pg8000>=1.29.0` в `pyproject.toml`. +```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. + +В репозитории есть `nginx.conf` и `nginx.production.conf` с готовыми примерами. --- -### 3. Нет транзакции при сохранении SVG ✅ исправлено +## Известные ошибки и решения -**Проблема:** После основного `session.commit()` (сохранение `RadarResult` и `RadarResultItem`) шло отдельное обновление `result.image_path`. Если процесс падал между первым и вторым commit — в БД оставалась запись без `image_path`, хотя файл уже существовал на диске. +### 1. `api` недоступен из браузера ✅ -**Последствие:** При повторном прохождении теста пересчёт (`compute_radar`) удалял старый `RadarResult` и создавал новый, что восстанавливало корректность. Но исторический файл `.svg` «осиротевал» на диске. +**Проблема:** Docker-имя `api` не резолвится у браузера пользователя. Любой URL вида `http://api:8000/...` в `page.launch_url()` давал ошибку DNS. -**Решение:** `items_data` теперь строится непосредственно из `scores_map`/`dimensions` (данные уже в памяти после `flush()`), SVG генерируется до коммита, `result.image_path` устанавливается до коммита. Итого один `session.commit()` атомарно сохраняет `RadarResult`, `RadarResultItem` и путь к SVG. +**Решение:** Фронтенд скачивает SVG-байты через httpx (внутренняя сеть), конвертирует в `data:image/svg+xml;base64,...` и передаёт готовый data URI в браузер. Прямых обращений к API из браузера нет. --- -### 4. Перенос подписей в диаграмме по буквам (устранено) +### 2. SVG записывался без `image_path` при сбое ✅ -**Проблема:** В виджете `RadarChart` (`web/radar_chart.py`) подписи осей переносились по буквам из-за слишком малой ширины контейнера (`_W_LBL=90`). +**Проблема:** `image_path` обновлялся вторым `session.commit()` — при падении между первым и вторым записи оставались без пути к файлу. -**Решение:** Увеличено до `_W_LBL=130`, добавлен `max_lines=2, no_wrap=False` — перенос только по словам. +**Решение:** SVG генерируется до коммита, `image_path` устанавливается до коммита. Один атомарный `commit()`. --- -### 5. Первоначально неверные пары профессий +### 3. `/docs` был открыт без авторизации ✅ -**Проблема:** В первой версии `holland_crud.py` использовались произвольные пары профессий, не соответствующие оригинальной методике. Ключ расшифровки (`CHOICE_KEY`) не был реализован — загрузка в БД не работала. - -**Решение:** Заменены на 42 официальные пары из модификации Резапкиной с корректным `CHOICE_KEY` — каждый из 6 типов (Р/И/С/К/П/А) получает ровно 14 баллов-вопросов. +**Решение:** `docs_url=None, openapi_url=None, redoc_url=None` — стандартные маршруты отключены. Вместо них собственные маршруты с HTTP Basic Auth (`secrets.compare_digest` против timing-атак). --- -### 6. Браузер не может напрямую обратиться к `http://api:8000` +### 4. Варианты ответов обрезались ✅ -**Проблема:** Внутреннее Docker-имя `api` недоступно из браузера пользователя. Если бы кнопка «Сохранить» формировала URL вида `http://api:8000/responses/.../radar/image` и передавала его в `page.launch_url()` — браузер получал бы ошибку DNS. +**Проблема:** `ft.Radio(label=...)` в Flet не переносит текст метки — длинные варианты обрезались. -**Решение:** Фронтенд скачивает SVG-байты с API через httpx (внутренняя сеть), конвертирует в base64 data URI и передаёт его в `page.launch_url()`. Браузер открывает data URI напрямую, без обращения к серверу. +**Решение:** Кастомные кликабельные контейнеры с `ft.Text(expand=True, no_wrap=False)`. --- -### 7. `image_path` добавлен после создания таблиц (требует миграции) +### 5. `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`. +Применяется через `POST /db/migrate`. --- -### 8. Документация API доступна без авторизации по умолчанию у FastAPI (устранено) +### 6. При удалении пользователя оставались его ответы ✅ -**Проблема:** FastAPI по умолчанию открывает `/docs` и `/openapi.json` без каких-либо ограничений. +**Проблема:** `responses.user_id` был простым UUID без FK-ограничения. При удалении пользователя его ответы, answers и radar_results оставались в БД (висящие строки). -**Решение:** `docs_url=None, openapi_url=None, redoc_url=None` — стандартные маршруты отключены. Вместо них зарегистрированы собственные `/docs`, `/redoc`, `/openapi.json` с HTTP Basic Auth через `secrets.compare_digest` (защита от timing-атак). +**Решение:** Добавлен FK `responses.user_id → users(id) ON DELETE CASCADE`. Теперь удаление пользователя каскадно удаляет все его данные. + +Применяется через `POST /db/migrate-cascade-user`. + +--- + +### 7. Статистика не обновлялась после удаления пользователя ✅ + +**Проблема:** После удаления пользователя кэш `/stats/*` в Redis ещё 5 минут отдавал старые данные (включая удалённые ответы). + +**Решение:** `delete_user` вызывает `cache_flush()` — удаляет все ключи `cache:*` через `scan_iter`. Следующий запрос к статистике сразу идёт в БД. + +--- + +### 8. Redis на Debian отключает команду `CONFIG` ✅ + +**Проблема:** `/etc/redis/redis.conf` содержал `rename-command CONFIG ""` — команда `CONFIG SET` возвращала ошибку `ERR unknown command`. + +**Решение:** Настройки меняются напрямую в `/etc/redis/redis.conf` + `systemctl restart redis`. Строку `rename-command CONFIG ""` при необходимости удалить или закомментировать. + +--- + +### 9. `pg8000` вместо `psycopg` ✅ + +**Проблема:** `pyproject.toml` указывал `psycopg>=3.3.3`, реально использовался `pg8000`. + +**Решение:** Зависимость заменена на `pg8000>=1.29.0`. Все запросы к БД синхронные. + +--- + +### 10. Дублирование SVG-генератора + +**Состояние:** `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент) — два разных генератора. Кнопка «Сохранить» скачивает серверный файл. `web/radar_svg.py` не используется кнопками, но файл остался в репозитории.