Skip to content

Commit 9007c8e

Browse files
pablohashescobarClarenceChen0627
authored andcommitted
[WEB-5237] feat: add workspace invitation and project member management endpoints (makeplane#8059)
1 parent 733406e commit 9007c8e

File tree

18 files changed

+982
-17
lines changed

18 files changed

+982
-17
lines changed

apps/api/plane/api/serializers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,5 @@
5353
GenericAssetUpdateSerializer,
5454
FileAssetSerializer,
5555
)
56+
from .invite import WorkspaceInviteSerializer
57+
from .member import ProjectMemberSerializer
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Django imports
2+
from django.core.exceptions import ValidationError
3+
from django.core.validators import validate_email
4+
from rest_framework import serializers
5+
6+
# Module imports
7+
from plane.db.models import WorkspaceMemberInvite
8+
from .base import BaseSerializer
9+
from plane.app.permissions.base import ROLE
10+
11+
12+
class WorkspaceInviteSerializer(BaseSerializer):
13+
"""
14+
Serializer for workspace invites.
15+
"""
16+
17+
class Meta:
18+
model = WorkspaceMemberInvite
19+
fields = [
20+
"id",
21+
"email",
22+
"role",
23+
"created_at",
24+
"updated_at",
25+
"responded_at",
26+
"accepted",
27+
]
28+
read_only_fields = [
29+
"id",
30+
"workspace",
31+
"created_at",
32+
"updated_at",
33+
"responded_at",
34+
"accepted",
35+
]
36+
37+
def validate_email(self, value):
38+
try:
39+
validate_email(value)
40+
except ValidationError:
41+
raise serializers.ValidationError("Invalid email address", code="INVALID_EMAIL_ADDRESS")
42+
return value
43+
44+
def validate_role(self, value):
45+
if value not in [ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value]:
46+
raise serializers.ValidationError("Invalid role", code="INVALID_WORKSPACE_MEMBER_ROLE")
47+
return value
48+
49+
def validate(self, data):
50+
slug = self.context["slug"]
51+
if (
52+
data.get("email")
53+
and WorkspaceMemberInvite.objects.filter(email=data["email"], workspace__slug=slug).exists()
54+
):
55+
raise serializers.ValidationError("Email already invited", code="EMAIL_ALREADY_INVITED")
56+
return data
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Third party imports
2+
from rest_framework import serializers
3+
4+
# Module imports
5+
from plane.db.models import ProjectMember, WorkspaceMember
6+
from .base import BaseSerializer
7+
from plane.db.models import User
8+
from plane.utils.permissions import ROLE
9+
10+
11+
class ProjectMemberSerializer(BaseSerializer):
12+
"""
13+
Serializer for project members.
14+
"""
15+
16+
member = serializers.PrimaryKeyRelatedField(
17+
queryset=User.objects.all(),
18+
required=True,
19+
)
20+
21+
def validate_member(self, value):
22+
slug = self.context.get("slug")
23+
if not slug:
24+
raise serializers.ValidationError("Slug is required", code="INVALID_SLUG")
25+
if not value:
26+
raise serializers.ValidationError("Member is required", code="INVALID_MEMBER")
27+
if not WorkspaceMember.objects.filter(workspace__slug=slug, member=value).exists():
28+
raise serializers.ValidationError("Member not found in workspace", code="INVALID_MEMBER")
29+
return value
30+
31+
def validate_role(self, value):
32+
if value not in [ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value]:
33+
raise serializers.ValidationError("Invalid role", code="INVALID_ROLE")
34+
return value
35+
36+
class Meta:
37+
model = ProjectMember
38+
fields = ["id", "member", "role"]
39+
read_only_fields = ["id"]

apps/api/plane/api/urls/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .state import urlpatterns as state_patterns
99
from .user import urlpatterns as user_patterns
1010
from .work_item import urlpatterns as work_item_patterns
11+
from .invite import urlpatterns as invite_patterns
1112

1213
urlpatterns = [
1314
*asset_patterns,
@@ -20,4 +21,5 @@
2021
*state_patterns,
2122
*user_patterns,
2223
*work_item_patterns,
24+
*invite_patterns,
2325
]

apps/api/plane/api/urls/invite.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Django imports
2+
from django.urls import path, include
3+
4+
# Third party imports
5+
from rest_framework.routers import DefaultRouter
6+
7+
# Module imports
8+
from plane.api.views import WorkspaceInvitationsViewset
9+
10+
11+
# Create router with just the invitations prefix (no workspace slug)
12+
router = DefaultRouter()
13+
router.register(r"invitations", WorkspaceInvitationsViewset, basename="workspace-invitations")
14+
15+
# Wrap the router URLs with the workspace slug path
16+
urlpatterns = [
17+
path("workspaces/<str:slug>/", include(router.urls)),
18+
]

apps/api/plane/api/urls/member.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
11
from django.urls import path
22

3-
from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
3+
from plane.api.views import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint
44

55
urlpatterns = [
6+
# Project members
67
path(
78
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
8-
ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
9+
ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
910
name="project-members",
1011
),
12+
path(
13+
"workspaces/<str:slug>/projects/<uuid:project_id>/members/<uuid:pk>/",
14+
ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]),
15+
name="project-member",
16+
),
17+
path(
18+
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
19+
ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
20+
name="project-members",
21+
),
22+
path(
23+
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/<uuid:pk>/",
24+
ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]),
25+
name="project-member",
26+
),
1127
path(
1228
"workspaces/<str:slug>/members/",
1329
WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]),

apps/api/plane/api/views/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
ModuleArchiveUnarchiveAPIEndpoint,
4444
)
4545

46-
from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
46+
from .member import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint
4747

4848
from .intake import (
4949
IntakeIssueListCreateAPIEndpoint,
@@ -53,3 +53,5 @@
5353
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
5454

5555
from .user import UserEndpoint
56+
57+
from .invite import WorkspaceInvitationsViewset

apps/api/plane/api/views/base.py

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,36 @@
11
# Python imports
22
import zoneinfo
3+
import logging
34

45
# Django imports
56
from django.conf import settings
67
from django.core.exceptions import ObjectDoesNotExist, ValidationError
78
from django.db import IntegrityError
89
from django.urls import resolve
910
from django.utils import timezone
10-
from plane.db.models.api import APIToken
11+
12+
# Third party imports
1113
from rest_framework import status
1214
from rest_framework.permissions import IsAuthenticated
1315
from rest_framework.response import Response
14-
15-
# Third party imports
16+
from django_filters.rest_framework import DjangoFilterBackend
17+
from rest_framework.filters import SearchFilter
18+
from rest_framework.viewsets import ModelViewSet
19+
from rest_framework.exceptions import APIException
1620
from rest_framework.generics import GenericAPIView
1721

1822
# Module imports
23+
from plane.db.models.api import APIToken
1924
from plane.api.middleware.api_authentication import APIKeyAuthentication
2025
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
2126
from plane.utils.exception_logger import log_exception
2227
from plane.utils.paginator import BasePaginator
2328
from plane.utils.core.mixins import ReadReplicaControlMixin
2429

2530

31+
logger = logging.getLogger("plane.api")
32+
33+
2634
class TimezoneMixin:
2735
"""
2836
This enables timezone conversion according
@@ -152,3 +160,118 @@ def fields(self):
152160
def expand(self):
153161
expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand]
154162
return expand if expand else None
163+
164+
165+
class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePaginator):
166+
model = None
167+
168+
authentication_classes = [APIKeyAuthentication]
169+
permission_classes = [
170+
IsAuthenticated,
171+
]
172+
use_read_replica = False
173+
174+
def get_queryset(self):
175+
try:
176+
return self.model.objects.all()
177+
except Exception as e:
178+
log_exception(e)
179+
raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST)
180+
181+
def handle_exception(self, exc):
182+
"""
183+
Handle any exception that occurs, by returning an appropriate response,
184+
or re-raising the error.
185+
"""
186+
try:
187+
response = super().handle_exception(exc)
188+
return response
189+
except Exception as e:
190+
if isinstance(e, IntegrityError):
191+
log_exception(e)
192+
return Response(
193+
{"error": "The payload is not valid"},
194+
status=status.HTTP_400_BAD_REQUEST,
195+
)
196+
197+
if isinstance(e, ValidationError):
198+
logger.warning(
199+
"Validation Error",
200+
extra={
201+
"error_code": "VALIDATION_ERROR",
202+
"error_message": str(e),
203+
},
204+
)
205+
return Response(
206+
{"error": "Please provide valid detail"},
207+
status=status.HTTP_400_BAD_REQUEST,
208+
)
209+
210+
if isinstance(e, ObjectDoesNotExist):
211+
logger.warning(
212+
"Object Does Not Exist",
213+
extra={
214+
"error_code": "OBJECT_DOES_NOT_EXIST",
215+
"error_message": str(e),
216+
},
217+
)
218+
return Response(
219+
{"error": "The required object does not exist."},
220+
status=status.HTTP_404_NOT_FOUND,
221+
)
222+
223+
if isinstance(e, KeyError):
224+
logger.error(
225+
"Key Error",
226+
extra={
227+
"error_code": "KEY_ERROR",
228+
"error_message": str(e),
229+
},
230+
)
231+
return Response(
232+
{"error": "The required key does not exist."},
233+
status=status.HTTP_400_BAD_REQUEST,
234+
)
235+
236+
log_exception(e)
237+
return Response(
238+
{"error": "Something went wrong please try again later"},
239+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
240+
)
241+
242+
def dispatch(self, request, *args, **kwargs):
243+
try:
244+
response = super().dispatch(request, *args, **kwargs)
245+
246+
if settings.DEBUG:
247+
from django.db import connection
248+
249+
print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}")
250+
251+
return response
252+
except Exception as exc:
253+
response = self.handle_exception(exc)
254+
return response
255+
256+
@property
257+
def workspace_slug(self):
258+
return self.kwargs.get("slug", None)
259+
260+
@property
261+
def project_id(self):
262+
project_id = self.kwargs.get("project_id", None)
263+
if project_id:
264+
return project_id
265+
266+
if resolve(self.request.path_info).url_name == "project":
267+
return self.kwargs.get("pk", None)
268+
269+
@property
270+
def fields(self):
271+
fields = [field for field in self.request.GET.get("fields", "").split(",") if field]
272+
return fields if fields else None
273+
274+
@property
275+
def expand(self):
276+
expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand]
277+
return expand if expand else None

0 commit comments

Comments
 (0)