-
Notifications
You must be signed in to change notification settings - Fork 3.6k
[SILO-1028] feat: Project Summary external API #8661
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: preview
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||
|
|
@@ -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, | ||||||||||||||||||||||||||
|
|
@@ -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( | ||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing The existing 🐛 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| "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} | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: makeplane/plane
Length of output: 7442
🏁 Script executed:
Repository: makeplane/plane
Length of output: 1141
🏁 Script executed:
Repository: makeplane/plane
Length of output: 10560
Reconsider permission consistency —
WorkSpaceAdminPermissionallows both admin and member roles, but other project endpoints useProjectBasePermissionWorkSpaceAdminPermissionallows workspace members with role Admin or Member to access this endpoint. However, all other project API endpoints (ProjectListCreateAPIEndpoint,ProjectDetailAPIEndpoint,ProjectArchiveUnarchiveAPIEndpoint) useProjectBasePermission, 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 usingProjectBasePermissionfor consistency.🤖 Prompt for AI Agents