From bdbdacd68c3757e465d7413739414a13e985217a Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 15 Nov 2023 15:53:16 +0530 Subject: [PATCH] chore: user workflow (#2762) * dev: workspace member deactivation and leave endpoints and filters * dev: deactivated for project members * dev: project members leave * dev: project member check on workspace deactivation * dev: project member queryset update and remove leave project endpoint * dev: rename is_deactivated to is_active and user deactivation apis * dev: check if the user is already part of workspace then make them active * dev: workspace and project save * dev: update project members to make them active * dev: project invitation * dev: automatic user workspace and project member create when user sign in/up * dev: fix member invites * dev: rename deactivation variable * dev: update project member invitation * dev: additional permission layer for workspace * dev: update the url for workspace invitations * dev: remove invitation urls from users * dev: cleanup workspace invitation workflow * dev: workspace and project invitation --- apiserver/plane/api/permissions/__init__.py | 2 +- apiserver/plane/api/permissions/project.py | 21 +- apiserver/plane/api/permissions/workspace.py | 24 +- apiserver/plane/api/serializers/project.py | 5 +- apiserver/plane/api/urls/project.py | 78 ++- apiserver/plane/api/urls/user.py | 39 +- apiserver/plane/api/urls/workspace.py | 47 +- apiserver/plane/api/views/__init__.py | 12 +- apiserver/plane/api/views/authentication.py | 362 +++++++++--- apiserver/plane/api/views/inbox.py | 144 +++-- apiserver/plane/api/views/issue.py | 18 +- apiserver/plane/api/views/notification.py | 10 +- apiserver/plane/api/views/oauth.py | 222 ++++++-- apiserver/plane/api/views/project.py | 518 ++++++++++-------- apiserver/plane/api/views/user.py | 30 +- apiserver/plane/api/views/workspace.py | 393 +++++++------ apiserver/plane/bgtasks/importer_task.py | 6 + .../plane/bgtasks/project_invitation_task.py | 9 +- .../bgtasks/workspace_invitation_task.py | 18 +- ...n_projectmember_is_deactivated_and_more.py | 26 + ...e_projectmember_is_deactivated_and_more.py | 26 + ...e_projectmember_is_deactivated_and_more.py | 24 + apiserver/plane/db/models/project.py | 1 + apiserver/plane/db/models/workspace.py | 1 + .../invitations/project_invitation.html | 2 +- 25 files changed, 1318 insertions(+), 720 deletions(-) create mode 100644 apiserver/plane/db/migrations/0047_issuemention_projectmember_is_deactivated_and_more.py create mode 100644 apiserver/plane/db/migrations/0048_issuemention_remove_projectmember_is_deactivated_and_more.py create mode 100644 apiserver/plane/db/migrations/0049_issuemention_remove_projectmember_is_deactivated_and_more.py diff --git a/apiserver/plane/api/permissions/__init__.py b/apiserver/plane/api/permissions/__init__.py index 8b15a9373..9164a5529 100644 --- a/apiserver/plane/api/permissions/__init__.py +++ b/apiserver/plane/api/permissions/__init__.py @@ -1,2 +1,2 @@ -from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission, WorkspaceViewerPermission +from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission, WorkspaceViewerPermission, WorkspaceUserPermission from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission diff --git a/apiserver/plane/api/permissions/project.py b/apiserver/plane/api/permissions/project.py index 4f907dbd6..80775cbf6 100644 --- a/apiserver/plane/api/permissions/project.py +++ b/apiserver/plane/api/permissions/project.py @@ -13,14 +13,15 @@ Guest = 5 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 + workspace__slug=view.workspace_slug, + member=request.user, + is_active=True, ).exists() ## Only workspace owners or admins can create the projects @@ -29,6 +30,7 @@ class ProjectBasePermission(BasePermission): workspace__slug=view.workspace_slug, member=request.user, role__in=[Admin, Member], + is_active=True, ).exists() ## Only Project Admins can update project attributes @@ -37,19 +39,21 @@ class ProjectBasePermission(BasePermission): member=request.user, role=Admin, project_id=view.project_id, + 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 + 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": @@ -57,6 +61,7 @@ class ProjectMemberPermission(BasePermission): workspace__slug=view.workspace_slug, member=request.user, role__in=[Admin, Member], + is_active=True, ).exists() ## Only Project Admins can update project attributes @@ -65,12 +70,12 @@ class ProjectMemberPermission(BasePermission): member=request.user, role__in=[Admin, Member], 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 @@ -80,6 +85,7 @@ class ProjectEntityPermission(BasePermission): 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 @@ -88,17 +94,18 @@ class ProjectEntityPermission(BasePermission): member=request.user, role__in=[Admin, Member], 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/apiserver/plane/api/permissions/workspace.py b/apiserver/plane/api/permissions/workspace.py index 66e836614..b2f5753a5 100644 --- a/apiserver/plane/api/permissions/workspace.py +++ b/apiserver/plane/api/permissions/workspace.py @@ -32,12 +32,16 @@ class WorkSpaceBasePermission(BasePermission): member=request.user, workspace__slug=view.workspace_slug, role__in=[Owner, Admin], + 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=Owner + member=request.user, + workspace__slug=view.workspace_slug, + role=Owner, + is_active=True, ).exists() @@ -50,6 +54,7 @@ class WorkSpaceAdminPermission(BasePermission): member=request.user, workspace__slug=view.workspace_slug, role__in=[Owner, Admin], + is_active=True, ).exists() @@ -63,12 +68,14 @@ class WorkspaceEntityPermission(BasePermission): 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=[Owner, Admin], + is_active=True, ).exists() @@ -78,5 +85,18 @@ class WorkspaceViewerPermission(BasePermission): return False return WorkspaceMember.objects.filter( - member=request.user, workspace__slug=view.workspace_slug, role__gte=10 + member=request.user, + workspace__slug=view.workspace_slug, + role__gte=10, + is_active=True, ).exists() + + +class WorkspaceUserPermission(BasePermission): + + def has_permission(self, request, view): + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + is_active=True, + ) diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 36fa6ecca..ca42dc8f7 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -103,7 +103,10 @@ class ProjectListSerializer(DynamicBaseSerializer): members = serializers.SerializerMethodField() def get_members(self, obj): - project_members = ProjectMember.objects.filter(project_id=obj.id).values( + project_members = ProjectMember.objects.filter( + project_id=obj.id, + is_active=True, + ).values( "id", "member_id", "member__display_name", diff --git a/apiserver/plane/api/urls/project.py b/apiserver/plane/api/urls/project.py index 2d9e513df..83bb765e6 100644 --- a/apiserver/plane/api/urls/project.py +++ b/apiserver/plane/api/urls/project.py @@ -2,17 +2,16 @@ from django.urls import path from plane.api.views import ( ProjectViewSet, - InviteProjectEndpoint, + ProjectInvitationsViewset, ProjectMemberViewSet, - ProjectMemberInvitationsViewset, ProjectMemberUserEndpoint, ProjectJoinEndpoint, AddTeamToProjectEndpoint, ProjectUserViewsEndpoint, ProjectIdentifierEndpoint, ProjectFavoritesViewSet, - LeaveProjectEndpoint, ProjectPublicCoverImagesEndpoint, + UserProjectInvitationsViewset, ) @@ -45,13 +44,48 @@ urlpatterns = [ name="project-identifiers", ), path( - "workspaces//projects//invite/", - InviteProjectEndpoint.as_view(), - name="invite-project", + "workspaces//projects//invitations/", + ProjectInvitationsViewset.as_view( + { + "get": "list", + "post": "create", + }, + ), + name="project-member-invite", + ), + path( + "workspaces//projects//invitations//", + ProjectInvitationsViewset.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + name="project-member-invite", + ), + path( + "users/me/invitations/projects/", + UserProjectInvitationsViewset.as_view( + { + "get": "list", + "post": "create", + }, + ), + name="user-project-invitations", + ), + path( + "workspaces//projects/join/", + ProjectJoinEndpoint.as_view(), + name="project-join", ), path( "workspaces//projects//members/", - ProjectMemberViewSet.as_view({"get": "list", "post": "create"}), + ProjectMemberViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), name="project-member", ), path( @@ -66,30 +100,19 @@ urlpatterns = [ name="project-member", ), path( - "workspaces//projects/join/", - ProjectJoinEndpoint.as_view(), - name="project-join", + "workspaces//projects//members/leave/", + ProjectMemberViewSet.as_view( + { + "post": "leave", + } + ), + name="project-member", ), path( "workspaces//projects//team-invite/", AddTeamToProjectEndpoint.as_view(), name="projects", ), - path( - "workspaces//projects//invitations/", - ProjectMemberInvitationsViewset.as_view({"get": "list"}), - name="project-member-invite", - ), - path( - "workspaces//projects//invitations//", - ProjectMemberInvitationsViewset.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - name="project-member-invite", - ), path( "workspaces//projects//project-views/", ProjectUserViewsEndpoint.as_view(), @@ -119,11 +142,6 @@ urlpatterns = [ ), name="project-favorite", ), - path( - "workspaces//projects//members/leave/", - LeaveProjectEndpoint.as_view(), - name="leave-project", - ), path( "project-covers/", ProjectPublicCoverImagesEndpoint.as_view(), diff --git a/apiserver/plane/api/urls/user.py b/apiserver/plane/api/urls/user.py index 5282a7cf6..00f95cd42 100644 --- a/apiserver/plane/api/urls/user.py +++ b/apiserver/plane/api/urls/user.py @@ -9,15 +9,10 @@ from plane.api.views import ( ChangePasswordEndpoint, ## End User ## Workspaces - UserWorkspaceInvitationsEndpoint, UserWorkSpacesEndpoint, - JoinWorkspaceEndpoint, - UserWorkspaceInvitationsEndpoint, - UserWorkspaceInvitationEndpoint, UserActivityGraphEndpoint, UserIssueCompletedGraphEndpoint, UserWorkspaceDashboardEndpoint, - UserProjectInvitationsViewset, ## End Workspaces ) @@ -26,7 +21,11 @@ urlpatterns = [ path( "users/me/", UserEndpoint.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + { + "get": "retrieve", + "patch": "partial_update", + "delete": "deactivate", + } ), name="users", ), @@ -65,23 +64,6 @@ urlpatterns = [ UserWorkSpacesEndpoint.as_view(), name="user-workspace", ), - # user workspace invitations - path( - "users/me/invitations/workspaces/", - UserWorkspaceInvitationsEndpoint.as_view({"get": "list", "post": "create"}), - name="user-workspace-invitations", - ), - # user workspace invitation - path( - "users/me/invitations//", - UserWorkspaceInvitationEndpoint.as_view( - { - "get": "retrieve", - } - ), - name="user-workspace-invitation", - ), - # user join workspace # User Graphs path( "users/me/workspaces//activity-graph/", @@ -99,15 +81,4 @@ urlpatterns = [ name="user-workspace-dashboard", ), ## End User Graph - path( - "users/me/invitations/workspaces///join/", - JoinWorkspaceEndpoint.as_view(), - name="user-join-workspace", - ), - # user project invitations - path( - "users/me/invitations/projects/", - UserProjectInvitationsViewset.as_view({"get": "list", "post": "create"}), - name="user-project-invitations", - ), ] diff --git a/apiserver/plane/api/urls/workspace.py b/apiserver/plane/api/urls/workspace.py index f26730833..64e558f10 100644 --- a/apiserver/plane/api/urls/workspace.py +++ b/apiserver/plane/api/urls/workspace.py @@ -2,8 +2,9 @@ from django.urls import path from plane.api.views import ( + UserWorkspaceInvitationsViewSet, WorkSpaceViewSet, - InviteWorkspaceEndpoint, + WorkspaceJoinEndpoint, WorkSpaceMemberViewSet, WorkspaceInvitationsViewset, WorkspaceMemberUserEndpoint, @@ -17,7 +18,6 @@ from plane.api.views import ( WorkspaceUserProfileEndpoint, WorkspaceUserProfileIssuesEndpoint, WorkspaceLabelsEndpoint, - LeaveWorkspaceEndpoint, ) @@ -49,14 +49,14 @@ urlpatterns = [ ), name="workspace", ), - path( - "workspaces//invite/", - InviteWorkspaceEndpoint.as_view(), - name="invite-workspace", - ), path( "workspaces//invitations/", - WorkspaceInvitationsViewset.as_view({"get": "list"}), + WorkspaceInvitationsViewset.as_view( + { + "get": "list", + "post": "create", + }, + ), name="workspace-invitations", ), path( @@ -69,6 +69,23 @@ urlpatterns = [ ), name="workspace-invitations", ), + # user workspace invitations + path( + "users/me/workspaces/invitations/", + UserWorkspaceInvitationsViewSet.as_view( + { + "get": "list", + "post": "create", + }, + ), + name="user-workspace-invitations", + ), + path( + "workspaces//invitations//join/", + WorkspaceJoinEndpoint.as_view(), + name="workspace-join", + ), + # user join workspace path( "workspaces//members/", WorkSpaceMemberViewSet.as_view({"get": "list"}), @@ -85,6 +102,15 @@ urlpatterns = [ ), name="workspace-member", ), + path( + "workspaces//members/leave/", + WorkSpaceMemberViewSet.as_view( + { + "post": "leave", + }, + ), + name="leave-workspace-members", + ), path( "workspaces//teams/", TeamMemberViewSet.as_view( @@ -168,9 +194,4 @@ urlpatterns = [ WorkspaceLabelsEndpoint.as_view(), name="workspace-labels", ), - path( - "workspaces//members/leave/", - LeaveWorkspaceEndpoint.as_view(), - name="leave-workspace-members", - ), ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index ca66ce48e..78c7ef341 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -2,10 +2,8 @@ from .project import ( ProjectViewSet, ProjectMemberViewSet, UserProjectInvitationsViewset, - InviteProjectEndpoint, + ProjectInvitationsViewset, AddTeamToProjectEndpoint, - ProjectMemberInvitationsViewset, - ProjectMemberInviteDetailViewSet, ProjectIdentifierEndpoint, ProjectJoinEndpoint, ProjectUserViewsEndpoint, @@ -14,7 +12,6 @@ from .project import ( ProjectDeployBoardViewSet, ProjectDeployBoardPublicSettingsEndpoint, WorkspaceProjectDeployBoardEndpoint, - LeaveProjectEndpoint, ProjectPublicCoverImagesEndpoint, ) from .user import ( @@ -32,13 +29,11 @@ from .workspace import ( WorkSpaceViewSet, UserWorkSpacesEndpoint, WorkSpaceAvailabilityCheckEndpoint, - InviteWorkspaceEndpoint, - JoinWorkspaceEndpoint, + WorkspaceJoinEndpoint, WorkSpaceMemberViewSet, TeamMemberViewSet, WorkspaceInvitationsViewset, - UserWorkspaceInvitationsEndpoint, - UserWorkspaceInvitationEndpoint, + UserWorkspaceInvitationsViewSet, UserLastProjectWithWorkspaceEndpoint, WorkspaceMemberUserEndpoint, WorkspaceMemberUserViewsEndpoint, @@ -51,7 +46,6 @@ from .workspace import ( WorkspaceUserProfileEndpoint, WorkspaceUserProfileIssuesEndpoint, WorkspaceLabelsEndpoint, - LeaveWorkspaceEndpoint, ) from .state import StateViewSet from .view import ( diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index dadee4a48..fe7b4c473 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -4,7 +4,7 @@ import random import string import json import requests - +from requests.exceptions import RequestException # Django imports from django.utils import timezone from django.core.exceptions import ValidationError @@ -22,8 +22,13 @@ from sentry_sdk import capture_exception, capture_message # Module imports from . import BaseAPIView -from plane.db.models import User -from plane.api.serializers import UserSerializer +from plane.db.models import ( + User, + WorkspaceMemberInvite, + WorkspaceMember, + ProjectMemberInvite, + ProjectMember, +) from plane.settings.redis import redis_instance from plane.bgtasks.magic_link_code_task import magic_link @@ -86,35 +91,93 @@ class SignUpEndpoint(BaseAPIView): user.token_updated_at = timezone.now() user.save() + # Check if user has any accepted invites for workspace and add them to workspace + workspace_member_invites = WorkspaceMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=workspace_member_invite.workspace_id, + member=user, + role=workspace_member_invite.role, + ) + for workspace_member_invite in workspace_member_invites + ], + ignore_conflicts=True, + ) + + # Check if user has any project invites + project_member_invites = ProjectMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + # Add user to workspace + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Now add the users to project + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + # Delete all the invites + workspace_member_invites.delete() + project_member_invites.delete() + + try: + # Send Analytics + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, + }, + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": "email", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get("HTTP_USER_AGENT"), + }, + "event_type": "SIGN_UP", + }, + ) + except RequestException as e: + capture_exception(e) + access_token, refresh_token = get_tokens_for_user(user) data = { "access_token": access_token, "refresh_token": refresh_token, } - - # Send Analytics - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": "email", - }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), - }, - "event_type": "SIGN_UP", - }, - ) - return Response(data, status=status.HTTP_200_OK) @@ -176,33 +239,92 @@ class SignInEndpoint(BaseAPIView): user.token_updated_at = timezone.now() user.save() - access_token, refresh_token = get_tokens_for_user(user) - # Send Analytics - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": "email", + # Check if user has any accepted invites for workspace and add them to workspace + workspace_member_invites = WorkspaceMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=workspace_member_invite.workspace_id, + member=user, + role=workspace_member_invite.role, + ) + for workspace_member_invite in workspace_member_invites + ], + ignore_conflicts=True, + ) + + # Check if user has any project invites + project_member_invites = ProjectMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + # Add user to workspace + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Now add the users to project + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Delete all the invites + workspace_member_invites.delete() + project_member_invites.delete() + try: + # Send Analytics + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": "email", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get("HTTP_USER_AGENT"), + }, + "event_type": "SIGN_IN", }, - "event_type": "SIGN_IN", - }, - ) + ) + except RequestException as e: + capture_exception(e) + data = { "access_token": access_token, "refresh_token": refresh_token, } - + access_token, refresh_token = get_tokens_for_user(user) return Response(data, status=status.HTTP_200_OK) @@ -320,27 +442,37 @@ class MagicSignInEndpoint(BaseAPIView): if str(token) == str(user_token): if User.objects.filter(email=email).exists(): user = User.objects.get(email=email) - # Send event to Jitsu for tracking - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": "code", - }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), - }, - "event_type": "SIGN_IN", + if not user.is_active: + return Response( + { + "error": "Your account has been deactivated. Please contact your site administrator." }, + status=status.HTTP_403_FORBIDDEN, ) + try: + # Send event to Jitsu for tracking + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, + }, + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": "code", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get("HTTP_USER_AGENT"), + }, + "event_type": "SIGN_IN", + }, + ) + except RequestException as e: + capture_exception(e) else: user = User.objects.create( email=email, @@ -348,27 +480,30 @@ class MagicSignInEndpoint(BaseAPIView): password=make_password(uuid.uuid4().hex), is_password_autoset=True, ) - # Send event to Jitsu for tracking - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": "code", + try: + # Send event to Jitsu for tracking + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": "code", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get("HTTP_USER_AGENT"), + }, + "event_type": "SIGN_UP", }, - "event_type": "SIGN_UP", - }, - ) + ) + except RequestException as e: + capture_exception(e) user.last_active = timezone.now() user.last_login_time = timezone.now() @@ -377,6 +512,63 @@ class MagicSignInEndpoint(BaseAPIView): user.token_updated_at = timezone.now() user.save() + # Check if user has any accepted invites for workspace and add them to workspace + workspace_member_invites = WorkspaceMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=workspace_member_invite.workspace_id, + member=user, + role=workspace_member_invite.role, + ) + for workspace_member_invite in workspace_member_invites + ], + ignore_conflicts=True, + ) + + # Check if user has any project invites + project_member_invites = ProjectMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + # Add user to workspace + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Now add the users to project + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Delete all the invites + workspace_member_invites.delete() + project_member_invites.delete() + access_token, refresh_token = get_tokens_for_user(user) data = { "access_token": access_token, diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 517e9b6de..999d0a459 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -64,9 +64,7 @@ class InboxViewSet(BaseViewSet): serializer.save(project_id=self.kwargs.get("project_id")) def destroy(self, request, slug, project_id, pk): - inbox = Inbox.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + inbox = Inbox.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) # Handle default inbox delete if inbox.is_default: return Response( @@ -128,9 +126,7 @@ class InboxIssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -150,7 +146,6 @@ class InboxIssueViewSet(BaseViewSet): status=status.HTTP_200_OK, ) - def create(self, request, slug, project_id, inbox_id): if not request.data.get("issue", {}).get("name", False): return Response( @@ -198,7 +193,7 @@ class InboxIssueViewSet(BaseViewSet): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, - epoch=int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) # create an inbox issue InboxIssue.objects.create( @@ -216,10 +211,20 @@ class InboxIssueViewSet(BaseViewSet): pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id ) # Get the project member - project_member = ProjectMember.objects.get(workspace__slug=slug, project_id=project_id, member=request.user) + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ) # Only project members admins and created_by users can access this endpoint - if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id): - return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST) + if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( + request.user.id + ): + return Response( + {"error": "You cannot edit inbox issues"}, + status=status.HTTP_400_BAD_REQUEST, + ) # Get issue data issue_data = request.data.pop("issue", False) @@ -230,11 +235,13 @@ class InboxIssueViewSet(BaseViewSet): ) # Only allow guests and viewers to edit name and description if project_member.role <= 10: - # viewers and guests since only viewers and guests + # viewers and guests since only viewers and guests issue_data = { "name": issue_data.get("name", issue.name), - "description_html": issue_data.get("description_html", issue.description_html), - "description": issue_data.get("description", issue.description) + "description_html": issue_data.get( + "description_html", issue.description_html + ), + "description": issue_data.get("description", issue.description), } issue_serializer = IssueCreateSerializer( @@ -256,7 +263,7 @@ class InboxIssueViewSet(BaseViewSet): IssueSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch=int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) issue_serializer.save() else: @@ -307,7 +314,9 @@ class InboxIssueViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: - return Response(InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK) + return Response( + InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK + ) def retrieve(self, request, slug, project_id, inbox_id, pk): inbox_issue = InboxIssue.objects.get( @@ -324,15 +333,27 @@ class InboxIssueViewSet(BaseViewSet): pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id ) # Get the project member - project_member = ProjectMember.objects.get(workspace__slug=slug, project_id=project_id, member=request.user) + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ) - if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id): - return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST) + if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( + request.user.id + ): + return Response( + {"error": "You cannot delete inbox issue"}, + status=status.HTTP_400_BAD_REQUEST, + ) # Check the issue status if inbox_issue.status in [-2, -1, 0, 2]: # Delete the issue also - Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id).delete() + Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id + ).delete() inbox_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -347,7 +368,10 @@ class InboxIssuePublicViewSet(BaseViewSet): ] def get_queryset(self): - project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id")) + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) if project_deploy_board is not None: return self.filter_queryset( super() @@ -363,9 +387,14 @@ class InboxIssuePublicViewSet(BaseViewSet): return InboxIssue.objects.none() def list(self, request, slug, project_id, inbox_id): - project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id) + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) if project_deploy_board.inbox is None: - return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Inbox is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) filters = issue_filters(request.query_params, "GET") issues = ( @@ -392,9 +421,7 @@ class InboxIssuePublicViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -415,9 +442,14 @@ class InboxIssuePublicViewSet(BaseViewSet): ) def create(self, request, slug, project_id, inbox_id): - project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id) + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) if project_deploy_board.inbox is None: - return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Inbox is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) if not request.data.get("issue", {}).get("name", False): return Response( @@ -465,7 +497,7 @@ class InboxIssuePublicViewSet(BaseViewSet): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, - epoch=int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) # create an inbox issue InboxIssue.objects.create( @@ -479,34 +511,41 @@ class InboxIssuePublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) def partial_update(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id) + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) if project_deploy_board.inbox is None: - return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Inbox is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) inbox_issue = InboxIssue.objects.get( pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id ) # Get the project member if str(inbox_issue.created_by_id) != str(request.user.id): - return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "You cannot edit inbox issues"}, + status=status.HTTP_400_BAD_REQUEST, + ) # Get issue data issue_data = request.data.pop("issue", False) - issue = Issue.objects.get( pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id ) - # viewers and guests since only viewers and guests + # viewers and guests since only viewers and guests issue_data = { "name": issue_data.get("name", issue.name), - "description_html": issue_data.get("description_html", issue.description_html), - "description": issue_data.get("description", issue.description) + "description_html": issue_data.get( + "description_html", issue.description_html + ), + "description": issue_data.get("description", issue.description), } - issue_serializer = IssueCreateSerializer( - issue, data=issue_data, partial=True - ) + issue_serializer = IssueCreateSerializer(issue, data=issue_data, partial=True) if issue_serializer.is_valid(): current_instance = issue @@ -523,17 +562,22 @@ class InboxIssuePublicViewSet(BaseViewSet): IssueSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch=int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) issue_serializer.save() return Response(issue_serializer.data, status=status.HTTP_200_OK) return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id) + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) if project_deploy_board.inbox is None: - return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST) - + return Response( + {"error": "Inbox is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + inbox_issue = InboxIssue.objects.get( pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id ) @@ -544,16 +588,24 @@ class InboxIssuePublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) def destroy(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id) + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) if project_deploy_board.inbox is None: - return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Inbox is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) inbox_issue = InboxIssue.objects.get( pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id ) if str(inbox_issue.created_by_id) != str(request.user.id): - return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "You cannot delete inbox issue"}, + status=status.HTTP_400_BAD_REQUEST, + ) inbox_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 258aee80d..302a49035 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -623,6 +623,7 @@ class IssueCommentViewSet(BaseViewSet): workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), member_id=self.request.user.id, + is_active=True, ) ) ) @@ -1254,7 +1255,11 @@ class IssueSubscriberViewSet(BaseViewSet): def list(self, request, slug, project_id, issue_id): members = ( - ProjectMember.objects.filter(workspace__slug=slug, project_id=project_id) + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + is_active=True, + ) .annotate( is_subscribed=Exists( IssueSubscriber.objects.filter( @@ -1498,6 +1503,7 @@ class IssueCommentPublicViewSet(BaseViewSet): workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), member_id=self.request.user.id, + is_active=True, ) ) ) @@ -1538,6 +1544,7 @@ class IssueCommentPublicViewSet(BaseViewSet): if not ProjectMember.objects.filter( project_id=project_id, member=request.user, + is_active=True, ).exists(): # Add the user for workspace tracking _ = ProjectPublicMember.objects.get_or_create( @@ -1651,6 +1658,7 @@ class IssueReactionPublicViewSet(BaseViewSet): if not ProjectMember.objects.filter( project_id=project_id, member=request.user, + is_active=True, ).exists(): # Add the user for workspace tracking _ = ProjectPublicMember.objects.get_or_create( @@ -1744,7 +1752,9 @@ class CommentReactionPublicViewSet(BaseViewSet): project_id=project_id, comment_id=comment_id, actor=request.user ) if not ProjectMember.objects.filter( - project_id=project_id, member=request.user + project_id=project_id, + member=request.user, + is_active=True, ).exists(): # Add the user for workspace tracking _ = ProjectPublicMember.objects.get_or_create( @@ -1829,7 +1839,9 @@ class IssueVotePublicViewSet(BaseViewSet): ) # Add the user for workspace tracking if not ProjectMember.objects.filter( - project_id=project_id, member=request.user + project_id=project_id, + member=request.user, + is_active=True, ).exists(): _ = ProjectPublicMember.objects.get_or_create( project_id=project_id, diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py index 978c01bac..19dcba734 100644 --- a/apiserver/plane/api/views/notification.py +++ b/apiserver/plane/api/views/notification.py @@ -85,7 +85,10 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # Created issues if type == "created": if WorkspaceMember.objects.filter( - workspace__slug=slug, member=request.user, role__lt=15 + workspace__slug=slug, + member=request.user, + role__lt=15, + is_active=True, ).exists(): notifications = Notification.objects.none() else: @@ -255,7 +258,10 @@ class MarkAllReadNotificationViewSet(BaseViewSet): # Created issues if type == "created": if WorkspaceMember.objects.filter( - workspace__slug=slug, member=request.user, role__lt=15 + workspace__slug=slug, + member=request.user, + role__lt=15, + is_active=True, ).exists(): notifications = Notification.objects.none() else: diff --git a/apiserver/plane/api/views/oauth.py b/apiserver/plane/api/views/oauth.py index f0ea9acc9..d2b65d926 100644 --- a/apiserver/plane/api/views/oauth.py +++ b/apiserver/plane/api/views/oauth.py @@ -2,6 +2,7 @@ import uuid import requests import os +from requests.exceptions import RequestException # Django imports from django.utils import timezone @@ -20,7 +21,14 @@ from google.oauth2 import id_token from google.auth.transport import requests as google_auth_request # Module imports -from plane.db.models import SocialLoginConnection, User +from plane.db.models import ( + SocialLoginConnection, + User, + WorkspaceMemberInvite, + WorkspaceMember, + ProjectMemberInvite, + ProjectMember, +) from plane.api.serializers import UserSerializer from .base import BaseAPIView @@ -168,7 +176,6 @@ class OauthEndpoint(BaseAPIView): ) ## Login Case - if not user.is_active: return Response( { @@ -185,12 +192,61 @@ class OauthEndpoint(BaseAPIView): user.is_email_verified = email_verified user.save() - access_token, refresh_token = get_tokens_for_user(user) + # Check if user has any accepted invites for workspace and add them to workspace + workspace_member_invites = WorkspaceMemberInvite.objects.filter( + email=user.email, accepted=True + ) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=workspace_member_invite.workspace_id, + member=user, + role=workspace_member_invite.role, + ) + for workspace_member_invite in workspace_member_invites + ], + ignore_conflicts=True, + ) + + # Check if user has any project invites + project_member_invites = ProjectMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + # Add user to workspace + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Now add the users to project + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + # Delete all the invites + workspace_member_invites.delete() + project_member_invites.delete() SocialLoginConnection.objects.update_or_create( medium=medium, @@ -201,26 +257,36 @@ class OauthEndpoint(BaseAPIView): "last_login_at": timezone.now(), }, ) - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": f"oauth-{medium}", + try: + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": f"oauth-{medium}", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get("HTTP_USER_AGENT"), + }, + "event_type": "SIGN_IN", }, - "event_type": "SIGN_IN", - }, - ) + ) + except RequestException as e: + capture_exception(e) + + access_token, refresh_token = get_tokens_for_user(user) + + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } return Response(data, status=status.HTTP_200_OK) except User.DoesNotExist: @@ -260,31 +326,85 @@ class OauthEndpoint(BaseAPIView): user.token_updated_at = timezone.now() user.save() - access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": f"oauth-{medium}", + # Check if user has any accepted invites for workspace and add them to workspace + workspace_member_invites = WorkspaceMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=workspace_member_invite.workspace_id, + member=user, + role=workspace_member_invite.role, + ) + for workspace_member_invite in workspace_member_invites + ], + ignore_conflicts=True, + ) + + # Check if user has any project invites + project_member_invites = ProjectMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + # Add user to workspace + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Now add the users to project + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + # Delete all the invites + workspace_member_invites.delete() + project_member_invites.delete() + + try: + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": f"oauth-{medium}", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get("HTTP_USER_AGENT"), + }, + "event_type": "SIGN_UP", }, - "event_type": "SIGN_UP", - }, - ) + ) + except RequestException as e: + capture_exception(e) SocialLoginConnection.objects.update_or_create( medium=medium, @@ -295,4 +415,10 @@ class OauthEndpoint(BaseAPIView): "last_login_at": timezone.now(), }, ) + + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } return Response(data, status=status.HTTP_201_CREATED) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 494760b8a..7833d051f 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -17,13 +17,13 @@ from django.db.models import ( ) from django.core.validators import validate_email from django.conf import settings +from django.utils import timezone # Third Party imports from rest_framework.response import Response from rest_framework import status from rest_framework import serializers from rest_framework.permissions import AllowAny -from sentry_sdk import capture_exception # Module imports from .base import BaseViewSet, BaseAPIView @@ -39,6 +39,7 @@ from plane.api.serializers import ( ) from plane.api.permissions import ( + WorkspaceUserPermission, ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, @@ -58,13 +59,6 @@ from plane.db.models import ( ProjectIdentifier, Module, Cycle, - CycleFavorite, - ModuleFavorite, - PageFavorite, - IssueViewFavorite, - Page, - IssueAssignee, - ModuleMember, Inbox, ProjectDeployBoard, IssueProperty, @@ -110,12 +104,15 @@ class ProjectViewSet(BaseViewSet): member=self.request.user, project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), + is_active=True, ) ) ) .annotate( total_members=ProjectMember.objects.filter( - project_id=OuterRef("id"), member__is_bot=False + project_id=OuterRef("id"), + member__is_bot=False, + is_active=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -137,6 +134,7 @@ class ProjectViewSet(BaseViewSet): member_role=ProjectMember.objects.filter( project_id=OuterRef("pk"), member_id=self.request.user.id, + is_active=True, ).values("role") ) .annotate( @@ -157,6 +155,7 @@ class ProjectViewSet(BaseViewSet): member=request.user, project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), + is_active=True, ).values("sort_order") projects = ( self.get_queryset() @@ -166,6 +165,7 @@ class ProjectViewSet(BaseViewSet): "project_projectmember", queryset=ProjectMember.objects.filter( workspace__slug=slug, + is_active=True, ).select_related("member"), ) ) @@ -345,66 +345,104 @@ class ProjectViewSet(BaseViewSet): ) -class InviteProjectEndpoint(BaseAPIView): +class ProjectInvitationsViewset(BaseViewSet): + serializer_class = ProjectMemberInviteSerializer + model = ProjectMemberInvite + + search_fields = [] + permission_classes = [ ProjectBasePermission, ] - def post(self, request, slug, project_id): - email = request.data.get("email", False) - role = request.data.get("role", False) - - # Check if email is provided - if not email: - return Response( - {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST - ) - - validate_email(email) - # Check if user is already a member of workspace - if ProjectMember.objects.filter( - project_id=project_id, - member__email=email, - member__is_bot=False, - ).exists(): - return Response( - {"error": "User is already member of workspace"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - user = User.objects.filter(email=email).first() - - if user is None: - token = jwt.encode( - {"email": email, "timestamp": datetime.now().timestamp()}, - settings.SECRET_KEY, - algorithm="HS256", - ) - project_invitation_obj = ProjectMemberInvite.objects.create( - email=email.strip().lower(), - project_id=project_id, - token=token, - role=role, - ) - domain = request.META.get('HTTP_ORIGIN') - project_invitation.delay(email, project_id, token, domain) - - return Response( - { - "message": "Email sent successfully", - "id": project_invitation_obj.id, - }, - status=status.HTTP_200_OK, - ) - - project_member = ProjectMember.objects.create( - member=user, project_id=project_id, role=role + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .select_related("project") + .select_related("workspace", "workspace__owner") ) - _ = IssueProperty.objects.create(user=user, project_id=project_id) + def create(self, request, slug, project_id): + emails = request.data.get("emails", []) + + # Check if email is provided + if not emails: + return Response( + {"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST + ) + + requesting_user = ProjectMember.objects.get( + workspace__slug=slug, project_id=project_id, member_id=request.user.id + ) + + # Check if any invited user has an higher role + if len( + [ + email + for email in emails + if int(email.get("role", 10)) > requesting_user.role + ] + ): + return Response( + {"error": "You cannot invite a user with higher role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.get(slug=slug) + + project_invitations = [] + for email in emails: + try: + validate_email(email.get("email")) + project_invitations.append( + ProjectMemberInvite( + email=email.get("email").strip().lower(), + project_id=project_id, + workspace_id=workspace.id, + token=jwt.encode( + { + "email": email, + "timestamp": datetime.now().timestamp(), + }, + settings.SECRET_KEY, + algorithm="HS256", + ), + role=email.get("role", 10), + created_by=request.user, + ) + ) + except ValidationError: + return Response( + { + "error": f"Invalid email - {email} provided a valid email address is required to send the invite" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Create workspace member invite + project_invitations = ProjectMemberInvite.objects.bulk_create( + project_invitations, batch_size=10, ignore_conflicts=True + ) + current_site = f"{request.scheme}://{request.get_host()}", + + # Send invitations + for invitation in project_invitations: + project_invitations.delay( + invitation.email, + project_id, + invitation.token, + current_site, + request.user.email, + ) return Response( - ProjectMemberSerializer(project_member).data, status=status.HTTP_200_OK + { + "message": "Email sent successfully", + }, + status=status.HTTP_200_OK, ) @@ -420,40 +458,134 @@ class UserProjectInvitationsViewset(BaseViewSet): .select_related("workspace", "workspace__owner", "project") ) - def create(self, request): - invitations = request.data.get("invitations") - project_invitations = ProjectMemberInvite.objects.filter( - pk__in=invitations, accepted=True + def create(self, request, slug): + project_ids = request.data.get("project_ids", []) + + # Get the workspace user role + workspace_member = WorkspaceMember.objects.get( + member=request.user, + workspace__slug=slug, + is_active=True, ) + + workspace_role = workspace_member.role + workspace = workspace_member.workspace + ProjectMember.objects.bulk_create( [ ProjectMember( - project=invitation.project, - workspace=invitation.project.workspace, + project_id=project_id, member=request.user, - role=invitation.role, + role=15 if workspace_role >= 15 else 10, + workspace=workspace, created_by=request.user, ) - for invitation in project_invitations - ] + for project_id in project_ids + ], + ignore_conflicts=True, ) IssueProperty.objects.bulk_create( [ - ProjectMember( - project=invitation.project, - workspace=invitation.project.workspace, + IssueProperty( + project_id=project_id, user=request.user, + workspace=workspace, created_by=request.user, ) - for invitation in project_invitations - ] + for project_id in project_ids + ], + ignore_conflicts=True, ) - # Delete joined project invites - project_invitations.delete() + return Response( + {"message": "Projects joined successfully"}, + status=status.HTTP_201_CREATED, + ) - return Response(status=status.HTTP_204_NO_CONTENT) + +class ProjectJoinEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request, slug, project_id, pk): + project_invite = ProjectMemberInvite.objects.get( + pk=pk, + project_id=project_id, + workspace__slug=slug, + ) + + email = request.data.get("email", "") + + if email == "" or project_invite.email != email: + return Response( + {"error": "You do not have permission to join the project"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if project_invite.responded_at is None: + project_invite.accepted = request.data.get("accepted", False) + project_invite.responded_at = timezone.now() + project_invite.save() + + if project_invite.accepted: + # Check if the user account exists + user = User.objects.filter(email=email).first() + + # Check if user is a part of workspace + workspace_member = WorkspaceMember.objects.filter( + workspace__slug=slug, member=user + ).first() + # Add him to workspace + if workspace_member is None: + _ = WorkspaceMember.objects.create( + workspace_id=project_invite.workspace_id, + member=user, + role=15 if project_invite.role >= 15 else project_invite.role, + ) + else: + # Else make him active + workspace_member.is_active = True + workspace_member.save() + + # Check if the user was already a member of project then activate the user + project_member = ProjectMember.objects.filter( + workspace_id=project_invite.workspace_id, member=user + ).first() + if project_member is None: + # Create a Project Member + _ = ProjectMember.objects.create( + workspace_id=project_invite.workspace_id, + member=user, + role=project_invite.role, + ) + else: + project_member.is_active = True + project_member.role = project_member.role + project_member.save() + + return Response( + {"message": "Project Invitation Accepted"}, + status=status.HTTP_200_OK, + ) + + return Response( + {"message": "Project Invitation was not accepted"}, + status=status.HTTP_200_OK, + ) + + return Response( + {"error": "You have already responded to the invitation request"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def get(self, request, slug, project_id, pk): + project_invitation = ProjectMemberInvite.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + serializer = ProjectMemberInviteSerializer(project_invitation) + return Response(serializer.data, status=status.HTTP_200_OK) class ProjectMemberViewSet(BaseViewSet): @@ -475,6 +607,7 @@ class ProjectMemberViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(member__is_bot=False) + .filter() .select_related("project") .select_related("member") .select_related("workspace", "workspace__owner") @@ -542,13 +675,17 @@ class ProjectMemberViewSet(BaseViewSet): def list(self, request, slug, project_id): project_member = ProjectMember.objects.get( - member=request.user, workspace__slug=slug, project_id=project_id + member=request.user, + workspace__slug=slug, + project_id=project_id, + is_active=True, ) project_members = ProjectMember.objects.filter( project_id=project_id, workspace__slug=slug, member__is_bot=False, + is_active=True, ).select_related("project", "member", "workspace") if project_member.role > 10: @@ -559,7 +696,10 @@ class ProjectMemberViewSet(BaseViewSet): def partial_update(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, + workspace__slug=slug, + project_id=project_id, + is_active=True, ) if request.user.id == project_member.member_id: return Response( @@ -568,7 +708,10 @@ class ProjectMemberViewSet(BaseViewSet): ) # Check while updating user roles requested_project_member = ProjectMember.objects.get( - project_id=project_id, workspace__slug=slug, member=request.user + project_id=project_id, + workspace__slug=slug, + member=request.user, + is_active=True, ) if ( "role" in request.data @@ -591,54 +734,66 @@ class ProjectMemberViewSet(BaseViewSet): def destroy(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk + workspace__slug=slug, + project_id=project_id, + pk=pk, + member__is_bot=False, + is_active=True, ) # check requesting user role requesting_project_member = ProjectMember.objects.get( - workspace__slug=slug, member=request.user, project_id=project_id + workspace__slug=slug, + member=request.user, + project_id=project_id, + is_active=True, ) + # User cannot remove himself + if str(project_member.id) == str(requesting_project_member.id): + return Response( + { + "error": "You cannot remove yourself from the workspace. Please use leave workspace" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # User cannot deactivate higher role if requesting_project_member.role < project_member.role: return Response( - {"error": "You cannot remove a user having role higher than yourself"}, + {"error": "You cannot remove a user having role higher than you"}, status=status.HTTP_400_BAD_REQUEST, ) - # Remove all favorites - ProjectFavorite.objects.filter( - workspace__slug=slug, project_id=project_id, user=project_member.member - ).delete() - CycleFavorite.objects.filter( - workspace__slug=slug, project_id=project_id, user=project_member.member - ).delete() - ModuleFavorite.objects.filter( - workspace__slug=slug, project_id=project_id, user=project_member.member - ).delete() - PageFavorite.objects.filter( - workspace__slug=slug, project_id=project_id, user=project_member.member - ).delete() - IssueViewFavorite.objects.filter( - workspace__slug=slug, project_id=project_id, user=project_member.member - ).delete() - # Also remove issue from issue assigned - IssueAssignee.objects.filter( - workspace__slug=slug, - project_id=project_id, - assignee=project_member.member, - ).delete() + project_member.is_active = False + project_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) - # Remove if module member - ModuleMember.objects.filter( + def leave(self, request, slug, project_id): + project_member = ProjectMember.objects.get( workspace__slug=slug, project_id=project_id, - member=project_member.member, - ).delete() - # Delete owned Pages - Page.objects.filter( - workspace__slug=slug, - project_id=project_id, - owned_by=project_member.member, - ).delete() - project_member.delete() + member=request.user, + is_active=True, + ) + + # Check if the leaving user is the only admin of the project + if ( + project_member.role == 20 + and not ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + role=20, + is_active=True, + ).count() + > 1 + ): + return Response( + { + "error": "You cannot leave the project as your the only admin of the project you will have to either delete the project or create an another admin", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Deactivate the user + project_member.is_active = False + project_member.save() return Response(status=status.HTTP_204_NO_CONTENT) @@ -691,46 +846,6 @@ class AddTeamToProjectEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_201_CREATED) -class ProjectMemberInvitationsViewset(BaseViewSet): - serializer_class = ProjectMemberInviteSerializer - model = ProjectMemberInvite - - search_fields = [] - - permission_classes = [ - ProjectBasePermission, - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .select_related("project") - .select_related("workspace", "workspace__owner") - ) - - -class ProjectMemberInviteDetailViewSet(BaseViewSet): - serializer_class = ProjectMemberInviteSerializer - model = ProjectMemberInvite - - search_fields = [] - - permission_classes = [ - ProjectBasePermission, - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .select_related("project") - .select_related("workspace", "workspace__owner") - ) - - class ProjectIdentifierEndpoint(BaseAPIView): permission_classes = [ ProjectBasePermission, @@ -774,59 +889,14 @@ class ProjectIdentifierEndpoint(BaseAPIView): ) -class ProjectJoinEndpoint(BaseAPIView): - def post(self, request, slug): - project_ids = request.data.get("project_ids", []) - - # Get the workspace user role - workspace_member = WorkspaceMember.objects.get( - member=request.user, workspace__slug=slug - ) - - workspace_role = workspace_member.role - workspace = workspace_member.workspace - - ProjectMember.objects.bulk_create( - [ - ProjectMember( - project_id=project_id, - member=request.user, - role=20 - if workspace_role >= 15 - else (15 if workspace_role == 10 else workspace_role), - workspace=workspace, - created_by=request.user, - ) - for project_id in project_ids - ], - ignore_conflicts=True, - ) - - IssueProperty.objects.bulk_create( - [ - IssueProperty( - project_id=project_id, - user=request.user, - workspace=workspace, - created_by=request.user, - ) - for project_id in project_ids - ], - ignore_conflicts=True, - ) - - return Response( - {"message": "Projects joined successfully"}, - status=status.HTTP_201_CREATED, - ) - - class ProjectUserViewsEndpoint(BaseAPIView): def post(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) project_member = ProjectMember.objects.filter( - member=request.user, project=project + member=request.user, + project=project, + is_active=True, ).first() if project_member is None: @@ -850,7 +920,10 @@ class ProjectUserViewsEndpoint(BaseAPIView): class ProjectMemberUserEndpoint(BaseAPIView): def get(self, request, slug, project_id): project_member = ProjectMember.objects.get( - project_id=project_id, workspace__slug=slug, member=request.user + project_id=project_id, + workspace__slug=slug, + member=request.user, + is_active=True, ) serializer = ProjectMemberSerializer(project_member) @@ -983,39 +1056,6 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): return Response(projects, status=status.HTTP_200_OK) -class LeaveProjectEndpoint(BaseAPIView): - permission_classes = [ - ProjectLitePermission, - ] - - def delete(self, request, slug, project_id): - project_member = ProjectMember.objects.get( - workspace__slug=slug, - member=request.user, - project_id=project_id, - ) - - # Only Admin case - if ( - project_member.role == 20 - and ProjectMember.objects.filter( - workspace__slug=slug, - role=20, - project_id=project_id, - ).count() - == 1 - ): - return Response( - { - "error": "You cannot leave the project since you are the only admin of the project you should delete the project" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - # Delete the member from workspace - project_member.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - class ProjectPublicCoverImagesEndpoint(BaseAPIView): permission_classes = [ AllowAny, diff --git a/apiserver/plane/api/views/user.py b/apiserver/plane/api/views/user.py index 2e40565b4..9b488489a 100644 --- a/apiserver/plane/api/views/user.py +++ b/apiserver/plane/api/views/user.py @@ -13,13 +13,7 @@ from plane.api.serializers import ( ) from plane.api.views.base import BaseViewSet, BaseAPIView -from plane.db.models import ( - User, - Workspace, - WorkspaceMemberInvite, - Issue, - IssueActivity, -) +from plane.db.models import User, IssueActivity, WorkspaceMember from plane.utils.paginator import BasePaginator @@ -41,10 +35,28 @@ class UserEndpoint(BaseViewSet): serialized_data = UserMeSettingsSerializer(request.user).data return Response(serialized_data, status=status.HTTP_200_OK) + def deactivate(self, request): + # Check all workspace user is active + user = self.get_object() + if WorkspaceMember.objects.filter( + member=request.user, is_active=True + ).exists(): + return Response( + { + "error": "User cannot deactivate account as user is active in some workspaces" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Deactivate the user + user.is_active = False + user.save() + return Response(status=status.HTTP_204_NO_CONTENT) + class UpdateUserOnBoardedEndpoint(BaseAPIView): def patch(self, request): - user = User.objects.get(pk=request.user.id) + user = User.objects.get(pk=request.user.id, is_active=True) user.is_onboarded = request.data.get("is_onboarded", False) user.save() return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) @@ -52,7 +64,7 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView): class UpdateUserTourCompletedEndpoint(BaseAPIView): def patch(self, request): - user = User.objects.get(pk=request.user.id) + user = User.objects.get(pk=request.user.id, is_active=True) user.is_tour_completed = request.data.get("is_tour_completed", False) user.save() return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index a30d68469..3fc9b7bde 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -2,7 +2,6 @@ import jwt from datetime import date, datetime from dateutil.relativedelta import relativedelta -from uuid import uuid4 # Django imports from django.db import IntegrityError @@ -26,13 +25,11 @@ from django.db.models import ( ) from django.db.models.functions import ExtractWeek, Cast, ExtractDay from django.db.models.fields import DateField -from django.contrib.auth.hashers import make_password # Third party modules from rest_framework import status from rest_framework.response import Response -from rest_framework.permissions import AllowAny -from sentry_sdk import capture_exception +from rest_framework.permissions import AllowAny, IsAuthenticated # Module imports from plane.api.serializers import ( @@ -59,14 +56,6 @@ from plane.db.models import ( IssueActivity, Issue, WorkspaceTheme, - IssueAssignee, - ProjectFavorite, - CycleFavorite, - ModuleMember, - ModuleFavorite, - PageFavorite, - Page, - IssueViewFavorite, IssueLink, IssueAttachment, IssueSubscriber, @@ -106,7 +95,9 @@ class WorkSpaceViewSet(BaseViewSet): def get_queryset(self): member_count = ( WorkspaceMember.objects.filter( - workspace=OuterRef("id"), member__is_bot=False + workspace=OuterRef("id"), + member__is_bot=False, + is_active=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -181,7 +172,9 @@ class UserWorkSpacesEndpoint(BaseAPIView): def get(self, request): member_count = ( WorkspaceMember.objects.filter( - workspace=OuterRef("id"), member__is_bot=False + workspace=OuterRef("id"), + member__is_bot=False, + is_active=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -227,23 +220,40 @@ class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView): return Response({"status": not workspace}, status=status.HTTP_200_OK) -class InviteWorkspaceEndpoint(BaseAPIView): +class WorkspaceInvitationsViewset(BaseViewSet): + """Endpoint for creating, listing and deleting workspaces""" + + serializer_class = WorkSpaceMemberInviteSerializer + model = WorkspaceMemberInvite + permission_classes = [ WorkSpaceAdminPermission, ] - def post(self, request, slug): - emails = request.data.get("emails", False) + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "workspace__owner", "created_by") + ) + + def create(self, request, slug): + emails = request.data.get("emails", []) # Check if email is provided - if not emails or not len(emails): + if not emails: return Response( {"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST ) - # check for role level + # check for role level of the requesting user requesting_user = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user + workspace__slug=slug, + member=request.user, + is_active=True, ) + + # Check if any invited user has an higher role if len( [ email @@ -256,15 +266,17 @@ class InviteWorkspaceEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # Get the workspace object workspace = Workspace.objects.get(slug=slug) # Check if user is already a member of workspace workspace_members = WorkspaceMember.objects.filter( workspace_id=workspace.id, member__email__in=[email.get("email") for email in emails], + is_active=True, ).select_related("member", "workspace", "workspace__owner") - if len(workspace_members): + if workspace_members: return Response( { "error": "Some users are already member of workspace", @@ -302,35 +314,20 @@ class InviteWorkspaceEndpoint(BaseAPIView): }, status=status.HTTP_400_BAD_REQUEST, ) - WorkspaceMemberInvite.objects.bulk_create( + # Create workspace member invite + workspace_invitations = WorkspaceMemberInvite.objects.bulk_create( workspace_invitations, batch_size=10, ignore_conflicts=True ) - workspace_invitations = WorkspaceMemberInvite.objects.filter( - email__in=[email.get("email") for email in emails] - ).select_related("workspace") - - # create the user if signup is disabled - if settings.DOCKERIZED and not settings.ENABLE_SIGNUP: - _ = User.objects.bulk_create( - [ - User( - username=str(uuid4().hex), - email=invitation.email, - password=make_password(uuid4().hex), - is_password_autoset=True, - ) - for invitation in workspace_invitations - ], - batch_size=100, - ) + current_site = f"{request.scheme}://{request.get_host()}", + # Send invitations for invitation in workspace_invitations: workspace_invitation.delay( invitation.email, workspace.id, invitation.token, - request.META.get('HTTP_ORIGIN'), + current_site, request.user.email, ) @@ -341,11 +338,19 @@ class InviteWorkspaceEndpoint(BaseAPIView): status=status.HTTP_200_OK, ) + def destroy(self, request, slug, pk): + workspace_member_invite = WorkspaceMemberInvite.objects.get( + pk=pk, workspace__slug=slug + ) + workspace_member_invite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) -class JoinWorkspaceEndpoint(BaseAPIView): + +class WorkspaceJoinEndpoint(BaseAPIView): permission_classes = [ AllowAny, ] + """Invitation response endpoint the user can respond to the invitation""" def post(self, request, slug, pk): workspace_invite = WorkspaceMemberInvite.objects.get( @@ -354,12 +359,14 @@ class JoinWorkspaceEndpoint(BaseAPIView): email = request.data.get("email", "") + # Check the email if email == "" or workspace_invite.email != email: return Response( {"error": "You do not have permission to join the workspace"}, status=status.HTTP_403_FORBIDDEN, ) + # If already responded then return error if workspace_invite.responded_at is None: workspace_invite.accepted = request.data.get("accepted", False) workspace_invite.responded_at = timezone.now() @@ -371,12 +378,23 @@ class JoinWorkspaceEndpoint(BaseAPIView): # If the user is present then create the workspace member if user is not None: - WorkspaceMember.objects.create( - workspace=workspace_invite.workspace, - member=user, - role=workspace_invite.role, - ) + # Check if the user was already a member of workspace then activate the user + workspace_member = WorkspaceMember.objects.filter( + workspace=workspace_invite.workspace, member=user + ).first() + if workspace_member is not None: + workspace_member.is_active = True + workspace_member.role = workspace_invite.role + workspace_member.save() + else: + # Create a Workspace + _ = WorkspaceMember.objects.create( + workspace=workspace_invite.workspace, + member=user, + role=workspace_invite.role, + ) + # Set the user last_workspace_id to the accepted workspace user.last_workspace_id = workspace_invite.workspace.id user.save() @@ -388,6 +406,7 @@ class JoinWorkspaceEndpoint(BaseAPIView): status=status.HTTP_200_OK, ) + # Workspace invitation rejected return Response( {"message": "Workspace Invitation was not accepted"}, status=status.HTTP_200_OK, @@ -398,37 +417,13 @@ class JoinWorkspaceEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - -class WorkspaceInvitationsViewset(BaseViewSet): - serializer_class = WorkSpaceMemberInviteSerializer - model = WorkspaceMemberInvite - - permission_classes = [ - WorkSpaceAdminPermission, - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("workspace", "workspace__owner", "created_by") - ) - - def destroy(self, request, slug, pk): - workspace_member_invite = WorkspaceMemberInvite.objects.get( - pk=pk, workspace__slug=slug - ) - # delete the user if signup is disabled - if settings.DOCKERIZED and not settings.ENABLE_SIGNUP: - user = User.objects.filter(email=workspace_member_invite.email).first() - if user is not None: - user.delete() - workspace_member_invite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + def get(self, request, slug, pk): + workspace_invitation = WorkspaceMemberInvite.objects.get(workspace__slug=slug, pk=pk) + serializer = WorkSpaceMemberInviteSerializer(workspace_invitation) + return Response(serializer.data, status=status.HTTP_200_OK) -class UserWorkspaceInvitationsEndpoint(BaseViewSet): +class UserWorkspaceInvitationsViewSet(BaseViewSet): serializer_class = WorkSpaceMemberInviteSerializer model = WorkspaceMemberInvite @@ -442,9 +437,19 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet): ) def create(self, request): - invitations = request.data.get("invitations") - workspace_invitations = WorkspaceMemberInvite.objects.filter(pk__in=invitations) + invitations = request.data.get("invitations", []) + workspace_invitations = WorkspaceMemberInvite.objects.filter( + pk__in=invitations, email=request.user.email + ).order_by("-created_at") + # If the user is already a member of workspace and was deactivated then activate the user + for invitation in workspace_invitations: + # Update the WorkspaceMember for this specific invitation + WorkspaceMember.objects.filter( + workspace_id=invitation.workspace_id, member=request.user + ).update(is_active=True, role=invitation.role) + + # Bulk create the user for all the workspaces WorkspaceMember.objects.bulk_create( [ WorkspaceMember( @@ -481,20 +486,24 @@ class WorkSpaceMemberViewSet(BaseViewSet): return self.filter_queryset( super() .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug"), member__is_bot=False) + .filter( + workspace__slug=self.kwargs.get("slug"), + member__is_bot=False, + is_active=True, + ) .select_related("workspace", "workspace__owner") .select_related("member") ) def list(self, request, slug): workspace_member = WorkspaceMember.objects.get( - member=request.user, workspace__slug=slug + member=request.user, + workspace__slug=slug, + is_active=True, ) - workspace_members = WorkspaceMember.objects.filter( - workspace__slug=slug, - member__is_bot=False, - ).select_related("workspace", "member") + # Get all active workspace members + workspace_members = self.get_queryset() if workspace_member.role > 10: serializer = WorkspaceMemberAdminSerializer(workspace_members, many=True) @@ -506,7 +515,12 @@ class WorkSpaceMemberViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) def partial_update(self, request, slug, pk): - workspace_member = WorkspaceMember.objects.get(pk=pk, workspace__slug=slug) + workspace_member = WorkspaceMember.objects.get( + pk=pk, + workspace__slug=slug, + member__is_bot=False, + is_active=True, + ) if request.user.id == workspace_member.member_id: return Response( {"error": "You cannot update your own role"}, @@ -515,7 +529,9 @@ class WorkSpaceMemberViewSet(BaseViewSet): # Get the requested user role requested_workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user + workspace__slug=slug, + member=request.user, + is_active=True, ) # Check if role is being updated # One cannot update role higher than his own role @@ -540,68 +556,121 @@ class WorkSpaceMemberViewSet(BaseViewSet): def destroy(self, request, slug, pk): # Check the user role who is deleting the user - workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, pk=pk) + workspace_member = WorkspaceMember.objects.get( + workspace__slug=slug, + pk=pk, + member__is_bot=False, + is_active=True, + ) # check requesting user role requesting_workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user + workspace__slug=slug, + member=request.user, + is_active=True, ) + + if str(workspace_member.id) == str(requesting_workspace_member.id): + return Response( + { + "error": "You cannot remove yourself from the workspace. Please use leave workspace" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + if requesting_workspace_member.role < workspace_member.role: return Response( {"error": "You cannot remove a user having role higher than you"}, status=status.HTTP_400_BAD_REQUEST, ) - # Check for the only member in the workspace if ( - workspace_member.role == 20 - and WorkspaceMember.objects.filter( - workspace__slug=slug, - role=20, - member__is_bot=False, - ).count() - == 1 + Project.objects.annotate( + total_members=Count("project_projectmember"), + member_with_role=Count( + "project_projectmember", + filter=Q( + project_projectmember__member_id=request.user.id, + project_projectmember__role=20, + ), + ), + ) + .filter(total_members=1, member_with_role=1, workspace__slug=slug) + .exists() ): return Response( - {"error": "Cannot delete the only Admin for the workspace"}, + { + "error": "User is part of some projects where they are the only admin you should leave that project first" + }, status=status.HTTP_400_BAD_REQUEST, ) - # Delete the user also from all the projects - ProjectMember.objects.filter( - workspace__slug=slug, member=workspace_member.member - ).delete() - # Remove all favorites - ProjectFavorite.objects.filter( - workspace__slug=slug, user=workspace_member.member - ).delete() - CycleFavorite.objects.filter( - workspace__slug=slug, user=workspace_member.member - ).delete() - ModuleFavorite.objects.filter( - workspace__slug=slug, user=workspace_member.member - ).delete() - PageFavorite.objects.filter( - workspace__slug=slug, user=workspace_member.member - ).delete() - IssueViewFavorite.objects.filter( - workspace__slug=slug, user=workspace_member.member - ).delete() - # Also remove issue from issue assigned - IssueAssignee.objects.filter( - workspace__slug=slug, assignee=workspace_member.member - ).delete() + # Deactivate the users from the projects where the user is part of + _ = ProjectMember.objects.filter( + workspace__slug=slug, + member_id=workspace_member.member_id, + is_active=True, + ).update(is_active=False) - # Remove if module member - ModuleMember.objects.filter( - workspace__slug=slug, member=workspace_member.member - ).delete() - # Delete owned Pages - Page.objects.filter( - workspace__slug=slug, owned_by=workspace_member.member - ).delete() + workspace_member.is_active = False + workspace_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) - workspace_member.delete() + def leave(self, request, slug): + workspace_member = WorkspaceMember.objects.get( + workspace__slug=slug, + member=request.user, + is_active=True, + ) + + # Check if the leaving user is the only admin of the workspace + if ( + workspace_member.role == 20 + and not WorkspaceMember.objects.filter( + workspace__slug=slug, + role=20, + is_active=True, + ).count() + > 1 + ): + return Response( + { + "error": "You cannot leave the workspace as your the only admin of the workspace you will have to either delete the workspace or create an another admin" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if ( + Project.objects.annotate( + total_members=Count("project_projectmember"), + member_with_role=Count( + "project_projectmember", + filter=Q( + project_projectmember__member_id=request.user.id, + project_projectmember__role=20, + ), + ), + ) + .filter(total_members=1, member_with_role=1, workspace__slug=slug) + .exists() + ): + return Response( + { + "error": "User is part of some projects where they are the only admin you should leave that project first" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # # Deactivate the users from the projects where the user is part of + _ = ProjectMember.objects.filter( + workspace__slug=slug, + member_id=workspace_member.member_id, + is_active=True, + ).update(is_active=False) + + # # Deactivate the user + workspace_member.is_active = False + workspace_member.save() return Response(status=status.HTTP_204_NO_CONTENT) @@ -629,7 +698,9 @@ class TeamMemberViewSet(BaseViewSet): def create(self, request, slug): members = list( WorkspaceMember.objects.filter( - workspace__slug=slug, member__id__in=request.data.get("members", []) + workspace__slug=slug, + member__id__in=request.data.get("members", []), + is_active=True, ) .annotate(member_str_id=Cast("member", output_field=CharField())) .distinct() @@ -658,23 +729,6 @@ class TeamMemberViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -class UserWorkspaceInvitationEndpoint(BaseViewSet): - model = WorkspaceMemberInvite - serializer_class = WorkSpaceMemberInviteSerializer - - permission_classes = [ - AllowAny, - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(pk=self.kwargs.get("pk")) - .select_related("workspace") - ) - - class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): def get(self, request): user = User.objects.get(pk=request.user.id) @@ -711,7 +765,9 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): class WorkspaceMemberUserEndpoint(BaseAPIView): def get(self, request, slug): workspace_member = WorkspaceMember.objects.get( - member=request.user, workspace__slug=slug + member=request.user, + workspace__slug=slug, + is_active=True, ) serializer = WorkspaceMemberMeSerializer(workspace_member) return Response(serializer.data, status=status.HTTP_200_OK) @@ -720,7 +776,9 @@ class WorkspaceMemberUserEndpoint(BaseAPIView): class WorkspaceMemberUserViewsEndpoint(BaseAPIView): def post(self, request, slug): workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user + workspace__slug=slug, + member=request.user, + is_active=True, ) workspace_member.view_props = request.data.get("view_props", {}) workspace_member.save() @@ -1046,7 +1104,9 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): user_data = User.objects.get(pk=user_id) requesting_workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user + workspace__slug=slug, + member=request.user, + is_active=True, ) projects = [] if requesting_workspace_member.role >= 10: @@ -1250,9 +1310,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): status=status.HTTP_200_OK, ) - return Response( - issues, status=status.HTTP_200_OK - ) + return Response(issues, status=status.HTTP_200_OK) class WorkspaceLabelsEndpoint(BaseAPIView): @@ -1266,30 +1324,3 @@ class WorkspaceLabelsEndpoint(BaseAPIView): project__project_projectmember__member=request.user, ).values("parent", "name", "color", "id", "project_id", "workspace__slug") return Response(labels, status=status.HTTP_200_OK) - - -class LeaveWorkspaceEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceEntityPermission, - ] - - def delete(self, request, slug): - workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user - ) - - # Only Admin case - if ( - workspace_member.role == 20 - and WorkspaceMember.objects.filter(workspace__slug=slug, role=20).count() - == 1 - ): - return Response( - { - "error": "You cannot leave the workspace since you are the only admin of the workspace you should delete the workspace" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - # Delete the member from workspace - workspace_member.delete() - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index 14bece21b..f9e3df21e 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -73,6 +73,12 @@ def service_importer(service, importer_id): ] ) + # Check if any of the users are already member of workspace + _ = WorkspaceMember.objects.filter( + member__in=[user for user in workspace_users], + workspace_id=importer.workspace_id, + ).update(is_active=True) + # Add new users to Workspace and project automatically WorkspaceMember.objects.bulk_create( [ diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index 8b8ef6e48..41f6da3ca 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -13,23 +13,24 @@ from plane.db.models import Project, User, ProjectMemberInvite @shared_task -def project_invitation(email, project_id, token, current_site): +def project_invitation(email, project_id, token, current_site, invitor): try: + user = User.objects.get(email=invitor) project = Project.objects.get(pk=project_id) project_member_invite = ProjectMemberInvite.objects.get( token=token, email=email ) - relativelink = f"/project-member-invitation/{project_member_invite.id}" + relativelink = f"/project-invitations/?invitation_id={project_member_invite.id}&email={email}&slug={project.workspace.slug}&project_id={str(project_id)}" abs_url = current_site + relativelink from_email_string = settings.EMAIL_FROM - subject = f"{project.created_by.first_name or project.created_by.email} invited you to join {project.name} on Plane" + subject = f"{user.first_name or user.display_name or user.email} invited you to join {project.name} on Plane" context = { "email": email, - "first_name": project.created_by.first_name, + "first_name": user.first_name, "project_name": project.name, "invitation_url": abs_url, } diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 94be6f879..fca34a84d 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -11,25 +11,33 @@ from slack_sdk import WebClient from slack_sdk.errors import SlackApiError # Module imports -from plane.db.models import Workspace, WorkspaceMemberInvite +from plane.db.models import User, Workspace, WorkspaceMemberInvite @shared_task def workspace_invitation(email, workspace_id, token, current_site, invitor): try: + + user = User.objects.get(email=invitor) + workspace = Workspace.objects.get(pk=workspace_id) workspace_member_invite = WorkspaceMemberInvite.objects.get( token=token, email=email ) - realtivelink = ( - f"/workspace-member-invitation/?invitation_id={workspace_member_invite.id}&email={email}" + # Relative link + relative_link = ( + f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" ) - abs_url = current_site + realtivelink + # The complete url including the domain + abs_url = current_site + relative_link + + # The email from from_email_string = settings.EMAIL_FROM - subject = f"{invitor or email} invited you to join {workspace.name} on Plane" + # Subject of the email + subject = f"{user.first_name or user.display_name or user.email} invited you to join {workspace.name} on Plane" context = { "email": email, diff --git a/apiserver/plane/db/migrations/0047_issuemention_projectmember_is_deactivated_and_more.py b/apiserver/plane/db/migrations/0047_issuemention_projectmember_is_deactivated_and_more.py new file mode 100644 index 000000000..922bb16a7 --- /dev/null +++ b/apiserver/plane/db/migrations/0047_issuemention_projectmember_is_deactivated_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.5 on 2023-11-09 11:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0046_alter_analyticview_created_by_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='projectmember', + name='is_deactivated', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='workspacemember', + name='is_deactivated', + field=models.BooleanField(default=False), + ), + ] diff --git a/apiserver/plane/db/migrations/0048_issuemention_remove_projectmember_is_deactivated_and_more.py b/apiserver/plane/db/migrations/0048_issuemention_remove_projectmember_is_deactivated_and_more.py new file mode 100644 index 000000000..4ac133ada --- /dev/null +++ b/apiserver/plane/db/migrations/0048_issuemention_remove_projectmember_is_deactivated_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.5 on 2023-11-10 09:41 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0047_issuemention_projectmember_is_deactivated_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='projectmember', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='workspacemember', + name='is_active', + field=models.BooleanField(default=True), + ), + ] diff --git a/apiserver/plane/db/migrations/0049_issuemention_remove_projectmember_is_deactivated_and_more.py b/apiserver/plane/db/migrations/0049_issuemention_remove_projectmember_is_deactivated_and_more.py new file mode 100644 index 000000000..060b970dc --- /dev/null +++ b/apiserver/plane/db/migrations/0049_issuemention_remove_projectmember_is_deactivated_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.5 on 2023-11-11 17:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0048_issuemention_remove_projectmember_is_deactivated_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='projectmember', + name='is_deactivated', + ), + migrations.RemoveField( + model_name='workspacemember', + name='is_deactivated', + ), + ] diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index f4ace65e5..fe72c260b 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -166,6 +166,7 @@ class ProjectMember(ProjectBaseModel): default_props = models.JSONField(default=get_default_props) preferences = models.JSONField(default=get_default_preferences) sort_order = models.FloatField(default=65535) + is_active = models.BooleanField(default=True) def save(self, *args, **kwargs): if self._state.adding: diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index d1012f549..3b694062b 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -99,6 +99,7 @@ class WorkspaceMember(BaseModel): view_props = models.JSONField(default=get_default_props) default_props = models.JSONField(default=get_default_props) issue_props = models.JSONField(default=get_issue_props) + is_active = models.BooleanField(default=True) class Meta: unique_together = ["workspace", "member"] diff --git a/apiserver/templates/emails/invitations/project_invitation.html b/apiserver/templates/emails/invitations/project_invitation.html index ea2f1cdcf..630a5eab3 100644 --- a/apiserver/templates/emails/invitations/project_invitation.html +++ b/apiserver/templates/emails/invitations/project_invitation.html @@ -5,7 +5,7 @@ - {{ Inviter }} invited you to join {{ Workspace-Name }} on Plane + {{ first_name }} invited you to join {{ project_name }} on Plane