infrasynth-backend-kit/infrasynth/workflows/engine.py
2026-08-28 14:38:47 -05:00

262 lines
9.8 KiB
Python

from django.db import transaction
from django.utils import timezone
from infrasynth.shared.enums import ApprovalStrategy
from .models import (
NodeAssignment,
Transition,
Workflow,
WorkflowInstance,
WorkflowNode,
WorkflowObserver,
)
from .signals import (
decision_submitted,
instance_completed,
instance_started,
node_reached,
)
REJECT_DECISIONS = {"rejected", "reject", "denied", "deny", "declined"}
class WorkflowEngine:
"""Workflow engine. Pure methods, no DRF dependencies."""
@transaction.atomic
def start(self, workflow_slug: str, owner, initial_data: dict | None = None) -> WorkflowInstance:
"""Starts a new workflow instance at its START node."""
workflow = Workflow.objects.filter(slug=workflow_slug).first()
if workflow is None:
raise ValueError(f"Workflow '{workflow_slug}' not found.")
if not workflow.is_active:
raise ValueError(f"Workflow '{workflow_slug}' is inactive.")
start_node = workflow.nodes.filter(node_type=WorkflowNode.NodeType.START).first()
if start_node is None:
start_node = workflow.nodes.order_by("id").first()
if start_node is None:
raise ValueError(f"Workflow '{workflow_slug}' has no nodes.")
instance = WorkflowInstance.objects.create(
workflow=workflow,
current_node=start_node,
owner=owner,
status=WorkflowInstance.Status.IN_PROGRESS,
metadata=initial_data or {},
)
instance_started.send(sender=WorkflowInstance, instance=instance, workflow_slug=workflow_slug, owner=owner)
node_reached.send(sender=WorkflowInstance, instance=instance, node=start_node, visit_number=1)
return instance
@transaction.atomic
def submit_decision(
self,
assignment_id: int,
decision: str,
data: dict | None = None,
*,
user=None,
comments: str = "",
) -> WorkflowInstance:
"""Records a decision on an assignment and advances if approvals are met."""
assignment = NodeAssignment.objects.select_related("instance", "instance__current_node", "node").get(
pk=assignment_id
)
if assignment.has_processed:
raise ValueError("This assignment has already been processed.")
if user is not None and assignment.user_id != user.id:
raise PermissionError("User is not assigned to this node.")
assignment.has_processed = True
assignment.decision = decision
assignment.comments = comments
assignment.submitted_data = data or {}
assignment.processed_at = timezone.now()
assignment.save()
instance = assignment.instance
decision_submitted.send(
sender=WorkflowInstance,
instance=instance,
node=assignment.node,
user=assignment.user,
decision=decision,
data=assignment.submitted_data,
)
if self._approvals_met(assignment.node, instance, assignment.visit_number):
self._advance(instance, decision)
return instance
def get_node_states(self, instance_id: int) -> dict[int, str]:
"""Visual state of every node: ACTIVE, COMPLETED, PENDING, REJECTED."""
instance = WorkflowInstance.objects.select_related("current_node").get(pk=instance_id)
nodes = instance.workflow.nodes.all()
states: dict[int, str] = {}
for node in nodes:
if instance.current_node_id == node.id:
states[node.id] = "ACTIVE"
else:
states[node.id] = "PENDING"
assignments = NodeAssignment.objects.filter(instance=instance).select_related("node")
for assignment in assignments:
if assignment.has_processed:
if (assignment.decision or "").lower() in REJECT_DECISIONS:
states[assignment.node_id] = "REJECTED"
elif states.get(assignment.node_id) != "ACTIVE":
states[assignment.node_id] = "COMPLETED"
if instance.status == WorkflowInstance.Status.COMPLETED:
for node_id in states:
if states[node_id] == "ACTIVE":
states[node_id] = "COMPLETED"
return states
def get_route(self, instance_id: int) -> list[dict]:
"""Route followed by the instance (visited nodes + decisions)."""
assignments = (
NodeAssignment.objects.filter(instance_id=instance_id)
.select_related("node", "user")
.order_by("processed_at", "visit_number")
)
return [
{
"node_id": a.node_id,
"node_name": a.node.name,
"visit_number": a.visit_number,
"user": ({"id": a.user_id, "display": str(a.user)} if a.user_id else None),
"decision": a.decision,
"comments": a.comments,
"submitted_data": a.submitted_data,
"has_processed": a.has_processed,
"processed_at": (a.processed_at.isoformat() if a.processed_at else None),
}
for a in assignments
]
def get_role_in_instance(self, instance_id: int, user) -> str:
"""OWNER | ASSIGNEE | OBSERVER | NONE"""
instance = WorkflowInstance.objects.get(pk=instance_id)
if user is None or not getattr(user, "is_authenticated", False):
return "NONE"
if instance.owner_id == user.id:
return "OWNER"
if NodeAssignment.objects.filter(instance=instance, user=user, has_processed=False).exists():
return "ASSIGNEE"
if WorkflowObserver.objects.filter(instance=instance, user=user).exists():
return "OBSERVER"
return "NONE"
@transaction.atomic
def assign_users(
self, instance_id: int, node_id: int, users: list, is_required: bool = True
) -> list[NodeAssignment]:
"""Assigns users as responsible for a node. Returns created assignments."""
instance = WorkflowInstance.objects.get(pk=instance_id)
node = WorkflowNode.objects.get(pk=node_id)
last_visit = (
NodeAssignment.objects.filter(instance=instance, node=node)
.order_by("-visit_number")
.values_list("visit_number", flat=True)
.first()
)
visit_number = (last_visit or 0) + 1
created = []
for user in users:
assignment, _ = NodeAssignment.objects.get_or_create(
instance=instance,
node=node,
user=user,
visit_number=visit_number,
defaults={"is_required": is_required},
)
created.append(assignment)
return created
@transaction.atomic
def add_observer(self, instance_id: int, user) -> WorkflowObserver:
"""Adds a read-only observer to an instance."""
instance = WorkflowInstance.objects.get(pk=instance_id)
observer, _ = WorkflowObserver.objects.get_or_create(instance=instance, user=user)
return observer
def _approvals_met(self, node: WorkflowNode, instance: WorkflowInstance, visit_number: int) -> bool:
assignments = list(NodeAssignment.objects.filter(instance=instance, node=node, visit_number=visit_number))
if not assignments:
return False
required = [a for a in assignments if a.is_required] or assignments
processed = [a for a in required if a.has_processed]
if not required:
return False
min_needed = min(node.min_approvals or 1, len(required))
strategy = node.approval_strategy
if strategy == ApprovalStrategy.ANY:
return len(processed) >= max(1, min_needed)
if strategy == ApprovalStrategy.MAJORITY:
return len(processed) >= max(min_needed, len(required) // 2 + 1)
return len(processed) >= len(required)
def _advance(self, instance: WorkflowInstance, decision: str) -> None:
node = instance.current_node
if node is None:
return
transition = (
Transition.objects.filter(from_node=node, condition_slug=decision).first()
or Transition.objects.filter(from_node=node, is_default=True).first()
)
if transition is None:
return
next_node = transition.to_node
instance.current_node = next_node
if next_node.node_type == WorkflowNode.NodeType.END:
instance.status = WorkflowInstance.Status.COMPLETED
instance.completed_at = timezone.now()
instance.save()
instance_completed.send(
sender=WorkflowInstance,
instance=instance,
workflow_slug=instance.workflow.slug,
final_node=next_node,
)
return
instance.save()
if node.id != next_node.id:
self._clone_assignees(instance, node, next_node)
node_reached.send(sender=WorkflowInstance, instance=instance, node=next_node, visit_number=1)
def _clone_assignees(self, instance: WorkflowInstance, from_node: WorkflowNode, to_node: WorkflowNode) -> None:
assignees = NodeAssignment.objects.filter(instance=instance, node=from_node, is_required=True).select_related(
"user"
)
last_visit = (
NodeAssignment.objects.filter(instance=instance, node=to_node)
.order_by("-visit_number")
.values_list("visit_number", flat=True)
.first()
)
visit_number = (last_visit or 0) + 1
for assignment in assignees:
NodeAssignment.objects.get_or_create(
instance=instance,
node=to_node,
user=assignment.user,
visit_number=visit_number,
defaults={"is_required": True},
)