Skip to content
2 changes: 2 additions & 0 deletions apps/api/plane/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,5 @@
GenericAssetUpdateSerializer,
FileAssetSerializer,
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
56 changes: 56 additions & 0 deletions apps/api/plane/api/serializers/invite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from rest_framework import serializers

# Module imports
from plane.db.models import WorkspaceMemberInvite
from .base import BaseSerializer
from plane.app.permissions.base import ROLE


class WorkspaceInviteSerializer(BaseSerializer):
"""
Serializer for workspace invites.
"""

class Meta:
model = WorkspaceMemberInvite
fields = [
"id",
"email",
"role",
"created_at",
"updated_at",
"responded_at",
"accepted",
]
read_only_fields = [
"id",
"workspace",
"created_at",
"updated_at",
"responded_at",
"accepted",
]

def validate_email(self, value):
try:
validate_email(value)
except ValidationError:
raise serializers.ValidationError("Invalid email address", code="INVALID_EMAIL_ADDRESS")
return value

def validate_role(self, value):
if value not in [ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value]:
raise serializers.ValidationError("Invalid role", code="INVALID_WORKSPACE_MEMBER_ROLE")
return value

def validate(self, data):
slug = self.context["slug"]
if (
data.get("email")
and WorkspaceMemberInvite.objects.filter(email=data["email"], workspace__slug=slug).exists()
):
raise serializers.ValidationError("Email already invited", code="EMAIL_ALREADY_INVITED")
return data
39 changes: 39 additions & 0 deletions apps/api/plane/api/serializers/member.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Third party imports
from rest_framework import serializers

# Module imports
from plane.db.models import ProjectMember, WorkspaceMember
from .base import BaseSerializer
from plane.db.models import User
from plane.utils.permissions import ROLE


class ProjectMemberSerializer(BaseSerializer):
"""
Serializer for project members.
"""

member = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
required=True,
)

def validate_member(self, value):
slug = self.context.get("slug")
if not slug:
raise serializers.ValidationError("Slug is required", code="INVALID_SLUG")
if not value:
raise serializers.ValidationError("Member is required", code="INVALID_MEMBER")
if not WorkspaceMember.objects.filter(workspace__slug=slug, member=value).exists():
raise serializers.ValidationError("Member not found in workspace", code="INVALID_MEMBER")
return value

def validate_role(self, value):
if value not in [ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value]:
raise serializers.ValidationError("Invalid role", code="INVALID_ROLE")
return value

class Meta:
model = ProjectMember
fields = ["id", "member", "role"]
read_only_fields = ["id"]
2 changes: 2 additions & 0 deletions apps/api/plane/api/urls/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .state import urlpatterns as state_patterns
from .user import urlpatterns as user_patterns
from .work_item import urlpatterns as work_item_patterns
from .invite import urlpatterns as invite_patterns

urlpatterns = [
*asset_patterns,
Expand All @@ -20,4 +21,5 @@
*state_patterns,
*user_patterns,
*work_item_patterns,
*invite_patterns,
]
18 changes: 18 additions & 0 deletions apps/api/plane/api/urls/invite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Django imports
from django.urls import path, include

# Third party imports
from rest_framework.routers import DefaultRouter

# Module imports
from plane.api.views import WorkspaceInvitationsViewset


# Create router with just the invitations prefix (no workspace slug)
router = DefaultRouter()
router.register(r"invitations", WorkspaceInvitationsViewset, basename="workspace-invitations")

# Wrap the router URLs with the workspace slug path
urlpatterns = [
path("workspaces/<str:slug>/", include(router.urls)),
]
20 changes: 18 additions & 2 deletions apps/api/plane/api/urls/member.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
from django.urls import path

from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
from plane.api.views import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint

urlpatterns = [
# Project members
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project-members",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/<uuid:pk>/",
ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]),
name="project-member",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project-members",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/<uuid:pk>/",
ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]),
name="project-member",
),
Comment on lines +17 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Duplicate URL pattern names and redundant routes.

These routes introduce two critical issues:

  1. Duplicate URL pattern names: The names "project-members" (line 20) and "project-member" (line 25) conflict with the names at lines 10 and 15. Django's reverse() function will not be able to resolve these names unambiguously.

  2. Redundant API endpoints: The /project-members/ routes (lines 17-26) appear functionally identical to the /members/ routes (lines 7-16)—they use the same view classes, HTTP methods, and provide the same functionality. Having two different URL paths for the same resource violates RESTful API design principles and creates confusion for API consumers.

Questions:

  • Is there a specific reason to maintain both /members/ and /project-members/ endpoints?
  • If this is for backward compatibility during a transition period, it should be documented and one set should eventually be deprecated.
  • If only one endpoint is needed, which one should be retained?

If both endpoints are truly required, apply this diff to fix the naming conflict:

     path(
         "workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
         ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
-        name="project-members",
+        name="project-members-alt",
     ),
     path(
         "workspaces/<str:slug>/projects/<uuid:project_id>/project-members/<uuid:pk>/",
         ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]),
-        name="project-member",
+        name="project-member-alt",
     ),

However, the recommended solution is to remove the duplicate routes entirely unless there is a documented justification for maintaining both.


I'll verify the concerns in the review comment by examining the full URL configuration and checking for references throughout the codebase.


---

🏁 Script executed:

```shell
#!/bin/bash
# Display the complete member.py file to see full context
cat apps/api/plane/api/urls/member.py

Length of output: 1225


🏁 Script executed:

#!/bin/bash
# Search for reverse() calls using "project-members" or "project-member" names
rg -n "reverse\(" -A2 -B2 --type=py | grep -E "(project-members|project-member)" -A2 -B2

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for any references to the URL names to understand usage
rg -nP '"project-members"|\'project-members\'|"project-member"|\'project-member\'' --type=py

Length of output: 68


Let me search more broadly for references and test usage:


---

🏁 Script executed:

```shell
#!/bin/bash
# Search for test files that might reference these project member endpoints
fd -e test.py -e tests.py --type f | head -20

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for any reverse calls in Python files to see patterns
rg -n 'reverse\s*\(' --type=py -A1 | head -40

Length of output: 2834


🏁 Script executed:

#!/bin/bash
# Search for project-members string anywhere (broader search)
rg 'project-members' --type=py --type=json

Length of output: 634


🏁 Script executed:

#!/bin/bash
# Check for TODO/FIXME comments in the member.py context
rg -n 'TODO|FIXME' apps/api/plane/api/urls/member.py

Length of output: 0


Remove duplicate URL pattern names and resolve redundant routes.

The codebase has confirmed duplicate URL pattern names in member.py:

  • Lines 10 and 20: both define name="project-members"
  • Lines 15 and 25: both define name="project-member"

Django's reverse() function cannot resolve duplicate names unambiguously. A test file comment in test_project_app.py (line 19-20) explicitly acknowledges this problem: "unreliable due to duplicate 'name' values in URL patterns."

Additionally, both /members/ and /project-members/ route pairs use identical views and HTTP methods, creating redundant API endpoints for the same resource.

Fix required:
Either:

  1. Remove one set of routes entirely (recommended), or
  2. Rename the second pair to name="project-members-alt" and name="project-member-alt" as a temporary measure

Determine which path (/members/ or /project-members/) should be the canonical endpoint and remove or deprecate the other.

🤖 Prompt for AI Agents
In apps/api/plane/api/urls/member.py around lines 17 to 26 there are duplicate
URL pattern names and redundant routes for the same views; remove the duplicate
route pair that uses the /members/ paths (keep /project-members/ as the
canonical endpoints), delete the duplicate path entries and their imports/usages
so only one set of routes remains, and ensure the remaining patterns use unique
names ("project-members" and "project-member"); update any references
(reverse(), tests) to point to the canonical names if needed.

path(
"workspaces/<str:slug>/members/",
WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]),
Expand Down
4 changes: 3 additions & 1 deletion apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
ModuleArchiveUnarchiveAPIEndpoint,
)

from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
from .member import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint

from .intake import (
IntakeIssueListCreateAPIEndpoint,
Expand All @@ -53,3 +53,5 @@
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint

from .user import UserEndpoint

from .invite import WorkspaceInvitationsViewset
129 changes: 126 additions & 3 deletions apps/api/plane/api/views/base.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
# Python imports
import zoneinfo
import logging

# Django imports
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
from django.urls import resolve
from django.utils import timezone
from plane.db.models.api import APIToken

# Third party imports
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

# Third party imports
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import APIException
from rest_framework.generics import GenericAPIView

# Module imports
from plane.db.models.api import APIToken
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
from plane.utils.core.mixins import ReadReplicaControlMixin


logger = logging.getLogger("plane.api")


class TimezoneMixin:
"""
This enables timezone conversion according
Expand Down Expand Up @@ -152,3 +160,118 @@ def fields(self):
def expand(self):
expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand]
return expand if expand else None


class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePaginator):
model = None

authentication_classes = [APIKeyAuthentication]
permission_classes = [
IsAuthenticated,
]
use_read_replica = False

def get_queryset(self):
try:
return self.model.objects.all()
except Exception as e:
log_exception(e)
raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST)

def handle_exception(self, exc):
"""
Handle any exception that occurs, by returning an appropriate response,
or re-raising the error.
"""
try:
response = super().handle_exception(exc)
return response
except Exception as e:
if isinstance(e, IntegrityError):
log_exception(e)
return Response(
{"error": "The payload is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)

if isinstance(e, ValidationError):
logger.warning(
"Validation Error",
extra={
"error_code": "VALIDATION_ERROR",
"error_message": str(e),
},
)
return Response(
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
)

if isinstance(e, ObjectDoesNotExist):
logger.warning(
"Object Does Not Exist",
extra={
"error_code": "OBJECT_DOES_NOT_EXIST",
"error_message": str(e),
},
)
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)

if isinstance(e, KeyError):
logger.error(
"Key Error",
extra={
"error_code": "KEY_ERROR",
"error_message": str(e),
},
)
return Response(
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)

log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

def dispatch(self, request, *args, **kwargs):
try:
response = super().dispatch(request, *args, **kwargs)

if settings.DEBUG:
from django.db import connection

print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}")

return response
except Exception as exc:
response = self.handle_exception(exc)
return response

@property
def workspace_slug(self):
return self.kwargs.get("slug", None)

@property
def project_id(self):
project_id = self.kwargs.get("project_id", None)
if project_id:
return project_id

if resolve(self.request.path_info).url_name == "project":
return self.kwargs.get("pk", None)

@property
def fields(self):
fields = [field for field in self.request.GET.get("fields", "").split(",") if field]
return fields if fields else None

@property
def expand(self):
expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand]
return expand if expand else None
Loading
Loading