feat(admin): improve uploads and dashboard UX
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user