Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5ac05e2
feat: calculate-has-metadata-required-based-on-all-entities
Zaimwa9 Feb 4, 2026
e895451
fix: calculate-has-metadata-required-based-on-all-entities
Zaimwa9 Feb 4, 2026
b81f1d6
fix: calculate-has-metadata-required-based-on-all-entities
Zaimwa9 Feb 4, 2026
1305509
feat: refactor-metadata-with-hooks
Zaimwa9 Feb 5, 2026
9f65d7c
feat: consolidated-requests-and-merged-strategy-in-rtk
Zaimwa9 Feb 5, 2026
242cc97
Merge branch 'main' of github.com:Flagsmith/flagsmith into fix/requir…
Zaimwa9 Feb 16, 2026
6f8c33f
feat: resolved-review-comments
Zaimwa9 Feb 16, 2026
79771ed
feat: added-project-support-for-custom-fields
Zaimwa9 Feb 16, 2026
2c39ce8
feat: reworked-comments
Zaimwa9 Feb 17, 2026
7057609
feat: project-custom-fields-override-org-ones
Zaimwa9 Feb 17, 2026
2c6cf8b
fix: skip-feature-metadata-fetch-when-creating-feature
Zaimwa9 Feb 17, 2026
e2dc65a
Merge branch 'fix/required-metadata-validation' of github.com:Flagsmi…
Zaimwa9 Feb 17, 2026
463d388
fix: fixed-parsing-error
Zaimwa9 Feb 17, 2026
0503ad3
feat: invalidate-tags-on-delete-metadata
Zaimwa9 Feb 17, 2026
aad191d
Merge branch 'fix/required-metadata-validation' of github.com:Flagsmi…
Zaimwa9 Feb 17, 2026
cd7b03c
feat: reviewed-query-params-and-filtering
Zaimwa9 Feb 17, 2026
4fea476
feat: fetch-metadata-model-in-api-and-separate-custom-fields-project-…
Zaimwa9 Feb 18, 2026
790ff64
feat: added-pagination-and-filtering-by-entity-to-custom-fields
Zaimwa9 Feb 19, 2026
81f4323
Merge branch 'main' of github.com:Flagsmith/flagsmith into fix/requir…
Zaimwa9 Feb 19, 2026
3cc20aa
feat: fixed-use-effect-on-entity-id-switch
Zaimwa9 Feb 19, 2026
6d3bb18
Merge branch 'fix/required-metadata-validation' of github.com:Flagsmi…
Zaimwa9 Feb 19, 2026
ce1522a
feat: fixed-edit-delete-project-permissions-for-custom-fields
Zaimwa9 Feb 19, 2026
733ece1
feat: refactored-validated-required-metadata
Zaimwa9 Feb 19, 2026
0161047
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 19, 2026
986df6a
feat: removed-unused-variable
Zaimwa9 Feb 19, 2026
936dee9
Merge branch 'feat/support-project-level-custom-fields' of github.com…
Zaimwa9 Feb 19, 2026
40c5620
feat: added-pagination-to-org-metadata-fields
Zaimwa9 Feb 19, 2026
59b9278
feat: reviewed-misleading-comment
Zaimwa9 Feb 24, 2026
28cc97d
feat: rebased-main
Zaimwa9 Feb 25, 2026
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
5 changes: 5 additions & 0 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,11 @@ def project(organisation): # type: ignore[no-untyped-def]
return Project.objects.create(name="Test Project", organisation=organisation)


@pytest.fixture()
def project_b(organisation: Organisation) -> Project:
return Project.objects.create(name="Test Project B", organisation=organisation) # type: ignore[no-any-return]


@pytest.fixture()
def segment(project: Project) -> Segment:
segment: Segment = Segment.objects.create(name="segment", project=project)
Expand Down
4 changes: 3 additions & 1 deletion api/environments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
attrs = super().validate(attrs)
project = self.instance.project if self.instance else attrs["project"] # type: ignore[union-attr]
organisation = project.organisation
self._validate_required_metadata(organisation, attrs.get("metadata", []))
self._validate_required_metadata(
organisation, attrs.get("metadata", []), project=project
)
return attrs

def create(self, validated_data: dict[str, Any]) -> Environment:
Expand Down
4 changes: 3 additions & 1 deletion api/features/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,9 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
attrs = super().validate(attrs)
project = self.instance.project if self.instance else self.context["project"] # type: ignore[union-attr]
organisation = project.organisation
self._validate_required_metadata(organisation, attrs.get("metadata", []))
self._validate_required_metadata(
organisation, attrs.get("metadata", []), project=project
)
return attrs

def create(self, validated_data: dict[str, Any]) -> Feature:
Expand Down
46 changes: 46 additions & 0 deletions api/metadata/migrations/0002_add_project_to_metadata_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Generated by Django 5.2.11 on 2026-02-16 10:22

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("metadata", "0001_initial"),
("organisations", "0058_update_audit_and_history_limits_in_sub_cache"),
("projects", "0027_add_create_project_level_change_requests_permission"),
]

operations = [
migrations.AlterUniqueTogether(
name="metadatafield",
unique_together=set(),
),
migrations.AddField(
model_name="metadatafield",
name="project",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="projects.project",
),
),
migrations.AddConstraint(
model_name="metadatafield",
constraint=models.UniqueConstraint(
condition=models.Q(("project__isnull", True)),
fields=("name", "organisation"),
name="unique_org_level_metadata_field",
),
),
migrations.AddConstraint(
model_name="metadatafield",
constraint=models.UniqueConstraint(
condition=models.Q(("project__isnull", False)),
fields=("name", "organisation", "project"),
name="unique_project_level_metadata_field",
),
),
]
16 changes: 15 additions & 1 deletion api/metadata/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ class MetadataField(AbstractBaseExportableModel):
)
description = models.TextField(blank=True, null=True)
organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE)
project = models.ForeignKey(
"projects.Project", on_delete=models.CASCADE, null=True, blank=True
)

def is_field_value_valid(self, field_value: str) -> bool:
if len(field_value) > FIELD_VALUE_MAX_LENGTH:
Expand Down Expand Up @@ -68,7 +71,18 @@ def validate_multiline_str(self, field_value: str): # type: ignore[no-untyped-d
return True

class Meta:
unique_together = ("name", "organisation")
constraints = [
models.UniqueConstraint(
fields=["name", "organisation"],
condition=models.Q(project__isnull=True),
name="unique_org_level_metadata_field",
),
models.UniqueConstraint(
fields=["name", "organisation", "project"],
condition=models.Q(project__isnull=False),
name="unique_project_level_metadata_field",
),
]


class MetadataModelField(AbstractBaseExportableModel):
Expand Down
18 changes: 16 additions & 2 deletions api/metadata/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from metadata.models import MetadataField
from organisations.models import Organisation
from projects.models import Project


class MetadataFieldPermissions(IsAuthenticated):
Expand All @@ -19,7 +20,16 @@ def has_permission(self, request, view): # type: ignore[no-untyped-def]
with suppress(Organisation.DoesNotExist):
organisation_id = request.data.get("organisation")
organisation = Organisation.objects.get(id=organisation_id)
return request.user.is_organisation_admin(organisation)

if request.user.is_organisation_admin(organisation):
return True

project_id = request.data.get("project")
if project_id is not None:
with suppress(Project.DoesNotExist):
project = Project.objects.get(id=project_id)
if project.organisation_id == organisation.id:
return request.user.is_project_admin(project)

return False

Expand All @@ -32,7 +42,11 @@ def has_object_permission(self, request, view, obj): # type: ignore[no-untyped-
"destroy",
"partial_update",
):
return request.user.is_organisation_admin(obj.organisation)
if request.user.is_organisation_admin(obj.organisation):
return True

if obj.project is not None:
return request.user.is_project_admin(obj.project)

return False

Expand Down
126 changes: 115 additions & 11 deletions api/metadata/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import Model
from django.db.models import Model, Q
from rest_framework import serializers

from metadata.models import (
Expand All @@ -13,6 +13,7 @@
MetadataModelFieldRequirement,
)
from organisations.models import Organisation
from projects.models import Project
from util.drf_writable_nested.serializers import (
DeleteBeforeUpdateWritableNestedModelSerializer,
)
Expand All @@ -24,14 +25,22 @@ class MetadataFieldQuerySerializer(serializers.Serializer): # type: ignore[type
)


class SupportedRequiredForModelQuerySerializer(serializers.Serializer): # type: ignore[type-arg]
model_name = serializers.CharField(required=True)
class ProjectMetadataFieldQuerySerializer(serializers.Serializer): # type: ignore[type-arg]
include_organisation = serializers.BooleanField(
required=False,
default=False,
help_text="Include inherited organisation-level fields. "
"Project-level fields override same-named org fields.",
)
entity = serializers.ChoiceField(
required=False,
choices=["feature", "segment", "environment"],
help_text="Filter by entity type (feature, segment, or environment).",
)


class MetadataFieldSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
class Meta:
model = MetadataField
fields = ("id", "name", "type", "description", "organisation")
class SupportedRequiredForModelQuerySerializer(serializers.Serializer): # type: ignore[type-arg]
model_name = serializers.CharField(required=True)


class MetadataModelFieldQuerySerializer(serializers.Serializer): # type: ignore[type-arg]
Expand All @@ -46,6 +55,71 @@ class Meta:
fields = ("content_type", "object_id")


class MetadataModelFieldNestedSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
is_required_for = MetadataModelFieldRequirementSerializer(many=True, read_only=True)

class Meta:
model = MetadataModelField
fields = ("id", "content_type", "is_required_for")


class MetadataFieldSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
project = serializers.IntegerField(
required=False, allow_null=True, default=None, source="project_id"
)
model_fields = MetadataModelFieldNestedSerializer(
source="metadatamodelfield_set", many=True, read_only=True
)

class Meta:
model = MetadataField
fields = (
"id",
"name",
"type",
"description",
"organisation",
"project",
"model_fields",
)
# Disable auto-generated unique validators — DRF can't generate
# them for conditional UniqueConstraints. Uniqueness is validated
# manually in validate() below.
validators: list[object] = []

def validate(self, data: dict[str, Any]) -> dict[str, Any]:
data = super().validate(data)
project_id = data.get("project_id")
organisation = data.get("organisation")

if (
project_id is not None
and not Project.objects.filter(
id=project_id, organisation=organisation
).exists()
):
raise serializers.ValidationError(
{"project": "Project must belong to the specified organisation."}
)

# Replicate uniqueness checks that DRF can't auto-generate
# from conditional UniqueConstraints.
qs = MetadataField.objects.filter(
name=data.get("name"),
organisation=organisation,
project_id=project_id,
)
if self.instance is not None:
assert isinstance(self.instance, MetadataField)
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise serializers.ValidationError(
{"name": "A metadata field with this name already exists."}
)

return data


class MetaDataModelFieldSerializer(DeleteBeforeUpdateWritableNestedModelSerializer):
is_required_for = MetadataModelFieldRequirementSerializer(many=True, required=False)

Expand Down Expand Up @@ -109,20 +183,50 @@ class MetadataSerializerMixin:
"""

def _validate_required_metadata(
self, organisation: Organisation, metadata: list[dict[str, Any]]
self,
organisation: Organisation,
metadata: list[dict[str, Any]],
project: Project | None = None,
) -> None:
content_type = ContentType.objects.get_for_model(self.Meta.model) # type: ignore[attr-defined]
requirements = MetadataModelFieldRequirement.objects.filter(
org_ct = ContentType.objects.get_for_model(Organisation)

# Field scoping: org-level fields + this project's fields
field_scope = Q(
model_field__content_type=content_type,
model_field__field__organisation=organisation,
model_field__field__project__isnull=True,
)
# Requirement scoping: org-level + this project's requirements
req_scope = Q(content_type=org_ct, object_id=organisation.id)

overridden_names: set[str] = set()
if project is not None:
field_scope |= Q(
model_field__content_type=content_type,
model_field__field__organisation=organisation,
model_field__field__project=project,
)
project_ct = ContentType.objects.get_for_model(Project)
req_scope |= Q(content_type=project_ct, object_id=project.id)
overridden_names = set(
MetadataField.objects.filter(
organisation=organisation, project=project
).values_list("name", flat=True)
)

requirements = MetadataModelFieldRequirement.objects.filter(
field_scope & req_scope,
).select_related("model_field__field")

metadata_fields = {field["model_field"] for field in metadata}
for requirement in requirements:
field = requirement.model_field.field
if field.project is None and field.name in overridden_names:
continue
if requirement.model_field not in metadata_fields:
field_name = requirement.model_field.field.name
raise serializers.ValidationError(
{"metadata": f"Missing required metadata field: {field_name}"}
{"metadata": f"Missing required metadata field: {field.name}"}
)

def _update_metadata(
Expand Down
Loading
Loading