Skip to content

Commit 835e9cb

Browse files
feat: support project level custom fields (#6736)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 3f6b061 commit 835e9cb

15 files changed

Lines changed: 1051 additions & 26 deletions

File tree

api/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,11 @@ def project(organisation): # type: ignore[no-untyped-def]
372372
return Project.objects.create(name="Test Project", organisation=organisation)
373373

374374

375+
@pytest.fixture()
376+
def project_b(organisation: Organisation) -> Project:
377+
return Project.objects.create(name="Test Project B", organisation=organisation) # type: ignore[no-any-return]
378+
379+
375380
@pytest.fixture()
376381
def segment(project: Project) -> Segment:
377382
segment: Segment = Segment.objects.create(name="segment", project=project)

api/environments/serializers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
8888
attrs = super().validate(attrs)
8989
project = self.instance.project if self.instance else attrs["project"] # type: ignore[union-attr]
9090
organisation = project.organisation
91-
self._validate_required_metadata(organisation, attrs.get("metadata", []))
91+
self._validate_required_metadata(
92+
organisation, attrs.get("metadata", []), project=project
93+
)
9294
return attrs
9395

9496
def create(self, validated_data: dict[str, Any]) -> Environment:

api/features/serializers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,9 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
382382
attrs = super().validate(attrs)
383383
project = self.instance.project if self.instance else self.context["project"] # type: ignore[union-attr]
384384
organisation = project.organisation
385-
self._validate_required_metadata(organisation, attrs.get("metadata", []))
385+
self._validate_required_metadata(
386+
organisation, attrs.get("metadata", []), project=project
387+
)
386388
return attrs
387389

388390
def create(self, validated_data: dict[str, Any]) -> Feature:
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Generated by Django 5.2.11 on 2026-02-16 10:22
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("metadata", "0001_initial"),
11+
("organisations", "0058_update_audit_and_history_limits_in_sub_cache"),
12+
("projects", "0027_add_create_project_level_change_requests_permission"),
13+
]
14+
15+
operations = [
16+
migrations.AlterUniqueTogether(
17+
name="metadatafield",
18+
unique_together=set(),
19+
),
20+
migrations.AddField(
21+
model_name="metadatafield",
22+
name="project",
23+
field=models.ForeignKey(
24+
blank=True,
25+
null=True,
26+
on_delete=django.db.models.deletion.CASCADE,
27+
to="projects.project",
28+
),
29+
),
30+
migrations.AddConstraint(
31+
model_name="metadatafield",
32+
constraint=models.UniqueConstraint(
33+
condition=models.Q(("project__isnull", True)),
34+
fields=("name", "organisation"),
35+
name="unique_org_level_metadata_field",
36+
),
37+
),
38+
migrations.AddConstraint(
39+
model_name="metadatafield",
40+
constraint=models.UniqueConstraint(
41+
condition=models.Q(("project__isnull", False)),
42+
fields=("name", "organisation", "project"),
43+
name="unique_project_level_metadata_field",
44+
),
45+
),
46+
]

api/metadata/models.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ class MetadataField(AbstractBaseExportableModel):
3636
)
3737
description = models.TextField(blank=True, null=True)
3838
organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE)
39+
project = models.ForeignKey(
40+
"projects.Project", on_delete=models.CASCADE, null=True, blank=True
41+
)
3942

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

7073
class Meta:
71-
unique_together = ("name", "organisation")
74+
constraints = [
75+
models.UniqueConstraint(
76+
fields=["name", "organisation"],
77+
condition=models.Q(project__isnull=True),
78+
name="unique_org_level_metadata_field",
79+
),
80+
models.UniqueConstraint(
81+
fields=["name", "organisation", "project"],
82+
condition=models.Q(project__isnull=False),
83+
name="unique_project_level_metadata_field",
84+
),
85+
]
7286

7387

7488
class MetadataModelField(AbstractBaseExportableModel):

api/metadata/permissions.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from metadata.models import MetadataField
66
from organisations.models import Organisation
7+
from projects.models import Project
78

89

910
class MetadataFieldPermissions(IsAuthenticated):
@@ -19,7 +20,16 @@ def has_permission(self, request, view): # type: ignore[no-untyped-def]
1920
with suppress(Organisation.DoesNotExist):
2021
organisation_id = request.data.get("organisation")
2122
organisation = Organisation.objects.get(id=organisation_id)
22-
return request.user.is_organisation_admin(organisation)
23+
24+
if request.user.is_organisation_admin(organisation):
25+
return True
26+
27+
project_id = request.data.get("project")
28+
if project_id is not None:
29+
with suppress(Project.DoesNotExist):
30+
project = Project.objects.get(id=project_id)
31+
if project.organisation_id == organisation.id:
32+
return request.user.is_project_admin(project)
2333

2434
return False
2535

@@ -32,7 +42,11 @@ def has_object_permission(self, request, view, obj): # type: ignore[no-untyped-
3242
"destroy",
3343
"partial_update",
3444
):
35-
return request.user.is_organisation_admin(obj.organisation)
45+
if request.user.is_organisation_admin(obj.organisation):
46+
return True
47+
48+
if obj.project is not None:
49+
return request.user.is_project_admin(obj.project)
3650

3751
return False
3852

api/metadata/serializers.py

Lines changed: 115 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django.contrib.contenttypes.models import ContentType
44
from django.core.exceptions import ObjectDoesNotExist
55
from django.db import transaction
6-
from django.db.models import Model
6+
from django.db.models import Model, Q
77
from rest_framework import serializers
88

99
from metadata.models import (
@@ -13,6 +13,7 @@
1313
MetadataModelFieldRequirement,
1414
)
1515
from organisations.models import Organisation
16+
from projects.models import Project
1617
from util.drf_writable_nested.serializers import (
1718
DeleteBeforeUpdateWritableNestedModelSerializer,
1819
)
@@ -24,14 +25,22 @@ class MetadataFieldQuerySerializer(serializers.Serializer): # type: ignore[type
2425
)
2526

2627

27-
class SupportedRequiredForModelQuerySerializer(serializers.Serializer): # type: ignore[type-arg]
28-
model_name = serializers.CharField(required=True)
28+
class ProjectMetadataFieldQuerySerializer(serializers.Serializer): # type: ignore[type-arg]
29+
include_organisation = serializers.BooleanField(
30+
required=False,
31+
default=False,
32+
help_text="Include inherited organisation-level fields. "
33+
"Project-level fields override same-named org fields.",
34+
)
35+
entity = serializers.ChoiceField(
36+
required=False,
37+
choices=["feature", "segment", "environment"],
38+
help_text="Filter by entity type (feature, segment, or environment).",
39+
)
2940

3041

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

3645

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

4857

58+
class MetadataModelFieldNestedSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
59+
is_required_for = MetadataModelFieldRequirementSerializer(many=True, read_only=True)
60+
61+
class Meta:
62+
model = MetadataModelField
63+
fields = ("id", "content_type", "is_required_for")
64+
65+
66+
class MetadataFieldSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
67+
project = serializers.IntegerField(
68+
required=False, allow_null=True, default=None, source="project_id"
69+
)
70+
model_fields = MetadataModelFieldNestedSerializer(
71+
source="metadatamodelfield_set", many=True, read_only=True
72+
)
73+
74+
class Meta:
75+
model = MetadataField
76+
fields = (
77+
"id",
78+
"name",
79+
"type",
80+
"description",
81+
"organisation",
82+
"project",
83+
"model_fields",
84+
)
85+
# Disable auto-generated unique validators — DRF can't generate
86+
# them for conditional UniqueConstraints. Uniqueness is validated
87+
# manually in validate() below.
88+
validators: list[object] = []
89+
90+
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
91+
data = super().validate(data)
92+
project_id = data.get("project_id")
93+
organisation = data.get("organisation")
94+
95+
if (
96+
project_id is not None
97+
and not Project.objects.filter(
98+
id=project_id, organisation=organisation
99+
).exists()
100+
):
101+
raise serializers.ValidationError(
102+
{"project": "Project must belong to the specified organisation."}
103+
)
104+
105+
# Replicate uniqueness checks that DRF can't auto-generate
106+
# from conditional UniqueConstraints.
107+
qs = MetadataField.objects.filter(
108+
name=data.get("name"),
109+
organisation=organisation,
110+
project_id=project_id,
111+
)
112+
if self.instance is not None:
113+
assert isinstance(self.instance, MetadataField)
114+
qs = qs.exclude(pk=self.instance.pk)
115+
if qs.exists():
116+
raise serializers.ValidationError(
117+
{"name": "A metadata field with this name already exists."}
118+
)
119+
120+
return data
121+
122+
49123
class MetaDataModelFieldSerializer(DeleteBeforeUpdateWritableNestedModelSerializer):
50124
is_required_for = MetadataModelFieldRequirementSerializer(many=True, required=False)
51125

@@ -109,20 +183,50 @@ class MetadataSerializerMixin:
109183
"""
110184

111185
def _validate_required_metadata(
112-
self, organisation: Organisation, metadata: list[dict[str, Any]]
186+
self,
187+
organisation: Organisation,
188+
metadata: list[dict[str, Any]],
189+
project: Project | None = None,
113190
) -> None:
114191
content_type = ContentType.objects.get_for_model(self.Meta.model) # type: ignore[attr-defined]
115-
requirements = MetadataModelFieldRequirement.objects.filter(
192+
org_ct = ContentType.objects.get_for_model(Organisation)
193+
194+
# Field scoping: org-level fields + this project's fields
195+
field_scope = Q(
116196
model_field__content_type=content_type,
117197
model_field__field__organisation=organisation,
198+
model_field__field__project__isnull=True,
199+
)
200+
# Requirement scoping: org-level + this project's requirements
201+
req_scope = Q(content_type=org_ct, object_id=organisation.id)
202+
203+
overridden_names: set[str] = set()
204+
if project is not None:
205+
field_scope |= Q(
206+
model_field__content_type=content_type,
207+
model_field__field__organisation=organisation,
208+
model_field__field__project=project,
209+
)
210+
project_ct = ContentType.objects.get_for_model(Project)
211+
req_scope |= Q(content_type=project_ct, object_id=project.id)
212+
overridden_names = set(
213+
MetadataField.objects.filter(
214+
organisation=organisation, project=project
215+
).values_list("name", flat=True)
216+
)
217+
218+
requirements = MetadataModelFieldRequirement.objects.filter(
219+
field_scope & req_scope,
118220
).select_related("model_field__field")
119221

120222
metadata_fields = {field["model_field"] for field in metadata}
121223
for requirement in requirements:
224+
field = requirement.model_field.field
225+
if field.project is None and field.name in overridden_names:
226+
continue
122227
if requirement.model_field not in metadata_fields:
123-
field_name = requirement.model_field.field.name
124228
raise serializers.ValidationError(
125-
{"metadata": f"Missing required metadata field: {field_name}"}
229+
{"metadata": f"Missing required metadata field: {field.name}"}
126230
)
127231

128232
def _update_metadata(

0 commit comments

Comments
 (0)