From 26b337d6a7cb99ca0543bf1a91fb46b9d88d2353 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:40:28 +0000 Subject: [PATCH 1/4] feat(workflows): Enhance device management with query capabilities and conflict resolution - Added `queryByConditions` method to `DeviceInstance` for flexible device querying based on dynamic conditions. - Introduced `interpolate_tokens` function to replace placeholders in action values with actual device data. - Updated `UpdateFieldAction` to handle cross-device updates and archive conflicting MAC addresses. - Implemented cascade prevention in `WorkflowManager` to avoid processing events for devices modified in the same batch. - Added unit tests for new functionalities, including token interpolation, condition querying, and action execution. - Created constants for device column validation to enhance security and maintainability. - Established a structured research skill specification to guide development practices. --- .../initiative-start/reasearch-skill.md | 131 ++++++ .gitignore | 1 + front/css/app.css | 12 + front/php/templates/language/en_us.json | 3 + front/workflowsCore.php | 182 +++++++- server/models/device_instance.py | 49 +++ server/workflows/actions.py | 124 ++++-- server/workflows/app_events.py | 23 +- server/workflows/constants.py | 41 ++ server/workflows/manager.py | 221 ++++++---- test/backend/test_workflows.py | 403 ++++++++++++++++++ test/db_test_helpers.py | 22 + 12 files changed, 1080 insertions(+), 132 deletions(-) create mode 100644 .gemini/skills/initiative-start/reasearch-skill.md create mode 100644 server/workflows/constants.py create mode 100644 test/backend/test_workflows.py diff --git a/.gemini/skills/initiative-start/reasearch-skill.md b/.gemini/skills/initiative-start/reasearch-skill.md new file mode 100644 index 000000000..c6195d6b5 --- /dev/null +++ b/.gemini/skills/initiative-start/reasearch-skill.md @@ -0,0 +1,131 @@ +### 🧠 NAX Research Skill β€” Specification + +This defines a **Research Skill module for NAX (NetAlertX)** focused on safe, structured analysis before any implementation work. + +--- + +## 1. Purpose + +Ensure all work begins with **documentation-first understanding**, **PRD validation**, and **conflict detection**, before any planning or coding. + +--- + +## 2. Core Workflow + +### Step 1 β€” Documentation First + +* Always begin by reading relevant repository documentation. + +* Priority order: + + 1. `/CONTRIBUTING.md` + 2. `/README.md` + 3. `/.github/skills/code-standards/SKILL.md` + 4. `/docs/**` + 5. Related module/code context if referenced + +* Extract: + + * Architecture expectations + * Coding standards + * Plugin or module conventions + * Existing workflows or constraints + +--- + +### Step 2 β€” PRD Check + +* If a PRD (Product Requirements Document) is NOT provided: + + * Explicitly request it before proceeding further + * Do not assume requirements + +* If PRD is provided: + + * Parse and restate key requirements internally + * Identify scope boundaries + +--- + +### Step 3 β€” Clarification Gate + +If anything is unclear: + +* Stop immediately +* Ask targeted clarifying questions +* Do NOT propose solutions yet + +--- + +### Step 4 β€” Codebase Cross-Check + +* Compare PRD + documentation against existing codebase + +* Identify: + + * Conflicting behavior + * Outdated patterns + * Duplicate logic + * Breaking assumptions + * Plugin or API mismatches + +* Clearly report inconsistencies before proceeding + +--- + +### Step 5 β€” Planning Requirement (Strict) + +Before any implementation: + +* Produce a structured plan including: + + * Approach overview + * Files/modules affected + * Dependencies + * Risk areas + * Migration considerations (if any) + +* Explicitly label: + + > β€œWAITING FOR USER CONFIRMATION” + +--- + +### Step 6 β€” Implementation Gate (Hard Rule) + +* Do NOT start implementation until user explicitly confirms the plan +* No partial coding, no early patches, no assumptions + +--- + +## 3. Behavioral Constraints + +* Always prioritize correctness over speed +* Never skip PRD validation +* Never proceed past ambiguity +* Never implement without approval +* Always surface contradictions in source material +* Always prefer asking questions over guessing + +--- + +## 4. Output Style Rules + +* Be structured and technical +* Avoid unnecessary verbosity +* Separate: + + * Findings + * Risks + * Questions + * Plan +* No hidden assumptions + +--- + +## 5. Summary Flow + +``` +Docs β†’ PRD β†’ Clarify β†’ Codebase Check β†’ Plan β†’ User Approval β†’ Implement +``` + diff --git a/.gitignore b/.gitignore index bc932eff1..ee4259ce3 100755 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ db/pialert.db db/app.db front/log/* /log/* +.gemini/internal-docs/PRDs/* /log/plugins/* front/api/* /api/* diff --git a/front/css/app.css b/front/css/app.css index 0e6eba8bc..4a5b39fab 100755 --- a/front/css/app.css +++ b/front/css/app.css @@ -2430,6 +2430,18 @@ textarea[readonly], color: var(--color-green) !important; } +.workflows .action-target-conditions +{ + opacity: 0.8; +} + +.workflows .bckg-icon-base +{ + display: block; + position: absolute; + opacity: 0.1; + right: 0.1em; +} .workflows .bckg-icon-1-line { font-size: 3em; diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index 2c3c081a9..102c7cb2b 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -742,6 +742,9 @@ "VERSION_name": "Version or timestamp", "WF_Action_Add": "Add Action", "WF_Action_field": "Field", + "WF_Action_target": "Apply action to", + "WF_Action_target_conditions": "Target device conditions", + "WF_Action_token_hint": "Use {{trigger.COLUMN}} to reference the triggering device (e.g. {{trigger.devLastIP}}, {{trigger.devMac}})", "WF_Action_type": "Type", "WF_Action_value": "Value", "WF_Actions": "Actions", diff --git a/front/workflowsCore.php b/front/workflowsCore.php index 09da6af0b..b66ef2840 100755 --- a/front/workflowsCore.php +++ b/front/workflowsCore.php @@ -295,8 +295,86 @@ class: "panel col-sm-12 col-sx-12" }); - // Dropdown for action.type - let $actionDropdown= createEditableDropdown( + // how big should the background icon be β€” computed after all content decisions + let numberOfLines = 1 + + // ------------------------------------------------------------------ + // Target selector β€” shown first so user picks the target before the action + // Applies to update_field and delete_device actions + // ------------------------------------------------------------------ + if (action.type == "update_field" || action.type == "delete_device") { + let currentStrategy = (action.target && action.target.strategy) ? action.target.strategy : "triggering_device"; + + let $targetDropdown = createEditableDropdown( + `[${wfIndex}].actions[${actionIndex}].target.strategy`, + getString("WF_Action_target"), + ["triggering_device", "query"], + currentStrategy, + `wf-${wfIndex}-actionIndex-${actionIndex}-target-strategy` + ); + + $actionEl.append($targetDropdown); + + // Conditional query conditions sub-form + let $targetConditionsWrap = $("
", { + class: `action-target-conditions panel col-sm-12 col-sx-12 ${currentStrategy === "query" ? "" : "hidden"}`, + id: `wf-${wfIndex}-actionIndex-${actionIndex}-target-conditions-wrap` + }); + + let $targetConditionsTitle = $("
", { class: "section-title" }) + .append($("", { class: "fa-solid fa-crosshairs" })) + .append(` ${getString("WF_Action_target_conditions")}:`); + + let $tokenHint = $("
", { class: "text-muted small col-sm-12 col-xs-12" }) + .text(getString("WF_Action_token_hint")); + + $targetConditionsWrap.append($targetConditionsTitle); + $targetConditionsWrap.append($tokenHint); + + let targetConditions = (action.target && action.target.conditions) ? action.target.conditions : []; + let targetBasePath = `[${wfIndex}].actions[${actionIndex}].target`; + + $.each(targetConditions, function(tcIdx, tc) { + let $tcRow = createTargetConditionRow(wfIndex, actionIndex, tcIdx, tc, targetBasePath); + $targetConditionsWrap.append($tcRow); + }); + + let $addTargetCondBtn = $("
", { + class: "pointer add-target-condition green-hover-text col-sm-12", + wfIndex: wfIndex, + actionIndex: actionIndex + }).append($("", { class: "fa-solid fa-plus" })).append(` ${getString("WF_Add_Condition")}`); + + $targetConditionsWrap.append($addTargetCondBtn); + $actionEl.append($targetConditionsWrap); + + // Show/hide conditions sub-form when strategy dropdown changes + $targetDropdown.find("select").on("change", function() { + let val = $(this).val(); + let $wrap = $(`#wf-${wfIndex}-actionIndex-${actionIndex}-target-conditions-wrap`); + if (val === "query") { + $wrap.removeClass("hidden"); + } else { + $wrap.addClass("hidden"); + // Strip target.conditions from the in-memory object when switching away from query + let wfs = getWorkflowsJson(); + if (wfs[wfIndex] && wfs[wfIndex].actions[actionIndex] && wfs[wfIndex].actions[actionIndex].target) { + delete wfs[wfIndex].actions[actionIndex].target.conditions; + } + updateWorkflowsJson(wfs); + } + }); + + // numberOfLines: 1 (target dropdown) = 1 base for both action types + // query mode adds: 1 (section title+hint) + NΓ—3 (each condition: field/op/value) + 1 (add btn) + let conditionLines = currentStrategy === "query" + ? 2 + (targetConditions.length * 3) + : 0; + numberOfLines = 1 + conditionLines; + } + + // Dropdown for action.type β€” rendered after target so user reads: who β†’ what + let $actionDropdown = createEditableDropdown( `[${wfIndex}].actions[${actionIndex}].type`, getString("WF_Action_type"), actionTypes, @@ -304,15 +382,13 @@ class: "panel col-sm-12 col-sx-12" `wf-${wfIndex}-actionIndex-${actionIndex}-type` ); - $actionEl.append($actionDropdown); - - // how big should the background icon be - let numberOfLines = 1 + numberOfLines += 1; if(action.type == "update_field") { - numberOfLines = 3 + // +2 for field dropdown and value input rows + numberOfLines += 2; // Dropdown for action.field let $fieldDropdown = createEditableDropdown( @@ -356,7 +432,8 @@ class: "pointer remove-action red-hover-text", $actionRemoveButtonWrap.append($actionRemoveButton); let $actionIcon = $("", { - class: `fa-solid fa-person-running fa-flip-horizontal bckg-icon-${numberOfLines}-line ` + class: `fa-solid fa-person-running fa-flip-horizontal bckg-icon-base`, + style: `font-size: ${numberOfLines * 3}em` }); $actionEl.prepend($actionIcon) @@ -721,6 +798,57 @@ class: className + " col-sm-8 col-xs-12 form-control " return $wrapper; } +// -------------------------------------- +// Render a single row in a target-conditions sub-form (cross-device query targeting v2) +function createTargetConditionRow(wfIndex, actionIndex, tcIdx, tc, targetBasePath) { + let basePath = `${targetBasePath}.conditions[${tcIdx}]`; + + let $row = $("
", { class: "panel col-sm-12 col-sx-12 target-condition-row" }); + + let $icon = $("", { class: "fa-solid fa-crosshairs bckg-icon-3-line" }); + $row.append($icon); + + let $inner = $("
", { class: "col-sm-11 col-sx-12" }); + + let $fieldDropdown = createEditableDropdown( + `${basePath}.field`, + getString("WF_Condition_field"), + fieldOptions, + tc.field || "", + `wf-${wfIndex}-act-${actionIndex}-tc-${tcIdx}-field` + ); + + let $operatorDropdown = createEditableDropdown( + `${basePath}.operator`, + getString("WF_Condition_operator"), + operatorTypes, + tc.operator || "equals", + `wf-${wfIndex}-act-${actionIndex}-tc-${tcIdx}-operator` + ); + + let $valueInput = createEditableInput( + `${basePath}.value`, + getString("WF_Condition_value"), + tc.value || "", + `wf-${wfIndex}-act-${actionIndex}-tc-${tcIdx}-value`, + "condition-value-input" + ); + + $inner.append($fieldDropdown).append($operatorDropdown).append($valueInput); + + let $removeWrap = $("
", { class: "button-container col-sm-1 col-sx-12" }); + let $removeBtn = $("
", { + class: "pointer red-hover-text remove-target-condition", + wfIndex: wfIndex, + actionIndex: actionIndex, + tcIdx: tcIdx + }).append($("", { class: "fa-solid fa-trash" })); + $removeWrap.append($removeBtn); + + $row.append($inner).append($removeWrap); + return $row; +} + // -------------------------------------- // Updating the in-memory workflow object function updateWorkflowObject(newValue, jsonPath) { @@ -1097,6 +1225,7 @@ function getEmptyWorkflowJson() // Save workflows JSON function saveWorkflows() { + showSpinner(); // encode for import appConfBase64 = btoa(JSON.stringify(getWorkflowsJson())) @@ -1104,6 +1233,7 @@ function saveWorkflows() $.post('php/server/query_replace_config.php', { base64data: appConfBase64, fileName: "workflows.json" }, function(msg) { console.log(msg); // showMessage(msg); + hideSpinner(); write_notification(`[WF]: ${msg}`, 'interrupt'); }); } @@ -1168,6 +1298,42 @@ function saveWorkflows() removeAction(getWorkflowsJson(), wfIndex, actionIndex); }); +// Event Listeners for target condition rows (v2 cross-device targeting) +$(document).on("click", ".add-target-condition", function () { + let wfIndex = parseInt($(this).attr("wfIndex"), 10); + let actionIndex = parseInt($(this).attr("actionIndex"), 10); + let wfs = getWorkflowsJson(); + + if (!wfs[wfIndex].actions[actionIndex].target) { + wfs[wfIndex].actions[actionIndex].target = { strategy: "query", conditions: [] }; + } + if (!wfs[wfIndex].actions[actionIndex].target.conditions) { + wfs[wfIndex].actions[actionIndex].target.conditions = []; + } + + wfs[wfIndex].actions[actionIndex].target.conditions.push({ + field: fieldOptions[0], + operator: "equals", + value: "" + }); + + updateWorkflowsJson(wfs); + renderWorkflows(); +}); + +$(document).on("click", ".remove-target-condition", function () { + let wfIndex = parseInt($(this).attr("wfIndex"), 10); + let actionIndex = parseInt($(this).attr("actionIndex"), 10); + let tcIdx = parseInt($(this).attr("tcIdx"), 10); + let wfs = getWorkflowsJson(); + + if (wfs[wfIndex].actions[actionIndex].target && wfs[wfIndex].actions[actionIndex].target.conditions) { + wfs[wfIndex].actions[actionIndex].target.conditions.splice(tcIdx, 1); + updateWorkflowsJson(wfs); + renderWorkflows(); + } +}); + // Event Listeners for Removing Condition Groups $(document).on("click", ".remove-condition-group", function () { let wfIndex = $(this).attr("wfindex"); diff --git a/server/models/device_instance.py b/server/models/device_instance.py index 10ba61cb8..1fafc9647 100755 --- a/server/models/device_instance.py +++ b/server/models/device_instance.py @@ -18,6 +18,7 @@ unlock_fields ) from helper import is_random_mac, get_setting_value +from workflows.constants import VALID_DEVICE_COLUMNS from utils.datetime_utils import timeNowUTC @@ -85,6 +86,11 @@ def getByGUID(self, devGUID): SELECT * FROM Devices WHERE devGUID = ? """, (devGUID,)) + def getByMac(self, mac): + return self._fetchone(""" + SELECT * FROM Devices WHERE devMac = ? + """, (mac,)) + def exists(self, devGUID): row = self._fetchone(""" SELECT COUNT(*) as count FROM Devices WHERE devGUID = ? @@ -96,6 +102,49 @@ def getByIP(self, ip): SELECT * FROM Devices WHERE devLastIP = ? """, (ip,)) + def queryByConditions(self, conditions): + """Query Devices using a list of condition dicts. + + Each condition dict must have ``field``, ``operator``, and ``value`` keys. + Supported operators: ``equals``, ``contains``. + + Returns a list of device dicts (may be empty). Only fields present in + the Devices schema are accepted; unrecognised fields are skipped with a + warning to prevent SQL injection. + """ + clauses = [] + params = [] + + for cond in conditions: + field = cond.get("field", "") + operator = cond.get("operator", "") + value = cond.get("value", "") + + if field not in VALID_DEVICE_COLUMNS: + mylog("none", [f"[WF] queryByConditions: unknown field '{field}' β€” skipped"]) + continue + + # Normalize MAC values before comparison to match stored format + if field == "devMac" and value: + value = normalize_mac(value) + + if operator == "equals": + clauses.append(f"{field} = ?") + params.append(value) + elif operator == "contains": + clauses.append(f"{field} LIKE ?") + params.append(f"%{value}%") + else: + mylog("none", [f"[WF] queryByConditions: unsupported operator '{operator}' β€” skipped"]) + continue + + if not clauses: + mylog("none", ["[WF] queryByConditions: no valid conditions β€” returning empty result"]) + return [] + + where = " AND ".join(clauses) + return self._fetchall(f"SELECT * FROM Devices WHERE {where}", tuple(params)) + def search(self, query): like = f"%{query}%" return self._fetchall(""" diff --git a/server/workflows/actions.py b/server/workflows/actions.py index 6169ee49a..ff4fe3d95 100755 --- a/server/workflows/actions.py +++ b/server/workflows/actions.py @@ -1,13 +1,31 @@ import sqlite3 from logger import mylog, Logger from helper import get_setting_value +from front.plugins.plugin_helper import normalize_mac from models.device_instance import DeviceInstance from models.plugin_object_instance import PluginObjectInstance +from workflows.constants import BOOLEAN_COLUMNS, TOKEN_RE # Make sure log level is initialized correctly Logger(get_setting_value("LOG_LEVEL")) +def interpolate_tokens(value, trigger_device): + """Replace every ``{{trigger.COLUMN}}`` placeholder in *value* with the + corresponding field from *trigger_device* (a plain dict). + + Unknown columns are left as-is so callers can log them separately. + """ + if not isinstance(value, str): + return value + + def _replace(match): + col = match.group(1) + return str(trigger_device.get(col, match.group(0))) + + return TOKEN_RE.sub(_replace, value) + + class Action: """Base class for all actions.""" @@ -28,90 +46,122 @@ def execute(self): class UpdateFieldAction(Action): - """Action to update a specific field of an object.""" + """Action to update a specific field of a device. + + When *target_device* is supplied the action operates on that device rather + than the one that raised the event, enabling cross-device targeting (v2). + *trigger* is still required for context / logging. + """ - def __init__(self, db, field, value, trigger): + def __init__(self, db, field, value, trigger, target_device=None): super().__init__(trigger) self.field = field self.value = value self.db = db + self.target_device = target_device def execute(self): - mylog("verbose", f"[WF] Updating field '{self.field}' to '{self.value}' for event object {self.trigger.object_type}") + # Resolve the device to operate on + obj = self.target_device if self.target_device is not None else self.get_object() - obj = self.get_object() + if isinstance(obj, sqlite3.Row): + obj = dict(obj) if obj is None: - mylog("none", "[WF] Object no longer exists") + mylog("none", "[WF] UpdateFieldAction: target device no longer exists") return None - if isinstance(obj, dict) and "objectGuid" in obj: - mylog("debug", f"[WF] Updating Object '{obj}'") + # Interpolate {{trigger.X}} tokens in the value using the triggering device + trigger_obj = self.get_object() or {} + final_value = interpolate_tokens(self.value, trigger_obj) - PluginObjectInstance().updateField( - obj["objectGuid"], - self.field, - self.value, - ) + # Cast to int for boolean CHECK columns to satisfy SQLite constraints + if self.field in BOOLEAN_COLUMNS: + try: + final_value = int(final_value) + except (ValueError, TypeError): + mylog("none", [f"[WF] Cannot cast value '{final_value}' to int for boolean field '{self.field}' β€” skipping"]) + return None - return obj + mylog("verbose", f"[WF] Updating field '{self.field}' to '{final_value}' on device {obj.get('devGUID', '?')}") - if isinstance(obj, dict) and "devGUID" in obj: - mylog("debug", f"[WF] Updating Device '{obj}'") + if "objectGuid" in obj: + mylog("debug", f"[WF] Updating Object '{obj}'") + PluginObjectInstance().updateField(obj["objectGuid"], self.field, final_value) + return obj - DeviceInstance().updateField( - obj["devGUID"], - self.field, - self.value, - ) + if "devGUID" in obj: + # Guard: if mutating devMac, normalize the value and archive any + # existing device already holding that MAC before writing to avoid + # a PK UNIQUE constraint violation. + if self.field == "devMac": + final_value = normalize_mac(final_value) + self._archive_conflicting_mac(final_value, obj["devGUID"]) + mylog("debug", f"[WF] Updating Device '{obj.get('devGUID')}'") + DeviceInstance().updateField(obj["devGUID"], self.field, final_value) return obj - mylog("none", f"[WF] Unsupported object format: {obj}") - + mylog("none", f"[WF] UpdateFieldAction: unsupported object format: {obj}") return None + def _archive_conflicting_mac(self, new_mac, current_guid): + """If another device already holds *new_mac*, archive it before the + primary-key mutation so SQLite's UNIQUE constraint is not violated.""" + normalized = normalize_mac(new_mac) + existing = DeviceInstance().getByMac(normalized) + if existing and existing.get("devGUID") != current_guid: + mylog("none", [ + f"[WF] Archiving conflicting device {existing['devGUID']} " + f"(MAC {normalized}) before devMac update" + ]) + DeviceInstance().updateField(existing["devGUID"], "devIsArchived", 1) + class DeleteObjectAction(Action): - """Action to delete an object.""" + """Action to delete a device or plugin object. - def __init__(self, db, trigger): + When *target_device* is supplied the action deletes that device rather than + the one that raised the event, enabling cross-device targeting (v2). + """ + + def __init__(self, db, trigger, target_device=None): super().__init__(trigger) self.db = db + self.target_device = target_device def execute(self): - mylog("verbose", f"[WF] Deleting event object {self.trigger.object_type}") + obj = self.target_device if self.target_device is not None else self.get_object() - obj = self.get_object() + if isinstance(obj, sqlite3.Row): + obj = dict(obj) if obj is None: - mylog("none", "[WF] Object no longer exists") + mylog("none", "[WF] DeleteObjectAction: target device no longer exists") return None - if isinstance(obj, dict) and "objectGuid" in obj: - mylog("debug", f"[WF] Deleting Object '{obj}'") + mylog("verbose", f"[WF] Deleting device {obj.get('devGUID', obj.get('objectGuid', '?'))}") + if "objectGuid" in obj: + mylog("debug", f"[WF] Deleting Object '{obj}'") PluginObjectInstance().delete(obj["objectGuid"]) - return obj - if isinstance(obj, dict) and "devGUID" in obj: - mylog("debug", f"[WF] Deleting Device '{obj}'") - + if "devGUID" in obj: + mylog("debug", f"[WF] Deleting Device '{obj.get('devGUID')}'") DeviceInstance().delete(obj["devGUID"]) - return obj - mylog("none", f"[WF] Unsupported object format: {obj}") - + mylog("none", f"[WF] DeleteObjectAction: unsupported object format: {obj}") return None class RunPluginAction(Action): """Action to run a specific plugin.""" - def __init__(self, plugin_name, params, trigger): + def __init__(self, db, plugin_name, params, trigger): super().__init__(trigger) + self.db = db self.plugin_name = plugin_name self.params = params diff --git a/server/workflows/app_events.py b/server/workflows/app_events.py index 236b16634..997256c91 100755 --- a/server/workflows/app_events.py +++ b/server/workflows/app_events.py @@ -162,7 +162,28 @@ def save(self): self.db.commitDB() -# Manage prefixes of column names +# --------------------------------------------------------------------------- +# AppEvents query helpers +# --------------------------------------------------------------------------- + +def get_unprocessed(db): + """Return all unprocessed AppEvents rows ordered by creation time.""" + return db.sql.execute(""" + SELECT * FROM AppEvents + WHERE appEventProcessed = 0 + ORDER BY dateTimeCreated ASC + """).fetchall() + + +def mark_processed(db, event_index): + """Mark a single AppEvent row as processed and commit.""" + db.sql.execute( + 'UPDATE AppEvents SET appEventProcessed = 1 WHERE "index" = ?', + (event_index,), + ) + db.commitDB() + + def manage_prefix(field, event): if event == "delete": return field.replace("NEW.", "OLD.") diff --git a/server/workflows/constants.py b/server/workflows/constants.py new file mode 100644 index 000000000..ea9cabae6 --- /dev/null +++ b/server/workflows/constants.py @@ -0,0 +1,41 @@ +""" +Shared constants for the workflow engine. + +Centralised here so that manager, actions, and models can all import from a +single source of truth rather than duplicating schema knowledge across files. +""" + +import re + +# --------------------------------------------------------------------------- +# Devices table column whitelist +# --------------------------------------------------------------------------- + +# Every column present in the Devices table schema. Used in two ways: +# 1. Token validation β€” {{trigger.COLUMN}} tokens are rejected at workflow +# load time if COLUMN is not in this set. +# 2. Query safety β€” queryByConditions() refuses to build WHERE clauses for +# columns not in this set, preventing SQL injection via workflow JSON. +VALID_DEVICE_COLUMNS = frozenset([ + "devMac", "devName", "devOwner", "devType", "devVendor", "devFavorite", + "devGroup", "devComments", "devFirstConnection", "devLastConnection", + "devLastIP", "devPrimaryIPv4", "devPrimaryIPv6", "devVlan", "devForceStatus", + "devStaticIP", "devScan", "devLogEvents", "devAlertEvents", "devAlertDown", + "devSkipRepeated", "devLastNotification", "devPresentLastScan", "devIsNew", + "devLocation", "devIsArchived", "devParentMAC", "devParentPort", + "devParentRelType", "devIcon", "devGUID", "devSite", "devSSID", + "devSyncHubNode", "devSourcePlugin", "devFQDN", "devMacSource", + "devNameSource", "devFQDNSource", "devLastIPSource", "devVendorSource", + "devSSIDSource", "devParentMACSource", "devParentPortSource", + "devParentRelTypeSource", "devVlanSource", "devCustomProps", +]) + +# Devices table columns whose CHECK constraint requires a strict integer 0 or 1. +# Values destined for these columns are cast to int before being written to DB. +BOOLEAN_COLUMNS = frozenset([ + "devFavorite", "devStaticIP", "devLogEvents", "devAlertEvents", + "devAlertDown", "devPresentLastScan", "devIsNew", "devIsArchived", +]) + +# Compiled regex for {{trigger.COLUMN_NAME}} token substitution and validation. +TOKEN_RE = re.compile(r"\{\{trigger\.(\w+)\}\}") diff --git a/server/workflows/manager.py b/server/workflows/manager.py index 77ffa71c7..e171866fd 100755 --- a/server/workflows/manager.py +++ b/server/workflows/manager.py @@ -1,11 +1,15 @@ import json +import sqlite3 from const import fullConfFolder from logger import mylog, Logger from helper import get_setting_value +from models.device_instance import DeviceInstance +from workflows.constants import VALID_DEVICE_COLUMNS, TOKEN_RE +from workflows.app_events import get_unprocessed, mark_processed from workflows.triggers import Trigger from workflows.conditions import ConditionGroup -from workflows.actions import DeleteObjectAction, RunPluginAction, UpdateFieldAction +from workflows.actions import DeleteObjectAction, RunPluginAction, UpdateFieldAction, interpolate_tokens # Make sure log level is initialized correctly @@ -17,140 +21,185 @@ def __init__(self, db): self.db = db self.workflows = self.load_workflows() self.update_api = False + # Tracks devGUIDs mutated by workflow actions within the current event batch. + # Events whose objectGuid appears here are skipped to prevent cascade loops. + # Cleared at the start of each new event batch via get_new_app_events(). + self._mutated_guids = set() + + # ------------------------------------------------------------------------- + # Token validation + + def _validate_workflow_tokens(self, workflow): + """Recursively scan a workflow dict for {{trigger.X}} tokens. + Returns True if every token maps to a valid Devices column.""" + def _scan(node): + if isinstance(node, str): + for col in TOKEN_RE.findall(node): + if col not in VALID_DEVICE_COLUMNS: + mylog("none", [ + f"[WF] Invalid token '{{{{trigger.{col}}}}}' in workflow " + f"'{workflow.get('name', '?')}' β€” must be a valid Devices column" + ]) + return False + return True + if isinstance(node, dict): + return all(_scan(v) for v in node.values()) + if isinstance(node, list): + return all(_scan(item) for item in node) + return True + + return _scan(workflow) + + # ------------------------------------------------------------------------- + # Loading def load_workflows(self): - """Load workflows from workflows.json.""" + """Load workflows from workflows.json, rejecting any with invalid tokens.""" try: workflows_json_path = fullConfFolder + "/workflows.json" with open(workflows_json_path, "r") as f: - workflows = json.load(f) - return workflows + raw = json.load(f) except (FileNotFoundError, json.JSONDecodeError): mylog("none", ["[WF] Failed to load workflows.json"]) return [] + valid = [] + for wf in raw: + if self._validate_workflow_tokens(wf): + valid.append(wf) + else: + mylog("none", [f"[WF] Workflow '{wf.get('name', '?')}' rejected β€” contains invalid trigger tokens"]) + return valid + + # ------------------------------------------------------------------------- + # Event fetching + def get_new_app_events(self): - """Get new unprocessed events from the AppEvents table.""" - result = self.db.sql.execute(""" - SELECT * FROM AppEvents - WHERE appEventProcessed = 0 - ORDER BY dateTimeCreated ASC - """).fetchall() + """Get new unprocessed events from the AppEvents table. + Resets _mutated_guids to start a fresh cascade-prevention window for this batch.""" + self._mutated_guids.clear() + + result = get_unprocessed(self.db) mylog("none", [f"[WF] get_new_app_events - new events count: {len(result)}"]) return result - def process_event(self, event): - """Process the events. Check if events match a workflow trigger""" + # ------------------------------------------------------------------------- + # Event processing + def process_event(self, event): + """Process one AppEvent against all enabled workflows.""" evGuid = event["guid"] + obj_guid = event["objectGuid"] + + # Cascade prevention: skip events for devices already mutated this batch + if obj_guid in self._mutated_guids: + mylog("debug", [f"[WF] Skipping event {evGuid} β€” device {obj_guid} was mutated by a workflow in this batch"]) + mark_processed(self.db, event["index"]) + return mylog("verbose", [f"[WF] Processing event with GUID {evGuid}"]) - # Check if the trigger conditions match for workflow in self.workflows: - # Ensure workflow is enabled before proceeding if workflow.get("enabled", "No").lower() == "yes": wfName = workflow["name"] mylog("debug", f"[WF] Checking if '{evGuid}' triggers the workflow '{wfName}'") - # construct trigger object which also evaluates if the current event triggers it trigger = Trigger(workflow["trigger"], event, self.db) if trigger.triggered: mylog("verbose", f"[WF] Event with GUID '{evGuid}' triggered the workflow '{wfName}'") - self.execute_workflow(workflow, trigger) - # After processing the event, mark the event as processed (set AppEventProcessed to 1) - self.db.sql.execute( - """ - UPDATE AppEvents - SET appEventProcessed = 1 - WHERE "index" = ? - """, - (event["index"],), - ) # Pass the event's unique identifier - self.db.commitDB() + mark_processed(self.db, event["index"]) - def execute_workflow(self, workflow, trigger): - """Execute the actions in the given workflow if conditions are met.""" + # ------------------------------------------------------------------------- + # Workflow execution + def execute_workflow(self, workflow, trigger): + """Execute workflow actions if any condition group evaluates to True.""" wfName = workflow["name"] - # Ensure conditions exist if not isinstance(workflow.get("conditions"), list): m = "[WF] workflow['conditions'] must be a list" mylog("none", [m]) raise ValueError(m) - # Evaluate each condition group separately for condition_group in workflow["conditions"]: evaluator = ConditionGroup(condition_group) - - if evaluator.evaluate(trigger): # If any group evaluates to True + if evaluator.evaluate(trigger): mylog("none", f"[WF] Workflow {wfName} will be executed - conditions were evaluated as TRUE") mylog("debug", [f"[WF] Workflow condition_group: {condition_group}"]) - self.execute_actions(workflow["actions"], trigger) - return # Stop if a condition group succeeds + return mylog("none", ["[WF] No condition group matched. Actions not executed."]) + def _resolve_target_devices(self, action, trigger_device): + """Return the list of device dicts that the action should be applied to. + + - No ``target`` key or ``strategy == "triggering_device"`` β†’ legacy behaviour, + targets only the device that raised the event. + - ``strategy == "query"`` β†’ query the Devices table using the action's + nested conditions (with {{trigger.X}} tokens already interpolated). + """ + target_block = action.get("target", {}) + strategy = target_block.get("strategy", "triggering_device") + + if strategy == "triggering_device": + return [trigger_device] if trigger_device is not None else [] + + if strategy == "query": + raw_conditions = target_block.get("conditions", []) + compiled_conditions = [] + for cond in raw_conditions: + compiled = dict(cond) + compiled["value"] = interpolate_tokens(cond["value"], trigger_device or {}) + compiled_conditions.append(compiled) + return DeviceInstance().queryByConditions(compiled_conditions) + + mylog("none", [f"[WF] Unknown target strategy '{strategy}' β€” skipping action"]) + return [] + def execute_actions(self, actions, trigger): - """Execute the actions defined in a workflow.""" + """Execute all actions defined in a workflow against their resolved targets.""" + # Normalise trigger object to a plain dict for token operations + trigger_obj = trigger.object + if isinstance(trigger_obj, sqlite3.Row): + trigger_obj = dict(trigger_obj) for action in actions: - if action["type"] == "update_field": - field = action["field"] - value = action["value"] - action_instance = UpdateFieldAction(self.db, field, value, trigger) - # indicate if the api has to be updated - self.update_api = True - - elif action["type"] == "run_plugin": - plugin_name = action["plugin"] - params = action["params"] - action_instance = RunPluginAction(self.db, plugin_name, params, trigger) - - elif action["type"] == "delete_device": - action_instance = DeleteObjectAction(self.db, trigger) - - # elif action["type"] == "send_notification": - # method = action["method"] - # message = action["message"] - # action_instance = SendNotificationAction(method, message, trigger) + action_type = action["type"] - else: - m = f"[WF] Unsupported action type: {action['type']}" - mylog("none", [m]) - raise ValueError(m) - - action_instance.execute() # Execute the action - - # if result: - # # Iterate through actions and execute them - # for action in workflow["actions"]: - # if action["type"] == "update_field": - # # Action type is "update_field", so map to UpdateFieldAction - # field = action["field"] - # value = action["value"] - # action_instance = UpdateFieldAction(field, value) - # action_instance.execute(trigger.event) - - # elif action["type"] == "run_plugin": - # # Action type is "run_plugin", so map to RunPluginAction - # plugin_name = action["plugin"] - # params = action["params"] - # action_instance = RunPluginAction(plugin_name, params) - # action_instance.execute(trigger.event) - # elif action["type"] == "send_notification": - # # Action type is "send_notification", so map to SendNotificationAction - # method = action["method"] - # message = action["message"] - # action_instance = SendNotificationAction(method, message) - # action_instance.execute(trigger.event) - # else: - # # Handle unsupported action types - # raise ValueError(f"Unsupported action type: {action['type']}") + # run_plugin does not support query targeting β€” always uses the trigger context + if action_type == "run_plugin": + RunPluginAction(self.db, action["plugin"], action["params"], trigger).execute() + continue + + target_devices = self._resolve_target_devices(action, trigger_obj) + + if not target_devices: + mylog("debug", [f"[WF] No target devices matched for action '{action_type}'"]) + continue + + for target_device in target_devices: + if action_type == "update_field": + action_instance = UpdateFieldAction( + self.db, action["field"], action["value"], trigger, target_device + ) + self.update_api = True + + elif action_type == "delete_device": + action_instance = DeleteObjectAction(self.db, trigger, target_device) + + else: + m = f"[WF] Unsupported action type: {action_type}" + mylog("none", [m]) + raise ValueError(m) + + action_instance.execute() + + # Record this device's GUID so cascade events are suppressed in this batch + if isinstance(target_device, dict) and target_device.get("devGUID"): + self._mutated_guids.add(target_device["devGUID"]) diff --git a/test/backend/test_workflows.py b/test/backend/test_workflows.py new file mode 100644 index 000000000..5a39d78af --- /dev/null +++ b/test/backend/test_workflows.py @@ -0,0 +1,403 @@ +""" +Unit tests for Workflow Engine v2 β€” cross-device targeting. + +Covers: + - interpolate_tokens() + - WorkflowManager.VALID_DEVICE_COLUMNS token validation + - WorkflowManager._validate_workflow_tokens() + - WorkflowManager.load_workflows() rejects invalid-token workflows + - DeviceInstance.queryByConditions() + - UpdateFieldAction boolean column casting + - UpdateFieldAction _archive_conflicting_mac guard + - WorkflowManager._mutated_guids cascade prevention +""" + +import sys +import os +import json +import tempfile +import unittest +from unittest.mock import patch, MagicMock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from db_test_helpers import make_db, make_device_dict, insert_device_from_dict + + +# --------------------------------------------------------------------------- +# Shared test helpers +# --------------------------------------------------------------------------- + +def _make_app_event(guid="evt-guid-1", obj_guid="dev-guid-1", obj_type="Devices", + event_type="update", index=1): + """Return a dict mimicking an AppEvents sqlite3.Row.""" + return { + "guid": guid, + "objectGuid": obj_guid, + "objectType": obj_type, + "appEventType": event_type, + "appEventProcessed": 0, + "index": index, + } + + +def make_stub_manager(): + """Return a WorkflowManager with a mock DB and no workflows loaded.""" + from workflows.manager import WorkflowManager + db = MagicMock() + db.sql = MagicMock() + db.sql.execute.return_value.fetchall.return_value = [] + db.commitDB = MagicMock() + with patch.object(WorkflowManager, "load_workflows", return_value=[]): + mgr = WorkflowManager(db) + return mgr + + +# --------------------------------------------------------------------------- +# interpolate_tokens +# --------------------------------------------------------------------------- + +class TestInterpolateTokens(unittest.TestCase): + + def setUp(self): + from workflows.actions import interpolate_tokens + self.interpolate = interpolate_tokens + + def test_replaces_known_token(self): + device = {"devLastIP": "10.0.0.5", "devMac": "aa:bb:cc:dd:ee:ff"} + result = self.interpolate("{{trigger.devLastIP}}", device) + self.assertEqual(result, "10.0.0.5") + + def test_replaces_multiple_tokens(self): + device = {"devLastIP": "10.0.0.5", "devMac": "aa:bb:cc:dd:ee:ff"} + result = self.interpolate("ip={{trigger.devLastIP}} mac={{trigger.devMac}}", device) + self.assertEqual(result, "ip=10.0.0.5 mac=aa:bb:cc:dd:ee:ff") + + def test_leaves_unknown_token_unchanged(self): + device = {"devLastIP": "10.0.0.5"} + result = self.interpolate("{{trigger.doesNotExist}}", device) + self.assertEqual(result, "{{trigger.doesNotExist}}") + + def test_non_string_value_returned_as_is(self): + device = {} + self.assertEqual(self.interpolate(42, device), 42) + self.assertIsNone(self.interpolate(None, device)) + + def test_empty_device_dict_leaves_token_unchanged(self): + result = self.interpolate("{{trigger.devMac}}", {}) + self.assertEqual(result, "{{trigger.devMac}}") + + +# --------------------------------------------------------------------------- +# Token validation +# --------------------------------------------------------------------------- + +class TestValidateWorkflowTokens(unittest.TestCase): + + def test_valid_token_passes(self): + mgr = make_stub_manager() + wf = {"name": "test", "actions": [{"value": "{{trigger.devLastIP}}"}]} + self.assertTrue(mgr._validate_workflow_tokens(wf)) + + def test_invalid_token_fails(self): + mgr = make_stub_manager() + wf = {"name": "test", "actions": [{"value": "{{trigger.ip_address}}"}]} + self.assertFalse(mgr._validate_workflow_tokens(wf)) + + def test_nested_invalid_token_fails(self): + mgr = make_stub_manager() + wf = { + "name": "test", + "actions": [{ + "target": { + "conditions": [{"value": "{{trigger.bad_field}}"}] + } + }] + } + self.assertFalse(mgr._validate_workflow_tokens(wf)) + + def test_no_tokens_passes(self): + mgr = make_stub_manager() + wf = {"name": "test", "conditions": [], "actions": [{"value": "static"}]} + self.assertTrue(mgr._validate_workflow_tokens(wf)) + + +class TestLoadWorkflowsRejectsInvalidTokens(unittest.TestCase): + + def _make_manager_loading(self, raw_workflows): + """Build a WorkflowManager whose load_workflows() reads from a temp file.""" + import workflows.manager as wf_mod + from workflows.manager import WorkflowManager + + with tempfile.TemporaryDirectory() as tmpdir: + wf_path = os.path.join(tmpdir, "workflows.json") + with open(wf_path, "w") as f: + json.dump(raw_workflows, f) + + orig = wf_mod.fullConfFolder + wf_mod.fullConfFolder = tmpdir + try: + db = MagicMock() + with patch.object(WorkflowManager, "load_workflows", return_value=[]): + mgr = WorkflowManager(db) + mgr.workflows = mgr.load_workflows() + finally: + wf_mod.fullConfFolder = orig + return mgr + + def test_valid_workflow_loaded(self): + wf = { + "name": "Valid WF", "enabled": "Yes", + "trigger": {"object_type": "Devices", "event_type": "update"}, + "conditions": [], + "actions": [{"type": "update_field", "field": "devIsNew", + "value": "{{trigger.devLastIP}}"}] + } + mgr = self._make_manager_loading([wf]) + self.assertEqual(len(mgr.workflows), 1) + + def test_invalid_token_workflow_rejected(self): + wf = { + "name": "Bad WF", "enabled": "Yes", + "trigger": {"object_type": "Devices", "event_type": "update"}, + "conditions": [], + "actions": [{"type": "update_field", "field": "devIsNew", + "value": "{{trigger.nonexistent_field}}"}] + } + mgr = self._make_manager_loading([wf]) + self.assertEqual(len(mgr.workflows), 0) + + +# --------------------------------------------------------------------------- +# DeviceInstance.queryByConditions +# --------------------------------------------------------------------------- + +class TestQueryByConditions(unittest.TestCase): + + def setUp(self): + self.conn = make_db() + dev_a = make_device_dict("aa:bb:cc:dd:ee:01", devLastIP="192.168.1.10", + devGUID="guid-a", devIsArchived=0) + dev_b = make_device_dict("aa:bb:cc:dd:ee:02", devLastIP="192.168.1.10", + devGUID="guid-b", devIsArchived=0) + dev_c = make_device_dict("aa:bb:cc:dd:ee:03", devLastIP="192.168.1.20", + devGUID="guid-c", devIsArchived=0) + for d in [dev_a, dev_b, dev_c]: + insert_device_from_dict(self.conn, d) + + def _instance(self): + from models.device_instance import DeviceInstance + inst = DeviceInstance() + # Patch _fetchall to use our in-memory connection + def _fetchall(q, p=()): + rows = self.conn.execute(q, p).fetchall() + return [dict(r) for r in rows] + inst._fetchall = _fetchall + return inst + + def test_equals_returns_matching_devices(self): + inst = self._instance() + results = inst.queryByConditions([ + {"field": "devLastIP", "operator": "equals", "value": "192.168.1.10"} + ]) + macs = {r["devMac"] for r in results} + self.assertIn("aa:bb:cc:dd:ee:01", macs) + self.assertIn("aa:bb:cc:dd:ee:02", macs) + self.assertNotIn("aa:bb:cc:dd:ee:03", macs) + + def test_multiple_conditions_and_logic(self): + inst = self._instance() + results = inst.queryByConditions([ + {"field": "devLastIP", "operator": "equals", "value": "192.168.1.10"}, + {"field": "devMac", "operator": "equals", "value": "aa:bb:cc:dd:ee:01"}, + ]) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["devMac"], "aa:bb:cc:dd:ee:01") + + def test_contains_operator(self): + inst = self._instance() + results = inst.queryByConditions([ + {"field": "devLastIP", "operator": "contains", "value": "192.168.1"} + ]) + self.assertEqual(len(results), 3) + + def test_empty_conditions_returns_empty(self): + inst = self._instance() + results = inst.queryByConditions([]) + self.assertEqual(results, []) + + def test_unknown_field_skipped_returns_empty(self): + inst = self._instance() + results = inst.queryByConditions([ + {"field": "nonexistent_column", "operator": "equals", "value": "x"} + ]) + self.assertEqual(results, []) + + def test_unknown_operator_skipped_returns_empty(self): + inst = self._instance() + results = inst.queryByConditions([ + {"field": "devLastIP", "operator": "regex", "value": ".*"} + ]) + self.assertEqual(results, []) + + +# --------------------------------------------------------------------------- +# UpdateFieldAction β€” boolean cast +# --------------------------------------------------------------------------- + +class TestUpdateFieldActionBooleanCast(unittest.TestCase): + + def setUp(self): + self.conn = make_db() + dev = make_device_dict("aa:bb:cc:dd:ee:ff", devGUID="guid-1", devIsArchived=0) + insert_device_from_dict(self.conn, dev) + + def _make_action(self, field, value, target_device): + from workflows.actions import UpdateFieldAction + trigger = MagicMock() + trigger.object = None + trigger.object_type = "Devices" + db = MagicMock() + + action = UpdateFieldAction(db, field, value, trigger, target_device) + + # Patch DeviceInstance.updateField to capture what value is written + self.written_value = None + def fake_update(guid, f, v): + self.written_value = v + with patch("workflows.actions.DeviceInstance") as MockDI: + MockDI.return_value.updateField.side_effect = fake_update + action.execute() + + return self.written_value + + def test_string_one_cast_to_int_for_boolean_column(self): + target = {"devGUID": "guid-1", "devIsArchived": 0} + written = self._make_action("devIsArchived", "1", target) + self.assertEqual(written, 1) + self.assertIsInstance(written, int) + + def test_string_zero_cast_to_int_for_boolean_column(self): + target = {"devGUID": "guid-1", "devIsArchived": 1} + written = self._make_action("devIsArchived", "0", target) + self.assertEqual(written, 0) + self.assertIsInstance(written, int) + + def test_non_boolean_column_not_cast(self): + target = {"devGUID": "guid-1", "devName": "OldName"} + written = self._make_action("devName", "NewName", target) + self.assertEqual(written, "NewName") + self.assertIsInstance(written, str) + + def test_invalid_boolean_value_skips_update(self): + target = {"devGUID": "guid-1", "devIsArchived": 0} + written = self._make_action("devIsArchived", "not_an_int", target) + self.assertIsNone(written) + + +# --------------------------------------------------------------------------- +# UpdateFieldAction β€” devMac conflict archive guard +# --------------------------------------------------------------------------- + +class TestUpdateFieldActionMacGuard(unittest.TestCase): + + def test_conflicting_mac_device_archived(self): + from workflows.actions import UpdateFieldAction + trigger = MagicMock() + trigger.object = None + db = MagicMock() + + conflicting = {"devGUID": "guid-conflict", "devMac": "aa:bb:cc:dd:ee:ff"} + current_guid = "guid-current" + target_device = {"devGUID": current_guid, "devMac": "11:22:33:44:55:66"} + + action = UpdateFieldAction(db, "devMac", "aa:bb:cc:dd:ee:ff", trigger, target_device) + + archived_guid = None + def fake_update(guid, field, value): + nonlocal archived_guid + if field == "devIsArchived": + archived_guid = guid + + with patch("workflows.actions.DeviceInstance") as MockDI: + MockDI.return_value.getByMac.return_value = conflicting + MockDI.return_value.updateField.side_effect = fake_update + action.execute() + + self.assertEqual(archived_guid, "guid-conflict") + + def test_no_conflicting_mac_no_archive(self): + from workflows.actions import UpdateFieldAction + trigger = MagicMock() + trigger.object = None + db = MagicMock() + + target_device = {"devGUID": "guid-current", "devMac": "11:22:33:44:55:66"} + action = UpdateFieldAction(db, "devMac", "aa:bb:cc:dd:ee:ff", trigger, target_device) + + archived_guid = None + def fake_update(guid, field, value): + nonlocal archived_guid + if field == "devIsArchived": + archived_guid = guid + + with patch("workflows.actions.DeviceInstance") as MockDI: + MockDI.return_value.getByMac.return_value = None + MockDI.return_value.updateField.side_effect = fake_update + action.execute() + + self.assertIsNone(archived_guid) + + +# --------------------------------------------------------------------------- +# Cascade prevention β€” _mutated_guids +# --------------------------------------------------------------------------- + +class TestCascadePrevention(unittest.TestCase): + + def test_mutated_guid_blocks_event(self): + mgr = make_stub_manager() + mgr._mutated_guids.add("dev-guid-42") + + event = _make_app_event(guid="evt-1", obj_guid="dev-guid-42") + # Make event dict-accessible + event = MagicMock() + event.__getitem__ = lambda s, k: {"guid": "evt-1", "objectGuid": "dev-guid-42", + "index": 1}[k] + + # process_event should skip without calling execute_workflow + with patch.object(mgr, "execute_workflow") as mock_exec: + mgr.process_event(event) + mock_exec.assert_not_called() + + def test_get_new_app_events_clears_mutated_guids(self): + mgr = make_stub_manager() + mgr._mutated_guids.add("some-guid") + + mgr.db.sql.execute.return_value.fetchall.return_value = [] + mgr.get_new_app_events() + + self.assertEqual(len(mgr._mutated_guids), 0) + + def test_execute_actions_adds_to_mutated_guids(self): + mgr = make_stub_manager() + + target_device = {"devGUID": "guid-mutated", "devIsArchived": 0} + + actions = [{"type": "update_field", "field": "devIsArchived", "value": "1"}] + + trigger = MagicMock() + trigger.object = None + + with patch("workflows.manager.DeviceInstance"), \ + patch("workflows.actions.DeviceInstance") as MockDI: + MockDI.return_value.updateField = MagicMock() + with patch.object(mgr, "_resolve_target_devices", return_value=[target_device]): + mgr.execute_actions(actions, trigger) + + self.assertIn("guid-mutated", mgr._mutated_guids) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/db_test_helpers.py b/test/db_test_helpers.py index c61d2ec94..afb941dbe 100644 --- a/test/db_test_helpers.py +++ b/test/db_test_helpers.py @@ -377,6 +377,28 @@ def down_event_macs(cur) -> set: return {r["eveMac"].lower() for r in cur.fetchall()} +def insert_device_from_dict(conn: sqlite3.Connection, device: dict) -> None: + """Insert a device dict (as produced by make_device_dict) into Devices. + + Uses INSERT OR IGNORE so duplicate MACs are silently skipped. Accepts any + subset of Devices columns β€” only keys present in the table are written. + """ + cur = conn.cursor() + cur.execute("PRAGMA table_info(Devices)") + db_columns = {row[1] for row in cur.fetchall()} + + cols = [k for k in device.keys() if k in db_columns] + placeholders = ", ".join("?" for _ in cols) + col_list = ", ".join(cols) + values = [device[c] for c in cols] + + cur.execute( + f"INSERT OR IGNORE INTO Devices ({col_list}) VALUES ({placeholders})", + values, + ) + conn.commit() + + # --------------------------------------------------------------------------- # DummyDB β€” minimal wrapper used by scan.session_events helpers # --------------------------------------------------------------------------- From 3e44fac2004e3f6f972c838e0d4adc38e8d70c70 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Tue, 16 Jun 2026 22:52:50 +1000 Subject: [PATCH 2/4] WF: fix suggestions --- front/workflowsCore.php | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/front/workflowsCore.php b/front/workflowsCore.php index b66ef2840..4cc027440 100755 --- a/front/workflowsCore.php +++ b/front/workflowsCore.php @@ -326,7 +326,7 @@ class: `action-target-conditions panel col-sm-12 col-sx-12 ${currentStrategy === .append(` ${getString("WF_Action_target_conditions")}:`); let $tokenHint = $("
", { class: "text-muted small col-sm-12 col-xs-12" }) - .text(getString("WF_Action_token_hint")); + .html(getString("WF_Action_token_hint")); $targetConditionsWrap.append($targetConditionsTitle); $targetConditionsWrap.append($tokenHint); @@ -1230,12 +1230,18 @@ function saveWorkflows() appConfBase64 = btoa(JSON.stringify(getWorkflowsJson())) // import - $.post('php/server/query_replace_config.php', { base64data: appConfBase64, fileName: "workflows.json" }, function(msg) { - console.log(msg); - // showMessage(msg); - hideSpinner(); - write_notification(`[WF]: ${msg}`, 'interrupt'); - }); + $.post('php/server/query_replace_config.php', { base64data: appConfBase64, fileName: "workflows.json" }) + .done(function(msg) { + console.log(msg); + write_notification(`[WF]: ${msg}`, 'interrupt'); + }) + .fail(function(jqXHR, textStatus, errorThrown) { + console.warn("Failed to save workflows.json:", textStatus, errorThrown); + write_notification(`[WF]: Save failed (${textStatus})`, 'interrupt'); + }) + .always(function() { + hideSpinner(); + }); } // --------------------------------------------------- From 7311ed8dc55a9ae88af79e1f66f68de2fbd84d50 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Tue, 16 Jun 2026 23:12:28 +1000 Subject: [PATCH 3/4] WF: fixes --- front/css/app.css | 11 +++++++++++ front/workflowsCore.php | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/front/css/app.css b/front/css/app.css index 4a5b39fab..7f5a43b0e 100755 --- a/front/css/app.css +++ b/front/css/app.css @@ -2338,6 +2338,17 @@ textarea[readonly], padding: 5px; } +.workflows .add-target-condition +{ + margin: 10px; + text-align: center; +} + +.workflows .inline-hint +{ + margin: 5px; +} + .workflows { max-width: 800px; diff --git a/front/workflowsCore.php b/front/workflowsCore.php index 4cc027440..4601e6287 100755 --- a/front/workflowsCore.php +++ b/front/workflowsCore.php @@ -325,7 +325,7 @@ class: `action-target-conditions panel col-sm-12 col-sx-12 ${currentStrategy === .append($("", { class: "fa-solid fa-crosshairs" })) .append(` ${getString("WF_Action_target_conditions")}:`); - let $tokenHint = $("
", { class: "text-muted small col-sm-12 col-xs-12" }) + let $tokenHint = $("
", { class: "text-muted inline-hint small col-sm-12 col-xs-12" }) .html(getString("WF_Action_token_hint")); $targetConditionsWrap.append($targetConditionsTitle); From 5ee3a04598ef7760a0370cb8b82563fac5e10cc3 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Tue, 16 Jun 2026 23:31:36 +1000 Subject: [PATCH 4/4] WF: docs --- docs/WORKFLOW_EXAMPLES.md | 71 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/WORKFLOW_EXAMPLES.md b/docs/WORKFLOW_EXAMPLES.md index f94647f86..9ec6c93d8 100755 --- a/docs/WORKFLOW_EXAMPLES.md +++ b/docs/WORKFLOW_EXAMPLES.md @@ -54,6 +54,7 @@ Sometimes devices are manually archived (e.g., no longer expected on the network * `devIsArchived` is `1` (archived). * `devPresentLastScan` is `1` (device was detected in the latest scan). + * **Action**: * Updates the device to set `devIsArchived` to `0` (unarchived). @@ -110,6 +111,7 @@ When new devices join your network, assigning them to the correct network node i * **Conditions**: * `devLastIP` contains `192.168.1.` (matches subnet). + * **Action**: * Sets `devNetworkNode` to the specified MAC address. @@ -175,6 +177,7 @@ You may want to automatically clear out newly detected Google devices (such as C * `devVendor` contains `Google`. * `devIsNew` is `1` (device marked as new). + * **Actions**: 1. Sets `devIsNew` to `0` (mark as not new). @@ -182,4 +185,70 @@ You may want to automatically clear out newly detected Google devices (such as C ### βœ… Result -Any newly detected Google devices are cleaned up instantly β€” first marked as not new, then deleted β€” helping you avoid clutter in your device records. \ No newline at end of file +Any newly detected Google devices are cleaned up instantly β€” first marked as not new, then deleted β€” helping you avoid clutter in your device records. + +--- + +## Example 4: On new device discovery archive the old device with the same ip + +This workflow automatically archives devices if a new device is discovered with an already assigned IP. + +### πŸ“‹ Use Case + +This workflow is useful if you are assigning static IPs to your devices. This workflow can also help with archiving device entries with [random MAC addresses](./RANDOM_MAC.md). + +### βš™οΈ Workflow Configuration + +```json +{ + "name": "Archive device with same ip", + "trigger": { + "object_type": "Devices", + "event_type": "insert" + }, + "conditions": [ + { + "logic": "AND", + "conditions": [] + } + ], + "actions": [ + { + "type": "update_field", + "field": "devIsArchived", + "value": "1", + "target": { + "strategy": "query", + "conditions": [ + { + "field": "devLastIP", + "operator": "equals", + "value": "{{trigger.devLastIP}}" + } + ] + } + } + ] +} +``` + +### πŸ” Explanation + +* **Trigger**: Runs on a new device being inserted. + +* **Conditions**: + + * `N/A` + +* **Target Conditions**: + + * `devLastIP` of the target is the same as the newly discovered device's `devLastIP` value. + +* **Actions**: + + 1. Sets `devIsArchived` to `1` (mark target device as archived). + + +### βœ… Result + +Any newly detected device that has the same IP as an existing device will automatically trigger the archival of the old device.