feat(admin): improve uploads and dashboard UX

This commit is contained in:
2026-03-23 16:07:11 +01:00
parent 45bca018b5
commit ef9763692d
22 changed files with 2531 additions and 212 deletions

View File

@@ -30,6 +30,16 @@ class FNSUploadResult:
task_ids: list[str] = field(default_factory=list)
@dataclass
class FNSSyncUploadResult:
"""Result of synchronous FNS file processing."""
processed: int = 0
skipped: int = 0
invalid: int = 0
failed: int = 0
class FNSUploadService:
"""Queue uploaded FNS Excel files and ZIP archives for processing."""
@@ -87,6 +97,60 @@ class FNSUploadService:
return result
@classmethod
def process_uploaded_files_sync(
cls, *, files, requested_by_id: int | None
) -> FNSSyncUploadResult:
result = FNSSyncUploadResult()
seen_hashes: set[str] = set()
for uploaded_file in files:
status = cls._process_file_bytes_sync(
file_name=uploaded_file.name,
file_content=uploaded_file.read(),
requested_by_id=requested_by_id,
seen_hashes=seen_hashes,
)
cls._accumulate_sync(result=result, status=status)
return result
@classmethod
def process_zip_archive_sync(
cls,
*,
archive_file,
requested_by_id: int | None,
) -> FNSSyncUploadResult:
result = FNSSyncUploadResult()
seen_hashes: set[str] = set()
archive_file.seek(0)
try:
with zipfile.ZipFile(archive_file) as archive:
for member in archive.infolist():
if member.is_dir():
continue
file_name = cls._extract_member_name(member.filename)
if not file_name or not FNS_XLSX_FILENAME_RE.match(file_name):
result.invalid += 1
continue
status = cls._process_file_bytes_sync(
file_name=file_name,
file_content=archive.read(member),
requested_by_id=requested_by_id,
seen_hashes=seen_hashes,
)
cls._accumulate_sync(result=result, status=status)
except zipfile.BadZipFile as exc:
raise ValueError(
"Загруженный файл не является корректным ZIP архивом"
) from exc
return result
@staticmethod
def _extract_member_name(member_name: str) -> str | None:
path = PurePosixPath(member_name)
@@ -106,27 +170,15 @@ class FNSUploadService:
requested_by_id: int | None,
seen_hashes: set[str],
) -> tuple[str, str | None]:
file_hash = hashlib.sha256(file_content).hexdigest()
if file_hash in seen_hashes or FNSReportService.exists_by_hash(file_hash):
status, file_path, file_hash = cls._prepare_file_bytes(
file_name=file_name,
file_content=file_content,
seen_hashes=seen_hashes,
)
if status == "skipped":
return "skipped", None
upload_dir = Path(settings.FNS_WATCH_DIRECTORY)
upload_dir.mkdir(parents=True, exist_ok=True)
file_path = upload_dir / file_name
if not cls._try_create_fns_lock(file_path):
return "skipped", None
lock_path = Path(f"{file_path}.lock")
if file_path.exists():
lock_path.unlink(missing_ok=True)
return "skipped", None
try:
file_path.write_bytes(file_content)
except Exception:
lock_path.unlink(missing_ok=True)
raise
if file_path is None or file_hash is None: # pragma: no cover
raise RuntimeError("Prepared FNS file is missing processing metadata")
task_id = str(uuid.uuid4())
try:
@@ -145,13 +197,83 @@ class FNSUploadService:
task_id=task_id,
)
except Exception:
lock_path.unlink(missing_ok=True)
Path(f"{file_path}.lock").unlink(missing_ok=True)
BackgroundJob.objects.filter(task_id=task_id).delete()
raise
seen_hashes.add(file_hash)
return "queued", task.id
@classmethod
def _process_file_bytes_sync(
cls,
*,
file_name: str,
file_content: bytes,
requested_by_id: int | None,
seen_hashes: set[str],
) -> str:
from apps.parsers.tasks import _process_fns_file_sync
status, file_path, file_hash = cls._prepare_file_bytes(
file_name=file_name,
file_content=file_content,
seen_hashes=seen_hashes,
)
if status == "skipped":
return "skipped"
if file_path is None or file_hash is None: # pragma: no cover
raise RuntimeError("Prepared FNS file is missing processing metadata")
result = _process_fns_file_sync(
str(file_path),
task_id=str(uuid.uuid4()),
requested_by_id=requested_by_id,
raise_on_error=False,
)
result_status = result.get("status")
if result_status == "success":
if file_hash is not None:
seen_hashes.add(file_hash)
return "processed"
if result_status == "skipped":
if file_hash is not None:
seen_hashes.add(file_hash)
return "skipped"
return "failed"
@classmethod
def _prepare_file_bytes(
cls,
*,
file_name: str,
file_content: bytes,
seen_hashes: set[str],
) -> tuple[str, Path | None, str | None]:
file_hash = hashlib.sha256(file_content).hexdigest()
if file_hash in seen_hashes or FNSReportService.exists_by_hash(file_hash):
return "skipped", None, None
upload_dir = Path(settings.FNS_WATCH_DIRECTORY)
upload_dir.mkdir(parents=True, exist_ok=True)
file_path = upload_dir / file_name
if not cls._try_create_fns_lock(file_path):
return "skipped", None, None
lock_path = Path(f"{file_path}.lock")
if file_path.exists():
lock_path.unlink(missing_ok=True)
return "skipped", None, None
try:
file_path.write_bytes(file_content)
except Exception:
lock_path.unlink(missing_ok=True)
raise
return "prepared", file_path, file_hash
@staticmethod
def _try_create_fns_lock(file_path: Path) -> bool:
lock_path = Path(f"{file_path}.lock")
@@ -182,3 +304,14 @@ class FNSUploadService:
return
if status == "skipped":
result.skipped += 1
@staticmethod
def _accumulate_sync(*, result: FNSSyncUploadResult, status: str) -> None:
if status == "processed":
result.processed += 1
return
if status == "skipped":
result.skipped += 1
return
if status == "failed":
result.failed += 1