Files
leocrm/app/core/commands.py
T

204 lines
6.8 KiB
Python
Raw Normal View History

"""Command pattern infrastructure for new modules.
This provides a clean, transactional command handler pattern:
HTTP Route → Command Handler → Authorization → Domain Operation → Audit + Outbox → one Commit
Existing services are NOT refactored — they continue to work as-is.
New modules (ERP, etc.) should use this pattern.
Usage:
@dataclass
class CreateInvoiceCommand:
customer_id: uuid.UUID
amount: Decimal
class CreateInvoiceHandler(CommandHandler[CreateInvoiceCommand, Invoice]):
async def handle(self, cmd: CreateInvoiceCommand, ctx: RequestContext, uow: UnitOfWork) -> Invoice:
ctx.require("invoices:create")
invoice = Invoice.create(tenant_id=ctx.tenant_id, owner_id=ctx.user_id, ...)
uow.add(invoice)
uow.outbox.add("crm.invoice.created.v1", invoice.id, "invoice", invoice.to_dict())
uow.audit.record("invoice.created", invoice.id)
return invoice
# In route:
@router.post("/invoices")
async def create_invoice(body: CreateInvoiceDTO, ctx: RequestContext = Depends(get_request_context)):
cmd = CreateInvoiceCommand(customer_id=body.customer_id, amount=body.amount)
handler = CreateInvoiceHandler()
result = await handler.execute(cmd, ctx)
return result
"""
from __future__ import annotations
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Generic, TypeVar
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.outbox import enqueue_outbox_event
TCommand = TypeVar("TCommand")
TResult = TypeVar("TResult")
@dataclass
class RequestContext:
"""Request context with user, tenant, and permission info.
Passed to every command handler. Provides authorization checks.
"""
user_id: uuid.UUID
tenant_id: uuid.UUID
is_system_admin: bool = False
permissions: set[str] = field(default_factory=set)
correlation_id: uuid.UUID = field(default_factory=uuid.uuid4)
def require(self, permission: str) -> None:
"""Require a permission. Raises PermissionError if not granted."""
if self.is_system_admin:
return
if permission not in self.permissions:
raise PermissionError(f"Missing permission: {permission}")
def has(self, permission: str) -> bool:
"""Check if user has a permission."""
if self.is_system_admin:
return True
return permission in self.permissions
class UnitOfWork:
"""Unit of Work — collects changes, audit, and outbox events.
One UoW per business operation. Commit happens once at the end.
"""
def __init__(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
self.db = db
self.tenant_id = tenant_id
self.user_id = user_id
self._audit_entries: list[dict[str, Any]] = []
self._outbox_events: list[dict[str, Any]] = []
def add(self, entity: Any) -> None:
"""Add an entity to the session."""
self.db.add(entity)
def outbox_add(
self,
event_name: str,
aggregate_id: uuid.UUID,
aggregate_type: str,
payload: dict[str, Any],
schema_version: int = 1,
) -> None:
"""Queue an outbox event for commit."""
self._outbox_events.append({
"event_name": event_name,
"aggregate_id": aggregate_id,
"aggregate_type": aggregate_type,
"payload": payload,
"schema_version": schema_version,
})
def audit_record(self, action: str, entity_id: uuid.UUID, entity_type: str = "", changes: dict[str, Any] | None = None) -> None:
"""Queue an audit log entry for commit."""
self._audit_entries.append({
"action": action,
"entity_id": entity_id,
"entity_type": entity_type,
"changes": changes or {},
})
async def commit(self) -> None:
"""Flush, write audit + outbox, then commit."""
# Flush to get entity IDs
await self.db.flush()
# Write outbox events
for evt in self._outbox_events:
await enqueue_outbox_event(
self.db,
self.tenant_id,
evt["event_name"],
evt["payload"],
aggregate_type=evt["aggregate_type"],
aggregate_id=evt["aggregate_id"],
schema_version=evt["schema_version"],
)
# Write audit entries
for entry in self._audit_entries:
await log_audit(
self.db,
self.tenant_id,
self.user_id,
entry["action"],
entry["entity_type"],
entry["entity_id"],
changes=entry["changes"],
)
# Single commit for everything
await self.db.commit()
async def rollback(self) -> None:
"""Rollback the transaction."""
await self.db.rollback()
class CommandHandler(ABC, Generic[TCommand, TResult]):
"""Base class for command handlers.
Subclasses implement `handle()` with the business logic.
The `execute()` method wraps it with UoW creation and error handling.
"""
@abstractmethod
async def handle(self, command: TCommand, ctx: RequestContext, uow: UnitOfWork) -> TResult:
"""Business logic. Use uow.add(), uow.outbox_add(), uow.audit_record()."""
...
async def execute(self, command: TCommand, ctx: RequestContext, db: AsyncSession) -> TResult:
"""Execute the command with a Unit of Work.
Creates a UoW, calls handle(), commits on success, rolls back on error.
"""
uow = UnitOfWork(db, ctx.tenant_id, ctx.user_id)
try:
result = await self.handle(command, ctx, uow)
await uow.commit()
return result
except Exception:
await uow.rollback()
raise
# ── FastAPI Dependency ───────────────────────────────────────────────────────
async def get_request_context(
current_user: dict = None, # Will be injected by FastAPI with require_permission
) -> RequestContext:
"""Build a RequestContext from the current user.
Usage in routes:
ctx: RequestContext = Depends(get_request_context)
"""
if current_user is None:
raise PermissionError("Not authenticated")
return RequestContext(
user_id=uuid.UUID(current_user["user_id"]),
tenant_id=uuid.UUID(current_user["tenant_id"]),
is_system_admin=current_user.get("is_system_admin", False),
permissions=set(current_user.get("permissions", [])),
)