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
5 changes: 5 additions & 0 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,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 @@ -359,7 +359,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",
),
),
Comment on lines +30 to +45
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is compatible with Oracle. Is it a requirement ?

]
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
125 changes: 114 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,70 @@ 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 — conditional
# UniqueConstraints are enforced at the database level.
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 +182,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