Compare commits

...

10 Commits

Author SHA1 Message Date
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
51 changed files with 417 additions and 3313 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.venv
data
web-react
web
__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
# Копируем зависимости
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Зависимости ставим через uv по pyproject.toml/uv.lock — тот же источник,
# что и в локальной разработке, версии не расходятся.
RUN pip install --no-cache-dir uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
# Копируем приложение
COPY . .
ENV PATH="/app/.venv/bin:$PATH"
# Стартуем приложение
CMD ["python", "main.py"]

View File

@@ -1,4 +1,4 @@
FROM python:3.11-slim
FROM python:3.14-slim
WORKDIR /app

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"]

103
README.md
View File

@@ -1,6 +1,6 @@
# api-copp
Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на Flet. Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов.
Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на React (SPA). Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. Тест можно пройти анонимно — достаточно выбрать школу и класс, аккаунт не нужен.
---
@@ -25,42 +25,42 @@
```
Браузер пользователя
│ :80
[ web — Flet ASGI ] ← единственный публичный сервис
[ web-react — nginx (SPA + reverse-proxy /api) ] ← единственный публичный сервис
│ http://api:8000 (только Docker-сеть)
[ api — FastAPI :8000 ]
│ │
[ 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 могут запускаться как:
- отдельным compose-файлом `docker-compose_db.yml` (локально в Docker)
- внешними сервисами (отдельный сервер, managed service) — достаточно прописать нужные `REDIS_HOST`/`DB_HOST` в переменных окружения
- сервисами `postgres`/`redis` в том же `docker-compose.yml`, за профилем `local-db` (локально в Docker)
- внешними сервисами (отдельный сервер, managed service) — достаточно прописать нужные `REDIS_HOST`/`DB_HOST` в `.env`, профиль `local-db` не поднимать
---
## Быстрый старт
### 1. Инфраструктура (PostgreSQL + Redis)
```bash
docker compose -f docker-compose_db.yml up -d
```
### 2. Приложение
Один файл `docker-compose.yml` на всё. По умолчанию поднимает только приложение (`api` + `web-react` + `stats`) — БД и Redis ожидаются внешними (`DB_HOST`/`REDIS_HOST` в `.env`, см. «Переменные окружения»).
```bash
# Только приложение (внешние БД/Redis из .env)
docker compose up -d --build
# + локальные PostgreSQL и Redis в Docker (профиль local-db)
docker compose --profile local-db up -d --build
```
| Адрес | Что |
|---|---|
| `http://localhost` | Веб-интерфейс (Flet) |
| `http://localhost` | Веб-интерфейс (React SPA) |
| `http://localhost:8000/docs` | Swagger UI (HTTP Basic Auth) |
| `http://localhost:8000/redoc` | ReDoc |
| `http://localhost:8081` | Дашборд статистики |
> Реальные значения переменных окружения (пароли, ключи) держите в `.env` в корне репозитория — `docker compose` подставляет их автоматически. Шаблон без секретов — в `.env.example`. Файл `.env` в `.gitignore`, в репозиторий не попадает.
### 3. Инициализация БД (первый запуск)
```bash
@@ -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` |
| `DEBUG` | Режим отладки API | `False` |
| `DB_HOST` | Хост PostgreSQL | `postgres` |
| `DB_PORT` | Порт PostgreSQL | `5432` |
| `DB_USER` | Пользователь БД | `postgres` |
@@ -126,12 +127,12 @@ curl -X POST http://localhost:8000/polls/glomshtok/<poll_id>/seed-scores -H "X-A
```
api-copp/
├── main.py # точка входа FastAPI, регистрация роутеров
├── requirements.txt # зависимости API
├── pyproject.toml / uv.lock # зависимости API (единственный источник — читает Dockerfile.api через uv)
├── .dockerignore # что не попадает в build context образов
├── .env.example # шаблон переменных окружения без секретов
├── Dockerfile.api # образ API
├── Dockerfile.web # образ фронтенда (Flet)
├── Dockerfile.stats # образ дашборда статистики
├── docker-compose.yml # API + Web + Stats
├── docker-compose_db.yml # PostgreSQL + Redis
├── docker-compose.yml # api + web-react + stats (+ postgres/redis за профилем local-db)
├── bd/
│ ├── __init__.py # Settings (pydantic), make_engine()
@@ -168,13 +169,13 @@ api-copp/
│ ├── stats_crud.py # статистика (кэшируется в Redis, TTL 5 мин)
│ └── transfer_crud.py # экспорт/импорт/бэкап/восстановление БД
├── web/ # Flet-фронтенд (порт 80)
│ ├── main.py
│ ├── router.py
│ ├── api_client.py
── designer.py # дизайн-система
├── radar_chart.py # виджет RadarChart
│ └── views/content_users/
├── web-react/ # React SPA-фронтенд (порт 80, свой Dockerfile+nginx)
│ ├── src/api/client.ts # единая точка HTTP-запросов к API
│ ├── src/auth/AuthContext.tsx # токен-логин + анонимная сессия (школа+класс без аккаунта)
│ ├── src/pages/ # страницы (Login, AnonymousEntry, Poll, MyTests, ...)
── nginx.conf # раздаёт статику SPA, проксирует /api → api:8000
├── stats/ # отдельный дашборд статистики (Flet, порт 8081)
└── data/ # том Docker (./data:/app/data)
├── postgres/ # данные PostgreSQL
@@ -259,6 +260,8 @@ GET /polls/users/{user_id}/responses — история пользовате
GET /responses/{id} — конкретный результат с ответами
```
`POST /polls/{poll_id}/responses` не требует авторизации: `user_id` необязателен. `web-react` использует это для анонимного прохождения — школьник на `/anonymous` выбирает только школу и класс (без пароля и аккаунта), выбор кладётся в `group_id`/`organization_id` тела запроса вместо `user_id`. Результат сохраняется в базе как снимок школы/класса без привязки к пользователю; истории результатов после закрытия вкладки у анонимной сессии нет — только у настоящих аккаунтов (`/my-tests`).
### Результаты (паутинная диаграмма)
```
@@ -446,7 +449,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 +500,7 @@ tcp-keepalive 60
## Деплой с внешним прокси
Минимальная конфигурация nginx:
`web-react` — статический SPA + reverse-proxy внутри своего же контейнера (`web-react/nginx.conf`), долгоживущих WebSocket-соединений (как было у Flet) больше нет. Минимальная конфигурация внешнего nginx перед контейнером:
```nginx
location / {
@@ -505,24 +508,20 @@ location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
```
Заголовки `Upgrade` / `Connection` обязательны — Flet использует WebSocket.
В репозитории есть `nginx.conf` и `nginx.production.conf` с готовыми примерами.
> **⚠ `nginx.conf` и `nginx.production.conf` в репозитории всё ещё написаны под старый Flet-фронтенд** (`upstream flet_app`, отдельный `location /ws` для WebSocket, `location /radar-image/`). После перехода на `web-react` эти файлы не актуальны и не обновлялись — `nginx.production.conf`, судя по всему, конфиг боевого внешнего nginx для `test.cloud-copp74.ru`, поэтому его правка не входит в эту уборку без отдельного подтверждения (ошибка здесь ломает публичный домен). Если используете внешний прокси в проде — сверьте его с примером выше перед следующим деплоем.
---
## Известные ошибки и решения
### 1. `api` недоступен из браузера ✅
### 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 +539,11 @@ location / {
---
### 4. Варианты ответов обрезались ✅
### 4. Варианты ответов обрезались ✅ (относилось к старому Flet-фронтенду)
**Проблема:** `ft.Radio(label=...)` в Flet не переносит текст метки — длинные варианты обрезались.
**Решение:** Кастомные кликабельные контейнеры с `ft.Text(expand=True, no_wrap=False)`.
**Решение:** актуально не для этого проекта — в `web-react` варианты ответа рендерятся обычной HTML-разметкой (`PollPage.tsx`), перенос текста работает из коробки.
---
@@ -592,6 +591,32 @@ 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` удалён.

View File

@@ -1,6 +1,5 @@
# Database configuration package
from pydantic import BaseModel
import re
import os
import urllib.parse
@@ -11,23 +10,6 @@ class Settings(BaseModel):
DB_PASS: str = os.getenv("DB_PASS", "")
DB_NAME: str = os.getenv("DB_NAME", "profi")
@classmethod
def validate_db_port(cls, v):
# Проверка и преобразование DB_PORT
if isinstance(v, str):
# Используем регулярное выражение для извлечения целого числа
match = re.search(r'\d+', v)
if match:
return int(match.group())
else:
raise ValueError(f"Invalid DB_PORT value: {v}")
return v
@property
def DATABASE_URL_asyncpg(self) -> str:
pwd = urllib.parse.quote_plus(str(self.DB_PASS))
return f"postgresql+asyncpg://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
@property
def DATABASE_URL_syncpg(self) -> str:
pwd = urllib.parse.quote_plus(str(self.DB_PASS))

View File

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

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

@@ -24,6 +24,8 @@ dependencies = [
"passlib>=1.7.4",
"python-jose>=3.5.0",
"matplotlib>=3.10.8",
"python-docx>=1.2.0",
"python-multipart>=0.0.20",
]
[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

@@ -43,7 +43,7 @@ def collect_metadatas():
@router.post("/db/create-tables", dependencies=[Depends(require_admin_key)])
async def create_tables():
def create_tables():
"""Создаёт все таблицы, описанные в модулях `db.tables`.
Endpoint вызывается по нажатию кнопки в UI (POST).
@@ -71,7 +71,7 @@ async def create_tables():
@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, если ещё не существует."""
engine = get_engine()
try:
@@ -95,7 +95,7 @@ async def migrate_cascade_user():
@router.post("/db/migrate-users", dependencies=[Depends(require_admin_key)])
async def migrate_users():
def migrate_users():
"""Добавляет колонки username и hashed_password в таблицу users, если они ещё не существуют."""
engine = get_engine()
try:
@@ -116,7 +116,7 @@ class ClearDBIn(BaseModel):
@router.post("/db/migrate", dependencies=[Depends(require_admin_key)])
async def migrate_tables():
def migrate_tables():
"""Приводит схему БД в соответствие с моделями: убирает устаревшие колонки, добавляет новые."""
engine = get_engine()
migrations = [
@@ -142,7 +142,7 @@ async def migrate_tables():
@router.post("/db/clear", dependencies=[Depends(require_admin_key)])
async def clear_tables(payload: ClearDBIn):
def clear_tables(payload: ClearDBIn):
"""Полная очистка всех таблиц, описанных в `bd.tables`.
Требуется явное подтверждение: POST с телом {"confirm": true}.

72
uv.lock generated
View File

@@ -44,8 +44,10 @@ dependencies = [
{ name = "passlib" },
{ name = "pg8000" },
{ name = "pydantic" },
{ name = "python-docx" },
{ name = "python-dotenv" },
{ name = "python-jose" },
{ name = "python-multipart" },
{ name = "redis" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
@@ -71,8 +73,10 @@ requires-dist = [
{ name = "pydantic", specifier = ">=2.12.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
{ name = "python-docx", specifier = ">=1.2.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "python-jose", specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "redis", specifier = ">=7.1.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
{ name = "sqlalchemy", specifier = ">=2.0.47" },
@@ -259,7 +263,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
@@ -268,7 +271,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
@@ -389,6 +391,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" },
]
[[package]]
name = "lxml"
version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
]
[[package]]
name = "matplotlib"
version = "3.10.8"
@@ -707,6 +753,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" },
]
[[package]]
name = "python-docx"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
@@ -730,6 +789,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" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pytokens"
version = "0.4.1"

View File

@@ -1,9 +1,10 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import { ThemeProvider } from "./theme/ThemeContext";
import { ProtectedRoute } from "./components/ProtectedRoute";
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";
@@ -11,10 +12,16 @@ 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>
@@ -23,12 +30,13 @@ export default function App() {
<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={protect(<MyTestsPage />)} />
<Route path="/response/:responseId" element={protect(<ResponsePage />)} />
<Route path="/profile" element={protect(<ProfilePage />)} />
<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>

View File

@@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from "
import * as api from "../api/client";
const TOKEN_KEY = "auth_token";
const ANON_KEY = "anon_identity";
export interface AuthUser {
id: string;
@@ -11,15 +12,38 @@ export interface AuthUser {
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 {
@@ -35,6 +59,7 @@ function meToUser(me: api.Me): AuthUser {
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
@@ -65,6 +90,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
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);
@@ -72,17 +99,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
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, ready, login, logout, setUserFromMe }}>
<Ctx.Provider value={{ token, user, anon, ready, login, loginAnonymous, logout, setUserFromMe }}>
{children}
</Ctx.Provider>
);

View File

@@ -3,8 +3,26 @@ 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 (

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

@@ -1,20 +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="Добро пожаловать в ЦОПП!">
<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

@@ -66,6 +66,9 @@ export function LoginPage() {
<button type="button" className="link-btn" onClick={() => setAskReg(true)}>
Нет аккаунта? Зарегистрироваться
</button>
<button type="button" className="link-btn" onClick={() => navigate("/anonymous")}>
Пройти тест без регистрации
</button>
</form>
{askReg && (

View File

@@ -18,7 +18,7 @@ type Phase =
export function PollPage() {
const { pollId = "" } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const { user, anon } = useAuth();
const [questions, setQuestions] = useState<api.Question[]>([]);
const [answers, setAnswers] = useState<Record<string, string>>({});
@@ -73,8 +73,8 @@ export function PollPage() {
}));
const { data: responseId, error } = await api.submitResponse(pollId, payload, {
user_id: user?.id,
group_id: user?.groupId || null,
organization_id: user?.orgId || null,
group_id: user?.groupId || anon?.groupId || null,
organization_id: user?.orgId || anon?.orgId || null,
});
if (error || !responseId) {
setPhase({ kind: "error", message: error ?? "Не удалось отправить ответы" });

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}"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 737 B

View File

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,546 +0,0 @@
import flet as ft
# Глобальный масштаб интерфейса. 1.0 = 100%, 1.7 = 170%.
# Меняйте только эту переменную (или через theme_manager.apply_scale),
# чтобы масштабировать весь UI.
SCALE: float = 1.7
def s(v: int | float) -> int:
"""Применяет SCALE к любому размеру: шрифтам, отступам, ицих."""
return round(v * SCALE)
class colors:
# Основная палитра в стиле copp74.ru
primary = "#F85A40" # Основной оранжево-красный (логотип ЦОПП)
primary_dark = "#D94530" # Тёмный для hover/активных состояний
primary_light = "#FFEAB7" # Светлый персиковый (вторичный фон)
# Фоны
background = "#F4F6F8" # Светло-серый фон страницы
surface = "#FFFFFF" # Белый для карточек и форм
surface_alt = "#F0F2F5" # Альтернативные блоки
# Акцент — жёлтый
accent = "#FFC845" # Жёлтый акцент
accent_lime = "#FFC845" # Алиас для совместимости
secondary_light = "#FFEAB7" # Алиас для совместимости
secondary_yellow = "#FFC845" # Алиас
# Синий — информация, ссылки
info = "#007FBD" # Синий
info_light = "#D9F1F4" # Светлый голубой
tertiary_light = "#D9F1F4" # Алиас для совместимости
tertiary_blue = "#007FBD" # Алиас для совместимости
secondary_orange = "#F85A40" # Алиас для совместимости
# Зелёный — успех
success = "#85C446" # Зелёный
success_light = "#C0DE9E" # Светло-зелёный
# Текст
text_primary = "#1E2A3A" # Почти чёрный для заголовков
text_secondary = "#5A6779" # Серый для второстепенного текста
text_on_primary = "#FFFFFF" # Белый текст на оранжевом фоне
# Граница
border = "#DDE3EA" # Светло-серая граница
class CastomText(ft.Text):
def __init__(self, text, size=14, color=colors.text_primary, weight=ft.FontWeight.NORMAL, **kwargs):
super().__init__(
value=text,
color=color,
size=s(size),
weight=weight,
max_lines=99,
**kwargs,
)
class CastomTextField_input(ft.TextField):
def __init__(self, on_change=None, on_submit=None, label="Ваше имя", hint_text="Введите текст...", prefix_icon=None, suffix_icon=None, **kwargs):
super().__init__(
label=label,
hint_text=hint_text,
filled=True,
bgcolor=colors.surface,
border_color=colors.border,
focused_border_color=colors.primary,
border_radius=s(10),
label_style=ft.TextStyle(
color=colors.text_secondary,
size=s(13),
weight=ft.FontWeight.W_500,
),
hint_style=ft.TextStyle(
color=colors.border,
size=s(14),
italic=True,
),
text_style=ft.TextStyle(
color=colors.text_primary,
size=s(15),
),
content_padding=ft.padding.symmetric(horizontal=s(14), vertical=s(14)),
prefix_icon=prefix_icon,
suffix_icon=suffix_icon,
on_change=on_change,
on_submit=on_submit,
**kwargs,
)
class CastomButton(ft.Button):
def __init__(self, text, on_click):
super().__init__(
content=ft.Text(
text,
color=colors.text_on_primary,
size=s(14),
weight=ft.FontWeight.W_600,
) if isinstance(text, str) else text,
style=ft.ButtonStyle(
color=colors.text_on_primary,
bgcolor={
ft.ControlState.DEFAULT: colors.primary,
ft.ControlState.HOVERED: colors.primary_dark,
},
shape=ft.RoundedRectangleBorder(radius=s(10)),
padding=ft.padding.symmetric(horizontal=s(20), vertical=s(12)),
),
on_click=on_click
)
class CastomIconButton(ft.Button):
"""Кнопка с иконкой и текстом."""
def __init__(self, text, icon, on_click):
super().__init__(
content=ft.Row(
spacing=s(8),
tight=True,
controls=[
ft.Icon(icon, color=colors.text_on_primary, size=s(18)),
ft.Text(text, color=colors.text_on_primary, size=s(14), weight=ft.FontWeight.W_600),
],
),
style=ft.ButtonStyle(
bgcolor={
ft.ControlState.DEFAULT: colors.info,
ft.ControlState.HOVERED: "#006AA3",
},
shape=ft.RoundedRectangleBorder(radius=s(10)),
padding=ft.padding.symmetric(horizontal=s(18), vertical=s(12)),
),
on_click=on_click,
)
class CastomDropdown(ft.Dropdown):
def __init__(self, label="", hint_text="", options=None, on_change=None):
super().__init__(
label=label,
hint_text=hint_text,
options=options or [],
filled=True,
fill_color=colors.surface,
color=colors.text_primary,
border_color=colors.border,
border_radius=s(10),
focused_border_color=colors.primary,
label_style=ft.TextStyle(
color=colors.text_secondary,
size=s(13),
weight=ft.FontWeight.W_500,
),
hint_style=ft.TextStyle(
color=colors.border,
size=s(14),
italic=True,
),
text_style=ft.TextStyle(
color=colors.text_primary,
size=s(15),
),
menu_style=ft.MenuStyle(
bgcolor=colors.surface,
),
)
if on_change is not None:
self.on_select = on_change
class SearchableDropdown(ft.Container):
"""Выпадающий список с поисковой строкой — замена CastomDropdown для больших наборов данных."""
def __init__(self, label: str = "", hint_text: str = "", options=None, on_select=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(13),
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(14), vertical=s(12)),
on_click=self._open_dialog,
ink=True,
)
@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
if self.page:
self.update()
@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 = CastomTextField_input(
label="Поиск",
hint_text="Введите для поиска...",
prefix_icon=ft.Icons.SEARCH,
autofocus=True,
)
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(14)),
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(16),
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(350),
width=s(400),
),
bgcolor=colors.surface,
shape=ft.RoundedRectangleBorder(radius=s(12)),
)
self.page.overlay.append(dlg)
dlg.open = True
self.page.update()
class CastomSwitch(ft.Switch):
def __init__(self, label, on_change):
super().__init__(
label=label,
label_text_style=ft.TextStyle(size=s(13), color=colors.text_primary),
active_color=colors.primary,
active_track_color=colors.primary_light,
inactive_thumb_color=colors.border,
inactive_track_color=colors.surface_alt,
on_change=on_change
)
class AlterDiaalogApproval(ft.AlertDialog):
def __init__(self, on_accept=None):
self._on_accept = on_accept
self.switch_approval = CastomSwitch(
label="",
on_change=self.switch_changed
)
self.button_approval = CastomButton(
text="Принять",
on_click=self.accept
)
self.button_approval.disabled = True
super().__init__(
bgcolor=colors.surface,
title=ft.Text(
"Пользовательское соглашение",
color=colors.primary,
size=s(16),
weight=ft.FontWeight.BOLD,
),
content=ft.Container(
height=s(400),
content=ft.Column(
scroll=ft.ScrollMode.AUTO,
expand=True,
controls=[
ft.Markdown(
"""
# ПОЛЬЗОВАТЕЛЬСКОЕ СОГЛАШЕНИЕ
## 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
---
*Соглашение соответствует законодательству Российской Федерации, включая Федеральный закон «О защите персональных данных» и Федеральный закон «О защите прав потребителей».*
""",
expand=True,
md_style_sheet=ft.MarkdownStyleSheet(
p_text_style=ft.TextStyle(color=colors.text_primary, size=s(12)),
h1_text_style=ft.TextStyle(color=colors.primary, size=s(18), weight=ft.FontWeight.BOLD),
h2_text_style=ft.TextStyle(color=colors.info, size=s(15), weight=ft.FontWeight.BOLD),
h3_text_style=ft.TextStyle(color=colors.text_secondary, size=s(13), weight=ft.FontWeight.BOLD),
a_text_style=ft.TextStyle(color=colors.primary),
),
)
]
),
),
actions=[
ft.Column(
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
controls=[
ft.Row(
expand=True,
alignment=ft.MainAxisAlignment.START,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
controls=[
self.switch_approval,
ft.Text(
"Я согласен с условиями пользовательского соглашения",
size=s(13),
color=colors.text_primary,
expand=True,
no_wrap=False,
),
]
),
ft.Row(
expand=True,
alignment=ft.MainAxisAlignment.CENTER,
controls=[
self.button_approval,
]
)
]
)
],
actions_alignment=ft.MainAxisAlignment.CENTER,
)
def switch_changed(self, e):
#self.switch_approval.value = not e.control.value
self.button_approval.disabled = not e.control.value
self.update()
def accept(self, e):
self.open = False
self.page.update()
if self._on_accept:
self._on_accept()
else:
self.page.run_task(self.page.push_route, "/reg")

View File

@@ -1,77 +0,0 @@
import secrets
import flet as ft
import api_client
_TOKEN_KEY = "auth_token"
class AuthService:
"""Сервис авторизации: логин, логаут, проверка токена.
HTTP-запросы делегируются в api_client.
"""
def __init__(self, page: ft.Page):
self._page = page
async def login(self, username: str, password: str) -> str | None:
"""POST /auth/login. Возвращает None при успехе, строку с ошибкой при неудаче."""
token, error = await api_client.login(username, password)
if error:
return error
self._page.session.store.set(_TOKEN_KEY, token)
self._page.session.store.set("url_key", secrets.token_hex(16)) # в адресную строку
self._page.session.store.set("session_key", secrets.token_hex(16)) # только в память
me = await api_client.get_me(token)
if me:
self._page.session.store.set("user_id", me["id"])
self._page.session.store.set("group_id", me.get("group_id") or "")
self._page.session.store.set("org_id", me.get("organization_id") or "")
return None
async def get_me(self) -> dict | None:
"""Возвращает данные текущего пользователя или None."""
token = self._page.session.store.get(_TOKEN_KEY)
if not token:
return None
return await api_client.get_me(token)
async def logout(self):
"""Инвалидирует токен на сервере и удаляет локальную сессию."""
token = self._page.session.store.get(_TOKEN_KEY)
if token:
await api_client.logout(token)
self._page.session.store.remove(_TOKEN_KEY)
self._page.session.store.remove("user_id")
self._page.session.store.remove("url_key")
self._page.session.store.remove("session_key")
self._page.session.store.remove("group_id")
self._page.session.store.remove("org_id")
async def get_token(self) -> str | None:
"""Возвращает сохранённый токен или None если не авторизован."""
return self._page.session.store.get(_TOKEN_KEY)
async def is_authenticated(self) -> bool:
token = await self.get_token()
return token is not None and token != ""
async def register(
self,
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 при успехе, строку с ошибкой при неудаче."""
return await api_client.register(
username, password, first_name, last_name, group_id, organization_id
)
async def get_organizations(self) -> list:
return await api_client.get_organizations()
async def get_groups(self) -> list:
return await api_client.get_groups()

View File

@@ -1,69 +0,0 @@
import os
import flet as ft
import httpx
import designer as ds
from router import setup_router
import theme_manager as tm
from starlette.applications import Starlette
from starlette.routing import Route, Mount
from starlette.responses import Response as StarletteResponse
_API_URL = os.getenv("API_URL", "http://api:8000")
async def _radar_image(request):
"""Проксирует SVG-диаграмму с API — браузер скачивает с собственного хоста."""
response_id = request.path_params["response_id"]
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{_API_URL}/responses/{response_id}/radar/image")
if r.status_code == 200:
return StarletteResponse(
content=r.content,
media_type="image/svg+xml",
headers={"Content-Disposition": 'attachment; filename="diagram.svg"'},
)
except Exception:
pass
return StarletteResponse(status_code=404)
async def main(page: ft.Page):
page.title = "COPP"
# Применяем сохранённые тему и масштаб до построения любых виджетов
tm.apply_scale(page)
tm.apply_theme(page)
# Масштабирование глобального text_theme синхронно с ds.SCALE:
# покрывает ft.Text() без явного size (body_medium по умолчанию = 14)
_ts = lambda base: ft.TextStyle(size=ds.s(base))
page.theme = ft.Theme(
text_theme=ft.TextTheme(
body_large=_ts(16),
body_medium=_ts(14),
body_small=_ts(12),
label_large=_ts(14),
label_medium=_ts(12),
label_small=_ts(11),
title_large=_ts(22),
title_medium=_ts(16),
title_small=_ts(14),
headline_medium=_ts(28),
headline_small=_ts(24),
display_small=_ts(36),
),
)
setup_router(page)
initial = page.route if page.route and page.route not in ("/", "") else "/login"
await page.push_route(initial)
_flet_app = ft.run(main, export_asgi_app=True, assets_dir="assets")
app = Starlette(
routes=[
Route("/radar-image/{response_id}", _radar_image),
Mount("/", _flet_app),
]
)

View File

@@ -1,153 +0,0 @@
"""
RadarChart — переиспользуемый виджет 2D паутинной диаграммы (ft.Stack).
Отрицательные значения — красный полигон, положительные — фиолетовый.
Параметры:
items — список осей из API /responses/{id}/radar:
[{dimension_name, dimension_color, dimension_position, value}, ...]
max_val — максимум на шкале (default 14 — для теста Голланда)
"""
import math
import flet as ft
import flet.canvas as cv
import designer as ds
# ── размеры ─────────────────────────────────────────────────────────────────
_CS = 260 # размер canvas
_CX = _CS // 2
_CY = _CS // 2
_R = 78 # радиус внешнего кольца
_PAD = 95 # отступ вокруг canvas под подписи
_LBL_R = _R + 52 # расстояние от центра до подписи (в координатах Stack)
_W_LBL = 130 # ширина контейнера подписи
def _axis_xy(n: int, i: int, frac: float = 1.0) -> tuple[float, float]:
a = math.radians(-90.0 + 360.0 * i / n)
return _CX + _R * frac * math.cos(a), _CY + _R * frac * math.sin(a)
def _closed_path(pts: list[tuple]) -> list:
els = [cv.Path.MoveTo(*pts[0])]
for p in pts[1:]:
els.append(cv.Path.LineTo(*p))
els.append(cv.Path.Close())
return els
class RadarChart(ft.Stack):
"""2D паутинная диаграмма. Отрицательные=красный, позитивные=синий."""
def __init__(self, items: list[dict], max_val: float = 14.0):
n = len(items)
shapes: list = []
vals = [it["value"] for it in items]
max_abs = max((abs(v) for v in vals), default=1.0) if vals else 1.0
if max_val > 0:
max_abs = max(max_abs, max_val)
max_abs = max(max_abs, 1.0)
# ── Сетка ─────────────────────────────────────────────────────────────
for lvl in range(1, 6):
ring = [_axis_xy(n, i, lvl / 5) for i in range(n)]
alpha = int((0.10 + 0.09 * (lvl / 5)) * 255)
shapes.append(cv.Path(
_closed_path(ring),
paint=ft.Paint(
style=ft.PaintingStyle.STROKE,
stroke_width=1,
color=f"#{alpha:02X}F4EFFA",
),
))
# ── Оси от центра ──────────────────────────────────────────────────────
for i in range(n):
x, y = _axis_xy(n, i)
shapes.append(cv.Path(
[cv.Path.MoveTo(_CX, _CY), cv.Path.LineTo(x, y)],
paint=ft.Paint(stroke_width=1, color="#38F4EFFA"),
))
# ── Полигоны данных ────────────────────────────────────────────────────
neg_pts = [_axis_xy(n, i, max(0.0, min(-it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
pos_pts = [_axis_xy(n, i, max(0.0, min( it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
# Красный (отрицательные)
shapes.append(cv.Path(_closed_path(neg_pts),
paint=ft.Paint(style=ft.PaintingStyle.FILL, color="#50CC3030")))
shapes.append(cv.Path(_closed_path(neg_pts),
paint=ft.Paint(style=ft.PaintingStyle.STROKE, stroke_width=2.5, color="#FF7070")))
# Фиолетовый (положительные)
shapes.append(cv.Path(_closed_path(pos_pts),
paint=ft.Paint(style=ft.PaintingStyle.FILL, color="#505030A0")))
shapes.append(cv.Path(_closed_path(pos_pts),
paint=ft.Paint(style=ft.PaintingStyle.STROKE, stroke_width=2.5, color="#A080FF")))
# ── Точки на вершинах ──────────────────────────────────────────────────
for i, it in enumerate(items):
v = it["value"]
if abs(v) > 0.05:
px, py = _axis_xy(n, i, abs(v) / max_abs)
col = "#FF7070" if v < 0 else "#A080FF"
shapes.append(cv.Circle(px, py, 4, paint=ft.Paint(color=col)))
shapes.append(cv.Circle(px, py, 4,
paint=ft.Paint(style=ft.PaintingStyle.STROKE, stroke_width=1.5, color="#F4EFFA")))
canvas = cv.Canvas(shapes=shapes, width=_CS, height=_CS)
# ── Подписи осей в Stack-координатах ─────────────────────────────────
sw = _CS + 2 * _PAD
sh = _CS + 2 * _PAD
ccx = _PAD + _CX # центр графика в Stack
ccy = _PAD + _CY
labels: list[ft.Control] = []
for i, item in enumerate(items):
a = math.radians(-90.0 + 360.0 * i / n)
lx = ccx + _LBL_R * math.cos(a)
ly = ccy + _LBL_R * math.sin(a)
col = item.get("dimension_color") or ds.colors.primary
labels.append(ft.Container(
left=lx - _W_LBL / 2,
top=ly - 24,
width=_W_LBL,
content=ft.Column(
spacing=1,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
controls=[
ft.Text(
item["dimension_name"],
size=10,
color=col,
text_align=ft.TextAlign.CENTER,
weight=ft.FontWeight.BOLD,
overflow=ft.TextOverflow.FADE,
max_lines=2,
no_wrap=False,
),
ft.Text(
str(int(item["value"])),
size=11,
color=ds.colors.primary,
text_align=ft.TextAlign.CENTER,
),
],
),
))
super().__init__(
width=sw,
height=sh,
controls=[
ft.Container(
left=_PAD,
top=_PAD,
width=_CS,
height=_CS,
content=canvas,
),
*labels,
],
)

View File

@@ -1,128 +0,0 @@
"""
radar_svg.py — генерация SVG паутинной диаграммы для сохранения.
Не требует сторонних зависимостей (чистый Python).
Отрицательные значения — красный полигон, положительные — фиолетовый.
"""
import math
import base64
def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520) -> str:
"""
Возвращает SVG-строку паутинной диаграммы.
items — список осей (dimension_name, dimension_color, value)
max_val — максимум шкалы
size — размер холста в пикселях
"""
n = len(items)
if n < 3:
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">'
f'<text x="{size//2}" y="{size//2}" text-anchor="middle" fill="#888" '
f'font-family="Arial,sans-serif" font-size="16">Нет данных</text></svg>'
)
cx = size / 2
cy = size / 2
R = size * 0.255
lbl_r = R + size * 0.105
vals = [it["value"] for it in items]
max_abs = max((abs(v) for v in vals), default=1.0)
if max_val > 0:
max_abs = max(max_abs, max_val)
max_abs = max(max_abs, 1.0)
def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]:
a = math.radians(-90.0 + 360.0 * i / n)
return cx + R * frac * math.cos(a), cy + R * frac * math.sin(a)
def poly(pts: list[tuple]) -> str:
return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
lines: list[str] = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">',
]
# ── Сетка ─────────────────────────────────────────────────────────────────
for lvl in range(1, 6):
frac = lvl / 5
pts = [axis_xy(i, frac) for i in range(n)]
alpha = 0.12 + 0.09 * frac
lines.append(
f'<polygon points="{poly(pts)}" fill="none" '
f'stroke="rgba(170,130,230,{alpha:.2f})" stroke-width="1"/>'
)
# ── Оси от центра ─────────────────────────────────────────────────────────
for i in range(n):
x, y = axis_xy(i)
lines.append(
f'<line x1="{cx:.2f}" y1="{cy:.2f}" x2="{x:.2f}" y2="{y:.2f}" '
f'stroke="rgba(170,130,230,0.45)" stroke-width="1"/>'
)
# ── Полигоны данных ───────────────────────────────────────────────────────
neg_pts = [axis_xy(i, max(0.0, min(-it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
pos_pts = [axis_xy(i, max(0.0, min( it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
lines.append(f'<polygon points="{poly(neg_pts)}" fill="rgba(220,50,50,0.30)" stroke="#FF7070" stroke-width="3.5"/>')
lines.append(f'<polygon points="{poly(pos_pts)}" fill="rgba(100,60,200,0.32)" stroke="#A080FF" stroke-width="3.5"/>')
# ── Точки на вершинах ─────────────────────────────────────────────────────
for i, it in enumerate(items):
v = it["value"]
if abs(v) > 0.05:
x, y = axis_xy(i, abs(v) / max_abs)
col = "#FF7070" if v < 0 else "#A080FF"
lines.append(
f'<circle cx="{x:.2f}" cy="{y:.2f}" r="6" fill="{col}" '
f'stroke="rgba(255,255,255,0.70)" stroke-width="1.5"/>'
)
# ── Метки шкалы (первая ось) ──────────────────────────────────────────────
for lvl in range(1, 6):
frac = lvl / 5
tx, ty = axis_xy(0, frac)
lines.append(
f'<text x="{tx + 4:.2f}" y="{ty - 2:.2f}" font-family="Arial,sans-serif" '
f'font-size="9" fill="rgba(170,130,230,0.65)">{max_abs * frac:.0f}</text>'
)
# ── Подписи осей ───────────────────────────────────────────────────────────
for i, item in enumerate(items):
a = math.radians(-90.0 + 360.0 * i / n)
lx = cx + lbl_r * math.cos(a)
ly = cy + lbl_r * math.sin(a)
col = item.get("dimension_color") or "#A080FF"
name = _xml_escape(item["dimension_name"])
val = int(item["value"])
val_col = "#FF7070" if item["value"] < 0 else "#A080FF"
lines.append(
f'<text x="{lx:.2f}" y="{ly:.2f}" text-anchor="middle" '
f'font-family="Arial,Helvetica,sans-serif" font-size="14" '
f'font-weight="bold" fill="{col}">{name}</text>'
)
lines.append(
f'<text x="{lx:.2f}" y="{ly + 20:.2f}" text-anchor="middle" '
f'font-family="Arial,Helvetica,sans-serif" font-size="13" '
f'fill="{val_col}">{val}</text>'
)
lines.append("</svg>")
return "\n".join(lines)
def _xml_escape(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def get_radar_data_uri(items: list[dict], max_val: float = 14.0) -> str:
"""
Возвращает data URI вида «data:image/svg+xml;base64,…».
Передайте в page.launch_url(...) чтобы открыть/сохранить изображение.
"""
svg = generate_radar_svg(items, max_val)
b64 = base64.b64encode(svg.encode("utf-8")).decode()
return f"data:image/svg+xml;base64,{b64}"

View File

@@ -1,83 +0,0 @@
import flet as ft
from view import resolve_view, PUBLIC_ROUTES, PROTECTED_PREFIXES
import theme_manager as tm
async def _redirect_to_login(page: ft.Page):
"""Сброс сессии и принудительный редирект на логин."""
page.session.store.remove("auth_token")
page.session.store.remove("url_key")
page.session.store.remove("session_key")
page.session.store.remove("user_id")
await page.push_route("/login")
async def _handle_route_change(page: ft.Page, e: ft.RouteChangeEvent):
route = page.route
token = page.session.store.get("auth_token")
url_key = page.session.store.get("url_key") or ""
session_key = page.session.store.get("session_key") or ""
# Публичные маршруты — без проверки токена
if route in PUBLIC_ROUTES:
views = resolve_view(page, route)
page.views.clear()
page.views.extend(views)
page.update()
return
# Защищённый маршрут без токена → логин
if not token:
await page.push_route("/login")
return
# Маршруты с ключом — первый сегмент после префикса = url_key
for prefix in PROTECTED_PREFIXES:
if route.startswith(prefix):
rest = route[len(prefix):]
key_in_url = rest.split("/")[0] # берём только первый сегмент
if not url_key or not session_key or key_in_url != url_key:
await _redirect_to_login(page)
return
break
views = resolve_view(page, route)
# Применяем сохранённые настройки пользователя перед построением виджетов
tm.apply_scale(page)
tm.apply_theme(page)
# Неизвестный маршрут → на страницу пользователя
if views is None:
await page.push_route(f"/user/{url_key}")
return
page.views.clear()
page.views.extend(views)
page.update()
# Запустить асинхронную загрузку данных, если вью её поддерживает
last_view = views[-1] if views else None
if last_view is not None and hasattr(last_view, "load"):
page.run_task(last_view.load)
def setup_router(page: ft.Page):
"""Привязывает router к странице. Вызывается один раз в main()."""
async def _on_route_change(e: ft.RouteChangeEvent):
await _handle_route_change(page, e)
def _on_resize(e):
"""Пересчитать масштаб при изменении размера окна/повороте телефона,
но только если пользователь не менял его вручную (нет сохранён. префа)."""
uid = tm._get_uid(page)
if uid not in tm._user_prefs:
new_scale = tm.auto_scale(page)
if abs(new_scale - (page.session.store.get("ds_scale_last") or 0)) > 0.05:
page.session.store.set("ds_scale_last", new_scale)
tm.apply_scale(page, new_scale)
# перезагрузим текущий маршрут чтобы виджеты пересоздались
page.go(page.route)
page.on_route_change = _on_route_change
page.on_resize = _on_resize

View File

@@ -1,178 +0,0 @@
"""Менеджер тем и масштаба приложения с сохранением в localStorage браузера."""
import flet as ft
import designer as ds
# ---------------------------------------------------------------------------
# Темы: каждый цвет surface должен КОНТРАСТИРОВАТЬ с background,
# чтобы карточки, поля и кнопки не сливались с фоном.
# ---------------------------------------------------------------------------
THEMES: dict[str, dict] = {
"default": {
"name": "ЦОПП (оранжевая)",
# фон страницы
"background": "#F4F6F8",
# карточки / формы — светлее фона
"surface": "#FFFFFF",
# альтернативные блоки — чуть темнее фона
"surface_alt": "#E4E8ED",
},
"warm": {
"name": "Тёплая",
"background": "#FFF3E6",
"surface": "#FFFFFF",
"surface_alt": "#FFD9B3",
},
"blue": {
"name": "Синяя",
"background": "#E3F2FD",
"surface": "#FFFFFF",
"surface_alt": "#B8D9F5",
},
"green": {
"name": "Зелёная",
"background": "#EDF7E7",
"surface": "#FFFFFF",
"surface_alt": "#BEE3B0",
},
"dark": {
"name": "Тёмная",
"background": "#1E2A3A",
"surface": "#2D3F54",
"surface_alt": "#3D5068",
},
}
_SCALE_KEY = "ui_scale"
_THEME_KEY = "ui_theme"
# Допустимый диапазон масштаба
SCALE_MIN = 0.8
SCALE_MAX = 2.5
SCALE_STEP = 0.1
SCALE_DEFAULT = 1.7
# Автомасштаб по ширине экрана
_MOBILE_BREAKPOINT = 600 # px: меньше — телефон, больше — ПК/планшет
SCALE_MOBILE = 1.0
SCALE_DESKTOP = 2.0
def auto_scale(page: ft.Page) -> float:
"""Вернуть масштаб на основе ширины viewport браузера."""
try:
w = page.width or 0
return SCALE_MOBILE if w < _MOBILE_BREAKPOINT else SCALE_DESKTOP
except Exception:
return SCALE_DEFAULT
# ---------------------------------------------------------------------------
# Серверное хранилище настроек по user_id.
# В памяти контейнера — переживает F5 в браузере, сбрасывается при рестарте.
# ---------------------------------------------------------------------------
_user_prefs: dict[str, dict] = {} # user_id -> {"theme": str, "scale": float}
def _get_uid(page: ft.Page) -> str:
return page.session.store.get("user_id") or ""
def _prefs_for(user_id: str) -> dict:
return _user_prefs.get(user_id, {"theme": "default", "scale": SCALE_DEFAULT})
# ---------------------------------------------------------------------------
# Публичный API
# ---------------------------------------------------------------------------
def apply_theme(page: ft.Page, theme_id: str | None = None) -> None:
"""Применить тему: обновить ds.colors и page.bgcolor."""
if theme_id is None:
uid = _get_uid(page)
theme_id = _prefs_for(uid)["theme"] if uid else "default"
if theme_id not in THEMES:
theme_id = "default"
t = THEMES[theme_id]
ds.colors.background = t["background"]
ds.colors.surface = t["surface"]
ds.colors.surface_alt = t["surface_alt"]
page.bgcolor = t["background"]
# кешируем в session.store для быстрого доступа внутри сессии
page.session.store.set(_THEME_KEY, theme_id)
def apply_scale(page: ft.Page, scale: float | None = None) -> None:
"""Применить масштаб: обновить ds.SCALE.
Если масштаб не задан явно и у пользователя нет сохранённого —
определяется автоматически по ширине экрана (мобильный / ПК).
"""
if scale is None:
uid = _get_uid(page)
if uid and uid in _user_prefs:
# есть явно сохранённая настройка пользователя
scale = _user_prefs[uid]["scale"]
else:
# первый вход — автоопределение по ширине экрана
scale = auto_scale(page)
scale = max(SCALE_MIN, min(SCALE_MAX, float(scale)))
ds.SCALE = scale
page.session.store.set(_SCALE_KEY, str(scale))
def save_preferences(page: ft.Page, theme_id: str, scale: float,
user_id: str | None = None) -> None:
"""Сохранить тему и масштаб. Хранятся в памяти сервера по user_id."""
uid = user_id or _get_uid(page)
if uid:
_user_prefs[uid] = {"theme": theme_id, "scale": float(scale)}
# также пишем в session.store — нужно для apply_* внутри текущей сессии
page.session.store.set(_THEME_KEY, theme_id)
page.session.store.set(_SCALE_KEY, str(scale))
def load_theme_id(page: ft.Page) -> str:
# сначала session.store (уже применено), потом serv dict
v = page.session.store.get(_THEME_KEY)
if v and v in THEMES:
return v
uid = _get_uid(page)
v2 = _prefs_for(uid)["theme"] if uid else "default"
return v2 if v2 in THEMES else "default"
def load_scale(page: ft.Page) -> float:
v = page.session.store.get(_SCALE_KEY)
try:
if v is not None:
return float(v)
except ValueError:
pass
uid = _get_uid(page)
return _prefs_for(uid)["scale"] if uid else SCALE_DEFAULT
def theme_list() -> list[dict]:
return [{"id": k, "name": v["name"]} for k, v in THEMES.items()]
# ---------------------------------------------------------------------------
# Устаревший алиас (оставлен для совместимости с роутером)
# ---------------------------------------------------------------------------
class ThemeManager:
THEMES = THEMES
@staticmethod
def get_theme_list():
return theme_list()
@staticmethod
def load_theme_preference(page):
return load_theme_id(page)
@staticmethod
def save_theme_preference(page, theme_id):
save_preferences(page, theme_id, load_scale(page))

View File

@@ -1,42 +0,0 @@
import flet as ft
from views import main_view, login_view, reg_view, user_view, take_test_view, poll_view, my_tests_view, response_view, profile_view
PUBLIC_ROUTES = {"/login", "/reg"}
# Маршруты вида /prefix/{url_key}/... — первый сегмент после prefix = url_key
PROTECTED_PREFIXES = (
"/user/",
"/take_test/",
"/poll/",
"/my_tests/",
"/response/",
"/profile/",
)
def resolve_view(page: ft.Page, route: str) -> list[ft.View] | None:
if route == "/login":
return [login_view.LoginView(page)]
elif route == "/reg":
return [reg_view.RegView(page)]
elif route == "/main":
return [main_view.MainView()]
elif route.startswith("/user/"):
return [user_view.UserView(page)]
elif route.startswith("/take_test/"):
return [take_test_view.TakeTestView(page)]
elif route.startswith("/my_tests/"):
return [my_tests_view.MyTestsView(page)]
elif route.startswith("/poll/"):
# /poll/{url_key}/{poll_id}
parts = route.strip("/").split("/")
poll_id = parts[-1] if len(parts) >= 3 else ""
return [poll_view.PollView(page, poll_id)]
elif route.startswith("/response/"):
# /response/{url_key}/{response_id}
parts = route.strip("/").split("/")
response_id = parts[-1] if len(parts) >= 3 else ""
return [response_view.ResponseView(page, response_id)]
elif route.startswith("/profile/"):
return [profile_view.ProfileView(page)]
return None

View File

@@ -1,92 +0,0 @@
import flet as ft
import designer as ds
class MainPageUser(ft.Column):
def __init__(self, page: ft.Page):
self._page = page
self.spacing_groop = ds.s(6)
self.border_radius = ds.s(15)
super().__init__(
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=self.spacing_groop,
controls=[
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
spacing=self.spacing_groop,
run_spacing=self.spacing_groop,
wrap=True,
controls=[
ft.Container(
border_radius=self.border_radius,
height=ds.s(160),
width=ds.s(160),
alignment=ft.Alignment(0.0, 0.0),
content=ft.Column(
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ft.Image(
src="icons/clipboard-question_16542596.svg",
width=ds.s(100),
height=ds.s(100),
)
]
),
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ds.CastomText(text="Все тесты")
]
)
]
),
bgcolor=ds.colors.surface,
on_click=self.take_test,
ink=True,
),
ft.Container(
border_radius=self.border_radius,
height=ds.s(160),
width=ds.s(160),
alignment=ft.Alignment(0.0, 0.0),
content=ft.Column(
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ft.Image(
src="icons/leaderboard-trophy_14227576.svg",
width=ds.s(100),
height=ds.s(100),
)
]
),
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ds.CastomText(text="Мои тесты")
]
)
]
),
bgcolor=ds.colors.surface,
on_click=self.my_test,
ink=True,
)
]
),
],
)
def take_test(self, e):
url_key = self._page.session.store.get("url_key") or ""
self._page.go(f"/take_test/{url_key}")
def my_test(self, e):
url_key = self._page.session.store.get("url_key") or ""
self._page.go(f"/my_tests/{url_key}")

View File

@@ -1,90 +0,0 @@
import asyncio
import flet as ft
import designer as ds
import api_client
class MyTestsPage(ft.Column):
def __init__(self, page: ft.Page):
self._page = page
self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light)
super().__init__(
alignment=ft.MainAxisAlignment.START,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
spacing=ds.s(8),
controls=[
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[self._loading],
)
],
)
async def load(self):
user_id = self._page.session.store.get("user_id")
if not user_id:
self.controls = [ds.CastomText(text="Нет данных пользователя", size=14)]
self.update()
return
responses = await api_client.get_user_responses(user_id)
if not responses:
self.controls = [ds.CastomText(text="Вы ещё не проходили тесты", size=14)]
self.update()
return
# Подгружаем названия опросов параллельно
poll_ids = list({r["poll_id"] for r in responses})
polls_list = await asyncio.gather(*[api_client.get_poll(pid) for pid in poll_ids])
polls_map = {p["id"]: p for p in polls_list if p}
self._render(responses, polls_map)
self.update()
def _render(self, responses: list, polls_map: dict):
url_key = self._page.session.store.get("url_key") or ""
# Сортируем по дате — новые вверху
responses = sorted(responses, key=lambda r: r["submitted_at"], reverse=True)
cards = []
for r in responses:
poll = polls_map.get(r["poll_id"])
title = poll["title"] if poll else "Неизвестный тест"
submitted_at = r["submitted_at"][:19].replace("T", " ")
cards.append(
ft.Container(
border_radius=ds.s(12),
padding=ds.s(16),
bgcolor=ds.colors.surface,
content=ft.Row(
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
controls=[
ft.Column(
spacing=ds.s(4),
expand=True,
controls=[
ds.CastomText(
text=title,
size=15,
weight=ft.FontWeight.BOLD,
),
ds.CastomText(
text=f"Пройден: {submitted_at}",
size=12,
),
],
),
ft.Icon(ft.Icons.CHEVRON_RIGHT, color=ds.colors.primary),
],
),
on_click=lambda e, rid=r["id"]: self._page.go(
f"/response/{url_key}/{rid}"
),
ink=True,
)
)
self.controls = cards

View File

@@ -1,361 +0,0 @@
import flet as ft
import base64
import designer as ds
import api_client
class PollPage(ft.Column):
def __init__(self, page: ft.Page, poll_id: str):
self._page = page
self._poll_id = poll_id
self._answers: dict[str, str] = {}
self._questions: list[dict] = []
self._current: int = 0
super().__init__(
expand=True,
alignment=ft.MainAxisAlignment.START,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
spacing=0,
controls=[
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light)],
)
],
)
async def load(self):
poll = await api_client.get_poll(self._poll_id)
if poll:
self._poll = poll
self._questions = poll.get("questions", [])
if self._questions:
self._current = 0
self._render_question()
else:
self.controls = [ds.CastomText(text="В тесте нет вопросов", size=16)]
else:
self.controls = [ds.CastomText(text="Тест не найден", size=16)]
self.update()
def _render_question(self):
q = self._questions[self._current]
total = len(self._questions)
idx = self._current
# --- Навигационная панель ---
prev_btn = ds.CastomButton(
text="← Предыдущий вопрос" if idx > 0 else "",
on_click=lambda _: self._go(self._current - 1),
)
if idx == 0:
prev_btn.disabled = True
stop_btn = ft.Button(
content=ft.Row(
tight=True,
spacing=6,
controls=[
ft.Icon(ft.Icons.STOP_CIRCLE_OUTLINED, color=ds.colors.primary, size=ds.s(16)),
ft.Text("Прервать тест", color=ds.colors.primary, size=ds.s(13)),
],
),
style=ft.ButtonStyle(
bgcolor=ds.colors.surface_alt,
color=ds.colors.primary,
),
on_click=self._confirm_abort,
)
counter_badge = ft.Container(
bgcolor=ds.colors.surface_alt,
border_radius=ds.s(10),
padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(10)),
content=ft.Text(
f"Вопрос {idx + 1} из {total}",
color=ds.colors.text_secondary,
size=ds.s(14),
weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER,
),
)
nav_row = ft.Column(
spacing=ds.s(6),
controls=[
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[counter_badge],
),
ft.Row(
wrap=True,
spacing=ds.s(8),
run_spacing=ds.s(8),
controls=[prev_btn, stop_btn],
),
],
)
# --- Блок вопроса ---
question_box = ft.Container(
border_radius=ds.s(14),
padding=ds.s(24),
bgcolor=ds.colors.surface,
content=ft.Text(
value=q.get("text", ""),
color=ds.colors.text_primary,
size=ds.s(16),
weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER,
no_wrap=False,
),
)
# --- Варианты ответа (Container-тайлы как в оригинальном рабочем коде) ---
choices = q.get("choices", [])
selected_choice = self._answers.get(q["id"])
choice_tiles: list[ft.Container] = []
def make_tile(c: dict) -> ft.Container:
cid = c["id"]
is_selected = cid == selected_choice
return ft.Container(
border_radius=ds.s(8),
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(10)),
bgcolor=ds.colors.primary_light if is_selected else ds.colors.surface_alt,
content=ft.Row(
spacing=ds.s(10),
vertical_alignment=ft.CrossAxisAlignment.CENTER,
controls=[
ft.Icon(
ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED,
color=ds.colors.primary,
size=ds.s(20),
),
ft.Text(
value=c["text"],
color=ds.colors.text_primary,
size=ds.s(14),
expand=True,
no_wrap=False,
),
],
),
data=cid,
ink=True,
)
for c in choices:
tile = make_tile(c)
tile.on_click = lambda e, _qid=q["id"]: self._select_answer(_qid, e.control.data)
choice_tiles.append(tile)
choices_col = ft.Column(controls=choice_tiles, spacing=ds.s(6))
self.controls = [
ft.Column(
scroll=ft.ScrollMode.AUTO,
spacing=ds.s(12),
controls=[
nav_row,
question_box,
choices_col,
],
)
]
# ------------------------------------------------------------------
# Обработчики
# ------------------------------------------------------------------
def _select_answer(self, question_id: str, choice_id: str):
self._answers[question_id] = choice_id
total = len(self._questions)
if self._current < total - 1:
self._go(self._current + 1)
else:
self._render_question()
self._append_finish_button()
self.update()
def _append_finish_button(self):
col = self.controls[0]
for ctrl in col.controls:
if getattr(ctrl, "data", None) == "__finish__":
return
finish_btn = ft.Button(
content=ft.Text(
"Завершить тест →",
color=ds.colors.text_on_primary,
size=ds.s(16),
weight=ft.FontWeight.BOLD,
),
data="__finish__",
style=ft.ButtonStyle(
bgcolor=ds.colors.primary,
color=ds.colors.text_on_primary,
),
on_click=self._submit,
)
col.controls.append(
ft.Row(
alignment=ft.MainAxisAlignment.END,
controls=[finish_btn],
)
)
def _go(self, idx: int):
self._current = idx
self._render_question()
q = self._questions[self._current]
if self._current == len(self._questions) - 1 and q["id"] in self._answers:
self._append_finish_button()
self.update()
def _confirm_abort(self, _):
url_key = self._page.session.store.get("url_key") or ""
def _do_abort(_):
dlg.open = False
self._page.update()
self._page.go(f"/take_test/{url_key}")
def _cancel(_):
dlg.open = False
self._page.update()
dlg = ft.AlertDialog(
modal=True,
title=ft.Text("Прервать тест?"),
content=ft.Text("Все ответы будут потеряны. Вы уверены?"),
actions=[
ft.TextButton(
content="Отмена",
on_click=_cancel,
),
ft.TextButton(
content="Прервать",
style=ft.ButtonStyle(color=ds.colors.secondary_light),
on_click=_do_abort,
),
],
actions_alignment=ft.MainAxisAlignment.END,
)
self._page.overlay.append(dlg)
dlg.open = True
self._page.update()
async def _submit(self, _):
questions = self._questions
unanswered = [q for q in questions if q["id"] not in self._answers]
if unanswered:
first_idx = questions.index(unanswered[0])
self._go(first_idx)
return
url_key = self._page.session.store.get("url_key") or ""
self.controls = [
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[ft.ProgressRing(width=ds.s(48), height=ds.s(48), color=ds.colors.secondary_light)],
)
]
self.update()
answers = [
{"question_id": qid, "choice_id": cid, "text": None}
for qid, cid in self._answers.items()
]
user_id = self._page.session.store.get("user_id")
group_id = self._page.session.store.get("group_id") or None
org_id = self._page.session.store.get("org_id") or None
response_id, error = await api_client.submit_response(
self._poll_id, answers, user_id, group_id, org_id
)
if error:
self.controls = [
ft.Column(
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
controls=[
ft.Icon(ft.Icons.ERROR_OUTLINE, size=ds.s(48), color=ds.colors.secondary_light),
ds.CastomText(text=error, size=14),
ds.CastomButton(
text="Попробовать снова",
on_click=lambda _: self._go(self._current),
),
],
)
]
self.update()
return
radar = await api_client.get_radar_result(response_id)
svg_bytes = (
await api_client.get_radar_svg_bytes(response_id)
if radar and radar.get("image_url")
else None
)
success_controls: list[ft.Control] = [
ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=ds.s(48), color=ds.colors.secondary_light),
ds.CastomText(text="Тест пройден!", size=20, weight=ft.FontWeight.BOLD),
]
if svg_bytes and radar and radar.get("image_url"):
b64 = base64.b64encode(svg_bytes).decode()
data_uri = f"data:image/svg+xml;base64,{b64}"
download_url = api_client.build_radar_download_url(self._page.url, response_id)
success_controls.append(
ft.Container(
expand=True,
content=ft.Image(
src=data_uri,
expand=True,
height=ds.s(380),
fit=ft.BoxFit.CONTAIN,
),
)
)
async def _download(_evt, _url=download_url):
await self._page.launch_url(_url)
success_controls.append(
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ds.CastomIconButton(
text="Сохранить диаграмму",
icon=ft.Icons.DOWNLOAD,
on_click=_download,
)
],
)
)
success_controls.append(
ds.CastomButton(
text="Вернуться к тестам",
on_click=lambda _: self._page.go(f"/take_test/{url_key}"),
)
)
self.controls = [
ft.Container(
padding=ds.s(20),
bgcolor=ds.colors.accent_lime,
border_radius=ds.s(16),
expand=True,
content=ft.Column(
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=ds.s(16),
controls=success_controls,
),
)
]
self.update()

View File

@@ -1,213 +0,0 @@
import asyncio
import flet as ft
import designer as ds
import api_client
import theme_manager as tm
class ProfilePage(ft.Column):
def __init__(self, page: ft.Page):
self._page = page
self._orgs: list = []
self._first_name = ds.CastomTextField_input(
label="Имя",
hint_text="Введите имя...",
prefix_icon=ft.Icons.PERSON,
)
self._last_name = ds.CastomTextField_input(
label="Фамилия",
hint_text="Введите фамилию...",
prefix_icon=ft.Icons.PERSON_OUTLINE,
)
self._org_dropdown = ds.SearchableDropdown(
label="Организация",
hint_text="Загрузка...",
)
self._org_dropdown.on_select = self._on_org_change
self._group_dropdown = ds.SearchableDropdown(
label="Группа",
hint_text="Загрузка...",
)
# ── Выбор темы ─────────────────────────────────────────────────
self._theme_dropdown = ds.SearchableDropdown(
label="Тема интерфейса",
hint_text="Выберите тему...",
options=[
ft.DropdownOption(key=t["id"], text=t["name"])
for t in tm.theme_list()
],
)
self._theme_dropdown.on_select = self._on_theme_preview
# ── Слайдер масштаба ───────────────────────────────────────────
self._scale_value = ds.SCALE
self._scale_label = ft.Text(
value=f"Масштаб: {ds.SCALE:.1f}×",
size=ds.s(13),
color=ds.colors.text_secondary,
)
self._scale_slider = ft.Slider(
min=tm.SCALE_MIN,
max=tm.SCALE_MAX,
divisions=round((tm.SCALE_MAX - tm.SCALE_MIN) / tm.SCALE_STEP),
value=ds.SCALE,
label="{value}×",
active_color=ds.colors.primary,
inactive_color=ds.colors.surface_alt,
thumb_color=ds.colors.primary,
on_change=self._on_scale_preview,
on_change_end=self._on_scale_apply,
)
self._loading = ft.ProgressRing(visible=False, width=ds.s(24), height=ds.s(24))
self._status = ft.Text(value="", size=ds.s(14))
super().__init__(
spacing=ds.s(16),
controls=[
ds.CastomText("Редактировать профиль", size=18, weight=ft.FontWeight.BOLD),
self._first_name,
self._last_name,
self._org_dropdown,
self._group_dropdown,
ft.Divider(height=ds.s(8), color=ds.colors.border),
ds.CastomText("Внешний вид", size=16, weight=ft.FontWeight.BOLD),
self._theme_dropdown,
self._scale_label,
self._scale_slider,
ft.Row(
spacing=ds.s(12),
controls=[
ds.CastomButton(text="Сохранить", on_click=self._save),
self._loading,
],
),
self._status,
],
)
# ── загрузка данных ─────────────────────────────────────────────────────
async def load(self):
token = self._page.session.store.get("auth_token") or ""
me, orgs, groups = await asyncio.gather(
api_client.get_me(token),
api_client.get_organizations(),
api_client.get_groups(),
)
self._orgs = orgs or []
_opt_style = ft.ButtonStyle(color=ds.colors.text_primary)
self._org_dropdown.options = [
ft.DropdownOption(key=o["id"], text=o["name_organization"], style=_opt_style)
for o in self._orgs
]
self._org_dropdown.hint_text = "Выберите организацию..."
self._group_dropdown.options = [
ft.DropdownOption(key=g["id"], text=g["name_group"], style=_opt_style)
for g in (groups or [])
]
self._group_dropdown.hint_text = "Выберите группу..."
# устанавливаем текущие настройки внешнего вида
self._theme_dropdown.value = tm.load_theme_id(self._page)
saved_scale = tm.load_scale(self._page)
self._scale_slider.value = saved_scale
self._scale_value = saved_scale
self._scale_label.value = f"Масштаб: {saved_scale:.1f}×"
if me:
self._first_name.value = me.get("first_name", "")
self._last_name.value = me.get("last_name", "")
self._org_dropdown.value = me.get("organization_id")
self._group_dropdown.value = me.get("group_id")
self.update()
# ── обработчики ─────────────────────────────────────────────────────────
def _on_org_change(self, e):
selected_id = e.control.value
for o in self._orgs:
if o["id"] == selected_id:
self._group_dropdown.value = o.get("group_id")
break
self.update()
def _on_theme_preview(self, e):
"""Применить тему предварительно (только bgcolor страницы)."""
theme_id = e.control.value
if theme_id:
tm.apply_theme(self._page, theme_id)
self._page.update()
def _on_scale_preview(self, e):
"""Обновить подпись слайдера во время перетаскивания."""
v = round(e.control.value, 1)
self._scale_value = v
self._scale_label.value = f"Масштаб: {v:.1f}×"
self._scale_label.update()
def _on_scale_apply(self, e):
"""Применить масштаб сразу при отпускании слайдера."""
scale = round(e.control.value, 1)
self._scale_value = scale
theme_id = self._theme_dropdown.value or tm.load_theme_id(self._page)
tm.save_preferences(self._page, theme_id, scale)
tm.apply_scale(self._page, scale)
tm.apply_theme(self._page, theme_id)
url_key = self._page.session.store.get("url_key") or ""
self._page.go(f"/profile/{url_key}")
# ── сохранение ──────────────────────────────────────────────────────────
async def _save(self, e):
self._loading.visible = True
self._status.value = ""
self.update()
token = self._page.session.store.get("auth_token") or ""
user_id = self._page.session.store.get("user_id") or ""
fn = (self._first_name.value or "").strip() or None
ln = (self._last_name.value or "").strip() or None
data, err = await api_client.update_user(
user_id=user_id,
token=token,
first_name=fn,
last_name=ln,
group_id=self._group_dropdown.value,
organization_id=self._org_dropdown.value,
)
self._loading.visible = False
if err:
self._status.value = err
self._status.color = "red"
self.update()
return
# всё успешно — сохраняем тему и масштаб в localStorage
theme_id = self._theme_dropdown.value or "default"
scale = self._scale_value
tm.save_preferences(self._page, theme_id, scale)
tm.apply_scale(self._page, scale)
tm.apply_theme(self._page, theme_id)
if data:
self._page.session.store.set("group_id", data.get("group_id") or "")
self._page.session.store.set("org_id", data.get("organization_id") or "")
# перезагружаем текущую страницу — пересоздаёт все виджеты
# с новым SCALE и новыми цветами
url_key = self._page.session.store.get("url_key") or ""
self._page.go(f"/profile/{url_key}")

View File

@@ -1,132 +0,0 @@
import flet as ft
import base64
import designer as ds
import api_client
class ResponsePage(ft.Column):
def __init__(self, page: ft.Page, response_id: str):
self._page = page
self._response_id = response_id
self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light)
super().__init__(
alignment=ft.MainAxisAlignment.START,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
spacing=ds.s(8),
controls=[
ft.Row(alignment=ft.MainAxisAlignment.CENTER, controls=[self._loading])
],
)
async def load(self):
response = await api_client.get_response(self._response_id)
if not response:
self.controls = [ds.CastomText(text="Результат не найден", size=14, color="#FF6B6B")]
self.update()
return
poll = await api_client.get_poll(response["poll_id"])
if not poll:
self.controls = [ds.CastomText(text="Тест не найден", size=14, color="#FF6B6B")]
self.update()
return
radar = await api_client.get_radar_result(self._response_id)
svg_bytes = await api_client.get_radar_svg_bytes(self._response_id) if radar and radar.get("image_url") else None
self._render(response, poll, radar, svg_bytes)
self.update()
def _render(self, response: dict, poll: dict, radar: dict | None, svg_bytes: bytes | None = None):
questions_map = {q["id"]: q for q in poll.get("questions", [])}
submitted_at = response["submitted_at"][:19].replace("T", " ")
controls: list[ft.Control] = [
ft.Container(
padding=ft.padding.only(bottom=ds.s(8)),
content=ft.Column(spacing=ds.s(4), controls=[
ds.CastomText(text=poll.get("title", ""), size=20, weight=ft.FontWeight.BOLD),
ds.CastomText(text=f"Пройден: {submitted_at}", size=12),
]),
)
]
# ── Паутинная диаграмма ──────────────────────────────────────────────
if svg_bytes and radar and radar.get("image_url"):
b64 = base64.b64encode(svg_bytes).decode()
data_uri = f"data:image/svg+xml;base64,{b64}"
download_url = api_client.build_radar_download_url(self._page.url, self._response_id)
async def _download(_evt, _url=download_url):
await self._page.launch_url(_url)
controls.append(ft.Container(
border_radius=ds.s(16),
padding=ds.s(12),
bgcolor=ds.colors.surface_alt,
content=ft.Column(
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=ds.s(10),
controls=[
ds.CastomText(text="Результат", size=16, weight=ft.FontWeight.BOLD),
ft.Container(
expand=True,
content=ft.Image(
src=data_uri,
expand=True,
height=ds.s(380),
fit=ft.BoxFit.CONTAIN,
),
),
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ds.CastomIconButton(
text="Сохранить диаграмму",
icon=ft.Icons.DOWNLOAD,
on_click=_download,
)
],
),
],
),
))
# ── Ответы ───────────────────────────────────────────────────────────
answer_cards: list[ft.Control] = []
for answer in response.get("answers", []):
q = questions_map.get(answer["question_id"])
if not q:
continue
choice_rows = []
for c in q.get("choices", []):
is_selected = c["id"] == answer.get("choice_id")
choice_rows.append(ft.Container(
border_radius=ds.s(8),
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(6)),
bgcolor=ds.colors.primary_light if is_selected else ds.colors.surface_alt,
content=ft.Row(spacing=ds.s(8), controls=[
ft.Icon(
ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED,
color=ds.colors.primary, size=ds.s(16),
),
ft.Container(expand=True, content=ds.CastomText(text=c["text"], size=13)),
]),
))
answer_cards.append(ft.Container(
border_radius=ds.s(12),
padding=ds.s(16),
bgcolor=ds.colors.surface,
content=ft.Column(spacing=ds.s(8), controls=[
ds.CastomText(text=q.get("text", ""), size=14, weight=ft.FontWeight.BOLD),
*choice_rows,
]),
))
if answer_cards:
controls.append(ds.CastomText(text="Ответы", size=16, weight=ft.FontWeight.BOLD))
controls.extend(answer_cards)
self.controls = controls

View File

@@ -1,53 +0,0 @@
import flet as ft
import designer as ds
import api_client
class TakeTestPage(ft.Column):
def __init__(self, page: ft.Page):
self._page = page
self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light)
super().__init__(
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
controls=[self._loading],
)
async def load(self):
await self._load_polls()
async def _load_polls(self):
polls = await api_client.get_polls()
if polls:
self._render_polls(polls)
else:
self.controls = [ds.CastomText(text="Тестов пока нет", size=16)]
self.update()
def _render_polls(self, polls: list):
self.controls = [
ft.Container(
border_radius=ds.s(12),
padding=ds.s(16),
bgcolor=ds.colors.surface,
content=ft.Column(
spacing=ds.s(6),
controls=[
ds.CastomText(
text=poll.get("title", ""),
size=16,
weight=ft.FontWeight.BOLD,
),
ds.CastomText(
text=poll.get("description") or "",
size=13,
),
],
),
on_click=lambda e, pid=poll["id"]: self._page.go(
f"/poll/{self._page.session.store.get('url_key') or ''}/{pid}"
),
ink=True,
)
for poll in polls
]

View File

@@ -1,104 +0,0 @@
import flet as ft
import designer as ds
from info import AuthService
class LoginView(ft.View):
def __init__(self, page: ft.Page):
self._auth = AuthService(page)
self.pageS = page
self._username = ds.CastomTextField_input(
label="Логин",
hint_text="Введите логин...",
prefix_icon=ft.Icons.PERSON,
)
self._password = ds.CastomTextField_input(
label="Пароль",
hint_text="Введите пароль...",
prefix_icon=ft.Icons.LOCK,
password=True,
can_reveal_password=True,
)
self._error = ft.Text("", color=ft.Colors.RED_400, size=ds.s(14), visible=False)
self._btn = ds.CastomButton(
text="Войти",
on_click=lambda e: page.run_task(self._do_login),
)
self._reg_link = ft.TextButton(
content=ft.Text("Нет аккаунта? Зарегистрироваться", color=ds.colors.primary, size=ds.s(14)),
on_click=self.open_dialog,
)
super().__init__(
route="/login",
bgcolor=ds.colors.background,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.CENTER,
padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(24)),
scroll=ft.ScrollMode.AUTO,
)
self.controls = [
ft.Text("ЦОПП", color=ds.colors.primary, size=ds.s(30), weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER),
ft.Container(height=ds.s(8)),
ft.Text("Это проект цопп по проведению иследовательских тестирований", color=ds.colors.primary, size=ds.s(16),
text_align=ft.TextAlign.CENTER),
ft.Container(height=ds.s(24)),
ft.Container(
padding=ft.padding.all(ds.s(24)),
bgcolor=ds.colors.surface_alt,
border_radius=ds.s(16),
alignment=ft.Alignment.CENTER,
content=ft.Column(
spacing=ds.s(16),
controls=[
self._username,
self._password,
self._error,
self._btn,
self._reg_link,
],
),
),
]
def open_dialog(self, e):
self.dialog = ds.AlterDiaalogApproval(
on_accept=lambda: self.pageS.run_task(self.pageS.push_route, "/reg")
)
self.dialog.open = True
self.pageS.overlay.append(self.dialog)
self.pageS.update()
async def _do_login(self):
username = self._username.value.strip()
password = self._password.value
if not username or not password:
self._show_error("Заполните логин и пароль")
return
self._btn.disabled = True
self._error.visible = False
self.update()
error = await self._auth.login(username, password)
if error:
self._btn.disabled = False
self._show_error(error)
else:
# Успешный вход — переходим на страницу пользователя
url_key = self.page.session.store.get("url_key")
await self.page.push_route(f"/user/{url_key}")
def _show_error(self, message: str):
self._error.value = message
self._error.visible = True
self.update()

View File

@@ -1,41 +0,0 @@
import flet as ft
import asyncio
import designer as ds
class MainView(ft.View):
def __init__(self):
self.button_temp = ds.CastomButton(
text="Приступим",
on_click=self.open_dialog,
)
super().__init__(
route="/main",
bgcolor=ds.colors.background,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
vertical_alignment=ft.MainAxisAlignment.CENTER,
)
self.controls = [
ft.Text("ЦОПП",color=ds.colors.primary, size=ds.s(30)),
ft.Text("Это проект цопп по проведению иследовательских тестирований",color=ds.colors.primary, size=ds.s(16)),
ft.Container(width=0, height=ds.s(20)),
self.button_temp
]
def did_mount(self):
async def _open_later():
await asyncio.sleep(0.1)
self.open_dialog(None)
self.page.run_task(_open_later)
def open_dialog(self, e):
self.dialog = ds.AlterDiaalogApproval()
self.dialog.open = True
self.page.overlay.append(self.dialog)
self.page.update()
def close_dialog(self, e):
self.dialog.open = False
self.page.update()

View File

@@ -1,47 +0,0 @@
import flet as ft
import designer as ds
from views.content_users.my_tests_page import MyTestsPage
class MyTestsView(ft.View):
def __init__(self, page: ft.Page):
url_key = page.session.store.get("url_key") or ""
back_btn = ft.IconButton(
icon=ft.Icons.ARROW_BACK,
icon_color=ds.colors.primary,
on_click=lambda _: page.go(f"/user/{url_key}"),
)
self._content = MyTestsPage(page)
super().__init__(
route=f"/my_tests/{url_key}",
bgcolor=ds.colors.background,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START,
scroll=ft.ScrollMode.AUTO,
controls=[
ft.Column(
expand=True,
spacing=ds.s(10),
controls=[
ft.Row(
controls=[
back_btn,
ds.CastomText(
text="Мои тесты",
size=22,
weight=ft.FontWeight.BOLD,
expand=True,
),
]
),
self._content,
],
)
],
)
async def load(self):
await self._content.load()

View File

@@ -1,47 +0,0 @@
import flet as ft
import designer as ds
from views.content_users.poll_page import PollPage
class PollView(ft.View):
def __init__(self, page: ft.Page, poll_id: str):
url_key = page.session.store.get("url_key") or ""
back_btn = ft.IconButton(
icon=ft.Icons.ARROW_BACK,
icon_color=ds.colors.primary,
on_click=lambda _: page.go(f"/take_test/{url_key}"),
)
self._content = PollPage(page, poll_id)
super().__init__(
route=f"/poll/{url_key}/{poll_id}",
bgcolor=ds.colors.background,
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START,
scroll=ft.ScrollMode.AUTO,
controls=[
ft.Column(
spacing=ds.s(10),
controls=[
ft.Row(
controls=[
back_btn,
ds.CastomText(
text="Прохождение теста",
size=22,
weight=ft.FontWeight.BOLD,
expand=True,
),
]
),
self._content,
],
)
],
)
async def load(self):
await self._content.load()

View File

@@ -1,47 +0,0 @@
import flet as ft
import designer as ds
from views.content_users.profile_page import ProfilePage
class ProfileView(ft.View):
def __init__(self, page: ft.Page):
url_key = page.session.store.get("url_key") or ""
back_btn = ft.IconButton(
icon=ft.Icons.ARROW_BACK,
icon_color=ds.colors.primary,
on_click=lambda _: page.go(f"/user/{url_key}"),
)
self._content = ProfilePage(page)
super().__init__(
route=f"/profile/{url_key}",
bgcolor=ds.colors.background,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START,
scroll=ft.ScrollMode.AUTO,
controls=[
ft.Column(
expand=True,
spacing=ds.s(10),
controls=[
ft.Row(
controls=[
back_btn,
ds.CastomText(
text="Профиль",
size=22,
weight=ft.FontWeight.BOLD,
expand=True,
),
]
),
self._content,
],
)
],
)
async def load(self):
await self._content.load()

View File

@@ -1,172 +0,0 @@
import asyncio
import flet as ft
import designer as ds
from info import AuthService
class RegView(ft.View):
def __init__(self, page: ft.Page):
self._auth = AuthService(page)
self._orgs: list = []
self._first_name = ds.CastomTextField_input(
label="Имя",
hint_text="Введите имя...",
prefix_icon=ft.Icons.PERSON,
)
self._last_name = ds.CastomTextField_input(
label="Фамилия",
hint_text="Введите фамилию...",
prefix_icon=ft.Icons.PERSON_OUTLINE,
)
self._username = ds.CastomTextField_input(
label="Логин",
hint_text="Введите логин...",
prefix_icon=ft.Icons.ALTERNATE_EMAIL,
)
self._password = ds.CastomTextField_input(
label="Пароль",
hint_text="Введите пароль...",
prefix_icon=ft.Icons.LOCK,
password=True,
can_reveal_password=True,
)
self._org_dropdown = ds.SearchableDropdown(
label="Организация",
hint_text="Загрузка...",
)
self._org_dropdown.on_select = self._on_org_change
self._group_dropdown = ds.SearchableDropdown(
label="Группа",
hint_text="Загрузка...",
)
self._error = ft.Text("", color=ft.Colors.RED_400, size=ds.s(14), visible=False)
self._btn = ds.CastomButton(
text="Зарегистрироваться",
on_click=lambda e: page.run_task(self._do_register),
)
self._btn_cansel = ds.CastomButton(
text="Назад",
on_click=lambda e: page.run_task(self._do_cancel),
)
super().__init__(
route="/reg",
bgcolor=ds.colors.background,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.CENTER,
padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(24)),
scroll=ft.ScrollMode.AUTO,
)
self.controls = [
ft.Text("COPP", color=ds.colors.primary, size=ds.s(30), weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER),
ft.Container(height=ds.s(8)),
ft.Text("Регистрация в системе", color=ds.colors.primary, size=ds.s(16),
text_align=ft.TextAlign.CENTER),
ft.Container(height=ds.s(24)),
ft.Container(
padding=ft.padding.all(ds.s(24)),
bgcolor=ds.colors.surface_alt,
border_radius=ds.s(16),
content=ft.Column(
spacing=ds.s(16),
controls=[
self._first_name,
self._last_name,
self._username,
self._password,
self._org_dropdown,
self._group_dropdown,
self._error,
ft.Row(
wrap=True,
spacing=ds.s(8),
run_spacing=ds.s(8),
controls=[
self._btn_cansel,
self._btn,
],
)
],
),
),
]
def did_mount(self):
self.page.run_task(self._load_data)
async def _load_data(self):
orgs, groups = await asyncio.gather(
self._auth.get_organizations(),
self._auth.get_groups(),
)
self._orgs = orgs or []
_opt_style = ft.ButtonStyle(color=ds.colors.text_primary)
self._org_dropdown.options = [
ft.DropdownOption(key=o["id"], text=o["name_organization"], style=_opt_style) for o in self._orgs
]
self._org_dropdown.hint_text = "Выберите организацию..."
self._group_dropdown.options = [
ft.DropdownOption(key=g["id"], text=g["name_group"], style=_opt_style) for g in (groups or [])
]
self._group_dropdown.hint_text = "Выберите группу..."
self.update()
def _on_org_change(self, e):
# Автоматически выбираем группу, к которой привязана организация
selected_id = e.control.value
for o in self._orgs:
if o["id"] == selected_id:
self._group_dropdown.value = o.get("group_id")
break
self.update()
async def _do_cancel(self):
await self.page.push_route("/login")
async def _do_register(self):
first_name = self._first_name.value.strip() if self._first_name.value else ""
last_name = self._last_name.value.strip() if self._last_name.value else ""
username = self._username.value.strip() if self._username.value else ""
password = self._password.value or ""
if not all([first_name, last_name, username, password]):
self._show_error("Заполните все обязательные поля")
return
self._btn.disabled = True
self._error.visible = False
self.update()
error = await self._auth.register(
username=username,
password=password,
first_name=first_name,
last_name=last_name,
group_id=self._group_dropdown.value,
organization_id=self._org_dropdown.value,
)
if error:
self._btn.disabled = False
self._show_error(error)
else:
await self.page.push_route("/login")
def _show_error(self, message: str):
self._error.value = message
self._error.visible = True
self.update()

View File

@@ -1,48 +0,0 @@
import flet as ft
import designer as ds
from views.content_users.response_page import ResponsePage
class ResponseView(ft.View):
def __init__(self, page: ft.Page, response_id: str):
url_key = page.session.store.get("url_key") or ""
back_btn = ft.IconButton(
icon=ft.Icons.ARROW_BACK,
icon_color=ds.colors.primary,
on_click=lambda _: page.go(f"/my_tests/{url_key}"),
)
self._content = ResponsePage(page, response_id)
super().__init__(
route=f"/response/{url_key}/{response_id}",
bgcolor=ds.colors.background,
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START,
scroll=ft.ScrollMode.AUTO,
controls=[
ft.Column(
expand=True,
spacing=ds.s(10),
controls=[
ft.Row(
controls=[
back_btn,
ds.CastomText(
text="Результаты теста",
size=22,
weight=ft.FontWeight.BOLD,
expand=True,
),
]
),
self._content,
],
)
],
)
async def load(self):
await self._content.load()

View File

@@ -1,47 +0,0 @@
import flet as ft
import designer as ds
from views.content_users.take_test_page import TakeTestPage
class TakeTestView(ft.View):
def __init__(self, page: ft.Page):
url_key = page.session.store.get("url_key") or ""
back_btn = ft.IconButton(
icon=ft.Icons.ARROW_BACK,
icon_color=ds.colors.primary,
on_click=lambda _: page.go(f"/user/{url_key}"),
)
self._content = TakeTestPage(page)
super().__init__(
route=f"/take_test/{url_key}",
bgcolor=ds.colors.background,
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
horizontal_alignment=ft.CrossAxisAlignment.START,
vertical_alignment=ft.MainAxisAlignment.START,
scroll=ft.ScrollMode.AUTO,
controls=[
ft.Column(
spacing=ds.s(10),
controls=[
ft.Row(
controls=[
back_btn,
ds.CastomText(
text="Все тесты",
size=22,
weight=ft.FontWeight.BOLD,
expand=True,
),
]
),
self._content,
],
)
],
)
async def load(self):
await self._content.load()

View File

@@ -1,48 +0,0 @@
import flet as ft
import designer as ds
from info import AuthService
from views.content_users.main_page import MainPageUser
class UserView(ft.View):
def __init__(self, page: ft.Page):
self._auth = AuthService(page)
url_key = page.session.store.get("url_key") or ""
profile_btn = ft.IconButton(
icon=ft.Icons.ACCOUNT_CIRCLE,
icon_color=ds.colors.primary,
icon_size=ds.s(30),
tooltip="Профиль",
on_click=lambda _: page.go(f"/profile/{url_key}"),
)
super().__init__(
route=f"/user/{url_key}",
bgcolor=ds.colors.background,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START,
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
scroll=ft.ScrollMode.AUTO,
)
self.controls = [
ft.Column(
spacing=ds.s(12),
controls=[
ft.Row(
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
controls=[
ft.Text(
"Добро пожаловать в ЦОПП!",
color=ds.colors.primary,
size=ds.s(20),
weight=ft.FontWeight.BOLD,
expand=True,
),
profile_btn,
]
),
MainPageUser(page),
]
)
]