Skip to content

Commit 9790bc7

Browse files
Backend: Enhance participant team download functionality to support JSON format and optimize queries (#4962)
* Backend: Enhance participant team download functionality to support JSON format and optimize queries * Refactor download_all_participants view to change query parameter from 'format' to 'output' for consistency. Update related unit tests to reflect this change and ensure proper functionality for JSON and CSV responses.
1 parent db59157 commit 9790bc7

File tree

4 files changed

+233
-51
lines changed

4 files changed

+233
-51
lines changed

apps/analytics/views.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -328,18 +328,38 @@ def get_challenge_phase_submission_analysis(
328328
@authentication_classes((JWTAuthentication, ExpiringTokenAuthentication))
329329
def download_all_participants(request, challenge_pk):
330330
"""
331-
Returns the List of Participant Teams and its details in csv format
331+
Returns the List of Participant Teams and its details.
332+
333+
Query Parameters:
334+
output (optional): Response format - 'json' or 'csv' (default: 'csv').
335+
336+
Returns:
337+
- If output=json: JSON response with participant teams data
338+
- If output=csv (default): CSV file download with participant teams data
332339
"""
333340
if is_user_a_host_of_challenge(
334341
user=request.user, challenge_pk=challenge_pk
335342
):
336343
challenge = get_challenge_model(challenge_pk)
337-
participant_teams = challenge.participant_teams.all().order_by(
338-
"-team_name"
339-
)
344+
# Use prefetch_related to avoid N+1 queries when accessing
345+
# participants and their user data in the serializer
346+
participant_teams = challenge.participant_teams.prefetch_related(
347+
"participants__user"
348+
).order_by("-team_name")
340349
teams = ChallengeParticipantSerializer(
341350
participant_teams, many=True, context={"request": request}
342351
)
352+
353+
response_format = request.query_params.get("output", "csv").lower()
354+
355+
if response_format == "json":
356+
response_data = {
357+
"participant_teams": teams.data,
358+
"count": len(teams.data),
359+
}
360+
return Response(response_data, status=status.HTTP_200_OK)
361+
362+
# Default: Return CSV format
343363
response = HttpResponse(content_type="text/csv")
344364
response["Content-Disposition"] = (
345365
"attachment; filename=participant_teams_{0}.csv".format(

apps/participants/serializers.py

Lines changed: 25 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from accounts.serializers import UserProfileSerializer
2-
from challenges.models import Challenge
32
from challenges.serializers import ChallengeSerializer
43
from django.contrib.auth.models import User
54
from django.utils import timezone
@@ -174,47 +173,41 @@ class ParticipantCountSerializer(serializers.Serializer):
174173

175174

176175
class ChallengeParticipantSerializer(serializers.Serializer):
176+
"""
177+
Serializer for participant teams in challenge analytics.
178+
179+
Note: For optimal performance, the queryset passed to this serializer
180+
should use prefetch_related('participants__user') to avoid N+1 queries.
181+
"""
182+
177183
team_name = serializers.SerializerMethodField()
178184
team_members = serializers.SerializerMethodField()
179185
team_members_email_ids = serializers.SerializerMethodField()
180186

181187
class Meta:
182-
model = Challenge
188+
model = ParticipantTeam
183189
fields = ("team_name", "team_members", "team_members_email_ids")
184190

185191
def get_team_name(self, obj):
186192
return obj.team_name
187193

188194
def get_team_members(self, obj):
189-
try:
190-
participant_team = ParticipantTeam.objects.get(
191-
team_name=obj.team_name
192-
)
193-
except ParticipantTeam.DoesNotExist:
194-
return "Participant team does not exist"
195-
196-
participant_ids = Participant.objects.filter(
197-
team=participant_team
198-
).values_list("user_id", flat=True)
199-
return list(
200-
User.objects.filter(id__in=participant_ids).values_list(
201-
"username", flat=True
202-
)
203-
)
195+
"""
196+
Get list of usernames for all participants in the team.
197+
Uses prefetched data if available to avoid additional queries.
198+
"""
199+
# Use prefetched participants if available (no additional query)
200+
# obj.participants is the related_name from Participant.team
201+
return [
202+
participant.user.username for participant in obj.participants.all()
203+
]
204204

205205
def get_team_members_email_ids(self, obj):
206-
try:
207-
participant_team = ParticipantTeam.objects.get(
208-
team_name=obj.team_name
209-
)
210-
except ParticipantTeam.DoesNotExist:
211-
return "Participant team does not exist"
212-
213-
participant_ids = Participant.objects.filter(
214-
team=participant_team
215-
).values_list("user_id", flat=True)
216-
return list(
217-
User.objects.filter(id__in=participant_ids).values_list(
218-
"email", flat=True
219-
)
220-
)
206+
"""
207+
Get list of email addresses for all participants in the team.
208+
Uses prefetched data if available to avoid additional queries.
209+
"""
210+
# Use prefetched participants if available (no additional query)
211+
return [
212+
participant.user.email for participant in obj.participants.all()
213+
]

tests/unit/analytics/test_views.py

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -964,18 +964,23 @@ def setUp(self):
964964
is_public=True,
965965
)
966966

967+
def _get_expected_participant_teams_queryset(self):
968+
"""Helper to get participant teams with prefetch for serializer"""
969+
challenge = get_challenge_model(self.challenge.pk)
970+
return challenge.participant_teams.prefetch_related(
971+
"participants__user"
972+
).order_by("-team_name")
973+
967974
def test_host_downloads_participant_team(self):
975+
"""Test that host can download participant teams as CSV"""
968976
expected = io.StringIO()
969977
expected_participant_teams = csv.writer(
970978
expected, quoting=csv.QUOTE_ALL
971979
)
972980
expected_participant_teams.writerow(
973981
["Team Name", "Team Members", "Email Id"]
974982
)
975-
challenge = get_challenge_model(self.challenge.pk)
976-
participant_team = challenge.participant_teams.all().order_by(
977-
"-team_name"
978-
)
983+
participant_team = self._get_expected_participant_teams_queryset()
979984
participant_teams = ChallengeParticipantSerializer(
980985
participant_team, many=True
981986
)
@@ -993,10 +998,136 @@ def test_host_downloads_participant_team(self):
993998
self.assertEqual(response.status_code, status.HTTP_200_OK)
994999

9951000
def test_user_not_host_downloads_participant_team(self):
1001+
"""Test that non-host user cannot download participant teams"""
9961002
expected = {
9971003
"error": "Sorry, you are not authorized to make this request"
9981004
}
9991005
self.client.force_authenticate(user=self.user2)
10001006
response = self.client.get(self.url, {})
10011007
self.assertEqual(response.data, expected)
10021008
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
1009+
1010+
def test_host_gets_participant_team_json_format(self):
1011+
"""Test that host can get participant teams in JSON format"""
1012+
participant_team = self._get_expected_participant_teams_queryset()
1013+
participant_teams = ChallengeParticipantSerializer(
1014+
participant_team, many=True
1015+
)
1016+
expected = {
1017+
"participant_teams": participant_teams.data,
1018+
"count": len(participant_teams.data),
1019+
}
1020+
self.client.force_authenticate(user=self.user)
1021+
response = self.client.get(self.url, {"output": "json"})
1022+
self.assertEqual(response.data, expected)
1023+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1024+
1025+
def test_user_not_host_gets_participant_team_json_format(self):
1026+
"""Test that non-host user cannot get participant teams in JSON format"""
1027+
expected = {
1028+
"error": "Sorry, you are not authorized to make this request"
1029+
}
1030+
self.client.force_authenticate(user=self.user2)
1031+
response = self.client.get(self.url, {"output": "json"})
1032+
self.assertEqual(response.data, expected)
1033+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
1034+
1035+
def test_json_format_case_insensitive(self):
1036+
"""Test that output parameter is case-insensitive"""
1037+
self.client.force_authenticate(user=self.user)
1038+
1039+
# Test uppercase (use 'output' to avoid DRF format content negotiation)
1040+
response = self.client.get(self.url, {"output": "JSON"})
1041+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1042+
self.assertIn("participant_teams", response.data)
1043+
self.assertIn("count", response.data)
1044+
1045+
# Test mixed case
1046+
response = self.client.get(self.url, {"output": "Json"})
1047+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1048+
self.assertIn("participant_teams", response.data)
1049+
1050+
def test_invalid_format_defaults_to_csv(self):
1051+
"""Test that invalid output parameter defaults to CSV download"""
1052+
self.client.force_authenticate(user=self.user)
1053+
response = self.client.get(self.url, {"output": "invalid"})
1054+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1055+
self.assertEqual(response["Content-Type"], "text/csv")
1056+
self.assertIn("attachment; filename=", response["Content-Disposition"])
1057+
1058+
def test_json_response_structure(self):
1059+
"""Test that JSON response has correct structure"""
1060+
self.client.force_authenticate(user=self.user)
1061+
response = self.client.get(self.url, {"output": "json"})
1062+
1063+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1064+
self.assertIn("participant_teams", response.data)
1065+
self.assertIn("count", response.data)
1066+
self.assertEqual(response.data["count"], 2) # Two teams added in setUp
1067+
1068+
# Verify each team has required fields
1069+
for team in response.data["participant_teams"]:
1070+
self.assertIn("team_name", team)
1071+
self.assertIn("team_members", team)
1072+
self.assertIn("team_members_email_ids", team)
1073+
self.assertIsInstance(team["team_members"], list)
1074+
self.assertIsInstance(team["team_members_email_ids"], list)
1075+
1076+
def test_json_response_contains_correct_data(self):
1077+
"""Test that JSON response contains correct participant data"""
1078+
self.client.force_authenticate(user=self.user)
1079+
response = self.client.get(self.url, {"output": "json"})
1080+
1081+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1082+
1083+
# Find participant_team in response
1084+
team_names = [
1085+
team["team_name"] for team in response.data["participant_teams"]
1086+
]
1087+
self.assertIn("Participant Team", team_names)
1088+
self.assertIn("Participant Team3", team_names)
1089+
1090+
# Verify member data for participant_team
1091+
for team in response.data["participant_teams"]:
1092+
if team["team_name"] == "Participant Team":
1093+
self.assertIn("otheruser", team["team_members"])
1094+
self.assertIn(
1095+
"otheruser@test.com", team["team_members_email_ids"]
1096+
)
1097+
elif team["team_name"] == "Participant Team3":
1098+
self.assertIn("user3", team["team_members"])
1099+
self.assertIn("user3@test.com", team["team_members_email_ids"])
1100+
1101+
def test_csv_response_headers(self):
1102+
"""Test that CSV response has correct headers"""
1103+
self.client.force_authenticate(user=self.user)
1104+
response = self.client.get(self.url, {})
1105+
1106+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1107+
self.assertEqual(response["Content-Type"], "text/csv")
1108+
self.assertIn(
1109+
"participant_teams_{}.csv".format(self.challenge.pk),
1110+
response["Content-Disposition"],
1111+
)
1112+
1113+
def test_empty_participant_teams(self):
1114+
"""Test response when challenge has no participant teams"""
1115+
# Remove all participant teams
1116+
self.challenge.participant_teams.clear()
1117+
1118+
self.client.force_authenticate(user=self.user)
1119+
1120+
# Test JSON format
1121+
response = self.client.get(self.url, {"output": "json"})
1122+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1123+
self.assertEqual(response.data["participant_teams"], [])
1124+
self.assertEqual(response.data["count"], 0)
1125+
1126+
# Test CSV format
1127+
response = self.client.get(self.url, {})
1128+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1129+
content = response.content.decode("utf-8")
1130+
# Should only have header row
1131+
lines = content.strip().split("\n")
1132+
self.assertEqual(len(lines), 1)
1133+
self.assertIn("Team Name", lines[0])

tests/unit/participants/test_serializers.py

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,60 @@
1111
from rest_framework import serializers
1212

1313

14-
class DummyObj:
15-
team_name = "nonexistent_team"
14+
class TestChallengeParticipantSerializer(TestCase):
15+
"""Tests for ChallengeParticipantSerializer (expects ParticipantTeam with participants)."""
1616

17+
def setUp(self):
18+
self.user = User.objects.create(
19+
username="testuser",
20+
email="test@example.com",
21+
)
22+
self.empty_team = ParticipantTeam.objects.create(
23+
team_name="Empty Team",
24+
created_by=self.user,
25+
)
1726

18-
class TestChallengeParticipantSerializer(TestCase):
19-
def test_get_team_members_team_does_not_exist(self):
27+
def test_get_team_members_empty_team(self):
28+
"""Team with no participants returns empty list."""
2029
serializer = ChallengeParticipantSerializer()
21-
obj = DummyObj()
22-
result = serializer.get_team_members(obj)
23-
self.assertEqual(result, "Participant team does not exist")
30+
result = serializer.get_team_members(self.empty_team)
31+
self.assertEqual(result, [])
2432

25-
def test_get_team_members_email_ids_team_does_not_exist(self):
33+
def test_get_team_members_email_ids_empty_team(self):
34+
"""Team with no participants returns empty email list."""
35+
serializer = ChallengeParticipantSerializer()
36+
result = serializer.get_team_members_email_ids(self.empty_team)
37+
self.assertEqual(result, [])
38+
39+
def test_get_team_members_with_participants(self):
40+
"""Team with participants returns usernames."""
41+
participant_user = User.objects.create(
42+
username="member1",
43+
email="member1@example.com",
44+
)
45+
Participant.objects.create(
46+
user=participant_user,
47+
status=Participant.SELF,
48+
team=self.empty_team,
49+
)
50+
serializer = ChallengeParticipantSerializer()
51+
result = serializer.get_team_members(self.empty_team)
52+
self.assertEqual(result, ["member1"])
53+
54+
def test_get_team_members_email_ids_with_participants(self):
55+
"""Team with participants returns emails."""
56+
participant_user = User.objects.create(
57+
username="member1",
58+
email="member1@example.com",
59+
)
60+
Participant.objects.create(
61+
user=participant_user,
62+
status=Participant.SELF,
63+
team=self.empty_team,
64+
)
2665
serializer = ChallengeParticipantSerializer()
27-
obj = DummyObj()
28-
result = serializer.get_team_members_email_ids(obj)
29-
self.assertEqual(result, "Participant team does not exist")
66+
result = serializer.get_team_members_email_ids(self.empty_team)
67+
self.assertEqual(result, ["member1@example.com"])
3068

3169

3270
class TestInviteParticipantToTeamSerializer(TestCase):

0 commit comments

Comments
 (0)