diff --git a/api/features/feature_health/migrations/0003_migrate_sample_to_webhook.py b/api/features/feature_health/migrations/0003_migrate_sample_to_webhook.py new file mode 100644 index 000000000000..381bde1066fa --- /dev/null +++ b/api/features/feature_health/migrations/0003_migrate_sample_to_webhook.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.26 on 2025-11-27 10:39 +from django.apps.registry import Apps +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from django.db import migrations, models + + +def migrate_sample_to_webhook_forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + FeatureHealthProvider = apps.get_model("feature_health", "FeatureHealthProvider") + FeatureHealthEvent = apps.get_model("feature_health", "FeatureHealthEvent") + + FeatureHealthProvider.objects.filter(name="Sample").update(name="Webhook") + FeatureHealthEvent.objects.filter(provider_name="Sample").update(provider_name="Webhook") + + +def migrate_sample_to_webhook_reverse(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + FeatureHealthProvider = apps.get_model("feature_health", "FeatureHealthProvider") + FeatureHealthEvent = apps.get_model("feature_health", "FeatureHealthEvent") + + FeatureHealthProvider.objects.filter(name="Webhook").update(name="Sample") + FeatureHealthEvent.objects.filter(provider_name="Webhook").update(provider_name="Sample") + + + +class Migration(migrations.Migration): + + dependencies = [ + ("feature_health", "0002_featurehealthevent_add_external_id_alter_created_at"), + ] + + operations = [ + migrations.AlterField( + model_name='featurehealthprovider', + name='name', + field=models.CharField( + choices=[('Webhook', 'Webhook'), ('Grafana', 'Grafana')], + max_length=50 + ), + ), + migrations.AlterField( + model_name='historicalfeaturehealthprovider', + name='name', + field=models.CharField( + choices=[('Webhook', 'Webhook'), ('Grafana', 'Grafana')], + max_length=50 + ), + ), + migrations.RunPython( + migrate_sample_to_webhook_forward, + migrate_sample_to_webhook_reverse, + ), + ] \ No newline at end of file diff --git a/api/features/feature_health/models.py b/api/features/feature_health/models.py index 225d03419aa1..1621c7e4dbf6 100644 --- a/api/features/feature_health/models.py +++ b/api/features/feature_health/models.py @@ -25,7 +25,7 @@ class FeatureHealthProviderName(models.Choices): - SAMPLE = "Sample" + WEBHOOK = "Webhook" GRAFANA = "Grafana" diff --git a/api/features/feature_health/providers/sample/mappers.py b/api/features/feature_health/providers/generic/mappers.py similarity index 74% rename from api/features/feature_health/providers/sample/mappers.py rename to api/features/feature_health/providers/generic/mappers.py index 012f6db08e42..0c025a7d2917 100644 --- a/api/features/feature_health/providers/sample/mappers.py +++ b/api/features/feature_health/providers/generic/mappers.py @@ -4,20 +4,20 @@ FeatureHealthEventType, FeatureHealthProviderName, ) -from features.feature_health.providers.sample.types import ( - SampleEvent, - SampleEventStatus, +from features.feature_health.providers.webhook.types import ( + WebhookEvent, + WebhookEventStatus, ) from features.feature_health.types import ( FeatureHealthEventData, FeatureHealthProviderResponse, ) -_sample_event_type_adapter = TypeAdapter(SampleEvent) +_sample_event_type_adapter = TypeAdapter(WebhookEvent) def map_sample_event_status_to_feature_health_event_type( - status: SampleEventStatus, + status: WebhookEventStatus, ) -> FeatureHealthEventType: return ( FeatureHealthEventType.UNHEALTHY @@ -29,7 +29,7 @@ def map_sample_event_status_to_feature_health_event_type( def map_payload_to_provider_response( payload: str, ) -> FeatureHealthProviderResponse: - event_data: SampleEvent = _sample_event_type_adapter.validate_json(payload) + event_data: WebhookEvent = _sample_event_type_adapter.validate_json(payload) return FeatureHealthProviderResponse( events=[ @@ -40,7 +40,7 @@ def map_payload_to_provider_response( event_data["status"] ), reason=event_data.get("reason"), - provider_name=FeatureHealthProviderName.SAMPLE.value, + provider_name=FeatureHealthProviderName.WEBHOOK.value, ), ], ) diff --git a/api/features/feature_health/providers/sample/__init__.py b/api/features/feature_health/providers/webhook/__init__.py similarity index 50% rename from api/features/feature_health/providers/sample/__init__.py rename to api/features/feature_health/providers/webhook/__init__.py index c783deb93886..56495e2437bd 100644 --- a/api/features/feature_health/providers/sample/__init__.py +++ b/api/features/feature_health/providers/webhook/__init__.py @@ -1,4 +1,4 @@ -from features.feature_health.providers.sample.services import ( +from features.feature_health.providers.webhook.services import ( get_provider_response, ) diff --git a/api/features/feature_health/providers/webhook/mappers.py b/api/features/feature_health/providers/webhook/mappers.py new file mode 100644 index 000000000000..0c025a7d2917 --- /dev/null +++ b/api/features/feature_health/providers/webhook/mappers.py @@ -0,0 +1,46 @@ +from pydantic.type_adapter import TypeAdapter + +from features.feature_health.models import ( + FeatureHealthEventType, + FeatureHealthProviderName, +) +from features.feature_health.providers.webhook.types import ( + WebhookEvent, + WebhookEventStatus, +) +from features.feature_health.types import ( + FeatureHealthEventData, + FeatureHealthProviderResponse, +) + +_sample_event_type_adapter = TypeAdapter(WebhookEvent) + + +def map_sample_event_status_to_feature_health_event_type( + status: WebhookEventStatus, +) -> FeatureHealthEventType: + return ( + FeatureHealthEventType.UNHEALTHY + if status == "unhealthy" + else FeatureHealthEventType.HEALTHY + ) + + +def map_payload_to_provider_response( + payload: str, +) -> FeatureHealthProviderResponse: + event_data: WebhookEvent = _sample_event_type_adapter.validate_json(payload) + + return FeatureHealthProviderResponse( + events=[ + FeatureHealthEventData( + feature_name=event_data["feature"], + environment_name=event_data.get("environment"), + type=map_sample_event_status_to_feature_health_event_type( + event_data["status"] + ), + reason=event_data.get("reason"), + provider_name=FeatureHealthProviderName.WEBHOOK.value, + ), + ], + ) diff --git a/api/features/feature_health/providers/sample/services.py b/api/features/feature_health/providers/webhook/services.py similarity index 79% rename from api/features/feature_health/providers/sample/services.py rename to api/features/feature_health/providers/webhook/services.py index 74ee7eeb121a..f3acc9992c65 100644 --- a/api/features/feature_health/providers/sample/services.py +++ b/api/features/feature_health/providers/webhook/services.py @@ -1,4 +1,4 @@ -from features.feature_health.providers.sample.mappers import ( +from features.feature_health.providers.webhook.mappers import ( map_payload_to_provider_response, ) from features.feature_health.types import FeatureHealthProviderResponse diff --git a/api/features/feature_health/providers/sample/types.py b/api/features/feature_health/providers/webhook/types.py similarity index 63% rename from api/features/feature_health/providers/sample/types.py rename to api/features/feature_health/providers/webhook/types.py index c0517ca87afa..f6e7ca5118c3 100644 --- a/api/features/feature_health/providers/sample/types.py +++ b/api/features/feature_health/providers/webhook/types.py @@ -4,11 +4,11 @@ from features.feature_health.types import FeatureHealthEventReason -SampleEventStatus: typing.TypeAlias = typing.Literal["healthy", "unhealthy"] +WebhookEventStatus: typing.TypeAlias = typing.Literal["healthy", "unhealthy"] -class SampleEvent(TypedDict): +class WebhookEvent(TypedDict): environment: typing.NotRequired[str] feature: str - status: SampleEventStatus + status: WebhookEventStatus reason: typing.NotRequired[FeatureHealthEventReason] diff --git a/api/features/feature_health/services.py b/api/features/feature_health/services.py index 9926b0f2d22e..cc4b48c574f1 100644 --- a/api/features/feature_health/services.py +++ b/api/features/feature_health/services.py @@ -19,7 +19,7 @@ FeatureHealthProvider, FeatureHealthProviderName, ) -from features.feature_health.providers import grafana, sample +from features.feature_health.providers import grafana, webhook from features.feature_health.types import FeatureHealthEventReason from features.models import Feature from projects.tags.models import Tag, TagType @@ -36,7 +36,7 @@ typing.Callable[[str], "FeatureHealthProviderResponse"], ] = { FeatureHealthProviderName.GRAFANA.value: grafana.get_provider_response, - FeatureHealthProviderName.SAMPLE.value: sample.get_provider_response, + FeatureHealthProviderName.WEBHOOK.value: webhook.get_provider_response, } diff --git a/api/tests/integration/features/feature_health/conftest.py b/api/tests/integration/features/feature_health/conftest.py index a7256f7d2089..f52f42f80097 100644 --- a/api/tests/integration/features/feature_health/conftest.py +++ b/api/tests/integration/features/feature_health/conftest.py @@ -16,25 +16,25 @@ def _get_feature_health_provider_webhook_url( @pytest.fixture -def sample_feature_health_provider_webhook_url( +def webhook_feature_health_provider_webhook_url( project: int, admin_client_new: APIClient ) -> str: return _get_feature_health_provider_webhook_url( project=project, api_client=admin_client_new, - name="Sample", + name="Webhook", ) @pytest.fixture def unhealthy_feature( - sample_feature_health_provider_webhook_url: str, + webhook_feature_health_provider_webhook_url: str, feature_name: str, feature: int, api_client: APIClient, ) -> int: api_client.post( - sample_feature_health_provider_webhook_url, + webhook_feature_health_provider_webhook_url, data=json.dumps({"feature": feature_name, "status": "unhealthy"}), content_type="application/json", ) diff --git a/api/tests/integration/features/feature_health/test_views.py b/api/tests/integration/features/feature_health/test_views.py index f071af3da3d3..e4e8ae28ce64 100644 --- a/api/tests/integration/features/feature_health/test_views.py +++ b/api/tests/integration/features/feature_health/test_views.py @@ -40,7 +40,7 @@ def test_feature_health_providers__get__expected_response( url = reverse("api-v1:projects:feature-health-providers-list", args=[project]) expected_feature_health_provider_data = admin_client_new.post( url, - data={"name": "Sample"}, + data={"name": "Webhook"}, ).json() # When @@ -49,7 +49,7 @@ def test_feature_health_providers__get__expected_response( # Then assert expected_feature_health_provider_data == { "created_by": expected_created_by, - "name": "Sample", + "name": "Webhook", "project": project, "webhook_url": mocker.ANY, } @@ -68,7 +68,7 @@ def test_feature_health_providers__delete__expected_response( url = reverse("api-v1:projects:feature-health-providers-list", args=[project]) admin_client_new.post( url, - data={"name": "Sample"}, + data={"name": "Webhook"}, ).json() # When @@ -76,7 +76,7 @@ def test_feature_health_providers__delete__expected_response( response = admin_client_new.delete( reverse( "api-v1:projects:feature-health-providers-detail", - args=[project, "sample"], + args=[project, "Webhook"], ) ) @@ -138,7 +138,7 @@ def test_feature_health_events__dismiss__expected_response( "environment": None, "feature": unhealthy_feature, "id": mocker.ANY, - "provider_name": "Sample", + "provider_name": "Webhook", "reason": { "text_blocks": [ {"text": f"Manually dismissed by {expected_dismissed_by}"} @@ -168,7 +168,7 @@ def test_webhook__sample_provider__post__expected_feature_health_event_created__ feature: int, project: int, feature_name: str, - sample_feature_health_provider_webhook_url: str, + webhook_feature_health_provider_webhook_url: str, api_client: APIClient, admin_client_new: APIClient, mocker: MockerFixture, @@ -186,7 +186,7 @@ def test_webhook__sample_provider__post__expected_feature_health_event_created__ "status": "unhealthy", } response = api_client.post( - sample_feature_health_provider_webhook_url, + webhook_feature_health_provider_webhook_url, data=json.dumps(webhook_data), content_type="application/json", ) @@ -200,7 +200,7 @@ def test_webhook__sample_provider__post__expected_feature_health_event_created__ "created_at": "2023-01-19T09:09:47.325132Z", "environment": None, "feature": feature, - "provider_name": "Sample", + "provider_name": "Webhook", "reason": None, "type": "UNHEALTHY", } @@ -229,7 +229,7 @@ def test_webhook__sample_provider__post_with_environment_expected_feature_health environment: int, feature_name: str, environment_name: str, - sample_feature_health_provider_webhook_url: str, + webhook_feature_health_provider_webhook_url: str, api_client: APIClient, admin_client_new: APIClient, mocker: MockerFixture, @@ -246,7 +246,7 @@ def test_webhook__sample_provider__post_with_environment_expected_feature_health "status": "unhealthy", } response = api_client.post( - sample_feature_health_provider_webhook_url, + webhook_feature_health_provider_webhook_url, data=json.dumps(webhook_data), content_type="application/json", ) @@ -260,7 +260,7 @@ def test_webhook__sample_provider__post_with_environment_expected_feature_health "created_at": "2023-01-19T09:09:47.325132Z", "environment": environment, "feature": feature, - "provider_name": "Sample", + "provider_name": "Webhook", "reason": None, "type": "UNHEALTHY", } @@ -272,7 +272,7 @@ def test_webhook__unhealthy_feature__post__expected_feature_health_event_created unhealthy_feature: int, project: int, feature_name: str, - sample_feature_health_provider_webhook_url: str, + webhook_feature_health_provider_webhook_url: str, api_client: APIClient, admin_client_new: APIClient, mocker: MockerFixture, @@ -291,7 +291,7 @@ def test_webhook__unhealthy_feature__post__expected_feature_health_event_created } with freeze_time(datetime.now() + timedelta(seconds=1)): response = api_client.post( - sample_feature_health_provider_webhook_url, + webhook_feature_health_provider_webhook_url, data=json.dumps(webhook_data), content_type="application/json", ) @@ -305,7 +305,7 @@ def test_webhook__unhealthy_feature__post__expected_feature_health_event_created "created_at": "2023-01-19T09:09:48.325132Z", "environment": None, "feature": unhealthy_feature, - "provider_name": "Sample", + "provider_name": "Webhook", "reason": None, "type": "HEALTHY", } @@ -330,14 +330,14 @@ def test_webhook__unhealthy_feature__post__expected_feature_health_event_created @pytest.mark.parametrize( "body", ["invalid", json.dumps({"status": "unhealthy", "feature": "non_existent"})] ) -def test_webhook__sample_provider__post__invalid_payload__expected_response( - sample_feature_health_provider_webhook_url: str, +def test_webhook__webhook_provider__post__invalid_payload__expected_response( + webhook_feature_health_provider_webhook_url: str, api_client: APIClient, body: str, ) -> None: # When response = api_client.post( - sample_feature_health_provider_webhook_url, + webhook_feature_health_provider_webhook_url, data=body, content_type="application/json", ) diff --git a/api/tests/unit/features/feature_health/conftest.py b/api/tests/unit/features/feature_health/conftest.py index 03c939aaba74..b042cc44f26e 100644 --- a/api/tests/unit/features/feature_health/conftest.py +++ b/api/tests/unit/features/feature_health/conftest.py @@ -13,5 +13,5 @@ def feature_health_provider( return FeatureHealthProvider.objects.create( # type: ignore[no-any-return] created_by=staff_user, project=project, - name="Sample", + name="Webhook", ) diff --git a/api/tests/unit/features/feature_health/test_admin.py b/api/tests/unit/features/feature_health/test_admin.py index d2b4b53ec6b1..e6f6a3fde106 100644 --- a/api/tests/unit/features/feature_health/test_admin.py +++ b/api/tests/unit/features/feature_health/test_admin.py @@ -49,7 +49,7 @@ def test_feature_health_provider_admin__webhook_url__return_expected( admin_instance = FeatureHealthProviderAdmin( FeatureHealthProvider, mocker.MagicMock() ) - feature_health_provider = FeatureHealthProvider(name="Sample") + feature_health_provider = FeatureHealthProvider(name="Webhook") admin_instance.changelist_view(feature_health_provider_admin_request) # When diff --git a/api/tests/unit/features/feature_health/test_models.py b/api/tests/unit/features/feature_health/test_models.py index c6b0f2b07f42..0a639bf5a500 100644 --- a/api/tests/unit/features/feature_health/test_models.py +++ b/api/tests/unit/features/feature_health/test_models.py @@ -25,7 +25,7 @@ def test_feature_health_provider__get_create_log_message__return_expected( log_message = feature_health_provider.get_create_log_message(mocker.Mock()) # Then - assert log_message == "Health provider Sample set up for project Test Project." + assert log_message == "Health provider Webhook set up for project Test Project." def test_feature_health_provider__get_delete_log_message__return_expected( @@ -36,7 +36,7 @@ def test_feature_health_provider__get_delete_log_message__return_expected( log_message = feature_health_provider.get_delete_log_message(mocker.Mock()) # Then - assert log_message == "Health provider Sample removed from project Test Project." + assert log_message == "Health provider Webhook removed from project Test Project." def test_feature_health_provider__get_audit_log_author__return_expected( diff --git a/api/tests/unit/features/feature_health/test_services.py b/api/tests/unit/features/feature_health/test_services.py index edee0c29e494..dc7943a00db7 100644 --- a/api/tests/unit/features/feature_health/test_services.py +++ b/api/tests/unit/features/feature_health/test_services.py @@ -51,7 +51,7 @@ def test_dismiss_feature_health_event__healthy_event__log_expected( healthy_event = FeatureHealthEvent.objects.create( feature=feature, environment=environment, - provider_name="Sample", + provider_name="Webhook", external_id="test_external_id", type=FeatureHealthEventType.HEALTHY, ) diff --git a/api/tests/unit/features/test_migrations.py b/api/tests/unit/features/test_migrations.py index 8ce7084cb251..181404551c81 100644 --- a/api/tests/unit/features/test_migrations.py +++ b/api/tests/unit/features/test_migrations.py @@ -241,3 +241,95 @@ def test_fix_feature_type_migration(migrator): # type: ignore[no-untyped-def] ) assert NewFeature.objects.get(id=standard_feature.id).type == STANDARD assert NewFeature.objects.get(id=mv_feature.id).type == MULTIVARIATE + + +def test_migrate_sample_to_webhook_forward(migrator): # type: ignore[no-untyped-def] + # Given + old_state = migrator.apply_initial_migration( + ("feature_health", "0002_featurehealthevent_add_external_id_alter_created_at") + ) + + FeatureHealthProvider = old_state.apps.get_model( + "feature_health", "FeatureHealthProvider" + ) + FeatureHealthEvent = old_state.apps.get_model( + "feature_health", "FeatureHealthEvent" + ) + Organisation = old_state.apps.get_model("organisations", "Organisation") + Project = old_state.apps.get_model("projects", "Project") + Feature = old_state.apps.get_model("features", "Feature") + + organisation = Organisation.objects.create(name="Test Org") + project = Project.objects.create(name="Test Project", organisation=organisation) + feature = Feature.objects.create(name="test_feature", project=project) + + provider = FeatureHealthProvider.objects.create(name="Sample", project=project) + event = FeatureHealthEvent.objects.create( + feature=feature, type="UNHEALTHY", provider_name="Sample" + ) + + # When + new_state = migrator.apply_tested_migration( + ("feature_health", "0003_migrate_sample_to_webhook") + ) + + NewFeatureHealthProvider = new_state.apps.get_model( + "feature_health", "FeatureHealthProvider" + ) + NewFeatureHealthEvent = new_state.apps.get_model( + "feature_health", "FeatureHealthEvent" + ) + + # Then + assert NewFeatureHealthProvider.objects.get(id=provider.id).name == "Webhook" + assert NewFeatureHealthEvent.objects.get(id=event.id).provider_name == "Webhook" + assert not NewFeatureHealthProvider.objects.filter(name="Sample").exists() + assert not NewFeatureHealthEvent.objects.filter(provider_name="Sample").exists() + + +@pytest.mark.skipif( + settings.SKIP_MIGRATION_TESTS is True, + reason="Skip migration tests to speed up tests where necessary", +) +def test_migrate_sample_to_webhook_reverse(migrator): # type: ignore[no-untyped-def] + # Given + old_state = migrator.apply_initial_migration( + ("feature_health", "0003_migrate_sample_to_webhook") + ) + + FeatureHealthProvider = old_state.apps.get_model( + "feature_health", "FeatureHealthProvider" + ) + FeatureHealthEvent = old_state.apps.get_model( + "feature_health", "FeatureHealthEvent" + ) + Organisation = old_state.apps.get_model("organisations", "Organisation") + Project = old_state.apps.get_model("projects", "Project") + Feature = old_state.apps.get_model("features", "Feature") + + organisation = Organisation.objects.create(name="Test Org") + project = Project.objects.create(name="Test Project", organisation=organisation) + feature = Feature.objects.create(name="test_feature_webhook", project=project) + + provider = FeatureHealthProvider.objects.create(name="Webhook", project=project) + event = FeatureHealthEvent.objects.create( + feature_id=feature.id, type="UNHEALTHY", provider_name="Webhook" + ) + + # When + new_state = migrator.apply_tested_migration( + ("feature_health", "0002_featurehealthevent_add_external_id_alter_created_at") + ) + + NewFeatureHealthProvider = new_state.apps.get_model( + "feature_health", "FeatureHealthProvider" + ) + NewFeatureHealthEvent = new_state.apps.get_model( + "feature_health", "FeatureHealthEvent" + ) + + # Then + assert NewFeatureHealthProvider.objects.get(id=provider.id).name == "Sample" + assert NewFeatureHealthEvent.objects.get(id=event.id).provider_name == "Sample" + assert not NewFeatureHealthProvider.objects.filter(name="Webhook").exists() + assert not NewFeatureHealthEvent.objects.filter(provider_name="Webhook").exists()