[SILO-1028] feat: Project Summary external API (#8661)

* add project summary endpoint

* update response structure
This commit is contained in:
Saurabh Kumar 2026-03-03 01:33:07 +05:30 committed by GitHub
parent da870a1513
commit a9d688f290
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 134 additions and 5 deletions

View file

@ -8,6 +8,7 @@ from plane.api.views import (
ProjectListCreateAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
ProjectSummaryAPIEndpoint,
)
urlpatterns = [
@ -26,4 +27,9 @@ urlpatterns = [
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",
),
]

View file

@ -6,6 +6,7 @@ from .project import (
ProjectListCreateAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
ProjectSummaryAPIEndpoint,
)
from .state import (

View file

@ -7,7 +7,8 @@ import json
# 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
@ -31,6 +32,11 @@ from plane.db.models import (
DEFAULT_STATES,
Workspace,
UserFavorite,
Label,
Issue,
StateGroup,
IntakeIssue,
ProjectPage,
)
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from .base import BaseAPIView
@ -40,7 +46,7 @@ from plane.api.serializers import (
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,
@ -183,9 +189,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
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(
@ -549,3 +555,119 @@ class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
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
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")
),
"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}