From 4768e6eeafed0a3da44bfad95af1f06121890342 Mon Sep 17 00:00:00 2001 From: Faizan Date: Wed, 7 Oct 2026 16:00:30 +0530 Subject: [PATCH] fix(api): expose project membership ids --- apps/api/plane/api/serializers/__init__.py | 1 + apps/api/plane/api/serializers/member.py | 37 +++++ apps/api/plane/api/views/member.py | 37 +++-- .../api/test_project_member_api_ids.py | 139 ++++++++++++++++++ 4 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 apps/api/plane/tests/contract/api/test_project_member_api_ids.py diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index d0278eb14157..b0f829a7b943 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -64,6 +64,7 @@ from .invite import WorkspaceInviteSerializer from .member import ( ProjectMemberSerializer, + ProjectMemberUserLiteSerializer, WorkspaceMemberLiteAPISerializer, ProjectMemberLiteAPISerializer, ) diff --git a/apps/api/plane/api/serializers/member.py b/apps/api/plane/api/serializers/member.py index 0fa5bb43e7ad..490ef945d114 100644 --- a/apps/api/plane/api/serializers/member.py +++ b/apps/api/plane/api/serializers/member.py @@ -43,6 +43,37 @@ class Meta: read_only_fields = ["id"] +class ProjectMemberUserLiteSerializer(BaseSerializer): + """ + Lightweight project member response serializer. + + Returns the existing user fields together with the project membership ID. + """ + + id = serializers.UUIDField(source="member.id", read_only=True) + first_name = serializers.CharField(source="member.first_name", read_only=True) + last_name = serializers.CharField(source="member.last_name", read_only=True) + email = serializers.EmailField(source="member.email", read_only=True) + avatar = serializers.CharField(source="member.avatar", read_only=True, allow_null=True) + avatar_url = serializers.CharField(source="member.avatar_url", read_only=True, allow_null=True) + display_name = serializers.CharField(source="member.display_name", read_only=True) + project_member_id = serializers.UUIDField(source="id", read_only=True) + + class Meta: + model = ProjectMember + fields = [ + "id", + "project_member_id", + "first_name", + "last_name", + "email", + "avatar", + "avatar_url", + "display_name", + ] + read_only_fields = fields + + class BaseMemberLiteAPISerializer(BaseSerializer): """Common flattened member representation for paginated member pickers/directories.""" @@ -81,5 +112,11 @@ class Meta(BaseMemberLiteAPISerializer.Meta): class ProjectMemberLiteAPISerializer(BaseMemberLiteAPISerializer): """Minimal ProjectMember representation for paginated member pickers/directories.""" + project_member_id = serializers.UUIDField(source="id", read_only=True) + class Meta(BaseMemberLiteAPISerializer.Meta): model = ProjectMember + fields = [ + *BaseMemberLiteAPISerializer.Meta.fields, + "project_member_id", + ] diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index 4c8a04d599c6..6717e56c4a5b 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -16,10 +16,11 @@ from plane.api.serializers import ( UserLiteSerializer, ProjectMemberSerializer, + ProjectMemberUserLiteSerializer, WorkspaceMemberLiteAPISerializer, ProjectMemberLiteAPISerializer, ) -from plane.db.models import User, Workspace, WorkspaceMember, Project, ProjectMember +from plane.db.models import Workspace, WorkspaceMember, Project, ProjectMember from plane.utils.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission from plane.utils.openapi import ( WORKSPACE_SLUG_PARAMETER, @@ -116,8 +117,8 @@ def get_permissions(self): parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], responses={ 200: OpenApiResponse( - description="List of project members with their roles", - response=UserLiteSerializer, + description="List of project members with their user and membership IDs", + response=ProjectMemberUserLiteSerializer(many=True), examples=[PROJECT_MEMBER_EXAMPLE], ), 401: UNAUTHORIZED_RESPONSE, @@ -139,13 +140,17 @@ def get(self, request, slug, project_id): status=status.HTTP_400_BAD_REQUEST, ) - # Get the workspace members that are present inside the workspace - project_members = ProjectMember.objects.filter(project_id=project_id, workspace__slug=slug).values_list( - "member_id", flat=True + project_members = ( + ProjectMember.objects.filter( + project_id=project_id, + workspace__slug=slug, + member__isnull=False, + ) + .select_related("member") + .order_by("-member__created_at") ) - # Get all the users that are present inside the workspace - users = UserLiteSerializer(User.objects.filter(id__in=project_members), many=True).data + users = ProjectMemberUserLiteSerializer(project_members, many=True).data return Response(users, status=status.HTTP_200_OK) @extend_schema( @@ -173,7 +178,10 @@ class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint): tags=["Members"], parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], responses={ - 200: OpenApiResponse(description="Project member", response=ProjectMemberSerializer), + 200: OpenApiResponse( + description="Project member", + response=ProjectMemberUserLiteSerializer, + ), 401: UNAUTHORIZED_RESPONSE, 403: FORBIDDEN_RESPONSE, 404: PROJECT_NOT_FOUND_RESPONSE, @@ -194,10 +202,13 @@ def get(self, request, slug, project_id, pk): ) # Get the workspace members that are present inside the workspace - project_members = ProjectMember.objects.get(project_id=project_id, workspace__slug=slug, pk=pk) - user = User.objects.get(id=project_members.member_id) - user = UserLiteSerializer(user).data - return Response(user, status=status.HTTP_200_OK) + project_member = ProjectMember.objects.get( + project_id=project_id, + workspace__slug=slug, + pk=pk, + ) + user_data = ProjectMemberUserLiteSerializer(project_member).data + return Response(user_data, status=status.HTTP_200_OK) @extend_schema( operation_id="update_project_member", diff --git a/apps/api/plane/tests/contract/api/test_project_member_api_ids.py b/apps/api/plane/tests/contract/api/test_project_member_api_ids.py new file mode 100644 index 000000000000..755bda0d3262 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_project_member_api_ids.py @@ -0,0 +1,139 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Contract tests for project member API identifiers.""" + +import pytest +from rest_framework import status + +from plane.db.models import Project, ProjectMember + + +def members_url(slug, project_id): + return f"/api/v1/workspaces/{slug}/projects/{project_id}/members/" + + +def project_members_url(slug, project_id): + return f"/api/v1/workspaces/{slug}/projects/{project_id}/project-members/" + + +def project_members_lite_url(slug, project_id): + return f"/api/v1/workspaces/{slug}/projects/{project_id}/project-members-lite/" + + +@pytest.fixture +def project(db, workspace, create_user): + project = Project.objects.create( + name="Project Member IDs", + identifier="PMI", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + project=project, + workspace=workspace, + member=create_user, + role=20, + is_active=True, + ) + return project + + +@pytest.mark.contract +class TestProjectMemberIdentifiers: + @pytest.mark.django_db + def test_project_member_list_exposes_membership_id(self, api_key_client, workspace, project, create_user): + response = api_key_client.get(members_url(workspace.slug, project.id)) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + + member = response.data[0] + project_member = ProjectMember.objects.get( + project=project, + member=create_user, + ) + + assert str(member["id"]) == str(create_user.id) + assert str(member["project_member_id"]) == str(project_member.id) + + @pytest.mark.django_db + def test_project_members_alias_exposes_membership_id(self, api_key_client, workspace, project, create_user): + response = api_key_client.get(project_members_url(workspace.slug, project.id)) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + + member = response.data[0] + project_member = ProjectMember.objects.get( + project=project, + member=create_user, + ) + + assert str(member["id"]) == str(create_user.id) + assert str(member["project_member_id"]) == str(project_member.id) + + @pytest.mark.django_db + def test_project_member_id_from_list_can_retrieve_member(self, api_key_client, workspace, project, create_user): + response = api_key_client.get(members_url(workspace.slug, project.id)) + + assert response.status_code == status.HTTP_200_OK + project_member_id = response.data[0]["project_member_id"] + + detail_response = api_key_client.get(f"{members_url(workspace.slug, project.id)}{project_member_id}/") + + assert detail_response.status_code == status.HTTP_200_OK + assert str(detail_response.data["id"]) == str(create_user.id) + assert str(detail_response.data["project_member_id"]) == str(project_member_id) + + @pytest.mark.django_db + def test_project_member_id_from_list_can_update_member(self, api_key_client, workspace, project, create_user): + response = api_key_client.get(members_url(workspace.slug, project.id)) + + assert response.status_code == status.HTTP_200_OK + project_member_id = response.data[0]["project_member_id"] + + update_response = api_key_client.patch( + f"{members_url(workspace.slug, project.id)}{project_member_id}/", + {"role": 15}, + format="json", + ) + + assert update_response.status_code == status.HTTP_200_OK + assert str(update_response.data["id"]) == str(project_member_id) + assert update_response.data["role"] == 15 + + project_member = ProjectMember.objects.get(id=project_member_id) + assert project_member.role == 15 + + @pytest.mark.django_db + def test_project_member_id_from_list_can_delete_member(self, api_key_client, workspace, project, create_user): + response = api_key_client.get(members_url(workspace.slug, project.id)) + + assert response.status_code == status.HTTP_200_OK + project_member_id = response.data[0]["project_member_id"] + + delete_response = api_key_client.delete(f"{members_url(workspace.slug, project.id)}{project_member_id}/") + + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + + project_member = ProjectMember.objects.get(id=project_member_id) + assert project_member.is_active is False + + @pytest.mark.django_db + def test_project_member_lite_exposes_membership_id(self, api_key_client, workspace, project, create_user): + response = api_key_client.get(project_members_lite_url(workspace.slug, project.id)) + + assert response.status_code == status.HTTP_200_OK + assert "results" in response.data + assert len(response.data["results"]) == 1 + + member = response.data["results"][0] + project_member = ProjectMember.objects.get( + project=project, + member=create_user, + ) + + assert str(member["id"]) == str(create_user.id) + assert str(member["project_member_id"]) == str(project_member.id)