Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion joeflow/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,25 @@ def get_inlines(self, *args, **kwargs):

def get_readonly_fields(self, *args, **kwargs):
return [
"get_instance_graph_svg",
"display_workflow_diagram",
*super().get_readonly_fields(*args, **kwargs),
"modified",
"created",
]

@admin.display(description="Workflow Diagram")
def display_workflow_diagram(self, obj):
"""Display workflow diagram using MermaidJS for client-side rendering."""
if obj.pk:
# Get Mermaid diagram syntax
mermaid_syntax = obj.get_instance_graph_mermaid()
# Wrap in div with mermaid class for client-side rendering
return format_html(
'<div class="mermaid-diagram"><div class="mermaid">{}</div></div>',
mermaid_syntax
)
return ""

@transaction.atomic()
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
Expand Down
152 changes: 152 additions & 0 deletions joeflow/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,40 @@ def get_graph_svg(cls):

get_graph_svg.short_description = t("graph")

@classmethod
def get_graph_mermaid(cls, color="black"):
"""
Return workflow graph as Mermaid diagram syntax.

This can be used with MermaidJS for client-side rendering in browsers.

Returns:
(str): Mermaid diagram syntax.
"""
lines = [f"graph {cls.rankdir}"]

# Add nodes
for name, node in cls.get_nodes():
node_id = name.replace(" ", "_")
# Keep original name with spaces for label
label = name.replace("_", " ")

# Determine shape based on node type
if node.type == HUMAN:
# Rounded rectangle for human tasks
lines.append(f" {node_id}({label})")
else:
# Rectangle for machine tasks
lines.append(f" {node_id}[{label}]")

# Add edges
for start, end in cls.edges:
start_id = start.name.replace(" ", "_")
end_id = end.name.replace(" ", "_")
lines.append(f" {start_id} --> {end_id}")

return "\n".join(lines)

def get_instance_graph(self):
"""Return workflow instance graph."""
graph = self.get_graph(color="#888888")
Expand Down Expand Up @@ -332,6 +366,124 @@ def get_instance_graph_svg(self, output_format="svg"):

get_instance_graph_svg.short_description = t("instance graph")

def get_instance_graph_mermaid(self):
"""
Return instance graph as Mermaid diagram syntax.

This can be used with MermaidJS for client-side rendering in admin.

Returns:
(str): Mermaid diagram syntax for the instance graph.
"""
lines = [f"graph {self.rankdir}"]
node_styles = []
edge_styles = []
edge_index = 0

names = dict(self.get_nodes()).keys()

# Add all nodes from workflow definition (inactive/gray style)
for name, node in self.get_nodes():
node_id = name.replace(" ", "_")
# Keep original name with spaces for label
label = name.replace("_", " ")

# Determine shape based on node type
if node.type == HUMAN:
lines.append(f" {node_id}({label})")
else:
lines.append(f" {node_id}[{label}]")

# Default gray styling for nodes not yet processed
node_styles.append(f" style {node_id} fill:#f9f9f9,stroke:#999,color:#999")

# Add edges from workflow definition (gray style)
for start, end in self.edges:
start_id = start.name.replace(" ", "_")
end_id = end.name.replace(" ", "_")
lines.append(f" {start_id} --> {end_id}")
edge_styles.append(f" linkStyle {edge_index} stroke:#999")
edge_index += 1

# Process actual tasks to highlight active/completed states
for task in self.task_set.filter(name__in=names):
node_id = task.name.replace(" ", "_")

# Active tasks (not completed) get bold black styling
if not task.completed:
node_styles.append(f" style {node_id} fill:#fff,stroke:#000,stroke-width:3px,color:#000")
else:
# Completed tasks get normal black styling
node_styles.append(f" style {node_id} fill:#fff,stroke:#000,stroke-width:2px,color:#000")

# Add edges for actual task connections (black style)
for child in task.child_task_set.exclude(name="override"):
child_id = child.name.replace(" ", "_")
lines.append(f" {node_id} --> {child_id}")
edge_styles.append(f" linkStyle {edge_index} stroke:#000,stroke-width:2px")
edge_index += 1

# Handle override tasks
for task in self.task_set.filter(name="override").prefetch_related(
"parent_task_set", "child_task_set"
):
override_id = f"override_{task.pk}"
override_label = f"override {task.pk}"

# Add override node with dashed style
lines.append(f" {override_id}({override_label})")
node_styles.append(f" style {override_id} fill:#fff,stroke:#000,stroke-width:2px,stroke-dasharray:5 5,color:#000")

# Add dashed edges for override connections
for parent in task.parent_task_set.all():
parent_id = parent.name.replace(" ", "_")
lines.append(f" {parent_id} -.-> {override_id}")
edge_styles.append(f" linkStyle {edge_index} stroke:#000,stroke-dasharray:5 5")
edge_index += 1

for child in task.child_task_set.all():
child_id = child.name.replace(" ", "_")
lines.append(f" {override_id} -.-> {child_id}")
edge_styles.append(f" linkStyle {edge_index} stroke:#000,stroke-dasharray:5 5")
edge_index += 1

# Handle obsolete/custom tasks (not in workflow definition)
for task in self.task_set.exclude(name__in=names).exclude(name="override"):
node_id = task.name.replace(" ", "_")
# Keep original name with spaces for label
label = task.name.replace("_", " ")

# Determine shape based on node type
if task.type == HUMAN:
lines.append(f" {node_id}({label})")
else:
lines.append(f" {node_id}[{label}]")

# Dashed styling for obsolete tasks
if not task.completed:
node_styles.append(f" style {node_id} fill:#fff,stroke:#000,stroke-width:3px,stroke-dasharray:5 5,color:#000")
else:
node_styles.append(f" style {node_id} fill:#fff,stroke:#000,stroke-width:2px,stroke-dasharray:5 5,color:#000")

# Add dashed edges for obsolete task connections
for parent in task.parent_task_set.all():
parent_id = parent.name.replace(" ", "_")
lines.append(f" {parent_id} -.-> {node_id}")
edge_styles.append(f" linkStyle {edge_index} stroke:#000,stroke-dasharray:5 5")
edge_index += 1

for child in task.child_task_set.all():
child_id = child.name.replace(" ", "_")
lines.append(f" {node_id} -.-> {child_id}")
edge_styles.append(f" linkStyle {edge_index} stroke:#000,stroke-dasharray:5 5")
edge_index += 1

# Add all styling at the end
lines.extend(node_styles)
lines.extend(edge_styles)

return "\n".join(lines)

def cancel(self, user=None):
self.task_set.cancel(user)

Expand Down
10 changes: 10 additions & 0 deletions joeflow/templates/admin/change_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% extends "admin/change_form.html" %}

{% block extrahead %}
{{ block.super }}
<!-- Mermaid JS for rendering workflow diagrams -->
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true });
</script>
{% endblock %}
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ requires-python = ">=3.9"
dependencies = [
"django>=2.2",
"django-appconf",
"graphviz>=0.18",
]

[project.optional-dependencies]
Expand All @@ -74,6 +73,10 @@ docs = [
"dramatiq",
"django_dramatiq",
"redis",
"graphviz>=0.18",
]
graphviz = [
"graphviz>=0.18",
]
reversion = [
"django-reversion",
Expand Down
95 changes: 95 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,101 @@ def test_get_instance_graph_svg(self, db, fixturedir):
svg = wf.get_instance_graph_svg()
assert isinstance(svg, SafeString)

def test_get_graph_mermaid(self):
"""Test that get_graph_mermaid returns valid Mermaid syntax."""
mermaid = workflows.SimpleWorkflow.get_graph_mermaid()

# Check it's a string
assert isinstance(mermaid, str)

# Check it starts with graph declaration
assert mermaid.startswith("graph LR") or mermaid.startswith("graph TD")

# Check it contains nodes
assert "start_method[start method]" in mermaid
assert "save_the_princess(save the princess)" in mermaid # HUMAN task, rounded
assert "end[end]" in mermaid

# Check it contains edges
assert "start_method --> save_the_princess" in mermaid
assert "save_the_princess --> end" in mermaid

def test_get_graph_mermaid_with_direction(self):
"""Test that get_graph_mermaid respects rankdir."""
workflows.SimpleWorkflow.rankdir = "TD"
mermaid = workflows.SimpleWorkflow.get_graph_mermaid()
assert mermaid.startswith("graph TD")

# Reset to default
workflows.SimpleWorkflow.rankdir = "LR"

def test_get_instance_graph_mermaid(self, db):
"""Test that get_instance_graph_mermaid returns valid Mermaid syntax with task states."""
wf = workflows.SimpleWorkflow.start_method()
mermaid = wf.get_instance_graph_mermaid()

# Check it's a string
assert isinstance(mermaid, str)

# Check it starts with graph declaration
assert mermaid.startswith("graph LR") or mermaid.startswith("graph TD")

# Check it contains nodes
assert "save_the_princess(save the princess)" in mermaid
assert "start_method[start method]" in mermaid

# Check it contains edges
assert "start_method --> save_the_princess" in mermaid

# Check it contains styling (for active/completed tasks)
assert "style " in mermaid
assert "linkStyle " in mermaid

def test_get_instance_graph_mermaid_with_override(
self, db, stub_worker, admin_client
):
"""Test that get_instance_graph_mermaid handles override tasks correctly."""
wf = workflows.SimpleWorkflow.start_method()
url = reverse("simpleworkflow:override", args=[wf.pk])
response = admin_client.post(url, data={"next_tasks": ["end"]})
assert response.status_code == 302
stub_worker.wait()

task = wf.task_set.get(name="override")
mermaid = wf.get_instance_graph_mermaid()

# Check override node exists
override_id = f"override_{task.pk}"
assert override_id in mermaid

# Check dashed edges (dotted arrow notation in Mermaid)
assert ".-.->" in mermaid

# Check override styling with dashed border
assert f"style {override_id}" in mermaid
assert "stroke-dasharray" in mermaid

def test_get_instance_graph_mermaid_with_obsolete(self, db):
"""Test that get_instance_graph_mermaid handles obsolete tasks correctly."""
workflow = workflows.SimpleWorkflow.objects.create()
start = workflow.task_set.create(name="start_method", status=Task.SUCCEEDED)
obsolete = workflow.task_set.create(name="obsolete", status=Task.SUCCEEDED)
end = workflow.task_set.create(name="end", status=Task.SUCCEEDED)
obsolete.parent_task_set.add(start)
end.parent_task_set.add(obsolete)

mermaid = workflow.get_instance_graph_mermaid()

# Check obsolete node exists
assert "obsolete[obsolete]" in mermaid

# Check dashed edges (dotted arrow notation in Mermaid)
assert "-.->obsolete" in mermaid.replace(" ", "") or "obsolete-.->end" in mermaid.replace(" ", "")

# Check obsolete task styling with dashed border
assert "style obsolete" in mermaid
assert "stroke-dasharray" in mermaid

def test_cancel(self, db):
workflow = workflows.SimpleWorkflow.objects.create()
workflow.task_set.create()
Expand Down