rename users

This commit is contained in:
2026-03-31 18:10:12 +05:00
parent 59b3bcd0f4
commit 4e9b89fbe5
22 changed files with 1262 additions and 163 deletions

View File

@@ -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)

View File

@@ -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")

233
web/api_client.py Normal file
View File

@@ -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}"

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="M15.829,2c-.413-1.164-1.525-2-2.829-2h-2c-1.304,0-2.416,.836-2.829,2H3V21c0,1.654,1.346,3,3,3h12c1.654,0,3-1.346,3-3V2h-5.171Zm3.171,19c0,.551-.449,1-1,1H6c-.551,0-1-.449-1-1V4h5v-1c0-.551,.449-1,1-1h2c.551,0,1,.449,1,1v1h5V21Zm-8-4h2v2.013h-2v-2.013Zm1.917-9.911c1.19,.308,2.158,1.276,2.465,2.467,.429,1.66-.376,3.381-1.913,4.092-.281,.13-.47,.472-.47,.852v.5h-2v-.5c0-1.162,.64-2.208,1.629-2.666,.548-.254,1.038-.919,.816-1.778-.126-.49-.54-.904-1.03-1.03-.483-.127-.956-.034-1.333,.258-.371,.287-.583,.72-.583,1.188h-2c0-1.091,.495-2.1,1.358-2.769,.863-.67,1.978-.894,3.059-.614Z"/>
</svg>

After

Width:  |  Height:  |  Size: 737 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="m19,0H5C2.243,0,0,2.243,0,5v14c0,2.757,2.243,5,5,5h14c2.757,0,5-2.243,5-5V5c0-2.757-2.243-5-5-5Zm3,19c0,1.654-1.346,3-3,3H5c-1.654,0-3-1.346-3-3V5c0-1.654,1.346-3,3-3h14c1.654,0,3,1.346,3,3v14Zm-3-1c0,.552-.447,1-1,1H6c-.552,0-1-.448-1-1s.448-1,1-1h12c.553,0,1,.448,1,1Zm-8.665-7h.665v.725c0,1.333-1.222,1.275-1.222,1.275,0,0-.778.448-.778,1s.448,1,1,1h4c.553,0,1-.448,1-1s-.778-1-.778-1c0,0-1.222.058-1.222-1.275v-.725h.664c2.548,0,4.503-1.258,5.229-3.366.206-.599.107-1.267-.264-1.787-.378-.53-.991-.847-1.641-.847h-1.008c.067-.526-.35-1-.891-1-1.557,0-4.621,0-6.177,0-.541,0-.959.474-.891,1h-1.008c-.649,0-1.263.317-1.641.847-.371.52-.469,1.188-.263,1.787.726,2.108,2.68,3.366,5.228,3.366Zm6.667-4.018c-.441,1.282-1.658,2.018-3.338,2.018h-.262c.138-.188.297-.364.492-.506.566-.412.999-.939,1.335-1.502l1.773-.01Zm-8.226.018c.335.56.767,1.084,1.33,1.494.195.142.353.317.492.506h-.262c-1.679,0-2.896-.735-3.324-2h1.765Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -7,6 +7,14 @@ 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):

View File

@@ -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"]
"""POST /auth/login. Возвращает None при успехе, строку с ошибкой при неудаче."""
token, error = await api_client.login(username, password)
if error:
return error
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)
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"])
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
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()

View File

@@ -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()."""

View File

@@ -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

View File

@@ -2,11 +2,88 @@ import flet as ft
import designer as ds
class MainPageUser(ft.Column):
def __init__(self):
super().__init__(
spacing=16,
controls=[
ft.Text("Добро пожаловать в COPP!", color=ds.clolors.ferst, size=24, weight=ft.FontWeight.BOLD),
def __init__(self, page: ft.Page):
self._page = page
self.spacing_groop = 6
self.border_radius = 15
super().__init__(
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,
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,
)
]
),
],
)
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}")

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

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

View File

@@ -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
]

View File

@@ -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

View File

@@ -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()

46
web/views/poll_view.py Normal file
View File

@@ -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()

46
web/views/profile_view.py Normal file
View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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,
url_key = page.session.store.get("url_key") or ""
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
)
]
),
],
)
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),
]
)
]