diff --git a/Dockerfile.web b/Dockerfile.web
deleted file mode 100644
index 5b18f32..0000000
--- a/Dockerfile.web
+++ /dev/null
@@ -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"]
\ No newline at end of file
diff --git a/web/api_client.py b/web/api_client.py
deleted file mode 100644
index e7cbb70..0000000
--- a/web/api_client.py
+++ /dev/null
@@ -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}"
diff --git a/web/assets/favicon.png b/web/assets/favicon.png
deleted file mode 100644
index 8ecde27..0000000
Binary files a/web/assets/favicon.png and /dev/null differ
diff --git a/web/assets/icons/IMG_4533.png b/web/assets/icons/IMG_4533.png
deleted file mode 100644
index 7c9703b..0000000
Binary files a/web/assets/icons/IMG_4533.png and /dev/null differ
diff --git a/web/assets/icons/README b/web/assets/icons/README
deleted file mode 100644
index 709aa22..0000000
--- a/web/assets/icons/README
+++ /dev/null
@@ -1,3 +0,0 @@
-# Положи сюда своё изображение с именем loading-animation.png
-# Это заменит мигающий логотип Flet при загрузке страницы.
-# Рекомендуемый размер: PNG, минимум 200x200 px.
diff --git a/web/assets/icons/clipboard-question_16542596.svg b/web/assets/icons/clipboard-question_16542596.svg
deleted file mode 100644
index 803761e..0000000
--- a/web/assets/icons/clipboard-question_16542596.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
diff --git a/web/assets/icons/leaderboard-trophy_14227576.svg b/web/assets/icons/leaderboard-trophy_14227576.svg
deleted file mode 100644
index 011f4e3..0000000
--- a/web/assets/icons/leaderboard-trophy_14227576.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
diff --git a/web/assets/icons/loading-animation.png b/web/assets/icons/loading-animation.png
deleted file mode 100644
index 91c0a0c..0000000
Binary files a/web/assets/icons/loading-animation.png and /dev/null differ
diff --git a/web/designer.py b/web/designer.py
deleted file mode 100644
index 88fe9e8..0000000
--- a/web/designer.py
+++ /dev/null
@@ -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")
\ No newline at end of file
diff --git a/web/info.py b/web/info.py
deleted file mode 100644
index 55db674..0000000
--- a/web/info.py
+++ /dev/null
@@ -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()
diff --git a/web/main.py b/web/main.py
deleted file mode 100644
index d0d8b45..0000000
--- a/web/main.py
+++ /dev/null
@@ -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),
- ]
-)
diff --git a/web/radar_chart.py b/web/radar_chart.py
deleted file mode 100644
index 4a8d7db..0000000
--- a/web/radar_chart.py
+++ /dev/null
@@ -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,
- ],
- )
diff --git a/web/radar_svg.py b/web/radar_svg.py
deleted file mode 100644
index 9e4eaf4..0000000
--- a/web/radar_svg.py
+++ /dev/null
@@ -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''
- )
-
- 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'")
- 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}"
diff --git a/web/router.py b/web/router.py
deleted file mode 100644
index e8360aa..0000000
--- a/web/router.py
+++ /dev/null
@@ -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
diff --git a/web/theme_manager.py b/web/theme_manager.py
deleted file mode 100644
index 97b07f8..0000000
--- a/web/theme_manager.py
+++ /dev/null
@@ -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))
diff --git a/web/view.py b/web/view.py
deleted file mode 100644
index cf3b513..0000000
--- a/web/view.py
+++ /dev/null
@@ -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
diff --git a/web/views/content_users/main_page.py b/web/views/content_users/main_page.py
deleted file mode 100644
index 0192c03..0000000
--- a/web/views/content_users/main_page.py
+++ /dev/null
@@ -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}")
\ No newline at end of file
diff --git a/web/views/content_users/my_tests_page.py b/web/views/content_users/my_tests_page.py
deleted file mode 100644
index d10b46e..0000000
--- a/web/views/content_users/my_tests_page.py
+++ /dev/null
@@ -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
diff --git a/web/views/content_users/poll_page.py b/web/views/content_users/poll_page.py
deleted file mode 100644
index 8358c45..0000000
--- a/web/views/content_users/poll_page.py
+++ /dev/null
@@ -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()
diff --git a/web/views/content_users/profile_page.py b/web/views/content_users/profile_page.py
deleted file mode 100644
index 83323e4..0000000
--- a/web/views/content_users/profile_page.py
+++ /dev/null
@@ -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}")
-
diff --git a/web/views/content_users/response_page.py b/web/views/content_users/response_page.py
deleted file mode 100644
index 3f0aad8..0000000
--- a/web/views/content_users/response_page.py
+++ /dev/null
@@ -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
diff --git a/web/views/content_users/take_test_page.py b/web/views/content_users/take_test_page.py
deleted file mode 100644
index 53064af..0000000
--- a/web/views/content_users/take_test_page.py
+++ /dev/null
@@ -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
- ]
\ No newline at end of file
diff --git a/web/views/login_view.py b/web/views/login_view.py
deleted file mode 100644
index 96809ea..0000000
--- a/web/views/login_view.py
+++ /dev/null
@@ -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()
diff --git a/web/views/main_view.py b/web/views/main_view.py
deleted file mode 100644
index 40c6e5b..0000000
--- a/web/views/main_view.py
+++ /dev/null
@@ -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()
-
\ No newline at end of file
diff --git a/web/views/my_tests_view.py b/web/views/my_tests_view.py
deleted file mode 100644
index 0a91508..0000000
--- a/web/views/my_tests_view.py
+++ /dev/null
@@ -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()
diff --git a/web/views/poll_view.py b/web/views/poll_view.py
deleted file mode 100644
index 82bc130..0000000
--- a/web/views/poll_view.py
+++ /dev/null
@@ -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()
diff --git a/web/views/profile_view.py b/web/views/profile_view.py
deleted file mode 100644
index 40a26ec..0000000
--- a/web/views/profile_view.py
+++ /dev/null
@@ -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()
diff --git a/web/views/reg_view.py b/web/views/reg_view.py
deleted file mode 100644
index b72dfe0..0000000
--- a/web/views/reg_view.py
+++ /dev/null
@@ -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()
-
diff --git a/web/views/response_view.py b/web/views/response_view.py
deleted file mode 100644
index 489e021..0000000
--- a/web/views/response_view.py
+++ /dev/null
@@ -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()
diff --git a/web/views/take_test_view.py b/web/views/take_test_view.py
deleted file mode 100644
index 3f12f86..0000000
--- a/web/views/take_test_view.py
+++ /dev/null
@@ -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()
diff --git a/web/views/user_view.py b/web/views/user_view.py
deleted file mode 100644
index d142a73..0000000
--- a/web/views/user_view.py
+++ /dev/null
@@ -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),
- ]
- )
- ]