Compare commits

...

37 Commits

Author SHA1 Message Date
jze9
485d5128bf fix: критическая регрессия — вход и регистрация падали с 500 (нет bcrypt-бэкенда)
При переходе Dockerfile.api на pyproject.toml/uv (fd098f9) зависимость
"passlib>=1.7.4" потеряла экстру [bcrypt] — requirements.txt раньше явно
тянул passlib[bcrypt] + отдельно bcrypt==4.0.1. Без бэкенда verify_password/
hash_password падали с passlib.exc.MissingBackendError на каждой попытке
войти или зарегистрироваться.

Добавлена экстра passlib[bcrypt], и bcrypt закреплён на 4.0.1 — свежий
bcrypt 5.0 (резолвился бы по умолчанию) несовместим с passlib 1.7.4:
ломает определение версии (module 'bcrypt' has no attribute '__about__')
и падает на служебном self-test с другой ошибкой про длину пароля.
Проверено напрямую внутри собранного образа: hash_password/verify_password
работают корректно, без обращения к БД.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 10:31:59 +05:00
jze9
0f13ef5256 fix: Cache-Control: no-cache на index.html в web-react и stats-react
location / отдавал index.html вообще без Cache-Control — браузер кэшировал
его эвристически (по Last-Modified), из-за чего после деплоя часть
пользователей продолжала видеть старую версию (например, без анонимного
входа), пока не делали hard refresh, а на следующей обычной загрузке
снова получали закэшированную старую версию. Симптом воспроизвёлся на
test.cloud-copp74.ru.

no-cache вынуждает браузер всегда идти на сервер за index.html (быстрый
304 по ETag, если файл не менялся) — хэшированные /assets/ ниже как
кэшировались на год, так и кэшируются, инвалидация им не нужна.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 14:50:22 +05:00
jze9
eb110f677c feat(stats): переписать дашборд статистики с Flet на React
stats/ (Flet) генерировал графики через matplotlib в PNG и отдавал их
через Flutter web с WebSocket — та же архитектура, что подводила
основной фронтенд (см. известные ошибки в README). Плавающая версия
flet>=0.82.2 недавно подтянула 0.86.0 с несовместимым протоколом
фреймирования, и дашборд перестал открываться в браузере.

stats-react/ — SPA по тому же паттерну, что и web-react (Vite+React+TS,
свой Dockerfile+nginx, same-origin прокси /api вместо WebSocket).
Функциональность 1:1: 4 summary-карточки, общие фильтры (тест/
организация/группа/год), два таба (прохождения / результаты тестов),
8 графиков, топ-10 таблица, фильтр по оси + сравнение по организациям/
группам. Круговая диаграмма заменена на 100%-stacked bar (dataviz:
"part-to-whole rides on the stacked bar chart; donut stays
deprioritized") с фолдингом длинного хвоста категорий в "Другое" —
у оригинала было 32 категории на 8 цветов, из-за чего сегменты
становились неразличимы.

Категориальная палитра графиков провалидирована на CVD-безопасность
(scripts/validate_palette.js, light+dark) — 8-цветная палитра из
исходного designer.py не проходила проверку (hard fail по
normal-vision floor). Бренд-оранжевый сохранён в палитре, остальные
7 слотов — из референсного набора skill'а.

stats/ и Dockerfile.stats удалены, docker-compose.yml переключён на
stats-react. Проверено сборкой всех трёх образов, полным compose up
и визуально (headless Chrome, light+dark, обе вкладки, реальные
данные с прод-сервера).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 14:11:06 +05:00
jze9
1728450778 fix: закрепить flet==0.82.2 в Dockerfile.stats — регресс дашборда
>=0.82.2 плавающей нижней границей сегодня подтянул flet 0.86.0 при
пересборке — мажорный релиз с изменённым протоколом фреймирования
(pre-0.86 и 0.86 клиент/сервер несовместимы) и другими breaking changes.
После деплоя интерфейс статистики перестал открываться в браузере.
Закреплена версия, которая стабильно работала 8 дней до пересборки.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 13:14:24 +05:00
jze9
db76401cef chore: удалить мёртвый docker-compose_web.yml
Ссылался на web/ и Dockerfile.web, удалённые вместе со старым
Flet-фронтендом (c197a59). Пропустил его в той уборке.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 12:52:04 +05:00
jze9
d8438da334 refactor: объединить docker-compose_db.yml в docker-compose.yml через profiles
Два файла и две команды разворачивания заменены одним docker-compose.yml.
postgres/redis перенесены туда же за профилем local-db — не поднимаются
по умолчанию (app по умолчанию идёт на внешние DB_HOST/REDIS_HOST из
.env), включаются явно через --profile local-db up -d. Проверено
`docker compose config` в обоих режимах.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 12:40:04 +05:00
jze9
94299d4af6 feat(web): анонимный вход для школьников — только школа и класс
Школьники не должны придумывать пароль, чтобы пройти тест. Бэкенд уже
поддерживал это: POST /polls/{poll_id}/responses принимает user_id как
Optional, а Response.group_id/organization_id — самостоятельные nullable
снимки школы/класса, не завязанные на аккаунт. Не хватало только
фронтенда — все роуты были жёстко завёрнуты в ProtectedRoute с
обязательным JWT.

- AuthContext: параллельная "анонимная идентичность" (org+group в
  localStorage, без токена и без запроса к API)
- ProtectedRoute пускает по токену ИЛИ анонимной сессии; новый
  AccountRoute — строго по токену, для /my-tests, /profile,
  /response/:id (у анонимной сессии нет истории — её негде хранить)
- Новая страница /anonymous — выбор школы+класса тем же
  SearchableDropdown, что и в регистрации
- PollPage при отправке ответа берёт org/group из анонимной сессии,
  если пользователь не залогинен
- HomePage скрывает "Мои тесты" и профиль для анонимов, добавлена
  кнопка выхода из анонимного режима

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 11:56:08 +05:00
jze9
dd0c29ccad docs: обновить README под актуальный web-react и проведённую уборку
Документация всё ещё описывала Flet как текущий фронтенд, хотя прод
уже на web-react с eac6192. Обновлены: архитектурная схема, структура
проекта, описание SVG-доставки через same-origin прокси, переменные
окружения (.env вместо демо-значений в compose), деплой с внешним
прокси (WS-заголовки Flet больше не нужны — предупреждение, что
nginx.conf/nginx.production.conf в репозитории написаны под старый
Flet и не обновлялись, править боевой конфиг test.cloud-copp74.ru не
стал без отдельного подтверждения). Добавлена документация анонимного
входа и записи по итогам этой уборки в «Известные ошибки».

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 11:55:56 +05:00
jze9
31ee79bd97 fix: выровнять Dockerfile.stats на python:3.14-slim
Три сервиса проекта были на разных мажорных версиях Python без общего
базового образа (api — 3.14, stats/web — 3.11). Проверил сборкой и
smoke-тестом: flet 0.86.0 ставится на 3.14 без проблем, контейнер
стартует и отвечает 200. Dockerfile.web выровнять было некому — файл
удалён вместе с мёртвым Flet-фронтендом отдельным коммитом.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 11:55:49 +05:00
jze9
c197a59c3f chore: удалить мёртвый Flet-фронтенд web/ и Dockerfile.web
Заменён на web-react в eac6192, но старый код и его Dockerfile
оставались в репозитории неиспользуемыми и вводили в заблуждение.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 11:55:38 +05:00
jze9
ef1d442f35 fix: убрать реальные секреты из docker-compose.yml в .env
Пароли БД/Redis, SECRET_KEY, ADMIN_KEY, DOCS_PASSWORD были прописаны в
открытом виде прямо в docker-compose.yml и docker-compose_db.yml,
закоммичены в git (71887b0). Вынесены в .env (уже в .gitignore),
compose-файлы читают их через ${VAR}. Добавлен .env.example — шаблон
без секретов для онбординга.

Важно: старые значения остаются в истории git, эта правка их не
отзывает — реальные пароли на серверах БД/Redis нужно ротировать
отдельно, вне зоны действия этого коммита.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 11:54:29 +05:00
jze9
ab1788c57f refactor: удалить мёртвый код в bd/__init__.py
DATABASE_URL_asyncpg нигде не использовался — реально работает только
DATABASE_URL_syncpg/pg8000 через синхронный engine. validate_db_port был
не подключён как pydantic-валидатор (нет декоратора) и никогда не
вызывался — DB_PORT конвертируется строкой выше через голый int().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 11:54:21 +05:00
jze9
6bda64ccbc fix: убрать блокирующие sync-вызовы БД из async-роутов миграций
route/init_data_base.py объявлял create_tables/migrate_cascade_user/
migrate_users/migrate_tables/clear_tables как async def, но внутри
дёргал синхронный SQLAlchemy engine напрямую в event loop — блокировал
весь однопроцессный uvicorn на время миграции. Остальные ~92 роута в
проекте корректно используют обычный def (FastAPI сам уносит их в
threadpool). Привёл эти пять к тому же паттерну.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 11:54:16 +05:00
jze9
fd098f917c fix: единый источник зависимостей API — Dockerfile.api через uv/pyproject.toml
Dockerfile.api ставил зависимости из requirements.txt, версии разошлись
с pyproject.toml/uv.lock (redis, sqlalchemy и др.). Теперь сборка идёт
через `uv sync --frozen` напрямую из pyproject.toml/uv.lock, requirements.txt
удалён. Заодно нашёлся отсутствующий python-multipart (нужен FastAPI для
UploadFile/Form в organizations_crud.py) — без него API падал при старте.

Добавлен .dockerignore в корень: раньше Dockerfile.api через COPY . .
затягивал в образ .git, data/, web-react, web — теперь только нужное.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 11:54:11 +05:00
jze9
eac61923f4 feat(web): React-фронтенд вместо Flet + fix restore rmtree на bind-mount
web-react/: SPA Vite+React+TS, nginx раздаёт статику и проксирует /api на api:8000 по локалке. docker-compose: web(Flet)->web-react на :80. transfer_crud.backup_restore: чистим содержимое data/radar вместо rmtree точки монтирования (EBUSY).
2026-06-02 13:56:44 +00:00
jze9
71887b084a config: docker-compose — реальные адреса БД и Redis (192.168.1.11, 192.168.1.19) 2026-05-18 11:13:28 +00:00
jze9
94e78a9e77 fix: единый singleton engine в bd, убраны 17 локальных make_engine()
- bd/__init__.py: добавлены get_engine() и get_session() — один SQLAlchemy engine на весь процесс
- make_engine(): pool_size=5, max_overflow=5, pool_timeout=30, pool_recycle=1800
- Все 17 route-файлов: убраны локальные get_session()/make_engine(), импорт из bd
- transfer_crud.py: _get_session() → get_session() из bd
- init_data_base.py: get_engine() вместо make_engine()
- Результат: 78 idle соединений → 1
2026-05-18 11:13:28 +00:00
7d90737986 new docs 2026-04-10 01:39:47 +05:00
7096cc8d66 fix remuve users 2026-04-10 01:16:59 +05:00
e2fb4ddb2a big update new dashboard and import export 2026-04-10 00:39:35 +05:00
4013701441 add dashboard 2026-04-09 17:24:07 +05:00
ed6de0a4cb fix pawd 2026-04-09 16:47:51 +05:00
3a690ecf39 fix org 2026-04-09 16:43:59 +05:00
0b7a5bc454 update diogram 2026-04-07 15:37:04 +05:00
0528393f2f fix 2026-04-06 15:58:29 +05:00
7435992e15 push web 2026-04-06 15:40:06 +05:00
10fb240dcd new intarfece 2026-04-06 12:15:09 +05:00
fe078b8560 new view testing 2026-04-02 14:14:08 +05:00
8a189e310b fix install diogram 2026-04-02 12:00:11 +05:00
22bbc3a0a6 new shems 2026-04-01 14:18:52 +05:00
4e9b89fbe5 rename users 2026-03-31 18:10:12 +05:00
59b3bcd0f4 web sttarting 2026-03-31 13:25:14 +05:00
9cd6d83428 add user? new page? new web 2026-03-23 21:28:10 +05:00
04bfff3d3f flet web test 2026-03-20 12:11:55 +05:00
bf58b2d592 flet web 2026-03-20 12:10:41 +05:00
6509971527 fix smile 2026-03-01 21:40:53 +05:00
a1dd1a3279 fers web 2026-03-01 21:40:09 +05:00
105 changed files with 13477 additions and 434 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.venv
data
web-react
stats-react
__pycache__
*.py[cod]
.pytest_cache
.ruff_cache
*.egg-info
.env
*.log
*.docx

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
APP_PATH=/app
DEBUG=False
DB_HOST=postgres
DB_PORT=5432
DB_USER=postgres
DB_PASS=change-me
DB_NAME=profi
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=0
REDIS_PASSWORD=change-me
# python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change-me
ADMIN_KEY=change-me
DOCS_USERNAME=admin
DOCS_PASSWORD=change-me
API_URL=http://api:8000
# Пароль локального Postgres из docker-compose_db.yml (если не используете внешнюю БД)
POSTGRES_PASSWORD=change-me

4
.gitignore vendored
View File

@@ -42,3 +42,7 @@ venv.bak/
.env
*.log
*.sqlite
./node_modules
node_modules
data

View File

@@ -1,13 +0,0 @@
FROM python:3.14-slim
WORKDIR /app
# Копируем зависимости
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копируем приложение
COPY . .
# Стартуем приложение
CMD ["python", "main.py"]

17
Dockerfile.api Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.14-slim
WORKDIR /app
# Зависимости ставим через uv по pyproject.toml/uv.lock — тот же источник,
# что и в локальной разработке, версии не расходятся.
RUN pip install --no-cache-dir uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
# Копируем приложение
COPY . .
ENV PATH="/app/.venv/bin:$PATH"
# Стартуем приложение
CMD ["python", "main.py"]

632
README.md
View File

@@ -0,0 +1,632 @@
# api-copp
Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на React (SPA). Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. Тест можно пройти анонимно — достаточно выбрать школу и класс, аккаунт не нужен.
---
## Содержание
- [Архитектура](#архитектура)
- [Быстрый старт](#быстрый-старт)
- [Переменные окружения](#переменные-окружения)
- [Структура проекта](#структура-проекта)
- [База данных](#база-данных)
- [API](#api)
- [Перенос данных между серверами](#перенос-данных-между-серверами)
- [Паутинная диаграмма](#паутинная-диаграмма)
- [Redis и кэширование](#redis-и-кэширование)
- [Деплой с внешним прокси](#деплой-с-внешним-прокси)
- [Известные ошибки и решения](#известные-ошибки-и-решения)
---
## Архитектура
```
Браузер пользователя
│ :80
[ web-react — nginx (SPA + reverse-proxy /api) ] ← единственный публичный сервис
│ http://api:8000 (только Docker-сеть)
[ api — FastAPI :8000 ]
│ │
[ PostgreSQL :5432 ] [ Redis :6379 ]
```
Сервис `api` **не публикует порты наружу** — доступен только контейнеру `web-react` внутри Docker-сети. Браузер обращается к `/api/*`, nginx внутри `web-react` перенаписывает и проксирует запрос на `api:8000` (см. `web-react/nginx.conf`). Пользователь никогда не обращается к API напрямую.
PostgreSQL и Redis могут запускаться как:
- сервисами `postgres`/`redis` в том же `docker-compose.yml`, за профилем `local-db` (локально в Docker)
- внешними сервисами (отдельный сервер, managed service) — достаточно прописать нужные `REDIS_HOST`/`DB_HOST` в `.env`, профиль `local-db` не поднимать
---
## Быстрый старт
Один файл `docker-compose.yml` на всё. По умолчанию поднимает только приложение (`api` + `web-react` + `stats`) — БД и Redis ожидаются внешними (`DB_HOST`/`REDIS_HOST` в `.env`, см. «Переменные окружения»).
```bash
# Только приложение (внешние БД/Redis из .env)
docker compose up -d --build
# + локальные PostgreSQL и Redis в Docker (профиль local-db)
docker compose --profile local-db up -d --build
```
| Адрес | Что |
|---|---|
| `http://localhost` | Веб-интерфейс (React SPA) |
| `http://localhost:8000/docs` | Swagger UI (HTTP Basic Auth) |
| `http://localhost:8000/redoc` | ReDoc |
| `http://localhost:8081` | Дашборд статистики |
> Реальные значения переменных окружения (пароли, ключи) держите в `.env` в корне репозитория — `docker compose` подставляет их автоматически. Шаблон без секретов — в `.env.example`. Файл `.env` в `.gitignore`, в репозиторий не попадает.
### 3. Инициализация БД (первый запуск)
```bash
# Создать таблицы
curl -X POST http://localhost:8000/db/create-tables \
-H "X-Admin-Key: <ADMIN_KEY>"
# Применить все миграции (безопасно запускать повторно — IF NOT EXISTS)
curl -X POST http://localhost:8000/db/migrate \
-H "X-Admin-Key: <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: <ADMIN_KEY>"
```
> **Важно:** `db/migrate-cascade-user` нужно выполнить **один раз** на каждом сервере с уже существующей БД (созданной до апреля 2026). На новых базах — уже включено автоматически через `create-tables`.
### 4. Загрузка тестов
```bash
# Голланд (42 пары профессий)
curl -X POST http://localhost:8000/polls/holland -H "X-Admin-Key: <ADMIN_KEY>"
curl -X POST http://localhost:8000/polls/holland/<poll_id>/seed-scores -H "X-Admin-Key: <ADMIN_KEY>"
# Климов (ДДО)
curl -X POST http://localhost:8000/polls/klimov -H "X-Admin-Key: <ADMIN_KEY>"
curl -X POST http://localhost:8000/polls/klimov/<poll_id>/seed-scores -H "X-Admin-Key: <ADMIN_KEY>"
# Гломшток (Карта интересов)
curl -X POST http://localhost:8000/polls/glomshtok -H "X-Admin-Key: <ADMIN_KEY>"
curl -X POST http://localhost:8000/polls/glomshtok/<poll_id>/seed-scores -H "X-Admin-Key: <ADMIN_KEY>"
```
---
## Переменные окружения
> `docker-compose.yml` читает значения из `.env` (не коммитится, см. `.env.example` за шаблоном). Перед продакшн-деплоем обязательно смените все пароли и ключи в своём `.env`.
| Переменная | Описание | Пример |
|---|---|---|
| `APP_PATH` | Путь к директории приложения внутри контейнера | `/app` |
| `DEBUG` | Режим отладки API | `False` |
| `DB_HOST` | Хост PostgreSQL | `postgres` |
| `DB_PORT` | Порт PostgreSQL | `5432` |
| `DB_USER` | Пользователь БД | `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-эндпоинтов (`X-Admin-Key`) | — |
| `DOCS_USERNAME` | Логин для `/docs` и `/redoc` | `admin` |
| `DOCS_PASSWORD` | Пароль для `/docs` и `/redoc` | — |
| `API_URL` | URL API для фронтенда (только Docker-сеть) | `http://api:8000` |
---
## Структура проекта
```
api-copp/
├── main.py # точка входа FastAPI, регистрация роутеров
├── pyproject.toml / uv.lock # зависимости API (единственный источник — читает Dockerfile.api через uv)
├── .dockerignore # что не попадает в build context образов
├── .env.example # шаблон переменных окружения без секретов
├── Dockerfile.api # образ API
├── docker-compose.yml # api + web-react + stats-react (+ postgres/redis за профилем local-db)
├── bd/
│ ├── __init__.py # Settings (pydantic), make_engine()
│ └── tables/
│ ├── 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/
│ ├── 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-react/ # React SPA-фронтенд (порт 80, свой Dockerfile+nginx)
│ ├── src/api/client.ts # единая точка HTTP-запросов к API
│ ├── src/auth/AuthContext.tsx # токен-логин + анонимная сессия (школа+класс без аккаунта)
│ ├── src/pages/ # страницы (Login, AnonymousEntry, Poll, MyTests, ...)
│ └── nginx.conf # раздаёт статику SPA, проксирует /api → api:8000
├── stats-react/ # дашборд статистики, React SPA (порт 8081, свой Dockerfile+nginx)
│ ├── src/api/client.ts # HTTP-клиент к /stats/*, /polls/, /organizations/, /groups/
│ ├── src/components/charts/ # BarChart, LineChart, PartToWholeBar — свои SVG/HTML, без сторонних libs
│ └── src/App.tsx # фильтры + два таба (прохождения / результаты тестов)
└── data/ # том Docker (./data:/app/data)
├── postgres/ # данные PostgreSQL
└── radar/ # SVG-файлы диаграмм (структура: <тест>/<группа>/<user_id>.svg)
```
---
## База данных
### Схема связей
```
organizations ──< groups ──< users
│ CASCADE DELETE
polls ──< questions ──< choices ──< choice_scores
└──< 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 при удалении оси)
```
### Каскадное удаление
При удалении **пользователя** автоматически удаляется:
- все его `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`
### Миграции
Применяются через admin-эндпоинты (безопасно запускать повторно — используют `IF NOT EXISTS`/`IF EXISTS`):
| Эндпоинт | Что делает |
|---|---|
| `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` |
---
## API
### Аутентификация
```
POST /auth/register — регистрация (username ≥ 3 симв., password ≥ 8 симв.)
POST /auth/login — JWT-токен (OAuth2 password flow)
POST /auth/logout — отзыв токена (добавляет в Redis-блэклист)
```
Токен: `Authorization: Bearer <token>`
Admin-операции: `X-Admin-Key: <ADMIN_KEY>`
### Тесты и вопросы
```
GET /polls — список тестов
POST /polls — создать тест [admin]
GET /polls/{id} — тест с вопросами и вариантами
DELETE /polls/{id} — удалить тест [admin]
POST /polls/{id}/questions — добавить вопрос [admin]
POST /questions/{id}/choices — добавить вариант ответа [admin]
```
### Прохождение теста
```
POST /polls/{poll_id}/responses — сдать тест (автоматически считает радар + генерирует SVG)
GET /polls/users/{user_id}/responses — история пользователя
GET /responses/{id} — конкретный результат с ответами
```
`POST /polls/{poll_id}/responses` не требует авторизации: `user_id` необязателен. `web-react` использует это для анонимного прохождения — школьник на `/anonymous` выбирает только школу и класс (без пароля и аккаунта), выбор кладётся в `group_id`/`organization_id` тела запроса вместо `user_id`. Результат сохраняется в базе как снимок школы/класса без привязки к пользователю; истории результатов после закрытия вкладки у анонимной сессии нет — только у настоящих аккаунтов (`/my-tests`).
### Результаты (паутинная диаграмма)
```
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 пары) [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]
```
### Пользователи / Группы / Организации
```
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
```
---
## Перенос данных между серверами
Два сценария использования:
### Сценарий 1: Дополнение (transfer)
Ученики прошли тест на сервере A — нужно перенести их результаты на сервер B, не удаляя существующие данные на B.
```bash
# Сервер A — скачать архив (все таблицы + SVG-диаграммы)
curl -H "X-Admin-Key: <KEY_A>" \
http://server-a:8000/transfer/export -o export.zip
# Сервер B — на новой БД сначала создать схему (один раз)
curl -X POST -H "X-Admin-Key: <KEY_B>" http://server-b:8000/db/create-tables
curl -X POST -H "X-Admin-Key: <KEY_B>" http://server-b:8000/db/migrate
curl -X POST -H "X-Admin-Key: <KEY_B>" http://server-b:8000/db/migrate-cascade-user
# Сервер B — импортировать (добавляет только новое, дубли по UUID пропускаются)
curl -X POST -H "X-Admin-Key: <KEY_B>" \
-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: <KEY>" \
http://localhost:8000/backup/export -o backup_20260410.zip
# Восстановить из резервной копии (УДАЛЯЕТ все текущие данные, вставляет из архива)
curl -X POST -H "X-Admin-Key: <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-связи не рвутся.
---
## Паутинная диаграмма
### Как работает
При сдаче теста (`POST /polls/{id}/responses`) сервер автоматически:
1. Суммирует баллы по каждой оси (`ChoiceScore.score`) для выбранных вариантов
2. Сохраняет `radar_result` + `radar_result_items`
3. Генерирует SVG и сохраняет в `{APP_PATH}/data/radar/<тест>/<группа>/<user_id>.svg`
4. Записывает путь в `image_path` — всё в **одном** `session.commit()`
### SVG-файл
Генерируется чистым Python (`route/radar_svg_gen.py`):
- Фон `#2F184B`, полигон `rgba(155,114,207,0.55)`
- 5 концентрических сеток
- Подписи с именем типа и баллом
- Размер: 520×520 пикселей
### Скачивание из браузера
`web-react` отдаёт SVG через тот же same-origin прокси, что и остальной API: `<img src="/api/responses/{id}/radar/image">`, nginx внутри контейнера `web-react` перенаправляет запрос на `api:8000` (см. `web-react/nginx.conf`). Браузер по-прежнему не обращается к `api` напрямую — `api` не публикует порты.
---
## Redis и кэширование
### Настройка Redis
Используется connection pool (`max_connections=20`). При Redis с отключённой командой `CONFIG` (например, Debian `redis-server` по умолчанию) — настройки меняются только через `/etc/redis/redis.conf` + `systemctl restart redis`.
Рекомендуемые настройки для продакшна:
```
maxmemory 2048mb
maxmemory-policy volatile-lru
appendfsync everysec
timeout 300
tcp-keepalive 60
```
### Назначение ключей
| Префикс | Что хранит | TTL |
|---|---|---|
| `token_blacklist:<jti>` | Отозванные JWT-токены | До истечения токена |
| `cache:<md5>` | Кэш ответов `/stats/*` | 5 минут |
### Сброс кэша
Кэш статистики (`cache:*`) автоматически сбрасывается при удалении пользователя — чтобы счётчики обновились немедленно, а не через 5 минут.
---
## Тест Голланда
Модификация Г.В. Резапкиной теста профессиональной ориентации Дж. Голланда. 42 пары профессий, испытуемый выбирает предпочтительную в каждой паре.
| Тип | Название | Цвет |
|---|---|---|
| Р | Реалистичный | `#FF8C00` |
| И | Интеллектуальный | `#4169E1` |
| С | Социальный | `#32CD32` |
| К | Конвенциональный | `#9370DB` |
| П | Предприимчивый | `#DC143C` |
| А | Артистический | `#FF69B4` |
Каждый тип — 14 вопросов (14 максимальных баллов). `seed-scores` создаёт 6 осей (`ScaleDimension`) и 84 записи расшифровки (`ChoiceScore`).
---
## Деплой с внешним прокси
`web-react` — статический SPA + reverse-proxy внутри своего же контейнера (`web-react/nginx.conf`), долгоживущих WebSocket-соединений (как было у Flet) больше нет. Минимальная конфигурация внешнего nginx перед контейнером:
```nginx
location / {
proxy_pass http://<server-ip>:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
}
```
> **⚠ `nginx.conf` и `nginx.production.conf` в репозитории всё ещё написаны под старый Flet-фронтенд** (`upstream flet_app`, отдельный `location /ws` для WebSocket, `location /radar-image/`). После перехода на `web-react` эти файлы не актуальны и не обновлялись — `nginx.production.conf`, судя по всему, конфиг боевого внешнего nginx для `test.cloud-copp74.ru`, поэтому его правка не входит в эту уборку без отдельного подтверждения (ошибка здесь ломает публичный домен). Если используете внешний прокси в проде — сверьте его с примером выше перед следующим деплоем.
---
## Известные ошибки и решения
### 1. `api` недоступен из браузера ✅ (относилось к старому Flet-фронтенду)
**Проблема:** Docker-имя `api` не резолвится у браузера пользователя. Любой URL вида `http://api:8000/...` в `page.launch_url()` (Flet) давал ошибку DNS.
**Решение в `web-react`:** same-origin прокси `/api/*` в `web-react/nginx.conf` — браузер всегда обращается к тому же origin, что и отдал SPA, прямых обращений к `api` из браузера нет в принципе (см. «Скачивание из браузера» выше).
---
### 2. SVG записывался без `image_path` при сбое
**Проблема:** `image_path` обновлялся вторым `session.commit()` — при падении между первым и вторым записи оставались без пути к файлу.
**Решение:** SVG генерируется до коммита, `image_path` устанавливается до коммита. Один атомарный `commit()`.
---
### 3. `/docs` был открыт без авторизации ✅
**Решение:** `docs_url=None, openapi_url=None, redoc_url=None` — стандартные маршруты отключены. Вместо них собственные маршруты с HTTP Basic Auth (`secrets.compare_digest` против timing-атак).
---
### 4. Варианты ответов обрезались ✅ (относилось к старому Flet-фронтенду)
**Проблема:** `ft.Radio(label=...)` в Flet не переносит текст метки — длинные варианты обрезались.
**Решение:** актуально не для этого проекта — в `web-react` варианты ответа рендерятся обычной HTML-разметкой (`PollPage.tsx`), перенос текста работает из коробки.
---
### 5. `image_path` требует миграции на старых БД ✅
**Решение:**
```sql
ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512);
```
Применяется через `POST /db/migrate`.
---
### 6. При удалении пользователя оставались его ответы ✅
**Проблема:** `responses.user_id` был простым UUID без FK-ограничения. При удалении пользователя его ответы, answers и radar_results оставались в БД (висящие строки).
**Решение:** Добавлен 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` (клиент, Flet) — два разных генератора, второй нигде не вызывался.
**Решение:** старый Flet-фронтенд `web/` целиком удалён вместе с `Dockerfile.web` (заменён на `web-react` в коммите `eac6192`). Единственный генератор SVG теперь — `route/radar_svg_gen.py`.
---
### 11. Реальные секреты в `docker-compose.yml` ✅
**Проблема:** пароли БД/Redis, `SECRET_KEY`, `ADMIN_KEY` были прописаны в открытом виде прямо в `docker-compose.yml` (тогда ещё вместе с отдельным `docker-compose_db.yml`), закоммичены в git.
**Решение:** значения вынесены в `.env``.gitignore`, не коммитится), compose-файлы читают их через `${VAR}`. Шаблон без секретов — `.env.example`. **Важно:** старые значения уже есть в истории git (коммит `71887b0`) — сама по себе эта правка их не отзывает, реальные пароли на серверах БД/Redis нужно ротировать отдельно.
---
### 12. Зависимости API расходились между `pyproject.toml` и `requirements.txt` ✅
**Проблема:** `Dockerfile.api` ставил зависимости из `requirements.txt`, а не из `pyproject.toml`/`uv.lock` — версии успели разойтись (`redis==5.0.0` vs `>=7.1.0`, `sqlalchemy==2.0.32` vs `>=2.0.47`).
**Решение:** `Dockerfile.api` теперь ставит зависимости через `uv sync --frozen` напрямую из `pyproject.toml`/`uv.lock`. `requirements.txt` удалён — единственный источник правды. При переходе обнаружилась ещё одна расхождение: `python-multipart` (нужен FastAPI для `UploadFile`/`Form`, используется в `organizations_crud.py` при импорте .docx) был в `requirements.txt`, но отсутствовал в `pyproject.toml` — без него API падал при старте с `RuntimeError`. Добавлен в зависимости.
---
### 13. Деплой требовал двух отдельных compose-файлов ✅
**Было:** `docker-compose.yml` (api + web-react + stats) и `docker-compose_db.yml` (postgres + redis) — два отдельных файла и две команды на разворачивание.
**Решение:** `postgres`/`redis` перенесены в тот же `docker-compose.yml`, за профилем `local-db` — не поднимаются командой `docker compose up -d` по умолчанию (приложение по умолчанию ходит на внешние `DB_HOST`/`REDIS_HOST` из `.env`), но доступны через `docker compose --profile local-db up -d`, когда нужна локальная БД. `docker-compose_db.yml` удалён.
---
### 14. Дашборд статистики был на Flet — переписан на React ✅
**Проблема:** `stats/` (Flet, `Dockerfile.stats`) генерировал графики через matplotlib в PNG на сервере и отдавал их клиенту через Flutter web c долгоживущим WebSocket-соединением — та же архитектура, что подвела основной фронтенд (см. записи №14 выше). Плавающая версия `flet>=0.82.2` в какой-то момент подтянула мажорный `0.86.0` с несовместимым протоколом фреймирования, и дашборд перестал открываться в браузере.
**Решение:** `stats/` и `Dockerfile.stats` удалены, дашборд переписан на React SPA (`stats-react/`, тот же паттерн, что и `web-react/`) — same-origin прокси `/api/*` вместо WebSocket, графики — свой SVG/HTML без сторонних библиотек (bar/line/part-to-whole с hover-тултипами), функциональность 1:1 с оригиналом (2 таба, 4+4 графика, топ-10 таблица, все фильтры). Категориальная палитра графиков провалидирована на CVD-безопасность (`node scripts/validate_palette.js`) — у исходной 8-цветной палитры из `designer.py` эта проверка проваливалась по нескольким пунктам.

306
SECURITY.md Normal file
View File

@@ -0,0 +1,306 @@
# Безопасность проекта — объяснение для чайников
Этот документ объясняет простым языком, **что и зачем** было сделано для защиты API.
---
## Оглавление
1. [Что такое «небезопасный» API и почему это плохо](#1-что-такое-небезопасный-api)
2. [Что было сделано — краткий список](#2-что-было-сделано)
3. [Аутентификация — кто ты такой?](#3-аутентификация)
4. [JWT-токены — цифровой пропуск](#4-jwt-токены)
5. [Хеширование паролей](#5-хеширование-паролей)
6. [Защита маршрутов (роутов)](#6-защита-маршрутов)
7. [Admin Key — ключ администратора](#7-admin-key)
8. [Защита документации `/docs`](#8-защита-документации-docs)
9. [CORS — кто может обращаться к API из браузера](#9-cors)
10. [Переменные окружения — секреты не в коде](#10-переменные-окружения)
11. [Как всё настроить — пошаговая инструкция](#11-как-настроить)
12. [Как использовать API — примеры](#12-примеры-использования)
---
## 1. Что такое «небезопасный» API
Представь, что у тебя есть склад. **Небезопасный API** — это когда дверь склада открыта для всех: любой может зайти, взять что хочет или сломать что хочет.
Конкретно, без защиты:
- любой человек в интернете мог удалить любого пользователя
- любой мог посмотреть чужие данные
- пароли хранились бы в открытом виде — украли базу = украли все пароли
- документация (Swagger) была видна всем, давая полную карту API для взломщика
---
## 2. Что было сделано
| Что | Зачем |
|-----|-------|
| JWT-токены | Пользователь должен войти, чтобы делать что-то важное |
| Хеширование паролей (bcrypt) | Даже если украдут БД — пароли не читаемы |
| Проверка владельца при PUT/DELETE | Ты можешь менять только свои данные |
| Admin Key | Опасные операции (инициализация БД) — только для администратора |
| HTTP Basic Auth на `/docs` | Документация API скрыта за паролем |
| CORS | Браузерные запросы принимаются только с разрешённых сайтов |
| Секреты в переменных окружения | Пароли и ключи не хранятся в коде |
---
## 3. Аутентификация
**Аутентификация** = подтверждение того, кто ты есть.
Как это работает в нашем API:
```
1. Ты отправляешь логин и пароль на POST /auth/login
2. Сервер проверяет: правильный ли пароль?
3. Если да — сервер выдаёт тебе "токен" (длинную строку-пропуск)
4. При каждом следующем запросе ты добавляешь этот токен в заголовок
5. Сервер смотрит на токен и понимает, кто ты
```
Аналогия: как билет в кино. Купил один раз — показываешь при входе, не нужно снова платить.
**Регистрация**`POST /auth/register`:
```json
{
"username": "ivan",
"password": ойпароль123",
"first_name": "Иван",
"last_name": "Иванов"
}
```
**Вход**`POST /auth/login` (формат form-data):
```
username=ivan
password=мойпароль123
```
Ответ:
```json
{
"access_token": "eyJhbGci...(длинная строка)...",
"token_type": "bearer"
}
```
---
## 4. JWT-токены
**JWT** (JSON Web Token) — это как паспорт, только цифровой.
Токен состоит из трёх частей, разделённых точкой:
```
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiIxMjMifQ . SflKxwRJSMeKKF2QT4
заголовок данные подпись
```
- **Заголовок**: тип токена и алгоритм шифрования
- **Данные (payload)**: ID пользователя, время истечения токена
- **Подпись**: криптографическая подпись — без секретного ключа сервера её нельзя подделать
Аналогия: токен — это как печать на паспорте. Печать нельзя нарисовать самому (без настоящей печати), поэтому её нельзя подделать.
**Как использовать токен в запросах:**
В заголовке HTTP:
```
Authorization: Bearer eyJhbGci...(твой токен)...
```
**Срок жизни токена** задаётся переменной `ACCESS_TOKEN_EXPIRE_MINUTES` (по умолчанию 60 минут).
---
## 5. Хеширование паролей
Пароли **никогда** не хранятся в открытом виде. Вместо этого хранится их хеш.
```
Пароль: "мойпароль123"
↓ bcrypt
Хеш: "$2b$12$eImiTXuWVxfM37uY4JANjQ..."
```
**Почему это безопасно:**
- Хеш нельзя "расшифровать" обратно в пароль
- Даже если база данных будет украдена — злоумышленник увидит только хеши
- При проверке он не расшифровывает, а снова хеширует введённый пароль и сравнивает хеши
Алгоритм **bcrypt** специально медленный — это делает перебор паролей (brute force) очень долгим.
---
## 6. Защита маршрутов
Некоторые действия требуют авторизации, другие открыты для всех.
| Маршрут | Открыт? | Объяснение |
|---------|---------|-----------|
| `POST /auth/register` | ✅ Да | Регистрация открыта всем |
| `POST /auth/login` | ✅ Да | Вход открыт всем |
| `GET /groups/` | ✅ Да | Просмотр групп — публичное |
| `POST /groups/` | 🔒 Нет | Создать группу — нужен токен |
| `PUT /users/{id}` | 🔒 Нет | Изменить профиль — только свой |
| `DELETE /users/{id}` | 🔒 Нет | Удалить — только свой аккаунт |
| `POST /init_database` | 🔒 Admin | Только с Admin Key |
**Проверка владельца**: при попытке изменить или удалить чужие данные сервер вернёт ошибку `403 Forbidden`.
---
## 7. Admin Key
Некоторые операции очень опасны (например, инициализация базы данных). Для них нужен специальный ключ администратора.
**Как использовать:**
```
Заголовок запроса: X-Admin-Key: твой_секретный_admin_ключ
```
Если `ADMIN_KEY` не задан в переменных окружения — такие операции полностью отключены (сервер вернёт `503`).
---
## 8. Защита документации `/docs`
Swagger UI (`/docs`) — это интерактивная документация API. Она показывает все маршруты, параметры, позволяет делать запросы прямо из браузера. Это очень удобно для разработчика, но и очень удобно для взломщика.
**Решение:** HTTP Basic Auth — браузер сам показывает окошко с запросом пароля.
```
Открываешь http://твой-сервер/docs
Браузер показывает окошко: "Введите логин и пароль"
Вводишь DOCS_USERNAME и DOCS_PASSWORD
Видишь документацию
```
То же самое работает для `/redoc` и `/openapi.json`.
---
## 9. CORS
**CORS** (Cross-Origin Resource Sharing) — механизм браузера, который контролирует, с каких сайтов можно делать запросы к API.
Аналогия: CORS — это как охранник на входе, который проверяет твой бейдж (адрес сайта). Если бейдж не из списка — не пропустит.
Разрешённые источники в нашем проекте:
```
http://localhost:3000
http://127.0.0.1:3000
```
Это значит, что только фронтенд, запущенный локально на порту 3000, может обращаться к API из браузера.
> ⚠️ **Важно для production**: если у тебя есть публичный домен, добавь его в список `allow_origins` в `main.py`.
---
## 10. Переменные окружения
**Секреты никогда не должны быть в коде.** Если код попадёт на GitHub — все пароли станут публичными.
Все секреты хранятся в переменных окружения (в `docker-compose.yml` или в `.env` файле).
| Переменная | Что это | Как создать |
|-----------|---------|------------|
| `SECRET_KEY` | Ключ для подписи JWT-токенов | `python -c "import secrets; print(secrets.token_hex(32))"` |
| `ADMIN_KEY` | Пароль для admin-операций | Придумай длинную случайную строку |
| `DOCS_USERNAME` | Логин для доступа к `/docs` | Например: `admin` |
| `DOCS_PASSWORD` | Пароль для доступа к `/docs` | Придумай надёжный пароль |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | Сколько минут живёт токен | По умолчанию 60 |
---
## 11. Как настроить
### Шаг 1. Сгенерируй SECRET_KEY
Открой терминал и запусти:
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
Скопируй результат — это твой `SECRET_KEY`.
Сделай то же самое для `ADMIN_KEY`.
### Шаг 2. Задай пароль для документации
Придумай любой логин и пароль для `DOCS_USERNAME` и `DOCS_PASSWORD`.
### Шаг 3. Обнови docker-compose.yml
Найди блок `environment` в `docker-compose.yml` и замени все `REPLACE_ME_...` на реальные значения:
```yaml
environment:
- SECRET_KEY=abcdef1234... # результат из шага 1
- ADMIN_KEY=supersecret456 # результат из шага 1
- DOCS_USERNAME=admin
- DOCS_PASSWORD=мойпарольдокументации
```
### Шаг 4. Запусти проект
```bash
docker-compose up --build
```
### Шаг 5. Проверь
Открой в браузере `http://localhost:8000/docs` — должно появиться окошко с запросом пароля.
---
## 12. Примеры использования
### Зарегистрироваться
```bash
curl -X POST http://localhost:8000/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"ivan","password":"пароль123","first_name":"Иван","last_name":"Иванов"}'
```
### Войти и получить токен
```bash
curl -X POST http://localhost:8000/auth/login \
-d "username=ivan&password=пароль123"
```
### Использовать токен в запросе
```bash
TOKEN="eyJhbGci..." # твой токен из предыдущего шага
curl -X GET http://localhost:8000/users/me \
-H "Authorization: Bearer $TOKEN"
```
### Создать группу (нужен токен)
```bash
curl -X POST http://localhost:8000/groups/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name_group":"Группа 1","user_id":"uuid-пользователя"}'
```
### Открыть документацию в браузере
Просто зайди на `http://localhost:8000/docs` — браузер спросит логин/пароль.
---
## Что ещё можно улучшить (на будущее)
- **Rate limiting** — ограничение количества запросов с одного IP (защита от брутфорса)
- **Refresh tokens** — долгоживущие токены для обновления access token без повторного входа
- **HTTPS** — шифрование соединения (критично в production, настраивается на уровне nginx/proxy)
- **Логирование** — запись подозрительных действий (много неудачных попыток входа)
- **Email-верификация** — подтверждение email при регистрации

View File

@@ -1,34 +1,77 @@
# Database configuration package
from pydantic.generics import GenericModel
import re
from pydantic import BaseModel
import os
import urllib.parse
class Settings(GenericModel):
class Settings(BaseModel):
DB_HOST: str = os.getenv("DB_HOST", "postgres")
DB_PORT: int = int(os.getenv("DB_PORT", "5432"))
DB_USER: str = os.getenv("DB_USER", "postgres")
DB_PASS: str = os.getenv("DB_PASS", "")
DB_NAME: str = os.getenv("DB_NAME", "profi")
@classmethod
def validate_db_port(cls, v):
# Проверка и преобразование DB_PORT
if isinstance(v, str):
# Используем регулярное выражение для извлечения целого числа
match = re.search(r'\d+', v)
if match:
return int(match.group())
else:
raise ValueError(f"Invalid DB_PORT value: {v}")
return v
@property
def DATABASE_URL_asyncpg(self) -> str:
pwd = urllib.parse.quote_plus(str(self.DB_PASS))
return f"postgresql+asyncpg://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
@property
def DATABASE_URL_syncpg(self) -> str:
pwd = urllib.parse.quote_plus(str(self.DB_PASS))
return f"postgresql+pg8000://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
@property
def PG8000_CONNECT_ARGS(self) -> dict:
"""connect_args для pg8000 — отключаем SSL (сервер его не поддерживает)."""
return {"ssl_context": None}
def make_engine():
"""Создаёт SQLAlchemy engine с правильными параметрами для pg8000."""
from sqlalchemy import create_engine
s = Settings()
return create_engine(
s.DATABASE_URL_syncpg,
connect_args=s.PG8000_CONNECT_ARGS,
future=True,
pool_pre_ping=True,
pool_size=5,
max_overflow=5,
pool_timeout=30,
pool_recycle=1800,
)
def wait_for_db(retries: int = 15, delay: float = 2.0) -> None:
"""Ждёт, пока PostgreSQL станет доступен. Вызывается при старте приложения."""
import time
import logging
from sqlalchemy import text
logger = logging.getLogger("bd")
engine = make_engine()
for attempt in range(1, retries + 1):
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
logger.info("PostgreSQL is ready.")
return
except Exception as exc:
logger.warning("DB not ready (attempt %d/%d): %s", attempt, retries, exc)
if attempt < retries:
time.sleep(delay)
raise RuntimeError("PostgreSQL did not become ready in time.")
# ── Singleton engine + session factory (один движок на весь процесс) ───────
_engine = None
_SessionFactory = None
def get_engine():
global _engine
if _engine is None:
_engine = make_engine()
return _engine
def get_session():
global _SessionFactory
if _SessionFactory is None:
from sqlalchemy.orm import sessionmaker
_SessionFactory = sessionmaker(bind=get_engine(), future=True)
return _SessionFactory

View File

@@ -1,17 +1,13 @@
from sqlalchemy import Column, String, ForeignKey
from sqlalchemy import Column, String
from sqlalchemy.dialects.postgresql import UUID
from .simple_base import Base
import uuid
from sqlalchemy.orm import relationship
class Group(Base):
__tablename__ = "group"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name_group = Column(String(150), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user = relationship("User", backref="groups", passive_deletes=True)
name_group = Column(String(150), nullable=False, unique=True)
__all__ = ["Base", "Group"]

View File

@@ -8,10 +8,7 @@ class Organization(Base):
__tablename__ = "organization"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name_organization = Column(String(150), nullable=False)
group_id = Column(UUID(as_uuid=True), ForeignKey("group.id", ondelete="CASCADE"), nullable=False)
group = relationship("Group", backref="organizations", passive_deletes=True)
name_organization = Column(String(150), nullable=False, unique=True)
__all__ = ["Base", "Organization"]

41
bd/tables/radar_result.py Normal file
View File

@@ -0,0 +1,41 @@
"""
RadarResult — сохранённый результат прохождения теста в виде значений по осям.
RadarResultItem — отдельная ось с итоговым значением внутри результата.
"""
from sqlalchemy import Column, Float, String, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from .simple_base import Base
import uuid
class RadarResult(Base):
"""Итоговый результат (набор баллов по осям) за одно прохождение теста."""
__tablename__ = "radar_results"
__table_args__ = (UniqueConstraint("response_id", name="uq_radar_response"),)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
response_id = Column(UUID(as_uuid=True), ForeignKey("responses.id", ondelete="CASCADE"), nullable=False, unique=True)
computed_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Путь к сохранённому SVG-файлу (относительно APP_PATH/data/radar/)
image_path = Column(String(512), nullable=True)
items = relationship("RadarResultItem", backref="result", cascade="all, delete-orphan", passive_deletes=True)
class RadarResultItem(Base):
"""Значение по одной оси в рамках результата."""
__tablename__ = "radar_result_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
result_id = Column(UUID(as_uuid=True), ForeignKey("radar_results.id", ondelete="CASCADE"), nullable=False)
dimension_id = Column(UUID(as_uuid=True), ForeignKey("scale_dimensions.id", ondelete="SET NULL"), nullable=True)
dimension_name = Column(String(255), nullable=False) # снимок имени на момент расчёта
dimension_color = Column(String(32), nullable=True) # снимок цвета
dimension_position = Column(Float, nullable=True) # порядок на диаграмме
value = Column(Float, nullable=False, default=0.0)
__all__ = ["Base", "RadarResult", "RadarResultItem"]

View File

@@ -10,8 +10,11 @@ class Response(Base):
__tablename__ = "responses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
poll_id = Column(UUID(as_uuid=True), ForeignKey("polls.id"), nullable=False)
user_id = Column(UUID(as_uuid=True), nullable=True)
poll_id = Column(UUID(as_uuid=True), ForeignKey("polls.id", ondelete="CASCADE"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
# Снимок группы и организации пользователя на момент прохождения
group_id = Column(UUID(as_uuid=True), nullable=True)
organization_id = Column(UUID(as_uuid=True), nullable=True)
submitted_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
answers = relationship("Answer", backref="response", cascade="all, delete-orphan", passive_deletes=True)
@@ -22,7 +25,7 @@ class Answer(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
response_id = Column(UUID(as_uuid=True), ForeignKey("responses.id", ondelete="CASCADE"), nullable=False)
question_id = Column(UUID(as_uuid=True), ForeignKey("questions.id"), nullable=False)
question_id = Column(UUID(as_uuid=True), ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
choice_id = Column(UUID(as_uuid=True), nullable=True)
text = Column(Text, nullable=True)

37
bd/tables/scale.py Normal file
View File

@@ -0,0 +1,37 @@
"""
ScaleDimension — ось/сфера радарной диаграммы, привязанная к тесту.
ChoiceScore — расшифровка варианта ответа: сколько баллов в какую ось он вносит.
"""
from sqlalchemy import Column, String, Float, Integer, ForeignKey, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from .simple_base import Base
import uuid
class ScaleDimension(Base):
"""Ось/категория радарной диаграммы для конкретного теста."""
__tablename__ = "scale_dimensions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
poll_id = Column(UUID(as_uuid=True), ForeignKey("polls.id", ondelete="CASCADE"), nullable=False)
name = Column(String(255), nullable=False) # напр. "ЗДОРОВЬЕ"
description = Column(Text, nullable=True)
color = Column(String(32), nullable=True) # напр. "#FF8C00"
position = Column(Integer, nullable=True) # порядок на диаграмме
scores = relationship("ChoiceScore", backref="dimension", cascade="all, delete-orphan", passive_deletes=True)
class ChoiceScore(Base):
"""Расшифровка: вклад конкретного варианта ответа в конкретную ось."""
__tablename__ = "choice_scores"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
choice_id = Column(UUID(as_uuid=True), ForeignKey("choices.id", ondelete="CASCADE"), nullable=False)
dimension_id = Column(UUID(as_uuid=True), ForeignKey("scale_dimensions.id", ondelete="CASCADE"), nullable=False)
score = Column(Float, nullable=False, default=1.0) # значение, которое добавляется при выборе
__all__ = ["Base", "ScaleDimension", "ChoiceScore"]

View File

@@ -1,5 +1,6 @@
from sqlalchemy import Column, String
from sqlalchemy import Column, String, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from .simple_base import Base
import uuid
@@ -9,6 +10,14 @@ class User(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
first_name = Column(String(150), nullable=False)
last_name = Column(String(150), nullable=False)
username = Column(String(150), nullable=False, unique=True)
hashed_password = Column(String(256), nullable=False)
group_id = Column(UUID(as_uuid=True), ForeignKey("group.id", ondelete="SET NULL"), nullable=True)
organization_id = Column(UUID(as_uuid=True), ForeignKey("organization.id", ondelete="SET NULL"), nullable=True)
group = relationship("Group", foreign_keys=[group_id])
organization = relationship("Organization", foreign_keys=[organization_id])
__all__ = ["Base", "User"]

View File

@@ -1,21 +1,90 @@
services:
api:
build: .
build:
context: .
dockerfile: Dockerfile.api
ports:
- "8000:8000"
environment:
- APP_PATH=/home/user/api-copp
- DEBUG=True
- APP_PATH=/app
- DEBUG=${DEBUG}
- DB_HOST=${DB_HOST}
- DB_PORT=${DB_PORT}
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME}
- DB_HOST=192.168.1.11
- DB_PORT=5432
- DB_USER=admin1
- DB_PASS=Y6%r35RpckeP^F
- DB_NAME=profi
# Redis connection (external)
- REDIS_HOST=192.168.1.19
- REDIS_PORT=6379
- REDIS_DB=0
- REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
- REDIS_HOST=${REDIS_HOST}
- REDIS_PORT=${REDIS_PORT}
- REDIS_DB=${REDIS_DB}
- REDIS_PASSWORD=${REDIS_PASSWORD}
- SECRET_KEY=${SECRET_KEY}
- ADMIN_KEY=${ADMIN_KEY}
- DOCS_USERNAME=${DOCS_USERNAME}
- DOCS_PASSWORD=${DOCS_PASSWORD}
volumes:
- ./data:/app/data
- ./data/radar:/app/data/radar
restart: unless-stopped
web-react:
build:
context: ./web-react
dockerfile: Dockerfile
ports:
- "80:80"
depends_on:
- api
restart: unless-stopped
stats-react:
build:
context: ./stats-react
dockerfile: Dockerfile
ports:
- "8081:80"
depends_on:
- api
restart: unless-stopped
# ── Локальные БД (опционально) ─────────────────────────────────────────
# По умолчанию api ходит на внешние DB_HOST/REDIS_HOST из .env. Эти два
# сервиса поднимаются только явным флагом профиля:
# docker compose --profile local-db up -d
postgres:
image: postgres:latest
container_name: postgres
profiles: ["local-db"]
ports:
- "5432:5432"
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
redis:
image: redis:alpine
container_name: redis
profiles: ["local-db"]
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--appendonly yes
--appendfsync everysec
--maxmemory 256mb
--maxmemory-policy volatile-lru
--tcp-keepalive 60
--timeout 300
--loglevel notice
ports:
- 6379:6379
volumes:
- ./data/redis:/data
restart: always

110
import_orgs.py Normal file
View File

@@ -0,0 +1,110 @@
"""
Скрипт для массового импорта организаций из .docx файла через API.
Использование:
python import_orgs.py МБОУ.docx
Требования:
pip install python-docx httpx
Настройки ниже (или через переменные окружения):
API_URL — адрес API (по умолчанию http://localhost:8000)
ADMIN_KEY — значение переменной ADMIN_KEY из .env
"""
import sys
import os
import httpx
from docx import Document
API_URL = os.getenv("API_URL", "http://localhost:8000")
ADMIN_KEY = os.getenv("ADMIN_KEY", "")
def extract_names_from_docx(path: str) -> list[str]:
"""Извлекает непустые строки из параграфов и ячеек таблиц docx."""
doc = Document(path)
names = []
# Параграфы
for para in doc.paragraphs:
text = para.text.strip()
if text:
names.append(text)
# Таблицы (если список оформлен таблицей)
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
text = cell.text.strip()
if text and text not in names:
names.append(text)
return names
def import_organizations(names: list[str]) -> None:
if not ADMIN_KEY:
print("ОШИБКА: задайте ADMIN_KEY=... перед запуском")
print(" Пример: ADMIN_KEY=mykey python import_orgs.py МБОУ.docx")
sys.exit(1)
headers = {"X-Admin-Key": ADMIN_KEY, "Content-Type": "application/json"}
added = 0
skipped = 0
errors = 0
with httpx.Client(base_url=API_URL, headers=headers, timeout=30) as client:
for name in names:
try:
resp = client.post("/organizations/", json={"name_organization": name})
if resp.status_code == 201:
added += 1
print(f" [+] {name}")
elif resp.status_code == 400:
skipped += 1
print(f" [=] УЖЕ ЕСТЬ: {name}")
else:
errors += 1
print(f" [!] ОШИБКА {resp.status_code}: {name}{resp.text}")
except Exception as e:
errors += 1
print(f" [!] ОШИБКА соединения: {name}{e}")
print(f"\nГотово: добавлено {added}, пропущено {skipped}, ошибок {errors}")
def main():
if len(sys.argv) < 2:
print("Использование: python import_orgs.py <файл.docx>")
sys.exit(1)
docx_path = sys.argv[1]
if not os.path.exists(docx_path):
print(f"Файл не найден: {docx_path}")
sys.exit(1)
print(f"Читаю: {docx_path}")
names = extract_names_from_docx(docx_path)
print(f"Найдено строк: {len(names)}")
print()
# Показываем первые 10 для проверки
print("Первые 10 записей:")
for n in names[:10]:
print(f"{n}")
if len(names) > 10:
print(f" ... и ещё {len(names) - 10}")
print()
confirm = input("Начать импорт? (y/n): ").strip().lower()
if confirm != "y":
print("Отменено.")
sys.exit(0)
print()
import_organizations(names)
if __name__ == "__main__":
main()

78
main.py
View File

@@ -1,13 +1,18 @@
import os
import secrets
import tomllib
from pathlib import Path
from fastapi import FastAPI
from route import base, holland_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud, ui
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from route import base, holland_crud, klimov_crud, glomshtok_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud, scale_crud, radar_crud, stats_crud, transfer_crud
from route import auth
from bd import wait_for_db
import uvicorn
# Читаем версию из pyproject.toml
try:
# Получаем абсолютный путь к pyproject.toml
pyproject_path = Path(__file__).parent / "pyproject.toml"
with open(pyproject_path, "rb") as f:
pyproject = tomllib.load(f)
@@ -16,10 +21,45 @@ except Exception as e:
print(f"Warning: Could not read version from pyproject.toml: {e}")
VERSION = "0.1.0"
# Читаем переменные окружения
APP_PATH = os.getenv("APP_PATH", "/home/user/api-copp")
app = FastAPI(title="api", version=VERSION)
# ---------------------------------------------------------------------------
# Защита документации HTTP Basic Auth
# ---------------------------------------------------------------------------
_basic = HTTPBasic()
def _verify_docs(credentials: HTTPBasicCredentials = Depends(_basic)):
"""Проверяет логин/пароль для доступа к /docs и /redoc."""
docs_user = os.getenv("DOCS_USERNAME", "")
docs_pass = os.getenv("DOCS_PASSWORD", "")
if not docs_user or not docs_pass:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="API docs are disabled: DOCS_USERNAME / DOCS_PASSWORD not configured",
)
# secrets.compare_digest защищает от timing-атак (перебора по времени ответа)
ok_user = secrets.compare_digest(credentials.username.encode(), docs_user.encode())
ok_pass = secrets.compare_digest(credentials.password.encode(), docs_pass.encode())
if not (ok_user and ok_pass):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
# docs_url/openapi_url/redoc_url отключены — добавляем защищённые маршруты вручную
app = FastAPI(title="api", version=VERSION, docs_url=None, openapi_url=None, redoc_url=None)
# Разрешаем CORS для фронтенда (dev). При необходимости сузить список origins.
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost", "http://127.0.0.1"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(base.router)
app.include_router(init_data_base.router)
app.include_router(groups_crud.router)
@@ -30,11 +70,35 @@ app.include_router(choice_crud.router)
app.include_router(response_crud.router)
app.include_router(poll_crud.router)
app.include_router(holland_crud.router)
app.include_router(ui.router)
app.include_router(klimov_crud.router)
app.include_router(glomshtok_crud.router)
app.include_router(scale_crud.router)
app.include_router(radar_crud.router)
app.include_router(stats_crud.router)
app.include_router(transfer_crud.router)
app.include_router(transfer_crud.backup_router)
# ---------------------------------------------------------------------------
# Защищённые маршруты документации
# ---------------------------------------------------------------------------
@app.get("/docs", include_in_schema=False)
def get_docs(_: None = Depends(_verify_docs)):
return get_swagger_ui_html(openapi_url="/openapi.json", title="API Docs")
@app.get("/redoc", include_in_schema=False)
def get_redoc(_: None = Depends(_verify_docs)):
return get_redoc_html(openapi_url="/openapi.json", title="API Docs")
@app.get("/openapi.json", include_in_schema=False)
def get_openapi_schema(_: None = Depends(_verify_docs)):
return app.openapi()
# Доступна переменная APP_PATH для использования в приложении
print(f"Application path: {APP_PATH}")
wait_for_db()
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)

35
nginx.conf Normal file
View File

@@ -0,0 +1,35 @@
events {}
http {
server {
listen 80;
# API — проксируем /api/* → api:8000 (убираем префикс /api)
location /api/ {
proxy_pass http://api:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Flet WebSocket
location /ws {
proxy_pass http://web:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
# Всё остальное → веб-приложение
location / {
proxy_pass http://web:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

120
nginx.production.conf Normal file
View File

@@ -0,0 +1,120 @@
# ============================================================
# nginx — production config для test.cloud-copp74.ru
# Устанавливается на отдельном nginx-сервере с certbot.
#
# Замените APP_IP на внутренний IP сервера с Docker.
#
# Первый запуск (получение сертификата):
# 1. Разместите этот файл в /etc/nginx/sites-available/copp
# 2. Создайте симлинк: ln -s /etc/nginx/sites-available/copp /etc/nginx/sites-enabled/
# 3. Временно раскомментируйте блок certbot-challenge (см. ниже)
# 4. nginx -t && systemctl reload nginx
# 5. certbot --nginx -d test.cloud-copp74.ru
# 6. certbot создаст ssl-блок автоматически или используйте конфиг ниже
# ============================================================
# Сервер приложения
upstream flet_app {
server 192.168.1.18:80;
keepalive 32;
}
# ── HTTP → HTTPS редирект ────────────────────────────────────
server {
listen 80;
listen [::]:80;
server_name test.cloud-copp74.ru;
# certbot challenge (оставьте всегда, certbot сам обновляет сертификат)
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# ── HTTPS ────────────────────────────────────────────────────
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name test.cloud-copp74.ru;
# Сертификаты Let's Encrypt (certbot заполнит сам, или укажите вручную)
ssl_certificate /etc/letsencrypt/live/test.cloud-copp74.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/test.cloud-copp74.ru/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# ── Безопасность ────────────────────────────────────────
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ── Производительность ──────────────────────────────────
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_vary on;
client_max_body_size 16m;
# ── WebSocket (Flet) ────────────────────────────────────
# Flet держит постоянное WS-соединение на /ws
location /ws {
proxy_pass http://flet_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Долгоживущие WS-соединения — не рвать
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 10s;
}
# ── Статика Flet (assets, иконки) ───────────────────────
location ~* \.(js|css|woff2?|svg|png|jpg|ico|webp)$ {
proxy_pass http://flet_app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# Кешируем статику на клиенте 7 дней
expires 7d;
add_header Cache-Control "public, immutable";
access_log off;
}
# ── SVG диаграмма (radar-image) ─────────────────────────
location /radar-image/ {
proxy_pass http://flet_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
# ── Всё остальное → Flet app ────────────────────────────
location / {
proxy_pass http://flet_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}

View File

@@ -18,7 +18,15 @@ dependencies = [
"python-dotenv>=1.2.1",
"redis>=7.1.0",
"sqlalchemy>=2.0.47",
"psycopg>=3.3.3",
"pg8000>=1.29.0",
"flet>=0.82.2",
"jose>=1.0.0",
"passlib[bcrypt]>=1.7.4",
"bcrypt==4.0.1",
"python-jose>=3.5.0",
"matplotlib>=3.10.8",
"python-docx>=1.2.0",
"python-multipart>=0.0.20",
]
[project.optional-dependencies]

View File

@@ -17,26 +17,10 @@ class RedisSettings(GenericModel):
else:
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
@property
def connection_kwargs(self) -> dict:
"""Получить параметры подключения для redis.Redis()"""
return {
"host": self.REDIS_HOST,
"port": self.REDIS_PORT,
"db": self.REDIS_DB,
"password": self.REDIS_PASSWORD,
"decode_responses": True,
}
def get_client(self) -> redis.Redis:
"""Создать и вернуть Redis клиент"""
return redis.Redis(**self.connection_kwargs)
def test_connection(self) -> bool:
"""Протестировать подключение к Redis"""
try:
client = self.get_client()
client.ping()
get_redis_client().ping()
print(f"✓ Успешно подключено к Redis на {self.REDIS_HOST}:{self.REDIS_PORT}")
return True
except redis.ConnectionError as e:
@@ -47,6 +31,19 @@ class RedisSettings(GenericModel):
# Глобальная конфигурация
_redis_settings = RedisSettings()
# Единый connection pool на весь процесс (max_connections=20 — для FastAPI с несколькими воркерами)
_pool = redis.ConnectionPool(
host=_redis_settings.REDIS_HOST,
port=_redis_settings.REDIS_PORT,
db=_redis_settings.REDIS_DB,
password=_redis_settings.REDIS_PASSWORD,
decode_responses=True,
max_connections=20,
socket_connect_timeout=3,
socket_timeout=3,
retry_on_timeout=True,
)
def get_redis_settings() -> RedisSettings:
"""Получить конфигурацию Redis"""
@@ -54,9 +51,72 @@ def get_redis_settings() -> RedisSettings:
def get_redis_client() -> redis.Redis:
"""Получить готовый клиент Redis"""
return _redis_settings.get_client()
"""Получить клиент из общего connection pool (не создаёт новое TCP-соединение)."""
return redis.Redis(connection_pool=_pool)
__all__ = ["RedisSettings", "get_redis_settings", "get_redis_client"]
# ---------------------------------------------------------------------------
# Блэклист токенов
# ---------------------------------------------------------------------------
_BLACKLIST_PREFIX = "token_blacklist:"
def blacklist_token(jti: str, ttl_seconds: int) -> None:
"""Добавить jti токена в блэклист на ttl_seconds секунд."""
try:
client = get_redis_client()
client.setex(f"{_BLACKLIST_PREFIX}{jti}", ttl_seconds, "1")
except redis.RedisError:
pass # Redis недоступен — не блокируем работу
def is_token_blacklisted(jti: str) -> bool:
"""Проверить, отозван ли токен."""
try:
client = get_redis_client()
return client.exists(f"{_BLACKLIST_PREFIX}{jti}") > 0
except redis.RedisError:
return False # при ошибке Redis не блокируем пользователей
# ---------------------------------------------------------------------------
# Кэш
# ---------------------------------------------------------------------------
_CACHE_PREFIX = "cache:"
def cache_get(key: str) -> str | None:
"""Получить значение из кэша по ключу. Возвращает None если нет или Redis недоступен."""
try:
client = get_redis_client()
return client.get(f"{_CACHE_PREFIX}{key}")
except redis.RedisError:
return None
def cache_set(key: str, value: str, ttl_seconds: int = 300) -> None:
"""Сохранить строку в кэш с TTL (по умолчанию 5 минут)."""
try:
client = get_redis_client()
client.setex(f"{_CACHE_PREFIX}{key}", ttl_seconds, value)
except redis.RedisError:
pass
def cache_delete(key: str) -> None:
"""Удалить ключ из кэша."""
try:
client = get_redis_client()
client.delete(f"{_CACHE_PREFIX}{key}")
except redis.RedisError:
pass
__all__ = [
"RedisSettings", "get_redis_settings", "get_redis_client",
"blacklist_token", "is_token_blacklisted",
"cache_get", "cache_set", "cache_delete",
]

View File

@@ -1,17 +0,0 @@
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.1
click==8.3.1
fastapi==0.128.0
h11==0.16.0
idna==3.11
pydantic==2.12.5
pydantic_core==2.41.5
python-dotenv==1.2.1
redis==5.0.0
starlette==0.50.0
typing-inspection==0.4.2
typing_extensions==4.15.0
uvicorn==0.40.0
sqlalchemy==2.0.32
pg8000==1.29.0

92
route/auth.py Normal file
View File

@@ -0,0 +1,92 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, field_validator
from typing import Optional
from datetime import datetime, timezone
import uuid
from bd import get_session
from bd.tables.users import User
from route.auth_utils import hash_password, verify_password, create_access_token, SECRET_KEY, ALGORITHM, oauth2_scheme
from jose import jwt, JWTError
from redis_db import blacklist_token
router = APIRouter(tags=["auth"], prefix="/auth")
class RegisterIn(BaseModel):
username: str
password: str
first_name: str
last_name: str
group_id: Optional[str] = None
organization_id: Optional[str] = None
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
return v
@field_validator("username")
@classmethod
def username_clean(cls, v: str) -> str:
v = v.strip()
if len(v) < 3:
raise ValueError("Username must be at least 3 characters")
return v
@router.post("/register", status_code=201)
def register(payload: RegisterIn):
Session = get_session()
with Session() as session:
existing = session.query(User).filter(User.username == payload.username).first()
if existing:
raise HTTPException(status_code=400, detail="Username already taken")
user = User(
first_name=payload.first_name.strip(),
last_name=payload.last_name.strip(),
username=payload.username,
hashed_password=hash_password(payload.password),
group_id=uuid.UUID(payload.group_id) if payload.group_id else None,
organization_id=uuid.UUID(payload.organization_id) if payload.organization_id else None,
)
session.add(user)
session.commit()
session.refresh(user)
return {"id": str(user.id), "username": user.username}
@router.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends()):
Session = get_session()
with Session() as session:
user = session.query(User).filter(User.username == form.username).first()
if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
token = create_access_token({"sub": str(user.id)})
return {"access_token": token, "token_type": "bearer"}
@router.post("/logout")
def logout(token: str = Depends(oauth2_scheme)):
"""Инвалидирует текущий Bearer-токен через Redis блэклист."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
jti: str | None = payload.get("jti")
exp: int | None = payload.get("exp")
if jti and exp:
ttl = max(0, exp - int(datetime.now(timezone.utc).timestamp()))
blacklist_token(jti, ttl)
except JWTError:
pass # токен уже невалиден — ничего не делаем
return {"detail": "Successfully logged out"}

102
route/auth_utils.py Normal file
View File

@@ -0,0 +1,102 @@
import hashlib
import os
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import Depends, Header, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from bd import get_session
SECRET_KEY: str = os.getenv("SECRET_KEY", "")
if not SECRET_KEY:
import secrets
SECRET_KEY = secrets.token_hex(32)
print("WARNING: SECRET_KEY not set in environment. Using a random key — tokens will be invalidated on restart!")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def _prehash(password: str) -> str:
"""SHA-256 пред-хеш чтобы обойти 72-байтовый лимит bcrypt."""
return hashlib.sha256(password.encode()).hexdigest()
def hash_password(password: str) -> str:
return pwd_context.hash(_prehash(password))
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(_prehash(plain), hashed)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode["exp"] = expire
to_encode["jti"] = str(uuid.uuid4()) # уникальный ID токена для блэклиста
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(token: str = Depends(oauth2_scheme)):
"""FastAPI dependency: валидирует Bearer-токен и возвращает объект User."""
from redis_db import is_token_blacklisted
credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
jti: str = payload.get("jti")
if user_id is None:
raise credentials_exc
if jti and is_token_blacklisted(jti):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has been revoked",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise credentials_exc
from bd.tables.users import User
try:
uid = uuid.UUID(user_id)
except Exception:
raise credentials_exc
Session = get_session()
with Session() as session:
user = session.get(User, uid)
if user is None:
raise credentials_exc
# detach from session so object can be used outside
session.expunge(user)
return user
def require_admin_key(x_admin_key: Optional[str] = Header(default=None)):
"""FastAPI dependency: проверяет секретный ключ администратора (заголовок X-Admin-Key)."""
admin_key = os.getenv("ADMIN_KEY", "")
if not admin_key:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Admin access is disabled: ADMIN_KEY not configured",
)
if x_admin_key != admin_key:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid admin key",
)

View File

@@ -1,14 +1,13 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
import uuid
from bd import Settings
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from bd import get_session
from bd.tables.choice import Choice
from bd.tables.question import Question
from route.auth_utils import require_admin_key
router = APIRouter(tags=["choices"], prefix="/choices")
@@ -24,13 +23,9 @@ class ChoiceUpdate(BaseModel):
position: Optional[int]
def get_session():
settings = Settings()
engine = create_engine(settings.DATABASE_URL_syncpg, future=True)
return sessionmaker(bind=engine, future=True)
@router.post("/", status_code=201)
@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])
def create_choice(payload: ChoiceCreate):
Session = get_session()
try:
@@ -48,7 +43,7 @@ def create_choice(payload: ChoiceCreate):
return {"id": str(ch.id)}
@router.put("/{choice_id}")
@router.put("/{choice_id}", dependencies=[Depends(require_admin_key)])
def update_choice(choice_id: str, payload: ChoiceUpdate):
Session = get_session()
try:
@@ -69,7 +64,7 @@ def update_choice(choice_id: str, payload: ChoiceUpdate):
return {"id": str(ch.id)}
@router.delete("/{choice_id}", status_code=204)
@router.delete("/{choice_id}", status_code=204, dependencies=[Depends(require_admin_key)])
def delete_choice(choice_id: str):
Session = get_session()
try:

501
route/glomshtok_crud.py Normal file
View File

@@ -0,0 +1,501 @@
"""Эндпоинты для работы с тестом «Карта интересов»
(А.Е. Гломшток, модификация А.А. Азабель, А.Г. Грецова).
POST /polls/glomshtok — создать опрос в БД (144 утверждения, 5 вариантов ответа)
POST /polls/glomshtok/{id}/seed-scores — загрузить ключ расшифровки (24 сферы интересов)
GET /polls/glomshtok — список тестов «Карта интересов»
Ключ: вопрос с position=N относится к измерению dimension = ((N - 1) % 24) + 1.
Пример: вопросы 1, 25, 49, 73, 97, 121 → Биология; вопросы 13, 37, 61, 85, 109, 133 → Военные специальности.
Варианты ответа и баллы: ++ = +2 | + = +1 | 0 = 0 | - = 1 | -- = 2
Интерпретация суммы по сфере (6 вопросов × ±2, диапазон 12..+12):
12..6 → область деятельности активно отрицается («Что угодно, только не это!»)
5..1 → область деятельности интереса не вызывает
+1..+4 → интерес выражен слабо
+5..+7 → выраженный интерес
+8..+12 → ярко выраженный интерес
После прохождения теста (POST /polls/{id}/responses) диаграмма строится автоматически
и сохраняется в data/radar/<poll_title>/<user_id>/<result_id>.svg.
"""
from fastapi import APIRouter, HTTPException
import uuid
from bd import get_session
from bd.tables.poll import Poll
from bd.tables.question import Question
from bd.tables.choice import Choice
from bd.tables.scale import ScaleDimension, ChoiceScore
router = APIRouter(tags=["polls"], prefix="/polls")
# ---------------------------------------------------------------------------
# 144 утверждения «Карты интересов»
# Порядок строго соответствует PDF; индекс + 1 = номер вопроса.
# ---------------------------------------------------------------------------
STATEMENTS: list[str] = [
"Изучать разнообразие животного и растительного мира.", # 1
"Проводить физические эксперименты.", # 2
"Разрабатывать новые технологии химического синтеза.", # 3
"Изучать месторождения полезных ископаемых.", # 4
"Разрабатывать новые методы диагностики и лечения различных болезней.", # 5
"Устанавливать и налаживать работу компьютерных программ.", # 6
"Следить за последовательностью и технологиями строительных работ.", # 7
"Решать задачки из книг типа «Математические игры», «Занимательная математика».", # 8
"Планировать финансовую и производственную деятельность предприятия, фирмы.", # 9
"Помогать делать переводы документации.", # 10
"Управлять машиной, автобусом, трейлером и т. д.", # 11
"Заниматься в секции парашютистов, в авиаклубе или заниматься в секции яхтсменов, аквалангистов.", # 12
"Заниматься в стрелковой секции.", # 13
"Заниматься в историческом клубе, разыгрывать ролевые игры по сюжетам исторических событий.", # 14
"Изучать устройство и режимы работы станков.", # 15
"Писать статьи, фельетоны, очерки в периодические издания и в Интернет.", # 16
"Оказывать юридическую помощь людям, консультируя их по вопросам законодательства.", # 17
"Организовывать для ребят игры и праздники.", # 18
"Консультировать людей при крупных покупках (автомобиль, заграничный тур и т. д.).", # 19
"Тренировать команду спортсменов.", # 20
"Работать на музыкальных радиоканалах, составлять списки произведений для звучания в эфире.", # 21
"Сниматься в кино или играть на сцене.", # 22
"Делать интернет-сайты, веб-страницы.", # 23
"Разрабатывать мероприятия по охране численности редких растений и животных.", # 24
"Участвовать в биологических экспедициях, посещать биологические секции.", # 25
"Изучать законы природы, находить физические закономерности.", # 26
"Проводить опыты с различными веществами, следить за ходом химических реакций.", # 27
"Вести наблюдения за изменениями состояния атмосферы.", # 28
"Читать о том, как люди учились бороться с болезнями и изобретали новые лекарства.", # 29
"Находить и устранять причины сбоя в компьютерах, аппаратуре, приборах.", # 30
"Набрасывать эскизы или выполнять чертежи различных построек.", # 31
"Соревноваться в решении математических задач.", # 32
"Выступать посредником при заключении торговых сделок (искать покупателям продавцов и наоборот).", # 33
"Проводить экскурсии на иностранном языке для гостей нашего города.", # 34
"Управлять пожарной машиной.", # 35
"Управлять самолётом МЧС или скоростным катером.", # 36
"Участвовать в военизированных учениях («Зарница», «Разведчик» и др.).", # 37
"Посещать исторические музеи, изучать исторические памятники разных народов.", # 38
"Регулировать механизмы и заменять их в случае неисправности.", # 39
"Наблюдать и анализировать события, поступки людей, делать об этом репортажи.", # 40
"Искать и фиксировать следы на месте преступления.", # 41
"Заниматься репетиторством и преподавательской деятельностью.", # 42
"Изучать спрос покупателей на определённый товар.", # 43
"Читать специальные спортивные новости в газетах и на сайтах.", # 44
"Исполнять произведения на музыкальных инструментах с использованием различных техник игры.", # 45
"Изучать основы сценического искусства, творчество знаменитых мастеров сцены.", # 46
"Искать наиболее рациональное и эстетическое цветовое решение для интерьеров.", # 47
"Проводить наблюдения и контроль загрязнения окружающей среды.", # 48
"Изучать анатомию и физиологию животных.", # 49
"Собирать установки для проведения физических экспериментов.", # 50
"В лабораторных условиях определять степень загрязнённости почвы химическими веществами.", # 51
"Создавать ландшафтные карты с помощью компьютерных геоинформационных систем.", # 52
"Интересоваться причинами и способами лечения болезни.", # 53
"Прокладывать сетевые кабели и кабели силового питания.", # 54
"Изучать новые технологии строительства.", # 55
"Изучать языки программирования на компьютере.", # 56
"Осуществлять финансовые расчёты между предприятиями.", # 57
"Читать литературу на иностранном языке или смотреть фильмы без перевода.", # 58
"Управлять современным поездом дальнего следования.", # 59
"Оказывать экстренную помощь людям на терпящих бедствие судах.", # 60
"Быть военным инженером или командиром.", # 61
"Обсуждать исторические и текущие политические события России и других стран.", # 62
"Подбирать цветовые оттенки, красить дома, расписывать стены.", # 63
"Добывать эксклюзивную информацию, передавать её читателям и зрителям.", # 64
"Контролировать своевременную уплату налогов фирмами и физическими лицами.", # 65
"Обучать маленьких детей, играя с ними.", # 66
"Встречать и размещать пассажиров в салоне самолёта.", # 67
"Тренироваться в профессиональном спортивном клубе.", # 68
"Играть в музыкальной группе или в оркестре, следуя указаниям дирижёра.", # 69
"Вести концертные программы, объявлять зрителям новые номера программы.", # 70
"Создавать необычные модели одежды.", # 71
"Разрабатывать меры по снижению количества вредных производственных выбросов в окружающую среду.", # 72
"Разрабатывать средства борьбы с возбудителями заболеваний животных и растений.", # 73
"Изучать процессы взаимодействия элементарных частиц в ядерных реакторах.", # 74
"Брать на анализ химические пробы воды, продуктов питания, почвы и пр.", # 75
"Вести разведку месторождений полезных ископаемых: нефти, газа, драгоценных металлов и др.", # 76
"Тренироваться в навыках первой медицинской помощи.", # 77
"Проверять, испытывать, регулировать работу узлов автомобиля, самолёта, корабля и т. д.", # 78
"Готовить растворы, смеси для строительных работ.", # 79
"Разрабатывать программные алгоритмы для выполнения сложных расчётов.", # 80
"Вести финансовую документацию фирмы, предприятия.", # 81
"Изучать иностранные языки.", # 82
"Регулировать движение транспортных потоков на улицах города.", # 83
"Оказывать экстренную помощь людям «с воздуха» (например, управляя вертолётом МЧС).", # 84
"Изучать устройство оружия, военной техники.", # 85
"Анализировать по книгам исторические факты.", # 86
"Обеспечивать в домах систему отопления, исправную работу водопровода.", # 87
"Редактировать тексты книг, статей, выступлений высокопоставленных лиц.", # 88
"Выдвигать обвинения преступникам, назначать им наказания в соответствии с законом.", # 89
"Готовить школьников к олимпиадным заданиям, конкурсам.", # 90
"Помогать подбирать людям наряды и украшения для торжественных церемоний.", # 91
"Сдавать спортивные нормативы.", # 92
"Выступать в качестве солиста перед публикой на концертах, в клубах.", # 93
"Подбирать актёров для съёмки художественного фильма или рекламного клипа.", # 94
"Делать фотоснимки, монтировать фотографии с помощью компьютера.", # 95
"Заниматься мониторингом состояния воздуха на оживлённых городских магистралях.", # 96
"Отбирать и заготавливать семена для посадки, высаживать растения в зимних садах.", # 97
"Моделировать на компьютере физические законы природы.", # 98
"Создавать новые синтетические вещества из нефтепродуктов и других полезных ископаемых.", # 99
"Изучать особенности флоры и фауны различных регионов.", # 100
"Осуществлять уход за человеком во время его выздоровления после болезни.", # 101
"Заниматься в технической или электротехнической секции (например, в авиа- или судомоделировании).", # 102
"Изучать качества и условия применения строительных материалов.", # 103
"Писать компьютерные программы на основе анализа математических алгоритмов.", # 104
"Рассчитывать предполагаемый размер прибыли предприятия.", # 105
"Осуществлять перевод телепередач на русский язык.", # 106
"Консультировать людей по соблюдению правил дорожного движения.", # 107
"Изучать особенности управления яхтой в штормовую погоду.", # 108
"Предупреждать незаконное пересечение государственной границы иностранными гражданами.", # 109
"Разыскивать и собирать материалы, свидетельствующие о событиях прошлого.", # 110
"Обрабатывать и изготовлять металлические детали на фрезерном станке.", # 111
"Работать в средствах массовой информации, вести телепередачи.", # 112
"Выяснять у людей причины незаконных поступков, которые они скрывают.", # 113
"Организовывать походы для школьников.", # 114
"Разрабатывать рекламные акции для продвижения товара в магазинах города.", # 115
"Ездить в качестве участника на спортивные соревнования в другой город.", # 116
"Сочинять музыку для кинофильмов, телепередач, для известных исполнителей.", # 117
"Подготавливать и ставить трюки в кино.", # 118
"Придумывать оригинальные ювелирные изделия, украшения.", # 119
"Изучать взаимоотношения живых организмов с их средой обитания.", # 120
"Заниматься дрессировкой служебных собак или других животных.", # 121
"Читать книги типа «Занимательная физика», «Физики шутят» и т. п.", # 122
"Управлять технологическими процессами производства лекарств.", # 123
"Участвовать в географических экспедициях.", # 124
"Работать врачом на станции «Скорой помощи».", # 125
"Ремонтировать радиоприборы и различную аппаратуру.", # 126
"Продумывать планировку домов, этажей, квартир. Намечать план строительства.", # 127
"Отражать в виде чисел и формул какие-либо события, процессы или явления.", # 128
"Заключать сделки, договоры на выполнение определённых работ.", # 129
"Изучать происхождение слов и различных словосочетаний в разных языках.", # 130
"Тренироваться водить машину или мотоцикл для подготовки к гонке.", # 131
"Изучать особенности управления лёгким самолётом в ветреную погоду.", # 132
"Жить согласно уставу, носить военную форму.", # 133
"Участвовать в археологических экспедициях, работать на раскопках.", # 134
"Делать из дерева различные предметы, мебель и пр.", # 135
"Осуществлять литературную обработку статей в соответствии с жанровым своеобразием.", # 136
"Продумывать новые законы и поправки в кодексы, которые были бы удобны для применения.", # 137
"Руководить одной из секций в доме детского творчества.", # 138
"Помогать человеку улучшить внешность с помощью причёски, макияжа, подбора красивой одежды и т. п.", # 139
"Осуществлять честное и грамотное судейство на спортивных соревнованиях.", # 140
"Изучать творчество выдающихся композиторов, поэтов песен и исполнителей.", # 141
"Создавать творческие проекты, «раскручивать» исполнителей.", # 142
"Заниматься дизайном интерьеров.", # 143
"Следить за качеством воды, поступающей в городскую водопроводную сеть.", # 144
]
# ---------------------------------------------------------------------------
# 24 сферы интересов
# Порядок соответствует столбцам бланка ответов из PDF.
# ---------------------------------------------------------------------------
DIMENSIONS = [
{
"code": "1", "name": "Биология", "position": 1, "color": "#6B8E5E",
"description": (
"Интерес к живой природе, биологии, зоологии, ботанике, экспедициям. "
"Рекомендуемые профессии: биолог, зоолог, ботаник, агроном, ветеринар, эколог."
),
},
{
"code": "2", "name": "Физика", "position": 2, "color": "#4A7FA5",
"description": (
"Интерес к физическим законам, экспериментам, моделированию. "
"Рекомендуемые профессии: физик, инженер-физик, научный сотрудник, метролог."
),
},
{
"code": "3", "name": "Химия", "position": 3, "color": "#9B59B6",
"description": (
"Интерес к химическим процессам, лабораторным опытам, синтезу веществ. "
"Рекомендуемые профессии: химик, технолог, лаборант, провизор."
),
},
{
"code": "4", "name": "География", "position": 4, "color": "#27AE60",
"description": (
"Интерес к изучению Земли, природных ресурсов, экспедициям, геоинформатике. "
"Рекомендуемые профессии: географ, геолог, картограф, метеоролог."
),
},
{
"code": "5", "name": "Медицина", "position": 5, "color": "#E74C3C",
"description": (
"Интерес к здоровью, лечению людей и животных, медицинским технологиям. "
"Рекомендуемые профессии: врач, медсестра, фельдшер, фармацевт."
),
},
{
"code": "6", "name": "Техника и электроника", "position": 6, "color": "#2980B9",
"description": (
"Интерес к приборам, компьютерам, электронике, ремонту техники. "
"Рекомендуемые профессии: инженер, электроник, системный администратор, радиотехник."
),
},
{
"code": "7", "name": "Строительство", "position": 7, "color": "#795548",
"description": (
"Интерес к строительным технологиям, архитектуре, планировке зданий. "
"Рекомендуемые профессии: архитектор, прораб, строитель, проектировщик."
),
},
{
"code": "8", "name": "Математика", "position": 8, "color": "#1ABC9C",
"description": (
"Интерес к числам, формулам, задачам, алгоритмам, программированию. "
"Рекомендуемые профессии: математик, программист, статистик, аналитик данных."
),
},
{
"code": "9", "name": "Экономика", "position": 9, "color": "#F39C12",
"description": (
"Интерес к финансам, торговле, производственному планированию. "
"Рекомендуемые профессии: экономист, бухгалтер, финансист, менеджер."
),
},
{
"code": "10", "name": "Иностранные языки", "position": 10, "color": "#3498DB",
"description": (
"Интерес к языкам, переводу, межкультурным коммуникациям. "
"Рекомендуемые профессии: переводчик, лингвист, преподаватель иностранного языка."
),
},
{
"code": "11", "name": "Транспорт", "position": 11, "color": "#E67E22",
"description": (
"Интерес к управлению транспортом, логистике, ПДД. "
"Рекомендуемые профессии: водитель, машинист, диспетчер, логист."
),
},
{
"code": "12", "name": "Авиация, морское дело", "position": 12, "color": "#16A085",
"description": (
"Интерес к полётам, мореплаванию, экстремальным видам деятельности. "
"Рекомендуемые профессии: лётчик, штурман, капитан, водолаз, парашютист."
),
},
{
"code": "13", "name": "Военные специальности", "position": 13, "color": "#2C3E50",
"description": (
"Интерес к армии, военной технике, патриотическим видам деятельности. "
"Рекомендуемые профессии: офицер, пограничник, военный инженер, командир."
),
},
{
"code": "14", "name": "История", "position": 14, "color": "#8E6B3E",
"description": (
"Интерес к истории, политике, археологии, изучению прошлого. "
"Рекомендуемые профессии: историк, археолог, политолог, архивист."
),
},
{
"code": "15", "name": "Рабочие специальности", "position": 15, "color": "#7F8C8D",
"description": (
"Интерес к ручному труду, ремёслам, работе на станках. "
"Рекомендуемые профессии: слесарь, токарь, столяр, сантехник, маляр."
),
},
{
"code": "16", "name": "Журналистика", "position": 16, "color": "#C0392B",
"description": (
"Интерес к написанию текстов, СМИ, репортажам, медиапроизводству. "
"Рекомендуемые профессии: журналист, редактор, корреспондент, телеведущий."
),
},
{
"code": "17", "name": "Юриспруденция", "position": 17, "color": "#1F3A5F",
"description": (
"Интерес к законам, правопорядку, криминалистике, судопроизводству. "
"Рекомендуемые профессии: юрист, адвокат, судья, следователь, нотариус."
),
},
{
"code": "18", "name": "Педагогика", "position": 18, "color": "#D4A84B",
"description": (
"Интерес к обучению, воспитанию, работе с детьми. "
"Рекомендуемые профессии: учитель, воспитатель, педагог-психолог, тренер."
),
},
{
"code": "19", "name": "Сфера обслуживания, торговля", "position": 19, "color": "#F1948A",
"description": (
"Интерес к торговле, обслуживанию клиентов, маркетингу, сервису. "
"Рекомендуемые профессии: продавец, менеджер по продажам, администратор, стилист."
),
},
{
"code": "20", "name": "Физкультура и спорт", "position": 20, "color": "#28B463",
"description": (
"Интерес к спорту, тренировкам, соревнованиям, физическому развитию. "
"Рекомендуемые профессии: спортсмен, тренер, инструктор, спортивный судья."
),
},
{
"code": "21", "name": "Музыка", "position": 21, "color": "#A569BD",
"description": (
"Интерес к музыке, игре на инструментах, сочинению, звукорежиссуре. "
"Рекомендуемые профессии: музыкант, композитор, дирижёр, звукорежиссёр."
),
},
{
"code": "22", "name": "Сценическое искусство", "position": 22, "color": "#EC407A",
"description": (
"Интерес к театру, кино, сцене, перевоплощению, продюсированию. "
"Рекомендуемые профессии: актёр, режиссёр, сценарист, продюсер."
),
},
{
"code": "23", "name": "Изобразительное искусство", "position": 23, "color": "#FF8C42",
"description": (
"Интерес к рисованию, дизайну, фотографии, созданию визуальных образов. "
"Рекомендуемые профессии: художник, дизайнер, фотограф, ювелир."
),
},
{
"code": "24", "name": "Экология", "position": 24, "color": "#2ECC71",
"description": (
"Интерес к охране природы, экологическому мониторингу, контролю загрязнений. "
"Рекомендуемые профессии: эколог, природоохранный инспектор, санитарный врач."
),
},
]
# ---------------------------------------------------------------------------
# Шкала оценок: позиция варианта ответа → числовой балл
# ---------------------------------------------------------------------------
CHOICE_SCORES: dict[int, float] = {
1: 2.0, # ++
2: 1.0, # +
3: 0.0, # 0
4: -1.0, # -
5: -2.0, # --
}
CHOICE_LABELS: list[str] = ["++", "+", "0", "-", "--"]
@router.post("/glomshtok", status_code=201)
def create_glomshtok_poll():
"""
Создаёт тест «Карта интересов» в базе данных (144 утверждения, 5 вариантов ответа).
После создания вызовите POST /polls/glomshtok/{id}/seed-scores для загрузки ключа расшифровки.
"""
Session = get_session()
with Session() as session:
poll = Poll(
title="Карта интересов — А.Е. Гломшток (модификация А.А. Азабель, А.Г. Грецова)",
description=(
"Методика «Карта интересов» для профориентации. Оцените 144 утверждения по шкале: "
"«++» (очень хотелось бы), «+» (нравится), «0» (сомневаюсь), "
"«-» (не нравится), «--» (совершенно не нравится). "
"Определяет выраженность интересов в 24 сферах деятельности."
),
)
session.add(poll)
for idx, text in enumerate(STATEMENTS, start=1):
q = Question(text=text, position=idx, type="single", poll=poll)
session.add(q)
for pos, label in enumerate(CHOICE_LABELS, start=1):
ch = Choice(question=q, text=label, position=pos)
session.add(ch)
session.commit()
session.refresh(poll)
return {"id": str(poll.id), "questions": len(poll.questions)}
@router.get("/glomshtok")
def list_glomshtok_polls():
"""Возвращает список тестов «Карта интересов» Гломштока."""
Session = get_session()
with Session() as session:
rows = session.query(Poll).filter(Poll.title.like("%Гломшток%")).all()
return [{"id": str(p.id), "title": p.title, "description": p.description} for p in rows]
@router.post("/glomshtok/{poll_id}/seed-scores", status_code=201)
def seed_glomshtok_scores(poll_id: str):
"""
Загружает ключ расшифровки «Карты интересов» в базу данных:
- создаёт 24 оси диаграммы (ScaleDimension) для указанного теста
- для каждого из 144 вопросов привязывает 5 вариантов ответа к соответствующей оси (ChoiceScore)
с баллами: ++ = +2 | + = +1 | 0 = 0 | - = 1 | -- = 2
Формула: вопрос с position=N → измерение ((N 1) % 24) + 1
При повторном вызове оси и расшифровки пересоздаются.
"""
Session = get_session()
try:
pid = uuid.UUID(poll_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid poll UUID")
with Session() as session:
poll = session.get(Poll, pid)
if not poll:
raise HTTPException(status_code=404, detail="Poll not found")
questions = (
session.query(Question)
.filter(Question.poll_id == pid)
.order_by(Question.position.asc())
.all()
)
if len(questions) != 144:
raise HTTPException(
status_code=422,
detail=(
f"Ожидается 144 вопроса, найдено {len(questions)}. "
"Убедитесь, что тест создан через POST /polls/glomshtok."
),
)
# Удаляем старые оси (ChoiceScore удаляются каскадно)
old_dims = session.query(ScaleDimension).filter(ScaleDimension.poll_id == pid).all()
for d in old_dims:
session.delete(d)
session.flush()
# Создаём 24 оси: position → объект
dim_map: dict[int, ScaleDimension] = {}
for d_data in DIMENSIONS:
dim = ScaleDimension(
poll_id=pid,
name=d_data["name"],
description=d_data["description"],
color=d_data["color"],
position=d_data["position"],
)
session.add(dim)
session.flush()
dim_map[d_data["position"]] = dim
# Для каждого вопроса привязываем все 5 вариантов к соответствующей оси
scores_created = 0
for q in questions:
dim_idx = ((q.position - 1) % 24) + 1
dim = dim_map.get(dim_idx)
if dim is None:
continue
choices = (
session.query(Choice)
.filter(Choice.question_id == q.id)
.order_by(Choice.position.asc())
.all()
)
for ch in choices:
score_val = CHOICE_SCORES.get(ch.position, 0.0)
session.add(ChoiceScore(choice_id=ch.id, dimension_id=dim.id, score=score_val))
scores_created += 1
session.commit()
return {
"status": "ok",
"dimensions": len(DIMENSIONS),
"choice_scores": scores_created,
"message": (
"Расшифровка «Карты интересов» успешно загружена. "
"После прохождения теста диаграмма строится автоматически и "
"сохраняется в data/radar/."
),
}

View File

@@ -1,50 +1,42 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
import uuid
from bd import Settings
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from bd import get_session
from bd.tables.group import Group
from bd.tables.users import User
from route.auth_utils import require_admin_key
router = APIRouter(tags=["groups"], prefix="/groups")
class GroupCreate(BaseModel):
name_group: str
user_id: str
class GroupUpdate(BaseModel):
name_group: Optional[str]
user_id: Optional[str]
name_group: Optional[str] = None
def get_session():
settings = Settings()
engine = create_engine(settings.DATABASE_URL_syncpg, future=True)
return sessionmaker(bind=engine, future=True)
@router.post("/", status_code=201)
def _group_dict(grp: Group) -> dict:
return {"id": str(grp.id), "name_group": grp.name_group}
@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])
def create_group(payload: GroupCreate):
Session = get_session()
try:
uid = uuid.UUID(payload.user_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid user_id UUID")
with Session() as session:
user = session.get(User, uid)
if not user:
raise HTTPException(status_code=404, detail="User not found")
grp = Group(name_group=payload.name_group, user_id=uid)
existing = session.query(Group).filter(Group.name_group == payload.name_group).first()
if existing:
raise HTTPException(status_code=400, detail="Group name already exists")
grp = Group(name_group=payload.name_group)
session.add(grp)
session.commit()
session.refresh(grp)
return {"id": str(grp.id), "name_group": grp.name_group, "user_id": str(grp.user_id)}
return _group_dict(grp)
@router.get("/")
@@ -52,7 +44,7 @@ def list_groups():
Session = get_session()
with Session() as session:
rows = session.query(Group).all()
return [{"id": str(g.id), "name_group": g.name_group, "user_id": str(g.user_id)} for g in rows]
return [_group_dict(g) for g in rows]
@router.get("/{group_id}")
@@ -66,10 +58,10 @@ def get_group(group_id: str):
grp = session.get(Group, gid)
if not grp:
raise HTTPException(status_code=404, detail="Group not found")
return {"id": str(grp.id), "name_group": grp.name_group, "user_id": str(grp.user_id)}
return _group_dict(grp)
@router.put("/{group_id}")
@router.put("/{group_id}", dependencies=[Depends(require_admin_key)])
def update_group(group_id: str, payload: GroupUpdate):
Session = get_session()
try:
@@ -82,22 +74,13 @@ def update_group(group_id: str, payload: GroupUpdate):
raise HTTPException(status_code=404, detail="Group not found")
if payload.name_group is not None:
grp.name_group = payload.name_group
if payload.user_id is not None:
try:
new_uid = uuid.UUID(payload.user_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid user_id UUID")
user = session.get(User, new_uid)
if not user:
raise HTTPException(status_code=404, detail="User not found")
grp.user_id = new_uid
session.add(grp)
session.commit()
session.refresh(grp)
return {"id": str(grp.id), "name_group": grp.name_group, "user_id": str(grp.user_id)}
return _group_dict(grp)
@router.delete("/{group_id}", status_code=204)
@router.delete("/{group_id}", status_code=204, dependencies=[Depends(require_admin_key)])
def delete_group(group_id: str):
Session = get_session()
try:

View File

@@ -2,84 +2,259 @@ from fastapi import APIRouter, HTTPException
from typing import Optional
import uuid
from bd import Settings
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from bd import get_session
from bd.tables.poll import Poll
from bd.tables.question import Question
from bd.tables.choice import Choice
from bd.tables.scale import ScaleDimension, ChoiceScore
router = APIRouter(tags=["polls"], prefix="/polls")
# ---------------------------------------------------------------------------
# Вопросы теста — модификация Г.В. Резапкиной
# Пары профессий: (вариант А, вариант В)
# ---------------------------------------------------------------------------
PAIRS = [
("инженер-техник", "инженер-контролер"),
("вязальщик", "санитарный врач"),
("повар", "наборщик"),
("фотограф", "зав. магазином"),
("чертежник", "дизайнер"),
("философ", "психиатр"),
("ученый-химик", "бухгалтер"),
("редактор научного журнала", "адвокат"),
("лингвист", "переводчик художественной литературы"),
("педиатр", "статистик"),
("организатор воспитательной работы", "председатель профсоюза"),
("спортивный врач", "фельетонист"),
("нотариус", "снабженец"),
("перфоратор", "карикатурист"),
("политический деятель", "писатель"),
("садовник", "метеоролог"),
("водитель", "медсестра"),
("инженер-электрик", "секретарь-машинистка"),
("маляр", "художник по металлу"),
("биолог", "главный врач"),
("телеоператор", "режиссер"),
("гидролог", "ревизор"),
("зоолог", "зоотехник"),
("математик", "архитектор"),
("работник ИДН", "счетовод"),
("учитель", "милиционер"),
("воспитатель", "художник по керамике"),
("экономист", "заведующий отделом"),
("корректор", "критик"),
("завхоз", "директор"),
("радиоинженер", "специалист по ядерной физике"),
("водопроводчик", "наборщик"),
("агроном", "председатель сельхозкооператива"),
("закройщик-модельер", "декоратор"),
("археолог", "эксперт"),
("работник музея", "консультант"),
("ученый", "актер"),
("логопед", "стенографист"),
("врач", "дипломат"),
("главный бухгалтер", "директор"),
("поэт", "психолог"),
("архивариус", "скульптор"),
("инженер-технолог", "инженер-конструктор"), # 1
("электрорадиотехник", "врач-терапевт"), # 2
("оператор станков с числовым программным управлением", "кодировщик (обработка информации)"), # 3
("фотограф", "коммерсант"), # 4
("спасатель МЧС", "дизайнер"), # 5
("политолог", "психиатр"), # 6
("ученый химик", "бухгалтер"), # 7
("философ", "частный предприниматель"), # 8
("лингвист", "модельер"), # 9
("инспектор службы занятости населения", "статист"), # 10
("социальный педагог", "биржевой маклер"), # 11
("тренер", "искусствовед"), # 12
("нотариус", "менеджер"), # 13
("перфораторщик", "художник"), # 14
("лидер политической партии, общего движения", "писатель"), # 15
("закройщик", "метеоролог"), # 16
("водитель", "работник пресс-службы"), # 17
("чертежник", "риэлтер"), # 18
("специалист по ремонту компьютеров и оргтехники", "секретарь-референт"), # 19
("микробиолог", "психолог"), # 20
("видеооператор", "режиссер"), # 21
("экономист", "провизор"), # 22
("зоолог", "главный инженер"), # 23
("программист", "архитектор"), # 24
("работник инспекции по делам несовершеннолетних", "коммивояжер (сетевой маркетинг)"), # 25
("преподаватель", "биржевой маклер"), # 26
("воспитатель", "декоратор"), # 27
("реставратор", "зав. отделом предприятия"), # 28
("корректор", "литератор и кинокритик"), # 29
("фермер", "визажист"), # 30
("парикмахер", "социолог"), # 31
("экспедитор", "редактор"), # 32
("ветеринар", "директор (финансовый)"), # 33
("автомеханик", "стилист"), # 34
("археолог", "эксперт"), # 35
("библиограф", "корреспондент"), # 36
("эколог", "актер"), # 37
("логопед", "контролер"), # 38
("адвокат", "директор (глава АО)"), # 39
("кассир", "продюсер"), # 40
("поэт, писатель", "продавец"), # 41
("криминалист (баллистик)", "композитор"), # 42
]
# ---------------------------------------------------------------------------
# Ключ расшифровки: номер пары → (тип выбора А, тип выбора В)
# Типы: Р=Реалистический, И=Интеллектуальный, С=Социальный,
# К=Конвенциональный, П=Предприимчивый, А=Художественный
# Источник: Тест Дж. Голланда, модификация Г.В. Резапкиной
# ---------------------------------------------------------------------------
CHOICE_KEY: dict[int, tuple[str, str]] = {
1: ("Р", "И"), 2: ("Р", "С"), 3: ("Р", "К"), 4: ("Р", "П"), 5: ("Р", "А"),
6: ("И", "С"), 7: ("И", "К"), 8: ("И", "П"), 9: ("И", "А"), 10: ("С", "К"),
11: ("С", "П"), 12: ("С", "А"), 13: ("К", "П"), 14: ("К", "А"), 15: ("П", "А"),
16: ("Р", "И"), 17: ("Р", "С"), 18: ("К", "П"), 19: ("Р", "К"), 20: ("И", "С"),
21: ("Р", "А"), 22: ("И", "К"), 23: ("И", "П"), 24: ("И", "А"), 25: ("С", "П"),
26: ("С", "П"), 27: ("С", "А"), 28: ("Р", "П"), 29: ("К", "А"), 30: ("П", "А"),
31: ("Р", "И"), 32: ("Р", "К"), 33: ("Р", "П"), 34: ("Р", "А"), 35: ("И", "К"),
36: ("И", "С"), 37: ("И", "А"), 38: ("С", "К"), 39: ("С", "П"), 40: ("К", "П"),
41: ("А", "С"), 42: ("К", "А"),
}
# ---------------------------------------------------------------------------
# Описания шести типов личности по Голланду
# ---------------------------------------------------------------------------
DIMENSIONS = [
{
"code": "Р", "name": "Реалистический", "position": 1, "color": "#6B8E5E",
"description": (
"Предпочитает работать с вещами, а не с людьми. Несоциальный, эмоционально-стабильный тип. "
"Ориентирован на настоящее, определённое. Занимается конкретными объектами и их использованием "
"(вещи, инструменты, техника). Трудолюбив, пластичен. Преобладают невербальные и математические "
"способности. Охотнее делает, чем говорит; настойчив, уверен в себе. "
"Рекомендуемые профессии: оператор ПК, техник, шофёр, ювелир, автомеханик, фермер, инженер."
),
},
{
"code": "И", "name": "Интеллектуальный", "position": 2, "color": "#4A7FA5",
"description": (
"Ориентирован на труд с идеями и объектами. Любознательный, методичный, любит работать в одиночку. "
"Аналитические способности, рационализм, независимость и оригинальность мышления. "
"Нередко выбирает научную и исследовательскую работу. Мир идей для него важнее, чем общение с людьми. "
"Рекомендуемые профессии: метеоролог, научный работник, физик, химик, хирург, биолог."
),
},
{
"code": "С", "name": "Социальный", "position": 3, "color": "#D4A84B",
"description": (
"Ориентирован на общение и взаимодействие с людьми. Ответственен, терпелив, эмпатичен. "
"Развитые вербальные способности. Предпочитает деятельность, связанную с обучением, воспитанием, "
"лечением, консультированием. Гуманен, чувствителен, активен. "
"Рекомендуемые профессии: учитель, психолог, логопед, врач, продавец."
),
},
{
"code": "К", "name": "Конвенциональный", "position": 4, "color": "#8B7355",
"description": (
"Отдаёт предпочтение чётко структурированной деятельности. Консервативен, аккуратен, пунктуален. "
"Хорошо работает с информацией в виде цифр, формул, текстов. Прекрасный исполнитель. "
"Склонен к канцелярской и конторской работе. "
"Рекомендуемые профессии: экономист, бухгалтер, нотариус, кассир, архивариус, налоговый инспектор."
),
},
{
"code": "П", "name": "Предприимчивый", "position": 5, "color": "#C0392B",
"description": (
"Выбирает цели и задачи, которые позволяют проявить энергию и энтузиазм. Самоуверен, настойчив. "
"Находчив, практичен, быстро ориентируется в сложной обстановке. Социально активен, готов рисковать. "
"Предпочитает деятельность, связанную с руководством и влиянием на людей. "
"Рекомендуемые профессии: руководитель, директор, судья, адвокат, брокер, предприниматель, риэлтор."
),
},
{
"code": "А", "name": "Художественный", "position": 6, "color": "#8E44AD",
"description": (
"Сложный взгляд на жизнь, гибкость и независимость в принятии решений. Богатое воображение, "
"склонность к творческой деятельности. Оригинален, эмоционален, не терпит жёсткой регламентации. "
"Преобладают вербальные способности. "
"Рекомендуемые профессии: писатель, фотограф, музыкант, художник, журналист, архитектор, дизайнер."
),
},
]
def get_session():
settings = Settings()
engine = create_engine(settings.DATABASE_URL_syncpg, future=True)
return sessionmaker(bind=engine, future=True)
@router.post("/holland", status_code=201)
def create_holland_poll(author_id: Optional[str] = None):
Session = get_session()
with Session() as session:
poll = Poll(title="Тест Д. Голланда — определение типа личности",
description="Тест Д. Голланда: выберите из каждой пары предпочитаемую профессию.")
# polls are anonymous; ignore author_id
poll = Poll(
title="Тест Дж. Голланда — определение типа личности",
description=(
"Тест Дж. Голланда (модификация Г.В. Резапкиной): выберите из каждой пары "
"предпочитаемую профессию. Из 42 пар профессий отметьте ту, которая вам больше нравится."
),
)
session.add(poll)
for idx, (a, b) in enumerate(PAIRS, start=1):
q = Question(text=f"Вариант {idx}", position=idx, type="single", poll=poll)
session.add(q)
ch1 = Choice(question=q, text=f"а) {a}", position=1)
ch2 = Choice(question=q, text=f"б) {b}", position=2)
ch1 = Choice(question=q, text=f"А) {a}", position=1)
ch2 = Choice(question=q, text=f"В) {b}", position=2)
session.add(ch1)
session.add(ch2)
session.commit()
session.refresh(poll)
return {"id": str(poll.id), "questions": len(poll.questions)}
@router.get("/holland")
def list_holland_polls():
Session = get_session()
with Session() as session:
rows = session.query(Poll).filter(Poll.title.like("%Голланда%")).all()
return [{"id": str(p.id), "title": p.title, "description": p.description} for p in rows]
@router.post("/holland/{poll_id}/seed-scores", status_code=201)
def seed_holland_scores(poll_id: str):
"""
Загружает расшифровку теста Голланда (модификация Резапкиной) в базу данных:
- создаёт 6 осей диаграммы (ScaleDimension) для указанного теста
- для каждого из 42 вопросов привязывает оба варианта ответа к своей оси (ChoiceScore)
При повторном вызове старые оси и расшифровки пересоздаются.
"""
Session = get_session()
try:
pid = uuid.UUID(poll_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid poll UUID")
with Session() as session:
poll = session.get(Poll, pid)
if not poll:
raise HTTPException(status_code=404, detail="Poll not found")
# Получаем вопросы упорядоченно по позиции
questions = (
session.query(Question)
.filter(Question.poll_id == pid)
.order_by(Question.position.asc())
.all()
)
if len(questions) != 42:
raise HTTPException(
status_code=422,
detail=f"Ожидается 42 вопроса, найдено {len(questions)}. Убедитесь, что тест создан через POST /polls/holland.",
)
# Удаляем старые оси (и ChoiceScore через cascade)
old_dims = session.query(ScaleDimension).filter(ScaleDimension.poll_id == pid).all()
for d in old_dims:
session.delete(d)
session.flush()
# Создаём 6 осей: код → объект
dim_map: dict[str, ScaleDimension] = {}
for d_data in DIMENSIONS:
dim = ScaleDimension(
poll_id=pid,
name=d_data["name"],
description=d_data["description"],
color=d_data["color"],
position=d_data["position"],
)
session.add(dim)
session.flush()
dim_map[d_data["code"]] = dim
# Для каждого вопроса привязываем варианты к осям
scores_created = 0
for q in questions:
pair_num = q.position # 1..42
if pair_num not in CHOICE_KEY:
continue
type_a, type_b = CHOICE_KEY[pair_num]
choices = (
session.query(Choice)
.filter(Choice.question_id == q.id)
.order_by(Choice.position.asc())
.all()
)
if len(choices) < 2:
continue
choice_a, choice_b = choices[0], choices[1]
session.add(ChoiceScore(choice_id=choice_a.id, dimension_id=dim_map[type_a].id, score=1.0))
session.add(ChoiceScore(choice_id=choice_b.id, dimension_id=dim_map[type_b].id, score=1.0))
scores_created += 2
session.commit()
return {
"status": "ok",
"dimensions": len(DIMENSIONS),
"choice_scores": scores_created,
"message": "Расшифровка теста Голланда успешно загружена в базу данных.",
}

View File

@@ -1,12 +1,15 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
import pkgutil
import importlib
from pathlib import Path
from typing import List
from bd import Settings
from sqlalchemy import create_engine
from bd import Settings, get_engine
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from pydantic import BaseModel
from route.auth_utils import require_admin_key
router = APIRouter(tags=["db"])
@@ -39,15 +42,13 @@ def collect_metadatas():
return metadatas
@router.post("/db/create-tables")
async def create_tables():
@router.post("/db/create-tables", dependencies=[Depends(require_admin_key)])
def create_tables():
"""Создаёт все таблицы, описанные в модулях `db.tables`.
Endpoint вызывается по нажатию кнопки в UI (POST).
"""
settings = Settings()
db_url = settings.DATABASE_URL_syncpg
engine = create_engine(db_url, future=True)
engine = get_engine()
metadatas = collect_metadatas()
if not metadatas:
@@ -67,3 +68,106 @@ async def create_tables():
return {"status": "ok", "detail": f"Created {len(unique)} metadata groups"}
except SQLAlchemyError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/db/migrate-cascade-user", dependencies=[Depends(require_admin_key)])
def migrate_cascade_user():
"""Добавляет FK responses.user_id → users.id ON DELETE CASCADE, если ещё не существует."""
engine = get_engine()
try:
with engine.connect() as conn:
conn.execute(text("""
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'responses_user_id_fkey'
) THEN
ALTER TABLE responses
ADD CONSTRAINT responses_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
END IF;
END $$;
"""))
conn.commit()
return {"status": "ok", "detail": "FK responses.user_id → users(id) ON DELETE CASCADE ensured"}
except SQLAlchemyError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/db/migrate-users", dependencies=[Depends(require_admin_key)])
def migrate_users():
"""Добавляет колонки username и hashed_password в таблицу users, если они ещё не существуют."""
engine = get_engine()
try:
with engine.connect() as conn:
conn.execute(text("""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS username VARCHAR(150),
ADD COLUMN IF NOT EXISTS hashed_password VARCHAR(256)
"""))
conn.commit()
return {"status": "ok", "detail": "Columns username and hashed_password ensured in users table"}
except SQLAlchemyError as e:
raise HTTPException(status_code=500, detail=str(e))
class ClearDBIn(BaseModel):
confirm: bool
@router.post("/db/migrate", dependencies=[Depends(require_admin_key)])
def migrate_tables():
"""Приводит схему БД в соответствие с моделями: убирает устаревшие колонки, добавляет новые."""
engine = get_engine()
migrations = [
# Убираем старый FK и колонку group.user_id (если остался от прежней схемы)
'ALTER TABLE "group" DROP COLUMN IF EXISTS user_id',
# Делаем organization.group_id необязательным
"ALTER TABLE organization ALTER COLUMN group_id DROP NOT NULL",
# Добавляем новые колонки в users
'ALTER TABLE users ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES "group"(id) ON DELETE SET NULL',
"ALTER TABLE users ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization(id) ON DELETE SET NULL",
# Добавляем путь к SVG-файлу диаграммы в radar_results
"ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512)",
]
applied = []
try:
with engine.begin() as conn:
for sql in migrations:
conn.execute(text(sql))
applied.append(sql)
return {"status": "ok", "applied": applied}
except SQLAlchemyError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/db/clear", dependencies=[Depends(require_admin_key)])
def clear_tables(payload: ClearDBIn):
"""Полная очистка всех таблиц, описанных в `bd.tables`.
Требуется явное подтверждение: POST с телом {"confirm": true}.
"""
if not payload.confirm:
raise HTTPException(status_code=400, detail="Confirmation required")
engine = get_engine()
metadatas = collect_metadatas()
if not metadatas:
raise HTTPException(status_code=400, detail="No table metadata found in db.tables")
# deduplicate metadata objects
unique = []
seen = set()
for md in metadatas:
if id(md) not in seen:
seen.add(id(md))
unique.append(md)
try:
with engine.begin() as conn:
conn.execute(text("DROP SCHEMA public CASCADE"))
conn.execute(text("CREATE SCHEMA public"))
return {"status": "ok", "detail": "All tables dropped via DROP SCHEMA CASCADE"}
except SQLAlchemyError as e:
raise HTTPException(status_code=500, detail=str(e))

290
route/klimov_crud.py Normal file
View File

@@ -0,0 +1,290 @@
"""
Эндпоинты для работы с тестом ДДО Е.А. Климова.
POST /polls/klimov — создать опрос в БД (42 пары → нет, 20 пар)
POST /polls/klimov/{id}/seed-scores — загрузить ключ расшифровки (5 типов)
GET /polls/klimov — список тестов Климова
После прохождения (POST /polls/{id}/responses) диаграмма строится автоматически
через response_crud → compute_radar и сохраняется в data/radar/<poll_title>/<user_id>/<result_id>.svg.
Путь к файлу хранится в таблице radar_results.image_path.
"""
from fastapi import APIRouter, HTTPException
from typing import Optional
import uuid
from bd import get_session
from bd.tables.poll import Poll
from bd.tables.question import Question
from bd.tables.choice import Choice
from bd.tables.scale import ScaleDimension, ChoiceScore
router = APIRouter(tags=["polls"], prefix="/polls")
# ---------------------------------------------------------------------------
# 20 пар вопросов ДДО Е.А. Климова
# Каждая пара: (вариант А, вариант Б)
# ---------------------------------------------------------------------------
PAIRS: list[tuple[str, str]] = [
("Ухаживать за животными.", # 1а
"Обслуживать машины, приборы (следить, регулировать)."), # 1б
("Помогать больным людям, лечить их.", # 2а
"Составлять таблицы, схемы, программы для вычислительных машин."), # 2б
("Следить за качеством книжных иллюстраций, плакатов, художественных открыток, грампластинок.", # 3а
"Следить за состоянием, развитием растений."), # 3б
("Обрабатывать материалы (дерево, ткань, пластмассу, металл и т.п.).", # 4а
"Доводить товары до потребителя (рекламировать, продавать)."), # 4б
("Обсуждать научно-популярные книги, статьи.", # 5а
"Обсуждать художественные книги (или пьесы, концерты)."), # 5б
("Выращивать молодняк (животных какой-либо породы).", # 6а
"Тренировать товарищей (или младших) в выполнении каких-либо действий (трудовых, учебных, спортивных)."), # 6б
("Копировать рисунки, изображения (или настраивать музыкальные инструменты).", # 7а
"Управлять каким-либо грузовым (подъёмным или транспортным) средством."), # 7б
("Сообщать, разъяснять людям нужные им сведения (в справочном бюро, на экскурсии и т.д.).", # 8а
"Художественно оформлять выставки, витрины (или участвовать в подготовке пьес, концертов)."), # 8б
("Ремонтировать вещи, изделия (одежду, технику), жилище.", # 9а
"Искать и исправлять ошибки в текстах, таблицах, рисунках."), # 9б
("Лечить животных.", # 10а
"Выполнять вычисления, расчёты."), # 10б
("Выводить новые сорта растений.", # 11а
"Конструировать, проектировать новые виды промышленных изделий (машины или одежду, дома, продукты питания и т.п.)."), # 11б
("Разбирать споры, ссоры между людьми, убеждать, разъяснять, поощрять, наказывать.", # 12а
"Разбираться в чертежах, схемах, таблицах (проверять, уточнять, приводить в порядок)."), # 12б
("Наблюдать, изучать работу кружков художественной самодеятельности.", # 13а
"Наблюдать, изучать жизнь микробов."), # 13б
("Обсуждать, налаживать медицинские приборы, аппараты.", # 14а
"Оказывать людям медицинскую помощь при ранениях, ушибах, ожогах и т.п."), # 14б
("Составлять точные описания, отчёты о наблюдаемых явлениях, событиях, измеряемых объектах.", # 15а
"Художественно описывать, изображать события (наблюдаемые или представляемые)."), # 15б
("Делать лабораторные анализы в больнице.", # 16а
"Принимать, осматривать больных, беседовать с ними, назначать лечение."), # 16б
("Красить или расписывать стены помещений, поверхность изделий.", # 17а
"Осуществлять монтаж зданий или сборку машин, приборов."), # 17б
("Организовывать культпоходы сверстников или младших в театры, музеи, экскурсии, туристические походы и т.п.", # 18а
"Играть на сцене, принимать участие в концертах."), # 18б
("Изготовлять по чертежам детали, изделия (машины, одежду), строить здания.", # 19а
"Заниматься черчением, копированием чертежей, карт."), # 19б
("Вести борьбу с болезнями растений, с вредителями леса, сада.", # 20а
"Работать на клавишных машинах."), # 20б
]
# ---------------------------------------------------------------------------
# Ключ расшифровки: номер пары → (тип варианта А, тип варианта Б)
# Типы: П — природа, Т — техника, Ч — человек, Х — худ. образ, З — знаки
# Источник: лист ответов ДДО Е.А. Климова
# ---------------------------------------------------------------------------
CHOICE_KEY: dict[int, tuple[str, str]] = {
1: ("П", "Т"),
2: ("Ч", "З"),
3: ("З", "П"), # 3а — знаковая, 3б — природа
4: ("Т", "Ч"),
5: ("З", "Х"),
6: ("П", "Ч"),
7: ("Х", "Т"),
8: ("Ч", "Х"),
9: ("Т", "З"),
10: ("П", "З"),
11: ("П", "Т"),
12: ("Ч", "З"),
13: ("Х", "П"),
14: ("Т", "Ч"),
15: ("З", "Х"),
16: ("П", "Ч"),
17: ("Х", "Т"),
18: ("Ч", "Х"),
19: ("Т", "З"),
20: ("П", "З"),
}
# ---------------------------------------------------------------------------
# 5 типов профессий по Климову
# ---------------------------------------------------------------------------
DIMENSIONS = [
{
"code": "П",
"name": "Человек — природа",
"position": 1,
"color": "#6B8E5E",
"description": (
"Представители этих профессий связаны с живой природой: сельское хозяйство, "
"биология, охрана окружающей среды. Требуются наблюдательность, заботливость, "
"терпение, самостоятельность. "
"Примеры: агроном, зоотехник, ветеринар, биолог, геолог, лесник, пчеловод."
),
},
{
"code": "Т",
"name": "Человек — техника",
"position": 2,
"color": "#4A7FA5",
"description": (
"Инженерно-технические профессии. Требуются точность, практическое мышление, "
"техническая фантазия. "
"Примеры: слесарь, токарь, водитель, электрик, инженер, монтажник, программист."
),
},
{
"code": "Ч",
"name": "Человек — человек",
"position": 3,
"color": "#D4A84B",
"description": (
"Профессии, основанные на взаимодействии с людьми. Требуются общительность, "
"эмпатия, терпение, умение слушать. "
"Примеры: учитель, врач, психолог, продавец, администратор."
),
},
{
"code": "З",
"name": "Человек — знаковая система",
"position": 4,
"color": "#8B7355",
"description": (
"Работа с цифрами, кодами, текстами, чертежами, формулами. Требуются "
"внимательность, аккуратность, умение абстрагироваться. "
"Примеры: программист, экономист, бухгалтер, корректор, чертёжник, переводчик."
),
},
{
"code": "Х",
"name": "Человек — художественный образ",
"position": 5,
"color": "#8E44AD",
"description": (
"Творческие профессии. Требуются воображение, образное мышление, талант, трудолюбие. "
"Примеры: художник, музыкант, актёр, архитектор, фотограф, дизайнер, писатель."
),
},
]
@router.post("/klimov", status_code=201)
def create_klimov_poll():
"""
Создаёт тест ДДО Е.А. Климова в базе данных (20 пар вопросов, 2 варианта каждый).
После создания вызовите POST /polls/klimov/{id}/seed-scores для загрузки ключа.
"""
Session = get_session()
with Session() as session:
poll = Poll(
title="ДДО Е.А. Климова — определение типа профессии",
description=(
"Дифференциально-диагностический опросник Е.А. Климова: выберите из каждой пары "
"предпочтительный вид занятий. 20 пар — выявляет ориентацию на 5 типов профессий: "
"«Человек — природа», «Человек — техника», «Человек — человек», "
"«Человек — знаковая система», «Человек — художественный образ»."
),
)
session.add(poll)
for idx, (a, b) in enumerate(PAIRS, start=1):
q = Question(text=f"Пара {idx}", position=idx, type="single", poll=poll)
session.add(q)
ch1 = Choice(question=q, text=f"А) {a}", position=1)
ch2 = Choice(question=q, text=f"Б) {b}", position=2)
session.add(ch1)
session.add(ch2)
session.commit()
session.refresh(poll)
return {"id": str(poll.id), "questions": len(poll.questions)}
@router.get("/klimov")
def list_klimov_polls():
"""Возвращает список тестов ДДО Климова."""
Session = get_session()
with Session() as session:
rows = session.query(Poll).filter(Poll.title.like("%Климова%")).all()
return [{"id": str(p.id), "title": p.title, "description": p.description} for p in rows]
@router.post("/klimov/{poll_id}/seed-scores", status_code=201)
def seed_klimov_scores(poll_id: str):
"""
Загружает расшифровку ДДО Климова в базу данных:
- создаёт 5 осей диаграммы (ScaleDimension) для указанного теста
- для каждого из 20 вопросов привязывает оба варианта ответа к своей оси (ChoiceScore)
При повторном вызове оси и расшифровки пересоздаются.
Диаграмма строится автоматически после прохождения теста (POST /polls/{id}/responses)
и сохраняется в data/radar/<poll_title>/<user_id>/<result_id>.svg.
"""
Session = get_session()
try:
pid = uuid.UUID(poll_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid poll UUID")
with Session() as session:
poll = session.get(Poll, pid)
if not poll:
raise HTTPException(status_code=404, detail="Poll not found")
questions = (
session.query(Question)
.filter(Question.poll_id == pid)
.order_by(Question.position.asc())
.all()
)
if len(questions) != 20:
raise HTTPException(
status_code=422,
detail=f"Ожидается 20 вопросов, найдено {len(questions)}. Убедитесь, что тест создан через POST /polls/klimov.",
)
# Удаляем старые оси (и ChoiceScore через cascade)
old_dims = session.query(ScaleDimension).filter(ScaleDimension.poll_id == pid).all()
for d in old_dims:
session.delete(d)
session.flush()
# Создаём 5 осей: код → объект
dim_map: dict[str, ScaleDimension] = {}
for d_data in DIMENSIONS:
dim = ScaleDimension(
poll_id=pid,
name=d_data["name"],
description=d_data["description"],
color=d_data["color"],
position=d_data["position"],
)
session.add(dim)
session.flush()
dim_map[d_data["code"]] = dim
# Для каждого вопроса привязываем варианты к осям
scores_created = 0
for q in questions:
pair_num = q.position # 1..20
if pair_num not in CHOICE_KEY:
continue
type_a, type_b = CHOICE_KEY[pair_num]
choices = (
session.query(Choice)
.filter(Choice.question_id == q.id)
.order_by(Choice.position.asc())
.all()
)
if len(choices) < 2:
continue
choice_a, choice_b = choices[0], choices[1]
session.add(ChoiceScore(choice_id=choice_a.id, dimension_id=dim_map[type_a].id, score=1.0))
session.add(ChoiceScore(choice_id=choice_b.id, dimension_id=dim_map[type_b].id, score=1.0))
scores_created += 2
session.commit()
return {
"status": "ok",
"dimensions": len(DIMENSIONS),
"choice_scores": scores_created,
"message": (
"Расшифровка ДДО Климова успешно загружена. "
"После прохождения теста диаграмма строится автоматически и "
"сохраняется в data/radar/."
),
}

View File

@@ -1,50 +1,48 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
import uuid
import io
from bd import Settings
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from bd import get_session
from bd.tables.organization import Organization
from bd.tables.group import Group
from route.auth_utils import require_admin_key
router = APIRouter(tags=["organizations"], prefix="/organizations")
class OrganizationCreate(BaseModel):
name_organization: str
group_id: str
class OrganizationUpdate(BaseModel):
name_organization: Optional[str]
group_id: Optional[str]
name_organization: Optional[str] = None
def get_session():
settings = Settings()
engine = create_engine(settings.DATABASE_URL_syncpg, future=True)
return sessionmaker(bind=engine, future=True)
@router.post("/", status_code=201)
def _org_dict(org: Organization) -> dict:
return {
"id": str(org.id),
"name_organization": org.name_organization,
}
@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])
def create_organization(payload: OrganizationCreate):
Session = get_session()
try:
gid = uuid.UUID(payload.group_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid group_id UUID")
with Session() as session:
grp = session.get(Group, gid)
if not grp:
raise HTTPException(status_code=404, detail="Group not found")
org = Organization(name_organization=payload.name_organization, group_id=gid)
existing = session.query(Organization).filter(
Organization.name_organization == payload.name_organization
).first()
if existing:
raise HTTPException(status_code=400, detail="Organization name already exists")
org = Organization(name_organization=payload.name_organization)
session.add(org)
session.commit()
session.refresh(org)
return {"id": str(org.id), "name_organization": org.name_organization, "group_id": str(org.group_id)}
return _org_dict(org)
@router.get("/")
@@ -52,7 +50,7 @@ def list_organizations():
Session = get_session()
with Session() as session:
rows = session.query(Organization).all()
return [{"id": str(o.id), "name_organization": o.name_organization, "group_id": str(o.group_id)} for o in rows]
return [_org_dict(o) for o in rows]
@router.get("/{org_id}")
@@ -66,10 +64,10 @@ def get_organization(org_id: str):
org = session.get(Organization, oid)
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
return {"id": str(org.id), "name_organization": org.name_organization, "group_id": str(org.group_id)}
return _org_dict(org)
@router.put("/{org_id}")
@router.put("/{org_id}", dependencies=[Depends(require_admin_key)])
def update_organization(org_id: str, payload: OrganizationUpdate):
Session = get_session()
try:
@@ -82,22 +80,13 @@ def update_organization(org_id: str, payload: OrganizationUpdate):
raise HTTPException(status_code=404, detail="Organization not found")
if payload.name_organization is not None:
org.name_organization = payload.name_organization
if payload.group_id is not None:
try:
new_gid = uuid.UUID(payload.group_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid group_id UUID")
grp = session.get(Group, new_gid)
if not grp:
raise HTTPException(status_code=404, detail="Group not found")
org.group_id = new_gid
session.add(org)
session.commit()
session.refresh(org)
return {"id": str(org.id), "name_organization": org.name_organization, "group_id": str(org.group_id)}
return _org_dict(org)
@router.delete("/{org_id}", status_code=204)
@router.delete("/{org_id}", status_code=204, dependencies=[Depends(require_admin_key)])
def delete_organization(org_id: str):
Session = get_session()
try:
@@ -111,3 +100,106 @@ def delete_organization(org_id: str):
session.delete(org)
session.commit()
return {}
def _extract_names_from_docx(content: bytes) -> list[str]:
"""Извлекает уникальные непустые строки из docx.
Для таблиц берётся только ПЕРВЫЙ столбец каждой строки.
Параграфы вне таблиц используются как запасной источник (если таблиц нет).
"""
from docx import Document as DocxDocument
doc = DocxDocument(io.BytesIO(content))
names: list[str] = []
seen: set[str] = set()
def _add(text: str):
t = text.strip()
if t and t not in seen:
seen.add(t)
names.append(t)
# Если есть таблицы — берём только первый столбец
if doc.tables:
for table in doc.tables:
for row in table.rows:
if row.cells:
_add(row.cells[0].text)
else:
# Нет таблиц — берём параграфы
for para in doc.paragraphs:
_add(para.text)
return names
@router.post("/import/docx/preview", dependencies=[Depends(require_admin_key)])
async def preview_import_from_docx(file: UploadFile = File(...)):
"""Предпросмотр: возвращает список строк, которые будут импортированы, БЕЗ записи в БД.
Используйте перед /import/docx чтобы убедиться, что извлекаются нужные данные.
"""
if not file.filename or not file.filename.lower().endswith(".docx"):
raise HTTPException(status_code=400, detail="Ожидается файл .docx")
try:
from docx import Document as DocxDocument # noqa: F401
except ImportError:
raise HTTPException(status_code=500, detail="python-docx не установлен на сервере")
content = await file.read()
try:
names = _extract_names_from_docx(content)
except Exception:
raise HTTPException(status_code=400, detail="Не удалось открыть файл")
return {"total": len(names), "names": names}
@router.post("/import/docx", dependencies=[Depends(require_admin_key)])
async def import_organizations_from_docx(file: UploadFile = File(...)):
"""Массовый импорт организаций из .docx файла.
Каждая непустая строка (параграф или ячейка таблицы) становится отдельной организацией.
Уже существующие названия пропускаются без ошибки.
Перед использованием проверьте результат через /import/docx/preview.
"""
if not file.filename or not file.filename.lower().endswith(".docx"):
raise HTTPException(status_code=400, detail="Ожидается файл .docx")
try:
from docx import Document as DocxDocument # noqa: F401
except ImportError:
raise HTTPException(status_code=500, detail="python-docx не установлен на сервере")
content = await file.read()
try:
names = _extract_names_from_docx(content)
except Exception:
raise HTTPException(status_code=400, detail="Не удалось открыть файл. Убедитесь, что это корректный .docx")
if not names:
raise HTTPException(status_code=400, detail="В файле не найдено ни одной строки")
Session = get_session()
added: list[str] = []
skipped: list[str] = []
with Session() as session:
for name in names:
existing = session.query(Organization).filter(
Organization.name_organization == name
).first()
if existing:
skipped.append(name)
continue
org = Organization(name_organization=name)
session.add(org)
added.append(name)
session.commit()
return {
"added": len(added),
"skipped": len(skipped),
"added_names": added,
"skipped_names": skipped,
}

View File

@@ -1,15 +1,14 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import List, Optional
import uuid
from bd import Settings
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from bd import get_session
from bd.tables.poll import Poll
from bd.tables.question import Question
from bd.tables.choice import Choice
from route.auth_utils import require_admin_key
router = APIRouter(tags=["polls"], prefix="/polls")
@@ -32,13 +31,9 @@ class PollIn(BaseModel):
questions: Optional[List[QuestionIn]]
def get_session():
settings = Settings()
engine = create_engine(settings.DATABASE_URL_syncpg, future=True)
return sessionmaker(bind=engine, future=True)
@router.post("/", status_code=201)
@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])
def create_poll(payload: PollIn):
Session = get_session()
with Session() as session:

View File

@@ -1,14 +1,13 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
import uuid
from bd import Settings
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from bd import get_session
from bd.tables.question import Question
from bd.tables.poll import Poll
from route.auth_utils import require_admin_key
router = APIRouter(tags=["questions"], prefix="/questions")
@@ -26,13 +25,9 @@ class QuestionUpdate(BaseModel):
position: Optional[int]
def get_session():
settings = Settings()
engine = create_engine(settings.DATABASE_URL_syncpg, future=True)
return sessionmaker(bind=engine, future=True)
@router.post("/", status_code=201)
@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])
def create_question(payload: QuestionCreate):
Session = get_session()
try:
@@ -50,7 +45,7 @@ def create_question(payload: QuestionCreate):
return {"id": str(q.id)}
@router.put("/{question_id}")
@router.put("/{question_id}", dependencies=[Depends(require_admin_key)])
def update_question(question_id: str, payload: QuestionUpdate):
Session = get_session()
try:
@@ -73,7 +68,7 @@ def update_question(question_id: str, payload: QuestionUpdate):
return {"id": str(q.id)}
@router.delete("/{question_id}", status_code=204)
@router.delete("/{question_id}", status_code=204, dependencies=[Depends(require_admin_key)])
def delete_question(question_id: str):
Session = get_session()
try:

267
route/radar_crud.py Normal file
View File

@@ -0,0 +1,267 @@
"""
Эндпоинты для работы с радарными результатами тестов.
POST /responses/{response_id}/radar/compute
— (пере)считать и сохранить результат как многогранную диаграмму.
Вызывается автоматически из response_crud при сдаче теста,
а также может быть вызван вручную (пересчёт).
GET /responses/{response_id}/radar
— получить сохранённый результат (данные для отрисовки диаграммы).
GET /users/{user_id}/radar-results
— история всех результатов пользователя (для всех тестов).
"""
import os
import uuid
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session as SASession
from bd import get_session
from bd.tables.response import Response, Answer
from bd.tables.poll import Poll
from bd.tables.scale import ScaleDimension, ChoiceScore
from bd.tables.radar_result import RadarResult, RadarResultItem
from route.radar_svg_gen import save_radar_svg
router = APIRouter(tags=["radar"])
# ---------------------------------------------------------------------------
# Схемы ответов
# ---------------------------------------------------------------------------
class RadarItemOut(BaseModel):
dimension_id: Optional[str]
dimension_name: str
dimension_color: Optional[str]
dimension_position: Optional[float]
value: float
class RadarResultOut(BaseModel):
id: str
response_id: str
computed_at: str
image_url: Optional[str] # URL для скачивания SVG с сервера
items: List[RadarItemOut]
# ---------------------------------------------------------------------------
# Вспомогательная функция расчёта — используется и внутри пакета
# ---------------------------------------------------------------------------
def compute_radar(response_id: uuid.UUID, session: SASession) -> RadarResult:
"""
Считает сумму баллов по каждой оси теста для данного ответа.
Если результат уже существует — пересоздаёт его.
Возвращает сохранённый объект RadarResult.
"""
resp: Response = session.get(Response, response_id)
if not resp:
raise HTTPException(status_code=404, detail="Response not found")
# Получаем все оси теста
dimensions = (
session.query(ScaleDimension)
.filter(ScaleDimension.poll_id == resp.poll_id)
.all()
)
if not dimensions:
raise HTTPException(
status_code=422,
detail="Poll has no scale dimensions defined. Add dimensions and choice scores first.",
)
# Собираем set выбранных вариантов
chosen_choice_ids = {
a.choice_id for a in resp.answers if a.choice_id is not None
}
# Считаем баллы по каждой оси
scores_map: dict[uuid.UUID, float] = {d.id: 0.0 for d in dimensions}
if chosen_choice_ids:
choice_scores = (
session.query(ChoiceScore)
.filter(ChoiceScore.choice_id.in_(chosen_choice_ids))
.all()
)
for cs in choice_scores:
if cs.dimension_id in scores_map:
scores_map[cs.dimension_id] += cs.score
# Удаляем старый результат, если есть
old = session.query(RadarResult).filter(RadarResult.response_id == response_id).first()
if old:
session.delete(old)
session.flush()
# Создаём новый
result = RadarResult(response_id=response_id)
session.add(result)
session.flush() # чтобы получить result.id до добавления items
dims_sorted = sorted(dimensions, key=lambda d: (d.position is None, d.position))
for dim in dims_sorted:
item = RadarResultItem(
result_id=result.id,
dimension_id=dim.id,
dimension_name=dim.name,
dimension_color=dim.color,
dimension_position=float(dim.position) if dim.position is not None else None,
value=scores_map[dim.id],
)
session.add(item)
# Генерируем SVG до коммита — result.id уже известен после flush(),
# items_data строим из scores_map/dimensions (они в памяти, не из БД).
# Так result + items + image_path фиксируются в одной транзакции.
items_data = [
{
"dimension_name": dim.name,
"dimension_color": dim.color,
"value": scores_map[dim.id],
}
for dim in dims_sorted
]
try:
poll = session.get(Poll, resp.poll_id)
poll_title = poll.title if poll else "unknown"
user_id_str = str(resp.user_id) if resp.user_id else "anonymous"
rel_path = save_radar_svg(
str(result.id), items_data,
max_val=0.0, # автомасштаб: шкала строится от данных
poll_title=poll_title,
user_id=user_id_str,
scale_padding=0.2, # +20% запаса над максимумом
min_scale=5.0, # шкала не менее 5 даже для малых значений
)
result.image_path = rel_path
except Exception:
pass # не ломаем сдачу, если генерация файла не удалась
session.commit() # единственный коммит: result + items + image_path
session.refresh(result)
return result
# ---------------------------------------------------------------------------
# Эндпоинты
# ---------------------------------------------------------------------------
@router.post("/responses/{response_id}/radar/compute", response_model=RadarResultOut,
status_code=200)
def compute_radar_endpoint(response_id: str):
"""Вычисляет (или пересчитывает) многогранный результат по прохождению теста."""
Session = get_session()
try:
rid = uuid.UUID(response_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid UUID")
with Session() as session:
result = compute_radar(rid, session)
return _result_to_out(result)
@router.get("/responses/{response_id}/radar", response_model=RadarResultOut)
def get_radar_result(response_id: str):
"""Возвращает сохранённый радарный результат для отображения диаграммы."""
Session = get_session()
try:
rid = uuid.UUID(response_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid UUID")
with Session() as session:
result = (
session.query(RadarResult)
.filter(RadarResult.response_id == rid)
.first()
)
if not result:
raise HTTPException(status_code=404, detail="Radar result not found. Submit the response first.")
return _result_to_out(result)
@router.get("/responses/{response_id}/radar/image")
def get_radar_image(response_id: str):
"""Отдаёт SVG-файл диаграммы. Если файл отсутствует — пересчитывает автоматически."""
Session = get_session()
try:
rid = uuid.UUID(response_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid UUID")
with Session() as session:
result = (
session.query(RadarResult)
.filter(RadarResult.response_id == rid)
.first()
)
if not result:
raise HTTPException(status_code=404, detail="Radar result not found. Submit the response first.")
app_path = os.getenv("APP_PATH", "/app")
full_path = os.path.join(app_path, result.image_path) if result.image_path else None
# Файл удалён или отсутствует — пересчитываем с актуальным алгоритмом
if not full_path or not os.path.isfile(full_path):
result = compute_radar(rid, session)
full_path = os.path.join(app_path, result.image_path) if result.image_path else None
if not full_path or not os.path.isfile(full_path):
raise HTTPException(status_code=500, detail="Failed to generate radar image")
return FileResponse(
full_path,
media_type="image/svg+xml",
filename=f"radar_{response_id}.svg",
headers={"Content-Disposition": f'attachment; filename="radar_{response_id}.svg"'},
)
@router.get("/users/{user_id}/radar-results", response_model=List[RadarResultOut])
def get_user_radar_history(user_id: str):
"""История радарных результатов пользователя по всем тестам."""
Session = get_session()
try:
uid = uuid.UUID(user_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid user UUID")
with Session() as session:
responses = (
session.query(Response)
.filter(Response.user_id == uid)
.all()
)
response_ids = [r.id for r in responses]
if not response_ids:
return []
results = (
session.query(RadarResult)
.filter(RadarResult.response_id.in_(response_ids))
.all()
)
return [_result_to_out(r) for r in results]
def _result_to_out(result: RadarResult) -> RadarResultOut:
# image_url возвращаем всегда — endpoint пересоздаст SVG если файл отсутствует
image_url = f"/responses/{result.response_id}/radar/image"
return RadarResultOut(
id=str(result.id),
response_id=str(result.response_id),
computed_at=result.computed_at.isoformat(),
image_url=image_url,
items=[
RadarItemOut(
dimension_id=str(item.dimension_id) if item.dimension_id else None,
dimension_name=item.dimension_name,
dimension_color=item.dimension_color,
dimension_position=item.dimension_position,
value=item.value,
)
for item in sorted(result.items, key=lambda i: (i.dimension_position is None, i.dimension_position))
],
)

209
route/radar_svg_gen.py Normal file
View File

@@ -0,0 +1,209 @@
"""
radar_svg_gen.py — серверная генерация SVG паутинной диаграммы.
Используется при сохранении результата: файл пишется в APP_PATH/data/radar/.
Не требует сторонних зависимостей.
"""
import math
import os
def generate_radar_svg(
items: list[dict],
max_val: float = 0.0,
size: int = 520,
scale_padding: float = 0.2,
min_scale: float = 5.0,
grid_levels: int = 5,
show_scale_labels: bool = True,
) -> str:
"""
Возвращает SVG паутинной диаграммы.
items — список осей: [{dimension_name, dimension_color, value}, ...]
max_val — максимум шкалы; 0 = авто (вычисляется из items)
size — размер холста в пикселях
scale_padding — запас над максимальным значением (доля, 0.2 = +20%)
min_scale — минимальное значение шкалы
grid_levels — количество концентрических колец
show_scale_labels — рисовать ли подписи значений на одной из осей
Отрицательные значения окрашиваются красным, положительные — фиолетовым.
"""
n = len(items)
if n < 3:
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">'
f'<text x="{size//2}" y="{size//2}" text-anchor="middle" fill="#333" '
f'font-family="Arial,sans-serif" font-size="16">Нет данных</text></svg>'
)
# ---- масштаб ----
vals = [it["value"] for it in items]
max_abs = max((abs(v) for v in vals), default=1.0)
if max_val > 0:
max_abs = max(max_abs, max_val)
max_abs = max(max_abs * (1.0 + scale_padding), min_scale, 1.0)
# ---- геометрия ----
cx = size / 2
cy = size / 2
lbl_margin = size * 0.22
R = (size / 2) - lbl_margin
lbl_r = R + size * 0.07
def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]:
a = math.radians(-90.0 + 360.0 * i / n)
return cx + R * frac * math.cos(a), cy + R * frac * math.sin(a)
def poly(pts: list[tuple]) -> str:
return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
lines: list[str] = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">',
]
# ── Сетка ─────────────────────────────────────────────────────────────────
for lvl in range(1, grid_levels + 1):
frac = lvl / grid_levels
pts = [axis_xy(i, frac) for i in range(n)]
alpha = 0.15 + 0.12 * frac
lines.append(
f'<polygon points="{poly(pts)}" fill="none" '
f'stroke="rgba(170,130,230,{alpha:.2f})" stroke-width="1.2"/>'
)
# ── Оси ───────────────────────────────────────────────────────────────────
for i in range(n):
x, y = axis_xy(i)
lines.append(
f'<line x1="{cx:.2f}" y1="{cy:.2f}" x2="{x:.2f}" y2="{y:.2f}" '
f'stroke="rgba(170,130,230,0.45)" stroke-width="1.2"/>'
)
# ── Метки шкалы (первая ось) ──────────────────────────────────────────────
if show_scale_labels:
for lvl in range(1, grid_levels + 1):
frac = lvl / grid_levels
tick_val = max_abs * frac
tx, ty = axis_xy(0, frac)
lines.append(
f'<text x="{tx + 4:.2f}" y="{ty:.2f}" '
f'font-family="Arial,sans-serif" font-size="10" '
f'fill="rgba(170,130,230,0.70)">{tick_val:.0f}</text>'
)
# ── Полигоны данных (красный=отрицательные, синий=положительные) ──────────
neg_pts = [axis_xy(i, max(0.0, min(-it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
pos_pts = [axis_xy(i, max(0.0, min( it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
lines.append(f'<polygon points="{poly(neg_pts)}" fill="rgba(220,50,50,0.30)" stroke="#FF7070" stroke-width="3.5"/>')
lines.append(f'<polygon points="{poly(pos_pts)}" fill="rgba(100,60,200,0.32)" stroke="#A080FF" stroke-width="3.5"/>')
# ── Точки ─────────────────────────────────────────────────────────────────
for i, it in enumerate(items):
v = it["value"]
if abs(v) > 0.05:
x, y = axis_xy(i, abs(v) / max_abs)
col = "#FF7070" if v < 0 else "#A080FF"
lines.append(
f'<circle cx="{x:.2f}" cy="{y:.2f}" r="6" fill="{col}" '
f'stroke="rgba(255,255,255,0.60)" stroke-width="1.5"/>'
)
# ── Подписи осей ───────────────────────────────────────────────────────────
font_size = max(11, min(14, int(size / 40)))
for i, item in enumerate(items):
a = math.radians(-90.0 + 360.0 * i / n)
lx = cx + lbl_r * math.cos(a)
ly = cy + lbl_r * math.sin(a)
col = item.get("dimension_color") or "#A070D0"
val = int(item["value"])
val_color = "#FF7070" if item["value"] < 0 else "#A080FF"
word_lines = _wrap_label(item["dimension_name"])
line_h = font_size + 3
total_h = line_h * len(word_lines) + (font_size + 2)
y0 = ly - total_h / 2 + line_h
for k, part in enumerate(word_lines):
lines.append(
f'<text x="{lx:.2f}" y="{y0 + k * line_h:.2f}" text-anchor="middle" '
f'font-family="Arial,Helvetica,sans-serif" font-size="{font_size}" '
f'font-weight="bold" fill="{col}">{_xml_escape(part)}</text>'
)
lines.append(
f'<text x="{lx:.2f}" y="{y0 + len(word_lines) * line_h:.2f}" '
f'text-anchor="middle" font-family="Arial,Helvetica,sans-serif" '
f'font-size="{font_size + 2}" fill="{val_color}">{val}</text>'
)
lines.append("</svg>")
return "\n".join(lines)
def _xml_escape(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _wrap_label(text: str, max_chars: int = 14) -> list[str]:
"""
Разбивает длинные подписи осей на строки.
Сначала пробует разрезать по '', затем по пробелам.
"""
if len(text) <= max_chars:
return [text]
# разрезаем по длинному тире
if "" in text:
parts = text.split("", 1)
return [parts[0] + "", parts[1]]
# разрезаем по последнему пробелу ≤ max_chars
idx = text.rfind(" ", 0, max_chars)
if idx == -1:
return [text]
return [text[:idx], text[idx + 1:]]
def save_radar_svg(
result_id: str,
items: list[dict],
max_val: float = 0.0,
poll_title: str = "unknown",
user_id: str = "anonymous",
size: int = 520,
scale_padding: float = 0.2,
min_scale: float = 5.0,
) -> str:
"""
Генерирует SVG и сохраняет его на диск.
Возвращает относительный путь к файлу (относительно APP_PATH).
Файл пишется в: {APP_PATH}/data/radar/{poll_title}/{user_id}/{result_id}.svg
Возвращает: data/radar/{poll_title}/{user_id}/{result_id}.svg
max_val=0 — автомасштаб (рекомендуется)
scale_padding — запас над максимумом (0.2 = +20%)
min_scale — нижняя граница шкалы
"""
import re
def _safe(s: str) -> str:
s = re.sub(r"[^\w\-. ]", "_", s, flags=re.UNICODE)
return s.strip("_. ").replace(" ", "_") or "_"
app_path = os.getenv("APP_PATH", "/app")
rel_dir = os.path.join("data", "radar", _safe(poll_title), str(user_id))
full_dir = os.path.join(app_path, rel_dir)
os.makedirs(full_dir, exist_ok=True)
filename = f"{result_id}.svg"
full_path = os.path.join(full_dir, filename)
svg = generate_radar_svg(
items,
max_val=max_val,
size=size,
scale_padding=scale_padding,
min_scale=min_scale,
)
with open(full_path, "w", encoding="utf-8") as f:
f.write(svg)
return os.path.join(rel_dir, filename)

View File

@@ -3,13 +3,12 @@ from pydantic import BaseModel
from typing import List, Optional
import uuid
from bd import Settings
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from bd import get_session
from bd.tables.poll import Poll
from bd.tables.question import Question
from bd.tables.response import Response, Answer
from bd.tables.scale import ScaleDimension
router = APIRouter(tags=["responses"], prefix="/polls")
@@ -21,14 +20,12 @@ class AnswerIn(BaseModel):
class ResponseIn(BaseModel):
user_id: Optional[str]
user_id: Optional[str] = None
group_id: Optional[str] = None
organization_id: Optional[str] = None
answers: List[AnswerIn]
def get_session():
settings = Settings()
engine = create_engine(settings.DATABASE_URL_syncpg, future=True)
return sessionmaker(bind=engine, future=True)
@router.post("/{poll_id}/responses", status_code=201)
@@ -48,6 +45,16 @@ def submit_response(poll_id: str, payload: ResponseIn):
resp.user_id = uuid.UUID(payload.user_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid user_id UUID")
if payload.group_id:
try:
resp.group_id = uuid.UUID(payload.group_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid group_id UUID")
if payload.organization_id:
try:
resp.organization_id = uuid.UUID(payload.organization_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid organization_id UUID")
session.add(resp)
# validate answers
for a in payload.answers:
@@ -68,7 +75,37 @@ def submit_response(poll_id: str, payload: ResponseIn):
session.add(answer)
session.commit()
session.refresh(resp)
return {"id": str(resp.id)}
# Автоматически считаем радарный результат, если у теста есть оси
has_dimensions = (
session.query(ScaleDimension)
.filter(ScaleDimension.poll_id == pid)
.first()
) is not None
radar_id: Optional[str] = None
if has_dimensions:
try:
from route.radar_crud import compute_radar
radar = compute_radar(resp.id, session)
radar_id = str(radar.id)
except Exception:
pass # не ломаем сдачу, если расчёт не удался
result: dict = {"id": str(resp.id)}
if radar_id:
result["radar_result_id"] = radar_id
return result
@router.get("/responses/")
def list_all_responses():
Session = get_session()
with Session() as session:
rows = session.query(Response).all()
return [
{"id": str(r.id), "poll_id": str(r.poll_id), "user_id": str(r.user_id) if r.user_id else None, "submitted_at": r.submitted_at.isoformat()}
for r in rows
]
@router.get("/{poll_id}/responses")
@@ -94,4 +131,27 @@ def get_response(response_id: str):
r = session.get(Response, rid)
if not r:
raise HTTPException(status_code=404, detail="Response not found")
return {"id": str(r.id), "poll_id": str(r.poll_id), "user_id": str(r.user_id) if r.user_id else None, "submitted_at": r.submitted_at.isoformat(), "answers": [{"id": str(a.id), "question_id": str(a.question_id), "choice_id": str(a.choice_id) if a.choice_id else None, "text": a.text} for a in r.answers]}
return {
"id": str(r.id),
"poll_id": str(r.poll_id),
"user_id": str(r.user_id) if r.user_id else None,
"group_id": str(r.group_id) if r.group_id else None,
"organization_id": str(r.organization_id) if r.organization_id else None,
"submitted_at": r.submitted_at.isoformat(),
"answers": [
{"id": str(a.id), "question_id": str(a.question_id), "choice_id": str(a.choice_id) if a.choice_id else None, "text": a.text}
for a in r.answers
],
}
@router.get("/users/{user_id}/responses")
def list_responses_by_user(user_id: str):
Session = get_session()
try:
uid = uuid.UUID(user_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid user UUID")
with Session() as session:
rows = session.query(Response).filter(Response.user_id == uid).all()
return [{"id": str(r.id), "poll_id": str(r.poll_id), "submitted_at": r.submitted_at.isoformat()} for r in rows]

224
route/scale_crud.py Normal file
View File

@@ -0,0 +1,224 @@
"""
CRUD для осей (ScaleDimension) и расшифровок вариантов ответов (ChoiceScore).
Эндпоинты:
POST /polls/{poll_id}/dimensions — создать ось для теста
GET /polls/{poll_id}/dimensions — список осей теста
PUT /dimensions/{dimension_id} — изменить ось
DELETE /dimensions/{dimension_id} — удалить ось
POST /dimensions/{dimension_id}/scores — задать расшифровку для варианта ответа
GET /dimensions/{dimension_id}/scores — список расшифровок по оси
GET /choices/{choice_id}/scores — расшифровки конкретного варианта
DELETE /choice-scores/{score_id} — удалить расшифровку
"""
import uuid
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from bd import get_session
from bd.tables.poll import Poll
from bd.tables.choice import Choice
from bd.tables.scale import ScaleDimension, ChoiceScore
router = APIRouter(tags=["scale"])
# ---------------------------------------------------------------------------
# Схемы
# ---------------------------------------------------------------------------
class DimensionIn(BaseModel):
name: str
description: Optional[str] = None
color: Optional[str] = None
position: Optional[int] = None
class DimensionOut(BaseModel):
id: str
poll_id: str
name: str
description: Optional[str]
color: Optional[str]
position: Optional[int]
class ChoiceScoreIn(BaseModel):
choice_id: str
score: float = 1.0
class ChoiceScoreOut(BaseModel):
id: str
choice_id: str
dimension_id: str
score: float
# ---------------------------------------------------------------------------
# Оси (ScaleDimension)
# ---------------------------------------------------------------------------
@router.post("/polls/{poll_id}/dimensions", response_model=DimensionOut, status_code=201,
tags=["scale"])
def create_dimension(poll_id: str, payload: DimensionIn):
Session = get_session()
try:
pid = uuid.UUID(poll_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid poll UUID")
with Session() as session:
if not session.get(Poll, pid):
raise HTTPException(status_code=404, detail="Poll not found")
dim = ScaleDimension(
poll_id=pid,
name=payload.name,
description=payload.description,
color=payload.color,
position=payload.position,
)
session.add(dim)
session.commit()
session.refresh(dim)
return DimensionOut(
id=str(dim.id), poll_id=str(dim.poll_id),
name=dim.name, description=dim.description,
color=dim.color, position=dim.position,
)
@router.get("/polls/{poll_id}/dimensions", response_model=List[DimensionOut],
tags=["scale"])
def list_dimensions(poll_id: str):
Session = get_session()
try:
pid = uuid.UUID(poll_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid poll UUID")
with Session() as session:
dims = (
session.query(ScaleDimension)
.filter(ScaleDimension.poll_id == pid)
.order_by(ScaleDimension.position.asc().nullslast())
.all()
)
return [
DimensionOut(id=str(d.id), poll_id=str(d.poll_id),
name=d.name, description=d.description,
color=d.color, position=d.position)
for d in dims
]
@router.put("/dimensions/{dimension_id}", response_model=DimensionOut, tags=["scale"])
def update_dimension(dimension_id: str, payload: DimensionIn):
Session = get_session()
try:
did = uuid.UUID(dimension_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid dimension UUID")
with Session() as session:
dim = session.get(ScaleDimension, did)
if not dim:
raise HTTPException(status_code=404, detail="Dimension not found")
dim.name = payload.name
dim.description = payload.description
dim.color = payload.color
dim.position = payload.position
session.commit()
session.refresh(dim)
return DimensionOut(id=str(dim.id), poll_id=str(dim.poll_id),
name=dim.name, description=dim.description,
color=dim.color, position=dim.position)
@router.delete("/dimensions/{dimension_id}", status_code=204, tags=["scale"])
def delete_dimension(dimension_id: str):
Session = get_session()
try:
did = uuid.UUID(dimension_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid dimension UUID")
with Session() as session:
dim = session.get(ScaleDimension, did)
if not dim:
raise HTTPException(status_code=404, detail="Dimension not found")
session.delete(dim)
session.commit()
# ---------------------------------------------------------------------------
# Расшифровки вариантов ответов (ChoiceScore)
# ---------------------------------------------------------------------------
@router.post("/dimensions/{dimension_id}/scores", response_model=ChoiceScoreOut,
status_code=201, tags=["scale"])
def create_choice_score(dimension_id: str, payload: ChoiceScoreIn):
Session = get_session()
try:
did = uuid.UUID(dimension_id)
cid = uuid.UUID(payload.choice_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid UUID")
with Session() as session:
if not session.get(ScaleDimension, did):
raise HTTPException(status_code=404, detail="Dimension not found")
if not session.get(Choice, cid):
raise HTTPException(status_code=404, detail="Choice not found")
cs = ChoiceScore(choice_id=cid, dimension_id=did, score=payload.score)
session.add(cs)
session.commit()
session.refresh(cs)
return ChoiceScoreOut(id=str(cs.id), choice_id=str(cs.choice_id),
dimension_id=str(cs.dimension_id), score=cs.score)
@router.get("/dimensions/{dimension_id}/scores", response_model=List[ChoiceScoreOut],
tags=["scale"])
def list_scores_by_dimension(dimension_id: str):
Session = get_session()
try:
did = uuid.UUID(dimension_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid dimension UUID")
with Session() as session:
rows = session.query(ChoiceScore).filter(ChoiceScore.dimension_id == did).all()
return [ChoiceScoreOut(id=str(r.id), choice_id=str(r.choice_id),
dimension_id=str(r.dimension_id), score=r.score)
for r in rows]
@router.get("/choices/{choice_id}/scores", response_model=List[ChoiceScoreOut],
tags=["scale"])
def list_scores_by_choice(choice_id: str):
Session = get_session()
try:
cid = uuid.UUID(choice_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid choice UUID")
with Session() as session:
rows = session.query(ChoiceScore).filter(ChoiceScore.choice_id == cid).all()
return [ChoiceScoreOut(id=str(r.id), choice_id=str(r.choice_id),
dimension_id=str(r.dimension_id), score=r.score)
for r in rows]
@router.delete("/choice-scores/{score_id}", status_code=204, tags=["scale"])
def delete_choice_score(score_id: str):
Session = get_session()
try:
sid = uuid.UUID(score_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid UUID")
with Session() as session:
cs = session.get(ChoiceScore, sid)
if not cs:
raise HTTPException(status_code=404, detail="Score not found")
session.delete(cs)
session.commit()

415
route/stats_crud.py Normal file
View File

@@ -0,0 +1,415 @@
from fastapi import APIRouter, Query
from typing import Optional
from sqlalchemy import func
import json
import hashlib
from bd import get_session
from bd.tables.response import Response
from bd.tables.poll import Poll
from bd.tables.group import Group
from bd.tables.organization import Organization
from bd.tables.radar_result import RadarResult, RadarResultItem
from bd.tables.users import User
from redis_db import cache_get, cache_set
router = APIRouter(tags=["stats"], prefix="/stats")
_MONTH_NAMES = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн",
"Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"]
_CACHE_TTL = 300 # 5 минут
def _cache_key(*parts) -> str:
raw = ":".join(str(p) for p in parts)
return hashlib.md5(raw.encode()).hexdigest()
@router.get("/summary")
def get_summary():
"""Общая сводка: кол-во ответов, тестов, активных тестов, организаций с активностью."""
key = _cache_key("summary")
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
total_responses = session.query(func.count(Response.id)).scalar() or 0
total_polls = session.query(func.count(Poll.id)).scalar() or 0
active_polls = session.query(func.count(Poll.id)).filter(Poll.is_active.is_(True)).scalar() or 0
orgs_active = session.query(func.count(func.distinct(Response.organization_id))).scalar() or 0
result = {
"total_responses": total_responses,
"total_polls": total_polls,
"active_polls": active_polls,
"orgs_active": orgs_active,
}
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/years")
def get_years():
"""Список лет, в которых есть хотя бы один ответ."""
key = _cache_key("years")
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
rows = session.query(
func.extract("year", Response.submitted_at).label("yr")
).distinct().order_by("yr").all()
result = [int(r.yr) for r in rows if r.yr is not None]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/responses")
def get_responses_grouped(
group_by: str = Query("poll", pattern="^(poll|organization|group|month)$"),
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
):
"""
Возвращает агрегированные данные для графиков.
- group_by=poll → количество ответов по каждому тесту
- group_by=organization → количество ответов по каждой организации
- group_by=group → количество ответов по каждой группе
- group_by=month → количество ответов по месяцам (хронологически)
Все фильтры опциональны и комбинируются.
"""
key = _cache_key("responses", group_by, poll_id, org_id, group_id, year)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
result = []
with Session() as session:
if group_by == "poll":
q = session.query(Poll.title, func.count(Response.id).label("cnt"))
q = q.join(Response, Poll.id == Response.poll_id)
if org_id:
q = q.filter(Response.organization_id == org_id)
if group_id:
q = q.filter(Response.group_id == group_id)
if year:
q = q.filter(func.extract("year", Response.submitted_at) == year)
if poll_id:
q = q.filter(Response.poll_id == poll_id)
rows = q.group_by(Poll.title).order_by(func.count(Response.id).desc()).limit(20).all()
result = [{"label": r[0], "count": r[1]} for r in rows]
elif group_by == "organization":
q = session.query(Organization.name_organization, func.count(Response.id).label("cnt"))
q = q.outerjoin(Response, Organization.id == Response.organization_id)
if poll_id:
q = q.filter(Response.poll_id == poll_id)
if group_id:
q = q.filter(Response.group_id == group_id)
if year:
q = q.filter(func.extract("year", Response.submitted_at) == year)
rows = q.group_by(Organization.name_organization).order_by(
func.count(Response.id).desc()
).limit(20).all()
result = [{"label": r[0], "count": r[1] or 0} for r in rows]
elif group_by == "group":
q = session.query(Group.name_group, func.count(Response.id).label("cnt"))
q = q.outerjoin(Response, Group.id == Response.group_id)
if poll_id:
q = q.filter(Response.poll_id == poll_id)
if org_id:
q = q.filter(Response.organization_id == org_id)
if year:
q = q.filter(func.extract("year", Response.submitted_at) == year)
rows = q.group_by(Group.name_group).order_by(
func.count(Response.id).desc()
).limit(20).all()
result = [{"label": r[0], "count": r[1] or 0} for r in rows]
elif group_by == "month":
yr_col = func.extract("year", Response.submitted_at).label("yr")
mo_col = func.extract("month", Response.submitted_at).label("mo")
q = session.query(yr_col, mo_col, func.count(Response.id).label("cnt"))
if poll_id:
q = q.filter(Response.poll_id == poll_id)
if org_id:
q = q.filter(Response.organization_id == org_id)
if group_id:
q = q.filter(Response.group_id == group_id)
if year:
q = q.filter(func.extract("year", Response.submitted_at) == year)
rows = q.group_by("yr", "mo").order_by("yr", "mo").all()
result = [
{
"label": f"{_MONTH_NAMES[int(r.mo) - 1]} {int(r.yr)}",
"count": r.cnt,
}
for r in rows
]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
# ---------------------------------------------------------------------------
# Эндпоинты по результатам (RadarResultItem)
# ---------------------------------------------------------------------------
@router.get("/results/avg-by-dimension")
def get_avg_by_dimension(
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
):
"""Средний балл по каждой оси/типу."""
key = _cache_key("avg_dim", poll_id, org_id, group_id, year)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
q = (
session.query(
RadarResultItem.dimension_name,
func.avg(RadarResultItem.value).label("avg"),
func.count(RadarResultItem.id).label("cnt"),
)
.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
)
if poll_id:
q = q.filter(Response.poll_id == poll_id)
if org_id:
q = q.filter(Response.organization_id == org_id)
if group_id:
q = q.filter(Response.group_id == group_id)
if year:
q = q.filter(func.extract("year", Response.submitted_at) == year)
rows = q.group_by(RadarResultItem.dimension_name).order_by(func.avg(RadarResultItem.value).desc()).all()
result = [{"label": r[0], "avg": round(float(r[1]), 2), "count": r[2]} for r in rows]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/results/leading-type")
def get_leading_type(
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
):
"""Распределение ведущего типа: у скольких прохождений данная ось оказалась максимальной."""
key = _cache_key("leading", poll_id, org_id, group_id, year)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
max_subq = (
session.query(
RadarResultItem.result_id,
func.max(RadarResultItem.value).label("max_val"),
)
.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
)
if poll_id:
max_subq = max_subq.filter(Response.poll_id == poll_id)
if org_id:
max_subq = max_subq.filter(Response.organization_id == org_id)
if group_id:
max_subq = max_subq.filter(Response.group_id == group_id)
if year:
max_subq = max_subq.filter(func.extract("year", Response.submitted_at) == year)
max_subq = max_subq.group_by(RadarResultItem.result_id).subquery()
rows = (
session.query(
RadarResultItem.dimension_name,
func.count(RadarResultItem.result_id).label("cnt"),
)
.join(max_subq, (RadarResultItem.result_id == max_subq.c.result_id) &
(RadarResultItem.value == max_subq.c.max_val))
.group_by(RadarResultItem.dimension_name)
.order_by(func.count(RadarResultItem.result_id).desc())
.all()
)
result = [{"label": r[0], "count": r[1]} for r in rows]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/results/compare-groups")
def get_compare_groups(
dimension_name: str,
poll_id: Optional[str] = None,
year: Optional[int] = None,
by: str = Query("organization", pattern="^(organization|group)$"),
):
"""Средний балл по выбранной оси в разбивке по организациям или группам."""
key = _cache_key("compare_groups", dimension_name, poll_id, year, by)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
if by == "organization":
label_col = Organization.name_organization
q = (
session.query(label_col, func.avg(RadarResultItem.value).label("avg"))
.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
.join(Organization, Response.organization_id == Organization.id)
.filter(RadarResultItem.dimension_name == dimension_name)
)
else:
label_col = Group.name_group
q = (
session.query(label_col, func.avg(RadarResultItem.value).label("avg"))
.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
.join(Group, Response.group_id == Group.id)
.filter(RadarResultItem.dimension_name == dimension_name)
)
if poll_id:
q = q.filter(Response.poll_id == poll_id)
if year:
q = q.filter(func.extract("year", Response.submitted_at) == year)
rows = q.group_by(label_col).order_by(func.avg(RadarResultItem.value).desc()).limit(20).all()
result = [{"label": r[0], "avg": round(float(r[1]), 2)} for r in rows]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/results/top-users")
def get_top_users(
dimension_name: str,
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
limit: int = Query(10, ge=1, le=50),
):
"""Топ-N прохождений с наибольшим баллом по выбранной оси."""
key = _cache_key("top_users", dimension_name, poll_id, org_id, group_id, year, limit)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
q = (
session.query(
User.last_name,
User.first_name,
Poll.title,
RadarResultItem.value,
)
.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
.join(Poll, Response.poll_id == Poll.id)
.outerjoin(User, Response.user_id == User.id)
.filter(RadarResultItem.dimension_name == dimension_name)
)
if poll_id:
q = q.filter(Response.poll_id == poll_id)
if org_id:
q = q.filter(Response.organization_id == org_id)
if group_id:
q = q.filter(Response.group_id == group_id)
if year:
q = q.filter(func.extract("year", Response.submitted_at) == year)
rows = q.order_by(RadarResultItem.value.desc()).limit(limit).all()
result = [
{
"name": f"{r[0] or '?'} {r[1] or ''}".strip(),
"poll": r[2],
"value": round(float(r[3]), 2),
}
for r in rows
]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/results/dimension-trend")
def get_dimension_trend(
dimension_name: str,
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
):
"""Динамика среднего балла по оси во времени (по месяцам)."""
key = _cache_key("dim_trend", dimension_name, poll_id, org_id, group_id)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
yr_col = func.extract("year", Response.submitted_at).label("yr")
mo_col = func.extract("month", Response.submitted_at).label("mo")
q = (
session.query(yr_col, mo_col, func.avg(RadarResultItem.value).label("avg"))
.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
.filter(RadarResultItem.dimension_name == dimension_name)
)
if poll_id:
q = q.filter(Response.poll_id == poll_id)
if org_id:
q = q.filter(Response.organization_id == org_id)
if group_id:
q = q.filter(Response.group_id == group_id)
rows = q.group_by("yr", "mo").order_by("yr", "mo").all()
result = [
{"label": f"{_MONTH_NAMES[int(r.mo) - 1]} {int(r.yr)}", "avg": round(float(r.avg), 2)}
for r in rows
]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result
@router.get("/results/dimensions")
def get_dimensions(poll_id: Optional[str] = None):
"""Список уникальных названий осей (для выпадающего списка)."""
key = _cache_key("dimensions", poll_id)
cached = cache_get(key)
if cached:
return json.loads(cached)
Session = get_session()
with Session() as session:
q = session.query(RadarResultItem.dimension_name).distinct()
if poll_id:
q = (
q.join(RadarResult, RadarResultItem.result_id == RadarResult.id)
.join(Response, RadarResult.response_id == Response.id)
.filter(Response.poll_id == poll_id)
)
rows = q.order_by(RadarResultItem.dimension_name).all()
result = [r[0] for r in rows]
cache_set(key, json.dumps(result), _CACHE_TTL)
return result

336
route/transfer_crud.py Normal file
View File

@@ -0,0 +1,336 @@
"""
transfer_crud.py — полный экспорт и импорт всех данных между инстанциями API.
Экспортирует ВСЁ: справочники, тесты, пользователей, ответы, результаты.
Импорт — upsert (INSERT ON CONFLICT DO NOTHING), UUID сохраняются,
FK-связи не рвутся. Безопасно запускать повторно — дубли пропускаются.
Порядок импорта (FK-зависимости):
Organization → Group → Poll → Question → Choice
→ ScaleDimension → ChoiceScore
→ User → Response → Answer
→ RadarResult → RadarResultItem + SVG-файлы
Оба эндпоинта защищены X-Admin-Key.
"""
import io
import json
import os
import shutil
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy.dialects.postgresql import insert as pg_insert
from bd import get_session
from bd.tables.organization import Organization
from bd.tables.group import Group
from bd.tables.poll import Poll
from bd.tables.question import Question
from bd.tables.choice import Choice
from bd.tables.scale import ScaleDimension, ChoiceScore
from bd.tables.users import User
from bd.tables.response import Response, Answer
from bd.tables.radar_result import RadarResult, RadarResultItem
from route.auth_utils import require_admin_key
router = APIRouter(prefix="/transfer", tags=["transfer"])
EXPORT_FORMAT_VERSION = "2.0"
_APP_PATH = os.getenv("APP_PATH", "/app")
_RADAR_DIR = Path(_APP_PATH) / "data" / "radar"
# Порядок экспорта/импорта — строго по FK-зависимостям
_TABLES: list[tuple[str, object]] = [
("organizations", Organization),
("groups", Group),
("polls", Poll),
("questions", Question),
("choices", Choice),
("scale_dimensions", ScaleDimension),
("choice_scores", ChoiceScore),
("users", User),
("responses", Response),
("answers", Answer),
("radar_results", RadarResult),
("radar_result_items", RadarResultItem),
]
def _row_to_dict(row) -> dict:
result = {}
for col in row.__table__.columns:
val = getattr(row, col.name)
if val is None:
result[col.name] = None
elif hasattr(val, "hex"): # UUID
result[col.name] = str(val)
elif hasattr(val, "isoformat"): # datetime
result[col.name] = val.isoformat()
else:
result[col.name] = val
return result
# ── EXPORT ──────────────────────────────────────────────────────────────────
@router.get(
"/export",
summary="Экспортировать все данные в ZIP-архив",
response_class=StreamingResponse,
dependencies=[Depends(require_admin_key)],
)
def export_data():
Session = get_session()
buf = io.BytesIO()
counts = {}
with Session() as session:
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
for filename, model_class in _TABLES:
rows = [_row_to_dict(r) for r in session.query(model_class).all()]
counts[filename] = len(rows)
zf.writestr(f"{filename}.json", json.dumps(rows, ensure_ascii=False))
# SVG-файлы радара
svg_count = 0
if _RADAR_DIR.exists():
for svg_file in _RADAR_DIR.rglob("*.svg"):
arc_name = "radar_svgs/" + svg_file.relative_to(_RADAR_DIR).as_posix()
zf.write(svg_file, arc_name)
svg_count += 1
counts["svg_files"] = svg_count
manifest = {
"format_version": EXPORT_FORMAT_VERSION,
"exported_at": datetime.now(timezone.utc).isoformat(),
"counts": counts,
}
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
buf.seek(0)
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="export_{ts}.zip"'},
)
# ── IMPORT ──────────────────────────────────────────────────────────────────
@router.post(
"/import",
summary="Импортировать данные из ZIP-архива (upsert по UUID)",
dependencies=[Depends(require_admin_key)],
)
def import_data(file: UploadFile = File(...)):
content = file.file.read()
try:
zf = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile:
raise HTTPException(status_code=400, detail="Файл не является валидным ZIP-архивом")
if "manifest.json" not in zf.namelist():
raise HTTPException(status_code=400, detail="Отсутствует manifest.json")
manifest = json.loads(zf.read("manifest.json"))
fmt = manifest.get("format_version", "")
if fmt not in (EXPORT_FORMAT_VERSION, "1.0"):
raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}")
Session = get_session()
result = {"inserted": {}, "skipped": {}}
with Session() as session:
for filename, model_class in _TABLES:
arc_file = f"{filename}.json"
if arc_file not in zf.namelist():
result["inserted"][filename] = 0
result["skipped"][filename] = 0
continue
rows: list[dict] = json.loads(zf.read(arc_file))
if not rows:
result["inserted"][filename] = 0
result["skipped"][filename] = 0
continue
table = model_class.__table__
inserted = 0
for row in rows:
stmt = (
pg_insert(table)
.values(**row)
.on_conflict_do_nothing(index_elements=["id"])
)
res = session.execute(stmt)
inserted += res.rowcount
result["inserted"][filename] = inserted
result["skipped"][filename] = len(rows) - inserted
session.commit()
# SVG-файлы
svg_names = [n for n in zf.namelist() if n.startswith("radar_svgs/")]
svg_written = 0
for arc_name in svg_names:
rel_path = arc_name[len("radar_svgs/"):]
if not rel_path:
continue
dest = _RADAR_DIR / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(zf.read(arc_name))
svg_written += 1
result["inserted"]["svg_files"] = svg_written
return {
"status": "ok",
"source_exported_at": manifest.get("exported_at"),
"result": result,
}
# ════════════════════════════════════════════════════════════════════════════
# BACKUP — полное резервное копирование и полное восстановление
# /backup/export — скачать полный снимок БД + SVG
# /backup/restore — ПОЛНАЯ замена БД из архива (все старые данные удаляются)
# ════════════════════════════════════════════════════════════════════════════
backup_router = APIRouter(prefix="/backup", tags=["backup"])
@backup_router.get(
"/export",
summary="Создать резервную копию всей БД (ZIP)",
response_class=StreamingResponse,
dependencies=[Depends(require_admin_key)],
)
def backup_export():
"""Идентично /transfer/export, но в имени файла указан префикс backup_."""
Session = get_session()
buf = io.BytesIO()
counts = {}
with Session() as session:
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
for filename, model_class in _TABLES:
rows = [_row_to_dict(r) for r in session.query(model_class).all()]
counts[filename] = len(rows)
zf.writestr(f"{filename}.json", json.dumps(rows, ensure_ascii=False))
svg_count = 0
if _RADAR_DIR.exists():
for svg_file in _RADAR_DIR.rglob("*.svg"):
arc_name = "radar_svgs/" + svg_file.relative_to(_RADAR_DIR).as_posix()
zf.write(svg_file, arc_name)
svg_count += 1
counts["svg_files"] = svg_count
manifest = {
"format_version": EXPORT_FORMAT_VERSION,
"backup_type": "full",
"exported_at": datetime.now(timezone.utc).isoformat(),
"counts": counts,
}
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
buf.seek(0)
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="backup_{ts}.zip"'},
)
@backup_router.post(
"/restore",
summary="Полное восстановление БД из резервной копии (УДАЛЯЕТ все текущие данные)",
dependencies=[Depends(require_admin_key)],
)
def backup_restore(file: UploadFile = File(...)):
"""
ВНИМАНИЕ: удаляет ВСЕ существующие данные в обратном FK-порядке,
затем вставляет данные из архива в прямом FK-порядке.
SVG-диаграммы тоже полностью заменяются.
"""
content = file.file.read()
try:
zf = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile:
raise HTTPException(status_code=400, detail="Файл не является валидным ZIP-архивом")
if "manifest.json" not in zf.namelist():
raise HTTPException(status_code=400, detail="Отсутствует manifest.json")
manifest = json.loads(zf.read("manifest.json"))
fmt = manifest.get("format_version", "")
if fmt not in (EXPORT_FORMAT_VERSION, "1.0"):
raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}")
Session = get_session()
deleted_counts: dict[str, int] = {}
inserted_counts: dict[str, int] = {}
with Session() as session:
# 1. Удаляем в ОБРАТНОМ порядке FK-зависимостей
for filename, model_class in reversed(_TABLES):
table = model_class.__table__
res = session.execute(table.delete())
deleted_counts[filename] = res.rowcount
session.commit()
# 2. Вставляем в ПРЯМОМ порядке FK-зависимостей
for filename, model_class in _TABLES:
arc_file = f"{filename}.json"
if arc_file not in zf.namelist():
inserted_counts[filename] = 0
continue
rows: list[dict] = json.loads(zf.read(arc_file))
if not rows:
inserted_counts[filename] = 0
continue
table = model_class.__table__
session.execute(table.insert(), rows)
inserted_counts[filename] = len(rows)
session.commit()
# 3. SVG-диаграммы: полностью очищаем каталог и восстанавливаем
svg_names = [n for n in zf.namelist() if n.startswith("radar_svgs/") and n != "radar_svgs/"]
if svg_names and _RADAR_DIR.exists():
for child in _RADAR_DIR.iterdir():
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
_RADAR_DIR.mkdir(parents=True, exist_ok=True)
svg_written = 0
for arc_name in svg_names:
rel_path = arc_name[len("radar_svgs/"):]
if not rel_path:
continue
dest = _RADAR_DIR / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(zf.read(arc_name))
svg_written += 1
inserted_counts["svg_files"] = svg_written
return {
"status": "ok",
"source_exported_at": manifest.get("exported_at"),
"deleted": deleted_counts,
"inserted": inserted_counts,
}

View File

@@ -1,111 +0,0 @@
from fastapi import APIRouter, Request
router = APIRouter()
@router.get("/ui/create-user")
def create_user_page(request: Request):
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Create user & Take test</title>
</head>
<body>
<h1>Create user</h1>
<form id="userForm">
<label>First name: <input name="first_name" required></label><br>
<label>Last name: <input name="last_name" required></label><br>
<button type="submit">Create user</button>
</form>
<h2>Or create Holland test</h2>
<button id="createHolland">Create Holland test</button>
<script>
document.getElementById('userForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const data = { first_name: fd.get('first_name'), last_name: fd.get('last_name') };
const res = await fetch('/users/', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data) });
if (!res.ok) { alert('Create user failed'); return; }
const js = await res.json();
const uid = js.id;
alert('User created: ' + uid);
// allow user to input poll id manually
const poll = prompt('Enter poll id to take (or leave empty to create Holland test)');
if (poll) {
location.href = '/ui/take-test?poll_id=' + encodeURIComponent(poll) + '&user_id=' + encodeURIComponent(uid);
} else {
// create Holland test then redirect
const r2 = await fetch('/polls/holland', { method: 'POST' });
const p = await r2.json();
location.href = '/ui/take-test?poll_id=' + encodeURIComponent(p.id) + '&user_id=' + encodeURIComponent(uid);
}
});
document.getElementById('createHolland').addEventListener('click', async () => {
const r = await fetch('/polls/holland', { method: 'POST' });
if (!r.ok) { alert('Create failed'); return; }
const p = await r.json();
alert('Created poll ' + p.id);
location.href = '/ui/take-test?poll_id=' + encodeURIComponent(p.id);
});
</script>
</body>
</html>
"""
@router.get("/ui/take-test")
def take_test_page(request: Request, poll_id: str = None, user_id: str = None):
return f"""
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Take test</title></head>
<body>
<h1>Take test</h1>
<div id="poll"></div>
<button id="submitBtn">Submit</button>
<script>
const pollId = '{poll_id or ''}';
const userId = '{user_id or ''}';
async function load() {{
if (!pollId) {{ document.getElementById('poll').innerText = 'No poll_id provided'; return; }}
const r = await fetch('/polls/' + encodeURIComponent(pollId));
if (!r.ok) {{ document.getElementById('poll').innerText = 'Failed to load poll'; return; }}
const p = await r.json();
const container = document.getElementById('poll');
const h = document.createElement('h2'); h.innerText = p.title; container.appendChild(h);
p.questions.forEach(q => {{
const div = document.createElement('div');
div.innerHTML = '<p>' + q.text + '</p>';
q.choices.forEach(c => {{
const id = 'q_' + q.id + '_c_' + c.id;
const input = `<label><input type="radio" name="${{q.id}}" value="${{c.id}}"> ${{c.text}}</label><br>`;
div.insertAdjacentHTML('beforeend', input);
}});
container.appendChild(div);
}});
}}
load();
document.getElementById('submitBtn').addEventListener('click', async () => {{
const answers = [];
const groups = new Set();
document.querySelectorAll('input[type=radio]').forEach(r => groups.add(r.name));
groups.forEach(name => {{
const checked = document.querySelector('input[name="' + name + '"]:checked');
if (checked) answers.push({{question_id: name, choice_id: checked.value}});
}});
const payload = {{ user_id: userId || null, answers }};
const res = await fetch('/polls/' + encodeURIComponent(pollId) + '/responses', {{ method: 'POST', headers: {{'Content-Type':'application/json'}}, body: JSON.stringify(payload) }});
if (!res.ok) {{ alert('Submit failed'); return; }}
const js = await res.json();
alert('Response saved: ' + js.id);
}});
</script>
</body>
</html>
"""

View File

@@ -1,51 +1,90 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, field_validator
from typing import Optional
import uuid
from bd import Settings
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from bd import get_session
from bd.tables.users import User
from route.auth_utils import get_current_user, require_admin_key, hash_password
router = APIRouter(tags=["users"], prefix="/users")
class UserCreate(BaseModel):
username: str
password: str
first_name: str
last_name: str
group_id: Optional[str] = None
organization_id: Optional[str] = None
@field_validator("username")
@classmethod
def username_clean(cls, v: str) -> str:
v = v.strip()
if len(v) < 3:
raise ValueError("Username must be at least 3 characters")
return v
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
return v
class UserUpdate(BaseModel):
first_name: Optional[str]
last_name: Optional[str]
first_name: Optional[str] = None
last_name: Optional[str] = None
group_id: Optional[str] = None
organization_id: Optional[str] = None
def get_session():
settings = Settings()
engine = create_engine(settings.DATABASE_URL_syncpg, future=True)
return sessionmaker(bind=engine, future=True)
@router.post("/", status_code=201)
def _user_dict(user: User) -> dict:
return {
"id": str(user.id),
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"group_id": str(user.group_id) if user.group_id else None,
"organization_id": str(user.organization_id) if user.organization_id else None,
}
@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])
def create_user(payload: UserCreate):
Session = get_session()
with Session() as session:
user = User(first_name=payload.first_name, last_name=payload.last_name)
existing = session.query(User).filter(User.username == payload.username).first()
if existing:
raise HTTPException(status_code=400, detail="Username already taken")
user = User(
first_name=payload.first_name.strip(),
last_name=payload.last_name.strip(),
username=payload.username,
hashed_password=hash_password(payload.password),
group_id=uuid.UUID(payload.group_id) if payload.group_id else None,
organization_id=uuid.UUID(payload.organization_id) if payload.organization_id else None,
)
session.add(user)
session.commit()
session.refresh(user)
return {"id": str(user.id), "first_name": user.first_name, "last_name": user.last_name}
return _user_dict(user)
@router.put("/{user_id}")
def update_user(user_id: str, payload: UserUpdate):
def update_user(user_id: str, payload: UserUpdate, current_user: User = Depends(get_current_user)):
Session = get_session()
try:
uid = uuid.UUID(user_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid UUID")
if uid != current_user.id:
raise HTTPException(status_code=403, detail="You can only update your own profile")
with Session() as session:
user = session.get(User, uid)
if not user:
@@ -54,19 +93,25 @@ def update_user(user_id: str, payload: UserUpdate):
user.first_name = payload.first_name
if payload.last_name is not None:
user.last_name = payload.last_name
if payload.group_id is not None:
user.group_id = uuid.UUID(payload.group_id)
if payload.organization_id is not None:
user.organization_id = uuid.UUID(payload.organization_id)
session.add(user)
session.commit()
session.refresh(user)
return {"id": str(user.id), "first_name": user.first_name, "last_name": user.last_name}
return _user_dict(user)
@router.delete("/{user_id}", status_code=204)
def delete_user(user_id: str):
def delete_user(user_id: str, current_user: User = Depends(get_current_user)):
Session = get_session()
try:
uid = uuid.UUID(user_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid UUID")
if uid != current_user.id:
raise HTTPException(status_code=403, detail="You can only delete your own account")
with Session() as session:
user = session.get(User, uid)
if not user:
@@ -76,16 +121,21 @@ def delete_user(user_id: str):
return {}
@router.get("/")
@router.get("/me")
def get_me(current_user: User = Depends(get_current_user)):
return _user_dict(current_user)
@router.get("/", dependencies=[Depends(require_admin_key)])
def list_users():
Session = get_session()
with Session() as session:
rows = session.query(User).all()
return [{"id": str(u.id), "first_name": u.first_name, "last_name": u.last_name} for u in rows]
return [_user_dict(u) for u in rows]
@router.get("/{user_id}")
def get_user(user_id: str):
def get_user(user_id: str, current_user: User = Depends(get_current_user)):
Session = get_session()
try:
uid = uuid.UUID(user_id)
@@ -95,4 +145,4 @@ def get_user(user_id: str):
user = session.get(User, uid)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {"id": str(user.id), "first_name": user.first_name, "last_name": user.last_name}
return _user_dict(user)

View File

@@ -0,0 +1,6 @@
node_modules
dist
.vite
*.local
Dockerfile
.dockerignore

5
stats-react/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
*.local
.vite
*.tsbuildinfo

14
stats-react/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# --- Стадия 1: сборка React-приложения ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# --- Стадия 2: раздача статики через nginx + прокси /api ---
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

13
stats-react/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЦОПП — Статистика</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

41
stats-react/nginx.conf Normal file
View File

@@ -0,0 +1,41 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Статика SPA: всё неизвестное отдаём index.html (клиентский роутинг).
# no-cache на index.html — см. web-react/nginx.conf, тот же приём: без
# него браузер кэширует HTML эвристически и после деплоя показывает
# старую версию до hard refresh. Хэшированные /assets/ ниже кэшируются
# честно на год, инвалидация им не нужна — имя меняется при сборке.
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
# Прокси на внутренний API-контейнер (браузер к api:8000 напрямую не ходит).
# Префикс /api срезается: /api/stats/summary -> http://api:8000/stats/summary
#
# Используем встроенный DNS Docker (127.0.0.11) и переменную в proxy_pass,
# чтобы nginx переразрешал имя `api` на каждый запрос. Иначе nginx кэширует
# IP при старте и ломается (502), если контейнер api перезапустился и сменил IP.
location /api/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $api_backend api;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://$api_backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Кэш статических ассетов с хэшем в имени
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

1732
stats-react/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
stats-react/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "copp-stats-react",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.6"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

342
stats-react/src/App.tsx Normal file
View File

@@ -0,0 +1,342 @@
import { useEffect, useState } from "react";
import * as api from "./api/client";
import type { AvgDatum, ChartDatum, Filters, TopUserRow } from "./api/client";
import { SearchableDropdown, type Option } from "./components/SearchableDropdown";
import { ChartCard } from "./components/ChartCard";
import { SummaryTile } from "./components/SummaryTile";
import { TopUsersTable } from "./components/TopUsersTable";
import { BarChart } from "./components/charts/BarChart";
import { LineChart } from "./components/charts/LineChart";
import { PartToWholeBar } from "./components/charts/PartToWholeBar";
import { useIsDark } from "./theme/useIsDark";
import { categoricalColor } from "./theme/palette";
import {
IconAssignment,
IconBarChart,
IconBusiness,
IconFilterOff,
IconInsights,
IconPlayCircle,
IconQuiz,
IconRefresh,
} from "./components/icons";
function toValueData(data: ChartDatum[]): { label: string; value: number }[] {
return data.map((d) => ({ label: d.label, value: d.count }));
}
const COMPARE_OPTIONS: Option[] = [
{ key: "organization", text: "Организациям" },
{ key: "group", text: "Группам" },
];
export default function App() {
const dark = useIsDark();
// ── справочники для фильтров ──────────────────────────────────────────
const [pollOptions, setPollOptions] = useState<Option[]>([]);
const [orgOptions, setOrgOptions] = useState<Option[]>([]);
const [groupOptions, setGroupOptions] = useState<Option[]>([]);
const [yearOptions, setYearOptions] = useState<Option[]>([]);
const [dimensionOptions, setDimensionOptions] = useState<Option[]>([]);
// ── общие фильтры ────────────────────────────────────────────────────
const [pollId, setPollId] = useState<string | null>(null);
const [orgId, setOrgId] = useState<string | null>(null);
const [groupId, setGroupId] = useState<string | null>(null);
const [year, setYear] = useState<string | null>(null);
// ── фильтры таба 2 ───────────────────────────────────────────────────
const [dimension, setDimension] = useState<string | null>(null);
const [compareBy, setCompareBy] = useState<string>("organization");
const [tab, setTab] = useState<0 | 1>(0);
// ── summary ──────────────────────────────────────────────────────────
const [summary, setSummary] = useState<api.Summary>({
total_responses: 0,
total_polls: 0,
active_polls: 0,
orgs_active: 0,
});
// ── tab1 данные ──────────────────────────────────────────────────────
const [tab1Loading, setTab1Loading] = useState(true);
const [pollData, setPollData] = useState<ChartDatum[]>([]);
const [orgData, setOrgData] = useState<ChartDatum[]>([]);
const [groupData, setGroupData] = useState<ChartDatum[]>([]);
const [monthData, setMonthData] = useState<ChartDatum[]>([]);
// ── tab2 данные ──────────────────────────────────────────────────────
const [tab2Loading, setTab2Loading] = useState(true);
const [avgDimData, setAvgDimData] = useState<AvgDatum[]>([]);
const [leadingData, setLeadingData] = useState<ChartDatum[]>([]);
const [compareData, setCompareData] = useState<AvgDatum[]>([]);
const [trendData, setTrendData] = useState<AvgDatum[]>([]);
const [topUsers, setTopUsers] = useState<TopUserRow[]>([]);
const filters = (): Filters => ({
poll_id: pollId,
org_id: orgId,
group_id: groupId,
year: year ? parseInt(year, 10) : null,
});
async function refreshTab1() {
setTab1Loading(true);
const f = filters();
const [p, o, g, m] = await Promise.all([
api.getResponsesGrouped("poll", f),
api.getResponsesGrouped("organization", f),
api.getResponsesGrouped("group", f),
api.getResponsesGrouped("month", f),
]);
setPollData(p);
setOrgData(o);
setGroupData(g);
setMonthData(m);
setTab1Loading(false);
}
async function refreshTab2() {
setTab2Loading(true);
const f = filters();
const dim = dimension;
const [avg, lead] = await Promise.all([api.getAvgByDimension(f), api.getLeadingType(f)]);
let top: TopUserRow[] = [];
let compare: AvgDatum[] = [];
let trend: AvgDatum[] = [];
if (dim) {
[top, compare, trend] = await Promise.all([
api.getTopUsers(dim, f),
api.getCompareGroups(dim, compareBy as api.CompareBy, f.poll_id, f.year),
api.getDimensionTrend(dim, f),
]);
}
setAvgDimData(avg);
setLeadingData(lead);
setTopUsers(top);
setCompareData(compare);
setTrendData(trend);
setTab2Loading(false);
}
function refreshCurrentTab() {
if (tab === 0) void refreshTab1();
else void refreshTab2();
}
// ── первичная загрузка ───────────────────────────────────────────────
useEffect(() => {
(async () => {
const [polls, orgs, groups, years, summaryData, dims] = await Promise.all([
api.getPolls(),
api.getOrganizations(),
api.getGroups(),
api.getYears(),
api.getSummary(),
api.getDimensions(),
]);
setPollOptions(polls.map((p) => ({ key: p.id, text: p.title })));
setOrgOptions(orgs.map((o) => ({ key: o.id, text: o.name_organization })));
setGroupOptions(groups.map((g) => ({ key: g.id, text: g.name_group })));
setYearOptions(years.map((y) => ({ key: String(y), text: String(y) })));
setDimensionOptions(dims.map((d) => ({ key: d, text: d })));
setSummary(summaryData);
if (dims.length > 0) setDimension(dims[0]);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Первый прогон обоих табов, как только известна выбранная ось (dimension).
const [initialized, setInitialized] = useState(false);
useEffect(() => {
if (initialized || dimensionOptions.length === 0) return;
setInitialized(true);
void refreshTab1();
void refreshTab2();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dimensionOptions]);
const onGeneralFilterChange = () => {
if (!initialized) return;
refreshCurrentTab();
};
const onResultsFilterChange = () => {
if (!initialized) return;
void refreshTab2();
};
const resetFilters = () => {
setPollId(null);
setOrgId(null);
setGroupId(null);
setYear(null);
setTimeout(refreshCurrentTab, 0);
};
const dimColor = (i: number) => categoricalColor(i, dark);
return (
<div className="page">
<header className="app-header">
<span className="brand">ЦОПП</span>
<span className="divider" />
<span className="subtitle">Статистика прохождений</span>
</header>
<div className="container">
<div className="summary-row">
<SummaryTile title="Всего ответов" value={summary.total_responses} icon={<IconAssignment />} color="var(--primary)" />
<SummaryTile title="Тестов всего" value={summary.total_polls} icon={<IconQuiz />} color="var(--info)" />
<SummaryTile title="Активных тестов" value={summary.active_polls} icon={<IconPlayCircle />} color="var(--success)" />
<SummaryTile title="Организаций с ответами" value={summary.orgs_active} icon={<IconBusiness />} color="var(--accent)" />
</div>
<div className="filters-card">
<span className="filters-label">Фильтры</span>
<div className="filters-row">
<SearchableDropdown
label="Тест"
hint="Все тесты"
options={pollOptions}
value={pollId}
onChange={(v) => {
setPollId(v);
setTimeout(onGeneralFilterChange, 0);
}}
/>
<SearchableDropdown
label="Организация"
hint="Все организации"
options={orgOptions}
value={orgId}
onChange={(v) => {
setOrgId(v);
setTimeout(onGeneralFilterChange, 0);
}}
/>
<SearchableDropdown
label="Группа"
hint="Все группы"
options={groupOptions}
value={groupId}
onChange={(v) => {
setGroupId(v);
setTimeout(onGeneralFilterChange, 0);
}}
/>
<SearchableDropdown
label="Год"
hint="Все годы"
options={yearOptions}
value={year}
onChange={(v) => {
setYear(v);
setTimeout(onGeneralFilterChange, 0);
}}
/>
</div>
<div className="filters-actions">
<button className="btn btn-ghost" onClick={resetFilters}>
<IconFilterOff /> Сбросить фильтры
</button>
<button className="btn btn-primary" onClick={refreshCurrentTab}>
<IconRefresh /> Обновить
</button>
</div>
</div>
<div className="tabs-bar">
<button className={"tab-btn" + (tab === 0 ? " active" : "")} onClick={() => setTab(0)}>
<IconBarChart /> Прохождения
</button>
<button className={"tab-btn" + (tab === 1 ? " active" : "")} onClick={() => setTab(1)}>
<IconInsights /> Результаты тестов
</button>
</div>
{tab === 0 ? (
<div className="chart-grid">
<ChartCard title="По тестам" loading={tab1Loading}>
<BarChart data={toValueData(pollData)} colorFor={() => "var(--primary)"} />
</ChartCard>
<ChartCard title="По организациям" loading={tab1Loading}>
<BarChart data={toValueData(orgData)} colorFor={() => "var(--info)"} />
</ChartCard>
<ChartCard title="По группам" loading={tab1Loading}>
<BarChart data={toValueData(groupData)} colorFor={() => "var(--success)"} />
</ChartCard>
<ChartCard title="Динамика по месяцам" loading={tab1Loading}>
<LineChart data={toValueData(monthData)} formatValue={(v) => String(v)} />
</ChartCard>
</div>
) : (
<div className="chart-grid">
<div className="filters-card">
<span className="filters-label">Фильтр по оси/типу</span>
<div className="filters-row">
<SearchableDropdown
label="Ось/Тип"
hint="Выберите ось"
options={dimensionOptions}
value={dimension}
onChange={(v) => {
setDimension(v);
setTimeout(onResultsFilterChange, 0);
}}
/>
<SearchableDropdown
label="Сравнение по"
hint="Организациям"
options={COMPARE_OPTIONS}
value={compareBy}
onChange={(v) => {
setCompareBy(v ?? "organization");
setTimeout(onResultsFilterChange, 0);
}}
/>
</div>
</div>
<ChartCard title="Средний балл по осям" loading={tab2Loading}>
<BarChart
data={avgDimData.map((d) => ({ label: d.label, value: d.avg }))}
colorFor={dimColor}
formatValue={(v) => v.toFixed(1)}
/>
</ChartCard>
<ChartCard title="Распределение ведущего типа" loading={tab2Loading}>
<PartToWholeBar data={leadingData.map((d) => ({ label: d.label, count: d.count }))} dark={dark} />
</ChartCard>
<ChartCard title={`Сравнение по ${compareBy === "organization" ? "организациям" : "группам"}`} loading={tab2Loading}>
<BarChart
data={compareData.map((d) => ({ label: d.label, value: d.avg }))}
colorFor={dimColor}
formatValue={(v) => v.toFixed(1)}
/>
</ChartCard>
<ChartCard title="Динамика среднего балла" loading={tab2Loading}>
<LineChart data={trendData.map((d) => ({ label: d.label, value: d.avg }))} formatValue={(v) => v.toFixed(1)} />
</ChartCard>
<div className="chart-card">
<div className="chart-card-title">Топ-10 участников по выбранной оси</div>
{tab2Loading ? (
<div className="chart-loading">
<div className="spinner" style={{ margin: "0 auto" }} />
</div>
) : (
<TopUsersTable rows={topUsers} />
)}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,156 @@
// Единое место для всех HTTP-запросов к бэкенду.
// Порт stats/api_client.py. Базовый путь /api проксируется nginx (прод) или Vite (dev) на api:8000.
// Все эндпоинты публичные — дашборд не требует авторизации (как и раньше).
const BASE = "/api";
async function getJson<T>(path: string): Promise<T | null> {
try {
const resp = await fetch(BASE + path);
if (resp.status !== 200) return null;
return (await resp.json()) as T;
} catch {
return null;
}
}
function qs(params: Record<string, string | number | null | undefined>): string {
const usp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== null && v !== undefined && v !== "") usp.set(k, String(v));
}
const s = usp.toString();
return s ? `?${s}` : "";
}
export interface Summary {
total_responses: number;
total_polls: number;
active_polls: number;
orgs_active: number;
}
export interface Poll {
id: string;
title: string;
}
export interface Organization {
id: string;
name_organization: string;
}
export interface Group {
id: string;
name_group: string;
}
export interface ChartDatum {
label: string;
count: number;
}
export interface AvgDatum {
label: string;
avg: number;
count?: number;
}
export interface TopUserRow {
name: string;
poll: string;
value: number;
}
export type GroupBy = "poll" | "organization" | "group" | "month";
export type CompareBy = "organization" | "group";
export interface Filters {
poll_id?: string | null;
org_id?: string | null;
group_id?: string | null;
year?: number | null;
}
export async function getSummary(): Promise<Summary> {
return (
(await getJson<Summary>("/stats/summary")) ?? {
total_responses: 0,
total_polls: 0,
active_polls: 0,
orgs_active: 0,
}
);
}
export async function getYears(): Promise<number[]> {
return (await getJson<number[]>("/stats/years")) ?? [];
}
export async function getPolls(): Promise<Poll[]> {
return (await getJson<Poll[]>("/polls/")) ?? [];
}
export async function getOrganizations(): Promise<Organization[]> {
return (await getJson<Organization[]>("/organizations/")) ?? [];
}
export async function getGroups(): Promise<Group[]> {
return (await getJson<Group[]>("/groups/")) ?? [];
}
export async function getResponsesGrouped(groupBy: GroupBy, f: Filters): Promise<ChartDatum[]> {
const query = qs({ group_by: groupBy, poll_id: f.poll_id, org_id: f.org_id, group_id: f.group_id, year: f.year });
return (await getJson<ChartDatum[]>(`/stats/responses${query}`)) ?? [];
}
export async function getDimensions(pollId?: string | null): Promise<string[]> {
const query = qs({ poll_id: pollId });
return (await getJson<string[]>(`/stats/results/dimensions${query}`)) ?? [];
}
export async function getAvgByDimension(f: Filters): Promise<AvgDatum[]> {
const query = qs({ poll_id: f.poll_id, org_id: f.org_id, group_id: f.group_id, year: f.year });
return (await getJson<AvgDatum[]>(`/stats/results/avg-by-dimension${query}`)) ?? [];
}
export async function getLeadingType(f: Filters): Promise<ChartDatum[]> {
const query = qs({ poll_id: f.poll_id, org_id: f.org_id, group_id: f.group_id, year: f.year });
return (await getJson<ChartDatum[]>(`/stats/results/leading-type${query}`)) ?? [];
}
export async function getCompareGroups(
dimensionName: string,
by: CompareBy,
pollId?: string | null,
year?: number | null,
): Promise<AvgDatum[]> {
const query = qs({ dimension_name: dimensionName, by, poll_id: pollId, year });
return (await getJson<AvgDatum[]>(`/stats/results/compare-groups${query}`)) ?? [];
}
export async function getTopUsers(
dimensionName: string,
f: Filters,
limit = 10,
): Promise<TopUserRow[]> {
const query = qs({
dimension_name: dimensionName,
poll_id: f.poll_id,
org_id: f.org_id,
group_id: f.group_id,
year: f.year,
limit,
});
return (await getJson<TopUserRow[]>(`/stats/results/top-users${query}`)) ?? [];
}
export async function getDimensionTrend(dimensionName: string, f: Filters): Promise<AvgDatum[]> {
const query = qs({
dimension_name: dimensionName,
poll_id: f.poll_id,
org_id: f.org_id,
group_id: f.group_id,
});
return (await getJson<AvgDatum[]>(`/stats/results/dimension-trend${query}`)) ?? [];
}

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
export function ChartCard({
title,
loading,
children,
}: {
title: string;
loading?: boolean;
children: ReactNode;
}) {
return (
<div className="chart-card">
<div className="chart-card-title">{title}</div>
{loading ? (
<div className="chart-loading">
<div className="spinner" style={{ margin: "0 auto" }} />
</div>
) : (
children
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useMemo, useState } from "react";
import { IconCaretDown } from "./icons";
export interface Option {
key: string;
text: string;
}
// Выпадающий список с поиском — порт web-react/src/components/SearchableDropdown.tsx
// (изначально web/designer.py SearchableDropdown из Flet-версии).
export function SearchableDropdown({
label,
hint,
options,
value,
onChange,
}: {
label?: string;
hint?: string;
options: Option[];
value: string | null;
onChange: (key: string | null) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const selected = useMemo(() => options.find((o) => o.key === value) ?? null, [options, value]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.text.toLowerCase().includes(q));
}, [options, query]);
return (
<div className="field">
{label && <span className="field-label">{label}</span>}
<div
className="dropdown-control"
onClick={() => {
setQuery("");
setOpen(true);
}}
>
<span className={"dropdown-value" + (selected ? "" : " placeholder")}>
{selected ? selected.text : hint ?? "Выберите..."}
</span>
<IconCaretDown style={{ color: "var(--text-secondary)", fontSize: "1.2rem", flexShrink: 0 }} />
</div>
{open && (
<div className="modal-backdrop" onClick={() => setOpen(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-title">{label}</div>
<input
className="dropdown-search"
placeholder="Введите для поиска..."
value={query}
autoFocus
onChange={(e) => setQuery(e.target.value)}
/>
<hr className="divider" />
<div className="sd-list">
{filtered.length === 0 && <div className="dropdown-empty">Ничего не найдено</div>}
{filtered.map((o) => (
<div
key={o.key}
className={"dropdown-option" + (o.key === value ? " active" : "")}
onClick={() => {
onChange(o.key);
setOpen(false);
}}
>
{o.text}
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
export function SummaryTile({
title,
value,
icon,
color,
}: {
title: string;
value: number | string;
icon: ReactNode;
color: string;
}) {
return (
<div className="summary-tile">
<div className="tile-head">
<span style={{ color, fontSize: "1.1rem", display: "flex" }}>{icon}</span>
<span>{title}</span>
</div>
<div className="tile-value">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import type { TopUserRow } from "../api/client";
export function TopUsersTable({ rows }: { rows: TopUserRow[] }) {
if (rows.length === 0) {
return <div className="chart-empty">Нет данных</div>;
}
return (
<div style={{ overflowX: "auto" }}>
<table className="stats-table">
<thead>
<tr>
<th>Участник</th>
<th>Тест</th>
<th style={{ textAlign: "right" }}>Балл</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td>{r.name}</td>
<td className="poll-cell" title={r.poll}>
{r.poll}
</td>
<td className="value-cell" style={{ textAlign: "right" }}>
{r.value}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { useState } from "react";
import { ChartTooltip, type TooltipState } from "./ChartTooltip";
export interface BarDatum {
label: string;
value: number;
}
// Горизонтальный bar chart: ≤24px толщина бара, 4px скруглённый конец,
// подпись значения у края, hover-тултип на каждом баре (сам бар — hit target).
export function BarChart({
data,
colorFor,
formatValue = (v) => String(v),
xLabel,
}: {
data: BarDatum[];
colorFor: (index: number) => string;
formatValue?: (v: number) => string;
xLabel?: string;
}) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
if (data.length === 0) {
return <div className="chart-empty">Нет данных</div>;
}
const max = Math.max(...data.map((d) => d.value), 1);
return (
<div style={{ position: "relative" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{data.map((d, i) => {
const pct = Math.max((d.value / max) * 100, 2);
const color = colorFor(i);
return (
<div
key={d.label + i}
style={{ display: "flex", alignItems: "center", gap: 10 }}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const parent = e.currentTarget.parentElement!.parentElement!.getBoundingClientRect();
setTooltip({
x: rect.left - parent.left + rect.width * 0.3,
y: rect.top - parent.top,
label: d.label,
value: formatValue(d.value),
swatch: color,
});
}}
onMouseLeave={() => setTooltip(null)}
onFocus={() =>
setTooltip({ x: 0, y: 0, label: d.label, value: formatValue(d.value), swatch: color })
}
onBlur={() => setTooltip(null)}
tabIndex={0}
>
<span
title={d.label}
style={{
width: 170,
flexShrink: 0,
fontSize: "0.78rem",
color: "var(--text-secondary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{d.label}
</span>
<div
style={{
flex: 1,
background: "var(--surface-alt)",
borderRadius: 4,
height: 20,
position: "relative",
}}
>
<div
style={{
width: `${pct}%`,
height: "100%",
background: color,
borderRadius: 4,
transition: "width 0.3s ease",
}}
/>
</div>
<span
style={{
minWidth: 44,
textAlign: "right",
fontSize: "0.8rem",
fontWeight: 700,
color: "var(--text-primary)",
fontVariantNumeric: "tabular-nums",
}}
>
{formatValue(d.value)}
</span>
</div>
);
})}
</div>
{xLabel && (
<div style={{ fontSize: "0.72rem", color: "var(--text-secondary)", marginTop: 8, marginLeft: 180 }}>
{xLabel}
</div>
)}
<ChartTooltip tooltip={tooltip} />
</div>
);
}

View File

@@ -0,0 +1,50 @@
// Плавающий тултип, привязанный к позиции курсора внутри контейнера графика.
// Общий для всех типов графиков — см. dataviz: "значение — сильный элемент, подпись — вторичная".
export interface TooltipState {
x: number;
y: number;
label: string;
value: string;
swatch?: string;
}
export function ChartTooltip({ tooltip }: { tooltip: TooltipState | null }) {
if (!tooltip) return null;
return (
<div
style={{
position: "absolute",
left: tooltip.x,
top: tooltip.y,
transform: "translate(-50%, -100%)",
marginTop: -10,
background: "var(--chart-tooltip-bg)",
color: "var(--chart-tooltip-text)",
borderRadius: 8,
padding: "0.4rem 0.6rem",
fontSize: "0.78rem",
pointerEvents: "none",
whiteSpace: "nowrap",
zIndex: 30,
display: "flex",
alignItems: "center",
gap: 6,
boxShadow: "0 4px 12px rgba(0,0,0,0.18)",
}}
>
{tooltip.swatch && (
<span
style={{
width: 8,
height: 8,
borderRadius: 2,
background: tooltip.swatch,
flexShrink: 0,
}}
/>
)}
<span style={{ opacity: 0.85 }}>{tooltip.label}</span>
<strong>{tooltip.value}</strong>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { useRef, useState } from "react";
import { ChartTooltip, type TooltipState } from "./ChartTooltip";
export interface LineDatum {
label: string;
value: number;
}
const W = 1000;
const H = 300;
const PAD_L = 44;
const PAD_R = 16;
const PAD_T = 16;
const PAD_B = 48;
// Линейный график для одного ряда: 2px линия, area fill ~10%, маркеры ≥8px
// с 2px кольцом в цвет поверхности, вертикальный crosshair с тултипом,
// прямая подпись значения на последней точке.
export function LineChart({
data,
color = "var(--primary)",
formatValue = (v) => String(v),
}: {
data: LineDatum[];
color?: string;
formatValue?: (v: number) => string;
}) {
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
if (data.length === 0) {
return <div className="chart-empty">Нет данных</div>;
}
const max = Math.max(...data.map((d) => d.value), 1);
const plotW = W - PAD_L - PAD_R;
const plotH = H - PAD_T - PAD_B;
const n = data.length;
const xAt = (i: number) => PAD_L + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW);
const yAt = (v: number) => PAD_T + plotH - (v / max) * plotH;
const points = data.map((d, i) => ({ x: xAt(i), y: yAt(d.value), ...d }));
const linePath = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
const areaPath = `${linePath} L ${points[points.length - 1].x} ${PAD_T + plotH} L ${points[0].x} ${PAD_T + plotH} Z`;
const gridLines = [0, 0.25, 0.5, 0.75, 1];
const handleMove = (e: React.PointerEvent<SVGSVGElement>) => {
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const relX = ((e.clientX - rect.left) / rect.width) * W;
let nearest = 0;
let best = Infinity;
points.forEach((p, i) => {
const d = Math.abs(p.x - relX);
if (d < best) {
best = d;
nearest = i;
}
});
setHoverIdx(nearest);
const p = points[nearest];
setTooltip({
x: (p.x / W) * rect.width,
y: (p.y / H) * rect.height,
label: p.label,
value: formatValue(p.value),
swatch: color,
});
};
const last = points[points.length - 1];
return (
<div style={{ position: "relative" }}>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: "100%", height: "auto", display: "block" }}
onPointerMove={handleMove}
onPointerLeave={() => {
setHoverIdx(null);
setTooltip(null);
}}
>
{/* gridlines */}
{gridLines.map((g) => {
const y = PAD_T + plotH * (1 - g);
return (
<g key={g}>
<line x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} stroke="var(--chart-grid)" strokeWidth={1} />
<text x={PAD_L - 8} y={y + 4} fontSize={11} fill="var(--text-secondary)" textAnchor="end">
{Math.round(max * g)}
</text>
</g>
);
})}
{/* area fill */}
<path d={areaPath} fill={color} opacity={0.1} stroke="none" />
{/* line */}
<path d={linePath} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{/* crosshair */}
{hoverIdx !== null && (
<line
x1={points[hoverIdx].x}
y1={PAD_T}
x2={points[hoverIdx].x}
y2={PAD_T + plotH}
stroke="var(--text-secondary)"
strokeWidth={1}
strokeDasharray="3 3"
/>
)}
{/* markers */}
{points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r={hoverIdx === i ? 6 : 4.5}
fill={color}
stroke="var(--surface)"
strokeWidth={2}
/>
))}
{/* end label */}
<text x={last.x} y={last.y - 12} fontSize={12} fontWeight={700} fill="var(--text-primary)" textAnchor="end">
{formatValue(last.value)}
</text>
{/* x labels */}
{points.map((p, i) => {
const anchor = i === 0 ? "start" : i === points.length - 1 ? "end" : "middle";
return (
<text key={i} x={p.x} y={H - PAD_B + 20} fontSize={11} fill="var(--text-secondary)" textAnchor={anchor}>
{p.label}
</text>
);
})}
</svg>
<ChartTooltip tooltip={tooltip} />
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { useState } from "react";
import { ChartTooltip, type TooltipState } from "./ChartTooltip";
import { categoricalColor } from "../../theme/palette";
export interface ShareDatum {
label: string;
count: number;
}
// Part-to-whole как один горизонтальный 100%-stacked bar вместо pie chart —
// dataviz: "part-to-whole rides on the stacked bar chart; donut stays deprioritized".
// Легенда обязательна (>=2 сегментов), т.к. на самих сегментах подписи не всегда влезают.
// Токен-потолок категориальной палитры — 8 слотов (см. dataviz choosing-a-form:
// "series 7-8: token ceiling; past it, fold the tail into Other"). Данные уже
// отсортированы бэкендом по убыванию — берём топ-7 и сворачиваем остаток.
const MAX_SLOTS = 7;
const OTHER_COLOR_LIGHT = "#898781";
const OTHER_COLOR_DARK = "#a7b2c0";
function foldTail(data: ShareDatum[]): ShareDatum[] {
if (data.length <= MAX_SLOTS + 1) return data;
const head = data.slice(0, MAX_SLOTS);
const tail = data.slice(MAX_SLOTS);
const otherCount = tail.reduce((s, d) => s + d.count, 0);
return [...head, { label: `Другое (${tail.length})`, count: otherCount }];
}
export function PartToWholeBar({ data, dark }: { data: ShareDatum[]; dark: boolean }) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
if (data.length === 0) {
return <div className="chart-empty">Нет данных</div>;
}
const folded = foldTail(data);
const wasFolded = folded.length < data.length;
const isOther = (i: number) => wasFolded && i === folded.length - 1;
const total = folded.reduce((s, d) => s + d.count, 0) || 1;
return (
<div style={{ position: "relative" }}>
<div
style={{
display: "flex",
height: 28,
borderRadius: 6,
overflow: "hidden",
gap: 2,
background: "var(--surface-alt)",
}}
>
{folded.map((d, i) => {
const pct = (d.count / total) * 100;
const color = isOther(i) ? (dark ? OTHER_COLOR_DARK : OTHER_COLOR_LIGHT) : categoricalColor(i, dark);
return (
<div
key={d.label + i}
style={{
flexBasis: `${pct}%`,
flexGrow: 0,
flexShrink: 0,
minWidth: 2,
background: color,
cursor: "pointer",
}}
tabIndex={0}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const parent = e.currentTarget.parentElement!.parentElement!.getBoundingClientRect();
setTooltip({
x: rect.left - parent.left + rect.width / 2,
y: rect.top - parent.top,
label: d.label,
value: `${d.count} (${pct.toFixed(1)}%)`,
swatch: color,
});
}}
onMouseLeave={() => setTooltip(null)}
onFocus={() =>
setTooltip({ x: 0, y: 0, label: d.label, value: `${d.count} (${pct.toFixed(1)}%)`, swatch: color })
}
onBlur={() => setTooltip(null)}
/>
);
})}
</div>
<div className="chart-legend">
{folded.map((d, i) => {
const pct = (d.count / total) * 100;
const color = isOther(i) ? (dark ? OTHER_COLOR_DARK : OTHER_COLOR_LIGHT) : categoricalColor(i, dark);
return (
<span className="legend-item" key={d.label + i}>
<span className="legend-swatch" style={{ background: color }} />
{d.label} {d.count} ({pct.toFixed(1)}%)
</span>
);
})}
</div>
<ChartTooltip tooltip={tooltip} />
</div>
);
}

View File

@@ -0,0 +1,67 @@
// Небольшой набор inline-SVG иконок (замена Material Icons из Flet-версии).
import type { SVGProps } from "react";
type P = SVGProps<SVGSVGElement>;
const base = (p: P) => ({
width: "1em",
height: "1em",
viewBox: "0 0 24 24",
fill: "currentColor",
...p,
});
export const IconAssignment = (p: P) => (
<svg {...base(p)}>
<path d="M19 3h-4.18a3 3 0 0 0-5.64 0H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Zm-7 0a1 1 0 1 1-1 1 1 1 0 0 1 1-1Zm-1.5 14.5-3.5-3.5 1.4-1.4 2.1 2.1 5.1-5.1L17 11l-6.5 6.5Z" />
</svg>
);
export const IconQuiz = (p: P) => (
<svg {...base(p)}>
<path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Zm-6.5 15h-1v-1h1Zm1-4.4c-.6.5-.7.7-.7 1.4h-1c0-1.1.3-1.6 1-2.2.5-.4.8-.7.8-1.3 0-.7-.5-1.1-1.3-1.1s-1.3.5-1.4 1.2l-1.1-.2c.2-1.2 1.1-2 2.5-2 1.5 0 2.4.9 2.4 2.1 0 .8-.4 1.4-1.2 2.1Z" />
</svg>
);
export const IconPlayCircle = (p: P) => (
<svg {...base(p)}>
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm-2 14.5v-9l7 4.5Z" />
</svg>
);
export const IconBusiness = (p: P) => (
<svg {...base(p)}>
<path d="M6 3h8v4h6v14H4V3Zm2 2v14h4v-4h4v4h4V9h-4V5H8Zm2 3h2v2h-2Zm4 0h2v2h-2ZM10 12h2v2h-2Zm4 0h2v2h-2Z" />
</svg>
);
export const IconFilterOff = (p: P) => (
<svg {...base(p)}>
<path d="M2.4 3.8 3.8 2.4l18 18-1.4 1.4-5.9-5.9-1.5 1.9v3.8h-2v-3.8L4.5 8.9l1.5-1.5H4V5h2.4Zm5.9 0H20v2h-9.7Z" />
</svg>
);
export const IconRefresh = (p: P) => (
<svg {...base(p)} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M20 11A8 8 0 1 0 18.5 16" />
<path d="M20 4v7h-7" />
</svg>
);
export const IconBarChart = (p: P) => (
<svg {...base(p)}>
<path d="M5 10h3v10H5Zm11-6h3v16h-3ZM10.5 15h3v5h-3Z" />
</svg>
);
export const IconInsights = (p: P) => (
<svg {...base(p)} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 17l6-6 4 4 8-8" />
<path d="M15 6h6v6" />
</svg>
);
export const IconCaretDown = (p: P) => (
<svg {...base(p)}>
<path d="M7 10l5 5 5-5Z" />
</svg>
);

10
stats-react/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/global.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,478 @@
/* Сброс + переменные. Цвета — web-react/src/theme/themes.ts (designer.py),
категориальная палитра графиков — theme/palette.ts (провалидирована dataviz-скиллом). */
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
:root {
color-scheme: light;
--primary: #f85a40;
--primary-dark: #d94530;
--primary-light: #ffeab7;
--accent: #ffc845;
--info: #007fbd;
--success: #85c446;
--background: #f4f6f8;
--surface: #ffffff;
--surface-alt: #f0f2f5;
--text-primary: #1e2a3a;
--text-secondary: #5a6779;
--text-on-primary: #ffffff;
--border: #dde3ea;
--chart-grid: #dde3ea;
--chart-tooltip-bg: #1e2a3a;
--chart-tooltip-text: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--primary: #f85a40;
--primary-dark: #d94530;
--primary-light: #ffeab7;
--accent: #ffc845;
--info: #4db3e8;
--success: #a2d97a;
--background: #141a22;
--surface: #1e2733;
--surface-alt: #263140;
--text-primary: #f2f4f7;
--text-secondary: #a7b2c0;
--text-on-primary: #ffffff;
--border: #33404f;
--chart-grid: #33404f;
--chart-tooltip-bg: #f2f4f7;
--chart-tooltip-text: #1e2a3a;
}
}
body {
background: var(--background);
color: var(--text-primary);
}
.muted {
color: var(--text-secondary);
}
/* ── Layout ──────────────────────────────────────────────────────────── */
.page {
min-height: 100vh;
padding-bottom: 2rem;
}
.app-header {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--surface);
padding: 0.9rem 1.25rem;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 20;
}
.app-header .brand {
color: var(--primary);
font-size: 1.35rem;
font-weight: 700;
}
.app-header .divider {
width: 1px;
height: 1.4rem;
background: var(--border);
}
.app-header .subtitle {
color: var(--text-primary);
font-size: 1rem;
}
.container {
max-width: 1300px;
margin: 0 auto;
padding: 0 1rem;
}
/* ── Summary tiles ───────────────────────────────────────────────────── */
.summary-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
padding: 1rem 0;
}
.summary-tile {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.9rem 1rem;
min-width: 190px;
flex: 1 1 190px;
}
.summary-tile .tile-head {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--text-secondary);
font-size: 0.72rem;
margin-bottom: 0.35rem;
}
.summary-tile .tile-value {
font-size: 1.7rem;
font-weight: 700;
color: var(--text-primary);
}
/* ── Filters ─────────────────────────────────────────────────────────── */
.filters-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
}
.filters-label {
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
margin-bottom: 0.5rem;
display: block;
}
.filters-row {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: flex-end;
}
.filters-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.7rem;
}
/* ── Buttons ─────────────────────────────────────────────────────────── */
.btn {
border: none;
border-radius: 8px;
padding: 0.5rem 0.9rem;
font-size: 0.85rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn-primary {
background: var(--primary);
color: var(--text-on-primary);
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-ghost {
background: transparent;
color: var(--primary);
}
.btn-ghost:hover {
background: var(--primary-light);
}
/* ── Dropdown ────────────────────────────────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 190px;
}
.field-label {
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 500;
}
.dropdown-control {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.55rem 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
cursor: pointer;
min-height: 2.3rem;
}
.dropdown-value {
font-size: 0.85rem;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-value.placeholder {
color: var(--text-secondary);
font-style: italic;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal {
background: var(--surface);
border-radius: 12px;
padding: 1rem;
width: 100%;
max-width: 380px;
max-height: 70vh;
display: flex;
flex-direction: column;
}
.modal-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.6rem;
}
.dropdown-search {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.7rem;
font-size: 0.85rem;
background: var(--background);
color: var(--text-primary);
}
.divider {
border: none;
border-top: 1px solid var(--border);
margin: 0.6rem 0;
}
.sd-list {
overflow-y: auto;
display: flex;
flex-direction: column;
}
.dropdown-option {
padding: 0.55rem 0.5rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
color: var(--text-primary);
}
.dropdown-option:hover {
background: var(--primary-light);
}
.dropdown-option.active {
background: var(--primary);
color: var(--text-on-primary);
}
.dropdown-empty {
color: var(--text-secondary);
font-size: 0.85rem;
padding: 0.5rem;
text-align: center;
}
/* ── Tabs ────────────────────────────────────────────────────────────── */
.tabs-bar {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
}
.tab-btn {
background: none;
border: none;
padding: 0.7rem 1rem;
font-size: 0.88rem;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.4rem;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab-btn.active {
color: var(--primary);
border-bottom-color: var(--primary);
font-weight: 600;
}
/* ── Chart cards ─────────────────────────────────────────────────────── */
.chart-grid {
display: flex;
flex-direction: column;
gap: 1rem;
padding-bottom: 1.5rem;
}
.chart-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem;
}
.chart-card-title {
font-size: 0.92rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.7rem;
}
.chart-empty {
color: var(--text-secondary);
font-style: italic;
font-size: 0.85rem;
padding: 2rem 0;
text-align: center;
}
.chart-loading {
padding: 2rem 0;
text-align: center;
color: var(--text-secondary);
font-size: 0.85rem;
}
/* ── Legend ──────────────────────────────────────────────────────────── */
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 0.6rem 1rem;
margin-top: 0.6rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.35rem;
}
.legend-swatch {
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
/* ── Table ───────────────────────────────────────────────────────────── */
.stats-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.stats-table th {
text-align: left;
color: var(--text-secondary);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.02em;
padding: 0.5rem 0.7rem;
border-bottom: 1px solid var(--border);
}
.stats-table td {
padding: 0.55rem 0.7rem;
border-bottom: 1px solid var(--border);
}
.stats-table tr:last-child td {
border-bottom: none;
}
.stats-table .value-cell {
font-weight: 700;
color: var(--primary);
font-variant-numeric: tabular-nums;
}
.stats-table .poll-cell {
color: var(--text-secondary);
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Spinner ─────────────────────────────────────────────────────────── */
.spinner {
width: 22px;
height: 22px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner-center {
display: flex;
justify-content: center;
padding: 2rem;
}

View File

@@ -0,0 +1,43 @@
// Палитра дашборда. Бренд-цвета — из web-react/src/theme/themes.ts (designer.py).
// Категориальная 8-цветная палитра для графиков (pie/multi-bar) провалидирована
// dataviz-скиллом (scripts/validate_palette.js) на CVD-безопасность в light и
// dark режимах — порядок слотов и hex не менять без повторной валидации.
// Единственная замена относительно референсного набора: слот "orange" — бренд-оранжевый.
export const BRAND = {
primary: "#F85A40",
primaryDark: "#D94530",
primaryLight: "#FFEAB7",
accent: "#FFC845",
info: "#007FBD",
success: "#85C446",
};
// [PASS] все проверки, light: surface #FFFFFF (WARN contrast на magenta/yellow/aqua — компенсируется прямыми подписями значений)
export const CATEGORICAL_LIGHT = [
"#2a78d6", // blue
"#008300", // green
"#e87ba4", // magenta
"#eda100", // yellow
"#1baf7a", // aqua
"#F85A40", // orange (бренд)
"#4a3aa7", // violet
"#e34948", // red
];
// [PASS] все проверки, dark: surface #1a1a19
export const CATEGORICAL_DARK = [
"#3987e5",
"#008300",
"#d55181",
"#c98500",
"#199e70",
"#D94530", // orange (бренд, тёмный шаг)
"#9085e9",
"#e66767",
];
export function categoricalColor(index: number, dark: boolean): string {
const arr = dark ? CATEGORICAL_DARK : CATEGORICAL_LIGHT;
return arr[index % arr.length];
}

View File

@@ -0,0 +1,18 @@
import { useEffect, useState } from "react";
// Категориальная палитра графиков зависит от режима (см. palette.ts) —
// нужно знать активный prefers-color-scheme, чтобы выбрать нужный набор.
export function useIsDark(): boolean {
const [dark, setDark] = useState(
() => typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches,
);
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => setDark(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return dark;
}

21
stats-react/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// В dev-режиме проксируем /api на запущенный локально API (docker compose up -d api).
// В проде эту роль выполняет nginx (stats-react/nginx.conf).
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
proxy: {
"/api": {
target: process.env.API_URL || "http://localhost:8000",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});

548
uv.lock generated
View File

@@ -37,10 +37,18 @@ name = "api-copp"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "bcrypt" },
{ name = "fastapi" },
{ name = "psycopg" },
{ name = "flet" },
{ name = "jose" },
{ name = "matplotlib" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pg8000" },
{ name = "pydantic" },
{ name = "python-docx" },
{ name = "python-dotenv" },
{ name = "python-jose" },
{ name = "python-multipart" },
{ name = "redis" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
@@ -56,13 +64,21 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "bcrypt", specifier = "==4.0.1" },
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
{ name = "fastapi", specifier = ">=0.128.0" },
{ name = "psycopg", specifier = ">=3.3.3" },
{ name = "flet", specifier = ">=0.82.2" },
{ name = "jose", specifier = ">=1.0.0" },
{ name = "matplotlib", specifier = ">=3.10.8" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "pg8000", specifier = ">=1.29.0" },
{ name = "pydantic", specifier = ">=2.12.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
{ name = "python-docx", specifier = ">=1.2.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "python-jose", specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "redis", specifier = ">=7.1.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
{ name = "sqlalchemy", specifier = ">=2.0.47" },
@@ -70,6 +86,34 @@ requires-dist = [
]
provides-extras = ["dev"]
[[package]]
name = "asn1crypto"
version = "1.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080, upload-time = "2022-03-15T14:46:52.889Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" },
]
[[package]]
name = "bcrypt"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/ae/3af7d006aacf513975fd1948a6b4d6f8b4a307f8a244e1a3d3774b297aad/bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd", size = 25498, upload-time = "2022-10-09T15:36:49.775Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/d4/3b2657bd58ef02b23a07729b0df26f21af97169dbd0b5797afa9e97ebb49/bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f", size = 473446, upload-time = "2022-10-09T15:36:25.481Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0a/1582790232fef6c2aa201f345577306b8bfe465c2c665dec04c86a016879/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0", size = 583044, upload-time = "2022-10-09T15:37:09.447Z" },
{ url = "https://files.pythonhosted.org/packages/41/16/49ff5146fb815742ad58cafb5034907aa7f166b1344d0ddd7fd1c818bd17/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410", size = 583189, upload-time = "2022-10-09T15:37:10.69Z" },
{ url = "https://files.pythonhosted.org/packages/aa/48/fd2b197a9741fa790ba0b88a9b10b5e88e62ff5cf3e1bc96d8354d7ce613/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344", size = 593473, upload-time = "2022-10-09T15:36:27.195Z" },
{ url = "https://files.pythonhosted.org/packages/7d/50/e683d8418974a602ba40899c8a5c38b3decaf5a4d36c32fc65dce454d8a8/bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a", size = 593249, upload-time = "2022-10-09T15:36:28.481Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a7/ee4561fd9b78ca23c8e5591c150cc58626a5dfb169345ab18e1c2c664ee0/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3", size = 583586, upload-time = "2022-10-09T15:37:11.962Z" },
{ url = "https://files.pythonhosted.org/packages/64/fe/da28a5916128d541da0993328dc5cf4b43dfbf6655f2c7a2abe26ca2dc88/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2", size = 593659, upload-time = "2022-10-09T15:36:30.049Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4f/3632a69ce344c1551f7c9803196b191a8181c6a1ad2362c225581ef0d383/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535", size = 613116, upload-time = "2022-10-09T15:37:14.107Z" },
{ url = "https://files.pythonhosted.org/packages/87/69/edacb37481d360d06fc947dab5734aaf511acb7d1a1f9e2849454376c0f8/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e", size = 624290, upload-time = "2022-10-09T15:36:31.251Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ca/6a534669890725cbb8c1fb4622019be31813c8edaa7b6d5b62fc9360a17e/bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab", size = 159428, upload-time = "2022-10-09T15:36:32.893Z" },
{ url = "https://files.pythonhosted.org/packages/46/81/d8c22cd7e5e1c6a7d48e41a1d1d46c92f17dae70a54d9814f746e6027dec/bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", size = 152930, upload-time = "2022-10-09T15:36:34.635Z" },
]
[[package]]
name = "black"
version = "26.1.0"
@@ -92,6 +136,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "click"
version = "8.3.1"
@@ -113,6 +166,60 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "contourpy"
version = "1.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" },
{ url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" },
{ url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" },
{ url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" },
{ url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" },
{ url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" },
{ url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" },
{ url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" },
{ url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" },
{ url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" },
{ url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" },
{ url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" },
{ url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" },
{ url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" },
{ url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" },
{ url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" },
{ url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" },
{ url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" },
{ url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
]
[[package]]
name = "cycler"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
]
[[package]]
name = "fastapi"
version = "0.128.0"
@@ -128,6 +235,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
]
[[package]]
name = "flet"
version = "0.82.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx", marker = "platform_system != 'Pyodide'" },
{ name = "msgpack" },
{ name = "oauthlib", marker = "platform_system != 'Pyodide'" },
{ name = "repath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/a7/46e73c489ec0f3fe65c3c183ddd64ef5c7fff14da2be81df6c0b6ad3e8fc/flet-0.82.2.tar.gz", hash = "sha256:eaad9675ea0c5b81a256c39f92354b219ca3c1fbe27ead7d2a7667a71c835dbe", size = 445579, upload-time = "2026-03-10T01:13:55.398Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/e3/4b8ee6a4bc226faa036056b6f24e27a64d41f85f70bc85bb17f0f386c947/flet-0.82.2-py3-none-any.whl", hash = "sha256:278806e5480423772d342566e49f06867bf13ced709bf449890f0bcb27d5a101", size = 541512, upload-time = "2026-03-10T01:13:53.679Z" },
]
[[package]]
name = "fonttools"
version = "4.62.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" },
{ url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" },
{ url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" },
{ url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" },
{ url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" },
{ url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" },
{ url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" },
{ url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" },
{ url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" },
{ url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" },
{ url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" },
{ url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" },
{ url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" },
{ url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" },
]
[[package]]
name = "greenlet"
version = "3.3.2"
@@ -137,7 +284,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
@@ -146,7 +292,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
@@ -162,6 +307,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
@@ -177,6 +335,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
@@ -195,6 +368,153 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jose"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/3d/832caa69cd0d3be2d608d8290be2221072669aa88e87690837f6b31c480f/jose-1.0.0.tar.gz", hash = "sha256:8436c3617cd94e1ba97828fbb1ce27c129f66c78fb855b4bb47e122b5f345fba", size = 9153, upload-time = "2015-11-13T10:52:21.506Z" }
[[package]]
name = "kiwisolver"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" },
{ url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" },
{ url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" },
{ url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" },
{ url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" },
{ url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" },
{ url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" },
{ url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" },
{ url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" },
{ url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" },
{ url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" },
{ url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" },
{ url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" },
{ url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" },
{ url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" },
{ url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" },
{ url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" },
{ url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" },
{ url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" },
{ url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" },
{ url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" },
{ url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" },
{ url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" },
]
[[package]]
name = "lxml"
version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
]
[[package]]
name = "matplotlib"
version = "3.10.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "contourpy" },
{ name = "cycler" },
{ name = "fonttools" },
{ name = "kiwisolver" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "pyparsing" },
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" },
{ url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" },
{ url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" },
{ url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" },
{ url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" },
{ url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" },
{ url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" },
{ url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" },
{ url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" },
{ url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" },
{ url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" },
{ url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" },
{ url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" },
]
[[package]]
name = "msgpack"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
{ url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
{ url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
{ url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
{ url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
{ url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
{ url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
{ url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
{ url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
{ url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
{ url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
@@ -204,6 +524,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "numpy"
version = "2.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
{ url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
{ url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
{ url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
{ url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
{ url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
{ url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
{ url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
{ url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
{ url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
{ url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
{ url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
{ url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
{ url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
{ url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
{ url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
{ url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
{ url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
]
[[package]]
name = "oauthlib"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@@ -213,6 +571,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "passlib"
version = "1.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
]
[package.optional-dependencies]
bcrypt = [
{ name = "bcrypt" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
@@ -222,6 +594,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "pg8000"
version = "1.31.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "scramp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c8/9a/077ab21e700051e03d8c5232b6bcb9a1a4d4b6242c9a0226df2cfa306414/pg8000-1.31.5.tar.gz", hash = "sha256:46ebb03be52b7a77c03c725c79da2ca281d6e8f59577ca66b17c9009618cae78", size = 118933, upload-time = "2025-09-14T09:16:49.748Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/07/5fd183858dff4d24840f07fc845f213cd371a19958558607ba22035dadd7/pg8000-1.31.5-py3-none-any.whl", hash = "sha256:0af2c1926b153307639868d2ee5cef6cd3a7d07448e12736989b10e1d491e201", size = 57816, upload-time = "2025-09-14T09:16:47.798Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]]
name = "platformdirs"
version = "4.5.1"
@@ -241,15 +659,12 @@ wheels = [
]
[[package]]
name = "psycopg"
version = "3.3.3"
name = "pyasn1"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
]
[[package]]
@@ -315,6 +730,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
@@ -343,6 +767,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-docx"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
@@ -352,6 +801,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pytokens"
version = "0.4.1"
@@ -406,6 +878,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
]
[[package]]
name = "repath"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/65/e1/824989291d0f01886074fdf9504ba54598f5665bc4dd373b589b87e76608/repath-0.9.0.tar.gz", hash = "sha256:8292139bac6a0e43fd9d70605d4e8daeb25d46672e484ed31a24c7ce0aef0fb7", size = 5492, upload-time = "2019-10-08T00:25:22.3Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/ed/92e9b8a3ffc562f21df14ef2538f54e911df29730e1f0d79130a4edc86e7/repath-0.9.0-py3-none-any.whl", hash = "sha256:ee079d6c91faeb843274d22d8f786094ee01316ecfe293a1eb6546312bb6a318", size = 4738, upload-time = "2019-10-08T00:25:20.842Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "ruff"
version = "0.14.14"
@@ -432,6 +928,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
]
[[package]]
name = "scramp"
version = "1.4.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asn1crypto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/52/a866f1ac9ae9025ec7f9bea803bba9d54796f8a84236165a700831f61b27/scramp-1.4.8.tar.gz", hash = "sha256:bd018fabfe46343cceeb9f1c3e8d23f55770271e777e3accbfaee3ff0a316e71", size = 16630, upload-time = "2026-01-06T21:01:01.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/07/a962d2477331abfdb2c6a8251b65c673dbb07ad707d1882d61562b8b9147/scramp-1.4.8-py3-none-any.whl", hash = "sha256:87c2f15976845a2872fe5490a06097f0d01813cceb53774ea168c911f2ad025c", size = 13121, upload-time = "2026-01-06T21:00:59.474Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.47"
@@ -491,15 +1008,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]
name = "uvicorn"
version = "0.40.0"

6
web-react/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.vite
*.local
Dockerfile
.dockerignore

4
web-react/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
*.local
.vite

14
web-react/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# --- Стадия 1: сборка React-приложения ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# --- Стадия 2: раздача статики через nginx + прокси /api ---
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

58
web-react/README.md Normal file
View File

@@ -0,0 +1,58 @@
# web-react — фронтенд на React (Vite + TypeScript)
Замена Flet-интерфейса (`../web`). Обычный SPA: грузится в браузер один раз и работает
локально, обращаясь к API по HTTP. **Нет WebSocket и серверного UI-стейта** — это убирает
зависания, которые были у Flet (он рендерил UI на сервере и держал WS на каждого юзера).
Flet-сервис `web` намеренно оставлен в `docker-compose.yml` как запасной. Этот новый
фронтенд публикуется на **порту 3000**.
## Архитектура
```
Браузер ──▶ [ nginx :80 (контейнер web-react) ]
├── / → статика React (SPA, fallback на index.html)
└── /api/* → proxy_pass http://api:8000/ (включая SVG-диаграммы)
```
API-контейнер доступен только внутри Docker-сети, поэтому браузер ходит к нему через
nginx-прокси на том же origin (`/api/...`). SVG-диаграмма тянется напрямую `<img>`/`<a download>`.
## Разработка (локально)
```bash
# 1. Поднять API + БД (из корня репозитория)
docker compose up -d api postgres redis # postgres/redis — из docker-compose_db.yml
# 2. Dev-сервер фронтенда
cd web-react
npm install
npm run dev # http://localhost:5173, /api проксируется на http://localhost:8000
```
Адрес API для dev-прокси переопределяется переменной `API_URL` (см. `vite.config.ts`).
## Сборка / прод
```bash
# из корня репозитория
docker compose up -d --build web-react # http://localhost:3000
```
Либо вручную: `npm run build` → статика в `dist/`, раздаётся nginx (см. `Dockerfile`, `nginx.conf`).
## Структура
| Путь | Назначение |
|---|---|
| `src/api/client.ts` | все запросы к API (порт `web/api_client.py`) |
| `src/auth/` | `AuthContext` — токен в localStorage + `/users/me` |
| `src/theme/` | 5 тем + слайдер масштаба через CSS-переменные |
| `src/components/` | переиспользуемые компоненты (Button, TextField, SearchableDropdown, Layout, Modal, RadarBlock…) |
| `src/pages/` | экраны: Login, Register, Home, TestList, Poll, MyTests, Response, Profile |
| `src/styles/global.css` | стили на CSS-переменных |
## Маршруты
`/login`, `/register`, `/` (домашняя), `/tests`, `/poll/:pollId`, `/my-tests`,
`/response/:responseId`, `/profile`. Защищённые маршруты требуют токен (`ProtectedRoute`).

13
web-react/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЦОПП</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
web-react/nginx.conf Normal file
View File

@@ -0,0 +1,43 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Статика SPA: всё неизвестное отдаём index.html (клиентский роутинг).
# index.html без явного Cache-Control браузер кэширует эвристически (по
# Last-Modified) — после деплоя часть пользователей видит старую версию,
# пока не сделают hard refresh. no-cache вынуждает всегда идти на сервер
# за свежим HTML (быстрый 304 по ETag, если файл не менялся); хэшированные
# /assets/ ниже как кэшировались на год, так и кэшируются — их имя меняется
# при каждой сборке, так что инвалидация не нужна.
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
# Прокси на внутренний API-контейнер (браузер к api:8000 напрямую не ходит).
# Префикс /api срезается: /api/auth/login -> http://api:8000/auth/login
#
# Используем встроенный DNS Docker (127.0.0.11) и переменную в proxy_pass,
# чтобы nginx переразрешал имя `api` на каждый запрос. Иначе nginx кэширует
# IP при старте и ломается (502), если контейнер api перезапустился и сменил IP.
location /api/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $api_backend api;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://$api_backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Кэш статических ассетов с хэшем в имени
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

1774
web-react/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
web-react/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "copp-web-react",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.6"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="M15.829,2c-.413-1.164-1.525-2-2.829-2h-2c-1.304,0-2.416,.836-2.829,2H3V21c0,1.654,1.346,3,3,3h12c1.654,0,3-1.346,3-3V2h-5.171Zm3.171,19c0,.551-.449,1-1,1H6c-.551,0-1-.449-1-1V4h5v-1c0-.551,.449-1,1-1h2c.551,0,1,.449,1,1v1h5V21Zm-8-4h2v2.013h-2v-2.013Zm1.917-9.911c1.19,.308,2.158,1.276,2.465,2.467,.429,1.66-.376,3.381-1.913,4.092-.281,.13-.47,.472-.47,.852v.5h-2v-.5c0-1.162,.64-2.208,1.629-2.666,.548-.254,1.038-.919,.816-1.778-.126-.49-.54-.904-1.03-1.03-.483-.127-.956-.034-1.333,.258-.371,.287-.583,.72-.583,1.188h-2c0-1.091,.495-2.1,1.358-2.769,.863-.67,1.978-.894,3.059-.614Z"/>
</svg>

After

Width:  |  Height:  |  Size: 737 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="m19,0H5C2.243,0,0,2.243,0,5v14c0,2.757,2.243,5,5,5h14c2.757,0,5-2.243,5-5V5c0-2.757-2.243-5-5-5Zm3,19c0,1.654-1.346,3-3,3H5c-1.654,0-3-1.346-3-3V5c0-1.654,1.346-3,3-3h14c1.654,0,3,1.346,3,3v14Zm-3-1c0,.552-.447,1-1,1H6c-.552,0-1-.448-1-1s.448-1,1-1h12c.553,0,1,.448,1,1Zm-8.665-7h.665v.725c0,1.333-1.222,1.275-1.222,1.275,0,0-.778.448-.778,1s.448,1,1,1h4c.553,0,1-.448,1-1s-.778-1-.778-1c0,0-1.222.058-1.222-1.275v-.725h.664c2.548,0,4.503-1.258,5.229-3.366.206-.599.107-1.267-.264-1.787-.378-.53-.991-.847-1.641-.847h-1.008c.067-.526-.35-1-.891-1-1.557,0-4.621,0-6.177,0-.541,0-.959.474-.891,1h-1.008c-.649,0-1.263.317-1.641.847-.371.52-.469,1.188-.263,1.787.726,2.108,2.68,3.366,5.228,3.366Zm6.667-4.018c-.441,1.282-1.658,2.018-3.338,2.018h-.262c.138-.188.297-.364.492-.506.566-.412.999-.939,1.335-1.502l1.773-.01Zm-8.226.018c.335.56.767,1.084,1.33,1.494.195.142.353.317.492.506h-.262c-1.679,0-2.896-.735-3.324-2h1.765Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

46
web-react/src/App.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import { ThemeProvider } from "./theme/ThemeContext";
import { ProtectedRoute, AccountRoute } from "./components/ProtectedRoute";
import { LoginPage } from "./pages/LoginPage";
import { RegisterPage } from "./pages/RegisterPage";
import { AnonymousEntryPage } from "./pages/AnonymousEntryPage";
import { HomePage } from "./pages/HomePage";
import { TestListPage } from "./pages/TestListPage";
import { PollPage } from "./pages/PollPage";
import { MyTestsPage } from "./pages/MyTestsPage";
import { ResponsePage } from "./pages/ResponsePage";
import { ProfilePage } from "./pages/ProfilePage";
// Доступно и по токену, и анонимно (школа+класс без аккаунта).
function protect(el: React.ReactNode) {
return <ProtectedRoute>{el}</ProtectedRoute>;
}
// Требует настоящий аккаунт — история и профиль анонимусам недоступны.
function account(el: React.ReactNode) {
return <AccountRoute>{el}</AccountRoute>;
}
export default function App() {
return (
<ThemeProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/anonymous" element={<AnonymousEntryPage />} />
<Route path="/" element={protect(<HomePage />)} />
<Route path="/tests" element={protect(<TestListPage />)} />
<Route path="/poll/:pollId" element={protect(<PollPage />)} />
<Route path="/my-tests" element={account(<MyTestsPage />)} />
<Route path="/response/:responseId" element={account(<ResponsePage />)} />
<Route path="/profile" element={account(<ProfilePage />)} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</ThemeProvider>
);
}

283
web-react/src/api/client.ts Normal file
View File

@@ -0,0 +1,283 @@
// Единое место для всех HTTP-запросов к бэкенду.
// Порт web/api_client.py. Базовый путь /api проксируется nginx (прод) или Vite (dev) на api:8000.
const BASE = "/api";
// ---------------------------------------------------------------------------
// Типы
// ---------------------------------------------------------------------------
export interface Me {
id: string;
first_name?: string;
last_name?: string;
group_id?: string | null;
organization_id?: string | null;
}
export interface Organization {
id: string;
name_organization: string;
group_id?: string | null;
}
export interface Group {
id: string;
name_group: string;
}
export interface Choice {
id: string;
text: string;
}
export interface Question {
id: string;
text: string;
choices: Choice[];
}
export interface Poll {
id: string;
title: string;
description?: string | null;
questions?: Question[];
}
export interface Answer {
question_id: string;
choice_id: string;
text?: string | null;
}
export interface ResponseDetail {
id: string;
poll_id: string;
submitted_at: string;
answers: Answer[];
}
export interface UserResponse {
id: string;
poll_id: string;
submitted_at: string;
}
export interface RadarResult {
image_url?: string | null;
[k: string]: unknown;
}
export type Result<T> = { data: T | null; error: string | null };
// ---------------------------------------------------------------------------
// Низкоуровневые помощники
// ---------------------------------------------------------------------------
function authHeaders(token?: string | null): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function safeJson(resp: Response): Promise<any> {
try {
return await resp.json();
} catch {
return null;
}
}
// GET, возвращает распарсенный JSON или null при любой ошибке (как в api_client.py)
async function getJson<T>(path: string, token?: string | null): Promise<T | null> {
try {
const resp = await fetch(BASE + path, { headers: authHeaders(token) });
if (resp.ok) return (await resp.json()) as T;
} catch {
/* network error -> null */
}
return null;
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
export async function login(username: string, password: string): Promise<Result<string>> {
try {
const body = new URLSearchParams({ username, password });
const resp = await fetch(BASE + "/auth/login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (resp.ok) {
const json = await resp.json();
return { data: json.access_token, error: null };
}
if (resp.status === 401) return { data: null, error: "Неверный логин или пароль" };
return { data: null, error: `Ошибка сервера: ${resp.status}` };
} catch {
return { data: null, error: "Не удалось подключиться к серверу" };
}
}
export async function logout(token: string): Promise<void> {
try {
await fetch(BASE + "/auth/logout", { method: "POST", headers: authHeaders(token) });
} catch {
/* локальный logout произойдёт в любом случае */
}
}
export async function getMe(token: string): Promise<Me | null> {
return getJson<Me>("/users/me", token);
}
const FIELD_NAMES: Record<string, string> = { password: "Пароль", username: "Логин" };
const MSG_MAP: Record<string, string> = {
"Password must be at least 8 characters": "Пароль должен быть не менее 8 символов",
"Username must be at least 3 characters": "Логин должен быть не менее 3 символов",
};
export interface RegisterPayload {
username: string;
password: string;
first_name: string;
last_name: string;
group_id?: string | null;
organization_id?: string | null;
}
// Возвращает строку с ошибкой или null при успехе (как register в api_client.py)
export async function register(p: RegisterPayload): Promise<string | null> {
const payload: Record<string, unknown> = {
username: p.username,
password: p.password,
first_name: p.first_name,
last_name: p.last_name,
};
if (p.group_id) payload.group_id = p.group_id;
if (p.organization_id) payload.organization_id = p.organization_id;
try {
const resp = await fetch(BASE + "/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.status === 201) return null;
const json = await safeJson(resp);
if (resp.status === 400) return json?.detail ?? "Ошибка регистрации";
if (resp.status === 422) {
const errors: any[] = json?.detail ?? [];
for (const err of errors) {
const raw = err?.ctx?.error || err?.msg || "";
if (raw in MSG_MAP) return MSG_MAP[raw];
const loc: any[] = err?.loc ?? [];
const field = FIELD_NAMES[loc[loc.length - 1]] ?? "";
const msg = (err?.msg ?? "Ошибка валидации").replace(/^Value error, /, "");
return `${field}: ${msg}`.replace(/^: |:?\s*$/g, "").trim() || "Проверьте правильность введённых данных";
}
return "Проверьте правильность введённых данных";
}
return `Ошибка сервера: ${resp.status}`;
} catch {
return "Не удалось подключиться к серверу";
}
}
// ---------------------------------------------------------------------------
// Organizations & Groups
// ---------------------------------------------------------------------------
export async function getOrganizations(): Promise<Organization[]> {
return (await getJson<Organization[]>("/organizations/")) ?? [];
}
export async function getGroups(): Promise<Group[]> {
return (await getJson<Group[]>("/groups/")) ?? [];
}
// ---------------------------------------------------------------------------
// Polls
// ---------------------------------------------------------------------------
export async function getPolls(): Promise<Poll[]> {
return (await getJson<Poll[]>("/polls/")) ?? [];
}
export async function getPoll(pollId: string): Promise<Poll | null> {
return getJson<Poll>(`/polls/${pollId}`);
}
export async function getResponse(responseId: string): Promise<ResponseDetail | null> {
return getJson<ResponseDetail>(`/polls/responses/${responseId}`);
}
export async function getRadarResult(responseId: string): Promise<RadarResult | null> {
return getJson<RadarResult>(`/responses/${responseId}/radar`);
}
// URL SVG-диаграммы — браузер тянет напрямую с того же origin (через nginx-прокси).
export function radarImageUrl(responseId: string): string {
return `${BASE}/responses/${responseId}/radar/image`;
}
export interface SubmitPayload {
user_id?: string | null;
group_id?: string | null;
organization_id?: string | null;
}
export async function submitResponse(
pollId: string,
answers: Answer[],
extra: SubmitPayload = {},
): Promise<Result<string>> {
const payload: Record<string, unknown> = { answers };
if (extra.user_id) payload.user_id = extra.user_id;
if (extra.group_id) payload.group_id = extra.group_id;
if (extra.organization_id) payload.organization_id = extra.organization_id;
try {
const resp = await fetch(BASE + `/polls/${pollId}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.status === 201) {
const json = await resp.json();
return { data: json.id, error: null };
}
return { data: null, error: `Ошибка сервера: ${resp.status}` };
} catch {
return { data: null, error: "Не удалось подключиться к серверу" };
}
}
export async function getUserResponses(userId: string): Promise<UserResponse[]> {
return (await getJson<UserResponse[]>(`/polls/users/${userId}/responses`)) ?? [];
}
export interface UpdateUserPayload {
first_name?: string | null;
last_name?: string | null;
group_id?: string | null;
organization_id?: string | null;
}
export async function updateUser(
userId: string,
token: string,
payload: UpdateUserPayload,
): Promise<Result<Me>> {
const body: Record<string, unknown> = {};
if (payload.first_name !== undefined) body.first_name = payload.first_name;
if (payload.last_name !== undefined) body.last_name = payload.last_name;
if (payload.group_id !== undefined) body.group_id = payload.group_id;
if (payload.organization_id !== undefined) body.organization_id = payload.organization_id;
try {
const resp = await fetch(BASE + `/users/${userId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...authHeaders(token) },
body: JSON.stringify(body),
});
if (resp.ok) return { data: await resp.json(), error: null };
return { data: null, error: `Ошибка сервера: ${resp.status}` };
} catch {
return { data: null, error: "Не удалось подключиться к серверу" };
}
}

View File

@@ -0,0 +1,131 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import * as api from "../api/client";
const TOKEN_KEY = "auth_token";
const ANON_KEY = "anon_identity";
export interface AuthUser {
id: string;
groupId: string;
orgId: string;
firstName: string;
lastName: string;
}
// Анонимная сессия: школьник выбрал школу+класс, но не регистрировался —
// аккаунта и токена нет, только org/group для снимка в Response.
export interface AnonIdentity {
orgId: string;
groupId: string;
}
interface AuthCtx {
token: string | null;
user: AuthUser | null;
anon: AnonIdentity | null;
ready: boolean; // завершена ли первичная проверка токена
login: (username: string, password: string) => Promise<string | null>;
loginAnonymous: (orgId: string, groupId: string) => void;
logout: () => Promise<void>;
setUserFromMe: (me: api.Me) => void;
}
function readAnon(): AnonIdentity | null {
const raw = localStorage.getItem(ANON_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.orgId === "string" && typeof parsed.groupId === "string") {
return parsed;
}
} catch {
// повреждённое значение — игнорируем
}
return null;
}
const Ctx = createContext<AuthCtx | null>(null);
function meToUser(me: api.Me): AuthUser {
return {
id: me.id,
groupId: me.group_id ?? "",
orgId: me.organization_id ?? "",
firstName: me.first_name ?? "",
lastName: me.last_name ?? "",
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [user, setUser] = useState<AuthUser | null>(null);
const [anon, setAnon] = useState<AnonIdentity | null>(() => readAnon());
const [ready, setReady] = useState(false);
// При старте: если есть токен — проверяем его через /users/me
useEffect(() => {
let cancelled = false;
(async () => {
if (!token) {
setReady(true);
return;
}
const me = await api.getMe(token);
if (cancelled) return;
if (me) {
setUser(meToUser(me));
} else {
// токен невалиден/просрочен
localStorage.removeItem(TOKEN_KEY);
setToken(null);
}
setReady(true);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const login = async (username: string, password: string): Promise<string | null> => {
const { data, error } = await api.login(username, password);
if (error || !data) return error ?? "Ошибка входа";
localStorage.removeItem(ANON_KEY);
setAnon(null);
localStorage.setItem(TOKEN_KEY, data);
setToken(data);
const me = await api.getMe(data);
if (me) setUser(meToUser(me));
return null;
};
// Анонимный вход: только школа+класс, без аккаунта и запроса к API.
const loginAnonymous = (orgId: string, groupId: string) => {
const identity: AnonIdentity = { orgId, groupId };
localStorage.setItem(ANON_KEY, JSON.stringify(identity));
setAnon(identity);
};
const logout = async () => {
if (token) await api.logout(token);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ANON_KEY);
setToken(null);
setUser(null);
setAnon(null);
};
const setUserFromMe = (me: api.Me) => setUser(meToUser(me));
return (
<Ctx.Provider value={{ token, user, anon, ready, login, loginAnonymous, logout, setUserFromMe }}>
{children}
</Ctx.Provider>
);
}
export function useAuth(): AuthCtx {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@@ -0,0 +1,193 @@
import { useState } from "react";
import { Button } from "./ui";
// Текст пользовательского соглашения (перенос из AlterDiaalogApproval в web/designer.py).
const AGREEMENT = `# ПОЛЬЗОВАТЕЛЬСКОЕ СОГЛАШЕНИЕ
## 1. ОБЩИЕ ПОЛОЖЕНИЯ
Настоящее Пользовательское соглашение (далее — «Соглашение») регулирует отношения между администрацией сайта https://copp74.ru (далее — «Сайт», «Мы», «Администрация») и пользователем (далее — «Пользователь», «Вы»).
Используя Сайт, Вы автоматически соглашаетесь со всеми условиями настоящего Соглашения. Если Вы не согласны с этими условиями, пожалуйста, не используйте Сайт.
## 2. ОПИСАНИЕ САЙТА
Сайт https://copp74.ru представляет собой онлайн-платформу для прохождения тестов и проверки знаний. Основная функция Сайта — предоставление Пользователям возможности проходить различные тесты и получать результаты.
## 3. РЕГИСТРАЦИЯ И ЛИЧНЫЕ ДАННЫЕ
### 3.1 Регистрация
Для использования Сайта Пользователь должен зарегистрироваться и указать следующую информацию:
- Фамилию
- Имя
- Учебное заведение (образовательную организацию)
### 3.2 Назначение личных данных
Указанные фамилия, имя и учебное заведение используются исключительно для:
- Идентификации Пользователя в системе
- Отображения результатов тестирования
- Ведения статистики прохождения тестов
- Организации пользователей по учебным заведениям для аналитики
- Внутреннего анализа и улучшения качества тестирования
### 3.3 Защита личных данных
**Мы гарантируем, что Ваши персональные данные (фамилия, имя и информация об учебном заведении):**
- Не будут переданы третьим лицам или организациям без Вашего согласия
- Не будут использованы в коммерческих целях
- Не будут распространяться без Вашего согласия
- Хранятся в защищенной базе данных
Данные будут удалены в соответствии с законодательством Российской Федерации по защите персональных данных при закрытии аккаунта или по запросу Пользователя.
## 4. ПРАВА И ОБЯЗАННОСТИ ПОЛЬЗОВАТЕЛЯ
### 4.1 Права Пользователя
Пользователь имеет право:
- Проходить тесты, размещенные на Сайте
- Получать результаты своего тестирования
- Запросить удаление своего аккаунта и данных
- Обратиться в поддержку по вопросам использования Сайта
- Получить информацию о том, как его данные обрабатываются
### 4.2 Обязанности Пользователя
Пользователь обязуется:
- Предоставлять достоверную информацию при регистрации
- Указывать корректное название учебного заведения
- Не использовать Сайт в противоправных целях
- Не пытаться получить несанкционированный доступ к системам Сайта
- Не воспроизводить, не копировать и не распространять содержимое Сайта без разрешения
- Соблюдать все применимые законы Российской Федерации
## 5. ОГРАНИЧЕНИЕ ОТВЕТСТВЕННОСТИ
### 5.1 Предоставление Сайта «Как есть»
Сайт предоставляется в режиме «как есть» без каких-либо явных или подразумеваемых гарантий. Администрация не гарантирует:
- Непрерывную работу Сайта
- Отсутствие ошибок в тестах или результатах
- Соответствие Сайта конкретным целям Пользователя
### 5.2 Ограничение ответственности
В максимальной степени, допускаемой законодательством Российской Федерации, Администрация не несет ответственность за:
- Убытки, возникшие в результате использования или невозможности использования Сайта
- Технические сбои, потерю данных
- Ущерб, причиненный прямо или косвенно в связи с использованием Сайта
## 6. ИНТЕЛЛЕКТУАЛЬНАЯ СОБСТВЕННОСТЬ
Все содержимое Сайта, включая тесты, вопросы, задания, дизайн, логотипы и тексты, защищено авторским правом и законами об интеллектуальной собственности Российской Федерации. Пользователь не имеет права использовать это содержимое в коммерческих целях без письменного разрешения Администрации.
## 7. ИЗМЕНЕНИЕ СОГЛАШЕНИЯ
Администрация оставляет за собой право изменять условия настоящего Соглашения в любое время. Изменения вступают в силе с момента их публикации на Сайте. Продолжение использования Сайта после публикации изменений означает согласие Пользователя с новыми условиями.
## 8. ПРЕКРАЩЕНИЕ ДОСТУПА
Администрация может заблокировать доступ Пользователя к Сайту без предварительного уведомления, если:
- Пользователь нарушает условия настоящего Соглашения
- Пользователь использует Сайт в противоправных целях
- Пользователь предоставил поддельную информацию при регистрации
## 9. КОНФИДЕНЦИАЛЬНОСТЬ
Пожалуйста, ознакомьтесь с нашей Политикой конфиденциальности, которая описывает, как мы обращаемся с Вашими личными данными и какие меры безопасности мы применяем.
## 10. ПРИМЕНИМОЕ ПРАВО
Настоящее Соглашение регулируется законодательством Российской Федерации. Все споры, возникающие в связи с Соглашением, подлежат разрешению в судах Российской Федерации.
## 11. КОНТАКТНАЯ ИНФОРМАЦИЯ
Если у Вас есть вопросы по данному Соглашению или функционированию Сайта https://copp74.ru, свяжитесь с нами через форму обратной связи на Сайте.
## 12. ЗАКЛЮЧИТЕЛЬНЫЕ ПОЛОЖЕНИЯ
Если какая-либо часть настоящего Соглашения будет признана недействительной, остальные положения остаются в полной силе.
Пользователь подтверждает, что прочитал, понял и согласен со всеми условиями настоящего Соглашения перед использованием Сайта.
**Дата последнего обновления:** 19.03.2026
---
*Соглашение соответствует законодательству Российской Федерации, включая Федеральный закон «О защите персональных данных» и Федеральный закон «О защите прав потребителей».*`;
// Рендер inline-жирного (**...**) внутри строки.
function inline(text: string): React.ReactNode {
const parts = text.split(/\*\*(.+?)\*\*/g);
return parts.map((p, i) => (i % 2 === 1 ? <strong key={i}>{p}</strong> : p));
}
// Мини-рендер markdown-подмножества (заголовки/списки/абзацы/hr).
function renderMarkdown(src: string) {
const lines = src.split("\n");
const out: React.ReactNode[] = [];
let list: string[] = [];
const flushList = (key: number) => {
if (list.length) {
out.push(
<ul key={`ul-${key}`} className="agr-ul">
{list.map((it, i) => (
<li key={i}>{inline(it)}</li>
))}
</ul>,
);
list = [];
}
};
lines.forEach((raw, i) => {
const line = raw.trim();
if (line.startsWith("- ")) {
list.push(line.slice(2));
return;
}
flushList(i);
if (!line) return;
if (line === "---") out.push(<hr key={i} className="divider" />);
else if (line.startsWith("### ")) out.push(<h3 key={i} className="agr-h3">{line.slice(4)}</h3>);
else if (line.startsWith("## ")) out.push(<h2 key={i} className="agr-h2">{line.slice(3)}</h2>);
else if (line.startsWith("# ")) out.push(<h1 key={i} className="agr-h1">{line.slice(2)}</h1>);
else if (line.startsWith("*") && line.endsWith("*") && !line.startsWith("**"))
out.push(<p key={i} className="agr-p muted" style={{ fontStyle: "italic" }}>{line.slice(1, -1)}</p>);
else out.push(<p key={i} className="agr-p">{inline(line)}</p>);
});
flushList(lines.length);
return out;
}
// Диалог пользовательского соглашения: текст + переключатель согласия + «Принять».
// «Принять» неактивна, пока не включён переключатель (как в оригинале).
export function AgreementDialog({
onAccept,
onClose,
}: {
onAccept: () => void;
onClose: () => void;
}) {
const [agreed, setAgreed] = useState(false);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal agr-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-title" style={{ color: "var(--primary)" }}>
Пользовательское соглашение
</div>
<div className="agr-body">{renderMarkdown(AGREEMENT)}</div>
<label className="agr-switch-row">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
<span>Я согласен с условиями пользовательского соглашения</span>
</label>
<div className="row" style={{ justifyContent: "center" }}>
<Button disabled={!agreed} onClick={onAccept}>
Принять
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { IconArrowBack, IconAccount } from "./icons";
// Оболочка экранов: заголовок + опциональная кнопка «назад» + кнопка профиля.
export function Layout({
title,
children,
back,
showProfile = true,
}: {
title: string;
children: ReactNode;
back?: string; // путь для кнопки «назад»
showProfile?: boolean;
}) {
const navigate = useNavigate();
return (
<div className="page">
<header className="app-header">
<div className="row row-gap" style={{ minWidth: 0 }}>
{back && (
<button className="icon-btn" onClick={() => navigate(back)} aria-label="Назад">
<IconArrowBack />
</button>
)}
<span className="app-title" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{title}
</span>
</div>
{showProfile && (
<button className="icon-btn" onClick={() => navigate("/profile")} aria-label="Профиль">
<IconAccount />
</button>
)}
</header>
{children}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Navigate } from "react-router-dom";
import type { ReactNode } from "react";
import { useAuth } from "../auth/AuthContext";
import { Spinner } from "./ui";
// Пускает дальше при наличии токена ИЛИ анонимной идентичности (школа+класс,
// без аккаунта). Используется для страниц, которые школьник может пройти
// анонимно: главная, список тестов, прохождение теста.
export function ProtectedRoute({ children }: { children: ReactNode }) {
const { token, anon, ready } = useAuth();
if (!ready) {
return (
<div className="spinner-center">
<Spinner />
</div>
);
}
if (!token && !anon) return <Navigate to="/login" replace />;
return <>{children}</>;
}
// Строже: пускает дальше только при реальном токене (не анонимной сессии).
// Для страниц, которым нужен настоящий аккаунт: история тестов, профиль,
// просмотр конкретного результата.
export function AccountRoute({ children }: { children: ReactNode }) {
const { token, ready } = useAuth();
if (!ready) {
return (
<div className="spinner-center">
<Spinner />
</div>
);
}
if (!token) return <Navigate to="/login" replace />;
return <>{children}</>;
}

View File

@@ -0,0 +1,19 @@
import { radarImageUrl } from "../api/client";
import { Button } from "./ui";
import { IconDownload } from "./icons";
// Блок с паутинной диаграммой: картинка + кнопка скачивания.
// SVG тянется напрямую с того же origin через nginx-прокси (/api/.../radar/image).
export function RadarBlock({ responseId }: { responseId: string }) {
const url = radarImageUrl(responseId);
return (
<div className="center-col" style={{ width: "100%" }}>
<img className="radar-img" src={url} alt="Паутинная диаграмма результата" />
<a href={url} download="diagram.svg">
<Button variant="info" icon={<IconDownload />}>
Сохранить диаграмму
</Button>
</a>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useMemo, useState } from "react";
import { IconCaretDown } from "./icons";
export interface Option {
key: string;
text: string;
}
// Выпадающий список с поиском — порт SearchableDropdown из web/designer.py.
// При клике открывается модальный диалог с полем поиска и списком (как в оригинале).
export function SearchableDropdown({
label,
hint,
options,
value,
onChange,
}: {
label?: string;
hint?: string;
options: Option[];
value: string | null;
onChange: (key: string | null) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const selected = useMemo(() => options.find((o) => o.key === value) ?? null, [options, value]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.text.toLowerCase().includes(q));
}, [options, query]);
return (
<div className="field">
{label && <span className="field-label">{label}</span>}
<div
className="dropdown-control"
onClick={() => {
setQuery("");
setOpen(true);
}}
>
<span className={"dropdown-value" + (selected ? "" : " placeholder")}>
{selected ? selected.text : hint ?? "Выберите..."}
</span>
<IconCaretDown style={{ color: "var(--text-secondary)", fontSize: "1.2rem", flexShrink: 0 }} />
</div>
{open && (
<div className="modal-backdrop" onClick={() => setOpen(false)}>
<div className="modal sd-dialog" onClick={(e) => e.stopPropagation()}>
<div className="modal-title">{label}</div>
<input
className="dropdown-search"
placeholder="Введите для поиска..."
value={query}
autoFocus
onChange={(e) => setQuery(e.target.value)}
/>
<hr className="divider" />
<div className="sd-list">
{filtered.length === 0 && <div className="dropdown-empty">Ничего не найдено</div>}
{filtered.map((o) => (
<div
key={o.key}
className={"dropdown-option" + (o.key === value ? " active" : "")}
onClick={() => {
onChange(o.key);
setOpen(false);
}}
>
{o.text}
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,90 @@
// Небольшой набор inline-SVG иконок (замена Material Icons из Flet).
// Наследуют цвет через currentColor и размер через 1em.
import type { SVGProps } from "react";
type P = SVGProps<SVGSVGElement>;
const base = (p: P) => ({
width: "1em",
height: "1em",
viewBox: "0 0 24 24",
fill: "currentColor",
...p,
});
export const IconPerson = (p: P) => (
<svg {...base(p)}>
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-3.3 0-10 1.7-10 5v3h20v-3c0-3.3-6.7-5-10-5Z" />
</svg>
);
export const IconLock = (p: P) => (
<svg {...base(p)}>
<path d="M17 9V7a5 5 0 0 0-10 0v2H5v13h14V9h-2ZM9 7a3 3 0 0 1 6 0v2H9V7Zm3 11a2 2 0 1 1 2-2 2 2 0 0 1-2 2Z" />
</svg>
);
export const IconAt = (p: P) => (
<svg {...base(p)}>
<path d="M12 2a10 10 0 1 0 4 19.2l-.8-1.8A8 8 0 1 1 20 12v1a1.5 1.5 0 0 1-3 0V8h-2v.7A4 4 0 1 0 16 15a3.5 3.5 0 0 0 6-2v-1A10 10 0 0 0 12 2Zm0 12a2 2 0 1 1 2-2 2 2 0 0 1-2 2Z" />
</svg>
);
export const IconCheckCircle = (p: P) => (
<svg {...base(p)}>
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm-2 15-5-5 1.4-1.4L10 14.2l7.6-7.6L19 8Z" />
</svg>
);
export const IconRadioUnchecked = (p: P) => (
<svg {...base(p)} fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="9" />
</svg>
);
export const IconDownload = (p: P) => (
<svg {...base(p)}>
<path d="M12 16 6 10l1.4-1.4L11 12.2V3h2v9.2l3.6-3.6L18 10Zm-8 3h16v2H4Z" />
</svg>
);
export const IconChevronRight = (p: P) => (
<svg {...base(p)}>
<path d="M9 6 7.6 7.4 12.2 12l-4.6 4.6L9 18l6-6Z" />
</svg>
);
export const IconArrowBack = (p: P) => (
<svg {...base(p)}>
<path d="M20 11H7.8l5.6-5.6L12 4l-8 8 8 8 1.4-1.4L7.8 13H20Z" />
</svg>
);
export const IconAccount = (p: P) => (
<svg {...base(p)}>
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm0 4a3 3 0 1 1-3 3 3 3 0 0 1 3-3Zm0 14a8 8 0 0 1-6-2.7c0-2 4-3.1 6-3.1s6 1.1 6 3.1A8 8 0 0 1 12 20Z" />
</svg>
);
export const IconStop = (p: P) => (
<svg {...base(p)}>
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2ZM8 8h8v8H8Z" />
</svg>
);
export const IconError = (p: P) => (
<svg {...base(p)}>
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm1 15h-2v-2h2Zm0-4h-2V7h2Z" />
</svg>
);
export const IconEye = (p: P) => (
<svg {...base(p)}>
<path d="M12 5C6 5 2 12 2 12s4 7 10 7 10-7 10-7-4-7-10-7Zm0 11a4 4 0 1 1 4-4 4 4 0 0 1-4 4Zm0-6a2 2 0 1 0 2 2 2 2 0 0 0-2-2Z" />
</svg>
);
export const IconCaretDown = (p: P) => (
<svg {...base(p)}>
<path d="m7 10 5 5 5-5Z" />
</svg>
);

View File

@@ -0,0 +1,139 @@
import {
useState,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
} from "react";
import { IconEye } from "./icons";
// ---------------------------------------------------------------------------
// Spinner
// ---------------------------------------------------------------------------
export function Spinner() {
return <div className="spinner" role="status" aria-label="Загрузка" />;
}
export function CenterSpinner() {
return (
<div className="spinner-center">
<Spinner />
</div>
);
}
// ---------------------------------------------------------------------------
// Button
// ---------------------------------------------------------------------------
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "info" | "ghost";
block?: boolean;
icon?: ReactNode;
};
export function Button({
variant = "primary",
block,
icon,
children,
className = "",
...rest
}: ButtonProps) {
const cls = [
"btn",
variant === "info" ? "btn-info" : "",
variant === "ghost" ? "btn-ghost" : "",
block ? "btn-block" : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<button className={cls} {...rest}>
{icon}
{children}
</button>
);
}
// ---------------------------------------------------------------------------
// Card
// ---------------------------------------------------------------------------
export function Card({
children,
alt,
onClick,
className = "",
}: {
children: ReactNode;
alt?: boolean;
onClick?: () => void;
className?: string;
}) {
const cls = ["card", alt ? "card-alt" : "", onClick ? "clickable" : "", className]
.filter(Boolean)
.join(" ");
return (
<div className={cls} onClick={onClick}>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// TextField
// ---------------------------------------------------------------------------
type FieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "prefix"> & {
label?: string;
icon?: ReactNode;
password?: boolean;
};
export function TextField({ label, icon, password, ...rest }: FieldProps) {
const [revealed, setRevealed] = useState(false);
const type = password ? (revealed ? "text" : "password") : rest.type ?? "text";
return (
<label className="field">
{label && <span className="field-label">{label}</span>}
<span className="field-input-wrap">
{icon && <span className="ficon">{icon}</span>}
<input {...rest} type={type} />
{password && (
<button
type="button"
className="reveal-btn"
onClick={() => setRevealed((v) => !v)}
tabIndex={-1}
aria-label="Показать пароль"
>
<IconEye />
</button>
)}
</span>
</label>
);
}
// ---------------------------------------------------------------------------
// Modal
// ---------------------------------------------------------------------------
export function Modal({
title,
children,
actions,
onClose,
}: {
title: string;
children?: ReactNode;
actions: ReactNode;
onClose?: () => void;
}) {
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-title">{title}</div>
{children && <div className="muted">{children}</div>}
<div className="modal-actions">{actions}</div>
</div>
</div>
);
}

10
web-react/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/global.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as api from "../api/client";
import { Button } from "../components/ui";
import { SearchableDropdown, type Option } from "../components/SearchableDropdown";
import { useAuth } from "../auth/AuthContext";
// Вход без регистрации: школьник выбирает только школу и класс, аккаунт
// и пароль не нужны. Результат теста запишется в базу со снимком
// организации/группы, но без привязки к пользователю.
export function AnonymousEntryPage() {
const navigate = useNavigate();
const { loginAnonymous } = useAuth();
const [orgId, setOrgId] = useState<string | null>(null);
const [groupId, setGroupId] = useState<string | null>(null);
const [orgs, setOrgs] = useState<api.Organization[]>([]);
const [groups, setGroups] = useState<api.Group[]>([]);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
const [o, g] = await Promise.all([api.getOrganizations(), api.getGroups()]);
if (cancelled) return;
setOrgs(o);
setGroups(g);
})();
return () => {
cancelled = true;
};
}, []);
const onOrgChange = (id: string | null) => {
setOrgId(id);
const org = orgs.find((o) => o.id === id);
if (org?.group_id) setGroupId(org.group_id);
};
const orgOptions: Option[] = orgs.map((o) => ({ key: o.id, text: o.name_organization }));
const groupOptions: Option[] = groups.map((g) => ({ key: g.id, text: g.name_group }));
const proceed = () => {
if (!orgId || !groupId) {
setError("Выберите школу и класс");
return;
}
loginAnonymous(orgId, groupId);
navigate("/");
};
return (
<div className="auth-wrap">
<div className="auth-logo">ЦОПП</div>
<div className="auth-sub">Пройти тест без регистрации выберите школу и класс</div>
<form
className="auth-card"
onSubmit={(e) => {
e.preventDefault();
proceed();
}}
>
<SearchableDropdown label="Школа" hint="Выберите школу..." options={orgOptions} value={orgId} onChange={onOrgChange} />
<SearchableDropdown label="Класс" hint="Выберите класс..." options={groupOptions} value={groupId} onChange={setGroupId} />
{error && <div className="error-text">{error}</div>}
<div className="wrap-row">
<Button type="button" variant="ghost" onClick={() => navigate("/login")}>
Назад
</Button>
<Button type="submit">Продолжить</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { useNavigate } from "react-router-dom";
import { Layout } from "../components/Layout";
import { useAuth } from "../auth/AuthContext";
export function HomePage() {
const navigate = useNavigate();
const { token, anon, logout } = useAuth();
const isAnonymous = !token && !!anon;
const exitAnonymous = async () => {
await logout();
navigate("/login");
};
return (
<Layout title="Добро пожаловать в ЦОПП!" showProfile={!!token}>
<div className="home-grid">
<div className="home-tile" onClick={() => navigate("/tests")}>
<img src="/icons/clipboard-question_16542596.svg" alt="" />
<span>Все тесты</span>
</div>
{/* «Мои тесты» требует аккаунта — у анонимной сессии нет истории */}
{!isAnonymous && (
<div className="home-tile" onClick={() => navigate("/my-tests")}>
<img src="/icons/leaderboard-trophy_14227576.svg" alt="" />
<span>Мои тесты</span>
</div>
)}
</div>
{isAnonymous && (
<button type="button" className="link-btn" onClick={exitAnonymous}>
Выйти из анонимного режима
</button>
)}
</Layout>
);
}

View File

@@ -0,0 +1,82 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { Button, TextField } from "../components/ui";
import { AgreementDialog } from "../components/AgreementDialog";
import { IconPerson, IconLock } from "../components/icons";
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [askReg, setAskReg] = useState(false);
const doLogin = async () => {
if (!username.trim() || !password) {
setError("Заполните логин и пароль");
return;
}
setBusy(true);
setError("");
const err = await login(username.trim(), password);
setBusy(false);
if (err) {
setError(err);
} else {
navigate("/");
}
};
return (
<div className="auth-wrap">
<div className="auth-logo">ЦОПП</div>
<div className="auth-sub">
Это проект ЦОПП по проведению исследовательских тестирований
</div>
<form
className="auth-card"
onSubmit={(e) => {
e.preventDefault();
doLogin();
}}
>
<TextField
label="Логин"
placeholder="Введите логин..."
icon={<IconPerson />}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<TextField
label="Пароль"
placeholder="Введите пароль..."
icon={<IconLock />}
password
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <div className="error-text">{error}</div>}
<Button type="submit" disabled={busy} block>
{busy ? "Вход..." : "Войти"}
</Button>
<button type="button" className="link-btn" onClick={() => setAskReg(true)}>
Нет аккаунта? Зарегистрироваться
</button>
<button type="button" className="link-btn" onClick={() => navigate("/anonymous")}>
Пройти тест без регистрации
</button>
</form>
{askReg && (
<AgreementDialog
onClose={() => setAskReg(false)}
onAccept={() => navigate("/register")}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import { Layout } from "../components/Layout";
import { Card, CenterSpinner } from "../components/ui";
import { IconChevronRight } from "../components/icons";
interface Row {
id: string;
title: string;
submittedAt: string;
}
export function MyTestsPage() {
const navigate = useNavigate();
const { user } = useAuth();
const [rows, setRows] = useState<Row[] | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
if (!user) return;
const responses = await api.getUserResponses(user.id);
if (cancelled) return;
if (responses.length === 0) {
setRows([]);
return;
}
// параллельно подгружаем названия опросов
const pollIds = [...new Set(responses.map((r) => r.poll_id))];
const polls = await Promise.all(pollIds.map((id) => api.getPoll(id)));
if (cancelled) return;
const titleById = new Map<string, string>();
polls.forEach((p) => {
if (p) titleById.set(p.id, p.title);
});
const mapped: Row[] = responses
.map((r) => ({
id: r.id,
title: titleById.get(r.poll_id) ?? "Неизвестный тест",
submittedAt: r.submitted_at,
}))
.sort((a, b) => b.submittedAt.localeCompare(a.submittedAt));
setRows(mapped);
})();
return () => {
cancelled = true;
};
}, [user]);
return (
<Layout title="Мои тесты" back="/" showProfile={false}>
{rows === null ? (
<CenterSpinner />
) : rows.length === 0 ? (
<p className="muted">Вы ещё не проходили тесты</p>
) : (
<div className="stack">
{rows.map((r) => (
<Card key={r.id} onClick={() => navigate(`/response/${r.id}`)}>
<div className="list-card">
<div className="col-gap" style={{ gap: "0.25rem" }}>
<span className="bold">{r.title}</span>
<span className="text-sm muted">
Пройден: {r.submittedAt.slice(0, 19).replace("T", " ")}
</span>
</div>
<IconChevronRight className="chevron" />
</div>
</Card>
))}
</div>
)}
</Layout>
);
}

View File

@@ -0,0 +1,192 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import * as api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import { Layout } from "../components/Layout";
import { Button, Card, CenterSpinner, Modal } from "../components/ui";
import { RadarBlock } from "../components/RadarBlock";
import { IconCheckCircle, IconRadioUnchecked, IconStop, IconError } from "../components/icons";
type Phase =
| { kind: "loading" }
| { kind: "empty"; message: string }
| { kind: "running" }
| { kind: "submitting" }
| { kind: "error"; message: string }
| { kind: "success"; responseId: string; hasRadar: boolean };
export function PollPage() {
const { pollId = "" } = useParams();
const navigate = useNavigate();
const { user, anon } = useAuth();
const [questions, setQuestions] = useState<api.Question[]>([]);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [current, setCurrent] = useState(0);
const [phase, setPhase] = useState<Phase>({ kind: "loading" });
const [askAbort, setAskAbort] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const p = await api.getPoll(pollId);
if (cancelled) return;
if (!p) {
setPhase({ kind: "empty", message: "Тест не найден" });
return;
}
const qs = p.questions ?? [];
setQuestions(qs);
if (qs.length === 0) {
setPhase({ kind: "empty", message: "В тесте нет вопросов" });
} else {
setPhase({ kind: "running" });
}
})();
return () => {
cancelled = true;
};
}, [pollId]);
const total = questions.length;
const allAnswered = total > 0 && questions.every((q) => answers[q.id]);
const selectAnswer = (questionId: string, choiceId: string) => {
setAnswers((prev) => ({ ...prev, [questionId]: choiceId }));
// авто-переход к следующему вопросу, если он не последний
if (current < total - 1) {
setCurrent((c) => c + 1);
}
};
const submit = async () => {
const firstUnanswered = questions.findIndex((q) => !answers[q.id]);
if (firstUnanswered !== -1) {
setCurrent(firstUnanswered);
return;
}
setPhase({ kind: "submitting" });
const payload: api.Answer[] = Object.entries(answers).map(([question_id, choice_id]) => ({
question_id,
choice_id,
text: null,
}));
const { data: responseId, error } = await api.submitResponse(pollId, payload, {
user_id: user?.id,
group_id: user?.groupId || anon?.groupId || null,
organization_id: user?.orgId || anon?.orgId || null,
});
if (error || !responseId) {
setPhase({ kind: "error", message: error ?? "Не удалось отправить ответы" });
return;
}
const radar = await api.getRadarResult(responseId);
setPhase({ kind: "success", responseId, hasRadar: !!radar?.image_url });
};
// ── рендер по фазам ──────────────────────────────────────────────────
let body: React.ReactNode;
if (phase.kind === "loading") {
body = <CenterSpinner />;
} else if (phase.kind === "empty") {
body = <p className="muted">{phase.message}</p>;
} else if (phase.kind === "submitting") {
body = <CenterSpinner />;
} else if (phase.kind === "error") {
body = (
<div className="center-col">
<span style={{ color: "var(--primary)", fontSize: "2.5rem" }}>
<IconError />
</span>
<span>{phase.message}</span>
<Button onClick={() => setPhase({ kind: "running" })}>Попробовать снова</Button>
</div>
);
} else if (phase.kind === "success") {
body = (
<Card className="success-box">
<div className="center-col">
<span style={{ color: "var(--primary-light)", fontSize: "2.6rem" }}>
<IconCheckCircle />
</span>
<span className="title-lg">Тест пройден!</span>
{phase.hasRadar && <RadarBlock responseId={phase.responseId} />}
<Button onClick={() => navigate("/tests")}>Вернуться к тестам</Button>
</div>
</Card>
);
} else {
// running
const q = questions[current];
const selected = answers[q.id];
body = (
<div className="stack">
<div className="center-col" style={{ gap: "0.5rem" }}>
<span className="counter-badge">
Вопрос {current + 1} из {total}
</span>
<div className="wrap-row" style={{ justifyContent: "center" }}>
<Button
variant="ghost"
disabled={current === 0}
onClick={() => setCurrent((c) => Math.max(0, c - 1))}
>
{current > 0 ? "← Предыдущий вопрос" : "←"}
</Button>
<Button variant="ghost" icon={<IconStop />} onClick={() => setAskAbort(true)}>
Прервать тест
</Button>
</div>
</div>
<div className="question-box">{q.text}</div>
<div className="col-gap" style={{ gap: "0.4rem" }}>
{q.choices.map((c) => {
const isSel = c.id === selected;
return (
<div
key={c.id}
className={"choice-tile" + (isSel ? " selected" : "")}
onClick={() => selectAnswer(q.id, c.id)}
>
<span className="check">{isSel ? <IconCheckCircle /> : <IconRadioUnchecked />}</span>
<span>{c.text}</span>
</div>
);
})}
</div>
{current === total - 1 && allAnswered && (
<div className="row" style={{ justifyContent: "flex-end" }}>
<Button onClick={submit}>Завершить тест </Button>
</div>
)}
</div>
);
}
return (
<Layout title="Прохождение теста" back="/tests" showProfile={false}>
{body}
{askAbort && (
<Modal
title="Прервать тест?"
onClose={() => setAskAbort(false)}
actions={
<>
<Button variant="ghost" onClick={() => setAskAbort(false)}>
Отмена
</Button>
<Button onClick={() => navigate("/tests")}>Прервать</Button>
</>
}
>
Все ответы будут потеряны. Вы уверены?
</Modal>
)}
</Layout>
);
}

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import { useTheme } from "../theme/ThemeContext";
import { THEMES, SCALE_MIN, SCALE_MAX, SCALE_STEP } from "../theme/themes";
import { Layout } from "../components/Layout";
import { Button, TextField } from "../components/ui";
import { SearchableDropdown, type Option } from "../components/SearchableDropdown";
import { IconPerson } from "../components/icons";
export function ProfilePage() {
const navigate = useNavigate();
const { token, user, logout, setUserFromMe } = useAuth();
const { themeId, scale, setThemeId, setScale } = useTheme();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [orgId, setOrgId] = useState<string | null>(null);
const [groupId, setGroupId] = useState<string | null>(null);
const [orgs, setOrgs] = useState<api.Organization[]>([]);
const [groups, setGroups] = useState<api.Group[]>([]);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<{ text: string; error: boolean } | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
if (!token) return;
const [me, o, g] = await Promise.all([
api.getMe(token),
api.getOrganizations(),
api.getGroups(),
]);
if (cancelled) return;
setOrgs(o);
setGroups(g);
if (me) {
setFirstName(me.first_name ?? "");
setLastName(me.last_name ?? "");
setOrgId(me.organization_id ?? null);
setGroupId(me.group_id ?? null);
}
setLoaded(true);
})();
return () => {
cancelled = true;
};
}, [token]);
const onOrgChange = (id: string | null) => {
setOrgId(id);
const org = orgs.find((o) => o.id === id);
if (org?.group_id) setGroupId(org.group_id);
};
const orgOptions: Option[] = orgs.map((o) => ({ key: o.id, text: o.name_organization }));
const groupOptions: Option[] = groups.map((g) => ({ key: g.id, text: g.name_group }));
const themeOptions: Option[] = THEMES.map((t) => ({ key: t.id, text: t.name }));
const save = async () => {
if (!token || !user) return;
setBusy(true);
setStatus(null);
const { data, error } = await api.updateUser(user.id, token, {
first_name: firstName.trim() || null,
last_name: lastName.trim() || null,
group_id: groupId,
organization_id: orgId,
});
setBusy(false);
if (error) {
setStatus({ text: error, error: true });
return;
}
if (data) setUserFromMe(data);
setStatus({ text: "Сохранено", error: false });
};
const doLogout = async () => {
await logout();
navigate("/login");
};
return (
<Layout title="Профиль" back="/" showProfile={false}>
{!loaded ? null : (
<div className="stack">
<span className="title-md">Редактировать профиль</span>
<TextField label="Имя" placeholder="Введите имя..." icon={<IconPerson />} value={firstName} onChange={(e) => setFirstName(e.target.value)} />
<TextField label="Фамилия" placeholder="Введите фамилию..." icon={<IconPerson />} value={lastName} onChange={(e) => setLastName(e.target.value)} />
<SearchableDropdown label="Организация" hint="Выберите организацию..." options={orgOptions} value={orgId} onChange={onOrgChange} />
<SearchableDropdown label="Группа" hint="Выберите группу..." options={groupOptions} value={groupId} onChange={setGroupId} />
<hr className="divider" />
<span className="title-md">Внешний вид</span>
<SearchableDropdown
label="Тема интерфейса"
hint="Выберите тему..."
options={themeOptions}
value={themeId}
onChange={(id) => id && setThemeId(id)}
/>
<div className="field">
<span className="field-label">Масштаб: {scale.toFixed(1)}×</span>
<input
className="scale-slider"
type="range"
min={SCALE_MIN}
max={SCALE_MAX}
step={SCALE_STEP}
value={scale}
onChange={(e) => setScale(parseFloat(e.target.value))}
/>
</div>
<div className="wrap-row" style={{ alignItems: "center" }}>
<Button onClick={save} disabled={busy}>
{busy ? "Сохранение..." : "Сохранить"}
</Button>
<Button variant="ghost" onClick={doLogout}>
Выйти
</Button>
{status && (
<span className={status.error ? "error-text" : "muted"}>{status.text}</span>
)}
</div>
</div>
)}
</Layout>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as api from "../api/client";
import { Button, TextField } from "../components/ui";
import { SearchableDropdown, type Option } from "../components/SearchableDropdown";
import { IconPerson, IconAt, IconLock } from "../components/icons";
export function RegisterPage() {
const navigate = useNavigate();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [orgId, setOrgId] = useState<string | null>(null);
const [groupId, setGroupId] = useState<string | null>(null);
const [orgs, setOrgs] = useState<api.Organization[]>([]);
const [groups, setGroups] = useState<api.Group[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const [o, g] = await Promise.all([api.getOrganizations(), api.getGroups()]);
if (cancelled) return;
setOrgs(o);
setGroups(g);
})();
return () => {
cancelled = true;
};
}, []);
// Автоподстановка группы по выбранной организации (как _on_org_change)
const onOrgChange = (id: string | null) => {
setOrgId(id);
const org = orgs.find((o) => o.id === id);
if (org?.group_id) setGroupId(org.group_id);
};
const orgOptions: Option[] = orgs.map((o) => ({ key: o.id, text: o.name_organization }));
const groupOptions: Option[] = groups.map((g) => ({ key: g.id, text: g.name_group }));
const doRegister = async () => {
if (!firstName.trim() || !lastName.trim() || !username.trim() || !password) {
setError("Заполните все обязательные поля");
return;
}
setBusy(true);
setError("");
const err = await api.register({
username: username.trim(),
password,
first_name: firstName.trim(),
last_name: lastName.trim(),
group_id: groupId,
organization_id: orgId,
});
setBusy(false);
if (err) {
setError(err);
} else {
navigate("/login");
}
};
return (
<div className="auth-wrap">
<div className="auth-logo">ЦОПП</div>
<div className="auth-sub">Регистрация в системе</div>
<form
className="auth-card"
onSubmit={(e) => {
e.preventDefault();
doRegister();
}}
>
<TextField label="Имя" placeholder="Введите имя..." icon={<IconPerson />} value={firstName} onChange={(e) => setFirstName(e.target.value)} />
<TextField label="Фамилия" placeholder="Введите фамилию..." icon={<IconPerson />} value={lastName} onChange={(e) => setLastName(e.target.value)} />
<TextField label="Логин" placeholder="Введите логин..." icon={<IconAt />} value={username} onChange={(e) => setUsername(e.target.value)} />
<TextField label="Пароль" placeholder="Введите пароль..." icon={<IconLock />} password value={password} onChange={(e) => setPassword(e.target.value)} />
<SearchableDropdown label="Организация" hint="Выберите организацию..." options={orgOptions} value={orgId} onChange={onOrgChange} />
<SearchableDropdown label="Группа" hint="Выберите группу..." options={groupOptions} value={groupId} onChange={setGroupId} />
{error && <div className="error-text">{error}</div>}
<div className="wrap-row">
<Button type="button" variant="ghost" onClick={() => navigate("/login")}>
Назад
</Button>
<Button type="submit" disabled={busy}>
{busy ? "Регистрация..." : "Зарегистрироваться"}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import * as api from "../api/client";
import { Layout } from "../components/Layout";
import { Card, CenterSpinner } from "../components/ui";
import { RadarBlock } from "../components/RadarBlock";
import { IconCheckCircle, IconRadioUnchecked } from "../components/icons";
interface State {
response: api.ResponseDetail;
poll: api.Poll;
hasRadar: boolean;
}
export function ResponsePage() {
const { responseId = "" } = useParams();
const [state, setState] = useState<State | null>(null);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
const response = await api.getResponse(responseId);
if (cancelled) return;
if (!response) {
setError("Результат не найден");
return;
}
const poll = await api.getPoll(response.poll_id);
if (cancelled) return;
if (!poll) {
setError("Тест не найден");
return;
}
const radar = await api.getRadarResult(responseId);
if (cancelled) return;
setState({ response, poll, hasRadar: !!radar?.image_url });
})();
return () => {
cancelled = true;
};
}, [responseId]);
return (
<Layout title="Результаты теста" back="/my-tests" showProfile={false}>
{error ? (
<p className="error-text">{error}</p>
) : !state ? (
<CenterSpinner />
) : (
<ResultBody {...state} responseId={responseId} />
)}
</Layout>
);
}
function ResultBody({ response, poll, hasRadar, responseId }: State & { responseId: string }) {
const questions = new Map(poll.questions?.map((q) => [q.id, q]) ?? []);
const submittedAt = response.submitted_at.slice(0, 19).replace("T", " ");
return (
<div className="stack">
<div className="col-gap" style={{ gap: "0.25rem" }}>
<span className="title-lg">{poll.title}</span>
<span className="text-sm muted">Пройден: {submittedAt}</span>
</div>
{hasRadar && (
<Card alt>
<div className="center-col">
<span className="title-md">Результат</span>
<RadarBlock responseId={responseId} />
</div>
</Card>
)}
<span className="title-md">Ответы</span>
{response.answers.map((ans, i) => {
const q = questions.get(ans.question_id);
if (!q) return null;
return (
<Card key={i}>
<div className="col-gap">
<span className="bold">{q.text}</span>
{q.choices.map((c) => {
const selected = c.id === ans.choice_id;
return (
<div key={c.id} className={"choice-tile" + (selected ? " selected" : "")}>
<span className="check">
{selected ? <IconCheckCircle /> : <IconRadioUnchecked />}
</span>
<span>{c.text}</span>
</div>
);
})}
</div>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as api from "../api/client";
import { Layout } from "../components/Layout";
import { Card, CenterSpinner } from "../components/ui";
export function TestListPage() {
const navigate = useNavigate();
const [polls, setPolls] = useState<api.Poll[] | null>(null);
useEffect(() => {
let cancelled = false;
api.getPolls().then((p) => {
if (!cancelled) setPolls(p);
});
return () => {
cancelled = true;
};
}, []);
return (
<Layout title="Все тесты" back="/" showProfile={false}>
{polls === null ? (
<CenterSpinner />
) : polls.length === 0 ? (
<p className="muted">Тестов пока нет</p>
) : (
<div className="stack">
{polls.map((poll) => (
<Card key={poll.id} onClick={() => navigate(`/poll/${poll.id}`)}>
<div className="col-gap" style={{ gap: "0.3rem" }}>
<span className="title-md">{poll.title || "—"}</span>
{poll.description && <span className="text-sm muted">{poll.description}</span>}
</div>
</Card>
))}
</div>
)}
</Layout>
);
}

View File

@@ -0,0 +1,600 @@
/* ============================================================
Глобальные стили. Цвета и масштаб — через CSS-переменные,
которые задаёт ThemeContext. 1rem масштабируется слайдером.
============================================================ */
:root {
/* статическая палитра (web/designer.py) */
--primary: #f85a40;
--primary-dark: #d94530;
--primary-light: #ffeab7;
--accent: #ffc845;
--info: #007fbd;
--info-dark: #006aa3;
--success: #85c446;
/* задаются ThemeContext, но даём дефолты */
--bg: #f4f6f8;
--surface: #ffffff;
--surface-alt: #e4e8ed;
--text-primary: #1e2a3a;
--text-secondary: #5a6779;
--border: #dde3ea;
--scale: 1.3;
font-size: calc(13px * var(--scale));
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
padding: 0;
min-height: 100%;
}
body {
background: var(--bg);
color: var(--text-primary);
font-family: "Segoe UI", system-ui, -apple-system, Roboto, Arial, sans-serif;
font-size: 1rem;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
transition: background 0.2s ease;
}
#root {
display: flex;
flex-direction: column;
}
button {
font-family: inherit;
cursor: pointer;
}
a {
color: var(--primary);
text-decoration: none;
}
/* ---------- Layout ---------- */
.page {
width: 100%;
max-width: 56rem;
margin: 0 auto;
padding: 1rem 1.2rem 3rem;
flex: 1;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.app-title {
color: var(--primary);
font-size: 1.4rem;
font-weight: 700;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--primary);
padding: 0.4rem;
border-radius: 0.6rem;
font-size: 1.6rem;
line-height: 1;
transition: background 0.15s ease;
}
.icon-btn:hover {
background: var(--surface-alt);
}
/* ---------- Card ---------- */
.card {
background: var(--surface);
border-radius: 0.9rem;
padding: 1.1rem;
box-shadow: 0 1px 3px rgba(30, 42, 58, 0.06);
}
.card-alt {
background: var(--surface-alt);
}
.card.clickable {
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.card.clickable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(30, 42, 58, 0.12);
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: none;
border-radius: 0.7rem;
padding: 0.75rem 1.3rem;
font-size: 1rem;
font-weight: 600;
color: var(--text-on-primary, #fff);
background: var(--primary);
transition: background 0.15s ease, opacity 0.15s ease;
}
.btn:hover:not(:disabled) {
background: var(--primary-dark);
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
.btn-info {
background: var(--info);
}
.btn-info:hover:not(:disabled) {
background: var(--info-dark);
}
.btn-ghost {
background: var(--surface-alt);
color: var(--primary);
}
.btn-ghost:hover:not(:disabled) {
background: var(--surface-alt);
filter: brightness(0.95);
}
.btn-block {
width: 100%;
}
.link-btn {
background: none;
border: none;
color: var(--primary);
font-size: 0.95rem;
padding: 0.3rem;
}
/* ---------- Fields ---------- */
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
}
.field-input-wrap {
display: flex;
align-items: center;
gap: 0.55rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.7rem;
padding: 0.7rem 0.9rem;
transition: border-color 0.15s ease;
}
.field-input-wrap:focus-within {
border-color: var(--primary);
}
.field-input-wrap .ficon {
color: var(--text-secondary);
font-size: 1.1rem;
flex-shrink: 0;
}
.field-input-wrap input {
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 1rem;
width: 100%;
font-family: inherit;
}
.field-input-wrap input::placeholder {
color: var(--border);
font-style: italic;
}
.reveal-btn {
background: none;
border: none;
color: var(--text-secondary);
padding: 0;
font-size: 1.05rem;
line-height: 1;
}
/* ---------- Dropdown ---------- */
.dropdown {
position: relative;
}
.dropdown-control {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.7rem;
padding: 0.7rem 0.9rem;
cursor: pointer;
}
.dropdown-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-value.placeholder {
color: var(--border);
font-style: italic;
}
.dropdown-menu {
position: absolute;
z-index: 30;
top: calc(100% + 0.3rem);
left: 0;
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.7rem;
box-shadow: 0 6px 20px rgba(30, 42, 58, 0.18);
max-height: 16rem;
overflow-y: auto;
}
.dropdown-search {
width: 100%;
border: none;
border-bottom: 1px solid var(--border);
padding: 0.6rem 0.9rem;
outline: none;
font-size: 0.95rem;
background: var(--surface);
color: var(--text-primary);
font-family: inherit;
position: sticky;
top: 0;
}
.dropdown-option {
padding: 0.6rem 0.9rem;
cursor: pointer;
color: var(--text-primary);
}
.dropdown-option:hover,
.dropdown-option.active {
background: var(--surface-alt);
}
.dropdown-empty {
padding: 0.7rem 0.9rem;
color: var(--text-secondary);
font-style: italic;
}
/* ---------- Auth screens ---------- */
.auth-wrap {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
}
.auth-logo {
color: var(--primary);
font-size: 1.9rem;
font-weight: 700;
text-align: center;
}
.auth-sub {
color: var(--primary);
font-size: 1rem;
text-align: center;
margin: 0.5rem 0 1.4rem;
max-width: 28rem;
}
.auth-card {
width: 100%;
max-width: 26rem;
background: var(--surface-alt);
border-radius: 1rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ---------- Misc ---------- */
.error-text {
color: #e23b3b;
font-size: 0.9rem;
}
.muted {
color: var(--text-secondary);
}
.center-col {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
}
.row {
display: flex;
align-items: center;
}
.row-gap {
gap: 0.6rem;
}
.col-gap {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.stack {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.wrap-row {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.spacer {
flex: 1;
}
/* ---------- Spinner ---------- */
.spinner {
width: 2.4rem;
height: 2.4rem;
border: 0.28rem solid var(--surface-alt);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------- Modal ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 22, 33, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem;
}
.modal {
background: var(--surface);
border-radius: 1rem;
padding: 1.4rem;
max-width: 24rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal-title {
font-size: 1.2rem;
font-weight: 700;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
}
/* ---------- Home cards ---------- */
.home-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
}
.home-tile {
width: 11rem;
height: 11rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.6rem;
background: var(--surface);
border-radius: 1rem;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.home-tile:hover {
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(30, 42, 58, 0.14);
}
.home-tile img {
width: 5.5rem;
height: 5.5rem;
}
.home-tile span {
font-weight: 600;
}
/* ---------- Poll / take test ---------- */
.counter-badge {
display: inline-block;
background: var(--surface-alt);
color: var(--text-secondary);
font-weight: 700;
border-radius: 0.7rem;
padding: 0.5rem 1.2rem;
text-align: center;
}
.question-box {
background: var(--surface);
border-radius: 1rem;
padding: 1.4rem;
text-align: center;
font-weight: 700;
font-size: 1.1rem;
}
.choice-tile {
display: flex;
align-items: center;
gap: 0.6rem;
border-radius: 0.6rem;
padding: 0.7rem 0.9rem;
background: var(--surface-alt);
cursor: pointer;
transition: background 0.12s ease;
}
.choice-tile.selected {
background: var(--primary-light);
}
.choice-tile .check {
color: var(--primary);
font-size: 1.2rem;
flex-shrink: 0;
}
.radar-img {
width: 100%;
max-width: 30rem;
height: auto;
display: block;
margin: 0 auto;
}
.success-box {
background: var(--accent);
border-radius: 1.1rem;
padding: 1.4rem;
}
/* ---------- Slider ---------- */
.scale-slider {
width: 100%;
accent-color: var(--primary);
}
/* ---------- List card row ---------- */
.list-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.chevron {
color: var(--primary);
font-size: 1.4rem;
}
.bold {
font-weight: 700;
}
.title-lg {
font-size: 1.3rem;
font-weight: 700;
}
.title-md {
font-size: 1.05rem;
font-weight: 700;
}
.text-sm {
font-size: 0.85rem;
}
.divider {
height: 1px;
background: var(--border);
border: none;
margin: 0.4rem 0;
}
/* ---------- Agreement dialog ---------- */
.agr-modal {
max-width: 34rem;
max-height: 90vh;
}
.agr-body {
overflow-y: auto;
max-height: 50vh;
padding-right: 0.4rem;
}
.agr-h1 {
color: var(--primary);
font-size: 1.25rem;
font-weight: 700;
margin: 0.6rem 0 0.4rem;
}
.agr-h2 {
color: var(--info);
font-size: 1.05rem;
font-weight: 700;
margin: 0.7rem 0 0.3rem;
}
.agr-h3 {
color: var(--text-secondary);
font-size: 0.95rem;
font-weight: 700;
margin: 0.5rem 0 0.2rem;
}
.agr-p {
font-size: 0.85rem;
margin: 0.35rem 0;
}
.agr-ul {
margin: 0.2rem 0 0.4rem 1.1rem;
font-size: 0.85rem;
}
.agr-ul li {
margin: 0.15rem 0;
}
.agr-switch-row {
display: flex;
align-items: flex-start;
gap: 0.55rem;
font-size: 0.85rem;
cursor: pointer;
}
.agr-switch-row input {
width: 1.1rem;
height: 1.1rem;
accent-color: var(--primary);
flex-shrink: 0;
margin-top: 0.1rem;
}
/* ---------- SearchableDropdown dialog ---------- */
.sd-dialog {
max-width: 25rem;
height: 26rem;
max-height: 85vh;
}
.sd-list {
flex: 1;
overflow-y: auto;
margin: 0 -0.4rem;
}
.sd-dialog .dropdown-search {
position: static;
border: 1px solid var(--border);
border-radius: 0.6rem;
}

View File

@@ -0,0 +1,86 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import {
THEMES,
DEFAULT_THEME_ID,
SCALE_DEFAULT,
SCALE_MIN,
SCALE_MAX,
getTheme,
autoScale,
} from "./themes";
const THEME_KEY = "ui_theme";
const SCALE_KEY = "ui_scale";
interface ThemeCtx {
themeId: string;
scale: number;
setThemeId: (id: string) => void;
setScale: (s: number) => void;
}
const Ctx = createContext<ThemeCtx | null>(null);
function clampScale(s: number): number {
return Math.max(SCALE_MIN, Math.min(SCALE_MAX, s));
}
function applyTheme(themeId: string) {
const t = getTheme(themeId);
const root = document.documentElement;
root.style.setProperty("--bg", t.background);
root.style.setProperty("--surface", t.surface);
root.style.setProperty("--surface-alt", t.surfaceAlt);
// тёмная тема — светлый текст
const isDark = themeId === "dark";
root.style.setProperty("--text-primary", isDark ? "#F4F6F8" : "#1E2A3A");
root.style.setProperty("--text-secondary", isDark ? "#AEB9C7" : "#5A6779");
root.style.setProperty("--border", isDark ? "#4A5C72" : "#DDE3EA");
}
function applyScale(scale: number) {
document.documentElement.style.setProperty("--scale", String(scale));
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [themeId, setThemeIdState] = useState<string>(() => {
const saved = localStorage.getItem(THEME_KEY);
return saved && THEMES.some((t) => t.id === saved) ? saved : DEFAULT_THEME_ID;
});
const [scale, setScaleState] = useState<number>(() => {
const saved = localStorage.getItem(SCALE_KEY);
if (saved != null) {
const n = parseFloat(saved);
if (!Number.isNaN(n)) return clampScale(n);
}
return autoScale() || SCALE_DEFAULT;
});
useEffect(() => {
applyTheme(themeId);
}, [themeId]);
useEffect(() => {
applyScale(scale);
}, [scale]);
const setThemeId = (id: string) => {
setThemeIdState(id);
localStorage.setItem(THEME_KEY, id);
};
const setScale = (s: number) => {
const c = clampScale(s);
setScaleState(c);
localStorage.setItem(SCALE_KEY, String(c));
};
return <Ctx.Provider value={{ themeId, scale, setThemeId, setScale }}>{children}</Ctx.Provider>;
}
export function useTheme(): ThemeCtx {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}

Some files were not shown because too many files have changed in this diff Show More