262 lines
9.8 KiB
Python
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},
|
|
)
|