Closes #18159: Expose snapshots to event rule condition evaluation#22637
Conversation
- Enrich the condition evaluation context in process_event_rules() with the event's pre/post snapshots, making snapshots.prechange.<attr> and snapshots.postchange.<attr> reachable via the existing dot-path syntax. - Add 'changed' and 'unchanged' snapshot operators to Condition. These compare self.attr across the two snapshots without requiring a value, covering the canonical use case of firing only when a field transitions to a particular value (e.g. status changes to active). - Make value optional in Condition.__init__() using a module-level _MISSING sentinel; snapshot operators reject a value, all others require one. - Add 16 tests covering operator validation, changed/unchanged behaviour, null-prechange edge cases, direct snapshot path access, and the eval_conditions() integration. - Update docs/reference/conditions.md to document the new operators, snapshot path syntax, and the serialization format difference between snapshot data and REST API data. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion test - if not snapshots → if snapshots is None in Condition.eval() to avoid treating an empty dict as absent - Remove redundant try/except InvalidCondition: raise (exception propagates naturally from _resolve_attr) - Add test_eventrule_snapshot_changed_condition() in test_event_rules.py covering the full process_event_rules() path: fires on planned→active transition, silent on subsequent description-only update Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@claude /review and limit response to areas of potential concern. |
|
Claude finished @bctiemann's task in 2m 16s —— View job PR Review — Areas of Potential Concern
Overall the implementation is clean and well-tested. Below are the specific concerns worth addressing before merge. 1.
|
- Validate that snapshot operators ('changed'/'unchanged') reject attrs
starting with 'snapshots.' — users combining the two syntaxes get an
explicit error at config time instead of a silent always-false condition
- Document delete-event behavior: postchange is null on delete events,
so 'changed' evaluates to true and 'unchanged' to false for any attr
present in the prechange snapshot
- Rename duplicate Site objects in SnapshotConditionTestCase tests so
each test has a unique, identifiable fixture
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Addressed items 2 and 3, and also added a test for the validation on 3. Fixed the duplicate site names in the fixtures as well. |
pheus
left a comment
There was a problem hiding this comment.
Thanks for working on this! The overall approach looks solid to me.
I think there are a few edge cases worth tightening before merge, mostly around fail-closed behavior for invalid snapshot paths.
- Catch TypeError in _resolve_snapshot_attr() so paths that traverse a scalar value (e.g. 'status.value' when status is a raw string in snapshot format) return _MISSING instead of bubbling an exception - Make eval_unchanged() fail-closed: return False when the attribute is absent from both snapshots, preventing typos from silently passing - Add regression tests for both behaviors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Addressed both suggestions. Thanks! |
pheus
left a comment
There was a problem hiding this comment.
Thanks for the follow-up!
I think there is one closely related edge case left before merge: direct snapshot paths using standard operators still go through _resolve_attr(), not _resolve_snapshot_attr().
For example, this is an easy mistake because REST API choice fields normally use .value, while snapshots store raw strings:
{
"attr": "snapshots.prechange.status.value",
"value": "planned"
}With snapshot data like:
{
"snapshots": {
"prechange": {"status": "planned"},
"postchange": {"status": "active"},
}
}_resolve_attr() will traverse into "planned" and call operator.getitem("planned", "value"), which raises TypeError. Since _resolve_attr() only catches KeyError, and Condition.eval() calls _resolve_attr() before the TypeError guard around eval_func(), this can still bubble out of event rule evaluation instead of failing closed.
Could we also catch TypeError in _resolve_attr() and convert it to InvalidCondition, similar to the snapshot-operator helper?
A direct snapshot path using a standard operator (e.g. attr
"snapshots.prechange.status.value") goes through _resolve_attr(),
which only caught KeyError. Snapshots store raw values (status is a
string, not a REST API-style {"value": ...} dict), so indexing into
one with a "value" key raises TypeError instead of KeyError, escaping
uncaught from Condition.eval() past the TypeError guard that only
wraps eval_func(). Catch TypeError alongside KeyError and convert it
to InvalidCondition, mirroring _resolve_snapshot_attr()'s existing
fail-closed handling.
Addresses review feedback from @pheus.
|
Good call, implemented (including tests). Thanks! |
pheus
left a comment
There was a problem hiding this comment.
Thanks for the follow-ups! This looks good to me!
Closes: #18159
Summary
Event rule conditions previously evaluated only the current (post-change) REST API representation of the object. Snapshots — the pre- and post-change data captured at event time — were present in the event queue but never exposed to condition logic, making it impossible to express rules like "fire only when status changes to active."
This PR adds two complementary mechanisms:
1.
changed/unchangedoperatorsNew snapshot-comparison operators that check whether an attribute's value differs between the prechange and postchange snapshots. No
valuekey is accepted.{ "and": [ { "attr": "status.value", "value": "active" }, { "attr": "status", "op": "changed" } ] }This is the canonical solution for the issue's use case: the webhook fires only on the transition to active, not on subsequent updates that leave status unchanged.
2. Direct snapshot path access
Snapshots are merged into the condition evaluation context, so any existing operator can reference pre/post values via
snapshots.prechange.<attr>andsnapshots.postchange.<attr>dot-paths.{ "attr": "snapshots.prechange.status", "value": "planned" }Changes
extras/conditions.pyCHANGED/UNCHANGEDoperators;_MISSINGsentinel; makevalueoptional;_resolve_snapshot_attr()helper;eval_changed()/eval_unchanged()methodsextras/events.pyprocess_event_rules()extras/tests/test_conditions.pyextras/tests/test_event_rules.pyprocess_event_rules()pathdocs/reference/conditions.mdComparison with community PR
A community implementation exists at https://github.com/kenancakir/netbox/pull/1/changes. Key differences from this PR:
changed/unchangedoperatorsprechange.status(intercepts path prefix)snapshots.prechange.status(transparent path merge)ConditionSet.eval()signatureprocess_event_rules, two eval methods)changed/unchangedare immuneThe main tradeoff:
prechange.statusis marginally more ergonomic thansnapshots.prechange.status, but requires special-casing the path prefix throughout the eval chain. Our approach falls out naturally from the existing dot-path traversal with no routing logic needed.🤖 Generated with Claude Code