Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 28 additions & 13 deletions apps/api/plane/app/views/project/invite.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
from plane.utils.host import base_host


from plane.bgtasks.project_invitation_task import project_invitation


class ProjectInvitationsViewset(BaseViewSet):
serializer_class = ProjectMemberInviteSerializer
model = ProjectMemberInvite
Expand All @@ -62,22 +65,34 @@ def create(self, request, slug, project_id):
return Response({"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST)

for email in emails:
workspace_role = WorkspaceMember.objects.filter(
workspace__slug=slug, member__email=email.get("email"), is_active=True
).role

if workspace_role in [5, 20] and workspace_role != email.get("role", 5):
return Response({"error": "You cannot invite a user with different role than workspace role"})
raw_email = email.get("email", "") if isinstance(email, dict) else ""
if not raw_email or not isinstance(raw_email, str):
continue
canonical_email = raw_email.strip().lower()

workspace_member = WorkspaceMember.objects.filter(
workspace__slug=slug, member__email__iexact=canonical_email, is_active=True
).first()

if workspace_member:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
workspace_role = workspace_member.role
if workspace_role in [5, 20] and workspace_role != email.get("role", 5):
return Response(
{"error": "You cannot invite a user with different role than workspace role"},
status=status.HTTP_400_BAD_REQUEST,
)

workspace = Workspace.objects.get(slug=slug)

project_invitations = []
invitation_objects = []
for email in emails:
raw_email = email.get("email", "") if isinstance(email, dict) else ""
try:
validate_email(email.get("email"))
project_invitations.append(
validate_email(raw_email)
canonical_email = raw_email.strip().lower()
invitation_objects.append(
ProjectMemberInvite(
email=email.get("email").strip().lower(),
email=canonical_email,
project_id=project_id,
workspace_id=workspace.id,
token=jwt.encode(
Expand All @@ -97,15 +112,15 @@ def create(self, request, slug, project_id):
status=status.HTTP_400_BAD_REQUEST,
)

# Create workspace member invite
# Create project member invites
project_invitations = ProjectMemberInvite.objects.bulk_create(
project_invitations, batch_size=10, ignore_conflicts=True
invitation_objects, batch_size=10, ignore_conflicts=True
)
current_site = base_host(request=request, is_app=True)

# Send invitations
for invitation in project_invitations:
project_invitations.delay(
project_invitation.delay(
invitation.email,
project_id,
invitation.token,
Expand Down
86 changes: 86 additions & 0 deletions apps/api/plane/tests/contract/app/test_project_invitations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from unittest.mock import patch
import pytest
from rest_framework import status

from plane.db.models import (
Project,
ProjectMember,
ProjectMemberInvite,
WorkspaceMember,
)


@pytest.mark.contract
class TestProjectInvitationsAPI:

@pytest.mark.django_db
@patch("plane.app.views.project.invite.project_invitation.delay")
def test_create_project_invitation_success(self, mock_delay, session_client, workspace, create_user):
"""Test inviting a user to a project creates ProjectMemberInvite and triggers task."""
project = Project.objects.create(name="Invite Test Project", identifier="ITP", workspace=workspace)
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)

url = f"/api/workspaces/{workspace.slug}/projects/{project.id}/invitations/"
data = {
"emails": [
{"email": "newuser@example.com", "role": 15}
]
}

response = session_client.post(url, data, format="json")

assert response.status_code == status.HTTP_200_OK
assert response.json()["message"] == "Email sent successfully"

# Verify ProjectMemberInvite DB record
invites = ProjectMemberInvite.objects.filter(project=project, email="newuser@example.com")
assert invites.count() == 1
invite = invites.first()
assert invite.role == 15

# Verify Celery delay was called
mock_delay.assert_called_once()
args, _ = mock_delay.call_args
assert args[0] == "newuser@example.com"
assert str(args[1]) == str(project.id)

@pytest.mark.django_db
def test_create_project_invitation_empty_emails(self, session_client, workspace, create_user):
"""Test sending empty emails array returns 400 Bad Request."""
project = Project.objects.create(name="Invite Test Project 2", identifier="ITP2", workspace=workspace)
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)

url = f"/api/workspaces/{workspace.slug}/projects/{project.id}/invitations/"
response = session_client.post(url, {"emails": []}, format="json")

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["error"] == "Emails are required"

@pytest.mark.django_db
def test_create_project_invitation_mixed_case_role_validation(self, session_client, workspace, create_user):
"""Test that mixed-case email lookup matches existing member and enforces role check."""
from plane.db.models import User
existing_member_user = User.objects.create_user(email="guest@example.com", username="guestmember")
# Add user as guest (role=5) in workspace
WorkspaceMember.objects.create(workspace=workspace, member=existing_member_user, role=5, is_active=True)

project = Project.objects.create(name="Invite Test Project 3", identifier="ITP3", workspace=workspace)
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)

url = f"/api/workspaces/{workspace.slug}/projects/{project.id}/invitations/"
# Attempting to invite ' Guest@Example.com ' with role 15 when workspace role is 5
data = {
"emails": [
{"email": " Guest@Example.com ", "role": 15}
]
}

response = session_client.post(url, data, format="json")

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["error"] == "You cannot invite a user with different role than workspace role"