"""Workflow service — CRUD, instance lifecycle, step transitions, event triggers.""" from __future__ import annotations import uuid from datetime import datetime, timezone, timedelta from typing import Any from sqlalchemy import select, func, desc, and_, or_ from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory from app.models.notification import Notification def _safe_iso(dt) -> str | None: """Safely convert datetime to ISO string, handling unloaded attributes.""" if dt is None: return None try: return dt.isoformat() if hasattr(dt, "isoformat") else None except Exception: return None def _get_attr(obj, name, default=None): """Safely get an attribute that might be expired/unloaded in async context.""" try: val = getattr(obj, name) return val if val is not None else default except Exception: return default def _workflow_to_dict(w: Workflow) -> dict[str, Any]: return { "id": str(w.id), "name": w.name, "description": w.description, "trigger_event": w.trigger_event, "steps": w.steps, "is_active": w.is_active, "created_by": str(w.created_by) if w.created_by else None, "created_at": _safe_iso(_get_attr(w, "created_at")), "updated_at": _safe_iso(_get_attr(w, "updated_at")), } def _instance_to_dict(i: WorkflowInstance, include_history: bool = False, history: list | None = None, workflow_name: str | None = None) -> dict[str, Any]: data = { "id": str(i.id), "workflow_id": str(i.workflow_id), "status": i.status, "current_step_index": i.current_step_index, "context": i.context, "initiated_by": str(i.initiated_by) if i.initiated_by else None, "completed_at": _safe_iso(_get_attr(i, "completed_at")), "timeout_hours": i.timeout_hours, "timeout_at": _safe_iso(_get_attr(i, "timeout_at")), "created_at": _safe_iso(_get_attr(i, "created_at")), "updated_at": _safe_iso(_get_attr(i, "updated_at")), } if include_history: data["history"] = history or [] data["workflow_name"] = workflow_name return data def _history_to_dict(h: WorkflowStepHistory) -> dict[str, Any]: return { "id": str(h.id), "instance_id": str(h.instance_id), "step_index": h.step_index, "step_type": h.step_type, "action": h.action, "actor_id": str(h.actor_id) if h.actor_id else None, "details": h.details, "created_at": _safe_iso(_get_attr(h, "created_at")), } async def _log_step_history( db: AsyncSession, tenant_id: uuid.UUID, instance_id: uuid.UUID, step_index: int, step_type: str, action: str, actor_id: uuid.UUID | None = None, details: dict[str, Any] | None = None, ) -> WorkflowStepHistory: """Create a workflow step history entry.""" entry = WorkflowStepHistory( tenant_id=tenant_id, instance_id=instance_id, step_index=step_index, step_type=step_type, action=action, actor_id=actor_id, details=details, ) db.add(entry) await db.flush() return entry # ─── Workflow CRUD ─── async def create_workflow( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict[str, Any], ) -> dict[str, Any]: """Create a new workflow definition.""" steps_raw = data.get("steps", []) # Validate steps if not steps_raw: raise ValueError("Workflow must have at least one step") steps_json = [s if isinstance(s, dict) else s.model_dump() for s in steps_raw] workflow = Workflow( tenant_id=tenant_id, name=data["name"], description=data.get("description"), trigger_event=data.get("trigger_event"), steps=steps_json, is_active=data.get("is_active", True), created_by=user_id, ) db.add(workflow) await db.flush() await log_audit( db, tenant_id, user_id, action="create", entity_type="workflow", entity_id=workflow.id, changes={"name": workflow.name}, ) return _workflow_to_dict(workflow) async def list_workflows( db: AsyncSession, tenant_id: uuid.UUID, page: int = 1, page_size: int = 20, is_active: bool | None = None, ) -> dict[str, Any]: """List workflows with pagination.""" page = max(1, page) page_size = max(1, min(100, page_size)) base = select(Workflow).where(Workflow.tenant_id == tenant_id) if is_active is not None: base = base.where(Workflow.is_active == is_active) count_q = select(func.count()).select_from(base.subquery()) total_result = await db.execute(count_q) total = total_result.scalar_one() offset = (page - 1) * page_size paginated = base.order_by(desc(Workflow.created_at)).offset(offset).limit(page_size) result = await db.execute(paginated) workflows = result.scalars().all() return { "items": [_workflow_to_dict(w) for w in workflows], "total": total, "page": page, "page_size": page_size, } async def get_workflow( db: AsyncSession, tenant_id: uuid.UUID, workflow_id: str, ) -> dict[str, Any] | None: """Get a single workflow by ID.""" wf_uuid = uuid.UUID(workflow_id) result = await db.execute( select(Workflow).where( Workflow.id == wf_uuid, Workflow.tenant_id == tenant_id, ) ) workflow = result.scalar_one_or_none() if workflow is None: return None return _workflow_to_dict(workflow) async def update_workflow( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, workflow_id: str, data: dict[str, Any], ) -> dict[str, Any] | None: """Update a workflow definition.""" wf_uuid = uuid.UUID(workflow_id) result = await db.execute( select(Workflow).where( Workflow.id == wf_uuid, Workflow.tenant_id == tenant_id, ) ) workflow = result.scalar_one_or_none() if workflow is None: return None if "name" in data: workflow.name = data["name"] if "description" in data: workflow.description = data["description"] if "trigger_event" in data: workflow.trigger_event = data["trigger_event"] if "steps" in data: steps_raw = data["steps"] workflow.steps = [s if isinstance(s, dict) else s.model_dump() for s in steps_raw] if "is_active" in data: workflow.is_active = data["is_active"] await db.flush() await log_audit( db, tenant_id, user_id, action="update", entity_type="workflow", entity_id=workflow.id, changes={"updated_fields": list(data.keys())}, ) return _workflow_to_dict(workflow) async def delete_workflow( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, workflow_id: str, ) -> bool: """Delete a workflow definition.""" wf_uuid = uuid.UUID(workflow_id) result = await db.execute( select(Workflow).where( Workflow.id == wf_uuid, Workflow.tenant_id == tenant_id, ) ) workflow = result.scalar_one_or_none() if workflow is None: return False await db.delete(workflow) await db.flush() await log_audit( db, tenant_id, user_id, action="delete", entity_type="workflow", entity_id=wf_uuid, ) return True # ─── Instance Lifecycle ─── async def create_instance( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, workflow_id: str, context: dict[str, Any] | None = None, timeout_hours: int | None = None, ) -> dict[str, Any] | None: """Create a new workflow instance with status=pending.""" wf_uuid = uuid.UUID(workflow_id) result = await db.execute( select(Workflow).where( Workflow.id == wf_uuid, Workflow.tenant_id == tenant_id, ) ) workflow = result.scalar_one_or_none() if workflow is None: return None timeout_at = None if timeout_hours: timeout_at = datetime.now(timezone.utc) + timedelta(hours=timeout_hours) instance = WorkflowInstance( tenant_id=tenant_id, workflow_id=wf_uuid, status="pending", current_step_index=0, context=context or {}, initiated_by=user_id, timeout_hours=timeout_hours, timeout_at=timeout_at, ) db.add(instance) await db.flush() await db.refresh(instance) # Log initial step entry steps = workflow.steps or [] first_step = steps[0] if steps else {} await _log_step_history( db, tenant_id, instance.id, step_index=0, step_type=first_step.get("type", "action"), action="entered", actor_id=user_id, details={"workflow_name": workflow.name}, ) await log_audit( db, tenant_id, user_id, action="create", entity_type="workflow_instance", entity_id=instance.id, changes={"workflow_id": str(wf_uuid), "status": "pending"}, ) return _instance_to_dict(instance) async def list_instances( db: AsyncSession, tenant_id: uuid.UUID, page: int = 1, page_size: int = 20, status_filter: str | None = None, ) -> dict[str, Any]: """List workflow instances with optional status filter.""" page = max(1, page) page_size = max(1, min(100, page_size)) base = select(WorkflowInstance).where(WorkflowInstance.tenant_id == tenant_id) if status_filter: base = base.where(WorkflowInstance.status == status_filter) count_q = select(func.count()).select_from(base.subquery()) total_result = await db.execute(count_q) total = total_result.scalar_one() offset = (page - 1) * page_size paginated = base.order_by(desc(WorkflowInstance.created_at)).offset(offset).limit(page_size) result = await db.execute(paginated) instances = result.scalars().all() return { "items": [_instance_to_dict(i) for i in instances], "total": total, "page": page, "page_size": page_size, } async def get_instance( db: AsyncSession, tenant_id: uuid.UUID, instance_id: str, ) -> dict[str, Any] | None: """Get a single workflow instance with step history.""" inst_uuid = uuid.UUID(instance_id) result = await db.execute( select(WorkflowInstance).where( WorkflowInstance.id == inst_uuid, WorkflowInstance.tenant_id == tenant_id, ) ) instance = result.scalar_one_or_none() if instance is None: return None # Get workflow name wf_result = await db.execute( select(Workflow.name).where(Workflow.id == instance.workflow_id) ) workflow_name = wf_result.scalar_one_or_none() # Get step history hist_q = select(WorkflowStepHistory).where( WorkflowStepHistory.instance_id == inst_uuid, WorkflowStepHistory.tenant_id == tenant_id, ).order_by(WorkflowStepHistory.created_at) hist_result = await db.execute(hist_q) history = hist_result.scalars().all() return _instance_to_dict( instance, include_history=True, history=[_history_to_dict(h) for h in history], workflow_name=workflow_name, ) async def advance_instance( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, instance_id: str, decision: str, comment: str | None = None, ) -> dict[str, Any] | None: """Advance or reject a workflow instance step. decision: "approve" or "reject" - approve: move to next step or complete if last step - reject: set status=rejected, notify initiator """ inst_uuid = uuid.UUID(instance_id) result = await db.execute( select(WorkflowInstance).where( WorkflowInstance.id == inst_uuid, WorkflowInstance.tenant_id == tenant_id, ) ) instance = result.scalar_one_or_none() if instance is None: return None if instance.status not in ("pending", "in_progress"): return {"error": f"Cannot advance instance with status {instance.status}", "status_code": 400} # Get workflow definition wf_result = await db.execute( select(Workflow).where(Workflow.id == instance.workflow_id) ) workflow = wf_result.scalar_one_or_none() if workflow is None: return {"error": "Workflow definition not found", "status_code": 404} steps = workflow.steps or [] current_idx = instance.current_step_index current_step = steps[current_idx] if current_idx < len(steps) else None step_type = current_step.get("type", "action") if current_step else "action" if decision == "reject": instance.status = "rejected" instance.completed_at = datetime.now(timezone.utc) await _log_step_history( db, tenant_id, instance.id, step_index=current_idx, step_type=step_type, action="rejected", actor_id=user_id, details={"comment": comment}, ) # Notify initiator if instance.initiated_by: notification = Notification( tenant_id=tenant_id, user_id=instance.initiated_by, type="workflow_rejected", title=f"Workflow '{workflow.name}' rejected", body=comment or f"Step {current_idx + 1} was rejected", ) db.add(notification) await db.flush() await log_audit( db, tenant_id, user_id, action="reject", entity_type="workflow_instance", entity_id=instance.id, changes={"step": current_idx, "comment": comment}, ) return _instance_to_dict(instance) # decision == approve # Move to in_progress if was pending if instance.status == "pending": instance.status = "in_progress" # Log approval await _log_step_history( db, tenant_id, instance.id, step_index=current_idx, step_type=step_type, action="approved", actor_id=user_id, details={"comment": comment}, ) next_idx = current_idx + 1 if next_idx >= len(steps): # Workflow complete instance.status = "completed" instance.completed_at = datetime.now(timezone.utc) await _log_step_history( db, tenant_id, instance.id, step_index=current_idx, step_type="complete", action="completed", actor_id=user_id, ) else: instance.current_step_index = next_idx next_step = steps[next_idx] await _log_step_history( db, tenant_id, instance.id, step_index=next_idx, step_type=next_step.get("type", "action"), action="entered", actor_id=user_id, ) await db.flush() await log_audit( db, tenant_id, user_id, action="advance", entity_type="workflow_instance", entity_id=instance.id, changes={"decision": decision, "step": current_idx, "next_step": next_idx if next_idx < len(steps) else None}, ) return _instance_to_dict(instance) async def cancel_instance( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, instance_id: str, ) -> dict[str, Any] | None: """Cancel a workflow instance.""" inst_uuid = uuid.UUID(instance_id) result = await db.execute( select(WorkflowInstance).where( WorkflowInstance.id == inst_uuid, WorkflowInstance.tenant_id == tenant_id, ) ) instance = result.scalar_one_or_none() if instance is None: return None if instance.status in ("completed", "rejected", "cancelled"): return {"error": f"Cannot cancel instance with status {instance.status}", "status_code": 400} instance.status = "cancelled" instance.completed_at = datetime.now(timezone.utc) # Get current step for history wf_result = await db.execute( select(Workflow).where(Workflow.id == instance.workflow_id) ) workflow = wf_result.scalar_one_or_none() steps = workflow.steps if workflow else [] current_step = steps[instance.current_step_index] if instance.current_step_index < len(steps) else {} await _log_step_history( db, tenant_id, instance.id, step_index=instance.current_step_index, step_type=current_step.get("type", "action"), action="cancelled", actor_id=user_id, ) await db.flush() await log_audit( db, tenant_id, user_id, action="cancel", entity_type="workflow_instance", entity_id=instance.id, ) return _instance_to_dict(instance) async def check_timeout(instance: WorkflowInstance) -> bool: """Check if an instance has timed out (for approval steps). Returns True if the instance should be auto-rejected. """ if instance.timeout_at is None: return False if instance.status not in ("pending", "in_progress"): return False return datetime.now(timezone.utc) > instance.timeout_at async def auto_reject_timeout( db: AsyncSession, tenant_id: uuid.UUID, instance: WorkflowInstance, ) -> dict[str, Any]: """Auto-reject a timed-out instance.""" instance.status = "rejected" instance.completed_at = datetime.now(timezone.utc) wf_result = await db.execute( select(Workflow).where(Workflow.id == instance.workflow_id) ) workflow = wf_result.scalar_one_or_none() steps = workflow.steps if workflow else [] current_step = steps[instance.current_step_index] if instance.current_step_index < len(steps) else {} await _log_step_history( db, tenant_id, instance.id, step_index=instance.current_step_index, step_type=current_step.get("type", "approval"), action="auto_rejected", details={"reason": "timeout"}, ) # Notify initiator if instance.initiated_by: notification = Notification( tenant_id=tenant_id, user_id=instance.initiated_by, type="workflow_timeout", title=f"Workflow '{workflow.name if workflow else 'Unknown'}' auto-rejected", body="The approval step timed out and was automatically rejected.", ) db.add(notification) await db.flush() await db.refresh(instance) return _instance_to_dict(instance) async def find_workflows_for_event( db: AsyncSession, tenant_id: uuid.UUID, event_name: str, ) -> list[Workflow]: """Find active workflows that trigger on a specific event.""" result = await db.execute( select(Workflow).where( Workflow.tenant_id == tenant_id, Workflow.is_active.is_(True), Workflow.trigger_event == event_name, ) ) return list(result.scalars().all()) async def start_instance_for_event( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID | None, event_name: str, context: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: """Start workflow instances for all workflows matching an event trigger. Called by the event bus when an event is published. """ workflows = await find_workflows_for_event(db, tenant_id, event_name) instances: list[dict[str, Any]] = [] for wf in workflows: inst = await create_instance( db, tenant_id, user_id or uuid.uuid4(), str(wf.id), context=context, ) if inst: instances.append(inst) return instances