From b7a8f57013f3cac28bdf9081dbb7f84f369db647 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 19 Feb 2026 19:01:32 -0300 Subject: [PATCH 1/3] Reproduce the webhook data issue in scheduled flag updates --- .../features/featurestate/test_webhooks.py | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/api/tests/integration/features/featurestate/test_webhooks.py b/api/tests/integration/features/featurestate/test_webhooks.py index 3cbd65b7d343..8eb4760b28f7 100644 --- a/api/tests/integration/features/featurestate/test_webhooks.py +++ b/api/tests/integration/features/featurestate/test_webhooks.py @@ -3,14 +3,33 @@ import pytest import responses from django.urls import reverse +from freezegun import freeze_time from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient +from audit.tasks import create_feature_state_went_live_audit_log +from features.models import FeatureState +from features.workflows.core.models import ChangeRequest +from users.models import FFAdminUser + WEBHOOK_URL = "https://example.com/webhook" +def _extract_webhook_payloads(event_type: str | None): + """Extract webhook payloads from responses cache""" + return ( + payload + for payload in ( + json.loads(r.request.body) + for r in responses.calls + if r.request.url == WEBHOOK_URL + ) + if event_type is None or payload["event_type"] == event_type + ) + + @pytest.fixture def environment_webhook( admin_client: APIClient, @@ -218,3 +237,101 @@ def test_update_multivariate_percentage__webhook_payload_includes_multivariate_v "percentage_allocation": old_percentage, }, ] + + +@pytest.mark.parametrize( + "webhook", + [lazy_fixture("environment_webhook"), lazy_fixture("organisation_webhook")], +) +@responses.activate +def test_update_feature_live__legacy_versioning__webhook_payload_has_correct_previous_and_new_states( + admin_client: APIClient, + environment: int, + feature: int, + webhook: str, +): + # Given + feature_state_json = admin_client.get( + f"/api/v1/features/featurestates/?environment={environment}&feature={feature}" + ).json()["results"][0] + feature_state_id = feature_state_json["id"] + previous_state = feature_state_json["enabled"] + previous_value = feature_state_json["feature_state_value"]["string_value"] + + # When + responses.add(responses.POST, webhook, status=200) + response = admin_client.put( + f"/api/v1/features/featurestates/{feature_state_id}/", + data={ + **feature_state_json, + "feature_state_value": {"string_value": "new_value"}, + "enabled": True, + }, + format="json", + ) + assert response.status_code == 200 + + # Then + payload = list(_extract_webhook_payloads("FLAG_UPDATED"))[-1] + assert payload["data"]["previous_state"]["enabled"] is previous_state + assert payload["data"]["previous_state"]["feature_state_value"] == previous_value + assert payload["data"]["new_state"]["enabled"] is True + assert payload["data"]["new_state"]["feature_state_value"] == "new_value" + + +@pytest.mark.parametrize( + "webhook", + [lazy_fixture("environment_webhook"), lazy_fixture("organisation_webhook")], +) +@responses.activate +def test_update_feature_scheduled__legacy_versioning__webhook_payload_has_correct_previous_and_new_states( + admin_user: FFAdminUser, + environment: int, + feature: int, + webhook: str, +): + """Covers https://github.com/Flagsmith/flagsmith/issues/2063""" + # Given + previous_feature_state = FeatureState.objects.get(feature_id=feature) + previous_state = previous_feature_state.enabled + previous_value = previous_feature_state.get_feature_state_value() + + # Simulate scheduled update in the frontend + with freeze_time("2048-02-29T02:00:00+0000"): + # POST create-change-request + change_request = ChangeRequest.objects.create( + environment=previous_feature_state.environment, + title="Scheduled update", + user=admin_user, + ) + new_feature_state = FeatureState.objects.create( + environment=previous_feature_state.environment, + feature=previous_feature_state.feature, + change_request=change_request, + enabled=True, + live_from="2048-02-29T06:00:00+0000", + version=None, + ) + new_feature_state.feature_state_value.string_value = "new_value" + new_feature_state.feature_state_value.save() + + with freeze_time("2048-02-29T02:00:01+0000"): + # PUT workflows:change-requests-detail pk={change_request.id} + new_feature_state.save() + new_feature_state.feature_state_value.save() + + responses.calls.reset() + + # When + responses.add(responses.POST, webhook, status=200) + change_request.commit(committed_by=admin_user) + assert not any(_extract_webhook_payloads("FLAG_UPDATED")) + with freeze_time("2048-02-29T06:00:00+0000"): + create_feature_state_went_live_audit_log(new_feature_state.id) + + # Then + payload = list(_extract_webhook_payloads("FLAG_UPDATED"))[-1] + assert payload["data"]["previous_state"]["enabled"] is previous_state + assert payload["data"]["previous_state"]["feature_state_value"] == previous_value + assert payload["data"]["new_state"]["enabled"] is True + assert payload["data"]["new_state"]["feature_state_value"] == "new_value" From f3322863ee6ee0c32700b0029719877de6b49702 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 19 Feb 2026 21:13:35 -0300 Subject: [PATCH 2/3] Come up with a solution --- api/features/tasks.py | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/api/features/tasks.py b/api/features/tasks.py index 4abf7540406b..69933a6872bc 100644 --- a/api/features/tasks.py +++ b/api/features/tasks.py @@ -68,14 +68,50 @@ def _get_previous_state( instance: FeatureState, history_instance: HistoricalFeatureState, event_type: WebhookEventType, -) -> dict: # type: ignore[type-arg] +) -> dict[str, Any] | None: if event_type == WebhookEventType.FLAG_DELETED: return _get_feature_state_webhook_data(instance) + + # Change requests create a new FeatureState with its own history + if instance.change_request_id is not None: + previous_fs = _get_previous_feature_state_for_change_request(instance) + if previous_fs: + return _get_feature_state_webhook_data(previous_fs) + return None + if history_instance and history_instance.prev_record: return _get_feature_state_webhook_data( history_instance.prev_record.instance, previous=True ) - return None # type: ignore[return-value] + return None + + +def _get_previous_feature_state_for_change_request( + instance: FeatureState, +) -> FeatureState | None: + """Find the previous live FeatureState for a change request (legacy versioning).""" + return ( + FeatureState.objects.exclude( + change_request_id=instance.change_request_id, + ) + .filter( + environment_id=instance.environment_id, + feature_id=instance.feature_id, + feature_segment=instance.feature_segment, + identity=instance.identity, + version__isnull=False, + live_from__lt=instance.live_from, + ) + .order_by("-live_from") + .select_related( + "feature", + "environment", + "feature_state_value", + "feature_segment", + "identity", + ) + .first() + ) def _get_feature_state_webhook_data( From dfe89e02e52b2178415ba8c79037f8994b29ce11 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 19 Feb 2026 21:35:45 -0300 Subject: [PATCH 3/3] Fix typing issues --- api/features/tasks.py | 3 ++- .../integration/features/featurestate/test_webhooks.py | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/api/features/tasks.py b/api/features/tasks.py index 69933a6872bc..7ae434d9b60d 100644 --- a/api/features/tasks.py +++ b/api/features/tasks.py @@ -90,7 +90,7 @@ def _get_previous_feature_state_for_change_request( instance: FeatureState, ) -> FeatureState | None: """Find the previous live FeatureState for a change request (legacy versioning).""" - return ( + result: FeatureState | None = ( FeatureState.objects.exclude( change_request_id=instance.change_request_id, ) @@ -112,6 +112,7 @@ def _get_previous_feature_state_for_change_request( ) .first() ) + return result def _get_feature_state_webhook_data( diff --git a/api/tests/integration/features/featurestate/test_webhooks.py b/api/tests/integration/features/featurestate/test_webhooks.py index 8eb4760b28f7..5b9e1e59505b 100644 --- a/api/tests/integration/features/featurestate/test_webhooks.py +++ b/api/tests/integration/features/featurestate/test_webhooks.py @@ -1,4 +1,6 @@ import json +from collections.abc import Generator +from typing import Any import pytest import responses @@ -17,7 +19,7 @@ WEBHOOK_URL = "https://example.com/webhook" -def _extract_webhook_payloads(event_type: str | None): +def _extract_webhook_payloads(event_type: str | None) -> Generator[dict[str, Any]]: """Extract webhook payloads from responses cache""" return ( payload @@ -249,7 +251,7 @@ def test_update_feature_live__legacy_versioning__webhook_payload_has_correct_pre environment: int, feature: int, webhook: str, -): +) -> None: # Given feature_state_json = admin_client.get( f"/api/v1/features/featurestates/?environment={environment}&feature={feature}" @@ -289,7 +291,7 @@ def test_update_feature_scheduled__legacy_versioning__webhook_payload_has_correc environment: int, feature: int, webhook: str, -): +) -> None: """Covers https://github.com/Flagsmith/flagsmith/issues/2063""" # Given previous_feature_state = FeatureState.objects.get(feature_id=feature)