From 86a787bad3f08ee2ae2c7eacd3159f1d649ccd54 Mon Sep 17 00:00:00 2001 From: Saurabhkmr98 Date: Wed, 25 Feb 2026 18:24:47 +0530 Subject: [PATCH 1/4] add project summary endpoint --- apps/api/plane/api/urls/project.py | 6 ++ apps/api/plane/api/views/__init__.py | 1 + apps/api/plane/api/views/project.py | 129 +++++++++++++++++++++++++-- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/apps/api/plane/api/urls/project.py b/apps/api/plane/api/urls/project.py index a419b37a09c..eb22249e0af 100644 --- a/apps/api/plane/api/urls/project.py +++ b/apps/api/plane/api/urls/project.py @@ -8,6 +8,7 @@ ProjectListCreateAPIEndpoint, ProjectDetailAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint, + ProjectSummaryAPIEndpoint, ) urlpatterns = [ @@ -26,4 +27,9 @@ ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]), name="project-archive-unarchive", ), + path( + "workspaces//projects//summary/", + ProjectSummaryAPIEndpoint.as_view(http_method_names=["get"]), + name="project-summary", + ), ] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 305ebfdb39a..644d87edc86 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -6,6 +6,7 @@ ProjectListCreateAPIEndpoint, ProjectDetailAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint, + ProjectSummaryAPIEndpoint, ) from .state import ( diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py index 3f6a3b1c1ff..7e9ffd75084 100644 --- a/apps/api/plane/api/views/project.py +++ b/apps/api/plane/api/views/project.py @@ -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,116 @@ 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) + summary = {field: counts[field] for field in requested_fields} + summary["project_id"] = project.id + summary["project_name"] = project.name + 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} From dda073934158428b02704115c9324c21261b8f68 Mon Sep 17 00:00:00 2001 From: Saurabhkmr98 Date: Thu, 26 Feb 2026 17:46:33 +0530 Subject: [PATCH 2/4] update response structure --- apps/api/plane/api/views/project.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py index 7e9ffd75084..b7bfd603aaa 100644 --- a/apps/api/plane/api/views/project.py +++ b/apps/api/plane/api/views/project.py @@ -557,7 +557,6 @@ def delete(self, request, slug, project_id): return Response(status=status.HTTP_204_NO_CONTENT) - ALLOWED_PROJECT_SUMMARY_FIELDS = [ "members", "states", @@ -589,9 +588,13 @@ def get(self, request, slug, project_id): # Single DB round-trip with only requested count subqueries counts = self._get_all_summary_counts(project_id, requested_fields) - summary = {field: counts[field] for field in requested_fields} - summary["project_id"] = project.id - summary["project_name"] = project.name + 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. From 52a255f936d86d51c4d9da84850989c92cc3727f Mon Sep 17 00:00:00 2001 From: Saurabhkmr98 Date: Thu, 26 Feb 2026 20:07:45 +0530 Subject: [PATCH 3/4] add estimates external API endpoints with migrations --- apps/api/plane/api/serializers/__init__.py | 2 +- apps/api/plane/api/serializers/estimate.py | 34 +- apps/api/plane/api/urls/estimate.py | 29 ++ apps/api/plane/api/views/estimate.py | 291 ++++++++++++++++++ .../db/migrations/0121_alter_estimate_type.py | 18 ++ apps/api/plane/db/models/estimate.py | 6 +- apps/api/plane/utils/openapi/__init__.py | 18 ++ apps/api/plane/utils/openapi/decorators.py | 26 ++ apps/api/plane/utils/openapi/examples.py | 83 +++++ apps/api/plane/utils/openapi/parameters.py | 8 + 10 files changed, 504 insertions(+), 11 deletions(-) create mode 100644 apps/api/plane/api/urls/estimate.py create mode 100644 apps/api/plane/api/views/estimate.py create mode 100644 apps/api/plane/db/migrations/0121_alter_estimate_type.py diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 44e527a2dc5..b162990619c 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -49,7 +49,7 @@ IntakeIssueCreateSerializer, IntakeIssueUpdateSerializer, ) -from .estimate import EstimatePointSerializer +from .estimate import EstimateSerializer, EstimatePointSerializer from .asset import ( UserAssetUploadSerializer, AssetUpdateSerializer, diff --git a/apps/api/plane/api/serializers/estimate.py b/apps/api/plane/api/serializers/estimate.py index e2a7c5e1d32..978003240b0 100644 --- a/apps/api/plane/api/serializers/estimate.py +++ b/apps/api/plane/api/serializers/estimate.py @@ -2,20 +2,36 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +# Third party imports +from rest_framework import serializers + # Module imports -from plane.db.models import EstimatePoint +from plane.db.models import Estimate, EstimatePoint from .base import BaseSerializer -class EstimatePointSerializer(BaseSerializer): - """ - Serializer for project estimation points and story point values. +class EstimateSerializer(BaseSerializer): + class Meta: + model = Estimate + fields = "__all__" + read_only_fields = ["workspace", "project", "deleted_at"] - Handles numeric estimation data for work item sizing and sprint planning, - providing standardized point values for project velocity calculations. - """ + def create(self, validated_data): + validated_data["workspace"] = self.context["workspace"] + validated_data["project"] = self.context["project"] + return super().create(validated_data) + + +class EstimatePointSerializer(BaseSerializer): + def validate(self, data): + if not data: + raise serializers.ValidationError("Estimate points are required") + value = data.get("value") + if value and len(value) > 20: + raise serializers.ValidationError("Value can't be more than 20 characters") + return data class Meta: model = EstimatePoint - fields = ["id", "value"] - read_only_fields = fields + fields = "__all__" + read_only_fields = ["estimate", "workspace", "project"] diff --git a/apps/api/plane/api/urls/estimate.py b/apps/api/plane/api/urls/estimate.py new file mode 100644 index 00000000000..3fe5d3b7b43 --- /dev/null +++ b/apps/api/plane/api/urls/estimate.py @@ -0,0 +1,29 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.urls import path + +from plane.api.views.estimate import ( + ProjectEstimateAPIEndpoint, + EstimatePointListCreateAPIEndpoint, + EstimatePointDetailAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//estimates/", + ProjectEstimateAPIEndpoint.as_view(http_method_names=["get", "post", "patch", "delete"]), + name="project-estimate", + ), + path( + "workspaces//projects//estimates//estimate-points/", + EstimatePointListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="estimate-point-list-create", + ), + path( + "workspaces//projects//estimates//estimate-points//", + EstimatePointDetailAPIEndpoint.as_view(http_method_names=["patch", "delete"]), + name="estimate-point-detail", + ), +] diff --git a/apps/api/plane/api/views/estimate.py b/apps/api/plane/api/views/estimate.py new file mode 100644 index 00000000000..9ecad27ee33 --- /dev/null +++ b/apps/api/plane/api/views/estimate.py @@ -0,0 +1,291 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from drf_spectacular.utils import OpenApiRequest, OpenApiResponse + +# Module imports +from plane.app.permissions.project import ProjectEntityPermission +from plane.api.views.base import BaseAPIView +from plane.db.models import Estimate, EstimatePoint, Project, Workspace +from plane.api.serializers import EstimateSerializer, EstimatePointSerializer +from plane.utils.openapi.decorators import estimate_docs, estimate_point_docs +from plane.utils.openapi import ( + ESTIMATE_CREATE_EXAMPLE, + ESTIMATE_UPDATE_EXAMPLE, + ESTIMATE_POINT_CREATE_EXAMPLE, + ESTIMATE_POINT_UPDATE_EXAMPLE, + ESTIMATE_EXAMPLE, + ESTIMATE_POINT_EXAMPLE, + DELETED_RESPONSE, + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + ESTIMATE_ID_PARAMETER, +) + + +class ProjectEstimateAPIEndpoint(BaseAPIView): + permission_classes = [ProjectEntityPermission] + model = Estimate + serializer_class = EstimateSerializer + + def get_queryset(self): + return self.model.objects.filter(workspace__slug=self.workspace_slug, project_id=self.project_id) + + @estimate_docs( + operation_id="create_estimate", + summary="Create an estimate", + description="Create an estimate for a project", + request=OpenApiRequest( + request=EstimateSerializer, + examples=[ESTIMATE_CREATE_EXAMPLE], + ), + ) + def post(self, request, slug, project_id): + project = Project.objects.get(id=project_id, workspace__slug=slug) + if not project: + return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Project not found"}) + + workspace = Workspace.objects.get(slug=slug) + if not workspace: + return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Workspace not found"}) + + project_estimate = self.get_queryset().first() + if project_estimate: + # return 409 if the project estimate already exists + return Response( + status=status.HTTP_409_CONFLICT, + data={"error": "An estimate already exists for this project", "id": str(project_estimate.id)}, + ) + # create the project estimate + serializer = self.serializer_class(data=request.data, context={"workspace": workspace, "project": project}) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @estimate_docs( + operation_id="get_estimate", + summary="Get an estimate", + description="Get an estimate for a project", + responses={ + 200: OpenApiResponse( + description="Estimate", + response=EstimateSerializer, + examples=[ESTIMATE_EXAMPLE], + ), + }, + ) + def get(self, request, slug, project_id): + estimate = self.get_queryset().first() + if not estimate: + return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"}) + serializer = self.serializer_class(estimate) + return Response(serializer.data, status=status.HTTP_200_OK) + + @estimate_docs( + operation_id="update_estimate", + summary="Update an estimate", + description="Update an estimate for a project", + request=OpenApiRequest( + request=EstimateSerializer, + examples=[ESTIMATE_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Estimate", + response=EstimateSerializer, + examples=[ESTIMATE_EXAMPLE], + ), + }, + ) + def patch(self, request, slug, project_id): + ALLOWED_FIELDS = ["name", "description"] + estimate = self.get_queryset().first() + if not estimate: + return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"}) + filtered_data = {k: v for k, v in request.data.items() if k in ALLOWED_FIELDS} + if not filtered_data: + serializer = self.serializer_class(estimate) + return Response(serializer.data, status=status.HTTP_200_OK) + serializer = self.serializer_class(estimate, data=filtered_data, partial=True) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + @estimate_docs( + operation_id="delete_estimate", + summary="Delete an estimate", + description="Delete an estimate for a project", + responses={ + 204: DELETED_RESPONSE, + }, + ) + def delete(self, request, slug, project_id): + estimate = self.get_queryset().first() + if not estimate: + return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"}) + estimate.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class EstimatePointListCreateAPIEndpoint(BaseAPIView): + """List and bulk create estimate points for an estimate.""" + + permission_classes = [ProjectEntityPermission] + model = EstimatePoint + serializer_class = EstimatePointSerializer + + def get_queryset(self): + return self.model.objects.filter( + estimate_id=self.kwargs["estimate_id"], + workspace__slug=self.kwargs["slug"], + project_id=self.kwargs["project_id"], + ).select_related("estimate", "workspace", "project") + + @estimate_point_docs( + operation_id="get_estimate_points", + summary="Get estimate points", + description="Get estimate points for an estimate", + parameters=[ + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + ESTIMATE_ID_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Estimate points", + response=EstimatePointSerializer(many=True), + examples=[ESTIMATE_POINT_EXAMPLE], + ), + }, + ) + def get(self, request, slug, project_id, estimate_id): + estimate = Estimate.objects.filter( + id=estimate_id, + workspace__slug=slug, + project_id=project_id, + ).first() + if not estimate: + return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"}) + estimate_points = self.get_queryset() + serializer = self.serializer_class(estimate_points, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @estimate_point_docs( + operation_id="create_estimate_points", + summary="Create estimate points", + description="Create estimate points for an estimate", + request=OpenApiRequest( + request=EstimatePointSerializer, + examples=[ESTIMATE_POINT_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Estimate points", + response=EstimatePointSerializer(many=True), + examples=[ESTIMATE_POINT_EXAMPLE], + ), + }, + ) + def post(self, request, slug, project_id, estimate_id): + estimate = Estimate.objects.filter( + id=estimate_id, + workspace__slug=slug, + project_id=project_id, + ).first() + if not estimate: + return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"}) + + estimate_points_data = ( + request.data if isinstance(request.data, list) else request.data.get("estimate_points", []) + ) + if not estimate_points_data: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={"error": "Estimate points are required"}, + ) + + serializer = self.serializer_class(data=estimate_points_data, many=True) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + estimate_points = [ + EstimatePoint( + estimate=estimate, + workspace=estimate.workspace, + project=estimate.project, + **item, + ) + for item in serializer.validated_data + ] + created = EstimatePoint.objects.bulk_create(estimate_points) + return Response( + self.serializer_class(created, many=True).data, + status=status.HTTP_201_CREATED, + ) + + +class EstimatePointDetailAPIEndpoint(BaseAPIView): + """Update and delete a single estimate point.""" + + permission_classes = [ProjectEntityPermission] + model = EstimatePoint + serializer_class = EstimatePointSerializer + + def get_queryset(self): + return self.model.objects.filter( + estimate_id=self.kwargs["estimate_id"], + workspace__slug=self.kwargs["slug"], + project_id=self.kwargs["project_id"], + ) + + @estimate_point_docs( + operation_id="update_estimate_point", + summary="Update an estimate point", + description="Update an estimate point for an estimate", + request=OpenApiRequest( + request=EstimatePointSerializer, + examples=[ESTIMATE_POINT_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Estimate point", + response=EstimatePointSerializer, + examples=[ESTIMATE_POINT_EXAMPLE], + ), + }, + ) + def patch(self, request, slug, project_id, estimate_id, estimate_point_id): + estimate_point = self.get_queryset().filter(id=estimate_point_id).first() + if not estimate_point: + return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate point not found"}) + ALLOWED_FIELDS = ["key", "value", "description"] + filtered_data = {k: v for k, v in request.data.items() if k in ALLOWED_FIELDS} + if not filtered_data: + return Response(self.serializer_class(estimate_point).data, status=status.HTTP_200_OK) + serializer = self.serializer_class(estimate_point, data=filtered_data, partial=True) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + @estimate_point_docs( + operation_id="delete_estimate_point", + summary="Delete an estimate point", + description="Delete an estimate point for an estimate", + responses={ + 204: DELETED_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, estimate_id, estimate_point_id): + estimate_point = self.get_queryset().filter(id=estimate_point_id).first() + if not estimate_point: + return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate point not found"}) + estimate_point.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/db/migrations/0121_alter_estimate_type.py b/apps/api/plane/db/migrations/0121_alter_estimate_type.py new file mode 100644 index 00000000000..73b75123f63 --- /dev/null +++ b/apps/api/plane/db/migrations/0121_alter_estimate_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.28 on 2026-02-26 14:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0120_issueview_archived_at'), + ] + + operations = [ + migrations.AlterField( + model_name='estimate', + name='type', + field=models.CharField(choices=[('categories', 'Categories'), ('points', 'Points')], default='categories', max_length=255), + ), + ] diff --git a/apps/api/plane/db/models/estimate.py b/apps/api/plane/db/models/estimate.py index aa1e66be11b..4f841f7c85c 100644 --- a/apps/api/plane/db/models/estimate.py +++ b/apps/api/plane/db/models/estimate.py @@ -10,11 +10,15 @@ # Module imports from .project import ProjectBaseModel +class EstimateType(models.TextChoices): + CATEGORIES = "categories", "Categories" + POINTS = "points", "Points" + class Estimate(ProjectBaseModel): name = models.CharField(max_length=255) description = models.TextField(verbose_name="Estimate Description", blank=True) - type = models.CharField(max_length=255, default="categories") + type = models.CharField(max_length=255, choices=EstimateType.choices, default=EstimateType.CATEGORIES) last_used = models.BooleanField(default=False) def __str__(self): diff --git a/apps/api/plane/utils/openapi/__init__.py b/apps/api/plane/utils/openapi/__init__.py index d54caf584eb..1a4acb1a85c 100644 --- a/apps/api/plane/utils/openapi/__init__.py +++ b/apps/api/plane/utils/openapi/__init__.py @@ -47,6 +47,7 @@ CYCLE_VIEW_PARAMETER, FIELDS_PARAMETER, EXPAND_PARAMETER, + ESTIMATE_ID_PARAMETER, ) # Responses @@ -126,6 +127,10 @@ STATE_UPDATE_EXAMPLE, INTAKE_ISSUE_CREATE_EXAMPLE, INTAKE_ISSUE_UPDATE_EXAMPLE, + ESTIMATE_CREATE_EXAMPLE, + ESTIMATE_UPDATE_EXAMPLE, + ESTIMATE_POINT_CREATE_EXAMPLE, + ESTIMATE_POINT_UPDATE_EXAMPLE, # Response Examples CYCLE_EXAMPLE, TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE, @@ -145,6 +150,8 @@ PROJECT_MEMBER_EXAMPLE, CYCLE_ISSUE_EXAMPLE, STICKY_EXAMPLE, + ESTIMATE_EXAMPLE, + ESTIMATE_POINT_EXAMPLE, ) # Helper decorators @@ -165,6 +172,8 @@ module_docs, module_issue_docs, state_docs, + estimate_docs, + estimate_point_docs, ) # Schema processing hooks @@ -206,6 +215,7 @@ "CYCLE_VIEW_PARAMETER", "FIELDS_PARAMETER", "EXPAND_PARAMETER", + "ESTIMATE_ID_PARAMETER", # Responses "UNAUTHORIZED_RESPONSE", "FORBIDDEN_RESPONSE", @@ -279,6 +289,10 @@ "STATE_UPDATE_EXAMPLE", "INTAKE_ISSUE_CREATE_EXAMPLE", "INTAKE_ISSUE_UPDATE_EXAMPLE", + "ESTIMATE_CREATE_EXAMPLE", + "ESTIMATE_UPDATE_EXAMPLE", + "ESTIMATE_POINT_CREATE_EXAMPLE", + "ESTIMATE_POINT_UPDATE_EXAMPLE", # Response Examples "CYCLE_EXAMPLE", "TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE", @@ -298,6 +312,8 @@ "PROJECT_MEMBER_EXAMPLE", "CYCLE_ISSUE_EXAMPLE", "STICKY_EXAMPLE", + "ESTIMATE_EXAMPLE", + "ESTIMATE_POINT_EXAMPLE", # Decorators "workspace_docs", "project_docs", @@ -315,6 +331,8 @@ "module_docs", "module_issue_docs", "state_docs", + "estimate_docs", + "estimate_point_docs", # Hooks "preprocess_filter_api_v1_paths", "generate_operation_summary", diff --git a/apps/api/plane/utils/openapi/decorators.py b/apps/api/plane/utils/openapi/decorators.py index 8b016f4c016..0defe1fb1dc 100644 --- a/apps/api/plane/utils/openapi/decorators.py +++ b/apps/api/plane/utils/openapi/decorators.py @@ -282,3 +282,29 @@ def sticky_docs(**kwargs): } return extend_schema(**_merge_schema_options(defaults, kwargs)) + +def estimate_docs(**kwargs): + """Decorator for estimate-related endpoints""" + defaults = { + "tags": ["Estimates"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + return extend_schema(**_merge_schema_options(defaults, kwargs)) + +def estimate_point_docs(**kwargs): + """Decorator for estimate point-related endpoints""" + defaults = { + "tags": ["Estimate Points"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + return extend_schema(**_merge_schema_options(defaults, kwargs)) \ No newline at end of file diff --git a/apps/api/plane/utils/openapi/examples.py b/apps/api/plane/utils/openapi/examples.py index 5a2188e6951..20aff18958a 100644 --- a/apps/api/plane/utils/openapi/examples.py +++ b/apps/api/plane/utils/openapi/examples.py @@ -686,6 +686,69 @@ }, ) +# Estimate Examples +ESTIMATE_EXAMPLE = OpenApiExample( + name="Estimate", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Estimate 1", + "description": "Estimate 1 description", + }, + description="Example response for an estimate", +) + +ESTIMATE_POINT_EXAMPLE = OpenApiExample( + name="EstimatePoint", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "estimate": "550e8400-e29b-41d4-a716-446655440001", + "key": 1, + "value": "1", + }, + description="Example response for an estimate point", +) +ESTIMATE_CREATE_EXAMPLE = OpenApiExample( + name="EstimateCreateSerializer", + value={ + "name": "Estimate 1", + "description": "Estimate 1 description", + }, + description="Example request for creating an estimate", +) +ESTIMATE_UPDATE_EXAMPLE = OpenApiExample( + name="EstimateUpdateSerializer", + value={ + "name": "Estimate 1", + "description": "Estimate 1 description", + }, + description="Example request for updating an estimate", +) + +# Estimate Point Examples +ESTIMATE_POINT_CREATE_EXAMPLE = OpenApiExample( + name="EstimatePointCreateSerializer", + value=[ + { + "value": "1", + "description": "Estimate Point 1 description", + }, + { + "value": "2", + "description": "Estimate Point 2 description", + }, + ], + description="Example request for creating an estimate point", +) +ESTIMATE_POINT_UPDATE_EXAMPLE = OpenApiExample( + name="EstimatePointUpdateSerializer", + value={ + "value": "1", + "description": "Estimate Point 1 description", + }, + description="Example request for updating an estimate point", +) + + # Sample data for different entity types SAMPLE_ISSUE = { "id": "550e8400-e29b-41d4-a716-446655440000", @@ -801,6 +864,24 @@ "created_at": "2024-01-01T10:30:00Z", } +SAMPLE_ESTIMATE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Estimate 1", + "description": "Estimate 1 description", + "type": "categories", + "last_used": False, + "created_at": "2024-01-01T10:30:00Z", +} + +SAMPLE_ESTIMATE_POINT = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "estimate": "550e8400-e29b-41d4-a716-446655440001", + "key": 1, + "value": "1", + "description": "Estimate Point 1 description", + "created_at": "2024-01-01T10:30:00Z", +} + # Mapping of schema types to sample data SCHEMA_EXAMPLES = { "Issue": SAMPLE_ISSUE, @@ -816,6 +897,8 @@ "Intake": SAMPLE_INTAKE, "CycleIssue": SAMPLE_CYCLE_ISSUE, "Sticky": SAMPLE_STICKY, + "Estimate": SAMPLE_ESTIMATE, + "EstimatePoint": SAMPLE_ESTIMATE_POINT, } diff --git a/apps/api/plane/utils/openapi/parameters.py b/apps/api/plane/utils/openapi/parameters.py index d0ceba6c526..2812892ec91 100644 --- a/apps/api/plane/utils/openapi/parameters.py +++ b/apps/api/plane/utils/openapi/parameters.py @@ -495,3 +495,11 @@ ), ], ) + +ESTIMATE_ID_PARAMETER = OpenApiParameter( + name="estimate_id", + description="Estimate ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, +) From 719cd6d98c5a91b99bbcc5d2a55456b4d86e81a9 Mon Sep 17 00:00:00 2001 From: Saurabhkmr98 Date: Wed, 25 Mar 2026 17:25:02 +0530 Subject: [PATCH 4/4] fix invalid project and workspace error --- apps/api/plane/api/views/estimate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/api/views/estimate.py b/apps/api/plane/api/views/estimate.py index 9ecad27ee33..915cc8e5cc3 100644 --- a/apps/api/plane/api/views/estimate.py +++ b/apps/api/plane/api/views/estimate.py @@ -45,11 +45,11 @@ def get_queryset(self): ), ) def post(self, request, slug, project_id): - project = Project.objects.get(id=project_id, workspace__slug=slug) + project = Project.objects.filter(id=project_id, workspace__slug=slug).first() if not project: return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Project not found"}) - workspace = Workspace.objects.get(slug=slug) + workspace = Workspace.objects.filter(slug=slug).first() if not workspace: return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Workspace not found"})