diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d088ae..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,34 +26,35 @@ 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: 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 +65,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/.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 07f430d..4e51c67 100644 --- a/joeflow/admin.py +++ b/joeflow/admin.py @@ -1,7 +1,9 @@ from django.contrib import admin, messages from django.contrib.auth import get_permission_codename from django.db import transaction +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 from . import forms, models @@ -19,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( @@ -37,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") @@ -135,6 +136,14 @@ class TaskInlineAdmin(admin.TabularInline): classes = ["collapse"] +class CSS(MediaAsset): + element_template = "{path}" + + @property + def path(self): + return mark_safe(self._path) # noqa: S308 + + class WorkflowAdmin(VersionAdmin): list_filter = ( "modified", @@ -147,13 +156,40 @@ 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: + return mark_safe( # noqa: S308 + 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/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 cfe51e1..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.: @@ -332,6 +330,156 @@ def get_instance_graph_svg(self, output_format="svg"): get_instance_graph_svg.short_description = t("instance graph") + def get_instance_graph_mermaid(self): + """Return instance graph as Mermaid diagram syntax. + + This can be used with MermaidJS for client-side rendering in admin. + + Returns: + (str): Mermaid diagram syntax for the instance graph. + """ + lines = [f"graph {self.rankdir}"] + node_styles = [] + edge_styles = {} # 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" + ) + + # 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(" ", "_") + 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): + 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" + ) + + # Update edge styling for actual task connections (black style) + for child in task.child_task_set.exclude(name="override"): + child_id = child.name.replace(" ", "_") + 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( + "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" + ) + + # Add dashed edges for override connections + for parent in task.parent_task_set.all(): + parent_id = parent.name.replace(" ", "_") + 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(" ", "_") + 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" + ) + 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(" ", "_") + 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(" ", "_") + 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) + + # 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): self.task_set.cancel(user) @@ -488,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 @@ -524,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): @@ -554,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. @@ -594,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..756fe79 100644 --- a/joeflow/utils.py +++ b/joeflow/utils.py @@ -1,11 +1,13 @@ from collections import defaultdict -import graphviz as gv +try: + import graphviz as gv +except ImportError: + gv = type("gv", (), {"Digraph": object}) 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 +35,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 9dfb557..0fea392 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,11 +46,10 @@ keywords = [ "framework", "task", ] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ - "django>=2.2", + "django>=5.2", "django-appconf", - "graphviz>=0.18", ] [project.optional-dependencies] @@ -61,19 +60,16 @@ test = [ "pytest-env", "redis", ] -lint = [ - "bandit==1.8.6", - "black==25.11.0", - "flake8==7.3.0", - "isort==6.1.0", - "pydocstyle[toml]==6.3.0", -] docs = [ "celery>=4.2.0", "django-reversion", "dramatiq", "django_dramatiq", "redis", + "graphviz>=0.18", +] +graphviz = [ + "graphviz>=0.18", ] reversion = [ "django-reversion", @@ -108,16 +104,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/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/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 00f96b0..45c8119 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,10 +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.tasks import HUMAN, MACHINE, StartView + from tests.testapp import models, workflows @@ -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() @@ -133,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"]}) @@ -153,6 +157,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,10 +175,82 @@ 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) + 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 + + 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) + print(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 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 + ) + + # 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() @@ -408,5 +485,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/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): 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``.