From c98ba76081d68358fa209a9c45883e18cf4cd889 Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Tue, 24 Mar 2026 13:58:24 +0100 Subject: [PATCH] feat(admin): expand exchange admin and unify admin UX --- docker-compose.service.yml | 18 + src/apps/core/admin_dashboard.py | 261 ++++++++- src/apps/exchange/admin.py | 462 ++++++++++++++- src/apps/exchange/forms.py | 331 +++++++++++ src/apps/exchange/serializers.py | 1 + src/apps/exchange/services.py | 254 +++++++-- src/apps/parsers/admin.py | 39 +- .../migrations/0016_auto_20260324_1120.py | 51 ++ src/apps/parsers/models.py | 42 +- src/apps/registers/admin.py | 111 +++- src/apps/user/apps.py | 2 +- src/apps/user/models.py | 8 +- src/settings/base.py | 16 +- src/static/admin/css/mostovik-admin-theme.css | 536 +++++++++++++++++- .../admin/js/mostovik-admin-particles.js | 153 +++++ src/static/admin/js/vendor/particles.min.js | 9 + .../exchangeconnection/change_list.html | 25 + .../exchangeconnection/form_page.html | 66 +++ .../exchangeconnection/periodic_tasks.html | 129 +++++ src/templates/admin/index.html | 7 +- .../parsers/financialreport/change_list.html | 18 +- .../parsers/financialreport/upload_excel.html | 4 +- .../parsers/financialreport/upload_zip.html | 4 +- .../registers/registerupload/change_list.html | 19 +- .../registerupload/upload_excel.html | 15 +- tests/apps/core/test_admin.py | 83 ++- tests/apps/exchange/test_admin.py | 224 ++++++++ tests/apps/exchange/test_serializers.py | 13 + tests/apps/exchange/test_service_units.py | 131 ++++- tests/apps/exchange/test_views.py | 9 +- tests/apps/parsers/test_admin.py | 21 +- tests/apps/registers/test_admin.py | 53 +- tests/test_api_inventory_e2e.py | 9 +- 33 files changed, 2915 insertions(+), 209 deletions(-) create mode 100644 src/apps/exchange/forms.py create mode 100644 src/apps/parsers/migrations/0016_auto_20260324_1120.py create mode 100644 src/static/admin/js/mostovik-admin-particles.js create mode 100644 src/static/admin/js/vendor/particles.min.js create mode 100644 src/templates/admin/exchange/exchangeconnection/change_list.html create mode 100644 src/templates/admin/exchange/exchangeconnection/form_page.html create mode 100644 src/templates/admin/exchange/exchangeconnection/periodic_tasks.html create mode 100644 tests/apps/exchange/test_admin.py diff --git a/docker-compose.service.yml b/docker-compose.service.yml index 801815e..7d02630 100644 --- a/docker-compose.service.yml +++ b/docker-compose.service.yml @@ -18,6 +18,24 @@ services: timeout: 10s retries: 3 + db_exchange_target: + image: postgres:15.10 + container_name: db_exchange_target + restart: unless-stopped + environment: + POSTGRES_DB: ${TARGET_POSTGRES_DB:-mostovik_target} + POSTGRES_USER: ${TARGET_POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${TARGET_POSTGRES_PASSWORD:-postgres} + volumes: + - ./data/db_exchange_target:/var/lib/postgresql/data + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 30s + timeout: 10s + retries: 3 + redis: image: redis:7-alpine container_name: redis diff --git a/src/apps/core/admin_dashboard.py b/src/apps/core/admin_dashboard.py index 4c4f7fc..e9cae32 100644 --- a/src/apps/core/admin_dashboard.py +++ b/src/apps/core/admin_dashboard.py @@ -4,7 +4,17 @@ from __future__ import annotations from typing import Any -from apps.parsers.models import ParserLoadLog, ProcurementRecord, Proxy +from apps.parsers.models import ( + FinancialReport, + FinancialReportLine, + IndustrialCertificateRecord, + IndustrialProductRecord, + InspectionRecord, + ManufacturerRecord, + ParserLoadLog, + ProcurementRecord, + Proxy, +) from apps.parsers.source_cards import SourceCardService from apps.registers.models import ( Organization, @@ -161,7 +171,7 @@ def build_admin_dashboard() -> dict[str, Any]: { "label": "Записи источников", "value": _format_int(total_records), - "caption": "Суммарный объём данных по внешним источникам", + "caption": "Суммарный объём по последним успешным срезам источников", "tone": "amber", }, { @@ -204,20 +214,26 @@ def build_admin_dashboard() -> dict[str, Any]: ), _build_quick_action( label="ФНС Excel", - description="Загрузить один или несколько файлов отчётности", + description="Загрузить один или несколько файлов бухгалтерской отчётности", url_name="admin:parsers_financialreport_upload_excel", ), _build_quick_action( - label="Логи источников", - description="Проверить последние загрузки и ошибки парсеров", + label="История обновлений", + description="Проверить последние загрузки и ошибки по источникам", url_name="admin:parsers_parserloadlog_changelist", ), ) if item is not None ], + "source_cards_note": ( + "Карточки и диаграмма строятся по последнему успешному срезу каждого " + "источника. Если успешной загрузки ещё не было, показываем текущее " + "содержимое таблиц." + ), "source_cards": source_cards, "source_mix": source_mix, "registry_rows": _build_registry_rows(), + "region_rows_note": _get_region_rows_note(), "region_rows": _build_region_rows(), "activity_feed": _build_activity_feed(), } @@ -225,31 +241,42 @@ def build_admin_dashboard() -> dict[str, Any]: 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) + snapshot_totals = [_get_card_snapshot_totals(card) for card in cards] + total_records = sum(item["records_count"] for item in snapshot_totals) + max_records = max((item["records_count"] for item in snapshot_totals), default=0) + max_organizations = max( + (item["organizations_count"] for item in snapshot_totals), + default=0, + ) enriched_cards = [] for index, card in enumerate(cards): + snapshot = snapshot_totals[index] 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_count": snapshot["records_count"], + "organizations_count": snapshot["organizations_count"], + "records_count_label": _format_int(snapshot["records_count"]), + "organizations_count_label": _format_int( + snapshot["organizations_count"] + ), + "metrics_scope_label": snapshot["scope_label"], "records_share": round( - (card["records_count"] / total_records) * 100, 1 + (snapshot["records_count"] / total_records) * 100, + 1, ) if total_records else 0, "records_bar_width": _relative_width( - value=card["records_count"], + value=snapshot["records_count"], max_value=max_records, ), "organizations_bar_width": _relative_width( - value=card["organizations_count"], + value=snapshot["organizations_count"], max_value=max_organizations, ), } @@ -258,6 +285,159 @@ def _build_source_cards() -> list[dict[str, Any]]: return enriched_cards +def _get_card_snapshot_totals(card: dict[str, Any]) -> dict[str, Any]: + source_items = card.get("source_items", []) + snapshot_items = [_get_source_item_snapshot(item) for item in source_items] + batch_based = any(item["batch_id"] is not None for item in snapshot_items) + records_count = sum(item["records_count"] for item in snapshot_items) + + if card["slug"] == "manufacturers-and-products": + organization_sets = [ + _get_source_batch_organizations(item["code"], item["batch_id"]) + if item["batch_id"] is not None + else _get_source_all_organizations(item["code"]) + for item in snapshot_items + ] + organizations_count = ( + len(set().union(*organization_sets)) if organization_sets else 0 + ) + else: + organizations_count = sum( + item["organizations_count"] for item in snapshot_items + ) + + return { + "records_count": records_count, + "organizations_count": organizations_count, + "scope_label": ( + "Последний успешный срез" if batch_based else "Текущее состояние таблиц" + ), + } + + +def _get_source_item_snapshot(item: dict[str, Any]) -> dict[str, Any]: + latest_success_load = item.get("latest_success_load") or {} + batch_id = latest_success_load.get("batch_id") + if batch_id is None: + return { + "code": item["code"], + "batch_id": None, + "records_count": item["records_count"], + "organizations_count": item["organizations_count"], + } + + return { + "code": item["code"], + "batch_id": batch_id, + "records_count": _get_source_batch_records_count(item["code"], batch_id), + "organizations_count": len( + _get_source_batch_organizations(item["code"], batch_id) + ), + } + + +def _get_source_batch_records_count(item_code: str, batch_id: int) -> int: + if item_code == "fns_reports": + return FinancialReportLine.objects.filter(report__load_batch=batch_id).count() + if item_code == "industrial": + return IndustrialCertificateRecord.objects.filter(load_batch=batch_id).count() + if item_code == "manufactures": + return ManufacturerRecord.objects.filter(load_batch=batch_id).count() + if item_code == "industrial_products": + return IndustrialProductRecord.objects.filter(load_batch=batch_id).count() + if item_code == "inspections": + return InspectionRecord.objects.filter(load_batch=batch_id).count() + if item_code == "procurements": + return ProcurementRecord.objects.filter(load_batch=batch_id).count() + return 0 + + +def _get_source_batch_organizations(item_code: str, batch_id: int) -> set[str]: + if item_code == "fns_reports": + return set( + FinancialReport.objects.filter(load_batch=batch_id) + .exclude(ogrn="") + .values_list("ogrn", flat=True) + .distinct() + ) + if item_code == "industrial": + return set( + IndustrialCertificateRecord.objects.filter(load_batch=batch_id) + .exclude(inn="") + .values_list("inn", flat=True) + .distinct() + ) + if item_code == "manufactures": + return set( + ManufacturerRecord.objects.filter(load_batch=batch_id) + .exclude(inn="") + .values_list("inn", flat=True) + .distinct() + ) + if item_code == "industrial_products": + return set( + IndustrialProductRecord.objects.filter(load_batch=batch_id) + .exclude(inn="") + .values_list("inn", flat=True) + .distinct() + ) + if item_code == "inspections": + return set( + InspectionRecord.objects.filter(load_batch=batch_id) + .exclude(inn="") + .values_list("inn", flat=True) + .distinct() + ) + if item_code == "procurements": + return set( + ProcurementRecord.objects.filter(load_batch=batch_id) + .exclude(customer_inn="") + .values_list("customer_inn", flat=True) + .distinct() + ) + return set() + + +def _get_source_all_organizations(item_code: str) -> set[str]: + if item_code == "fns_reports": + return set( + FinancialReport.objects.exclude(ogrn="") + .values_list("ogrn", flat=True) + .distinct() + ) + if item_code == "industrial": + return set( + IndustrialCertificateRecord.objects.exclude(inn="") + .values_list("inn", flat=True) + .distinct() + ) + if item_code == "manufactures": + return set( + ManufacturerRecord.objects.exclude(inn="") + .values_list("inn", flat=True) + .distinct() + ) + if item_code == "industrial_products": + return set( + IndustrialProductRecord.objects.exclude(inn="") + .values_list("inn", flat=True) + .distinct() + ) + if item_code == "inspections": + return set( + InspectionRecord.objects.exclude(inn="") + .values_list("inn", flat=True) + .distinct() + ) + if item_code == "procurements": + return set( + ProcurementRecord.objects.exclude(customer_inn="") + .values_list("customer_inn", flat=True) + .distinct() + ) + return set() + + 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) @@ -278,13 +458,13 @@ def _build_source_mix(source_cards: list[dict[str, Any]]) -> dict[str, Any]: cursor = 0.0 gradient_parts: list[str] = [] segments = [] - for card in sorted(non_empty_cards, key=lambda item: item["records_count"], reverse=True): + 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}%" - ) + gradient_parts.append(f"{card['color']} {start:.2f}% {end:.2f}%") cursor = end segments.append( { @@ -313,10 +493,11 @@ def _build_registry_rows() -> list[dict[str, Any]]: 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] + ).order_by("-active_organizations", "name")[:6] + ) + max_active = max( + (registry.active_organizations for registry in registries), default=0 ) - max_active = max((registry.active_organizations for registry in registries), default=0) return [ { @@ -337,9 +518,13 @@ def _build_registry_rows() -> list[dict[str, Any]]: def _build_region_rows() -> list[dict[str, Any]]: + queryset = ProcurementRecord.objects.exclude(region_code="") + latest_batch_id = _get_latest_success_batch_id(ParserLoadLog.Source.PROCUREMENTS) + if latest_batch_id is not None: + queryset = queryset.filter(load_batch=latest_batch_id) + regions = list( - ProcurementRecord.objects.exclude(region_code="") - .values("region_code") + queryset.values("region_code") .annotate( records_count=Count("id"), organizations_count=Count("customer_inn", distinct=True), @@ -367,6 +552,12 @@ def _build_region_rows() -> list[dict[str, Any]]: ] +def _get_region_rows_note() -> str: + if _get_latest_success_batch_id(ParserLoadLog.Source.PROCUREMENTS) is not None: + return "Показываем только последний успешный срез ЕИС закупок." + return "Успешной загрузки закупок ещё не было, поэтому используем текущее содержимое таблицы." + + def _build_activity_feed() -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] @@ -374,18 +565,17 @@ def _build_activity_feed() -> list[dict[str, Any]]: events.append( { "timestamp": load.created_at, - "title": ( - SourceCardService.get_card_title_by_parser_source(load.source) - or load.get_source_display() - ), + "title": load.get_source_display(), "kind": "Источник", "meta": f"Пакет #{load.batch_id} · {_format_int(load.records_count)} записей", - "note": load.error_message or load.status, + "note": load.error_message or load.get_status_display(), "tone": STATUS_TONES.get(load.status, "muted"), } ) - for upload in RegisterUpload.objects.select_related("registry").order_by("-created_at")[:5]: + for upload in RegisterUpload.objects.select_related("registry").order_by( + "-created_at" + )[:5]: events.append( { "timestamp": upload.created_at, @@ -432,6 +622,21 @@ def _format_int(value: int) -> str: return f"{value:,}".replace(",", " ") +def _get_latest_success_batch_id(source: str) -> int | None: + latest_load = ( + ParserLoadLog.objects.filter( + source=source, + status__in={ + ParserLoadLog.Status.SUCCESS, + ParserLoadLog.Status.SKIPPED, + }, + ) + .order_by("-updated_at", "-created_at") + .first() + ) + return latest_load.batch_id if latest_load else None + + def _safe_reverse(url_name: str) -> str | None: try: return reverse(url_name) diff --git a/src/apps/exchange/admin.py b/src/apps/exchange/admin.py index 29c9c09..7e9ee2e 100644 --- a/src/apps/exchange/admin.py +++ b/src/apps/exchange/admin.py @@ -1,25 +1,475 @@ """Admin configuration for exchange app.""" +from __future__ import annotations + +from contextlib import suppress + +from apps.core.services import BackgroundJobService +from apps.exchange.forms import ( + ExchangeConnectionAdminForm, + ExchangeConnectionTestForm, + ExchangeCopyAdminForm, + ExchangePeriodicTaskAdminForm, +) from apps.exchange.models import ExchangeConnection -from django.contrib import admin +from apps.exchange.serializers import ( + ExchangePeriodicTaskSerializer, + get_periodic_task_payload, +) +from apps.exchange.services import ( + ExchangeConnectionService, + ExchangePeriodicTaskService, + ExchangeServiceError, +) +from apps.exchange.tasks import copy_parsers_data_async +from django.contrib import admin, messages +from django.db import IntegrityError +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.urls import NoReverseMatch, path, reverse +from django.utils.html import format_html @admin.register(ExchangeConnection) class ExchangeConnectionAdmin(admin.ModelAdmin): """Admin для подключений обмена.""" + change_list_template = "admin/exchange/exchangeconnection/change_list.html" + form = ExchangeConnectionAdminForm list_display = [ "id", - "server", - "port", - "username", + "connection_target", "database_name", "schema_name", - "is_active", + "status_badge", + "health_badge", "last_checked_at", + "last_error_short", "created_at", ] list_filter = ["is_active", "created_at", "last_checked_at"] search_fields = ["server", "username", "database_name", "schema_name"] - readonly_fields = ["created_at", "updated_at", "last_checked_at", "last_error"] + readonly_fields = [ + "is_active", + "status_badge", + "health_badge", + "last_checked_at", + "last_error", + "created_at", + "updated_at", + ] ordering = ["-is_active", "-created_at"] + actions = [ + "validate_selected_connections", + "activate_selected_connection", + "deactivate_selected_connections", + ] + fieldsets = ( + ( + "Подключение", + { + "fields": ( + "server", + "port", + "username", + "password", + "database_name", + "schema_name", + ) + }, + ), + ( + "Статус и проверка", + { + "fields": ( + "is_active", + "status_badge", + "health_badge", + "last_checked_at", + "last_error", + ) + }, + ), + ( + "Служебное", + { + "fields": ("created_at", "updated_at"), + "classes": ("collapse",), + }, + ), + ) + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + "test-connection/", + self.admin_site.admin_view(self.test_connection_view), + name="exchange_exchangeconnection_test_connection", + ), + path( + "copy-data/", + self.admin_site.admin_view(self.copy_data_view), + name="exchange_exchangeconnection_copy_data", + ), + path( + "periodic-tasks/", + self.admin_site.admin_view(self.periodic_tasks_view), + name="exchange_exchangeconnection_periodic_tasks", + ), + path( + "periodic-tasks//", + self.admin_site.admin_view(self.periodic_tasks_view), + name="exchange_exchangeconnection_periodic_task_change", + ), + ] + return custom_urls + urls + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + extra_context.update( + { + "test_connection_url": reverse( + "admin:exchange_exchangeconnection_test_connection" + ), + "copy_data_url": reverse("admin:exchange_exchangeconnection_copy_data"), + "periodic_tasks_url": reverse( + "admin:exchange_exchangeconnection_periodic_tasks" + ), + } + ) + return super().changelist_view(request, extra_context=extra_context) + + def connection_target(self, obj: ExchangeConnection): + return format_html( + "{}
{}@{}:{}", + obj.database_name, + obj.username, + obj.server, + obj.port, + ) + + connection_target.short_description = "Подключение" + connection_target.admin_order_field = "server" + + def status_badge(self, obj: ExchangeConnection): + if obj.is_active: + return format_html( + 'Активно' + ) + return format_html( + 'Черновик' + ) + + status_badge.short_description = "Статус" + status_badge.admin_order_field = "is_active" + + def health_badge(self, obj: ExchangeConnection): + if not obj.last_checked_at: + color = "#6c757d" + label = "Не проверялось" + elif obj.last_error: + color = "#dc3545" + label = "Ошибка проверки" + else: + color = "#198754" + label = "Проверено" + + return format_html( + '{}', + color, + label, + ) + + health_badge.short_description = "Проверка" + health_badge.admin_order_field = "last_checked_at" + + def last_error_short(self, obj: ExchangeConnection) -> str: + if not obj.last_error: + return "OK" + return obj.last_error[:80] + ("..." if len(obj.last_error) > 80 else "") + + last_error_short.short_description = "Последняя ошибка" + + @admin.action(description="Проверить выбранные подключения") + def validate_selected_connections(self, request, queryset): + success_count = 0 + errors = [] + + for connection in queryset: + try: + ExchangeConnectionService.validate_saved_connection(connection) + except ExchangeServiceError as exc: + errors.append(f"{connection}: {exc}") + else: + success_count += 1 + + if success_count: + self.message_user( + request, + f"Успешно проверено подключений: {success_count}.", + level=messages.SUCCESS, + ) + if errors: + self.message_user( + request, + "Ошибки проверки: " + "; ".join(errors), + level=messages.ERROR, + ) + + @admin.action(description="Сделать выбранное подключение активным") + def activate_selected_connection(self, request, queryset): + if queryset.count() != 1: + self.message_user( + request, + "Для активации выберите ровно одно подключение.", + level=messages.WARNING, + ) + return + + connection = queryset.first() + try: + ExchangeConnectionService.activate_connection(connection) + except ExchangeServiceError as exc: + self.message_user( + request, + f"Не удалось активировать подключение: {exc}", + level=messages.ERROR, + ) + return + + self.message_user( + request, + f"Подключение '{connection}' успешно активировано.", + level=messages.SUCCESS, + ) + + @admin.action(description="Деактивировать выбранные подключения") + def deactivate_selected_connections(self, request, queryset): + updated = queryset.filter(is_active=True).update(is_active=False) + self.message_user( + request, + f"Деактивировано подключений: {updated}.", + level=messages.SUCCESS, + ) + + def test_connection_view(self, request): + changelist_url = reverse("admin:exchange_exchangeconnection_changelist") + form = ExchangeConnectionTestForm(request.POST or None) + + if request.method == "POST" and form.is_valid(): + try: + result = ExchangeConnectionService.test_connection_payload( + **form.cleaned_data + ) + except ExchangeServiceError as exc: + form.add_error(None, str(exc)) + else: + self.message_user( + request, + result["message"], + level=messages.SUCCESS, + ) + return redirect(changelist_url) + + return self._render_form_page( + request, + template_name="admin/exchange/exchangeconnection/form_page.html", + title="Проверка подключения к внешней БД", + form=form, + submit_label="Проверить подключение", + changelist_url=changelist_url, + intro=( + "Проверка выполняет соединение с внешней PostgreSQL БД " + "и валидирует структуру обязательных таблиц." + ), + ) + + def copy_data_view(self, request): + changelist_url = reverse("admin:exchange_exchangeconnection_changelist") + form = ExchangeCopyAdminForm(request.POST or None) + active_connection = self._get_active_connection_or_none() + + if request.method == "POST" and form.is_valid(): + try: + active_connection = ExchangeConnectionService.get_active_connection() + payload = form.get_copy_payload() + task = copy_parsers_data_async.delay( + connection_id=active_connection.id, + payload=payload, + requested_by_id=request.user.id, + ) + with suppress(IntegrityError): + BackgroundJobService.create_job( + task_id=task.id, + task_name="apps.exchange.tasks.copy_parsers_data_async", + user_id=request.user.id, + meta={ + "connection_id": active_connection.id, + **payload, + }, + ) + except ExchangeServiceError as exc: + form.add_error(None, str(exc)) + else: + self.message_user( + request, + ( + "Обмен поставлен в очередь. " + f"Task ID: {task.id}, подключение: {active_connection}." + ), + level=messages.SUCCESS, + ) + return redirect(changelist_url) + + return self._render_form_page( + request, + template_name="admin/exchange/exchangeconnection/form_page.html", + title="Ручной запуск обмена данными", + form=form, + submit_label="Запустить обмен", + changelist_url=changelist_url, + intro=( + "Данные копируются в фоне из локальной БД в текущее активное " + "подключение exchange." + ), + active_connection=active_connection, + requires_active_connection=True, + ) + + def periodic_tasks_view(self, request, task_id: int | None = None): + changelist_url = reverse("admin:exchange_exchangeconnection_changelist") + task = ( + get_object_or_404(ExchangePeriodicTaskService.get_queryset(), id=task_id) + if task_id is not None + else None + ) + form = ExchangePeriodicTaskAdminForm( + request.POST or None, + task=task, + ) + + if request.method == "POST" and form.is_valid(): + try: + if task is None: + ExchangePeriodicTaskService.create_periodic_task( + name=form.cleaned_data["name"], + description=form.cleaned_data.get("description", ""), + enabled=form.cleaned_data.get("enabled", False), + payload=form.get_copy_payload(), + schedule=form.get_schedule(), + ) + message = "Периодическая задача обмена создана." + else: + ExchangePeriodicTaskService.update_periodic_task( + task=task, + name=form.cleaned_data["name"], + description=form.cleaned_data.get("description", ""), + enabled=form.cleaned_data.get("enabled", False), + payload=form.get_copy_payload(), + schedule=form.get_schedule(), + ) + message = "Периодическая задача обмена обновлена." + except ExchangeServiceError as exc: + form.add_error(None, str(exc)) + else: + self.message_user(request, message, level=messages.SUCCESS) + return redirect("admin:exchange_exchangeconnection_periodic_tasks") + + context = { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "title": ( + "Редактирование периодического обмена" + if task is not None + else "Периодический обмен данными" + ), + "form_title": ( + "Редактировать задачу" if task is not None else "Новая задача обмена" + ), + "submit_label": "Сохранить задачу", + "changelist_url": changelist_url, + "list_url": reverse("admin:exchange_exchangeconnection_periodic_tasks"), + "active_connection": self._get_active_connection_or_none(), + "tasks": self._build_periodic_task_rows(), + "form": form, + } + return TemplateResponse( + request, + "admin/exchange/exchangeconnection/periodic_tasks.html", + context, + ) + + def _render_form_page( + self, + request, + *, + template_name: str, + title: str, + form, + submit_label: str, + changelist_url: str, + intro: str, + active_connection: ExchangeConnection | None = None, + requires_active_connection: bool = False, + ): + context = { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "title": title, + "intro": intro, + "form": form, + "submit_label": submit_label, + "changelist_url": changelist_url, + "active_connection": active_connection, + "requires_active_connection": requires_active_connection, + } + return TemplateResponse(request, template_name, context) + + def _get_active_connection_or_none(self) -> ExchangeConnection | None: + return ExchangeConnection.objects.filter(is_active=True).first() + + def _build_periodic_task_rows(self) -> list[dict]: + queryset = list(ExchangePeriodicTaskService.get_queryset()) + serialized = ExchangePeriodicTaskSerializer(queryset, many=True).data + rows = [] + + for task, data in zip(queryset, serialized, strict=False): + payload = get_periodic_task_payload(task) + rows.append( + { + "id": task.id, + "name": task.name, + "description": task.description, + "enabled": task.enabled, + "schedule_label": self._format_schedule_label(data), + "mode": payload.get("mode", "all"), + "truncate_before_copy": payload.get("truncate_before_copy", True), + "notify_on_error": payload.get("notify_on_error", False), + "edit_url": reverse( + "admin:exchange_exchangeconnection_periodic_task_change", + args=[task.id], + ), + "raw_admin_url": self._get_periodic_task_admin_url(task.id), + } + ) + + return rows + + @staticmethod + def _format_schedule_label(data: dict) -> str: + if data.get("schedule_type") == "interval": + return f"Каждые {data['interval_every']} {data['interval_period']}" + return f"Ежедневно в {int(data['crontab_hour']):02d}:{int(data['crontab_minute']):02d}" + + @staticmethod + def _get_periodic_task_admin_url(task_id: int) -> str: + try: + return reverse( + "admin:django_celery_beat_periodictask_change", args=[task_id] + ) + except NoReverseMatch: + return "" diff --git a/src/apps/exchange/forms.py b/src/apps/exchange/forms.py new file mode 100644 index 0000000..1f94c00 --- /dev/null +++ b/src/apps/exchange/forms.py @@ -0,0 +1,331 @@ +"""Формы админки для приложения exchange.""" + +from __future__ import annotations + +import re + +from apps.exchange.models import ExchangeConnection +from apps.exchange.serializers import get_periodic_task_payload +from apps.exchange.services import ExchangeConnectionService +from django import forms +from django.core.exceptions import ValidationError +from django_celery_beat.models import IntervalSchedule, PeriodicTask + +_SCHEMA_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class _SchemaNameValidationMixin: + """Общая валидация имени схемы внешней БД.""" + + schema_error_message = ( + "Имя схемы должно начинаться с буквы/_, содержать только буквы, цифры и _." + ) + + def clean_schema_name(self) -> str: + value = str(self.cleaned_data["schema_name"]).strip() + if not _SCHEMA_NAME_RE.fullmatch(value): + raise ValidationError(self.schema_error_message) + return value + + +class _ExchangeCopySettingsMixin: + """Общие поля и валидация режимов обмена данными.""" + + COPY_MODE_CHOICES = [ + ("all", "Все parser-таблицы"), + ("single", "Одна таблица"), + ("selected", "Выбранные таблицы"), + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if "mode" not in self.fields: + self.fields["mode"] = forms.ChoiceField( + label="Режим обмена", + choices=self.COPY_MODE_CHOICES, + initial="all", + ) + if "table" not in self.fields: + self.fields["table"] = forms.ChoiceField( + label="Таблица", + required=False, + ) + if "tables" not in self.fields: + self.fields["tables"] = forms.MultipleChoiceField( + label="Таблицы", + required=False, + widget=forms.SelectMultiple(attrs={"size": 8}), + ) + if "truncate_before_copy" not in self.fields: + self.fields["truncate_before_copy"] = forms.BooleanField( + label="Очищать целевые таблицы перед загрузкой", + required=False, + initial=True, + ) + + table_choices = ExchangeConnectionService.get_copy_table_choices() + self.fields["table"].choices = [("", "---------"), *table_choices] + self.fields["tables"].choices = table_choices + + def clean(self): + cleaned_data = super().clean() + if self.errors: + return cleaned_data + + mode = cleaned_data.get("mode") + table = cleaned_data.get("table") + tables = cleaned_data.get("tables") or [] + errors = {} + + if mode == "single" and not table: + errors["table"] = "Для режима одной таблицы выберите таблицу." + + if mode == "selected" and not tables: + errors["tables"] = "Для режима выбранных таблиц укажите хотя бы одну." + + if mode != "single" and table: + errors["table"] = "Поле таблицы допустимо только для режима single." + + if mode != "selected" and tables: + errors["tables"] = "Поле таблиц допустимо только для режима selected." + + if errors: + raise ValidationError(errors) + + return cleaned_data + + def get_copy_payload(self) -> dict: + mode = self.cleaned_data["mode"] + table = self.cleaned_data.get("table") or None + tables = self.cleaned_data.get("tables") or [] + return { + "mode": mode, + "table": table if mode == "single" else None, + "tables": tables if mode == "selected" else None, + "truncate_before_copy": self.cleaned_data.get( + "truncate_before_copy", True + ), + } + + +class ExchangeConnectionAdminForm(_SchemaNameValidationMixin, forms.ModelForm): + """Форма сохранения подключения с безопасным обновлением пароля.""" + + password = forms.CharField( + label="Пароль", + required=False, + widget=forms.PasswordInput(render_value=False), + help_text=( + "Для существующего подключения можно оставить поле пустым, " + "тогда сохранится текущий пароль." + ), + ) + + class Meta: + model = ExchangeConnection + fields = [ + "server", + "port", + "username", + "password", + "database_name", + "schema_name", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.instance.pk: + self.fields["password"].required = True + self.fields["password"].help_text = "Пароль будет сохранён в зашифрованном виде." + + def clean_password(self) -> str: + password = self.cleaned_data.get("password") + if password: + return password + if self.instance.pk: + return self.instance.password + raise ValidationError("Укажите пароль для нового подключения.") + + +class ExchangeConnectionTestForm(_SchemaNameValidationMixin, forms.Form): + """Форма проверки подключения без сохранения в БД.""" + + server = forms.CharField(label="Сервер", max_length=255) + port = forms.IntegerField(label="Порт", min_value=1, max_value=65535, initial=5432) + username = forms.CharField(label="Пользователь", max_length=255) + password = forms.CharField( + label="Пароль", + widget=forms.PasswordInput(render_value=False), + ) + database_name = forms.CharField(label="Имя БД", max_length=255) + schema_name = forms.CharField(label="Имя схемы", max_length=255, initial="public") + + +class ExchangeCopyAdminForm(_ExchangeCopySettingsMixin, forms.Form): + """Форма ручного запуска обмена с активным подключением.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.order_fields(["mode", "table", "tables", "truncate_before_copy"]) + + +class ExchangePeriodicTaskAdminForm( + _ExchangeCopySettingsMixin, + forms.Form, +): + """Форма создания и изменения периодического обмена.""" + + name = forms.CharField(label="Название задачи", max_length=200) + description = forms.CharField( + label="Описание", + required=False, + widget=forms.Textarea(attrs={"rows": 2}), + ) + enabled = forms.BooleanField( + label="Задача включена", + required=False, + initial=True, + ) + schedule_type = forms.ChoiceField( + label="Тип расписания", + choices=[ + ("interval", "Интервал"), + ("daily", "Ежедневно"), + ], + initial="interval", + ) + interval_every = forms.IntegerField( + label="Каждые N единиц", + min_value=1, + required=False, + initial=1, + ) + interval_period = forms.ChoiceField( + label="Интервальная единица", + required=False, + choices=IntervalSchedule.PERIOD_CHOICES, + initial=IntervalSchedule.HOURS, + ) + crontab_minute = forms.IntegerField( + label="Минута запуска", + min_value=0, + max_value=59, + required=False, + initial=0, + ) + crontab_hour = forms.IntegerField( + label="Час запуска", + min_value=0, + max_value=23, + required=False, + initial=4, + ) + notify_on_error = forms.BooleanField( + label="Уведомлять об ошибках в payload", + required=False, + ) + + def __init__( + self, + *args, + task: PeriodicTask | None = None, + **kwargs, + ): + self.task = task + if task is not None: + payload = get_periodic_task_payload(task) + initial = dict(kwargs.get("initial") or {}) + initial.update( + { + "name": task.name, + "description": task.description, + "enabled": task.enabled, + "mode": payload.get("mode", "all"), + "table": payload.get("table"), + "tables": payload.get("tables") or [], + "truncate_before_copy": payload.get("truncate_before_copy", True), + "notify_on_error": payload.get("notify_on_error", False), + "schedule_type": "interval" if task.interval_id else "daily", + "interval_every": task.interval.every if task.interval_id else 1, + "interval_period": ( + task.interval.period if task.interval_id else IntervalSchedule.HOURS + ), + "crontab_minute": ( + int(task.crontab.minute) if task.crontab_id else 0 + ), + "crontab_hour": int(task.crontab.hour) if task.crontab_id else 4, + } + ) + kwargs["initial"] = initial + + super().__init__(*args, **kwargs) + self.fields["name"].help_text = ( + "Название должно быть уникальным в django-celery-beat." + ) + self.fields["mode"].help_text = ( + "Периодическая задача использует текущее активное подключение exchange." + ) + self.order_fields( + [ + "name", + "description", + "enabled", + "mode", + "table", + "tables", + "truncate_before_copy", + "notify_on_error", + "schedule_type", + "interval_every", + "interval_period", + "crontab_minute", + "crontab_hour", + ] + ) + + def clean(self): + cleaned_data = super().clean() + if self.errors: + return cleaned_data + + schedule_type = cleaned_data.get("schedule_type") + errors = {} + if schedule_type == "interval": + if cleaned_data.get("interval_every") is None: + errors["interval_every"] = "Для interval укажите число." + if not cleaned_data.get("interval_period"): + errors["interval_period"] = "Для interval укажите единицу времени." + elif schedule_type == "daily": + if cleaned_data.get("crontab_minute") is None: + errors["crontab_minute"] = "Для daily укажите минуту." + if cleaned_data.get("crontab_hour") is None: + errors["crontab_hour"] = "Для daily укажите час." + else: + errors["schedule_type"] = "Допустимые значения: interval или daily." + + if errors: + raise ValidationError(errors) + + return cleaned_data + + def get_copy_payload(self) -> dict: + payload = super().get_copy_payload() + payload["notify_on_error"] = self.cleaned_data.get("notify_on_error", False) + return payload + + def get_schedule(self) -> dict: + if self.cleaned_data["schedule_type"] == "interval": + return { + "type": "interval", + "every": self.cleaned_data["interval_every"], + "period": self.cleaned_data["interval_period"], + } + + return { + "type": "crontab", + "minute": str(self.cleaned_data["crontab_minute"]), + "hour": str(self.cleaned_data["crontab_hour"]), + "day_of_week": "*", + "day_of_month": "*", + "month_of_year": "*", + } diff --git a/src/apps/exchange/serializers.py b/src/apps/exchange/serializers.py index 02001c6..4a94fb5 100644 --- a/src/apps/exchange/serializers.py +++ b/src/apps/exchange/serializers.py @@ -196,6 +196,7 @@ class ExchangePeriodicTaskUpsertSerializer(serializers.Serializer): ) crontab_minute = serializers.IntegerField(min_value=0, max_value=59, required=False) crontab_hour = serializers.IntegerField(min_value=0, max_value=23, required=False) + truncate_before_copy = serializers.BooleanField(required=False) notify_on_error = serializers.BooleanField(required=False) def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: diff --git a/src/apps/exchange/services.py b/src/apps/exchange/services.py index 40ddae6..57848ca 100644 --- a/src/apps/exchange/services.py +++ b/src/apps/exchange/services.py @@ -37,17 +37,7 @@ class ExchangeConnectionService: def test_connection_payload(cls, **payload) -> dict[str, str]: """Проверить подключение и структуру без сохранения в БД.""" connection = ExchangeConnection(is_active=False, **payload) - alias = cls.test_connection(connection) - cls.validate_target_structure( - connection=connection, - alias=alias, - schema_name=connection.schema_name, - ) - - with suppress(Exception): - connections[alias].close() - with suppress(Exception): - connections.databases.pop(alias, None) + cls.validate_saved_connection(connection) return { "status": "success", @@ -71,19 +61,10 @@ class ExchangeConnectionService: connection = ExchangeConnection.objects.create(is_active=True, **payload) try: - alias = cls.test_connection(connection) - cls.validate_target_structure( - connection=connection, - alias=alias, - schema_name=connection.schema_name, - ) + cls.validate_saved_connection(connection, prepare_target=True) except Exception as exc: # noqa: BLE001 raise ExchangeServiceError(str(exc)) from exc - connection.last_checked_at = timezone.now() - connection.last_error = "" - connection.save(update_fields=["last_checked_at", "last_error", "updated_at"]) - return connection @classmethod @@ -104,12 +85,106 @@ class ExchangeConnectionService: cursor.execute("SELECT 1") except Exception as exc: # noqa: BLE001 cls._mark_connection_error(connection, str(exc)) + cls._cleanup_alias(alias) raise ExchangeServiceError( f"Ошибка подключения к целевой БД: {exc}" ) from exc return alias + @classmethod + def validate_saved_connection( + cls, + connection: ExchangeConnection, + *, + models_to_copy: list | None = None, + prepare_target: bool = False, + ) -> ExchangeConnection: + """Проверить соединение и структуру сохранённого подключения.""" + alias = None + + try: + alias = cls.test_connection(connection) + if prepare_target: + cls.prepare_target_structure( + connection=connection, + alias=alias, + schema_name=connection.schema_name, + models_to_copy=models_to_copy, + ) + cls.validate_target_structure( + connection=connection, + alias=alias, + schema_name=connection.schema_name, + models_to_copy=models_to_copy, + ) + finally: + if alias: + cls._cleanup_alias(alias) + + connection.last_checked_at = timezone.now() + connection.last_error = "" + if connection.pk: + connection.save( + update_fields=["last_checked_at", "last_error", "updated_at"] + ) + return connection + + @classmethod + @transaction.atomic + def activate_connection(cls, connection: ExchangeConnection) -> ExchangeConnection: + """Сделать подключение активным после успешной проверки.""" + if not connection.pk: + raise ExchangeServiceError("Подключение должно быть сохранено в БД") + + cls.validate_saved_connection(connection, prepare_target=True) + ExchangeConnection.objects.exclude(pk=connection.pk).filter( + is_active=True + ).update(is_active=False) + if not connection.is_active: + connection.is_active = True + connection.save(update_fields=["is_active", "updated_at"]) + return connection + + @classmethod + def prepare_target_structure( + cls, + *, + connection: ExchangeConnection, + alias: str, + schema_name: str, + models_to_copy: list | None = None, + ) -> None: + """Подготовить target БД: создать схему и недостающие таблицы.""" + try: + db_connection = connections[alias] + db_connection.ensure_connection() + required_models = models_to_copy or cls._extend_models_with_dependencies( + cls._get_parser_models() + ) + cls._create_schema_if_missing(alias=alias, schema_name=schema_name) + cls._create_missing_tables( + alias=alias, + schema_name=schema_name, + models_to_copy=required_models, + ) + except ExchangeServiceError as exc: + cls._mark_connection_error(connection, str(exc)) + raise + except Exception as exc: # noqa: BLE001 + cls._mark_connection_error(connection, str(exc)) + raise ExchangeServiceError( + f"Ошибка подготовки структуры целевой БД: {exc}" + ) from exc + + @classmethod + def get_copy_table_choices(cls) -> list[tuple[str, str]]: + """Вернуть список таблиц parsers для выбора в админке/API.""" + return [ + (model._meta.db_table, cls._get_model_title(model)) + for model in cls._get_parser_models() + ] + @classmethod def validate_target_structure( cls, @@ -169,44 +244,59 @@ class ExchangeConnectionService: models_to_copy = cls._extend_models_with_dependencies(selected_models) try: - connections[alias].ensure_connection() - except Exception as exc: # noqa: BLE001 - cls._mark_connection_error(connection, str(exc)) - raise ExchangeServiceError( - f"Ошибка подключения к целевой БД: {exc}" - ) from exc + try: + connections[alias].ensure_connection() + except Exception as exc: # noqa: BLE001 + cls._mark_connection_error(connection, str(exc)) + raise ExchangeServiceError( + f"Ошибка подключения к целевой БД: {exc}" + ) from exc - cls.validate_target_structure( - connection=connection, - alias=alias, - schema_name=connection.schema_name, - models_to_copy=models_to_copy, - ) - - if truncate_before_copy: - cls._truncate_tables(alias=alias, models_to_copy=models_to_copy) - - copied_by_table: dict[str, int] = {} - for model in models_to_copy: - copied_by_table[model._meta.db_table] = cls._copy_model_data( - model=model, + cls.prepare_target_structure( + connection=connection, alias=alias, - truncate_before_copy=truncate_before_copy, + schema_name=connection.schema_name, + models_to_copy=models_to_copy, + ) + cls.validate_target_structure( + connection=connection, + alias=alias, + schema_name=connection.schema_name, + models_to_copy=models_to_copy, ) - total_rows = sum(copied_by_table.values()) + if truncate_before_copy: + cls._truncate_tables(alias=alias, models_to_copy=models_to_copy) - connection.last_checked_at = timezone.now() - connection.last_error = "" - connection.save(update_fields=["last_checked_at", "last_error", "updated_at"]) + copied_by_table: dict[str, int] = {} + for model in models_to_copy: + copied_by_table[model._meta.db_table] = cls._copy_model_data( + model=model, + alias=alias, + truncate_before_copy=truncate_before_copy, + ) - return { - "mode": mode, - "tables": list(copied_by_table.keys()), - "rows_by_table": copied_by_table, - "total_rows": total_rows, - "truncate_before_copy": truncate_before_copy, - } + total_rows = sum(copied_by_table.values()) + + connection.last_checked_at = timezone.now() + connection.last_error = "" + connection.save( + update_fields=["last_checked_at", "last_error", "updated_at"] + ) + + return { + "mode": mode, + "tables": list(copied_by_table.keys()), + "rows_by_table": copied_by_table, + "total_rows": total_rows, + "truncate_before_copy": truncate_before_copy, + } + except Exception as exc: # noqa: BLE001 + if not isinstance(exc, ExchangeServiceError): + cls._mark_connection_error(connection, str(exc)) + raise + finally: + cls._cleanup_alias(alias) @classmethod def _configure_alias(cls, connection: ExchangeConnection) -> str: @@ -317,6 +407,53 @@ class ExchangeConnectionService: def _get_parser_models(cls) -> list: return [django_apps.get_model(label) for label in cls.PARSER_MODEL_LABELS] + @classmethod + def _get_model_title(cls, model) -> str: + verbose_name = str( + model._meta.verbose_name_plural or model._meta.verbose_name + ).strip() + if verbose_name: + verbose_name = verbose_name[0].upper() + verbose_name[1:] + return f"{verbose_name} ({model._meta.db_table})" + + @classmethod + def _create_schema_if_missing(cls, *, alias: str, schema_name: str) -> None: + db_connection = connections[alias] + schema_sql = db_connection.ops.quote_name(schema_name) + with db_connection.cursor() as cursor: + cursor.execute(f"CREATE SCHEMA IF NOT EXISTS {schema_sql}") + + @classmethod + def _create_missing_tables( + cls, + *, + alias: str, + schema_name: str, + models_to_copy: list, + ) -> None: + existing_tables = cls._get_existing_tables(alias=alias, schema_name=schema_name) + db_connection = connections[alias] + + with db_connection.schema_editor() as schema_editor: + for model in models_to_copy: + if model._meta.db_table in existing_tables: + continue + schema_editor.create_model(model) + existing_tables.add(model._meta.db_table) + + @classmethod + def _get_existing_tables(cls, *, alias: str, schema_name: str) -> set[str]: + with connections[alias].cursor() as cursor: + cursor.execute( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = %s + """, + [schema_name], + ) + return {row[0] for row in cursor.fetchall()} + @classmethod def _resolve_models( cls, @@ -486,6 +623,17 @@ class ExchangeConnectionService: update_fields=["last_checked_at", "last_error", "updated_at"] ) + @classmethod + def _cleanup_alias(cls, alias: str) -> None: + with suppress(Exception): + connections[alias].close() + with suppress(Exception): + connections.databases.pop(alias, None) + + storage = getattr(connections, "_connections", None) + if storage is not None and hasattr(storage, "__dict__"): + storage.__dict__.pop(alias, None) + class ExchangePeriodicTaskService: """Сервис управления периодическими задачами обмена.""" diff --git a/src/apps/parsers/admin.py b/src/apps/parsers/admin.py index 3dc7957..24353bc 100644 --- a/src/apps/parsers/admin.py +++ b/src/apps/parsers/admin.py @@ -171,41 +171,52 @@ class ParserLoadLogAdmin(admin.ModelAdmin): list_display = [ "id", - "source", - "batch_id", + "source_title", "status_badge", "records_count", "created_at", ] list_filter = ["source", "status", "created_at"] - search_fields = ["batch_id", "error_message"] + search_fields = ["error_message"] readonly_fields = ["created_at", "updated_at"] ordering = ["-created_at"] list_per_page = 50 date_hierarchy = "created_at" fieldsets = ( - ("Основное", {"fields": ("source", "batch_id", "status")}), + ("Основное", {"fields": ("source", "status")}), ("Результат", {"fields": ("records_count", "error_message")}), - ("Даты", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), + ( + "Служебное", + { + "fields": ("batch_id", "created_at", "updated_at"), + "classes": ("collapse",), + }, + ), ) + def source_title(self, obj): + """Полное название источника.""" + return obj.get_source_display() + + source_title.short_description = "Источник" + source_title.admin_order_field = "source" + def status_badge(self, obj): """Цветной бейдж статуса.""" colors = { - "success": "#28a745", - "failed": "#dc3545", - "in_progress": "#ffc107", - "pending": "#6c757d", + ParserLoadLog.Status.SUCCESS: "#28a745", + ParserLoadLog.Status.FAILED: "#dc3545", + ParserLoadLog.Status.IN_PROGRESS: "#ffc107", + ParserLoadLog.Status.PENDING: "#6c757d", + ParserLoadLog.Status.SKIPPED: "#17a2b8", } color = colors.get(obj.status, "#6c757d") return format_html( '{}', color, - obj.get_status_display() - if hasattr(obj, "get_status_display") - else obj.status, + obj.get_status_display(), ) status_badge.short_description = "Статус" @@ -917,7 +928,7 @@ class FinancialReportAdmin(admin.ModelAdmin): context = { **self.admin_site.each_context(request), "opts": self.model._meta, - "title": "Загрузка Excel отчетности ФНС", + "title": "Загрузка Excel бухгалтерской отчетности ФНС", "changelist_url": changelist_url, } return TemplateResponse( @@ -961,7 +972,7 @@ class FinancialReportAdmin(admin.ModelAdmin): context = { **self.admin_site.each_context(request), "opts": self.model._meta, - "title": "Загрузка ZIP отчетности ФНС", + "title": "Загрузка ZIP бухгалтерской отчетности ФНС", "changelist_url": changelist_url, } return TemplateResponse( diff --git a/src/apps/parsers/migrations/0016_auto_20260324_1120.py b/src/apps/parsers/migrations/0016_auto_20260324_1120.py new file mode 100644 index 0000000..b931a88 --- /dev/null +++ b/src/apps/parsers/migrations/0016_auto_20260324_1120.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.25 on 2026-03-24 11:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parsers', '0015_add_proxy_metadata'), + ] + + operations = [ + migrations.AlterModelOptions( + name='financialreport', + options={'ordering': ['-created_at'], 'verbose_name': 'запись бухгалтерской отчетности ФНС', 'verbose_name_plural': 'Бухгалтерская отчетность ФНС'}, + ), + migrations.AlterModelOptions( + name='industrialcertificaterecord', + options={'ordering': ['-created_at'], 'verbose_name': 'сертификат промышленного производства', 'verbose_name_plural': 'Сертификаты промышленного производства'}, + ), + migrations.AlterModelOptions( + name='industrialproductrecord', + options={'ordering': ['-created_at'], 'verbose_name': 'запись реестра промышленной продукции', 'verbose_name_plural': 'Реестр промышленной продукции'}, + ), + migrations.AlterModelOptions( + name='inspectionrecord', + options={'ordering': ['-created_at'], 'verbose_name': 'запись единого реестра проверок', 'verbose_name_plural': 'Единый реестр проверок'}, + ), + migrations.AlterModelOptions( + name='manufacturerrecord', + options={'ordering': ['-created_at'], 'verbose_name': 'запись реестра производителей', 'verbose_name_plural': 'Реестр производителей'}, + ), + migrations.AlterModelOptions( + name='parserloadlog', + options={'ordering': ['-created_at'], 'verbose_name': 'обновление источника', 'verbose_name_plural': 'история обновлений'}, + ), + migrations.AlterModelOptions( + name='procurementrecord', + options={'ordering': ['-created_at'], 'verbose_name': 'запись ЕИС закупок', 'verbose_name_plural': 'Единая информационная система закупок'}, + ), + migrations.AlterField( + model_name='parserloadlog', + name='source', + field=models.CharField(choices=[('industrial', 'Сертификаты промышленного производства'), ('industrial_products', 'Реестр промышленной продукции'), ('manufactures', 'Реестр производителей'), ('inspections', 'Единый реестр проверок'), ('procurements', 'Единая информационная система закупок'), ('fns_reports', 'Бухгалтерская отчетность ФНС')], db_index=True, help_text='Источник данных', max_length=50, verbose_name='источник'), + ), + migrations.AlterField( + model_name='parserloadlog', + name='status', + field=models.CharField(choices=[('success', 'Успешно'), ('failed', 'Ошибка'), ('in_progress', 'В процессе'), ('pending', 'В очереди'), ('skipped', 'Пропущено')], default='success', help_text='Статус загрузки', max_length=20, verbose_name='статус'), + ), + ] diff --git a/src/apps/parsers/models.py b/src/apps/parsers/models.py index 1a20815..9cec881 100644 --- a/src/apps/parsers/models.py +++ b/src/apps/parsers/models.py @@ -17,13 +17,20 @@ class ParserLoadLog(TimestampMixin, models.Model): """ class Source(models.TextChoices): - INDUSTRIAL = "industrial", _("Промышленное производство") + INDUSTRIAL = "industrial", _("Сертификаты промышленного производства") INDUSTRIAL_PRODUCTS = "industrial_products", _("Реестр промышленной продукции") MANUFACTURES = "manufactures", _("Реестр производителей") INSPECTIONS = "inspections", _("Единый реестр проверок") - PROCUREMENTS = "procurements", _("Государственные закупки") + PROCUREMENTS = "procurements", _("Единая информационная система закупок") FNS_REPORTS = "fns_reports", _("Бухгалтерская отчетность ФНС") + class Status(models.TextChoices): + SUCCESS = "success", _("Успешно") + FAILED = "failed", _("Ошибка") + IN_PROGRESS = "in_progress", _("В процессе") + PENDING = "pending", _("В очереди") + SKIPPED = "skipped", _("Пропущено") + batch_id = models.PositiveIntegerField( _("ID пакета"), db_index=True, @@ -44,7 +51,8 @@ class ParserLoadLog(TimestampMixin, models.Model): status = models.CharField( _("статус"), max_length=20, - default="success", + choices=Status.choices, + default=Status.SUCCESS, help_text=_("Статус загрузки"), ) error_message = models.TextField( @@ -55,8 +63,8 @@ class ParserLoadLog(TimestampMixin, models.Model): class Meta: db_table = "parsers_load_log" - verbose_name = _("лог загрузки") - verbose_name_plural = _("логи загрузок") + verbose_name = _("обновление источника") + verbose_name_plural = _("история обновлений") ordering = ["-created_at"] indexes = [ models.Index(fields=["source", "batch_id"]), @@ -149,8 +157,8 @@ class IndustrialCertificateRecord(TimestampMixin, models.Model): class Meta: db_table = "parsers_industrial_certificate" - verbose_name = _("сертификат промпроизводства") - verbose_name_plural = _("сертификаты промпроизводства") + verbose_name = _("сертификат промышленного производства") + verbose_name_plural = _("Сертификаты промышленного производства") ordering = ["-created_at"] indexes = [ models.Index(fields=["inn", "certificate_number"]), @@ -212,8 +220,8 @@ class ManufacturerRecord(TimestampMixin, models.Model): class Meta: db_table = "parsers_manufacturer" - verbose_name = _("производитель") - verbose_name_plural = _("производители") + verbose_name = _("запись реестра производителей") + verbose_name_plural = _("Реестр производителей") ordering = ["-created_at"] indexes = [ models.Index(fields=["load_batch", "inn"]), @@ -303,8 +311,8 @@ class IndustrialProductRecord(TimestampMixin, models.Model): class Meta: db_table = "parsers_industrial_product" - verbose_name = _("промышленная продукция") - verbose_name_plural = _("промышленная продукция") + verbose_name = _("запись реестра промышленной продукции") + verbose_name_plural = _("Реестр промышленной продукции") ordering = ["-created_at"] indexes = [ models.Index(fields=["load_batch", "inn"]), @@ -507,8 +515,8 @@ class InspectionRecord(TimestampMixin, models.Model): class Meta: db_table = "parsers_inspection" - verbose_name = _("проверка") - verbose_name_plural = _("проверки") + verbose_name = _("запись единого реестра проверок") + verbose_name_plural = _("Единый реестр проверок") ordering = ["-created_at"] indexes = [ models.Index(fields=["inn", "registration_number"]), @@ -683,8 +691,8 @@ class ProcurementRecord(TimestampMixin, models.Model): class Meta: db_table = "parsers_procurement" - verbose_name = _("закупка") - verbose_name_plural = _("закупки") + verbose_name = _("запись ЕИС закупок") + verbose_name_plural = _("Единая информационная система закупок") ordering = ["-created_at"] indexes = [ models.Index(fields=["customer_inn", "purchase_number"]), @@ -781,8 +789,8 @@ class FinancialReport(TimestampMixin, models.Model): class Meta: db_table = "parsers_financial_report" - verbose_name = _("финансовый отчет") - verbose_name_plural = _("финансовые отчеты") + verbose_name = _("запись бухгалтерской отчетности ФНС") + verbose_name_plural = _("Бухгалтерская отчетность ФНС") ordering = ["-created_at"] indexes = [ models.Index(fields=["ogrn", "status"]), diff --git a/src/apps/registers/admin.py b/src/apps/registers/admin.py index 081380e..4c89ec6 100644 --- a/src/apps/registers/admin.py +++ b/src/apps/registers/admin.py @@ -12,6 +12,7 @@ from django.contrib import admin, messages from django.shortcuts import redirect from django.template.response import TemplateResponse from django.urls import path, reverse +from rest_framework import serializers @admin.register(Register) @@ -91,16 +92,90 @@ class RegisterUploadAdmin(admin.ModelAdmin): ) return super().changelist_view(request, extra_context=extra_context) + @staticmethod + def _get_uploaded_files(request): + files = request.FILES.getlist("files") + if files: + return files + + uploaded_file = request.FILES.get("file") + return [uploaded_file] if uploaded_file is not None else [] + + def _build_upload_serializer(self, request, uploaded_files): + data = request.POST.copy() + data["file"] = uploaded_files[0] + return RegisterFileUploadSerializer(data=data) + + def _get_invalid_files(self, serializer, uploaded_files): + invalid_files = [] + for uploaded_file in uploaded_files[1:]: + try: + serializer.validate_file(uploaded_file) + except serializers.ValidationError as exc: + invalid_files.append(f"{uploaded_file.name}: {exc.detail[0]}") + return invalid_files + + def _process_uploaded_files(self, serializer, uploaded_files, request): + processed_results = [] + failed_files = [] + for uploaded_file in uploaded_files: + try: + result = RegisterImportService.sync_registry_memberships( + registry=serializer.validated_data["registry"], + uploaded_file=uploaded_file, + actual_date=serializer.validated_data.get("actual_date"), + uploaded_by=request.user, + ) + except RegisterImportError as exc: + failed_files.append(f"{uploaded_file.name}: {exc}") + continue + + processed_results.append(result) + return processed_results, failed_files + + def _message_upload_results(self, request, processed_results, failed_files): + if processed_results: + total_rows = sum(item["rows_in_file"] for item in processed_results) + total_created = sum( + item["organizations_created"] for item in processed_results + ) + total_updated = sum( + item["organizations_updated"] for item in processed_results + ) + self.message_user( + request, + ( + "Загрузка завершена: " + f"{processed_results[0]['registry_name']}, " + f"файлов: {len(processed_results)}, " + f"строк: {total_rows}, " + f"новых организаций: {total_created}, " + f"обновлено: {total_updated}." + ), + level=messages.SUCCESS, + ) + + if failed_files: + self.message_user( + request, + "Не удалось импортировать файлы: " + "; ".join(failed_files), + level=messages.ERROR, + ) + def upload_excel_view(self, request): changelist_url = reverse("admin:registers_registerupload_changelist") if request.method == "POST": - data = request.POST.copy() - uploaded_file = request.FILES.get("file") - if uploaded_file is not None: - data["file"] = uploaded_file + uploaded_files = self._get_uploaded_files(request) + if not uploaded_files: + self.message_user( + request, + "Выберите хотя бы один Excel-файл.", + level=messages.ERROR, + ) + return redirect(changelist_url) - serializer = RegisterFileUploadSerializer(data=data) + serializer = self._build_upload_serializer(request, uploaded_files) if not serializer.is_valid(): self.message_user( request, @@ -109,37 +184,27 @@ class RegisterUploadAdmin(admin.ModelAdmin): ) return redirect(changelist_url) - try: - result = RegisterImportService.sync_registry_memberships( - registry=serializer.validated_data["registry"], - uploaded_file=serializer.validated_data["file"], - actual_date=serializer.validated_data.get("actual_date"), - uploaded_by=request.user, - ) - except RegisterImportError as exc: + invalid_files = self._get_invalid_files(serializer, uploaded_files) + if invalid_files: self.message_user( request, - f"Ошибка импорта Excel: {exc}", + "Ошибка валидации загрузки: " + "; ".join(invalid_files), level=messages.ERROR, ) return redirect(changelist_url) - self.message_user( + processed_results, failed_files = self._process_uploaded_files( + serializer, + uploaded_files, request, - ( - "Загрузка завершена: " - f"{result['registry_name']}, строк: {result['rows_in_file']}, " - f"новых организаций: {result['organizations_created']}, " - f"обновлено: {result['organizations_updated']}." - ), - level=messages.SUCCESS, ) + self._message_upload_results(request, processed_results, failed_files) return redirect(changelist_url) context = { **self.admin_site.each_context(request), "opts": self.model._meta, - "title": "Загрузка списка организаций из Excel", + "title": "Загрузка справочников из Excel", "changelist_url": changelist_url, "registries": Register.objects.only("id", "name").order_by("name"), } diff --git a/src/apps/user/apps.py b/src/apps/user/apps.py index 7107b88..fd83ada 100644 --- a/src/apps/user/apps.py +++ b/src/apps/user/apps.py @@ -4,7 +4,7 @@ from django.apps import AppConfig class UserConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.user" - verbose_name = "User Management" + verbose_name = "Пользователи" def ready(self): import apps.user.signals # noqa diff --git a/src/apps/user/models.py b/src/apps/user/models.py index f3efcd6..f44372b 100644 --- a/src/apps/user/models.py +++ b/src/apps/user/models.py @@ -54,8 +54,8 @@ class User(AbstractUser): class Meta: db_table = "users" - verbose_name = _("user") - verbose_name_plural = _("users") + verbose_name = _("пользователь") + verbose_name_plural = _("пользователи") ordering = ["-created_at"] def __str__(self): @@ -100,8 +100,8 @@ class Profile(models.Model): class Meta: db_table = "profiles" - verbose_name = _("profile") - verbose_name_plural = _("profiles") + verbose_name = _("профиль") + verbose_name_plural = _("профили") ordering = ["-created_at"] def __str__(self): diff --git a/src/settings/base.py b/src/settings/base.py index c357104..51dde46 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -56,16 +56,16 @@ INSTALLED_APPS = [ # Jazzmin Admin Configuration JAZZMIN_SETTINGS = { # Title - "site_title": "Mostovik Admin", - "site_header": "Mostovik", - "site_brand": "Mostovik", + "site_title": "Панель управления", + "site_header": "Панель управления", + "site_brand": "Панель управления", "site_logo": None, "login_logo": None, "login_logo_dark": None, "site_logo_classes": "img-circle", "site_icon": None, - "welcome_sign": "Добро пожаловать в панель управления", - "copyright": "Mostovik Backend", + "welcome_sign": "Панель администрирования данных", + "copyright": "Административная панель", # Search "search_model": ["user.User", "parsers.IndustrialCertificateRecord"], # User menu @@ -98,6 +98,10 @@ JAZZMIN_SETTINGS = { "parsers.ParserLoadLog": "fas fa-history", "parsers.IndustrialCertificateRecord": "fas fa-certificate", "parsers.ManufacturerRecord": "fas fa-industry", + "parsers.IndustrialProductRecord": "fas fa-boxes-stacked", + "parsers.InspectionRecord": "fas fa-clipboard-check", + "parsers.ProcurementRecord": "fas fa-file-signature", + "parsers.FinancialReport": "fas fa-chart-line", "registers.Register": "fas fa-book", "registers.Organization": "fas fa-building", "exchange.ExchangeConnection": "fas fa-database", @@ -113,7 +117,7 @@ JAZZMIN_SETTINGS = { "related_modal_active": True, # UI Tweaks "custom_css": "admin/css/mostovik-admin-theme.css", - "custom_js": None, + "custom_js": "admin/js/mostovik-admin-particles.js", "use_google_fonts_cdn": True, "show_ui_builder": False, # Change view diff --git a/src/static/admin/css/mostovik-admin-theme.css b/src/static/admin/css/mostovik-admin-theme.css index 5150469..3e58ffc 100644 --- a/src/static/admin/css/mostovik-admin-theme.css +++ b/src/static/admin/css/mostovik-admin-theme.css @@ -17,7 +17,14 @@ --mx-success: #2dd4bf; --mx-warning: #ffc857; --mx-danger: #ff6b9f; + --mx-table-head: rgba(10, 34, 40, 0.96); + --mx-table-head-active: rgba(13, 43, 50, 0.98); + --mx-table-row: rgba(6, 20, 25, 0.84); + --mx-table-row-alt: rgba(9, 28, 34, 0.94); + --mx-table-row-hover: rgba(15, 47, 56, 0.96); + --mx-table-row-selected: rgba(73, 208, 200, 0.16); --mx-shadow: 0 24px 60px rgba(1, 8, 11, 0.44); + --mx-sidebar-width: clamp(23rem, 27vw, 26rem); } html, @@ -83,11 +90,63 @@ body, .main-sidebar { background: - linear-gradient(180deg, rgba(3, 13, 17, 0.985) 0%, rgba(5, 19, 24, 0.985) 100%), - radial-gradient(circle at top, rgba(73, 208, 200, 0.1), transparent 34%); + linear-gradient(180deg, rgba(3, 13, 17, 0.985) 0%, rgba(5, 19, 24, 0.985) 100%); border-right: 1px solid rgba(73, 208, 200, 0.1); box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.03); overflow-x: hidden; + position: relative; +} + +.main-sidebar .brand-link, +.main-sidebar .sidebar { + width: 100%; +} + +.main-sidebar .brand-link, +.main-sidebar .sidebar, +.main-sidebar .user-panel { + position: relative; + z-index: 2; +} + +#mx-sidebar-particles { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; + opacity: 0.98; + background: + radial-gradient(circle at 14% 10%, rgba(73, 208, 200, 0.22), transparent 30%), + radial-gradient(circle at 84% 16%, rgba(136, 217, 255, 0.16), transparent 27%), + radial-gradient(circle at 46% 42%, rgba(90, 100, 201, 0.12), transparent 34%), + linear-gradient(180deg, rgba(4, 15, 20, 0.06), rgba(4, 15, 20, 0.2)); +} + +#mx-sidebar-particles::after { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(2, 10, 13, 0.02), rgba(2, 10, 13, 0.22) 42%, rgba(2, 10, 13, 0.34)); + box-shadow: + inset 0 18px 38px rgba(120, 244, 233, 0.05), + inset 0 -24px 52px rgba(7, 17, 34, 0.22); +} + +#mx-sidebar-particles .particles-js-canvas-el { + display: block; + width: 100% !important; + height: 100% !important; + opacity: 0.98; + filter: saturate(1.18) brightness(1.08); +} + +.layout-fixed .brand-link, +.layout-fixed .main-sidebar .brand-link, +.layout-navbar-fixed.layout-fixed .wrapper .brand-link, +.layout-navbar-fixed.layout-fixed .wrapper .brand-link.text-sm { + width: var(--mx-sidebar-width) !important; } .brand-link { @@ -97,13 +156,18 @@ body, .brand-link .brand-text { font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; + letter-spacing: 0.01em; + text-transform: none; + display: block; + white-space: normal; + line-height: 1.15; + font-size: 0.98rem; } .nav-sidebar > .nav-item > .nav-link { margin: 0 0 0.4rem; - border-radius: 14px; + padding: 0.78rem 0.9rem; + border-radius: 10px; color: var(--mx-text-muted); transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease; } @@ -118,18 +182,23 @@ body, .nav-sidebar > .nav-item > .nav-link.active, .nav-sidebar .nav-treeview > .nav-item > .nav-link.active { - background: linear-gradient(90deg, rgba(73, 208, 200, 0.18), rgba(86, 100, 201, 0.18)); - border: 1px solid rgba(73, 208, 200, 0.16); + background: rgba(91, 127, 177, 0.2); + border: 1px solid rgba(119, 155, 204, 0.2); color: #ffffff; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), - 0 8px 18px rgba(6, 18, 22, 0.28); + 0 6px 16px rgba(6, 18, 22, 0.2); } #jazzy-sidebar .sidebar { padding: 0.85rem 0.75rem 1rem; } +#jazzy-sidebar .brand-link { + min-height: 4.1rem; + padding-right: 1rem; +} + #jazzy-sidebar .user-panel { margin-left: 0; margin-right: 0; @@ -137,10 +206,51 @@ body, padding-right: 0.25rem; } +#jazzy-sidebar nav, +#jazzy-sidebar .nav-sidebar, +#jazzy-sidebar .nav-sidebar > .nav-item, +#jazzy-sidebar .nav-sidebar > .nav-item > .nav-link, +#jazzy-sidebar .nav-sidebar .nav-treeview > .nav-item, +#jazzy-sidebar .nav-sidebar .nav-treeview > .nav-item > .nav-link, +#jazzy-sidebar .user-panel { + width: 100%; + max-width: none; +} + #jazzy-sidebar .nav-sidebar { padding-right: 0.1rem; } +#jazzy-sidebar .nav-sidebar .nav-link { + display: flex; + align-items: flex-start; + gap: 0.35rem; +} + +#jazzy-sidebar .nav-sidebar .nav-link p { + white-space: normal; + overflow: visible; + text-overflow: clip; + line-height: 1.24; + margin-right: 1.1rem; + overflow-wrap: anywhere; + word-break: normal; + font-size: 0.9rem; + font-weight: 600; +} + +#jazzy-sidebar .nav-sidebar .nav-icon { + margin-top: 0.15rem; +} + +#jazzy-sidebar .nav-sidebar .nav-treeview { + padding-left: 0.6rem; +} + +#jazzy-sidebar .nav-sidebar .nav-link > .right { + top: 0.95rem; +} + .nav-sidebar .nav-header { color: var(--mx-text-dim); font-size: 0.7rem; @@ -269,8 +379,9 @@ table, } .results thead th, -.table thead th { - background: rgba(10, 34, 40, 0.96); +.table thead th, +#result_list thead th { + background: var(--mx-table-head); border-bottom: 1px solid rgba(73, 208, 200, 0.12); color: #d5f6f2; font-size: 0.76rem; @@ -279,31 +390,102 @@ table, text-transform: uppercase; } +.results thead th.sorted, +.table thead th.sorted, +#result_list thead th.sorted { + background: var(--mx-table-head-active); +} + +.results thead th a, +.results thead th .text a, +.results thead th .sortoptions a, +.table thead th a, +#result_list thead th a { + color: inherit; +} + +.results thead th a:hover, +.results thead th .sortoptions a:hover, +.table thead th a:hover, +#result_list thead th a:hover { + color: #ffffff; +} + .results tbody tr, -.table tbody tr { - background: rgba(5, 18, 23, 0.68); +.results tbody tr.row1, +.results tbody tr.row2, +.table tbody tr, +.table-striped tbody tr, +#result_list tbody tr { + background: var(--mx-table-row); +} + +.results tbody tr:nth-child(odd), +.results tbody tr.row1, +.table tbody tr:nth-child(odd), +.table-striped tbody tr:nth-of-type(odd), +#result_list tbody tr:nth-child(odd) { + background: var(--mx-table-row); } .results tbody tr:nth-child(even), -.table tbody tr:nth-child(even) { - background: rgba(8, 25, 31, 0.8); +.results tbody tr.row2, +.table tbody tr:nth-child(even), +.table-striped tbody tr:nth-of-type(even), +#result_list tbody tr:nth-child(even) { + background: var(--mx-table-row-alt); } .results tbody tr:hover, -.table tbody tr:hover { - background: rgba(13, 40, 48, 0.9); +.results tbody tr.row1:hover, +.results tbody tr.row2:hover, +.table tbody tr:hover, +.table-striped tbody tr:hover, +#result_list tbody tr:hover { + background: var(--mx-table-row-hover) !important; +} + +.results tbody tr.selected, +.results tbody tr.selected:nth-child(odd), +.results tbody tr.selected:nth-child(even), +.table tbody tr.selected, +.table-striped tbody tr.selected, +#result_list tbody tr.selected { + background: var(--mx-table-row-selected) !important; } .results td, .results th, .table td, -.table th { +.table th, +#result_list td, +#result_list th { border-color: rgba(73, 208, 200, 0.08); + border-top: 1px solid rgba(73, 208, 200, 0.08); padding-top: 0.78rem; padding-bottom: 0.78rem; vertical-align: middle; } +.results tbody a, +.table tbody a, +#result_list tbody a { + color: #86e2dc; + font-weight: 600; +} + +.results tbody a:hover, +.table tbody a:hover, +#result_list tbody a:hover { + color: #c9f8ff; +} + +.results .action-checkbox-column, +#result_list .action-checkbox-column { + background: transparent; + width: 2.8rem; +} + input[type="text"], input[type="password"], input[type="email"], @@ -414,6 +596,288 @@ a.button:hover, cursor: pointer; } +.mx-admin-action-bar { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; + margin: 0 0 1rem; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; + backdrop-filter: none; +} + +.mx-admin-action-bar__link { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 14rem; + min-height: 2.7rem; + padding: 0.72rem 1rem; + border: 1px solid rgba(73, 208, 200, 0.14); + border-radius: 10px; + color: #f5fbff; + font-weight: 600; + line-height: 1.2; + text-align: center; + text-decoration: none; + box-shadow: 0 8px 18px rgba(2, 10, 13, 0.16); + transition: + transform 0.16s ease, + filter 0.16s ease, + border-color 0.16s ease; +} + +.mx-admin-action-bar__link:hover { + color: #ffffff; + filter: brightness(1.06); + transform: translateY(-1px); +} + +.mx-admin-action-bar__link--primary { + background: rgba(73, 208, 200, 0.14); + border-color: rgba(73, 208, 200, 0.22); +} + +.mx-admin-action-bar__link--secondary { + background: rgba(255, 255, 255, 0.03); +} + +.mx-admin-action-bar__link--ghost { + background: rgba(91, 127, 177, 0.12); + border-color: rgba(255, 255, 255, 0.12); +} + +.mx-admin-page { + display: grid; + gap: 1rem; + max-width: 1240px; +} + +.mx-admin-page__intro { + margin: 0; + color: var(--mx-text-muted); + line-height: 1.55; +} + +.mx-admin-page__section { + overflow: hidden; +} + +.mx-admin-page__section-title { + padding: 0.9rem 1.1rem; + background: linear-gradient(90deg, rgba(10, 34, 40, 0.96), rgba(12, 40, 47, 0.94)); + border-bottom: 1px solid rgba(73, 208, 200, 0.12); + color: #f6f9ff; + font-size: 0.98rem; + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1.35; + text-transform: none; +} + +.mx-admin-page__section-body { + padding: 1rem 1.1rem 1.1rem; +} + +.mx-admin-page__section-body--table { + padding: 0; +} + +.mx-admin-page__connection { + display: grid; + gap: 0.3rem; +} + +.mx-admin-page__connection strong { + font-size: 1rem; +} + +.mx-admin-page__meta { + color: var(--mx-text-muted); + font-size: 0.9rem; + line-height: 1.45; +} + +.mx-admin-page__table-wrap { + overflow-x: auto; +} + +.mx-admin-page__table { + width: 100%; +} + +.mx-admin-page__table td, +.mx-admin-page__table th { + white-space: normal; +} + +.mx-admin-page__table td:last-child { + width: 1%; + white-space: nowrap; +} + +.mx-admin-page__empty-cell { + padding: 1rem 1.1rem !important; + color: var(--mx-text-muted); +} + +.mx-admin-inline-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.mx-admin-inline-actions__link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2rem; + padding: 0.42rem 0.75rem; + background: rgba(73, 208, 200, 0.1); + border: 1px solid rgba(73, 208, 200, 0.16); + border-radius: 8px; + color: #dff9f6; + font-size: 0.84rem; + font-weight: 600; + line-height: 1.2; + text-decoration: none; +} + +.mx-admin-inline-actions__link:hover { + color: #ffffff; + background: rgba(73, 208, 200, 0.16); +} + +.mx-admin-inline-actions__link--ghost { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.12); +} + +.mx-admin-status-badge { + display: inline-flex; + align-items: center; + min-height: 1.55rem; + padding: 0.18rem 0.55rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +.mx-admin-status-badge--success { + background: rgba(45, 212, 191, 0.16); + border: 1px solid rgba(45, 212, 191, 0.24); + color: #d5fffb; +} + +.mx-admin-status-badge--muted { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.11); + color: var(--mx-text-muted); +} + +.mx-admin-page__form { + display: grid; + gap: 1rem; +} + +.mx-admin-page__form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem 1.1rem; +} + +.mx-admin-page__field { + min-width: 0; +} + +.mx-admin-page__field--full { + grid-column: 1 / -1; +} + +.mx-admin-page__field label { + display: block; + margin: 0 0 0.45rem; + color: #d8e7ff; + font-size: 0.9rem; + font-weight: 600; + line-height: 1.35; +} + +.mx-admin-page__field input[type="text"], +.mx-admin-page__field input[type="password"], +.mx-admin-page__field input[type="email"], +.mx-admin-page__field input[type="number"], +.mx-admin-page__field input[type="url"], +.mx-admin-page__field input[type="search"], +.mx-admin-page__field input[type="date"], +.mx-admin-page__field select, +.mx-admin-page__field textarea { + width: 100%; +} + +.mx-admin-page__field select[multiple] { + min-height: 13rem; + padding-top: 0.4rem; + padding-bottom: 0.4rem; +} + +.mx-admin-page__field .help { + margin-top: 0.45rem; +} + +.mx-admin-page__field .errorlist { + margin: 0 0 0.5rem; + padding-left: 1rem; +} + +.mx-admin-page__field.errors input, +.mx-admin-page__field.errors select, +.mx-admin-page__field.errors textarea { + border-color: rgba(255, 107, 159, 0.4); +} + +.mx-admin-page__field--checkbox { + display: flex; + align-items: center; + min-height: 2.7rem; +} + +.mx-admin-page__checkbox { + display: inline-flex !important; + align-items: center; + gap: 0.6rem; + margin: 0; +} + +.mx-admin-page__checkbox input[type="checkbox"] { + width: 1rem; + height: 1rem; + margin: 0; +} + +.mx-admin-page__checkbox span { + color: #d8e7ff; + font-size: 0.9rem; + font-weight: 600; +} + +.submit-row.mx-admin-page__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + justify-content: flex-start; + padding: 1rem 1.1rem; +} + +.submit-row.mx-admin-page__actions .button, +.submit-row.mx-admin-page__actions input[type="submit"] { + margin: 0; +} + .deletelink, .btn-danger { background: linear-gradient(135deg, rgba(255, 107, 159, 0.82), rgba(167, 53, 118, 0.88)); @@ -591,6 +1055,31 @@ code { } } +@media (min-width: 992px) { + body.sidebar-mini:not(.sidebar-collapse) .main-sidebar, + body.sidebar-mini:not(.sidebar-collapse) .main-sidebar::before, + body.sidebar-mini-md:not(.sidebar-collapse) .main-sidebar, + body.sidebar-mini-md:not(.sidebar-collapse) .main-sidebar::before { + width: var(--mx-sidebar-width) !important; + } + + body.sidebar-mini:not(.sidebar-collapse) .content-wrapper, + body.sidebar-mini:not(.sidebar-collapse) .main-footer, + body.sidebar-mini:not(.sidebar-collapse) .main-header, + body.sidebar-mini-md:not(.sidebar-collapse) .content-wrapper, + body.sidebar-mini-md:not(.sidebar-collapse) .main-footer, + body.sidebar-mini-md:not(.sidebar-collapse) .main-header { + margin-left: var(--mx-sidebar-width) !important; + } + + body.sidebar-mini.sidebar-collapse .main-sidebar, + body.sidebar-mini.sidebar-collapse .main-sidebar::before, + body.sidebar-mini-md.sidebar-collapse .main-sidebar, + body.sidebar-mini-md.sidebar-collapse .main-sidebar::before { + width: 4.6rem !important; + } +} + @media (max-width: 991.98px) { .dashboard .admin-dashboard-grid { padding-right: 0.15rem; @@ -628,4 +1117,17 @@ code { .main-header .form-control-navbar { width: 100%; } + + .mx-admin-page__form-grid { + grid-template-columns: 1fr; + } + + .mx-admin-page__field--full { + grid-column: auto; + } + + .mx-admin-action-bar__link { + width: 100%; + min-width: 0; + } } diff --git a/src/static/admin/js/mostovik-admin-particles.js b/src/static/admin/js/mostovik-admin-particles.js new file mode 100644 index 0000000..00f542f --- /dev/null +++ b/src/static/admin/js/mostovik-admin-particles.js @@ -0,0 +1,153 @@ +(function () { + const SIDEBAR_SELECTOR = ".main-sidebar"; + const CONTAINER_ID = "mx-sidebar-particles"; + const DESKTOP_MEDIA_QUERY = + "(min-width: 992px) and (prefers-reduced-motion: no-preference)"; + + let particlesLoaderPromise = null; + + function getVendorUrl() { + const currentScript = document.currentScript; + + if (currentScript && currentScript.src) { + return new URL("./vendor/particles.min.js", currentScript.src).href; + } + + return "/static/admin/js/vendor/particles.min.js"; + } + + function loadParticlesLibrary() { + if (window.particlesJS) { + return Promise.resolve(); + } + + if (particlesLoaderPromise) { + return particlesLoaderPromise; + } + + particlesLoaderPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + + script.src = getVendorUrl(); + script.async = true; + script.onload = resolve; + script.onerror = reject; + + document.head.appendChild(script); + }); + + return particlesLoaderPromise; + } + + function shouldEnableParticles() { + return window.matchMedia(DESKTOP_MEDIA_QUERY).matches; + } + + function ensureContainer(sidebar) { + let container = document.getElementById(CONTAINER_ID); + + if (container) { + return container; + } + + container = document.createElement("div"); + container.id = CONTAINER_ID; + container.setAttribute("aria-hidden", "true"); + sidebar.prepend(container); + + return container; + } + + function initSidebarParticles() { + const sidebar = document.querySelector(SIDEBAR_SELECTOR); + + if (!sidebar || !shouldEnableParticles() || document.getElementById(CONTAINER_ID)) { + return; + } + + loadParticlesLibrary() + .then(() => { + if (!window.particlesJS || document.getElementById(CONTAINER_ID)) { + return; + } + + ensureContainer(sidebar); + + window.particlesJS(CONTAINER_ID, { + particles: { + number: { + value: 52, + density: { + enable: true, + value_area: 900, + }, + }, + color: { + value: ["#49d0c8", "#8cefeb", "#dffdfb", "#8ba8ff"], + }, + shape: { + type: "circle", + }, + opacity: { + value: 0.52, + random: true, + anim: { + enable: true, + speed: 0.7, + opacity_min: 0.18, + sync: false, + }, + }, + size: { + value: 3.8, + random: true, + anim: { + enable: true, + speed: 2.2, + size_min: 1, + sync: false, + }, + }, + line_linked: { + enable: true, + distance: 142, + color: "#7be8df", + opacity: 0.2, + width: 1.15, + }, + move: { + enable: true, + speed: 1.28, + direction: "none", + random: true, + straight: false, + out_mode: "out", + bounce: false, + }, + }, + interactivity: { + detect_on: "canvas", + events: { + onhover: { + enable: false, + }, + onclick: { + enable: false, + }, + resize: true, + }, + }, + retina_detect: true, + }); + }) + .catch(() => { + // Silently skip the effect if the vendor script fails to load. + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initSidebarParticles, { once: true }); + } else { + initSidebarParticles(); + } +})(); diff --git a/src/static/admin/js/vendor/particles.min.js b/src/static/admin/js/vendor/particles.min.js new file mode 100644 index 0000000..b3d46d1 --- /dev/null +++ b/src/static/admin/js/vendor/particles.min.js @@ -0,0 +1,9 @@ +/* ----------------------------------------------- +/* Author : Vincent Garreau - vincentgarreau.com +/* MIT license: http://opensource.org/licenses/MIT +/* Demo / Generator : vincentgarreau.com/particles.js +/* GitHub : github.com/VincentGarreau/particles.js +/* How to use? : Check the GitHub README +/* v2.0.0 +/* ----------------------------------------------- */ +function hexToRgb(e){var a=/^#?([a-f\d])([a-f\d])([a-f\d])$/i;e=e.replace(a,function(e,a,t,i){return a+a+t+t+i+i});var t=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return t?{r:parseInt(t[1],16),g:parseInt(t[2],16),b:parseInt(t[3],16)}:null}function clamp(e,a,t){return Math.min(Math.max(e,a),t)}function isInArray(e,a){return a.indexOf(e)>-1}var pJS=function(e,a){var t=document.querySelector("#"+e+" > .particles-js-canvas-el");this.pJS={canvas:{el:t,w:t.offsetWidth,h:t.offsetHeight},particles:{number:{value:400,density:{enable:!0,value_area:800}},color:{value:"#fff"},shape:{type:"circle",stroke:{width:0,color:"#ff0000"},polygon:{nb_sides:5},image:{src:"",width:100,height:100}},opacity:{value:1,random:!1,anim:{enable:!1,speed:2,opacity_min:0,sync:!1}},size:{value:20,random:!1,anim:{enable:!1,speed:20,size_min:0,sync:!1}},line_linked:{enable:!0,distance:100,color:"#fff",opacity:1,width:1},move:{enable:!0,speed:2,direction:"none",random:!1,straight:!1,out_mode:"out",bounce:!1,attract:{enable:!1,rotateX:3e3,rotateY:3e3}},array:[]},interactivity:{detect_on:"canvas",events:{onhover:{enable:!0,mode:"grab"},onclick:{enable:!0,mode:"push"},resize:!0},modes:{grab:{distance:100,line_linked:{opacity:1}},bubble:{distance:200,size:80,duration:.4},repulse:{distance:200,duration:.4},push:{particles_nb:4},remove:{particles_nb:2}},mouse:{}},retina_detect:!1,fn:{interact:{},modes:{},vendors:{}},tmp:{}};var i=this.pJS;a&&Object.deepExtend(i,a),i.tmp.obj={size_value:i.particles.size.value,size_anim_speed:i.particles.size.anim.speed,move_speed:i.particles.move.speed,line_linked_distance:i.particles.line_linked.distance,line_linked_width:i.particles.line_linked.width,mode_grab_distance:i.interactivity.modes.grab.distance,mode_bubble_distance:i.interactivity.modes.bubble.distance,mode_bubble_size:i.interactivity.modes.bubble.size,mode_repulse_distance:i.interactivity.modes.repulse.distance},i.fn.retinaInit=function(){i.retina_detect&&window.devicePixelRatio>1?(i.canvas.pxratio=window.devicePixelRatio,i.tmp.retina=!0):(i.canvas.pxratio=1,i.tmp.retina=!1),i.canvas.w=i.canvas.el.offsetWidth*i.canvas.pxratio,i.canvas.h=i.canvas.el.offsetHeight*i.canvas.pxratio,i.particles.size.value=i.tmp.obj.size_value*i.canvas.pxratio,i.particles.size.anim.speed=i.tmp.obj.size_anim_speed*i.canvas.pxratio,i.particles.move.speed=i.tmp.obj.move_speed*i.canvas.pxratio,i.particles.line_linked.distance=i.tmp.obj.line_linked_distance*i.canvas.pxratio,i.interactivity.modes.grab.distance=i.tmp.obj.mode_grab_distance*i.canvas.pxratio,i.interactivity.modes.bubble.distance=i.tmp.obj.mode_bubble_distance*i.canvas.pxratio,i.particles.line_linked.width=i.tmp.obj.line_linked_width*i.canvas.pxratio,i.interactivity.modes.bubble.size=i.tmp.obj.mode_bubble_size*i.canvas.pxratio,i.interactivity.modes.repulse.distance=i.tmp.obj.mode_repulse_distance*i.canvas.pxratio},i.fn.canvasInit=function(){i.canvas.ctx=i.canvas.el.getContext("2d")},i.fn.canvasSize=function(){i.canvas.el.width=i.canvas.w,i.canvas.el.height=i.canvas.h,i&&i.interactivity.events.resize&&window.addEventListener("resize",function(){i.canvas.w=i.canvas.el.offsetWidth,i.canvas.h=i.canvas.el.offsetHeight,i.tmp.retina&&(i.canvas.w*=i.canvas.pxratio,i.canvas.h*=i.canvas.pxratio),i.canvas.el.width=i.canvas.w,i.canvas.el.height=i.canvas.h,i.particles.move.enable||(i.fn.particlesEmpty(),i.fn.particlesCreate(),i.fn.particlesDraw(),i.fn.vendors.densityAutoParticles()),i.fn.vendors.densityAutoParticles()})},i.fn.canvasPaint=function(){i.canvas.ctx.fillRect(0,0,i.canvas.w,i.canvas.h)},i.fn.canvasClear=function(){i.canvas.ctx.clearRect(0,0,i.canvas.w,i.canvas.h)},i.fn.particle=function(e,a,t){if(this.radius=(i.particles.size.random?Math.random():1)*i.particles.size.value,i.particles.size.anim.enable&&(this.size_status=!1,this.vs=i.particles.size.anim.speed/100,i.particles.size.anim.sync||(this.vs=this.vs*Math.random())),this.x=t?t.x:Math.random()*i.canvas.w,this.y=t?t.y:Math.random()*i.canvas.h,this.x>i.canvas.w-2*this.radius?this.x=this.x-this.radius:this.x<2*this.radius&&(this.x=this.x+this.radius),this.y>i.canvas.h-2*this.radius?this.y=this.y-this.radius:this.y<2*this.radius&&(this.y=this.y+this.radius),i.particles.move.bounce&&i.fn.vendors.checkOverlap(this,t),this.color={},"object"==typeof e.value)if(e.value instanceof Array){var s=e.value[Math.floor(Math.random()*i.particles.color.value.length)];this.color.rgb=hexToRgb(s)}else void 0!=e.value.r&&void 0!=e.value.g&&void 0!=e.value.b&&(this.color.rgb={r:e.value.r,g:e.value.g,b:e.value.b}),void 0!=e.value.h&&void 0!=e.value.s&&void 0!=e.value.l&&(this.color.hsl={h:e.value.h,s:e.value.s,l:e.value.l});else"random"==e.value?this.color.rgb={r:Math.floor(256*Math.random())+0,g:Math.floor(256*Math.random())+0,b:Math.floor(256*Math.random())+0}:"string"==typeof e.value&&(this.color=e,this.color.rgb=hexToRgb(this.color.value));this.opacity=(i.particles.opacity.random?Math.random():1)*i.particles.opacity.value,i.particles.opacity.anim.enable&&(this.opacity_status=!1,this.vo=i.particles.opacity.anim.speed/100,i.particles.opacity.anim.sync||(this.vo=this.vo*Math.random()));var n={};switch(i.particles.move.direction){case"top":n={x:0,y:-1};break;case"top-right":n={x:.5,y:-.5};break;case"right":n={x:1,y:-0};break;case"bottom-right":n={x:.5,y:.5};break;case"bottom":n={x:0,y:1};break;case"bottom-left":n={x:-.5,y:1};break;case"left":n={x:-1,y:0};break;case"top-left":n={x:-.5,y:-.5};break;default:n={x:0,y:0}}i.particles.move.straight?(this.vx=n.x,this.vy=n.y,i.particles.move.random&&(this.vx=this.vx*Math.random(),this.vy=this.vy*Math.random())):(this.vx=n.x+Math.random()-.5,this.vy=n.y+Math.random()-.5),this.vx_i=this.vx,this.vy_i=this.vy;var r=i.particles.shape.type;if("object"==typeof r){if(r instanceof Array){var c=r[Math.floor(Math.random()*r.length)];this.shape=c}}else this.shape=r;if("image"==this.shape){var o=i.particles.shape;this.img={src:o.image.src,ratio:o.image.width/o.image.height},this.img.ratio||(this.img.ratio=1),"svg"==i.tmp.img_type&&void 0!=i.tmp.source_svg&&(i.fn.vendors.createSvgImg(this),i.tmp.pushing&&(this.img.loaded=!1))}},i.fn.particle.prototype.draw=function(){function e(){i.canvas.ctx.drawImage(r,a.x-t,a.y-t,2*t,2*t/a.img.ratio)}var a=this;if(void 0!=a.radius_bubble)var t=a.radius_bubble;else var t=a.radius;if(void 0!=a.opacity_bubble)var s=a.opacity_bubble;else var s=a.opacity;if(a.color.rgb)var n="rgba("+a.color.rgb.r+","+a.color.rgb.g+","+a.color.rgb.b+","+s+")";else var n="hsla("+a.color.hsl.h+","+a.color.hsl.s+"%,"+a.color.hsl.l+"%,"+s+")";switch(i.canvas.ctx.fillStyle=n,i.canvas.ctx.beginPath(),a.shape){case"circle":i.canvas.ctx.arc(a.x,a.y,t,0,2*Math.PI,!1);break;case"edge":i.canvas.ctx.rect(a.x-t,a.y-t,2*t,2*t);break;case"triangle":i.fn.vendors.drawShape(i.canvas.ctx,a.x-t,a.y+t/1.66,2*t,3,2);break;case"polygon":i.fn.vendors.drawShape(i.canvas.ctx,a.x-t/(i.particles.shape.polygon.nb_sides/3.5),a.y-t/.76,2.66*t/(i.particles.shape.polygon.nb_sides/3),i.particles.shape.polygon.nb_sides,1);break;case"star":i.fn.vendors.drawShape(i.canvas.ctx,a.x-2*t/(i.particles.shape.polygon.nb_sides/4),a.y-t/1.52,2*t*2.66/(i.particles.shape.polygon.nb_sides/3),i.particles.shape.polygon.nb_sides,2);break;case"image":if("svg"==i.tmp.img_type)var r=a.img.obj;else var r=i.tmp.img_obj;r&&e()}i.canvas.ctx.closePath(),i.particles.shape.stroke.width>0&&(i.canvas.ctx.strokeStyle=i.particles.shape.stroke.color,i.canvas.ctx.lineWidth=i.particles.shape.stroke.width,i.canvas.ctx.stroke()),i.canvas.ctx.fill()},i.fn.particlesCreate=function(){for(var e=0;e=i.particles.opacity.value&&(a.opacity_status=!1),a.opacity+=a.vo):(a.opacity<=i.particles.opacity.anim.opacity_min&&(a.opacity_status=!0),a.opacity-=a.vo),a.opacity<0&&(a.opacity=0)),i.particles.size.anim.enable&&(1==a.size_status?(a.radius>=i.particles.size.value&&(a.size_status=!1),a.radius+=a.vs):(a.radius<=i.particles.size.anim.size_min&&(a.size_status=!0),a.radius-=a.vs),a.radius<0&&(a.radius=0)),"bounce"==i.particles.move.out_mode)var s={x_left:a.radius,x_right:i.canvas.w,y_top:a.radius,y_bottom:i.canvas.h};else var s={x_left:-a.radius,x_right:i.canvas.w+a.radius,y_top:-a.radius,y_bottom:i.canvas.h+a.radius};switch(a.x-a.radius>i.canvas.w?(a.x=s.x_left,a.y=Math.random()*i.canvas.h):a.x+a.radius<0&&(a.x=s.x_right,a.y=Math.random()*i.canvas.h),a.y-a.radius>i.canvas.h?(a.y=s.y_top,a.x=Math.random()*i.canvas.w):a.y+a.radius<0&&(a.y=s.y_bottom,a.x=Math.random()*i.canvas.w),i.particles.move.out_mode){case"bounce":a.x+a.radius>i.canvas.w?a.vx=-a.vx:a.x-a.radius<0&&(a.vx=-a.vx),a.y+a.radius>i.canvas.h?a.vy=-a.vy:a.y-a.radius<0&&(a.vy=-a.vy)}if(isInArray("grab",i.interactivity.events.onhover.mode)&&i.fn.modes.grabParticle(a),(isInArray("bubble",i.interactivity.events.onhover.mode)||isInArray("bubble",i.interactivity.events.onclick.mode))&&i.fn.modes.bubbleParticle(a),(isInArray("repulse",i.interactivity.events.onhover.mode)||isInArray("repulse",i.interactivity.events.onclick.mode))&&i.fn.modes.repulseParticle(a),i.particles.line_linked.enable||i.particles.move.attract.enable)for(var n=e+1;n0){var c=i.particles.line_linked.color_rgb_line;i.canvas.ctx.strokeStyle="rgba("+c.r+","+c.g+","+c.b+","+r+")",i.canvas.ctx.lineWidth=i.particles.line_linked.width,i.canvas.ctx.beginPath(),i.canvas.ctx.moveTo(e.x,e.y),i.canvas.ctx.lineTo(a.x,a.y),i.canvas.ctx.stroke(),i.canvas.ctx.closePath()}}},i.fn.interact.attractParticles=function(e,a){var t=e.x-a.x,s=e.y-a.y,n=Math.sqrt(t*t+s*s);if(n<=i.particles.line_linked.distance){var r=t/(1e3*i.particles.move.attract.rotateX),c=s/(1e3*i.particles.move.attract.rotateY);e.vx-=r,e.vy-=c,a.vx+=r,a.vy+=c}},i.fn.interact.bounceParticles=function(e,a){var t=e.x-a.x,i=e.y-a.y,s=Math.sqrt(t*t+i*i),n=e.radius+a.radius;n>=s&&(e.vx=-e.vx,e.vy=-e.vy,a.vx=-a.vx,a.vy=-a.vy)},i.fn.modes.pushParticles=function(e,a){i.tmp.pushing=!0;for(var t=0;e>t;t++)i.particles.array.push(new i.fn.particle(i.particles.color,i.particles.opacity.value,{x:a?a.pos_x:Math.random()*i.canvas.w,y:a?a.pos_y:Math.random()*i.canvas.h})),t==e-1&&(i.particles.move.enable||i.fn.particlesDraw(),i.tmp.pushing=!1)},i.fn.modes.removeParticles=function(e){i.particles.array.splice(0,e),i.particles.move.enable||i.fn.particlesDraw()},i.fn.modes.bubbleParticle=function(e){function a(){e.opacity_bubble=e.opacity,e.radius_bubble=e.radius}function t(a,t,s,n,c){if(a!=t)if(i.tmp.bubble_duration_end){if(void 0!=s){var o=n-p*(n-a)/i.interactivity.modes.bubble.duration,l=a-o;d=a+l,"size"==c&&(e.radius_bubble=d),"opacity"==c&&(e.opacity_bubble=d)}}else if(r<=i.interactivity.modes.bubble.distance){if(void 0!=s)var v=s;else var v=n;if(v!=a){var d=n-p*(n-a)/i.interactivity.modes.bubble.duration;"size"==c&&(e.radius_bubble=d),"opacity"==c&&(e.opacity_bubble=d)}}else"size"==c&&(e.radius_bubble=void 0),"opacity"==c&&(e.opacity_bubble=void 0)}if(i.interactivity.events.onhover.enable&&isInArray("bubble",i.interactivity.events.onhover.mode)){var s=e.x-i.interactivity.mouse.pos_x,n=e.y-i.interactivity.mouse.pos_y,r=Math.sqrt(s*s+n*n),c=1-r/i.interactivity.modes.bubble.distance;if(r<=i.interactivity.modes.bubble.distance){if(c>=0&&"mousemove"==i.interactivity.status){if(i.interactivity.modes.bubble.size!=i.particles.size.value)if(i.interactivity.modes.bubble.size>i.particles.size.value){var o=e.radius+i.interactivity.modes.bubble.size*c;o>=0&&(e.radius_bubble=o)}else{var l=e.radius-i.interactivity.modes.bubble.size,o=e.radius-l*c;o>0?e.radius_bubble=o:e.radius_bubble=0}if(i.interactivity.modes.bubble.opacity!=i.particles.opacity.value)if(i.interactivity.modes.bubble.opacity>i.particles.opacity.value){var v=i.interactivity.modes.bubble.opacity*c;v>e.opacity&&v<=i.interactivity.modes.bubble.opacity&&(e.opacity_bubble=v)}else{var v=e.opacity-(i.particles.opacity.value-i.interactivity.modes.bubble.opacity)*c;v=i.interactivity.modes.bubble.opacity&&(e.opacity_bubble=v)}}}else a();"mouseleave"==i.interactivity.status&&a()}else if(i.interactivity.events.onclick.enable&&isInArray("bubble",i.interactivity.events.onclick.mode)){if(i.tmp.bubble_clicking){var s=e.x-i.interactivity.mouse.click_pos_x,n=e.y-i.interactivity.mouse.click_pos_y,r=Math.sqrt(s*s+n*n),p=((new Date).getTime()-i.interactivity.mouse.click_time)/1e3;p>i.interactivity.modes.bubble.duration&&(i.tmp.bubble_duration_end=!0),p>2*i.interactivity.modes.bubble.duration&&(i.tmp.bubble_clicking=!1,i.tmp.bubble_duration_end=!1)}i.tmp.bubble_clicking&&(t(i.interactivity.modes.bubble.size,i.particles.size.value,e.radius_bubble,e.radius,"size"),t(i.interactivity.modes.bubble.opacity,i.particles.opacity.value,e.opacity_bubble,e.opacity,"opacity"))}},i.fn.modes.repulseParticle=function(e){function a(){var a=Math.atan2(d,p);if(e.vx=u*Math.cos(a),e.vy=u*Math.sin(a),"bounce"==i.particles.move.out_mode){var t={x:e.x+e.vx,y:e.y+e.vy};t.x+e.radius>i.canvas.w?e.vx=-e.vx:t.x-e.radius<0&&(e.vx=-e.vx),t.y+e.radius>i.canvas.h?e.vy=-e.vy:t.y-e.radius<0&&(e.vy=-e.vy)}}if(i.interactivity.events.onhover.enable&&isInArray("repulse",i.interactivity.events.onhover.mode)&&"mousemove"==i.interactivity.status){var t=e.x-i.interactivity.mouse.pos_x,s=e.y-i.interactivity.mouse.pos_y,n=Math.sqrt(t*t+s*s),r={x:t/n,y:s/n},c=i.interactivity.modes.repulse.distance,o=100,l=clamp(1/c*(-1*Math.pow(n/c,2)+1)*c*o,0,50),v={x:e.x+r.x*l,y:e.y+r.y*l};"bounce"==i.particles.move.out_mode?(v.x-e.radius>0&&v.x+e.radius0&&v.y+e.radius=m&&a()}else 0==i.tmp.repulse_clicking&&(e.vx=e.vx_i,e.vy=e.vy_i)},i.fn.modes.grabParticle=function(e){if(i.interactivity.events.onhover.enable&&"mousemove"==i.interactivity.status){var a=e.x-i.interactivity.mouse.pos_x,t=e.y-i.interactivity.mouse.pos_y,s=Math.sqrt(a*a+t*t);if(s<=i.interactivity.modes.grab.distance){var n=i.interactivity.modes.grab.line_linked.opacity-s/(1/i.interactivity.modes.grab.line_linked.opacity)/i.interactivity.modes.grab.distance;if(n>0){var r=i.particles.line_linked.color_rgb_line;i.canvas.ctx.strokeStyle="rgba("+r.r+","+r.g+","+r.b+","+n+")",i.canvas.ctx.lineWidth=i.particles.line_linked.width,i.canvas.ctx.beginPath(),i.canvas.ctx.moveTo(e.x,e.y),i.canvas.ctx.lineTo(i.interactivity.mouse.pos_x,i.interactivity.mouse.pos_y),i.canvas.ctx.stroke(),i.canvas.ctx.closePath()}}}},i.fn.vendors.eventsListeners=function(){"window"==i.interactivity.detect_on?i.interactivity.el=window:i.interactivity.el=i.canvas.el,(i.interactivity.events.onhover.enable||i.interactivity.events.onclick.enable)&&(i.interactivity.el.addEventListener("mousemove",function(e){if(i.interactivity.el==window)var a=e.clientX,t=e.clientY;else var a=e.offsetX||e.clientX,t=e.offsetY||e.clientY;i.interactivity.mouse.pos_x=a,i.interactivity.mouse.pos_y=t,i.tmp.retina&&(i.interactivity.mouse.pos_x*=i.canvas.pxratio,i.interactivity.mouse.pos_y*=i.canvas.pxratio),i.interactivity.status="mousemove"}),i.interactivity.el.addEventListener("mouseleave",function(e){i.interactivity.mouse.pos_x=null,i.interactivity.mouse.pos_y=null,i.interactivity.status="mouseleave"})),i.interactivity.events.onclick.enable&&i.interactivity.el.addEventListener("click",function(){if(i.interactivity.mouse.click_pos_x=i.interactivity.mouse.pos_x,i.interactivity.mouse.click_pos_y=i.interactivity.mouse.pos_y,i.interactivity.mouse.click_time=(new Date).getTime(),i.interactivity.events.onclick.enable)switch(i.interactivity.events.onclick.mode){case"push":i.particles.move.enable?i.fn.modes.pushParticles(i.interactivity.modes.push.particles_nb,i.interactivity.mouse):1==i.interactivity.modes.push.particles_nb?i.fn.modes.pushParticles(i.interactivity.modes.push.particles_nb,i.interactivity.mouse):i.interactivity.modes.push.particles_nb>1&&i.fn.modes.pushParticles(i.interactivity.modes.push.particles_nb);break;case"remove":i.fn.modes.removeParticles(i.interactivity.modes.remove.particles_nb);break;case"bubble":i.tmp.bubble_clicking=!0;break;case"repulse":i.tmp.repulse_clicking=!0,i.tmp.repulse_count=0,i.tmp.repulse_finish=!1,setTimeout(function(){i.tmp.repulse_clicking=!1},1e3*i.interactivity.modes.repulse.duration)}})},i.fn.vendors.densityAutoParticles=function(){if(i.particles.number.density.enable){var e=i.canvas.el.width*i.canvas.el.height/1e3;i.tmp.retina&&(e/=2*i.canvas.pxratio);var a=e*i.particles.number.value/i.particles.number.density.value_area,t=i.particles.array.length-a;0>t?i.fn.modes.pushParticles(Math.abs(t)):i.fn.modes.removeParticles(t)}},i.fn.vendors.checkOverlap=function(e,a){for(var t=0;tv;v++)e.lineTo(i,0),e.translate(i,0),e.rotate(l);e.fill(),e.restore()},i.fn.vendors.exportImg=function(){window.open(i.canvas.el.toDataURL("image/png"),"_blank")},i.fn.vendors.loadImg=function(e){if(i.tmp.img_error=void 0,""!=i.particles.shape.image.src)if("svg"==e){var a=new XMLHttpRequest;a.open("GET",i.particles.shape.image.src),a.onreadystatechange=function(e){4==a.readyState&&(200==a.status?(i.tmp.source_svg=e.currentTarget.response,i.fn.vendors.checkBeforeDraw()):(console.log("Error pJS - Image not found"),i.tmp.img_error=!0))},a.send()}else{var t=new Image;t.addEventListener("load",function(){i.tmp.img_obj=t,i.fn.vendors.checkBeforeDraw()}),t.src=i.particles.shape.image.src}else console.log("Error pJS - No image.src"),i.tmp.img_error=!0},i.fn.vendors.draw=function(){"image"==i.particles.shape.type?"svg"==i.tmp.img_type?i.tmp.count_svg>=i.particles.number.value?(i.fn.particlesDraw(),i.particles.move.enable?i.fn.drawAnimFrame=requestAnimFrame(i.fn.vendors.draw):cancelRequestAnimFrame(i.fn.drawAnimFrame)):i.tmp.img_error||(i.fn.drawAnimFrame=requestAnimFrame(i.fn.vendors.draw)):void 0!=i.tmp.img_obj?(i.fn.particlesDraw(),i.particles.move.enable?i.fn.drawAnimFrame=requestAnimFrame(i.fn.vendors.draw):cancelRequestAnimFrame(i.fn.drawAnimFrame)):i.tmp.img_error||(i.fn.drawAnimFrame=requestAnimFrame(i.fn.vendors.draw)):(i.fn.particlesDraw(),i.particles.move.enable?i.fn.drawAnimFrame=requestAnimFrame(i.fn.vendors.draw):cancelRequestAnimFrame(i.fn.drawAnimFrame))},i.fn.vendors.checkBeforeDraw=function(){"image"==i.particles.shape.type?"svg"==i.tmp.img_type&&void 0==i.tmp.source_svg?i.tmp.checkAnimFrame=requestAnimFrame(check):(cancelRequestAnimFrame(i.tmp.checkAnimFrame),i.tmp.img_error||(i.fn.vendors.init(),i.fn.vendors.draw())):(i.fn.vendors.init(),i.fn.vendors.draw())},i.fn.vendors.init=function(){i.fn.retinaInit(),i.fn.canvasInit(),i.fn.canvasSize(),i.fn.canvasPaint(),i.fn.particlesCreate(),i.fn.vendors.densityAutoParticles(),i.particles.line_linked.color_rgb_line=hexToRgb(i.particles.line_linked.color)},i.fn.vendors.start=function(){isInArray("image",i.particles.shape.type)?(i.tmp.img_type=i.particles.shape.image.src.substr(i.particles.shape.image.src.length-3),i.fn.vendors.loadImg(i.tmp.img_type)):i.fn.vendors.checkBeforeDraw()},i.fn.vendors.eventsListeners(),i.fn.vendors.start()};Object.deepExtend=function(e,a){for(var t in a)a[t]&&a[t].constructor&&a[t].constructor===Object?(e[t]=e[t]||{},arguments.callee(e[t],a[t])):e[t]=a[t];return e},window.requestAnimFrame=function(){return window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(e){window.setTimeout(e,1e3/60)}}(),window.cancelRequestAnimFrame=function(){return window.cancelAnimationFrame||window.webkitCancelRequestAnimationFrame||window.mozCancelRequestAnimationFrame||window.oCancelRequestAnimationFrame||window.msCancelRequestAnimationFrame||clearTimeout}(),window.pJSDom=[],window.particlesJS=function(e,a){"string"!=typeof e&&(a=e,e="particles-js"),e||(e="particles-js");var t=document.getElementById(e),i="particles-js-canvas-el",s=t.getElementsByClassName(i);if(s.length)for(;s.length>0;)t.removeChild(s[0]);var n=document.createElement("canvas");n.className=i,n.style.width="100%",n.style.height="100%";var r=document.getElementById(e).appendChild(n);null!=r&&pJSDom.push(new pJS(e,a))},window.particlesJS.load=function(e,a,t){var i=new XMLHttpRequest;i.open("GET",a),i.onreadystatechange=function(a){if(4==i.readyState)if(200==i.status){var s=JSON.parse(a.currentTarget.response);window.particlesJS(e,s),t&&t()}else console.log("Error pJS - XMLHttpRequest status: "+i.status),console.log("Error pJS - File config not found")},i.send()}; \ No newline at end of file diff --git a/src/templates/admin/exchange/exchangeconnection/change_list.html b/src/templates/admin/exchange/exchangeconnection/change_list.html new file mode 100644 index 0000000..8e24dcf --- /dev/null +++ b/src/templates/admin/exchange/exchangeconnection/change_list.html @@ -0,0 +1,25 @@ +{% extends "admin/change_list.html" %} +{% load admin_urls %} + +{% block content %} + + {{ block.super }} +{% endblock %} + +{% block object-tools %}{% endblock %} diff --git a/src/templates/admin/exchange/exchangeconnection/form_page.html b/src/templates/admin/exchange/exchangeconnection/form_page.html new file mode 100644 index 0000000..700fc72 --- /dev/null +++ b/src/templates/admin/exchange/exchangeconnection/form_page.html @@ -0,0 +1,66 @@ +{% extends "admin/base_site.html" %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+

{{ intro }}

+ + {% if active_connection %} +
+
Активное подключение
+
+
+ {{ active_connection.username }}@{{ active_connection.server }}:{{ active_connection.port }}
+ {{ active_connection.database_name }} [{{ active_connection.schema_name }}] +
+
+
+ {% elif requires_active_connection %} +
    +
  • Активное подключение не выбрано. Сначала проверьте и активируйте одно из сохранённых подключений.
  • +
+ {% endif %} + +
+ {% csrf_token %} + {% if form.non_field_errors %} +

{{ form.non_field_errors }}

+ {% endif %} +
+
{{ title }}
+
+
+ {% for field in form %} +
+ {{ field.errors }} + {% if field.field.widget.input_type == "checkbox" %} + + {% else %} + {{ field.label_tag }} + {{ field }} + {% endif %} + {% if field.help_text %} +
{{ field.help_text|safe }}
+ {% endif %} +
+ {% endfor %} +
+
+
+ +
+
+{% endblock %} diff --git a/src/templates/admin/exchange/exchangeconnection/periodic_tasks.html b/src/templates/admin/exchange/exchangeconnection/periodic_tasks.html new file mode 100644 index 0000000..eadf317 --- /dev/null +++ b/src/templates/admin/exchange/exchangeconnection/periodic_tasks.html @@ -0,0 +1,129 @@ +{% extends "admin/base_site.html" %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+

Периодические задачи используют текущее активное подключение exchange на момент запуска.

+ + {% if active_connection %} +
+
Активное подключение
+
+
+ {{ active_connection.username }}@{{ active_connection.server }}:{{ active_connection.port }}
+ {{ active_connection.database_name }} [{{ active_connection.schema_name }}] +
+
+
+ {% else %} +
    +
  • Сейчас нет активного подключения. Задача сохранится, но выполниться сможет только после активации подключения.
  • +
+ {% endif %} + +
+
Сохранённые периодические задачи
+
+
+ + + + + + + + + + + + {% for task in tasks %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
НазваниеРасписаниеРежимОпцииДействия
+ {{ task.name }} + {% if task.description %} +
{{ task.description }}
+ {% endif %} +
+ {% if task.enabled %} + Включена + {% else %} + Отключена + {% endif %} +
+
{{ task.schedule_label }}{{ task.mode }} +
+ {% if task.truncate_before_copy %}truncate{% else %}append{% endif %} + / + {% if task.notify_on_error %}notify{% else %}silent{% endif %} +
+
+
+ Изменить + {% if task.raw_admin_url %} + Celery beat + {% endif %} +
+
Периодические задачи обмена ещё не настроены.
+
+
+
+ +
+ {% csrf_token %} + {% if form.non_field_errors %} +

{{ form.non_field_errors }}

+ {% endif %} +
+
{{ form_title }}
+
+
+ {% for field in form %} +
+ {{ field.errors }} + {% if field.field.widget.input_type == "checkbox" %} + + {% else %} + {{ field.label_tag }} + {{ field }} + {% endif %} + {% if field.help_text %} +
{{ field.help_text|safe }}
+ {% endif %} +
+ {% endfor %} +
+
+
+
+ + {% if request.resolver_match.url_name == "exchange_exchangeconnection_periodic_task_change" %} + К списку задач + {% else %} + Вернуться к подключениям + {% endif %} +
+
+
+{% endblock %} diff --git a/src/templates/admin/index.html b/src/templates/admin/index.html index 399ad14..88f0b00 100644 --- a/src/templates/admin/index.html +++ b/src/templates/admin/index.html @@ -23,7 +23,7 @@
-

Mostovik Admin

+

Панель управления

Панель данных и загрузок

Живой обзор по внешним источникам, состоянию реестров, географии закупок @@ -132,7 +132,7 @@

Покрытие по внешним данным

- По каждой карточке видно объём данных, число организаций и состояние последней загрузки. + {{ admin_dashboard_data.source_cards_note }}

@@ -174,6 +174,7 @@ {% else %} Ещё не загружалось {% endif %} + {{ card.metrics_scope_label }} {% if card.error_message %} {{ card.error_message|truncatechars:80 }} {% endif %} @@ -279,7 +280,7 @@

Топ регионов по закупкам

- На основе записей ЕИС. Показываем и объём закупок, и число уникальных заказчиков. + {{ admin_dashboard_data.region_rows_note }}

diff --git a/src/templates/admin/parsers/financialreport/change_list.html b/src/templates/admin/parsers/financialreport/change_list.html index 6884e20..97b7cb6 100644 --- a/src/templates/admin/parsers/financialreport/change_list.html +++ b/src/templates/admin/parsers/financialreport/change_list.html @@ -1,11 +1,15 @@ {% extends "admin/change_list.html" %} -{% block object-tools-items %} -
  • - Загрузить Excel ФНС -
  • -
  • - Загрузить ZIP ФНС -
  • +{% block content %} + {{ block.super }} {% endblock %} + +{% block object-tools %}{% endblock %} diff --git a/src/templates/admin/parsers/financialreport/upload_excel.html b/src/templates/admin/parsers/financialreport/upload_excel.html index 846dd1a..74a65d5 100644 --- a/src/templates/admin/parsers/financialreport/upload_excel.html +++ b/src/templates/admin/parsers/financialreport/upload_excel.html @@ -9,7 +9,7 @@ {% block breadcrumbs %} {% endblock %} @@ -18,7 +18,7 @@
    -

    Загрузка Excel отчетности ФНС

    +

    Загрузка Excel бухгалтерской отчетности ФНС

    Загрузка из админки обрабатывается сразу, без очереди. Можно выбрать несколько Excel-файлов за один раз. diff --git a/src/templates/admin/parsers/financialreport/upload_zip.html b/src/templates/admin/parsers/financialreport/upload_zip.html index 204e0ec..60c6b60 100644 --- a/src/templates/admin/parsers/financialreport/upload_zip.html +++ b/src/templates/admin/parsers/financialreport/upload_zip.html @@ -9,7 +9,7 @@ {% block breadcrumbs %}

    {% endblock %} @@ -18,7 +18,7 @@
    -

    Загрузка ZIP отчетности ФНС

    +

    Загрузка ZIP бухгалтерской отчетности ФНС

    Архив разбирается и обрабатывается сразу в админке. Внутри должны лежать Excel-файлы `fin_{id}_{ogrn}.xlsx` в корне архива. diff --git a/src/templates/admin/registers/registerupload/change_list.html b/src/templates/admin/registers/registerupload/change_list.html index 592d0ff..ef06272 100644 --- a/src/templates/admin/registers/registerupload/change_list.html +++ b/src/templates/admin/registers/registerupload/change_list.html @@ -1,8 +1,19 @@ {% extends "admin/change_list.html" %} +{% load admin_urls %} -{% block object-tools-items %} -

  • - Загрузить Excel организаций -
  • +{% block content %} +
    + + Загрузить справочники из Excel + + {% if has_add_permission %} + {% url cl.opts|admin_urlname:'add' as add_url %} + + Добавить загрузку реестра + + {% endif %} +
    {{ block.super }} {% endblock %} + +{% block object-tools %}{% endblock %} diff --git a/src/templates/admin/registers/registerupload/upload_excel.html b/src/templates/admin/registers/registerupload/upload_excel.html index 685fb07..cd67509 100644 --- a/src/templates/admin/registers/registerupload/upload_excel.html +++ b/src/templates/admin/registers/registerupload/upload_excel.html @@ -18,10 +18,11 @@
    -

    Загрузка Excel реестра

    +

    Загрузка Excel справочников

    Импорт выполняется сразу в рамках запроса. Выберите реестр, при необходимости - укажите дату актуальности и загрузите Excel-файл со списком организаций. + укажите дату актуальности и загрузите один или несколько Excel-файлов + со списком организаций.

    @@ -58,17 +59,19 @@
    - +

    - Ожидаемые колонки: pn_name, mn_ogrn, mn_inn, mn_okpo. Опционально: in_kpp. + Можно выбрать несколько файлов. Ожидаемые колонки: pn_name, mn_ogrn, + mn_inn, mn_okpo. Опционально: in_kpp.

    diff --git a/tests/apps/core/test_admin.py b/tests/apps/core/test_admin.py index 015b14d..c38e739 100644 --- a/tests/apps/core/test_admin.py +++ b/tests/apps/core/test_admin.py @@ -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"], + "Показываем только последний успешный срез ЕИС закупок.", + ) diff --git a/tests/apps/exchange/test_admin.py b/tests/apps/exchange/test_admin.py new file mode 100644 index 0000000..0c967b7 --- /dev/null +++ b/tests/apps/exchange/test_admin.py @@ -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": "*", + }, + ) diff --git a/tests/apps/exchange/test_serializers.py b/tests/apps/exchange/test_serializers.py index c0a62c8..539cec8 100644 --- a/tests/apps/exchange/test_serializers.py +++ b/tests/apps/exchange/test_serializers.py @@ -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"]) diff --git a/tests/apps/exchange/test_service_units.py b/tests/apps/exchange/test_service_units.py index be42a7d..fc73e14 100644 --- a/tests/apps/exchange/test_service_units.py +++ b/tests/apps/exchange/test_service_units.py @@ -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", diff --git a/tests/apps/exchange/test_views.py b/tests/apps/exchange/test_views.py index 5831974..29d44a6 100644 --- a/tests/apps/exchange/test_views.py +++ b/tests/apps/exchange/test_views.py @@ -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") diff --git a/tests/apps/parsers/test_admin.py b/tests/apps/parsers/test_admin.py index d6a59df..65b443d 100644 --- a/tests/apps/parsers/test_admin.py +++ b/tests/apps/parsers/test_admin.py @@ -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)) diff --git a/tests/apps/registers/test_admin.py b/tests/apps/registers/test_admin.py index 317d4e2..3cec4fe 100644 --- a/tests/apps/registers/test_admin.py +++ b/tests/apps/registers/test_admin.py @@ -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) diff --git a/tests/test_api_inventory_e2e.py b/tests/test_api_inventory_e2e.py index 930c1d7..f36d6d2 100644 --- a/tests/test_api_inventory_e2e.py +++ b/tests/test_api_inventory_e2e.py @@ -593,16 +593,18 @@ class ExchangeApiInventoryE2ETest(AuthenticatedApiMixin, APITestCase): self.admin = UserFactory.create_superuser() @patch("apps.exchange.services.ExchangeConnectionService.validate_target_structure") + @patch("apps.exchange.services.ExchangeConnectionService.prepare_target_structure") @patch("apps.exchange.services.ExchangeConnectionService.test_connection") @patch("apps.exchange.services.ExchangeConnectionService.test_connection_payload") - @patch("apps.exchange.views.copy_parsers_data_async.delay") + @patch("apps.exchange.views.copy_parsers_data_async") @patch("apps.exchange.services.ExchangeConnectionService.get_active_connection") def test_exchange_endpoints( self, get_active_connection_mock, - delay_mock, + copy_task_mock, test_connection_payload_mock, _test_connection_mock, + _prepare_mock, _validate_mock, ): self.authenticate(self.admin) @@ -623,6 +625,7 @@ class ExchangeApiInventoryE2ETest(AuthenticatedApiMixin, APITestCase): "status": "success", "message": "ok", } + _test_connection_mock.return_value = "exchange_target_test" list_connections = self.client.get(connections_url) create_connection = self.client.post( @@ -640,7 +643,7 @@ class ExchangeApiInventoryE2ETest(AuthenticatedApiMixin, APITestCase): id=create_connection.data["id"] ) get_active_connection_mock.return_value = active_connection - delay_mock.return_value = SimpleNamespace(id="exchange-task-1") + copy_task_mock.delay.return_value = SimpleNamespace(id="exchange-task-1") copy_response = self.client.post(copy_url, {"mode": "all"}, format="json") list_periodic = self.client.get(periodic_tasks_url)