mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
feat: issue estimations (#696)
* dev: initialize estimation * dev: issue estimation field in issues and project settings * dev: update issue estimation logic
This commit is contained in:
parent
97386e9d07
commit
cc07e2790d
@ -66,3 +66,5 @@ from .integration import (
|
|||||||
from .importer import ImporterSerializer
|
from .importer import ImporterSerializer
|
||||||
|
|
||||||
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
|
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
|
||||||
|
|
||||||
|
from .estimate import EstimateSerializer, EstimatePointSerializer
|
||||||
|
25
apiserver/plane/api/serializers/estimate.py
Normal file
25
apiserver/plane/api/serializers/estimate.py
Normal file
@ -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",
|
||||||
|
]
|
@ -79,6 +79,11 @@ from plane.api.views import (
|
|||||||
# States
|
# States
|
||||||
StateViewSet,
|
StateViewSet,
|
||||||
## End States
|
## End States
|
||||||
|
# Estimates
|
||||||
|
EstimateViewSet,
|
||||||
|
EstimatePointViewSet,
|
||||||
|
ProjectEstimatePointEndpoint,
|
||||||
|
## End Estimates
|
||||||
# Shortcuts
|
# Shortcuts
|
||||||
ShortCutViewSet,
|
ShortCutViewSet,
|
||||||
## End Shortcuts
|
## End Shortcuts
|
||||||
@ -479,6 +484,57 @@ urlpatterns = [
|
|||||||
name="project-state",
|
name="project-state",
|
||||||
),
|
),
|
||||||
# End States ##
|
# End States ##
|
||||||
|
# States
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/",
|
||||||
|
EstimateViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "list",
|
||||||
|
"post": "create",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="project-estimates",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:pk>/",
|
||||||
|
EstimateViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "retrieve",
|
||||||
|
"put": "update",
|
||||||
|
"patch": "partial_update",
|
||||||
|
"delete": "destroy",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="project-estimates",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/",
|
||||||
|
EstimatePointViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "list",
|
||||||
|
"post": "create",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="project-estimate-points",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/<uuid:pk>/",
|
||||||
|
EstimatePointViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "retrieve",
|
||||||
|
"put": "update",
|
||||||
|
"patch": "partial_update",
|
||||||
|
"delete": "destroy",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="project-estimates",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/project-estimates/",
|
||||||
|
ProjectEstimatePointEndpoint.as_view(),
|
||||||
|
name="project-estimate-points",
|
||||||
|
),
|
||||||
|
# End States ##
|
||||||
# Shortcuts
|
# Shortcuts
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/shortcuts/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/shortcuts/",
|
||||||
|
@ -130,3 +130,9 @@ from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
|||||||
|
|
||||||
|
|
||||||
from .gpt import GPTIntegrationEndpoint
|
from .gpt import GPTIntegrationEndpoint
|
||||||
|
|
||||||
|
from .estimate import (
|
||||||
|
EstimateViewSet,
|
||||||
|
EstimatePointViewSet,
|
||||||
|
ProjectEstimatePointEndpoint,
|
||||||
|
)
|
||||||
|
137
apiserver/plane/api/views/estimate.py
Normal file
137
apiserver/plane/api/views/estimate.py
Normal file
@ -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,
|
||||||
|
)
|
@ -63,3 +63,5 @@ from .integration import (
|
|||||||
from .importer import Importer
|
from .importer import Importer
|
||||||
|
|
||||||
from .page import Page, PageBlock, PageFavorite, PageLabel
|
from .page import Page, PageBlock, PageFavorite, PageLabel
|
||||||
|
|
||||||
|
from .estimate import Estimate, EstimatePoint
|
||||||
|
47
apiserver/plane/db/models/estimate.py
Normal file
47
apiserver/plane/db/models/estimate.py
Normal file
@ -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",)
|
@ -8,6 +8,7 @@ from django.conf import settings
|
|||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
@ -37,6 +38,9 @@ class Issue(ProjectBaseModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name="state_issue",
|
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")
|
name = models.CharField(max_length=255, verbose_name="Issue Name")
|
||||||
description = models.JSONField(blank=True, default=dict)
|
description = models.JSONField(blank=True, default=dict)
|
||||||
description_html = models.TextField(blank=True, default="<p></p>")
|
description_html = models.TextField(blank=True, default="<p></p>")
|
||||||
|
@ -69,6 +69,9 @@ class Project(BaseModel):
|
|||||||
issue_views_view = models.BooleanField(default=True)
|
issue_views_view = models.BooleanField(default=True)
|
||||||
page_view = models.BooleanField(default=True)
|
page_view = models.BooleanField(default=True)
|
||||||
cover_image = models.URLField(blank=True, null=True, max_length=800)
|
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):
|
def __str__(self):
|
||||||
"""Return name of the project"""
|
"""Return name of the project"""
|
||||||
|
Loading…
Reference in New Issue
Block a user