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

@@ -1,15 +1,31 @@
"""Tests for core admin configurations."""
from datetime import timedelta
from datetime import date, timedelta
from unittest.mock import patch
from apps.core.admin import BackgroundJobAdmin
from apps.core.models import BackgroundJob
from apps.parsers.models import FinancialReport, FinancialReportLine, ParserLoadLog
from django.contrib.admin.sites import AdminSite
from django.contrib.messages.storage.fallback import FallbackStorage
from django.test import RequestFactory, TestCase
from django.urls import reverse
from django.utils import timezone
from tests.apps.parsers.factories import (
IndustrialCertificateRecordFactory,
InspectionRecordFactory,
ManufacturerRecordFactory,
ParserLoadLogFactory,
ProcurementRecordFactory,
ProxyFactory,
)
from tests.apps.registers.factories import (
OrganizationFactory,
RegisterFactory,
RegisterUploadFactory,
RegistryMembershipPeriodFactory,
)
from tests.apps.user.factories import UserFactory
from tests.utils.fixtures import fake
@@ -94,3 +110,97 @@ class CoreAdminTest(TestCase):
revoke_mock.assert_called_once_with(job.task_id, terminate=True)
job.refresh_from_db()
self.assertEqual(job.status, "revoked")
class AdminDashboardTest(TestCase):
def setUp(self):
self.user = UserFactory.create_superuser()
self.client.force_login(self.user)
def test_admin_index_renders_custom_dashboard_with_live_metrics(self):
primary_registry = RegisterFactory(name="Реестр Росатом ОПК тестовый")
secondary_registry = RegisterFactory(name="Реестр Роскосмос ГОЗ тестовый")
primary_upload = RegisterUploadFactory(
registry=primary_registry,
actual_date=date(2026, 3, 20),
rows_count=24,
)
secondary_upload = RegisterUploadFactory(
registry=secondary_registry,
actual_date=date(2026, 3, 21),
rows_count=11,
)
first_org = OrganizationFactory(pn_name='АО "Росатом Тест"')
second_org = OrganizationFactory(pn_name='АО "Роскосмос Тест"')
RegistryMembershipPeriodFactory(
registry=primary_registry,
organization=first_org,
started_by_upload=primary_upload,
)
RegistryMembershipPeriodFactory(
registry=secondary_registry,
organization=second_org,
started_by_upload=secondary_upload,
)
report = FinancialReport.objects.create(
external_id="fns-dashboard-1",
ogrn="1027700000001",
file_name="fin_dashboard_1.xlsx",
file_hash="fns-dashboard-file-hash-1",
load_batch=101,
status=FinancialReport.Status.SUCCESS,
source=FinancialReport.SourceType.API,
)
FinancialReportLine.objects.create(
report=report,
form_code="1",
line_code="1100",
line_name="Итого по разделу",
year=2025,
period_start=10,
period_end=15,
)
IndustrialCertificateRecordFactory(inn="7700000001")
ManufacturerRecordFactory(inn="7700000002")
InspectionRecordFactory(inn="7800000001")
ProcurementRecordFactory(region_code="77", customer_inn="7700000001")
ProcurementRecordFactory(region_code="77", customer_inn="7700000002")
ProcurementRecordFactory(region_code="78", customer_inn="7800000001")
ProxyFactory(is_active=True)
ParserLoadLogFactory(
source=ParserLoadLog.Source.PROCUREMENTS,
status="success",
records_count=3,
)
ParserLoadLogFactory(
source=ParserLoadLog.Source.INDUSTRIAL,
status="failed",
records_count=0,
error_message="Источник временно недоступен",
)
response = self.client.get(reverse("admin:index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Панель данных и загрузок")
self.assertContains(response, "Обзор")
self.assertContains(response, "Аналитика")
self.assertContains(response, "Админка")
self.assertContains(response, "Покрытие по внешним данным")
self.assertContains(response, "Топ регионов по закупкам")
self.assertContains(response, "Реестр Росатом ОПК тестовый")
self.assertContains(response, "Реестр Роскосмос ГОЗ тестовый")
self.assertContains(response, "Москва")
self.assertContains(response, "Санкт-Петербург")
self.assertContains(response, "Источник временно недоступен")
self.assertContains(
response,
reverse("admin:registers_registerupload_upload_excel"),
)
self.assertContains(
response,
reverse("admin:parsers_financialreport_upload_excel"),
)

View File

@@ -4,6 +4,7 @@ import io
import os
import tempfile
import zipfile
from unittest.mock import patch
from apps.parsers.admin import (
FinancialReportAdmin,
@@ -84,14 +85,24 @@ def _build_fns_zip_upload() -> SimpleUploadedFile:
)
def _build_fns_excel_upload() -> SimpleUploadedFile:
return SimpleUploadedFile(
f"fin_{_digits(5)}_{_digits(13)}.xlsx",
_build_fns_excel_bytes(),
content_type=(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
)
class ParsersAdminTest(TestCase):
def setUp(self):
self.site = AdminSite()
self.factory = RequestFactory()
self.user = UserFactory.create_superuser()
def _request(self):
request = self.factory.get("/")
def _request(self, path="/"):
request = self.factory.get(path)
request.user = self.user
request.session = {}
request._messages = FallbackStorage(request)
@@ -122,6 +133,42 @@ class ParsersAdminTest(TestCase):
proxy.refresh_from_db()
self.assertEqual(proxy.fail_count, 0)
def test_proxy_admin_has_sync_route(self):
admin = ProxyAdmin(Proxy, self.site)
route_names = [route.name for route in admin.get_urls()]
self.assertIn("parsers_proxy_sync_proxy_tools", route_names)
def test_proxy_admin_changelist_renders_sync_button(self):
admin = ProxyAdmin(Proxy, self.site)
response = admin.changelist_view(self._request("/admin/parsers/proxy/"))
response.render()
content = response.content.decode("utf-8")
self.assertEqual(response.status_code, 200)
self.assertIn("Обновить список прокси", content)
self.assertIn("mx-object-tool-form", content)
@patch("apps.parsers.admin.ProxyToolsSyncService.sync_ru_proxies")
def test_proxy_admin_sync_view_calls_service(self, sync_mock):
sync_mock.return_value = {
"status": "success",
"fetched": 3,
"created": 2,
"updated": 1,
"deactivated": 0,
}
admin = ProxyAdmin(Proxy, self.site)
request = self._post_request(
"/admin/parsers/proxy/sync-proxy-tools/",
{},
)
response = admin.sync_proxy_tools_view(request)
self.assertEqual(response.status_code, 302)
sync_mock.assert_called_once_with()
def test_proxy_active_badge(self):
admin = ProxyAdmin(Proxy, self.site)
active = ProxyFactory(is_active=True)
@@ -299,6 +346,17 @@ class ParsersAdminTest(TestCase):
self.assertIn("parsers_financialreport_upload_excel", route_names)
self.assertIn("parsers_financialreport_upload_zip", route_names)
def test_financial_report_admin_upload_excel_get_renders_custom_file_picker(self):
admin = FinancialReportAdmin(FinancialReport, self.site)
response = admin.upload_excel_view(self._request())
response.render()
content = response.content.decode("utf-8")
self.assertEqual(response.status_code, 200)
self.assertIn('type="file"', content)
self.assertIn("mx-upload-file", content)
self.assertIn("multiple", content)
def test_financial_report_admin_upload_zip_view(self):
admin = FinancialReportAdmin(FinancialReport, self.site)
archive_upload = _build_fns_zip_upload()
@@ -311,9 +369,57 @@ class ParsersAdminTest(TestCase):
FNS_WATCH_DIRECTORY=os.path.join(tmpdir, "watch"),
FNS_PROCESSED_DIRECTORY=os.path.join(tmpdir, "processed"),
FNS_FAILED_DIRECTORY=os.path.join(tmpdir, "failed"),
):
), patch("apps.parsers.fns_upload.process_fns_file") as task_mock:
task_mock.apply_async.side_effect = AssertionError(
"admin upload should not enqueue celery task"
)
response = admin.upload_zip_view(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(FinancialReport.objects.count(), 1)
self.assertEqual(FinancialReportLine.objects.count(), 1)
def test_financial_report_admin_upload_excel_view_processes_multiple_files(self):
admin = FinancialReportAdmin(FinancialReport, self.site)
first_upload = _build_fns_excel_upload()
second_upload = _build_fns_excel_upload()
request = self._post_request(
"/admin/parsers/financialreport/upload-excel/",
{"files": [first_upload, second_upload]},
)
with tempfile.TemporaryDirectory() as tmpdir, override_settings(
FNS_WATCH_DIRECTORY=os.path.join(tmpdir, "watch"),
FNS_PROCESSED_DIRECTORY=os.path.join(tmpdir, "processed"),
FNS_FAILED_DIRECTORY=os.path.join(tmpdir, "failed"),
), patch("apps.parsers.fns_upload.process_fns_file") as task_mock:
task_mock.apply_async.side_effect = AssertionError(
"admin upload should not enqueue celery task"
)
response = admin.upload_excel_view(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(FinancialReport.objects.count(), 2)
self.assertEqual(FinancialReportLine.objects.count(), 2)
def test_financial_report_admin_upload_excel_view_processes_sync(self):
admin = FinancialReportAdmin(FinancialReport, self.site)
excel_upload = _build_fns_excel_upload()
request = self._post_request(
"/admin/parsers/financialreport/upload-excel/",
{"files": [excel_upload]},
)
with tempfile.TemporaryDirectory() as tmpdir, override_settings(
FNS_WATCH_DIRECTORY=os.path.join(tmpdir, "watch"),
FNS_PROCESSED_DIRECTORY=os.path.join(tmpdir, "processed"),
FNS_FAILED_DIRECTORY=os.path.join(tmpdir, "failed"),
), patch("apps.parsers.fns_upload.process_fns_file") as task_mock:
task_mock.apply_async.side_effect = AssertionError(
"admin upload should not enqueue celery task"
)
response = admin.upload_excel_view(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(FinancialReport.objects.count(), 1)
self.assertEqual(FinancialReportLine.objects.count(), 1)

View File

@@ -1,6 +1,7 @@
"""Tests for registers admin configuration."""
import io
from unittest.mock import patch
from apps.registers.admin import RegisterUploadAdmin
from apps.registers.models import Organization, RegisterUpload, RegistryMembershipPeriod
@@ -45,6 +46,13 @@ class RegistersAdminTest(TestCase):
self.factory = RequestFactory()
self.user = UserFactory.create_superuser()
def _request(self):
request = self.factory.get("/admin/registers/registerupload/upload-excel/")
request.user = self.user
request.session = {}
request._messages = FallbackStorage(request)
return request
def _post_request(self, data):
request = self.factory.post(
"/admin/registers/registerupload/upload-excel/",
@@ -61,6 +69,16 @@ class RegistersAdminTest(TestCase):
self.assertIn("registers_registerupload_upload_excel", route_names)
def test_register_upload_admin_get_renders_custom_file_picker(self):
admin = RegisterUploadAdmin(RegisterUpload, self.site)
response = admin.upload_excel_view(self._request())
response.render()
content = response.content.decode("utf-8")
self.assertEqual(response.status_code, 200)
self.assertIn('type="file"', content)
self.assertIn("mx-upload-file", content)
def test_register_upload_admin_upload_excel_success(self):
admin = RegisterUploadAdmin(RegisterUpload, self.site)
registry = RegisterFactory()
@@ -100,3 +118,29 @@ class RegistersAdminTest(TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(RegisterUpload.objects.count(), 0)
def test_register_upload_admin_uses_sync_import_service(self):
admin = RegisterUploadAdmin(RegisterUpload, self.site)
registry = RegisterFactory()
uploaded_file = _build_register_excel_upload()
request = self._post_request(
{
"registry": str(registry.id),
"actual_date": "2026-03-20",
"file": uploaded_file,
}
)
with patch(
"apps.registers.admin.RegisterImportService.sync_registry_memberships",
return_value={
"registry_name": registry.name,
"rows_in_file": 1,
"organizations_created": 1,
"organizations_updated": 0,
},
) as sync_mock:
response = admin.upload_excel_view(request)
self.assertEqual(response.status_code, 302)
sync_mock.assert_called_once()

View File

@@ -113,7 +113,11 @@ class RegistersViewsTest(APITestCase):
names = {item["name"] for item in _extract_results(response.data)}
self.assertIn("Реестр предприятий ОПК", names)
self.assertIn("Реестр госкорпорации Роскосмос", names)
self.assertIn("Реестр госкорпорации Роскосмос ГОЗ", names)
self.assertIn("Реестр госкорпорации Роскосмос ОПК", names)
self.assertIn("Реестр госкорпорации Росатом", names)
self.assertIn("Реестр госкорпорации Росатом ГОЗ", names)
self.assertIn("Реестр госкорпорации Росатом ОПК", names)
def test_organizations_list_and_retrieve(self):
organization = OrganizationFactory()

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from apps.user.admin import ProfileAdmin, UserAdmin
from apps.user.models import Profile, User
from django.contrib.admin.sites import AdminSite
from django.contrib.admin.utils import flatten_fieldsets
from django.contrib.messages.storage.fallback import FallbackStorage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import RequestFactory, TestCase
@@ -76,6 +77,24 @@ class UserAdminTest(TestCase):
self.admin.activate_users(request, qs)
self.assertTrue(User.objects.filter(is_active=True).count() >= 3)
def test_add_form_includes_staff_and_superuser_flags(self):
add_fields = flatten_fieldsets(self.admin.add_fieldsets)
self.assertIn("is_staff", add_fields)
self.assertIn("is_superuser", add_fields)
def test_permissions_fieldset_is_visible_with_staff_and_superuser_flags(self):
permissions_section = next(
section
for section in self.admin.fieldsets
if "is_staff" in section[1]["fields"]
and "is_superuser" in section[1]["fields"]
)
self.assertIn("is_staff", permissions_section[1]["fields"])
self.assertIn("is_superuser", permissions_section[1]["fields"])
self.assertNotIn("classes", permissions_section[1])
class ProfileAdminTest(TestCase):
def setUp(self):