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 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 @@ - -
From b2fccede9c9409ba73e5ba696b5644b649ed85d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:45:56 +0000 Subject: [PATCH 08/13] Resolve TODO: Add styling for completed/active tasks in Mermaid diagrams - Implemented full task state visualization in get_instance_graph_mermaid() - Active tasks shown with bold styling (stroke-width:3px) - Completed tasks shown with normal styling - Override tasks shown with dashed borders and dotted edges - Obsolete tasks shown with dashed borders and dotted edges - Gray styling for tasks not yet in workflow - Added comprehensive unit tests for new Mermaid methods - Tests cover basic graphs, instance graphs, override tasks, and obsolete tasks Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- joeflow/models.py | 96 +++++++++++++++++++++++++++++++++++++++++--- tests/test_models.py | 95 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 6 deletions(-) diff --git a/joeflow/models.py b/joeflow/models.py index f332f19..d77cd77 100644 --- a/joeflow/models.py +++ b/joeflow/models.py @@ -257,7 +257,8 @@ def get_graph_mermaid(cls, color="black"): # Add nodes for name, node in cls.get_nodes(): node_id = name.replace(" ", "_") - label = name + # Keep original name with spaces for label + label = name.replace("_", " ") # Determine shape based on node type if node.type == HUMAN: @@ -375,28 +376,111 @@ def get_instance_graph_mermaid(self): (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 + # Add all nodes from workflow definition (inactive/gray style) for name, node in self.get_nodes(): node_id = name.replace(" ", "_") - label = name + # 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 + # 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 - # TODO: Add styling for completed/active tasks - # This would require additional Mermaid syntax for node styling + # Add all styling at the end + lines.extend(node_styles) + lines.extend(edge_styles) return "\n".join(lines) diff --git a/tests/test_models.py b/tests/test_models.py index 00f96b0..ff01c21 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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() From c9c2ab3e979dabf4ca0a6b269e85aee02c1181c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Nov 2025 17:16:48 +0000 Subject: [PATCH 09/13] Address review feedback: quote node IDs, fix edge duplication, include MermaidJS in output - Quote all node IDs with single quotes to handle reserved keywords (e.g., 'end') - Include MermaidJS script directly in display_workflow_diagram output - Use mark_safe() to prevent Django from escaping HTML - Remove get_graph_mermaid() method (not needed) - Fix edge duplication by tracking edges in a dict and only adding once - Update edge styling to replace (not duplicate) gray default with active styling - Update tests to match new quoted node ID format - Remove tests for deleted get_graph_mermaid() method Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- joeflow/admin.py | 17 ++++-- joeflow/models.py | 129 +++++++++++++++++++------------------------ tests/test_models.py | 46 +++------------ 3 files changed, 79 insertions(+), 113 deletions(-) diff --git a/joeflow/admin.py b/joeflow/admin.py index 7f7dbc5..6a3520f 100644 --- a/joeflow/admin.py +++ b/joeflow/admin.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_permission_codename from django.db import transaction from django.utils.html import format_html +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as t from . import forms, models @@ -159,11 +160,17 @@ def display_workflow_diagram(self, obj): 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 - ) + # Include MermaidJS script and wrap diagram + html = f""" + +
+
{mermaid_syntax}
+
+ """ + return mark_safe(html) return "" @transaction.atomic() diff --git a/joeflow/models.py b/joeflow/models.py index d77cd77..e987acb 100644 --- a/joeflow/models.py +++ b/joeflow/models.py @@ -242,40 +242,6 @@ 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") @@ -377,8 +343,8 @@ def get_instance_graph_mermaid(self): """ lines = [f"graph {self.rankdir}"] node_styles = [] - edge_styles = [] - edge_index = 0 + edge_styles = {} # Map of (start, end) -> style + edge_list = [] # List to maintain order of edges names = dict(self.get_nodes()).keys() @@ -388,22 +354,23 @@ def get_instance_graph_mermaid(self): # Keep original name with spaces for label label = name.replace("_", " ") - # Determine shape based on node type + # Determine shape based on node type, quote IDs to handle reserved words if node.type == HUMAN: - lines.append(f" {node_id}({label})") + lines.append(f" '{node_id}'({label})") else: - lines.append(f" {node_id}[{label}]") + 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") + node_styles.append(f" style '{node_id}' fill:#f9f9f9,stroke:#999,color:#999") - # Add edges from workflow definition (gray style) + # Add edges from workflow definition with default 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 + edge_key = (start_id, end_id) + if edge_key not in edge_styles: + edge_list.append(edge_key) + edge_styles[edge_key] = "stroke:#999" # Process actual tasks to highlight active/completed states for task in self.task_set.filter(name__in=names): @@ -411,17 +378,19 @@ def get_instance_graph_mermaid(self): # 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") + 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") + node_styles.append(f" style '{node_id}' fill:#fff,stroke:#000,stroke-width:2px,color:#000") - # Add edges for actual task connections (black style) + # Update edge styling 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 + edge_key = (node_id, child_id) + if edge_key not in edge_styles: + edge_list.append(edge_key) + # Update styling to black (overrides gray) + edge_styles[edge_key] = "stroke:#000,stroke-width:2px" # Handle override tasks for task in self.task_set.filter(name="override").prefetch_related( @@ -430,22 +399,24 @@ def get_instance_graph_mermaid(self): 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 override node with dashed style, quote ID + 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 + edge_key = (parent_id, override_id) + if edge_key not in edge_styles: + edge_list.append(edge_key) + edge_styles[edge_key] = "stroke:#000,stroke-dasharray:5 5" 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 + edge_key = (override_id, child_id) + if edge_key not in edge_styles: + edge_list.append(edge_key) + edge_styles[edge_key] = "stroke:#000,stroke-dasharray:5 5" # Handle obsolete/custom tasks (not in workflow definition) for task in self.task_set.exclude(name__in=names).exclude(name="override"): @@ -453,34 +424,50 @@ def get_instance_graph_mermaid(self): # Keep original name with spaces for label label = task.name.replace("_", " ") - # Determine shape based on node type + # Determine shape based on node type, quote IDs if task.type == HUMAN: - lines.append(f" {node_id}({label})") + lines.append(f" '{node_id}'({label})") else: - lines.append(f" {node_id}[{label}]") + 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") + 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") + 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 + edge_key = (parent_id, node_id) + if edge_key not in edge_styles: + edge_list.append(edge_key) + edge_styles[edge_key] = "stroke:#000,stroke-dasharray:5 5" 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 + edge_key = (node_id, child_id) + if edge_key not in edge_styles: + edge_list.append(edge_key) + edge_styles[edge_key] = "stroke:#000,stroke-dasharray:5 5" + + # Add edges to output (using dotted arrow for dashed edges) + for start_id, end_id in edge_list: + style = edge_styles[(start_id, end_id)] + if "dasharray" in style: + # Use dotted arrow for dashed edges + lines.append(f" '{start_id}' -.-> '{end_id}'") + else: + # Use solid arrow for normal edges + lines.append(f" '{start_id}' --> '{end_id}'") # Add all styling at the end lines.extend(node_styles) - lines.extend(edge_styles) + + # Add edge styling + for idx, (start_id, end_id) in enumerate(edge_list): + style = edge_styles[(start_id, end_id)] + lines.append(f" linkStyle {idx} {style}") return "\n".join(lines) diff --git a/tests/test_models.py b/tests/test_models.py index ff01c21..df35f7d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -174,34 +174,6 @@ 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() @@ -213,12 +185,12 @@ def test_get_instance_graph_mermaid(self, db): # 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 nodes with quoted IDs + 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 edges with quoted IDs + assert "'start_method' --> 'save_the_princess'" in mermaid # Check it contains styling (for active/completed tasks) assert "style " in mermaid @@ -259,14 +231,14 @@ def test_get_instance_graph_mermaid_with_obsolete(self, db): mermaid = workflow.get_instance_graph_mermaid() - # Check obsolete node exists - assert "obsolete[obsolete]" in mermaid + # Check obsolete node exists with quoted ID + assert "'obsolete'[obsolete]" in mermaid # Check dashed edges (dotted arrow notation in Mermaid) - assert "-.->obsolete" in mermaid.replace(" ", "") or "obsolete-.->end" in mermaid.replace(" ", "") + assert "'start_method' -.-> 'obsolete'" in mermaid or "'obsolete' -.-> 'end'" in mermaid # Check obsolete task styling with dashed border - assert "style obsolete" in mermaid + assert "style 'obsolete'" in mermaid assert "stroke-dasharray" in mermaid def test_cancel(self, db): From 9bebc8aa24f199363cf681a231a156e7c41bbf90 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Wed, 12 Nov 2025 11:49:34 +0100 Subject: [PATCH 10/13] Apply finishing touches --- .github/workflows/ci.yml | 44 ++++------------ joeflow/admin.py | 44 +++++++++++----- joeflow/models.py | 66 ++++++++++++++---------- joeflow/templates/admin/change_form.html | 10 ---- pyproject.toml | 12 ++--- 5 files changed, 86 insertions(+), 90 deletions(-) delete mode 100644 joeflow/templates/admin/change_form.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d088ae..6282ce3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,25 +59,28 @@ jobs: strategy: matrix: python-version: - - "3.9" - "3.10" - "3.11" + - "3.12" + - "3.13" + - "3.14" django-version: - - "3.2" - - "4.0" - - "4.1" + - "5.2" extras: - "test" include: - python-version: "3.x" - django-version: "4.1" + django-version: "5.2" extras: "test,dramatiq" - python-version: "3.x" - django-version: "4.1" + django-version: "5.2" extras: "test,celery" - python-version: "3.x" - django-version: "4.1" + django-version: "5.2" extras: "test,reversion" + - python-version: "3.x" + django-version: "5.2" + extras: "test,graphiz" steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v6 @@ -88,30 +91,3 @@ jobs: - run: python -m pip install -e .[${{ matrix.extras }}] - run: python -m pytest - uses: codecov/codecov-action@v5 - - analyze: - name: CodeQL - needs: [ PyTest ] - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - language: [ python ] - steps: - - name: Checkout - uses: actions/checkout@v5 - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - queries: +security-and-quality - - name: Autobuild - uses: github/codeql-action/autobuild@v4 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{ matrix.language }}" diff --git a/joeflow/admin.py b/joeflow/admin.py index 6a3520f..2cb6fed 100644 --- a/joeflow/admin.py +++ b/joeflow/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin, messages from django.contrib.auth import get_permission_codename from django.db import transaction +from django.forms.widgets import MediaAsset, Media, Script from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as t @@ -136,6 +137,15 @@ class TaskInlineAdmin(admin.TabularInline): classes = ["collapse"] +class CSS(MediaAsset): + + element_template = "{path}" + + @property + def path(self): + return mark_safe(self._path) + + class WorkflowAdmin(VersionAdmin): list_filter = ( "modified", @@ -158,22 +168,30 @@ def get_readonly_fields(self, *args, **kwargs): 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() - # Include MermaidJS script and wrap diagram - html = f""" - -
-
{mermaid_syntax}
-
- """ - return mark_safe(html) + return mark_safe( + f"""
{obj.get_instance_graph_mermaid()}
""" + ) return "" @transaction.atomic() def save_model(self, request, obj, form, change): super().save_model(request, obj, form, change) form.start_next_tasks(request.user) + + @property + def media(self): + return super().media + Media( + js=[ + Script( + "https://cdn.jsdelivr.net/npm/mermaid@latest/dist/mermaid.esm.min.mjs", + type="module", + ) + ], + css={ + "all": [ + CSS( + ".field-display_workflow_diagram .flex-container > .readonly { flex: 1 }" + ) + ] + }, + ) diff --git a/joeflow/models.py b/joeflow/models.py index e987acb..9411ed1 100644 --- a/joeflow/models.py +++ b/joeflow/models.py @@ -345,24 +345,26 @@ def get_instance_graph_mermaid(self): node_styles = [] edge_styles = {} # Map of (start, end) -> style edge_list = [] # List to maintain order of edges - + 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, quote IDs to handle reserved words 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") - + node_styles.append( + f" style '{node_id}' fill:#f9f9f9,stroke:#999,color:#999" + ) + # Add edges from workflow definition with default gray style for start, end in self.edges: start_id = start.name.replace(" ", "_") @@ -371,18 +373,22 @@ def get_instance_graph_mermaid(self): if edge_key not in edge_styles: edge_list.append(edge_key) edge_styles[edge_key] = "stroke:#999" - + # 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") + 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") - + node_styles.append( + f" style '{node_id}' fill:#fff,stroke:#000,stroke-width:2px,color:#000" + ) + # Update edge styling for actual task connections (black style) for child in task.child_task_set.exclude(name="override"): child_id = child.name.replace(" ", "_") @@ -391,18 +397,20 @@ def get_instance_graph_mermaid(self): edge_list.append(edge_key) # Update styling to black (overrides gray) edge_styles[edge_key] = "stroke:#000,stroke-width:2px" - + # 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, quote ID 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") - + 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(" ", "_") @@ -410,32 +418,36 @@ def get_instance_graph_mermaid(self): if edge_key not in edge_styles: edge_list.append(edge_key) edge_styles[edge_key] = "stroke:#000,stroke-dasharray:5 5" - + for child in task.child_task_set.all(): child_id = child.name.replace(" ", "_") edge_key = (override_id, child_id) if edge_key not in edge_styles: edge_list.append(edge_key) edge_styles[edge_key] = "stroke:#000,stroke-dasharray:5 5" - + # 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, quote IDs 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") + 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") - + 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(" ", "_") @@ -443,14 +455,14 @@ def get_instance_graph_mermaid(self): if edge_key not in edge_styles: edge_list.append(edge_key) edge_styles[edge_key] = "stroke:#000,stroke-dasharray:5 5" - + for child in task.child_task_set.all(): child_id = child.name.replace(" ", "_") edge_key = (node_id, child_id) if edge_key not in edge_styles: edge_list.append(edge_key) edge_styles[edge_key] = "stroke:#000,stroke-dasharray:5 5" - + # Add edges to output (using dotted arrow for dashed edges) for start_id, end_id in edge_list: style = edge_styles[(start_id, end_id)] @@ -460,15 +472,15 @@ def get_instance_graph_mermaid(self): else: # Use solid arrow for normal edges lines.append(f" '{start_id}' --> '{end_id}'") - + # Add all styling at the end lines.extend(node_styles) - + # Add edge styling for idx, (start_id, end_id) in enumerate(edge_list): style = edge_styles[(start_id, end_id)] lines.append(f" linkStyle {idx} {style}") - + return "\n".join(lines) def cancel(self, user=None): diff --git a/joeflow/templates/admin/change_form.html b/joeflow/templates/admin/change_form.html deleted file mode 100644 index ba3a4b9..0000000 --- a/joeflow/templates/admin/change_form.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "admin/change_form.html" %} - -{% block extrahead %} -{{ block.super }} - - -{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index a5fbe9e..9a71d66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,14 +19,14 @@ classifiers = [ "Programming Language :: JavaScript", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3 :: Only", "Framework :: Django", - "Framework :: Django :: 3.2", - "Framework :: Django :: 4.0", - "Framework :: Django :: 4.1", + "Framework :: Django :: 5.2", "Topic :: Software Development", "Topic :: Home Automation", "Topic :: Internet", @@ -46,9 +46,9 @@ keywords = [ "framework", "task", ] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ - "django>=2.2", + "django>=5.2", "django-appconf", ] From f8811932cb242999c5f23343a0444368ffa65752 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Wed, 12 Nov 2025 12:08:11 +0100 Subject: [PATCH 11/13] Add pre-commit CI --- .github/workflows/ci.yml | 26 -------- .github/workflows/release.yml | 3 - .pre-commit-config.yaml | 53 +++++++++++++++ .readthedocs.yaml | 4 -- CONTRIBUTING.md | 1 - docs/conf.py | 11 ++-- docs/tutorial/testing.rst | 1 - joeflow/admin.py | 14 ++-- joeflow/conf.py | 3 +- .../commands/render_workflow_graph.py | 5 +- joeflow/models.py | 47 ++++++-------- joeflow/tasks/__init__.py | 3 +- joeflow/tasks/human.py | 6 +- joeflow/tasks/machine.py | 9 +-- joeflow/utils.py | 5 +- joeflow/views.py | 16 ++--- pyproject.toml | 49 ++++++++++---- setup.cfg | 4 -- tests/tasks/test_machine.py | 2 +- tests/test_admin.py | 6 +- tests/test_docs.py | 2 +- tests/test_forms.py | 1 + tests/test_integration.py | 2 +- tests/test_models.py | 35 +++++----- tests/testapp/admin.py | 1 - tests/testapp/models.py | 3 +- tests/testapp/settings.py | 4 +- .../templates/django/forms/widgets/input.html | 6 +- .../django/forms/widgets/select.html | 14 ++-- tests/testapp/templates/testapp/base.html | 16 ++--- .../templates/testapp/workflow_detail.html | 65 +++++++++---------- .../templates/testapp/workflow_form.html | 25 ++++--- tests/testapp/urls.py | 16 ----- tests/testapp/workflows.py | 1 - tests/testapp/wsgi.py | 3 +- 35 files changed, 232 insertions(+), 230 deletions(-) create mode 100644 .pre-commit-config.yaml delete mode 100644 setup.cfg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6282ce3..c2c59b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,33 +1,10 @@ name: CI - on: push: branches: - main pull_request: - jobs: - - lint: - runs-on: ubuntu-latest - strategy: - matrix: - lint-command: - - bandit -r . -x ./tests - - black --check --diff . - - flake8 . - - isort --check-only --diff . - - pydocstyle . - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-python@v6 - with: - python-version: "3.x" - cache: 'pip' - cache-dependency-path: pyproject.toml - - run: python -m pip install .[lint] - - run: ${{ matrix.lint-command }} - docs: runs-on: ubuntu-latest steps: @@ -39,7 +16,6 @@ jobs: - run: python -m pip install sphinxcontrib-spelling - run: python -m pip install -e '.[docs]' - run: python -m sphinx -W -b spelling docs docs/_build - dist: runs-on: ubuntu-latest steps: @@ -50,11 +26,9 @@ jobs: - run: python -m pip install --upgrade pip build wheel twine readme-renderer - run: python -m build --sdist --wheel - run: python -m twine check dist/* - PyTest: needs: - dist - - lint runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa45736..f1aa2e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,8 @@ name: Release - on: release: types: [published] - jobs: - PyPi: runs-on: ubuntu-latest steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4853ecf --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,53 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: check-merge-conflict + - id: check-ast + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: name-tests-test + args: ['--pytest-test-first'] + exclude: ^tests/(testapp/|manage.py$) + - id: no-commit-to-branch + args: [--branch, main] + - repo: https://github.com/asottile/pyupgrade + rev: v3.21.1 + hooks: + - id: pyupgrade + - repo: https://github.com/adamchainz/django-upgrade + rev: 1.29.1 + hooks: + - id: django-upgrade + - repo: https://github.com/hukkin/mdformat + rev: 1.0.0 + hooks: + - id: mdformat + additional_dependencies: + - mdformat-ruff + - mdformat-footnote + - mdformat-gfm + - mdformat-gfm-alerts + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.4 + hooks: + - id: ruff-check + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format + - repo: https://github.com/google/yamlfmt + rev: v0.20.0 + hooks: + - id: yamlfmt + - repo: https://github.com/djlint/djLint + rev: v1.36.4 + hooks: + - id: djlint-reformat-django +ci: + autoupdate_schedule: weekly + skip: + - no-commit-to-branch diff --git a/.readthedocs.yaml b/.readthedocs.yaml index f97f047..45b3438 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,19 +1,15 @@ # .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details - version: 2 - build: os: ubuntu-20.04 tools: python: "3.11" apt_packages: - graphviz - sphinx: configuration: docs/conf.py - python: install: - method: pip diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abf13b8..7295d8e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,6 @@ to add code to their runtime environment that they don't need. All features need to be tested. A CI suite should be in place. Running and writing tests should be reasonably accessible for first time contributors. - ## Release We follow [semantic versioning](https://semver.org/). To release a new version diff --git a/docs/conf.py b/docs/conf.py index 7e1979b..b7cd569 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,7 +49,7 @@ def linkcode_resolve(domain, info): pass try: lines, first_line = inspect.getsourcelines(item) - lineno = "#L%d-L%s" % (first_line, first_line + len(lines) - 1) + lineno = f"#L{first_line:d}-L{first_line + len(lines) - 1:d}" except (TypeError, OSError): pass return ( @@ -79,6 +79,9 @@ def linkcode_resolve(domain, info): graphviz_output_format = "svg" -inheritance_graph_attrs = dict( - rankdir="TB", size='"6.0, 8.0"', fontsize=14, ratio="compress" -) +inheritance_graph_attrs = { + "rankdir": "TB", + "size": '"6.0, 8.0"', + "fontsize": 14, + "ratio": "compress", +} diff --git a/docs/tutorial/testing.rst b/docs/tutorial/testing.rst index bdc4811..016c0a1 100644 --- a/docs/tutorial/testing.rst +++ b/docs/tutorial/testing.rst @@ -90,4 +90,3 @@ running workflow. You can test any other task by simply creating the workflow and task in during test setup. In those cases you will need pass the task primary key. You can find more information about this in the :ref:`URLs documentation`. - diff --git a/joeflow/admin.py b/joeflow/admin.py index 2cb6fed..4e51c67 100644 --- a/joeflow/admin.py +++ b/joeflow/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin, messages from django.contrib.auth import get_permission_codename from django.db import transaction -from django.forms.widgets import MediaAsset, Media, Script +from django.forms.widgets import Media, MediaAsset, Script from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as t @@ -21,13 +21,13 @@ def rerun(modeladmin, request, queryset): if succeeded: messages.warning( request, - "Only failed tasks can be retried. %s tasks have been skipped" % succeeded, + f"Only failed tasks can be retried. {succeeded} tasks have been skipped", ) counter = 0 for obj in queryset.not_succeeded().iterator(): obj.enqueue() counter += 1 - messages.success(request, "%s tasks have been successfully queued" % counter) + messages.success(request, f"{counter} tasks have been successfully queued") @admin.action( @@ -39,8 +39,7 @@ def cancel(modeladmin, request, queryset): if not_scheduled: messages.warning( request, - "Only scheduled tasks can be canceled. %s tasks have been skipped" - % not_scheduled, + f"Only scheduled tasks can be canceled. {not_scheduled} tasks have been skipped", ) queryset.scheduled().cancel(request.user) messages.success(request, "Tasks have been successfully canceled") @@ -138,12 +137,11 @@ class TaskInlineAdmin(admin.TabularInline): class CSS(MediaAsset): - element_template = "{path}" @property def path(self): - return mark_safe(self._path) + return mark_safe(self._path) # noqa: S308 class WorkflowAdmin(VersionAdmin): @@ -168,7 +166,7 @@ def get_readonly_fields(self, *args, **kwargs): def display_workflow_diagram(self, obj): """Display workflow diagram using MermaidJS for client-side rendering.""" if obj.pk: - return mark_safe( + return mark_safe( # noqa: S308 f"""
{obj.get_instance_graph_mermaid()}
""" ) return "" diff --git a/joeflow/conf.py b/joeflow/conf.py index 97935e7..edbc891 100644 --- a/joeflow/conf.py +++ b/joeflow/conf.py @@ -5,8 +5,7 @@ class JoeflowAppConfig(AppConf): - """ - List of available settings. + """List of available settings. To change the default values just set the setting in your settings file. """ diff --git a/joeflow/management/commands/render_workflow_graph.py b/joeflow/management/commands/render_workflow_graph.py index 8bc8353..6e36cec 100644 --- a/joeflow/management/commands/render_workflow_graph.py +++ b/joeflow/management/commands/render_workflow_graph.py @@ -53,8 +53,7 @@ def handle(self, *args, **options): opt = workflow._meta if verbosity > 0: self.stdout.write( - "Rendering graph for '%s.%s'… " - % (opt.app_label, opt.model_name), + f"Rendering graph for '{opt.app_label}.{opt.model_name}'… ", ending="", ) filename = f"{opt.app_label}_{workflow.__name__}".lower() @@ -65,5 +64,5 @@ def handle(self, *args, **options): self.stdout.write("Done!", self.style.SUCCESS) else: self.stderr.write( - "%r is not a Workflow subclass" % workflow, self.style.WARNING + f"{workflow!r} is not a Workflow subclass", self.style.WARNING ) diff --git a/joeflow/models.py b/joeflow/models.py index 9411ed1..5a79e96 100644 --- a/joeflow/models.py +++ b/joeflow/models.py @@ -56,8 +56,7 @@ def __new__(mcs, name, bases, attrs): class Workflow(models.Model, metaclass=WorkflowBase): - """ - The `WorkflowState` object holds the state of a workflow instances. + """The `WorkflowState` object holds the state of a workflow instances. It is represented by a Django Model. This way all workflow states are persisted in your database. @@ -119,8 +118,7 @@ def get_nodes(cls): @classmethod def urls(cls): - """ - Return all URLs to workflow related task and other special views. + """Return all URLs to workflow related task and other special views. Example:: @@ -182,16 +180,15 @@ def get_url_namespace(cls): def get_absolute_url(self): """Return URL to workflow detail view.""" - return reverse(f"{self.get_url_namespace()}:detail", kwargs=dict(pk=self.pk)) + return reverse(f"{self.get_url_namespace()}:detail", kwargs={"pk": self.pk}) def get_override_url(self): """Return URL to workflow override view.""" - return reverse(f"{self.get_url_namespace()}:override", kwargs=dict(pk=self.pk)) + return reverse(f"{self.get_url_namespace()}:override", kwargs={"pk": self.pk}) @classmethod def get_graph(cls, color="black"): - """ - Return workflow graph. + """Return workflow graph. Returns: (graphviz.Digraph): Directed graph of the workflow. @@ -201,9 +198,12 @@ def get_graph(cls, color="black"): graph.attr("graph", rankdir=cls.rankdir) graph.attr( "node", - _attributes=dict( - fontname="sans-serif", shape="rect", style="filled", fillcolor="white" - ), + _attributes={ + "fontname": "sans-serif", + "shape": "rect", + "style": "filled", + "fillcolor": "white", + }, ) for name, node in cls.get_nodes(): node_style = "filled" @@ -217,8 +217,7 @@ 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 SVG. The SVG is HTML safe and can be included in a template, e.g.: @@ -273,12 +272,12 @@ def get_instance_graph(self): for task in self.task_set.filter(name="override").prefetch_related( "parent_task_set", "child_task_set" ): - label = "override_%s" % task.pk + label = f"override_{task.pk}" peripheries = "1" for parent in task.parent_task_set.all(): - graph.edge(parent.name, "override_%s" % task.pk, style="dashed") + graph.edge(parent.name, f"override_{task.pk}", style="dashed") for child in task.child_task_set.all(): - graph.edge("override_%s" % task.pk, child.name, style="dashed") + graph.edge(f"override_{task.pk}", child.name, style="dashed") if not task.child_task_set.all() and task.completed: peripheries = "2" graph.node(label, style="filled, rounded, dashed", peripheries=peripheries) @@ -307,8 +306,7 @@ def get_instance_graph(self): return graph 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 SVG. The SVG is HTML safe and can be included in a template, e.g.: @@ -333,8 +331,7 @@ 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. + """Return instance graph as Mermaid diagram syntax. This can be used with MermaidJS for client-side rendering in admin. @@ -639,7 +636,7 @@ def get_absolute_url(self): return # completed tasks have no detail view url_name = f"{self.workflow.get_url_namespace()}:{self.name}" try: - return reverse(url_name, kwargs=dict(pk=self.pk)) + return reverse(url_name, kwargs={"pk": self.pk}) except NoReverseMatch: return # no URL was defined for this task @@ -675,8 +672,7 @@ def fail(self): self.save(update_fields=["status", "exception", "stacktrace"]) def enqueue(self, countdown=None, eta=None): - """ - Schedule the tasks for execution. + """Schedule the tasks for execution. Args: countdown (int): @@ -705,8 +701,7 @@ def enqueue(self, countdown=None, eta=None): ) def start_next_tasks(self, next_nodes: list = None): - """ - Start new tasks following another tasks. + """Start new tasks following another tasks. Args: self (Task): The task that precedes the next tasks. @@ -745,7 +740,7 @@ def get_workflows() -> types.GeneratorType: return # empty generator -def get_workflow(name) -> typing.Optional[Workflow]: +def get_workflow(name) -> Workflow | None: for workflow_cls in get_workflows(): if ( name.lower() diff --git a/joeflow/tasks/__init__.py b/joeflow/tasks/__init__.py index 8269cd8..f88857f 100644 --- a/joeflow/tasks/__init__.py +++ b/joeflow/tasks/__init__.py @@ -1,5 +1,4 @@ -""" -A task defines the behavior or a workflow. +"""A task defines the behavior or a workflow. A task can be considered as a simple transaction that changes state of a workflow. There are two types of tasks, human and machine tasks. diff --git a/joeflow/tasks/human.py b/joeflow/tasks/human.py index a82e45c..55a3cd4 100644 --- a/joeflow/tasks/human.py +++ b/joeflow/tasks/human.py @@ -11,8 +11,7 @@ class StartView(StartViewMixin, generic.CreateView): - """ - Start a new workflow by a human with a view. + """Start a new workflow by a human with a view. Starting a workflow with a view allows users to provide initial data. @@ -24,8 +23,7 @@ class StartView(StartViewMixin, generic.CreateView): class UpdateView(TaskViewMixin, generic.UpdateView): - """ - Modify the workflow state and complete a human task. + """Modify the workflow state and complete a human task. Similar to Django's :class:`UpdateView` but does not only update the workflow but also completes a tasks. diff --git a/joeflow/tasks/machine.py b/joeflow/tasks/machine.py index d2bdc6b..d2614f0 100644 --- a/joeflow/tasks/machine.py +++ b/joeflow/tasks/machine.py @@ -21,8 +21,7 @@ class Start: - """ - Start a new function via a callable. + """Start a new function via a callable. Creates a new workflow instance and executes a start task. The start task does not do anything beyond creating the workflow. @@ -61,8 +60,7 @@ def __call__(self, **kwargs): class Join: - """ - Wait for all parent tasks to complete before continuing the workflow. + """Wait for all parent tasks to complete before continuing the workflow. Args: *parents (str): List of parent task names to wait for. @@ -121,8 +119,7 @@ def create_task(self, workflow, prev_task): class Wait: - """ - Wait for a certain amount of time and then continue with the next tasks. + """Wait for a certain amount of time and then continue with the next tasks. Args: duration (datetime.timedelta): Time to wait in time delta from creation of task. diff --git a/joeflow/utils.py b/joeflow/utils.py index 3b9d775..1e3558a 100644 --- a/joeflow/utils.py +++ b/joeflow/utils.py @@ -4,8 +4,7 @@ class NoDashDiGraph(gv.Digraph): - """ - Like `.graphviz.Digraph` but with unique nodes and edges. + """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 @@ -33,7 +32,7 @@ def __iter__(self, subgraph=False): yield head(self._quote(self.name) + " " if self.name else "") for kw in ("graph", "node", "edge"): - attrs = getattr(self, "%s_attr" % kw) + attrs = getattr(self, f"{kw}_attr") if attrs: yield self._attr(kw, self._attr_list(None, kwargs=attrs)) diff --git a/joeflow/views.py b/joeflow/views.py index a66adb1..914b62a 100644 --- a/joeflow/views.py +++ b/joeflow/views.py @@ -14,17 +14,11 @@ class WorkflowTemplateNameViewMixin: def get_template_names(self): names = [ - "%s/%s_%s.html" - % ( - self.model._meta.app_label, - self.model._meta.model_name, - self.name, - ) + f"{self.model._meta.app_label}/{self.model._meta.model_name}_{self.name}.html" ] names.extend(super().get_template_names()) names.append( - "%s/workflow%s.html" - % (self.model._meta.app_label, self.template_name_suffix) + f"{self.model._meta.app_label}/workflow{self.template_name_suffix}.html" ) return names @@ -75,8 +69,7 @@ def form_valid(self, form): return response def create_task(self, workflow, prev_task): - """ - Return a new database instance of this task. + """Return a new database instance of this task. The factory method should be overridden, to create custom database instances based on the task node's class, the workflow or the previous task. @@ -98,8 +91,7 @@ def create_task(self, workflow, prev_task): class StartViewMixin(TaskViewMixin): - """ - View-mixin to create a start workflow. + """View-mixin to create a start workflow. Example: class MyStartWorkflowView(StartViewMixin, View): diff --git a/pyproject.toml b/pyproject.toml index 9a71d66..8cea7cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,16 +111,41 @@ env = "D:DRAMATIQ_BROKER = dramatiq.brokers.stub.StubBroker" [tool.coverage.report] show_missing = true -[tool.isort] -atomic = true -line_length = 88 -known_first_party = "joeflow, tests" -include_trailing_comma = true -default_section = "THIRDPARTY" -combine_as_imports = true -skip = ["joeflow/_version.py"] -[tool.pydocstyle] -add_ignore = "D1" -match_dir = "(?!tests|env|docs|\\.).*" -match = "(?!setup).*.py" +[tool.ruff] +src = ["joeflow", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "S", # flake8-bandit + "D", # pydocstyle + "UP", # pyupgrade + "B", # flake8-bugbear + "C", # flake8-comprehensions +] + +ignore = ["B904", "D1", "E501", "S101", "C901"] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "S", "DJ", "E731", "B018" +] + +[tool.ruff.lint.isort] +combine-as-imports = true +split-on-trailing-comma = true +section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] +force-wrap-aliases = true + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.djlint] +profile="django" +indent=2 +max_line_length=120 +exclude=".direnv,.venv,venv" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 6f60592..0000000 --- a/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -max-line-length=88 -select = C,E,F,W,B,B950 -ignore = E203, E501, W503, E731 diff --git a/tests/tasks/test_machine.py b/tests/tasks/test_machine.py index 7848ecd..4b95aac 100644 --- a/tests/tasks/test_machine.py +++ b/tests/tasks/test_machine.py @@ -1,9 +1,9 @@ from datetime import timedelta from django.utils import timezone - from joeflow import tasks from joeflow.models import Task + from tests.testapp import workflows diff --git a/tests/test_admin.py b/tests/test_admin.py index f2b7022..ab08869 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -6,10 +6,10 @@ from django.contrib.messages.storage.fallback import FallbackStorage from django.contrib.sessions.middleware import SessionMiddleware from django.urls import reverse - from joeflow import admin from joeflow.admin import WorkflowAdmin from joeflow.models import Task + from tests.testapp import models, workflows @@ -25,8 +25,8 @@ def post_request(self, rf): # adding messages messages = FallbackStorage(request) - setattr(request, "_messages", messages) - setattr(request, "user", AnonymousUser()) + request._messages = messages + request.user = AnonymousUser() return request def test_rerun(self, db, post_request, stub_worker): diff --git a/tests/test_docs.py b/tests/test_docs.py index e445334..9eecf88 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -53,7 +53,7 @@ def test_start__post_with_user(self): username="spidy", ) - response = self.client.post(self.start_url, data=dict(user=user.pk)) + response = self.client.post(self.start_url, data={"user": user.pk}) self.assertEqual(response.status_code, 302) workflow = models.WelcomeWorkflow.objects.get() self.assertTrue(workflow.user) diff --git a/tests/test_forms.py b/tests/test_forms.py index 5d177c8..d1e13a4 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -1,4 +1,5 @@ from joeflow import forms + from tests.testapp.workflows import SimpleWorkflow diff --git a/tests/test_integration.py b/tests/test_integration.py index d759396..ca470a3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,8 +1,8 @@ """High level integration tests.""" from django.urls import reverse - from joeflow.models import Task + from tests.testapp import workflows diff --git a/tests/test_models.py b/tests/test_models.py index df35f7d..25b8064 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,9 +4,9 @@ from django.urls import reverse from django.utils.safestring import SafeString from graphviz import Digraph - from joeflow.models import Task, Workflow from joeflow.tasks import HUMAN, MACHINE, StartView + from tests.testapp import models, workflows @@ -178,20 +178,20 @@ 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 with quoted IDs assert "'save_the_princess'(save the princess)" in mermaid assert "'start_method'[start method]" in mermaid - + # Check it contains edges with quoted IDs assert "'start_method' --> 'save_the_princess'" in mermaid - + # Check it contains styling (for active/completed tasks) assert "style " in mermaid assert "linkStyle " in mermaid @@ -205,17 +205,17 @@ def test_get_instance_graph_mermaid_with_override( 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 @@ -228,15 +228,18 @@ def test_get_instance_graph_mermaid_with_obsolete(self, db): 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 with quoted ID assert "'obsolete'[obsolete]" in mermaid - + # Check dashed edges (dotted arrow notation in Mermaid) - assert "'start_method' -.-> 'obsolete'" in mermaid or "'obsolete' -.-> 'end'" in mermaid - + assert ( + "'start_method' -.-> 'obsolete'" in mermaid + or "'obsolete' -.-> 'end'" in mermaid + ) + # Check obsolete task styling with dashed border assert "style 'obsolete'" in mermaid assert "stroke-dasharray" in mermaid @@ -475,5 +478,5 @@ def test_save__no_update_fields(self, db): with pytest.raises(ValueError) as e: task.save() assert ( - "You need to provide explicit 'update_fields'" " to avoid race conditions." + "You need to provide explicit 'update_fields' to avoid race conditions." ) in str(e.value) diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py index 4bbda0f..b8e0593 100644 --- a/tests/testapp/admin.py +++ b/tests/testapp/admin.py @@ -1,5 +1,4 @@ from django.contrib import admin - from joeflow.admin import WorkflowAdmin from . import workflows diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 399384d..78550c9 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -1,6 +1,5 @@ from django.conf import settings from django.db import models - from joeflow import tasks from joeflow.models import Workflow @@ -32,7 +31,7 @@ def has_user(self): def send_welcome_email(self): self.user.email_user( subject="Welcome", - message="Hello %s!" % self.user.get_short_name(), + message=f"Hello {self.user.get_short_name()}!", ) def end(self): diff --git a/tests/testapp/settings.py b/tests/testapp/settings.py index bd6f0ca..03d654f 100644 --- a/tests/testapp/settings.py +++ b/tests/testapp/settings.py @@ -1,5 +1,4 @@ -""" -Django settings for testapp project. +"""Django settings for testapp project. Generated by 'django-admin startproject' using Django 2.1.1. @@ -117,7 +116,6 @@ USE_I18N = True -USE_L10N = True USE_TZ = True diff --git a/tests/testapp/templates/django/forms/widgets/input.html b/tests/testapp/templates/django/forms/widgets/input.html index 94bd7a9..732925e 100644 --- a/tests/testapp/templates/django/forms/widgets/input.html +++ b/tests/testapp/templates/django/forms/widgets/input.html @@ -1 +1,5 @@ - \ No newline at end of file + diff --git a/tests/testapp/templates/django/forms/widgets/select.html b/tests/testapp/templates/django/forms/widgets/select.html index d0c63ba..e668d15 100644 --- a/tests/testapp/templates/django/forms/widgets/select.html +++ b/tests/testapp/templates/django/forms/widgets/select.html @@ -1,5 +1,11 @@ - + {% for group_name, group_choices, group_index in widget.optgroups %} + {% if group_name %}{% endif %} + {% for option in group_choices %} + {% include option.template_name with widget=option %} + {% endfor %} + {% if group_name %}{% endif %} + {% endfor %} diff --git a/tests/testapp/templates/testapp/base.html b/tests/testapp/templates/testapp/base.html index 580fcc2..89268c2 100644 --- a/tests/testapp/templates/testapp/base.html +++ b/tests/testapp/templates/testapp/base.html @@ -1,10 +1,10 @@ - - - - -
-{% block body %} -{% endblock body %} - + + + + +
+ {% block body %} + {% endblock body %} + diff --git a/tests/testapp/templates/testapp/workflow_detail.html b/tests/testapp/templates/testapp/workflow_detail.html index b21f828..d039e74 100644 --- a/tests/testapp/templates/testapp/workflow_detail.html +++ b/tests/testapp/templates/testapp/workflow_detail.html @@ -1,39 +1,34 @@ {% extends 'testapp/base.html' %} - {% block body %} -
-
- {{ object.get_instance_graph_svg }} +
+
{{ object.get_instance_graph_svg }}
+

{{ object }}

+ + + + + + + + + + {% for task in object.task_set.all %} + + + + + + {% endfor %} + +
idnode namecompleted
{{ task.pk }} + {% if task.get_absolute_url %} + {{ task.name }} + {% else %} + {{ task.name }} + {% endif %} + {{ task.completed }}
+

+ Override +

-

{{ object }}

- - - - - - - - - - {% for task in object.task_set.all %} - - - - - - {% endfor %} - -
idnode namecompleted
{{ task.pk }} - {% if task.get_absolute_url %} - {{ task.name }} - {% else %} - {{ task.name }} - {% endif %} - {{ task.completed }}
-

- - Override - -

-
{% endblock body %} diff --git a/tests/testapp/templates/testapp/workflow_form.html b/tests/testapp/templates/testapp/workflow_form.html index cdab63d..68d75a0 100644 --- a/tests/testapp/templates/testapp/workflow_form.html +++ b/tests/testapp/templates/testapp/workflow_form.html @@ -1,18 +1,15 @@ {% extends 'testapp/base.html' %} - {% block body %} -
-
- {{ object.get_workflow_graph }} +
+
{{ object.get_workflow_graph }}
+
+ {% csrf_token %} + {{ form.as_p }} +

+ {% block form-actions %} + + {% endblock %} +

+
-
- {% csrf_token %} - {{ form.as_p }} -

- {% block form-actions %} - - {% endblock %} -

-
-
{% endblock body %} diff --git a/tests/testapp/urls.py b/tests/testapp/urls.py index b45f980..0773b0d 100644 --- a/tests/testapp/urls.py +++ b/tests/testapp/urls.py @@ -1,19 +1,3 @@ -"""testapp URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/2.1/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" - from django.contrib import admin from django.urls import include, path diff --git a/tests/testapp/workflows.py b/tests/testapp/workflows.py index bb97a83..fc79240 100644 --- a/tests/testapp/workflows.py +++ b/tests/testapp/workflows.py @@ -2,7 +2,6 @@ from django.core.mail import send_mail from django.views import generic - from joeflow import tasks from joeflow.views import StartViewMixin diff --git a/tests/testapp/wsgi.py b/tests/testapp/wsgi.py index ef63430..ac5e47f 100644 --- a/tests/testapp/wsgi.py +++ b/tests/testapp/wsgi.py @@ -1,5 +1,4 @@ -""" -WSGI config for threesixty project. +"""WSGI config for threesixty project. It exposes the WSGI callable as a module-level variable named ``application``. From d93b1770e797b0d11adb1f63c058ec2383ff7106 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Wed, 12 Nov 2025 12:19:31 +0100 Subject: [PATCH 12/13] Skip tests if graphviz isn't installed --- joeflow/utils.py | 5 ++++- pyproject.toml | 7 ------- tests/commands/test_render_workflow_graph.py | 3 +++ tests/test_models.py | 9 +++++++-- tests/test_utils.py | 3 +++ 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/joeflow/utils.py b/joeflow/utils.py index 1e3558a..756fe79 100644 --- a/joeflow/utils.py +++ b/joeflow/utils.py @@ -1,6 +1,9 @@ from collections import defaultdict -import graphviz as gv +try: + import graphviz as gv +except ImportError: + gv = type("gv", (), {"Digraph": object}) class NoDashDiGraph(gv.Digraph): diff --git a/pyproject.toml b/pyproject.toml index 8cea7cd..0fea392 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,13 +60,6 @@ test = [ "pytest-env", "redis", ] -lint = [ - "bandit==1.8.6", - "black==25.9.0", - "flake8==7.3.0", - "isort==6.1.0", - "pydocstyle[toml]==6.3.0", -] docs = [ "celery>=4.2.0", "django-reversion", diff --git a/tests/commands/test_render_workflow_graph.py b/tests/commands/test_render_workflow_graph.py index d649dcf..63e4398 100644 --- a/tests/commands/test_render_workflow_graph.py +++ b/tests/commands/test_render_workflow_graph.py @@ -2,8 +2,11 @@ import tempfile from pathlib import Path +import pytest from django.core.management import call_command +pytest.importorskip("graphviz") + def test_call_no_args(): tmp_dir = Path(tempfile.mkdtemp()) diff --git a/tests/test_models.py b/tests/test_models.py index 25b8064..802eece 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,7 +3,6 @@ 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.tasks import HUMAN, MACHINE, StartView @@ -104,23 +103,27 @@ class Meta: assert list(MyWorkflow.get_next_nodes(MyWorkflow.c)) == [] def test_get_graph(self, fixturedir): + graphviz = pytest.importorskip("graphviz") graph = workflows.SimpleWorkflow.get_graph() - assert isinstance(graph, Digraph) + assert isinstance(graph, graphviz.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) def test_change_graph_direction(self, fixturedir): + pytest.importorskip("graphviz") workflows.SimpleWorkflow.rankdir = "TD" graph = workflows.SimpleWorkflow.get_graph() assert "rankdir=TD" in str(graph) def test_get_graph_svg(self, fixturedir): + pytest.importorskip("graphviz") svg = workflows.SimpleWorkflow.get_graph_svg() assert isinstance(svg, SafeString) def test_get_instance_graph(self, db, fixturedir): + pytest.importorskip("graphviz") wf = workflows.SimpleWorkflow.start_method() task_url = wf.task_set.get(name="save_the_princess").get_absolute_url() graph = wf.get_instance_graph() @@ -153,6 +156,7 @@ def test_get_instance_graph__override( 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): + pytest.importorskip("graphviz") 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) @@ -170,6 +174,7 @@ def test_get_instance_graph__obsolete(self, db, fixturedir, admin_client): assert "\tobsolete -> end [style=dashed]\n" in list(graph) def test_get_instance_graph_svg(self, db, fixturedir): + pytest.importorskip("graphviz") wf = workflows.SimpleWorkflow.start_method() svg = wf.get_instance_graph_svg() assert isinstance(svg, SafeString) diff --git a/tests/test_utils.py b/tests/test_utils.py index d1a70e8..8b61317 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,8 @@ +import pytest from joeflow.utils import NoDashDiGraph +pytest.importorskip("graphviz") + class TestNoDashDiGraph: def test_node(self): From 1b22c1395f1959c5883d63e59c439ea72d4bfd83 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Wed, 12 Nov 2025 12:27:17 +0100 Subject: [PATCH 13/13] Fix mermaid instance test --- tests/test_models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 802eece..45c8119 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -136,6 +136,7 @@ def test_get_instance_graph(self, db, fixturedir): def test_get_instance_graph__override( self, db, stub_worker, fixturedir, admin_client ): + pytest.importorskip("graphviz") wf = workflows.SimpleWorkflow.start_method() url = reverse("simpleworkflow:override", args=[wf.pk]) response = admin_client.post(url, data={"next_tasks": ["end"]}) @@ -219,10 +220,11 @@ def test_get_instance_graph_mermaid_with_override( assert override_id in mermaid # Check dashed edges (dotted arrow notation in Mermaid) - assert ".-.->" in mermaid + print(mermaid) + assert "-.->" in mermaid # Check override styling with dashed border - assert f"style {override_id}" in mermaid + assert f"style '{override_id}'" in mermaid assert "stroke-dasharray" in mermaid def test_get_instance_graph_mermaid_with_obsolete(self, db):