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: 6 additions & 0 deletions apps/api/plane/api/urls/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
ProjectListCreateAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
ProjectSummaryAPIEndpoint,
)

urlpatterns = [
Expand All @@ -26,4 +27,9 @@
ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]),
name="project-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/summary/",
ProjectSummaryAPIEndpoint.as_view(http_method_names=["get"]),
name="project-summary",
),
]
1 change: 1 addition & 0 deletions apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
ProjectListCreateAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
ProjectSummaryAPIEndpoint,
)

from .state import (
Expand Down
132 changes: 127 additions & 5 deletions apps/api/plane/api/views/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

# Django imports
from django.db import IntegrityError
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery, Count
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder

Expand All @@ -31,6 +32,11 @@
DEFAULT_STATES,
Workspace,
UserFavorite,
Label,
Issue,
StateGroup,
IntakeIssue,
ProjectPage,
)
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from .base import BaseAPIView
Expand All @@ -40,7 +46,7 @@
ProjectCreateSerializer,
ProjectUpdateSerializer,
)
from plane.app.permissions import ProjectBasePermission
from plane.app.permissions import ProjectBasePermission, WorkSpaceAdminPermission
from plane.utils.openapi import (
project_docs,
PROJECT_ID_PARAMETER,
Expand Down Expand Up @@ -183,9 +189,9 @@ def get(self, request, slug):
return self.paginate(
request=request,
queryset=(projects),
on_results=lambda projects: ProjectSerializer(
projects, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda projects: (
ProjectSerializer(projects, many=True, fields=self.fields, expand=self.expand).data
),
)

@project_docs(
Expand Down Expand Up @@ -549,3 +555,119 @@ def delete(self, request, slug, project_id):
project.archived_at = None
project.save()
return Response(status=status.HTTP_204_NO_CONTENT)


ALLOWED_PROJECT_SUMMARY_FIELDS = [
"members",
"states",
"labels",
"cycles",
"modules",
"issues",
"intakes",
"pages",
]


class ProjectSummaryAPIEndpoint(BaseAPIView):
permission_classes = [WorkSpaceAdminPermission]
use_read_replica = True
Comment on lines +572 to +574
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check WorkSpaceAdminPermission implementation
echo "=== WorkSpaceAdminPermission ==="
rg -n "class WorkSpaceAdminPermission" --type py -A 15

echo ""
echo "=== ProjectBasePermission ==="
rg -n "class ProjectBasePermission" --type py -A 15

echo ""
echo "=== ProjectSummaryAPIEndpoint context ==="
rg -n "class ProjectSummaryAPIEndpoint" --type py -B 2 -A 20

Repository: makeplane/plane

Length of output: 7442


🏁 Script executed:

#!/bin/bash
# Check what role types exist
echo "=== Role definitions (Admin, Member constants) ==="
rg -n "^(Admin|Member)\s*=" --type py | head -20

echo ""
echo "=== Other ProjectAPIView endpoints and their permissions ==="
rg -n "class Project.*APIView.*|permission_classes" apps/api/plane/api/views/project.py | head -40

echo ""
echo "=== Check if ProjectBasePermission is used elsewhere ==="
rg -l "ProjectBasePermission" --type py

Repository: makeplane/plane

Length of output: 1141


🏁 Script executed:

#!/bin/bash
# Get full ProjectBasePermission implementation
echo "=== Full ProjectBasePermission implementation ==="
rg -n "class ProjectBasePermission" --type py -A 40 | head -60

echo ""
echo "=== Check if there are project-level roles/permissions ==="
rg -n "ProjectMember|project.*role" --type py -i | grep -E "(class|def)" | head -20

echo ""
echo "=== Check context around ProjectSummaryAPIEndpoint for comments/rationale ==="
rg -n "class ProjectSummaryAPIEndpoint" --type py -B 10 -A 30

Repository: makeplane/plane

Length of output: 10560


Reconsider permission consistency — WorkSpaceAdminPermission allows both admin and member roles, but other project endpoints use ProjectBasePermission

WorkSpaceAdminPermission allows workspace members with role Admin or Member to access this endpoint. However, all other project API endpoints (ProjectListCreateAPIEndpoint, ProjectDetailAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint) use ProjectBasePermission, which for GET requests permits any workspace member. This endpoint is inconsistent with the pattern. If project summary counts are sensitive and should be restricted to admins/members only, confirm this is intentional; otherwise, consider using ProjectBasePermission for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/project.py` around lines 573 - 575,
ProjectSummaryAPIEndpoint currently uses WorkSpaceAdminPermission (allowing
Admin and Member) while other project endpoints use ProjectBasePermission (which
permits any workspace member on GET); update
ProjectSummaryAPIEndpoint.permission_classes to ProjectBasePermission to match
the pattern unless the intent is to restrict summary counts to admins only—if
admin-only behavior is intended, add an explicit comment explaining that and
keep WorkSpaceAdminPermission; reference the class name
ProjectSummaryAPIEndpoint and the permission symbols WorkSpaceAdminPermission
and ProjectBasePermission when making the change.


def get(self, request, slug, project_id):
"""Get project summary

Get the summary of a project
"""
project = Project.objects.filter(pk=project_id, workspace__slug=slug).first()
if not project:
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
fields = request.GET.get("fields", "").split(",")
requested_fields = set(filter(None, (f.strip() for f in fields))) & set(ALLOWED_PROJECT_SUMMARY_FIELDS)
if not requested_fields:
requested_fields = set(ALLOWED_PROJECT_SUMMARY_FIELDS)

# Single DB round-trip with only requested count subqueries
counts = self._get_all_summary_counts(project_id, requested_fields)
counts_dict = {field: counts[field] for field in requested_fields}
summary = {
"id": project.id,
"name": project.name,
"identifier": project.identifier,
"counts": counts_dict,
}
return Response(summary, status=status.HTTP_200_OK)

# Getting all summary counts in one ORM query; only runs subqueries for requested fields.
def _get_all_summary_counts(self, project_id, requested_fields):
"""Return requested summary counts in one ORM query; only runs subqueries for requested fields."""

# Using a different annotation name for 'pages' to avoid conflict with Project.pages (M2M from Page)
def _annotation_name(field):
return "pages_count" if field == "pages" else field

subquery_builders = {
"members": lambda: (
ProjectMember.objects.filter(project_id=OuterRef("pk"), is_active=True)
.values("project_id")
.annotate(count=Count("*"))
.values("count")
),
Comment on lines +609 to +614
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 | 🟡 Minor

Missing member__is_bot=False filter — inconsistent with existing member count logic.

The existing total_members annotation at lines 105-106 and 320-321 filters out bot members with member__is_bot=False, but this new members subquery does not include that filter. This inconsistency will cause different member counts between the project list/detail endpoints and this summary endpoint.

🐛 Proposed fix
             "members": lambda: (
-                ProjectMember.objects.filter(project_id=OuterRef("pk"), is_active=True)
+                ProjectMember.objects.filter(project_id=OuterRef("pk"), is_active=True, member__is_bot=False)
                 .values("project_id")
                 .annotate(count=Count("*"))
                 .values("count")
             ),
📝 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
"members": lambda: (
ProjectMember.objects.filter(project_id=OuterRef("pk"), is_active=True)
.values("project_id")
.annotate(count=Count("*"))
.values("count")
),
"members": lambda: (
ProjectMember.objects.filter(project_id=OuterRef("pk"), is_active=True, member__is_bot=False)
.values("project_id")
.annotate(count=Count("*"))
.values("count")
),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/project.py` around lines 609 - 614, The new
"members" subquery on ProjectMember (the lambda returning
ProjectMember.objects.filter(project_id=OuterRef("pk"),
is_active=True).values("project_id").annotate(count=Count("*")).values("count"))
is missing the same bot-exclusion used elsewhere; update that filter to include
member__is_bot=False so it matches the existing total_members logic (i.e., add
member__is_bot=False to the filter on ProjectMember in the members lambda) to
ensure consistent member counts across endpoints.

"states": lambda: (
State.objects.filter(project_id=OuterRef("pk"))
.values("project_id")
.annotate(count=Count("*"))
.values("count")
),
"labels": lambda: (
Label.objects.filter(project_id=OuterRef("pk"))
.values("project_id")
.annotate(count=Count("*"))
.values("count")
),
"cycles": lambda: (
Cycle.objects.filter(project_id=OuterRef("pk"))
.values("project_id")
.annotate(count=Count("*"))
.values("count")
),
"modules": lambda: (
Module.objects.filter(project_id=OuterRef("pk"))
.values("project_id")
.annotate(count=Count("*"))
.values("count")
),
"issues": lambda: (
Issue.objects.filter(project_id=OuterRef("pk"))
.exclude(state__group=StateGroup.TRIAGE.value)
.values("project_id")
.annotate(count=Count("*"))
.values("count")
),
"intakes": lambda: (
IntakeIssue.objects.filter(project_id=OuterRef("pk"))
.values("project_id")
.annotate(count=Count("*"))
.values("count")
),
"pages": lambda: (
ProjectPage.objects.filter(project_id=OuterRef("pk"))
.values("project_id")
.annotate(count=Count("*"))
.values("count")
),
}

# Build annotations dictionary for the requested fields
annotations = {
_annotation_name(field): Coalesce(Subquery(subquery_builders[field]()), 0) for field in requested_fields
}

# Prepare values list for the annotation names
fields_list = sorted(requested_fields)
values_list = [_annotation_name(f) for f in fields_list]
# Execute the query and get the result
query_result = Project.objects.filter(pk=project_id).annotate(**annotations).values(*values_list).first()
if not query_result:
return {field: 0 for field in requested_fields}
# Return the result as a dictionary
return {field: query_result[_annotation_name(field)] for field in requested_fields}
Loading