From 634e3a22d0bac0583eb289129b2498ad246c93b3 Mon Sep 17 00:00:00 2001 From: blockgroot <170620375+blockgroot@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:20:09 +0530 Subject: [PATCH 1/2] fix: bucket cycle/module analytics chart by Issue.created_at, not join-table created_at work_item_completion_chart() reassigned its queryset to a bare CycleIssue/ModuleIssue id projection when scoped to a cycle or module, so grouping by created_at__date resolved against the join table's created_at (when the issue was added to the cycle/module) instead of the issue's own creation date. The "work items created" line on cycle/module charts was bucketed under the wrong date. Fix by keeping the queryset as Issue.issue_objects.filter(id__in=...), matching the pattern already used a few lines up in get_work_items_stats(), and correcting the completed_count filter from issue__state__group to state__group to match the corrected queryset shape. Adds a regression test that builds a real Workspace/Project/Cycle/Issue/ CycleIssue graph and proves the date-bucketing mismatch before the fix and the correct behavior after. Fixes GIT-229 --- .../app/views/analytic/project_analytics.py | 6 +- .../views/test_project_analytics_chart.py | 84 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 apps/api/plane/tests/unit/views/test_project_analytics_chart.py diff --git a/apps/api/plane/app/views/analytic/project_analytics.py b/apps/api/plane/app/views/analytic/project_analytics.py index 064e556a2cd..9fb4b275750 100644 --- a/apps/api/plane/app/views/analytic/project_analytics.py +++ b/apps/api/plane/app/views/analytic/project_analytics.py @@ -199,7 +199,7 @@ def work_item_completion_chart(self, project_id, cycle_id=None, module_id=None) end_date = cycle.end_date.date() else: return {"data": [], "schema": {}} - queryset = cycle_issues + queryset = Issue.issue_objects.filter(id__in=cycle_issues) elif module_id is not None: module_issues = ModuleIssue.objects.filter(**self.filters["base_filters"], module_id=module_id).values_list( @@ -211,7 +211,7 @@ def work_item_completion_chart(self, project_id, cycle_id=None, module_id=None) end_date = module.target_date else: return {"data": [], "schema": {}} - queryset = module_issues + queryset = Issue.issue_objects.filter(id__in=module_issues) else: project = Project.objects.filter(id=project_id).first() @@ -226,7 +226,7 @@ def work_item_completion_chart(self, project_id, cycle_id=None, module_id=None) queryset.values("created_at__date") .annotate( created_count=Count("id"), - completed_count=Count("id", filter=Q(issue__state__group="completed")), + completed_count=Count("id", filter=Q(state__group="completed")), ) .order_by("created_at__date") ) diff --git a/apps/api/plane/tests/unit/views/test_project_analytics_chart.py b/apps/api/plane/tests/unit/views/test_project_analytics_chart.py new file mode 100644 index 00000000000..65812805173 --- /dev/null +++ b/apps/api/plane/tests/unit/views/test_project_analytics_chart.py @@ -0,0 +1,84 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +""" +Regression test for the cycle/module work-item completion chart. + +``ProjectAdvanceAnalyticsChartEndpoint.work_item_completion_chart`` reassigns +its queryset to a bare ``CycleIssue``/``ModuleIssue`` id list +(``values_list("issue_id", flat=True)``) when a ``cycle_id``/``module_id`` is +given, then buckets the chart with ``queryset.values("created_at__date")``. +Because the queryset is still shaped like ``CycleIssue``, not ``Issue``, that +groups by *when the issue was added to the cycle* instead of *when the issue +was created* -- so the "work items created" line on a cycle/module chart is +keyed to the wrong date entirely. + +See: https://github.com/makeplane/plane/issues/9177 +""" + +from datetime import timedelta + +import pytest +from django.utils import timezone + +from plane.app.views.analytic.project_analytics import ProjectAdvanceAnalyticsChartEndpoint +from plane.db.models import Cycle, CycleIssue, Issue, Project, ProjectMember, State +from plane.utils.date_utils import get_analytics_filters + + +@pytest.mark.unit +@pytest.mark.django_db +class TestCycleWorkItemCompletionChartDateBucketing: + def _build_view(self, workspace, user): + view = ProjectAdvanceAnalyticsChartEndpoint() + view.filters = get_analytics_filters(slug=workspace.slug, user=user, type="chart") + return view + + def test_cycle_chart_buckets_by_issue_created_at_not_cycle_issue_created_at(self, workspace, create_user): + project = Project.objects.create(name="Project 1", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, is_active=True) + state = State.objects.create(project=project, name="Done", color="#000000", group="completed") + + today = timezone.now().date() + issue_created_on = today - timedelta(days=10) + added_to_cycle_on = today - timedelta(days=2) + + cycle = Cycle.objects.create( + project=project, + name="Cycle 1", + owned_by=create_user, + start_date=timezone.now() - timedelta(days=15), + end_date=timezone.now(), + ) + issue = Issue.objects.create(project=project, name="Issue 1", state=state) + # created_at is auto_now_add — bypass save() to pin it to a controlled date. + Issue.objects.filter(pk=issue.pk).update( + created_at=timezone.make_aware(timezone.datetime.combine(issue_created_on, timezone.datetime.min.time())) + ) + + cycle_issue = CycleIssue.objects.create(project=project, cycle=cycle, issue=issue) + CycleIssue.objects.filter(pk=cycle_issue.pk).update( + created_at=timezone.make_aware( + timezone.datetime.combine(added_to_cycle_on, timezone.datetime.min.time()) + ) + ) + + view = self._build_view(workspace, create_user) + result = view.work_item_completion_chart(project_id=project.id, cycle_id=cycle.id) + + by_key = {entry["key"]: entry for entry in result["data"]} + issue_created_key = issue_created_on.strftime("%Y-%m-%d") + added_to_cycle_key = added_to_cycle_on.strftime("%Y-%m-%d") + + assert by_key[issue_created_key]["created_issues"] == 1, ( + "expected the cycle chart to bucket the work item under the date it was " + f"actually created ({issue_created_key}), but got " + f"{by_key[issue_created_key]['created_issues']} -- it is bucketing by " + "CycleIssue.created_at (when the issue was added to the cycle) instead " + "of Issue.created_at" + ) + assert by_key[added_to_cycle_key]["created_issues"] == 0, ( + f"the work item leaked into the {added_to_cycle_key} bucket (the date it " + "was added to the cycle) instead of staying under its own creation date" + ) From 398b55ec9b4c22eeeb3441583628d52bfba595aa Mon Sep 17 00:00:00 2001 From: blockgroot <170620375+blockgroot@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:28:18 +0530 Subject: [PATCH 2/2] fix: preserve project-scoped queryset when applying cycle/module id filter Per CodeRabbit review on #9532: filter the existing project-scoped queryset (base_filters + project_id + select_related/prefetch_related) by id__in=cycle_issues/module_issues instead of rebuilding a fresh Issue.issue_objects queryset, which silently dropped the project scoping and query optimizations. --- apps/api/plane/app/views/analytic/project_analytics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/app/views/analytic/project_analytics.py b/apps/api/plane/app/views/analytic/project_analytics.py index 9fb4b275750..0ea87b3d645 100644 --- a/apps/api/plane/app/views/analytic/project_analytics.py +++ b/apps/api/plane/app/views/analytic/project_analytics.py @@ -199,7 +199,7 @@ def work_item_completion_chart(self, project_id, cycle_id=None, module_id=None) end_date = cycle.end_date.date() else: return {"data": [], "schema": {}} - queryset = Issue.issue_objects.filter(id__in=cycle_issues) + queryset = queryset.filter(id__in=cycle_issues) elif module_id is not None: module_issues = ModuleIssue.objects.filter(**self.filters["base_filters"], module_id=module_id).values_list( @@ -211,7 +211,7 @@ def work_item_completion_chart(self, project_id, cycle_id=None, module_id=None) end_date = module.target_date else: return {"data": [], "schema": {}} - queryset = Issue.issue_objects.filter(id__in=module_issues) + queryset = queryset.filter(id__in=module_issues) else: project = Project.objects.filter(id=project_id).first()