feature/registers-generate-test-data-command #13

Merged
avm merged 12 commits from feature/registers-generate-test-data-command into dev 2026-03-20 17:05:02 +03:00
8 changed files with 485 additions and 5 deletions
Showing only changes of commit e470189f44 - Show all commits

View File

@@ -2,6 +2,13 @@
Admin configuration for parsers app. Admin configuration for parsers app.
""" """
import hashlib
import time
import uuid
from pathlib import Path
from apps.core.models import BackgroundJob
from apps.core.services import BackgroundJobService
from apps.parsers.models import ( from apps.parsers.models import (
FinancialReport, FinancialReport,
FinancialReportLine, FinancialReportLine,
@@ -13,7 +20,14 @@ from apps.parsers.models import (
ProcurementRecord, ProcurementRecord,
Proxy, Proxy,
) )
from django.contrib import admin from apps.parsers.serializers import FNSFileUploadSerializer
from apps.parsers.services import FNSReportService
from apps.parsers.tasks import process_fns_file
from django.conf import settings
from django.contrib import admin, messages
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils.html import format_html from django.utils.html import format_html
@@ -629,9 +643,11 @@ class FinancialReportLineInline(admin.TabularInline):
class FinancialReportAdmin(admin.ModelAdmin): class FinancialReportAdmin(admin.ModelAdmin):
"""Admin для финансовых отчетов ФНС.""" """Admin для финансовых отчетов ФНС."""
change_list_template = "admin/parsers/financialreport/change_list.html"
list_display = [ list_display = [
"external_id", "external_id",
"ogrn", "ogrn",
"registry_organization",
"file_name", "file_name",
"status_badge", "status_badge",
"source", "source",
@@ -639,11 +655,20 @@ class FinancialReportAdmin(admin.ModelAdmin):
"load_batch", "load_batch",
"created_at", "created_at",
] ]
list_filter = ["status", "source", "load_batch", "created_at"] list_filter = ["status", "source", "load_batch", "registry_organization", "created_at"]
search_fields = ["external_id", "ogrn", "file_name"] search_fields = [
"external_id",
"ogrn",
"file_name",
"registry_organization__pn_name",
"registry_organization__mn_ogrn",
"registry_organization__mn_inn",
]
list_select_related = ["registry_organization"]
readonly_fields = [ readonly_fields = [
"external_id", "external_id",
"ogrn", "ogrn",
"registry_organization",
"file_name", "file_name",
"file_hash", "file_hash",
"load_batch", "load_batch",
@@ -661,7 +686,15 @@ class FinancialReportAdmin(admin.ModelAdmin):
fieldsets = ( fieldsets = (
( (
"Основное", "Основное",
{"fields": ("external_id", "ogrn", "file_name", "file_hash")}, {
"fields": (
"external_id",
"ogrn",
"registry_organization",
"file_name",
"file_hash",
)
},
), ),
( (
"Статус", "Статус",
@@ -701,6 +734,166 @@ class FinancialReportAdmin(admin.ModelAdmin):
status_badge.short_description = "Статус" status_badge.short_description = "Статус"
status_badge.admin_order_field = "status" status_badge.admin_order_field = "status"
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"upload-excel/",
self.admin_site.admin_view(self.upload_excel_view),
name="parsers_financialreport_upload_excel",
),
]
return custom_urls + urls
def changelist_view(self, request, extra_context=None):
extra_context = extra_context or {}
extra_context["upload_excel_url"] = reverse(
"admin:parsers_financialreport_upload_excel"
)
return super().changelist_view(request, extra_context=extra_context)
def upload_excel_view(self, request):
changelist_url = reverse("admin:parsers_financialreport_changelist")
if request.method == "POST":
files = request.FILES.getlist("files")
serializer = FNSFileUploadSerializer(data={"files": files})
if not serializer.is_valid():
self.message_user(
request,
f"Ошибка валидации файлов: {serializer.errors}",
level=messages.ERROR,
)
return redirect(changelist_url)
try:
queued, skipped, task_ids = self._enqueue_fns_files(
request,
serializer.validated_data["files"],
)
except Exception as exc: # noqa: BLE001
self.message_user(
request,
f"Ошибка постановки файлов в очередь: {exc}",
level=messages.ERROR,
)
return redirect(changelist_url)
if queued:
self.message_user(
request,
f"Файлов поставлено в очередь: {queued}. Task IDs: {', '.join(task_ids[:5])}",
level=messages.SUCCESS,
)
if skipped:
self.message_user(
request,
f"Пропущено файлов: {skipped} (дубликаты или уже обрабатываются).",
level=messages.WARNING,
)
if not queued and not skipped:
self.message_user(
request,
"Файлы не были обработаны.",
level=messages.WARNING,
)
return redirect(changelist_url)
context = {
**self.admin_site.each_context(request),
"opts": self.model._meta,
"title": "Загрузка Excel отчетности ФНС",
"changelist_url": changelist_url,
}
return TemplateResponse(
request,
"admin/parsers/financialreport/upload_excel.html",
context,
)
@staticmethod
def _try_create_fns_lock(file_path: Path) -> bool:
lock_path = Path(f"{file_path}.lock")
if lock_path.exists():
try:
age_seconds = time.time() - lock_path.stat().st_mtime
ttl_seconds = getattr(settings, "FNS_LOCK_TTL_SECONDS", 3600)
if age_seconds > ttl_seconds:
lock_path.unlink()
else:
return False
except FileNotFoundError:
pass
try:
lock_path.touch(exist_ok=False)
except FileExistsError:
return False
return True
def _enqueue_fns_files(self, request, files):
upload_dir = Path(settings.FNS_WATCH_DIRECTORY)
upload_dir.mkdir(parents=True, exist_ok=True)
task_ids = []
queued = 0
skipped = 0
for file in files:
file_content = file.read()
file_hash = hashlib.sha256(file_content).hexdigest()
file.seek(0)
if FNSReportService.exists_by_hash(file_hash):
skipped += 1
continue
file_path = upload_dir / file.name
if not self._try_create_fns_lock(file_path):
skipped += 1
continue
lock_path = Path(f"{file_path}.lock")
if file_path.exists():
lock_path.unlink(missing_ok=True)
skipped += 1
continue
try:
with open(file_path, "wb") as f:
for chunk in file.chunks():
f.write(chunk)
except Exception:
lock_path.unlink(missing_ok=True)
raise
task_id = str(uuid.uuid4())
try:
BackgroundJobService.create_job(
task_id=task_id,
task_name="apps.parsers.tasks.process_fns_file",
user_id=request.user.id,
meta={
"source": "fns_reports",
"file": file.name,
},
)
task = process_fns_file.apply_async(
args=[str(file_path)],
kwargs={"requested_by_id": request.user.id},
task_id=task_id,
)
except Exception:
lock_path.unlink(missing_ok=True)
BackgroundJob.objects.filter(task_id=task_id).delete()
raise
task_ids.append(task.id)
queued += 1
return queued, skipped, task_ids
def has_add_permission(self, request): def has_add_permission(self, request):
"""Запретить создание записей вручную.""" """Запретить создание записей вручную."""
return False return False

View File

@@ -6,7 +6,12 @@ from apps.registers.models import (
RegisterUpload, RegisterUpload,
RegistryMembershipPeriod, RegistryMembershipPeriod,
) )
from django.contrib import admin from apps.registers.serializers import RegisterFileUploadSerializer
from apps.registers.services import RegisterImportError, RegisterImportService
from django.contrib import admin, messages
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.urls import path, reverse
@admin.register(Register) @admin.register(Register)
@@ -53,6 +58,7 @@ class OrganizationAdmin(admin.ModelAdmin):
class RegisterUploadAdmin(admin.ModelAdmin): class RegisterUploadAdmin(admin.ModelAdmin):
"""Admin для загрузок реестров.""" """Admin для загрузок реестров."""
change_list_template = "admin/registers/registerupload/change_list.html"
list_display = [ list_display = [
"id", "id",
"registry", "registry",
@@ -67,6 +73,82 @@ class RegisterUploadAdmin(admin.ModelAdmin):
readonly_fields = ["created_at", "updated_at", "file_hash"] readonly_fields = ["created_at", "updated_at", "file_hash"]
ordering = ["-actual_date", "-created_at"] ordering = ["-actual_date", "-created_at"]
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"upload-excel/",
self.admin_site.admin_view(self.upload_excel_view),
name="registers_registerupload_upload_excel",
),
]
return custom_urls + urls
def changelist_view(self, request, extra_context=None):
extra_context = extra_context or {}
extra_context["upload_excel_url"] = reverse(
"admin:registers_registerupload_upload_excel"
)
return super().changelist_view(request, extra_context=extra_context)
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
serializer = RegisterFileUploadSerializer(data=data)
if not serializer.is_valid():
self.message_user(
request,
f"Ошибка валидации загрузки: {serializer.errors}",
level=messages.ERROR,
)
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:
self.message_user(
request,
f"Ошибка импорта Excel: {exc}",
level=messages.ERROR,
)
return redirect(changelist_url)
self.message_user(
request,
(
"Загрузка завершена: "
f"{result['registry_name']}, строк: {result['rows_in_file']}, "
f"новых организаций: {result['organizations_created']}, "
f"обновлено: {result['organizations_updated']}."
),
level=messages.SUCCESS,
)
return redirect(changelist_url)
context = {
**self.admin_site.each_context(request),
"opts": self.model._meta,
"title": "Загрузка списка организаций из Excel",
"changelist_url": changelist_url,
"registries": Register.objects.only("id", "name").order_by("name"),
}
return TemplateResponse(
request,
"admin/registers/registerupload/upload_excel.html",
context,
)
@admin.register(RegistryMembershipPeriod) @admin.register(RegistryMembershipPeriod)
class RegistryMembershipPeriodAdmin(admin.ModelAdmin): class RegistryMembershipPeriodAdmin(admin.ModelAdmin):

View File

@@ -0,0 +1,8 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
<li>
<a href="{{ upload_excel_url }}" class="addlink">Загрузить Excel ФНС</a>
</li>
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends "admin/base_site.html" %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Главная</a>
&rsaquo; <a href="{{ changelist_url }}">Финансовые отчеты ФНС</a>
&rsaquo; Загрузка Excel
</div>
{% endblock %}
{% block content %}
<div id="content-main">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<fieldset class="module aligned">
<div class="form-row">
<label for="id_files" class="required">Excel файлы:</label>
<input
type="file"
name="files"
id="id_files"
accept=".xlsx"
multiple
required
/>
<p class="help">Ожидаемый формат имени: fin_{id}_{ogrn}.xlsx</p>
</div>
</fieldset>
<div class="submit-row">
<input type="submit" value="Загрузить" class="default" />
<a href="{{ changelist_url }}" class="closelink">Отмена</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
<li>
<a href="{{ upload_excel_url }}" class="addlink">Загрузить Excel организаций</a>
</li>
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "admin/base_site.html" %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Главная</a>
&rsaquo; <a href="{{ changelist_url }}">Загрузки реестров</a>
&rsaquo; Загрузка Excel
</div>
{% endblock %}
{% block content %}
<div id="content-main">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<fieldset class="module aligned">
<div class="form-row">
<label for="id_registry" class="required">Реестр:</label>
<select name="registry" id="id_registry" required>
<option value="">---------</option>
{% for registry in registries %}
<option value="{{ registry.id }}">{{ registry.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-row">
<label for="id_actual_date">Дата актуальности:</label>
<input type="date" name="actual_date" id="id_actual_date" />
<p class="help">Если не указана, будет использована текущая дата.</p>
</div>
<div class="form-row">
<label for="id_file" class="required">Excel файл:</label>
<input type="file" name="file" id="id_file" accept=".xlsx" required />
<p class="help">
Ожидаемые колонки: pn_name, mn_ogrn, mn_inn, mn_okpo. Опционально: in_kpp.
</p>
</div>
</fieldset>
<div class="submit-row">
<input type="submit" value="Загрузить" class="default" />
<a href="{{ changelist_url }}" class="closelink">Отмена</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -244,3 +244,7 @@ class ParsersAdminTest(TestCase):
) )
self.assertEqual(admin.lines_count(report), 1) self.assertEqual(admin.lines_count(report), 1)
self.assertIn("span", str(admin.status_badge(report))) self.assertIn("span", str(admin.status_badge(report)))
self.assertIn("registry_organization", admin.list_display)
self.assertIn("registry_organization__pn_name", admin.search_fields)
route_names = [route.name for route in admin.get_urls()]
self.assertIn("parsers_financialreport_upload_excel", route_names)

View File

@@ -0,0 +1,102 @@
"""Tests for registers admin configuration."""
import io
from apps.registers.admin import RegisterUploadAdmin
from apps.registers.models import Organization, RegisterUpload, RegistryMembershipPeriod
from django.contrib.admin.sites import AdminSite
from django.contrib.messages.storage.fallback import FallbackStorage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import RequestFactory, TestCase
from openpyxl import Workbook
from tests.apps.registers.factories import RegisterFactory
from tests.apps.user.factories import UserFactory
def _build_register_excel_upload(filename: str = "registry.xlsx") -> SimpleUploadedFile:
workbook = Workbook()
worksheet = workbook.active
worksheet.append(["pn_name", "mn_ogrn", "mn_inn", "in_kpp", "mn_okpo"])
worksheet.append(
[
'АО "Тестовая организация"',
1027600980990,
7601000086,
760401001,
"07506197",
]
)
buffer = io.BytesIO()
workbook.save(buffer)
workbook.close()
return SimpleUploadedFile(
filename,
buffer.getvalue(),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
class RegistersAdminTest(TestCase):
def setUp(self):
self.site = AdminSite()
self.factory = RequestFactory()
self.user = UserFactory.create_superuser()
def _post_request(self, data):
request = self.factory.post(
"/admin/registers/registerupload/upload-excel/",
data=data,
)
request.user = self.user
request.session = {}
request._messages = FallbackStorage(request)
return request
def test_register_upload_admin_has_custom_upload_route(self):
admin = RegisterUploadAdmin(RegisterUpload, self.site)
route_names = [route.name for route in admin.get_urls()]
self.assertIn("registers_registerupload_upload_excel", route_names)
def test_register_upload_admin_upload_excel_success(self):
admin = RegisterUploadAdmin(RegisterUpload, self.site)
registry = RegisterFactory()
uploaded_file = _build_register_excel_upload()
request = self._post_request(
{
"registry": str(registry.id),
"actual_date": "2026-03-20",
"file": uploaded_file,
}
)
response = admin.upload_excel_view(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(RegisterUpload.objects.count(), 1)
self.assertEqual(Organization.objects.count(), 1)
self.assertEqual(RegistryMembershipPeriod.objects.count(), 1)
upload = RegisterUpload.objects.first()
self.assertEqual(upload.registry, registry)
self.assertEqual(upload.actual_date.isoformat(), "2026-03-20")
def test_register_upload_admin_upload_excel_invalid_extension(self):
admin = RegisterUploadAdmin(RegisterUpload, self.site)
registry = RegisterFactory()
invalid_file = SimpleUploadedFile("registry.txt", b"text/plain")
request = self._post_request(
{
"registry": str(registry.id),
"actual_date": "2026-03-20",
"file": invalid_file,
}
)
response = admin.upload_excel_view(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(RegisterUpload.objects.count(), 0)