from datetime import date, datetime from decimal import Decimal from typing import Any from fastapi import HTTPException, status from sqlalchemy import Select, func, select from sqlalchemy.orm import Session from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService from app.modules.approvals.service import ApprovalService from app.modules.business.registry import HIGH_RISK_DOMAINS, get_domain_model def serialize_model(record: Any) -> dict[str, Any]: """Convert a SQLAlchemy model instance into a JSON-friendly dictionary.""" data: dict[str, Any] = {} for column in record.__table__.columns: value = getattr(record, column.name) if isinstance(value, (datetime, date)): data[column.name] = value.isoformat() elif isinstance(value, Decimal): data[column.name] = float(value) else: data[column.name] = value return data class BusinessService: """Manage generic CRUD operations across registered business domains.""" def __init__(self, db: Session): self.db = db self.audit = AuditService(db) def list_records( self, domain: str, limit: int = 50, offset: int = 0, status_filter: str | None = None, ) -> tuple[int, list[dict[str, Any]]]: model = get_domain_model(domain) stmt: Select = select(model) count_stmt = select(func.count()).select_from(model) if status_filter and hasattr(model, "status"): stmt = stmt.where(model.status == status_filter) count_stmt = count_stmt.where(model.status == status_filter) stmt = stmt.order_by(model.id.desc()).limit(min(limit, 500)).offset(max(offset, 0)) total = int(self.db.execute(count_stmt).scalar() or 0) return total, [serialize_model(item) for item in self.db.execute(stmt).scalars()] def get_record(self, domain: str, record_id: int) -> dict[str, Any]: model = get_domain_model(domain) record = self.db.get(model, record_id) if record is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Record not found") return serialize_model(record) def create_record( self, domain: str, data: dict[str, Any], actor: str = "api", ) -> dict[str, Any]: model = get_domain_model(domain) allowed = {column.name for column in model.__table__.columns if column.name != "id"} payload = {key: value for key, value in data.items() if key in allowed} record = model(**payload) self.db.add(record) self.db.commit() self.db.refresh(record) result = serialize_model(record) self.audit.log( AuditLogCreate( actor=actor, source="api", action=f"create:{domain}", target_type=domain, target_id=str(record.id), request_payload=data, response_payload=result, ) ) return result def update_record( self, domain: str, record_id: int, data: dict[str, Any], actor: str = "api", approval_ticket_id: str | None = None, ) -> dict[str, Any]: if domain in HIGH_RISK_DOMAINS: if not approval_ticket_id: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="High-risk domain update requires approval_ticket_id", ) if not ApprovalService(self.db).is_approved_for( approval_ticket_id, domain, record_id, f"update:{domain}", ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Approval ticket is not approved for this update", ) model = get_domain_model(domain) record = self.db.get(model, record_id) if record is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Record not found", ) allowed = {column.name for column in model.__table__.columns if column.name != "id"} for key, value in data.items(): if key in allowed: setattr(record, key, value) self.db.commit() self.db.refresh(record) result = serialize_model(record) self.audit.log( AuditLogCreate( actor=actor, source="api", action=f"update:{domain}", target_type=domain, target_id=str(record.id), risk_level="high" if domain in HIGH_RISK_DOMAINS else "low", request_payload={"data": data, "approval_ticket_id": approval_ticket_id}, response_payload=result, ) ) return result