diff --git a/api/environments/models.py b/api/environments/models.py index 3cd7e11e3b49..9523d45be442 100644 --- a/api/environments/models.py +++ b/api/environments/models.py @@ -635,9 +635,9 @@ def generate_webhook_feature_state_data( environment: Environment, enabled: bool, value: typing.Union[str, int, bool, type(None)], # type: ignore[valid-type] - identity_id: typing.Union[int, str] = None, # type: ignore[assignment] - identity_identifier: str = None, # type: ignore[assignment] - feature_segment: FeatureSegment = None, # type: ignore[assignment] + identity_id: typing.Union[int, str, None] = None, + identity_identifier: typing.Union[str, None] = None, + feature_segment: typing.Union[FeatureSegment, None] = None, ) -> dict: # type: ignore[type-arg] if (identity_id or identity_identifier) and not ( identity_id and identity_identifier diff --git a/api/features/tasks.py b/api/features/tasks.py index 32fb4a73eb3f..1489e5a91785 100644 --- a/api/features/tasks.py +++ b/api/features/tasks.py @@ -90,7 +90,7 @@ def _get_feature_state_webhook_data(feature_state, previous=False): # type: ign enabled=feature_state.enabled, value=feature_state_value, identity_id=feature_state.identity_id, - identity_identifier=getattr(feature_state.identity, "identifier", None), # type: ignore[arg-type] + identity_identifier=getattr(feature_state.identity, "identifier", None), feature_segment=feature_state.feature_segment, ) diff --git a/api/features/versioning/models.py b/api/features/versioning/models.py index 36d57ed6be74..7265b045011b 100644 --- a/api/features/versioning/models.py +++ b/api/features/versioning/models.py @@ -26,7 +26,7 @@ if typing.TYPE_CHECKING: from environments.models import Environment - from features.models import Feature + from features.models import Feature, FeatureState from users.models import FFAdminUser @@ -159,6 +159,46 @@ def get_previous_version(self) -> typing.Optional["EnvironmentFeatureVersion"]: .first() ) + def get_updated_feature_states(self) -> list["FeatureState"]: + """ + Returns feature states from this version that have been updated compared to the previous version. + + A feature state is considered updated if: + - It's a new feature state (didn't exist in previous version) + - The enabled flag has changed + - The feature state value has changed + + Returns a list of feature states that have been updated. + """ + from features.models import FeatureState + + def get_match_key(fs: FeatureState) -> tuple[int | None, int | None]: + segment_id = fs.feature_segment.segment_id if fs.feature_segment else None + return (fs.identity_id, segment_id) + + # Build map of previous version's feature states + previous_version = self.get_previous_version() + previous_feature_states_map = ( + {get_match_key(fs): fs for fs in previous_version.feature_states.all()} + if previous_version + else {} + ) + + # Filter for changed feature states + changed_feature_states = [] + for feature_state in self.feature_states.all(): + previous_fs = previous_feature_states_map.get(get_match_key(feature_state)) + + # New feature state or changed enabled/value + if previous_fs is None or ( + feature_state.enabled != previous_fs.enabled + or feature_state.get_feature_state_value() + != previous_fs.get_feature_state_value() + ): + changed_feature_states.append(feature_state) + + return changed_feature_states + def publish( self, published_by: typing.Union["FFAdminUser", None] = None, diff --git a/api/features/versioning/tasks.py b/api/features/versioning/tasks.py index d1308b76bfd5..7b6c255f7a68 100644 --- a/api/features/versioning/tasks.py +++ b/api/features/versioning/tasks.py @@ -25,7 +25,8 @@ get_environment_flags_queryset, ) from users.models import FFAdminUser -from webhooks.webhooks import WebhookEventType, call_environment_webhooks +from webhooks.tasks import call_environment_webhooks, call_organisation_webhooks +from webhooks.webhooks import WebhookEventType if typing.TYPE_CHECKING: from environments.models import Environment @@ -131,6 +132,102 @@ def _create_initial_feature_versions(environment: "Environment"): # type: ignor ) +def _trigger_feature_state_webhooks_for_version( + environment_feature_version: EnvironmentFeatureVersion, +) -> None: + """ + Trigger FLAG_UPDATED webhooks for feature states that have changed in the newly published version. + + This allows webhook consumers to receive granular per-featurestate updates in the same + format as non-versioned environments, while NEW_VERSION_PUBLISHED serves as a + summary event. + """ + from environments.models import Webhook + from webhooks.constants import WEBHOOK_DATETIME_FORMAT + + # Get metadata from the version + timestamp = environment_feature_version.published_at.strftime( # type: ignore[union-attr] + WEBHOOK_DATETIME_FORMAT + ) + changed_by = ( + environment_feature_version.published_by.email + if environment_feature_version.published_by + else ( + environment_feature_version.published_by_api_key.name + if environment_feature_version.published_by_api_key + else "" + ) + ) + + # Get only the feature states that have changed + changed_feature_states = environment_feature_version.get_updated_feature_states() + + # Get previous version for retrieving previous states + previous_version = environment_feature_version.get_previous_version() + previous_feature_states_map = {} + if previous_version: + for fs in previous_version.feature_states.all(): + segment_id = fs.feature_segment.segment_id if fs.feature_segment else None + key = (fs.identity_id, segment_id) + previous_feature_states_map[key] = fs + + # Trigger FLAG_UPDATED webhooks for each changed feature state + for feature_state in changed_feature_states: + # Get the current state data + assert feature_state.environment is not None + new_state = Webhook.generate_webhook_feature_state_data( + feature_state.feature, + environment=feature_state.environment, + enabled=feature_state.enabled, + value=feature_state.get_feature_state_value(), + identity_id=feature_state.identity_id, + identity_identifier=getattr(feature_state.identity, "identifier", None), + feature_segment=feature_state.feature_segment, + ) + + # Build webhook data + data = { + "new_state": new_state, + "changed_by": changed_by, + "timestamp": timestamp, + } + + # Add previous state if it exists + segment_id = ( + feature_state.feature_segment.segment_id + if feature_state.feature_segment + else None + ) + key = (feature_state.identity_id, segment_id) + previous_fs = previous_feature_states_map.get(key) + + if previous_fs: + assert previous_fs.environment is not None + previous_state = Webhook.generate_webhook_feature_state_data( + previous_fs.feature, + environment=previous_fs.environment, + enabled=previous_fs.enabled, + value=previous_fs.get_feature_state_value(), + identity_id=previous_fs.identity_id, + identity_identifier=getattr(previous_fs.identity, "identifier", None), + feature_segment=previous_fs.feature_segment, + ) + data["previous_state"] = previous_state + + # Trigger webhooks + call_environment_webhooks( + environment_id=environment_feature_version.environment_id, + data=data, + event_type=WebhookEventType.FLAG_UPDATED.value, + ) + + call_organisation_webhooks( + organisation_id=environment_feature_version.environment.project.organisation_id, + data=data, + event_type=WebhookEventType.FLAG_UPDATED.value, + ) + + @register_task_handler() def trigger_update_version_webhooks(environment_feature_version_uuid: str) -> None: environment_feature_version = EnvironmentFeatureVersion.objects.get( @@ -140,6 +237,10 @@ def trigger_update_version_webhooks(environment_feature_version_uuid: str) -> No logger.exception("Feature version has not been published.") return + # Trigger FLAG_UPDATED webhooks for any feature states that have changed + _trigger_feature_state_webhooks_for_version(environment_feature_version) + + # Then trigger the NEW_VERSION_PUBLISHED webhook as a summary event data = environment_feature_version_webhook_schema.dump(environment_feature_version) call_environment_webhooks( environment_id=environment_feature_version.environment_id, diff --git a/api/tests/unit/features/versioning/test_unit_versioning_models.py b/api/tests/unit/features/versioning/test_unit_versioning_models.py index 8b9e0d7ae5b2..678a8f018896 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_models.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_models.py @@ -683,3 +683,217 @@ def test_version_change_set_get_conflicts_returns_empty_list_if_no_change_sets_s # Then assert conflicts == [] + + +def test_get_updated_feature_states_returns_empty_list_when_no_changes( + environment_v2_versioning: Environment, + feature: Feature, + staff_user: FFAdminUser, +) -> None: + # Given + # v1 exists from fixture, create v2 with no changes + v2 = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + + # When + updated_feature_states = v2.get_updated_feature_states() + + # Then + assert updated_feature_states == [] + + +def test_get_updated_feature_states_returns_feature_state_when_enabled_changes( + environment_v2_versioning: Environment, + feature: Feature, + staff_user: FFAdminUser, +) -> None: + # Given + # Create and publish v1 + v1 = EnvironmentFeatureVersion.objects.get( + feature=feature, environment=environment_v2_versioning + ) + v1_fs = v1.feature_states.first() + + # Create v2 with changed enabled flag + v2 = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + v2_fs = v2.feature_states.first() + v2_fs.enabled = not v1_fs.enabled # Change enabled + v2_fs.save() + + # When + updated_feature_states = v2.get_updated_feature_states() + + # Then + assert len(updated_feature_states) == 1 + assert updated_feature_states[0].id == v2_fs.id + assert updated_feature_states[0].enabled is not v1_fs.enabled + + +def test_get_updated_feature_states_returns_feature_state_when_value_changes( + environment_v2_versioning: Environment, + feature: Feature, + staff_user: FFAdminUser, +) -> None: + # Given + # v1 exists from fixture, create v2 with changed value + v2 = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + v2_fs = v2.feature_states.first() + v2_fs.feature_state_value.type = STRING + v2_fs.feature_state_value.string_value = "changed_value" # Different + v2_fs.feature_state_value.save() + v2_fs.save() + + # When + updated_feature_states = v2.get_updated_feature_states() + + # Then + assert len(updated_feature_states) == 1 + assert updated_feature_states[0].id == v2_fs.id + assert updated_feature_states[0].get_feature_state_value() == "changed_value" + + +def test_get_updated_feature_states_returns_new_segment_override( + environment_v2_versioning: Environment, + feature: Feature, + staff_user: FFAdminUser, + segment: Segment, +) -> None: + # Given + # v1 exists from fixture, create v2 with a new segment override + v2 = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + feature_segment = FeatureSegment.objects.create( + segment=segment, + feature=feature, + environment=environment_v2_versioning, + environment_feature_version=v2, + ) + segment_override = FeatureState.objects.create( + environment=environment_v2_versioning, + feature=feature, + feature_segment=feature_segment, + environment_feature_version=v2, + enabled=True, + ) + + # When + updated_feature_states = v2.get_updated_feature_states() + + # Then + # Should only return the new segment override + assert len(updated_feature_states) == 1 + assert updated_feature_states[0].id == segment_override.id + assert updated_feature_states[0].feature_segment == feature_segment + + +def test_get_updated_feature_states_detects_environment_value_change( + environment_v2_versioning: Environment, + feature: Feature, + staff_user: FFAdminUser, + segment: Segment, +) -> None: + # Given + # Create v1 with environment feature state value and segment override + v1 = EnvironmentFeatureVersion.objects.get( + feature=feature, environment=environment_v2_versioning + ) + v1_default = v1.feature_states.filter(feature_segment__isnull=True).first() + v1_default.feature_state_value.type = STRING + v1_default.feature_state_value.string_value = "default_value_v1" + v1_default.feature_state_value.save() + + feature_segment_v1 = FeatureSegment.objects.create( + segment=segment, + feature=feature, + environment=environment_v2_versioning, + environment_feature_version=v1, + ) + v1_segment_override = FeatureState.objects.create( + environment=environment_v2_versioning, + feature=feature, + feature_segment=feature_segment_v1, + environment_feature_version=v1, + enabled=True, + ) + v1_segment_override.feature_state_value.type = STRING + v1_segment_override.feature_state_value.string_value = "segment_value" + v1_segment_override.feature_state_value.save() + v1.publish(published_by=staff_user) + + # Create v2 - environment feature state value changes but segment override stays same + v2 = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + v2_default = v2.feature_states.filter(feature_segment__isnull=True).first() + v2_default.feature_state_value.string_value = "default_value_v2" # Changed! + v2_default.feature_state_value.save() + + # Segment override value stays the same (no changes to segment override) + + # When + updated_feature_states = v2.get_updated_feature_states() + + # Then + # Should only return the environment feature state(not the segment override) + assert len(updated_feature_states) == 1 + assert updated_feature_states[0].feature_segment is None + assert updated_feature_states[0].get_feature_state_value() == "default_value_v2" + + +def test_get_updated_feature_states_detects_segment_override_changes( + environment_v2_versioning: Environment, + feature: Feature, + staff_user: FFAdminUser, + segment: Segment, +) -> None: + # Given + # Create v1 with a segment override + v1 = EnvironmentFeatureVersion.objects.get( + feature=feature, environment=environment_v2_versioning + ) + feature_segment_v1 = FeatureSegment.objects.create( + segment=segment, + feature=feature, + environment=environment_v2_versioning, + environment_feature_version=v1, + ) + v1_segment_override = FeatureState.objects.create( + environment=environment_v2_versioning, + feature=feature, + feature_segment=feature_segment_v1, + environment_feature_version=v1, + enabled=False, + ) + v1_segment_override.feature_state_value.type = STRING + v1_segment_override.feature_state_value.string_value = "segment_value_v1" + v1_segment_override.feature_state_value.save() + v1.publish(published_by=staff_user) + + # Create v2 - segment override value changes but environment default stays same + v2 = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + # Find the cloned segment override and change its value + v2_segment_override = v2.feature_states.filter( + feature_segment__segment=segment + ).first() + v2_segment_override.feature_state_value.string_value = ( + "segment_value_v2" # Changed! + ) + v2_segment_override.feature_state_value.save() + + # When + updated_feature_states = v2.get_updated_feature_states() + + # Then + # Should only return the segment override (not the environment default) + assert len(updated_feature_states) == 1 + assert updated_feature_states[0].feature_segment is not None + assert updated_feature_states[0].feature_segment.segment == segment + assert updated_feature_states[0].get_feature_state_value() == "segment_value_v2" diff --git a/api/tests/unit/features/versioning/test_unit_versioning_tasks.py b/api/tests/unit/features/versioning/test_unit_versioning_tasks.py index 73e93d27b887..72d4e5f97bee 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_tasks.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_tasks.py @@ -157,39 +157,136 @@ def test_disable_v2_versioning( @responses.activate -def test_trigger_update_version_webhooks( - environment_v2_versioning: Environment, feature: Feature +def test_trigger_update_version_webhooks__with_changes( + environment_v2_versioning: Environment, + feature: Feature, + staff_user: FFAdminUser, ) -> None: # Given - version = EnvironmentFeatureVersion.objects.get( + v1 = EnvironmentFeatureVersion.objects.get( feature=feature, environment=environment_v2_versioning ) - feature_state = version.feature_states.first() + v1_fs = v1.feature_states.first() + + v2 = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + v2_fs = v2.feature_states.first() + v2_fs.enabled = not v1_fs.enabled # Make a change + v2_fs.save() + v2.publish(published_by=staff_user) - webhook_url = "https://example.com/webhook/" - Webhook.objects.create(environment=environment_v2_versioning, url=webhook_url) + # Setup webhooks + from organisations.models import OrganisationWebhook - responses.post(url=webhook_url, status=200) + environment_webhook_url = "https://example.com/env-webhook/" + organisation_webhook_url = "https://example.com/org-webhook/" + Webhook.objects.create( + environment=environment_v2_versioning, url=environment_webhook_url, enabled=True + ) + OrganisationWebhook.objects.create( + organisation=environment_v2_versioning.project.organisation, + name="Test Org Webhook", + url=organisation_webhook_url, + enabled=True, + ) + responses.post(url=environment_webhook_url, status=200) + responses.post(url=organisation_webhook_url, status=200) # When - trigger_update_version_webhooks(str(version.uuid)) + trigger_update_version_webhooks(str(v2.uuid)) # Then - assert len(responses.calls) == 1 - assert responses.calls[0].request.url == webhook_url # type: ignore[union-attr] - assert json.loads(responses.calls[0].request.body) == { # type: ignore[union-attr] + # Should trigger 3 webhook calls: 2 environment (FLAG_UPDATED + NEW_VERSION_PUBLISHED) + # and 1 organisation (FLAG_UPDATED only) + assert len(responses.calls) == 3 + + # Verify FLAG_UPDATED webhook to environment (first call) + flag_updated_env_body = json.loads(responses.calls[0].request.body) # type: ignore[union-attr] + assert flag_updated_env_body["event_type"] == WebhookEventType.FLAG_UPDATED.name + assert flag_updated_env_body["data"]["new_state"]["enabled"] == v2_fs.enabled + assert flag_updated_env_body["data"]["new_state"]["feature"]["id"] == feature.id + assert flag_updated_env_body["data"]["new_state"]["feature"]["name"] == feature.name + assert ( + flag_updated_env_body["data"]["new_state"]["feature_state_value"] + == v2_fs.get_feature_state_value() + ) + assert flag_updated_env_body["data"]["previous_state"]["enabled"] == v1_fs.enabled + assert ( + flag_updated_env_body["data"]["previous_state"]["feature_state_value"] + == v1_fs.get_feature_state_value() + ) + assert flag_updated_env_body["data"]["changed_by"] == staff_user.email + assert "timestamp" in flag_updated_env_body["data"] + + # Verify FLAG_UPDATED webhook to organisation (second call) + flag_updated_org_body = json.loads(responses.calls[1].request.body) # type: ignore[union-attr] + assert flag_updated_org_body == flag_updated_env_body # Should be identical + + # Verify NEW_VERSION_PUBLISHED webhook to environment (third call) + new_version_body = json.loads(responses.calls[2].request.body) # type: ignore[union-attr] + assert new_version_body == { + "event_type": WebhookEventType.NEW_VERSION_PUBLISHED.name, "data": { - "uuid": str(version.uuid), + "uuid": str(v2.uuid), "feature": {"id": feature.id, "name": feature.name}, - "published_by": None, + "published_by": {"id": staff_user.id, "email": staff_user.email}, "feature_states": [ { - "enabled": feature_state.enabled, - "value": feature_state.get_feature_state_value(), + "enabled": v2_fs.enabled, + "value": v2_fs.get_feature_state_value(), } ], }, + } + + +@responses.activate +def test_trigger_update_version_webhooks__without_changes( + environment_v2_versioning: Environment, + feature: Feature, + staff_user: FFAdminUser, +) -> None: + # Given + v1 = EnvironmentFeatureVersion.objects.get( + feature=feature, environment=environment_v2_versioning + ) + v1.publish(published_by=staff_user) + + v2 = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + v2.publish(published_by=staff_user) + + # Setup webhook + environment_webhook_url = "https://example.com/env-webhook/" + Webhook.objects.create( + environment=environment_v2_versioning, url=environment_webhook_url, enabled=True + ) + responses.post(url=environment_webhook_url, status=200) + + # When + trigger_update_version_webhooks(str(v2.uuid)) + + # Then + # Should trigger only 1 webhook call: NEW_VERSION_PUBLISHED (no FLAG_UPDATED since no changes) + assert len(responses.calls) == 1 + + # Verify NEW_VERSION_PUBLISHED webhook data + new_version_body = json.loads(responses.calls[0].request.body) # type: ignore[union-attr] + assert new_version_body == { "event_type": WebhookEventType.NEW_VERSION_PUBLISHED.name, + "data": { + "uuid": str(v2.uuid), + "feature": {"id": feature.id, "name": feature.name}, + "published_by": {"id": staff_user.id, "email": staff_user.email}, + "feature_states": [ + { + "enabled": v2.feature_states.first().enabled, + "value": v2.feature_states.first().get_feature_state_value(), + } + ], + }, }