big update new dashboard and import export

This commit is contained in:
2026-04-10 00:39:35 +05:00
parent 4013701441
commit e2fb4ddb2a
19 changed files with 1324 additions and 159 deletions

View File

@@ -89,3 +89,121 @@ async def get_groups() -> list[dict]:
except Exception:
pass
return []
async def get_dimensions(poll_id: Optional[str] = None) -> list[str]:
params = {}
if poll_id:
params["poll_id"] = poll_id
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/dimensions", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_avg_by_dimension(
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
) -> list[dict]:
params = {}
if poll_id: params["poll_id"] = poll_id
if org_id: params["org_id"] = org_id
if group_id: params["group_id"] = group_id
if year: params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/avg-by-dimension", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_leading_type(
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
) -> list[dict]:
params = {}
if poll_id: params["poll_id"] = poll_id
if org_id: params["org_id"] = org_id
if group_id: params["group_id"] = group_id
if year: params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/leading-type", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_compare_groups(
dimension_name: str,
by: str = "organization",
poll_id: Optional[str] = None,
year: Optional[int] = None,
) -> list[dict]:
params = {"dimension_name": dimension_name, "by": by}
if poll_id: params["poll_id"] = poll_id
if year: params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/compare-groups", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_top_users(
dimension_name: str,
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
year: Optional[int] = None,
limit: int = 10,
) -> list[dict]:
params = {"dimension_name": dimension_name, "limit": limit}
if poll_id: params["poll_id"] = poll_id
if org_id: params["org_id"] = org_id
if group_id: params["group_id"] = group_id
if year: params["year"] = year
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/top-users", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_dimension_trend(
dimension_name: str,
poll_id: Optional[str] = None,
org_id: Optional[str] = None,
group_id: Optional[str] = None,
) -> list[dict]:
params = {"dimension_name": dimension_name}
if poll_id: params["poll_id"] = poll_id
if org_id: params["org_id"] = org_id
if group_id: params["group_id"] = group_id
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
resp = await client.get("/stats/results/dimension-trend", params=params)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []

BIN
stats/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -0,0 +1,3 @@
# Положи сюда своё изображение с именем loading-animation.png
# Это заменит мигающий логотип Flet при загрузке страницы.
# Рекомендуемый размер: PNG, минимум 200x200 px.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -3,11 +3,13 @@ dashboard.py — главный вид статистического дашбо
Графики строятся через matplotlib и отображаются как изображения (ft.Image).
"""
import asyncio
import base64
import io
import math
import flet as ft
import matplotlib
import matplotlib.colors
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
@@ -50,8 +52,8 @@ def _build_chart_image(data: list[dict], title: str, color: str) -> bytes | None
counts = [d["count"] for d in data]
n = len(labels)
fig_h = max(3.0, n * 0.45 + 0.8)
fig, ax = plt.subplots(figsize=(7, fig_h))
fig_h = max(4.5, n * 0.55 + 1.2)
fig, ax = plt.subplots(figsize=(10, fig_h))
fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
@@ -71,26 +73,26 @@ def _build_chart_image(data: list[dict], title: str, color: str) -> bytes | None
str(v),
va="center",
ha="left",
fontsize=9,
fontsize=11,
color="#1E2A3A",
)
ax.set_yticks(range(n))
ax.set_yticklabels(labels, fontsize=9, color="#5A6779")
ax.set_yticklabels(labels, fontsize=11, color="#5A6779")
ax.invert_yaxis()
ax.set_xlabel("Количество ответов", fontsize=9, color="#5A6779")
ax.xaxis.set_tick_params(labelsize=9, colors="#5A6779")
ax.set_xlabel("Количество ответов", fontsize=11, color="#5A6779")
ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA")
ax.spines["bottom"].set_color("#DDE3EA")
ax.set_xlim(0, max(counts) * 1.18)
ax.set_xlim(0, max(counts) * 1.18 if max(counts) > 0 else 1)
ax.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=0.8)
plt.tight_layout(pad=1.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
@@ -105,23 +107,23 @@ def _build_line_chart_image(data: list[dict]) -> bytes | None:
counts = [d["count"] for d in data]
n = len(labels)
fig, ax = plt.subplots(figsize=(max(6, n * 0.7), 3.5))
fig, ax = plt.subplots(figsize=(max(10, n * 1.2), 5.5))
fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
ax.plot(range(n), counts, color="#F85A40", linewidth=2, marker="o",
markersize=5, markerfacecolor="#F85A40", markeredgecolor="white",
ax.plot(range(n), counts, color="#F85A40", linewidth=2.5, marker="o",
markersize=7, markerfacecolor="#F85A40", markeredgecolor="white",
markeredgewidth=1.5)
ax.fill_between(range(n), counts, alpha=0.12, color="#F85A40")
for i, v in enumerate(counts):
ax.text(i, v + max(counts) * 0.03, str(v), ha="center",
fontsize=8, color="#1E2A3A")
fontsize=11, color="#1E2A3A")
ax.set_xticks(range(n))
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=8, color="#5A6779")
ax.yaxis.set_tick_params(labelsize=8, colors="#5A6779")
ax.set_ylabel("Ответов", fontsize=8, color="#5A6779")
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779")
ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.set_ylabel("Ответов", fontsize=11, color="#5A6779")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA")
@@ -129,22 +131,149 @@ def _build_line_chart_image(data: list[dict]) -> bytes | None:
ax.set_ylim(0, max(counts) * 1.25)
ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=0.8)
plt.tight_layout(pad=1.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _image_or_placeholder(img_bytes: bytes | None) -> ft.Control:
def _build_avg_bar(data: list[dict], xlabel: str = "Средний балл") -> bytes | None:
"""Горизонтальный bar chart со средними баллами (поле 'avg')."""
if not data:
return None
labels = [_wrap_label(d["label"]) for d in data]
values = [d["avg"] for d in data]
n = len(labels)
colors = [_PALETTE[i % len(_PALETTE)] for i in range(n)]
fig_h = max(5.5, n * 0.65 + 1.5)
fig, ax = plt.subplots(figsize=(14, fig_h))
fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
bars = ax.barh(range(n), values, color=colors, height=0.6, edgecolor="white", linewidth=0.5)
for bar, v in zip(bars, values):
ax.text(v + max(values) * 0.01, bar.get_y() + bar.get_height() / 2,
f"{v:.1f}", va="center", ha="left", fontsize=11, color="#1E2A3A")
ax.set_yticks(range(n))
ax.set_yticklabels(labels, fontsize=11, color="#5A6779")
ax.invert_yaxis()
ax.set_xlabel(xlabel, fontsize=11, color="#5A6779")
ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA")
ax.spines["bottom"].set_color("#DDE3EA")
ax.set_xlim(0, max(values) * 1.18 if max(values) > 0 else 1)
ax.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=1.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _build_pie_chart(data: list[dict]) -> bytes | None:
"""Круговая диаграмма распределения ведущего типа."""
if not data:
return None
labels = [d["label"] for d in data]
values = [d["count"] for d in data]
n_colors = len(labels)
if n_colors <= len(_PALETTE):
colors = _PALETTE[:n_colors]
else:
# Генерируем уникальные цвета через hsv colormap
cmap = matplotlib.colormaps["hsv"]
colors = [matplotlib.colors.to_hex(cmap(i / n_colors)) for i in range(n_colors)]
# Размер фигуры растёт с числом элементов для читаемой легенды
legend_rows = math.ceil(n_colors / 2)
fig_h = max(7, legend_rows * 0.35 + 5)
fig, ax = plt.subplots(figsize=(11, fig_h))
fig.patch.set_facecolor("#FFFFFF")
wedges, texts, autotexts = ax.pie(
values,
labels=None,
autopct="%1.1f%%",
colors=colors,
startangle=140,
wedgeprops=dict(edgecolor="white", linewidth=1.5),
pctdistance=0.78,
)
for t in autotexts:
t.set_fontsize(9 if n_colors > 15 else 10)
t.set_color("#1E2A3A")
ncol = 3 if n_colors > 20 else 2
ax.legend(
wedges,
[f"{l} ({v})" for l, v in zip(labels, values)],
loc="lower center",
bbox_to_anchor=(0.5, -0.05 - legend_rows * 0.04),
ncol=ncol,
fontsize=9,
frameon=False,
)
plt.tight_layout(pad=1.5)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _build_avg_line(data: list[dict]) -> bytes | None:
"""Линейный тренд средних баллов по месяцам (поле 'avg')."""
if not data:
return None
labels = [d["label"] for d in data]
values = [d["avg"] for d in data]
n = len(labels)
fig, ax = plt.subplots(figsize=(max(10, n * 1.2), 5.5))
fig.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
ax.plot(range(n), values, color="#F85A40", linewidth=2.5, marker="o",
markersize=7, markerfacecolor="#F85A40", markeredgecolor="white",
markeredgewidth=1.5)
ax.fill_between(range(n), values, alpha=0.12, color="#F85A40")
for i, v in enumerate(values):
ax.text(i, v + max(values) * 0.03, f"{v:.1f}", ha="center", fontsize=11, color="#1E2A3A")
ax.set_xticks(range(n))
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779")
ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779")
ax.set_ylabel("Средний балл", fontsize=11, color="#5A6779")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#DDE3EA")
ax.spines["bottom"].set_color("#DDE3EA")
ax.set_ylim(0, max(values) * 1.25 if max(values) > 0 else 1)
ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=1.2)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _image_or_placeholder(img_bytes: bytes | None, width: int = 1100) -> ft.Control:
if img_bytes:
return ft.Image(src=img_bytes, fit=ft.ImageFit.CONTAIN, expand=True)
b64 = base64.b64encode(img_bytes).decode()
return ft.Image(
src=f"data:image/png;base64,{b64}",
fit=ft.BoxFit.FIT_WIDTH,
width=width,
)
return ft.Container(
content=ft.Text("Нет данных", color=ds.colors.text_secondary, size=ds.s(13), italic=True),
alignment=ft.alignment.center,
alignment=ft.Alignment(0, 0),
height=ds.s(120),
expand=True,
width=width,
)
@@ -152,7 +281,7 @@ class DashboardView(ft.Column):
def __init__(self, page: ft.Page):
self._page = page
# ── Фильтры ─────────────────────────────────────────────────────────
# ── Общие фильтры ────────────────────────────────────────────────────
self._poll_dd = ds.SearchableDropdown(label="Тест", hint_text="Все тесты")
self._poll_dd.on_select = self._on_filter_change
@@ -181,7 +310,7 @@ class DashboardView(ft.Column):
color=ds.colors.text_on_primary,
shape=ft.RoundedRectangleBorder(radius=ds.s(8)),
),
on_click=lambda e: page.run_task(self._refresh),
on_click=lambda e: page.run_task(self._refresh_current_tab),
)
# ── Summary карточки ─────────────────────────────────────────────────
@@ -190,22 +319,149 @@ class DashboardView(ft.Column):
self._card_active = ft.Text("", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
self._card_orgs = ft.Text("", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
# ── Контейнеры графиков ──────────────────────────────────────────────
self._chart_poll = ft.Container(
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)),
alignment=ft.alignment.center, height=ds.s(200), expand=True,
# ── Таб 1: графики по прохождениям ───────────────────────────────────
self._chart_poll = self._make_chart_box()
self._chart_org = self._make_chart_box()
self._chart_group = self._make_chart_box()
self._chart_month = self._make_chart_box()
# ── Таб 2: результаты — фильтр по оси ────────────────────────────────
self._dim_dd = ds.SearchableDropdown(label="Ось/Тип", hint_text="Выберите ось")
self._dim_dd.on_select = self._on_results_filter_change
self._compare_by_dd = ds.SearchableDropdown(label="Сравнение по", hint_text="Организациям")
self._compare_by_dd.options = [
ft.DropdownOption(key="organization", text="Организациям"),
ft.DropdownOption(key="group", text="Группам"),
]
self._compare_by_dd.value = "organization"
self._compare_by_dd.on_select = self._on_results_filter_change
self._chart_avg_dim = self._make_chart_box()
self._chart_leading = self._make_chart_box()
self._chart_compare = self._make_chart_box()
self._chart_trend = self._make_chart_box()
# Таблица топ-пользователей
self._top_table = ft.DataTable(
columns=[
ft.DataColumn(ft.Text("Участник", size=ds.s(12), weight=ft.FontWeight.W_600)),
ft.DataColumn(ft.Text("Тест", size=ds.s(12), weight=ft.FontWeight.W_600)),
ft.DataColumn(ft.Text("Балл", size=ds.s(12), weight=ft.FontWeight.W_600), numeric=True),
],
rows=[],
border=ft.border.all(1, ds.colors.border),
border_radius=ds.s(8),
horizontal_lines=ft.BorderSide(1, ds.colors.border),
column_spacing=ds.s(20),
)
self._chart_org = ft.Container(
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)),
alignment=ft.alignment.center, height=ds.s(200), expand=True,
# ── Навигация по табам ────────────────────────────────────────────────
self._current_tab = 0
# ── Содержимое табов ──────────────────────────────────────────────────
self._tab1_content = ft.Column(
spacing=ds.s(16),
controls=[
ft.Container(
content=self._chart_card_wrap("По тестам", self._chart_poll),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("По организациям", self._chart_org),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("По группам", self._chart_group),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("Динамика по месяцам", self._chart_month),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
margin=ft.margin.only(bottom=ds.s(24)),
),
],
)
self._chart_group = ft.Container(
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)),
alignment=ft.alignment.center, height=ds.s(200), expand=True,
self._tab2_content = ft.Column(
spacing=ds.s(16),
controls=[
# Дополнительный фильтр по оси
ft.Container(
content=ft.Column(
spacing=ds.s(8),
controls=[
ft.Text("Фильтр по оси/типу", color=ds.colors.text_secondary,
size=ds.s(12), weight=ft.FontWeight.W_600),
ft.Row(
wrap=True, spacing=ds.s(10), run_spacing=ds.s(10),
controls=[self._dim_dd, self._compare_by_dd],
),
],
),
bgcolor=ds.colors.surface,
border_radius=ds.s(12),
padding=ft.padding.all(ds.s(16)),
margin=ft.margin.symmetric(horizontal=ds.s(16)),
border=ft.border.all(1, ds.colors.border),
),
ft.Container(
content=self._chart_card_wrap("Средний балл по осям", self._chart_avg_dim),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("Распределение ведущего типа", self._chart_leading),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("Сравнение по группам/организациям", self._chart_compare),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
ft.Container(
content=self._chart_card_wrap("Динамика среднего балла", self._chart_trend),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
# Топ участников
ft.Container(
content=ft.Column(
spacing=ds.s(10),
controls=[
ft.Text("Топ-10 участников по выбранной оси",
color=ds.colors.text_primary, size=ds.s(14),
weight=ft.FontWeight.W_600),
ft.Container(content=self._top_table, expand=True),
],
),
bgcolor=ds.colors.surface,
border_radius=ds.s(12),
padding=ft.padding.all(ds.s(16)),
margin=ft.margin.only(left=ds.s(16), right=ds.s(16), bottom=ds.s(24)),
border=ft.border.all(1, ds.colors.border),
),
],
)
self._chart_month = ft.Container(
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)),
alignment=ft.alignment.center, height=ds.s(200), expand=True,
# ── Tabs (TabBar + ручное переключение) ──────────────────────────────
self._tab_body = ft.Container(content=self._tab1_content, expand=True)
self._tabs_ctrl = ft.Tabs(
content=ft.Column(
expand=True,
spacing=0,
controls=[
ft.TabBar(
tabs=[
ft.Tab(label="Прохождения", icon=ft.Icons.BAR_CHART),
ft.Tab(label="Результаты тестов", icon=ft.Icons.INSIGHTS),
],
scrollable=False,
),
self._tab_body,
],
),
length=2,
selected_index=0,
on_change=self._on_tab_change,
expand=True,
)
super().__init__(
@@ -229,13 +485,10 @@ class DashboardView(ft.Column):
padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(14)),
border=ft.border.only(bottom=ft.BorderSide(1, ds.colors.border)),
),
# Summary карточки
ft.Container(
content=ft.Row(
wrap=True,
spacing=ds.s(12),
run_spacing=ds.s(12),
wrap=True, spacing=ds.s(12), run_spacing=ds.s(12),
controls=[
self._summary_tile("Всего ответов", self._card_total,
ft.Icons.ASSIGNMENT_TURNED_IN, ds.colors.primary),
@@ -249,8 +502,7 @@ class DashboardView(ft.Column):
),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
# Фильтры
# Общие фильтры
ft.Container(
content=ft.Column(
spacing=ds.s(10),
@@ -258,20 +510,12 @@ class DashboardView(ft.Column):
ft.Text("Фильтры", color=ds.colors.text_secondary,
size=ds.s(12), weight=ft.FontWeight.W_600),
ft.Row(
wrap=True,
spacing=ds.s(10),
run_spacing=ds.s(10),
controls=[
self._poll_dd,
self._org_dd,
self._group_dd,
self._year_dd,
],
),
ft.Row(
spacing=ds.s(8),
controls=[self._reset_btn, self._refresh_btn],
wrap=True, spacing=ds.s(10), run_spacing=ds.s(10),
controls=[self._poll_dd, self._org_dd,
self._group_dd, self._year_dd],
),
ft.Row(spacing=ds.s(8),
controls=[self._reset_btn, self._refresh_btn]),
],
),
bgcolor=ds.colors.surface,
@@ -280,49 +524,29 @@ class DashboardView(ft.Column):
margin=ft.margin.symmetric(horizontal=ds.s(16)),
border=ft.border.all(1, ds.colors.border),
),
# Графики: строка 1
ft.Container(
content=ft.Row(
spacing=ds.s(16),
controls=[
self._chart_card_wrap("По тестам", self._chart_poll),
self._chart_card_wrap("По организациям", self._chart_org),
],
),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
# Графики: строка 2
ft.Container(
content=ft.Row(
spacing=ds.s(16),
controls=[
self._chart_card_wrap("По группам", self._chart_group),
self._chart_card_wrap("По месяцам", self._chart_month),
],
),
padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(4)),
margin=ft.margin.only(bottom=ds.s(24)),
),
# Вкладки
self._tabs_ctrl,
],
)
# ── helpers ─────────────────────────────────────────────────────────────
def _make_chart_box(self) -> ft.Container:
return ft.Container(
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)),
alignment=ft.Alignment(0, 0),
expand=True,
)
def _summary_tile(self, title: str, value_text: ft.Text, icon: str, color: str) -> ft.Container:
return ft.Container(
content=ft.Column(
spacing=ds.s(4),
tight=True,
spacing=ds.s(4), tight=True,
controls=[
ft.Row(
spacing=ds.s(6),
controls=[
ft.Icon(icon, color=color, size=ds.s(18)),
ft.Text(title, color=ds.colors.text_secondary, size=ds.s(11)),
],
),
ft.Row(spacing=ds.s(6), controls=[
ft.Icon(icon, color=color, size=ds.s(18)),
ft.Text(title, color=ds.colors.text_secondary, size=ds.s(11)),
]),
value_text,
],
),
@@ -336,8 +560,7 @@ class DashboardView(ft.Column):
def _chart_card_wrap(self, title: str, body: ft.Container) -> ft.Container:
return ft.Container(
content=ft.Column(
spacing=ds.s(10),
tight=True,
spacing=ds.s(10), tight=True,
controls=[
ft.Text(title, color=ds.colors.text_primary, size=ds.s(14),
weight=ft.FontWeight.W_600),
@@ -353,40 +576,66 @@ class DashboardView(ft.Column):
# ── mounting ─────────────────────────────────────────────────────────────
def _chart_width(self) -> int:
"""Ширина графика = ширина страницы минус отступы."""
try:
w = self.page.width or 1200
except Exception:
w = 1200
return max(600, int(w) - 80)
def did_mount(self):
self.page.run_task(self._initial_load)
async def _initial_load(self):
"""Загрузка справочников и первичных данных."""
polls, orgs, groups, years, summary = await asyncio.gather(
polls, orgs, groups, years, summary, dimensions = await asyncio.gather(
ac.get_polls(),
ac.get_organizations(),
ac.get_groups(),
ac.get_years(),
ac.get_summary(),
ac.get_dimensions(),
)
# Наполняем дропдауны
_dd_opt = lambda key, text: ft.DropdownOption(key=key, text=text)
self._poll_dd.options = [_dd_opt(p["id"], p["title"]) for p in (polls or [])]
self._org_dd.options = [_dd_opt(o["id"], o["name_organization"]) for o in (orgs or [])]
self._group_dd.options = [_dd_opt(g["id"], g["name_group"]) for g in (groups or [])]
self._year_dd.options = [_dd_opt(str(y), str(y)) for y in (years or [])]
self._dim_dd.options = [_dd_opt(d, d) for d in (dimensions or [])]
if dimensions:
self._dim_dd.value = dimensions[0]
# Summary
self._card_total.value = str(summary.get("total_responses", ""))
self._card_polls.value = str(summary.get("total_polls", ""))
self._card_active.value = str(summary.get("active_polls", ""))
self._card_orgs.value = str(summary.get("orgs_active", ""))
self.update()
await self._refresh()
await self._refresh_tab1()
await self._refresh_tab2()
# ── filter helpers ───────────────────────────────────────────────────────
# ── tab switching ─────────────────────────────────────────────────────────
def _on_tab_change(self, e):
self._current_tab = int(e.data)
self._tab_body.content = self._tab1_content if self._current_tab == 0 else self._tab2_content
self._tab_body.update()
async def _refresh_current_tab(self):
if self._current_tab == 0:
await self._refresh_tab1()
else:
await self._refresh_tab2()
# ── filters ───────────────────────────────────────────────────────────────
def _on_filter_change(self, e):
self.page.run_task(self._refresh)
self.page.run_task(self._refresh_current_tab)
def _on_results_filter_change(self, e):
self.page.run_task(self._refresh_tab2)
def _reset_filters(self, e):
self._poll_dd.value = None
@@ -394,7 +643,7 @@ class DashboardView(ft.Column):
self._group_dd.value = None
self._year_dd.value = None
self.update()
self.page.run_task(self._refresh)
self.page.run_task(self._refresh_current_tab)
def _get_filters(self) -> dict:
year_str = self._year_dd.value
@@ -405,28 +654,21 @@ class DashboardView(ft.Column):
"year": int(year_str) if year_str else None,
}
# ── data refresh ─────────────────────────────────────────────────────────
# ── TAB 1: прохождения ───────────────────────────────────────────────────
async def _refresh(self):
async def _refresh_tab1(self):
f = self._get_filters()
# Показываем спиннеры
spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24))
self._chart_poll.content = spinner()
self._chart_org.content = spinner()
self._chart_group.content = spinner()
self._chart_month.content = spinner()
for c in [self._chart_poll, self._chart_org, self._chart_group, self._chart_month]:
c.content = spinner()
self.update()
# Параллельно загружаем все 4 набора данных
d_poll, d_org, d_group, d_month = await asyncio.gather(
ac.get_chart_data("poll", **f),
ac.get_chart_data("organization", **f),
ac.get_chart_data("group", **f),
ac.get_chart_data("month", **f),
)
# Строим изображения (CPU работа — в executor чтобы не блокировать event loop)
loop = asyncio.get_event_loop()
img_poll, img_org, img_group, img_month = await asyncio.gather(
loop.run_in_executor(None, _build_chart_image, d_poll, "По тестам", "#F85A40"),
@@ -434,10 +676,70 @@ class DashboardView(ft.Column):
loop.run_in_executor(None, _build_chart_image, d_group, "По группам", "#85C446"),
loop.run_in_executor(None, _build_line_chart_image, d_month),
)
self._chart_poll.content = _image_or_placeholder(img_poll)
self._chart_org.content = _image_or_placeholder(img_org)
self._chart_group.content = _image_or_placeholder(img_group)
self._chart_month.content = _image_or_placeholder(img_month)
w = self._chart_width()
self._chart_poll.content = _image_or_placeholder(img_poll, w)
self._chart_org.content = _image_or_placeholder(img_org, w)
self._chart_group.content = _image_or_placeholder(img_group, w)
self._chart_month.content = _image_or_placeholder(img_month, w)
self.update()
# ── TAB 2: результаты ────────────────────────────────────────────────────
async def _refresh_tab2(self):
f = self._get_filters()
dim = self._dim_dd.value
by = self._compare_by_dd.value or "organization"
spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24))
for c in [self._chart_avg_dim, self._chart_leading,
self._chart_compare, self._chart_trend]:
c.content = spinner()
self._top_table.rows = []
self.update()
# Параллельная загрузка
d_avg, d_lead, d_top = await asyncio.gather(
ac.get_avg_by_dimension(**f),
ac.get_leading_type(**f),
ac.get_top_users(dim, **f) if dim else asyncio.sleep(0),
)
if not dim:
d_top = []
d_compare = await ac.get_compare_groups(dim or "", by=by,
poll_id=f["poll_id"],
year=f["year"]) if dim else []
d_trend = await ac.get_dimension_trend(dim or "",
poll_id=f["poll_id"],
org_id=f["org_id"],
group_id=f["group_id"]) if dim else []
loop = asyncio.get_event_loop()
img_avg, img_lead, img_compare, img_trend = await asyncio.gather(
loop.run_in_executor(None, _build_avg_bar, d_avg, "Средний балл"),
loop.run_in_executor(None, _build_pie_chart, d_lead),
loop.run_in_executor(None, _build_avg_bar, d_compare,
f"Средний балл по {'организациям' if by == 'organization' else 'группам'}"),
loop.run_in_executor(None, _build_avg_line, d_trend),
)
w = self._chart_width()
self._chart_avg_dim.content = _image_or_placeholder(img_avg, w)
self._chart_leading.content = _image_or_placeholder(img_lead, w)
self._chart_compare.content = _image_or_placeholder(img_compare, w)
self._chart_trend.content = _image_or_placeholder(img_trend, w)
# Таблица топ
self._top_table.rows = [
ft.DataRow(cells=[
ft.DataCell(ft.Text(row["name"], size=ds.s(12))),
ft.DataCell(ft.Text(row["poll"], size=ds.s(11),
color=ds.colors.text_secondary,
overflow=ft.TextOverflow.ELLIPSIS,
max_lines=1)),
ft.DataCell(ft.Text(str(row["value"]), size=ds.s(12),
weight=ft.FontWeight.BOLD,
color=ds.colors.primary)),
])
for row in (d_top or [])
]
self.update()

View File

@@ -30,7 +30,7 @@ class colors:
class SearchableDropdown(ft.Container):
"""Выпадающий список с поисковой строкой."""
def __init__(self, label: str = "", hint_text: str = "", options=None, on_select=None):
def __init__(self, label: str = "", hint_text: str = "", options=None, on_select=None, width: int | None = None):
self._label_str = label
self._hint_str = hint_text
self._options: list = options or []
@@ -69,7 +69,7 @@ class SearchableDropdown(ft.Container):
padding=ft.padding.symmetric(horizontal=s(12), vertical=s(10)),
on_click=self._open_dialog,
ink=True,
expand=True,
width=width or s(220),
)
@property
@@ -90,8 +90,11 @@ class SearchableDropdown(ft.Container):
self._value_text.color = colors.text_primary
self._value_text.italic = False
break
if self.page:
self.update()
try:
if self.page:
self.update()
except RuntimeError:
pass
@property
def options(self):

View File

@@ -15,5 +15,5 @@ async def main(page: ft.Page):
page.add(view)
_flet_app = ft.run(main, export_asgi_app=True)
_flet_app = ft.run(main, export_asgi_app=True, assets_dir="assets")
app = Starlette(routes=[Mount("/", _flet_app)])