Skip to content

Commit cad05d9

Browse files
Backend: Optimize participant serializers and views to reduce N+1 queries (#4956)
1 parent 0f70305 commit cad05d9

File tree

4 files changed

+280
-6
lines changed

4 files changed

+280
-6
lines changed

apps/participants/serializers.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from accounts.models import Profile
21
from accounts.serializers import UserProfileSerializer
32
from challenges.models import Challenge
43
from challenges.serializers import ChallengeSerializer
@@ -95,7 +94,9 @@ def get_email(self, obj):
9594
return obj.user.email
9695

9796
def get_profile(self, obj):
98-
user_profile = Profile.objects.get(user=obj.user)
97+
# Use user.profile directly - with select_related this won't trigger extra queries
98+
# Falls back to lazy loading if not prefetched
99+
user_profile = obj.user.profile
99100
serializer = UserProfileSerializer(user_profile)
100101
return serializer.data
101102

@@ -113,7 +114,9 @@ class Meta:
113114
fields = ("id", "team_name", "created_by", "members", "team_url")
114115

115116
def get_members(self, obj):
116-
participants = Participant.objects.filter(team__pk=obj.id)
117+
# Use prefetched participants via related manager (works with or
118+
# without prefetch)
119+
participants = obj.participants.all()
117120
serializer = ParticipantSerializer(participants, many=True)
118121
return serializer.data
119122

apps/participants/views.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
is_user_in_blocked_email_domains,
1010
)
1111
from django.contrib.auth.models import User
12+
from django.db.models import Prefetch
1213
from drf_spectacular.utils import (
1314
OpenApiParameter,
1415
OpenApiResponse,
@@ -65,9 +66,18 @@ def participant_team_list(request):
6566
participant_teams_id = Participant.objects.filter(
6667
user_id=request.user
6768
).values_list("team_id", flat=True)
68-
participant_teams = ParticipantTeam.objects.filter(
69-
id__in=participant_teams_id
70-
).order_by("-id")
69+
participant_teams = (
70+
ParticipantTeam.objects.filter(id__in=participant_teams_id)
71+
.prefetch_related(
72+
Prefetch(
73+
"participants",
74+
queryset=Participant.objects.select_related(
75+
"user", "user__profile"
76+
),
77+
)
78+
)
79+
.order_by("-id")
80+
)
7181
filtered_teams = ParticipantTeamsFilter(
7282
request.GET, queryset=participant_teams
7383
)

tests/unit/participants/test_serializers.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
from django.contrib.auth.models import User
2+
from django.db.models import Prefetch
23
from django.test import RequestFactory, TestCase
4+
from participants.models import Participant, ParticipantTeam
35
from participants.serializers import (
46
ChallengeParticipantSerializer,
57
InviteParticipantToTeamSerializer,
8+
ParticipantSerializer,
9+
ParticipantTeamDetailSerializer,
610
)
711
from rest_framework import serializers
812

@@ -52,3 +56,154 @@ def test_validate_email_self_invite(self):
5256
serializers.ValidationError, "A participant cannot invite himself"
5357
):
5458
serializer.validate_email("test@example.com")
59+
60+
61+
class TestParticipantTeamDetailSerializer(TestCase):
62+
def setUp(self):
63+
self.user1 = User.objects.create(
64+
username="user1",
65+
email="user1@test.com",
66+
first_name="First1",
67+
last_name="Last1",
68+
)
69+
self.user2 = User.objects.create(
70+
username="user2",
71+
email="user2@test.com",
72+
first_name="First2",
73+
last_name="Last2",
74+
)
75+
76+
self.team = ParticipantTeam.objects.create(
77+
team_name="Test Team",
78+
created_by=self.user1,
79+
)
80+
81+
self.participant1 = Participant.objects.create(
82+
user=self.user1,
83+
status=Participant.SELF,
84+
team=self.team,
85+
)
86+
self.participant2 = Participant.objects.create(
87+
user=self.user2,
88+
status=Participant.ACCEPTED,
89+
team=self.team,
90+
)
91+
92+
def test_get_members_returns_all_team_members(self):
93+
"""Test that get_members returns all participants of a team."""
94+
serializer = ParticipantTeamDetailSerializer(self.team)
95+
data = serializer.data
96+
97+
self.assertEqual(len(data["members"]), 2)
98+
member_names = [m["member_name"] for m in data["members"]]
99+
self.assertIn("user1", member_names)
100+
self.assertIn("user2", member_names)
101+
102+
def test_get_members_with_prefetched_data(self):
103+
"""
104+
Test that get_members works correctly with prefetched participants.
105+
This verifies the N+1 query fix works when data is prefetched.
106+
"""
107+
# Query with prefetch_related (as done in the fixed view)
108+
team_with_prefetch = (
109+
ParticipantTeam.objects.filter(pk=self.team.pk)
110+
.prefetch_related(
111+
Prefetch(
112+
"participants",
113+
queryset=Participant.objects.select_related(
114+
"user", "user__profile"
115+
),
116+
)
117+
)
118+
.first()
119+
)
120+
121+
serializer = ParticipantTeamDetailSerializer(team_with_prefetch)
122+
data = serializer.data
123+
124+
self.assertEqual(len(data["members"]), 2)
125+
member_names = [m["member_name"] for m in data["members"]]
126+
self.assertIn("user1", member_names)
127+
self.assertIn("user2", member_names)
128+
129+
def test_serializer_includes_all_expected_fields(self):
130+
"""Test that the serializer includes all expected fields."""
131+
serializer = ParticipantTeamDetailSerializer(self.team)
132+
data = serializer.data
133+
134+
self.assertIn("id", data)
135+
self.assertIn("team_name", data)
136+
self.assertIn("created_by", data)
137+
self.assertIn("members", data)
138+
self.assertIn("team_url", data)
139+
140+
# Check member fields
141+
member = data["members"][0]
142+
self.assertIn("member_name", member)
143+
self.assertIn("first_name", member)
144+
self.assertIn("last_name", member)
145+
self.assertIn("email", member)
146+
self.assertIn("status", member)
147+
self.assertIn("profile", member)
148+
149+
150+
class TestParticipantSerializer(TestCase):
151+
def setUp(self):
152+
self.user = User.objects.create(
153+
username="testuser",
154+
email="testuser@test.com",
155+
first_name="Test",
156+
last_name="User",
157+
)
158+
self.team = ParticipantTeam.objects.create(
159+
team_name="Test Team",
160+
created_by=self.user,
161+
)
162+
self.participant = Participant.objects.create(
163+
user=self.user,
164+
status=Participant.SELF,
165+
team=self.team,
166+
)
167+
168+
def test_get_profile_returns_user_profile(self):
169+
"""
170+
Test that get_profile correctly returns the user's profile.
171+
Profile is auto-created by a signal when User is created.
172+
"""
173+
serializer = ParticipantSerializer(self.participant)
174+
data = serializer.data
175+
176+
self.assertIn("profile", data)
177+
self.assertIsInstance(data["profile"], dict)
178+
# Profile should have these fields from UserProfileSerializer
179+
self.assertIn("affiliation", data["profile"])
180+
self.assertIn("github_url", data["profile"])
181+
self.assertIn("google_scholar_url", data["profile"])
182+
self.assertIn("linkedin_url", data["profile"])
183+
184+
def test_get_profile_with_prefetched_profile(self):
185+
"""
186+
Test that get_profile works correctly when profile is prefetched
187+
via select_related on user__profile.
188+
"""
189+
# Query participant with select_related (as done in the fixed view)
190+
participant_with_prefetch = Participant.objects.select_related(
191+
"user", "user__profile"
192+
).get(pk=self.participant.pk)
193+
194+
serializer = ParticipantSerializer(participant_with_prefetch)
195+
data = serializer.data
196+
197+
self.assertIn("profile", data)
198+
self.assertIsInstance(data["profile"], dict)
199+
200+
def test_member_fields_are_correct(self):
201+
"""Test that member fields return correct values."""
202+
serializer = ParticipantSerializer(self.participant)
203+
data = serializer.data
204+
205+
self.assertEqual(data["member_name"], "testuser")
206+
self.assertEqual(data["first_name"], "Test")
207+
self.assertEqual(data["last_name"], "User")
208+
self.assertEqual(data["email"], "testuser@test.com")
209+
self.assertEqual(data["status"], Participant.SELF)

tests/unit/participants/test_views.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,112 @@ def test_get_challenge(self):
119119
self.assertEqual(response.data["results"], expected)
120120
self.assertEqual(response.status_code, status.HTTP_200_OK)
121121

122+
def test_no_n_plus_one_queries_for_participant_teams(self):
123+
"""
124+
Test that the participant_team_list endpoint doesn't have N+1 query issues.
125+
The number of queries should remain bounded regardless of:
126+
- Number of participant teams
127+
- Number of participants per team
128+
- Number of user profiles
129+
130+
Before the fix, queries grew as:
131+
- 1 query per team for participants
132+
- 1 query per participant for user
133+
- 1 query per participant for profile
134+
135+
After the fix using prefetch_related with select_related:
136+
- 1 query for teams
137+
- 1 query for participants with users and profiles (prefetched)
138+
"""
139+
# Create additional teams with multiple participants
140+
additional_teams = []
141+
for i in range(3):
142+
# Create a new user for each team
143+
team_creator = User.objects.create(
144+
username=f"team_creator_{i}",
145+
email=f"team_creator_{i}@platform.com",
146+
password="password",
147+
)
148+
EmailAddress.objects.create(
149+
user=team_creator,
150+
email=f"team_creator_{i}@platform.com",
151+
primary=True,
152+
verified=True,
153+
)
154+
155+
# Create the team
156+
team = ParticipantTeam.objects.create(
157+
team_name=f"Test Team {i}",
158+
created_by=team_creator,
159+
)
160+
additional_teams.append(team)
161+
162+
# Add the creator as a participant
163+
Participant.objects.create(
164+
user=team_creator,
165+
status=Participant.SELF,
166+
team=team,
167+
)
168+
169+
# Add the main user to each team (so they show up in the response)
170+
Participant.objects.create(
171+
user=self.user,
172+
status=Participant.ACCEPTED,
173+
team=team,
174+
)
175+
176+
# Add additional participants to each team
177+
for j in range(2):
178+
member = User.objects.create(
179+
username=f"team_{i}_member_{j}",
180+
email=f"team_{i}_member_{j}@platform.com",
181+
password="password",
182+
)
183+
EmailAddress.objects.create(
184+
user=member,
185+
email=f"team_{i}_member_{j}@platform.com",
186+
primary=True,
187+
verified=True,
188+
)
189+
Participant.objects.create(
190+
user=member,
191+
status=Participant.ACCEPTED,
192+
team=team,
193+
)
194+
195+
# Now we have:
196+
# - 1 original team with 2 participants
197+
# - 3 additional teams with 4 participants each (creator + main user + 2 members)
198+
# Total: 4 teams, 14 participants
199+
200+
# Capture queries during the request
201+
with CaptureQueriesContext(connection) as context:
202+
response = self.client.get(self.url, {})
203+
204+
query_count = len(context)
205+
206+
self.assertEqual(response.status_code, status.HTTP_200_OK)
207+
208+
# Should return 4 teams (original + 3 new ones where user is a
209+
# participant)
210+
self.assertEqual(len(response.data["results"]), 4)
211+
212+
# With the N+1 fix using prefetch_related and select_related:
213+
# - Queries should be bounded regardless of team/participant count
214+
# - Without the fix, we'd have: 4 (teams) + 14 (users) + 14 (profiles) = 32+ queries
215+
# - With the fix, we expect roughly:
216+
# - 1 for participant IDs
217+
# - 1 for teams
218+
# - 1 for participants with users and profiles (prefetched)
219+
# - Plus auth/session overhead (~5-10 queries)
220+
# Total should be under 20 queries
221+
self.assertLessEqual(
222+
query_count,
223+
20,
224+
f"Too many queries ({query_count}) - possible N+1 issue. "
225+
f"Queries: {[q['sql'][:100] for q in context.captured_queries]}",
226+
)
227+
122228

123229
class CreateParticipantTeamTest(BaseAPITestClass):
124230

0 commit comments

Comments
 (0)