Skip to content

Commit 1fbb5a6

Browse files
committed
feat: trigger FLAG_UPDATED webhooks for v2 versioning environments
When a new version is published in v2 versioning environments, trigger FLAG_UPDATED webhooks for individual feature states that have changed, in addition to the existing NEW_VERSION_PUBLISHED webhook. This allows webhook consumers to receive granular per-feature updates in the same format as non-versioned environments. Changes: - Add get_updated_feature_states() method to EnvironmentFeatureVersion to detect changed feature states by comparing enabled and value fields - Trigger FLAG_UPDATED webhooks for each changed feature state before NEW_VERSION_PUBLISHED webhook in trigger_update_version_webhooks
1 parent 3f8b260 commit 1fbb5a6

File tree

6 files changed

+473
-21
lines changed

6 files changed

+473
-21
lines changed

api/environments/models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -635,9 +635,9 @@ def generate_webhook_feature_state_data(
635635
environment: Environment,
636636
enabled: bool,
637637
value: typing.Union[str, int, bool, type(None)], # type: ignore[valid-type]
638-
identity_id: typing.Union[int, str] = None, # type: ignore[assignment]
639-
identity_identifier: str = None, # type: ignore[assignment]
640-
feature_segment: FeatureSegment = None, # type: ignore[assignment]
638+
identity_id: typing.Union[int, str, None] = None,
639+
identity_identifier: typing.Union[str, None] = None,
640+
feature_segment: typing.Union[FeatureSegment, None] = None,
641641
) -> dict: # type: ignore[type-arg]
642642
if (identity_id or identity_identifier) and not (
643643
identity_id and identity_identifier

api/features/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def _get_feature_state_webhook_data(feature_state, previous=False): # type: ign
9090
enabled=feature_state.enabled,
9191
value=feature_state_value,
9292
identity_id=feature_state.identity_id,
93-
identity_identifier=getattr(feature_state.identity, "identifier", None), # type: ignore[arg-type]
93+
identity_identifier=getattr(feature_state.identity, "identifier", None),
9494
feature_segment=feature_state.feature_segment,
9595
)
9696

api/features/versioning/models.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
if typing.TYPE_CHECKING:
2828
from environments.models import Environment
29-
from features.models import Feature
29+
from features.models import Feature, FeatureState
3030
from users.models import FFAdminUser
3131

3232

@@ -159,6 +159,46 @@ def get_previous_version(self) -> typing.Optional["EnvironmentFeatureVersion"]:
159159
.first()
160160
)
161161

162+
def get_updated_feature_states(self) -> list["FeatureState"]:
163+
"""
164+
Returns feature states from this version that have been updated compared to the previous version.
165+
166+
A feature state is considered updated if:
167+
- It's a new feature state (didn't exist in previous version)
168+
- The enabled flag has changed
169+
- The feature state value has changed
170+
171+
Returns a list of feature states that have been updated.
172+
"""
173+
from features.models import FeatureState
174+
175+
def get_match_key(fs: FeatureState) -> tuple[int | None, int | None]:
176+
segment_id = fs.feature_segment.segment_id if fs.feature_segment else None
177+
return (fs.identity_id, segment_id)
178+
179+
# Build map of previous version's feature states
180+
previous_version = self.get_previous_version()
181+
previous_feature_states_map = (
182+
{get_match_key(fs): fs for fs in previous_version.feature_states.all()}
183+
if previous_version
184+
else {}
185+
)
186+
187+
# Filter for changed feature states
188+
changed_feature_states = []
189+
for feature_state in self.feature_states.all():
190+
previous_fs = previous_feature_states_map.get(get_match_key(feature_state))
191+
192+
# New feature state or changed enabled/value
193+
if previous_fs is None or (
194+
feature_state.enabled != previous_fs.enabled
195+
or feature_state.get_feature_state_value()
196+
!= previous_fs.get_feature_state_value()
197+
):
198+
changed_feature_states.append(feature_state)
199+
200+
return changed_feature_states
201+
162202
def publish(
163203
self,
164204
published_by: typing.Union["FFAdminUser", None] = None,

api/features/versioning/tasks.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
get_environment_flags_queryset,
2626
)
2727
from users.models import FFAdminUser
28-
from webhooks.webhooks import WebhookEventType, call_environment_webhooks
28+
from webhooks.tasks import call_environment_webhooks, call_organisation_webhooks
29+
from webhooks.webhooks import WebhookEventType
2930

3031
if typing.TYPE_CHECKING:
3132
from environments.models import Environment
@@ -131,6 +132,102 @@ def _create_initial_feature_versions(environment: "Environment"): # type: ignor
131132
)
132133

133134

135+
def _trigger_feature_state_webhooks_for_version(
136+
environment_feature_version: EnvironmentFeatureVersion,
137+
) -> None:
138+
"""
139+
Trigger FLAG_UPDATED webhooks for feature states that have changed in the newly published version.
140+
141+
This allows webhook consumers to receive granular per-featurestate updates in the same
142+
format as non-versioned environments, while NEW_VERSION_PUBLISHED serves as a
143+
summary event.
144+
"""
145+
from environments.models import Webhook
146+
from webhooks.constants import WEBHOOK_DATETIME_FORMAT
147+
148+
# Get metadata from the version
149+
timestamp = environment_feature_version.published_at.strftime( # type: ignore[union-attr]
150+
WEBHOOK_DATETIME_FORMAT
151+
)
152+
changed_by = (
153+
environment_feature_version.published_by.email
154+
if environment_feature_version.published_by
155+
else (
156+
environment_feature_version.published_by_api_key.name
157+
if environment_feature_version.published_by_api_key
158+
else ""
159+
)
160+
)
161+
162+
# Get only the feature states that have changed
163+
changed_feature_states = environment_feature_version.get_updated_feature_states()
164+
165+
# Get previous version for retrieving previous states
166+
previous_version = environment_feature_version.get_previous_version()
167+
previous_feature_states_map = {}
168+
if previous_version:
169+
for fs in previous_version.feature_states.all():
170+
segment_id = fs.feature_segment.segment_id if fs.feature_segment else None
171+
key = (fs.identity_id, segment_id)
172+
previous_feature_states_map[key] = fs
173+
174+
# Trigger FLAG_UPDATED webhooks for each changed feature state
175+
for feature_state in changed_feature_states:
176+
# Get the current state data
177+
assert feature_state.environment is not None
178+
new_state = Webhook.generate_webhook_feature_state_data(
179+
feature_state.feature,
180+
environment=feature_state.environment,
181+
enabled=feature_state.enabled,
182+
value=feature_state.get_feature_state_value(),
183+
identity_id=feature_state.identity_id,
184+
identity_identifier=getattr(feature_state.identity, "identifier", None),
185+
feature_segment=feature_state.feature_segment,
186+
)
187+
188+
# Build webhook data
189+
data = {
190+
"new_state": new_state,
191+
"changed_by": changed_by,
192+
"timestamp": timestamp,
193+
}
194+
195+
# Add previous state if it exists
196+
segment_id = (
197+
feature_state.feature_segment.segment_id
198+
if feature_state.feature_segment
199+
else None
200+
)
201+
key = (feature_state.identity_id, segment_id)
202+
previous_fs = previous_feature_states_map.get(key)
203+
204+
if previous_fs:
205+
assert previous_fs.environment is not None
206+
previous_state = Webhook.generate_webhook_feature_state_data(
207+
previous_fs.feature,
208+
environment=previous_fs.environment,
209+
enabled=previous_fs.enabled,
210+
value=previous_fs.get_feature_state_value(),
211+
identity_id=previous_fs.identity_id,
212+
identity_identifier=getattr(previous_fs.identity, "identifier", None),
213+
feature_segment=previous_fs.feature_segment,
214+
)
215+
data["previous_state"] = previous_state
216+
217+
# Trigger webhooks
218+
call_environment_webhooks(
219+
environment_id=environment_feature_version.environment_id,
220+
data=data,
221+
event_type=WebhookEventType.FLAG_UPDATED.value,
222+
)
223+
224+
call_organisation_webhooks(
225+
organisation_id=environment_feature_version.environment.project.organisation_id,
226+
data=data,
227+
event_type=WebhookEventType.FLAG_UPDATED.value,
228+
)
229+
230+
134231
@register_task_handler()
135232
def trigger_update_version_webhooks(environment_feature_version_uuid: str) -> None:
136233
environment_feature_version = EnvironmentFeatureVersion.objects.get(
@@ -140,6 +237,10 @@ def trigger_update_version_webhooks(environment_feature_version_uuid: str) -> No
140237
logger.exception("Feature version has not been published.")
141238
return
142239

240+
# Trigger FLAG_UPDATED webhooks for any feature states that have changed
241+
_trigger_feature_state_webhooks_for_version(environment_feature_version)
242+
243+
# Then trigger the NEW_VERSION_PUBLISHED webhook as a summary event
143244
data = environment_feature_version_webhook_schema.dump(environment_feature_version)
144245
call_environment_webhooks(
145246
environment_id=environment_feature_version.environment_id,

0 commit comments

Comments
 (0)