feat(admin): expand exchange admin and unify admin UX
Some checks failed
CI/CD Pipeline / Code Quality Checks (pull_request) Failing after 2m39s
CI/CD Pipeline / Run Tests (pull_request) Successful in 3m0s
CI/CD Pipeline / Run API Inventory E2E Tests (pull_request) Successful in 35s
CI/CD Pipeline / Telegram Notify Success (pull_request) Has been skipped

This commit is contained in:
2026-03-24 13:58:24 +01:00
parent 559b9bc5ef
commit c98ba76081
33 changed files with 2915 additions and 209 deletions

View File

@@ -4,6 +4,7 @@ from datetime import date, timedelta
from unittest.mock import patch
from apps.core.admin import BackgroundJobAdmin
from apps.core.admin_dashboard import build_admin_dashboard
from apps.core.models import BackgroundJob
from apps.parsers.models import FinancialReport, FinancialReportLine, ParserLoadLog
from django.contrib.admin.sites import AdminSite
@@ -165,13 +166,26 @@ class AdminDashboardTest(TestCase):
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")
ProcurementRecordFactory(
load_batch=303,
region_code="77",
customer_inn="7700000001",
)
ProcurementRecordFactory(
load_batch=303,
region_code="77",
customer_inn="7700000002",
)
ProcurementRecordFactory(
load_batch=303,
region_code="78",
customer_inn="7800000001",
)
ProxyFactory(is_active=True)
ParserLoadLogFactory(
source=ParserLoadLog.Source.PROCUREMENTS,
batch_id=303,
status="success",
records_count=3,
)
@@ -185,6 +199,7 @@ class AdminDashboardTest(TestCase):
response = self.client.get(reverse("admin:index"))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "Mostovik")
self.assertContains(response, "Панель данных и загрузок")
self.assertContains(response, "Обзор")
self.assertContains(response, "Аналитика")
@@ -204,3 +219,65 @@ class AdminDashboardTest(TestCase):
response,
reverse("admin:parsers_financialreport_upload_excel"),
)
def test_dashboard_source_cards_use_latest_successful_source_slice(self):
InspectionRecordFactory(load_batch=101, registration_number="old-1")
InspectionRecordFactory(load_batch=101, registration_number="old-2")
InspectionRecordFactory(load_batch=202, registration_number="new-1")
ParserLoadLogFactory(
source=ParserLoadLog.Source.INSPECTIONS,
batch_id=101,
status=ParserLoadLog.Status.SUCCESS,
records_count=2,
)
ParserLoadLogFactory(
source=ParserLoadLog.Source.INSPECTIONS,
batch_id=202,
status=ParserLoadLog.Status.SUCCESS,
records_count=1,
)
dashboard = build_admin_dashboard()
cards = {item["slug"]: item for item in dashboard["source_cards"]}
self.assertEqual(cards["planned-inspections"]["records_count"], 1)
self.assertEqual(cards["planned-inspections"]["organizations_count"], 1)
self.assertEqual(
cards["planned-inspections"]["metrics_scope_label"],
"Последний успешный срез",
)
def test_dashboard_regions_use_latest_successful_procurements_batch(self):
ProcurementRecordFactory(
load_batch=11,
purchase_number="1111111111111111111",
region_code="77",
customer_inn="7700000001",
)
ProcurementRecordFactory(
load_batch=22,
purchase_number="2222222222222222222",
region_code="78",
customer_inn="7800000001",
)
ParserLoadLogFactory(
source=ParserLoadLog.Source.PROCUREMENTS,
batch_id=11,
status=ParserLoadLog.Status.SUCCESS,
records_count=1,
)
ParserLoadLogFactory(
source=ParserLoadLog.Source.PROCUREMENTS,
batch_id=22,
status=ParserLoadLog.Status.SUCCESS,
records_count=1,
)
dashboard = build_admin_dashboard()
self.assertEqual(len(dashboard["region_rows"]), 1)
self.assertEqual(dashboard["region_rows"][0]["code"], "78")
self.assertEqual(
dashboard["region_rows_note"],
"Показываем только последний успешный срез ЕИС закупок.",
)

View File

@@ -0,0 +1,224 @@
"""Tests for exchange admin configuration."""
from types import SimpleNamespace
from unittest.mock import patch
from apps.exchange.admin import ExchangeConnectionAdmin
from apps.exchange.forms import ExchangeConnectionAdminForm
from apps.exchange.models import ExchangeConnection
from apps.exchange.services import ExchangePeriodicTaskService
from django.contrib.admin.sites import AdminSite
from django.contrib.messages.storage.fallback import FallbackStorage
from django.test import RequestFactory, TestCase
from django_celery_beat.models import IntervalSchedule, PeriodicTask
from tests.apps.exchange.factories import ExchangeConnectionFactory
from tests.apps.user.factories import UserFactory
_DB_PASSWORD = "secret" # noqa: S105
class ExchangeAdminTest(TestCase):
def setUp(self):
self.site = AdminSite()
self.admin = ExchangeConnectionAdmin(ExchangeConnection, self.site)
self.factory = RequestFactory()
self.user = UserFactory.create_superuser()
def _request(self, path: str):
request = self.factory.get(path)
request.user = self.user
request.session = {}
request._messages = FallbackStorage(request)
return request
def _post_request(self, path: str, data: dict):
request = self.factory.post(path, data=data)
request.user = self.user
request.session = {}
request._messages = FallbackStorage(request)
return request
def test_exchange_admin_has_custom_routes(self):
route_names = [route.name for route in self.admin.get_urls()]
self.assertIn("exchange_exchangeconnection_test_connection", route_names)
self.assertIn("exchange_exchangeconnection_copy_data", route_names)
self.assertIn("exchange_exchangeconnection_periodic_tasks", route_names)
self.assertIn("exchange_exchangeconnection_periodic_task_change", route_names)
def test_exchange_admin_changelist_renders_custom_buttons(self):
response = self.admin.changelist_view(
self._request("/admin/exchange/exchangeconnection/")
)
response.render()
content = response.content.decode("utf-8")
self.assertEqual(response.status_code, 200)
self.assertIn("Проверить новое подключение", content)
self.assertIn("Запустить обмен", content)
self.assertIn("Настроить периодический обмен", content)
self.assertIn("Добавить подключение обмена", content)
def test_exchange_connection_admin_form_keeps_existing_password_when_blank(self):
connection = ExchangeConnectionFactory(password=_DB_PASSWORD)
form = ExchangeConnectionAdminForm(
data={
"server": connection.server,
"port": connection.port,
"username": connection.username,
"password": "",
"database_name": connection.database_name,
"schema_name": connection.schema_name,
},
instance=connection,
)
self.assertTrue(form.is_valid(), form.errors)
saved_connection = form.save()
self.assertEqual(saved_connection.get_decrypted_password(), _DB_PASSWORD)
@patch("apps.exchange.admin.ExchangeConnectionService.validate_saved_connection")
def test_validate_selected_connections_calls_service(self, validate_mock):
connection = ExchangeConnectionFactory()
self.admin.validate_selected_connections(
self._request("/admin/exchange/exchangeconnection/"),
ExchangeConnection.objects.filter(id=connection.id),
)
validate_mock.assert_called_once_with(connection)
@patch("apps.exchange.admin.ExchangeConnectionService.activate_connection")
def test_activate_selected_connection_calls_service(self, activate_mock):
connection = ExchangeConnectionFactory()
self.admin.activate_selected_connection(
self._request("/admin/exchange/exchangeconnection/"),
ExchangeConnection.objects.filter(id=connection.id),
)
activate_mock.assert_called_once_with(connection)
@patch("apps.exchange.admin.ExchangeConnectionService.test_connection_payload")
def test_test_connection_view_success_redirects_after_validation(
self,
test_connection_mock,
):
test_connection_mock.return_value = {
"status": "success",
"message": "Подключение проверено.",
}
request = self._post_request(
"/admin/exchange/exchangeconnection/test-connection/",
{
"server": "127.0.0.1",
"port": 5432,
"username": "postgres",
"password": _DB_PASSWORD,
"database_name": "target_db",
"schema_name": "public",
},
)
response = self.admin.test_connection_view(request)
self.assertEqual(response.status_code, 302)
test_connection_mock.assert_called_once_with(
server="127.0.0.1",
port=5432,
username="postgres",
password=_DB_PASSWORD,
database_name="target_db",
schema_name="public",
)
@patch("apps.exchange.admin.BackgroundJobService.create_job")
@patch("apps.exchange.admin.copy_parsers_data_async")
def test_copy_data_view_enqueues_background_job(self, task_mock, create_job_mock):
active_connection = ExchangeConnectionFactory(is_active=True)
task_mock.delay.return_value = SimpleNamespace(id="task-123")
request = self._post_request(
"/admin/exchange/exchangeconnection/copy-data/",
{
"mode": "all",
"truncate_before_copy": "on",
},
)
response = self.admin.copy_data_view(request)
self.assertEqual(response.status_code, 302)
task_mock.delay.assert_called_once_with(
connection_id=active_connection.id,
payload={
"mode": "all",
"table": None,
"tables": None,
"truncate_before_copy": True,
},
requested_by_id=self.user.id,
)
create_job_mock.assert_called_once()
def test_periodic_tasks_view_lists_existing_tasks(self):
interval = IntervalSchedule.objects.create(every=2, period="hours")
PeriodicTask.objects.create(
name="exchange-copy-hourly",
task=ExchangePeriodicTaskService.TASK_NAME,
interval=interval,
kwargs='{"payload": {"mode": "all", "truncate_before_copy": true}}',
)
response = self.admin.periodic_tasks_view(
self._request("/admin/exchange/exchangeconnection/periodic-tasks/")
)
response.render()
content = response.content.decode("utf-8")
self.assertEqual(response.status_code, 200)
self.assertIn("exchange-copy-hourly", content)
self.assertIn("Каждые 2 hours", content)
@patch("apps.exchange.admin.ExchangePeriodicTaskService.create_periodic_task")
def test_periodic_tasks_view_creates_task(self, create_task_mock):
request = self._post_request(
"/admin/exchange/exchangeconnection/periodic-tasks/",
{
"name": "exchange-copy-nightly",
"description": "Nightly sync",
"enabled": "on",
"mode": "selected",
"tables": ["parsers_manufacturer"],
"truncate_before_copy": "on",
"notify_on_error": "on",
"schedule_type": "daily",
"crontab_minute": 15,
"crontab_hour": 3,
},
)
response = self.admin.periodic_tasks_view(request)
self.assertEqual(response.status_code, 302)
create_task_mock.assert_called_once_with(
name="exchange-copy-nightly",
description="Nightly sync",
enabled=True,
payload={
"mode": "selected",
"table": None,
"tables": ["parsers_manufacturer"],
"truncate_before_copy": True,
"notify_on_error": True,
},
schedule={
"type": "crontab",
"minute": "15",
"hour": "3",
"day_of_week": "*",
"day_of_month": "*",
"month_of_year": "*",
},
)

View File

@@ -101,3 +101,16 @@ class ExchangePeriodicTaskUpsertSerializerTest(SimpleTestCase):
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertTrue(serializer.validated_data["payload"]["notify_on_error"])
def test_truncate_before_copy_is_added_to_payload(self):
serializer = ExchangePeriodicTaskUpsertSerializer(
data={
"schedule_type": "interval",
"interval_every": 1,
"interval_period": "hours",
"truncate_before_copy": False,
}
)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertFalse(serializer.validated_data["payload"]["truncate_before_copy"])

View File

@@ -54,6 +54,9 @@ class ExchangeConnectionServiceUnitTest(TestCase):
"test_connection",
return_value="target_alias",
) as test_connection_mock, patch.object(
ExchangeConnectionService,
"prepare_target_structure",
) as prepare_mock, patch.object(
ExchangeConnectionService,
"validate_target_structure",
) as validate_mock:
@@ -70,13 +73,22 @@ class ExchangeConnectionServiceUnitTest(TestCase):
self.assertIsNotNone(connection.last_checked_at)
self.assertEqual(connection.last_error, "")
test_connection_mock.assert_called_once_with(connection)
prepare_mock.assert_called_once_with(
connection=connection,
alias="target_alias",
schema_name="public",
models_to_copy=None,
)
validate_mock.assert_called_once_with(
connection=connection,
alias="target_alias",
schema_name="public",
models_to_copy=None,
)
def test_test_connection_payload_does_not_persist_connection(self):
def test_validate_saved_connection_updates_timestamp_and_cleans_alias(self):
connection = ExchangeConnectionFactory(last_error="old error")
with patch.object(
ExchangeConnectionService,
"test_connection",
@@ -84,10 +96,85 @@ class ExchangeConnectionServiceUnitTest(TestCase):
) as test_connection_mock, patch.object(
ExchangeConnectionService,
"validate_target_structure",
) as validate_mock, patch(
"apps.exchange.services.connections"
) as connections_mock:
connections_mock.databases = {"target_alias": {}}
) as validate_mock, patch.object(
ExchangeConnectionService,
"_cleanup_alias",
) as cleanup_mock:
result = ExchangeConnectionService.validate_saved_connection(connection)
self.assertEqual(result, connection)
connection.refresh_from_db()
self.assertEqual(connection.last_error, "")
self.assertIsNotNone(connection.last_checked_at)
test_connection_mock.assert_called_once_with(connection)
validate_mock.assert_called_once_with(
connection=connection,
alias="target_alias",
schema_name=connection.schema_name,
models_to_copy=None,
)
cleanup_mock.assert_called_once_with("target_alias")
def test_validate_saved_connection_prepares_target_when_requested(self):
connection = ExchangeConnectionFactory(last_error="old error")
with patch.object(
ExchangeConnectionService,
"test_connection",
return_value="target_alias",
) as test_connection_mock, patch.object(
ExchangeConnectionService,
"prepare_target_structure",
) as prepare_mock, patch.object(
ExchangeConnectionService,
"validate_target_structure",
) as validate_mock, patch.object(
ExchangeConnectionService,
"_cleanup_alias",
) as cleanup_mock:
ExchangeConnectionService.validate_saved_connection(
connection,
prepare_target=True,
)
test_connection_mock.assert_called_once_with(connection)
prepare_mock.assert_called_once_with(
connection=connection,
alias="target_alias",
schema_name=connection.schema_name,
models_to_copy=None,
)
validate_mock.assert_called_once_with(
connection=connection,
alias="target_alias",
schema_name=connection.schema_name,
models_to_copy=None,
)
cleanup_mock.assert_called_once_with("target_alias")
def test_activate_connection_validates_and_switches_active_flag(self):
old_active = ExchangeConnectionFactory(is_active=True)
new_connection = ExchangeConnectionFactory(is_active=False)
with patch.object(
ExchangeConnectionService,
"validate_saved_connection",
return_value=new_connection,
) as validate_mock:
result = ExchangeConnectionService.activate_connection(new_connection)
self.assertEqual(result, new_connection)
validate_mock.assert_called_once_with(new_connection, prepare_target=True)
old_active.refresh_from_db()
new_connection.refresh_from_db()
self.assertFalse(old_active.is_active)
self.assertTrue(new_connection.is_active)
def test_test_connection_payload_does_not_persist_connection(self):
with patch.object(
ExchangeConnectionService,
"validate_saved_connection",
) as validate_mock:
result = ExchangeConnectionService.test_connection_payload(
server="127.0.0.1",
port=5432,
@@ -103,7 +190,6 @@ class ExchangeConnectionServiceUnitTest(TestCase):
"Подключение проверено. Соединение и структура БД валидны.",
)
self.assertEqual(ExchangeConnection.objects.count(), 0)
test_connection_mock.assert_called_once()
validate_mock.assert_called_once()
def test_get_active_connection_raises_when_missing(self):
@@ -272,6 +358,30 @@ class ExchangeConnectionServiceUnitTest(TestCase):
connection.refresh_from_db()
self.assertEqual(connection.last_error, "unexpected")
def test_prepare_target_structure_creates_schema_and_missing_tables(self):
connection = ExchangeConnectionFactory(schema_name="target_schema")
db_connection = MagicMock()
db_connection.ops.quote_name.return_value = '"target_schema"'
cursor = MagicMock()
cursor.fetchall.return_value = [("fake_table",)]
db_connection.cursor.return_value.__enter__.return_value = cursor
schema_editor = MagicMock()
db_connection.schema_editor.return_value.__enter__.return_value = schema_editor
connections_mock = MagicMock()
connections_mock.__getitem__.return_value = db_connection
with patch("apps.exchange.services.connections", connections_mock):
ExchangeConnectionService.prepare_target_structure(
connection=connection,
alias="target_alias",
schema_name="target_schema",
models_to_copy=[_FakeModel, _AnotherFakeModel],
)
db_connection.ensure_connection.assert_called_once_with()
cursor.execute.assert_any_call('CREATE SCHEMA IF NOT EXISTS "target_schema"')
schema_editor.create_model.assert_called_once_with(_AnotherFakeModel)
def test_copy_parsers_data_success(self):
connection = ExchangeConnectionFactory(schema_name="target_schema")
db_connection = MagicMock()
@@ -293,6 +403,9 @@ class ExchangeConnectionServiceUnitTest(TestCase):
"_extend_models_with_dependencies",
return_value=[_FakeModel, _AnotherFakeModel],
), patch.object(
ExchangeConnectionService,
"prepare_target_structure",
) as prepare_mock, patch.object(
ExchangeConnectionService,
"validate_target_structure",
) as validate_mock, patch.object(
@@ -312,6 +425,12 @@ class ExchangeConnectionServiceUnitTest(TestCase):
self.assertEqual(result["rows_by_table"], {"fake_table": 2, "another_table": 3})
self.assertEqual(result["total_rows"], 5)
self.assertFalse(result["truncate_before_copy"])
prepare_mock.assert_called_once_with(
connection=connection,
alias="target_alias",
schema_name="target_schema",
models_to_copy=[_FakeModel, _AnotherFakeModel],
)
validate_mock.assert_called_once_with(
connection=connection,
alias="target_alias",

View File

@@ -39,8 +39,14 @@ class ExchangeViewsTest(APITestCase):
self.assertIsInstance(response.data["results"], list)
@patch("apps.exchange.services.ExchangeConnectionService.validate_target_structure")
@patch("apps.exchange.services.ExchangeConnectionService.prepare_target_structure")
@patch("apps.exchange.services.ExchangeConnectionService.test_connection")
def test_create_connection_success(self, connection_mock, validate_mock):
def test_create_connection_success(
self,
connection_mock,
prepare_mock,
validate_mock,
):
old_active = ExchangeConnectionFactory(is_active=True)
payload = {
@@ -79,6 +85,7 @@ class ExchangeViewsTest(APITestCase):
self.assertFalse(old_active.is_active)
connection_mock.assert_called_once()
prepare_mock.assert_called_once()
validate_mock.assert_called_once()
@patch("apps.exchange.services.ExchangeConnectionService.test_connection_payload")

View File

@@ -149,6 +149,19 @@ class ParsersAdminTest(TestCase):
self.assertIn("Обновить список прокси", content)
self.assertIn("mx-object-tool-form", content)
def test_financial_report_changelist_renders_toolbar_buttons(self):
admin = FinancialReportAdmin(FinancialReport, self.site)
response = admin.changelist_view(
self._request("/admin/parsers/financialreport/")
)
response.render()
content = response.content.decode("utf-8")
self.assertEqual(response.status_code, 200)
self.assertIn("Загрузить Excel бухгалтерской отчетности", content)
self.assertIn("Загрузить ZIP бухгалтерской отчетности", content)
self.assertIn("mx-admin-action-bar", content)
@patch("apps.parsers.admin.ProxyToolsSyncService.sync_ru_proxies")
def test_proxy_admin_sync_view_calls_service(self, sync_mock):
sync_mock.return_value = {
@@ -178,9 +191,15 @@ class ParsersAdminTest(TestCase):
def test_parser_load_log_admin_status_badge(self):
admin = ParserLoadLogAdmin(ParserLoadLog, self.site)
log = ParserLoadLogFactory(status="success")
log = ParserLoadLogFactory(
source=ParserLoadLog.Source.FNS_REPORTS,
status=ParserLoadLog.Status.SUCCESS,
)
badge = admin.status_badge(log)
self.assertIn("span", str(badge))
self.assertEqual(admin.source_title(log), "Бухгалтерская отчетность ФНС")
self.assertIn("source_title", admin.list_display)
self.assertNotIn("batch_id", admin.list_display)
request = self._request()
self.assertFalse(admin.has_add_permission(request))

View File

@@ -46,8 +46,8 @@ class RegistersAdminTest(TestCase):
self.factory = RequestFactory()
self.user = UserFactory.create_superuser()
def _request(self):
request = self.factory.get("/admin/registers/registerupload/upload-excel/")
def _request(self, path="/admin/registers/registerupload/upload-excel/"):
request = self.factory.get(path)
request.user = self.user
request.session = {}
request._messages = FallbackStorage(request)
@@ -78,6 +78,20 @@ class RegistersAdminTest(TestCase):
self.assertEqual(response.status_code, 200)
self.assertIn('type="file"', content)
self.assertIn("mx-upload-file", content)
self.assertIn("multiple", content)
def test_register_upload_changelist_renders_toolbar_buttons(self):
admin = RegisterUploadAdmin(RegisterUpload, self.site)
response = admin.changelist_view(
self._request("/admin/registers/registerupload/")
)
response.render()
content = response.content.decode("utf-8")
self.assertEqual(response.status_code, 200)
self.assertIn("Загрузить справочники из Excel", content)
self.assertIn("Добавить загрузку реестра", content)
self.assertIn("mx-admin-action-bar", content)
def test_register_upload_admin_upload_excel_success(self):
admin = RegisterUploadAdmin(RegisterUpload, self.site)
@@ -144,3 +158,38 @@ class RegistersAdminTest(TestCase):
self.assertEqual(response.status_code, 302)
sync_mock.assert_called_once()
def test_register_upload_admin_processes_multiple_files(self):
admin = RegisterUploadAdmin(RegisterUpload, self.site)
registry = RegisterFactory()
first_upload = _build_register_excel_upload("registry_1.xlsx")
second_upload = _build_register_excel_upload("registry_2.xlsx")
request = self._post_request(
{
"registry": str(registry.id),
"actual_date": "2026-03-20",
"files": [first_upload, second_upload],
}
)
with patch(
"apps.registers.admin.RegisterImportService.sync_registry_memberships",
side_effect=[
{
"registry_name": registry.name,
"rows_in_file": 1,
"organizations_created": 1,
"organizations_updated": 0,
},
{
"registry_name": registry.name,
"rows_in_file": 1,
"organizations_created": 0,
"organizations_updated": 1,
},
],
) as sync_mock:
response = admin.upload_excel_view(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(sync_mock.call_count, 2)