feat(admin): improve uploads and dashboard UX

This commit is contained in:
2026-03-23 16:07:11 +01:00
parent 45bca018b5
commit ef9763692d
22 changed files with 2531 additions and 212 deletions

View File

@@ -0,0 +1,439 @@
"""Aggregated data for the custom admin dashboard."""
from __future__ import annotations
from typing import Any
from apps.parsers.models import ParserLoadLog, ProcurementRecord, Proxy
from apps.parsers.source_cards import SourceCardService
from apps.registers.models import (
Organization,
Register,
RegisterUpload,
RegistryMembershipPeriod,
)
from django.db.models import Count, Max, Q
from django.urls import NoReverseMatch, reverse
SOURCE_COLORS = (
"#49d0c8",
"#7ea6ff",
"#ffc857",
"#ff7aa2",
"#89f0c7",
"#6ed7ff",
)
STATUS_TONES = {
"success": "success",
"in_progress": "warning",
"error": "danger",
"idle": "muted",
"unavailable": "muted",
}
REGION_NAMES = {
"01": "Адыгея",
"02": "Башкортостан",
"03": "Бурятия",
"04": "Алтай",
"05": "Дагестан",
"06": "Ингушетия",
"07": "Кабардино-Балкария",
"08": "Калмыкия",
"09": "Карачаево-Черкесия",
"10": "Карелия",
"11": "Коми",
"12": "Марий Эл",
"13": "Мордовия",
"14": "Саха (Якутия)",
"15": "Северная Осетия",
"16": "Татарстан",
"17": "Тыва",
"18": "Удмуртия",
"19": "Хакасия",
"20": "Чечня",
"21": "Чувашия",
"22": "Алтайский край",
"23": "Краснодарский край",
"24": "Красноярский край",
"25": "Приморский край",
"26": "Ставропольский край",
"27": "Хабаровский край",
"28": "Амурская область",
"29": "Архангельская область",
"30": "Астраханская область",
"31": "Белгородская область",
"32": "Брянская область",
"33": "Владимирская область",
"34": "Волгоградская область",
"35": "Вологодская область",
"36": "Воронежская область",
"37": "Ивановская область",
"38": "Иркутская область",
"39": "Калининградская область",
"40": "Калужская область",
"41": "Камчатский край",
"42": "Кемеровская область",
"43": "Кировская область",
"44": "Костромская область",
"45": "Курганская область",
"46": "Курская область",
"47": "Ленинградская область",
"48": "Липецкая область",
"49": "Магаданская область",
"50": "Московская область",
"51": "Мурманская область",
"52": "Нижегородская область",
"53": "Новгородская область",
"54": "Новосибирская область",
"55": "Омская область",
"56": "Оренбургская область",
"57": "Орловская область",
"58": "Пензенская область",
"59": "Пермский край",
"60": "Псковская область",
"61": "Ростовская область",
"62": "Рязанская область",
"63": "Самарская область",
"64": "Саратовская область",
"65": "Сахалинская область",
"66": "Свердловская область",
"67": "Смоленская область",
"68": "Тамбовская область",
"69": "Тверская область",
"70": "Томская область",
"71": "Тульская область",
"72": "Тюменская область",
"73": "Ульяновская область",
"74": "Челябинская область",
"75": "Забайкальский край",
"76": "Ярославская область",
"77": "Москва",
"78": "Санкт-Петербург",
"79": "Еврейская автономная область",
"82": "Республика Крым",
"83": "Ненецкий АО",
"86": "ХМАО",
"87": "Чукотский АО",
"89": "ЯНАО",
"91": "Севастополь",
"92": "Севастополь",
}
def build_admin_dashboard() -> dict[str, Any]:
"""Return aggregated data for the admin index page."""
source_cards = _build_source_cards()
source_mix = _build_source_mix(source_cards)
active_registry_orgs = (
RegistryMembershipPeriod.objects.filter(ended_at__isnull=True)
.values("organization_id")
.distinct()
.count()
)
total_organizations = Organization.objects.count()
healthy_sources = sum(1 for card in source_cards if card["status"] == "success")
problem_sources = sum(1 for card in source_cards if card["status"] == "error")
running_sources = sum(1 for card in source_cards if card["status"] == "in_progress")
total_records = sum(card["records_count"] for card in source_cards)
total_uploads = RegisterUpload.objects.count()
active_proxies = Proxy.objects.filter(is_active=True).count()
last_registry_upload_at = RegisterUpload.objects.aggregate(
last_upload=Max("created_at")
)["last_upload"]
return {
"overview_cards": [
{
"label": "Организации",
"value": _format_int(total_organizations),
"caption": "Канонические карточки компаний в системе",
"tone": "cyan",
},
{
"label": "Активно в реестрах",
"value": _format_int(active_registry_orgs),
"caption": "Организации с актуальным членством хотя бы в одном реестре",
"tone": "blue",
},
{
"label": "Записи источников",
"value": _format_int(total_records),
"caption": "Суммарный объём данных по внешним источникам",
"tone": "amber",
},
{
"label": "Источники в норме",
"value": f"{healthy_sources}/{len(source_cards)}",
"caption": (
f"Ошибок: {problem_sources}, в работе: {running_sources}"
if source_cards
else "Подключённых источников пока нет"
),
"tone": "rose",
},
],
"hero_stats": [
{
"label": "Загрузок реестров",
"value": _format_int(total_uploads),
},
{
"label": "Активных прокси",
"value": _format_int(active_proxies),
},
{
"label": "Последняя загрузка реестра",
"value": last_registry_upload_at,
},
],
"quick_actions": [
item
for item in (
_build_quick_action(
label="Организации",
description="Просмотр канонического списка организаций",
url_name="admin:registers_organization_changelist",
),
_build_quick_action(
label="Загрузить реестр",
description="Синхронный импорт Excel по выбранному реестру",
url_name="admin:registers_registerupload_upload_excel",
),
_build_quick_action(
label="ФНС Excel",
description="Загрузить один или несколько файлов отчётности",
url_name="admin:parsers_financialreport_upload_excel",
),
_build_quick_action(
label="Логи источников",
description="Проверить последние загрузки и ошибки парсеров",
url_name="admin:parsers_parserloadlog_changelist",
),
)
if item is not None
],
"source_cards": source_cards,
"source_mix": source_mix,
"registry_rows": _build_registry_rows(),
"region_rows": _build_region_rows(),
"activity_feed": _build_activity_feed(),
}
def _build_source_cards() -> list[dict[str, Any]]:
cards = sorted(SourceCardService.list_cards(), key=lambda item: item["order"])
total_records = sum(card["records_count"] for card in cards)
max_records = max((card["records_count"] for card in cards), default=0)
max_organizations = max((card["organizations_count"] for card in cards), default=0)
enriched_cards = []
for index, card in enumerate(cards):
color = SOURCE_COLORS[index % len(SOURCE_COLORS)]
enriched_cards.append(
{
**card,
"color": color,
"status_tone": STATUS_TONES.get(card["status"], "muted"),
"records_count_label": _format_int(card["records_count"]),
"organizations_count_label": _format_int(card["organizations_count"]),
"records_share": round(
(card["records_count"] / total_records) * 100, 1
)
if total_records
else 0,
"records_bar_width": _relative_width(
value=card["records_count"],
max_value=max_records,
),
"organizations_bar_width": _relative_width(
value=card["organizations_count"],
max_value=max_organizations,
),
}
)
return enriched_cards
def _build_source_mix(source_cards: list[dict[str, Any]]) -> dict[str, Any]:
non_empty_cards = [card for card in source_cards if card["records_count"] > 0]
total_records = sum(card["records_count"] for card in non_empty_cards)
if not non_empty_cards or total_records == 0:
return {
"total_records_label": "0",
"segments": [],
"background": (
"conic-gradient("
"from 210deg, "
"rgba(73, 208, 200, 0.18), "
"rgba(126, 166, 255, 0.08), "
"rgba(73, 208, 200, 0.18)"
")"
),
}
cursor = 0.0
gradient_parts: list[str] = []
segments = []
for card in sorted(non_empty_cards, key=lambda item: item["records_count"], reverse=True):
share = (card["records_count"] / total_records) * 100
start = cursor
end = start + share
gradient_parts.append(
f"{card['color']} {start:.2f}% {end:.2f}%"
)
cursor = end
segments.append(
{
"title": card["title"],
"value": card["records_count_label"],
"share": round(share, 1),
"color": card["color"],
}
)
return {
"total_records_label": _format_int(total_records),
"segments": segments,
"background": f"conic-gradient(from 210deg, {', '.join(gradient_parts)})",
}
def _build_registry_rows() -> list[dict[str, Any]]:
registries = list(
Register.objects.annotate(
active_organizations=Count(
"membership_periods__organization",
filter=Q(membership_periods__ended_at__isnull=True),
distinct=True,
),
uploads_count=Count("uploads", distinct=True),
last_upload_at=Max("uploads__created_at"),
last_actual_date=Max("uploads__actual_date"),
)
.order_by("-active_organizations", "name")[:6]
)
max_active = max((registry.active_organizations for registry in registries), default=0)
return [
{
"name": registry.name,
"active_organizations": registry.active_organizations,
"active_organizations_label": _format_int(registry.active_organizations),
"uploads_count": registry.uploads_count,
"uploads_count_label": _format_int(registry.uploads_count),
"last_upload_at": registry.last_upload_at,
"last_actual_date": registry.last_actual_date,
"bar_width": _relative_width(
value=registry.active_organizations,
max_value=max_active,
),
}
for registry in registries
]
def _build_region_rows() -> list[dict[str, Any]]:
regions = list(
ProcurementRecord.objects.exclude(region_code="")
.values("region_code")
.annotate(
records_count=Count("id"),
organizations_count=Count("customer_inn", distinct=True),
)
.order_by("-records_count", "region_code")[:8]
)
max_records = max((item["records_count"] for item in regions), default=0)
return [
{
"code": item["region_code"],
"name": REGION_NAMES.get(
item["region_code"], f"Регион {item['region_code']}"
),
"records_count": item["records_count"],
"records_count_label": _format_int(item["records_count"]),
"organizations_count": item["organizations_count"],
"organizations_count_label": _format_int(item["organizations_count"]),
"bar_width": _relative_width(
value=item["records_count"],
max_value=max_records,
),
}
for item in regions
]
def _build_activity_feed() -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
for load in ParserLoadLog.objects.order_by("-created_at")[:5]:
events.append(
{
"timestamp": load.created_at,
"title": (
SourceCardService.get_card_title_by_parser_source(load.source)
or load.get_source_display()
),
"kind": "Источник",
"meta": f"Пакет #{load.batch_id} · {_format_int(load.records_count)} записей",
"note": load.error_message or load.status,
"tone": STATUS_TONES.get(load.status, "muted"),
}
)
for upload in RegisterUpload.objects.select_related("registry").order_by("-created_at")[:5]:
events.append(
{
"timestamp": upload.created_at,
"title": upload.registry.name,
"kind": "Реестр",
"meta": (
f"Срез на {upload.actual_date:%d.%m.%Y} · "
f"{_format_int(upload.rows_count)} строк"
),
"note": upload.file_name,
"tone": "cyan",
}
)
events.sort(key=lambda item: item["timestamp"], reverse=True)
return events[:8]
def _build_quick_action(
*,
label: str,
description: str,
url_name: str,
) -> dict[str, str] | None:
url = _safe_reverse(url_name)
if url is None:
return None
return {
"label": label,
"description": description,
"url": url,
}
def _relative_width(*, value: int, max_value: int) -> int:
if value <= 0 or max_value <= 0:
return 0
if value == max_value:
return 100
return max(12, round((value / max_value) * 100))
def _format_int(value: int) -> str:
return f"{value:,}".replace(",", " ")
def _safe_reverse(url_name: str) -> str | None:
try:
return reverse(url_name)
except NoReverseMatch:
return None

View File

@@ -0,0 +1,14 @@
"""Template context processors for custom admin pages."""
from __future__ import annotations
from apps.core.admin_dashboard import build_admin_dashboard
def admin_dashboard(request):
"""Inject dashboard data only for the admin index page."""
resolver_match = getattr(request, "resolver_match", None)
if resolver_match is None or resolver_match.view_name != "admin:index":
return {}
return {"admin_dashboard_data": build_admin_dashboard()}