Compare commits

...

17 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 11:54:11 +05:00
jze9
eac61923f4 feat(web): React-фронтенд вместо Flet + fix restore rmtree на bind-mount
web-react/: SPA Vite+React+TS, nginx раздаёт статику и проксирует /api на api:8000 по локалке. docker-compose: web(Flet)->web-react на :80. transfer_crud.backup_restore: чистим содержимое data/radar вместо rmtree точки монтирования (EBUSY).
2026-06-02 13:56:44 +00:00
jze9
71887b084a config: docker-compose — реальные адреса БД и Redis (192.168.1.11, 192.168.1.19) 2026-05-18 11:13:28 +00:00
jze9
94e78a9e77 fix: единый singleton engine в bd, убраны 17 локальных make_engine()
- bd/__init__.py: добавлены get_engine() и get_session() — один SQLAlchemy engine на весь процесс
- make_engine(): pool_size=5, max_overflow=5, pool_timeout=30, pool_recycle=1800
- Все 17 route-файлов: убраны локальные get_session()/make_engine(), импорт из bd
- transfer_crud.py: _get_session() → get_session() из bd
- init_data_base.py: get_engine() вместо make_engine()
- Результат: 78 idle соединений → 1
2026-05-18 11:13:28 +00:00
128 changed files with 8545 additions and 4614 deletions

13
.dockerignore Normal file
View File

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

24
.env.example Normal file
View File

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

View File

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

View File

@@ -1,22 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
# matplotlib нужен fontconfig для кириллицы
RUN apt-get update && apt-get install -y --no-install-recommends \
fontconfig \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir \
"flet>=0.82.2" \
"flet-web>=0.82.2" \
"uvicorn[standard]" \
httpx \
matplotlib
COPY stats/ .
EXPOSE 8081
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8081"]

View File

@@ -1,14 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
# Устанавливаем flet (uvicorn уже входит в зависимости flet)
RUN pip install --no-cache-dir flet flet-web "uvicorn[standard]" httpx
# Копируем исходники веб-приложения
COPY . .
EXPOSE 80
# Запускаем Flet как ASGI-приложение через uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"]

117
README.md
View File

@@ -1,6 +1,6 @@
# api-copp # api-copp
Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на Flet. Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на React (SPA). Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. Тест можно пройти анонимно — достаточно выбрать школу и класс, аккаунт не нужен.
--- ---
@@ -25,42 +25,42 @@
``` ```
Браузер пользователя Браузер пользователя
│ :80 │ :80
[ web — Flet ASGI ] ← единственный публичный сервис [ web-react — nginx (SPA + reverse-proxy /api) ] ← единственный публичный сервис
│ http://api:8000 (только Docker-сеть) │ http://api:8000 (только Docker-сеть)
[ api — FastAPI :8000 ] [ api — FastAPI :8000 ]
│ │ │ │
[ PostgreSQL :5432 ] [ Redis :6379 ] [ PostgreSQL :5432 ] [ Redis :6379 ]
``` ```
Сервис `api` **не публикует порты наружу** — доступен только контейнеру `web` внутри Docker-сети. Пользователь никогда не обращается к API напрямую. Сервис `api` **не публикует порты наружу** — доступен только контейнеру `web-react` внутри Docker-сети. Браузер обращается к `/api/*`, nginx внутри `web-react` перенаписывает и проксирует запрос на `api:8000` (см. `web-react/nginx.conf`). Пользователь никогда не обращается к API напрямую.
PostgreSQL и Redis могут запускаться как: PostgreSQL и Redis могут запускаться как:
- отдельным compose-файлом `docker-compose_db.yml` (локально в Docker) - сервисами `postgres`/`redis` в том же `docker-compose.yml`, за профилем `local-db` (локально в Docker)
- внешними сервисами (отдельный сервер, managed service) — достаточно прописать нужные `REDIS_HOST`/`DB_HOST` в переменных окружения - внешними сервисами (отдельный сервер, managed service) — достаточно прописать нужные `REDIS_HOST`/`DB_HOST` в `.env`, профиль `local-db` не поднимать
--- ---
## Быстрый старт ## Быстрый старт
### 1. Инфраструктура (PostgreSQL + Redis) Один файл `docker-compose.yml` на всё. По умолчанию поднимает только приложение (`api` + `web-react` + `stats`) — БД и Redis ожидаются внешними (`DB_HOST`/`REDIS_HOST` в `.env`, см. «Переменные окружения»).
```bash
docker compose -f docker-compose_db.yml up -d
```
### 2. Приложение
```bash ```bash
# Только приложение (внешние БД/Redis из .env)
docker compose up -d --build docker compose up -d --build
# + локальные PostgreSQL и Redis в Docker (профиль local-db)
docker compose --profile local-db up -d --build
``` ```
| Адрес | Что | | Адрес | Что |
|---|---| |---|---|
| `http://localhost` | Веб-интерфейс (Flet) | | `http://localhost` | Веб-интерфейс (React SPA) |
| `http://localhost:8000/docs` | Swagger UI (HTTP Basic Auth) | | `http://localhost:8000/docs` | Swagger UI (HTTP Basic Auth) |
| `http://localhost:8000/redoc` | ReDoc | | `http://localhost:8000/redoc` | ReDoc |
| `http://localhost:8081` | Дашборд статистики | | `http://localhost:8081` | Дашборд статистики |
> Реальные значения переменных окружения (пароли, ключи) держите в `.env` в корне репозитория — `docker compose` подставляет их автоматически. Шаблон без секретов — в `.env.example`. Файл `.env` в `.gitignore`, в репозиторий не попадает.
### 3. Инициализация БД (первый запуск) ### 3. Инициализация БД (первый запуск)
```bash ```bash
@@ -99,11 +99,12 @@ curl -X POST http://localhost:8000/polls/glomshtok/<poll_id>/seed-scores -H "X-A
## Переменные окружения ## Переменные окружения
> Файл `docker-compose.yml` содержит **демо-значения**. Перед продакшн-деплоем обязательно смените все пароли и ключи. > `docker-compose.yml` читает значения из `.env` (не коммитится, см. `.env.example` за шаблоном). Перед продакшн-деплоем обязательно смените все пароли и ключи в своём `.env`.
| Переменная | Описание | Пример | | Переменная | Описание | Пример |
|---|---|---| |---|---|---|
| `APP_PATH` | Путь к директории приложения внутри контейнера | `/app` | | `APP_PATH` | Путь к директории приложения внутри контейнера | `/app` |
| `DEBUG` | Режим отладки API | `False` |
| `DB_HOST` | Хост PostgreSQL | `postgres` | | `DB_HOST` | Хост PostgreSQL | `postgres` |
| `DB_PORT` | Порт PostgreSQL | `5432` | | `DB_PORT` | Порт PostgreSQL | `5432` |
| `DB_USER` | Пользователь БД | `postgres` | | `DB_USER` | Пользователь БД | `postgres` |
@@ -126,12 +127,11 @@ curl -X POST http://localhost:8000/polls/glomshtok/<poll_id>/seed-scores -H "X-A
``` ```
api-copp/ api-copp/
├── main.py # точка входа FastAPI, регистрация роутеров ├── main.py # точка входа FastAPI, регистрация роутеров
├── requirements.txt # зависимости API ├── pyproject.toml / uv.lock # зависимости API (единственный источник — читает Dockerfile.api через uv)
├── Dockerfile.api # образ API ├── .dockerignore # что не попадает в build context образов
├── Dockerfile.web # образ фронтенда (Flet) ├── .env.example # шаблон переменных окружения без секретов
├── Dockerfile.stats # образ дашборда статистики ├── Dockerfile.api # образ API
├── docker-compose.yml # API + Web + Stats ├── docker-compose.yml # api + web-react + stats-react (+ postgres/redis за профилем local-db)
├── docker-compose_db.yml # PostgreSQL + Redis
├── bd/ ├── bd/
│ ├── __init__.py # Settings (pydantic), make_engine() │ ├── __init__.py # Settings (pydantic), make_engine()
@@ -168,13 +168,16 @@ api-copp/
│ ├── stats_crud.py # статистика (кэшируется в Redis, TTL 5 мин) │ ├── stats_crud.py # статистика (кэшируется в Redis, TTL 5 мин)
│ └── transfer_crud.py # экспорт/импорт/бэкап/восстановление БД │ └── transfer_crud.py # экспорт/импорт/бэкап/восстановление БД
├── web/ # Flet-фронтенд (порт 80) ├── web-react/ # React SPA-фронтенд (порт 80, свой Dockerfile+nginx)
│ ├── main.py │ ├── src/api/client.ts # единая точка HTTP-запросов к API
│ ├── router.py │ ├── src/auth/AuthContext.tsx # токен-логин + анонимная сессия (школа+класс без аккаунта)
│ ├── api_client.py │ ├── src/pages/ # страницы (Login, AnonymousEntry, Poll, MyTests, ...)
── designer.py # дизайн-система ── nginx.conf # раздаёт статику SPA, проксирует /api → api:8000
├── radar_chart.py # виджет RadarChart
│ └── views/content_users/ ├── 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) └── data/ # том Docker (./data:/app/data)
├── postgres/ # данные PostgreSQL ├── postgres/ # данные PostgreSQL
@@ -259,6 +262,8 @@ GET /polls/users/{user_id}/responses — история пользовате
GET /responses/{id} — конкретный результат с ответами GET /responses/{id} — конкретный результат с ответами
``` ```
`POST /polls/{poll_id}/responses` не требует авторизации: `user_id` необязателен. `web-react` использует это для анонимного прохождения — школьник на `/anonymous` выбирает только школу и класс (без пароля и аккаунта), выбор кладётся в `group_id`/`organization_id` тела запроса вместо `user_id`. Результат сохраняется в базе как снимок школы/класса без привязки к пользователю; истории результатов после закрытия вкладки у анонимной сессии нет — только у настоящих аккаунтов (`/my-tests`).
### Результаты (паутинная диаграмма) ### Результаты (паутинная диаграмма)
``` ```
@@ -446,7 +451,7 @@ radar_svgs/ ← все SVG-файлы (структура папок
### Скачивание из браузера ### Скачивание из браузера
Фронтенд скачивает SVG-байты через внутренний API (httpx, Docker-сеть), кодирует в base64 data URI и открывает в браузере. Браузер не обращается к API напрямую — это важно, так как `api` не имеет публичного адреса. `web-react` отдаёт SVG через тот же same-origin прокси, что и остальной API: `<img src="/api/responses/{id}/radar/image">`, nginx внутри контейнера `web-react` перенаправляет запрос на `api:8000` (см. `web-react/nginx.conf`). Браузер по-прежнему не обращается к `api` напрямую — `api` не публикует порты.
--- ---
@@ -497,7 +502,7 @@ tcp-keepalive 60
## Деплой с внешним прокси ## Деплой с внешним прокси
Минимальная конфигурация nginx: `web-react` — статический SPA + reverse-proxy внутри своего же контейнера (`web-react/nginx.conf`), долгоживущих WebSocket-соединений (как было у Flet) больше нет. Минимальная конфигурация внешнего nginx перед контейнером:
```nginx ```nginx
location / { location / {
@@ -505,24 +510,20 @@ location / {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
} }
``` ```
Заголовки `Upgrade` / `Connection` обязательны — Flet использует WebSocket. > **⚠ `nginx.conf` и `nginx.production.conf` в репозитории всё ещё написаны под старый Flet-фронтенд** (`upstream flet_app`, отдельный `location /ws` для WebSocket, `location /radar-image/`). После перехода на `web-react` эти файлы не актуальны и не обновлялись — `nginx.production.conf`, судя по всему, конфиг боевого внешнего nginx для `test.cloud-copp74.ru`, поэтому его правка не входит в эту уборку без отдельного подтверждения (ошибка здесь ломает публичный домен). Если используете внешний прокси в проде — сверьте его с примером выше перед следующим деплоем.
В репозитории есть `nginx.conf` и `nginx.production.conf` с готовыми примерами.
--- ---
## Известные ошибки и решения ## Известные ошибки и решения
### 1. `api` недоступен из браузера ✅ ### 1. `api` недоступен из браузера ✅ (относилось к старому Flet-фронтенду)
**Проблема:** Docker-имя `api` не резолвится у браузера пользователя. Любой URL вида `http://api:8000/...` в `page.launch_url()` давал ошибку DNS. **Проблема:** Docker-имя `api` не резолвится у браузера пользователя. Любой URL вида `http://api:8000/...` в `page.launch_url()` (Flet) давал ошибку DNS.
**Решение:** Фронтенд скачивает SVG-байты через httpx (внутренняя сеть), конвертирует в `data:image/svg+xml;base64,...` и передаёт готовый data URI в браузер. Прямых обращений к API из браузера нет. **Решение в `web-react`:** same-origin прокси `/api/*` в `web-react/nginx.conf` — браузер всегда обращается к тому же origin, что и отдал SPA, прямых обращений к `api` из браузера нет в принципе (см. «Скачивание из браузера» выше).
--- ---
@@ -540,11 +541,11 @@ location / {
--- ---
### 4. Варианты ответов обрезались ✅ ### 4. Варианты ответов обрезались ✅ (относилось к старому Flet-фронтенду)
**Проблема:** `ft.Radio(label=...)` в Flet не переносит текст метки — длинные варианты обрезались. **Проблема:** `ft.Radio(label=...)` в Flet не переносит текст метки — длинные варианты обрезались.
**Решение:** Кастомные кликабельные контейнеры с `ft.Text(expand=True, no_wrap=False)`. **Решение:** актуально не для этого проекта — в `web-react` варианты ответа рендерятся обычной HTML-разметкой (`PollPage.tsx`), перенос текста работает из коробки.
--- ---
@@ -592,6 +593,40 @@ ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512);
--- ---
### 10. Дублирование SVG-генератора ### 10. Дублирование SVG-генератора
**Состояние:** `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент) — два разных генератора. Кнопка «Сохранить» скачивает серверный файл. `web/radar_svg.py` не используется кнопками, но файл остался в репозитории. **Было:** `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент, Flet) — два разных генератора, второй нигде не вызывался.
**Решение:** старый Flet-фронтенд `web/` целиком удалён вместе с `Dockerfile.web` (заменён на `web-react` в коммите `eac6192`). Единственный генератор SVG теперь — `route/radar_svg_gen.py`.
---
### 11. Реальные секреты в `docker-compose.yml` ✅
**Проблема:** пароли БД/Redis, `SECRET_KEY`, `ADMIN_KEY` были прописаны в открытом виде прямо в `docker-compose.yml` (тогда ещё вместе с отдельным `docker-compose_db.yml`), закоммичены в git.
**Решение:** значения вынесены в `.env``.gitignore`, не коммитится), compose-файлы читают их через `${VAR}`. Шаблон без секретов — `.env.example`. **Важно:** старые значения уже есть в истории git (коммит `71887b0`) — сама по себе эта правка их не отзывает, реальные пароли на серверах БД/Redis нужно ротировать отдельно.
---
### 12. Зависимости API расходились между `pyproject.toml` и `requirements.txt` ✅
**Проблема:** `Dockerfile.api` ставил зависимости из `requirements.txt`, а не из `pyproject.toml`/`uv.lock` — версии успели разойтись (`redis==5.0.0` vs `>=7.1.0`, `sqlalchemy==2.0.32` vs `>=2.0.47`).
**Решение:** `Dockerfile.api` теперь ставит зависимости через `uv sync --frozen` напрямую из `pyproject.toml`/`uv.lock`. `requirements.txt` удалён — единственный источник правды. При переходе обнаружилась ещё одна расхождение: `python-multipart` (нужен FastAPI для `UploadFile`/`Form`, используется в `organizations_crud.py` при импорте .docx) был в `requirements.txt`, но отсутствовал в `pyproject.toml` — без него API падал при старте с `RuntimeError`. Добавлен в зависимости.
---
### 13. Деплой требовал двух отдельных compose-файлов ✅
**Было:** `docker-compose.yml` (api + web-react + stats) и `docker-compose_db.yml` (postgres + redis) — два отдельных файла и две команды на разворачивание.
**Решение:** `postgres`/`redis` перенесены в тот же `docker-compose.yml`, за профилем `local-db` — не поднимаются командой `docker compose up -d` по умолчанию (приложение по умолчанию ходит на внешние `DB_HOST`/`REDIS_HOST` из `.env`), но доступны через `docker compose --profile local-db up -d`, когда нужна локальная БД. `docker-compose_db.yml` удалён.
---
### 14. Дашборд статистики был на Flet — переписан на React ✅
**Проблема:** `stats/` (Flet, `Dockerfile.stats`) генерировал графики через matplotlib в PNG на сервере и отдавал их клиенту через Flutter web c долгоживущим WebSocket-соединением — та же архитектура, что подвела основной фронтенд (см. записи №14 выше). Плавающая версия `flet>=0.82.2` в какой-то момент подтянула мажорный `0.86.0` с несовместимым протоколом фреймирования, и дашборд перестал открываться в браузере.
**Решение:** `stats/` и `Dockerfile.stats` удалены, дашборд переписан на React SPA (`stats-react/`, тот же паттерн, что и `web-react/`) — same-origin прокси `/api/*` вместо WebSocket, графики — свой SVG/HTML без сторонних библиотек (bar/line/part-to-whole с hover-тултипами), функциональность 1:1 с оригиналом (2 таба, 4+4 графика, топ-10 таблица, все фильтры). Категориальная палитра графиков провалидирована на CVD-безопасность (`node scripts/validate_palette.js`) — у исходной 8-цветной палитры из `designer.py` эта проверка проваливалась по нескольким пунктам.

View File

@@ -1,6 +1,5 @@
# Database configuration package # Database configuration package
from pydantic import BaseModel from pydantic import BaseModel
import re
import os import os
import urllib.parse import urllib.parse
@@ -11,23 +10,6 @@ class Settings(BaseModel):
DB_PASS: str = os.getenv("DB_PASS", "") DB_PASS: str = os.getenv("DB_PASS", "")
DB_NAME: str = os.getenv("DB_NAME", "profi") 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: def DATABASE_URL_syncpg(self) -> str:
pwd = urllib.parse.quote_plus(str(self.DB_PASS)) pwd = urllib.parse.quote_plus(str(self.DB_PASS))
@@ -47,7 +29,11 @@ def make_engine():
s.DATABASE_URL_syncpg, s.DATABASE_URL_syncpg,
connect_args=s.PG8000_CONNECT_ARGS, connect_args=s.PG8000_CONNECT_ARGS,
future=True, future=True,
pool_pre_ping=True, # проверяет соединение перед каждым запросом pool_pre_ping=True,
pool_size=5,
max_overflow=5,
pool_timeout=30,
pool_recycle=1800,
) )
@@ -70,3 +56,22 @@ def wait_for_db(retries: int = 15, delay: float = 2.0) -> None:
if attempt < retries: if attempt < retries:
time.sleep(delay) time.sleep(delay)
raise RuntimeError("PostgreSQL did not become ready in time.") raise RuntimeError("PostgreSQL did not become ready in time.")
# ── Singleton engine + session factory (один движок на весь процесс) ───────
_engine = None
_SessionFactory = None
def get_engine():
global _engine
if _engine is None:
_engine = make_engine()
return _engine
def get_session():
global _SessionFactory
if _SessionFactory is None:
from sqlalchemy.orm import sessionmaker
_SessionFactory = sessionmaker(bind=get_engine(), future=True)
return _SessionFactory

View File

@@ -7,47 +7,84 @@ services:
- "8000:8000" - "8000:8000"
environment: environment:
- APP_PATH=/app - APP_PATH=/app
- DEBUG=True - DEBUG=${DEBUG}
- DB_HOST=postgres - DB_HOST=${DB_HOST}
- DB_PORT=5432 - DB_PORT=${DB_PORT}
- DB_USER=postgres - DB_USER=${DB_USER}
- DB_PASS=postgres - DB_PASS=${DB_PASS}
- DB_NAME=profi - DB_NAME=${DB_NAME}
# Redis connection (external) # Redis connection (external)
- REDIS_HOST=redis - REDIS_HOST=${REDIS_HOST}
- REDIS_PORT=6379 - REDIS_PORT=${REDIS_PORT}
- REDIS_DB=0 - REDIS_DB=${REDIS_DB}
- REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn - REDIS_PASSWORD=${REDIS_PASSWORD}
# JWT — замените на длинную случайную строку (python -c "import secrets; print(secrets.token_hex(32))") - SECRET_KEY=${SECRET_KEY}
- SECRET_KEY=e8fe23297b4664a78ba8e8ebe34bac158398ac4d7dc6b664fd8d52b0094ee67b - ADMIN_KEY=${ADMIN_KEY}
# Ключ для admin-операций - DOCS_USERNAME=${DOCS_USERNAME}
- ADMIN_KEY=b00ac081344a99bf50dffeb942e7f4f1 - DOCS_PASSWORD=${DOCS_PASSWORD}
# Логин и пароль для доступа к /docs и /redoc
- DOCS_USERNAME=admin
- DOCS_PASSWORD=2cbae90b3d9f6710cf51354f
volumes: volumes:
- ./data/radar:/app/data/radar - ./data/radar:/app/data/radar
restart: unless-stopped
web: web-react:
build: build:
context: ./web context: ./web-react
dockerfile: ../Dockerfile.web dockerfile: Dockerfile
ports: ports:
- "80:80" - "80:80"
environment:
- API_URL=http://api:8000
depends_on: depends_on:
- api - api
restart: unless-stopped
stats: stats-react:
build: build:
context: . context: ./stats-react
dockerfile: Dockerfile.stats dockerfile: Dockerfile
ports: ports:
- "8081:8081" - "8081:80"
environment:
- API_URL=http://api:8000
depends_on: depends_on:
- api - api
restart: unless-stopped
# ── Локальные БД (опционально) ─────────────────────────────────────────
# По умолчанию api ходит на внешние DB_HOST/REDIS_HOST из .env. Эти два
# сервиса поднимаются только явным флагом профиля:
# docker compose --profile local-db up -d
postgres:
image: postgres:latest
container_name: postgres
profiles: ["local-db"]
ports:
- "5432:5432"
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
redis:
image: redis:alpine
container_name: redis
profiles: ["local-db"]
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--appendonly yes
--appendfsync everysec
--maxmemory 256mb
--maxmemory-policy volatile-lru
--tcp-keepalive 60
--timeout 300
--loglevel notice
ports:
- 6379:6379
volumes:
- ./data/redis:/data
restart: always

View File

@@ -1,38 +0,0 @@
services:
redis:
image: redis:alpine
container_name: redis
command: >
redis-server
--requirepass CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
--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
postgres:
image: postgres:latest
container_name: postgres
#restart: always
ports:
- "5432:5432"
environment:
- POSTGRES_PASSWORD=postgres
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s

View File

@@ -1,10 +0,0 @@
services:
web:
build:
context: ./web
dockerfile: ./Dockerfile.web
ports:
- "80:80"
environment:
- API_URL=http://api:8000

View File

@@ -21,9 +21,12 @@ dependencies = [
"pg8000>=1.29.0", "pg8000>=1.29.0",
"flet>=0.82.2", "flet>=0.82.2",
"jose>=1.0.0", "jose>=1.0.0",
"passlib>=1.7.4", "passlib[bcrypt]>=1.7.4",
"bcrypt==4.0.1",
"python-jose>=3.5.0", "python-jose>=3.5.0",
"matplotlib>=3.10.8", "matplotlib>=3.10.8",
"python-docx>=1.2.0",
"python-multipart>=0.0.20",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -1,22 +0,0 @@
python-docx==1.2.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
python-multipart==0.0.20
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
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1

View File

@@ -5,7 +5,7 @@ from typing import Optional
from datetime import datetime, timezone from datetime import datetime, timezone
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.users import User from bd.tables.users import User
from route.auth_utils import hash_password, verify_password, create_access_token, SECRET_KEY, ALGORITHM, oauth2_scheme from route.auth_utils import hash_password, verify_password, create_access_token, SECRET_KEY, ALGORITHM, oauth2_scheme
@@ -15,9 +15,6 @@ from redis_db import blacklist_token
router = APIRouter(tags=["auth"], prefix="/auth") router = APIRouter(tags=["auth"], prefix="/auth")
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
class RegisterIn(BaseModel): class RegisterIn(BaseModel):

View File

@@ -9,7 +9,7 @@ from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from bd import make_engine from bd import get_session
SECRET_KEY: str = os.getenv("SECRET_KEY", "") SECRET_KEY: str = os.getenv("SECRET_KEY", "")
if not SECRET_KEY: if not SECRET_KEY:
@@ -45,9 +45,6 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
def get_current_user(token: str = Depends(oauth2_scheme)): def get_current_user(token: str = Depends(oauth2_scheme)):

View File

@@ -3,7 +3,7 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.choice import Choice from bd.tables.choice import Choice
from bd.tables.question import Question from bd.tables.question import Question
@@ -23,9 +23,6 @@ class ChoiceUpdate(BaseModel):
position: Optional[int] position: Optional[int]
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)]) @router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])

View File

@@ -24,7 +24,7 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.question import Question from bd.tables.question import Question
from bd.tables.choice import Choice from bd.tables.choice import Choice
@@ -372,9 +372,6 @@ CHOICE_SCORES: dict[int, float] = {
CHOICE_LABELS: list[str] = ["++", "+", "0", "-", "--"] CHOICE_LABELS: list[str] = ["++", "+", "0", "-", "--"]
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
@router.post("/glomshtok", status_code=201) @router.post("/glomshtok", status_code=201)

View File

@@ -3,7 +3,7 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.group import Group from bd.tables.group import Group
from route.auth_utils import require_admin_key from route.auth_utils import require_admin_key
@@ -19,9 +19,6 @@ class GroupUpdate(BaseModel):
name_group: Optional[str] = None name_group: Optional[str] = None
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
def _group_dict(grp: Group) -> dict: def _group_dict(grp: Group) -> dict:

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException
from typing import Optional from typing import Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.question import Question from bd.tables.question import Question
@@ -141,9 +141,6 @@ DIMENSIONS = [
] ]
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
@router.post("/holland", status_code=201) @router.post("/holland", status_code=201)

View File

@@ -4,7 +4,7 @@ import importlib
from pathlib import Path from pathlib import Path
from typing import List from typing import List
from bd import Settings, make_engine from bd import Settings, get_engine
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from pydantic import BaseModel from pydantic import BaseModel
@@ -43,12 +43,12 @@ def collect_metadatas():
@router.post("/db/create-tables", dependencies=[Depends(require_admin_key)]) @router.post("/db/create-tables", dependencies=[Depends(require_admin_key)])
async def create_tables(): def create_tables():
"""Создаёт все таблицы, описанные в модулях `db.tables`. """Создаёт все таблицы, описанные в модулях `db.tables`.
Endpoint вызывается по нажатию кнопки в UI (POST). Endpoint вызывается по нажатию кнопки в UI (POST).
""" """
engine = make_engine() engine = get_engine()
metadatas = collect_metadatas() metadatas = collect_metadatas()
if not metadatas: if not metadatas:
@@ -71,9 +71,9 @@ async def create_tables():
@router.post("/db/migrate-cascade-user", dependencies=[Depends(require_admin_key)]) @router.post("/db/migrate-cascade-user", dependencies=[Depends(require_admin_key)])
async def migrate_cascade_user(): def migrate_cascade_user():
"""Добавляет FK responses.user_id → users.id ON DELETE CASCADE, если ещё не существует.""" """Добавляет FK responses.user_id → users.id ON DELETE CASCADE, если ещё не существует."""
engine = make_engine() engine = get_engine()
try: try:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text(""" conn.execute(text("""
@@ -95,9 +95,9 @@ async def migrate_cascade_user():
@router.post("/db/migrate-users", dependencies=[Depends(require_admin_key)]) @router.post("/db/migrate-users", dependencies=[Depends(require_admin_key)])
async def migrate_users(): def migrate_users():
"""Добавляет колонки username и hashed_password в таблицу users, если они ещё не существуют.""" """Добавляет колонки username и hashed_password в таблицу users, если они ещё не существуют."""
engine = make_engine() engine = get_engine()
try: try:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text(""" conn.execute(text("""
@@ -116,9 +116,9 @@ class ClearDBIn(BaseModel):
@router.post("/db/migrate", dependencies=[Depends(require_admin_key)]) @router.post("/db/migrate", dependencies=[Depends(require_admin_key)])
async def migrate_tables(): def migrate_tables():
"""Приводит схему БД в соответствие с моделями: убирает устаревшие колонки, добавляет новые.""" """Приводит схему БД в соответствие с моделями: убирает устаревшие колонки, добавляет новые."""
engine = make_engine() engine = get_engine()
migrations = [ migrations = [
# Убираем старый FK и колонку group.user_id (если остался от прежней схемы) # Убираем старый FK и колонку group.user_id (если остался от прежней схемы)
'ALTER TABLE "group" DROP COLUMN IF EXISTS user_id', 'ALTER TABLE "group" DROP COLUMN IF EXISTS user_id',
@@ -142,7 +142,7 @@ async def migrate_tables():
@router.post("/db/clear", dependencies=[Depends(require_admin_key)]) @router.post("/db/clear", dependencies=[Depends(require_admin_key)])
async def clear_tables(payload: ClearDBIn): def clear_tables(payload: ClearDBIn):
"""Полная очистка всех таблиц, описанных в `bd.tables`. """Полная очистка всех таблиц, описанных в `bd.tables`.
Требуется явное подтверждение: POST с телом {"confirm": true}. Требуется явное подтверждение: POST с телом {"confirm": true}.
@@ -150,7 +150,7 @@ async def clear_tables(payload: ClearDBIn):
if not payload.confirm: if not payload.confirm:
raise HTTPException(status_code=400, detail="Confirmation required") raise HTTPException(status_code=400, detail="Confirmation required")
engine = make_engine() engine = get_engine()
metadatas = collect_metadatas() metadatas = collect_metadatas()
if not metadatas: if not metadatas:

View File

@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException
from typing import Optional from typing import Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.question import Question from bd.tables.question import Question
from bd.tables.choice import Choice from bd.tables.choice import Choice
@@ -159,9 +159,6 @@ DIMENSIONS = [
] ]
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
@router.post("/klimov", status_code=201) @router.post("/klimov", status_code=201)

View File

@@ -4,7 +4,7 @@ from typing import Optional
import uuid import uuid
import io import io
from bd import make_engine from bd import get_session
from bd.tables.organization import Organization from bd.tables.organization import Organization
from route.auth_utils import require_admin_key from route.auth_utils import require_admin_key
@@ -20,9 +20,6 @@ class OrganizationUpdate(BaseModel):
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
def _org_dict(org: Organization) -> dict: def _org_dict(org: Organization) -> dict:

View File

@@ -3,7 +3,7 @@ from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.question import Question from bd.tables.question import Question
@@ -31,9 +31,6 @@ class PollIn(BaseModel):
questions: Optional[List[QuestionIn]] questions: Optional[List[QuestionIn]]
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)]) @router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])

View File

@@ -3,7 +3,7 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.question import Question from bd.tables.question import Question
from bd.tables.poll import Poll from bd.tables.poll import Poll
@@ -25,9 +25,6 @@ class QuestionUpdate(BaseModel):
position: Optional[int] position: Optional[int]
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)]) @router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])

View File

@@ -22,7 +22,7 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session as SASession from sqlalchemy.orm import Session as SASession
from bd import make_engine from bd import get_session
from bd.tables.response import Response, Answer from bd.tables.response import Response, Answer
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.scale import ScaleDimension, ChoiceScore from bd.tables.scale import ScaleDimension, ChoiceScore
@@ -32,9 +32,6 @@ from route.radar_svg_gen import save_radar_svg
router = APIRouter(tags=["radar"]) router = APIRouter(tags=["radar"])
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -3,7 +3,7 @@ from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.question import Question from bd.tables.question import Question
@@ -26,9 +26,6 @@ class ResponseIn(BaseModel):
answers: List[AnswerIn] answers: List[AnswerIn]
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
@router.post("/{poll_id}/responses", status_code=201) @router.post("/{poll_id}/responses", status_code=201)

View File

@@ -19,7 +19,7 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from bd import make_engine from bd import get_session
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.choice import Choice from bd.tables.choice import Choice
from bd.tables.scale import ScaleDimension, ChoiceScore from bd.tables.scale import ScaleDimension, ChoiceScore
@@ -27,9 +27,6 @@ from bd.tables.scale import ScaleDimension, ChoiceScore
router = APIRouter(tags=["scale"]) router = APIRouter(tags=["scale"])
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -4,7 +4,7 @@ from sqlalchemy import func
import json import json
import hashlib import hashlib
from bd import make_engine from bd import get_session
from bd.tables.response import Response from bd.tables.response import Response
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.group import Group from bd.tables.group import Group
@@ -26,9 +26,6 @@ def _cache_key(*parts) -> str:
return hashlib.md5(raw.encode()).hexdigest() return hashlib.md5(raw.encode()).hexdigest()
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
@router.get("/summary") @router.get("/summary")

View File

@@ -24,9 +24,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import sessionmaker from bd import get_session
from bd import make_engine
from bd.tables.organization import Organization from bd.tables.organization import Organization
from bd.tables.group import Group from bd.tables.group import Group
from bd.tables.poll import Poll from bd.tables.poll import Poll
@@ -61,8 +59,6 @@ _TABLES: list[tuple[str, object]] = [
] ]
def _get_session():
return sessionmaker(bind=make_engine(), future=True)
def _row_to_dict(row) -> dict: def _row_to_dict(row) -> dict:
@@ -89,7 +85,7 @@ def _row_to_dict(row) -> dict:
dependencies=[Depends(require_admin_key)], dependencies=[Depends(require_admin_key)],
) )
def export_data(): def export_data():
Session = _get_session() Session = get_session()
buf = io.BytesIO() buf = io.BytesIO()
counts = {} counts = {}
@@ -147,7 +143,7 @@ def import_data(file: UploadFile = File(...)):
if fmt not in (EXPORT_FORMAT_VERSION, "1.0"): if fmt not in (EXPORT_FORMAT_VERSION, "1.0"):
raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}") raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}")
Session = _get_session() Session = get_session()
result = {"inserted": {}, "skipped": {}} result = {"inserted": {}, "skipped": {}}
with Session() as session: with Session() as session:
@@ -218,7 +214,7 @@ backup_router = APIRouter(prefix="/backup", tags=["backup"])
) )
def backup_export(): def backup_export():
"""Идентично /transfer/export, но в имени файла указан префикс backup_.""" """Идентично /transfer/export, но в имени файла указан префикс backup_."""
Session = _get_session() Session = get_session()
buf = io.BytesIO() buf = io.BytesIO()
counts = {} counts = {}
@@ -279,7 +275,7 @@ def backup_restore(file: UploadFile = File(...)):
if fmt not in (EXPORT_FORMAT_VERSION, "1.0"): if fmt not in (EXPORT_FORMAT_VERSION, "1.0"):
raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}") raise HTTPException(status_code=400, detail=f"Неподдерживаемая версия формата: {fmt}")
Session = _get_session() Session = get_session()
deleted_counts: dict[str, int] = {} deleted_counts: dict[str, int] = {}
inserted_counts: dict[str, int] = {} inserted_counts: dict[str, int] = {}
@@ -313,7 +309,11 @@ def backup_restore(file: UploadFile = File(...)):
# 3. SVG-диаграммы: полностью очищаем каталог и восстанавливаем # 3. SVG-диаграммы: полностью очищаем каталог и восстанавливаем
svg_names = [n for n in zf.namelist() if n.startswith("radar_svgs/") and n != "radar_svgs/"] svg_names = [n for n in zf.namelist() if n.startswith("radar_svgs/") and n != "radar_svgs/"]
if svg_names and _RADAR_DIR.exists(): if svg_names and _RADAR_DIR.exists():
shutil.rmtree(_RADAR_DIR) for child in _RADAR_DIR.iterdir():
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
_RADAR_DIR.mkdir(parents=True, exist_ok=True) _RADAR_DIR.mkdir(parents=True, exist_ok=True)
svg_written = 0 svg_written = 0

View File

@@ -3,7 +3,7 @@ from pydantic import BaseModel, field_validator
from typing import Optional from typing import Optional
import uuid import uuid
from bd import make_engine from bd import get_session
from bd.tables.users import User from bd.tables.users import User
from route.auth_utils import get_current_user, require_admin_key, hash_password from route.auth_utils import get_current_user, require_admin_key, hash_password
@@ -42,9 +42,6 @@ class UserUpdate(BaseModel):
organization_id: Optional[str] = None organization_id: Optional[str] = None
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
def _user_dict(user: User) -> dict: def _user_dict(user: User) -> dict:

View File

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

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

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

14
stats-react/Dockerfile Normal file
View File

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

View File

Before

Width:  |  Height:  |  Size: 851 B

After

Width:  |  Height:  |  Size: 851 B

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,209 +0,0 @@
"""
api_client.py — HTTP-клиент для stats-приложения.
Запросы идут к основному API без авторизации.
"""
import os
from typing import Optional
import httpx
API_URL = os.getenv("API_URL", "http://api:8000")
_TIMEOUT = 15
async def get_summary() -> dict:
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/summary")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return {"total_responses": 0, "total_polls": 0, "active_polls": 0, "orgs_active": 0}
async def get_years() -> list[int]:
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/years")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_chart_data(
group_by: str,
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
) -> list[dict]:
params: dict = {"group_by": group_by}
if poll_id:
params["poll_id"] = poll_id
if org_id:
params["org_id"] = org_id
if group_id:
params["group_id"] = group_id
if year:
params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/responses", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_polls() -> list[dict]:
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/polls/")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_organizations() -> list[dict]:
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/organizations/")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_groups() -> list[dict]:
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/groups/")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_dimensions(poll_id: Optional[str] = None) -> list[str]:
params = {}
if poll_id:
params["poll_id"] = poll_id
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/dimensions", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_avg_by_dimension(
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
) -> list[dict]:
params = {}
if poll_id: params["poll_id"] = poll_id
if org_id: params["org_id"] = org_id
if group_id: params["group_id"] = group_id
if year: params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/avg-by-dimension", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_leading_type(
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
) -> list[dict]:
params = {}
if poll_id: params["poll_id"] = poll_id
if org_id: params["org_id"] = org_id
if group_id: params["group_id"] = group_id
if year: params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/leading-type", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_compare_groups(
dimension_name: str,
by: str = "organization",
poll_id: Optional[str] = None,
year: Optional[int] = None,
) -> list[dict]:
params = {"dimension_name": dimension_name, "by": by}
if poll_id: params["poll_id"] = poll_id
if year: params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/compare-groups", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async 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 = 10,
) -> list[dict]:
params = {"dimension_name": dimension_name, "limit": limit}
if poll_id: params["poll_id"] = poll_id
if org_id: params["org_id"] = org_id
if group_id: params["group_id"] = group_id
if year: params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/top-users", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_dimension_trend(
dimension_name: str,
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
) -> list[dict]:
params = {"dimension_name": dimension_name}
if poll_id: params["poll_id"] = poll_id
if org_id: params["org_id"] = org_id
if group_id: params["group_id"] = group_id
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/dimension-trend", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -1,3 +0,0 @@
# Положи сюда своё изображение с именем loading-animation.png
# Это заменит мигающий логотип Flet при загрузке страницы.
# Рекомендуемый размер: PNG, минимум 200x200 px.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,745 +0,0 @@
"""
dashboard.py — главный вид статистического дашборда.
Графики строятся через matplotlib и отображаются как изображения (ft.Image).
"""
import asyncio
import base64
import io
import math
import flet as ft
import matplotlib
import matplotlib.colors
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
import designer as ds
import api_client as ac
matplotlib.use("Agg") # без GUI, только в память
# Цвета графиков
_PALETTE = [
"#F85A40", "#007FBD", "#85C446", "#FFC845",
"#D94530", "#5A6779", "#006AA3", "#4A9E21",
]
def _wrap_label(text: str, max_len: int = 18) -> str:
"""Перенос длинного текста через \n для подписей осей."""
if len(text) <= max_len:
return text
words = text.split()
lines, line = [], ""
for w in words:
if len(line) + len(w) + 1 <= max_len:
line = f"{line} {w}".strip()
else:
if line:
lines.append(line)
line = w
if line:
lines.append(line)
return "\n".join(lines)
def _build_chart_image(data: list[dict], title: str, color: str) -> bytes | None:
"""Строит горизонтальную bar chart через matplotlib, возвращает PNG bytes."""
if not data:
return None
labels = [_wrap_label(d["label"]) for d in data]
counts = [d["count"] for d in data]
n = len(labels)
fig_h = max(4.5, n * 0.55 + 1.2)
fig, ax = plt.subplots(figsize=(10, fig_h))
fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
bars = ax.barh(
range(n), counts,
color=color,
height=0.6,
edgecolor="white",
linewidth=0.5,
)
# Подписи значений
for bar, v in zip(bars, counts):
ax.text(
v + max(counts) * 0.01,
bar.get_y() + bar.get_height() / 2,
str(v),
va="center",
ha="left",
fontsize=11,
color="#1E2A3A",
)
ax.set_yticks(range(n))
ax.set_yticklabels(labels, fontsize=11, color="#5A6779")
ax.invert_yaxis()
ax.set_xlabel("Количество ответов", fontsize=11, color="#5A6779")
ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA")
ax.spines["bottom"].set_color("#DDE3EA")
ax.set_xlim(0, max(counts) * 1.18 if max(counts) > 0 else 1)
ax.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=1.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _build_line_chart_image(data: list[dict]) -> bytes | None:
"""Строит линейный график для данных по месяцам."""
if not data:
return None
labels = [d["label"] for d in data]
counts = [d["count"] for d in data]
n = len(labels)
fig, ax = plt.subplots(figsize=(max(10, n * 1.2), 5.5))
fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
ax.plot(range(n), counts, color="#F85A40", linewidth=2.5, marker="o",
markersize=7, markerfacecolor="#F85A40", markeredgecolor="white",
markeredgewidth=1.5)
ax.fill_between(range(n), counts, alpha=0.12, color="#F85A40")
for i, v in enumerate(counts):
ax.text(i, v + max(counts) * 0.03, str(v), ha="center",
fontsize=11, color="#1E2A3A")
ax.set_xticks(range(n))
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779")
ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.set_ylabel("Ответов", fontsize=11, color="#5A6779")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA")
ax.spines["bottom"].set_color("#DDE3EA")
ax.set_ylim(0, max(counts) * 1.25)
ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=1.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _build_avg_bar(data: list[dict], xlabel: str = "Средний балл") -> bytes | None:
"""Горизонтальный bar chart со средними баллами (поле 'avg')."""
if not data:
return None
labels = [_wrap_label(d["label"]) for d in data]
values = [d["avg"] for d in data]
n = len(labels)
colors = [_PALETTE[i % len(_PALETTE)] for i in range(n)]
fig_h = max(5.5, n * 0.65 + 1.5)
fig, ax = plt.subplots(figsize=(14, fig_h))
fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
bars = ax.barh(range(n), values, color=colors, height=0.6, edgecolor="white", linewidth=0.5)
for bar, v in zip(bars, values):
ax.text(v + max(values) * 0.01, bar.get_y() + bar.get_height() / 2,
f"{v:.1f}", va="center", ha="left", fontsize=11, color="#1E2A3A")
ax.set_yticks(range(n))
ax.set_yticklabels(labels, fontsize=11, color="#5A6779")
ax.invert_yaxis()
ax.set_xlabel(xlabel, fontsize=11, color="#5A6779")
ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA")
ax.spines["bottom"].set_color("#DDE3EA")
ax.set_xlim(0, max(values) * 1.18 if max(values) > 0 else 1)
ax.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=1.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _build_pie_chart(data: list[dict]) -> bytes | None:
"""Круговая диаграмма распределения ведущего типа."""
if not data:
return None
labels = [d["label"] for d in data]
values = [d["count"] for d in data]
n_colors = len(labels)
if n_colors <= len(_PALETTE):
colors = _PALETTE[:n_colors]
else:
# Генерируем уникальные цвета через hsv colormap
cmap = matplotlib.colormaps["hsv"]
colors = [matplotlib.colors.to_hex(cmap(i / n_colors)) for i in range(n_colors)]
# Размер фигуры растёт с числом элементов для читаемой легенды
legend_rows = math.ceil(n_colors / 2)
fig_h = max(7, legend_rows * 0.35 + 5)
fig, ax = plt.subplots(figsize=(11, fig_h))
fig.patch.set_facecolor("#FFFFFF")
wedges, texts, autotexts = ax.pie(
values,
labels=None,
autopct="%1.1f%%",
colors=colors,
startangle=140,
wedgeprops=dict(edgecolor="white", linewidth=1.5),
pctdistance=0.78,
)
for t in autotexts:
t.set_fontsize(9 if n_colors > 15 else 10)
t.set_color("#1E2A3A")
ncol = 3 if n_colors > 20 else 2
ax.legend(
wedges,
[f"{l} ({v})" for l, v in zip(labels, values)],
loc="lower center",
bbox_to_anchor=(0.5, -0.05 - legend_rows * 0.04),
ncol=ncol,
fontsize=9,
frameon=False,
)
plt.tight_layout(pad=1.5)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _build_avg_line(data: list[dict]) -> bytes | None:
"""Линейный тренд средних баллов по месяцам (поле 'avg')."""
if not data:
return None
labels = [d["label"] for d in data]
values = [d["avg"] for d in data]
n = len(labels)
fig, ax = plt.subplots(figsize=(max(10, n * 1.2), 5.5))
fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
ax.plot(range(n), values, color="#F85A40", linewidth=2.5, marker="o",
markersize=7, markerfacecolor="#F85A40", markeredgecolor="white",
markeredgewidth=1.5)
ax.fill_between(range(n), values, alpha=0.12, color="#F85A40")
for i, v in enumerate(values):
ax.text(i, v + max(values) * 0.03, f"{v:.1f}", ha="center", fontsize=11, color="#1E2A3A")
ax.set_xticks(range(n))
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779")
ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.set_ylabel("Средний балл", fontsize=11, color="#5A6779")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA")
ax.spines["bottom"].set_color("#DDE3EA")
ax.set_ylim(0, max(values) * 1.25 if max(values) > 0 else 1)
ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=1.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _image_or_placeholder(img_bytes: bytes | None, width: int = 1100) -> ft.Control:
if img_bytes:
b64 = base64.b64encode(img_bytes).decode()
return ft.Image(
src=f"data:image/png;base64,{b64}",
fit=ft.BoxFit.FIT_WIDTH,
width=width,
)
return ft.Container(
content=ft.Text("Нет данных", color=ds.colors.text_secondary, size=ds.s(13), italic=True),
alignment=ft.Alignment(0, 0),
height=ds.s(120),
width=width,
)
class DashboardView(ft.Column):
def __init__(self, page: ft.Page):
self._page = page
# ── Общие фильтры ────────────────────────────────────────────────────
self._poll_dd = ds.SearchableDropdown(label="Тест", hint_text="Все тесты")
self._poll_dd.on_select = self._on_filter_change
self._org_dd = ds.SearchableDropdown(label="Организация", hint_text="Все организации")
self._org_dd.on_select = self._on_filter_change
self._group_dd = ds.SearchableDropdown(label="Группа", hint_text="Все группы")
self._group_dd.on_select = self._on_filter_change
self._year_dd = ds.SearchableDropdown(label="Год", hint_text="Все годы")
self._year_dd.on_select = self._on_filter_change
self._reset_btn = ft.TextButton(
"Сбросить фильтры",
icon=ft.Icons.FILTER_ALT_OFF,
style=ft.ButtonStyle(color=ds.colors.primary),
on_click=self._reset_filters,
)
self._refresh_btn = ft.FilledButton(
"Обновить",
icon=ft.Icons.REFRESH,
style=ft.ButtonStyle(
bgcolor={ft.ControlState.DEFAULT: ds.colors.primary,
ft.ControlState.HOVERED: ds.colors.primary_dark},
color=ds.colors.text_on_primary,
shape=ft.RoundedRectangleBorder(radius=ds.s(8)),
),
on_click=lambda e: page.run_task(self._refresh_current_tab),
)
# ── Summary карточки ─────────────────────────────────────────────────
self._card_total = ft.Text("", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
self._card_polls = ft.Text("", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
self._card_active = ft.Text("", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
self._card_orgs = ft.Text("", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
# ── Таб 1: графики по прохождениям ───────────────────────────────────
self._chart_poll = self._make_chart_box()
self._chart_org = self._make_chart_box()
self._chart_group = self._make_chart_box()
self._chart_month = self._make_chart_box()
# ── Таб 2: результаты — фильтр по оси ────────────────────────────────
self._dim_dd = ds.SearchableDropdown(label="Ось/Тип", hint_text="Выберите ось")
self._dim_dd.on_select = self._on_results_filter_change
self._compare_by_dd = ds.SearchableDropdown(label="Сравнение по", hint_text="Организациям")
self._compare_by_dd.options = [
ft.DropdownOption(key="organization", text="Организациям"),
ft.DropdownOption(key="group", text="Группам"),
]
self._compare_by_dd.value = "organization"
self._compare_by_dd.on_select = self._on_results_filter_change
self._chart_avg_dim = self._make_chart_box()
self._chart_leading = self._make_chart_box()
self._chart_compare = self._make_chart_box()
self._chart_trend = self._make_chart_box()
# Таблица топ-пользователей
self._top_table = ft.DataTable(
columns=[
ft.DataColumn(ft.Text("Участник", size=ds.s(12), weight=ft.FontWeight.W_600)),
ft.DataColumn(ft.Text("Тест", size=ds.s(12), weight=ft.FontWeight.W_600)),
ft.DataColumn(ft.Text("Балл", size=ds.s(12), weight=ft.FontWeight.W_600), numeric=True),
],
rows=[],
border=ft.border.all(1, ds.colors.border),
border_radius=ds.s(8),
horizontal_lines=ft.BorderSide(1, ds.colors.border),
column_spacing=ds.s(20),
)
# ── Навигация по табам ────────────────────────────────────────────────
self._current_tab = 0
# ── Содержимое табов ──────────────────────────────────────────────────
self._tab1_content = ft.Column(
spacing=ds.s(16),
controls=[
ft.Container(
content=self._chart_card_wrap("По тестам", self._chart_poll),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("По организациям", self._chart_org),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("По группам", self._chart_group),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("Динамика по месяцам", self._chart_month),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
margin=ft.margin.only(bottom=ds.s(24)),
),
],
)
self._tab2_content = ft.Column(
spacing=ds.s(16),
controls=[
# Дополнительный фильтр по оси
ft.Container(
content=ft.Column(
spacing=ds.s(8),
controls=[
ft.Text("Фильтр по оси/типу", color=ds.colors.text_secondary,
size=ds.s(12), weight=ft.FontWeight.W_600),
ft.Row(
wrap=True, spacing=ds.s(10), run_spacing=ds.s(10),
controls=[self._dim_dd, self._compare_by_dd],
),
],
),
bgcolor=ds.colors.surface,
border_radius=ds.s(12),
padding=ft.padding.all(ds.s(16)),
margin=ft.margin.symmetric(horizontal=ds.s(16)),
border=ft.border.all(1, ds.colors.border),
),
ft.Container(
content=self._chart_card_wrap("Средний балл по осям", self._chart_avg_dim),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("Распределение ведущего типа", self._chart_leading),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("Сравнение по группам/организациям", self._chart_compare),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("Динамика среднего балла", self._chart_trend),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
# Топ участников
ft.Container(
content=ft.Column(
spacing=ds.s(10),
controls=[
ft.Text("Топ-10 участников по выбранной оси",
color=ds.colors.text_primary, size=ds.s(14),
weight=ft.FontWeight.W_600),
ft.Container(content=self._top_table, expand=True),
],
),
bgcolor=ds.colors.surface,
border_radius=ds.s(12),
padding=ft.padding.all(ds.s(16)),
margin=ft.margin.only(left=ds.s(16), right=ds.s(16), bottom=ds.s(24)),
border=ft.border.all(1, ds.colors.border),
),
],
)
# ── Tabs (TabBar + ручное переключение) ──────────────────────────────
self._tab_body = ft.Container(content=self._tab1_content, expand=True)
self._tabs_ctrl = ft.Tabs(
content=ft.Column(
expand=True,
spacing=0,
controls=[
ft.TabBar(
tabs=[
ft.Tab(label="Прохождения", icon=ft.Icons.BAR_CHART),
ft.Tab(label="Результаты тестов", icon=ft.Icons.INSIGHTS),
],
scrollable=False,
),
self._tab_body,
],
),
length=2,
selected_index=0,
on_change=self._on_tab_change,
expand=True,
)
super().__init__(
spacing=ds.s(16),
scroll=ft.ScrollMode.AUTO,
expand=True,
controls=[
# Заголовок
ft.Container(
content=ft.Row(
controls=[
ft.Text("ЦОПП", color=ds.colors.primary, size=ds.s(22),
weight=ft.FontWeight.BOLD),
ft.VerticalDivider(width=ds.s(16), color=ds.colors.border),
ft.Text("Статистика прохождений", color=ds.colors.text_primary,
size=ds.s(16)),
],
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
bgcolor=ds.colors.surface,
padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(14)),
border=ft.border.only(bottom=ft.BorderSide(1, ds.colors.border)),
),
# Summary карточки
ft.Container(
content=ft.Row(
wrap=True, spacing=ds.s(12), run_spacing=ds.s(12),
controls=[
self._summary_tile("Всего ответов", self._card_total,
ft.Icons.ASSIGNMENT_TURNED_IN, ds.colors.primary),
self._summary_tile("Тестов всего", self._card_polls,
ft.Icons.QUIZ, ds.colors.info),
self._summary_tile("Активных тестов", self._card_active,
ft.Icons.PLAY_CIRCLE, ds.colors.success),
self._summary_tile("Организаций с ответами", self._card_orgs,
ft.Icons.BUSINESS, ds.colors.accent),
],
),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
# Общие фильтры
ft.Container(
content=ft.Column(
spacing=ds.s(10),
controls=[
ft.Text("Фильтры", color=ds.colors.text_secondary,
size=ds.s(12), weight=ft.FontWeight.W_600),
ft.Row(
wrap=True, spacing=ds.s(10), run_spacing=ds.s(10),
controls=[self._poll_dd, self._org_dd,
self._group_dd, self._year_dd],
),
ft.Row(spacing=ds.s(8),
controls=[self._reset_btn, self._refresh_btn]),
],
),
bgcolor=ds.colors.surface,
border_radius=ds.s(12),
padding=ft.padding.all(ds.s(16)),
margin=ft.margin.symmetric(horizontal=ds.s(16)),
border=ft.border.all(1, ds.colors.border),
),
# Вкладки
self._tabs_ctrl,
],
)
# ── helpers ─────────────────────────────────────────────────────────────
def _make_chart_box(self) -> ft.Container:
return ft.Container(
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)),
alignment=ft.Alignment(0, 0),
expand=True,
)
def _summary_tile(self, title: str, value_text: ft.Text, icon: str, color: str) -> ft.Container:
return ft.Container(
content=ft.Column(
spacing=ds.s(4), tight=True,
controls=[
ft.Row(spacing=ds.s(6), controls=[
ft.Icon(icon, color=color, size=ds.s(18)),
ft.Text(title, color=ds.colors.text_secondary, size=ds.s(11)),
]),
value_text,
],
),
bgcolor=ds.colors.surface,
border_radius=ds.s(10),
padding=ft.padding.all(ds.s(14)),
border=ft.border.all(1, ds.colors.border),
width=ds.s(200),
)
def _chart_card_wrap(self, title: str, body: ft.Container) -> ft.Container:
return ft.Container(
content=ft.Column(
spacing=ds.s(10), tight=True,
controls=[
ft.Text(title, color=ds.colors.text_primary, size=ds.s(14),
weight=ft.FontWeight.W_600),
body,
],
),
bgcolor=ds.colors.surface,
border_radius=ds.s(12),
padding=ft.padding.all(ds.s(16)),
border=ft.border.all(1, ds.colors.border),
expand=True,
)
# ── mounting ─────────────────────────────────────────────────────────────
def _chart_width(self) -> int:
"""Ширина графика = ширина страницы минус отступы."""
try:
w = self.page.width or 1200
except Exception:
w = 1200
return max(600, int(w) - 80)
def did_mount(self):
self.page.run_task(self._initial_load)
async def _initial_load(self):
polls, orgs, groups, years, summary, dimensions = await asyncio.gather(
ac.get_polls(),
ac.get_organizations(),
ac.get_groups(),
ac.get_years(),
ac.get_summary(),
ac.get_dimensions(),
)
_dd_opt = lambda key, text: ft.DropdownOption(key=key, text=text)
self._poll_dd.options = [_dd_opt(p["id"], p["title"]) for p in (polls or [])]
self._org_dd.options = [_dd_opt(o["id"], o["name_organization"]) for o in (orgs or [])]
self._group_dd.options = [_dd_opt(g["id"], g["name_group"]) for g in (groups or [])]
self._year_dd.options = [_dd_opt(str(y), str(y)) for y in (years or [])]
self._dim_dd.options = [_dd_opt(d, d) for d in (dimensions or [])]
if dimensions:
self._dim_dd.value = dimensions[0]
self._card_total.value = str(summary.get("total_responses", ""))
self._card_polls.value = str(summary.get("total_polls", ""))
self._card_active.value = str(summary.get("active_polls", ""))
self._card_orgs.value = str(summary.get("orgs_active", ""))
self.update()
await self._refresh_tab1()
await self._refresh_tab2()
# ── tab switching ─────────────────────────────────────────────────────────
def _on_tab_change(self, e):
self._current_tab = int(e.data)
self._tab_body.content = self._tab1_content if self._current_tab == 0 else self._tab2_content
self._tab_body.update()
async def _refresh_current_tab(self):
if self._current_tab == 0:
await self._refresh_tab1()
else:
await self._refresh_tab2()
# ── filters ───────────────────────────────────────────────────────────────
def _on_filter_change(self, e):
self.page.run_task(self._refresh_current_tab)
def _on_results_filter_change(self, e):
self.page.run_task(self._refresh_tab2)
def _reset_filters(self, e):
self._poll_dd.value = None
self._org_dd.value = None
self._group_dd.value = None
self._year_dd.value = None
self.update()
self.page.run_task(self._refresh_current_tab)
def _get_filters(self) -> dict:
year_str = self._year_dd.value
return {
"poll_id": self._poll_dd.value,
"org_id": self._org_dd.value,
"group_id": self._group_dd.value,
"year": int(year_str) if year_str else None,
}
# ── TAB 1: прохождения ───────────────────────────────────────────────────
async def _refresh_tab1(self):
f = self._get_filters()
spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24))
for c in [self._chart_poll, self._chart_org, self._chart_group, self._chart_month]:
c.content = spinner()
self.update()
d_poll, d_org, d_group, d_month = await asyncio.gather(
ac.get_chart_data("poll", **f),
ac.get_chart_data("organization", **f),
ac.get_chart_data("group", **f),
ac.get_chart_data("month", **f),
)
loop = asyncio.get_event_loop()
img_poll, img_org, img_group, img_month = await asyncio.gather(
loop.run_in_executor(None, _build_chart_image, d_poll, "По тестам", "#F85A40"),
loop.run_in_executor(None, _build_chart_image, d_org, "По организациям", "#007FBD"),
loop.run_in_executor(None, _build_chart_image, d_group, "По группам", "#85C446"),
loop.run_in_executor(None, _build_line_chart_image, d_month),
)
w = self._chart_width()
self._chart_poll.content = _image_or_placeholder(img_poll, w)
self._chart_org.content = _image_or_placeholder(img_org, w)
self._chart_group.content = _image_or_placeholder(img_group, w)
self._chart_month.content = _image_or_placeholder(img_month, w)
self.update()
# ── TAB 2: результаты ────────────────────────────────────────────────────
async def _refresh_tab2(self):
f = self._get_filters()
dim = self._dim_dd.value
by = self._compare_by_dd.value or "organization"
spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24))
for c in [self._chart_avg_dim, self._chart_leading,
self._chart_compare, self._chart_trend]:
c.content = spinner()
self._top_table.rows = []
self.update()
# Параллельная загрузка
d_avg, d_lead, d_top = await asyncio.gather(
ac.get_avg_by_dimension(**f),
ac.get_leading_type(**f),
ac.get_top_users(dim, **f) if dim else asyncio.sleep(0),
)
if not dim:
d_top = []
d_compare = await ac.get_compare_groups(dim or "", by=by,
poll_id=f["poll_id"],
year=f["year"]) if dim else []
d_trend = await ac.get_dimension_trend(dim or "",
poll_id=f["poll_id"],
org_id=f["org_id"],
group_id=f["group_id"]) if dim else []
loop = asyncio.get_event_loop()
img_avg, img_lead, img_compare, img_trend = await asyncio.gather(
loop.run_in_executor(None, _build_avg_bar, d_avg, "Средний балл"),
loop.run_in_executor(None, _build_pie_chart, d_lead),
loop.run_in_executor(None, _build_avg_bar, d_compare,
f"Средний балл по {'организациям' if by == 'organization' else 'группам'}"),
loop.run_in_executor(None, _build_avg_line, d_trend),
)
w = self._chart_width()
self._chart_avg_dim.content = _image_or_placeholder(img_avg, w)
self._chart_leading.content = _image_or_placeholder(img_lead, w)
self._chart_compare.content = _image_or_placeholder(img_compare, w)
self._chart_trend.content = _image_or_placeholder(img_trend, w)
# Таблица топ
self._top_table.rows = [
ft.DataRow(cells=[
ft.DataCell(ft.Text(row["name"], size=ds.s(12))),
ft.DataCell(ft.Text(row["poll"], size=ds.s(11),
color=ds.colors.text_secondary,
overflow=ft.TextOverflow.ELLIPSIS,
max_lines=1)),
ft.DataCell(ft.Text(str(row["value"]), size=ds.s(12),
weight=ft.FontWeight.BOLD,
color=ds.colors.primary)),
])
for row in (d_top or [])
]
self.update()

View File

@@ -1,223 +0,0 @@
"""
designer.py для stats-приложения.
Та же палитра и компоненты что в web/designer.py, адаптированные для дашборда.
"""
import flet as ft
SCALE: float = 1.0
def s(v: int | float) -> int:
return round(v * SCALE)
class colors:
primary = "#F85A40"
primary_dark = "#D94530"
primary_light = "#FFEAB7"
background = "#F4F6F8"
surface = "#FFFFFF"
surface_alt = "#F0F2F5"
accent = "#FFC845"
info = "#007FBD"
success = "#85C446"
text_primary = "#1E2A3A"
text_secondary = "#5A6779"
text_on_primary = "#FFFFFF"
border = "#DDE3EA"
class SearchableDropdown(ft.Container):
"""Выпадающий список с поисковой строкой."""
def __init__(self, label: str = "", hint_text: str = "", options=None, on_select=None, width: int | None = None):
self._label_str = label
self._hint_str = hint_text
self._options: list = options or []
self.on_select = on_select
self._value = None
self._value_text = ft.Text(
hint_text,
color=colors.border,
size=s(14),
italic=True,
expand=True,
no_wrap=True,
overflow=ft.TextOverflow.ELLIPSIS,
)
super().__init__(
content=ft.Column(
spacing=s(2),
tight=True,
controls=[
ft.Text(label, color=colors.text_secondary, size=s(12), weight=ft.FontWeight.W_500),
ft.Row(
controls=[
self._value_text,
ft.Icon(ft.Icons.ARROW_DROP_DOWN, color=colors.text_secondary, size=s(20)),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
],
),
bgcolor=colors.surface,
border=ft.border.all(1, colors.border),
border_radius=s(10),
padding=ft.padding.symmetric(horizontal=s(12), vertical=s(10)),
on_click=self._open_dialog,
ink=True,
width=width or s(220),
)
@property
def value(self):
return self._value
@value.setter
def value(self, val):
self._value = val
if val is None:
self._value_text.value = self._hint_str
self._value_text.color = colors.border
self._value_text.italic = True
else:
for opt in self._options:
if opt.key == val:
self._value_text.value = opt.text
self._value_text.color = colors.text_primary
self._value_text.italic = False
break
try:
if self.page:
self.update()
except RuntimeError:
pass
@property
def options(self):
return self._options
@options.setter
def options(self, opts):
self._options = opts or []
@property
def hint_text(self):
return self._hint_str
@hint_text.setter
def hint_text(self, val):
self._hint_str = val
if self._value is None:
self._value_text.value = val
if self.page:
self.update()
def _open_dialog(self, e):
search_field = ft.TextField(
label="Поиск",
hint_text="Введите для поиска...",
prefix_icon=ft.Icons.SEARCH,
autofocus=True,
filled=True,
bgcolor=colors.surface,
border_color=colors.border,
focused_border_color=colors.primary,
border_radius=s(8),
text_style=ft.TextStyle(color=colors.text_primary, size=s(14)),
)
items_list = ft.ListView(spacing=0, expand=True)
def _build_items(query: str = ""):
q = query.lower()
items_list.controls = [
ft.ListTile(
title=ft.Text(opt.text, color=colors.text_primary, size=s(13)),
hover_color=colors.primary_light,
on_click=lambda ev, o=opt: _select(o),
)
for opt in self._options
if not q or q in opt.text.lower()
]
def _on_search(ev):
_build_items(search_field.value or "")
items_list.update()
def _select(option):
self._value = option.key
self._value_text.value = option.text
self._value_text.color = colors.text_primary
self._value_text.italic = False
dlg.open = False
self.page.update()
self.update()
if self.on_select:
self.on_select(type("_E", (), {"control": self})())
search_field.on_change = _on_search
_build_items()
dlg = ft.AlertDialog(
title=ft.Text(self._label_str, color=colors.text_primary, size=s(15), weight=ft.FontWeight.W_600),
content=ft.Container(
content=ft.Column(
controls=[search_field, ft.Divider(color=colors.border), items_list],
spacing=s(8),
tight=True,
),
height=s(320),
width=s(380),
),
bgcolor=colors.surface,
shape=ft.RoundedRectangleBorder(radius=s(12)),
)
self.page.overlay.append(dlg)
dlg.open = True
self.page.update()
def stat_card(title: str, value: str, icon: str, color: str) -> ft.Container:
"""Карточка с одной цифрой для summary-панели."""
return ft.Container(
content=ft.Column(
spacing=s(6),
tight=True,
controls=[
ft.Row(
spacing=s(8),
controls=[
ft.Icon(icon, color=color, size=s(20)),
ft.Text(title, color=colors.text_secondary, size=s(12)),
],
),
ft.Text(value, color=colors.text_primary, size=s(28), weight=ft.FontWeight.BOLD),
],
),
bgcolor=colors.surface,
border_radius=s(12),
padding=ft.padding.all(s(16)),
border=ft.border.all(1, colors.border),
expand=True,
)
def chart_card(title: str, body: ft.Control) -> ft.Container:
"""Обёртка-карточка для графика."""
return ft.Container(
content=ft.Column(
spacing=s(12),
controls=[
ft.Text(title, color=colors.text_primary, size=s(15), weight=ft.FontWeight.W_600),
body,
],
),
bgcolor=colors.surface,
border_radius=s(12),
padding=ft.padding.all(s(16)),
border=ft.border.all(1, colors.border),
expand=True,
)

View File

@@ -1,19 +0,0 @@
import flet as ft
import designer as ds
from dashboard import DashboardView
from starlette.applications import Starlette
from starlette.routing import Mount
async def main(page: ft.Page):
page.title = "ЦОПП — Статистика"
page.bgcolor = ds.colors.background
page.padding = ft.padding.all(0)
page.scroll = ft.ScrollMode.HIDDEN
view = DashboardView(page)
page.add(view)
_flet_app = ft.run(main, export_asgi_app=True, assets_dir="assets")
app = Starlette(routes=[Mount("/", _flet_app)])

102
uv.lock generated
View File

@@ -37,15 +37,18 @@ name = "api-copp"
version = "0.3.0" version = "0.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "flet" }, { name = "flet" },
{ name = "jose" }, { name = "jose" },
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "passlib" }, { name = "passlib", extra = ["bcrypt"] },
{ name = "pg8000" }, { name = "pg8000" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-docx" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-jose" }, { name = "python-jose" },
{ name = "python-multipart" },
{ name = "redis" }, { name = "redis" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
@@ -61,18 +64,21 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "bcrypt", specifier = "==4.0.1" },
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
{ name = "fastapi", specifier = ">=0.128.0" }, { name = "fastapi", specifier = ">=0.128.0" },
{ name = "flet", specifier = ">=0.82.2" }, { name = "flet", specifier = ">=0.82.2" },
{ name = "jose", specifier = ">=1.0.0" }, { name = "jose", specifier = ">=1.0.0" },
{ name = "matplotlib", specifier = ">=3.10.8" }, { name = "matplotlib", specifier = ">=3.10.8" },
{ name = "passlib", specifier = ">=1.7.4" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "pg8000", specifier = ">=1.29.0" }, { name = "pg8000", specifier = ">=1.29.0" },
{ name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic", specifier = ">=2.12.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.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-dotenv", specifier = ">=1.2.1" },
{ name = "python-jose", specifier = ">=3.5.0" }, { name = "python-jose", specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "redis", specifier = ">=7.1.0" }, { name = "redis", specifier = ">=7.1.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
{ name = "sqlalchemy", specifier = ">=2.0.47" }, { name = "sqlalchemy", specifier = ">=2.0.47" },
@@ -89,6 +95,25 @@ 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" }, { 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]] [[package]]
name = "black" name = "black"
version = "26.1.0" version = "26.1.0"
@@ -259,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/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/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/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/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/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" }, { 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" },
@@ -268,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/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/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/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/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/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" }, { 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" },
@@ -389,6 +412,50 @@ wheels = [
{ 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" }, { 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]] [[package]]
name = "matplotlib" name = "matplotlib"
version = "3.10.8" version = "3.10.8"
@@ -513,6 +580,11 @@ 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" }, { 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]] [[package]]
name = "pathspec" name = "pathspec"
version = "1.0.4" version = "1.0.4"
@@ -707,6 +779,19 @@ 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" }, { 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]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.1" version = "1.2.1"
@@ -730,6 +815,15 @@ 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" }, { 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]] [[package]]
name = "pytokens" name = "pytokens"
version = "0.4.1" version = "0.4.1"

6
web-react/.dockerignore Normal file
View File

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

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

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

14
web-react/Dockerfile Normal file
View File

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

View File

Before

Width:  |  Height:  |  Size: 851 B

After

Width:  |  Height:  |  Size: 851 B

View File

Before

Width:  |  Height:  |  Size: 737 B

After

Width:  |  Height:  |  Size: 737 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}

1
web-react/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

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

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

18
web-react/vite.config.ts Normal file
View File

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

View File

@@ -1,300 +0,0 @@
"""
api_client.py — единое место для всех HTTP-запросов к бэкенду.
Не зависит от Flet. Все функции async, возвращают данные или None/ошибку.
"""
import os
from typing import Any
from urllib.parse import urlparse
import httpx
API_URL = os.getenv("API_URL", "http://api:8000")
_TIMEOUT = 10
def build_radar_download_url(page_url: str, response_id: str) -> str:
"""
Строит URL для скачивания SVG-диаграммы через веб-сервер (/radar-image/{id}).
page_url — значение page.url из Flet (может быть ws:// или wss://).
"""
parsed = urlparse(page_url)
scheme = "https" if parsed.scheme in ("wss", "https") else "http"
port = parsed.port
port_str = f":{port}" if port and port not in (80, 443) else ""
return f"{scheme}://{parsed.hostname}{port_str}/radar-image/{response_id}"
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
async def login(username: str, password: str) -> tuple[str | None, str | None]:
"""
POST /auth/login
Возвращает (token, None) при успехе или (None, error_message) при ошибке.
"""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.post(
"/auth/login",
data={"username": username, "password": password},
)
if resp.status_code == 200:
return resp.json()["access_token"], None
elif resp.status_code == 401:
return None, "Неверный логин или пароль"
return None, f"Ошибка сервера: {resp.status_code}"
except httpx.ConnectError:
return None, "Не удалось подключиться к серверу"
except Exception as e:
return None, f"Ошибка: {e}"
async def logout(token: str) -> None:
"""POST /auth/logout — инвалидирует токен на сервере (Redis блэклист)."""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
await client.post(
"/auth/logout",
headers={"Authorization": f"Bearer {token}"},
)
except Exception:
pass # даже если сервер недоступен — локальный logout всё равно произойдёт
async def get_me(token: str) -> dict | None:
"""GET /users/me — данные текущего пользователя."""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get(
"/users/me",
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
async def register(
username: str,
password: str,
first_name: str,
last_name: str,
group_id: str | None = None,
organization_id: str | None = None,
) -> str | None:
"""
POST /auth/register
Возвращает None при успехе или строку с ошибкой.
"""
payload: dict[str, Any] = {
"username": username,
"password": password,
"first_name": first_name,
"last_name": last_name,
}
if group_id:
payload["group_id"] = group_id
if organization_id:
payload["organization_id"] = organization_id
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.post("/auth/register", json=payload)
if resp.status_code == 201:
return None
elif resp.status_code == 400:
return resp.json().get("detail", "Ошибка регистрации")
elif resp.status_code == 422:
# Ошибки валидации Pydantic — извлекаем первое понятное сообщение
errors = resp.json().get("detail", [])
_field_names = {"password": "Пароль", "username": "Логин"}
_msg_map = {
"Password must be at least 8 characters": "Пароль должен быть не менее 8 символов",
"Username must be at least 3 characters": "Логин должен быть не менее 3 символов",
}
for err in errors:
raw = err.get("ctx", {}).get("error") or err.get("msg", "")
if raw in _msg_map:
return _msg_map[raw]
# fallback: поле + сообщение
loc = err.get("loc", [])
field = _field_names.get(loc[-1] if loc else "", "")
msg = err.get("msg", "Ошибка валидации").removeprefix("Value error, ")
return f"{field}: {msg}".strip(": ")
return "Проверьте правильность введённых данных"
return f"Ошибка сервера: {resp.status_code}"
except httpx.ConnectError:
return "Не удалось подключиться к серверу"
except Exception as e:
return f"Ошибка: {e}"
# ---------------------------------------------------------------------------
# Organizations & Groups
# ---------------------------------------------------------------------------
async def get_organizations() -> list:
"""GET /organizations/"""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/organizations/")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_groups() -> list:
"""GET /groups/"""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/groups/")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
# ---------------------------------------------------------------------------
# Polls
# ---------------------------------------------------------------------------
async def get_polls() -> list:
"""GET /polls/ — список всех опросов."""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/polls/")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_poll(poll_id: str) -> dict | None:
"""GET /polls/{poll_id} — опрос с вопросами и вариантами ответов."""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get(f"/polls/{poll_id}")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
async def get_response(response_id: str) -> dict | None:
"""GET /polls/responses/{response_id} — детальный ответ с answers."""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get(f"/polls/responses/{response_id}")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
async def get_radar_result(response_id: str) -> dict | None:
"""GET /responses/{response_id}/radar — данные для радарной диаграммы."""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get(f"/responses/{response_id}/radar")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
async def get_radar_svg_bytes(response_id: str) -> bytes | None:
"""GET /responses/{response_id}/radar/image — SVG-файл с диаграммой."""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get(f"/responses/{response_id}/radar/image")
if resp.status_code == 200:
return resp.content
except Exception:
pass
return None
async def submit_response(
poll_id: str,
answers: list[dict],
user_id: str | None = None,
group_id: str | None = None,
organization_id: str | None = None,
) -> tuple[str | None, str | None]:
"""
POST /polls/{poll_id}/responses
answers: [{"question_id": ..., "choice_id": ..., "text": ...}, ...]
Возвращает (response_id, None) или (None, error_message).
"""
payload: dict[str, Any] = {"answers": answers}
if user_id:
payload["user_id"] = user_id
if group_id:
payload["group_id"] = group_id
if organization_id:
payload["organization_id"] = organization_id
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.post(f"/polls/{poll_id}/responses", json=payload)
if resp.status_code == 201:
return resp.json()["id"], None
return None, f"Ошибка сервера: {resp.status_code}"
except httpx.ConnectError:
return None, "Не удалось подключиться к серверу"
except Exception as e:
return None, f"Ошибка: {e}"
async def get_user_responses(user_id: str) -> list:
"""GET /polls/users/{user_id}/responses — ответы конкретного пользователя."""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get(f"/polls/users/{user_id}/responses")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def update_user(
user_id: str,
token: str,
first_name: str | None = None,
last_name: str | None = None,
group_id: str | None = None,
organization_id: str | None = None,
) -> tuple[dict | None, str | None]:
"""PUT /users/{user_id} — обновить данные пользователя."""
payload: dict[str, Any] = {}
if first_name is not None:
payload["first_name"] = first_name
if last_name is not None:
payload["last_name"] = last_name
if group_id is not None:
payload["group_id"] = group_id
if organization_id is not None:
payload["organization_id"] = organization_id
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.put(
f"/users/{user_id}",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code == 200:
return resp.json(), None
return None, f"Ошибка сервера: {resp.status_code}"
except httpx.ConnectError:
return None, "Не удалось подключиться к серверу"
except Exception as e:
return None, f"Ошибка: {e}"

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