chore: удалить мёртвый Flet-фронтенд web/ и Dockerfile.web
Заменён на web-react в eac6192, но старый код и его Dockerfile
оставались в репозитории неиспользуемыми и вводили в заблуждение.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||
@@ -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 |
@@ -1,3 +0,0 @@
|
||||
# Положи сюда своё изображение с именем loading-animation.png
|
||||
# Это заменит мигающий логотип Flet при загрузке страницы.
|
||||
# Рекомендуемый размер: PNG, минимум 200x200 px.
|
||||
@@ -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 |
@@ -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 |
546
web/designer.py
546
web/designer.py
@@ -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")
|
||||
77
web/info.py
77
web/info.py
@@ -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()
|
||||
69
web/main.py
69
web/main.py
@@ -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),
|
||||
]
|
||||
)
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
128
web/radar_svg.py
128
web/radar_svg.py
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
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}"
|
||||
@@ -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
|
||||
@@ -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))
|
||||
42
web/view.py
42
web/view.py
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
]
|
||||
)
|
||||
]
|
||||
Reference in New Issue
Block a user