add dashboard
This commit is contained in:
443
stats/dashboard.py
Normal file
443
stats/dashboard.py
Normal 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()
|
||||
Reference in New Issue
Block a user