feat(admin): improve uploads and dashboard UX
This commit is contained in:
439
src/apps/core/admin_dashboard.py
Normal file
439
src/apps/core/admin_dashboard.py
Normal 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
|
||||
14
src/apps/core/context_processors.py
Normal file
14
src/apps/core/context_processors.py
Normal 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()}
|
||||
Reference in New Issue
Block a user