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
6 changes: 5 additions & 1 deletion apps/api/plane/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,9 @@
FileAssetSerializer,
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .member import (
ProjectMemberSerializer,
WorkspaceMemberLiteAPISerializer,
ProjectMemberLiteAPISerializer,
)
from .sticky import StickySerializer
42 changes: 42 additions & 0 deletions apps/api/plane/api/serializers/member.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,45 @@ class Meta:
model = ProjectMember
fields = ["id", "member", "role"]
read_only_fields = ["id"]


class BaseMemberLiteAPISerializer(BaseSerializer):
"""Common flattened member representation for paginated member pickers/directories."""

id = serializers.UUIDField(source="member.id", read_only=True)
first_name = serializers.CharField(source="member.first_name", read_only=True)
last_name = serializers.CharField(source="member.last_name", read_only=True)
email = serializers.EmailField(source="member.email", read_only=True)
avatar = serializers.CharField(source="member.avatar", read_only=True, allow_null=True)
avatar_url = serializers.CharField(source="member.avatar_url", read_only=True, allow_null=True)
display_name = serializers.CharField(source="member.display_name", read_only=True)
is_bot = serializers.BooleanField(source="member.is_bot", read_only=True)

class Meta:
fields = [
"id",
"first_name",
"last_name",
"email",
"avatar",
"avatar_url",
"display_name",
"role",
"is_active",
"is_bot",
]
read_only_fields = fields


class WorkspaceMemberLiteAPISerializer(BaseMemberLiteAPISerializer):
"""Minimal WorkspaceMember representation for paginated member pickers/directories."""

class Meta(BaseMemberLiteAPISerializer.Meta):
model = WorkspaceMember


class ProjectMemberLiteAPISerializer(BaseMemberLiteAPISerializer):
"""Minimal ProjectMember representation for paginated member pickers/directories."""

class Meta(BaseMemberLiteAPISerializer.Meta):
model = ProjectMember
1 change: 1 addition & 0 deletions apps/api/plane/api/serializers/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,5 +318,6 @@ class Meta:
"emoji",
"description",
"cover_image_url",
"archived_at",
]
read_only_fields = fields
6 changes: 6 additions & 0 deletions apps/api/plane/api/urls/cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from plane.api.views.cycle import (
CycleListCreateAPIEndpoint,
CycleListLiteAPIEndpoint,
CycleDetailAPIEndpoint,
CycleIssueListCreateAPIEndpoint,
CycleIssueDetailAPIEndpoint,
Expand All @@ -19,6 +20,11 @@
CycleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="cycles",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles-lite/",
CycleListLiteAPIEndpoint.as_view(http_method_names=["get"]),
name="cycles-lite",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/",
CycleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
Expand Down
12 changes: 12 additions & 0 deletions apps/api/plane/api/urls/member.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
from plane.api.views import (
ProjectMemberListCreateAPIEndpoint,
ProjectMemberDetailAPIEndpoint,
ProjectMemberLiteAPIEndpoint,
WorkspaceMemberAPIEndpoint,
WorkspaceMemberLiteAPIEndpoint,
)

urlpatterns = [
Expand All @@ -27,6 +29,11 @@
ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project-members",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members-lite/",
ProjectMemberLiteAPIEndpoint.as_view(http_method_names=["get"]),
name="project-members-lite",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/<uuid:pk>/",
ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]),
Expand All @@ -37,4 +44,9 @@
WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]),
name="workspace-members",
),
path(
"workspaces/<str:slug>/members-lite/",
WorkspaceMemberLiteAPIEndpoint.as_view(http_method_names=["get"]),
name="workspace-members-lite",
),
]
6 changes: 6 additions & 0 deletions apps/api/plane/api/urls/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from plane.api.views import (
ModuleListCreateAPIEndpoint,
ModuleListLiteAPIEndpoint,
ModuleDetailAPIEndpoint,
ModuleIssueListCreateAPIEndpoint,
ModuleIssueDetailAPIEndpoint,
Expand All @@ -18,6 +19,11 @@
ModuleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="modules",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules-lite/",
ModuleListLiteAPIEndpoint.as_view(http_method_names=["get"]),
name="modules-lite",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/",
ModuleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
Expand Down
6 changes: 6 additions & 0 deletions apps/api/plane/api/urls/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from plane.api.views import (
ProjectListCreateAPIEndpoint,
ProjectListLiteAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
ProjectSummaryAPIEndpoint,
Expand All @@ -17,6 +18,11 @@
ProjectListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project",
),
path(
"workspaces/<str:slug>/projects-lite/",
ProjectListLiteAPIEndpoint.as_view(http_method_names=["get"]),
name="project-lite",
),
path(
"workspaces/<str:slug>/projects/<uuid:pk>/",
ProjectDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
Expand Down
11 changes: 10 additions & 1 deletion apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from .project import (
ProjectListCreateAPIEndpoint,
ProjectListLiteAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
ProjectSummaryAPIEndpoint,
Expand Down Expand Up @@ -34,6 +35,7 @@

from .cycle import (
CycleListCreateAPIEndpoint,
CycleListLiteAPIEndpoint,
CycleDetailAPIEndpoint,
CycleIssueListCreateAPIEndpoint,
CycleIssueDetailAPIEndpoint,
Expand All @@ -43,13 +45,20 @@

from .module import (
ModuleListCreateAPIEndpoint,
ModuleListLiteAPIEndpoint,
ModuleDetailAPIEndpoint,
ModuleIssueListCreateAPIEndpoint,
ModuleIssueDetailAPIEndpoint,
ModuleArchiveUnarchiveAPIEndpoint,
)

from .member import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint
from .member import (
ProjectMemberListCreateAPIEndpoint,
ProjectMemberDetailAPIEndpoint,
ProjectMemberLiteAPIEndpoint,
WorkspaceMemberAPIEndpoint,
WorkspaceMemberLiteAPIEndpoint,
)

from .intake import (
IntakeIssueListCreateAPIEndpoint,
Expand Down
43 changes: 43 additions & 0 deletions apps/api/plane/api/views/cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from plane.api.serializers import (
CycleIssueSerializer,
CycleSerializer,
CycleLiteSerializer,
CycleIssueRequestSerializer,
TransferCycleIssueRequestSerializer,
CycleCreateSerializer,
Expand Down Expand Up @@ -356,6 +357,48 @@ def post(self, request, slug, project_id):
)


class CycleListLiteAPIEndpoint(BaseAPIView):
"""Cycle Lite List Endpoint"""

serializer_class = CycleLiteSerializer
model = Cycle
permission_classes = [ProjectEntityPermission]
use_read_replica = True

@cycle_docs(
operation_id="list_cycles_lite",
summary="List cycles (lite)",
description="Retrieve a paginated, lightweight list of cycles in a project for pickers and references.",
parameters=[CURSOR_PARAMETER, PER_PAGE_PARAMETER, ORDER_BY_PARAMETER],
responses={
200: create_paginated_response(
CycleLiteSerializer,
"PaginatedCycleLiteResponse",
"Paginated list of cycles with minimal fields",
"Paginated Cycles (Lite)",
),
},
)
def get(self, request, slug, project_id):
"""List cycles (lite)

Retrieve a paginated, lightweight list of non-archived cycles in a project,
optimized for pickers and references.
"""
cycles = (
Cycle.objects.filter(workspace__slug=slug, project_id=project_id)
.filter(archived_at__isnull=True)
.select_related("project", "workspace", "owned_by")
.order_by(request.GET.get("order_by", "-created_at"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether ORDER_BY_PARAMETER has an enum constraint or any runtime validation exists.
rg -n "ORDER_BY_PARAMETER" --type=py -A5 apps/api/plane/utils/openapi.py
# Also check if FieldError is handled anywhere in the base views.
rg -n "FieldError" --type=py apps/api/plane/api/views/

Repository: makeplane/plane

Length of output: 225


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant files and inspect the order_by handling plus any FieldError handling.
fd -a "cycle.py|base.py|openapi.py" apps/api/plane -t f || true
echo '--- rg sanitize_order_by ---'
rg -n "sanitize_order_by|order_by\\(" apps/api/plane/api/views apps/api/plane -g '*.py' || true
echo '--- rg FieldError ---'
rg -n "FieldError" apps/api/plane -g '*.py' || true

Repository: makeplane/plane

Length of output: 39733


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the specific view file around the reported line once located.
fd -a "cycle.py" apps -t f || true

Repository: makeplane/plane

Length of output: 659


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the cycle view around the reported line and the base exception handler.
sed -n '360,420p' /home/jailuser/git/apps/api/plane/api/views/cycle.py | cat -n
echo '--- base handler ---'
sed -n '1,220p' /home/jailuser/git/apps/api/plane/api/views/base.py | cat -n
echo '--- order sanitizer ---'
sed -n '1,220p' /home/jailuser/git/apps/api/plane/utils/order_queryset.py | cat -n

Repository: makeplane/plane

Length of output: 18406


Validate order_by before passing it to .order_by()
request.GET.get("order_by", "-created_at") still reaches .order_by() directly here, so an invalid field can raise FieldError and fall through the generic 500 path. Reuse sanitize_order_by() here; the same raw pattern also appears in the module and project endpoints.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/api/views/cycle.py` at line 392, Update the cycle query
ordering to pass the requested value through the existing sanitize_order_by()
helper before calling order_by(), preserving "-created_at" as the default. Apply
the same validation to the matching raw request.GET order_by usage in the module
and project endpoints.

.distinct()
)
return self.paginate(
request=request,
queryset=cycles,
on_results=lambda cycles: CycleLiteSerializer(cycles, many=True).data,
)


class CycleDetailAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `retrieve`, `update` and `destroy` actions related to cycle.
Expand Down
14 changes: 14 additions & 0 deletions apps/api/plane/api/views/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,20 @@ def get(self, request, slug, project_id):
Supports filtering, ordering, and field selection through query parameters.
"""

unsupported_filters = [param for param in ("pql", "filters") if request.GET.get(param)]
if unsupported_filters:
Comment on lines +317 to +318

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject unsupported parameters by presence, not truthiness.

request.GET.get(param) skips empty-but-supplied values, so requests such as ?pql= or ?filters= continue into normal issue listing instead of returning the documented 400. Check membership in the query parameters instead.

Proposed fix
-        unsupported_filters = [param for param in ("pql", "filters") if request.GET.get(param)]
+        unsupported_filters = [param for param in ("pql", "filters") if param in request.GET]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
unsupported_filters = [param for param in ("pql", "filters") if request.GET.get(param)]
if unsupported_filters:
unsupported_filters = [param for param in ("pql", "filters") if param in request.GET]
if unsupported_filters:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/api/views/issue.py` around lines 317 - 318, Update the
unsupported_filters check in the issue-listing view to detect whether “pql” or
“filters” exists in request.GET, regardless of its value. Preserve the existing
400 response for any supplied unsupported parameter, including empty values.

return Response(
{
"pql": (
"PQL and structured filters are not supported on this Plane edition. "
"Remove the pql/filters parameter and filter results client-side, or use "
"a Plane edition that supports work item query filtering."
),
"unsupported_parameters": unsupported_filters,
},
status=status.HTTP_400_BAD_REQUEST,
)

external_id = request.GET.get("external_id")
external_source = request.GET.get("external_source")

Expand Down
106 changes: 105 additions & 1 deletion apps/api/plane/api/views/member.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,26 @@

# Module imports
from .base import BaseAPIView
from plane.api.serializers import UserLiteSerializer, ProjectMemberSerializer
from plane.api.serializers import (
UserLiteSerializer,
ProjectMemberSerializer,
WorkspaceMemberLiteAPISerializer,
ProjectMemberLiteAPISerializer,
)
from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember
from plane.utils.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission
from plane.utils.openapi import (
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
UNAUTHORIZED_RESPONSE,
FORBIDDEN_RESPONSE,
WORKSPACE_NOT_FOUND_RESPONSE,
PROJECT_NOT_FOUND_RESPONSE,
WORKSPACE_MEMBER_EXAMPLE,
PROJECT_MEMBER_EXAMPLE,
create_paginated_response,
)


Expand Down Expand Up @@ -220,3 +228,99 @@ def delete(self, request, slug, project_id, pk):
project_member.is_active = False
project_member.save()
return Response(status=status.HTTP_204_NO_CONTENT)


class WorkspaceMemberLiteAPIEndpoint(BaseAPIView):
"""Workspace members (lite) list endpoint."""

permission_classes = [WorkSpaceAdminPermission]
use_read_replica = True

@extend_schema(
operation_id="get_workspace_members_lite",
summary="List workspace members (lite)",
description="Retrieve a paginated, lightweight list of workspace members for pickers and directories.",
tags=["Members"],
parameters=[WORKSPACE_SLUG_PARAMETER, CURSOR_PARAMETER, PER_PAGE_PARAMETER],
responses={
200: create_paginated_response(
WorkspaceMemberLiteAPISerializer,
"PaginatedWorkspaceMemberLite",
"Paginated list of workspace members with minimal fields",
"Paginated Workspace Members (Lite)",
),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: WORKSPACE_NOT_FOUND_RESPONSE,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

OpenAPI schema documents 404 but code returns 400 for non-existent workspace.

Both WorkspaceMemberLiteAPIEndpoint (line 254: 404: WORKSPACE_NOT_FOUND_RESPONSE) and ProjectMemberLiteAPIEndpoint (line 301: 404: PROJECT_NOT_FOUND_RESPONSE) document 404 responses, but the actual code at lines 264-268 and 311-315 returns status.HTTP_400_BAD_REQUEST. API consumers relying on the schema would expect 404 and receive 400 instead. Either change the responses to 404 (matching REST conventions for "not found") or update the schema to document 400.

Also applies to: 301-301

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/api/views/member.py` at line 254, Align the non-existent
workspace and project handling with the documented OpenAPI responses: update the
error returns in WorkspaceMemberLiteAPIEndpoint and ProjectMemberLiteAPIEndpoint
from HTTP 400 to HTTP 404, preserving their existing not-found response payloads
and schema declarations.

},
)
def get(self, request, slug):
"""List workspace members (lite)

Retrieve a paginated, lightweight list of workspace members, optimized for
pickers and directories.
"""
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
{"error": "Provided workspace does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)

workspace_members = (
WorkspaceMember.objects.filter(workspace__slug=slug).select_related("member").order_by("-created_at")
)
return self.paginate(
request=request,
queryset=workspace_members,
on_results=lambda members: WorkspaceMemberLiteAPISerializer(members, many=True).data,
)


class ProjectMemberLiteAPIEndpoint(BaseAPIView):
"""Project members (lite) list endpoint."""

permission_classes = [ProjectMemberPermission]
use_read_replica = True

@extend_schema(
operation_id="get_project_members_lite",
summary="List project members (lite)",
description="Retrieve a paginated, lightweight list of project members for pickers and directories.",
tags=["Members"],
parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER, CURSOR_PARAMETER, PER_PAGE_PARAMETER],
responses={
200: create_paginated_response(
ProjectMemberLiteAPISerializer,
"PaginatedProjectMemberLite",
"Paginated list of project members with minimal fields",
"Paginated Project Members (Lite)",
),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: PROJECT_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug, project_id):
"""List project members (lite)

Retrieve a paginated, lightweight list of project members, optimized for
pickers and directories.
"""
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
{"error": "Provided workspace does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)

project_members = (
ProjectMember.objects.filter(project_id=project_id, workspace__slug=slug)
.select_related("member")
.order_by("-created_at")
)
Comment on lines +304 to +321

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if ProjectMemberPermission validates project existence and what exception it raises.
rg -n "class ProjectMemberPermission" --type=py -A30 apps/api/plane/utils/permissions.py

Repository: makeplane/plane

Length of output: 229


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the permission class and the lite endpoint implementation.
git ls-files | rg 'member\.py$|permissions\.py$|project.*member|ProjectMember'
rg -n "class ProjectMemberPermission|ProjectMemberLiteAPIEndpoint|def get\(self, request, slug, project_id\)" apps/api -S

Repository: makeplane/plane

Length of output: 4263


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the lite endpoint and the matching permission class.
sed -n '280,340p' apps/api/plane/api/views/member.py
printf '\n---\n'
sed -n '1,180p' apps/api/plane/utils/permissions/project.py
printf '\n---\n'
sed -n '1,220p' apps/api/plane/app/permissions/project.py

Repository: makeplane/plane

Length of output: 12256


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether the lite endpoint uses ProjectMemberPermission and how exceptions are handled.
sed -n '240,320p' apps/api/plane/api/views/member.py
printf '\n---\n'
sed -n '1,220p' apps/api/plane/api/views/base.py
printf '\n---\n'
rg -n "DoesNotExist|handle_exception" apps/api/plane/api/views/base.py apps/api/plane/api/views -S

Repository: makeplane/plane

Length of output: 12844


Add an explicit project existence check here. ProjectMemberPermission only enforces access; it doesn’t raise on a missing project_id, so an invalid project in an existing workspace can still return 200 with an empty list instead of the documented 404.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/api/views/member.py` around lines 304 - 321, Add an explicit
project existence check in the `get` method before querying `project_members`,
validating the project belongs to the workspace identified by `slug`. Return the
documented 404 response when no matching project exists, while preserving the
existing member listing behavior for valid projects.

return self.paginate(
request=request,
queryset=project_members,
on_results=lambda members: ProjectMemberLiteAPISerializer(members, many=True).data,
)
Loading
Loading