fix error in gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# workflows app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WorkflowsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.workflows"
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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__"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user