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
This commit is contained in:
Nikhil 2023-11-15 15:53:16 +05:30 committed by GitHub
parent 96f6e37cc5
commit fc51ffc589
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1318 additions and 720 deletions

View File

@ -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 from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission

View File

@ -13,14 +13,15 @@ Guest = 5
class ProjectBasePermission(BasePermission): class ProjectBasePermission(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
## Safe Methods -> Handle the filtering logic in queryset ## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS: if request.method in SAFE_METHODS:
return WorkspaceMember.objects.filter( return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug, member=request.user workspace__slug=view.workspace_slug,
member=request.user,
is_active=True,
).exists() ).exists()
## Only workspace owners or admins can create the projects ## Only workspace owners or admins can create the projects
@ -29,6 +30,7 @@ class ProjectBasePermission(BasePermission):
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
member=request.user, member=request.user,
role__in=[Admin, Member], role__in=[Admin, Member],
is_active=True,
).exists() ).exists()
## Only Project Admins can update project attributes ## Only Project Admins can update project attributes
@ -37,19 +39,21 @@ class ProjectBasePermission(BasePermission):
member=request.user, member=request.user,
role=Admin, role=Admin,
project_id=view.project_id, project_id=view.project_id,
is_active=True,
).exists() ).exists()
class ProjectMemberPermission(BasePermission): class ProjectMemberPermission(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
## Safe Methods -> Handle the filtering logic in queryset ## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS: if request.method in SAFE_METHODS:
return ProjectMember.objects.filter( return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug, member=request.user workspace__slug=view.workspace_slug,
member=request.user,
is_active=True,
).exists() ).exists()
## Only workspace owners or admins can create the projects ## Only workspace owners or admins can create the projects
if request.method == "POST": if request.method == "POST":
@ -57,6 +61,7 @@ class ProjectMemberPermission(BasePermission):
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
member=request.user, member=request.user,
role__in=[Admin, Member], role__in=[Admin, Member],
is_active=True,
).exists() ).exists()
## Only Project Admins can update project attributes ## Only Project Admins can update project attributes
@ -65,12 +70,12 @@ class ProjectMemberPermission(BasePermission):
member=request.user, member=request.user,
role__in=[Admin, Member], role__in=[Admin, Member],
project_id=view.project_id, project_id=view.project_id,
is_active=True,
).exists() ).exists()
class ProjectEntityPermission(BasePermission): class ProjectEntityPermission(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
@ -80,6 +85,7 @@ class ProjectEntityPermission(BasePermission):
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
member=request.user, member=request.user,
project_id=view.project_id, project_id=view.project_id,
is_active=True,
).exists() ).exists()
## Only project members or admins can create and edit the project attributes ## Only project members or admins can create and edit the project attributes
@ -88,17 +94,18 @@ class ProjectEntityPermission(BasePermission):
member=request.user, member=request.user,
role__in=[Admin, Member], role__in=[Admin, Member],
project_id=view.project_id, project_id=view.project_id,
is_active=True,
).exists() ).exists()
class ProjectLitePermission(BasePermission): class ProjectLitePermission(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
return ProjectMember.objects.filter( return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
member=request.user, member=request.user,
project_id=view.project_id, project_id=view.project_id,
is_active=True,
).exists() ).exists()

View File

@ -32,12 +32,16 @@ class WorkSpaceBasePermission(BasePermission):
member=request.user, member=request.user,
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
role__in=[Owner, Admin], role__in=[Owner, Admin],
is_active=True,
).exists() ).exists()
# allow only owner to delete the workspace # allow only owner to delete the workspace
if request.method == "DELETE": if request.method == "DELETE":
return WorkspaceMember.objects.filter( 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() ).exists()
@ -50,6 +54,7 @@ class WorkSpaceAdminPermission(BasePermission):
member=request.user, member=request.user,
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
role__in=[Owner, Admin], role__in=[Owner, Admin],
is_active=True,
).exists() ).exists()
@ -63,12 +68,14 @@ class WorkspaceEntityPermission(BasePermission):
return WorkspaceMember.objects.filter( return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
member=request.user, member=request.user,
is_active=True,
).exists() ).exists()
return WorkspaceMember.objects.filter( return WorkspaceMember.objects.filter(
member=request.user, member=request.user,
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
role__in=[Owner, Admin], role__in=[Owner, Admin],
is_active=True,
).exists() ).exists()
@ -78,5 +85,18 @@ class WorkspaceViewerPermission(BasePermission):
return False return False
return WorkspaceMember.objects.filter( 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() ).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,
)

View File

@ -103,7 +103,10 @@ class ProjectListSerializer(DynamicBaseSerializer):
members = serializers.SerializerMethodField() members = serializers.SerializerMethodField()
def get_members(self, obj): 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", "id",
"member_id", "member_id",
"member__display_name", "member__display_name",

View File

@ -2,17 +2,16 @@ from django.urls import path
from plane.api.views import ( from plane.api.views import (
ProjectViewSet, ProjectViewSet,
InviteProjectEndpoint, ProjectInvitationsViewset,
ProjectMemberViewSet, ProjectMemberViewSet,
ProjectMemberInvitationsViewset,
ProjectMemberUserEndpoint, ProjectMemberUserEndpoint,
ProjectJoinEndpoint, ProjectJoinEndpoint,
AddTeamToProjectEndpoint, AddTeamToProjectEndpoint,
ProjectUserViewsEndpoint, ProjectUserViewsEndpoint,
ProjectIdentifierEndpoint, ProjectIdentifierEndpoint,
ProjectFavoritesViewSet, ProjectFavoritesViewSet,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint, ProjectPublicCoverImagesEndpoint,
UserProjectInvitationsViewset,
) )
@ -45,13 +44,48 @@ urlpatterns = [
name="project-identifiers", name="project-identifiers",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/invite/", "workspaces/<str:slug>/projects/<uuid:project_id>/invitations/",
InviteProjectEndpoint.as_view(), ProjectInvitationsViewset.as_view(
name="invite-project", {
"get": "list",
"post": "create",
},
),
name="project-member-invite",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/invitations/<uuid:pk>/",
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/<str:slug>/projects/join/",
ProjectJoinEndpoint.as_view(),
name="project-join",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/", "workspaces/<str:slug>/projects/<uuid:project_id>/members/",
ProjectMemberViewSet.as_view({"get": "list", "post": "create"}), ProjectMemberViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-member", name="project-member",
), ),
path( path(
@ -66,30 +100,19 @@ urlpatterns = [
name="project-member", name="project-member",
), ),
path( path(
"workspaces/<str:slug>/projects/join/", "workspaces/<str:slug>/projects/<uuid:project_id>/members/leave/",
ProjectJoinEndpoint.as_view(), ProjectMemberViewSet.as_view(
name="project-join", {
"post": "leave",
}
),
name="project-member",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/team-invite/", "workspaces/<str:slug>/projects/<uuid:project_id>/team-invite/",
AddTeamToProjectEndpoint.as_view(), AddTeamToProjectEndpoint.as_view(),
name="projects", name="projects",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/invitations/",
ProjectMemberInvitationsViewset.as_view({"get": "list"}),
name="project-member-invite",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/invitations/<uuid:pk>/",
ProjectMemberInvitationsViewset.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
name="project-member-invite",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-views/", "workspaces/<str:slug>/projects/<uuid:project_id>/project-views/",
ProjectUserViewsEndpoint.as_view(), ProjectUserViewsEndpoint.as_view(),
@ -119,11 +142,6 @@ urlpatterns = [
), ),
name="project-favorite", name="project-favorite",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/leave/",
LeaveProjectEndpoint.as_view(),
name="leave-project",
),
path( path(
"project-covers/", "project-covers/",
ProjectPublicCoverImagesEndpoint.as_view(), ProjectPublicCoverImagesEndpoint.as_view(),

View File

@ -9,15 +9,10 @@ from plane.api.views import (
ChangePasswordEndpoint, ChangePasswordEndpoint,
## End User ## End User
## Workspaces ## Workspaces
UserWorkspaceInvitationsEndpoint,
UserWorkSpacesEndpoint, UserWorkSpacesEndpoint,
JoinWorkspaceEndpoint,
UserWorkspaceInvitationsEndpoint,
UserWorkspaceInvitationEndpoint,
UserActivityGraphEndpoint, UserActivityGraphEndpoint,
UserIssueCompletedGraphEndpoint, UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint, UserWorkspaceDashboardEndpoint,
UserProjectInvitationsViewset,
## End Workspaces ## End Workspaces
) )
@ -26,7 +21,11 @@ urlpatterns = [
path( path(
"users/me/", "users/me/",
UserEndpoint.as_view( UserEndpoint.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"} {
"get": "retrieve",
"patch": "partial_update",
"delete": "deactivate",
}
), ),
name="users", name="users",
), ),
@ -65,23 +64,6 @@ urlpatterns = [
UserWorkSpacesEndpoint.as_view(), UserWorkSpacesEndpoint.as_view(),
name="user-workspace", 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/<uuid:pk>/",
UserWorkspaceInvitationEndpoint.as_view(
{
"get": "retrieve",
}
),
name="user-workspace-invitation",
),
# user join workspace
# User Graphs # User Graphs
path( path(
"users/me/workspaces/<str:slug>/activity-graph/", "users/me/workspaces/<str:slug>/activity-graph/",
@ -99,15 +81,4 @@ urlpatterns = [
name="user-workspace-dashboard", name="user-workspace-dashboard",
), ),
## End User Graph ## End User Graph
path(
"users/me/invitations/workspaces/<str:slug>/<uuid:pk>/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",
),
] ]

View File

@ -2,8 +2,9 @@ from django.urls import path
from plane.api.views import ( from plane.api.views import (
UserWorkspaceInvitationsViewSet,
WorkSpaceViewSet, WorkSpaceViewSet,
InviteWorkspaceEndpoint, WorkspaceJoinEndpoint,
WorkSpaceMemberViewSet, WorkSpaceMemberViewSet,
WorkspaceInvitationsViewset, WorkspaceInvitationsViewset,
WorkspaceMemberUserEndpoint, WorkspaceMemberUserEndpoint,
@ -17,7 +18,6 @@ from plane.api.views import (
WorkspaceUserProfileEndpoint, WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint, WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint, WorkspaceLabelsEndpoint,
LeaveWorkspaceEndpoint,
) )
@ -49,14 +49,14 @@ urlpatterns = [
), ),
name="workspace", name="workspace",
), ),
path(
"workspaces/<str:slug>/invite/",
InviteWorkspaceEndpoint.as_view(),
name="invite-workspace",
),
path( path(
"workspaces/<str:slug>/invitations/", "workspaces/<str:slug>/invitations/",
WorkspaceInvitationsViewset.as_view({"get": "list"}), WorkspaceInvitationsViewset.as_view(
{
"get": "list",
"post": "create",
},
),
name="workspace-invitations", name="workspace-invitations",
), ),
path( path(
@ -69,6 +69,23 @@ urlpatterns = [
), ),
name="workspace-invitations", name="workspace-invitations",
), ),
# user workspace invitations
path(
"users/me/workspaces/invitations/",
UserWorkspaceInvitationsViewSet.as_view(
{
"get": "list",
"post": "create",
},
),
name="user-workspace-invitations",
),
path(
"workspaces/<str:slug>/invitations/<uuid:pk>/join/",
WorkspaceJoinEndpoint.as_view(),
name="workspace-join",
),
# user join workspace
path( path(
"workspaces/<str:slug>/members/", "workspaces/<str:slug>/members/",
WorkSpaceMemberViewSet.as_view({"get": "list"}), WorkSpaceMemberViewSet.as_view({"get": "list"}),
@ -85,6 +102,15 @@ urlpatterns = [
), ),
name="workspace-member", name="workspace-member",
), ),
path(
"workspaces/<str:slug>/members/leave/",
WorkSpaceMemberViewSet.as_view(
{
"post": "leave",
},
),
name="leave-workspace-members",
),
path( path(
"workspaces/<str:slug>/teams/", "workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view( TeamMemberViewSet.as_view(
@ -168,9 +194,4 @@ urlpatterns = [
WorkspaceLabelsEndpoint.as_view(), WorkspaceLabelsEndpoint.as_view(),
name="workspace-labels", name="workspace-labels",
), ),
path(
"workspaces/<str:slug>/members/leave/",
LeaveWorkspaceEndpoint.as_view(),
name="leave-workspace-members",
),
] ]

View File

@ -2,10 +2,8 @@ from .project import (
ProjectViewSet, ProjectViewSet,
ProjectMemberViewSet, ProjectMemberViewSet,
UserProjectInvitationsViewset, UserProjectInvitationsViewset,
InviteProjectEndpoint, ProjectInvitationsViewset,
AddTeamToProjectEndpoint, AddTeamToProjectEndpoint,
ProjectMemberInvitationsViewset,
ProjectMemberInviteDetailViewSet,
ProjectIdentifierEndpoint, ProjectIdentifierEndpoint,
ProjectJoinEndpoint, ProjectJoinEndpoint,
ProjectUserViewsEndpoint, ProjectUserViewsEndpoint,
@ -14,7 +12,6 @@ from .project import (
ProjectDeployBoardViewSet, ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint, ProjectDeployBoardPublicSettingsEndpoint,
WorkspaceProjectDeployBoardEndpoint, WorkspaceProjectDeployBoardEndpoint,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint, ProjectPublicCoverImagesEndpoint,
) )
from .user import ( from .user import (
@ -32,13 +29,11 @@ from .workspace import (
WorkSpaceViewSet, WorkSpaceViewSet,
UserWorkSpacesEndpoint, UserWorkSpacesEndpoint,
WorkSpaceAvailabilityCheckEndpoint, WorkSpaceAvailabilityCheckEndpoint,
InviteWorkspaceEndpoint, WorkspaceJoinEndpoint,
JoinWorkspaceEndpoint,
WorkSpaceMemberViewSet, WorkSpaceMemberViewSet,
TeamMemberViewSet, TeamMemberViewSet,
WorkspaceInvitationsViewset, WorkspaceInvitationsViewset,
UserWorkspaceInvitationsEndpoint, UserWorkspaceInvitationsViewSet,
UserWorkspaceInvitationEndpoint,
UserLastProjectWithWorkspaceEndpoint, UserLastProjectWithWorkspaceEndpoint,
WorkspaceMemberUserEndpoint, WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint, WorkspaceMemberUserViewsEndpoint,
@ -51,7 +46,6 @@ from .workspace import (
WorkspaceUserProfileEndpoint, WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint, WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint, WorkspaceLabelsEndpoint,
LeaveWorkspaceEndpoint,
) )
from .state import StateViewSet from .state import StateViewSet
from .view import ( from .view import (

View File

@ -4,7 +4,7 @@ import random
import string import string
import json import json
import requests import requests
from requests.exceptions import RequestException
# Django imports # Django imports
from django.utils import timezone from django.utils import timezone
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -22,8 +22,13 @@ from sentry_sdk import capture_exception, capture_message
# Module imports # Module imports
from . import BaseAPIView from . import BaseAPIView
from plane.db.models import User from plane.db.models import (
from plane.api.serializers import UserSerializer User,
WorkspaceMemberInvite,
WorkspaceMember,
ProjectMemberInvite,
ProjectMember,
)
from plane.settings.redis import redis_instance from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link from plane.bgtasks.magic_link_code_task import magic_link
@ -86,35 +91,93 @@ class SignUpEndpoint(BaseAPIView):
user.token_updated_at = timezone.now() user.token_updated_at = timezone.now()
user.save() 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) access_token, refresh_token = get_tokens_for_user(user)
data = { data = {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_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) return Response(data, status=status.HTTP_200_OK)
@ -176,33 +239,92 @@ class SignInEndpoint(BaseAPIView):
user.token_updated_at = timezone.now() user.token_updated_at = timezone.now()
user.save() 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
# Send Analytics workspace_member_invites = WorkspaceMemberInvite.objects.filter(
if settings.ANALYTICS_BASE_API: email=user.email, accepted=True
_ = requests.post( )
settings.ANALYTICS_BASE_API,
headers={ WorkspaceMember.objects.bulk_create(
"Content-Type": "application/json", [
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY, WorkspaceMember(
}, workspace_id=workspace_member_invite.workspace_id,
json={ member=user,
"event_id": uuid.uuid4().hex, role=workspace_member_invite.role,
"event_data": { )
"medium": "email", 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)}, json={
"device_ctx": { "event_id": uuid.uuid4().hex,
"ip": request.META.get("REMOTE_ADDR"), "event_data": {
"user_agent": request.META.get("HTTP_USER_AGENT"), "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 = { data = {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
} }
access_token, refresh_token = get_tokens_for_user(user)
return Response(data, status=status.HTTP_200_OK) return Response(data, status=status.HTTP_200_OK)
@ -320,27 +442,37 @@ class MagicSignInEndpoint(BaseAPIView):
if str(token) == str(user_token): if str(token) == str(user_token):
if User.objects.filter(email=email).exists(): if User.objects.filter(email=email).exists():
user = User.objects.get(email=email) user = User.objects.get(email=email)
# Send event to Jitsu for tracking if not user.is_active:
if settings.ANALYTICS_BASE_API: return Response(
_ = requests.post( {
settings.ANALYTICS_BASE_API, "error": "Your account has been deactivated. Please contact your site administrator."
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",
}, },
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: else:
user = User.objects.create( user = User.objects.create(
email=email, email=email,
@ -348,27 +480,30 @@ class MagicSignInEndpoint(BaseAPIView):
password=make_password(uuid.uuid4().hex), password=make_password(uuid.uuid4().hex),
is_password_autoset=True, is_password_autoset=True,
) )
# Send event to Jitsu for tracking try:
if settings.ANALYTICS_BASE_API: # Send event to Jitsu for tracking
_ = requests.post( if settings.ANALYTICS_BASE_API:
settings.ANALYTICS_BASE_API, _ = requests.post(
headers={ settings.ANALYTICS_BASE_API,
"Content-Type": "application/json", headers={
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY, "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)}, json={
"device_ctx": { "event_id": uuid.uuid4().hex,
"ip": request.META.get("REMOTE_ADDR"), "event_data": {
"user_agent": request.META.get("HTTP_USER_AGENT"), "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_active = timezone.now()
user.last_login_time = timezone.now() user.last_login_time = timezone.now()
@ -377,6 +512,63 @@ class MagicSignInEndpoint(BaseAPIView):
user.token_updated_at = timezone.now() user.token_updated_at = timezone.now()
user.save() 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) access_token, refresh_token = get_tokens_for_user(user)
data = { data = {
"access_token": access_token, "access_token": access_token,

View File

@ -64,9 +64,7 @@ class InboxViewSet(BaseViewSet):
serializer.save(project_id=self.kwargs.get("project_id")) serializer.save(project_id=self.kwargs.get("project_id"))
def destroy(self, request, slug, project_id, pk): def destroy(self, request, slug, project_id, pk):
inbox = Inbox.objects.get( inbox = Inbox.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
workspace__slug=slug, project_id=project_id, pk=pk
)
# Handle default inbox delete # Handle default inbox delete
if inbox.is_default: if inbox.is_default:
return Response( return Response(
@ -128,9 +126,7 @@ class InboxIssueViewSet(BaseViewSet):
.values("count") .values("count")
) )
.annotate( .annotate(
attachment_count=IssueAttachment.objects.filter( attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
issue=OuterRef("id")
)
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
@ -150,7 +146,6 @@ class InboxIssueViewSet(BaseViewSet):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
def create(self, request, slug, project_id, inbox_id): def create(self, request, slug, project_id, inbox_id):
if not request.data.get("issue", {}).get("name", False): if not request.data.get("issue", {}).get("name", False):
return Response( return Response(
@ -198,7 +193,7 @@ class InboxIssueViewSet(BaseViewSet):
issue_id=str(issue.id), issue_id=str(issue.id),
project_id=str(project_id), project_id=str(project_id),
current_instance=None, current_instance=None,
epoch=int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp()),
) )
# create an inbox issue # create an inbox issue
InboxIssue.objects.create( InboxIssue.objects.create(
@ -216,10 +211,20 @@ class InboxIssueViewSet(BaseViewSet):
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
) )
# Get the project member # 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 # 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): if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST) request.user.id
):
return Response(
{"error": "You cannot edit inbox issues"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get issue data # Get issue data
issue_data = request.data.pop("issue", False) issue_data = request.data.pop("issue", False)
@ -230,11 +235,13 @@ class InboxIssueViewSet(BaseViewSet):
) )
# Only allow guests and viewers to edit name and description # Only allow guests and viewers to edit name and description
if project_member.role <= 10: if project_member.role <= 10:
# viewers and guests since only viewers and guests # viewers and guests since only viewers and guests
issue_data = { issue_data = {
"name": issue_data.get("name", issue.name), "name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html), "description_html": issue_data.get(
"description": issue_data.get("description", issue.description) "description_html", issue.description_html
),
"description": issue_data.get("description", issue.description),
} }
issue_serializer = IssueCreateSerializer( issue_serializer = IssueCreateSerializer(
@ -256,7 +263,7 @@ class InboxIssueViewSet(BaseViewSet):
IssueSerializer(current_instance).data, IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch=int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp()),
) )
issue_serializer.save() issue_serializer.save()
else: else:
@ -307,7 +314,9 @@ class InboxIssueViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else: 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): def retrieve(self, request, slug, project_id, inbox_id, pk):
inbox_issue = InboxIssue.objects.get( 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 pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
) )
# Get the project member # 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): if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST) request.user.id
):
return Response(
{"error": "You cannot delete inbox issue"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check the issue status # Check the issue status
if inbox_issue.status in [-2, -1, 0, 2]: if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also # 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() inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@ -347,7 +368,10 @@ class InboxIssuePublicViewSet(BaseViewSet):
] ]
def get_queryset(self): 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: if project_deploy_board is not None:
return self.filter_queryset( return self.filter_queryset(
super() super()
@ -363,9 +387,14 @@ class InboxIssuePublicViewSet(BaseViewSet):
return InboxIssue.objects.none() return InboxIssue.objects.none()
def list(self, request, slug, project_id, inbox_id): 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: 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") filters = issue_filters(request.query_params, "GET")
issues = ( issues = (
@ -392,9 +421,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
.values("count") .values("count")
) )
.annotate( .annotate(
attachment_count=IssueAttachment.objects.filter( attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
issue=OuterRef("id")
)
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
@ -415,9 +442,14 @@ class InboxIssuePublicViewSet(BaseViewSet):
) )
def create(self, request, slug, project_id, inbox_id): 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: 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): if not request.data.get("issue", {}).get("name", False):
return Response( return Response(
@ -465,7 +497,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
issue_id=str(issue.id), issue_id=str(issue.id),
project_id=str(project_id), project_id=str(project_id),
current_instance=None, current_instance=None,
epoch=int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp()),
) )
# create an inbox issue # create an inbox issue
InboxIssue.objects.create( InboxIssue.objects.create(
@ -479,34 +511,41 @@ class InboxIssuePublicViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, inbox_id, pk): 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: 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( inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
) )
# Get the project member # Get the project member
if str(inbox_issue.created_by_id) != str(request.user.id): 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 # Get issue data
issue_data = request.data.pop("issue", False) issue_data = request.data.pop("issue", False)
issue = Issue.objects.get( issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id 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 = { issue_data = {
"name": issue_data.get("name", issue.name), "name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html), "description_html": issue_data.get(
"description": issue_data.get("description", issue.description) "description_html", issue.description_html
),
"description": issue_data.get("description", issue.description),
} }
issue_serializer = IssueCreateSerializer( issue_serializer = IssueCreateSerializer(issue, data=issue_data, partial=True)
issue, data=issue_data, partial=True
)
if issue_serializer.is_valid(): if issue_serializer.is_valid():
current_instance = issue current_instance = issue
@ -523,17 +562,22 @@ class InboxIssuePublicViewSet(BaseViewSet):
IssueSerializer(current_instance).data, IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch=int(timezone.now().timestamp()) epoch=int(timezone.now().timestamp()),
) )
issue_serializer.save() issue_serializer.save()
return Response(issue_serializer.data, status=status.HTTP_200_OK) return Response(issue_serializer.data, status=status.HTTP_200_OK)
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, inbox_id, pk): 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: 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( inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id 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) return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, pk): 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: 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( inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
) )
if str(inbox_issue.created_by_id) != str(request.user.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() inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -623,6 +623,7 @@ class IssueCommentViewSet(BaseViewSet):
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
member_id=self.request.user.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): def list(self, request, slug, project_id, issue_id):
members = ( 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( .annotate(
is_subscribed=Exists( is_subscribed=Exists(
IssueSubscriber.objects.filter( IssueSubscriber.objects.filter(
@ -1498,6 +1503,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
member_id=self.request.user.id, member_id=self.request.user.id,
is_active=True,
) )
) )
) )
@ -1538,6 +1544,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
if not ProjectMember.objects.filter( if not ProjectMember.objects.filter(
project_id=project_id, project_id=project_id,
member=request.user, member=request.user,
is_active=True,
).exists(): ).exists():
# Add the user for workspace tracking # Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create( _ = ProjectPublicMember.objects.get_or_create(
@ -1651,6 +1658,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
if not ProjectMember.objects.filter( if not ProjectMember.objects.filter(
project_id=project_id, project_id=project_id,
member=request.user, member=request.user,
is_active=True,
).exists(): ).exists():
# Add the user for workspace tracking # Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create( _ = ProjectPublicMember.objects.get_or_create(
@ -1744,7 +1752,9 @@ class CommentReactionPublicViewSet(BaseViewSet):
project_id=project_id, comment_id=comment_id, actor=request.user project_id=project_id, comment_id=comment_id, actor=request.user
) )
if not ProjectMember.objects.filter( if not ProjectMember.objects.filter(
project_id=project_id, member=request.user project_id=project_id,
member=request.user,
is_active=True,
).exists(): ).exists():
# Add the user for workspace tracking # Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create( _ = ProjectPublicMember.objects.get_or_create(
@ -1829,7 +1839,9 @@ class IssueVotePublicViewSet(BaseViewSet):
) )
# Add the user for workspace tracking # Add the user for workspace tracking
if not ProjectMember.objects.filter( if not ProjectMember.objects.filter(
project_id=project_id, member=request.user project_id=project_id,
member=request.user,
is_active=True,
).exists(): ).exists():
_ = ProjectPublicMember.objects.get_or_create( _ = ProjectPublicMember.objects.get_or_create(
project_id=project_id, project_id=project_id,

View File

@ -85,7 +85,10 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
# Created issues # Created issues
if type == "created": if type == "created":
if WorkspaceMember.objects.filter( 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(): ).exists():
notifications = Notification.objects.none() notifications = Notification.objects.none()
else: else:
@ -255,7 +258,10 @@ class MarkAllReadNotificationViewSet(BaseViewSet):
# Created issues # Created issues
if type == "created": if type == "created":
if WorkspaceMember.objects.filter( 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(): ).exists():
notifications = Notification.objects.none() notifications = Notification.objects.none()
else: else:

View File

@ -2,6 +2,7 @@
import uuid import uuid
import requests import requests
import os import os
from requests.exceptions import RequestException
# Django imports # Django imports
from django.utils import timezone 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 from google.auth.transport import requests as google_auth_request
# Module imports # 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 plane.api.serializers import UserSerializer
from .base import BaseAPIView from .base import BaseAPIView
@ -168,7 +176,6 @@ class OauthEndpoint(BaseAPIView):
) )
## Login Case ## Login Case
if not user.is_active: if not user.is_active:
return Response( return Response(
{ {
@ -185,12 +192,61 @@ class OauthEndpoint(BaseAPIView):
user.is_email_verified = email_verified user.is_email_verified = email_verified
user.save() 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 = { WorkspaceMember.objects.bulk_create(
"access_token": access_token, [
"refresh_token": refresh_token, 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( SocialLoginConnection.objects.update_or_create(
medium=medium, medium=medium,
@ -201,26 +257,36 @@ class OauthEndpoint(BaseAPIView):
"last_login_at": timezone.now(), "last_login_at": timezone.now(),
}, },
) )
if settings.ANALYTICS_BASE_API: try:
_ = requests.post( if settings.ANALYTICS_BASE_API:
settings.ANALYTICS_BASE_API, _ = requests.post(
headers={ settings.ANALYTICS_BASE_API,
"Content-Type": "application/json", headers={
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY, "Content-Type": "application/json",
}, "X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": f"oauth-{medium}",
}, },
"user": {"email": email, "id": str(user.id)}, json={
"device_ctx": { "event_id": uuid.uuid4().hex,
"ip": request.META.get("REMOTE_ADDR"), "event_data": {
"user_agent": request.META.get("HTTP_USER_AGENT"), "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) return Response(data, status=status.HTTP_200_OK)
except User.DoesNotExist: except User.DoesNotExist:
@ -260,31 +326,85 @@ class OauthEndpoint(BaseAPIView):
user.token_updated_at = timezone.now() user.token_updated_at = timezone.now()
user.save() 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
data = { workspace_member_invites = WorkspaceMemberInvite.objects.filter(
"access_token": access_token, email=user.email, accepted=True
"refresh_token": refresh_token, )
}
if settings.ANALYTICS_BASE_API: WorkspaceMember.objects.bulk_create(
_ = requests.post( [
settings.ANALYTICS_BASE_API, WorkspaceMember(
headers={ workspace_id=workspace_member_invite.workspace_id,
"Content-Type": "application/json", member=user,
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY, role=workspace_member_invite.role,
}, )
json={ for workspace_member_invite in workspace_member_invites
"event_id": uuid.uuid4().hex, ],
"event_data": { ignore_conflicts=True,
"medium": f"oauth-{medium}", )
# 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)}, json={
"device_ctx": { "event_id": uuid.uuid4().hex,
"ip": request.META.get("REMOTE_ADDR"), "event_data": {
"user_agent": request.META.get("HTTP_USER_AGENT"), "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( SocialLoginConnection.objects.update_or_create(
medium=medium, medium=medium,
@ -295,4 +415,10 @@ class OauthEndpoint(BaseAPIView):
"last_login_at": timezone.now(), "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) return Response(data, status=status.HTTP_201_CREATED)

View File

@ -17,13 +17,13 @@ from django.db.models import (
) )
from django.core.validators import validate_email from django.core.validators import validate_email
from django.conf import settings from django.conf import settings
from django.utils import timezone
# Third Party imports # Third Party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework import serializers from rest_framework import serializers
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from sentry_sdk import capture_exception
# Module imports # Module imports
from .base import BaseViewSet, BaseAPIView from .base import BaseViewSet, BaseAPIView
@ -39,6 +39,7 @@ from plane.api.serializers import (
) )
from plane.api.permissions import ( from plane.api.permissions import (
WorkspaceUserPermission,
ProjectBasePermission, ProjectBasePermission,
ProjectEntityPermission, ProjectEntityPermission,
ProjectMemberPermission, ProjectMemberPermission,
@ -58,13 +59,6 @@ from plane.db.models import (
ProjectIdentifier, ProjectIdentifier,
Module, Module,
Cycle, Cycle,
CycleFavorite,
ModuleFavorite,
PageFavorite,
IssueViewFavorite,
Page,
IssueAssignee,
ModuleMember,
Inbox, Inbox,
ProjectDeployBoard, ProjectDeployBoard,
IssueProperty, IssueProperty,
@ -110,12 +104,15 @@ class ProjectViewSet(BaseViewSet):
member=self.request.user, member=self.request.user,
project_id=OuterRef("pk"), project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
is_active=True,
) )
) )
) )
.annotate( .annotate(
total_members=ProjectMember.objects.filter( 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() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
@ -137,6 +134,7 @@ class ProjectViewSet(BaseViewSet):
member_role=ProjectMember.objects.filter( member_role=ProjectMember.objects.filter(
project_id=OuterRef("pk"), project_id=OuterRef("pk"),
member_id=self.request.user.id, member_id=self.request.user.id,
is_active=True,
).values("role") ).values("role")
) )
.annotate( .annotate(
@ -157,6 +155,7 @@ class ProjectViewSet(BaseViewSet):
member=request.user, member=request.user,
project_id=OuterRef("pk"), project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
is_active=True,
).values("sort_order") ).values("sort_order")
projects = ( projects = (
self.get_queryset() self.get_queryset()
@ -166,6 +165,7 @@ class ProjectViewSet(BaseViewSet):
"project_projectmember", "project_projectmember",
queryset=ProjectMember.objects.filter( queryset=ProjectMember.objects.filter(
workspace__slug=slug, workspace__slug=slug,
is_active=True,
).select_related("member"), ).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 = [ permission_classes = [
ProjectBasePermission, ProjectBasePermission,
] ]
def post(self, request, slug, project_id): def get_queryset(self):
email = request.data.get("email", False) return self.filter_queryset(
role = request.data.get("role", False) super()
.get_queryset()
# Check if email is provided .filter(workspace__slug=self.kwargs.get("slug"))
if not email: .filter(project_id=self.kwargs.get("project_id"))
return Response( .select_related("project")
{"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST .select_related("workspace", "workspace__owner")
)
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
) )
_ = 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( 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") .select_related("workspace", "workspace__owner", "project")
) )
def create(self, request): def create(self, request, slug):
invitations = request.data.get("invitations") project_ids = request.data.get("project_ids", [])
project_invitations = ProjectMemberInvite.objects.filter(
pk__in=invitations, accepted=True # 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.objects.bulk_create(
[ [
ProjectMember( ProjectMember(
project=invitation.project, project_id=project_id,
workspace=invitation.project.workspace,
member=request.user, member=request.user,
role=invitation.role, role=15 if workspace_role >= 15 else 10,
workspace=workspace,
created_by=request.user, created_by=request.user,
) )
for invitation in project_invitations for project_id in project_ids
] ],
ignore_conflicts=True,
) )
IssueProperty.objects.bulk_create( IssueProperty.objects.bulk_create(
[ [
ProjectMember( IssueProperty(
project=invitation.project, project_id=project_id,
workspace=invitation.project.workspace,
user=request.user, user=request.user,
workspace=workspace,
created_by=request.user, created_by=request.user,
) )
for invitation in project_invitations for project_id in project_ids
] ],
ignore_conflicts=True,
) )
# Delete joined project invites return Response(
project_invitations.delete() {"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): class ProjectMemberViewSet(BaseViewSet):
@ -475,6 +607,7 @@ class ProjectMemberViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(member__is_bot=False) .filter(member__is_bot=False)
.filter()
.select_related("project") .select_related("project")
.select_related("member") .select_related("member")
.select_related("workspace", "workspace__owner") .select_related("workspace", "workspace__owner")
@ -542,13 +675,17 @@ class ProjectMemberViewSet(BaseViewSet):
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
project_member = ProjectMember.objects.get( 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_members = ProjectMember.objects.filter(
project_id=project_id, project_id=project_id,
workspace__slug=slug, workspace__slug=slug,
member__is_bot=False, member__is_bot=False,
is_active=True,
).select_related("project", "member", "workspace") ).select_related("project", "member", "workspace")
if project_member.role > 10: if project_member.role > 10:
@ -559,7 +696,10 @@ class ProjectMemberViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get( 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: if request.user.id == project_member.member_id:
return Response( return Response(
@ -568,7 +708,10 @@ class ProjectMemberViewSet(BaseViewSet):
) )
# Check while updating user roles # Check while updating user roles
requested_project_member = ProjectMember.objects.get( 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 ( if (
"role" in request.data "role" in request.data
@ -591,54 +734,66 @@ class ProjectMemberViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk): def destroy(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get( 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 # check requesting user role
requesting_project_member = ProjectMember.objects.get( 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: if requesting_project_member.role < project_member.role:
return Response( 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, status=status.HTTP_400_BAD_REQUEST,
) )
# Remove all favorites project_member.is_active = False
ProjectFavorite.objects.filter( project_member.save()
workspace__slug=slug, project_id=project_id, user=project_member.member return Response(status=status.HTTP_204_NO_CONTENT)
).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()
# Remove if module member def leave(self, request, slug, project_id):
ModuleMember.objects.filter( project_member = ProjectMember.objects.get(
workspace__slug=slug, workspace__slug=slug,
project_id=project_id, project_id=project_id,
member=project_member.member, member=request.user,
).delete() is_active=True,
# Delete owned Pages )
Page.objects.filter(
workspace__slug=slug, # Check if the leaving user is the only admin of the project
project_id=project_id, if (
owned_by=project_member.member, project_member.role == 20
).delete() and not ProjectMember.objects.filter(
project_member.delete() 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) return Response(status=status.HTTP_204_NO_CONTENT)
@ -691,46 +846,6 @@ class AddTeamToProjectEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED) 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): class ProjectIdentifierEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectBasePermission, 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): class ProjectUserViewsEndpoint(BaseAPIView):
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug) project = Project.objects.get(pk=project_id, workspace__slug=slug)
project_member = ProjectMember.objects.filter( project_member = ProjectMember.objects.filter(
member=request.user, project=project member=request.user,
project=project,
is_active=True,
).first() ).first()
if project_member is None: if project_member is None:
@ -850,7 +920,10 @@ class ProjectUserViewsEndpoint(BaseAPIView):
class ProjectMemberUserEndpoint(BaseAPIView): class ProjectMemberUserEndpoint(BaseAPIView):
def get(self, request, slug, project_id): def get(self, request, slug, project_id):
project_member = ProjectMember.objects.get( 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) serializer = ProjectMemberSerializer(project_member)
@ -983,39 +1056,6 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
return Response(projects, status=status.HTTP_200_OK) 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): class ProjectPublicCoverImagesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
AllowAny, AllowAny,

View File

@ -13,13 +13,7 @@ from plane.api.serializers import (
) )
from plane.api.views.base import BaseViewSet, BaseAPIView from plane.api.views.base import BaseViewSet, BaseAPIView
from plane.db.models import ( from plane.db.models import User, IssueActivity, WorkspaceMember
User,
Workspace,
WorkspaceMemberInvite,
Issue,
IssueActivity,
)
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
@ -41,10 +35,28 @@ class UserEndpoint(BaseViewSet):
serialized_data = UserMeSettingsSerializer(request.user).data serialized_data = UserMeSettingsSerializer(request.user).data
return Response(serialized_data, status=status.HTTP_200_OK) 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): class UpdateUserOnBoardedEndpoint(BaseAPIView):
def patch(self, request): 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.is_onboarded = request.data.get("is_onboarded", False)
user.save() user.save()
return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK)
@ -52,7 +64,7 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
class UpdateUserTourCompletedEndpoint(BaseAPIView): class UpdateUserTourCompletedEndpoint(BaseAPIView):
def patch(self, request): 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.is_tour_completed = request.data.get("is_tour_completed", False)
user.save() user.save()
return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK)

View File

@ -2,7 +2,6 @@
import jwt import jwt
from datetime import date, datetime from datetime import date, datetime
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from uuid import uuid4
# Django imports # Django imports
from django.db import IntegrityError 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.functions import ExtractWeek, Cast, ExtractDay
from django.db.models.fields import DateField from django.db.models.fields import DateField
from django.contrib.auth.hashers import make_password
# Third party modules # Third party modules
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny, IsAuthenticated
from sentry_sdk import capture_exception
# Module imports # Module imports
from plane.api.serializers import ( from plane.api.serializers import (
@ -59,14 +56,6 @@ from plane.db.models import (
IssueActivity, IssueActivity,
Issue, Issue,
WorkspaceTheme, WorkspaceTheme,
IssueAssignee,
ProjectFavorite,
CycleFavorite,
ModuleMember,
ModuleFavorite,
PageFavorite,
Page,
IssueViewFavorite,
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
IssueSubscriber, IssueSubscriber,
@ -106,7 +95,9 @@ class WorkSpaceViewSet(BaseViewSet):
def get_queryset(self): def get_queryset(self):
member_count = ( member_count = (
WorkspaceMember.objects.filter( WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False workspace=OuterRef("id"),
member__is_bot=False,
is_active=True,
) )
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
@ -181,7 +172,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
def get(self, request): def get(self, request):
member_count = ( member_count = (
WorkspaceMember.objects.filter( WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False workspace=OuterRef("id"),
member__is_bot=False,
is_active=True,
) )
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
@ -227,23 +220,40 @@ class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
return Response({"status": not workspace}, status=status.HTTP_200_OK) 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 = [ permission_classes = [
WorkSpaceAdminPermission, WorkSpaceAdminPermission,
] ]
def post(self, request, slug): def get_queryset(self):
emails = request.data.get("emails", False) 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 # Check if email is provided
if not emails or not len(emails): if not emails:
return Response( return Response(
{"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST {"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( 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( if len(
[ [
email email
@ -256,15 +266,17 @@ class InviteWorkspaceEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Get the workspace object
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
# Check if user is already a member of workspace # Check if user is already a member of workspace
workspace_members = WorkspaceMember.objects.filter( workspace_members = WorkspaceMember.objects.filter(
workspace_id=workspace.id, workspace_id=workspace.id,
member__email__in=[email.get("email") for email in emails], member__email__in=[email.get("email") for email in emails],
is_active=True,
).select_related("member", "workspace", "workspace__owner") ).select_related("member", "workspace", "workspace__owner")
if len(workspace_members): if workspace_members:
return Response( return Response(
{ {
"error": "Some users are already member of workspace", "error": "Some users are already member of workspace",
@ -302,35 +314,20 @@ class InviteWorkspaceEndpoint(BaseAPIView):
}, },
status=status.HTTP_400_BAD_REQUEST, 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, batch_size=10, ignore_conflicts=True
) )
workspace_invitations = WorkspaceMemberInvite.objects.filter( current_site = f"{request.scheme}://{request.get_host()}",
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,
)
# Send invitations
for invitation in workspace_invitations: for invitation in workspace_invitations:
workspace_invitation.delay( workspace_invitation.delay(
invitation.email, invitation.email,
workspace.id, workspace.id,
invitation.token, invitation.token,
request.META.get('HTTP_ORIGIN'), current_site,
request.user.email, request.user.email,
) )
@ -341,11 +338,19 @@ class InviteWorkspaceEndpoint(BaseAPIView):
status=status.HTTP_200_OK, 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 = [ permission_classes = [
AllowAny, AllowAny,
] ]
"""Invitation response endpoint the user can respond to the invitation"""
def post(self, request, slug, pk): def post(self, request, slug, pk):
workspace_invite = WorkspaceMemberInvite.objects.get( workspace_invite = WorkspaceMemberInvite.objects.get(
@ -354,12 +359,14 @@ class JoinWorkspaceEndpoint(BaseAPIView):
email = request.data.get("email", "") email = request.data.get("email", "")
# Check the email
if email == "" or workspace_invite.email != email: if email == "" or workspace_invite.email != email:
return Response( return Response(
{"error": "You do not have permission to join the workspace"}, {"error": "You do not have permission to join the workspace"},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
# If already responded then return error
if workspace_invite.responded_at is None: if workspace_invite.responded_at is None:
workspace_invite.accepted = request.data.get("accepted", False) workspace_invite.accepted = request.data.get("accepted", False)
workspace_invite.responded_at = timezone.now() workspace_invite.responded_at = timezone.now()
@ -371,12 +378,23 @@ class JoinWorkspaceEndpoint(BaseAPIView):
# If the user is present then create the workspace member # If the user is present then create the workspace member
if user is not None: if user is not None:
WorkspaceMember.objects.create( # Check if the user was already a member of workspace then activate the user
workspace=workspace_invite.workspace, workspace_member = WorkspaceMember.objects.filter(
member=user, workspace=workspace_invite.workspace, member=user
role=workspace_invite.role, ).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.last_workspace_id = workspace_invite.workspace.id
user.save() user.save()
@ -388,6 +406,7 @@ class JoinWorkspaceEndpoint(BaseAPIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
# Workspace invitation rejected
return Response( return Response(
{"message": "Workspace Invitation was not accepted"}, {"message": "Workspace Invitation was not accepted"},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
@ -398,37 +417,13 @@ class JoinWorkspaceEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
def get(self, request, slug, pk):
class WorkspaceInvitationsViewset(BaseViewSet): workspace_invitation = WorkspaceMemberInvite.objects.get(workspace__slug=slug, pk=pk)
serializer_class = WorkSpaceMemberInviteSerializer serializer = WorkSpaceMemberInviteSerializer(workspace_invitation)
model = WorkspaceMemberInvite return Response(serializer.data, status=status.HTTP_200_OK)
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)
class UserWorkspaceInvitationsEndpoint(BaseViewSet): class UserWorkspaceInvitationsViewSet(BaseViewSet):
serializer_class = WorkSpaceMemberInviteSerializer serializer_class = WorkSpaceMemberInviteSerializer
model = WorkspaceMemberInvite model = WorkspaceMemberInvite
@ -442,9 +437,19 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
) )
def create(self, request): def create(self, request):
invitations = request.data.get("invitations") invitations = request.data.get("invitations", [])
workspace_invitations = WorkspaceMemberInvite.objects.filter(pk__in=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.objects.bulk_create(
[ [
WorkspaceMember( WorkspaceMember(
@ -481,20 +486,24 @@ class WorkSpaceMemberViewSet(BaseViewSet):
return self.filter_queryset( return self.filter_queryset(
super() super()
.get_queryset() .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("workspace", "workspace__owner")
.select_related("member") .select_related("member")
) )
def list(self, request, slug): def list(self, request, slug):
workspace_member = WorkspaceMember.objects.get( 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( # Get all active workspace members
workspace__slug=slug, workspace_members = self.get_queryset()
member__is_bot=False,
).select_related("workspace", "member")
if workspace_member.role > 10: if workspace_member.role > 10:
serializer = WorkspaceMemberAdminSerializer(workspace_members, many=True) serializer = WorkspaceMemberAdminSerializer(workspace_members, many=True)
@ -506,7 +515,12 @@ class WorkSpaceMemberViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, pk): 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: if request.user.id == workspace_member.member_id:
return Response( return Response(
{"error": "You cannot update your own role"}, {"error": "You cannot update your own role"},
@ -515,7 +529,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Get the requested user role # Get the requested user role
requested_workspace_member = WorkspaceMember.objects.get( 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 # Check if role is being updated
# One cannot update role higher than his own role # One cannot update role higher than his own role
@ -540,68 +556,121 @@ class WorkSpaceMemberViewSet(BaseViewSet):
def destroy(self, request, slug, pk): def destroy(self, request, slug, pk):
# Check the user role who is deleting the user # 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 # check requesting user role
requesting_workspace_member = WorkspaceMember.objects.get( 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: if requesting_workspace_member.role < workspace_member.role:
return Response( return Response(
{"error": "You cannot remove a user having role higher than you"}, {"error": "You cannot remove a user having role higher than you"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Check for the only member in the workspace
if ( if (
workspace_member.role == 20 Project.objects.annotate(
and WorkspaceMember.objects.filter( total_members=Count("project_projectmember"),
workspace__slug=slug, member_with_role=Count(
role=20, "project_projectmember",
member__is_bot=False, filter=Q(
).count() project_projectmember__member_id=request.user.id,
== 1 project_projectmember__role=20,
),
),
)
.filter(total_members=1, member_with_role=1, workspace__slug=slug)
.exists()
): ):
return Response( 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, status=status.HTTP_400_BAD_REQUEST,
) )
# Delete the user also from all the projects # Deactivate the users from the projects where the user is part of
ProjectMember.objects.filter( _ = ProjectMember.objects.filter(
workspace__slug=slug, member=workspace_member.member workspace__slug=slug,
).delete() member_id=workspace_member.member_id,
# Remove all favorites is_active=True,
ProjectFavorite.objects.filter( ).update(is_active=False)
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()
# Remove if module member workspace_member.is_active = False
ModuleMember.objects.filter( workspace_member.save()
workspace__slug=slug, member=workspace_member.member return Response(status=status.HTTP_204_NO_CONTENT)
).delete()
# Delete owned Pages
Page.objects.filter(
workspace__slug=slug, owned_by=workspace_member.member
).delete()
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) return Response(status=status.HTTP_204_NO_CONTENT)
@ -629,7 +698,9 @@ class TeamMemberViewSet(BaseViewSet):
def create(self, request, slug): def create(self, request, slug):
members = list( members = list(
WorkspaceMember.objects.filter( 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())) .annotate(member_str_id=Cast("member", output_field=CharField()))
.distinct() .distinct()
@ -658,23 +729,6 @@ class TeamMemberViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
def get(self, request): def get(self, request):
user = User.objects.get(pk=request.user.id) user = User.objects.get(pk=request.user.id)
@ -711,7 +765,9 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
class WorkspaceMemberUserEndpoint(BaseAPIView): class WorkspaceMemberUserEndpoint(BaseAPIView):
def get(self, request, slug): def get(self, request, slug):
workspace_member = WorkspaceMember.objects.get( workspace_member = WorkspaceMember.objects.get(
member=request.user, workspace__slug=slug member=request.user,
workspace__slug=slug,
is_active=True,
) )
serializer = WorkspaceMemberMeSerializer(workspace_member) serializer = WorkspaceMemberMeSerializer(workspace_member)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@ -720,7 +776,9 @@ class WorkspaceMemberUserEndpoint(BaseAPIView):
class WorkspaceMemberUserViewsEndpoint(BaseAPIView): class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
def post(self, request, slug): def post(self, request, slug):
workspace_member = WorkspaceMember.objects.get( 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.view_props = request.data.get("view_props", {})
workspace_member.save() workspace_member.save()
@ -1046,7 +1104,9 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
user_data = User.objects.get(pk=user_id) user_data = User.objects.get(pk=user_id)
requesting_workspace_member = WorkspaceMember.objects.get( requesting_workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user workspace__slug=slug,
member=request.user,
is_active=True,
) )
projects = [] projects = []
if requesting_workspace_member.role >= 10: if requesting_workspace_member.role >= 10:
@ -1250,9 +1310,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
return Response( return Response(issues, status=status.HTTP_200_OK)
issues, status=status.HTTP_200_OK
)
class WorkspaceLabelsEndpoint(BaseAPIView): class WorkspaceLabelsEndpoint(BaseAPIView):
@ -1266,30 +1324,3 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
).values("parent", "name", "color", "id", "project_id", "workspace__slug") ).values("parent", "name", "color", "id", "project_id", "workspace__slug")
return Response(labels, status=status.HTTP_200_OK) 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)

View File

@ -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 # Add new users to Workspace and project automatically
WorkspaceMember.objects.bulk_create( WorkspaceMember.objects.bulk_create(
[ [

View File

@ -13,23 +13,24 @@ from plane.db.models import Project, User, ProjectMemberInvite
@shared_task @shared_task
def project_invitation(email, project_id, token, current_site): def project_invitation(email, project_id, token, current_site, invitor):
try: try:
user = User.objects.get(email=invitor)
project = Project.objects.get(pk=project_id) project = Project.objects.get(pk=project_id)
project_member_invite = ProjectMemberInvite.objects.get( project_member_invite = ProjectMemberInvite.objects.get(
token=token, email=email 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 abs_url = current_site + relativelink
from_email_string = settings.EMAIL_FROM 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 = { context = {
"email": email, "email": email,
"first_name": project.created_by.first_name, "first_name": user.first_name,
"project_name": project.name, "project_name": project.name,
"invitation_url": abs_url, "invitation_url": abs_url,
} }

View File

@ -11,25 +11,33 @@ from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError from slack_sdk.errors import SlackApiError
# Module imports # Module imports
from plane.db.models import Workspace, WorkspaceMemberInvite from plane.db.models import User, Workspace, WorkspaceMemberInvite
@shared_task @shared_task
def workspace_invitation(email, workspace_id, token, current_site, invitor): def workspace_invitation(email, workspace_id, token, current_site, invitor):
try: try:
user = User.objects.get(email=invitor)
workspace = Workspace.objects.get(pk=workspace_id) workspace = Workspace.objects.get(pk=workspace_id)
workspace_member_invite = WorkspaceMemberInvite.objects.get( workspace_member_invite = WorkspaceMemberInvite.objects.get(
token=token, email=email token=token, email=email
) )
realtivelink = ( # Relative link
f"/workspace-member-invitation/?invitation_id={workspace_member_invite.id}&email={email}" 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 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 = { context = {
"email": email, "email": email,

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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',
),
]

View File

@ -166,6 +166,7 @@ class ProjectMember(ProjectBaseModel):
default_props = models.JSONField(default=get_default_props) default_props = models.JSONField(default=get_default_props)
preferences = models.JSONField(default=get_default_preferences) preferences = models.JSONField(default=get_default_preferences)
sort_order = models.FloatField(default=65535) sort_order = models.FloatField(default=65535)
is_active = models.BooleanField(default=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self._state.adding: if self._state.adding:

View File

@ -99,6 +99,7 @@ class WorkspaceMember(BaseModel):
view_props = models.JSONField(default=get_default_props) view_props = models.JSONField(default=get_default_props)
default_props = models.JSONField(default=get_default_props) default_props = models.JSONField(default=get_default_props)
issue_props = models.JSONField(default=get_issue_props) issue_props = models.JSONField(default=get_issue_props)
is_active = models.BooleanField(default=True)
class Meta: class Meta:
unique_together = ["workspace", "member"] unique_together = ["workspace", "member"]

View File

@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ Inviter }} invited you to join {{ Workspace-Name }} on Plane</title> <title>{{ first_name }} invited you to join {{ project_name }} on Plane</title>
<style type="text/css" emogrify="no">#outlook a { padding:0; } .ExternalClass { width:100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide:all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0; } img { outline:none; text-decoration:none; -ms-interpolation-mode: bicubic; } a img { border:none; } table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style> <style type="text/css" emogrify="no">#outlook a { padding:0; } .ExternalClass { width:100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide:all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0; } img { outline:none; text-decoration:none; -ms-interpolation-mode: bicubic; } a img { border:none; } table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} } </style> <style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} } </style>
<style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r2-i { background-color: #ffffff !important } .r3-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r4-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-top: 20px !important; width: 100% !important } .r5-i { background-color: #f8f9fa !important; padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important } .r6-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r7-o { border-style: solid !important; width: 100% !important } .r8-i { padding-left: 0px !important; padding-right: 0px !important } .r9-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r10-i { padding-bottom: 35px !important; padding-top: 15px !important } .r11-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r12-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r13-i { padding-left: 20px !important; padding-right: 20px !important; padding-top: 0px !important; text-align: center !important } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 20px !important; margin-top: 20px !important; width: 100% !important } .r15-i { text-align: center !important } .r16-r { background-color: #ffffff !important; border-color: #3f76ff !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r17-i { padding-bottom: 15px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 15px !important; text-align: left !important } .r18-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r19-i { padding-bottom: 15px !important; padding-top: 15px !important } .r20-i { color: #3b3f44 !important; padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important } .r21-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r22-c { box-sizing: border-box !important; width: 100% !important } .r23-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important } .r24-c { box-sizing: border-box !important; width: 32px !important } .r25-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important } .r26-i { padding-bottom: 5px !important; padding-top: 5px !important } .r27-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important } .r28-i { color: #3b3f44 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important } .r29-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important } .r30-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important } .r31-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style> <style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r2-i { background-color: #ffffff !important } .r3-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r4-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-top: 20px !important; width: 100% !important } .r5-i { background-color: #f8f9fa !important; padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important } .r6-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r7-o { border-style: solid !important; width: 100% !important } .r8-i { padding-left: 0px !important; padding-right: 0px !important } .r9-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r10-i { padding-bottom: 35px !important; padding-top: 15px !important } .r11-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r12-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r13-i { padding-left: 20px !important; padding-right: 20px !important; padding-top: 0px !important; text-align: center !important } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 20px !important; margin-top: 20px !important; width: 100% !important } .r15-i { text-align: center !important } .r16-r { background-color: #ffffff !important; border-color: #3f76ff !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r17-i { padding-bottom: 15px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 15px !important; text-align: left !important } .r18-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r19-i { padding-bottom: 15px !important; padding-top: 15px !important } .r20-i { color: #3b3f44 !important; padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important } .r21-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r22-c { box-sizing: border-box !important; width: 100% !important } .r23-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important } .r24-c { box-sizing: border-box !important; width: 32px !important } .r25-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important } .r26-i { padding-bottom: 5px !important; padding-top: 5px !important } .r27-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important } .r28-i { color: #3b3f44 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important } .r29-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important } .r30-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important } .r31-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style>