diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 5831b4898..2ab639d54 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -53,7 +53,7 @@ from .intake import ( 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 e2a7c5e1d..978003240 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 000000000..3fe5d3b7b --- /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 000000000..915cc8e5c --- /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.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) 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 000000000..73b75123f --- /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 ded6f97bc..fb472a69b 100644 --- a/apps/api/plane/db/models/estimate.py +++ b/apps/api/plane/db/models/estimate.py @@ -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): diff --git a/apps/api/plane/utils/openapi/__init__.py b/apps/api/plane/utils/openapi/__init__.py index 4472e8141..090d076ec 100644 --- a/apps/api/plane/utils/openapi/__init__.py +++ b/apps/api/plane/utils/openapi/__init__.py @@ -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", diff --git a/apps/api/plane/utils/openapi/decorators.py b/apps/api/plane/utils/openapi/decorators.py index 8edb5987e..7ded9fb10 100644 --- a/apps/api/plane/utils/openapi/decorators.py +++ b/apps/api/plane/utils/openapi/decorators.py @@ -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)) \ 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 5a2188e69..20aff1895 100644 --- a/apps/api/plane/utils/openapi/examples.py +++ b/apps/api/plane/utils/openapi/examples.py @@ -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, } diff --git a/apps/api/plane/utils/openapi/parameters.py b/apps/api/plane/utils/openapi/parameters.py index d0ceba6c5..2812892ec 100644 --- a/apps/api/plane/utils/openapi/parameters.py +++ b/apps/api/plane/utils/openapi/parameters.py @@ -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, +)