From 6b1d20449b538cc0bf3ea01844f2d182d1933927 Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Fri, 16 Jun 2023 18:57:49 +0530 Subject: [PATCH] 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 --- apiserver/plane/api/serializers/cycle.py | 25 +++- apiserver/plane/api/views/cycle.py | 177 +++++++++++++++++++++-- apiserver/plane/utils/analytics_plot.py | 46 ++++++ 3 files changed, 237 insertions(+), 11 deletions(-) diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 5c1c68fb8..760f42dcc 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -1,3 +1,6 @@ +# Django imports +from django.db.models.functions import TruncDate + # Third party imports from rest_framework import serializers @@ -20,13 +23,13 @@ class CycleSerializer(BaseSerializer): unstarted_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True) assignees = serializers.SerializerMethodField(read_only=True) + labels = serializers.SerializerMethodField(read_only=True) total_estimates = serializers.IntegerField(read_only=True) completed_estimates = serializers.IntegerField(read_only=True) started_estimates = serializers.IntegerField(read_only=True) - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") project_detail = ProjectLiteSerializer(read_only=True, source="project") - + def get_assignees(self, obj): members = [ { @@ -44,6 +47,24 @@ class CycleSerializer(BaseSerializer): unique_list = [dict(item) for item in unique_objects] 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: model = Cycle diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 86c1002d1..f959f62b5 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -1,5 +1,6 @@ # Python imports import json +from datetime import datetime, timedelta # Django imports from django.db import IntegrityError @@ -14,6 +15,7 @@ from django.db.models import ( Prefetch, Sum, ) +from django.db.models.functions import TruncDate from django.core import serializers from django.utils import timezone from django.utils.decorators import method_decorator @@ -41,10 +43,12 @@ from plane.db.models import ( CycleFavorite, IssueLink, IssueAttachment, + Label, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters +from plane.utils.analytics_plot import burndown_plot class CycleViewSet(BaseViewSet): @@ -148,12 +152,18 @@ class CycleViewSet(BaseViewSet): 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") .distinct() ) def list(self, request, slug, project_id): - try: + # try: queryset = self.get_queryset() cycle_view = request.GET.get("cycle_view", False) if not cycle_view: @@ -167,15 +177,83 @@ class CycleViewSet(BaseViewSet): return Response( CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK ) - + # Current Cycle if cycle_view == "current": queryset = queryset.filter( start_date__lte=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( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK + data, status=status.HTTP_200_OK ) # Upcoming Cycles @@ -198,6 +276,7 @@ class CycleViewSet(BaseViewSet): end_date=None, start_date=None, ) + return Response( CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK ) @@ -214,12 +293,12 @@ class CycleViewSet(BaseViewSet): return Response( {"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST ) - except Exception as e: - capture_exception(e) - return Response( - {"error": "Something went wrong please try again later"}, - status=status.HTTP_400_BAD_REQUEST, - ) + # except Exception as e: + # print(e) + # return Response( + # {"error": "Something went wrong please try again later"}, + # status=status.HTTP_400_BAD_REQUEST, + # ) def create(self, request, slug, project_id): try: @@ -282,6 +361,86 @@ class CycleViewSet(BaseViewSet): 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): serializer_class = CycleIssueSerializer diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index 161f6497e..045e2bf26 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -1,11 +1,16 @@ # Python imports from itertools import groupby +from datetime import timedelta # Django import 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.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): @@ -74,3 +79,44 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): else: sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0]))) 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 \ No newline at end of file