Skip to content

Commit 53b93ba

Browse files
authored
fix: allow project admins to create and manage project-scoped custom fields (#7518)
1 parent b34ef1f commit 53b93ba

2 files changed

Lines changed: 178 additions & 8 deletions

File tree

api/metadata/permissions.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,10 @@ def has_permission(self, request, view): # type: ignore[no-untyped-def]
7474
if view.action == "create":
7575
field = MetadataField.objects.get(id=request.data.get("field"))
7676

77-
return (
77+
return (organisation_pk == field.organisation.id) and (
7878
request.user.is_organisation_admin(organisation_pk)
79-
and organisation_pk == field.organisation.id
79+
or (project := field.project)
80+
and request.user.is_project_admin(project)
8081
)
8182

8283
return False
@@ -86,6 +87,10 @@ def has_object_permission(self, request, view, obj): # type: ignore[no-untyped-
8687
return request.user.belongs_to(obj.field.organisation.id)
8788

8889
if view.action in ("update", "destroy", "partial_update"):
89-
return request.user.is_organisation_admin(obj.field.organisation)
90+
return (
91+
request.user.is_organisation_admin(obj.field.organisation)
92+
or (project := obj.field.project)
93+
and request.user.is_project_admin(project)
94+
)
9095

9196
return False

api/tests/unit/metadata/test_views.py

Lines changed: 170 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
)
1515
from metadata.views import SUPPORTED_REQUIREMENTS_MAPPING # type: ignore[attr-defined]
1616
from organisations.models import Organisation
17-
from projects.models import Project
17+
from projects.models import Project, UserProjectPermission
1818
from users.models import FFAdminUser
1919

2020

@@ -511,8 +511,6 @@ def test_create_metadata_field__project_admin__returns_201(
511511
project: Project,
512512
) -> None:
513513
# Given
514-
from projects.models import UserProjectPermission
515-
516514
UserProjectPermission.objects.create(user=staff_user, project=project, admin=True)
517515

518516
url = reverse("api-v1:metadata:metadata-fields-list")
@@ -942,8 +940,6 @@ def test_modify_metadata_field__project_admin__permission_check(
942940
request: pytest.FixtureRequest,
943941
) -> None:
944942
# Given
945-
from projects.models import UserProjectPermission
946-
947943
UserProjectPermission.objects.create(user=staff_user, project=project, admin=True)
948944
field_project = (
949945
request.getfixturevalue(field_project_attr) if field_project_attr else None
@@ -969,3 +965,172 @@ def test_modify_metadata_field__project_admin__permission_check(
969965

970966
# Then
971967
assert response.status_code == expected_status
968+
969+
970+
def test_create_model_metadata_field__project_admin__returns_201(
971+
staff_user: FFAdminUser,
972+
staff_client: APIClient,
973+
organisation: Organisation,
974+
project: Project,
975+
feature_content_type: ContentType,
976+
) -> None:
977+
# Given - a project-scoped MetadataField and a user who is project admin (not org admin)
978+
UserProjectPermission.objects.create(user=staff_user, project=project, admin=True)
979+
project_field = MetadataField.objects.create(
980+
name="proj_field",
981+
type="int",
982+
organisation=organisation,
983+
project=project,
984+
)
985+
url = reverse(
986+
"api-v1:organisations:metadata-model-fields-list", args=[organisation.id]
987+
)
988+
data = {
989+
"field": project_field.id,
990+
"content_type": feature_content_type.id,
991+
"is_required_for": [],
992+
}
993+
994+
# When
995+
response = staff_client.post(
996+
url, data=json.dumps(data), content_type="application/json"
997+
)
998+
999+
# Then - project admin should be allowed to bind a project-scoped field
1000+
assert response.status_code == status.HTTP_201_CREATED
1001+
assert response.json()["field"] == project_field.id
1002+
1003+
1004+
def test_create_model_metadata_field__project_admin_org_scoped_field__returns_403(
1005+
staff_user: FFAdminUser,
1006+
staff_client: APIClient,
1007+
organisation: Organisation,
1008+
project: Project,
1009+
feature_content_type: ContentType,
1010+
) -> None:
1011+
# Given - an org-scoped MetadataField (project=None) and a user who is project admin only
1012+
UserProjectPermission.objects.create(user=staff_user, project=project, admin=True)
1013+
org_field = MetadataField.objects.create(
1014+
name="org_field",
1015+
type="int",
1016+
organisation=organisation,
1017+
)
1018+
url = reverse(
1019+
"api-v1:organisations:metadata-model-fields-list", args=[organisation.id]
1020+
)
1021+
data = {
1022+
"field": org_field.id,
1023+
"content_type": feature_content_type.id,
1024+
"is_required_for": [],
1025+
}
1026+
1027+
# When
1028+
response = staff_client.post(
1029+
url, data=json.dumps(data), content_type="application/json"
1030+
)
1031+
1032+
# Then - project admin must not be able to bind org-scoped fields
1033+
assert response.status_code == status.HTTP_403_FORBIDDEN
1034+
1035+
1036+
@pytest.mark.parametrize(
1037+
"field_project_attr, expected_status",
1038+
[
1039+
("project", status.HTTP_200_OK),
1040+
(None, status.HTTP_403_FORBIDDEN),
1041+
("project_b", status.HTTP_403_FORBIDDEN),
1042+
],
1043+
ids=[
1044+
"own_project_field_allowed",
1045+
"org_scoped_field_denied",
1046+
"other_project_field_denied",
1047+
],
1048+
)
1049+
def test_update_model_metadata_field__project_admin__returns_expected_status(
1050+
staff_user: FFAdminUser,
1051+
staff_client: APIClient,
1052+
organisation: Organisation,
1053+
project: Project,
1054+
project_b: Project,
1055+
feature_content_type: ContentType,
1056+
field_project_attr: str | None,
1057+
expected_status: int,
1058+
request: pytest.FixtureRequest,
1059+
) -> None:
1060+
# Given - a project admin for `project`, and a MetadataModelField whose parent
1061+
# MetadataField belongs to one of: `project`, `project_b`, or the org (None)
1062+
UserProjectPermission.objects.create(user=staff_user, project=project, admin=True)
1063+
field_project = (
1064+
request.getfixturevalue(field_project_attr) if field_project_attr else None
1065+
)
1066+
field = MetadataField.objects.create(
1067+
name="model_field", type="int", organisation=organisation, project=field_project
1068+
)
1069+
model_field = MetadataModelField.objects.create(
1070+
field=field, content_type=feature_content_type
1071+
)
1072+
url = reverse(
1073+
"api-v1:organisations:metadata-model-fields-detail",
1074+
args=[organisation.id, model_field.id],
1075+
)
1076+
data = {
1077+
"field": field.id,
1078+
"content_type": feature_content_type.id,
1079+
"is_required_for": [],
1080+
}
1081+
1082+
# When
1083+
response = staff_client.put(
1084+
url, data=json.dumps(data), content_type="application/json"
1085+
)
1086+
1087+
# Then
1088+
assert response.status_code == expected_status
1089+
1090+
1091+
@pytest.mark.parametrize(
1092+
"field_project_attr, expected_status",
1093+
[
1094+
("project", status.HTTP_204_NO_CONTENT),
1095+
(None, status.HTTP_403_FORBIDDEN),
1096+
("project_b", status.HTTP_403_FORBIDDEN),
1097+
],
1098+
ids=[
1099+
"own_project_field_allowed",
1100+
"org_scoped_field_denied",
1101+
"other_project_field_denied",
1102+
],
1103+
)
1104+
def test_delete_model_metadata_field__project_admin__returns_expected_status(
1105+
staff_user: FFAdminUser,
1106+
staff_client: APIClient,
1107+
organisation: Organisation,
1108+
project: Project,
1109+
project_b: Project,
1110+
feature_content_type: ContentType,
1111+
field_project_attr: str | None,
1112+
expected_status: int,
1113+
request: pytest.FixtureRequest,
1114+
) -> None:
1115+
# Given - a project admin for `project`, and a MetadataModelField whose parent
1116+
# MetadataField belongs to one of: `project`, `project_b`, or the org (None)
1117+
UserProjectPermission.objects.create(user=staff_user, project=project, admin=True)
1118+
field_project = (
1119+
request.getfixturevalue(field_project_attr) if field_project_attr else None
1120+
)
1121+
field = MetadataField.objects.create(
1122+
name="model_field", type="int", organisation=organisation, project=field_project
1123+
)
1124+
model_field = MetadataModelField.objects.create(
1125+
field=field, content_type=feature_content_type
1126+
)
1127+
url = reverse(
1128+
"api-v1:organisations:metadata-model-fields-detail",
1129+
args=[organisation.id, model_field.id],
1130+
)
1131+
1132+
# When
1133+
response = staff_client.delete(url)
1134+
1135+
# Then
1136+
assert response.status_code == expected_status

0 commit comments

Comments
 (0)