From 79771ed3788f49a075f8895b9eb396957dd305a1 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 16 Feb 2026 18:28:39 +0100 Subject: [PATCH 01/11] feat: added-project-support-for-custom-fields --- api/conftest.py | 5 + api/environments/serializers.py | 4 +- api/features/serializers.py | 4 +- .../0002_add_project_to_metadata_field.py | 46 ++++ api/metadata/models.py | 16 +- api/metadata/permissions.py | 12 +- api/metadata/serializers.py | 71 +++++- api/metadata/views.py | 7 + api/segments/serializers.py | 4 +- .../test_unit_environments_views.py | 36 ++- .../unit/features/test_unit_features_views.py | 38 ++- api/tests/unit/metadata/test_serializers.py | 123 +++++++++- api/tests/unit/metadata/test_views.py | 224 ++++++++++++++++++ .../unit/segments/test_unit_segments_views.py | 43 +++- 14 files changed, 620 insertions(+), 13 deletions(-) create mode 100644 api/metadata/migrations/0002_add_project_to_metadata_field.py diff --git a/api/conftest.py b/api/conftest.py index 13f72607cf2b..0d0b8efa9109 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -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) diff --git a/api/environments/serializers.py b/api/environments/serializers.py index 408f3c575c02..c6a3ae8724b8 100644 --- a/api/environments/serializers.py +++ b/api/environments/serializers.py @@ -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: diff --git a/api/features/serializers.py b/api/features/serializers.py index 134181afe3a0..2607560e7ac5 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -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: diff --git a/api/metadata/migrations/0002_add_project_to_metadata_field.py b/api/metadata/migrations/0002_add_project_to_metadata_field.py new file mode 100644 index 000000000000..d14651304655 --- /dev/null +++ b/api/metadata/migrations/0002_add_project_to_metadata_field.py @@ -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", + ), + ), + ] diff --git a/api/metadata/models.py b/api/metadata/models.py index 4e134731efae..9f69de8e70bb 100644 --- a/api/metadata/models.py +++ b/api/metadata/models.py @@ -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: @@ -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): diff --git a/api/metadata/permissions.py b/api/metadata/permissions.py index c287a8ab9165..194b567a1900 100644 --- a/api/metadata/permissions.py +++ b/api/metadata/permissions.py @@ -4,6 +4,7 @@ from metadata.models import MetadataField from organisations.models import Organisation +from projects.models import Project class MetadataFieldPermissions(IsAuthenticated): @@ -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 diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 8cfca2adb5b4..e89f43990060 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -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 ( @@ -13,6 +13,7 @@ MetadataModelFieldRequirement, ) from organisations.models import Organisation +from projects.models import Project from util.drf_writable_nested.serializers import ( DeleteBeforeUpdateWritableNestedModelSerializer, ) @@ -22,6 +23,9 @@ class MetadataFieldQuerySerializer(serializers.Serializer): # type: ignore[type organisation = serializers.IntegerField( required=True, help_text="Organisation ID to filter by" ) + project = serializers.IntegerField( + required=False, help_text="Project ID to filter by" + ) class SupportedRequiredForModelQuerySerializer(serializers.Serializer): # type: ignore[type-arg] @@ -29,9 +33,45 @@ class SupportedRequiredForModelQuerySerializer(serializers.Serializer): # type: class MetadataFieldSerializer(serializers.ModelSerializer): # type: ignore[type-arg] + project = serializers.IntegerField( + required=False, allow_null=True, default=None, source="project_id" + ) + class Meta: model = MetadataField - fields = ("id", "name", "type", "description", "organisation") + fields = ("id", "name", "type", "description", "organisation", "project") + # 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: + if 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: + 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 MetadataModelFieldQuerySerializer(serializers.Serializer): # type: ignore[type-arg] @@ -109,12 +149,35 @@ 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, + ) + if project is not None: + field_scope |= Q( + model_field__content_type=content_type, + model_field__field__organisation=organisation, + model_field__field__project=project, + ) + + # Requirement scoping: org-level + this project's requirements + req_scope = Q(content_type=org_ct, object_id=organisation.id) + if project is not None: + project_ct = ContentType.objects.get_for_model(Project) + req_scope |= Q(content_type=project_ct, object_id=project.id) + + requirements = MetadataModelFieldRequirement.objects.filter( + field_scope & req_scope, ).select_related("model_field__field") metadata_fields = {field["model_field"] for field in metadata} diff --git a/api/metadata/views.py b/api/metadata/views.py index 4600989f90f8..0ec72d39ef99 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -1,6 +1,7 @@ from itertools import chain from django.contrib.contenttypes.models import ContentType +from django.db.models import Q from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets @@ -49,6 +50,12 @@ def get_queryset(self): # type: ignore[no-untyped-def] raise ValidationError("organisation parameter is required") queryset = queryset.filter(organisation__id=organisation_id) + project_id = serializer.validated_data.get("project") + if project_id is not None: + queryset = queryset.filter( + Q(project__isnull=True) | Q(project_id=project_id) + ) + return queryset diff --git a/api/segments/serializers.py b/api/segments/serializers.py index 4cbc203894f4..2bf44a4778e1 100644 --- a/api/segments/serializers.py +++ b/api/segments/serializers.py @@ -118,7 +118,9 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: 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 + ) self._validate_segment_rules_conditions_limit(attrs["rules"]) self._validate_project_segment_limit(project) return attrs diff --git a/api/tests/unit/environments/test_unit_environments_views.py b/api/tests/unit/environments/test_unit_environments_views.py index 16537fa6efcf..5db43d2557d2 100644 --- a/api/tests/unit/environments/test_unit_environments_views.py +++ b/api/tests/unit/environments/test_unit_environments_views.py @@ -24,7 +24,12 @@ from environments.permissions.models import UserEnvironmentPermission from features.models import Feature, FeatureState from features.versioning.models import EnvironmentFeatureVersion -from metadata.models import Metadata, MetadataModelField +from metadata.models import ( + Metadata, + MetadataField, + MetadataModelField, + MetadataModelFieldRequirement, +) from organisations.models import Organisation from permissions.models import PermissionModel from projects.models import Project, UserProjectPermission @@ -1325,3 +1330,32 @@ def test_total_segment_overrides_correctly_ignores_old_versions( # Then assert response.json()["total_segment_overrides"] == 1 + + +def test_create_environment__required_metadata_on_other_project__returns_201( + admin_client: APIClient, + project: Project, + project_b: Project, + organisation: Organisation, + a_metadata_field: MetadataField, + environment_content_type: ContentType, + project_content_type: ContentType, +) -> None: + # Given - a required metadata field scoped to project_b + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=environment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project_b.id, + model_field=model_field, + ) + url = reverse("api-v1:environments:environment-list") + data = {"name": "New env", "project": project.id} + + # When - creating an environment in project (not project_b) + response = admin_client.post(url, data=data) + + # Then - should succeed because the requirement is on project_b, not project + assert response.status_code == status.HTTP_201_CREATED diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 05abbecf7562..574b16ff8f15 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -16,6 +16,7 @@ VIEW_PROJECT, ) from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.forms import model_to_dict from django.urls import reverse from django.utils import timezone @@ -49,7 +50,11 @@ from features.multivariate.models import MultivariateFeatureOption from features.value_types import STRING from features.versioning.models import EnvironmentFeatureVersion -from metadata.models import MetadataModelField +from metadata.models import ( + MetadataField, + MetadataModelField, + MetadataModelFieldRequirement, +) from organisations.models import Organisation, OrganisationRole from permissions.models import PermissionModel from projects.code_references.models import FeatureFlagCodeReferencesScan @@ -4240,3 +4245,34 @@ def test_create_multiple_features_with_metadata_keeps_metadata_isolated( second_feature_metadata_after = second_feature_check.json()["metadata"] assert len(second_feature_metadata_after) == 1 assert second_feature_metadata_after[0]["field_value"] == "200" + + +def test_create_feature__required_metadata_on_other_project__returns_201( + admin_client: APIClient, + project: Project, + project_b: Project, + organisation: Organisation, + a_metadata_field: MetadataField, + feature_content_type: ContentType, + project_content_type: ContentType, +) -> None: + # Given - a required metadata field scoped to project_b + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=feature_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project_b.id, + model_field=model_field, + ) + url = reverse("api-v1:projects:project-features-list", args=[project.id]) + data = {"name": "Test feature cross project", "description": "desc"} + + # When - creating a feature in project (not project_b) + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then - should succeed because the requirement is on project_b, not project + assert response.status_code == status.HTTP_201_CREATED diff --git a/api/tests/unit/metadata/test_serializers.py b/api/tests/unit/metadata/test_serializers.py index 8e2e5059d24c..4db0bc36c160 100644 --- a/api/tests/unit/metadata/test_serializers.py +++ b/api/tests/unit/metadata/test_serializers.py @@ -2,15 +2,22 @@ import pytest from django.contrib.contenttypes.models import ContentType +from rest_framework import serializers from metadata.models import ( FIELD_VALUE_MAX_LENGTH, MetadataField, MetadataModelField, + MetadataModelFieldRequirement, +) +from metadata.serializers import ( + MetaDataModelFieldSerializer, + MetadataSerializer, + MetadataSerializerMixin, ) -from metadata.serializers import MetaDataModelFieldSerializer, MetadataSerializer from organisations.models import Organisation from projects.models import Project +from segments.models import Segment @pytest.mark.parametrize( @@ -168,3 +175,117 @@ def test_metadata_model_field_serializer_validation_invalid_content_type( serializer.errors["non_field_errors"][0] == "The requirement content type must be project or organisation" ) + + +class _DummySegmentSerializer(MetadataSerializerMixin, serializers.Serializer): # type: ignore[type-arg] + """A minimal serializer for testing _validate_required_metadata with Segment model.""" + + class Meta: + model = Segment + + +def test_validate_required_metadata__requirement_on_project_a__not_enforced_in_project_b( + organisation: Organisation, + project: Project, + project_b: Project, + a_metadata_field: MetadataField, + segment_content_type: ContentType, + project_content_type: ContentType, +) -> None: + # Given - a required metadata field scoped to project A + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=segment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project.id, + model_field=model_field, + ) + serializer = _DummySegmentSerializer() + + # When / Then - no error when validating for project B (no metadata provided) + serializer._validate_required_metadata(organisation, [], project=project_b) + + +def test_validate_required_metadata__requirement_on_project_a__enforced_in_project_a( + organisation: Organisation, + project: Project, + a_metadata_field: MetadataField, + segment_content_type: ContentType, + project_content_type: ContentType, +) -> None: + # Given - a required metadata field scoped to project A + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=segment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project.id, + model_field=model_field, + ) + serializer = _DummySegmentSerializer() + + # When / Then - error when validating for project A without required metadata + with pytest.raises(serializers.ValidationError, match="Missing required metadata"): + serializer._validate_required_metadata(organisation, [], project=project) + + +def test_validate_required_metadata__org_level_requirement__enforced_in_all_projects( + organisation: Organisation, + project: Project, + project_b: Project, + a_metadata_field: MetadataField, + segment_content_type: ContentType, + organisation_content_type: ContentType, +) -> None: + # Given - an org-level required metadata field + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=segment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=organisation_content_type, + object_id=organisation.id, + model_field=model_field, + ) + serializer = _DummySegmentSerializer() + + # When / Then - error in project A + with pytest.raises(serializers.ValidationError, match="Missing required metadata"): + serializer._validate_required_metadata(organisation, [], project=project) + + # When / Then - also error in project B + with pytest.raises(serializers.ValidationError, match="Missing required metadata"): + serializer._validate_required_metadata(organisation, [], project=project_b) + + +def test_validate_required_metadata__project_level_field_with_requirement__only_enforced_in_its_project( + organisation: Organisation, + project: Project, + project_b: Project, + segment_content_type: ContentType, + project_content_type: ContentType, +) -> None: + # Given - a project-scoped metadata field required for project A + project_field = MetadataField.objects.create( + name="proj_field", type="str", organisation=organisation, project=project + ) + model_field = MetadataModelField.objects.create( + field=project_field, + content_type=segment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project.id, + model_field=model_field, + ) + serializer = _DummySegmentSerializer() + + # When / Then - enforced in project A + with pytest.raises(serializers.ValidationError, match="Missing required metadata"): + serializer._validate_required_metadata(organisation, [], project=project) + + # When / Then - NOT enforced in project B + serializer._validate_required_metadata(organisation, [], project=project_b) diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index b9fde12eb8e3..a2fcf227880a 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -1,6 +1,7 @@ import json from itertools import chain +import pytest from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status @@ -14,6 +15,7 @@ from metadata.views import SUPPORTED_REQUIREMENTS_MAPPING # type: ignore[attr-defined] from organisations.models import Organisation from projects.models import Project +from users.models import FFAdminUser def test_can_create_metadata_field(admin_client, organisation): # type: ignore[no-untyped-def] @@ -439,3 +441,225 @@ def test_get_supported_required_for_models(admin_client, organisation): # type: assert len(response.json()) == 2 assert response.json()[0]["model"] == "organisation" assert response.json()[1]["model"] == "project" + + +def test_create_metadata_field__with_project__returns_201( + admin_client: APIClient, + organisation: Organisation, + project: Project, +) -> None: + # Given + url = reverse("api-v1:metadata:metadata-fields-list") + data = { + "name": "project_field", + "type": "str", + "organisation": organisation.id, + "project": project.id, + } + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["project"] == project.id + assert response.json()["organisation"] == organisation.id + + +def test_create_metadata_field__project_from_different_org__returns_400( + admin_client: APIClient, + organisation: Organisation, + project: Project, +) -> None: + # Given + other_org = Organisation.objects.create(name="Other Org") + other_project = Project.objects.create(name="Other Project", organisation=other_org) + url = reverse("api-v1:metadata:metadata-fields-list") + data = { + "name": "bad_field", + "type": "str", + "organisation": organisation.id, + "project": other_project.id, + } + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_create_metadata_field__project_admin__returns_201( + staff_user: FFAdminUser, + staff_client: APIClient, + organisation: Organisation, + project: Project, +) -> None: + # Given + from projects.models import UserProjectPermission + + UserProjectPermission.objects.create(user=staff_user, project=project, admin=True) + + url = reverse("api-v1:metadata:metadata-fields-list") + data = { + "name": "project_admin_field", + "type": "str", + "organisation": organisation.id, + "project": project.id, + } + + # When + response = staff_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + + +def test_list_metadata_fields__filter_by_project__returns_org_and_project_fields( + admin_client: APIClient, + organisation: Organisation, + project: Project, +) -> None: + # Given + org_field = MetadataField.objects.create( + name="org_field", type="str", organisation=organisation + ) + project_field = MetadataField.objects.create( + name="project_field", type="str", organisation=organisation, project=project + ) + base_url = reverse("api-v1:metadata:metadata-fields-list") + url = f"{base_url}?organisation={organisation.id}&project={project.id}" + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert org_field.id in returned_ids + assert project_field.id in returned_ids + + +def test_list_metadata_fields__filter_by_project__excludes_other_project_fields( + admin_client: APIClient, + organisation: Organisation, + project: Project, + project_b: Project, +) -> None: + # Given + org_field = MetadataField.objects.create( + name="org_field", type="str", organisation=organisation + ) + project_a_field = MetadataField.objects.create( + name="proj_a_field", type="str", organisation=organisation, project=project + ) + project_b_field = MetadataField.objects.create( + name="proj_b_field", type="str", organisation=organisation, project=project_b + ) + base_url = reverse("api-v1:metadata:metadata-fields-list") + url = f"{base_url}?organisation={organisation.id}&project={project.id}" + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert org_field.id in returned_ids + assert project_a_field.id in returned_ids + assert project_b_field.id not in returned_ids + + +def test_list_metadata_fields__no_project_filter__returns_all( + admin_client: APIClient, + organisation: Organisation, + project: Project, + project_b: Project, +) -> None: + # Given + org_field = MetadataField.objects.create( + name="org_field", type="str", organisation=organisation + ) + project_a_field = MetadataField.objects.create( + name="proj_a_field", type="str", organisation=organisation, project=project + ) + project_b_field = MetadataField.objects.create( + name="proj_b_field", type="str", organisation=organisation, project=project_b + ) + base_url = reverse("api-v1:metadata:metadata-fields-list") + url = f"{base_url}?organisation={organisation.id}" + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert org_field.id in returned_ids + assert project_a_field.id in returned_ids + assert project_b_field.id in returned_ids + + +@pytest.mark.parametrize( + "existing_project_attr, new_project_attr, expected_status", + [ + (None, None, status.HTTP_400_BAD_REQUEST), + ("project", "project", status.HTTP_400_BAD_REQUEST), + ("project", "project_b", status.HTTP_201_CREATED), + (None, "project", status.HTTP_201_CREATED), + ], + ids=[ + "duplicate_org_level", + "duplicate_project_level", + "same_name_different_projects", + "same_name_org_and_project_level", + ], +) +def test_create_metadata_field__uniqueness( + admin_client: APIClient, + organisation: Organisation, + project: Project, + project_b: Project, + existing_project_attr: str | None, + new_project_attr: str | None, + expected_status: int, + request: pytest.FixtureRequest, +) -> None: + # Given - a field already exists + existing_project = ( + request.getfixturevalue(existing_project_attr) + if existing_project_attr + else None + ) + MetadataField.objects.create( + name="the_field", + type="str", + organisation=organisation, + project=existing_project, + ) + + new_project = ( + request.getfixturevalue(new_project_attr) if new_project_attr else None + ) + url = reverse("api-v1:metadata:metadata-fields-list") + data: dict[str, object] = { + "name": "the_field", + "type": "str", + "organisation": organisation.id, + } + if new_project is not None: + data["project"] = new_project.id + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == expected_status diff --git a/api/tests/unit/segments/test_unit_segments_views.py b/api/tests/unit/segments/test_unit_segments_views.py index 57cc212884ed..7add85bfbf49 100644 --- a/api/tests/unit/segments/test_unit_segments_views.py +++ b/api/tests/unit/segments/test_unit_segments_views.py @@ -24,7 +24,13 @@ from environments.models import Environment from features.models import Feature, FeatureSegment, FeatureState from features.versioning.models import EnvironmentFeatureVersion -from metadata.models import Metadata, MetadataModelField +from metadata.models import ( + Metadata, + MetadataField, + MetadataModelField, + MetadataModelFieldRequirement, +) +from organisations.models import Organisation from projects.models import Project from segments.models import Condition, Segment, SegmentRule, WhitelistedSegment from tests.types import WithProjectPermissionsCallable @@ -1842,3 +1848,38 @@ def test_clone_segment_without_name_should_fail( # Then assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_create_segment__required_metadata_on_other_project__returns_201( + admin_client: APIClient, + project: Project, + project_b: Project, + organisation: Organisation, + a_metadata_field: MetadataField, + segment_content_type: ContentType, + project_content_type: ContentType, +) -> None: + # Given - a required metadata field scoped to project_b + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=segment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project_b.id, + model_field=model_field, + ) + url = reverse("api-v1:projects:project-segments-list", args=[project.id]) + data = { + "name": "Test segment cross project", + "project": project.id, + "rules": [{"type": "ALL", "rules": [], "conditions": []}], + } + + # When - creating a segment in project (not project_b) + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then - should succeed because the requirement is on project_b, not project + assert response.status_code == status.HTTP_201_CREATED From 2c39ce8df319cfeb32354cfb7c033f5e140b500a Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 17 Feb 2026 09:55:17 +0100 Subject: [PATCH 02/11] feat: reworked-comments --- .../test_unit_environments_views.py | 6 +++--- .../unit/features/test_unit_features_views.py | 6 +++--- api/tests/unit/metadata/test_serializers.py | 18 ++++++++---------- api/tests/unit/metadata/test_views.py | 2 +- .../unit/segments/test_unit_segments_views.py | 6 +++--- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/api/tests/unit/environments/test_unit_environments_views.py b/api/tests/unit/environments/test_unit_environments_views.py index 5db43d2557d2..88e5efd86b33 100644 --- a/api/tests/unit/environments/test_unit_environments_views.py +++ b/api/tests/unit/environments/test_unit_environments_views.py @@ -1341,7 +1341,7 @@ def test_create_environment__required_metadata_on_other_project__returns_201( environment_content_type: ContentType, project_content_type: ContentType, ) -> None: - # Given - a required metadata field scoped to project_b + # Given model_field = MetadataModelField.objects.create( field=a_metadata_field, content_type=environment_content_type, @@ -1354,8 +1354,8 @@ def test_create_environment__required_metadata_on_other_project__returns_201( url = reverse("api-v1:environments:environment-list") data = {"name": "New env", "project": project.id} - # When - creating an environment in project (not project_b) + # When response = admin_client.post(url, data=data) - # Then - should succeed because the requirement is on project_b, not project + # Then assert response.status_code == status.HTTP_201_CREATED diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 574b16ff8f15..7097dcab33a0 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -4256,7 +4256,7 @@ def test_create_feature__required_metadata_on_other_project__returns_201( feature_content_type: ContentType, project_content_type: ContentType, ) -> None: - # Given - a required metadata field scoped to project_b + # Given model_field = MetadataModelField.objects.create( field=a_metadata_field, content_type=feature_content_type, @@ -4269,10 +4269,10 @@ def test_create_feature__required_metadata_on_other_project__returns_201( url = reverse("api-v1:projects:project-features-list", args=[project.id]) data = {"name": "Test feature cross project", "description": "desc"} - # When - creating a feature in project (not project_b) + # When response = admin_client.post( url, data=json.dumps(data), content_type="application/json" ) - # Then - should succeed because the requirement is on project_b, not project + # Then assert response.status_code == status.HTTP_201_CREATED diff --git a/api/tests/unit/metadata/test_serializers.py b/api/tests/unit/metadata/test_serializers.py index 4db0bc36c160..dbe493038103 100644 --- a/api/tests/unit/metadata/test_serializers.py +++ b/api/tests/unit/metadata/test_serializers.py @@ -192,7 +192,7 @@ def test_validate_required_metadata__requirement_on_project_a__not_enforced_in_p segment_content_type: ContentType, project_content_type: ContentType, ) -> None: - # Given - a required metadata field scoped to project A + # Given model_field = MetadataModelField.objects.create( field=a_metadata_field, content_type=segment_content_type, @@ -204,7 +204,7 @@ def test_validate_required_metadata__requirement_on_project_a__not_enforced_in_p ) serializer = _DummySegmentSerializer() - # When / Then - no error when validating for project B (no metadata provided) + # When / Then serializer._validate_required_metadata(organisation, [], project=project_b) @@ -215,7 +215,7 @@ def test_validate_required_metadata__requirement_on_project_a__enforced_in_proje segment_content_type: ContentType, project_content_type: ContentType, ) -> None: - # Given - a required metadata field scoped to project A + # Given model_field = MetadataModelField.objects.create( field=a_metadata_field, content_type=segment_content_type, @@ -227,7 +227,7 @@ def test_validate_required_metadata__requirement_on_project_a__enforced_in_proje ) serializer = _DummySegmentSerializer() - # When / Then - error when validating for project A without required metadata + # When / Then with pytest.raises(serializers.ValidationError, match="Missing required metadata"): serializer._validate_required_metadata(organisation, [], project=project) @@ -240,7 +240,7 @@ def test_validate_required_metadata__org_level_requirement__enforced_in_all_proj segment_content_type: ContentType, organisation_content_type: ContentType, ) -> None: - # Given - an org-level required metadata field + # Given model_field = MetadataModelField.objects.create( field=a_metadata_field, content_type=segment_content_type, @@ -252,11 +252,10 @@ def test_validate_required_metadata__org_level_requirement__enforced_in_all_proj ) serializer = _DummySegmentSerializer() - # When / Then - error in project A + # When / Then with pytest.raises(serializers.ValidationError, match="Missing required metadata"): serializer._validate_required_metadata(organisation, [], project=project) - # When / Then - also error in project B with pytest.raises(serializers.ValidationError, match="Missing required metadata"): serializer._validate_required_metadata(organisation, [], project=project_b) @@ -268,7 +267,7 @@ def test_validate_required_metadata__project_level_field_with_requirement__only_ segment_content_type: ContentType, project_content_type: ContentType, ) -> None: - # Given - a project-scoped metadata field required for project A + # Given project_field = MetadataField.objects.create( name="proj_field", type="str", organisation=organisation, project=project ) @@ -283,9 +282,8 @@ def test_validate_required_metadata__project_level_field_with_requirement__only_ ) serializer = _DummySegmentSerializer() - # When / Then - enforced in project A + # When / Then with pytest.raises(serializers.ValidationError, match="Missing required metadata"): serializer._validate_required_metadata(organisation, [], project=project) - # When / Then - NOT enforced in project B serializer._validate_required_metadata(organisation, [], project=project_b) diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index a2fcf227880a..5b6cda242588 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -631,7 +631,7 @@ def test_create_metadata_field__uniqueness( expected_status: int, request: pytest.FixtureRequest, ) -> None: - # Given - a field already exists + # Given existing_project = ( request.getfixturevalue(existing_project_attr) if existing_project_attr diff --git a/api/tests/unit/segments/test_unit_segments_views.py b/api/tests/unit/segments/test_unit_segments_views.py index 7add85bfbf49..5dfd501f9406 100644 --- a/api/tests/unit/segments/test_unit_segments_views.py +++ b/api/tests/unit/segments/test_unit_segments_views.py @@ -1859,7 +1859,7 @@ def test_create_segment__required_metadata_on_other_project__returns_201( segment_content_type: ContentType, project_content_type: ContentType, ) -> None: - # Given - a required metadata field scoped to project_b + # Given model_field = MetadataModelField.objects.create( field=a_metadata_field, content_type=segment_content_type, @@ -1876,10 +1876,10 @@ def test_create_segment__required_metadata_on_other_project__returns_201( "rules": [{"type": "ALL", "rules": [], "conditions": []}], } - # When - creating a segment in project (not project_b) + # When response = admin_client.post( url, data=json.dumps(data), content_type="application/json" ) - # Then - should succeed because the requirement is on project_b, not project + # Then assert response.status_code == status.HTTP_201_CREATED From 705760906ba9274b5299d1f312067654cfc158af Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 17 Feb 2026 10:37:58 +0100 Subject: [PATCH 03/11] feat: project-custom-fields-override-org-ones --- api/metadata/serializers.py | 14 ++++++++-- api/metadata/views.py | 6 ++++- api/tests/unit/metadata/test_serializers.py | 30 +++++++++++++++++++++ api/tests/unit/metadata/test_views.py | 25 +++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index e89f43990060..221d46f8d2d5 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -180,12 +180,22 @@ def _validate_required_metadata( field_scope & req_scope, ).select_related("model_field__field") + overridden_names: set[str] = set() + if project is not None: + overridden_names = set( + MetadataField.objects.filter( + organisation=organisation, project=project + ).values_list("name", flat=True) + ) + 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( diff --git a/api/metadata/views.py b/api/metadata/views.py index 0ec72d39ef99..5cbd5dffa641 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -52,9 +52,13 @@ def get_queryset(self): # type: ignore[no-untyped-def] project_id = serializer.validated_data.get("project") if project_id is not None: + overridden_names = MetadataField.objects.filter( + organisation_id=organisation_id, + project_id=project_id, + ).values_list("name", flat=True) queryset = queryset.filter( Q(project__isnull=True) | Q(project_id=project_id) - ) + ).exclude(project__isnull=True, name__in=overridden_names) return queryset diff --git a/api/tests/unit/metadata/test_serializers.py b/api/tests/unit/metadata/test_serializers.py index dbe493038103..085fc9a2f13d 100644 --- a/api/tests/unit/metadata/test_serializers.py +++ b/api/tests/unit/metadata/test_serializers.py @@ -287,3 +287,33 @@ def test_validate_required_metadata__project_level_field_with_requirement__only_ serializer._validate_required_metadata(organisation, [], project=project) serializer._validate_required_metadata(organisation, [], project=project_b) + + +@pytest.mark.django_db +def test_validate_required_metadata__project_field_overrides_required_org_field__org_requirement_skipped( + organisation: Organisation, + project: Project, + a_metadata_field: MetadataField, + segment_content_type: ContentType, + organisation_content_type: ContentType, +) -> None: + # Given + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=segment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=organisation_content_type, + object_id=organisation.id, + model_field=model_field, + ) + MetadataField.objects.create( + name=a_metadata_field.name, + type=a_metadata_field.type, + organisation=organisation, + project=project, + ) + serializer = _DummySegmentSerializer() + + # When / Then + serializer._validate_required_metadata(organisation, [], project=project) diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index 5b6cda242588..f729c6f8c10a 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -606,6 +606,31 @@ def test_list_metadata_fields__no_project_filter__returns_all( assert project_b_field.id in returned_ids +def test_list_metadata_fields__project_field_overrides_org_field__org_field_excluded( + admin_client: APIClient, + organisation: Organisation, + project: Project, +) -> None: + # Given + org_field = MetadataField.objects.create( + name="shared_name", type="str", organisation=organisation + ) + project_field = MetadataField.objects.create( + name="shared_name", type="int", organisation=organisation, project=project + ) + base_url = reverse("api-v1:metadata:metadata-fields-list") + url = f"{base_url}?organisation={organisation.id}&project={project.id}" + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert project_field.id in returned_ids + assert org_field.id not in returned_ids + + @pytest.mark.parametrize( "existing_project_attr, new_project_attr, expected_status", [ From cd7b03c36c508c633c3ef6a84211fc34aaba948a Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 17 Feb 2026 18:42:29 +0100 Subject: [PATCH 04/11] feat: reviewed-query-params-and-filtering --- api/metadata/serializers.py | 8 ++++++- api/metadata/views.py | 2 ++ api/tests/unit/metadata/test_views.py | 32 ++++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 221d46f8d2d5..4dd7db454157 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -24,7 +24,13 @@ class MetadataFieldQuerySerializer(serializers.Serializer): # type: ignore[type required=True, help_text="Organisation ID to filter by" ) project = serializers.IntegerField( - required=False, help_text="Project ID to filter by" + required=False, + help_text="Project ID. Returns organisation-level fields plus this project's fields.", + ) + include_projects = serializers.BooleanField( + required=False, + default=False, + help_text="Include fields from all projects. Ignored when project is specified.", ) diff --git a/api/metadata/views.py b/api/metadata/views.py index 5cbd5dffa641..0001601c0f37 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -59,6 +59,8 @@ def get_queryset(self): # type: ignore[no-untyped-def] queryset = queryset.filter( Q(project__isnull=True) | Q(project_id=project_id) ).exclude(project__isnull=True, name__in=overridden_names) + elif not serializer.validated_data.get("include_projects"): + queryset = queryset.filter(project__isnull=True) return queryset diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index f729c6f8c10a..3ef99e712054 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -576,7 +576,7 @@ def test_list_metadata_fields__filter_by_project__excludes_other_project_fields( assert project_b_field.id not in returned_ids -def test_list_metadata_fields__no_project_filter__returns_all( +def test_list_metadata_fields__no_project_filter__returns_org_level_only( admin_client: APIClient, organisation: Organisation, project: Project, @@ -598,6 +598,36 @@ def test_list_metadata_fields__no_project_filter__returns_all( # When response = admin_client.get(url) + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert org_field.id in returned_ids + assert project_a_field.id not in returned_ids + assert project_b_field.id not in returned_ids + + +def test_list_metadata_fields__include_projects__returns_all( + admin_client: APIClient, + organisation: Organisation, + project: Project, + project_b: Project, +) -> None: + # Given + org_field = MetadataField.objects.create( + name="org_field", type="str", organisation=organisation + ) + project_a_field = MetadataField.objects.create( + name="proj_a_field", type="str", organisation=organisation, project=project + ) + project_b_field = MetadataField.objects.create( + name="proj_b_field", type="str", organisation=organisation, project=project_b + ) + base_url = reverse("api-v1:metadata:metadata-fields-list") + url = f"{base_url}?organisation={organisation.id}&include_projects=true" + + # When + response = admin_client.get(url) + # Then assert response.status_code == status.HTTP_200_OK returned_ids = {r["id"] for r in response.json()["results"]} From 4fea4767c3429f8f223f217f18771075b4ead859 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 18 Feb 2026 10:11:47 +0100 Subject: [PATCH 05/11] feat: fetch-metadata-model-in-api-and-separate-custom-fields-project-endpoint --- api/metadata/serializers.py | 57 ++++++++---- api/metadata/views.py | 65 ++++++++++---- api/projects/urls.py | 6 ++ api/tests/unit/metadata/test_views.py | 125 ++++++++++++++++---------- 4 files changed, 171 insertions(+), 82 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 4dd7db454157..20abe765620a 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -23,14 +23,14 @@ class MetadataFieldQuerySerializer(serializers.Serializer): # type: ignore[type organisation = serializers.IntegerField( required=True, help_text="Organisation ID to filter by" ) - project = serializers.IntegerField( - required=False, - help_text="Project ID. Returns organisation-level fields plus this project's fields.", - ) - include_projects = serializers.BooleanField( + + +class ProjectMetadataFieldQuerySerializer(serializers.Serializer): # type: ignore[type-arg] + include_organisation = serializers.BooleanField( required=False, default=False, - help_text="Include fields from all projects. Ignored when project is specified.", + help_text="Include inherited organisation-level fields. " + "Project-level fields override same-named org fields.", ) @@ -38,14 +38,45 @@ class SupportedRequiredForModelQuerySerializer(serializers.Serializer): # type: model_name = serializers.CharField(required=True) +class MetadataModelFieldQuerySerializer(serializers.Serializer): # type: ignore[type-arg] + content_type = serializers.IntegerField( + required=False, help_text="Content type of the model to filter by." + ) + + +class MetadataModelFieldRequirementSerializer(serializers.ModelSerializer): # type: ignore[type-arg] + class Meta: + model = MetadataModelFieldRequirement + 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") + 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] = [] @@ -80,18 +111,6 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: return data -class MetadataModelFieldQuerySerializer(serializers.Serializer): # type: ignore[type-arg] - content_type = serializers.IntegerField( - required=False, help_text="Content type of the model to filter by." - ) - - -class MetadataModelFieldRequirementSerializer(serializers.ModelSerializer): # type: ignore[type-arg] - class Meta: - model = MetadataModelFieldRequirement - fields = ("content_type", "object_id") - - class MetaDataModelFieldSerializer(DeleteBeforeUpdateWritableNestedModelSerializer): is_required_for = MetadataModelFieldRequirementSerializer(many=True, required=False) diff --git a/api/metadata/views.py b/api/metadata/views.py index 0001601c0f37..91d5ab713cf4 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -1,7 +1,7 @@ from itertools import chain from django.contrib.contenttypes.models import ContentType -from django.db.models import Q +from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets @@ -9,6 +9,9 @@ from rest_framework.exceptions import ValidationError from rest_framework.response import Response +from projects.models import Project +from projects.permissions import VIEW_PROJECT, NestedProjectPermissions + from .models import ( SUPPORTED_REQUIREMENTS_MAPPING, MetadataField, @@ -24,6 +27,7 @@ MetadataFieldSerializer, MetadataModelFieldQuerySerializer, MetaDataModelFieldSerializer, + ProjectMetadataFieldQuerySerializer, SupportedRequiredForModelQuerySerializer, ) @@ -46,23 +50,12 @@ def get_queryset(self): # type: ignore[no-untyped-def] serializer.is_valid(raise_exception=True) organisation_id = serializer.validated_data["organisation"] - if organisation_id is None: - raise ValidationError("organisation parameter is required") - queryset = queryset.filter(organisation__id=organisation_id) - - project_id = serializer.validated_data.get("project") - if project_id is not None: - overridden_names = MetadataField.objects.filter( - organisation_id=organisation_id, - project_id=project_id, - ).values_list("name", flat=True) - queryset = queryset.filter( - Q(project__isnull=True) | Q(project_id=project_id) - ).exclude(project__isnull=True, name__in=overridden_names) - elif not serializer.validated_data.get("include_projects"): - queryset = queryset.filter(project__isnull=True) + queryset = queryset.filter( + organisation_id=organisation_id, + project__isnull=True, + ) - return queryset + return queryset.prefetch_related("metadatamodelfield_set__is_required_for") class MetaDataModelFieldViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] @@ -126,3 +119,41 @@ def supported_required_for_models(self, request, organisation_pk=None): # type: serializer = ContentTypeSerializer(qs, many=True) # type: ignore[assignment] return Response(serializer.data) + + +@method_decorator( + name="list", + decorator=extend_schema(parameters=[ProjectMetadataFieldQuerySerializer]), +) +class ProjectMetadataFieldViewSet(viewsets.ReadOnlyModelViewSet): # type: ignore[type-arg] + serializer_class = MetadataFieldSerializer + permission_classes = [NestedProjectPermissions] + + def get_queryset(self): # type: ignore[no-untyped-def] + if getattr(self, "swagger_fake_view", False): + return MetadataField.objects.none() + + project = get_object_or_404( + self.request.user.get_permitted_projects(VIEW_PROJECT), + pk=self.kwargs["project_pk"], + ) + + queryset = MetadataField.objects.filter( + organisation=project.organisation, + project_id=project.id, + ) + + serializer = ProjectMetadataFieldQuerySerializer( + data=self.request.query_params + ) + serializer.is_valid(raise_exception=True) + + if serializer.validated_data.get("include_organisation"): + overridden_names = queryset.values_list("name", flat=True) + org_fields = MetadataField.objects.filter( + organisation=project.organisation, + project__isnull=True, + ).exclude(name__in=overridden_names) + queryset = queryset | org_fields + + return queryset.prefetch_related("metadatamodelfield_set__is_required_for") diff --git a/api/projects/urls.py b/api/projects/urls.py index 01f6e70ad3ed..e65b86ffb19f 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -22,6 +22,7 @@ from integrations.grafana.views import GrafanaProjectConfigurationViewSet from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet +from metadata.views import ProjectMetadataFieldViewSet from projects.tags.views import TagViewSet from segments.views import SegmentViewSet @@ -84,6 +85,11 @@ FeatureHealthEventViewSet, basename="feature-health-events", ) +projects_router.register( + r"metadata/fields", + ProjectMetadataFieldViewSet, + basename="project-metadata-fields", +) if settings.WORKFLOWS_LOGIC_INSTALLED: # pragma: no cover workflow_views = importlib.import_module("workflows_logic.views") diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index 3ef99e712054..30c17275edd3 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -521,20 +521,24 @@ def test_create_metadata_field__project_admin__returns_201( assert response.status_code == status.HTTP_201_CREATED -def test_list_metadata_fields__filter_by_project__returns_org_and_project_fields( +def test_list_metadata_fields__returns_org_level_only( admin_client: APIClient, organisation: Organisation, project: Project, + project_b: Project, ) -> None: # Given org_field = MetadataField.objects.create( name="org_field", type="str", organisation=organisation ) - project_field = MetadataField.objects.create( - name="project_field", type="str", organisation=organisation, project=project + MetadataField.objects.create( + name="proj_a_field", type="str", organisation=organisation, project=project + ) + MetadataField.objects.create( + name="proj_b_field", type="str", organisation=organisation, project=project_b ) base_url = reverse("api-v1:metadata:metadata-fields-list") - url = f"{base_url}?organisation={organisation.id}&project={project.id}" + url = f"{base_url}?organisation={organisation.id}" # When response = admin_client.get(url) @@ -542,58 +546,69 @@ def test_list_metadata_fields__filter_by_project__returns_org_and_project_fields # Then assert response.status_code == status.HTTP_200_OK returned_ids = {r["id"] for r in response.json()["results"]} - assert org_field.id in returned_ids - assert project_field.id in returned_ids + assert returned_ids == {org_field.id} -def test_list_metadata_fields__filter_by_project__excludes_other_project_fields( +def test_list_metadata_fields__response_includes_nested_model_fields( admin_client: APIClient, organisation: Organisation, + environment_content_type: ContentType, + project_content_type: ContentType, project: Project, - project_b: Project, ) -> None: # Given - org_field = MetadataField.objects.create( - name="org_field", type="str", organisation=organisation + field = MetadataField.objects.create( + name="my_field", type="str", organisation=organisation ) - project_a_field = MetadataField.objects.create( - name="proj_a_field", type="str", organisation=organisation, project=project + model_field = MetadataModelField.objects.create( + field=field, content_type=environment_content_type ) - project_b_field = MetadataField.objects.create( - name="proj_b_field", type="str", organisation=organisation, project=project_b + requirement = MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project.id, + model_field=model_field, ) base_url = reverse("api-v1:metadata:metadata-fields-list") - url = f"{base_url}?organisation={organisation.id}&project={project.id}" + url = f"{base_url}?organisation={organisation.id}" # When response = admin_client.get(url) # Then assert response.status_code == status.HTTP_200_OK - returned_ids = {r["id"] for r in response.json()["results"]} - assert org_field.id in returned_ids - assert project_a_field.id in returned_ids - assert project_b_field.id not in returned_ids - - -def test_list_metadata_fields__no_project_filter__returns_org_level_only( + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["model_fields"] == [ + { + "id": model_field.id, + "content_type": environment_content_type.id, + "is_required_for": [ + { + "content_type": project_content_type.id, + "object_id": project.id, + } + ], + } + ] + + +def test_list_project_metadata_fields__returns_project_fields_only( admin_client: APIClient, organisation: Organisation, project: Project, project_b: Project, ) -> None: # Given - org_field = MetadataField.objects.create( + MetadataField.objects.create( name="org_field", type="str", organisation=organisation ) - project_a_field = MetadataField.objects.create( + project_field = MetadataField.objects.create( name="proj_a_field", type="str", organisation=organisation, project=project ) - project_b_field = MetadataField.objects.create( + MetadataField.objects.create( name="proj_b_field", type="str", organisation=organisation, project=project_b ) - base_url = reverse("api-v1:metadata:metadata-fields-list") - url = f"{base_url}?organisation={organisation.id}" + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) # When response = admin_client.get(url) @@ -601,42 +616,34 @@ def test_list_metadata_fields__no_project_filter__returns_org_level_only( # Then assert response.status_code == status.HTTP_200_OK returned_ids = {r["id"] for r in response.json()["results"]} - assert org_field.id in returned_ids - assert project_a_field.id not in returned_ids - assert project_b_field.id not in returned_ids + assert returned_ids == {project_field.id} -def test_list_metadata_fields__include_projects__returns_all( +def test_list_project_metadata_fields__include_organisation__returns_both( admin_client: APIClient, organisation: Organisation, project: Project, - project_b: Project, ) -> None: # Given org_field = MetadataField.objects.create( name="org_field", type="str", organisation=organisation ) - project_a_field = MetadataField.objects.create( - name="proj_a_field", type="str", organisation=organisation, project=project - ) - project_b_field = MetadataField.objects.create( - name="proj_b_field", type="str", organisation=organisation, project=project_b + project_field = MetadataField.objects.create( + name="project_field", type="str", organisation=organisation, project=project ) - base_url = reverse("api-v1:metadata:metadata-fields-list") - url = f"{base_url}?organisation={organisation.id}&include_projects=true" + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) # When - response = admin_client.get(url) + response = admin_client.get(f"{url}?include_organisation=true") # Then assert response.status_code == status.HTTP_200_OK returned_ids = {r["id"] for r in response.json()["results"]} assert org_field.id in returned_ids - assert project_a_field.id in returned_ids - assert project_b_field.id in returned_ids + assert project_field.id in returned_ids -def test_list_metadata_fields__project_field_overrides_org_field__org_field_excluded( +def test_list_project_metadata_fields__include_organisation__project_overrides_org( admin_client: APIClient, organisation: Organisation, project: Project, @@ -648,11 +655,10 @@ def test_list_metadata_fields__project_field_overrides_org_field__org_field_excl project_field = MetadataField.objects.create( name="shared_name", type="int", organisation=organisation, project=project ) - base_url = reverse("api-v1:metadata:metadata-fields-list") - url = f"{base_url}?organisation={organisation.id}&project={project.id}" + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) # When - response = admin_client.get(url) + response = admin_client.get(f"{url}?include_organisation=true") # Then assert response.status_code == status.HTTP_200_OK @@ -661,6 +667,33 @@ def test_list_metadata_fields__project_field_overrides_org_field__org_field_excl assert org_field.id not in returned_ids +def test_list_project_metadata_fields__excludes_other_project_fields( + admin_client: APIClient, + organisation: Organisation, + project: Project, + project_b: Project, +) -> None: + # Given + project_a_field = MetadataField.objects.create( + name="proj_a_field", type="str", organisation=organisation, project=project + ) + MetadataField.objects.create( + name="proj_b_field", type="str", organisation=organisation, project=project_b + ) + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) + + # When + response = admin_client.get(f"{url}?include_organisation=true") + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert project_a_field.id in returned_ids + assert all( + r["project"] in (project.id, None) for r in response.json()["results"] + ) + + @pytest.mark.parametrize( "existing_project_attr, new_project_attr, expected_status", [ From 790ff64c6e1eeceb351b4d1b6c3f55d67e2857e2 Mon Sep 17 00:00:00 2001 From: wood Date: Thu, 19 Feb 2026 12:56:25 +0100 Subject: [PATCH 06/11] feat: added-pagination-and-filtering-by-entity-to-custom-fields --- api/metadata/serializers.py | 5 + api/metadata/views.py | 15 ++- api/tests/unit/metadata/test_views.py | 161 +++++++++++++++++++++++++- 3 files changed, 173 insertions(+), 8 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 20abe765620a..6a22ade1542e 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -32,6 +32,11 @@ class ProjectMetadataFieldQuerySerializer(serializers.Serializer): # type: igno 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 SupportedRequiredForModelQuerySerializer(serializers.Serializer): # type: ignore[type-arg] diff --git a/api/metadata/views.py b/api/metadata/views.py index 91d5ab713cf4..91e232a58e69 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -6,10 +6,9 @@ from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError from rest_framework.response import Response -from projects.models import Project +from app.pagination import CustomPagination from projects.permissions import VIEW_PROJECT, NestedProjectPermissions from .models import ( @@ -128,6 +127,7 @@ def supported_required_for_models(self, request, organisation_pk=None): # type: class ProjectMetadataFieldViewSet(viewsets.ReadOnlyModelViewSet): # type: ignore[type-arg] serializer_class = MetadataFieldSerializer permission_classes = [NestedProjectPermissions] + pagination_class = CustomPagination def get_queryset(self): # type: ignore[no-untyped-def] if getattr(self, "swagger_fake_view", False): @@ -143,9 +143,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] project_id=project.id, ) - serializer = ProjectMetadataFieldQuerySerializer( - data=self.request.query_params - ) + serializer = ProjectMetadataFieldQuerySerializer(data=self.request.query_params) serializer.is_valid(raise_exception=True) if serializer.validated_data.get("include_organisation"): @@ -156,4 +154,11 @@ def get_queryset(self): # type: ignore[no-untyped-def] ).exclude(name__in=overridden_names) queryset = queryset | org_fields + entity = serializer.validated_data.get("entity") + if entity: + content_type = ContentType.objects.get(model=entity) + queryset = queryset.filter( + metadatamodelfield__content_type=content_type, + ) + return queryset.prefetch_related("metadatamodelfield_set__is_required_for") diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index 30c17275edd3..829081c26c42 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -689,9 +689,7 @@ def test_list_project_metadata_fields__excludes_other_project_fields( assert response.status_code == status.HTTP_200_OK returned_ids = {r["id"] for r in response.json()["results"]} assert project_a_field.id in returned_ids - assert all( - r["project"] in (project.id, None) for r in response.json()["results"] - ) + assert all(r["project"] in (project.id, None) for r in response.json()["results"]) @pytest.mark.parametrize( @@ -751,3 +749,160 @@ def test_create_metadata_field__uniqueness( # Then assert response.status_code == expected_status + + +def test_list_project_metadata_fields__page_size__returns_all( + admin_client: APIClient, + organisation: Organisation, + project: Project, + feature_content_type: ContentType, +) -> None: + # Given - create 15 fields to exceed the default page size of 10 + fields = [] + for i in range(15): + field = MetadataField.objects.create( + name=f"field_{i}", type="str", organisation=organisation, project=project + ) + MetadataModelField.objects.create( + field=field, content_type=feature_content_type + ) + fields.append(field) + + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) + + # When + response = admin_client.get(f"{url}?page_size=100") + + # Then + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 15 + + +@pytest.mark.parametrize( + "entity", + ["feature", "segment", "environment"], +) +def test_list_project_metadata_fields__entity_filter__returns_matching_only( + admin_client: APIClient, + organisation: Organisation, + project: Project, + feature_content_type: ContentType, + segment_content_type: ContentType, + environment_content_type: ContentType, + entity: str, +) -> None: + # Given + content_types = { + "feature": feature_content_type, + "segment": segment_content_type, + "environment": environment_content_type, + } + + created_fields: dict[str, MetadataField] = {} + for name, ct in content_types.items(): + field = MetadataField.objects.create( + name=f"{name}_only_field", + type="str", + organisation=organisation, + project=project, + ) + MetadataModelField.objects.create(field=field, content_type=ct) + created_fields[name] = field + + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) + + # When + response = admin_client.get(f"{url}?entity={entity}") + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert returned_ids == {created_fields[entity].id} + + +def test_list_project_metadata_fields__entity_filter__includes_multi_entity_fields( + admin_client: APIClient, + organisation: Organisation, + project: Project, + feature_content_type: ContentType, + segment_content_type: ContentType, + environment_content_type: ContentType, +) -> None: + # Given - a field assigned to all three entities + all_entities_field = MetadataField.objects.create( + name="all_entities_field", + type="str", + organisation=organisation, + project=project, + ) + for ct in [feature_content_type, segment_content_type, environment_content_type]: + MetadataModelField.objects.create(field=all_entities_field, content_type=ct) + + # And a field assigned only to feature + feature_only_field = MetadataField.objects.create( + name="feature_only_field", + type="str", + organisation=organisation, + project=project, + ) + MetadataModelField.objects.create( + field=feature_only_field, content_type=feature_content_type + ) + + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) + + # When - filtering by segment + response = admin_client.get(f"{url}?entity=segment") + + # Then - only the all_entities_field is returned + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert returned_ids == {all_entities_field.id} + + +def test_list_project_metadata_fields__entity_filter_with_include_organisation__returns_both( + admin_client: APIClient, + organisation: Organisation, + project: Project, + feature_content_type: ContentType, + segment_content_type: ContentType, +) -> None: + # Given - an org-level field for feature + org_field = MetadataField.objects.create( + name="org_feature_field", type="str", organisation=organisation + ) + MetadataModelField.objects.create( + field=org_field, content_type=feature_content_type + ) + + # And a project-level field for feature + project_field = MetadataField.objects.create( + name="project_feature_field", + type="str", + organisation=organisation, + project=project, + ) + MetadataModelField.objects.create( + field=project_field, content_type=feature_content_type + ) + + # And a project-level field for segment (should be excluded) + segment_field = MetadataField.objects.create( + name="project_segment_field", + type="str", + organisation=organisation, + project=project, + ) + MetadataModelField.objects.create( + field=segment_field, content_type=segment_content_type + ) + + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) + + # When + response = admin_client.get(f"{url}?include_organisation=true&entity=feature") + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert returned_ids == {org_field.id, project_field.id} From ce1522aa76da705d640170ab9ee8a61b83c95fa5 Mon Sep 17 00:00:00 2001 From: wood Date: Thu, 19 Feb 2026 16:26:02 +0100 Subject: [PATCH 07/11] feat: fixed-edit-delete-project-permissions-for-custom-fields --- api/metadata/permissions.py | 6 +++- api/metadata/serializers.py | 1 + api/metadata/views.py | 7 ++-- api/tests/unit/metadata/test_views.py | 52 +++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/api/metadata/permissions.py b/api/metadata/permissions.py index 194b567a1900..65cd54a57881 100644 --- a/api/metadata/permissions.py +++ b/api/metadata/permissions.py @@ -42,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 diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 6a22ade1542e..74873d74db4c 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -107,6 +107,7 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: 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( diff --git a/api/metadata/views.py b/api/metadata/views.py index 91e232a58e69..62ee33ea9ca3 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -9,7 +9,8 @@ from rest_framework.response import Response from app.pagination import CustomPagination -from projects.permissions import VIEW_PROJECT, NestedProjectPermissions +from common.projects.permissions import VIEW_PROJECT +from projects.permissions import NestedProjectPermissions from .models import ( SUPPORTED_REQUIREMENTS_MAPPING, @@ -134,7 +135,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] return MetadataField.objects.none() project = get_object_or_404( - self.request.user.get_permitted_projects(VIEW_PROJECT), + self.request.user.get_permitted_projects(VIEW_PROJECT), # type: ignore[union-attr] pk=self.kwargs["project_pk"], ) @@ -156,7 +157,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] entity = serializer.validated_data.get("entity") if entity: - content_type = ContentType.objects.get(model=entity) + content_type = get_object_or_404(ContentType, model=entity) queryset = queryset.filter( metadatamodelfield__content_type=content_type, ) diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index 829081c26c42..615c8b37fece 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -906,3 +906,55 @@ def test_list_project_metadata_fields__entity_filter_with_include_organisation__ assert response.status_code == status.HTTP_200_OK returned_ids = {r["id"] for r in response.json()["results"]} assert returned_ids == {org_field.id, project_field.id} + + +@pytest.mark.parametrize( + "field_project_attr, action, expected_status", + [ + ("project", "put", status.HTTP_200_OK), + ("project", "delete", status.HTTP_204_NO_CONTENT), + (None, "put", status.HTTP_403_FORBIDDEN), + (None, "delete", status.HTTP_403_FORBIDDEN), + ("project_b", "put", status.HTTP_403_FORBIDDEN), + ("project_b", "delete", status.HTTP_403_FORBIDDEN), + ], +) +def test_modify_metadata_field__project_admin__permission_check( + staff_user: FFAdminUser, + staff_client: APIClient, + organisation: Organisation, + project: Project, + project_b: Project, + field_project_attr: str | None, + action: str, + expected_status: int, + request: pytest.FixtureRequest, +) -> None: + # Given + from projects.models import UserProjectPermission + + UserProjectPermission.objects.create(user=staff_user, project=project, admin=True) + field_project = ( + request.getfixturevalue(field_project_attr) if field_project_attr else None + ) + field = MetadataField.objects.create( + name="the_field", type="str", organisation=organisation, project=field_project + ) + url = reverse("api-v1:metadata:metadata-fields-detail", args=[field.id]) + + # When + if action == "put": + data = { + "name": "the_field_updated", + "type": "str", + "organisation": organisation.id, + **({"project": field_project.id} if field_project else {}), + } + response = staff_client.put( + url, data=json.dumps(data), content_type="application/json" + ) + else: + response = staff_client.delete(url) + + # Then + assert response.status_code == expected_status From 733ece1cee8743554f562af1ee6ef01a8811d91d Mon Sep 17 00:00:00 2001 From: wood Date: Thu, 19 Feb 2026 16:43:03 +0100 Subject: [PATCH 08/11] feat: refactored-validated-required-metadata --- api/metadata/serializers.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 74873d74db4c..7d30b144c303 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -91,13 +91,12 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: project_id = data.get("project_id") organisation = data.get("organisation") - if project_id is not None: - if not Project.objects.filter( - id=project_id, organisation=organisation - ).exists(): - raise serializers.ValidationError( - {"project": "Project must belong to the specified 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. @@ -194,31 +193,28 @@ def _validate_required_metadata( 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, ) - - # Requirement scoping: org-level + this project's requirements - req_scope = Q(content_type=org_ct, object_id=organisation.id) - if project is not None: project_ct = ContentType.objects.get_for_model(Project) req_scope |= Q(content_type=project_ct, object_id=project.id) - - requirements = MetadataModelFieldRequirement.objects.filter( - field_scope & req_scope, - ).select_related("model_field__field") - - overridden_names: set[str] = set() - if project is not None: 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 From 01610476acb1d18153fd2ea82647b1cacf4d25ba Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:47:18 +0000 Subject: [PATCH 09/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/metadata/serializers.py | 9 ++++++--- api/metadata/views.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 7d30b144c303..5e53b957be88 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -91,9 +91,12 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: 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(): + 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."} ) diff --git a/api/metadata/views.py b/api/metadata/views.py index 62ee33ea9ca3..b70000b9428f 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -1,5 +1,6 @@ from itertools import chain +from common.projects.permissions import VIEW_PROJECT from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator @@ -9,7 +10,6 @@ from rest_framework.response import Response from app.pagination import CustomPagination -from common.projects.permissions import VIEW_PROJECT from projects.permissions import NestedProjectPermissions from .models import ( From 986df6a4ac396bf6ca88e3a7ad8d26261e3b4652 Mon Sep 17 00:00:00 2001 From: wood Date: Thu, 19 Feb 2026 16:49:41 +0100 Subject: [PATCH 10/11] feat: removed-unused-variable --- api/tests/unit/metadata/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index 615c8b37fece..172cf8953949 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -563,7 +563,7 @@ def test_list_metadata_fields__response_includes_nested_model_fields( model_field = MetadataModelField.objects.create( field=field, content_type=environment_content_type ) - requirement = MetadataModelFieldRequirement.objects.create( + MetadataModelFieldRequirement.objects.create( content_type=project_content_type, object_id=project.id, model_field=model_field, From 40c5620177f94d66482330a804cb563122a9a50f Mon Sep 17 00:00:00 2001 From: wood Date: Thu, 19 Feb 2026 17:25:17 +0100 Subject: [PATCH 11/11] feat: added-pagination-to-org-metadata-fields --- api/metadata/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/metadata/views.py b/api/metadata/views.py index b70000b9428f..afa32052ddea 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -39,6 +39,7 @@ class MetadataFieldViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] permission_classes = [MetadataFieldPermissions] serializer_class = MetadataFieldSerializer + pagination_class = CustomPagination def get_queryset(self): # type: ignore[no-untyped-def] if getattr(self, "swagger_fake_view", False):