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

"""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