Compare commits
35 Commits
6509971527
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
485d5128bf | ||
|
|
0f13ef5256 | ||
|
|
eb110f677c | ||
|
|
1728450778 | ||
|
|
db76401cef | ||
|
|
d8438da334 | ||
|
|
94299d4af6 | ||
|
|
dd0c29ccad | ||
|
|
31ee79bd97 | ||
|
|
c197a59c3f | ||
|
|
ef1d442f35 | ||
|
|
ab1788c57f | ||
|
|
6bda64ccbc | ||
|
|
fd098f917c | ||
|
|
eac61923f4 | ||
|
|
71887b084a | ||
|
|
94e78a9e77 | ||
| 7d90737986 | |||
| 7096cc8d66 | |||
| e2fb4ddb2a | |||
| 4013701441 | |||
| ed6de0a4cb | |||
| 3a690ecf39 | |||
| 0b7a5bc454 | |||
| 0528393f2f | |||
| 7435992e15 | |||
| 10fb240dcd | |||
| fe078b8560 | |||
| 8a189e310b | |||
| 22bbc3a0a6 | |||
| 4e9b89fbe5 | |||
| 59b3bcd0f4 | |||
| 9cd6d83428 | |||
| 04bfff3d3f | |||
| bf58b2d592 |
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
24
.env.example
Normal 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
4
.gitignore
vendored
@@ -42,3 +42,7 @@ venv.bak/
|
||||
.env
|
||||
*.log
|
||||
*.sqlite
|
||||
|
||||
./node_modules
|
||||
node_modules
|
||||
data
|
||||
@@ -2,12 +2,16 @@ FROM python:3.14-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем зависимости
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Зависимости ставим через 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"]
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM nginx:alpine
|
||||
COPY . /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
632
README.md
632
README.md
@@ -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-соединением — та же архитектура, что подвела основной фронтенд (см. записи №1–4 выше). Плавающая версия `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
306
SECURITY.md
Normal 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 при регистрации
|
||||
@@ -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
|
||||
@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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
41
bd/tables/radar_result.py
Normal 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"]
|
||||
@@ -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
37
bd/tables/scale.py
Normal 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"]
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -6,28 +6,85 @@ services:
|
||||
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
|
||||
web:
|
||||
- ./data/radar:/app/data/radar
|
||||
restart: unless-stopped
|
||||
|
||||
web-react:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: ../Dockerfile.web
|
||||
context: ./web-react
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:80"
|
||||
- "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:
|
||||
- ./web:/usr/share/nginx/html:ro
|
||||
- ./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
110
import_orgs.py
Normal 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()
|
||||
69
main.py
69
main.py
@@ -1,14 +1,18 @@
|
||||
import os
|
||||
import secrets
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Depends, FastAPI, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from route import base, holland_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud
|
||||
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)
|
||||
@@ -17,18 +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:3000", "http://127.0.0.1:3000"],
|
||||
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)
|
||||
@@ -39,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(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
35
nginx.conf
Normal file
@@ -0,0 +1,35 @@
|
||||
events {}
|
||||
|
||||
http {
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# API — проксируем /api/* → api:8000 (убираем префикс /api)
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Flet WebSocket
|
||||
location /ws {
|
||||
proxy_pass http://web:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# Всё остальное → веб-приложение
|
||||
location / {
|
||||
proxy_pass http://web:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
120
nginx.production.conf
Normal file
120
nginx.production.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
92
route/auth.py
Normal 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
102
route/auth_utils.py
Normal 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",
|
||||
)
|
||||
@@ -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
501
route/glomshtok_crud.py
Normal 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/."
|
||||
),
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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": "Расшифровка теста Голланда успешно загружена в базу данных.",
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
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"])
|
||||
|
||||
|
||||
@@ -40,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:
|
||||
@@ -70,12 +70,79 @@ async def create_tables():
|
||||
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/clear")
|
||||
async def clear_tables(payload: ClearDBIn):
|
||||
@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}.
|
||||
@@ -83,9 +150,7 @@ async def clear_tables(payload: ClearDBIn):
|
||||
if not payload.confirm:
|
||||
raise HTTPException(status_code=400, detail="Confirmation required")
|
||||
|
||||
settings = Settings()
|
||||
db_url = settings.DATABASE_URL_syncpg
|
||||
engine = create_engine(db_url, future=True)
|
||||
engine = get_engine()
|
||||
|
||||
metadatas = collect_metadatas()
|
||||
if not metadatas:
|
||||
@@ -100,8 +165,9 @@ async def clear_tables(payload: ClearDBIn):
|
||||
unique.append(md)
|
||||
|
||||
try:
|
||||
for md in unique:
|
||||
md.drop_all(bind=engine)
|
||||
return {"status": "ok", "detail": f"Dropped {len(unique)} metadata groups"}
|
||||
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
290
route/klimov_crud.py
Normal 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/."
|
||||
),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
267
route/radar_crud.py
Normal 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
209
route/radar_svg_gen.py
Normal 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
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)
|
||||
@@ -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,7 +131,18 @@ 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")
|
||||
|
||||
224
route/scale_crud.py
Normal file
224
route/scale_crud.py
Normal 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
415
route/stats_crud.py
Normal 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
336
route/transfer_crud.py
Normal 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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
6
stats-react/.dockerignore
Normal file
6
stats-react/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.local
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
5
stats-react/.gitignore
vendored
Normal file
5
stats-react/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.vite
|
||||
*.tsbuildinfo
|
||||
14
stats-react/Dockerfile
Normal file
14
stats-react/Dockerfile
Normal 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
13
stats-react/index.html
Normal 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
41
stats-react/nginx.conf
Normal 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
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
22
stats-react/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
stats-react/public/favicon.png
Normal file
BIN
stats-react/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 851 B |
342
stats-react/src/App.tsx
Normal file
342
stats-react/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
stats-react/src/api/client.ts
Normal file
156
stats-react/src/api/client.ts
Normal 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}`)) ?? [];
|
||||
}
|
||||
24
stats-react/src/components/ChartCard.tsx
Normal file
24
stats-react/src/components/ChartCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
stats-react/src/components/SearchableDropdown.tsx
Normal file
83
stats-react/src/components/SearchableDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
stats-react/src/components/SummaryTile.tsx
Normal file
23
stats-react/src/components/SummaryTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
stats-react/src/components/TopUsersTable.tsx
Normal file
33
stats-react/src/components/TopUsersTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
stats-react/src/components/charts/BarChart.tsx
Normal file
115
stats-react/src/components/charts/BarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
stats-react/src/components/charts/ChartTooltip.tsx
Normal file
50
stats-react/src/components/charts/ChartTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
stats-react/src/components/charts/LineChart.tsx
Normal file
152
stats-react/src/components/charts/LineChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
stats-react/src/components/charts/PartToWholeBar.tsx
Normal file
104
stats-react/src/components/charts/PartToWholeBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
stats-react/src/components/icons.tsx
Normal file
67
stats-react/src/components/icons.tsx
Normal 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
10
stats-react/src/main.tsx
Normal 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>,
|
||||
);
|
||||
478
stats-react/src/styles/global.css
Normal file
478
stats-react/src/styles/global.css
Normal 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;
|
||||
}
|
||||
43
stats-react/src/theme/palette.ts
Normal file
43
stats-react/src/theme/palette.ts
Normal 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];
|
||||
}
|
||||
18
stats-react/src/theme/useIsDark.ts
Normal file
18
stats-react/src/theme/useIsDark.ts
Normal 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
21
stats-react/tsconfig.json
Normal 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"]
|
||||
}
|
||||
18
stats-react/vite.config.ts
Normal file
18
stats-react/vite.config.ts
Normal 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
548
uv.lock
generated
@@ -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
6
web-react/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.local
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
4
web-react/.gitignore
vendored
Normal file
4
web-react/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.vite
|
||||
14
web-react/Dockerfile
Normal file
14
web-react/Dockerfile
Normal 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
58
web-react/README.md
Normal 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
13
web-react/index.html
Normal 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
43
web-react/nginx.conf
Normal 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
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
23
web-react/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
web-react/public/favicon.png
Normal file
BIN
web-react/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 851 B |
4
web-react/public/icons/clipboard-question_16542596.svg
Normal file
4
web-react/public/icons/clipboard-question_16542596.svg
Normal 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 |
4
web-react/public/icons/leaderboard-trophy_14227576.svg
Normal file
4
web-react/public/icons/leaderboard-trophy_14227576.svg
Normal 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
46
web-react/src/App.tsx
Normal 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
283
web-react/src/api/client.ts
Normal 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: "Не удалось подключиться к серверу" };
|
||||
}
|
||||
}
|
||||
131
web-react/src/auth/AuthContext.tsx
Normal file
131
web-react/src/auth/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
193
web-react/src/components/AgreementDialog.tsx
Normal file
193
web-react/src/components/AgreementDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
web-react/src/components/Layout.tsx
Normal file
40
web-react/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
web-react/src/components/ProtectedRoute.tsx
Normal file
36
web-react/src/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
19
web-react/src/components/RadarBlock.tsx
Normal file
19
web-react/src/components/RadarBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
web-react/src/components/SearchableDropdown.tsx
Normal file
83
web-react/src/components/SearchableDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
web-react/src/components/icons.tsx
Normal file
90
web-react/src/components/icons.tsx
Normal 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>
|
||||
);
|
||||
139
web-react/src/components/ui.tsx
Normal file
139
web-react/src/components/ui.tsx
Normal 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
10
web-react/src/main.tsx
Normal 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>,
|
||||
);
|
||||
75
web-react/src/pages/AnonymousEntryPage.tsx
Normal file
75
web-react/src/pages/AnonymousEntryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
web-react/src/pages/HomePage.tsx
Normal file
37
web-react/src/pages/HomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
web-react/src/pages/LoginPage.tsx
Normal file
82
web-react/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
web-react/src/pages/MyTestsPage.tsx
Normal file
77
web-react/src/pages/MyTestsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
web-react/src/pages/PollPage.tsx
Normal file
192
web-react/src/pages/PollPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
web-react/src/pages/ProfilePage.tsx
Normal file
133
web-react/src/pages/ProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
web-react/src/pages/RegisterPage.tsx
Normal file
97
web-react/src/pages/RegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
web-react/src/pages/ResponsePage.tsx
Normal file
102
web-react/src/pages/ResponsePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
web-react/src/pages/TestListPage.tsx
Normal file
41
web-react/src/pages/TestListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
600
web-react/src/styles/global.css
Normal file
600
web-react/src/styles/global.css
Normal 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;
|
||||
}
|
||||
86
web-react/src/theme/ThemeContext.tsx
Normal file
86
web-react/src/theme/ThemeContext.tsx
Normal 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;
|
||||
}
|
||||
58
web-react/src/theme/themes.ts
Normal file
58
web-react/src/theme/themes.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// Темы и палитра. Перенос из web/theme_manager.py (THEMES) и web/designer.py (colors).
|
||||
// Каждая тема переопределяет только фоновые цвета; остальная палитра общая.
|
||||
|
||||
export interface Theme {
|
||||
id: string;
|
||||
name: string;
|
||||
background: string;
|
||||
surface: string;
|
||||
surfaceAlt: string;
|
||||
}
|
||||
|
||||
export const THEMES: Theme[] = [
|
||||
{ id: "default", name: "ЦОПП (оранжевая)", background: "#F4F6F8", surface: "#FFFFFF", surfaceAlt: "#E4E8ED" },
|
||||
{ id: "warm", name: "Тёплая", background: "#FFF3E6", surface: "#FFFFFF", surfaceAlt: "#FFD9B3" },
|
||||
{ id: "blue", name: "Синяя", background: "#E3F2FD", surface: "#FFFFFF", surfaceAlt: "#B8D9F5" },
|
||||
{ id: "green", name: "Зелёная", background: "#EDF7E7", surface: "#FFFFFF", surfaceAlt: "#BEE3B0" },
|
||||
{ id: "dark", name: "Тёмная", background: "#1E2A3A", surface: "#2D3F54", surfaceAlt: "#3D5068" },
|
||||
];
|
||||
|
||||
export const DEFAULT_THEME_ID = "default";
|
||||
|
||||
// Постоянная палитра (web/designer.py: class colors)
|
||||
export const PALETTE = {
|
||||
primary: "#F85A40",
|
||||
primaryDark: "#D94530",
|
||||
primaryLight: "#FFEAB7",
|
||||
accent: "#FFC845",
|
||||
accentLime: "#FFC845",
|
||||
secondaryLight: "#FFEAB7",
|
||||
info: "#007FBD",
|
||||
infoDark: "#006AA3",
|
||||
infoLight: "#D9F1F4",
|
||||
success: "#85C446",
|
||||
successLight: "#C0DE9E",
|
||||
textPrimary: "#1E2A3A",
|
||||
textSecondary: "#5A6779",
|
||||
textOnPrimary: "#FFFFFF",
|
||||
border: "#DDE3EA",
|
||||
};
|
||||
|
||||
// Масштаб (web/theme_manager.py)
|
||||
export const SCALE_MIN = 0.8;
|
||||
export const SCALE_MAX = 2.5;
|
||||
export const SCALE_STEP = 0.1;
|
||||
export const SCALE_DEFAULT = 1.3;
|
||||
// Автоопределение по ширине экрана
|
||||
export const MOBILE_BREAKPOINT = 600;
|
||||
export const SCALE_MOBILE = 1.0;
|
||||
export const SCALE_DESKTOP = 1.5;
|
||||
|
||||
export function getTheme(id: string | null | undefined): Theme {
|
||||
return THEMES.find((t) => t.id === id) ?? THEMES[0];
|
||||
}
|
||||
|
||||
export function autoScale(): number {
|
||||
if (typeof window === "undefined") return SCALE_DEFAULT;
|
||||
return window.innerWidth < MOBILE_BREAKPOINT ? SCALE_MOBILE : SCALE_DESKTOP;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user