[WEB-5237] feat: add workspace invitation and project member management endpoints (#8059)

This commit is contained in:
Nikhil 2025-11-04 14:56:21 +05:30 committed by GitHub
parent 96bbbec588
commit 3c6f24de64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 982 additions and 17 deletions

View file

@ -53,3 +53,5 @@ from .asset import (
GenericAssetUpdateSerializer,
FileAssetSerializer,
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer

View file

@ -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

View file

@ -0,0 +1,39 @@
# 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
from plane.utils.permissions import ROLE
class ProjectMemberSerializer(BaseSerializer):
"""
Serializer for project members.
"""
member = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
required=True,
)
def validate_member(self, value):
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 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"]
read_only_fields = ["id"]

View file

@ -8,6 +8,7 @@ from .project import urlpatterns as project_patterns
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 @@ urlpatterns = [
*state_patterns,
*user_patterns,
*work_item_patterns,
*invite_patterns,
]

View file

@ -0,0 +1,18 @@
# Django imports
from django.urls import path, include
# 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/<str:slug>/", include(router.urls)),
]

View file

@ -1,13 +1,29 @@
from django.urls import path
from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
from plane.api.views import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint
urlpatterns = [
# Project members
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project-members",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/<uuid:pk>/",
ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]),
name="project-member",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project-members",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/<uuid:pk>/",
ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]),
name="project-member",
),
path(
"workspaces/<str:slug>/members/",
WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]),

View file

@ -43,7 +43,7 @@ from .module import (
ModuleArchiveUnarchiveAPIEndpoint,
)
from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
from .member import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint
from .intake import (
IntakeIssueListCreateAPIEndpoint,
@ -53,3 +53,5 @@ from .intake import (
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
from .user import UserEndpoint
from .invite import WorkspaceInvitationsViewset

View file

@ -1,5 +1,6 @@
# Python imports
import zoneinfo
import logging
# Django imports
from django.conf import settings
@ -7,15 +8,19 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
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.paginator import BasePaginator
from plane.utils.core.mixins import ReadReplicaControlMixin
logger = logging.getLogger("plane.api")
class TimezoneMixin:
"""
This enables timezone conversion according
@ -152,3 +160,118 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa
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

View file

@ -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.utils.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)

View file

@ -1808,7 +1808,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
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(
@ -1961,10 +1961,10 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
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],
allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value],
allow_creator=True,
):
return Response(
@ -2034,7 +2034,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
"""
# 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,10 +2099,10 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
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],
allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value],
allow_creator=True,
):
return Response(

View file

@ -4,13 +4,14 @@ 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.utils.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission
from plane.utils.openapi import (
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
@ -86,11 +87,15 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
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
def get_permissions(self):
if self.request.method == "GET":
return [ProjectMemberPermission()]
return [ProjectAdminPermission()]
@extend_schema(
operation_id="get_project_members",
summary="List project members",
@ -129,5 +134,86 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
# 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(
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)
# API endpoint to get and update a project member
class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint):
@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="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)