from datetime import date, datetime from decimal import Decimal, InvalidOperation from typing import Any from fastapi import HTTPException, status from sqlalchemy import Date as SQLDate from sqlalchemy import DateTime as SQLDateTime from sqlalchemy import Numeric as SQLNumeric from sqlalchemy import Select, func, select from sqlalchemy.sql.schema import Column from sqlalchemy.orm import Session from app.core.constants import ActorValue from app.modules.audit.constants import AuditRiskLevel, AuditSource 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 def _coerce_column_value(column: Column, value: Any) -> Any: """Coerce API JSON values into the Python type expected by a SQLAlchemy column.""" if value is None: return None if isinstance(column.type, SQLDateTime) and isinstance(value, str): return datetime.fromisoformat(value.replace("Z", "+00:00")) if isinstance(column.type, SQLDate) and isinstance(value, str): return date.fromisoformat(value) if isinstance(column.type, SQLNumeric) and not isinstance(value, Decimal): return Decimal(str(value)) return value def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]: """Filter unknown keys and coerce values according to model column types.""" columns = {column.name: column for column in model.__table__.columns if column.name != "id"} payload: dict[str, Any] = {} for key, value in data.items(): column = columns.get(key) if column is None: continue try: payload[key] = _coerce_column_value(column, value) except (ValueError, TypeError, InvalidOperation) as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Invalid value for field '{key}'", ) from exc return payload 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 = ActorValue.API, ) -> dict[str, Any]: model = get_domain_model(domain) payload = _model_payload(model, data) 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=AuditSource.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 = ActorValue.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", ) for key, value in _model_payload(model, data).items(): setattr(record, key, value) self.db.commit() self.db.refresh(record) result = serialize_model(record) self.audit.log( AuditLogCreate( actor=actor, source=AuditSource.API, action=f"update:{domain}", target_type=domain, target_id=str(record.id), risk_level=( AuditRiskLevel.HIGH if domain in HIGH_RISK_DOMAINS else AuditRiskLevel.LOW ), request_payload={"data": data, "approval_ticket_id": approval_ticket_id}, response_payload=result, ) ) return result