diff --git a/api/audit/signals.py b/api/audit/signals.py index d77fd68448fa..416aec1ed8a0 100644 --- a/api/audit/signals.py +++ b/api/audit/signals.py @@ -270,8 +270,12 @@ def trigger_feature_state_change_webhooks( # Skip deleted feature states - handled in views return - # Skip versioned environments - handled by trigger_update_version_webhooks - if fresh_feature_state.environment_feature_version_id: + # Skip versioned environments - handled by trigger_update_version_webhooks, + # except for v2 feature creation, which creates an initial version without + # going through the version publish webhook path. + if fresh_feature_state.environment_feature_version_id and not kwargs.get( + "feature_created" + ): return tasks.trigger_feature_state_change_webhooks(fresh_feature_state) diff --git a/api/features/models.py b/api/features/models.py index e30a6bbb9fda..7f6cfb4f2fac 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -921,7 +921,7 @@ def _send_change_went_live_for_v2_feature_create(self) -> None: and self.environment.use_v2_feature_versioning and self.environment.created_date <= self.feature.created_date ): - feature_state_change_went_live.send(self) + feature_state_change_went_live.send(self, feature_created=True) @classmethod def get_next_version_number( # type: ignore[no-untyped-def] diff --git a/api/features/tasks.py b/api/features/tasks.py index 7ae434d9b60d..f9da03de34c8 100644 --- a/api/features/tasks.py +++ b/api/features/tasks.py @@ -6,6 +6,7 @@ ) from environments.models import Webhook +from features.constants import ENVIRONMENT from features.models import Feature, FeatureState from features.multivariate.models import MultivariateFeatureStateValue from webhooks.constants import WEBHOOK_DATETIME_FORMAT @@ -21,11 +22,20 @@ def trigger_feature_state_change_webhooks( # type: ignore[no-untyped-def] - instance: FeatureState, event_type: WebhookEventType = WebhookEventType.FLAG_UPDATED + instance: FeatureState, event_type: WebhookEventType | None = None ): - assert event_type in [WebhookEventType.FLAG_UPDATED, WebhookEventType.FLAG_DELETED] + # FLAG_CREATED is inferred from the FeatureState history below; callers only + # pass explicit event types for non-default behaviour like deletes. + assert event_type in [ + None, + WebhookEventType.FLAG_UPDATED, + WebhookEventType.FLAG_DELETED, + ] history_instance = instance.history.first() + event_type = event_type or _get_feature_state_webhook_event_type( + instance, history_instance + ) timestamp = ( history_instance.history_date.strftime(WEBHOOK_DATETIME_FORMAT) if history_instance and history_instance.history_date @@ -64,6 +74,23 @@ def trigger_feature_state_change_webhooks( # type: ignore[no-untyped-def] ) +def _get_feature_state_webhook_event_type( + instance: FeatureState, + history_instance: HistoricalFeatureState | None, +) -> WebhookEventType: + if ( + history_instance + and history_instance.history_type == "+" + and instance.change_request_id is None + and instance.type == ENVIRONMENT + and instance.environment is not None + and instance.environment.created_date <= instance.feature.created_date + ): + return WebhookEventType.FLAG_CREATED + + return WebhookEventType.FLAG_UPDATED + + def _get_previous_state( instance: FeatureState, history_instance: HistoricalFeatureState, diff --git a/api/tests/unit/audit/test_unit_audit_signals.py b/api/tests/unit/audit/test_unit_audit_signals.py index 5ddf4e671396..28a9d9f9e6eb 100644 --- a/api/tests/unit/audit/test_unit_audit_signals.py +++ b/api/tests/unit/audit/test_unit_audit_signals.py @@ -418,6 +418,28 @@ def test_trigger_feature_state_change_webhooks__versioned_environment__skips_web mock_trigger_webhooks.assert_not_called() +def test_trigger_feature_state_change_webhooks__versioned_environment_feature_created__triggers_webhook( + environment_v2_versioning: Environment, + mocker: MockerFixture, +) -> None: + # Given + mock_trigger_webhooks = mocker.patch( + "features.tasks.trigger_feature_state_change_webhooks" + ) + + # When + feature = Feature.objects.create( + name="v2_created_feature", + project=environment_v2_versioning.project, + ) + + # Then + mock_trigger_webhooks.assert_called_once() + called_feature_state = mock_trigger_webhooks.call_args.args[0] + assert called_feature_state.feature_id == feature.id + assert called_feature_state.environment_id == environment_v2_versioning.id + + def test_trigger_feature_state_change_webhooks__deleted_feature_state__skips_webhook( environment: Environment, feature: Feature, diff --git a/api/tests/unit/features/test_unit_features_tasks.py b/api/tests/unit/features/test_unit_features_tasks.py index 480b872a3d17..3794159f60d6 100644 --- a/api/tests/unit/features/test_unit_features_tasks.py +++ b/api/tests/unit/features/test_unit_features_tasks.py @@ -1,11 +1,15 @@ +from datetime import timedelta + import pytest +from django.utils import timezone from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] from pytest_mock import MockerFixture from api_keys.models import MasterAPIKey from environments.models import Environment -from features.models import Feature, FeatureState +from features.models import Feature, FeatureSegment, FeatureState from features.tasks import trigger_feature_state_change_webhooks +from features.workflows.core.models import ChangeRequest from organisations.models import Organisation from projects.models import Project from users.models import FFAdminUser @@ -84,6 +88,160 @@ def test_trigger_feature_state_change_webhooks__value_updated__calls_webhooks_wi assert event_type == WebhookEventType.FLAG_UPDATED.value +@pytest.mark.django_db +def test_trigger_feature_state_change_webhooks__environment_default_created__sends_created_event( + mocker: MockerFixture, + organisation: Organisation, + project: Project, + environment: Environment, +) -> None: + # Given + feature = Feature.objects.create(name="Created feature", project=project) + feature_state = FeatureState.objects.get(feature=feature, environment=environment) + + mock_call_environment_webhooks = mocker.patch( + "features.tasks.call_environment_webhooks" + ) + mock_call_organisation_webhooks = mocker.patch( + "features.tasks.call_organisation_webhooks" + ) + + # When + trigger_feature_state_change_webhooks(feature_state) + + # Then + environment_webhook_call_args = ( + mock_call_environment_webhooks.delay.call_args.kwargs["args"] + ) + organisation_webhook_call_args = ( + mock_call_organisation_webhooks.delay.call_args.kwargs["args"] + ) + + assert environment_webhook_call_args[0] == environment.id + assert organisation_webhook_call_args[0] == organisation.id + + data = environment_webhook_call_args[1] + event_type = environment_webhook_call_args[2] + assert data["new_state"]["feature"]["id"] == feature.id + assert "previous_state" not in data + assert event_type == WebhookEventType.FLAG_CREATED.value + + +@pytest.mark.django_db +def test_trigger_feature_state_change_webhooks__environment_created_for_existing_feature__sends_updated_event( + mocker: MockerFixture, + project: Project, + feature: Feature, +) -> None: + # Given + environment = Environment.objects.create( + name="Created environment", project=project + ) + feature_state = FeatureState.objects.get(feature=feature, environment=environment) + + mock_call_environment_webhooks = mocker.patch( + "features.tasks.call_environment_webhooks" + ) + mock_call_organisation_webhooks = mocker.patch( + "features.tasks.call_organisation_webhooks" + ) + + # When + trigger_feature_state_change_webhooks(feature_state) + + # Then + environment_webhook_call_args = ( + mock_call_environment_webhooks.delay.call_args.kwargs["args"] + ) + organisation_webhook_call_args = ( + mock_call_organisation_webhooks.delay.call_args.kwargs["args"] + ) + + assert environment_webhook_call_args[1] == organisation_webhook_call_args[1] + assert environment_webhook_call_args[2] == WebhookEventType.FLAG_UPDATED.value + + +@pytest.mark.parametrize("scheduled", [False, True]) +@pytest.mark.django_db +def test_trigger_feature_state_change_webhooks__change_request_environment_default_created__sends_updated_event( + mocker: MockerFixture, + feature: Feature, + environment: Environment, + change_request: ChangeRequest, + scheduled: bool, +) -> None: + # Given + feature_state = FeatureState.objects.create( + feature=feature, + environment=environment, + enabled=True, + live_from=timezone.now() + timedelta(days=1) if scheduled else timezone.now(), + change_request=change_request, + version=None, + ) + + mock_call_environment_webhooks = mocker.patch( + "features.tasks.call_environment_webhooks" + ) + mock_call_organisation_webhooks = mocker.patch( + "features.tasks.call_organisation_webhooks" + ) + + # When + trigger_feature_state_change_webhooks(feature_state) + + # Then + environment_webhook_call_args = ( + mock_call_environment_webhooks.delay.call_args.kwargs["args"] + ) + organisation_webhook_call_args = ( + mock_call_organisation_webhooks.delay.call_args.kwargs["args"] + ) + + assert environment_webhook_call_args[1] == organisation_webhook_call_args[1] + assert environment_webhook_call_args[2] == WebhookEventType.FLAG_UPDATED.value + + +@pytest.mark.django_db +def test_trigger_feature_state_change_webhooks__segment_override_created__sends_updated_event( + mocker: MockerFixture, + feature: Feature, + environment: Environment, + feature_segment: FeatureSegment, +) -> None: + # Given + env_default = FeatureState.objects.get( + feature=feature, environment=environment, feature_segment__isnull=True + ) + feature_state = FeatureState.objects.create( + feature=feature, + environment=environment, + feature_segment=feature_segment, + enabled=not env_default.enabled, + ) + + mock_call_environment_webhooks = mocker.patch( + "features.tasks.call_environment_webhooks" + ) + mock_call_organisation_webhooks = mocker.patch( + "features.tasks.call_organisation_webhooks" + ) + + # When + trigger_feature_state_change_webhooks(feature_state) + + # Then + environment_webhook_call_args = ( + mock_call_environment_webhooks.delay.call_args.kwargs["args"] + ) + organisation_webhook_call_args = ( + mock_call_organisation_webhooks.delay.call_args.kwargs["args"] + ) + + assert environment_webhook_call_args[1] == organisation_webhook_call_args[1] + assert environment_webhook_call_args[2] == WebhookEventType.FLAG_UPDATED.value + + @pytest.mark.django_db def test_trigger_feature_state_change_webhooks__flag_deleted__sends_delete_event( # type: ignore[no-untyped-def] mocker, organisation, project, environment, feature diff --git a/api/tests/unit/webhooks/test_unit_webhooks.py b/api/tests/unit/webhooks/test_unit_webhooks.py index 9ef69bcd2561..9123801d4ef6 100644 --- a/api/tests/unit/webhooks/test_unit_webhooks.py +++ b/api/tests/unit/webhooks/test_unit_webhooks.py @@ -20,6 +20,7 @@ from core.signing import sign_payload from environments.models import Environment, Webhook from organisations.models import Organisation, OrganisationWebhook +from webhooks.serializers import WebhookSerializer from webhooks.webhooks import ( WebhookEventType, WebhookType, @@ -31,6 +32,19 @@ ) +def test_webhook_serializer__flag_created_event_type__is_valid() -> None: + # Given + serializer = WebhookSerializer( + data={"event_type": WebhookEventType.FLAG_CREATED.value, "data": {}} + ) + + # When + is_valid = serializer.is_valid() + + # Then + assert is_valid is True + + @mock.patch("webhooks.webhooks.requests") def test_call_environment_webhooks__multiple_enabled_webhooks__requests_made_to_all_urls( mock_requests: MagicMock, diff --git a/api/webhooks/serializers.py b/api/webhooks/serializers.py index ef8ee518a837..04e189e8c010 100644 --- a/api/webhooks/serializers.py +++ b/api/webhooks/serializers.py @@ -4,7 +4,15 @@ class WebhookSerializer(serializers.Serializer[None]): - event_type = serializers.ChoiceField(choices=["FLAG_UPDATED", "AUDIT_LOG_CREATED"]) + event_type = serializers.ChoiceField( + choices=[ + "FLAG_CREATED", + "FLAG_UPDATED", + "FLAG_DELETED", + "AUDIT_LOG_CREATED", + "NEW_VERSION_PUBLISHED", + ] + ) data = serializers.DictField() # type: ignore[assignment] diff --git a/api/webhooks/webhooks.py b/api/webhooks/webhooks.py index 4b765541c075..664a037e06d7 100644 --- a/api/webhooks/webhooks.py +++ b/api/webhooks/webhooks.py @@ -40,6 +40,7 @@ class WebhookEventType(enum.Enum): + FLAG_CREATED = "FLAG_CREATED" FLAG_UPDATED = "FLAG_UPDATED" FLAG_DELETED = "FLAG_DELETED" AUDIT_LOG_CREATED = "AUDIT_LOG_CREATED" diff --git a/docs/docs/administration-and-security/platform-configuration/environment-settings.md b/docs/docs/administration-and-security/platform-configuration/environment-settings.md index 54ef93b93082..52450c7dce9a 100644 --- a/docs/docs/administration-and-security/platform-configuration/environment-settings.md +++ b/docs/docs/administration-and-security/platform-configuration/environment-settings.md @@ -38,12 +38,12 @@ You can define any number of webhook endpoints per environment. Each event will ### Events That Trigger Webhooks -The following events will generate a webhook action (all sent as `event_type: "FLAG_UPDATED"`): +The following events will generate a webhook action: -- Creating a new feature -- Updating a feature value/state in an environment -- Overriding a feature for an identity -- Overriding a feature for a segment +- Creating a new feature (`event_type: "FLAG_CREATED"`) +- Updating a feature value/state in an environment (`event_type: "FLAG_UPDATED"`) +- Overriding a feature for an identity (`event_type: "FLAG_UPDATED"`) +- Overriding a feature for a segment (`event_type: "FLAG_UPDATED"`) ### Environment Webhook Payload