fix error in gitignore

This commit is contained in:
2026-09-24 12:32:59 +03:30
commit 86c117ee2a
165 changed files with 5963 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# workflows app
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class WorkflowsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.workflows"
+169
View File
@@ -0,0 +1,169 @@
"""Generic, database-driven workflow engine.
The engine understands only generic concepts: steps, transitions, conditions,
and versions. It has no knowledge of "washing", "drying", or any other
business-specific term.
"""
from django.db import transaction
from django.utils import timezone
from apps.core.errors import ApiError
class WorkflowError(ApiError):
def __init__(self, message: str, details=None, code: str = "WORKFLOW_INVALID"):
super().__init__(code, message, details)
# ---------------------------------------------------------------------------
# Condition registry. New condition types can be added without touching the
# rest of the system.
# ---------------------------------------------------------------------------
CONDITION_HANDLERS = {}
def register_condition(key: str):
def decorator(fn):
CONDITION_HANDLERS[key] = fn
return fn
return decorator
@register_condition("step_selected")
def _cond_step_selected(ctx, cond):
return cond.get("step") in ctx.get("selected_step_keys", [])
@register_condition("step_completed")
def _cond_step_completed(ctx, cond):
return cond.get("step") in ctx.get("completed_step_keys", [])
@register_condition("weight_gte")
def _cond_weight_gte(ctx, cond):
try:
return float(ctx.get("net_weight") or 0) >= float(cond.get("value", 0))
except (TypeError, ValueError):
return False
@register_condition("weight_lte")
def _cond_weight_lte(ctx, cond):
try:
return float(ctx.get("net_weight") or 0) <= float(cond.get("value", 0))
except (TypeError, ValueError):
return False
@register_condition("payment_status")
def _cond_payment_status(ctx, cond):
return ctx.get("payment_status") == cond.get("value")
@register_condition("customer_category")
def _cond_customer_category(ctx, cond):
return ctx.get("customer_category") == cond.get("value")
@register_condition("role")
def _cond_role(ctx, cond):
return cond.get("value") in ctx.get("user_roles", [])
@register_condition("field")
def _cond_field(ctx, cond):
return ctx.get("fields", {}).get(cond.get("key")) == cond.get("value")
def evaluate_condition(cond, context) -> bool:
handler = CONDITION_HANDLERS.get(cond.get("type"))
if handler is None:
return False
try:
return bool(handler(context, cond))
except Exception:
return False
def evaluate_conditions(conditions, context) -> bool:
if not conditions:
return True
return all(evaluate_condition(c, context) for c in conditions)
class WorkflowEngine:
"""Validates and publishes workflow versions and evaluates transitions."""
def validate_version(self, version) -> bool:
errors = []
steps = list(version.steps.all())
if not steps:
errors.append("workflow has no steps")
if not any(s.is_start for s in steps):
errors.append("missing start step")
if not any(s.is_terminal for s in steps):
errors.append("missing terminal step")
step_ids = {s.id for s in steps}
transitions = list(version.transitions.select_related("from_step", "to_step").all())
for t in transitions:
if t.from_step_id not in step_ids or t.to_step_id not in step_ids:
errors.append(f"broken transition: {t}")
if self._has_cycle(steps, transitions):
errors.append("circular dependency detected")
if errors:
raise WorkflowError("Workflow validation failed", {"errors": errors})
return True
def _has_cycle(self, steps, transitions) -> bool:
adj = {s.id: [] for s in steps}
for t in transitions:
adj.setdefault(t.from_step_id, []).append(t.to_step_id)
WHITE, GRAY, BLACK = 0, 1, 2
color = {s.id: WHITE for s in steps}
def dfs(u):
color[u] = GRAY
for v in adj.get(u, []):
if v not in color:
continue
if color[v] == WHITE and dfs(v):
return True
if color[v] == GRAY:
return True
color[u] = BLACK
return False
for s in steps:
if color[s.id] == WHITE and dfs(s.id):
return True
return False
@transaction.atomic
def publish(self, workflow, version):
self.validate_version(version)
version.status = "active"
version.published_at = timezone.now()
version.save(update_fields=["status", "published_at"])
workflow.active_version = version
workflow.status = "active"
workflow.save(update_fields=["active_version", "status"])
return version
def start_steps(self, version):
return list(version.steps.filter(is_start=True, enabled=True))
def available_next_steps(self, version, current_step, context):
"""Return the list of steps reachable from `current_step` given context."""
result = []
qs = version.transitions.filter(from_step=current_step).select_related("to_step")
for t in qs:
if evaluate_conditions(t.conditions, context):
result.append(t.to_step)
return result
def can_transition(self, version, from_step, to_step, context) -> bool:
t = version.transitions.filter(from_step=from_step, to_step=to_step).first()
if t is None:
return False
return evaluate_conditions(t.conditions, context)
+109
View File
@@ -0,0 +1,109 @@
# Generated by Django 5.2.17 on 2026-09-15 09:28
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('rbac', '__first__'),
]
operations = [
migrations.CreateModel(
name='Workflow',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=200)),
('slug', models.SlugField(max_length=200, unique=True)),
('description', models.TextField(blank=True)),
('status', models.CharField(choices=[('draft', 'Draft'), ('active', 'Active'), ('inactive', 'Inactive'), ('archived', 'Archived')], default='draft', max_length=16)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='WorkflowStep',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('key', models.SlugField(max_length=100)),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('order', models.PositiveIntegerField(default=0)),
('step_type', models.CharField(choices=[('task', 'Task'), ('decision', 'Decision'), ('input', 'Input'), ('output', 'Output'), ('integration', 'Integration')], default='task', max_length=16)),
('required', models.BooleanField(default=False)),
('enabled', models.BooleanField(default=True)),
('estimated_duration_minutes', models.PositiveIntegerField(default=0)),
('is_start', models.BooleanField(default=False)),
('is_terminal', models.BooleanField(default=False)),
('requires_customer_confirmation', models.BooleanField(default=False)),
('requires_payment', models.BooleanField(default=False)),
('triggers_invoice', models.BooleanField(default=False)),
('triggers_odoo_sync', models.BooleanField(default=False)),
('customer_visible', models.BooleanField(default=True)),
('operator_visible', models.BooleanField(default=True)),
('live_monitoring_enabled', models.BooleanField(default=False)),
('input_schema', models.JSONField(blank=True, default=dict)),
('output_schema', models.JSONField(blank=True, default=dict)),
('availability_condition', models.JSONField(blank=True, default=dict)),
],
options={
'ordering': ['order'],
},
),
migrations.CreateModel(
name='WorkflowVersion',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('version', models.PositiveIntegerField()),
('status', models.CharField(choices=[('draft', 'Draft'), ('active', 'Active'), ('inactive', 'Inactive'), ('archived', 'Archived')], default='draft', max_length=16)),
('published_at', models.DateTimeField(blank=True, null=True)),
('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='workflows.workflow')),
],
options={
'ordering': ['-version'],
},
),
migrations.CreateModel(
name='WorkflowTransition',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('conditions', models.JSONField(blank=True, default=list)),
('from_step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outgoing', to='workflows.workflowstep')),
('required_roles', models.ManyToManyField(blank=True, to='rbac.role')),
('to_step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incoming', to='workflows.workflowstep')),
('version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transitions', to='workflows.workflowversion')),
],
),
migrations.AddField(
model_name='workflowstep',
name='version',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='steps', to='workflows.workflowversion'),
),
migrations.AddField(
model_name='workflow',
name='active_version',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='workflows.workflowversion'),
),
migrations.AddConstraint(
model_name='workflowversion',
constraint=models.UniqueConstraint(fields=('workflow', 'version'), name='uniq_workflow_version'),
),
migrations.AddConstraint(
model_name='workflowtransition',
constraint=models.UniqueConstraint(fields=('version', 'from_step', 'to_step'), name='uniq_transition'),
),
migrations.AddConstraint(
model_name='workflowstep',
constraint=models.UniqueConstraint(fields=('version', 'key'), name='uniq_step_key'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-09-15 10:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='workflowstep',
name='position_x',
field=models.FloatField(default=0),
),
migrations.AddField(
model_name='workflowstep',
name='position_y',
field=models.FloatField(default=0),
),
]
+111
View File
@@ -0,0 +1,111 @@
"""Database-driven workflow definitions.
The engine knows only generic concepts (steps, transitions, conditions,
versions). Business processes (washing, drying, sorting, ...) are data, never
code. Do NOT add hard-coded step names or branching here.
"""
from django.db import models
from apps.core.models import TimeStampedModel, UUIDModel
class Workflow(UUIDModel, TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
ACTIVE = "active", "Active"
INACTIVE = "inactive", "Inactive"
ARCHIVED = "archived", "Archived"
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
description = models.TextField(blank=True)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT)
active_version = models.OneToOneField(
"WorkflowVersion", null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
)
def __str__(self) -> str:
return self.name
class WorkflowVersion(UUIDModel, TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
ACTIVE = "active", "Active"
INACTIVE = "inactive", "Inactive"
ARCHIVED = "archived", "Archived"
workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, related_name="versions")
version = models.PositiveIntegerField()
status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT)
published_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-version"]
constraints = [
models.UniqueConstraint(fields=["workflow", "version"], name="uniq_workflow_version")
]
def __str__(self) -> str:
return f"{self.workflow.slug} v{self.version}"
class WorkflowStep(UUIDModel):
class StepType(models.TextChoices):
TASK = "task", "Task"
DECISION = "decision", "Decision"
INPUT = "input", "Input"
OUTPUT = "output", "Output"
INTEGRATION = "integration", "Integration"
version = models.ForeignKey(WorkflowVersion, on_delete=models.CASCADE, related_name="steps")
key = models.SlugField(max_length=100)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
order = models.PositiveIntegerField(default=0)
step_type = models.CharField(max_length=16, choices=StepType.choices, default=StepType.TASK)
required = models.BooleanField(default=False)
enabled = models.BooleanField(default=True)
estimated_duration_minutes = models.PositiveIntegerField(default=0)
is_start = models.BooleanField(default=False)
is_terminal = models.BooleanField(default=False)
requires_customer_confirmation = models.BooleanField(default=False)
requires_payment = models.BooleanField(default=False)
triggers_invoice = models.BooleanField(default=False)
triggers_odoo_sync = models.BooleanField(default=False)
customer_visible = models.BooleanField(default=True)
operator_visible = models.BooleanField(default=True)
live_monitoring_enabled = models.BooleanField(default=False)
input_schema = models.JSONField(default=dict, blank=True)
output_schema = models.JSONField(default=dict, blank=True)
# Optional condition (dict) that gates whether this step is available.
availability_condition = models.JSONField(default=dict, blank=True)
# Canvas position for the visual workflow designer.
position_x = models.FloatField(default=0)
position_y = models.FloatField(default=0)
class Meta:
ordering = ["order"]
constraints = [
models.UniqueConstraint(fields=["version", "key"], name="uniq_step_key")
]
def __str__(self) -> str:
return f"{self.key} ({self.version})"
class WorkflowTransition(UUIDModel):
version = models.ForeignKey(WorkflowVersion, on_delete=models.CASCADE, related_name="transitions")
from_step = models.ForeignKey(WorkflowStep, on_delete=models.CASCADE, related_name="outgoing")
to_step = models.ForeignKey(WorkflowStep, on_delete=models.CASCADE, related_name="incoming")
# List of condition dicts, e.g. [{"type": "step_completed", "step": "drying"}]
conditions = models.JSONField(default=list, blank=True)
required_roles = models.ManyToManyField("rbac.Role", blank=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["version", "from_step", "to_step"], name="uniq_transition")
]
def __str__(self) -> str:
return f"{self.from_step.key} -> {self.to_step.key}"
+34
View File
@@ -0,0 +1,34 @@
from rest_framework import serializers
from apps.workflows.models import Workflow, WorkflowStep, WorkflowTransition, WorkflowVersion
class WorkflowStepSerializer(serializers.ModelSerializer):
class Meta:
model = WorkflowStep
fields = "__all__"
read_only_fields = ["id"]
class WorkflowTransitionSerializer(serializers.ModelSerializer):
class Meta:
model = WorkflowTransition
fields = "__all__"
read_only_fields = ["id"]
class WorkflowVersionSerializer(serializers.ModelSerializer):
steps = WorkflowStepSerializer(many=True, read_only=True)
transitions = WorkflowTransitionSerializer(many=True, read_only=True)
class Meta:
model = WorkflowVersion
fields = "__all__"
class WorkflowSerializer(serializers.ModelSerializer):
active_version = WorkflowVersionSerializer(read_only=True)
class Meta:
model = Workflow
fields = "__all__"
+75
View File
@@ -0,0 +1,75 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from apps.accounts.models import Customer
from apps.orders.models import ProcessingOrder
from apps.workflows.engine import WorkflowEngine
from apps.workflows.models import Workflow, WorkflowStep, WorkflowTransition, WorkflowVersion
User = get_user_model()
def make_workflow(slug="test"):
wf = Workflow.objects.create(name="Test", slug=slug)
v = WorkflowVersion.objects.create(workflow=wf, version=1, status="draft")
start = WorkflowStep.objects.create(version=v, key="start", name="Start", order=1, is_start=True)
mid = WorkflowStep.objects.create(version=v, key="mid", name="Mid", order=2)
end = WorkflowStep.objects.create(version=v, key="end", name="End", order=3, is_terminal=True)
WorkflowTransition.objects.create(version=v, from_step=start, to_step=mid)
WorkflowTransition.objects.create(version=v, from_step=mid, to_step=end)
return wf, v, start, mid, end
class WorkflowEngineTests(TestCase):
def test_publish_valid_version(self):
wf, v, *_ = make_workflow()
WorkflowEngine().publish(wf, v)
wf.refresh_from_db()
v.refresh_from_db()
self.assertEqual(wf.status, "active")
self.assertEqual(v.status, "active")
self.assertEqual(wf.active_version, v)
def test_publish_rejects_missing_terminal(self):
wf = Workflow.objects.create(name="T2", slug="t2")
v = WorkflowVersion.objects.create(workflow=wf, version=1)
WorkflowStep.objects.create(version=v, key="start", name="Start", is_start=True)
WorkflowStep.objects.create(version=v, key="mid", name="Mid")
with self.assertRaises(Exception) as ctx:
WorkflowEngine().publish(wf, v)
errors = ctx.exception.details.get("errors", [])
self.assertTrue(any("terminal" in e for e in errors), errors)
def test_available_next_steps_respect_conditions(self):
wf, v, start, mid, end = make_workflow()
WorkflowTransition.objects.create(
version=v, from_step=start, to_step=end,
conditions=[{"type": "step_selected", "step": "mid"}],
)
engine = WorkflowEngine()
keys = {s.key for s in engine.available_next_steps(v, start, {"selected_step_keys": []})}
self.assertIn("mid", keys)
self.assertNotIn("end", keys)
keys2 = {s.key for s in engine.available_next_steps(v, start, {"selected_step_keys": ["mid"]})}
self.assertIn("end", keys2)
class WorkflowVersioningTests(TestCase):
def test_order_keeps_its_version_after_new_publish(self):
wf, v, *_ = make_workflow()
WorkflowEngine().publish(wf, v)
user = User.objects.create_user(username="cust", password="x")
customer = Customer.objects.create(user=user)
order = ProcessingOrder.objects.create(customer=customer, workflow_version=v)
# Publish a second version.
v2 = WorkflowVersion.objects.create(workflow=wf, version=2, status="draft")
s2 = WorkflowStep.objects.create(version=v2, key="start", name="Start", is_start=True)
e2 = WorkflowStep.objects.create(version=v2, key="end", name="End", is_terminal=True)
WorkflowTransition.objects.create(version=v2, from_step=s2, to_step=e2)
WorkflowEngine().publish(wf, v2)
wf.refresh_from_db()
order.refresh_from_db()
self.assertEqual(wf.active_version, v2)
self.assertEqual(order.workflow_version, v)
+18
View File
@@ -0,0 +1,18 @@
from rest_framework.routers import DefaultRouter
from apps.workflows.views import (
WorkflowStepViewSet,
WorkflowTransitionViewSet,
WorkflowVersionViewSet,
WorkflowViewSet,
)
router = DefaultRouter()
# Register specific prefixes BEFORE the empty prefix, otherwise "/workflows/steps/"
# matches the empty-prefix detail route (^(?P<pk>[^/.]+)/$) and POST is not allowed.
router.register("versions", WorkflowVersionViewSet, basename="workflow-version")
router.register("steps", WorkflowStepViewSet, basename="workflow-step")
router.register("transitions", WorkflowTransitionViewSet, basename="workflow-transition")
router.register("", WorkflowViewSet, basename="workflow")
urlpatterns = router.urls
+66
View File
@@ -0,0 +1,66 @@
from rest_framework import decorators, response, status, viewsets
from apps.core.errors import ApiError, error_response
from apps.rbac.permissions import ActionPermission
from apps.workflows.engine import WorkflowEngine
from apps.workflows.models import Workflow, WorkflowStep, WorkflowTransition, WorkflowVersion
from apps.workflows.serializers import (
WorkflowSerializer,
WorkflowStepSerializer,
WorkflowTransitionSerializer,
WorkflowVersionSerializer,
)
class WorkflowViewSet(viewsets.ModelViewSet):
queryset = Workflow.objects.all()
serializer_class = WorkflowSerializer
permission_classes = [ActionPermission]
code_map = {
"list": "workflow.view",
"retrieve": "workflow.view",
"create": "workflow.create",
"update": "workflow.update",
"partial_update": "workflow.update",
"destroy": "workflow.update",
"publish": "workflow.publish",
}
@decorators.action(detail=True, methods=["post"])
def publish(self, request, pk=None):
workflow = self.get_object()
version = workflow.versions.filter(status="draft").order_by("-version").first()
if version is None:
return response.Response(
error_response("WORKFLOW_INVALID", "No draft version to publish"),
status=status.HTTP_400_BAD_REQUEST,
)
try:
WorkflowEngine().publish(workflow, version)
except ApiError as exc:
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
return response.Response(WorkflowSerializer(workflow).data)
class WorkflowVersionViewSet(viewsets.ModelViewSet):
queryset = WorkflowVersion.objects.all()
serializer_class = WorkflowVersionSerializer
permission_classes = [ActionPermission]
filterset_fields = ["workflow"]
code_map = {"create": "workflow.create", "update": "workflow.update", "partial_update": "workflow.update"}
class WorkflowStepViewSet(viewsets.ModelViewSet):
queryset = WorkflowStep.objects.all()
serializer_class = WorkflowStepSerializer
permission_classes = [ActionPermission]
filterset_fields = ["version", "key"]
code_map = {"create": "workflow.create", "update": "workflow.update", "partial_update": "workflow.update"}
class WorkflowTransitionViewSet(viewsets.ModelViewSet):
queryset = WorkflowTransition.objects.all()
serializer_class = WorkflowTransitionSerializer
permission_classes = [ActionPermission]
filterset_fields = ["version"]
code_map = {"create": "workflow.create", "update": "workflow.update", "partial_update": "workflow.update"}