Skip to content
Open
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
6 changes: 3 additions & 3 deletions api/environments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/features/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
42 changes: 41 additions & 1 deletion api/features/versioning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down
103 changes: 102 additions & 1 deletion api/features/versioning/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
Loading