abbe7a18fc
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner - P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var - P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup - P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns - P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs) - P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import - P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default - P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed - P2: 28 frontend TODOs (hardcoded constants, deprecated notification API) - P3: dead code, duplicates, deprecated imports, private attr, __import__ inline - P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n) - ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix) - F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String) - Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
789 lines
23 KiB
Python
789 lines
23 KiB
Python
"""Workflow service — CRUD, instance lifecycle, step transitions, event triggers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from sqlalchemy import desc, func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.audit import log_audit
|
|
from app.core.notifications import post_system_message
|
|
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
|
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
|
|
|
|
|
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,
|
|
owner_id=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,
|
|
user_id: uuid.UUID | None = None,
|
|
is_system_admin: bool = False,
|
|
) -> 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)
|
|
|
|
if user_id and not is_system_admin:
|
|
base = await apply_visibility_filter(
|
|
db, base, "workflow", Workflow, user_id, tenant_id, is_system_admin
|
|
)
|
|
|
|
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,
|
|
user_id: uuid.UUID | None = None,
|
|
is_system_admin: bool = False,
|
|
) -> 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
|
|
if user_id and not is_system_admin:
|
|
has_access = await check_single_entity_access(
|
|
db, "workflow", workflow.id, user_id, tenant_id, "read", is_system_admin
|
|
)
|
|
if not has_access:
|
|
raise PermissionError("No access")
|
|
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],
|
|
is_system_admin: bool = False,
|
|
) -> 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 not is_system_admin:
|
|
has_access = await check_single_entity_access(
|
|
db, "workflow", workflow.id, user_id, tenant_id, "write", is_system_admin
|
|
)
|
|
if not has_access:
|
|
raise PermissionError("No access")
|
|
|
|
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,
|
|
is_system_admin: bool = False,
|
|
) -> 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
|
|
|
|
if not is_system_admin:
|
|
has_access = await check_single_entity_access(
|
|
db, "workflow", workflow.id, user_id, tenant_id, "admin", is_system_admin
|
|
)
|
|
if not has_access:
|
|
raise PermissionError("No access")
|
|
|
|
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(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,
|
|
)
|
|
from app.core.hooks import do_action
|
|
await do_action("workflow.before_start", instance_id=instance.id, workflow_id=wf_uuid, tenant_id=tenant_id, user_id=user_id)
|
|
db.add(instance)
|
|
await db.flush()
|
|
await db.refresh(instance)
|
|
await do_action("workflow.after_start", instance_id=instance.id, workflow_id=wf_uuid, tenant_id=tenant_id, user_id=user_id)
|
|
from app.core.outbox import enqueue_outbox_event
|
|
await enqueue_outbox_event(
|
|
db,
|
|
tenant_id,
|
|
'workflow.started',
|
|
{'instance_id': str(instance.id), 'workflow_id': str(wf_uuid), 'tenant_id': str(tenant_id)},
|
|
aggregate_type='workflow_instance',
|
|
aggregate_id=instance.id,
|
|
)
|
|
|
|
# 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(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:
|
|
await post_system_message(
|
|
db,
|
|
tenant_id,
|
|
instance.initiated_by,
|
|
"workflow_rejected",
|
|
f"Workflow '{workflow.name}' rejected",
|
|
comment or f"Step {current_idx + 1} was rejected",
|
|
)
|
|
|
|
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(UTC)
|
|
await _log_step_history(
|
|
db,
|
|
tenant_id,
|
|
instance.id,
|
|
step_index=current_idx,
|
|
step_type="complete",
|
|
action="completed",
|
|
actor_id=user_id,
|
|
)
|
|
from app.core.hooks import do_action
|
|
await do_action("workflow.after_complete", instance_id=instance.id, workflow_id=instance.workflow_id, tenant_id=tenant_id, user_id=user_id)
|
|
from app.core.outbox import enqueue_outbox_event
|
|
await enqueue_outbox_event(
|
|
db,
|
|
tenant_id,
|
|
'workflow.completed',
|
|
{'instance_id': str(instance.id), 'workflow_id': str(instance.workflow_id), 'tenant_id': str(tenant_id), 'status': 'completed'},
|
|
aggregate_type='workflow_instance',
|
|
aggregate_id=instance.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(UTC)
|
|
|
|
from app.core.hooks import do_action
|
|
await do_action("workflow.after_cancel", instance_id=instance.id, workflow_id=instance.workflow_id, tenant_id=tenant_id, user_id=user_id)
|
|
from app.core.outbox import enqueue_outbox_event
|
|
await enqueue_outbox_event(
|
|
db,
|
|
tenant_id,
|
|
'workflow.cancelled',
|
|
{'instance_id': str(instance.id), 'workflow_id': str(instance.workflow_id), 'tenant_id': str(tenant_id), 'status': 'cancelled'},
|
|
aggregate_type='workflow_instance',
|
|
aggregate_id=instance.id,
|
|
)
|
|
|
|
# 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(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(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:
|
|
await post_system_message(
|
|
db,
|
|
tenant_id,
|
|
instance.initiated_by,
|
|
"workflow_timeout",
|
|
f"Workflow '{workflow.name if workflow else 'Unknown'}' auto-rejected",
|
|
"The approval step timed out and was automatically rejected.",
|
|
)
|
|
|
|
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
|