[SILO-1026] feat: add estimates external API endpoints (#8664)

* add project summary endpoint

* update response structure

* add estimates external API endpoints with migrations

* fix invalid project and workspace error
This commit is contained in:
Saurabh Kumar 2026-03-30 15:30:02 +05:30 committed by GitHub
parent d7c80885fd
commit 9fa707b260
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 504 additions and 11 deletions

View file

@ -53,7 +53,7 @@ from .intake import (
IntakeIssueCreateSerializer,
IntakeIssueUpdateSerializer,
)
from .estimate import EstimatePointSerializer
from .estimate import EstimateSerializer, EstimatePointSerializer
from .asset import (
UserAssetUploadSerializer,
AssetUpdateSerializer,

View file

@ -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"]

View file

@ -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/<str:slug>/projects/<uuid:project_id>/estimates/",
ProjectEstimateAPIEndpoint.as_view(http_method_names=["get", "post", "patch", "delete"]),
name="project-estimate",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/",
EstimatePointListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="estimate-point-list-create",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/<uuid:estimate_point_id>/",
EstimatePointDetailAPIEndpoint.as_view(http_method_names=["patch", "delete"]),
name="estimate-point-detail",
),
]

View file

@ -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.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.filter(slug=slug).first()
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)

View file

@ -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),
),
]

View file

@ -10,11 +10,15 @@ from django.db.models import Q
# 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):

View file

@ -47,6 +47,7 @@ from .parameters import (
CYCLE_VIEW_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
ESTIMATE_ID_PARAMETER,
)
# Responses
@ -126,6 +127,10 @@ from .examples import (
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 @@ from .examples import (
PROJECT_MEMBER_EXAMPLE,
CYCLE_ISSUE_EXAMPLE,
STICKY_EXAMPLE,
ESTIMATE_EXAMPLE,
ESTIMATE_POINT_EXAMPLE,
)
# Helper decorators
@ -166,6 +173,8 @@ from .decorators import (
module_docs,
module_issue_docs,
state_docs,
estimate_docs,
estimate_point_docs,
)
# Schema processing hooks
@ -207,6 +216,7 @@ __all__ = [
"CYCLE_VIEW_PARAMETER",
"FIELDS_PARAMETER",
"EXPAND_PARAMETER",
"ESTIMATE_ID_PARAMETER",
# Responses
"UNAUTHORIZED_RESPONSE",
"FORBIDDEN_RESPONSE",
@ -280,6 +290,10 @@ __all__ = [
"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",
@ -299,6 +313,8 @@ __all__ = [
"PROJECT_MEMBER_EXAMPLE",
"CYCLE_ISSUE_EXAMPLE",
"STICKY_EXAMPLE",
"ESTIMATE_EXAMPLE",
"ESTIMATE_POINT_EXAMPLE",
# Decorators
"workspace_docs",
"project_docs",
@ -317,6 +333,8 @@ __all__ = [
"module_docs",
"module_issue_docs",
"state_docs",
"estimate_docs",
"estimate_point_docs",
# Hooks
"preprocess_filter_api_v1_paths",
"generate_operation_summary",

View file

@ -297,3 +297,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))

View file

@ -686,6 +686,69 @@ STICKY_EXAMPLE = OpenApiExample(
},
)
# 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 @@ SAMPLE_STICKY = {
"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 @@ SCHEMA_EXAMPLES = {
"Intake": SAMPLE_INTAKE,
"CycleIssue": SAMPLE_CYCLE_ISSUE,
"Sticky": SAMPLE_STICKY,
"Estimate": SAMPLE_ESTIMATE,
"EstimatePoint": SAMPLE_ESTIMATE_POINT,
}

View file

@ -495,3 +495,11 @@ EXPAND_PARAMETER = OpenApiParameter(
),
],
)
ESTIMATE_ID_PARAMETER = OpenApiParameter(
name="estimate_id",
description="Estimate ID",
required=True,
type=OpenApiTypes.UUID,
location=OpenApiParameter.PATH,
)