Skip to content

Commit 94afd46

Browse files
authored
feat: trigger FLAG_UPDATED webhooks for v2 versioning environments (#6240)
1 parent bad846e commit 94afd46

File tree

8 files changed

+716
-21
lines changed

8 files changed

+716
-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: int | str | None = None,
639+
identity_identifier: str | None = None,
640+
feature_segment: 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/tasks.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@
2323
)
2424
from features.versioning.versioning_service import (
2525
get_environment_flags_queryset,
26+
get_updated_feature_states_for_version,
2627
)
2728
from users.models import FFAdminUser
28-
from webhooks.webhooks import WebhookEventType, call_environment_webhooks
29+
from webhooks import mappers as webhook_mappers
30+
from webhooks.tasks import call_environment_webhooks, call_organisation_webhooks
31+
from webhooks.webhooks import WebhookEventType
2932

3033
if typing.TYPE_CHECKING:
3134
from environments.models import Environment
@@ -131,6 +134,98 @@ def _create_initial_feature_versions(environment: "Environment"): # type: ignor
131134
)
132135

133136

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

238+
# Trigger FLAG_UPDATED webhooks for any feature states that have changed
239+
_trigger_feature_state_webhooks_for_version(environment_feature_version)
240+
241+
# Then trigger the NEW_VERSION_PUBLISHED webhook as a summary event
143242
data = environment_feature_version_webhook_schema.dump(environment_feature_version)
144243
call_environment_webhooks(
145244
environment_id=environment_feature_version.environment_id,

api/features/versioning/versioning_service.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,52 @@ def get_current_live_environment_feature_version(
101101
)
102102

103103

104+
def get_updated_feature_states_for_version(
105+
version: EnvironmentFeatureVersion,
106+
) -> list[FeatureState]:
107+
"""
108+
Returns feature states that changed compared to the previous version.
109+
"""
110+
111+
def get_match_key(fs: FeatureState) -> tuple[int | None, int | None]:
112+
segment_id = fs.feature_segment.segment_id if fs.feature_segment else None
113+
return (fs.identity_id, segment_id)
114+
115+
def multivariate_values_changed(
116+
fs: FeatureState, previous_fs: FeatureState
117+
) -> bool:
118+
current_mv_values = {
119+
mv.multivariate_feature_option_id: mv.percentage_allocation
120+
for mv in fs.multivariate_feature_state_values.all()
121+
}
122+
previous_mv_values = {
123+
mv.multivariate_feature_option_id: mv.percentage_allocation
124+
for mv in previous_fs.multivariate_feature_state_values.all()
125+
}
126+
return current_mv_values != previous_mv_values
127+
128+
previous_version = version.get_previous_version()
129+
previous_feature_states_map = (
130+
{get_match_key(fs): fs for fs in previous_version.feature_states.all()}
131+
if previous_version
132+
else {}
133+
)
134+
135+
changed_feature_states = []
136+
for feature_state in version.feature_states.all():
137+
previous_fs = previous_feature_states_map.get(get_match_key(feature_state))
138+
139+
if previous_fs is None or (
140+
feature_state.enabled != previous_fs.enabled
141+
or feature_state.get_feature_state_value()
142+
!= previous_fs.get_feature_state_value()
143+
or multivariate_values_changed(feature_state, previous_fs)
144+
):
145+
changed_feature_states.append(feature_state)
146+
147+
return changed_feature_states
148+
149+
104150
def _get_feature_states_queryset(
105151
environment: "Environment",
106152
feature_name: str | None = None,

api/tests/unit/features/versioning/test_unit_versioning_tasks.py

Lines changed: 112 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -157,39 +157,136 @@ def test_disable_v2_versioning(
157157

158158

159159
@responses.activate
160-
def test_trigger_update_version_webhooks(
161-
environment_v2_versioning: Environment, feature: Feature
160+
def test_trigger_update_version_webhooks__with_changes(
161+
environment_v2_versioning: Environment,
162+
feature: Feature,
163+
staff_user: FFAdminUser,
162164
) -> None:
163165
# Given
164-
version = EnvironmentFeatureVersion.objects.get(
166+
v1 = EnvironmentFeatureVersion.objects.get(
165167
feature=feature, environment=environment_v2_versioning
166168
)
167-
feature_state = version.feature_states.first()
169+
v1_fs = v1.feature_states.first()
170+
171+
v2 = EnvironmentFeatureVersion.objects.create(
172+
environment=environment_v2_versioning, feature=feature
173+
)
174+
v2_fs = v2.feature_states.first()
175+
v2_fs.enabled = not v1_fs.enabled # Make a change
176+
v2_fs.save()
177+
v2.publish(published_by=staff_user)
168178

169-
webhook_url = "https://example.com/webhook/"
170-
Webhook.objects.create(environment=environment_v2_versioning, url=webhook_url)
179+
# Setup webhooks
180+
from organisations.models import OrganisationWebhook
171181

172-
responses.post(url=webhook_url, status=200)
182+
environment_webhook_url = "https://example.com/env-webhook/"
183+
organisation_webhook_url = "https://example.com/org-webhook/"
184+
Webhook.objects.create(
185+
environment=environment_v2_versioning, url=environment_webhook_url, enabled=True
186+
)
187+
OrganisationWebhook.objects.create(
188+
organisation=environment_v2_versioning.project.organisation,
189+
name="Test Org Webhook",
190+
url=organisation_webhook_url,
191+
enabled=True,
192+
)
193+
responses.post(url=environment_webhook_url, status=200)
194+
responses.post(url=organisation_webhook_url, status=200)
173195

174196
# When
175-
trigger_update_version_webhooks(str(version.uuid))
197+
trigger_update_version_webhooks(str(v2.uuid))
176198

177199
# Then
178-
assert len(responses.calls) == 1
179-
assert responses.calls[0].request.url == webhook_url # type: ignore[union-attr]
180-
assert json.loads(responses.calls[0].request.body) == { # type: ignore[union-attr]
200+
# Should trigger 3 webhook calls: 2 environment (FLAG_UPDATED + NEW_VERSION_PUBLISHED)
201+
# and 1 organisation (FLAG_UPDATED only)
202+
assert len(responses.calls) == 3
203+
204+
# Verify FLAG_UPDATED webhook to environment (first call)
205+
flag_updated_env_body = json.loads(responses.calls[0].request.body) # type: ignore[union-attr]
206+
assert flag_updated_env_body["event_type"] == WebhookEventType.FLAG_UPDATED.name
207+
assert flag_updated_env_body["data"]["new_state"]["enabled"] == v2_fs.enabled
208+
assert flag_updated_env_body["data"]["new_state"]["feature"]["id"] == feature.id
209+
assert flag_updated_env_body["data"]["new_state"]["feature"]["name"] == feature.name
210+
assert (
211+
flag_updated_env_body["data"]["new_state"]["feature_state_value"]
212+
== v2_fs.get_feature_state_value()
213+
)
214+
assert flag_updated_env_body["data"]["previous_state"]["enabled"] == v1_fs.enabled
215+
assert (
216+
flag_updated_env_body["data"]["previous_state"]["feature_state_value"]
217+
== v1_fs.get_feature_state_value()
218+
)
219+
assert flag_updated_env_body["data"]["changed_by"] == staff_user.email
220+
assert "timestamp" in flag_updated_env_body["data"]
221+
222+
# Verify FLAG_UPDATED webhook to organisation (second call)
223+
flag_updated_org_body = json.loads(responses.calls[1].request.body) # type: ignore[union-attr]
224+
assert flag_updated_org_body == flag_updated_env_body # Should be identical
225+
226+
# Verify NEW_VERSION_PUBLISHED webhook to environment (third call)
227+
new_version_body = json.loads(responses.calls[2].request.body) # type: ignore[union-attr]
228+
assert new_version_body == {
229+
"event_type": WebhookEventType.NEW_VERSION_PUBLISHED.name,
181230
"data": {
182-
"uuid": str(version.uuid),
231+
"uuid": str(v2.uuid),
183232
"feature": {"id": feature.id, "name": feature.name},
184-
"published_by": None,
233+
"published_by": {"id": staff_user.id, "email": staff_user.email},
185234
"feature_states": [
186235
{
187-
"enabled": feature_state.enabled,
188-
"value": feature_state.get_feature_state_value(),
236+
"enabled": v2_fs.enabled,
237+
"value": v2_fs.get_feature_state_value(),
189238
}
190239
],
191240
},
241+
}
242+
243+
244+
@responses.activate
245+
def test_trigger_update_version_webhooks__without_changes(
246+
environment_v2_versioning: Environment,
247+
feature: Feature,
248+
staff_user: FFAdminUser,
249+
) -> None:
250+
# Given
251+
v1 = EnvironmentFeatureVersion.objects.get(
252+
feature=feature, environment=environment_v2_versioning
253+
)
254+
v1.publish(published_by=staff_user)
255+
256+
v2 = EnvironmentFeatureVersion.objects.create(
257+
environment=environment_v2_versioning, feature=feature
258+
)
259+
v2.publish(published_by=staff_user)
260+
261+
# Setup webhook
262+
environment_webhook_url = "https://example.com/env-webhook/"
263+
Webhook.objects.create(
264+
environment=environment_v2_versioning, url=environment_webhook_url, enabled=True
265+
)
266+
responses.post(url=environment_webhook_url, status=200)
267+
268+
# When
269+
trigger_update_version_webhooks(str(v2.uuid))
270+
271+
# Then
272+
# Should trigger only 1 webhook call: NEW_VERSION_PUBLISHED (no FLAG_UPDATED since no changes)
273+
assert len(responses.calls) == 1
274+
275+
# Verify NEW_VERSION_PUBLISHED webhook data
276+
new_version_body = json.loads(responses.calls[0].request.body) # type: ignore[union-attr]
277+
assert new_version_body == {
192278
"event_type": WebhookEventType.NEW_VERSION_PUBLISHED.name,
279+
"data": {
280+
"uuid": str(v2.uuid),
281+
"feature": {"id": feature.id, "name": feature.name},
282+
"published_by": {"id": staff_user.id, "email": staff_user.email},
283+
"feature_states": [
284+
{
285+
"enabled": v2.feature_states.first().enabled,
286+
"value": v2.feature_states.first().get_feature_state_value(),
287+
}
288+
],
289+
},
193290
}
194291

195292

0 commit comments

Comments
 (0)