diff --git a/docs/WORKFLOW_EXAMPLES.md b/docs/WORKFLOW_EXAMPLES.md index 9ec6c93d..e680c663 100755 --- a/docs/WORKFLOW_EXAMPLES.md +++ b/docs/WORKFLOW_EXAMPLES.md @@ -195,7 +195,9 @@ This workflow automatically archives devices if a new device is discovered with ### 📋 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). +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). + +The `not_equals` condition on `devMac` ensures the device that triggered the workflow is never archived — only the *other* device sharing the same IP is targeted. ### ⚙️ Workflow Configuration @@ -224,6 +226,11 @@ This workflow is useful if you are assigning static IPs to your devices. This wo "field": "devLastIP", "operator": "equals", "value": "{{trigger.devLastIP}}" + }, + { + "field": "devMac", + "operator": "not_equals", + "value": "{{trigger.devMac}}" } ] } @@ -240,15 +247,15 @@ This workflow is useful if you are assigning static IPs to your devices. This wo * `N/A` -* **Target Conditions**: +* **Target Conditions** (evaluated as AND): - * `devLastIP` of the target is the same as the newly discovered device's `devLastIP` value. + * `devLastIP` equals the triggering device's IP (`{{trigger.devLastIP}}`). + * `devMac` **not equals** the triggering device's MAC (`{{trigger.devMac}}`). * **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. +Any existing device that shares the same IP as the newly discovered device is automatically archived. The triggering device itself is excluded from the target query, so it is never archived by its own workflow. diff --git a/front/workflowsCore.php b/front/workflowsCore.php index ebc81725..12b9c0ec 100755 --- a/front/workflowsCore.php +++ b/front/workflowsCore.php @@ -66,7 +66,7 @@ let wfEnabledOptions = [ ]; let operatorTypes = [ - "equals", "contains" , "regex" + "equals", "not_equals", "contains", "not_contains", "regex" ]; let actionTypes = [ diff --git a/server/models/device_instance.py b/server/models/device_instance.py index 1fafc964..021c89d4 100755 --- a/server/models/device_instance.py +++ b/server/models/device_instance.py @@ -106,7 +106,7 @@ class DeviceInstance: """Query Devices using a list of condition dicts. Each condition dict must have ``field``, ``operator``, and ``value`` keys. - Supported operators: ``equals``, ``contains``. + Supported operators: ``equals``, ``not_equals``, ``contains``, ``not_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 @@ -131,9 +131,15 @@ class DeviceInstance: if operator == "equals": clauses.append(f"{field} = ?") params.append(value) + elif operator == "not_equals": + clauses.append(f"{field} != ?") + params.append(value) elif operator == "contains": clauses.append(f"{field} LIKE ?") params.append(f"%{value}%") + elif operator == "not_contains": + clauses.append(f"{field} NOT LIKE ?") + params.append(f"%{value}%") else: mylog("none", [f"[WF] queryByConditions: unsupported operator '{operator}' — skipped"]) continue diff --git a/server/workflows/conditions.py b/server/workflows/conditions.py index ed19d851..d68b8c48 100755 --- a/server/workflows/conditions.py +++ b/server/workflows/conditions.py @@ -36,8 +36,12 @@ class Condition: # process based on operators if self.operator == "equals": result = str(obj_value) == str(self.value) + elif self.operator == "not_equals": + result = str(obj_value) != str(self.value) elif self.operator == "contains": result = str(self.value).lower() in str(obj_value).lower() + elif self.operator == "not_contains": + result = str(self.value).lower() not in str(obj_value).lower() elif self.operator == "regex": result = bool(re.match(self.value, str(obj_value))) else: diff --git a/test/backend/test_workflows.py b/test/backend/test_workflows.py index 5a39d78a..c075aaa4 100644 --- a/test/backend/test_workflows.py +++ b/test/backend/test_workflows.py @@ -241,6 +241,39 @@ class TestQueryByConditions(unittest.TestCase): ]) self.assertEqual(results, []) + def test_not_equals_operator(self): + inst = self._instance() + results = inst.queryByConditions([ + {"field": "devLastIP", "operator": "not_equals", "value": "192.168.1.10"} + ]) + macs = {r["devMac"] for r in results} + self.assertNotIn("aa:bb:cc:dd:ee:01", macs) + self.assertNotIn("aa:bb:cc:dd:ee:02", macs) + self.assertIn("aa:bb:cc:dd:ee:03", macs) + + def test_not_contains_operator(self): + inst = self._instance() + results = inst.queryByConditions([ + {"field": "devLastIP", "operator": "not_contains", "value": "192.168.1.1"} + ]) + macs = {r["devMac"] for r in results} + # .10 contains "192.168.1.1", .20 does not + self.assertNotIn("aa:bb:cc:dd:ee:01", macs) + self.assertNotIn("aa:bb:cc:dd:ee:02", macs) + self.assertIn("aa:bb:cc:dd:ee:03", macs) + + def test_not_equals_combined_with_equals(self): + """Exclude trigger device by MAC while matching on IP — the core use case.""" + inst = self._instance() + results = inst.queryByConditions([ + {"field": "devLastIP", "operator": "equals", "value": "192.168.1.10"}, + {"field": "devMac", "operator": "not_equals", "value": "aa:bb:cc:dd:ee:01"}, + ]) + macs = {r["devMac"] for r in results} + self.assertNotIn("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) + # --------------------------------------------------------------------------- # UpdateFieldAction — boolean cast