feat(parsers): support FNS zip uploads in admin
Some checks failed
CI/CD Pipeline / Run Tests (push) Successful in 3m0s
CI/CD Pipeline / Code Quality Checks (push) Failing after 7m39s
CI/CD Pipeline / Telegram Notify Success (push) Has been skipped

This commit is contained in:
2026-03-20 13:43:11 +01:00
parent e470189f44
commit b8015d9cdd
8 changed files with 459 additions and 198 deletions

View File

@@ -2,13 +2,7 @@
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.fns_upload import FNSUploadService
from apps.parsers.models import (
FinancialReport,
FinancialReportLine,
@@ -20,10 +14,7 @@ from apps.parsers.models import (
ProcurementRecord,
Proxy,
)
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 apps.parsers.serializers import FNSFileUploadSerializer, FNSZipUploadSerializer
from django.contrib import admin, messages
from django.shortcuts import redirect
from django.template.response import TemplateResponse
@@ -742,6 +733,11 @@ class FinancialReportAdmin(admin.ModelAdmin):
self.admin_site.admin_view(self.upload_excel_view),
name="parsers_financialreport_upload_excel",
),
path(
"upload-zip/",
self.admin_site.admin_view(self.upload_zip_view),
name="parsers_financialreport_upload_zip",
),
]
return custom_urls + urls
@@ -750,6 +746,9 @@ class FinancialReportAdmin(admin.ModelAdmin):
extra_context["upload_excel_url"] = reverse(
"admin:parsers_financialreport_upload_excel"
)
extra_context["upload_zip_url"] = reverse(
"admin:parsers_financialreport_upload_zip"
)
return super().changelist_view(request, extra_context=extra_context)
def upload_excel_view(self, request):
@@ -768,9 +767,9 @@ class FinancialReportAdmin(admin.ModelAdmin):
return redirect(changelist_url)
try:
queued, skipped, task_ids = self._enqueue_fns_files(
request,
serializer.validated_data["files"],
result = FNSUploadService.queue_uploaded_files(
files=serializer.validated_data["files"],
requested_by_id=request.user.id,
)
except Exception as exc: # noqa: BLE001
self.message_user(
@@ -780,19 +779,21 @@ class FinancialReportAdmin(admin.ModelAdmin):
)
return redirect(changelist_url)
if queued:
if result.queued:
self.message_user(
request,
f"Файлов поставлено в очередь: {queued}. Task IDs: {', '.join(task_ids[:5])}",
"Файлов поставлено в очередь: "
f"{result.queued}. Task IDs: {', '.join(result.task_ids[:5])}",
level=messages.SUCCESS,
)
if skipped:
if result.skipped:
self.message_user(
request,
f"Пропущено файлов: {skipped} (дубликаты или уже обрабатываются).",
"Пропущено файлов: "
f"{result.skipped} (дубликаты или уже обрабатываются).",
level=messages.WARNING,
)
if not queued and not skipped:
if not result.queued and not result.skipped:
self.message_user(
request,
"Файлы не были обработаны.",
@@ -813,86 +814,74 @@ class FinancialReportAdmin(admin.ModelAdmin):
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 upload_zip_view(self, request):
changelist_url = reverse("admin:parsers_financialreport_changelist")
def _enqueue_fns_files(self, request, files):
upload_dir = Path(settings.FNS_WATCH_DIRECTORY)
upload_dir.mkdir(parents=True, exist_ok=True)
if request.method == "POST":
serializer = FNSZipUploadSerializer(
data={"file": request.FILES.get("file")}
)
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,
},
if not serializer.is_valid():
self.message_user(
request,
f"Ошибка валидации архива: {serializer.errors}",
level=messages.ERROR,
)
task = process_fns_file.apply_async(
args=[str(file_path)],
kwargs={"requested_by_id": request.user.id},
task_id=task_id,
return redirect(changelist_url)
try:
result = FNSUploadService.queue_zip_archive(
archive_file=serializer.validated_data["file"],
requested_by_id=request.user.id,
)
except Exception:
lock_path.unlink(missing_ok=True)
BackgroundJob.objects.filter(task_id=task_id).delete()
raise
except Exception as exc: # noqa: BLE001
self.message_user(
request,
f"Ошибка обработки ZIP архива: {exc}",
level=messages.ERROR,
)
return redirect(changelist_url)
task_ids.append(task.id)
queued += 1
if result.queued:
self.message_user(
request,
"Файлов из архива поставлено в очередь: "
f"{result.queued}. Task IDs: {', '.join(result.task_ids[:5])}",
level=messages.SUCCESS,
)
if result.skipped:
self.message_user(
request,
f"Пропущено файлов из архива: {result.skipped}.",
level=messages.WARNING,
)
if result.invalid:
self.message_user(
request,
f"Невалидных элементов в архиве: {result.invalid}.",
level=messages.WARNING,
)
if not result.queued and not result.skipped and not result.invalid:
self.message_user(
request,
"Архив не содержит подходящих файлов.",
level=messages.WARNING,
)
return queued, skipped, task_ids
return redirect(changelist_url)
context = {
**self.admin_site.each_context(request),
"opts": self.model._meta,
"title": "Загрузка ZIP отчетности ФНС",
"changelist_url": changelist_url,
}
return TemplateResponse(
request,
"admin/parsers/financialreport/upload_zip.html",
context,
)
def has_add_permission(self, request):
"""Запретить создание записей вручную."""