Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions apps/api/plane/app/views/workspace/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,29 @@
# See the LICENSE file for details.

# Django imports
from django.db.models import Prefetch, Q, Count
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
Count,
Exists,
FloatField,
OuterRef,
Prefetch,
Q,
Subquery,
Sum,
UUIDField,
Value,
)
from django.db.models.functions import Cast, Coalesce

# Third party modules
from rest_framework import status
from rest_framework.response import Response

# Module imports
from plane.app.views.base import BaseAPIView
from plane.db.models import Module, ModuleLink
from plane.db.models import Issue, Module, ModuleLink, UserFavorite
from plane.app.permissions import WorkspaceViewerPermission
from plane.app.serializers.module import ModuleSerializer

Expand All @@ -20,6 +34,37 @@ class WorkspaceModulesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]

def get(self, request, slug):
# Subqueries for the computed fields that ModuleSerializer exposes.
# These mirror ModuleViewSet.get_queryset (the project-scoped endpoint)
# so the same serializer is fed the same annotations here.
favorite_subquery = UserFavorite.objects.filter(
user=request.user,
entity_type="module",
entity_identifier=OuterRef("pk"),
workspace__slug=slug,
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_module__module_id=OuterRef("pk"),
issue_module__deleted_at__isnull=True,
)
.values("issue_module__module_id")
.annotate(total_estimate_points=Sum(Cast("estimate_point__value", FloatField())))
.values("total_estimate_points")[:1]
)
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_module__module_id=OuterRef("pk"),
issue_module__deleted_at__isnull=True,
)
.values("issue_module__module_id")
.annotate(completed_estimate_points=Sum(Cast("estimate_point__value", FloatField())))
.values("completed_estimate_points")[:1]
)

modules = (
Module.objects.filter(workspace__slug=slug)
.select_related("project")
Expand All @@ -33,6 +78,34 @@ def get(self, request, slug):
queryset=ModuleLink.objects.select_related("module", "created_by"),
)
)
# Computed fields required by ModuleSerializer. Without these
# annotations `member_ids`, `is_favorite` and the estimate-point
# fields are resolved per row while serializing, which turns the
# list into an N+1 (one members lookup per module).
.annotate(is_favorite=Exists(favorite_subquery))
.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())),
)
)
Comment on lines +86 to +98

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect Module.members M2M + ModuleMember through model, and compare member_ids annotation with ModuleViewSet.
fd -t f 'module.py' apps/api/plane/db/models | while read -r f; do
  echo "=== $f ==="
  rg -nP -C3 '(class Module\b|class ModuleMember\b|members\s*=\s*models\.ManyToManyField|through=|related_name=)' "$f"
done

echo "===== ModuleViewSet member_ids / through-name usage ====="
rg -nP -C4 'member_ids\s*=' apps/api/plane/app/views
rg -nP -C1 'module_members|modulemember' apps/api/plane/app/views/module apps/api/plane/db/models

Repository: makeplane/plane

Length of output: 6397


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== ModuleMember model and inheritance ==="
sed -n '1,220p' apps/api/plane/db/models/module.py | cat -n

echo "=== Workspace module view ==="
sed -n '1,180p' apps/api/plane/app/views/workspace/module.py | cat -n

echo "=== Base module view ==="
sed -n '250,310p' apps/api/plane/app/views/module/base.py | cat -n

echo "=== Archive module view ==="
sed -n '235,265p' apps/api/plane/app/views/module/archive.py | cat -n

echo "=== Search for ModuleMember reverse-name usage ==="
rg -n "modulemember__|module_members__|modulemember_set|ModuleMember" apps/api/plane -g '!**/__pycache__/**'

Repository: makeplane/plane

Length of output: 22962


Aggregate from the through model on one join. members__id plus modulemember__deleted_at can split across two ModuleMember joins, so deleted memberships may still appear in member_ids. Switch the aggregate to the ModuleMember path and keep the soft-delete filter there.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/app/views/workspace/module.py` around lines 86 - 98, The
member_ids aggregation is currently joining through members__id and
modulemember__deleted_at separately, which can allow soft-deleted memberships to
leak into the result. Update the annotate/ArrayAgg in the module query to
aggregate directly from the ModuleMember path, keeping the
deleted_at__isnull=True filter on that same join and preserving the distinct
UUID list behavior.

.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point), Value(0, output_field=FloatField())
)
)
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point), Value(0, output_field=FloatField())
)
)
.annotate(
total_issues=Count(
"issue_module",
Expand Down