Skip to content

Commit c7b8c81

Browse files
Backend: Optimize participant team detail view to avoid N+1 queries using select_related and prefetch_related (#4990)
1 parent 573b853 commit c7b8c81

File tree

3 files changed

+83
-1
lines changed

3 files changed

+83
-1
lines changed

apps/challenges/views.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
from django.core.files.base import ContentFile
5757
from django.core.files.uploadedfile import SimpleUploadedFile
5858
from django.db import transaction
59+
from django.db.models import Prefetch
5960
from django.http import HttpResponse
6061
from django.utils import timezone
6162
from drf_spectacular.utils import (
@@ -415,7 +416,18 @@ def participant_team_detail_for_challenge(request, challenge_pk):
415416
participant_team_pk = get_participant_team_id_of_user_for_a_challenge(
416417
request.user, challenge_pk
417418
)
418-
participant_team = get_participant_model(participant_team_pk)
419+
participant_team = (
420+
ParticipantTeam.objects.select_related("created_by")
421+
.prefetch_related(
422+
Prefetch(
423+
"participants",
424+
queryset=Participant.objects.select_related(
425+
"user", "user__profile"
426+
),
427+
)
428+
)
429+
.get(pk=participant_team_pk)
430+
)
419431
serializer = ParticipantTeamDetailSerializer(participant_team)
420432
if challenge.approved_participant_teams.filter(
421433
pk=participant_team_pk

tests/unit/challenges/test_views.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
from django.conf import settings
2929
from django.contrib.auth.models import User
3030
from django.core.files.uploadedfile import SimpleUploadedFile
31+
from django.db import connection
3132
from django.test import override_settings
33+
from django.test.utils import CaptureQueriesContext
3234
from django.urls import reverse_lazy
3335
from django.utils import timezone
3436
from hosts.models import ChallengeHost, ChallengeHostTeam
@@ -269,6 +271,42 @@ def test_team_name_for_challenge_with_participant_team_does_not_exist(
269271
self.assertEqual(response.data, expected)
270272
self.assertEqual(response.status_code, status.HTTP_406_NOT_ACCEPTABLE)
271273

274+
def test_participant_team_detail_avoids_n_plus_one_queries(self):
275+
"""
276+
Verify participant_team_detail_for_challenge uses prefetch to avoid N+1
277+
queries when serializing team members (user + profile per participant).
278+
"""
279+
# Add 4 more participants (5 total) to stress-test the N+1 fix
280+
for i in range(4):
281+
extra_user = User.objects.create(
282+
username=f"member{i}",
283+
email=f"member{i}@test.com",
284+
)
285+
Participant.objects.create(
286+
user=extra_user,
287+
status=Participant.ACCEPTED,
288+
team=self.participant_team,
289+
)
290+
291+
url = reverse_lazy(
292+
"challenges:participant_team_detail_for_challenge",
293+
kwargs={"challenge_pk": self.challenge.pk},
294+
)
295+
296+
# With prefetch (select_related/prefetch_related), query count should be
297+
# bounded. Without fix, we'd have 2*N extra queries (auth_user +
298+
# user_profile per participant) = 10+ for 5 participants.
299+
with CaptureQueriesContext(connection) as context:
300+
response = self.client.get(url, {})
301+
302+
self.assertEqual(response.status_code, status.HTTP_200_OK)
303+
self.assertEqual(len(response.data["participant_team"]["members"]), 5)
304+
self.assertLessEqual(
305+
len(context.captured_queries),
306+
12,
307+
"Query count too high - possible N+1 when loading participants",
308+
)
309+
272310

273311
class GetApprovedParticipantTeamNameTest(BaseAPITestClass):
274312
def setUp(self):

tests/unit/participants/test_serializers.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from django.contrib.auth.models import User
2+
from django.db import connection
23
from django.db.models import Prefetch
34
from django.test import RequestFactory, TestCase
5+
from django.test.utils import CaptureQueriesContext
46
from participants.models import Participant, ParticipantTeam
57
from participants.serializers import (
68
ChallengeParticipantSerializer,
@@ -164,6 +166,36 @@ def test_get_members_with_prefetched_data(self):
164166
self.assertIn("user1", member_names)
165167
self.assertIn("user2", member_names)
166168

169+
def test_get_members_with_prefetch_avoids_n_plus_one_queries(self):
170+
"""
171+
Verify ParticipantTeamDetailSerializer with prefetched data uses
172+
no additional queries when serializing members (no N+1 on user/profile).
173+
"""
174+
team_with_prefetch = (
175+
ParticipantTeam.objects.select_related("created_by")
176+
.prefetch_related(
177+
Prefetch(
178+
"participants",
179+
queryset=Participant.objects.select_related(
180+
"user", "user__profile"
181+
),
182+
)
183+
)
184+
.get(pk=self.team.pk)
185+
)
186+
187+
with CaptureQueriesContext(connection) as context:
188+
serializer = ParticipantTeamDetailSerializer(team_with_prefetch)
189+
_ = serializer.data
190+
191+
# With prefetch, serialization should use 0 extra queries (all data
192+
# already loaded). Without prefetch, we'd have 2 per participant.
193+
self.assertEqual(
194+
len(context.captured_queries),
195+
0,
196+
"Serializer should not trigger queries when data is prefetched",
197+
)
198+
167199
def test_serializer_includes_all_expected_fields(self):
168200
"""Test that the serializer includes all expected fields."""
169201
serializer = ParticipantTeamDetailSerializer(self.team)

0 commit comments

Comments
 (0)