diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 633ca6961..776864ee0 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -66,3 +66,5 @@ from .integration import ( from .importer import ImporterSerializer from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer + +from .estimate import EstimateSerializer, EstimatePointSerializer diff --git a/apiserver/plane/api/serializers/estimate.py b/apiserver/plane/api/serializers/estimate.py new file mode 100644 index 000000000..0aa4d331e --- /dev/null +++ b/apiserver/plane/api/serializers/estimate.py @@ -0,0 +1,25 @@ +# Module imports +from .base import BaseSerializer + +from plane.db.models import Estimate, EstimatePoint + + +class EstimateSerializer(BaseSerializer): + class Meta: + model = Estimate + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + ] + + +class EstimatePointSerializer(BaseSerializer): + class Meta: + model = EstimatePoint + fields = "__all__" + read_only_fields = [ + "estimate", + "workspace", + "project", + ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 44559ee0c..6a6fcf1ae 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -79,6 +79,11 @@ from plane.api.views import ( # States StateViewSet, ## End States + # Estimates + EstimateViewSet, + EstimatePointViewSet, + ProjectEstimatePointEndpoint, + ## End Estimates # Shortcuts ShortCutViewSet, ## End Shortcuts @@ -479,6 +484,57 @@ urlpatterns = [ name="project-state", ), # End States ## + # States + path( + "workspaces//projects//estimates/", + EstimateViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-estimates", + ), + path( + "workspaces//projects//estimates//", + EstimateViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-estimates", + ), + path( + "workspaces//projects//estimates//estimate-points/", + EstimatePointViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-estimate-points", + ), + path( + "workspaces//projects//estimates//estimate-points//", + EstimatePointViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-estimates", + ), + path( + "workspaces//projects//project-estimates/", + ProjectEstimatePointEndpoint.as_view(), + name="project-estimate-points", + ), + # End States ## # Shortcuts path( "workspaces//projects//shortcuts/", diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index b945fecf7..be1cfb4cd 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -130,3 +130,9 @@ from .search import GlobalSearchEndpoint, IssueSearchEndpoint from .gpt import GPTIntegrationEndpoint + +from .estimate import ( + EstimateViewSet, + EstimatePointViewSet, + ProjectEstimatePointEndpoint, +) diff --git a/apiserver/plane/api/views/estimate.py b/apiserver/plane/api/views/estimate.py new file mode 100644 index 000000000..fcc2ddfb0 --- /dev/null +++ b/apiserver/plane/api/views/estimate.py @@ -0,0 +1,137 @@ +# Django imports +from django.db import IntegrityError + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from sentry_sdk import capture_exception + +# Module imports +from .base import BaseViewSet, BaseAPIView +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import Project, Estimate, EstimatePoint +from plane.api.serializers import EstimateSerializer, EstimatePointSerializer + + +class EstimateViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + model = Estimate + serializer_class = EstimateSerializer + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .distinct() + ) + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + +class EstimatePointViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + model = EstimatePoint + serializer_class = EstimatePointSerializer + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .filter(estimate_id=self.kwargs.get("estimate_id")) + .select_related("project") + .select_related("workspace") + .distinct() + ) + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + estimate_id=self.kwargs.get("estimate_id"), + ) + + def create(self, request, slug, project_id, estimate_id): + try: + serializer = EstimatePointSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(estimate_id=estimate_id, project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"error": "The estimate point is already taken"}, + status=status.HTTP_410_GONE, + ) + else: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def partial_update(self, request, slug, project_id, estimate_id, pk): + try: + estimate_point = EstimatePoint.objects.get( + pk=pk, + estimate_id=estimate_id, + project_id=project_id, + workspace__slug=slug, + ) + serializer = EstimatePointSerializer( + estimate_point, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save(estimate_id=estimate_id, project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except EstimatePoint.DoesNotExist: + return Response( + {"error": "Estimate Point does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"error": "The estimate point value is already taken"}, + status=status.HTTP_410_GONE, + ) + else: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class ProjectEstimatePointEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + try: + project = Project.objects.get(workspace__slug=slug, pk=project_id) + if project.estimate_id is not None: + estimate_points = EstimatePoint.objects.filter( + estimate_id=project.estimate_id, + project_id=project_id, + workspace__slug=slug, + ) + serializer = EstimatePointSerializer(estimate_points, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response([], status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 46b459bbd..5df899362 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -63,3 +63,5 @@ from .integration import ( from .importer import Importer from .page import Page, PageBlock, PageFavorite, PageLabel + +from .estimate import Estimate, EstimatePoint diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py new file mode 100644 index 000000000..a04c48360 --- /dev/null +++ b/apiserver/plane/db/models/estimate.py @@ -0,0 +1,47 @@ +# Django imports +from django.db import models +from django.core.validators import MinValueValidator, MaxValueValidator + +# Module imports +from . import ProjectBaseModel + + +class Estimate(ProjectBaseModel): + name = models.CharField(max_length=255) + description = models.TextField(verbose_name="Estimate Description", blank=True) + + def __str__(self): + """Return name of the estimate""" + return f"{self.name} <{self.project.name}>" + + class Meta: + unique_together = ["name", "project"] + verbose_name = "Estimate" + verbose_name_plural = "Estimates" + db_table = "estimates" + ordering = ("name",) + + +class EstimatePoint(ProjectBaseModel): + estimate = models.ForeignKey( + "db.Estimate", + on_delete=models.CASCADE, + related_name="points", + limit_choices_to={"estimate__points__count__lt": 10}, + ) + key = models.IntegerField( + default=0, validators=[MinValueValidator(0), MaxValueValidator(7)] + ) + description = models.TextField(blank=True) + value = models.CharField(max_length=20) + + def __str__(self): + """Return name of the estimate""" + return f"{self.estimate.name} <{self.key}> <{self.value}>" + + class Meta: + unique_together = ["value", "estimate"] + verbose_name = "Estimate Point" + verbose_name_plural = "Estimate Points" + db_table = "estimate_points" + ordering = ("value",) diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index aeee54348..2443054a1 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -8,6 +8,7 @@ from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from django.core.validators import MinValueValidator, MaxValueValidator from django.core.exceptions import ValidationError # Module imports @@ -37,6 +38,9 @@ class Issue(ProjectBaseModel): blank=True, related_name="state_issue", ) + estimate_point = models.IntegerField( + default=0, validators=[MinValueValidator(0), MaxValueValidator(7)] + ) name = models.CharField(max_length=255, verbose_name="Issue Name") description = models.JSONField(blank=True, default=dict) description_html = models.TextField(blank=True, default="

") diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index b3c8f669a..b5ba8f198 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -69,6 +69,9 @@ class Project(BaseModel): issue_views_view = models.BooleanField(default=True) page_view = models.BooleanField(default=True) cover_image = models.URLField(blank=True, null=True, max_length=800) + estimate = models.ForeignKey( + "db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True + ) def __str__(self): """Return name of the project"""