nautobot-workflow-engine 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. nautobot_workflow_engine/__init__.py +35 -0
  2. nautobot_workflow_engine/app-config-schema.json +31 -0
  3. nautobot_workflow_engine/datasources.py +257 -0
  4. nautobot_workflow_engine/executors.py +1138 -0
  5. nautobot_workflow_engine/filters.py +23 -0
  6. nautobot_workflow_engine/forms.py +745 -0
  7. nautobot_workflow_engine/jobs.py +211 -0
  8. nautobot_workflow_engine/migrations/0001_initial.py +152 -0
  9. nautobot_workflow_engine/migrations/__init__.py +0 -0
  10. nautobot_workflow_engine/models.py +271 -0
  11. nautobot_workflow_engine/navigation.py +36 -0
  12. nautobot_workflow_engine/scheduling.py +184 -0
  13. nautobot_workflow_engine/servicenow.py +348 -0
  14. nautobot_workflow_engine/static/nautobot_workflow_engine/js/workflow_list.js +116 -0
  15. nautobot_workflow_engine/tables.py +22 -0
  16. nautobot_workflow_engine/tasks.py +135 -0
  17. nautobot_workflow_engine/templates/nautobot_workflow_engine/partials/workflow_list_grouped.html +92 -0
  18. nautobot_workflow_engine/templates/nautobot_workflow_engine/partials/workflow_tiles.html +129 -0
  19. nautobot_workflow_engine/templates/nautobot_workflow_engine/workflow_launch.html +2972 -0
  20. nautobot_workflow_engine/templates/nautobot_workflow_engine/workflow_list.html +144 -0
  21. nautobot_workflow_engine/templates/nautobot_workflow_engine/workflow_report.html +560 -0
  22. nautobot_workflow_engine/templates/nautobot_workflow_engine/workflow_run.html +535 -0
  23. nautobot_workflow_engine/templates/nautobot_workflow_engine/workflow_run_list.html +331 -0
  24. nautobot_workflow_engine/templatetags/__init__.py +2 -0
  25. nautobot_workflow_engine/templatetags/workflow_extras.py +38 -0
  26. nautobot_workflow_engine/tests/__init__.py +1 -0
  27. nautobot_workflow_engine/tests/test_action_ordering.py +102 -0
  28. nautobot_workflow_engine/tests/test_basic.py +30 -0
  29. nautobot_workflow_engine/tests/test_executors.py +137 -0
  30. nautobot_workflow_engine/tests/test_forms_listdict.py +110 -0
  31. nautobot_workflow_engine/tests/test_repo_filters.py +143 -0
  32. nautobot_workflow_engine/tests/test_servicenow_scheduling.py +291 -0
  33. nautobot_workflow_engine/urls.py +27 -0
  34. nautobot_workflow_engine/views.py +1381 -0
  35. nautobot_workflow_engine-1.0.1.dist-info/METADATA +88 -0
  36. nautobot_workflow_engine-1.0.1.dist-info/RECORD +37 -0
  37. nautobot_workflow_engine-1.0.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,35 @@
1
+ """App declaration for nautobot_workflow_engine."""
2
+
3
+ # Metadata is inherited from Nautobot. If not including Nautobot in the environment, this should be added
4
+ import os
5
+ from importlib import metadata
6
+
7
+ from nautobot.apps import NautobotAppConfig
8
+
9
+ __version__ = metadata.version(__name__)
10
+
11
+
12
+ class WorkflowEngineConfig(NautobotAppConfig):
13
+ """App configuration for the nautobot_workflow_engine app."""
14
+
15
+ name = "nautobot_workflow_engine"
16
+ verbose_name = "Workflow Engine"
17
+ version = __version__
18
+ author = "Eric Fetty"
19
+ description = "General-purpose workflow engine for Nautobot"
20
+ base_url = "workflow-engine"
21
+ required_settings = []
22
+ default_settings = {
23
+ "awx_external_integration_name": "AWX",
24
+ "workflow_dir": "workflows",
25
+ "filters_dir": "filters",
26
+ "awx_poll_seconds": 5,
27
+ "show_disabled": False,
28
+ "show_add_new_item": os.getenv("SHOW_ADD_NEW_ITEM", "").split(","),
29
+ }
30
+ caching_config = {}
31
+ docs_view_name = "plugins:nautobot_workflow_engine:docs"
32
+ searchable_models = ["workflow"]
33
+
34
+
35
+ config = WorkflowEngineConfig # pylint:disable=invalid-name
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "awx_external_integration_name": {
6
+ "type": "string",
7
+ "default": "AWX"
8
+ },
9
+ "workflow_dir": {
10
+ "type": "string",
11
+ "default": "workflows"
12
+ },
13
+ "filters_dir": {
14
+ "type": "string",
15
+ "default": "filters"
16
+ },
17
+ "awx_poll_seconds": {
18
+ "type": "integer",
19
+ "default": 5
20
+ },
21
+ "show_disabled": {
22
+ "type": "boolean",
23
+ "default": false
24
+ },
25
+ "show_add_new_item": {
26
+ "type": "array",
27
+ "default": []
28
+ }
29
+ },
30
+ "additionalProperties": false
31
+ }
@@ -0,0 +1,257 @@
1
+ """Git repository sync functionality for workflows."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+ from django.conf import settings
8
+ from django.contrib.contenttypes.models import ContentType
9
+ from nautobot.apps.datasources import DatasourceContent
10
+ from nautobot.extras.models import GitRepository
11
+
12
+ from .executors import clear_repo_script_cache, validate_repo_filter_modules
13
+ from .models import Workflow, WorkflowAction, WorkflowFilterModule, WorkflowHelper, WorkflowInput
14
+
15
+ PLUGIN_CFG = settings.PLUGINS_CONFIG.get("nautobot_workflow_engine", {})
16
+
17
+
18
+ def _process_includes(job_result, file_handler):
19
+ """Process any includes in the content."""
20
+ content = file_handler.read_text(encoding="utf-8")
21
+ include_pattern = re.compile(r"""^\s*{%\s*include\s+[\"'](?P<path>[\w\/\.\-]+)[\"']\s*%}""")
22
+ for line in content.splitlines():
23
+ match = include_pattern.search(line)
24
+ if match:
25
+ # Load the template path from the match
26
+ template_path = match.group("path")
27
+ template_file = file_handler.parent / template_path
28
+ if template_file.exists():
29
+ # If the template file exists, read its content and replace the include
30
+ template_content = template_file.read_text(encoding="utf-8")
31
+ content = content.replace(line, template_content)
32
+ else:
33
+ # If the template file doesn't exist, log a warning
34
+ job_result.log(f"Template file not found: {template_file}")
35
+
36
+ return content
37
+
38
+
39
+ def _get_action_list(data, *keys):
40
+ """Return first non-empty action list for any provided YAML key alias."""
41
+ for key in keys:
42
+ value = data.get(key)
43
+ if value is None:
44
+ continue
45
+ if isinstance(value, list):
46
+ return value
47
+ # Be forgiving if user accidentally provides an empty scalar/object.
48
+ return []
49
+ return []
50
+
51
+
52
+ def _is_filter_module_path(relative_path):
53
+ """Return True when the repository-relative path is inside the configured filters directory."""
54
+ filters_dir = Path(PLUGIN_CFG.get("filters_dir", "filters"))
55
+ filters_parts = filters_dir.parts
56
+ rel_parts = relative_path.parts
57
+ return len(rel_parts) >= len(filters_parts) and rel_parts[: len(filters_parts)] == filters_parts
58
+
59
+
60
+ def _sync_repo_python_modules(repository_record, repo_path, job_result):
61
+ """Store repository Python modules in helper or filter-module models."""
62
+ WorkflowHelper.objects.filter(source_repo=repository_record).delete()
63
+ WorkflowFilterModule.objects.filter(source_repo=repository_record).delete()
64
+
65
+ for py_file in repo_path.rglob("*.py"):
66
+ if "__pycache__" in str(py_file) or py_file.name.startswith("."):
67
+ continue
68
+
69
+ relative_path = py_file.relative_to(repo_path)
70
+ model = WorkflowFilterModule if _is_filter_module_path(relative_path) else WorkflowHelper
71
+ model.objects.create(
72
+ source_repo=repository_record,
73
+ file_path=str(relative_path),
74
+ content=py_file.read_text(encoding="utf-8"),
75
+ )
76
+
77
+ if model is WorkflowFilterModule:
78
+ job_result.log(message=f"Stored filter module: {relative_path}")
79
+ else:
80
+ job_result.log(message=f"Stored Python file: {relative_path}")
81
+
82
+
83
+ def sync_workflows_from_repo(repository_record: GitRepository, job_result, delete=False):
84
+ """Sync workflow definitions and helper files from a Git repository."""
85
+ repo_path = Path(repository_record.filesystem_path)
86
+ wf_dir = PLUGIN_CFG["workflow_dir"]
87
+ workflows_path = repo_path / wf_dir
88
+ if not workflows_path.exists():
89
+ clear_repo_script_cache(repository_record)
90
+ return
91
+
92
+ # Track workflows found in this sync
93
+ synced_workflow_keys = set()
94
+
95
+ _sync_repo_python_modules(repository_record, repo_path, job_result)
96
+ validate_repo_filter_modules(repository_record)
97
+
98
+ for yml in sorted(list(workflows_path.glob("*.y*ml"))):
99
+ content = _process_includes(job_result, yml)
100
+ data = yaml.safe_load(content)
101
+
102
+ # Track this workflow key
103
+ workflow_key = data.get("key")
104
+ if workflow_key:
105
+ synced_workflow_keys.add(workflow_key)
106
+
107
+ # Check if workflow is explicitly disabled in YAML
108
+ yaml_enabled = data.get("enabled", True)
109
+
110
+ wf, _ = Workflow.objects.update_or_create(
111
+ key=data["key"],
112
+ defaults={
113
+ "name": data.get("name", data["key"]),
114
+ "description": data.get("description", ""),
115
+ "category": data.get("category", ""),
116
+ "enabled": yaml_enabled, # Use enabled flag from YAML
117
+ "manual_duration_minutes": data.get("manual_duration_minutes"),
118
+ "raw_definition": data,
119
+ "source_repo": repository_record,
120
+ },
121
+ )
122
+ wf.inputs.all().delete()
123
+ for order, ip in enumerate(data.get("inputs", []), start=1):
124
+ ct = None
125
+ if ip.get("type") in ("object", "multiobject") and ip.get("model"):
126
+ app_label, model = ip["model"].split(".")
127
+ try:
128
+ ct = ContentType.objects.get(app_label=app_label, model=model.lower())
129
+ except ContentType.DoesNotExist:
130
+ raise ValueError(f"[Workflow {wf.key}] Invalid model for input {ip['key']}: {ip['model']}")
131
+
132
+ # Normalize validators:
133
+ # - support either a single "regex" (with optional message/flags near root)
134
+ # - or a list under "validators", e.g. [{"regex": "...", "flags": ["IGNORECASE"], "message": "..."}]
135
+ validators = ip.get("validators")
136
+ if not validators:
137
+ if "regex" in ip:
138
+ v = {"regex": ip.get("regex")}
139
+ if "message" in ip:
140
+ v["message"] = ip["message"]
141
+ if "flags" in ip:
142
+ v["flags"] = ip["flags"]
143
+ validators = [v]
144
+
145
+ WorkflowInput.objects.create(
146
+ workflow=wf,
147
+ key=ip["key"],
148
+ label=ip.get("label", ip["key"]),
149
+ input_type=ip["type"],
150
+ content_type=ct,
151
+ required=ip.get("required", False),
152
+ default=ip.get("default"),
153
+ depends_on=ip.get("depends_on", []),
154
+ filter_query=ip.get("filter", {}).get("query"),
155
+ choices=ip.get("choices"),
156
+ columns=ip.get("columns"),
157
+ visible_when=ip.get("show_if"),
158
+ validators=validators,
159
+ order=order,
160
+ )
161
+ # Use bulk deletion to avoid get_absolute_url issues
162
+ WorkflowAction.objects.filter(workflow=wf).delete()
163
+
164
+ phased_actions = [
165
+ ("pre", _get_action_list(data, "pre_actions", "pre-actions", "before_actions", "before-actions")),
166
+ # backwards-compatible existing key
167
+ ("main", _get_action_list(data, "actions", "main_actions", "main-actions")),
168
+ ("post", _get_action_list(data, "post_actions", "post-actions", "after_actions", "after-actions")),
169
+ ]
170
+
171
+ for phase, actions in phased_actions:
172
+ for order, act in enumerate(actions, start=1):
173
+
174
+ if act.get("type") == "nautobot":
175
+ script_path = (act.get("script") or "").lstrip("/")
176
+ if not script_path:
177
+ raise ValueError(f"[Workflow {wf.key}] 'script' is required for nautobot actions")
178
+
179
+ # Verify the script exists in WorkflowHelper
180
+ if not WorkflowHelper.objects.filter(source_repo=repository_record, file_path=script_path).exists():
181
+ raise ValueError(f"[Workflow {wf.key}] Script not found in repository: {script_path}")
182
+
183
+ WorkflowAction.objects.create(
184
+ workflow=wf,
185
+ phase=phase,
186
+ order=order,
187
+ action_type="nautobot",
188
+ name=act.get("name", "nautobot"),
189
+ when=act.get("when", ""),
190
+ script_path="/" + script_path,
191
+ with_vars=act.get("with", {}),
192
+ )
193
+
194
+ elif act.get("type") == "awx":
195
+ WorkflowAction.objects.create(
196
+ workflow=wf,
197
+ phase=phase,
198
+ order=order,
199
+ action_type="awx",
200
+ name=act.get("name", "awx"),
201
+ when=act.get("when", ""),
202
+ awx_template_id=act.get("template_id"),
203
+ awx_extra_vars=act.get("extra_vars", {}),
204
+ monitor=act.get("monitor", True),
205
+ )
206
+
207
+ else:
208
+ raise ValueError(
209
+ f"[Workflow {wf.key}] Unsupported action type in phase '{phase}': {act.get('type')}"
210
+ )
211
+
212
+ # Disable workflows that are no longer in the repository (instead of deleting)
213
+ if delete:
214
+ workflows_to_disable = Workflow.objects.filter(
215
+ source_repo=repository_record,
216
+ enabled=True, # Only disable currently enabled workflows
217
+ ).exclude(key__in=synced_workflow_keys)
218
+
219
+ disabled_count = 0
220
+
221
+ for workflow in workflows_to_disable:
222
+ # Count related objects for logging
223
+ action_count = workflow.actions.count()
224
+ input_count = workflow.inputs.count()
225
+ run_count = workflow.runs.count()
226
+
227
+ job_result.log(
228
+ message=f"Disabling workflow '{workflow.name}' (key: {workflow.key}) "
229
+ f"with {action_count} actions, {input_count} inputs, {run_count} runs - no longer in repository"
230
+ )
231
+
232
+ # Disable instead of delete to preserve history
233
+ workflow.enabled = False
234
+ workflow.save()
235
+ disabled_count += 1
236
+
237
+ if disabled_count > 0:
238
+ job_result.log(
239
+ message=f"Disabled {disabled_count} workflows no longer present in repository. Run history preserved."
240
+ )
241
+
242
+ clear_repo_script_cache(repository_record)
243
+
244
+
245
+ datasource_contents = []
246
+
247
+ datasource_contents.append(
248
+ (
249
+ "extras.gitrepository",
250
+ DatasourceContent(
251
+ name="workflows",
252
+ content_identifier="nautobot_workflow_engine.workflows",
253
+ icon="mdi-file-document-outline",
254
+ callback=sync_workflows_from_repo,
255
+ ),
256
+ )
257
+ )