Files

676 lines
20 KiB
Python
Raw Permalink Normal View History

"""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