From 65d8520186d5bcffb0e6b3191d4938042206d3a7 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 3 Nov 2025 16:35:48 +0530 Subject: [PATCH 1/7] feat: add workspace invitation and project member management endpoints - Introduced `WorkspaceInviteSerializer` for handling workspace invites. - Added `WorkspaceInvitationsViewset` to manage invites (list, create, retrieve, update, delete). - Created `ProjectMemberSerializer` for managing project members. - Updated `ProjectMemberAPIEndpoint` to support creating and updating project members with appropriate permissions. - Enhanced URL routing to include new invite and member management paths. --- apps/api/plane/api/serializers/__init__.py | 2 + apps/api/plane/api/serializers/invite.py | 56 ++++++++ apps/api/plane/api/serializers/member.py | 35 +++++ apps/api/plane/api/urls/__init__.py | 2 + apps/api/plane/api/urls/invite.py | 19 +++ apps/api/plane/api/urls/member.py | 7 +- apps/api/plane/api/views/__init__.py | 2 + apps/api/plane/api/views/base.py | 129 +++++++++++++++++- apps/api/plane/api/views/invite.py | 150 +++++++++++++++++++++ apps/api/plane/api/views/issue.py | 6 +- apps/api/plane/api/views/member.py | 56 +++++++- apps/api/plane/app/permissions/__init__.py | 1 + apps/api/plane/app/permissions/project.py | 14 ++ 13 files changed, 470 insertions(+), 9 deletions(-) create mode 100644 apps/api/plane/api/serializers/invite.py create mode 100644 apps/api/plane/api/serializers/member.py create mode 100644 apps/api/plane/api/urls/invite.py create mode 100644 apps/api/plane/api/views/invite.py diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 7596915eb40..b58b9fdcb75 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -53,3 +53,5 @@ GenericAssetUpdateSerializer, FileAssetSerializer, ) +from .invite import WorkspaceInviteSerializer +from .member import ProjectMemberSerializer \ No newline at end of file diff --git a/apps/api/plane/api/serializers/invite.py b/apps/api/plane/api/serializers/invite.py new file mode 100644 index 00000000000..5b52dc03c4c --- /dev/null +++ b/apps/api/plane/api/serializers/invite.py @@ -0,0 +1,56 @@ +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from rest_framework import serializers + +# Module imports +from plane.db.models import WorkspaceMemberInvite +from .base import BaseSerializer +from plane.app.permissions.base import ROLE + + +class WorkspaceInviteSerializer(BaseSerializer): + """ + Serializer for workspace invites. + """ + + class Meta: + model = WorkspaceMemberInvite + fields = [ + "id", + "email", + "role", + "created_at", + "updated_at", + "responded_at", + "accepted", + ] + read_only_fields = [ + "id", + "workspace", + "created_at", + "updated_at", + "responded_at", + "accepted", + ] + + def validate_email(self, value): + try: + validate_email(value) + except ValidationError: + raise serializers.ValidationError("Invalid email address", code="INVALID_EMAIL_ADDRESS") + return value + + def validate_role(self, value): + if value not in [ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value]: + raise serializers.ValidationError("Invalid role", code="INVALID_WORKSPACE_MEMBER_ROLE") + return value + + def validate(self, data): + slug = self.context["slug"] + if ( + data.get("email") + and WorkspaceMemberInvite.objects.filter(email=data["email"], workspace__slug=slug).exists() + ): + raise serializers.ValidationError("Email already invited", code="EMAIL_ALREADY_INVITED") + return data diff --git a/apps/api/plane/api/serializers/member.py b/apps/api/plane/api/serializers/member.py new file mode 100644 index 00000000000..bce782ce1df --- /dev/null +++ b/apps/api/plane/api/serializers/member.py @@ -0,0 +1,35 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from plane.db.models import ProjectMember, WorkspaceMember +from .base import BaseSerializer +from plane.db.models import User + + +class ProjectMemberSerializer(BaseSerializer): + """ + Serializer for project members. + """ + + member = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), + required=True, + ) + + def validate_member(self, value): + slug = self.context["slug"] + + if not value: + raise serializers.ValidationError("Member is required", code="INVALID_MEMBER") + + if not User.objects.filter(id=value).exists(): + raise serializers.ValidationError("Member not found", code="INVALID_MEMBER") + if not WorkspaceMember.objects.filter(workspace__slug=slug, member=value).exists(): + raise serializers.ValidationError("Member not found in workspace", code="INVALID_MEMBER") + return value + + class Meta: + model = ProjectMember + fields = ["id", "member", "role"] + read_only_fields = ["id"] diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index 10cad2068e3..d239b67887f 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -8,6 +8,7 @@ from .state import urlpatterns as state_patterns from .user import urlpatterns as user_patterns from .work_item import urlpatterns as work_item_patterns +from .invite import urlpatterns as invite_patterns urlpatterns = [ *asset_patterns, @@ -20,4 +21,5 @@ *state_patterns, *user_patterns, *work_item_patterns, + *invite_patterns, ] diff --git a/apps/api/plane/api/urls/invite.py b/apps/api/plane/api/urls/invite.py new file mode 100644 index 00000000000..030bc73df9c --- /dev/null +++ b/apps/api/plane/api/urls/invite.py @@ -0,0 +1,19 @@ +from django.urls import path + +from plane.api.views import ( + WorkspaceInvitationsViewset, +) + + +urlpatterns = [ + path( + "workspaces//invitations/", + WorkspaceInvitationsViewset.as_view({"get": "list", "post": "create"}), + name="workspace-invitations", + ), + path( + "workspaces//invitations//", + WorkspaceInvitationsViewset.as_view({"get": "retrieve", "delete": "destroy", "patch": "partial_update"}), + name="workspace-invitations", + ), +] \ No newline at end of file diff --git a/apps/api/plane/api/urls/member.py b/apps/api/plane/api/urls/member.py index a2b331ea1c5..bd030944a66 100644 --- a/apps/api/plane/api/urls/member.py +++ b/apps/api/plane/api/urls/member.py @@ -5,7 +5,12 @@ urlpatterns = [ path( "workspaces//projects//members/", - ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]), + ProjectMemberAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="project-members", + ), + path( + "workspaces//projects//members//", + ProjectMemberAPIEndpoint.as_view(http_method_names=["patch", "delete"]), name="project-members", ), path( diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 8535d4858bc..0eef9cc0307 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -53,3 +53,5 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint from .user import UserEndpoint + +from .invite import WorkspaceInvitationsViewset \ No newline at end of file diff --git a/apps/api/plane/api/views/base.py b/apps/api/plane/api/views/base.py index b3acbab360f..f17ae2e3281 100644 --- a/apps/api/plane/api/views/base.py +++ b/apps/api/plane/api/views/base.py @@ -1,5 +1,6 @@ # Python imports import zoneinfo +import logging # Django imports from django.conf import settings @@ -7,15 +8,19 @@ from django.db import IntegrityError from django.urls import resolve from django.utils import timezone -from plane.db.models.api import APIToken + +# Third party imports from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response - -# Third party imports +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import SearchFilter +from rest_framework.viewsets import ModelViewSet +from rest_framework.exceptions import APIException from rest_framework.generics import GenericAPIView # Module imports +from plane.db.models.api import APIToken from plane.api.middleware.api_authentication import APIKeyAuthentication from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle from plane.utils.exception_logger import log_exception @@ -23,6 +28,9 @@ from plane.utils.core.mixins import ReadReplicaControlMixin +logger = logging.getLogger("plane.api") + + class TimezoneMixin: """ This enables timezone conversion according @@ -152,3 +160,118 @@ def fields(self): def expand(self): expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand] return expand if expand else None + + +class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePaginator): + model = None + + authentication_classes = [APIKeyAuthentication] + permission_classes = [ + IsAuthenticated, + ] + use_read_replica = False + + def get_queryset(self): + try: + return self.model.objects.all() + except Exception as e: + log_exception(e) + raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) + + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + if isinstance(e, IntegrityError): + log_exception(e) + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + logger.warning( + "Validation Error", + extra={ + "error_code": "VALIDATION_ERROR", + "error_message": str(e), + }, + ) + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + logger.warning( + "Object Does Not Exist", + extra={ + "error_code": "OBJECT_DOES_NOT_EXIST", + "error_message": str(e), + }, + ) + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + logger.error( + "Key Error", + extra={ + "error_code": "KEY_ERROR", + "error_message": str(e), + }, + ) + return Response( + {"error": "The required key does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + log_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") + + return response + except Exception as exc: + response = self.handle_exception(exc) + return response + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def project_id(self): + project_id = self.kwargs.get("project_id", None) + if project_id: + return project_id + + if resolve(self.request.path_info).url_name == "project": + return self.kwargs.get("pk", None) + + @property + def fields(self): + fields = [field for field in self.request.GET.get("fields", "").split(",") if field] + return fields if fields else None + + @property + def expand(self): + expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand] + return expand if expand else None diff --git a/apps/api/plane/api/views/invite.py b/apps/api/plane/api/views/invite.py new file mode 100644 index 00000000000..f835d480876 --- /dev/null +++ b/apps/api/plane/api/views/invite.py @@ -0,0 +1,150 @@ +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from drf_spectacular.utils import ( + extend_schema, + OpenApiResponse, + OpenApiRequest, + OpenApiParameter, + OpenApiTypes, +) + +# Module imports +from plane.api.views.base import BaseViewSet +from plane.db.models import WorkspaceMemberInvite, Workspace +from plane.api.serializers import WorkspaceInviteSerializer +from plane.app.permissions import WorkspaceOwnerPermission +from plane.utils.openapi.parameters import WORKSPACE_SLUG_PARAMETER + + +class WorkspaceInvitationsViewset(BaseViewSet): + """ + Endpoint for creating, listing and deleting workspace invites. + """ + + serializer_class = WorkspaceInviteSerializer + model = WorkspaceMemberInvite + + permission_classes = [ + WorkspaceOwnerPermission, + ] + + def get_queryset(self): + return self.filter_queryset(super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))) + + def get_object(self): + return self.get_queryset().get(pk=self.kwargs.get("pk")) + + @extend_schema( + summary="List workspace invites", + description="List all workspace invites for a workspace", + responses={ + 200: OpenApiResponse( + description="Workspace invites", + response=WorkspaceInviteSerializer(many=True), + ) + }, + parameters=[ + WORKSPACE_SLUG_PARAMETER, + ], + ) + def list(self, request, slug): + workspace_member_invites = self.get_queryset() + serializer = WorkspaceInviteSerializer(workspace_member_invites, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Get workspace invite", + description="Get a workspace invite by ID", + responses={200: OpenApiResponse(description="Workspace invite", response=WorkspaceInviteSerializer)}, + parameters=[ + WORKSPACE_SLUG_PARAMETER, + OpenApiParameter( + name="pk", + description="Workspace invite ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + ), + ], + ) + def retrieve(self, request, slug, pk): + workspace_member_invite = self.get_object() + serializer = WorkspaceInviteSerializer(workspace_member_invite) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Create workspace invite", + description="Create a workspace invite", + responses={201: OpenApiResponse(description="Workspace invite", response=WorkspaceInviteSerializer)}, + request=OpenApiRequest(request=WorkspaceInviteSerializer), + parameters=[ + WORKSPACE_SLUG_PARAMETER, + ], + ) + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + serializer = WorkspaceInviteSerializer(data=request.data, context={"slug": slug}) + serializer.is_valid(raise_exception=True) + serializer.save(workspace=workspace, created_by=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @extend_schema( + summary="Update workspace invite", + description="Update a workspace invite", + responses={200: OpenApiResponse(description="Workspace invite", response=WorkspaceInviteSerializer)}, + request=OpenApiRequest(request=WorkspaceInviteSerializer), + parameters=[ + WORKSPACE_SLUG_PARAMETER, + OpenApiParameter( + name="pk", + description="Workspace invite ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + ), + ], + ) + def partial_update(self, request, slug, pk): + workspace_member_invite = self.get_object() + if request.data.get("email"): + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={"error": "Email cannot be updated after invite is created.", "code": "EMAIL_CANNOT_BE_UPDATED"}, + ) + serializer = WorkspaceInviteSerializer( + workspace_member_invite, data=request.data, partial=True, context={"slug": slug} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Delete workspace invite", + description="Delete a workspace invite", + responses={204: OpenApiResponse(description="Workspace invite deleted")}, + parameters=[ + WORKSPACE_SLUG_PARAMETER, + OpenApiParameter( + name="pk", + description="Workspace invite ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + ), + ], + ) + def destroy(self, request, slug, pk): + workspace_member_invite = self.get_object() + if workspace_member_invite.accepted: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={"error": "Invite already accepted", "code": "INVITE_ALREADY_ACCEPTED"}, + ) + if workspace_member_invite.responded_at: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={"error": "Invite already responded", "code": "INVITE_ALREADY_RESPONDED"}, + ) + workspace_member_invite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index c6fd073bfdc..5cf97c6468b 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -1808,7 +1808,7 @@ def post(self, request, slug, project_id, issue_id): request.user.id, project_id=project_id, issue=issue, - allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value], + allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value], allow_creator=True, ): return Response( @@ -1964,7 +1964,7 @@ def delete(self, request, slug, project_id, issue_id, pk): request.user, project_id=project_id, issue=issue, - allowed_roles=[ROLE.ADMIN.value], + allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value], allow_creator=True, ): return Response( @@ -2102,7 +2102,7 @@ def patch(self, request, slug, project_id, issue_id, pk): request.user, project_id=project_id, issue=issue, - allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value], + allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value], allow_creator=True, ): return Response( diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index f761d5c91e9..be20d3e358a 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -1,16 +1,18 @@ # Third Party imports +from calendar import c from rest_framework.response import Response from rest_framework import status from drf_spectacular.utils import ( extend_schema, OpenApiResponse, + OpenApiRequest, ) # Module imports from .base import BaseAPIView -from plane.api.serializers import UserLiteSerializer +from plane.api.serializers import UserLiteSerializer, ProjectMemberSerializer from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember -from plane.app.permissions import ProjectMemberPermission, WorkSpaceAdminPermission +from plane.app.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission from plane.utils.openapi import ( WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER, @@ -91,6 +93,11 @@ class ProjectMemberAPIEndpoint(BaseAPIView): permission_classes = [ProjectMemberPermission] use_read_replica = True + def get_permissions(self): + if self.request.method == "GET": + return [ProjectMemberPermission()] + return [ProjectAdminPermission()] + @extend_schema( operation_id="get_project_members", summary="List project members", @@ -131,3 +138,48 @@ def get(self, request, slug, project_id): users = UserLiteSerializer(User.objects.filter(id__in=project_members), many=True).data return Response(users, status=status.HTTP_200_OK) + + @extend_schema( + operation_id="create_project_member", + summary="Create project member", + description="Create a new project member", + tags=["Members"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={201: OpenApiResponse(description="Project member created", response=ProjectMemberSerializer)}, + request=OpenApiRequest(request=ProjectMemberSerializer), + ) + def post(self, request, slug, project_id): + serializer = ProjectMemberSerializer(data=request.data, context={"slug": slug}) + serializer.is_valid(raise_exception=True) + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @extend_schema( + operation_id="update_project_member", + summary="Update project member", + description="Update a project member", + tags=["Members"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={200: OpenApiResponse(description="Project member updated", response=ProjectMemberSerializer)}, + request=OpenApiRequest(request=ProjectMemberSerializer), + ) + def patch(self, request, slug, project_id, pk): + project_member = ProjectMember.objects.get(project_id=project_id, workspace__slug=slug, pk=pk) + serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True, context={"slug": slug}) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + operation_id="delete_project_member", + summary="Delete project member", + description="Delete a project member", + tags=["Members"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={204: OpenApiResponse(description="Project member deleted")}, + ) + def delete(self, request, slug, project_id, pk): + project_member = ProjectMember.objects.get(project_id=project_id, workspace__slug=slug, pk=pk) + project_member.is_active = False + project_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/permissions/__init__.py b/apps/api/plane/app/permissions/__init__.py index 95ee038e184..849f7ba3ee1 100644 --- a/apps/api/plane/app/permissions/__init__.py +++ b/apps/api/plane/app/permissions/__init__.py @@ -11,6 +11,7 @@ ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission, + ProjectAdminPermission, ) from .base import allow_permission, ROLE from .page import ProjectPagePermission diff --git a/apps/api/plane/app/permissions/project.py b/apps/api/plane/app/permissions/project.py index e095ffed483..a8c0f92a27a 100644 --- a/apps/api/plane/app/permissions/project.py +++ b/apps/api/plane/app/permissions/project.py @@ -112,6 +112,20 @@ def has_permission(self, request, view): ).exists() +class ProjectAdminPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role=ROLE.ADMIN.value, + project_id=view.project_id, + is_active=True, + ).exists() + + class ProjectLitePermission(BasePermission): def has_permission(self, request, view): if request.user.is_anonymous: From 5500e50112e805cb5d67ba25d8010608f385a10f Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 3 Nov 2025 17:06:51 +0530 Subject: [PATCH 2/7] refactor: simplify invitation URL routing with DefaultRouter - Replaced manual URL paths for workspace invitations with a DefaultRouter for cleaner routing. - Updated imports for better organization and clarity. --- apps/api/plane/api/urls/invite.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/apps/api/plane/api/urls/invite.py b/apps/api/plane/api/urls/invite.py index 030bc73df9c..9d73cb6ef80 100644 --- a/apps/api/plane/api/urls/invite.py +++ b/apps/api/plane/api/urls/invite.py @@ -1,19 +1,18 @@ -from django.urls import path +# Django imports +from django.urls import path, include -from plane.api.views import ( - WorkspaceInvitationsViewset, -) +# Third party imports +from rest_framework.routers import DefaultRouter +# Module imports +from plane.api.views import WorkspaceInvitationsViewset + +# Create router with just the invitations prefix (no workspace slug) +router = DefaultRouter() +router.register(r"invitations", WorkspaceInvitationsViewset, basename="workspace-invitations") + +# Wrap the router URLs with the workspace slug path urlpatterns = [ - path( - "workspaces//invitations/", - WorkspaceInvitationsViewset.as_view({"get": "list", "post": "create"}), - name="workspace-invitations", - ), - path( - "workspaces//invitations//", - WorkspaceInvitationsViewset.as_view({"get": "retrieve", "delete": "destroy", "patch": "partial_update"}), - name="workspace-invitations", - ), + path("workspaces//", include(router.urls)), ] \ No newline at end of file From c72688bca1b8aad120c9b7b7f2b4fe4ddf121ad9 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 3 Nov 2025 17:57:53 +0530 Subject: [PATCH 3/7] refactor: reorganize permission imports and introduce new permission utilities - Updated import paths for `WorkspaceOwnerPermission`, `ProjectMemberPermission`, and others to improve code organization. - Added new permission utility classes in `utils/permissions` for better role management and access control. - Implemented `allow_permission` decorator for streamlined permission checks across views. --- apps/api/plane/api/views/invite.py | 2 +- apps/api/plane/api/views/member.py | 2 +- apps/api/plane/utils/permissions/__init__.py | 17 +++ apps/api/plane/utils/permissions/base.py | 73 +++++++++ apps/api/plane/utils/permissions/page.py | 121 +++++++++++++++ apps/api/plane/utils/permissions/project.py | 139 ++++++++++++++++++ apps/api/plane/utils/permissions/workspace.py | 106 +++++++++++++ 7 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 apps/api/plane/utils/permissions/__init__.py create mode 100644 apps/api/plane/utils/permissions/base.py create mode 100644 apps/api/plane/utils/permissions/page.py create mode 100644 apps/api/plane/utils/permissions/project.py create mode 100644 apps/api/plane/utils/permissions/workspace.py diff --git a/apps/api/plane/api/views/invite.py b/apps/api/plane/api/views/invite.py index f835d480876..f1263b00902 100644 --- a/apps/api/plane/api/views/invite.py +++ b/apps/api/plane/api/views/invite.py @@ -13,7 +13,7 @@ from plane.api.views.base import BaseViewSet from plane.db.models import WorkspaceMemberInvite, Workspace from plane.api.serializers import WorkspaceInviteSerializer -from plane.app.permissions import WorkspaceOwnerPermission +from plane.utils.permissions import WorkspaceOwnerPermission from plane.utils.openapi.parameters import WORKSPACE_SLUG_PARAMETER diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index be20d3e358a..4fe63a30cc0 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -12,7 +12,7 @@ from .base import BaseAPIView from plane.api.serializers import UserLiteSerializer, ProjectMemberSerializer from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember -from plane.app.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission +from plane.utils.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission from plane.utils.openapi import ( WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER, diff --git a/apps/api/plane/utils/permissions/__init__.py b/apps/api/plane/utils/permissions/__init__.py new file mode 100644 index 00000000000..849f7ba3ee1 --- /dev/null +++ b/apps/api/plane/utils/permissions/__init__.py @@ -0,0 +1,17 @@ +from .workspace import ( + WorkSpaceBasePermission, + WorkspaceOwnerPermission, + WorkSpaceAdminPermission, + WorkspaceEntityPermission, + WorkspaceViewerPermission, + WorkspaceUserPermission, +) +from .project import ( + ProjectBasePermission, + ProjectEntityPermission, + ProjectMemberPermission, + ProjectLitePermission, + ProjectAdminPermission, +) +from .base import allow_permission, ROLE +from .page import ProjectPagePermission diff --git a/apps/api/plane/utils/permissions/base.py b/apps/api/plane/utils/permissions/base.py new file mode 100644 index 00000000000..a2b1a18ff85 --- /dev/null +++ b/apps/api/plane/utils/permissions/base.py @@ -0,0 +1,73 @@ +from plane.db.models import WorkspaceMember, ProjectMember +from functools import wraps +from rest_framework.response import Response +from rest_framework import status + +from enum import Enum + + +class ROLE(Enum): + ADMIN = 20 + MEMBER = 15 + GUEST = 5 + + +def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None): + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(instance, request, *args, **kwargs): + # Check for creator if required + if creator and model: + obj = model.objects.filter(id=kwargs["pk"], created_by=request.user).exists() + if obj: + return view_func(instance, request, *args, **kwargs) + + # Convert allowed_roles to their values if they are enum members + allowed_role_values = [role.value if isinstance(role, ROLE) else role for role in allowed_roles] + + # Check role permissions + if level == "WORKSPACE": + if WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + role__in=allowed_role_values, + is_active=True, + ).exists(): + return view_func(instance, request, *args, **kwargs) + else: + is_user_has_allowed_role = ProjectMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + project_id=kwargs["project_id"], + role__in=allowed_role_values, + is_active=True, + ).exists() + + # Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role # noqa: E501 + if is_user_has_allowed_role: + return view_func(instance, request, *args, **kwargs) + elif ( + ProjectMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + project_id=kwargs["project_id"], + is_active=True, + ).exists() + and WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ): + return view_func(instance, request, *args, **kwargs) + + # Return permission denied if no conditions are met + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) + + return _wrapped_view + + return decorator diff --git a/apps/api/plane/utils/permissions/page.py b/apps/api/plane/utils/permissions/page.py new file mode 100644 index 00000000000..bea878f4c49 --- /dev/null +++ b/apps/api/plane/utils/permissions/page.py @@ -0,0 +1,121 @@ +from plane.db.models import ProjectMember, Page +from plane.app.permissions import ROLE + + +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +# Permission Mappings for workspace members +ADMIN = ROLE.ADMIN.value +MEMBER = ROLE.MEMBER.value +GUEST = ROLE.GUEST.value + + +class ProjectPagePermission(BasePermission): + """ + Custom permission to control access to pages within a workspace + based on user roles, page visibility (public/private), and feature flags. + """ + + def has_permission(self, request, view): + """ + Check basic project-level permissions before checking object-level permissions. + """ + if request.user.is_anonymous: + return False + + user_id = request.user.id + slug = view.kwargs.get("slug") + page_id = view.kwargs.get("page_id") + project_id = view.kwargs.get("project_id") + + # Hook for extended validation + extended_access, role = self._check_access_and_get_role(request, slug, project_id) + if extended_access is False: + return False + + if page_id: + page = Page.objects.get(id=page_id, workspace__slug=slug) + + # Allow access if the user is the owner of the page + if page.owned_by_id == user_id: + return True + + # Handle private page access + if page.access == Page.PRIVATE_ACCESS: + return self._has_private_page_action_access(request, slug, page, project_id) + + # Handle public page access + return self._has_public_page_action_access(request, role) + + def _check_project_member_access(self, request, slug, project_id): + """ + Check if the user is a project member. + """ + return ( + ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + project_id=project_id, + ) + .values_list("role", flat=True) + .first() + ) + + def _check_access_and_get_role(self, request, slug, project_id): + """ + Hook for extended access checking + Returns: True (allow), False (deny), None (continue with normal flow) + """ + role = self._check_project_member_access(request, slug, project_id) + if not role: + return False, None + return True, role + + def _has_private_page_action_access(self, request, slug, page, project_id): + """ + Check access to private pages. Override for feature flag logic. + """ + # Base implementation: only owner can access private pages + return False + + def _check_project_action_access(self, request, role): + method = request.method + + # Only admins can create (POST) pages + if method == "POST": + if role in [ADMIN, MEMBER]: + return True + return False + + # Safe methods (GET, HEAD, OPTIONS) allowed for all active roles + if method in SAFE_METHODS: + if role in [ADMIN, MEMBER, GUEST]: + return True + return False + + # PUT/PATCH: Admins and members can update + if method in ["PUT", "PATCH"]: + if role in [ADMIN, MEMBER]: + return True + return False + + # DELETE: Only admins can delete + if method == "DELETE": + if role in [ADMIN]: + return True + return False + + # Deny by default + return False + + def _has_public_page_action_access(self, request, role): + """ + Check if the user has permission to access a public page + and can perform operations on the page. + """ + project_member_exists = self._check_project_action_access(request, role) + if not project_member_exists: + return False + return True diff --git a/apps/api/plane/utils/permissions/project.py b/apps/api/plane/utils/permissions/project.py new file mode 100644 index 00000000000..a8c0f92a27a --- /dev/null +++ b/apps/api/plane/utils/permissions/project.py @@ -0,0 +1,139 @@ +# Third Party imports +from rest_framework.permissions import SAFE_METHODS, BasePermission + +# Module import +from plane.db.models import ProjectMember, WorkspaceMember +from plane.db.models.project import ROLE + + +class ProjectBasePermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user, is_active=True + ).exists() + + ## Only workspace owners or admins can create the projects + if request.method == "POST": + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + is_active=True, + ).exists() + + project_member_qs = ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project_id=view.project_id, + is_active=True, + ) + + ## Only project admins or workspace admin who is part of the project can access + + if project_member_qs.filter(role=ROLE.ADMIN.value).exists(): + return True + else: + return ( + project_member_qs.exists() + and WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ) + + +class ProjectMemberPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user, is_active=True + ).exists() + ## Only workspace owners or admins can create the projects + if request.method == "POST": + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + is_active=True, + ).exists() + + ## Only Project Admins can update project attributes + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + project_id=view.project_id, + is_active=True, + ).exists() + + +class ProjectEntityPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + # Handle requests based on project__identifier + if hasattr(view, "project_identifier") and view.project_identifier: + if request.method in SAFE_METHODS: + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project__identifier=view.project_identifier, + is_active=True, + ).exists() + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project_id=view.project_id, + is_active=True, + ).exists() + + ## Only project members or admins can create and edit the project attributes + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + project_id=view.project_id, + is_active=True, + ).exists() + + +class ProjectAdminPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role=ROLE.ADMIN.value, + project_id=view.project_id, + is_active=True, + ).exists() + + +class ProjectLitePermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project_id=view.project_id, + is_active=True, + ).exists() diff --git a/apps/api/plane/utils/permissions/workspace.py b/apps/api/plane/utils/permissions/workspace.py new file mode 100644 index 00000000000..8dc791c0cc9 --- /dev/null +++ b/apps/api/plane/utils/permissions/workspace.py @@ -0,0 +1,106 @@ +# Third Party imports +from rest_framework.permissions import BasePermission, SAFE_METHODS + +# Module imports +from plane.db.models import WorkspaceMember + + +# Permission Mappings +Admin = 20 +Member = 15 +Guest = 5 + + +# TODO: Move the below logic to python match - python v3.10 +class WorkSpaceBasePermission(BasePermission): + def has_permission(self, request, view): + # allow anyone to create a workspace + if request.user.is_anonymous: + return False + + if request.method == "POST": + return True + + ## Safe Methods + if request.method in SAFE_METHODS: + return True + + # allow only admins and owners to update the workspace settings + if request.method in ["PUT", "PATCH"]: + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Admin, Member], + is_active=True, + ).exists() + + # allow only owner to delete the workspace + if request.method == "DELETE": + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role=Admin, + is_active=True, + ).exists() + + +class WorkspaceOwnerPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user, role=Admin + ).exists() + + +class WorkSpaceAdminPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Admin, Member], + is_active=True, + ).exists() + + +class WorkspaceEntityPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user, is_active=True + ).exists() + + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Admin, Member], + is_active=True, + ).exists() + + +class WorkspaceViewerPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, workspace__slug=view.workspace_slug, is_active=True + ).exists() + + +class WorkspaceUserPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, workspace__slug=view.workspace_slug, is_active=True + ).exists() From 78f8c33d14f57a01a9a82583de80fd27020dc0f1 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 3 Nov 2025 18:10:55 +0530 Subject: [PATCH 4/7] refactor: update project member API endpoints for improved functionality - Renamed `ProjectMemberAPIEndpoint` to `ProjectMemberListCreateAPIEndpoint` and introduced `ProjectMemberDetailAPIEndpoint` for better separation of concerns. - Updated URL routing to reflect the new endpoint structure, allowing for distinct handling of member listing, creation, and detail retrieval. - Enhanced the detail endpoint to support fetching and updating specific project members. --- apps/api/plane/api/urls/member.py | 8 ++-- apps/api/plane/api/views/__init__.py | 2 +- apps/api/plane/api/views/member.py | 58 ++++++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/apps/api/plane/api/urls/member.py b/apps/api/plane/api/urls/member.py index bd030944a66..9b58e3bb775 100644 --- a/apps/api/plane/api/urls/member.py +++ b/apps/api/plane/api/urls/member.py @@ -1,17 +1,17 @@ from django.urls import path -from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint +from plane.api.views import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint urlpatterns = [ path( "workspaces//projects//members/", - ProjectMemberAPIEndpoint.as_view(http_method_names=["get", "post"]), + ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="project-members", ), path( "workspaces//projects//members//", - ProjectMemberAPIEndpoint.as_view(http_method_names=["patch", "delete"]), - name="project-members", + ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]), + name="project-member", ), path( "workspaces//members/", diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 0eef9cc0307..280c23bc239 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -43,7 +43,7 @@ ModuleArchiveUnarchiveAPIEndpoint, ) -from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint +from .member import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint from .intake import ( IntakeIssueListCreateAPIEndpoint, diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index 4fe63a30cc0..cacf30f5204 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -88,8 +88,7 @@ def get(self, request, slug): return Response(users_with_roles, status=status.HTTP_200_OK) -# API endpoint to get and insert users inside the workspace -class ProjectMemberAPIEndpoint(BaseAPIView): +class ProjectMemberListCreateAPIEndpoint(BaseAPIView): permission_classes = [ProjectMemberPermission] use_read_replica = True @@ -136,7 +135,6 @@ def get(self, request, slug, project_id): # Get all the users that are present inside the workspace users = UserLiteSerializer(User.objects.filter(id__in=project_members), many=True).data - return Response(users, status=status.HTTP_200_OK) @extend_schema( @@ -154,6 +152,60 @@ def post(self, request, slug, project_id): serializer.save(project_id=project_id) return Response(serializer.data, status=status.HTTP_201_CREATED) + +# API endpoint to get and update a project member +class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint): + def get_object(self): + return self.get_queryset().get(pk=self.kwargs.get("pk")) + + @extend_schema( + operation_id="get_project_member", + summary="Get project member", + description="Retrieve a project member by ID.", + tags=["Members"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={ + 200: OpenApiResponse(description="Project member", response=ProjectMemberSerializer), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + # Get a project member by ID + def get(self, request, slug, project_id, pk): + """Get project member + + Retrieve a project member by ID. + Returns a project member with their project-specific roles and access levels. + """ + # Check if the workspace exists + if not Workspace.objects.filter(slug=slug).exists(): + return Response( + {"error": "Provided workspace does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # 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) + + @extend_schema( + operation_id="create_project_member", + summary="Create project member", + description="Create a new project member", + tags=["Members"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={201: OpenApiResponse(description="Project member created", response=ProjectMemberSerializer)}, + request=OpenApiRequest(request=ProjectMemberSerializer), + ) + def post(self, request, slug, project_id): + serializer = ProjectMemberSerializer(data=request.data, context={"slug": slug}) + serializer.is_valid(raise_exception=True) + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + @extend_schema( operation_id="update_project_member", summary="Update project member", From 5275e21f181728909698171b39024ee0550fb123 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 4 Nov 2025 12:00:42 +0530 Subject: [PATCH 5/7] refactor: enhance member validation and permission checks in serializers and views - Updated `ProjectMemberSerializer` to include role validation, ensuring only valid roles are accepted. - Improved error handling for missing slug in member validation. - Refactored permission checks in `IssueAttachmentDetailAPIEndpoint` to use user ID instead of user object for consistency and clarity. - Cleaned up unused imports in `member.py` for better code organization. --- apps/api/plane/api/serializers/member.py | 14 +++++++++----- apps/api/plane/api/views/issue.py | 6 +++--- apps/api/plane/api/views/member.py | 1 - 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/api/plane/api/serializers/member.py b/apps/api/plane/api/serializers/member.py index bce782ce1df..3aa9644b4c4 100644 --- a/apps/api/plane/api/serializers/member.py +++ b/apps/api/plane/api/serializers/member.py @@ -5,6 +5,7 @@ from plane.db.models import ProjectMember, WorkspaceMember from .base import BaseSerializer from plane.db.models import User +from plane.utils.permissions import ROLE class ProjectMemberSerializer(BaseSerializer): @@ -18,17 +19,20 @@ class ProjectMemberSerializer(BaseSerializer): ) def validate_member(self, value): - slug = self.context["slug"] - + slug = self.context.get("slug") + if not slug: + raise serializers.ValidationError("Slug is required", code="INVALID_SLUG") if not value: raise serializers.ValidationError("Member is required", code="INVALID_MEMBER") - - if not User.objects.filter(id=value).exists(): - raise serializers.ValidationError("Member not found", code="INVALID_MEMBER") if not WorkspaceMember.objects.filter(workspace__slug=slug, member=value).exists(): raise serializers.ValidationError("Member not found in workspace", code="INVALID_MEMBER") return value + def validate_role(self, value): + if value not in [ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value]: + raise serializers.ValidationError("Invalid role", code="INVALID_ROLE") + return value + class Meta: model = ProjectMember fields = ["id", "member", "role"] diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index 5cf97c6468b..fe32fe3fddd 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -1961,7 +1961,7 @@ def delete(self, request, slug, project_id, issue_id, pk): issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id) # if the request user is creator or admin then delete the attachment if not user_has_issue_permission( - request.user, + request.user.id, project_id=project_id, issue=issue, allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value], @@ -2034,7 +2034,7 @@ def get(self, request, slug, project_id, issue_id, pk): """ # if the user is part of the project then allow the download if not user_has_issue_permission( - request.user, + request.user.id, project_id=project_id, issue=None, allowed_roles=None, @@ -2099,7 +2099,7 @@ def patch(self, request, slug, project_id, issue_id, pk): issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id) # if the user is creator or admin then allow the upload if not user_has_issue_permission( - request.user, + request.user.id, project_id=project_id, issue=issue, allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value], diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index cacf30f5204..fff27b68943 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -1,5 +1,4 @@ # Third Party imports -from calendar import c from rest_framework.response import Response from rest_framework import status from drf_spectacular.utils import ( From f0e9ac4fc629e9e115faf68cdb1a431dcc25bb2f Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 4 Nov 2025 12:21:21 +0530 Subject: [PATCH 6/7] refactor: remove unused methods and clean up project member detail endpoint - Removed the `get_object` method from `ProjectMemberDetailAPIEndpoint` as it was redundant. - Deleted the `post` method for creating project members, streamlining the endpoint to focus on retrieval and updates only. - Updated the endpoint documentation to reflect the changes in functionality. --- apps/api/plane/api/views/member.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index fff27b68943..854bc7ae67e 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -154,8 +154,6 @@ def post(self, request, slug, project_id): # API endpoint to get and update a project member class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint): - def get_object(self): - return self.get_queryset().get(pk=self.kwargs.get("pk")) @extend_schema( operation_id="get_project_member", @@ -190,21 +188,6 @@ def get(self, request, slug, project_id, pk): user = UserLiteSerializer(user).data return Response(user, status=status.HTTP_200_OK) - @extend_schema( - operation_id="create_project_member", - summary="Create project member", - description="Create a new project member", - tags=["Members"], - parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], - responses={201: OpenApiResponse(description="Project member created", response=ProjectMemberSerializer)}, - request=OpenApiRequest(request=ProjectMemberSerializer), - ) - def post(self, request, slug, project_id): - serializer = ProjectMemberSerializer(data=request.data, context={"slug": slug}) - serializer.is_valid(raise_exception=True) - serializer.save(project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - @extend_schema( operation_id="update_project_member", summary="Update project member", From f823a89bc5678e660ce080d07bb73964e0e17138 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 4 Nov 2025 13:21:26 +0530 Subject: [PATCH 7/7] feat: add additional project member API endpoints for improved access - Introduced new URL paths for managing project members, including a dedicated endpoint for listing and creating project members. - Added a detail endpoint for specific project members to enhance retrieval and update capabilities. - Updated existing URL patterns to maintain consistency and clarity in member management. --- apps/api/plane/api/urls/member.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/api/plane/api/urls/member.py b/apps/api/plane/api/urls/member.py index 9b58e3bb775..a33d8bbe353 100644 --- a/apps/api/plane/api/urls/member.py +++ b/apps/api/plane/api/urls/member.py @@ -3,6 +3,7 @@ from plane.api.views import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint urlpatterns = [ + # Project members path( "workspaces//projects//members/", ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), @@ -13,6 +14,16 @@ ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]), name="project-member", ), + path( + "workspaces//projects//project-members/", + ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="project-members", + ), + path( + "workspaces//projects//project-members//", + ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]), + name="project-member", + ), path( "workspaces//members/", WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]),