Files
api-copp/stats/dashboard.py
2026-04-09 17:24:07 +05:00

444 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()