746 lines
32 KiB
Python
746 lines
32 KiB
Python
"""
|
||
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
|
||
|
||
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(4.5, n * 0.55 + 1.2)
|
||
fig, ax = plt.subplots(figsize=(10, 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=11,
|
||
color="#1E2A3A",
|
||
)
|
||
|
||
ax.set_yticks(range(n))
|
||
ax.set_yticklabels(labels, fontsize=11, color="#5A6779")
|
||
ax.invert_yaxis()
|
||
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 if max(counts) > 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_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(10, n * 1.2), 5.5))
|
||
fig.patch.set_facecolor("#FFFFFF")
|
||
ax.set_facecolor("#F4F6F8")
|
||
|
||
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=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(counts) * 1.25)
|
||
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 _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:
|
||
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(0, 0),
|
||
height=ds.s(120),
|
||
width=width,
|
||
)
|
||
|
||
|
||
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_current_tab),
|
||
)
|
||
|
||
# ── 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)
|
||
|
||
# ── Таб 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._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._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),
|
||
),
|
||
],
|
||
)
|
||
|
||
# ── 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__(
|
||
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),
|
||
),
|
||
# Вкладки
|
||
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,
|
||
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 _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, 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]
|
||
|
||
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_tab1()
|
||
await self._refresh_tab2()
|
||
|
||
# ── 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_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
|
||
self._org_dd.value = None
|
||
self._group_dd.value = None
|
||
self._year_dd.value = None
|
||
self.update()
|
||
self.page.run_task(self._refresh_current_tab)
|
||
|
||
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,
|
||
}
|
||
|
||
# ── TAB 1: прохождения ───────────────────────────────────────────────────
|
||
|
||
async def _refresh_tab1(self):
|
||
f = self._get_filters()
|
||
spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24))
|
||
for c in [self._chart_poll, self._chart_org, self._chart_group, self._chart_month]:
|
||
c.content = spinner()
|
||
self.update()
|
||
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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()
|