From 374ec1a48d8e6e15587793daa0ecb6c8ce819eb4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:45:54 +0000 Subject: [PATCH 1/7] Initial plan From 0dea1ba00f56738c5ebe53bdcec55c041db8668d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:02:47 +0000 Subject: [PATCH 2/7] Switch from Graphviz to Mermaid for workflow visualization Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- .../commands/render_workflow_graph.py | 18 +- joeflow/mermaid_utils.py | 237 ++++++++++++++++++ joeflow/models.py | 26 +- joeflow/templates/admin/change_form.html | 10 + joeflow/utils.py | 78 +----- pyproject.toml | 1 - tests/commands/test_render_workflow_graph.py | 34 +-- tests/fixtures/simpleworkflow.mmd | 15 ++ tests/fixtures/simpleworkflow_instance.mmd | 19 ++ tests/test_models.py | 44 ++-- tests/test_utils.py | 75 ++---- tests/testapp/templates/testapp/base.html | 5 + 12 files changed, 367 insertions(+), 195 deletions(-) create mode 100644 joeflow/mermaid_utils.py create mode 100644 joeflow/templates/admin/change_form.html create mode 100644 tests/fixtures/simpleworkflow.mmd create mode 100644 tests/fixtures/simpleworkflow_instance.mmd diff --git a/joeflow/management/commands/render_workflow_graph.py b/joeflow/management/commands/render_workflow_graph.py index 8bc8353..1acafef 100644 --- a/joeflow/management/commands/render_workflow_graph.py +++ b/joeflow/management/commands/render_workflow_graph.py @@ -18,9 +18,9 @@ def add_arguments(self, parser): "--format", dest="format", type=str, - choices=("svg", "pdf", "png"), - default="svg", - help="Output file format. Default: svg", + choices=("mmd", "mermaid"), + default="mmd", + help="Output file format. Default: mmd (Mermaid markdown)", ) parser.add_argument( "-d", @@ -29,19 +29,12 @@ def add_arguments(self, parser): type=str, help="Output directory. Default is current working directory.", ) - parser.add_argument( - "-c", - "--cleanup", - dest="cleanup", - action="store_true", - help="Remove dot-files after rendering.", - ) + def handle(self, *args, **options): workflows = options["workflow"] verbosity = options["verbosity"] file_format = options["format"] - cleanup = options["cleanup"] directory = options.get("directory", None) workflows = [ @@ -59,8 +52,7 @@ def handle(self, *args, **options): ) filename = f"{opt.app_label}_{workflow.__name__}".lower() graph = workflow.get_graph() - graph.format = file_format - graph.render(filename=filename, directory=directory, cleanup=cleanup) + graph.render(filename=filename, directory=directory, format=file_format) if verbosity > 0: self.stdout.write("Done!", self.style.SUCCESS) else: diff --git a/joeflow/mermaid_utils.py b/joeflow/mermaid_utils.py new file mode 100644 index 0000000..26cc9af --- /dev/null +++ b/joeflow/mermaid_utils.py @@ -0,0 +1,237 @@ +"""Utilities for generating Mermaid diagrams.""" +from collections import defaultdict + + +class MermaidDiagram: + """ + Generate Mermaid diagram syntax for workflow visualization. + + Similar to graphviz.Digraph but generates Mermaid markup instead. + Nodes and edges are unique and their attributes will be overridden + should the same node or edge be added twice. + + Underscores are replaced with whitespaces from identifiers. + """ + + def __init__(self, name="", comment=None, **kwargs): + self.name = name + self.comment = comment + self.graph_attr = {} + self.node_attr = {} + self.edge_attr = {} + self._nodes = defaultdict(dict) + self._edges = defaultdict(dict) + self.body = [] + + def attr(self, kw, **kwargs): + """Set graph, node, or edge attributes.""" + if kw == "graph": + self.graph_attr.update(kwargs) + elif kw == "node": + self.node_attr.update(kwargs) + elif kw == "edge": + self.edge_attr.update(kwargs) + + def node(self, name, **attrs): + """Add or update a node.""" + self._nodes[name] = attrs + + def edge(self, tail_name, head_name, **attrs): + """Add or update an edge between two nodes.""" + self._edges[(tail_name, head_name)] = attrs + + @staticmethod + def _sanitize_id(name): + """Convert name to valid Mermaid node ID.""" + # Replace spaces and special chars with underscores + sanitized = name.replace(" ", "_").replace("-", "_") + return sanitized + + @staticmethod + def _format_label(name): + """Format label for display (replace underscores with spaces).""" + return name.replace("_", " ") + + def _get_node_shape(self, attrs): + """Determine Mermaid node shape based on attributes.""" + style = attrs.get("style", "") + + # Check for rounded style (human tasks) + if "rounded" in style: + # Rounded rectangle: (text) + return "(", ")" + else: + # Rectangle: [text] + return "[", "]" + + def _generate_node_styles(self): + """Generate style definitions for nodes.""" + styles = [] + node_styles = {} + + for name, attrs in sorted(self._nodes.items()): + node_id = self._sanitize_id(name) + style_attrs = [] + + color = attrs.get("color", "black") + fontcolor = attrs.get("fontcolor", "black") + fillcolor = attrs.get("fillcolor", "white") + style = attrs.get("style", "") + + # Map colors + if color == "#888888": + stroke_color = "#888888" + else: + stroke_color = "#000" + + if fontcolor == "#888888": + text_color = "#888888" + else: + text_color = "#000" + + # Determine stroke width based on bold + if "bold" in style: + stroke_width = "3px" + else: + stroke_width = "2px" + + # Determine stroke style based on dashed + if "dashed" in style: + stroke_style = "stroke-dasharray: 5 5" + else: + stroke_style = "" + + # Build style + style_parts = [ + f"fill:{fillcolor}", + f"stroke:{stroke_color}", + f"stroke-width:{stroke_width}", + f"color:{text_color}", + ] + if stroke_style: + style_parts.append(stroke_style) + + node_styles[node_id] = ",".join(style_parts) + + # Generate style commands + for node_id, style_str in node_styles.items(): + styles.append(f" style {node_id} {style_str}") + + return styles + + def _generate_edge_styles(self): + """Generate style definitions for edges.""" + styles = [] + edge_styles = {} + + for idx, ((tail, head), attrs) in enumerate(sorted(self._edges.items())): + style = attrs.get("style", "") + color = attrs.get("color", "black") + + # Determine link style based on attributes + if "dashed" in style: + # Mermaid uses linkStyle to style edges + if color == "#888888": + edge_styles[idx] = "stroke:#888888,stroke-dasharray: 5 5" + else: + edge_styles[idx] = "stroke:#000,stroke-dasharray: 5 5" + elif color == "#888888": + edge_styles[idx] = "stroke:#888888" + # else: default black stroke + + # Generate linkStyle commands + for idx, style_str in edge_styles.items(): + styles.append(f" linkStyle {idx} {style_str}") + + return styles + + def __iter__(self): + """Yield the Mermaid source code line by line.""" + lines = [] + + # Comment + if self.comment: + lines.append(f"%% {self.comment}") + + # Graph declaration + rankdir = self.graph_attr.get("rankdir", "LR") + lines.append(f"graph {rankdir}") + + # Nodes + for name, attrs in sorted(self._nodes.items()): + node_id = self._sanitize_id(name) + label = self._format_label(name) + + # Determine shape + left, right = self._get_node_shape(attrs) + + # Add href if present + href = attrs.get("href", "") + if href: + lines.append(f" {node_id}{left}{label}{right}") + lines.append(f' click {node_id} "{href}"') + else: + lines.append(f" {node_id}{left}{label}{right}") + + # Edges + for tail_name, head_name in sorted(self._edges.keys()): + tail_id = self._sanitize_id(tail_name) + head_id = self._sanitize_id(head_name) + lines.append(f" {tail_id} --> {head_id}") + + # Styles + node_styles = self._generate_node_styles() + lines.extend(node_styles) + + edge_styles = self._generate_edge_styles() + lines.extend(edge_styles) + + for line in lines: + yield line + + def __str__(self): + """Return the complete Mermaid diagram as a string.""" + return "\n".join(self) + + def source(self): + """Return the Mermaid diagram source.""" + return str(self) + + def pipe(self, format="svg", encoding="utf-8"): + """ + Return the diagram in the specified format. + + For Mermaid, we return the source wrapped in appropriate HTML. + This is meant for compatibility with the graphviz API. + """ + source = self.source() + if format == "svg": + # Return raw mermaid source - rendering happens client-side + return source + elif format == "png" or format == "pdf": + # For file formats, return the source as-is + # The management command will handle file writing + return source + return source + + def render(self, filename, directory=None, format="svg", cleanup=False): + """ + Save the Mermaid diagram to a file. + + Args: + filename: Base filename (without extension) + directory: Output directory + format: Output format (svg, png, pdf) - for compatibility + cleanup: Cleanup intermediate files (not used for Mermaid) + """ + import os + + if directory: + filepath = os.path.join(directory, f"{filename}.mmd") + else: + filepath = f"{filename}.mmd" + + with open(filepath, "w", encoding="utf-8") as f: + f.write(self.source()) + + return filepath diff --git a/joeflow/models.py b/joeflow/models.py index cfe51e1..8d7d492 100644 --- a/joeflow/models.py +++ b/joeflow/models.py @@ -194,7 +194,7 @@ def get_graph(cls, color="black"): Return workflow graph. Returns: - (graphviz.Digraph): Directed graph of the workflow. + (MermaidDiagram): Directed graph of the workflow in Mermaid format. """ graph = NoDashDiGraph() @@ -218,9 +218,9 @@ def get_graph(cls, color="black"): @classmethod def get_graph_svg(cls): """ - Return graph representation of a model workflow as SVG. + Return graph representation of a model workflow as Mermaid diagram. - The SVG is HTML safe and can be included in a template, e.g.: + The diagram is HTML safe and can be included in a template, e.g.: .. code-block:: html @@ -233,12 +233,14 @@ def get_graph_svg(cls): Returns: - (django.utils.safestring.SafeString): SVG representation of a running workflow. + (django.utils.safestring.SafeString): Mermaid diagram markup wrapped in HTML. """ graph = cls.get_graph() - graph.format = "svg" - return SafeString(graph.pipe(encoding="utf-8")) # nosec + mermaid_source = graph.pipe(encoding="utf-8") + # Wrap in HTML div with mermaid class for rendering + html = f'
\n{mermaid_source}\n
' + return SafeString(html) # nosec get_graph_svg.short_description = t("graph") @@ -308,9 +310,9 @@ def get_instance_graph(self): def get_instance_graph_svg(self, output_format="svg"): """ - Return graph representation of a running workflow as SVG. + Return graph representation of a running workflow as Mermaid diagram. - The SVG is HTML safe and can be included in a template, e.g.: + The diagram is HTML safe and can be included in a template, e.g.: .. code-block:: html @@ -323,12 +325,14 @@ def get_instance_graph_svg(self, output_format="svg"): Returns: - (django.utils.safestring.SafeString): SVG representation of a running workflow. + (django.utils.safestring.SafeString): Mermaid diagram markup wrapped in HTML. """ graph = self.get_instance_graph() - graph.format = output_format - return SafeString(graph.pipe(encoding="utf-8")) # nosec + mermaid_source = graph.pipe(encoding="utf-8") + # Wrap in HTML div with mermaid class for rendering + html = f'
\n{mermaid_source}\n
' + return SafeString(html) # nosec get_instance_graph_svg.short_description = t("instance graph") diff --git a/joeflow/templates/admin/change_form.html b/joeflow/templates/admin/change_form.html new file mode 100644 index 0000000..ba3a4b9 --- /dev/null +++ b/joeflow/templates/admin/change_form.html @@ -0,0 +1,10 @@ +{% extends "admin/change_form.html" %} + +{% block extrahead %} +{{ block.super }} + + +{% endblock %} diff --git a/joeflow/utils.py b/joeflow/utils.py index 3b9d775..e2e5729 100644 --- a/joeflow/utils.py +++ b/joeflow/utils.py @@ -1,76 +1,4 @@ -from collections import defaultdict +from .mermaid_utils import MermaidDiagram -import graphviz as gv - - -class NoDashDiGraph(gv.Digraph): - """ - Like `.graphviz.Digraph` but with unique nodes and edges. - - Nodes and edges are unique and their attributes will be overridden - should the same node or edge be added twice. Nodes are unique by name - and edges unique by head and tail. - - Underscores are replaced with whitespaces from identifiers. - """ - - def __init__(self, *args, **kwargs): - self._nodes = defaultdict(dict) - self._edges = defaultdict(dict) - super().__init__(*args, **kwargs) - - def __iter__(self, subgraph=False): - """Yield the DOT source code line by line (as graph or subgraph).""" - if self.comment: - yield self._comment(self.comment) - - if subgraph: - if self.strict: - raise ValueError("subgraphs cannot be strict") - head = self._subgraph if self.name else self._subgraph_plain - else: - head = self._head_strict if self.strict else self._head - yield head(self._quote(self.name) + " " if self.name else "") - - for kw in ("graph", "node", "edge"): - attrs = getattr(self, "%s_attr" % kw) - if attrs: - yield self._attr(kw, self._attr_list(None, kwargs=attrs)) - - yield from self.body - - for name, attrs in sorted(self._nodes.items()): - name = self._quote(name) - label = attrs.pop("label", None) - _attributes = attrs.pop("_attributes", None) - attr_list = self._attr_list(label, kwargs=attrs, attributes=_attributes) - yield self._node(name, attr_list) - - for edge, attrs in sorted(self._edges.items()): - tail_name, head_name = edge - tail_name = self._quote_edge(tail_name) - head_name = self._quote_edge(head_name) - label = attrs.pop("label", None) - _attributes = attrs.pop("_attributes", None) - attr_list = self._attr_list(label, kwargs=attrs, attributes=_attributes) - yield self._edge(tail=tail_name, head=head_name, attr=attr_list) - - yield self._tail - - def node(self, name, **attrs): - self._nodes[name] = attrs - - def edge(self, tail_name, head_name, **attrs): - self._edges[(tail_name, head_name)] = attrs - - @staticmethod - def _quote(identifier, *args, **kwargs): - """Remove underscores from labels.""" - identifier = identifier.replace("_", " ") - return gv.quoting.quote(identifier, *args, **kwargs) - - @staticmethod - def _quote_edge(identifier): - """Remove underscores from labels.""" - identifier = identifier.replace("_", " ") - return gv.quoting.quote_edge(identifier) +# For backwards compatibility, export MermaidDiagram as NoDashDiGraph +NoDashDiGraph = MermaidDiagram diff --git a/pyproject.toml b/pyproject.toml index be18ada..d0230ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,6 @@ requires-python = ">=3.9" dependencies = [ "django>=2.2", "django-appconf", - "graphviz>=0.18", ] [project.optional-dependencies] diff --git a/tests/commands/test_render_workflow_graph.py b/tests/commands/test_render_workflow_graph.py index d649dcf..172fb65 100644 --- a/tests/commands/test_render_workflow_graph.py +++ b/tests/commands/test_render_workflow_graph.py @@ -8,27 +8,13 @@ def test_call_no_args(): tmp_dir = Path(tempfile.mkdtemp()) call_command("render_workflow_graph", "-d", tmp_dir) - assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.svg")) - assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow")) + assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.mmd")) -def test_call_cleanup(): +def test_call_format_mermaid(): tmp_dir = Path(tempfile.mkdtemp()) - call_command("render_workflow_graph", "-d", tmp_dir, "-c") - assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.svg")) - assert not os.path.exists(str(tmp_dir / "testapp_simpleworkflow")) - - -def test_call_format_pdf(): - tmp_dir = Path(tempfile.mkdtemp()) - call_command("render_workflow_graph", "-d", tmp_dir, "-f", "pdf") - assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.pdf")) - - -def test_call_format_png(): - tmp_dir = Path(tempfile.mkdtemp()) - call_command("render_workflow_graph", "-d", tmp_dir, "-f", "png") - assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.png")) + call_command("render_workflow_graph", "-d", tmp_dir, "-f", "mermaid") + assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.mmd")) def test_call_explicit_workflow(): @@ -40,9 +26,9 @@ def test_call_explicit_workflow(): "testapp.loopworkflow", "testapp.splitjoinworkflow", ) - assert not os.path.exists(str(tmp_dir / "testapp_simpleworkflow.svg")) - assert os.path.exists(str(tmp_dir / "testapp_loopworkflow.svg")) - assert os.path.exists(str(tmp_dir / "testapp_splitjoinworkflow.svg")) + assert not os.path.exists(str(tmp_dir / "testapp_simpleworkflow.mmd")) + assert os.path.exists(str(tmp_dir / "testapp_loopworkflow.mmd")) + assert os.path.exists(str(tmp_dir / "testapp_splitjoinworkflow.mmd")) def test_call_explicit_workflow_invalid(): @@ -50,6 +36,6 @@ def test_call_explicit_workflow_invalid(): call_command( "render_workflow_graph", "-d", tmp_dir, "auth.user", "testapp.splitjoinworkflow" ) - assert not os.path.exists(str(tmp_dir / "testapp_simpleworkflow.svg")) - assert not os.path.exists(str(tmp_dir / "auth_user.svg")) - assert os.path.exists(str(tmp_dir / "testapp_splitjoinworkflow.svg")) + assert not os.path.exists(str(tmp_dir / "testapp_simpleworkflow.mmd")) + assert not os.path.exists(str(tmp_dir / "auth_user.mmd")) + assert os.path.exists(str(tmp_dir / "testapp_splitjoinworkflow.mmd")) diff --git a/tests/fixtures/simpleworkflow.mmd b/tests/fixtures/simpleworkflow.mmd new file mode 100644 index 0000000..2732515 --- /dev/null +++ b/tests/fixtures/simpleworkflow.mmd @@ -0,0 +1,15 @@ +graph LR + custom_start_view(custom start view) + end[end] + save_the_princess(save the princess) + start_method[start method] + start_view(start view) + custom_start_view --> save_the_princess + save_the_princess --> end + start_method --> save_the_princess + start_view --> save_the_princess + style custom_start_view fill:white,stroke:#000,stroke-width:2px,color:#000 + style end fill:white,stroke:#000,stroke-width:2px,color:#000 + style save_the_princess fill:white,stroke:#000,stroke-width:2px,color:#000 + style start_method fill:white,stroke:#000,stroke-width:2px,color:#000 + style start_view fill:white,stroke:#000,stroke-width:2px,color:#000 diff --git a/tests/fixtures/simpleworkflow_instance.mmd b/tests/fixtures/simpleworkflow_instance.mmd new file mode 100644 index 0000000..81820e6 --- /dev/null +++ b/tests/fixtures/simpleworkflow_instance.mmd @@ -0,0 +1,19 @@ +graph LR + custom_start_view(custom start view) + end[end] + save_the_princess(save the princess) + click save_the_princess "{url}" + start_method[start method] + start_view(start view) + custom_start_view --> save_the_princess + save_the_princess --> end + start_method --> save_the_princess + start_view --> save_the_princess + style custom_start_view fill:white,stroke:#888888,stroke-width:2px,color:#888888 + style end fill:white,stroke:#888888,stroke-width:2px,color:#888888 + style save_the_princess fill:white,stroke:#000,stroke-width:3px,color:#000 + style start_method fill:white,stroke:#000,stroke-width:2px,color:#000 + style start_view fill:white,stroke:#888888,stroke-width:2px,color:#888888 + linkStyle 0 stroke:#888888 + linkStyle 1 stroke:#888888 + linkStyle 3 stroke:#888888 diff --git a/tests/test_models.py b/tests/test_models.py index 00f96b0..c21964b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,9 +3,9 @@ from django.contrib.auth.models import AnonymousUser from django.urls import reverse from django.utils.safestring import SafeString -from graphviz import Digraph from joeflow.models import Task, Workflow +from joeflow.mermaid_utils import MermaidDiagram from joeflow.tasks import HUMAN, MACHINE, StartView from tests.testapp import models, workflows @@ -105,8 +105,8 @@ class Meta: def test_get_graph(self, fixturedir): graph = workflows.SimpleWorkflow.get_graph() - assert isinstance(graph, Digraph) - with open(str(fixturedir / "simpleworkflow.dot")) as fp: + assert isinstance(graph, MermaidDiagram) + with open(str(fixturedir / "simpleworkflow.mmd")) as fp: expected_graph = fp.read().splitlines() print(str(graph)) assert set(str(graph).splitlines()) == set(expected_graph) @@ -114,7 +114,7 @@ def test_get_graph(self, fixturedir): def test_change_graph_direction(self, fixturedir): workflows.SimpleWorkflow.rankdir = "TD" graph = workflows.SimpleWorkflow.get_graph() - assert "rankdir=TD" in str(graph) + assert "graph TD" in str(graph) def test_get_graph_svg(self, fixturedir): svg = workflows.SimpleWorkflow.get_graph_svg() @@ -125,7 +125,7 @@ def test_get_instance_graph(self, db, fixturedir): task_url = wf.task_set.get(name="save_the_princess").get_absolute_url() graph = wf.get_instance_graph() print(str(graph)) - with open(str(fixturedir / "simpleworkflow_instance.dot")) as fp: + with open(str(fixturedir / "simpleworkflow_instance.mmd")) as fp: assert set(str(graph).splitlines()) == set( fp.read().replace("{url}", task_url).splitlines() ) @@ -141,16 +141,16 @@ def test_get_instance_graph__override( task = wf.task_set.get(name="override") graph = wf.get_instance_graph() print(str(graph)) + graph_str = str(graph) - assert ( - f'\t"{task.name} {task.pk}" [peripheries=1 style="filled, rounded, dashed"]\n' - in list(graph) - ) - assert ( - f'\t"save the princess" -> "{task.name} {task.pk}" [style=dashed]\n' - in list(graph) - ) - assert f'\t"{task.name} {task.pk}" -> end [style=dashed]\n' in list(graph) + # Check for override node (rounded with dashed style) + override_node_id = f"override_{task.pk}" + assert f"{override_node_id}(override {task.pk})" in graph_str + # Check for dashed edges + assert f"save_the_princess --> {override_node_id}" in graph_str + assert f"{override_node_id} --> end" in graph_str + # Check for dashed edge styling + assert "stroke-dasharray" in graph_str def test_get_instance_graph__obsolete(self, db, fixturedir, admin_client): workflow = workflows.SimpleWorkflow.objects.create() @@ -161,13 +161,15 @@ def test_get_instance_graph__obsolete(self, db, fixturedir, admin_client): end.parent_task_set.add(obsolete) graph = workflow.get_instance_graph() print(str(graph)) - - assert ( - '\tobsolete [color=black fontcolor=black peripheries=1 style="filled, dashed, bold"]' - in str(graph) - ) - assert '\t"start method" -> obsolete [style=dashed]\n' in list(graph) - assert "\tobsolete -> end [style=dashed]\n" in list(graph) + graph_str = str(graph) + + # Check for obsolete node (with dashed and bold styling) + assert "obsolete[obsolete]" in graph_str + # Check for dashed edges + assert "start_method --> obsolete" in graph_str + assert "obsolete --> end" in graph_str + # Check for dashed styling + assert "stroke-dasharray" in graph_str def test_get_instance_graph_svg(self, db, fixturedir): wf = workflows.SimpleWorkflow.start_method() diff --git a/tests/test_utils.py b/tests/test_utils.py index d1a70e8..595ca61 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,32 +5,26 @@ class TestNoDashDiGraph: def test_node(self): graph = NoDashDiGraph() graph.node("foo", color="blue") - assert list(graph) == [ - "digraph {\n", - "\tfoo [color=blue]\n", - "}\n", - ] + graph_str = str(graph) + assert "graph LR" in graph_str + assert "foo[foo]" in graph_str + # Test that updating node works graph.node("foo", color="red") - assert list(graph) == [ - "digraph {\n", - "\tfoo [color=red]\n", - "}\n", - ] + graph_str = str(graph) + assert "graph LR" in graph_str + assert "foo[foo]" in graph_str def test_edge(self): graph = NoDashDiGraph() graph.edge("foo", "bar", color="blue") - assert list(graph) == [ - "digraph {\n", - "\tfoo -> bar [color=blue]\n", - "}\n", - ] + graph_str = str(graph) + assert "graph LR" in graph_str + assert "foo --> bar" in graph_str + # Test that updating edge works graph.edge("foo", "bar", color="red") - assert list(graph) == [ - "digraph {\n", - "\tfoo -> bar [color=red]\n", - "}\n", - ] + graph_str = str(graph) + assert "graph LR" in graph_str + assert "foo --> bar" in graph_str def test_iter(self): graph = NoDashDiGraph(node_attr={"style": "filled"}) @@ -39,35 +33,16 @@ def test_iter(self): graph.edge("foo", "bar", color="blue") graph.comment = "This is a comment." print(str(graph)) - assert list(graph.__iter__()) == [ - "// This is a comment.\n", - "digraph {\n", - "\tnode [style=filled]\n", - "\tbar [color=green]\n", - "\tfoo [color=red]\n", - "\tfoo -> bar [color=blue]\n", - "}\n", - ] + graph_str = str(graph) + assert "%% This is a comment." in graph_str + assert "graph LR" in graph_str + assert "bar[bar]" in graph_str + assert "foo[foo]" in graph_str + assert "foo --> bar" in graph_str - def test_iter__subgraph(self): - graph = NoDashDiGraph(node_attr={"style": "filled"}) - graph.node("foo", color="red") - graph.node("bar", color="green") - graph.edge("foo", "bar", color="blue") - graph.comment = "This is a comment." - print(str(graph)) - assert list(graph.__iter__(subgraph=True)) == [ - "// This is a comment.\n", - "{\n", - "\tnode [style=filled]\n", - "\tbar [color=green]\n", - "\tfoo [color=red]\n", - "\tfoo -> bar [color=blue]\n", - "}\n", - ] - - def test_quote(self): - assert NoDashDiGraph._quote("foo_bar") == '"foo bar"' + def test_sanitize_id(self): + assert NoDashDiGraph._sanitize_id("foo_bar") == "foo_bar" + assert NoDashDiGraph._sanitize_id("foo bar") == "foo_bar" - def test_quote_edge(self): - assert NoDashDiGraph._quote_edge("foo_bar") == '"foo bar"' + def test_format_label(self): + assert NoDashDiGraph._format_label("foo_bar") == "foo bar" diff --git a/tests/testapp/templates/testapp/base.html b/tests/testapp/templates/testapp/base.html index 580fcc2..e04a80f 100644 --- a/tests/testapp/templates/testapp/base.html +++ b/tests/testapp/templates/testapp/base.html @@ -1,6 +1,11 @@ + +
From 8d118adcab062433b49e2437894ec20860223dbd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:09:51 +0000 Subject: [PATCH 3/7] Update documentation to reference Mermaid instead of Graphviz Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .readthedocs.yaml | 2 -- docs/commands.rst | 11 +++++------ docs/conf.py | 3 --- docs/index.rst | 35 +++++++++++++++++++---------------- docs/tutorial/workflow.rst | 29 +++++++++++++++-------------- 6 files changed, 41 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d088ae..5d31658 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/setup-python@v6 with: python-version: "3.x" - - run: sudo apt install -y python3-enchant graphviz + - run: sudo apt install -y python3-enchant - run: python -m pip install sphinxcontrib-spelling - run: python -m pip install -e '.[docs]' - run: python -m sphinx -W -b spelling docs docs/_build @@ -83,7 +83,7 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - run: sudo apt install -y graphviz redis-server + - run: sudo apt install -y redis-server - run: python -m pip install "django==${{ matrix.django-version }}.*" - run: python -m pip install -e .[${{ matrix.extras }}] - run: python -m pytest diff --git a/.readthedocs.yaml b/.readthedocs.yaml index f97f047..bb73bc5 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,8 +8,6 @@ build: os: ubuntu-20.04 tools: python: "3.11" - apt_packages: - - graphviz sphinx: configuration: docs/conf.py diff --git a/docs/commands.rst b/docs/commands.rst index cee1448..995a6be 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -9,10 +9,10 @@ render_workflow_graph Render workflow graph to file:: - usage: manage.py render_workflow_graph [-h] [-f {svg,pdf,png}] [-d DIRECTORY] - [-c] [model [model ...]] + usage: manage.py render_workflow_graph [-h] [-f {mmd,mermaid}] [-d DIRECTORY] + [workflow [workflow ...]] - Render workflow graph to file. + Render workflow graph to file in Mermaid format. positional arguments: workflow List of workflow to render in the form @@ -20,9 +20,8 @@ Render workflow graph to file:: optional arguments: -h, --help show this help message and exit - -f {svg,pdf,png}, --format {svg,pdf,png} - Output file format. Default: svg + -f {mmd,mermaid}, --format {mmd,mermaid} + Output file format. Default: mmd (Mermaid markdown) -d DIRECTORY, --directory DIRECTORY Output directory. Default is current working directory. - -c, --cleanup Remove dot-files after rendering. diff --git a/docs/conf.py b/docs/conf.py index 7e1979b..1459250 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -71,14 +71,11 @@ def linkcode_resolve(domain, info): ), "dramatiq": ("https://dramatiq.io/", None), "celery": ("https://docs.celeryproject.org/en/stable/", None), - "graphviz": ("https://graphviz.readthedocs.io/en/stable/", None), } spelling_word_list_filename = "spelling_wordlist.txt" spelling_show_suggestions = True -graphviz_output_format = "svg" - inheritance_graph_attrs = dict( rankdir="TB", size='"6.0, 8.0"', fontsize=14, ratio="compress" ) diff --git a/docs/index.rst b/docs/index.rst index db739bc..19d2492 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,22 +17,25 @@ Django_ web framework. Here is a little sample of what a workflow or process written with joeflow may look like: -.. graphviz:: - - digraph { - graph [rankdir=LR] - node [fillcolor=white fontname="sans-serif" shape=rect style=filled] - checkout [color=black fontcolor=black style="filled, rounded"] - "has email" [color=black fontcolor=black style=filled] - ship [color=black fontcolor=black style="filled, rounded"] - end [color=black fontcolor=black style=filled peripheries=2] - "send tracking code" [color=black fontcolor=black style=filled] - checkout -> ship - ship -> "has email" - "has email" -> "send tracking code" - "has email" -> end [color="#888888"] - "send tracking code" -> end - } +.. code-block:: mermaid + + graph LR + checkout(checkout) + has_email[has email] + ship(ship) + end[end] + send_tracking_code[send tracking code] + checkout --> ship + ship --> has_email + has_email --> send_tracking_code + has_email --> end + send_tracking_code --> end + style checkout fill:white,stroke:#000,stroke-width:2px,color:#000 + style has_email fill:white,stroke:#000,stroke-width:2px,color:#000 + style ship fill:white,stroke:#000,stroke-width:2px,color:#000 + style end fill:white,stroke:#000,stroke-width:4px,color:#000 + style send_tracking_code fill:white,stroke:#000,stroke-width:2px,color:#000 + linkStyle 3 stroke:#888888 .. code-block:: python diff --git a/docs/tutorial/workflow.rst b/docs/tutorial/workflow.rst index 4148903..b8c83a2 100644 --- a/docs/tutorial/workflow.rst +++ b/docs/tutorial/workflow.rst @@ -8,20 +8,21 @@ user. A human selects the user (or leaves the field blank). If the user is set a welcome emails is being sent. If the user is blank no email will be send and the workflow will end right way. -.. graphviz:: - - digraph { - graph [rankdir=LR] - node [fillcolor=white fontname="Georgia, serif" shape=rect style=filled] - start [color=black fontcolor=black style="filled, rounded"] - "send welcome email" [color=black fontcolor=black style=filled] - end [color=black fontcolor=black style=filled] - "has user" [color=black fontcolor=black style=filled] - start -> "has user" - "has user" -> end - "has user" -> "send welcome email" - "send welcome email" -> end - } +.. code-block:: mermaid + + graph LR + start(start) + send_welcome_email[send welcome email] + end[end] + has_user[has user] + start --> has_user + has_user --> end + has_user --> send_welcome_email + send_welcome_email --> end + style start fill:white,stroke:#000,stroke-width:2px,color:#000 + style send_welcome_email fill:white,stroke:#000,stroke-width:2px,color:#000 + style end fill:white,stroke:#000,stroke-width:2px,color:#000 + style has_user fill:white,stroke:#000,stroke-width:2px,color:#000 Let's start with the data structure or workflow state. We need a model that can store a user. Like so: From d1696a8d09e4a81afff5ae2f8d3e908a32d09396 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:13:55 +0000 Subject: [PATCH 4/7] Refactor: Extract color and style constants in MermaidDiagram Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- joeflow/mermaid_utils.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/joeflow/mermaid_utils.py b/joeflow/mermaid_utils.py index 26cc9af..26a909e 100644 --- a/joeflow/mermaid_utils.py +++ b/joeflow/mermaid_utils.py @@ -1,6 +1,14 @@ """Utilities for generating Mermaid diagrams.""" from collections import defaultdict +# Color constants +COLOR_BLACK = "#000" +COLOR_GRAY = "#888888" +COLOR_WHITE = "white" + +# Style constants +STROKE_DASHARRAY = "5 5" + class MermaidDiagram: """ @@ -79,15 +87,15 @@ def _generate_node_styles(self): style = attrs.get("style", "") # Map colors - if color == "#888888": - stroke_color = "#888888" + if color == COLOR_GRAY: + stroke_color = COLOR_GRAY else: - stroke_color = "#000" + stroke_color = COLOR_BLACK - if fontcolor == "#888888": - text_color = "#888888" + if fontcolor == COLOR_GRAY: + text_color = COLOR_GRAY else: - text_color = "#000" + text_color = COLOR_BLACK # Determine stroke width based on bold if "bold" in style: @@ -97,7 +105,7 @@ def _generate_node_styles(self): # Determine stroke style based on dashed if "dashed" in style: - stroke_style = "stroke-dasharray: 5 5" + stroke_style = f"stroke-dasharray: {STROKE_DASHARRAY}" else: stroke_style = "" @@ -131,12 +139,12 @@ def _generate_edge_styles(self): # Determine link style based on attributes if "dashed" in style: # Mermaid uses linkStyle to style edges - if color == "#888888": - edge_styles[idx] = "stroke:#888888,stroke-dasharray: 5 5" + if color == COLOR_GRAY: + edge_styles[idx] = f"stroke:{COLOR_GRAY},stroke-dasharray: {STROKE_DASHARRAY}" else: - edge_styles[idx] = "stroke:#000,stroke-dasharray: 5 5" - elif color == "#888888": - edge_styles[idx] = "stroke:#888888" + edge_styles[idx] = f"stroke:{COLOR_BLACK},stroke-dasharray: {STROKE_DASHARRAY}" + elif color == COLOR_GRAY: + edge_styles[idx] = f"stroke:{COLOR_GRAY}" # else: default black stroke # Generate linkStyle commands From b86b6acfd27165c8169e7d26bbb4b1e0fc9518f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 1 Nov 2025 14:38:49 +0000 Subject: [PATCH 5/7] Add SVG rendering via mermaid.ink API and restore SVG as default format Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- docs/commands.rst | 8 +- .../commands/render_workflow_graph.py | 6 +- joeflow/mermaid_utils.py | 74 +++++++++++++++---- joeflow/models.py | 24 +++--- pyproject.toml | 1 + 5 files changed, 79 insertions(+), 34 deletions(-) diff --git a/docs/commands.rst b/docs/commands.rst index 995a6be..ee02e5b 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -9,10 +9,10 @@ render_workflow_graph Render workflow graph to file:: - usage: manage.py render_workflow_graph [-h] [-f {mmd,mermaid}] [-d DIRECTORY] + usage: manage.py render_workflow_graph [-h] [-f {svg,mmd,mermaid}] [-d DIRECTORY] [workflow [workflow ...]] - Render workflow graph to file in Mermaid format. + Render workflow graph to file. positional arguments: workflow List of workflow to render in the form @@ -20,8 +20,8 @@ Render workflow graph to file:: optional arguments: -h, --help show this help message and exit - -f {mmd,mermaid}, --format {mmd,mermaid} - Output file format. Default: mmd (Mermaid markdown) + -f {svg,mmd,mermaid}, --format {svg,mmd,mermaid} + Output file format. Default: svg -d DIRECTORY, --directory DIRECTORY Output directory. Default is current working directory. diff --git a/joeflow/management/commands/render_workflow_graph.py b/joeflow/management/commands/render_workflow_graph.py index 1acafef..71fffdc 100644 --- a/joeflow/management/commands/render_workflow_graph.py +++ b/joeflow/management/commands/render_workflow_graph.py @@ -18,9 +18,9 @@ def add_arguments(self, parser): "--format", dest="format", type=str, - choices=("mmd", "mermaid"), - default="mmd", - help="Output file format. Default: mmd (Mermaid markdown)", + choices=("svg", "mmd", "mermaid"), + default="svg", + help="Output file format. Default: svg", ) parser.add_argument( "-d", diff --git a/joeflow/mermaid_utils.py b/joeflow/mermaid_utils.py index 26a909e..f5f4763 100644 --- a/joeflow/mermaid_utils.py +++ b/joeflow/mermaid_utils.py @@ -1,5 +1,15 @@ """Utilities for generating Mermaid diagrams.""" from collections import defaultdict +import urllib.parse +import urllib.request +import base64 + +try: + from mermaid import Mermaid + from mermaid.graph import Graph + MERMAID_PKG_AVAILABLE = True +except ImportError: + MERMAID_PKG_AVAILABLE = False # Color constants COLOR_BLACK = "#000" @@ -24,6 +34,7 @@ class MermaidDiagram: def __init__(self, name="", comment=None, **kwargs): self.name = name self.comment = comment + self.format = "svg" # Default format for compatibility with graphviz self.graph_attr = {} self.node_attr = {} self.edge_attr = {} @@ -209,18 +220,47 @@ def pipe(self, format="svg", encoding="utf-8"): """ Return the diagram in the specified format. - For Mermaid, we return the source wrapped in appropriate HTML. - This is meant for compatibility with the graphviz API. + For SVG format, renders via mermaid.ink API. + For other formats, returns the Mermaid source. + + This maintains compatibility with the graphviz API. """ source = self.source() + if format == "svg": - # Return raw mermaid source - rendering happens client-side - return source - elif format == "png" or format == "pdf": - # For file formats, return the source as-is - # The management command will handle file writing - return source - return source + # Render to SVG using mermaid.ink API + try: + svg_content = self._render_to_svg(source) + return svg_content if encoding else svg_content.encode('utf-8') + except Exception: + # Fallback to source if rendering fails + return source if encoding else source.encode('utf-8') + else: + # For other formats, return the Mermaid source + return source if encoding else source.encode('utf-8') + + def _render_to_svg(self, mermaid_source): + """ + Render Mermaid source to SVG using mermaid.ink API. + + Args: + mermaid_source: Mermaid diagram source code + + Returns: + SVG content as string + """ + # Use mermaid.ink API to render + # https://mermaid.ink/svg/ + encoded = base64.b64encode(mermaid_source.encode('utf-8')).decode('ascii') + url = f"https://mermaid.ink/svg/{encoded}" + + try: + with urllib.request.urlopen(url, timeout=10) as response: + svg_content = response.read().decode('utf-8') + return svg_content + except Exception as e: + # If API call fails, return a fallback SVG with error message + raise Exception(f"Failed to render via mermaid.ink: {e}") def render(self, filename, directory=None, format="svg", cleanup=False): """ @@ -229,17 +269,25 @@ def render(self, filename, directory=None, format="svg", cleanup=False): Args: filename: Base filename (without extension) directory: Output directory - format: Output format (svg, png, pdf) - for compatibility + format: Output format (svg or mmd) cleanup: Cleanup intermediate files (not used for Mermaid) """ import os + # Determine file extension and content based on format + if format == "svg": + ext = "svg" + content = self.pipe(format="svg", encoding="utf-8") + else: + ext = "mmd" + content = self.source() + if directory: - filepath = os.path.join(directory, f"{filename}.mmd") + filepath = os.path.join(directory, f"{filename}.{ext}") else: - filepath = f"{filename}.mmd" + filepath = f"{filename}.{ext}" with open(filepath, "w", encoding="utf-8") as f: - f.write(self.source()) + f.write(content) return filepath diff --git a/joeflow/models.py b/joeflow/models.py index 8d7d492..60c77ee 100644 --- a/joeflow/models.py +++ b/joeflow/models.py @@ -218,9 +218,9 @@ def get_graph(cls, color="black"): @classmethod def get_graph_svg(cls): """ - Return graph representation of a model workflow as Mermaid diagram. + Return graph representation of a model workflow as SVG. - The diagram is HTML safe and can be included in a template, e.g.: + The SVG is HTML safe and can be included in a template, e.g.: .. code-block:: html @@ -233,14 +233,12 @@ def get_graph_svg(cls): Returns: - (django.utils.safestring.SafeString): Mermaid diagram markup wrapped in HTML. + (django.utils.safestring.SafeString): SVG representation of the workflow. """ graph = cls.get_graph() - mermaid_source = graph.pipe(encoding="utf-8") - # Wrap in HTML div with mermaid class for rendering - html = f'
\n{mermaid_source}\n
' - return SafeString(html) # nosec + graph.format = "svg" + return SafeString(graph.pipe(encoding="utf-8")) # nosec get_graph_svg.short_description = t("graph") @@ -310,9 +308,9 @@ def get_instance_graph(self): def get_instance_graph_svg(self, output_format="svg"): """ - Return graph representation of a running workflow as Mermaid diagram. + Return graph representation of a running workflow as SVG. - The diagram is HTML safe and can be included in a template, e.g.: + The SVG is HTML safe and can be included in a template, e.g.: .. code-block:: html @@ -325,14 +323,12 @@ def get_instance_graph_svg(self, output_format="svg"): Returns: - (django.utils.safestring.SafeString): Mermaid diagram markup wrapped in HTML. + (django.utils.safestring.SafeString): SVG representation of a running workflow. """ graph = self.get_instance_graph() - mermaid_source = graph.pipe(encoding="utf-8") - # Wrap in HTML div with mermaid class for rendering - html = f'
\n{mermaid_source}\n
' - return SafeString(html) # nosec + graph.format = output_format + return SafeString(graph.pipe(encoding="utf-8")) # nosec get_instance_graph_svg.short_description = t("instance graph") diff --git a/pyproject.toml b/pyproject.toml index d0230ab..ee782f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ requires-python = ">=3.9" dependencies = [ "django>=2.2", "django-appconf", + "mermaid>=0.3.2", ] [project.optional-dependencies] From 864826a023072580dbb3317f0a3f950547e85519 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 1 Nov 2025 14:41:06 +0000 Subject: [PATCH 6/7] Update tests to expect SVG as default format Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- tests/commands/test_render_workflow_graph.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/commands/test_render_workflow_graph.py b/tests/commands/test_render_workflow_graph.py index 172fb65..6751bd8 100644 --- a/tests/commands/test_render_workflow_graph.py +++ b/tests/commands/test_render_workflow_graph.py @@ -8,7 +8,7 @@ def test_call_no_args(): tmp_dir = Path(tempfile.mkdtemp()) call_command("render_workflow_graph", "-d", tmp_dir) - assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.mmd")) + assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.svg")) def test_call_format_mermaid(): @@ -17,6 +17,12 @@ def test_call_format_mermaid(): assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.mmd")) +def test_call_format_svg(): + tmp_dir = Path(tempfile.mkdtemp()) + call_command("render_workflow_graph", "-d", tmp_dir, "-f", "svg") + assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.svg")) + + def test_call_explicit_workflow(): tmp_dir = Path(tempfile.mkdtemp()) call_command( @@ -26,9 +32,9 @@ def test_call_explicit_workflow(): "testapp.loopworkflow", "testapp.splitjoinworkflow", ) - assert not os.path.exists(str(tmp_dir / "testapp_simpleworkflow.mmd")) - assert os.path.exists(str(tmp_dir / "testapp_loopworkflow.mmd")) - assert os.path.exists(str(tmp_dir / "testapp_splitjoinworkflow.mmd")) + assert not os.path.exists(str(tmp_dir / "testapp_simpleworkflow.svg")) + assert os.path.exists(str(tmp_dir / "testapp_loopworkflow.svg")) + assert os.path.exists(str(tmp_dir / "testapp_splitjoinworkflow.svg")) def test_call_explicit_workflow_invalid(): @@ -36,6 +42,6 @@ def test_call_explicit_workflow_invalid(): call_command( "render_workflow_graph", "-d", tmp_dir, "auth.user", "testapp.splitjoinworkflow" ) - assert not os.path.exists(str(tmp_dir / "testapp_simpleworkflow.mmd")) - assert not os.path.exists(str(tmp_dir / "auth_user.mmd")) - assert os.path.exists(str(tmp_dir / "testapp_splitjoinworkflow.mmd")) + assert not os.path.exists(str(tmp_dir / "testapp_simpleworkflow.svg")) + assert not os.path.exists(str(tmp_dir / "auth_user.svg")) + assert os.path.exists(str(tmp_dir / "testapp_splitjoinworkflow.svg")) From f84cf3cdcd178382281575b9c27f44a4b2846567 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:31:22 +0000 Subject: [PATCH 7/7] Use MermaidJS in admin frontend, make graphviz optional - Removed graphviz from required dependencies - Added graphviz as optional dependency (extras: graphviz, docs) - Added get_graph_mermaid() and get_instance_graph_mermaid() methods - Updated admin to use MermaidJS for client-side diagram rendering - Added admin template with MermaidJS CDN for browser rendering - Reverted all previous Mermaid server-side rendering changes Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- .github/workflows/ci.yml | 4 +- .readthedocs.yaml | 2 + docs/commands.rst | 7 +- docs/conf.py | 3 + docs/index.rst | 35 +-- docs/tutorial/workflow.rst | 29 +- joeflow/admin.py | 15 +- .../commands/render_workflow_graph.py | 14 +- joeflow/mermaid_utils.py | 293 ------------------ joeflow/models.py | 72 ++++- joeflow/utils.py | 78 ++++- pyproject.toml | 5 +- tests/commands/test_render_workflow_graph.py | 20 +- tests/fixtures/simpleworkflow.mmd | 15 - tests/fixtures/simpleworkflow_instance.mmd | 19 -- tests/test_models.py | 44 ++- tests/test_utils.py | 75 +++-- tests/testapp/templates/testapp/base.html | 5 - 18 files changed, 300 insertions(+), 435 deletions(-) delete mode 100644 joeflow/mermaid_utils.py delete mode 100644 tests/fixtures/simpleworkflow.mmd delete mode 100644 tests/fixtures/simpleworkflow_instance.mmd diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d31658..8d088ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/setup-python@v6 with: python-version: "3.x" - - run: sudo apt install -y python3-enchant + - run: sudo apt install -y python3-enchant graphviz - run: python -m pip install sphinxcontrib-spelling - run: python -m pip install -e '.[docs]' - run: python -m sphinx -W -b spelling docs docs/_build @@ -83,7 +83,7 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - run: sudo apt install -y redis-server + - run: sudo apt install -y graphviz redis-server - run: python -m pip install "django==${{ matrix.django-version }}.*" - run: python -m pip install -e .[${{ matrix.extras }}] - run: python -m pytest diff --git a/.readthedocs.yaml b/.readthedocs.yaml index bb73bc5..f97f047 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,6 +8,8 @@ build: os: ubuntu-20.04 tools: python: "3.11" + apt_packages: + - graphviz sphinx: configuration: docs/conf.py diff --git a/docs/commands.rst b/docs/commands.rst index ee02e5b..cee1448 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -9,8 +9,8 @@ render_workflow_graph Render workflow graph to file:: - usage: manage.py render_workflow_graph [-h] [-f {svg,mmd,mermaid}] [-d DIRECTORY] - [workflow [workflow ...]] + usage: manage.py render_workflow_graph [-h] [-f {svg,pdf,png}] [-d DIRECTORY] + [-c] [model [model ...]] Render workflow graph to file. @@ -20,8 +20,9 @@ Render workflow graph to file:: optional arguments: -h, --help show this help message and exit - -f {svg,mmd,mermaid}, --format {svg,mmd,mermaid} + -f {svg,pdf,png}, --format {svg,pdf,png} Output file format. Default: svg -d DIRECTORY, --directory DIRECTORY Output directory. Default is current working directory. + -c, --cleanup Remove dot-files after rendering. diff --git a/docs/conf.py b/docs/conf.py index 1459250..7e1979b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -71,11 +71,14 @@ def linkcode_resolve(domain, info): ), "dramatiq": ("https://dramatiq.io/", None), "celery": ("https://docs.celeryproject.org/en/stable/", None), + "graphviz": ("https://graphviz.readthedocs.io/en/stable/", None), } spelling_word_list_filename = "spelling_wordlist.txt" spelling_show_suggestions = True +graphviz_output_format = "svg" + inheritance_graph_attrs = dict( rankdir="TB", size='"6.0, 8.0"', fontsize=14, ratio="compress" ) diff --git a/docs/index.rst b/docs/index.rst index 19d2492..db739bc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,25 +17,22 @@ Django_ web framework. Here is a little sample of what a workflow or process written with joeflow may look like: -.. code-block:: mermaid - - graph LR - checkout(checkout) - has_email[has email] - ship(ship) - end[end] - send_tracking_code[send tracking code] - checkout --> ship - ship --> has_email - has_email --> send_tracking_code - has_email --> end - send_tracking_code --> end - style checkout fill:white,stroke:#000,stroke-width:2px,color:#000 - style has_email fill:white,stroke:#000,stroke-width:2px,color:#000 - style ship fill:white,stroke:#000,stroke-width:2px,color:#000 - style end fill:white,stroke:#000,stroke-width:4px,color:#000 - style send_tracking_code fill:white,stroke:#000,stroke-width:2px,color:#000 - linkStyle 3 stroke:#888888 +.. graphviz:: + + digraph { + graph [rankdir=LR] + node [fillcolor=white fontname="sans-serif" shape=rect style=filled] + checkout [color=black fontcolor=black style="filled, rounded"] + "has email" [color=black fontcolor=black style=filled] + ship [color=black fontcolor=black style="filled, rounded"] + end [color=black fontcolor=black style=filled peripheries=2] + "send tracking code" [color=black fontcolor=black style=filled] + checkout -> ship + ship -> "has email" + "has email" -> "send tracking code" + "has email" -> end [color="#888888"] + "send tracking code" -> end + } .. code-block:: python diff --git a/docs/tutorial/workflow.rst b/docs/tutorial/workflow.rst index b8c83a2..4148903 100644 --- a/docs/tutorial/workflow.rst +++ b/docs/tutorial/workflow.rst @@ -8,21 +8,20 @@ user. A human selects the user (or leaves the field blank). If the user is set a welcome emails is being sent. If the user is blank no email will be send and the workflow will end right way. -.. code-block:: mermaid - - graph LR - start(start) - send_welcome_email[send welcome email] - end[end] - has_user[has user] - start --> has_user - has_user --> end - has_user --> send_welcome_email - send_welcome_email --> end - style start fill:white,stroke:#000,stroke-width:2px,color:#000 - style send_welcome_email fill:white,stroke:#000,stroke-width:2px,color:#000 - style end fill:white,stroke:#000,stroke-width:2px,color:#000 - style has_user fill:white,stroke:#000,stroke-width:2px,color:#000 +.. graphviz:: + + digraph { + graph [rankdir=LR] + node [fillcolor=white fontname="Georgia, serif" shape=rect style=filled] + start [color=black fontcolor=black style="filled, rounded"] + "send welcome email" [color=black fontcolor=black style=filled] + end [color=black fontcolor=black style=filled] + "has user" [color=black fontcolor=black style=filled] + start -> "has user" + "has user" -> end + "has user" -> "send welcome email" + "send welcome email" -> end + } Let's start with the data structure or workflow state. We need a model that can store a user. Like so: diff --git a/joeflow/admin.py b/joeflow/admin.py index 07f430d..7f7dbc5 100644 --- a/joeflow/admin.py +++ b/joeflow/admin.py @@ -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( + '
{}
', + mermaid_syntax + ) + return "" + @transaction.atomic() def save_model(self, request, obj, form, change): super().save_model(request, obj, form, change) diff --git a/joeflow/management/commands/render_workflow_graph.py b/joeflow/management/commands/render_workflow_graph.py index 71fffdc..8bc8353 100644 --- a/joeflow/management/commands/render_workflow_graph.py +++ b/joeflow/management/commands/render_workflow_graph.py @@ -18,7 +18,7 @@ def add_arguments(self, parser): "--format", dest="format", type=str, - choices=("svg", "mmd", "mermaid"), + choices=("svg", "pdf", "png"), default="svg", help="Output file format. Default: svg", ) @@ -29,12 +29,19 @@ def add_arguments(self, parser): type=str, help="Output directory. Default is current working directory.", ) - + parser.add_argument( + "-c", + "--cleanup", + dest="cleanup", + action="store_true", + help="Remove dot-files after rendering.", + ) def handle(self, *args, **options): workflows = options["workflow"] verbosity = options["verbosity"] file_format = options["format"] + cleanup = options["cleanup"] directory = options.get("directory", None) workflows = [ @@ -52,7 +59,8 @@ def handle(self, *args, **options): ) filename = f"{opt.app_label}_{workflow.__name__}".lower() graph = workflow.get_graph() - graph.render(filename=filename, directory=directory, format=file_format) + graph.format = file_format + graph.render(filename=filename, directory=directory, cleanup=cleanup) if verbosity > 0: self.stdout.write("Done!", self.style.SUCCESS) else: diff --git a/joeflow/mermaid_utils.py b/joeflow/mermaid_utils.py deleted file mode 100644 index f5f4763..0000000 --- a/joeflow/mermaid_utils.py +++ /dev/null @@ -1,293 +0,0 @@ -"""Utilities for generating Mermaid diagrams.""" -from collections import defaultdict -import urllib.parse -import urllib.request -import base64 - -try: - from mermaid import Mermaid - from mermaid.graph import Graph - MERMAID_PKG_AVAILABLE = True -except ImportError: - MERMAID_PKG_AVAILABLE = False - -# Color constants -COLOR_BLACK = "#000" -COLOR_GRAY = "#888888" -COLOR_WHITE = "white" - -# Style constants -STROKE_DASHARRAY = "5 5" - - -class MermaidDiagram: - """ - Generate Mermaid diagram syntax for workflow visualization. - - Similar to graphviz.Digraph but generates Mermaid markup instead. - Nodes and edges are unique and their attributes will be overridden - should the same node or edge be added twice. - - Underscores are replaced with whitespaces from identifiers. - """ - - def __init__(self, name="", comment=None, **kwargs): - self.name = name - self.comment = comment - self.format = "svg" # Default format for compatibility with graphviz - self.graph_attr = {} - self.node_attr = {} - self.edge_attr = {} - self._nodes = defaultdict(dict) - self._edges = defaultdict(dict) - self.body = [] - - def attr(self, kw, **kwargs): - """Set graph, node, or edge attributes.""" - if kw == "graph": - self.graph_attr.update(kwargs) - elif kw == "node": - self.node_attr.update(kwargs) - elif kw == "edge": - self.edge_attr.update(kwargs) - - def node(self, name, **attrs): - """Add or update a node.""" - self._nodes[name] = attrs - - def edge(self, tail_name, head_name, **attrs): - """Add or update an edge between two nodes.""" - self._edges[(tail_name, head_name)] = attrs - - @staticmethod - def _sanitize_id(name): - """Convert name to valid Mermaid node ID.""" - # Replace spaces and special chars with underscores - sanitized = name.replace(" ", "_").replace("-", "_") - return sanitized - - @staticmethod - def _format_label(name): - """Format label for display (replace underscores with spaces).""" - return name.replace("_", " ") - - def _get_node_shape(self, attrs): - """Determine Mermaid node shape based on attributes.""" - style = attrs.get("style", "") - - # Check for rounded style (human tasks) - if "rounded" in style: - # Rounded rectangle: (text) - return "(", ")" - else: - # Rectangle: [text] - return "[", "]" - - def _generate_node_styles(self): - """Generate style definitions for nodes.""" - styles = [] - node_styles = {} - - for name, attrs in sorted(self._nodes.items()): - node_id = self._sanitize_id(name) - style_attrs = [] - - color = attrs.get("color", "black") - fontcolor = attrs.get("fontcolor", "black") - fillcolor = attrs.get("fillcolor", "white") - style = attrs.get("style", "") - - # Map colors - if color == COLOR_GRAY: - stroke_color = COLOR_GRAY - else: - stroke_color = COLOR_BLACK - - if fontcolor == COLOR_GRAY: - text_color = COLOR_GRAY - else: - text_color = COLOR_BLACK - - # Determine stroke width based on bold - if "bold" in style: - stroke_width = "3px" - else: - stroke_width = "2px" - - # Determine stroke style based on dashed - if "dashed" in style: - stroke_style = f"stroke-dasharray: {STROKE_DASHARRAY}" - else: - stroke_style = "" - - # Build style - style_parts = [ - f"fill:{fillcolor}", - f"stroke:{stroke_color}", - f"stroke-width:{stroke_width}", - f"color:{text_color}", - ] - if stroke_style: - style_parts.append(stroke_style) - - node_styles[node_id] = ",".join(style_parts) - - # Generate style commands - for node_id, style_str in node_styles.items(): - styles.append(f" style {node_id} {style_str}") - - return styles - - def _generate_edge_styles(self): - """Generate style definitions for edges.""" - styles = [] - edge_styles = {} - - for idx, ((tail, head), attrs) in enumerate(sorted(self._edges.items())): - style = attrs.get("style", "") - color = attrs.get("color", "black") - - # Determine link style based on attributes - if "dashed" in style: - # Mermaid uses linkStyle to style edges - if color == COLOR_GRAY: - edge_styles[idx] = f"stroke:{COLOR_GRAY},stroke-dasharray: {STROKE_DASHARRAY}" - else: - edge_styles[idx] = f"stroke:{COLOR_BLACK},stroke-dasharray: {STROKE_DASHARRAY}" - elif color == COLOR_GRAY: - edge_styles[idx] = f"stroke:{COLOR_GRAY}" - # else: default black stroke - - # Generate linkStyle commands - for idx, style_str in edge_styles.items(): - styles.append(f" linkStyle {idx} {style_str}") - - return styles - - def __iter__(self): - """Yield the Mermaid source code line by line.""" - lines = [] - - # Comment - if self.comment: - lines.append(f"%% {self.comment}") - - # Graph declaration - rankdir = self.graph_attr.get("rankdir", "LR") - lines.append(f"graph {rankdir}") - - # Nodes - for name, attrs in sorted(self._nodes.items()): - node_id = self._sanitize_id(name) - label = self._format_label(name) - - # Determine shape - left, right = self._get_node_shape(attrs) - - # Add href if present - href = attrs.get("href", "") - if href: - lines.append(f" {node_id}{left}{label}{right}") - lines.append(f' click {node_id} "{href}"') - else: - lines.append(f" {node_id}{left}{label}{right}") - - # Edges - for tail_name, head_name in sorted(self._edges.keys()): - tail_id = self._sanitize_id(tail_name) - head_id = self._sanitize_id(head_name) - lines.append(f" {tail_id} --> {head_id}") - - # Styles - node_styles = self._generate_node_styles() - lines.extend(node_styles) - - edge_styles = self._generate_edge_styles() - lines.extend(edge_styles) - - for line in lines: - yield line - - def __str__(self): - """Return the complete Mermaid diagram as a string.""" - return "\n".join(self) - - def source(self): - """Return the Mermaid diagram source.""" - return str(self) - - def pipe(self, format="svg", encoding="utf-8"): - """ - Return the diagram in the specified format. - - For SVG format, renders via mermaid.ink API. - For other formats, returns the Mermaid source. - - This maintains compatibility with the graphviz API. - """ - source = self.source() - - if format == "svg": - # Render to SVG using mermaid.ink API - try: - svg_content = self._render_to_svg(source) - return svg_content if encoding else svg_content.encode('utf-8') - except Exception: - # Fallback to source if rendering fails - return source if encoding else source.encode('utf-8') - else: - # For other formats, return the Mermaid source - return source if encoding else source.encode('utf-8') - - def _render_to_svg(self, mermaid_source): - """ - Render Mermaid source to SVG using mermaid.ink API. - - Args: - mermaid_source: Mermaid diagram source code - - Returns: - SVG content as string - """ - # Use mermaid.ink API to render - # https://mermaid.ink/svg/ - encoded = base64.b64encode(mermaid_source.encode('utf-8')).decode('ascii') - url = f"https://mermaid.ink/svg/{encoded}" - - try: - with urllib.request.urlopen(url, timeout=10) as response: - svg_content = response.read().decode('utf-8') - return svg_content - except Exception as e: - # If API call fails, return a fallback SVG with error message - raise Exception(f"Failed to render via mermaid.ink: {e}") - - def render(self, filename, directory=None, format="svg", cleanup=False): - """ - Save the Mermaid diagram to a file. - - Args: - filename: Base filename (without extension) - directory: Output directory - format: Output format (svg or mmd) - cleanup: Cleanup intermediate files (not used for Mermaid) - """ - import os - - # Determine file extension and content based on format - if format == "svg": - ext = "svg" - content = self.pipe(format="svg", encoding="utf-8") - else: - ext = "mmd" - content = self.source() - - if directory: - filepath = os.path.join(directory, f"{filename}.{ext}") - else: - filepath = f"{filename}.{ext}" - - with open(filepath, "w", encoding="utf-8") as f: - f.write(content) - - return filepath diff --git a/joeflow/models.py b/joeflow/models.py index 60c77ee..f332f19 100644 --- a/joeflow/models.py +++ b/joeflow/models.py @@ -194,7 +194,7 @@ def get_graph(cls, color="black"): Return workflow graph. Returns: - (MermaidDiagram): Directed graph of the workflow in Mermaid format. + (graphviz.Digraph): Directed graph of the workflow. """ graph = NoDashDiGraph() @@ -233,7 +233,7 @@ def get_graph_svg(cls): Returns: - (django.utils.safestring.SafeString): SVG representation of the workflow. + (django.utils.safestring.SafeString): SVG representation of a running workflow. """ graph = cls.get_graph() @@ -242,6 +242,39 @@ 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(" ", "_") + label = name + + # 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") @@ -332,6 +365,41 @@ 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}"] + + names = dict(self.get_nodes()).keys() + + # Add all nodes from workflow definition + for name, node in self.get_nodes(): + node_id = name.replace(" ", "_") + label = name + + # Determine shape based on node type + if node.type == HUMAN: + lines.append(f" {node_id}({label})") + else: + lines.append(f" {node_id}[{label}]") + + # Add edges from workflow definition + for start, end in self.edges: + start_id = start.name.replace(" ", "_") + end_id = end.name.replace(" ", "_") + lines.append(f" {start_id} --> {end_id}") + + # TODO: Add styling for completed/active tasks + # This would require additional Mermaid syntax for node styling + + return "\n".join(lines) + def cancel(self, user=None): self.task_set.cancel(user) diff --git a/joeflow/utils.py b/joeflow/utils.py index e2e5729..3b9d775 100644 --- a/joeflow/utils.py +++ b/joeflow/utils.py @@ -1,4 +1,76 @@ -from .mermaid_utils import MermaidDiagram +from collections import defaultdict -# For backwards compatibility, export MermaidDiagram as NoDashDiGraph -NoDashDiGraph = MermaidDiagram +import graphviz as gv + + +class NoDashDiGraph(gv.Digraph): + """ + Like `.graphviz.Digraph` but with unique nodes and edges. + + Nodes and edges are unique and their attributes will be overridden + should the same node or edge be added twice. Nodes are unique by name + and edges unique by head and tail. + + Underscores are replaced with whitespaces from identifiers. + """ + + def __init__(self, *args, **kwargs): + self._nodes = defaultdict(dict) + self._edges = defaultdict(dict) + super().__init__(*args, **kwargs) + + def __iter__(self, subgraph=False): + """Yield the DOT source code line by line (as graph or subgraph).""" + if self.comment: + yield self._comment(self.comment) + + if subgraph: + if self.strict: + raise ValueError("subgraphs cannot be strict") + head = self._subgraph if self.name else self._subgraph_plain + else: + head = self._head_strict if self.strict else self._head + yield head(self._quote(self.name) + " " if self.name else "") + + for kw in ("graph", "node", "edge"): + attrs = getattr(self, "%s_attr" % kw) + if attrs: + yield self._attr(kw, self._attr_list(None, kwargs=attrs)) + + yield from self.body + + for name, attrs in sorted(self._nodes.items()): + name = self._quote(name) + label = attrs.pop("label", None) + _attributes = attrs.pop("_attributes", None) + attr_list = self._attr_list(label, kwargs=attrs, attributes=_attributes) + yield self._node(name, attr_list) + + for edge, attrs in sorted(self._edges.items()): + tail_name, head_name = edge + tail_name = self._quote_edge(tail_name) + head_name = self._quote_edge(head_name) + label = attrs.pop("label", None) + _attributes = attrs.pop("_attributes", None) + attr_list = self._attr_list(label, kwargs=attrs, attributes=_attributes) + yield self._edge(tail=tail_name, head=head_name, attr=attr_list) + + yield self._tail + + def node(self, name, **attrs): + self._nodes[name] = attrs + + def edge(self, tail_name, head_name, **attrs): + self._edges[(tail_name, head_name)] = attrs + + @staticmethod + def _quote(identifier, *args, **kwargs): + """Remove underscores from labels.""" + identifier = identifier.replace("_", " ") + return gv.quoting.quote(identifier, *args, **kwargs) + + @staticmethod + def _quote_edge(identifier): + """Remove underscores from labels.""" + identifier = identifier.replace("_", " ") + return gv.quoting.quote_edge(identifier) diff --git a/pyproject.toml b/pyproject.toml index ee782f1..a5fbe9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,6 @@ requires-python = ">=3.9" dependencies = [ "django>=2.2", "django-appconf", - "mermaid>=0.3.2", ] [project.optional-dependencies] @@ -74,6 +73,10 @@ docs = [ "dramatiq", "django_dramatiq", "redis", + "graphviz>=0.18", +] +graphviz = [ + "graphviz>=0.18", ] reversion = [ "django-reversion", diff --git a/tests/commands/test_render_workflow_graph.py b/tests/commands/test_render_workflow_graph.py index 6751bd8..d649dcf 100644 --- a/tests/commands/test_render_workflow_graph.py +++ b/tests/commands/test_render_workflow_graph.py @@ -9,18 +9,26 @@ def test_call_no_args(): tmp_dir = Path(tempfile.mkdtemp()) call_command("render_workflow_graph", "-d", tmp_dir) assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.svg")) + assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow")) -def test_call_format_mermaid(): +def test_call_cleanup(): tmp_dir = Path(tempfile.mkdtemp()) - call_command("render_workflow_graph", "-d", tmp_dir, "-f", "mermaid") - assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.mmd")) + call_command("render_workflow_graph", "-d", tmp_dir, "-c") + assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.svg")) + assert not os.path.exists(str(tmp_dir / "testapp_simpleworkflow")) -def test_call_format_svg(): +def test_call_format_pdf(): tmp_dir = Path(tempfile.mkdtemp()) - call_command("render_workflow_graph", "-d", tmp_dir, "-f", "svg") - assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.svg")) + call_command("render_workflow_graph", "-d", tmp_dir, "-f", "pdf") + assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.pdf")) + + +def test_call_format_png(): + tmp_dir = Path(tempfile.mkdtemp()) + call_command("render_workflow_graph", "-d", tmp_dir, "-f", "png") + assert os.path.exists(str(tmp_dir / "testapp_simpleworkflow.png")) def test_call_explicit_workflow(): diff --git a/tests/fixtures/simpleworkflow.mmd b/tests/fixtures/simpleworkflow.mmd deleted file mode 100644 index 2732515..0000000 --- a/tests/fixtures/simpleworkflow.mmd +++ /dev/null @@ -1,15 +0,0 @@ -graph LR - custom_start_view(custom start view) - end[end] - save_the_princess(save the princess) - start_method[start method] - start_view(start view) - custom_start_view --> save_the_princess - save_the_princess --> end - start_method --> save_the_princess - start_view --> save_the_princess - style custom_start_view fill:white,stroke:#000,stroke-width:2px,color:#000 - style end fill:white,stroke:#000,stroke-width:2px,color:#000 - style save_the_princess fill:white,stroke:#000,stroke-width:2px,color:#000 - style start_method fill:white,stroke:#000,stroke-width:2px,color:#000 - style start_view fill:white,stroke:#000,stroke-width:2px,color:#000 diff --git a/tests/fixtures/simpleworkflow_instance.mmd b/tests/fixtures/simpleworkflow_instance.mmd deleted file mode 100644 index 81820e6..0000000 --- a/tests/fixtures/simpleworkflow_instance.mmd +++ /dev/null @@ -1,19 +0,0 @@ -graph LR - custom_start_view(custom start view) - end[end] - save_the_princess(save the princess) - click save_the_princess "{url}" - start_method[start method] - start_view(start view) - custom_start_view --> save_the_princess - save_the_princess --> end - start_method --> save_the_princess - start_view --> save_the_princess - style custom_start_view fill:white,stroke:#888888,stroke-width:2px,color:#888888 - style end fill:white,stroke:#888888,stroke-width:2px,color:#888888 - style save_the_princess fill:white,stroke:#000,stroke-width:3px,color:#000 - style start_method fill:white,stroke:#000,stroke-width:2px,color:#000 - style start_view fill:white,stroke:#888888,stroke-width:2px,color:#888888 - linkStyle 0 stroke:#888888 - linkStyle 1 stroke:#888888 - linkStyle 3 stroke:#888888 diff --git a/tests/test_models.py b/tests/test_models.py index c21964b..00f96b0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,9 +3,9 @@ from django.contrib.auth.models import AnonymousUser from django.urls import reverse from django.utils.safestring import SafeString +from graphviz import Digraph from joeflow.models import Task, Workflow -from joeflow.mermaid_utils import MermaidDiagram from joeflow.tasks import HUMAN, MACHINE, StartView from tests.testapp import models, workflows @@ -105,8 +105,8 @@ class Meta: def test_get_graph(self, fixturedir): graph = workflows.SimpleWorkflow.get_graph() - assert isinstance(graph, MermaidDiagram) - with open(str(fixturedir / "simpleworkflow.mmd")) as fp: + assert isinstance(graph, Digraph) + with open(str(fixturedir / "simpleworkflow.dot")) as fp: expected_graph = fp.read().splitlines() print(str(graph)) assert set(str(graph).splitlines()) == set(expected_graph) @@ -114,7 +114,7 @@ def test_get_graph(self, fixturedir): def test_change_graph_direction(self, fixturedir): workflows.SimpleWorkflow.rankdir = "TD" graph = workflows.SimpleWorkflow.get_graph() - assert "graph TD" in str(graph) + assert "rankdir=TD" in str(graph) def test_get_graph_svg(self, fixturedir): svg = workflows.SimpleWorkflow.get_graph_svg() @@ -125,7 +125,7 @@ def test_get_instance_graph(self, db, fixturedir): task_url = wf.task_set.get(name="save_the_princess").get_absolute_url() graph = wf.get_instance_graph() print(str(graph)) - with open(str(fixturedir / "simpleworkflow_instance.mmd")) as fp: + with open(str(fixturedir / "simpleworkflow_instance.dot")) as fp: assert set(str(graph).splitlines()) == set( fp.read().replace("{url}", task_url).splitlines() ) @@ -141,16 +141,16 @@ def test_get_instance_graph__override( task = wf.task_set.get(name="override") graph = wf.get_instance_graph() print(str(graph)) - graph_str = str(graph) - # Check for override node (rounded with dashed style) - override_node_id = f"override_{task.pk}" - assert f"{override_node_id}(override {task.pk})" in graph_str - # Check for dashed edges - assert f"save_the_princess --> {override_node_id}" in graph_str - assert f"{override_node_id} --> end" in graph_str - # Check for dashed edge styling - assert "stroke-dasharray" in graph_str + assert ( + f'\t"{task.name} {task.pk}" [peripheries=1 style="filled, rounded, dashed"]\n' + in list(graph) + ) + assert ( + f'\t"save the princess" -> "{task.name} {task.pk}" [style=dashed]\n' + in list(graph) + ) + assert f'\t"{task.name} {task.pk}" -> end [style=dashed]\n' in list(graph) def test_get_instance_graph__obsolete(self, db, fixturedir, admin_client): workflow = workflows.SimpleWorkflow.objects.create() @@ -161,15 +161,13 @@ def test_get_instance_graph__obsolete(self, db, fixturedir, admin_client): end.parent_task_set.add(obsolete) graph = workflow.get_instance_graph() print(str(graph)) - graph_str = str(graph) - - # Check for obsolete node (with dashed and bold styling) - assert "obsolete[obsolete]" in graph_str - # Check for dashed edges - assert "start_method --> obsolete" in graph_str - assert "obsolete --> end" in graph_str - # Check for dashed styling - assert "stroke-dasharray" in graph_str + + assert ( + '\tobsolete [color=black fontcolor=black peripheries=1 style="filled, dashed, bold"]' + in str(graph) + ) + assert '\t"start method" -> obsolete [style=dashed]\n' in list(graph) + assert "\tobsolete -> end [style=dashed]\n" in list(graph) def test_get_instance_graph_svg(self, db, fixturedir): wf = workflows.SimpleWorkflow.start_method() diff --git a/tests/test_utils.py b/tests/test_utils.py index 595ca61..d1a70e8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,26 +5,32 @@ class TestNoDashDiGraph: def test_node(self): graph = NoDashDiGraph() graph.node("foo", color="blue") - graph_str = str(graph) - assert "graph LR" in graph_str - assert "foo[foo]" in graph_str - # Test that updating node works + assert list(graph) == [ + "digraph {\n", + "\tfoo [color=blue]\n", + "}\n", + ] graph.node("foo", color="red") - graph_str = str(graph) - assert "graph LR" in graph_str - assert "foo[foo]" in graph_str + assert list(graph) == [ + "digraph {\n", + "\tfoo [color=red]\n", + "}\n", + ] def test_edge(self): graph = NoDashDiGraph() graph.edge("foo", "bar", color="blue") - graph_str = str(graph) - assert "graph LR" in graph_str - assert "foo --> bar" in graph_str - # Test that updating edge works + assert list(graph) == [ + "digraph {\n", + "\tfoo -> bar [color=blue]\n", + "}\n", + ] graph.edge("foo", "bar", color="red") - graph_str = str(graph) - assert "graph LR" in graph_str - assert "foo --> bar" in graph_str + assert list(graph) == [ + "digraph {\n", + "\tfoo -> bar [color=red]\n", + "}\n", + ] def test_iter(self): graph = NoDashDiGraph(node_attr={"style": "filled"}) @@ -33,16 +39,35 @@ def test_iter(self): graph.edge("foo", "bar", color="blue") graph.comment = "This is a comment." print(str(graph)) - graph_str = str(graph) - assert "%% This is a comment." in graph_str - assert "graph LR" in graph_str - assert "bar[bar]" in graph_str - assert "foo[foo]" in graph_str - assert "foo --> bar" in graph_str + assert list(graph.__iter__()) == [ + "// This is a comment.\n", + "digraph {\n", + "\tnode [style=filled]\n", + "\tbar [color=green]\n", + "\tfoo [color=red]\n", + "\tfoo -> bar [color=blue]\n", + "}\n", + ] - def test_sanitize_id(self): - assert NoDashDiGraph._sanitize_id("foo_bar") == "foo_bar" - assert NoDashDiGraph._sanitize_id("foo bar") == "foo_bar" + def test_iter__subgraph(self): + graph = NoDashDiGraph(node_attr={"style": "filled"}) + graph.node("foo", color="red") + graph.node("bar", color="green") + graph.edge("foo", "bar", color="blue") + graph.comment = "This is a comment." + print(str(graph)) + assert list(graph.__iter__(subgraph=True)) == [ + "// This is a comment.\n", + "{\n", + "\tnode [style=filled]\n", + "\tbar [color=green]\n", + "\tfoo [color=red]\n", + "\tfoo -> bar [color=blue]\n", + "}\n", + ] + + def test_quote(self): + assert NoDashDiGraph._quote("foo_bar") == '"foo bar"' - def test_format_label(self): - assert NoDashDiGraph._format_label("foo_bar") == "foo bar" + def test_quote_edge(self): + assert NoDashDiGraph._quote_edge("foo_bar") == '"foo bar"' diff --git a/tests/testapp/templates/testapp/base.html b/tests/testapp/templates/testapp/base.html index e04a80f..580fcc2 100644 --- a/tests/testapp/templates/testapp/base.html +++ b/tests/testapp/templates/testapp/base.html @@ -1,11 +1,6 @@ - -