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.core.pagination import bounded_limit, bounded_offset 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.constants import ApprovalActionValue, approval_action from app.modules.approvals.service import ApprovalService from app.modules.business.registry import get_domain_model, is_high_risk_domain from app.modules.business.constants import ( INVALID_FIELD_VALUE_TEMPLATE, UNKNOWN_FIELD_TEMPLATE, BusinessErrorDetail, BusinessField, BusinessPayloadKey, ) 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]: """Validate keys and coerce values according to model column types.""" columns = { column.name: column for column in model.__table__.columns if column.name != BusinessField.ID } payload: dict[str, Any] = {} for key, value in data.items(): column = columns.get(key) if column is None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=UNKNOWN_FIELD_TEMPLATE.format(field=key), ) try: payload[key] = _coerce_column_value(column, value) except (ValueError, TypeError, InvalidOperation) as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=INVALID_FIELD_VALUE_TEMPLATE.format(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, BusinessField.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(bounded_limit(limit)).offset( bounded_offset(offset) ) 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=BusinessErrorDetail.RECORD_NOT_FOUND, ) return serialize_model(record) def create_record( self, domain: str, data: dict[str, Any], actor: str = ActorValue.API, approval_ticket_id: str | None = None, ) -> dict[str, Any]: model = get_domain_model(domain) high_risk = is_high_risk_domain(domain) payload = _model_payload(model, data) record = model(**payload) self.db.add(record) if high_risk: self.db.flush() self._consume_approval( approval_ticket_id, domain, record.id, approval_action(ApprovalActionValue.CREATE, domain), data, actor, ) self.db.commit() self.db.refresh(record) result = serialize_model(record) self.audit.log( AuditLogCreate( actor=actor, source=AuditSource.API, action=approval_action(ApprovalActionValue.CREATE, domain), target_type=domain, target_id=str(record.id), risk_level=AuditRiskLevel.HIGH if high_risk else AuditRiskLevel.LOW, request_payload={ BusinessPayloadKey.DATA: data, BusinessPayloadKey.APPROVAL_TICKET_ID: approval_ticket_id, }, 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]: model = get_domain_model(domain) high_risk = is_high_risk_domain(domain) record = self.db.get(model, record_id) if record is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=BusinessErrorDetail.RECORD_NOT_FOUND, ) payload = _model_payload(model, data) if high_risk: self._consume_approval( approval_ticket_id, domain, record_id, approval_action(ApprovalActionValue.UPDATE, domain), data, actor, ) for key, value in payload.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=approval_action(ApprovalActionValue.UPDATE, domain), target_type=domain, target_id=str(record.id), risk_level=AuditRiskLevel.HIGH if high_risk else AuditRiskLevel.LOW, request_payload={ BusinessPayloadKey.DATA: data, BusinessPayloadKey.APPROVAL_TICKET_ID: approval_ticket_id, }, response_payload=result, ) ) return result def _consume_approval( self, approval_ticket_id: str | None, domain: str, record_id: str | int | None, action: str, payload: dict[str, Any], actor: str, ) -> None: if not approval_ticket_id: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=BusinessErrorDetail.HIGH_RISK_APPROVAL_REQUIRED, ) ApprovalService(self.db).consume_for( approval_ticket_id, domain, record_id, action, payload, actor, )