[SILO-1028] feat: Project Summary external API (#8661)
* add project summary endpoint * update response structure
This commit is contained in:
parent
da870a1513
commit
a9d688f290
3 changed files with 134 additions and 5 deletions
|
|
@ -8,6 +8,7 @@ from plane.api.views import (
|
||||||
ProjectListCreateAPIEndpoint,
|
ProjectListCreateAPIEndpoint,
|
||||||
ProjectDetailAPIEndpoint,
|
ProjectDetailAPIEndpoint,
|
||||||
ProjectArchiveUnarchiveAPIEndpoint,
|
ProjectArchiveUnarchiveAPIEndpoint,
|
||||||
|
ProjectSummaryAPIEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
@ -26,4 +27,9 @@ urlpatterns = [
|
||||||
ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]),
|
ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]),
|
||||||
name="project-archive-unarchive",
|
name="project-archive-unarchive",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/summary/",
|
||||||
|
ProjectSummaryAPIEndpoint.as_view(http_method_names=["get"]),
|
||||||
|
name="project-summary",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from .project import (
|
||||||
ProjectListCreateAPIEndpoint,
|
ProjectListCreateAPIEndpoint,
|
||||||
ProjectDetailAPIEndpoint,
|
ProjectDetailAPIEndpoint,
|
||||||
ProjectArchiveUnarchiveAPIEndpoint,
|
ProjectArchiveUnarchiveAPIEndpoint,
|
||||||
|
ProjectSummaryAPIEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .state import (
|
from .state import (
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import json
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.db import IntegrityError
|
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.utils import timezone
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
|
||||||
|
|
@ -31,6 +32,11 @@ from plane.db.models import (
|
||||||
DEFAULT_STATES,
|
DEFAULT_STATES,
|
||||||
Workspace,
|
Workspace,
|
||||||
UserFavorite,
|
UserFavorite,
|
||||||
|
Label,
|
||||||
|
Issue,
|
||||||
|
StateGroup,
|
||||||
|
IntakeIssue,
|
||||||
|
ProjectPage,
|
||||||
)
|
)
|
||||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||||
from .base import BaseAPIView
|
from .base import BaseAPIView
|
||||||
|
|
@ -40,7 +46,7 @@ from plane.api.serializers import (
|
||||||
ProjectCreateSerializer,
|
ProjectCreateSerializer,
|
||||||
ProjectUpdateSerializer,
|
ProjectUpdateSerializer,
|
||||||
)
|
)
|
||||||
from plane.app.permissions import ProjectBasePermission
|
from plane.app.permissions import ProjectBasePermission, WorkSpaceAdminPermission
|
||||||
from plane.utils.openapi import (
|
from plane.utils.openapi import (
|
||||||
project_docs,
|
project_docs,
|
||||||
PROJECT_ID_PARAMETER,
|
PROJECT_ID_PARAMETER,
|
||||||
|
|
@ -183,9 +189,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||||
return self.paginate(
|
return self.paginate(
|
||||||
request=request,
|
request=request,
|
||||||
queryset=(projects),
|
queryset=(projects),
|
||||||
on_results=lambda projects: ProjectSerializer(
|
on_results=lambda projects: (
|
||||||
projects, many=True, fields=self.fields, expand=self.expand
|
ProjectSerializer(projects, many=True, fields=self.fields, expand=self.expand).data
|
||||||
).data,
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@project_docs(
|
@project_docs(
|
||||||
|
|
@ -549,3 +555,119 @@ class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||||
project.archived_at = None
|
project.archived_at = None
|
||||||
project.save()
|
project.save()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue