From c44962227184f50665de5b628e081a10afcf2841 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 10 Nov 2025 19:33:00 -0300 Subject: [PATCH 1/5] Simplify Segment version field with default value Co-authored-by: Claude --- .../0030_add_default_to_segment_version.py | 23 +++++++++++++++++++ api/segments/models.py | 8 +------ 2 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 api/segments/migrations/0030_add_default_to_segment_version.py diff --git a/api/segments/migrations/0030_add_default_to_segment_version.py b/api/segments/migrations/0030_add_default_to_segment_version.py new file mode 100644 index 000000000000..4813ecd210a5 --- /dev/null +++ b/api/segments/migrations/0030_add_default_to_segment_version.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.22 on 2025-11-11 00:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("segments", "0029_add_is_system_segment"), + ] + + operations = [ + migrations.AlterField( + model_name="historicalsegment", + name="version", + field=models.IntegerField(default=1, null=True), + ), + migrations.AlterField( + model_name="segment", + name="version", + field=models.IntegerField(default=1, null=True), + ), + ] diff --git a/api/segments/models.py b/api/segments/models.py index c1892383eb55..5007a7eb8912 100644 --- a/api/segments/models.py +++ b/api/segments/models.py @@ -87,8 +87,7 @@ class Segment( Feature, on_delete=models.CASCADE, related_name="segments", null=True ) - # This defaults to 1 for newly created segments. - version = models.IntegerField(null=True) + version = models.IntegerField(default=1, null=True) version_of = models.ForeignKey( "self", @@ -134,11 +133,6 @@ def get_skip_create_audit_log(self) -> bool: return False - @hook(BEFORE_CREATE, when="version_of", is_now=None) - def set_default_version_to_one_if_new_segment(self): # type: ignore[no-untyped-def] - if self.version is None: - self.version = 1 - @hook(AFTER_CREATE, when="version_of", is_now=None) def set_version_of_to_self_if_none(self): # type: ignore[no-untyped-def] """ From bfee1b915fbbf847576e6367a9b7ed6337f3d4db Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 10 Nov 2025 19:33:00 -0300 Subject: [PATCH 2/5] Move segment managers inline and improve naming Co-authored-by: Claude --- api/segments/managers.py | 16 ---------------- api/segments/models.py | 16 +++++++++++++--- 2 files changed, 13 insertions(+), 19 deletions(-) delete mode 100644 api/segments/managers.py diff --git a/api/segments/managers.py b/api/segments/managers.py deleted file mode 100644 index d1adddb94771..000000000000 --- a/api/segments/managers.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db.models import F - -from core.models import SoftDeleteExportableManager - - -class SegmentManager(SoftDeleteExportableManager): - pass - - -class LiveSegmentManager(SoftDeleteExportableManager): - def get_queryset(self): # type: ignore[no-untyped-def] - """ - Returns only the canonical segments, which will always be - the highest version. - """ - return super().get_queryset().filter(id=F("version_of")) diff --git a/api/segments/models.py b/api/segments/models.py index 5007a7eb8912..c8ab7811dbcb 100644 --- a/api/segments/models.py +++ b/api/segments/models.py @@ -9,7 +9,6 @@ from django.db import models, transaction from django_lifecycle import ( # type: ignore[import-untyped] AFTER_CREATE, - BEFORE_CREATE, LifecycleModelMixin, hook, ) @@ -30,13 +29,24 @@ from metadata.models import Metadata from projects.models import Project -from .managers import LiveSegmentManager, SegmentManager - ModelT = typing.TypeVar("ModelT", bound=models.Model) logger = logging.getLogger(__name__) +class SegmentManager(SoftDeleteExportableManager): + pass + + +class LiveSegmentManager(SoftDeleteExportableManager): + def get_queryset(self): # type: ignore[no-untyped-def] + """ + Returns only the canonical segments, which will always be + the highest version. + """ + return super().get_queryset().filter(id=models.F("version_of")) + + class ConfiguredOrderManager(SoftDeleteExportableManager, models.Manager[ModelT]): setting_name: str From 57bdbdc1506703be2201de8c7fab4469d8c9b04d Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 10 Nov 2025 19:33:00 -0300 Subject: [PATCH 3/5] Improve segment audit log and tests Co-authored-by: Claude --- api/segments/models.py | 9 +-- .../segments/test_unit_segments_models.py | 73 ++++++++++++------- 2 files changed, 49 insertions(+), 33 deletions(-) diff --git a/api/segments/models.py b/api/segments/models.py index c8ab7811dbcb..89966e97f818 100644 --- a/api/segments/models.py +++ b/api/segments/models.py @@ -135,13 +135,8 @@ def __str__(self): # type: ignore[no-untyped-def] def get_skip_create_audit_log(self) -> bool: if self.is_system_segment: return True - try: - if self.version_of_id and self.version_of_id != self.id: - return True - except Segment.DoesNotExist: - return True - - return False + is_revision = bool(self.version_of_id and self.version_of_id != self.id) + return is_revision @hook(AFTER_CREATE, when="version_of", is_now=None) def set_version_of_to_self_if_none(self): # type: ignore[no-untyped-def] diff --git a/api/tests/unit/segments/test_unit_segments_models.py b/api/tests/unit/segments/test_unit_segments_models.py index bb689d0239ff..ae9b392bdeb1 100644 --- a/api/tests/unit/segments/test_unit_segments_models.py +++ b/api/tests/unit/segments/test_unit_segments_models.py @@ -1,6 +1,5 @@ from collections.abc import Callable from typing import Any -from unittest.mock import PropertyMock import pytest from django.core.exceptions import ValidationError @@ -29,7 +28,15 @@ def test_Condition_str__returns_readable_representation_of_condition( assert result == "Condition for ALL rule for Segment - segment: foo EQUAL bar" -def test_Condition_get_skip_create_audit_log__returns_true( +@pytest.mark.parametrize( + "delete", + [ + lambda rule: rule.delete(), + lambda rule: rule.hard_delete(), + ], +) +def test_Condition_get_skip_create_audit_log__rule_deleted__returns_true( + delete: Callable[[SegmentRule], None], segment_rule: SegmentRule, ) -> None: # Given @@ -38,16 +45,45 @@ def test_Condition_get_skip_create_audit_log__returns_true( property="foo", operator=EQUAL, value="bar", + created_with_segment=False, ) # When - result = condition.get_skip_create_audit_log() + delete(segment_rule) # Then - assert result is True + assert condition.get_skip_create_audit_log() is True -def test_manager_returns_only_highest_version_of_segments( +@pytest.mark.parametrize( + "delete", + [ + lambda segment: segment.delete(), + lambda segment: segment.hard_delete(), + ], +) +def test_Condition_get_skip_create_audit_log__segment_deleted__returns_true( + delete: Callable[[Segment], None], + segment: Segment, + segment_rule: SegmentRule, +) -> None: + # Given + condition = Condition.objects.create( + rule=segment_rule, + property="foo", + operator=EQUAL, + value="bar", + created_with_segment=False, + ) + + # When + delete(segment) + + # Then + assert condition.get_skip_create_audit_log() is True + + +def test_LiveSegmentManager__returns_only_highest_version_of_segments( segment: Segment, ) -> None: # Given @@ -108,24 +144,7 @@ def test_SegmentRule_get_skip_create_audit_log__returns_true( assert result is True -def test_segment_get_skip_create_audit_log_when_exception( - mocker: MockerFixture, - segment: Segment, -) -> None: - # Given - patched_segment = mocker.patch.object( - Segment, "version_of_id", new_callable=PropertyMock - ) - patched_segment.side_effect = Segment.DoesNotExist("Segment missing") - - # When - result = segment.get_skip_create_audit_log() - - # Then - assert result is True - - -def test_delete_segment_only_schedules_one_task_for_audit_log_creation( +def test_Segment_delete__multiple_rules_conditions__schedules_audit_log_task_once( mocker: MockerFixture, segment: Segment ) -> None: # Given @@ -143,11 +162,11 @@ def test_delete_segment_only_schedules_one_task_for_audit_log_creation( ) # When - mocked_tasks = mocker.patch("core.signals.tasks") + task = mocker.patch("core.signals.tasks.create_audit_log_from_historical_record") segment.delete() # Then - assert len(mocked_tasks.mock_calls) == 1 + assert task.delay.call_count == 1 def test_Segment_clone__can_create_standalone_segment_clone( @@ -264,7 +283,9 @@ def test_Segment_clone__segment_with_rules__returns_new_segment_with_copied_rule ] -def test_system_segment_get_skip_create_audit_log(system_segment: Segment) -> None: +def test_Segment_get_skip_create_audit_log__system_segment__returns_true( + system_segment: Segment, +) -> None: # When result = system_segment.get_skip_create_audit_log() From e4d5b553ac19f669bde8b2b89ca7ac4e769fbdcb Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 11 Nov 2025 10:48:24 -0300 Subject: [PATCH 4/5] Inherit MetadataSerializerMixin from Serializer Co-authored-by: Claude --- api/environments/serializers.py | 4 +--- api/features/serializers.py | 4 +--- api/metadata/serializers.py | 8 ++++++-- api/segments/serializers.py | 3 +-- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/api/environments/serializers.py b/api/environments/serializers.py index 3448b2b85374..e1702c4d3bc4 100644 --- a/api/environments/serializers.py +++ b/api/environments/serializers.py @@ -4,7 +4,7 @@ from environments.models import Environment, EnvironmentAPIKey, Webhook from features.serializers import FeatureStateSerializerFull -from metadata.serializers import MetadataSerializer, MetadataSerializerMixin +from metadata.serializers import MetadataSerializerMixin from organisations.models import Subscription from organisations.subscriptions.serializers.mixins import ( ReadOnlyIfNotValidPlanMixin, @@ -79,8 +79,6 @@ class EnvironmentSerializerWithMetadata( DeleteBeforeUpdateWritableNestedModelSerializer, EnvironmentSerializerLight, ): - metadata = MetadataSerializer(required=False, many=True) - class Meta(EnvironmentSerializerLight.Meta): fields = EnvironmentSerializerLight.Meta.fields + ("metadata",) # type: ignore[assignment] diff --git a/api/features/serializers.py b/api/features/serializers.py index d922dd11ae47..5de6c17a44af 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -23,7 +23,7 @@ ) from integrations.github.constants import GitHubEventType from integrations.github.github import call_github_task -from metadata.serializers import MetadataSerializer, MetadataSerializerMixin +from metadata.serializers import MetadataSerializerMixin from projects.code_references.serializers import ( FeatureFlagCodeReferencesRepositoryCountSerializer, ) @@ -345,8 +345,6 @@ def get_last_modified_in_current_environment( class FeatureSerializerWithMetadata(MetadataSerializerMixin, CreateFeatureSerializer): - metadata = MetadataSerializer(required=False, many=True) - code_references_counts = FeatureFlagCodeReferencesRepositoryCountSerializer( many=True, read_only=True, diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 8cfca2adb5b4..104d1241e821 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -103,11 +103,15 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: return attrs -class MetadataSerializerMixin: +class MetadataSerializerMixin(serializers.Serializer): # type: ignore[type-arg] """ - Functionality for serializers that need to handle metadata + Mixin for serializers that need to handle metadata + + NOTE: Child serializers should include 'metadata' in their Meta.fields. """ + metadata = MetadataSerializer(required=False, many=True) + def _validate_required_metadata( self, organisation: Organisation, metadata: list[dict[str, Any]] ) -> None: diff --git a/api/segments/serializers.py b/api/segments/serializers.py index 4cbc203894f4..049961f17f3e 100644 --- a/api/segments/serializers.py +++ b/api/segments/serializers.py @@ -7,7 +7,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError -from metadata.serializers import MetadataSerializer, MetadataSerializerMixin +from metadata.serializers import MetadataSerializerMixin from projects.models import Project from segments.models import Condition, Segment, SegmentRule @@ -80,7 +80,6 @@ class Meta: class SegmentSerializer(MetadataSerializerMixin, WritableNestedModelSerializer): rules = SegmentRuleSerializer(many=True, required=True, allow_empty=False) - metadata = MetadataSerializer(required=False, many=True) def __init__(self, *args: Any, **kwargs: Any) -> None: """ From 97a3b7785ef3cb31bf09ed3cd7f85ecc1ab1a35a Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 12 Nov 2025 09:17:07 +0000 Subject: [PATCH 5/5] revert metadata changes --- api/environments/serializers.py | 4 +++- api/features/serializers.py | 4 +++- api/metadata/serializers.py | 8 ++------ api/segments/serializers.py | 3 ++- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/api/environments/serializers.py b/api/environments/serializers.py index e1702c4d3bc4..3448b2b85374 100644 --- a/api/environments/serializers.py +++ b/api/environments/serializers.py @@ -4,7 +4,7 @@ from environments.models import Environment, EnvironmentAPIKey, Webhook from features.serializers import FeatureStateSerializerFull -from metadata.serializers import MetadataSerializerMixin +from metadata.serializers import MetadataSerializer, MetadataSerializerMixin from organisations.models import Subscription from organisations.subscriptions.serializers.mixins import ( ReadOnlyIfNotValidPlanMixin, @@ -79,6 +79,8 @@ class EnvironmentSerializerWithMetadata( DeleteBeforeUpdateWritableNestedModelSerializer, EnvironmentSerializerLight, ): + metadata = MetadataSerializer(required=False, many=True) + class Meta(EnvironmentSerializerLight.Meta): fields = EnvironmentSerializerLight.Meta.fields + ("metadata",) # type: ignore[assignment] diff --git a/api/features/serializers.py b/api/features/serializers.py index 5de6c17a44af..d922dd11ae47 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -23,7 +23,7 @@ ) from integrations.github.constants import GitHubEventType from integrations.github.github import call_github_task -from metadata.serializers import MetadataSerializerMixin +from metadata.serializers import MetadataSerializer, MetadataSerializerMixin from projects.code_references.serializers import ( FeatureFlagCodeReferencesRepositoryCountSerializer, ) @@ -345,6 +345,8 @@ def get_last_modified_in_current_environment( class FeatureSerializerWithMetadata(MetadataSerializerMixin, CreateFeatureSerializer): + metadata = MetadataSerializer(required=False, many=True) + code_references_counts = FeatureFlagCodeReferencesRepositoryCountSerializer( many=True, read_only=True, diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 104d1241e821..8cfca2adb5b4 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -103,15 +103,11 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: return attrs -class MetadataSerializerMixin(serializers.Serializer): # type: ignore[type-arg] +class MetadataSerializerMixin: """ - Mixin for serializers that need to handle metadata - - NOTE: Child serializers should include 'metadata' in their Meta.fields. + Functionality for serializers that need to handle metadata """ - metadata = MetadataSerializer(required=False, many=True) - def _validate_required_metadata( self, organisation: Organisation, metadata: list[dict[str, Any]] ) -> None: diff --git a/api/segments/serializers.py b/api/segments/serializers.py index 049961f17f3e..4cbc203894f4 100644 --- a/api/segments/serializers.py +++ b/api/segments/serializers.py @@ -7,7 +7,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError -from metadata.serializers import MetadataSerializerMixin +from metadata.serializers import MetadataSerializer, MetadataSerializerMixin from projects.models import Project from segments.models import Condition, Segment, SegmentRule @@ -80,6 +80,7 @@ class Meta: class SegmentSerializer(MetadataSerializerMixin, WritableNestedModelSerializer): rules = SegmentRuleSerializer(many=True, required=True, allow_empty=False) + metadata = MetadataSerializer(required=False, many=True) def __init__(self, *args: Any, **kwargs: Any) -> None: """