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'' - 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'', - ] - - # ── Сетка ───────────────────────────────────────────────────────────────── - 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'' - ) - - # ── Оси от центра ───────────────────────────────────────────────────────── - for i in range(n): - x, y = axis_xy(i) - lines.append( - f'' - ) - - # ── Полигоны данных ─────────────────────────────────────────────────────── - 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'') - lines.append(f'') - - # ── Точки на вершинах ───────────────────────────────────────────────────── - 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'' - ) - - # ── Метки шкалы (первая ось) ────────────────────────────────────────────── - for lvl in range(1, 6): - frac = lvl / 5 - tx, ty = axis_xy(0, frac) - lines.append( - f'{max_abs * frac:.0f}' - ) - - # ── Подписи осей ─────────────────────────────────────────────────────────── - 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'{name}' - ) - lines.append( - f'{val}' - ) - - lines.append("") - 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), - ] - ) - ]