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}, )