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

View File

@@ -15,6 +15,7 @@ from apps.parsers.models import (
Proxy,
)
from apps.parsers.serializers import FNSFileUploadSerializer, FNSZipUploadSerializer
from apps.parsers.services import ProxyToolsSyncError, ProxyToolsSyncService
from django.contrib import admin, messages
from django.shortcuts import redirect
from django.template.response import TemplateResponse
@@ -26,6 +27,7 @@ from django.utils.html import format_html
class ProxyAdmin(admin.ModelAdmin):
"""Admin для прокси-серверов."""
change_list_template = "admin/parsers/proxy/change_list.html"
list_display = [
"address",
"country_code",
@@ -73,6 +75,78 @@ class ProxyAdmin(admin.ModelAdmin):
is_active_badge.short_description = "Статус"
is_active_badge.admin_order_field = "is_active"
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"sync-proxy-tools/",
self.admin_site.admin_view(self.sync_proxy_tools_view),
name="parsers_proxy_sync_proxy_tools",
),
]
return custom_urls + urls
def changelist_view(self, request, extra_context=None):
extra_context = extra_context or {}
extra_context["sync_proxy_tools_url"] = reverse(
"admin:parsers_proxy_sync_proxy_tools"
)
return super().changelist_view(request, extra_context=extra_context)
def sync_proxy_tools_view(self, request):
changelist_url = reverse("admin:parsers_proxy_changelist")
if request.method != "POST":
self.message_user(
request,
"Обновление списка прокси доступно только через POST.",
level=messages.WARNING,
)
return redirect(changelist_url)
try:
result = ProxyToolsSyncService.sync_ru_proxies()
except ProxyToolsSyncError as exc:
self.message_user(
request,
f"Ошибка обновления списка прокси: {exc}",
level=messages.ERROR,
)
return redirect(changelist_url)
if result.get("status") == "success":
self.message_user(
request,
(
"Список прокси обновлён: "
f"загружено {result['fetched']}, "
f"создано {result['created']}, "
f"обновлено {result['updated']}, "
f"деактивировано {result['deactivated']}."
),
level=messages.SUCCESS,
)
elif result.get("status") == "skipped":
reason = result.get("reason")
message = (
"Обновление списка прокси пропущено: не задан PROXY_TOOLS_API_KEY."
if reason == "missing_api_key"
else f"Обновление списка прокси пропущено: {reason}."
)
self.message_user(
request,
message,
level=messages.WARNING,
)
else:
self.message_user(
request,
"Обновление списка прокси завершилось без результата.",
level=messages.WARNING,
)
return redirect(changelist_url)
actions = ["activate_proxies", "deactivate_proxies", "reset_fail_count"]
@admin.action(description="Активировать выбранные прокси")
@@ -770,6 +844,45 @@ class FinancialReportAdmin(admin.ModelAdmin):
)
return super().changelist_view(request, extra_context=extra_context)
def _message_sync_upload_result(self, request, result, *, archive: bool) -> None:
noun = "файлов из архива" if archive else "файлов"
if result.processed:
self.message_user(
request,
f"Успешно обработано {noun}: {result.processed}.",
level=messages.SUCCESS,
)
if result.skipped:
self.message_user(
request,
f"Пропущено {noun}: {result.skipped}.",
level=messages.WARNING,
)
if result.failed:
self.message_user(
request,
f"Не удалось обработать {noun}: {result.failed}.",
level=messages.ERROR,
)
if result.invalid:
self.message_user(
request,
f"Невалидных элементов в архиве: {result.invalid}.",
level=messages.WARNING,
)
if not result.processed and not result.skipped and not result.failed:
empty_message = (
"Архив не содержит подходящих файлов."
if archive
else "Файлы не были обработаны."
)
self.message_user(
request,
empty_message,
level=messages.WARNING,
)
def upload_excel_view(self, request):
changelist_url = reverse("admin:parsers_financialreport_changelist")
@@ -786,39 +899,19 @@ class FinancialReportAdmin(admin.ModelAdmin):
return redirect(changelist_url)
try:
result = FNSUploadService.queue_uploaded_files(
result = FNSUploadService.process_uploaded_files_sync(
files=serializer.validated_data["files"],
requested_by_id=request.user.id,
)
except Exception as exc: # noqa: BLE001
self.message_user(
request,
f"Ошибка постановки файлов в очередь: {exc}",
f"Ошибка синхронной обработки файлов: {exc}",
level=messages.ERROR,
)
return redirect(changelist_url)
if result.queued:
self.message_user(
request,
"Файлов поставлено в очередь: "
f"{result.queued}. Task IDs: {', '.join(result.task_ids[:5])}",
level=messages.SUCCESS,
)
if result.skipped:
self.message_user(
request,
"Пропущено файлов: "
f"{result.skipped} (дубликаты или уже обрабатываются).",
level=messages.WARNING,
)
if not result.queued and not result.skipped:
self.message_user(
request,
"Файлы не были обработаны.",
level=messages.WARNING,
)
self._message_sync_upload_result(request, result, archive=False)
return redirect(changelist_url)
context = {
@@ -850,7 +943,7 @@ class FinancialReportAdmin(admin.ModelAdmin):
return redirect(changelist_url)
try:
result = FNSUploadService.queue_zip_archive(
result = FNSUploadService.process_zip_archive_sync(
archive_file=serializer.validated_data["file"],
requested_by_id=request.user.id,
)
@@ -862,32 +955,7 @@ class FinancialReportAdmin(admin.ModelAdmin):
)
return redirect(changelist_url)
if result.queued:
self.message_user(
request,
"Файлов из архива поставлено в очередь: "
f"{result.queued}. Task IDs: {', '.join(result.task_ids[:5])}",
level=messages.SUCCESS,
)
if result.skipped:
self.message_user(
request,
f"Пропущено файлов из архива: {result.skipped}.",
level=messages.WARNING,
)
if result.invalid:
self.message_user(
request,
f"Невалидных элементов в архиве: {result.invalid}.",
level=messages.WARNING,
)
if not result.queued and not result.skipped and not result.invalid:
self.message_user(
request,
"Архив не содержит подходящих файлов.",
level=messages.WARNING,
)
self._message_sync_upload_result(request, result, archive=True)
return redirect(changelist_url)
context = {

View File

@@ -30,6 +30,16 @@ class FNSUploadResult:
task_ids: list[str] = field(default_factory=list)
@dataclass
class FNSSyncUploadResult:
"""Result of synchronous FNS file processing."""
processed: int = 0
skipped: int = 0
invalid: int = 0
failed: int = 0
class FNSUploadService:
"""Queue uploaded FNS Excel files and ZIP archives for processing."""
@@ -87,6 +97,60 @@ class FNSUploadService:
return result
@classmethod
def process_uploaded_files_sync(
cls, *, files, requested_by_id: int | None
) -> FNSSyncUploadResult:
result = FNSSyncUploadResult()
seen_hashes: set[str] = set()
for uploaded_file in files:
status = cls._process_file_bytes_sync(
file_name=uploaded_file.name,
file_content=uploaded_file.read(),
requested_by_id=requested_by_id,
seen_hashes=seen_hashes,
)
cls._accumulate_sync(result=result, status=status)
return result
@classmethod
def process_zip_archive_sync(
cls,
*,
archive_file,
requested_by_id: int | None,
) -> FNSSyncUploadResult:
result = FNSSyncUploadResult()
seen_hashes: set[str] = set()
archive_file.seek(0)
try:
with zipfile.ZipFile(archive_file) as archive:
for member in archive.infolist():
if member.is_dir():
continue
file_name = cls._extract_member_name(member.filename)
if not file_name or not FNS_XLSX_FILENAME_RE.match(file_name):
result.invalid += 1
continue
status = cls._process_file_bytes_sync(
file_name=file_name,
file_content=archive.read(member),
requested_by_id=requested_by_id,
seen_hashes=seen_hashes,
)
cls._accumulate_sync(result=result, status=status)
except zipfile.BadZipFile as exc:
raise ValueError(
"Загруженный файл не является корректным ZIP архивом"
) from exc
return result
@staticmethod
def _extract_member_name(member_name: str) -> str | None:
path = PurePosixPath(member_name)
@@ -106,27 +170,15 @@ class FNSUploadService:
requested_by_id: int | None,
seen_hashes: set[str],
) -> tuple[str, str | None]:
file_hash = hashlib.sha256(file_content).hexdigest()
if file_hash in seen_hashes or FNSReportService.exists_by_hash(file_hash):
status, file_path, file_hash = cls._prepare_file_bytes(
file_name=file_name,
file_content=file_content,
seen_hashes=seen_hashes,
)
if status == "skipped":
return "skipped", None
upload_dir = Path(settings.FNS_WATCH_DIRECTORY)
upload_dir.mkdir(parents=True, exist_ok=True)
file_path = upload_dir / file_name
if not cls._try_create_fns_lock(file_path):
return "skipped", None
lock_path = Path(f"{file_path}.lock")
if file_path.exists():
lock_path.unlink(missing_ok=True)
return "skipped", None
try:
file_path.write_bytes(file_content)
except Exception:
lock_path.unlink(missing_ok=True)
raise
if file_path is None or file_hash is None: # pragma: no cover
raise RuntimeError("Prepared FNS file is missing processing metadata")
task_id = str(uuid.uuid4())
try:
@@ -145,13 +197,83 @@ class FNSUploadService:
task_id=task_id,
)
except Exception:
lock_path.unlink(missing_ok=True)
Path(f"{file_path}.lock").unlink(missing_ok=True)
BackgroundJob.objects.filter(task_id=task_id).delete()
raise
seen_hashes.add(file_hash)
return "queued", task.id
@classmethod
def _process_file_bytes_sync(
cls,
*,
file_name: str,
file_content: bytes,
requested_by_id: int | None,
seen_hashes: set[str],
) -> str:
from apps.parsers.tasks import _process_fns_file_sync
status, file_path, file_hash = cls._prepare_file_bytes(
file_name=file_name,
file_content=file_content,
seen_hashes=seen_hashes,
)
if status == "skipped":
return "skipped"
if file_path is None or file_hash is None: # pragma: no cover
raise RuntimeError("Prepared FNS file is missing processing metadata")
result = _process_fns_file_sync(
str(file_path),
task_id=str(uuid.uuid4()),
requested_by_id=requested_by_id,
raise_on_error=False,
)
result_status = result.get("status")
if result_status == "success":
if file_hash is not None:
seen_hashes.add(file_hash)
return "processed"
if result_status == "skipped":
if file_hash is not None:
seen_hashes.add(file_hash)
return "skipped"
return "failed"
@classmethod
def _prepare_file_bytes(
cls,
*,
file_name: str,
file_content: bytes,
seen_hashes: set[str],
) -> tuple[str, Path | None, str | None]:
file_hash = hashlib.sha256(file_content).hexdigest()
if file_hash in seen_hashes or FNSReportService.exists_by_hash(file_hash):
return "skipped", None, None
upload_dir = Path(settings.FNS_WATCH_DIRECTORY)
upload_dir.mkdir(parents=True, exist_ok=True)
file_path = upload_dir / file_name
if not cls._try_create_fns_lock(file_path):
return "skipped", None, None
lock_path = Path(f"{file_path}.lock")
if file_path.exists():
lock_path.unlink(missing_ok=True)
return "skipped", None, None
try:
file_path.write_bytes(file_content)
except Exception:
lock_path.unlink(missing_ok=True)
raise
return "prepared", file_path, file_hash
@staticmethod
def _try_create_fns_lock(file_path: Path) -> bool:
lock_path = Path(f"{file_path}.lock")
@@ -182,3 +304,14 @@ class FNSUploadService:
return
if status == "skipped":
result.skipped += 1
@staticmethod
def _accumulate_sync(*, result: FNSSyncUploadResult, status: str) -> None:
if status == "processed":
result.processed += 1
return
if status == "skipped":
result.skipped += 1
return
if status == "failed":
result.failed += 1

View File

@@ -0,0 +1,29 @@
from django.db import migrations
ADDITIONAL_REGISTER_NAMES = (
"Реестр госкорпорации Роскосмос ГОЗ",
"Реестр госкорпорации Роскосмос ОПК",
"Реестр госкорпорации Росатом ГОЗ",
"Реестр госкорпорации Росатом ОПК",
)
def seed_additional_registers(apps, schema_editor):
Register = apps.get_model("registers", "Register")
db_alias = schema_editor.connection.alias
for name in ADDITIONAL_REGISTER_NAMES:
Register.objects.using(db_alias).get_or_create(name=name)
class Migration(migrations.Migration):
dependencies = [
("registers", "0004_seed_default_registers"),
]
operations = [
migrations.RunPython(
seed_additional_registers,
migrations.RunPython.noop,
),
]

View File

@@ -7,7 +7,11 @@ from django.dispatch import receiver
DEFAULT_REGISTER_NAMES = (
"Реестр предприятий ОПК",
"Реестр госкорпорации Роскосмос",
"Реестр госкорпорации Роскосмос ГОЗ",
"Реестр госкорпорации Роскосмос ОПК",
"Реестр госкорпорации Росатом",
"Реестр госкорпорации Росатом ГОЗ",
"Реестр госкорпорации Росатом ОПК",
)

View File

@@ -63,8 +63,7 @@ class UserAdmin(BaseUserAdmin):
"is_verified",
"groups",
"user_permissions",
),
"classes": ("collapse",),
)
},
),
(
@@ -84,6 +83,7 @@ class UserAdmin(BaseUserAdmin):
"password1",
"password2",
"is_staff",
"is_superuser",
"is_active",
),
},