Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/plane/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
from .invite import WorkspaceInviteSerializer
from .member import (
ProjectMemberSerializer,
ProjectMemberUserLiteSerializer,
WorkspaceMemberLiteAPISerializer,
ProjectMemberLiteAPISerializer,
)
Expand Down
37 changes: 37 additions & 0 deletions apps/api/plane/api/serializers/member.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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",
]
37 changes: 24 additions & 13 deletions apps/api/plane/api/views/member.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down
139 changes: 139 additions & 0 deletions apps/api/plane/tests/contract/api/test_project_member_api_ids.py
Original file line number Diff line number Diff line change
@@ -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)