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.
89 lines
3.2 KiB
89 lines
3.2 KiB
"""Public writes own a transaction; internal helpers never open public operations.""" |
|
from dataclasses import dataclass |
|
from decimal import Decimal, InvalidOperation |
|
from functools import wraps |
|
from uuid import UUID |
|
|
|
from django.core.exceptions import ValidationError |
|
from django.db import IntegrityError, connection, transaction |
|
|
|
from finance.models import AuditEvent |
|
|
|
|
|
@dataclass(frozen=True) |
|
class Actor: |
|
kind: str |
|
identifier: str |
|
|
|
|
|
class DomainError(ValueError): |
|
def __init__(self, code): |
|
self.code = code |
|
super().__init__(code) |
|
|
|
|
|
def money(value, *, allow_zero=False): |
|
if not isinstance(value, (Decimal, str, int)) or isinstance(value, bool): |
|
raise DomainError("invalid_amount") |
|
try: |
|
result = Decimal(value) |
|
except (InvalidOperation, ValueError): |
|
raise DomainError("invalid_amount") from None |
|
if not result.is_finite() or result.as_tuple().exponent < -2 or result.adjusted() >= 16: |
|
raise DomainError("invalid_amount") |
|
if not allow_zero and result == 0: |
|
raise DomainError("zero_amount") |
|
return result |
|
|
|
|
|
def identity(value): |
|
try: |
|
return UUID(str(value)) |
|
except (ValueError, TypeError, AttributeError): |
|
raise DomainError("invalid_identifier") from None |
|
|
|
|
|
def lock(model, identifier, *, active=False): |
|
try: |
|
obj = model.objects.select_for_update().get(pk=identity(identifier)) |
|
except model.DoesNotExist: |
|
raise DomainError("not_found") from None |
|
if active and obj.archived: |
|
raise DomainError("archived_reference") |
|
return obj |
|
|
|
|
|
def save(obj): |
|
obj.full_clean() |
|
obj.save() |
|
return obj |
|
|
|
|
|
def audit(actor, operation, outcome, target_id=None, fields=(), error_code=""): |
|
AuditEvent.objects.create(actor_kind=actor.kind, actor_identifier=actor.identifier, |
|
operation=operation, target_id=target_id, outcome=outcome, |
|
changed_fields=list(fields), error_code=error_code) |
|
|
|
|
|
def write(operation): |
|
def decorate(function): |
|
@wraps(function) |
|
def wrapped(*args, actor, **kwargs): |
|
if not isinstance(actor, Actor) or actor.kind not in {"HUMAN", "APPLICATION", "AGENT"} or not isinstance(actor.identifier, str) or not actor.identifier.strip() or len(actor.identifier) > 200: |
|
raise DomainError("invalid_actor") |
|
if connection.in_atomic_block: |
|
raise DomainError("transaction_boundary") |
|
try: |
|
with transaction.atomic(durable=True): |
|
result, fields = function(*args, **kwargs) |
|
audit(actor, operation, "SUCCESS", result.pk, fields) |
|
return result |
|
except (DomainError, ValidationError, IntegrityError) as exc: |
|
code = exc.code if isinstance(exc, DomainError) else ("conflict" if isinstance(exc, IntegrityError) else "validation_error") |
|
# The failed mutation transaction has exited before this audit. |
|
# Audit persistence errors propagate: do not conceal a database outage. |
|
with transaction.atomic(durable=True): |
|
audit(actor, operation, "FAILURE", error_code=code) |
|
raise DomainError(code) from None |
|
return wrapped |
|
return decorate
|
|
|