fix install diogram

This commit is contained in:
2026-04-02 12:00:11 +05:00
parent 22bbc3a0a6
commit 8a189e310b
23 changed files with 1212 additions and 189 deletions

View File

@@ -4,6 +4,7 @@ api_client.py — единое место для всех HTTP-запросов
"""
import os
from typing import Any
from urllib.parse import urlparse
import httpx
@@ -11,6 +12,18 @@ API_URL = os.getenv("API_URL", "http://api:8000")
_TIMEOUT = 10
def build_radar_download_url(page_url: str, response_id: str) -> str:
"""
Строит URL для скачивания SVG-диаграммы через веб-сервер (/radar-image/{id}).
page_url — значение page.url из Flet (может быть ws:// или wss://).
"""
parsed = urlparse(page_url)
scheme = "https" if parsed.scheme in ("wss", "https") else "http"
port = parsed.port
port_str = f":{port}" if port and port not in (80, 443) else ""
return f"{scheme}://{parsed.hostname}{port_str}/radar-image/{response_id}"
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------

View File

@@ -13,7 +13,8 @@ class CastomText(ft.Text):
value=text,
color=color,
size=size,
weight=weight
weight=weight,
max_lines=99,
)
@@ -48,7 +49,7 @@ class CastomTextField_input(ft.TextField):
class CastomButton(ft.Button):
def __init__(self, text, on_click):
super().__init__(
text,
content=text,
style=ft.ButtonStyle(
color=clolors.ferst,
bgcolor=clolors.laer1,
@@ -59,6 +60,27 @@ class CastomButton(ft.Button):
on_click=on_click
)
class CastomIconButton(ft.Button):
"""Кнопка с иконкой и текстом."""
def __init__(self, text, icon, on_click):
super().__init__(
content=ft.Row(
spacing=8,
tight=True,
controls=[
ft.Icon(icon, color=clolors.ferst, size=18),
ft.Text(text, color=clolors.ferst, size=14),
],
),
style=ft.ButtonStyle(
color=clolors.ferst,
bgcolor=clolors.laer2,
text_style=ft.TextStyle(size=14),
),
on_click=on_click,
)
class CastomDropdown(ft.Dropdown):
def __init__(self, label="", hint_text="", options=None, on_change=None):
super().__init__(

View File

@@ -1,6 +1,30 @@
import os
import flet as ft
import httpx
import designer as ds
from router import setup_router
from starlette.applications import Starlette
from starlette.routing import Route, Mount
from starlette.responses import Response as StarletteResponse
_API_URL = os.getenv("API_URL", "http://api:8000")
async def _radar_image(request):
"""Проксирует SVG-диаграмму с API — браузер скачивает с собственного хоста."""
response_id = request.path_params["response_id"]
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{_API_URL}/responses/{response_id}/radar/image")
if r.status_code == 200:
return StarletteResponse(
content=r.content,
media_type="image/svg+xml",
headers={"Content-Disposition": 'attachment; filename="diagram.svg"'},
)
except Exception:
pass
return StarletteResponse(status_code=404)
async def main(page: ft.Page):
@@ -11,4 +35,11 @@ async def main(page: ft.Page):
await page.push_route(initial)
app = ft.run(main, export_asgi_app=True, assets_dir="assets")
_flet_app = ft.run(main, export_asgi_app=True, assets_dir="assets")
app = Starlette(
routes=[
Route("/radar-image/{response_id}", _radar_image),
Mount("/", _flet_app),
]
)

View File

@@ -9,16 +9,19 @@ class MainPageUser(ft.Column):
super().__init__(
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=self.spacing_groop,
controls=[
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
spacing=self.spacing_groop,
run_spacing=self.spacing_groop,
wrap=True,
controls=[
ft.Container(
border_radius=self.border_radius,
height=200,
width=200,
height=160,
width=160,
alignment=ft.Alignment(0.0, 0.0),
content=ft.Column(
alignment=ft.MainAxisAlignment.CENTER,
@@ -28,8 +31,8 @@ class MainPageUser(ft.Column):
controls=[
ft.Image(
src="icons/clipboard-question_16542596.svg",
width=120,
height=120,
width=100,
height=100,
)
]
),
@@ -47,8 +50,8 @@ class MainPageUser(ft.Column):
),
ft.Container(
border_radius=self.border_radius,
height=200,
width=200,
height=160,
width=160,
alignment=ft.Alignment(0.0, 0.0),
content=ft.Column(
alignment=ft.MainAxisAlignment.CENTER,
@@ -58,10 +61,10 @@ class MainPageUser(ft.Column):
controls=[
ft.Image(
src="icons/leaderboard-trophy_14227576.svg",
width=120,
height=120,
width=100,
height=100,
)
]
]
),
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,

View File

@@ -64,6 +64,7 @@ class MyTestsPage(ft.Column):
controls=[
ft.Column(
spacing=4,
expand=True,
controls=[
ds.CastomText(
text=title,

View File

@@ -1,8 +1,7 @@
import base64
import flet as ft
import base64
import designer as ds
import api_client
from radar_chart import RadarChart
class PollPage(ft.Column):
@@ -38,43 +37,64 @@ class PollPage(ft.Column):
self.controls = [ds.CastomText(text="Тест не найден", size=16, color="#FF6B6B")]
self.update()
def _build_choice_group(self, choices: list, qid: str) -> ft.Column:
"""Кастомные кнопки выбора с переносом текста вместо ft.Radio."""
containers: list[ft.Container] = []
def make_tile(choice_id: str, choice_text: str) -> ft.Container:
return ft.Container(
border_radius=8,
padding=ft.padding.symmetric(horizontal=12, vertical=10),
bgcolor=ds.clolors.laer3,
border=ft.border.all(2, ds.clolors.laer3),
content=ft.Row(
spacing=10,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
controls=[
ft.Icon(
ft.Icons.RADIO_BUTTON_UNCHECKED,
color=ds.clolors.ferst,
size=20,
),
ft.Text(
value=choice_text,
color=ds.clolors.ferst,
size=14,
expand=True,
no_wrap=False,
),
],
),
data=choice_id,
)
def on_tile_click(e, _qid=qid, _containers=containers):
selected_id = e.control.data
self._answers[_qid] = selected_id
for tile in _containers:
is_selected = tile.data == selected_id
tile.bgcolor = ds.clolors.laer1 if is_selected else ds.clolors.laer3
tile.border = ft.border.all(2, ds.clolors.ferst if is_selected else ds.clolors.laer3)
row = tile.content
row.controls[0].name = ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED
tile.update()
for c in choices:
tile = make_tile(c["id"], c["text"])
tile.on_click = on_tile_click
tile.ink = True
containers.append(tile)
return ft.Column(controls=containers, spacing=6)
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),
)
input_widget = self._build_choice_group(choices, q["id"])
card = ft.Container(
border_radius=12,
@@ -122,9 +142,6 @@ class PollPage(ft.Column):
]
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:
@@ -170,49 +187,41 @@ class PollPage(ft.Column):
self.update()
radar = await api_client.get_radar_result(response_id)
svg_bytes = await api_client.get_radar_svg_bytes(response_id) if radar and radar.get("image_url") else None
success_controls: list[ft.Control] = [
ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=48, color=ds.clolors.laer1),
ds.CastomText(text="Тест пройден!", size=20, weight=ft.FontWeight.BOLD),
]
if radar and radar.get("items"):
items = sorted(
radar["items"],
key=lambda x: (
x.get("dimension_position") is None,
x.get("dimension_position", 0),
),
)
actual_max = max((it["value"] for it in items), default=1.0)
max_val = max(actual_max, 14.0)
if svg_bytes and radar and radar.get("image_url"):
b64 = base64.b64encode(svg_bytes).decode()
data_uri = f"data:image/svg+xml;base64,{b64}"
download_url = api_client.build_radar_download_url(self._page.url, response_id)
success_controls.append(
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[RadarChart(items, max_val)],
ft.Container(
expand=True,
content=ft.Image(
src=data_uri,
expand=True,
height=380,
fit=ft.BoxFit.CONTAIN,
),
)
)
async def _download(_evt, _rid=response_id):
svg_bytes = await api_client.get_radar_svg_bytes(_rid)
if svg_bytes:
b64 = base64.b64encode(svg_bytes).decode()
uri = f"data:image/svg+xml;base64,{b64}"
self._page.launch_url(uri, web_window_name="_blank")
async def _download(_evt, _url=download_url):
await self._page.launch_url(_url)
success_controls.append(
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ft.ElevatedButton(
ds.CastomIconButton(
text="Сохранить диаграмму",
icon=ft.Icons.DOWNLOAD,
on_click=_download,
style=ft.ButtonStyle(
color=ds.clolors.ferst,
bgcolor=ds.clolors.laer2,
),
)
],
)
@@ -228,6 +237,9 @@ class PollPage(ft.Column):
self.controls = [
ft.Container(
padding=20,
bgcolor=ds.clolors.laer3,
border_radius=16,
expand=True,
content=ft.Column(
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,

View File

@@ -1,8 +1,7 @@
import base64
import flet as ft
import base64
import designer as ds
import api_client
from radar_chart import RadarChart
class ResponsePage(ft.Column):
@@ -34,10 +33,11 @@ class ResponsePage(ft.Column):
return
radar = await api_client.get_radar_result(self._response_id)
self._render(response, poll, radar)
svg_bytes = await api_client.get_radar_svg_bytes(self._response_id) if radar and radar.get("image_url") else None
self._render(response, poll, radar, svg_bytes)
self.update()
def _render(self, response: dict, poll: dict, radar: dict | None):
def _render(self, response: dict, poll: dict, radar: dict | None, svg_bytes: bytes | None = None):
questions_map = {q["id"]: q for q in poll.get("questions", [])}
submitted_at = response["submitted_at"][:19].replace("T", " ")
@@ -52,20 +52,13 @@ class ResponsePage(ft.Column):
]
# ── Паутинная диаграмма ──────────────────────────────────────────────
if radar and radar.get("items"):
items = sorted(
radar["items"],
key=lambda x: (x.get("dimension_position") is None, x.get("dimension_position", 0)),
)
actual_max = max((it["value"] for it in items), default=1.0)
max_val = max(actual_max, 14.0)
if svg_bytes and radar and radar.get("image_url"):
b64 = base64.b64encode(svg_bytes).decode()
data_uri = f"data:image/svg+xml;base64,{b64}"
download_url = api_client.build_radar_download_url(self._page.url, self._response_id)
async def _download(_evt, _rid=self._response_id):
svg_bytes = await api_client.get_radar_svg_bytes(_rid)
if svg_bytes:
b64 = base64.b64encode(svg_bytes).decode()
uri = f"data:image/svg+xml;base64,{b64}"
self._page.launch_url(uri, web_window_name="_blank")
async def _download(_evt, _url=download_url):
await self._page.launch_url(_url)
controls.append(ft.Container(
border_radius=16,
@@ -76,21 +69,22 @@ class ResponsePage(ft.Column):
spacing=10,
controls=[
ds.CastomText(text="Результат", size=16, weight=ft.FontWeight.BOLD),
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[RadarChart(items, max_val)],
ft.Container(
expand=True,
content=ft.Image(
src=data_uri,
expand=True,
height=380,
fit=ft.BoxFit.CONTAIN,
),
),
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ft.ElevatedButton(
ds.CastomIconButton(
text="Сохранить диаграмму",
icon=ft.Icons.DOWNLOAD,
on_click=_download,
style=ft.ButtonStyle(
color=ds.clolors.ferst,
bgcolor=ds.clolors.laer2,
),
)
],
),
@@ -117,7 +111,7 @@ class ResponsePage(ft.Column):
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),
ft.Container(expand=True, content=ds.CastomText(text=c["text"], size=13)),
]),
))

View File

@@ -37,16 +37,19 @@ class LoginView(ft.View):
super().__init__(
route="/login",
bgcolor=ds.clolors.background,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.CENTER,
padding=ft.padding.symmetric(horizontal=16, vertical=24),
scroll=ft.ScrollMode.AUTO,
)
self.controls = [
ft.Text("COPP", color=ds.clolors.ferst, size=30, weight=ft.FontWeight.BOLD),
ft.Text("COPP", color=ds.clolors.ferst, size=30, weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER),
ft.Container(height=8),
ft.Text("Вход в систему", color=ds.clolors.ferst, size=16),
ft.Text("Вход в систему", color=ds.clolors.ferst, size=16,
text_align=ft.TextAlign.CENTER),
ft.Container(height=24),
ft.Container(
width=360,
padding=ft.padding.all(24),
bgcolor=ds.clolors.laer3,
border_radius=16,

View File

@@ -18,6 +18,7 @@ class PollView(ft.View):
super().__init__(
route=f"/poll/{url_key}/{poll_id}",
bgcolor=ds.clolors.background,
padding=ft.padding.symmetric(horizontal=12, vertical=8),
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START,
scroll=ft.ScrollMode.AUTO,

View File

@@ -61,22 +61,24 @@ class RegView(ft.View):
super().__init__(
route="/reg",
bgcolor=ds.clolors.background,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.CENTER,
padding=ft.padding.symmetric(horizontal=16, vertical=24),
scroll=ft.ScrollMode.AUTO,
)
self.controls = [
ft.Text("COPP", color=ds.clolors.ferst, size=30, weight=ft.FontWeight.BOLD),
ft.Text("COPP", color=ds.clolors.ferst, size=30, weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER),
ft.Container(height=8),
ft.Text("Регистрация в системе", color=ds.clolors.ferst, size=16),
ft.Text("Регистрация в системе", color=ds.clolors.ferst, size=16,
text_align=ft.TextAlign.CENTER),
ft.Container(height=24),
ft.Container(
width=360,
padding=ft.padding.all(24),
bgcolor=ds.clolors.laer3,
border_radius=16,
content=ft.Column(
spacing=16,
scroll="auto",
controls=[
self._first_name,
self._last_name,
@@ -86,6 +88,9 @@ class RegView(ft.View):
self._group_dropdown,
self._error,
ft.Row(
wrap=True,
spacing=8,
run_spacing=8,
controls=[
self._btn_cansel,
self._btn,

View File

@@ -18,6 +18,7 @@ class ResponseView(ft.View):
super().__init__(
route=f"/response/{url_key}/{response_id}",
bgcolor=ds.clolors.background,
padding=ft.padding.symmetric(horizontal=12, vertical=8),
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START,
scroll=ft.ScrollMode.AUTO,

View File

@@ -18,6 +18,7 @@ class TakeTestView(ft.View):
super().__init__(
route=f"/take_test/{url_key}",
bgcolor=ds.clolors.background,
padding=ft.padding.symmetric(horizontal=12, vertical=8),
horizontal_alignment=ft.CrossAxisAlignment.START,
vertical_alignment=ft.MainAxisAlignment.START,
controls=[

View File

@@ -20,8 +20,9 @@ class UserView(ft.View):
super().__init__(
route=f"/user/{url_key}",
bgcolor=ds.clolors.background,
horizontal_alignment=ft.CrossAxisAlignment.START,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START,
padding=ft.padding.symmetric(horizontal=12, vertical=8),
)
self.controls = [
ft.Column(
@@ -31,7 +32,13 @@ class UserView(ft.View):
ft.Row(
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
controls=[
ds.CastomText(text="Добро пожаловать в COPP!", size=24, weight=ft.FontWeight.BOLD),
ft.Text(
"Добро пожаловать в COPP!",
color=ds.clolors.ferst,
size=20,
weight=ft.FontWeight.BOLD,
expand=True,
),
profile_btn,
]
),