You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
295 lines
12 KiB
295 lines
12 KiB
"""Safe discovery of local source documents for the guided import workflow.""" |
|
|
|
import os |
|
import stat |
|
from dataclasses import dataclass, replace |
|
from hashlib import sha256 |
|
from pathlib import Path |
|
|
|
from django.conf import settings |
|
from django.core.exceptions import ValidationError |
|
|
|
from finance.models import Account |
|
from finance.services import Actor, DomainError |
|
from finance.services.base import identity |
|
|
|
from . import services |
|
from .models import ImportBatch |
|
from .parsers import MAX_BYTES, ParseError, parse_document, partial_context_from_creation_line |
|
|
|
|
|
@dataclass(frozen=True) |
|
class DiscoveredFile: |
|
name: str |
|
document_hash: str |
|
profile: str | None |
|
sections: tuple[str, ...] |
|
code: str | None |
|
|
|
|
|
_PROFILES_BY_SUFFIX = { |
|
".csv": ("BANK_CSV_V1",), |
|
".txt": ("OUROCARD_FULL_V1", "OUROCARD_PARTIAL_V1"), |
|
} |
|
|
|
_SUPPORTS_SAFE_DIRECTORY_FDS = ( |
|
os.name == "posix" |
|
and os.open in os.supports_dir_fd |
|
and os.stat in os.supports_dir_fd |
|
and os.listdir in os.supports_fd |
|
and hasattr(os, "O_DIRECTORY") |
|
and hasattr(os, "O_NOFOLLOW") |
|
) |
|
|
|
|
|
def _profile_and_sections(path: Path, data: bytes) -> tuple[str | None, tuple[str, ...]]: |
|
for profile in _PROFILES_BY_SUFFIX.get(path.suffix.casefold(), ()): |
|
try: |
|
result = parse_document(data, profile) |
|
except ParseError: |
|
continue |
|
items = tuple(item for item in result.items if not item.errors) |
|
if items: |
|
return profile, tuple(dict.fromkeys(item.section for item in items)) |
|
return None, () |
|
|
|
|
|
def _unreadable(name: str) -> DiscoveredFile: |
|
return DiscoveredFile(name, "", None, (), "UNREADABLE_FILE") |
|
|
|
|
|
def _close(descriptor: int | None) -> None: |
|
if descriptor is not None: |
|
try: |
|
os.close(descriptor) |
|
except OSError: |
|
pass |
|
|
|
|
|
def _open_import_root() -> int | None: |
|
if not _SUPPORTS_SAFE_DIRECTORY_FDS: |
|
return None |
|
try: |
|
root = os.path.normpath(os.fspath(settings.IMPORT_ROOT)) |
|
if not os.path.isabs(root): |
|
root = os.path.abspath(root) |
|
path = Path(root) |
|
descriptor = os.open(path.anchor, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) |
|
for component in path.parts[1:]: |
|
next_descriptor = os.open( |
|
component, |
|
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, |
|
dir_fd=descriptor, |
|
) |
|
_close(descriptor) |
|
descriptor = next_descriptor |
|
if not stat.S_ISDIR(os.fstat(descriptor).st_mode): |
|
_close(descriptor) |
|
return None |
|
return descriptor |
|
except (OSError, RuntimeError, TypeError, ValueError): |
|
_close(locals().get("descriptor")) |
|
return None |
|
|
|
|
|
def _read_file_bytes(name: str, root_descriptor: int, expected: os.stat_result) -> bytes: |
|
descriptor = None |
|
try: |
|
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NONBLOCK", 0) |
|
if no_follow := getattr(os, "O_NOFOLLOW", 0): |
|
flags |= no_follow |
|
descriptor = os.open(name, flags, dir_fd=root_descriptor) |
|
opened = os.fstat(descriptor) |
|
if not stat.S_ISREG(opened.st_mode) or not os.path.samestat(expected, opened): |
|
raise ParseError("UNREADABLE_FILE") |
|
with os.fdopen(descriptor, "rb", closefd=False) as stream: |
|
data = stream.read(MAX_BYTES + 1) |
|
except OSError: |
|
raise ParseError("UNREADABLE_FILE") from None |
|
finally: |
|
_close(descriptor) |
|
if len(data) > MAX_BYTES: |
|
raise ParseError("INPUT_TOO_LARGE") |
|
return data |
|
|
|
|
|
def _read_discovered_file(name: str, root_descriptor: int, expected: os.stat_result) -> DiscoveredFile: |
|
try: |
|
data = _read_file_bytes(name, root_descriptor, expected) |
|
except ParseError as error: |
|
return DiscoveredFile(name, "", None, (), error.code) |
|
document_hash = sha256(data).hexdigest() |
|
if Path(name).suffix.casefold() not in _PROFILES_BY_SUFFIX: |
|
return DiscoveredFile(name, document_hash, None, (), "UNSUPPORTED_FILE") |
|
profile, sections = _profile_and_sections(Path(name), data) |
|
if profile is None: |
|
return DiscoveredFile(name, document_hash, None, (), "UNRECOGNIZED_LAYOUT") |
|
return DiscoveredFile(name, document_hash, profile, sections, None) |
|
|
|
|
|
def discover_imports() -> tuple[DiscoveredFile, ...]: |
|
"""Return safe metadata for direct regular files under ``IMPORT_ROOT``.""" |
|
root_descriptor = _open_import_root() |
|
if root_descriptor is None: |
|
return () |
|
try: |
|
try: |
|
entries = os.listdir(root_descriptor) |
|
except OSError: |
|
return () |
|
discovered = [] |
|
for name in entries: |
|
try: |
|
expected = os.stat(name, dir_fd=root_descriptor, follow_symlinks=False) |
|
except OSError: |
|
discovered.append(_unreadable(name)) |
|
continue |
|
if stat.S_ISREG(expected.st_mode): |
|
discovered.append(_read_discovered_file(name, root_descriptor, expected)) |
|
return tuple(sorted(discovered, key=lambda row: row.name)) |
|
finally: |
|
_close(root_descriptor) |
|
|
|
|
|
@dataclass(frozen=True) |
|
class PreparedImport: |
|
name: str |
|
document_hash: str |
|
profile: str | None |
|
sections: tuple[str, ...] |
|
state: str |
|
batch_id: str | None |
|
revision: int | None |
|
counts: dict[str, int] |
|
code: str | None |
|
|
|
|
|
_SAFE_CODES = frozenset({ |
|
"UNREADABLE_FILE", "INPUT_TOO_LARGE", "UNSUPPORTED_FILE", "UNRECOGNIZED_LAYOUT", |
|
"FILE_CHANGED", "INVALID_SECTION_MAPPING", "MISSING_CREATION_DATE", "INVALID_CREATION_DATE", |
|
"STALE_REVISION", "STALE_PREVIEW", "BATCH_NOT_FOUND", "BATCH_NOT_PREVIEW", |
|
"REVIEW_REQUIRED", "INVALID_PREPARED_IMPORT", |
|
}) |
|
|
|
|
|
def _verified_bytes(discovered: DiscoveredFile | PreparedImport) -> bytes: |
|
name = discovered.name |
|
if (not isinstance(name, str) or not name or name in {".", ".."} |
|
or any(char in name for char in ("/", "\\", "\x00", ":"))): |
|
raise ParseError("UNREADABLE_FILE") |
|
root_descriptor = _open_import_root() |
|
if root_descriptor is None: |
|
raise ParseError("UNREADABLE_FILE") |
|
try: |
|
expected = os.stat(name, dir_fd=root_descriptor, follow_symlinks=False) |
|
if not stat.S_ISREG(expected.st_mode): |
|
raise ParseError("UNREADABLE_FILE") |
|
data = _read_file_bytes(name, root_descriptor, expected) |
|
except OSError: |
|
raise ParseError("UNREADABLE_FILE") from None |
|
finally: |
|
_close(root_descriptor) |
|
if sha256(data).hexdigest() != discovered.document_hash: |
|
raise ParseError("FILE_CHANGED") |
|
profile, sections = _profile_and_sections(Path(name), data) |
|
if profile != discovered.profile or sections != discovered.sections or profile is None: |
|
raise ParseError("FILE_CHANGED") |
|
return data |
|
|
|
|
|
def _validated_mapping(discovered, section_mapping): |
|
if (not isinstance(section_mapping, dict) or set(section_mapping) != set(discovered.sections) |
|
or any(not isinstance(value, str) for value in section_mapping.values())): |
|
raise DomainError("INVALID_SECTION_MAPPING") |
|
try: |
|
mapping = {key: str(identity(value)) for key, value in section_mapping.items()} |
|
except DomainError: |
|
raise DomainError("INVALID_SECTION_MAPPING") from None |
|
active = set(Account.objects.filter(pk__in=mapping.values(), archived=False, currency="BRL") |
|
.values_list("pk", flat=True)) |
|
if {str(pk) for pk in active} != set(mapping.values()): |
|
raise DomainError("INVALID_SECTION_MAPPING") |
|
return mapping |
|
|
|
|
|
def _cancel_unfinished(batch_id, actor): |
|
# Cancellation must use the latest revision after a stale-review failure. |
|
for _ in range(3): |
|
batch = ImportBatch.objects.filter(pk=batch_id, state="PREVIEW").first() |
|
if batch is None: |
|
return |
|
try: |
|
services.cancel_preview(batch_id=batch.pk, revision=batch.revision, actor=actor) |
|
return |
|
except DomainError as error: |
|
if error.code != "STALE_REVISION": |
|
raise |
|
raise DomainError("CANCELLATION_FAILED") |
|
|
|
|
|
def _attention(prepared, error, actor, *, cancel=True): |
|
code = getattr(error, "code", None) |
|
code = code if isinstance(code, str) and code in _SAFE_CODES else "IMPORT_FAILED" |
|
if cancel and prepared.batch_id: |
|
try: |
|
_cancel_unfinished(prepared.batch_id, actor) |
|
except DomainError: |
|
code = "CANCELLATION_FAILED" |
|
return replace(prepared, state="NEEDS_ATTENTION", counts={"created": 0, "matched": 0, "skipped": 0}, code=code) |
|
|
|
|
|
def prepare_import(*, discovered: DiscoveredFile, section_mapping: dict[str, str], actor: Actor) -> PreparedImport: |
|
"""Prepare complete deterministic decisions through the audited lifecycle.""" |
|
prepared = PreparedImport(discovered.name, discovered.document_hash, discovered.profile, |
|
discovered.sections, "NEEDS_ATTENTION", None, None, |
|
{"created": 0, "matched": 0, "skipped": 0}, None) |
|
try: |
|
if discovered.code: |
|
raise ParseError(discovered.code) |
|
data = _verified_bytes(discovered) |
|
mapping = _validated_mapping(discovered, section_mapping) |
|
context = partial_context_from_creation_line(data) if discovered.profile == "OUROCARD_PARTIAL_V1" else {} |
|
batch = services.create_preview(data=data, profile=discovered.profile, |
|
section_mapping=mapping, context=context, actor=actor) |
|
prepared = replace(prepared, batch_id=str(batch.pk), revision=batch.revision) |
|
decisions = [] |
|
counts = {"created": 0, "matched": 0, "skipped": 0} |
|
for item in batch.items.order_by("locator"): |
|
if item.issues == ["ALREADY_IMPORTED"]: |
|
decisions.append({"locator": item.locator, "action": "SKIP", "reason": "ALREADY_IMPORTED"}) |
|
counts["skipped"] += 1 |
|
elif not item.issues and not item.duplicates["existing"] and not item.duplicates["within"]: |
|
decisions.append({"locator": item.locator, "action": "CREATE"}) |
|
counts["created"] += 1 |
|
else: |
|
raise DomainError("REVIEW_REQUIRED") |
|
if not decisions: |
|
raise DomainError("REVIEW_REQUIRED") |
|
batch = services.apply_decisions(batch_id=batch.pk, revision=batch.revision, |
|
decisions=decisions, actor=actor) |
|
return replace(prepared, state="READY" if counts["created"] else "ALREADY_IMPORTED", |
|
revision=batch.revision, counts=counts) |
|
except (DomainError, ParseError, ValidationError) as error: |
|
return _attention(prepared, error, actor) |
|
|
|
|
|
def commit_prepared_import(*, prepared: PreparedImport, actor: Actor) -> PreparedImport: |
|
"""Revalidate source identity and delegate the atomic commit to persistence.""" |
|
try: |
|
batch_id = identity(prepared.batch_id) |
|
except DomainError: |
|
return _attention(prepared, DomainError("INVALID_PREPARED_IMPORT"), actor, cancel=False) |
|
try: |
|
batch = ImportBatch.objects.filter(pk=batch_id).first() |
|
if batch is None: |
|
raise DomainError("BATCH_NOT_FOUND") |
|
if batch.document_hash != prepared.document_hash or batch.profile != prepared.profile: |
|
return _attention(prepared, DomainError("INVALID_PREPARED_IMPORT"), actor, cancel=False) |
|
if prepared.state not in {"READY", "ALREADY_IMPORTED", "COMMITTED"} or type(prepared.revision) is not int: |
|
raise DomainError("INVALID_PREPARED_IMPORT") |
|
_verified_bytes(prepared) |
|
batch = services.commit_preview(batch_id=batch.pk, revision=prepared.revision, actor=actor) |
|
return replace(prepared, state="COMMITTED", revision=batch.revision, |
|
counts={key: batch.result[key] for key in ("created", "matched", "skipped")}, code=None) |
|
except (DomainError, ParseError, ValidationError) as error: |
|
return _attention(prepared, error, actor)
|
|
|