jira/apps/api/plane/app/views/workspace/module.py

133 lines
4.9 KiB
Python
Raw Permalink Normal View History

# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Prefetch, Q, Count, UUIDField, Value
from django.db.models.functions import Coalesce
# Third party modules
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.views.base import BaseAPIView
2024-11-21 12:12:44 +00:00
from plane.db.models import Module, ModuleLink
from plane.app.permissions import WorkspaceViewerPermission
from plane.app.serializers.module import ModuleSerializer
2024-11-21 12:12:44 +00:00
class WorkspaceModulesEndpoint(BaseAPIView):
2024-11-21 12:12:44 +00:00
permission_classes = [WorkspaceViewerPermission]
def get(self, request, slug):
modules = (
[WEB-8068] fix: scope workspace cycles/modules listing to project membership (#9373) * [WEB-8068] fix: scope workspace cycles/modules listing to project membership WorkspaceCyclesEndpoint and WorkspaceModulesEndpoint are guarded only by WorkspaceViewerPermission (any active workspace member) and filtered by workspace__slug alone, letting any workspace member enumerate cycle/module metadata (names, dates, issue counts) of private projects they are not a member of (GHSA-wcc5-qgfr-8g9c). Restrict both querysets to projects the requesting user is an active member of, mirroring WorkspaceStatesEndpoint / WorkspaceLabelsEndpoint: project__project_projectmember__member=request.user project__project_projectmember__is_active=True project__archived_at__isnull=True Add .distinct() to the Module query (the member join is to-many; Cycle already had it). Contract regression tests cover hidden cycles/modules for a non-project member, the positive project-member path, and no row duplication; fail-before verified. Co-authored-by: Plane AI <noreply@plane.so> * [WEB-8068] refactor: drop unnecessary distinct() from module listing Address Copilot review: the project-membership join is filtered to request.user, and ProjectMember has a unique constraint on (project, member) where deleted_at IS NULL, so the join yields at most one row per project and cannot duplicate Module rows. distinct() was dead weight (and a planner cost for large workspaces). Matches the reference WorkspaceStates/WorkspaceLabels endpoints, which use no distinct(). Also drop the distinct-focused contract test: adding a *different* project member never fans out the request.user-filtered join, so it would pass with or without distinct() — misleading coverage. Co-authored-by: Plane AI <noreply@plane.so> --------- Co-authored-by: Plane AI <noreply@plane.so>
2026-07-16 10:30:08 +00:00
Module.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("project")
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.filter(archived_at__isnull=True)
.prefetch_related(
Prefetch(
"link_module",
2024-11-21 12:12:44 +00:00
queryset=ModuleLink.objects.select_related("module", "created_by"),
)
)
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
2024-11-21 12:12:44 +00:00
)
)
.annotate(
completed_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
cancelled_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
started_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
unstarted_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
backlog_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
member_ids=Coalesce(
ArrayAgg(
"members__id",
distinct=True,
filter=Q(
members__id__isnull=False,
modulemember__deleted_at__isnull=True,
),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
)
serializer = ModuleSerializer(modules, many=True).data
return Response(serializer, status=status.HTTP_200_OK)