add dashboard

This commit is contained in:
2026-04-09 17:24:07 +05:00
parent ed6de0a4cb
commit 4013701441
10 changed files with 1202 additions and 24 deletions

443
stats/dashboard.py Normal file
View File

@@ -0,0 +1,443 @@
"""
dashboard.py — главный вид статистического дашборда.
Графики строятся через matplotlib и отображаются как изображения (ft.Image).
"""
import asyncio
import io
import math
import flet as ft
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
import designer as ds
import api_client as ac
matplotlib.use("Agg") # без GUI, только в память
# Цвета графиков
_PALETTE = [
"#F85A40", "#007FBD", "#85C446", "#FFC845",
"#D94530", "#5A6779", "#006AA3", "#4A9E21",
]
def _wrap_label(text: str, max_len: int = 18) -> str:
"""Перенос длинного текста через \n для подписей осей."""
if len(text) <= max_len:
return text
words = text.split()
lines, line = [], ""
for w in words:
if len(line) + len(w) + 1 <= max_len:
line = f"{line} {w}".strip()
else:
if line:
lines.append(line)
line = w
if line:
lines.append(line)
return "\n".join(lines)
def _build_chart_image(data: list[dict], title: str, color: str) -> bytes | None:
"""Строит горизонтальную bar chart через matplotlib, возвращает PNG bytes."""
if not data:
return None
labels = [_wrap_label(d["label"]) for d in data]
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.patch.set_facecolor("#FFFFFF")
ax.set_facecolor("#F4F6F8")
bars = ax.barh(
range(n), counts,
color=color,
height=0.6,
edgecolor="white",
linewidth=0.5,
)
# Подписи значений
for bar, v in zip(bars, counts):
ax.text(
v + max(counts) * 0.01,
bar.get_y() + bar.get_height() / 2,
str(v),
va="center",
ha="left",
fontsize=9,
color="#1E2A3A",
)
ax.set_yticks(range(n))
ax.set_yticklabels(labels, fontsize=9, color="#5A6779")
ax.invert_yaxis()
ax.set_xlabel("Количество ответов", fontsize=9, color="#5A6779")
ax.xaxis.set_tick_params(labelsize=9, 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.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=0.8)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _build_line_chart_image(data: list[dict]) -> bytes | None:
"""Строит линейный график для данных по месяцам."""
if not data:
return None
labels = [d["label"] for d in data]
counts = [d["count"] for d in data]
n = len(labels)
fig, ax = plt.subplots(figsize=(max(6, n * 0.7), 3.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",
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")
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.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(counts) * 1.25)
ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--")
plt.tight_layout(pad=0.8)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf.read()
def _image_or_placeholder(img_bytes: bytes | None) -> ft.Control:
if img_bytes:
return ft.Image(src=img_bytes, fit=ft.ImageFit.CONTAIN, expand=True)
return ft.Container(
content=ft.Text("Нет данных", color=ds.colors.text_secondary, size=ds.s(13), italic=True),
alignment=ft.alignment.center,
height=ds.s(120),
expand=True,
)
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
self._org_dd = ds.SearchableDropdown(label="Организация", hint_text="Все организации")
self._org_dd.on_select = self._on_filter_change
self._group_dd = ds.SearchableDropdown(label="Группа", hint_text="Все группы")
self._group_dd.on_select = self._on_filter_change
self._year_dd = ds.SearchableDropdown(label="Год", hint_text="Все годы")
self._year_dd.on_select = self._on_filter_change
self._reset_btn = ft.TextButton(
"Сбросить фильтры",
icon=ft.Icons.FILTER_ALT_OFF,
style=ft.ButtonStyle(color=ds.colors.primary),
on_click=self._reset_filters,
)
self._refresh_btn = ft.FilledButton(
"Обновить",
icon=ft.Icons.REFRESH,
style=ft.ButtonStyle(
bgcolor={ft.ControlState.DEFAULT: ds.colors.primary,
ft.ControlState.HOVERED: ds.colors.primary_dark},
color=ds.colors.text_on_primary,
shape=ft.RoundedRectangleBorder(radius=ds.s(8)),
),
on_click=lambda e: page.run_task(self._refresh),
)
# ── Summary карточки ─────────────────────────────────────────────────
self._card_total = ft.Text("", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
self._card_polls = ft.Text("", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
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,
)
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._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._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,
)
super().__init__(
spacing=ds.s(16),
scroll=ft.ScrollMode.AUTO,
expand=True,
controls=[
# Заголовок
ft.Container(
content=ft.Row(
controls=[
ft.Text("ЦОПП", color=ds.colors.primary, size=ds.s(22),
weight=ft.FontWeight.BOLD),
ft.VerticalDivider(width=ds.s(16), color=ds.colors.border),
ft.Text("Статистика прохождений", color=ds.colors.text_primary,
size=ds.s(16)),
],
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
bgcolor=ds.colors.surface,
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),
controls=[
self._summary_tile("Всего ответов", self._card_total,
ft.Icons.ASSIGNMENT_TURNED_IN, ds.colors.primary),
self._summary_tile("Тестов всего", self._card_polls,
ft.Icons.QUIZ, ds.colors.info),
self._summary_tile("Активных тестов", self._card_active,
ft.Icons.PLAY_CIRCLE, ds.colors.success),
self._summary_tile("Организаций с ответами", self._card_orgs,
ft.Icons.BUSINESS, ds.colors.accent),
],
),
padding=ft.padding.symmetric(horizontal=ds.s(16)),
),
# Фильтры
ft.Container(
content=ft.Column(
spacing=ds.s(10),
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._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,
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),
),
# Графики: строка 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)),
),
],
)
# ── helpers ─────────────────────────────────────────────────────────────
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,
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)),
],
),
value_text,
],
),
bgcolor=ds.colors.surface,
border_radius=ds.s(10),
padding=ft.padding.all(ds.s(14)),
border=ft.border.all(1, ds.colors.border),
width=ds.s(200),
)
def _chart_card_wrap(self, title: str, body: ft.Container) -> ft.Container:
return ft.Container(
content=ft.Column(
spacing=ds.s(10),
tight=True,
controls=[
ft.Text(title, color=ds.colors.text_primary, size=ds.s(14),
weight=ft.FontWeight.W_600),
body,
],
),
bgcolor=ds.colors.surface,
border_radius=ds.s(12),
padding=ft.padding.all(ds.s(16)),
border=ft.border.all(1, ds.colors.border),
expand=True,
)
# ── mounting ─────────────────────────────────────────────────────────────
def did_mount(self):
self.page.run_task(self._initial_load)
async def _initial_load(self):
"""Загрузка справочников и первичных данных."""
polls, orgs, groups, years, summary = await asyncio.gather(
ac.get_polls(),
ac.get_organizations(),
ac.get_groups(),
ac.get_years(),
ac.get_summary(),
)
# Наполняем дропдауны
_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 [])]
# 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()
# ── filter helpers ───────────────────────────────────────────────────────
def _on_filter_change(self, e):
self.page.run_task(self._refresh)
def _reset_filters(self, e):
self._poll_dd.value = None
self._org_dd.value = None
self._group_dd.value = None
self._year_dd.value = None
self.update()
self.page.run_task(self._refresh)
def _get_filters(self) -> dict:
year_str = self._year_dd.value
return {
"poll_id": self._poll_dd.value,
"org_id": self._org_dd.value,
"group_id": self._group_dd.value,
"year": int(year_str) if year_str else None,
}
# ── data refresh ─────────────────────────────────────────────────────────
async def _refresh(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()
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"),
loop.run_in_executor(None, _build_chart_image, d_org, "По организациям", "#007FBD"),
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)
self.update()