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),
]
)
-
]