Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions api/audit/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion api/features/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
31 changes: 29 additions & 2 deletions api/features/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions api/tests/unit/audit/test_unit_audit_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
160 changes: 159 additions & 1 deletion api/tests/unit/features/test_unit_features_tasks.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions api/tests/unit/webhooks/test_unit_webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion api/webhooks/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]


Expand Down
1 change: 1 addition & 0 deletions api/webhooks/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading