forked from github/plane
chore: add labels data in cycles (#1223)
* dev: add labels data for all cycles * dev: add assignees and labels percentage * dev: initial peice on cycle burn down chart * dev: cycles burn down chat
This commit is contained in:
parent
e9a0eb87cc
commit
6b1d20449b
@ -1,3 +1,6 @@
|
|||||||
|
# Django imports
|
||||||
|
from django.db.models.functions import TruncDate
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
@ -20,13 +23,13 @@ class CycleSerializer(BaseSerializer):
|
|||||||
unstarted_issues = serializers.IntegerField(read_only=True)
|
unstarted_issues = serializers.IntegerField(read_only=True)
|
||||||
backlog_issues = serializers.IntegerField(read_only=True)
|
backlog_issues = serializers.IntegerField(read_only=True)
|
||||||
assignees = serializers.SerializerMethodField(read_only=True)
|
assignees = serializers.SerializerMethodField(read_only=True)
|
||||||
|
labels = serializers.SerializerMethodField(read_only=True)
|
||||||
total_estimates = serializers.IntegerField(read_only=True)
|
total_estimates = serializers.IntegerField(read_only=True)
|
||||||
completed_estimates = serializers.IntegerField(read_only=True)
|
completed_estimates = serializers.IntegerField(read_only=True)
|
||||||
started_estimates = serializers.IntegerField(read_only=True)
|
started_estimates = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||||
|
|
||||||
def get_assignees(self, obj):
|
def get_assignees(self, obj):
|
||||||
members = [
|
members = [
|
||||||
{
|
{
|
||||||
@ -44,6 +47,24 @@ class CycleSerializer(BaseSerializer):
|
|||||||
unique_list = [dict(item) for item in unique_objects]
|
unique_list = [dict(item) for item in unique_objects]
|
||||||
|
|
||||||
return unique_list
|
return unique_list
|
||||||
|
|
||||||
|
def get_labels(self, obj):
|
||||||
|
labels = [
|
||||||
|
{
|
||||||
|
"name": label.name,
|
||||||
|
"color": label.color,
|
||||||
|
"id": label.id,
|
||||||
|
}
|
||||||
|
for issue_cycle in obj.issue_cycle.all()
|
||||||
|
for label in issue_cycle.issue.labels.all()
|
||||||
|
]
|
||||||
|
# Use a set comprehension to return only the unique objects
|
||||||
|
unique_objects = {frozenset(item.items()) for item in labels}
|
||||||
|
|
||||||
|
# Convert the set back to a list of dictionaries
|
||||||
|
unique_list = [dict(item) for item in unique_objects]
|
||||||
|
|
||||||
|
return unique_list
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Cycle
|
model = Cycle
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
@ -14,6 +15,7 @@ from django.db.models import (
|
|||||||
Prefetch,
|
Prefetch,
|
||||||
Sum,
|
Sum,
|
||||||
)
|
)
|
||||||
|
from django.db.models.functions import TruncDate
|
||||||
from django.core import serializers
|
from django.core import serializers
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
@ -41,10 +43,12 @@ from plane.db.models import (
|
|||||||
CycleFavorite,
|
CycleFavorite,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
|
Label,
|
||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.utils.grouper import group_results
|
from plane.utils.grouper import group_results
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
|
|
||||||
|
|
||||||
class CycleViewSet(BaseViewSet):
|
class CycleViewSet(BaseViewSet):
|
||||||
@ -148,12 +152,18 @@ class CycleViewSet(BaseViewSet):
|
|||||||
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
|
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_cycle__issue__labels",
|
||||||
|
queryset=Label.objects.only("name", "color", "id").distinct(),
|
||||||
|
)
|
||||||
|
)
|
||||||
.order_by("-is_favorite", "name")
|
.order_by("-is_favorite", "name")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
def list(self, request, slug, project_id):
|
def list(self, request, slug, project_id):
|
||||||
try:
|
# try:
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
cycle_view = request.GET.get("cycle_view", False)
|
cycle_view = request.GET.get("cycle_view", False)
|
||||||
if not cycle_view:
|
if not cycle_view:
|
||||||
@ -167,15 +177,83 @@ class CycleViewSet(BaseViewSet):
|
|||||||
return Response(
|
return Response(
|
||||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||||
)
|
)
|
||||||
|
|
||||||
# Current Cycle
|
# Current Cycle
|
||||||
if cycle_view == "current":
|
if cycle_view == "current":
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
start_date__lte=timezone.now(),
|
start_date__lte=timezone.now(),
|
||||||
end_date__gte=timezone.now(),
|
end_date__gte=timezone.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data = CycleSerializer(queryset, many=True).data
|
||||||
|
|
||||||
|
if len(data):
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=data[0]["id"],
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(first_name=F("assignees__first_name"))
|
||||||
|
.annotate(last_name=F("assignees__last_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.values("first_name", "last_name", "assignee_id")
|
||||||
|
.annotate(total_issues=Count("assignee_id"))
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"assignee_id",
|
||||||
|
filter=Q(completed_at__isnull=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"assignee_id",
|
||||||
|
filter=Q(completed_at__isnull=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("first_name", "last_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=data[0]["id"],
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(total_issues=Count("label_id"))
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"label_id",
|
||||||
|
filter=Q(completed_at__isnull=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"label_id",
|
||||||
|
filter=Q(completed_at__isnull=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
data[0]["distribution"] = {
|
||||||
|
"assignees": assignee_distribution,
|
||||||
|
"labels": label_distribution,
|
||||||
|
"completion_chart": {},
|
||||||
|
}
|
||||||
|
if data[0]["start_date"] and data[0]["end_date"]:
|
||||||
|
data[0]["distribution"]["completion_chart"] = burndown_plot(
|
||||||
|
queryset=queryset.first(),
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
cycle_id=data[0]["id"],
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
data, status=status.HTTP_200_OK
|
||||||
)
|
)
|
||||||
|
|
||||||
# Upcoming Cycles
|
# Upcoming Cycles
|
||||||
@ -198,6 +276,7 @@ class CycleViewSet(BaseViewSet):
|
|||||||
end_date=None,
|
end_date=None,
|
||||||
start_date=None,
|
start_date=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||||
)
|
)
|
||||||
@ -214,12 +293,12 @@ class CycleViewSet(BaseViewSet):
|
|||||||
return Response(
|
return Response(
|
||||||
{"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST
|
{"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
except Exception as e:
|
# except Exception as e:
|
||||||
capture_exception(e)
|
# print(e)
|
||||||
return Response(
|
# return Response(
|
||||||
{"error": "Something went wrong please try again later"},
|
# {"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
# status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
# )
|
||||||
|
|
||||||
def create(self, request, slug, project_id):
|
def create(self, request, slug, project_id):
|
||||||
try:
|
try:
|
||||||
@ -282,6 +361,86 @@ class CycleViewSet(BaseViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def retrieve(self, request, slug, project_id, pk):
|
||||||
|
try:
|
||||||
|
queryset = self.get_queryset().get(pk=pk)
|
||||||
|
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(first_name=F("assignees__first_name"))
|
||||||
|
.annotate(last_name=F("assignees__last_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.values("first_name", "last_name", "assignee_id")
|
||||||
|
.annotate(total_issues=Count("assignee_id"))
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"assignee_id",
|
||||||
|
filter=Q(completed_at__isnull=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"assignee_id",
|
||||||
|
filter=Q(completed_at__isnull=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("first_name", "last_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(total_issues=Count("label_id"))
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"label_id",
|
||||||
|
filter=Q(completed_at__isnull=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"label_id",
|
||||||
|
filter=Q(completed_at__isnull=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
data = CycleSerializer(queryset).data
|
||||||
|
data["distribution"] = {
|
||||||
|
"assignees": assignee_distribution,
|
||||||
|
"labels": label_distribution,
|
||||||
|
"completion_chart": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if queryset.start_date and queryset.end_date:
|
||||||
|
data["distribution"]["completion_chart"] = burndown_plot(
|
||||||
|
queryset=queryset, slug=slug, project_id=project_id, cycle_id=pk
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
data,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CycleIssueViewSet(BaseViewSet):
|
class CycleIssueViewSet(BaseViewSet):
|
||||||
serializer_class = CycleIssueSerializer
|
serializer_class = CycleIssueSerializer
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
# Django import
|
# Django import
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.functions import TruncDate
|
||||||
from django.db.models import Count, F, Sum, Value, Case, When, CharField
|
from django.db.models import Count, F, Sum, Value, Case, When, CharField
|
||||||
from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Concat
|
from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Concat
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from plane.db.models import Issue
|
||||||
|
|
||||||
|
|
||||||
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
||||||
|
|
||||||
@ -74,3 +79,44 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
|||||||
else:
|
else:
|
||||||
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0])))
|
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0])))
|
||||||
return sorted_data
|
return sorted_data
|
||||||
|
|
||||||
|
|
||||||
|
def burndown_plot(queryset, slug, project_id, cycle_id):
|
||||||
|
# Get all dates between the two dates
|
||||||
|
date_range = [
|
||||||
|
queryset.start_date + timedelta(days=x)
|
||||||
|
for x in range((queryset.end_date - queryset.start_date).days + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
chart_data = {str(date): 0 for date in date_range}
|
||||||
|
|
||||||
|
# Total Issues in Cycle
|
||||||
|
total_issues = queryset.total_issues
|
||||||
|
|
||||||
|
completed_issues_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
issue_cycle__cycle_id=cycle_id,
|
||||||
|
)
|
||||||
|
.annotate(date=TruncDate("completed_at"))
|
||||||
|
.values("date")
|
||||||
|
.annotate(total_completed=Count("id"))
|
||||||
|
.values("date", "total_completed")
|
||||||
|
.order_by("date")
|
||||||
|
)
|
||||||
|
|
||||||
|
for date in date_range:
|
||||||
|
cumulative_pending_issues = total_issues
|
||||||
|
total_completed = 0
|
||||||
|
total_completed = sum(
|
||||||
|
[
|
||||||
|
item["total_completed"]
|
||||||
|
for item in completed_issues_distribution
|
||||||
|
if item["date"] is not None and item["date"] <= date
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cumulative_pending_issues -= total_completed
|
||||||
|
chart_data[str(date)] = cumulative_pending_issues
|
||||||
|
|
||||||
|
return chart_data
|
Loading…
Reference in New Issue
Block a user