""" 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()