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
4 changes: 1 addition & 3 deletions api/environments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]

Expand Down
4 changes: 1 addition & 3 deletions api/features/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 6 additions & 6 deletions api/import_export/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import boto3
from django.core import serializers
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import F, Model, Q
from django.db.models import Model, Q

from edge_api.identities.export import export_edge_identity_and_overrides
from environments.identities.models import Identity
Expand Down Expand Up @@ -130,28 +130,28 @@ def export_projects(
*_export_entities(
_EntityExportConfig(
Segment,
Q(project__organisation__id=organisation_id, id=F("version_of")),
Q(project__organisation__id=organisation_id, version_of__isnull=True),
),
_EntityExportConfig(
SegmentRule,
Q(
segment__project__organisation__id=organisation_id,
segment_id=F("segment__version_of"),
segment__version_of__isnull=True,
)
| Q(
rule__segment__project__organisation__id=organisation_id,
rule__segment_id=F("rule__segment__version_of"),
rule__segment__version_of__isnull=True,
),
),
_EntityExportConfig(
Condition,
Q(
rule__segment__project__organisation__id=organisation_id,
rule__segment_id=F("rule__segment__version_of"),
rule__segment__version_of__isnull=True,
)
| Q(
rule__rule__segment__project__organisation__id=organisation_id,
rule__rule__segment_id=F("rule__rule__segment__version_of"),
rule__rule__segment__version_of__isnull=True,
),
),
_EntityExportConfig(Tag, default_filter),
Expand Down
8 changes: 6 additions & 2 deletions api/metadata/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 0 additions & 16 deletions api/segments/managers.py

This file was deleted.

23 changes: 23 additions & 0 deletions api/segments/migrations/0030_add_default_to_segment_version.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 4.2.22 on 2025-11-11 03:43

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("segments", "0030_add_default_to_segment_version"),
]

operations = [
# Set version_of to NULL for canonical segments (where version_of_id = id).
# This follows the same pattern as migration 0023 which originally set
# version_of_id = id. Like that migration, this may block during deployment
# depending on the number of canonical segments in the database.
migrations.RunSQL(
sql="UPDATE segments_segment SET version_of_id = NULL WHERE version_of_id = id;",
reverse_sql="UPDATE segments_segment SET version_of_id = id WHERE version_of_id IS NULL;",
),
]
68 changes: 35 additions & 33 deletions api/segments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django_lifecycle import ( # type: ignore[import-untyped]
AFTER_CREATE,
BEFORE_CREATE,
LifecycleModelMixin,
hook,
)
from flag_engine.segments import constants

Expand All @@ -30,13 +27,37 @@
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 LiveSegmentManager(SoftDeleteExportableManager):
def get_queryset(self): # type: ignore[no-untyped-def]
"""
Returns only canonical segments (where version_of is NULL).
Canonical segments represent the current/live version.
"""
return super().get_queryset().filter(version_of__isnull=True)


class RevisionsManager(SoftDeleteExportableManager):
def get_queryset(self): # type: ignore[no-untyped-def]
"""
Returns only segment revisions (where version_of is NOT NULL).
Revisions are historical versions of segments.
"""
return super().get_queryset().filter(version_of__isnull=False)


class AllSegmentsManager(SoftDeleteExportableManager):
"""
Returns all segments (both canonical and revisions).
Only filters out soft-deleted segments.
"""
pass


class ConfiguredOrderManager(SoftDeleteExportableManager, models.Manager[ModelT]):
setting_name: str

Expand Down Expand Up @@ -87,8 +108,7 @@
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",
Expand All @@ -112,10 +132,11 @@
updated_at = models.DateTimeField(null=True, auto_now=True)
is_system_segment = models.BooleanField(default=False)

objects = SegmentManager() # type: ignore[misc]

# Only serves segments that are the canonical version.
live_objects = LiveSegmentManager()
# Manager declarations - order matters! First manager is the base_manager used in relations.
objects = LiveSegmentManager() # type: ignore[misc] # Default: canonical segments only
live_objects = objects # Explicit alias for clarity
revisions = RevisionsManager() # type: ignore[misc] # Only historical versions

Check failure on line 138 in api/segments/models.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Unused "type: ignore" comment

Check failure on line 138 in api/segments/models.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Unused "type: ignore" comment
all_objects = AllSegmentsManager() # type: ignore[misc] # Both canonical and revisions

Check failure on line 139 in api/segments/models.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Unused "type: ignore" comment

Check failure on line 139 in api/segments/models.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Unused "type: ignore" comment

class Meta:
ordering = ("id",) # explicit ordering to prevent pagination warnings
Expand All @@ -126,27 +147,8 @@
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

@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]
"""
This allows the segment model to reference all versions of
itself including itself.
"""
self.version_of = self
self.save_without_historical_record()
is_revision = self.version_of_id is not None
return is_revision

@transaction.atomic
def clone(self, is_revision: bool = False, **extra_attrs: typing.Any) -> "Segment":
Expand All @@ -165,7 +167,7 @@
cloned_segment.copy_rules_and_conditions_from(self)

# Handle versioning
version_of = self if is_revision else cloned_segment
version_of = self if is_revision else None
cloned_segment.version_of = extra_attrs.get("version_of", version_of)
cloned_segment.version = self.version if is_revision else 1
Segment.objects.filter(pk=cloned_segment.pk).update(
Expand Down
3 changes: 1 addition & 2 deletions api/segments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
"""
Expand Down
2 changes: 1 addition & 1 deletion api/tests/unit/segments/test_unit_segments_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def test_add_versioning_to_segments_forwards(migrator: Migrator) -> None:
# Then the version_of attribute is correctly set.
NewSegment = new_state.apps.get_model("segments", "Segment")
new_segment = NewSegment.objects.get(id=segment.id)
assert new_segment.version_of == new_segment
assert new_segment.version_of_id == new_segment.id


@pytest.mark.skipif(
Expand Down
Loading
Loading