From ba31fddeff3d9dd90ed90c11c8d8a6d4966ba630 Mon Sep 17 00:00:00 2001 From: Josh Rimes - Desktop Date: Fri, 4 Sep 2026 22:04:05 +0100 Subject: [PATCH 1/2] add test for group_by presence in issue call --- .../plane/tests/contract/api/test_issues.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/apps/api/plane/tests/contract/api/test_issues.py b/apps/api/plane/tests/contract/api/test_issues.py index cda57b64800..dbbc5f58fa1 100644 --- a/apps/api/plane/tests/contract/api/test_issues.py +++ b/apps/api/plane/tests/contract/api/test_issues.py @@ -94,3 +94,57 @@ def test_legitimate_order_by_still_works(self, api_key_client, workspace, projec assert response.status_code == status.HTTP_200_OK, ( f"order_by={value!r} got {response.status_code}: {response.data!r}" ) + + +@pytest.mark.contract +class TestIssueListGrouping: + def get_url(self, workspace_slug, project_id): + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/" + + @pytest.mark.django_db + def test_group_by_state_alias_returns_grouped_results(self, api_key_client, workspace, project, state, issue): + response = api_key_client.get(self.get_url(workspace.slug, project.id), {"group_by": "state"}) + + assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}" + assert response.data["grouped_by"] == "state_id" + assert response.data["sub_grouped_by"] is None + assert str(state.id) in response.data["results"] + assert response.data["results"][str(state.id)]["results"][0]["id"] == issue.id + + @pytest.mark.django_db + def test_group_by_state_id_and_sub_group_by_priority_are_supported( + self, api_key_client, workspace, project, state, issue + ): + response = api_key_client.get( + self.get_url(workspace.slug, project.id), + {"group_by": "state_id", "sub_group_by": "priority"}, + ) + + assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}" + assert response.data["grouped_by"] == "state_id" + assert response.data["sub_grouped_by"] == "priority" + assert str(state.id) in response.data["results"] + + @pytest.mark.django_db + def test_without_group_by_returns_flat_results(self, api_key_client, workspace, project, issue): + response = api_key_client.get(self.get_url(workspace.slug, project.id)) + + assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}" + assert response.data["grouped_by"] is None + assert response.data["sub_grouped_by"] is None + assert response.data["results"][0]["id"] == issue.id + + @pytest.mark.django_db + @pytest.mark.parametrize( + "query_params", + [ + {"group_by": "not_a_field"}, + {"group_by": "state", "sub_group_by": "state_id"}, + ], + ) + def test_invalid_grouping_parameters_return_bad_request( + self, api_key_client, workspace, project, issue, query_params + ): + response = api_key_client.get(self.get_url(workspace.slug, project.id), query_params) + + assert response.status_code == status.HTTP_400_BAD_REQUEST, f"Got {response.status_code}: {response.data!r}" From 8ac0463a11cf391da278c7426e53df9374782c79 Mon Sep 17 00:00:00 2001 From: Josh Rimes - Desktop Date: Fri, 4 Sep 2026 22:06:43 +0100 Subject: [PATCH 2/2] add group_by logic to issue.py --- apps/api/plane/api/views/issue.py | 63 +++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index da9edc66d66..631f8100d31 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -85,6 +85,8 @@ ISSUE_ORDER_BY_ALLOWLIST, sanitize_order_by, ) +from plane.utils.grouper import issue_group_values, issue_on_results, issue_queryset_grouper +from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from .base import BaseAPIView from plane.utils.host import base_host @@ -420,6 +422,67 @@ def get(self, request, slug, project_id): else: issue_queryset = issue_queryset.order_by(order_by_param) + group_by = {"state": "state_id"}.get(request.GET.get("group_by"), request.GET.get("group_by")) + sub_group_by = {"state": "state_id"}.get( + request.GET.get("sub_group_by"), request.GET.get("sub_group_by") + ) + + if group_by: + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + grouping_kwargs = { + "request": request, + "order_by": order_by_param, + "queryset": issue_queryset, + "total_count_queryset": total_issue_queryset, + "on_results": lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + "group_by_fields": issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + queryset=issue_queryset, + ), + "group_by_field_name": group_by, + "count_filter": Q( + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + } + + if sub_group_by: + if group_by == sub_group_by: + return Response( + {"error": "Group by and sub group by cannot have same parameters"}, + status=status.HTTP_400_BAD_REQUEST, + ) + grouping_kwargs.update( + { + "paginator_cls": SubGroupedOffsetPaginator, + "sub_group_by_fields": issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + queryset=issue_queryset, + ), + "sub_group_by_field_name": sub_group_by, + } + ) + else: + grouping_kwargs["paginator_cls"] = GroupedOffsetPaginator + + return self.paginate(**grouping_kwargs) + return self.paginate( request=request, queryset=(issue_queryset),