diff --git a/bd/tables/response.py b/bd/tables/response.py index 0ee3e73..41e4daa 100644 --- a/bd/tables/response.py +++ b/bd/tables/response.py @@ -12,6 +12,9 @@ class Response(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) poll_id = Column(UUID(as_uuid=True), ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) user_id = Column(UUID(as_uuid=True), nullable=True) + # Снимок группы и организации пользователя на момент прохождения + group_id = Column(UUID(as_uuid=True), nullable=True) + organization_id = Column(UUID(as_uuid=True), nullable=True) submitted_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) answers = relationship("Answer", backref="response", cascade="all, delete-orphan", passive_deletes=True) diff --git a/route/response_crud.py b/route/response_crud.py index 6faf996..fc47c5e 100644 --- a/route/response_crud.py +++ b/route/response_crud.py @@ -19,7 +19,9 @@ class AnswerIn(BaseModel): class ResponseIn(BaseModel): - user_id: Optional[str] + user_id: Optional[str] = None + group_id: Optional[str] = None + organization_id: Optional[str] = None answers: List[AnswerIn] @@ -45,6 +47,16 @@ def submit_response(poll_id: str, payload: ResponseIn): resp.user_id = uuid.UUID(payload.user_id) except Exception: raise HTTPException(status_code=400, detail="Invalid user_id UUID") + if payload.group_id: + try: + resp.group_id = uuid.UUID(payload.group_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid group_id UUID") + if payload.organization_id: + try: + resp.organization_id = uuid.UUID(payload.organization_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid organization_id UUID") session.add(resp) # validate answers for a in payload.answers: @@ -102,7 +114,18 @@ def get_response(response_id: str): r = session.get(Response, rid) if not r: raise HTTPException(status_code=404, detail="Response not found") - return {"id": str(r.id), "poll_id": str(r.poll_id), "user_id": str(r.user_id) if r.user_id else None, "submitted_at": r.submitted_at.isoformat(), "answers": [{"id": str(a.id), "question_id": str(a.question_id), "choice_id": str(a.choice_id) if a.choice_id else None, "text": a.text} for a in r.answers]} + return { + "id": str(r.id), + "poll_id": str(r.poll_id), + "user_id": str(r.user_id) if r.user_id else None, + "group_id": str(r.group_id) if r.group_id else None, + "organization_id": str(r.organization_id) if r.organization_id else None, + "submitted_at": r.submitted_at.isoformat(), + "answers": [ + {"id": str(a.id), "question_id": str(a.question_id), "choice_id": str(a.choice_id) if a.choice_id else None, "text": a.text} + for a in r.answers + ], + } @router.get("/users/{user_id}/responses") diff --git a/web/api_client.py b/web/api_client.py new file mode 100644 index 0000000..7faa548 --- /dev/null +++ b/web/api_client.py @@ -0,0 +1,233 @@ +""" +api_client.py — единое место для всех HTTP-запросов к бэкенду. +Не зависит от Flet. Все функции async, возвращают данные или None/ошибку. +""" +import os +from typing import Any + +import httpx + +API_URL = os.getenv("API_URL", "http://api:8000") +_TIMEOUT = 10 + + +# --------------------------------------------------------------------------- +# 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 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", "Ошибка регистрации") + 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 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/icons/clipboard-question_16542596.svg b/web/assets/icons/clipboard-question_16542596.svg new file mode 100644 index 0000000..803761e --- /dev/null +++ b/web/assets/icons/clipboard-question_16542596.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/assets/icons/leaderboard-trophy_14227576.svg b/web/assets/icons/leaderboard-trophy_14227576.svg new file mode 100644 index 0000000..011f4e3 --- /dev/null +++ b/web/assets/icons/leaderboard-trophy_14227576.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/designer.py b/web/designer.py index 3457258..5db05d1 100644 --- a/web/designer.py +++ b/web/designer.py @@ -7,7 +7,15 @@ class clolors: laer3 = "#2F184B" ferst = "#F4EFFA" - +class CastomText(ft.Text): + def __init__(self, text, size=14, color=clolors.ferst, weight=ft.FontWeight.NORMAL): + super().__init__( + value=text, + color=color, + size=size, + weight=weight + ) + class CastomTextField_input(ft.TextField): def __init__(self, on_change=None, on_submit=None, label="Ваше имя", hint_text="Введите текст...", prefix_icon=None, suffix_icon=None, **kwargs): diff --git a/web/info.py b/web/info.py index 4b02b9d..4c90bd3 100644 --- a/web/info.py +++ b/web/info.py @@ -1,60 +1,32 @@ -import os import secrets -import httpx import flet as ft -API_URL = os.getenv("API_URL", "http://api:8000") +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 при успехе, строку с ошибкой при неудаче. - """ - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=10) as client: - resp = await client.post( - "/auth/login", - data={"username": username, "password": password}, - ) - if resp.status_code == 200: - token = resp.json()["access_token"] - self._page.session.store.set(_TOKEN_KEY, token) - # Случайный ключ для URL профиля — хранится только в памяти сессии - self._page.session.store.set("session_key", secrets.token_hex(16)) - # user_id Для фетчинга данных - me = await self._get_me(token) - if me: - self._page.session.store.set("user_id", me["id"]) - return None - elif resp.status_code == 401: - return "Неверный логин или пароль" - else: - return f"Ошибка сервера: {resp.status_code}" - except httpx.ConnectError: - return "Не удалось подключиться к серверу" - except Exception as e: - return f"Ошибка: {e}" - - async def _get_me(self, token: str) -> dict | None: - """Внутренний метод: получает данные текущего пользователя по токену.""" - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=10) as client: - resp = await client.get( - "/users/me", - headers={"Authorization": f"Bearer {token}"}, - ) - if resp.status_code == 200: - return resp.json() - except Exception: - pass + """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: @@ -62,13 +34,16 @@ class AuthService: token = self._page.session.store.get(_TOKEN_KEY) if not token: return None - return await self._get_me(token) + return await api_client.get_me(token) async def logout(self): """Удаляет токен — пользователь разлогинен.""" 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 если не авторизован.""" @@ -87,50 +62,13 @@ class AuthService: group_id: str | None = None, organization_id: str | None = None, ) -> str | None: - """ - Отправляет запрос на POST /auth/register. - Возвращает None при успехе, строку с ошибкой при неудаче. - """ - payload = { - "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=10) 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", "Ошибка регистрации") - else: - return f"Ошибка сервера: {resp.status_code}" - except httpx.ConnectError: - return "Не удалось подключиться к серверу" - except Exception as e: - return f"Ошибка: {e}" + """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: - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=10) as client: - resp = await client.get("/organizations/") - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] + return await api_client.get_organizations() async def get_groups(self) -> list: - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=10) as client: - resp = await client.get("/groups/") - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] + return await api_client.get_groups() diff --git a/web/router.py b/web/router.py index 48e19df..6fccbf6 100644 --- a/web/router.py +++ b/web/router.py @@ -1,10 +1,21 @@ import flet as ft -from view import resolve_view, PUBLIC_ROUTES +from view import resolve_view, PUBLIC_ROUTES, PROTECTED_PREFIXES + + +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: @@ -19,18 +30,32 @@ async def _handle_route_change(page: ft.Page, e: ft.RouteChangeEvent): 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) # Неизвестный маршрут → на страницу пользователя if views is None: - session_key = page.session.store.get("session_key") or "" - await page.push_route(f"/user/{session_key}") + 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().""" diff --git a/web/view.py b/web/view.py index 1fc4671..cf3b513 100644 --- a/web/view.py +++ b/web/view.py @@ -1,14 +1,20 @@ import flet as ft -from views import main_view, login_view, reg_view, user_view +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: - """ - Возвращает список вью для заданного маршрута или None если маршрут неизвестен. - Flet использует page.views как стек: последний элемент — активный экран. - """ if route == "/login": return [login_view.LoginView(page)] elif route == "/reg": @@ -17,4 +23,20 @@ def resolve_view(page: ft.Page, route: str) -> list[ft.View] | None: 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 index c9a73ab..3c734d0 100644 --- a/web/views/content_users/main_page.py +++ b/web/views/content_users/main_page.py @@ -2,11 +2,88 @@ import flet as ft import designer as ds class MainPageUser(ft.Column): - def __init__(self): + def __init__(self, page: ft.Page): + self._page = page + self.spacing_groop = 6 + self.border_radius = 15 + super().__init__( - spacing=16, + alignment=ft.MainAxisAlignment.CENTER, + spacing=self.spacing_groop, controls=[ - ft.Text("Добро пожаловать в COPP!", color=ds.clolors.ferst, size=24, weight=ft.FontWeight.BOLD), - + ft.Row( + alignment=ft.MainAxisAlignment.CENTER, + spacing=self.spacing_groop, + controls=[ + ft.Container( + border_radius=self.border_radius, + height=200, + width=200, + 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=120, + height=120, + ) + ] + ), + ft.Row( + alignment=ft.MainAxisAlignment.CENTER, + controls=[ + ds.CastomText(text="Все тесты") + ] + ) + ] + ), + bgcolor=ds.clolors.laer2, + on_click=self.take_test, + ink=True, + ), + ft.Container( + border_radius=self.border_radius, + height=200, + width=200, + 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=120, + height=120, + ) + ] + ), + ft.Row( + alignment=ft.MainAxisAlignment.CENTER, + controls=[ + ds.CastomText(text="Мои тесты") + ] + ) + ] + ), + bgcolor=ds.clolors.laer2, + on_click=self.my_test, + ink=True, + ) + ] + ), ], - ) \ No newline at end of file + ) + + 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 new file mode 100644 index 0000000..eadad22 --- /dev/null +++ b/web/views/content_users/my_tests_page.py @@ -0,0 +1,89 @@ +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=40, height=40, color=ds.clolors.laer1) + + super().__init__( + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + spacing=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=12, + padding=16, + bgcolor=ds.clolors.laer2, + content=ft.Row( + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + controls=[ + ft.Column( + spacing=4, + 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.clolors.ferst), + ], + ), + 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 new file mode 100644 index 0000000..11340e0 --- /dev/null +++ b/web/views/content_users/poll_page.py @@ -0,0 +1,184 @@ +import flet as ft +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] = {} # question_id -> choice_id + self._error_text = ds.CastomText(text="", size=13, color="#FF6B6B") + self._submit_btn = ds.CastomButton( + text="Отправить ответы", + on_click=self._submit, + ) + self._submit_btn.visible = False + self._loading = ft.ProgressRing(width=40, height=40, color=ds.clolors.laer1) + + super().__init__( + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + spacing=10, + controls=[ + ft.Row( + alignment=ft.MainAxisAlignment.CENTER, + controls=[self._loading], + ) + ], + ) + + async def load(self): + poll = await api_client.get_poll(self._poll_id) + if poll: + self._render_poll(poll) + else: + self.controls = [ds.CastomText(text="Тест не найден", size=16, color="#FF6B6B")] + self.update() + + def _render_poll(self, poll: dict): + self._poll = poll + question_cards = [] + + for q in poll.get("questions", []): + choices = q.get("choices", []) + q_type = q.get("type", "single") + + if q_type == "single": + radio_controls = [ + ft.Radio( + value=c["id"], + label=c["text"], + fill_color=ds.clolors.laer1, + label_style=ft.TextStyle(color=ds.clolors.ferst, size=14), + ) + for c in choices + ] + input_widget = ft.RadioGroup( + content=ft.Column(controls=radio_controls, spacing=4), + on_change=lambda e, qid=q["id"]: self._on_single_answer(qid, e.control.value), + ) + else: + # multiple / text — показываем как single если нет multiple в API + radio_controls = [ + ft.Radio( + value=c["id"], + label=c["text"], + fill_color=ds.clolors.laer1, + label_style=ft.TextStyle(color=ds.clolors.ferst, size=14), + ) + for c in choices + ] + input_widget = ft.RadioGroup( + content=ft.Column(controls=radio_controls, spacing=4), + on_change=lambda e, qid=q["id"]: self._on_single_answer(qid, e.control.value), + ) + + card = ft.Container( + border_radius=12, + padding=16, + margin=ft.margin.only(bottom=8), + bgcolor=ds.clolors.laer2, + content=ft.Column( + spacing=10, + controls=[ + ds.CastomText( + text=q.get("text", ""), + size=15, + weight=ft.FontWeight.BOLD, + ), + input_widget, + ], + ), + ) + question_cards.append(card) + + self.controls = [ + ft.Container( + padding=ft.padding.only(bottom=4), + content=ft.Column( + spacing=4, + controls=[ + ds.CastomText( + text=poll.get("title", ""), + size=20, + weight=ft.FontWeight.BOLD, + ), + ds.CastomText( + text=poll.get("description") or "", + size=13, + ), + ], + ), + ), + *question_cards, + self._error_text, + ft.Row( + alignment=ft.MainAxisAlignment.END, + controls=[self._submit_btn], + ), + ] + self._submit_btn.visible = True + + def _on_single_answer(self, question_id: str, choice_id: str): + self._answers[question_id] = choice_id + + async def _submit(self, e): + poll = getattr(self, "_poll", None) + if not poll: + return + + questions = poll.get("questions", []) + unanswered = [q for q in questions if q["id"] not in self._answers] + if unanswered: + self._error_text.value = f"Ответьте на все вопросы ({len(unanswered)} осталось)" + self._error_text.update() + return + + self._error_text.value = "" + self._submit_btn.disabled = True + self._submit_btn.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._error_text.value = error + self._submit_btn.disabled = False + self._submit_btn.update() + self._error_text.update() + else: + url_key = self._page.session.store.get("url_key") or "" + # Заменяем контент на сообщение об успехе + self.controls = [ + ft.Container( + alignment=ft.Alignment(0, 0), + padding=40, + content=ft.Column( + alignment=ft.MainAxisAlignment.CENTER, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + spacing=16, + controls=[ + ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=64, color=ds.clolors.laer1), + ds.CastomText( + text="Ответы отправлены!", + size=20, + weight=ft.FontWeight.BOLD, + ), + ds.CastomButton( + text="Вернуться к тестам", + on_click=lambda _: self._page.go(f"/take_test/{url_key}"), + ), + ], + ), + ) + ] + self.update() diff --git a/web/views/content_users/profile_page.py b/web/views/content_users/profile_page.py new file mode 100644 index 0000000..52635e4 --- /dev/null +++ b/web/views/content_users/profile_page.py @@ -0,0 +1,131 @@ +import asyncio +import flet as ft +import designer as ds +import api_client + + +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.CastomDropdown( + label="Организация", + hint_text="Загрузка...", + ) + self._org_dropdown.on_select = self._on_org_change + + self._group_dropdown = ds.CastomDropdown( + label="Группа", + hint_text="Загрузка...", + ) + + self._loading = ft.ProgressRing(visible=False, width=24, height=24) + self._status = ft.Text(value="", size=14) + + super().__init__( + spacing=16, + controls=[ + ds.CastomText( + text="Редактировать профиль", + size=18, + weight=ft.FontWeight.BOLD, + ), + self._first_name, + self._last_name, + self._org_dropdown, + self._group_dropdown, + ft.Row( + spacing=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.clolors.ferst) + + 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 = "Выберите группу..." + + 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() + + 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.strip() if self._first_name.value else None + ln = self._last_name.value.strip() if self._last_name.value else 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" + else: + self._status.value = "Данные успешно сохранены!" + self._status.color = ds.clolors.ferst + # Обновляем сессию для корректного сохранения при прохождении тестов + 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 "") + self.update() + diff --git a/web/views/content_users/response_page.py b/web/views/content_users/response_page.py new file mode 100644 index 0000000..94ac4ff --- /dev/null +++ b/web/views/content_users/response_page.py @@ -0,0 +1,117 @@ +import flet as ft +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=40, height=40, color=ds.clolors.laer1) + + super().__init__( + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + spacing=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 + + self._render(response, poll) + self.update() + + def _render(self, response: dict, poll: dict): + # Строим индексы для быстрого поиска + questions_map = {q["id"]: q for q in poll.get("questions", [])} + choices_map = { + c["id"]: c["text"] + for q in poll.get("questions", []) + for c in q.get("choices", []) + } + + submitted_at = response["submitted_at"][:19].replace("T", " ") + answer_cards = [] + + for answer in response.get("answers", []): + q = questions_map.get(answer["question_id"]) + if not q: + continue + + selected_text = choices_map.get(answer.get("choice_id") or "") or answer.get("text") or "—" + + # Варианты с подсветкой выбранного + choice_rows = [] + for c in q.get("choices", []): + is_selected = c["id"] == answer.get("choice_id") + choice_rows.append( + ft.Container( + border_radius=8, + padding=ft.padding.symmetric(horizontal=12, vertical=6), + bgcolor=ds.clolors.laer1 if is_selected else ds.clolors.laer3, + content=ft.Row( + spacing=8, + controls=[ + ft.Icon( + ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED, + color=ds.clolors.ferst, + size=16, + ), + ds.CastomText(text=c["text"], size=13), + ], + ), + ) + ) + + answer_cards.append( + ft.Container( + border_radius=12, + padding=16, + bgcolor=ds.clolors.laer2, + content=ft.Column( + spacing=8, + controls=[ + ds.CastomText( + text=q.get("text", ""), + size=14, + weight=ft.FontWeight.BOLD, + ), + *choice_rows, + ], + ), + ) + ) + + self.controls = [ + ft.Container( + padding=ft.padding.only(bottom=8), + content=ft.Column( + spacing=4, + controls=[ + ds.CastomText( + text=poll.get("title", ""), + size=20, + weight=ft.FontWeight.BOLD, + ), + ds.CastomText(text=f"Пройден: {submitted_at}", size=12), + ], + ), + ), + *answer_cards, + ] diff --git a/web/views/content_users/take_test_page.py b/web/views/content_users/take_test_page.py new file mode 100644 index 0000000..840e56a --- /dev/null +++ b/web/views/content_users/take_test_page.py @@ -0,0 +1,53 @@ +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=40, height=40, color=ds.clolors.laer1) + 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=12, + padding=16, + bgcolor=ds.clolors.laer2, + content=ft.Column( + spacing=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 index 38b7d03..553bd30 100644 --- a/web/views/login_view.py +++ b/web/views/login_view.py @@ -82,8 +82,8 @@ class LoginView(ft.View): self._show_error(error) else: # Успешный вход — переходим на страницу пользователя - session_key = self.page.session.store.get("session_key") - await self.page.push_route(f"/user/{session_key}") + 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 diff --git a/web/views/my_tests_view.py b/web/views/my_tests_view.py new file mode 100644 index 0000000..8efb056 --- /dev/null +++ b/web/views/my_tests_view.py @@ -0,0 +1,46 @@ +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.clolors.ferst, + on_click=lambda _: page.go(f"/user/{url_key}"), + ) + + self._content = MyTestsPage(page) + + super().__init__( + route=f"/my_tests/{url_key}", + bgcolor=ds.clolors.background, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + vertical_alignment=ft.MainAxisAlignment.START, + scroll=ft.ScrollMode.AUTO, + controls=[ + ft.Column( + expand=True, + spacing=10, + controls=[ + ft.Row( + controls=[ + back_btn, + ds.CastomText( + text="Мои тесты", + size=22, + weight=ft.FontWeight.BOLD, + ), + ] + ), + self._content, + ], + ) + ], + ) + + async def load(self): + await self._content.load() diff --git a/web/views/poll_view.py b/web/views/poll_view.py new file mode 100644 index 0000000..334b273 --- /dev/null +++ b/web/views/poll_view.py @@ -0,0 +1,46 @@ +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.clolors.ferst, + 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.clolors.background, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + vertical_alignment=ft.MainAxisAlignment.START, + scroll=ft.ScrollMode.AUTO, + controls=[ + ft.Column( + expand=True, + spacing=10, + controls=[ + ft.Row( + controls=[ + back_btn, + ds.CastomText( + text="Прохождение теста", + size=22, + weight=ft.FontWeight.BOLD, + ), + ] + ), + self._content, + ], + ) + ], + ) + + async def load(self): + await self._content.load() diff --git a/web/views/profile_view.py b/web/views/profile_view.py new file mode 100644 index 0000000..faf5b4f --- /dev/null +++ b/web/views/profile_view.py @@ -0,0 +1,46 @@ +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.clolors.ferst, + on_click=lambda _: page.go(f"/user/{url_key}"), + ) + + self._content = ProfilePage(page) + + super().__init__( + route=f"/profile/{url_key}", + bgcolor=ds.clolors.background, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + vertical_alignment=ft.MainAxisAlignment.START, + scroll=ft.ScrollMode.AUTO, + controls=[ + ft.Column( + expand=True, + spacing=10, + controls=[ + ft.Row( + controls=[ + back_btn, + ds.CastomText( + text="Профиль", + size=22, + weight=ft.FontWeight.BOLD, + ), + ] + ), + self._content, + ], + ) + ], + ) + + async def load(self): + await self._content.load() diff --git a/web/views/response_view.py b/web/views/response_view.py new file mode 100644 index 0000000..84dc258 --- /dev/null +++ b/web/views/response_view.py @@ -0,0 +1,46 @@ +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.clolors.ferst, + 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.clolors.background, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + vertical_alignment=ft.MainAxisAlignment.START, + scroll=ft.ScrollMode.AUTO, + controls=[ + ft.Column( + expand=True, + spacing=10, + controls=[ + ft.Row( + controls=[ + back_btn, + ds.CastomText( + text="Результаты теста", + size=22, + weight=ft.FontWeight.BOLD, + ), + ] + ), + 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 new file mode 100644 index 0000000..a251555 --- /dev/null +++ b/web/views/take_test_view.py @@ -0,0 +1,44 @@ +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.clolors.ferst, + on_click=lambda _: page.go(f"/user/{url_key}"), + ) + + self._content = TakeTestPage(page) + + super().__init__( + route=f"/take_test/{url_key}", + bgcolor=ds.clolors.background, + horizontal_alignment=ft.CrossAxisAlignment.START, + vertical_alignment=ft.MainAxisAlignment.START, + controls=[ + ft.Column( + expand=True, + controls=[ + ft.Row( + controls=[ + back_btn, + ds.CastomText( + text="Все тесты", + size=22, + weight=ft.FontWeight.BOLD, + ), + ] + ), + self._content, + ], + ) + ], + ) + + async def load(self): + await self._content.load() diff --git a/web/views/user_view.py b/web/views/user_view.py index 7971810..1f51c2e 100644 --- a/web/views/user_view.py +++ b/web/views/user_view.py @@ -1,65 +1,24 @@ 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) - session_key = page.session.store.get("session_key") or "" - self.spacing_groop = 6 - self.border_radius = 15 - self.content = ft.Container( - expand=True, - content=ft.Column( - alignment=ft.MainAxisAlignment.CENTER, - - spacing=self.spacing_groop, - controls=[ - ft.Row( - alignment=ft.MainAxisAlignment.CENTER, - - spacing=self.spacing_groop, - controls=[ - ft.Container( - border_radius=self.border_radius, - height=200, - width=200, - bgcolor=ds.clolors.laer2 - ), - ft.Container( - border_radius=self.border_radius, - height=200, - width=200, - bgcolor=ds.clolors.laer2 - ) - ] - ), - ft.Row( - alignment=ft.MainAxisAlignment.CENTER, - - spacing=self.spacing_groop, - controls=[ - ft.Container( - border_radius=self.border_radius, - height=200, - width=200, - bgcolor=ds.clolors.laer2 - ), - ft.Container( - border_radius=self.border_radius, - height=200, - width=200, - bgcolor=ds.clolors.laer2 - ) - ] - ), - ], - ) + url_key = page.session.store.get("url_key") or "" + + profile_btn = ft.IconButton( + icon=ft.Icons.ACCOUNT_CIRCLE, + icon_color=ds.clolors.ferst, + icon_size=30, + tooltip="Профиль", + on_click=lambda _: page.go(f"/profile/{url_key}"), ) - + super().__init__( - route=f"/user/{session_key}", + route=f"/user/{url_key}", bgcolor=ds.clolors.background, horizontal_alignment=ft.CrossAxisAlignment.START, vertical_alignment=ft.MainAxisAlignment.START, @@ -67,15 +26,16 @@ class UserView(ft.View): self.controls = [ ft.Column( expand=True, - alignment=ft.MainAxisAlignment.CENTER, + alignment=ft.MainAxisAlignment.START, controls=[ ft.Row( + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, controls=[ - ft.Text("Добро пожаловать в COPP!", color=ds.clolors.ferst, size=24, weight=ft.FontWeight.BOLD), + ds.CastomText(text="Добро пожаловать в COPP!", size=24, weight=ft.FontWeight.BOLD), + profile_btn, ] ), - self.content + MainPageUser(page), ] ) - ]