Skip to content

Commit b37d394

Browse files
authored
Merge branch 'master' into fix/challenge-creation-permissions
2 parents 82d2236 + 5d736d7 commit b37d394

2 files changed

Lines changed: 240 additions & 11 deletions

File tree

apps/challenges/views.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +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
59+
from django.db.models import Count, Prefetch
6060
from django.http import HttpResponse
6161
from django.utils import timezone
6262
from drf_spectacular.utils import (
@@ -898,18 +898,23 @@ def get_challenge_submission_metrics_by_pk(request, pk):
898898
}
899899
return Response(response_data, status=status.HTTP_403_FORBIDDEN)
900900
challenge = get_challenge_model(pk)
901-
challenge_phases = ChallengePhase.objects.filter(challenge=challenge)
902-
submission_metrics = {}
903901

904-
submission_statuses = [status[0] for status in Submission.STATUS_OPTIONS]
902+
submission_statuses = [s[0] for s in Submission.STATUS_OPTIONS]
905903

906-
# Fetch challenge phases for the challenge
907-
challenge_phases = ChallengePhase.objects.filter(challenge=challenge)
908-
for submission_status in submission_statuses:
909-
count = Submission.objects.filter(
910-
challenge_phase__in=challenge_phases, status=submission_status
911-
).count()
912-
submission_metrics[submission_status] = count
904+
# Single aggregated query: JOIN + GROUP BY instead of 10 separate
905+
# COUNT subqueries (one per status).
906+
submission_counts = (
907+
Submission.objects.filter(challenge_phase__challenge=challenge)
908+
.values("status")
909+
.annotate(count=Count("id"))
910+
)
911+
submission_metrics = {
912+
row["status"]: row["count"] for row in submission_counts
913+
}
914+
915+
# Ensure every status key is present even when count is 0
916+
for s in submission_statuses:
917+
submission_metrics.setdefault(s, 0)
913918

914919
return Response(submission_metrics, status=status.HTTP_200_OK)
915920

tests/unit/challenges/test_views.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7499,3 +7499,227 @@ def test_update_challenge_attributes_when_not_a_staff(self):
74997499

75007500
self.assertEqual(response.data, expected)
75017501
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
7502+
7503+
7504+
class GetChallengeSubmissionMetricsByPkTest(BaseAPITestClass):
7505+
"""Tests for the get_challenge_submission_metrics_by_pk endpoint."""
7506+
7507+
def setUp(self):
7508+
super().setUp()
7509+
7510+
with self.settings(MEDIA_ROOT="/tmp/evalai"):
7511+
self.challenge_phase1 = ChallengePhase.objects.create(
7512+
name="Phase 1",
7513+
description="Description for Phase 1",
7514+
leaderboard_public=False,
7515+
is_public=True,
7516+
start_date=timezone.now() - timedelta(days=2),
7517+
end_date=timezone.now() + timedelta(days=1),
7518+
challenge=self.challenge,
7519+
codename="phase_1_codename",
7520+
test_annotation=SimpleUploadedFile(
7521+
"test_annotation1.txt",
7522+
b"Dummy file content",
7523+
content_type="text/plain",
7524+
),
7525+
)
7526+
7527+
self.challenge_phase2 = ChallengePhase.objects.create(
7528+
name="Phase 2",
7529+
description="Description for Phase 2",
7530+
leaderboard_public=False,
7531+
is_public=True,
7532+
start_date=timezone.now() - timedelta(days=2),
7533+
end_date=timezone.now() + timedelta(days=1),
7534+
challenge=self.challenge,
7535+
codename="phase_2_codename",
7536+
test_annotation=SimpleUploadedFile(
7537+
"test_annotation2.txt",
7538+
b"Dummy file content",
7539+
content_type="text/plain",
7540+
),
7541+
)
7542+
7543+
self.url = reverse_lazy(
7544+
"challenges:get_challenge_submission_metrics_by_pk",
7545+
kwargs={"pk": self.challenge.pk},
7546+
)
7547+
7548+
def _create_submissions(self, phase, status_counts):
7549+
"""Helper to create submissions with given status counts.
7550+
7551+
Note: Submission.save() always forces status='submitted' for new
7552+
records, so we create them first and then use queryset .update()
7553+
to set the desired status without triggering the save override.
7554+
"""
7555+
with self.settings(MEDIA_ROOT="/tmp/evalai"):
7556+
for sub_status, count in status_counts.items():
7557+
pks = []
7558+
for _ in range(count):
7559+
sub = Submission.objects.create(
7560+
participant_team=self.participant_team,
7561+
challenge_phase=phase,
7562+
created_by=self.user,
7563+
status="submitted",
7564+
input_file=SimpleUploadedFile(
7565+
"test_file.txt",
7566+
b"Dummy file content",
7567+
content_type="text/plain",
7568+
),
7569+
)
7570+
pks.append(sub.pk)
7571+
if sub_status != "submitted":
7572+
Submission.objects.filter(pk__in=pks).update(
7573+
status=sub_status
7574+
)
7575+
7576+
def test_returns_403_for_non_staff_user(self):
7577+
"""Non-staff users should be denied access."""
7578+
self.user.is_staff = False
7579+
self.user.save()
7580+
self.client.force_authenticate(user=self.user)
7581+
7582+
response = self.client.get(self.url)
7583+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
7584+
7585+
def test_returns_all_status_keys_for_empty_challenge(self):
7586+
"""Even with no submissions, all STATUS_OPTIONS keys should appear as 0."""
7587+
self.user.is_staff = True
7588+
self.user.save()
7589+
self.client.force_authenticate(user=self.user)
7590+
7591+
response = self.client.get(self.url)
7592+
self.assertEqual(response.status_code, status.HTTP_200_OK)
7593+
7594+
expected_statuses = [s[0] for s in Submission.STATUS_OPTIONS]
7595+
for s in expected_statuses:
7596+
self.assertIn(s, response.data)
7597+
self.assertEqual(response.data[s], 0)
7598+
7599+
def test_returns_correct_counts_single_phase(self):
7600+
"""Counts should be correct when submissions exist in one phase."""
7601+
self.user.is_staff = True
7602+
self.user.save()
7603+
self.client.force_authenticate(user=self.user)
7604+
7605+
self._create_submissions(
7606+
self.challenge_phase1,
7607+
{"submitted": 3, "finished": 5, "failed": 2},
7608+
)
7609+
7610+
response = self.client.get(self.url)
7611+
self.assertEqual(response.status_code, status.HTTP_200_OK)
7612+
self.assertEqual(response.data["submitted"], 3)
7613+
self.assertEqual(response.data["finished"], 5)
7614+
self.assertEqual(response.data["failed"], 2)
7615+
self.assertEqual(response.data["running"], 0)
7616+
7617+
def test_returns_correct_counts_multiple_phases(self):
7618+
"""Counts should aggregate across all phases of the challenge."""
7619+
self.user.is_staff = True
7620+
self.user.save()
7621+
self.client.force_authenticate(user=self.user)
7622+
7623+
self._create_submissions(
7624+
self.challenge_phase1,
7625+
{"submitted": 2, "finished": 4},
7626+
)
7627+
self._create_submissions(
7628+
self.challenge_phase2,
7629+
{"submitted": 1, "finished": 3, "failed": 7},
7630+
)
7631+
7632+
response = self.client.get(self.url)
7633+
self.assertEqual(response.status_code, status.HTTP_200_OK)
7634+
self.assertEqual(response.data["submitted"], 3) # 2 + 1
7635+
self.assertEqual(response.data["finished"], 7) # 4 + 3
7636+
self.assertEqual(response.data["failed"], 7)
7637+
self.assertEqual(response.data["running"], 0)
7638+
7639+
def test_does_not_count_submissions_from_other_challenges(self):
7640+
"""Submissions belonging to a different challenge must not be counted."""
7641+
self.user.is_staff = True
7642+
self.user.save()
7643+
self.client.force_authenticate(user=self.user)
7644+
7645+
other_challenge = Challenge.objects.create(
7646+
title="Other Challenge",
7647+
short_description="Other short desc",
7648+
description="Other description",
7649+
terms_and_conditions="Other terms",
7650+
submission_guidelines="Other guidelines",
7651+
creator=self.challenge_host_team,
7652+
published=False,
7653+
is_registration_open=True,
7654+
enable_forum=True,
7655+
anonymous_leaderboard=False,
7656+
start_date=timezone.now() - timedelta(days=2),
7657+
end_date=timezone.now() + timedelta(days=1),
7658+
approved_by_admin=False,
7659+
)
7660+
with self.settings(MEDIA_ROOT="/tmp/evalai"):
7661+
other_phase = ChallengePhase.objects.create(
7662+
name="Other Phase",
7663+
description="Other phase desc",
7664+
leaderboard_public=False,
7665+
is_public=True,
7666+
start_date=timezone.now() - timedelta(days=2),
7667+
end_date=timezone.now() + timedelta(days=1),
7668+
challenge=other_challenge,
7669+
codename="other_phase_codename",
7670+
test_annotation=SimpleUploadedFile(
7671+
"test_annotation_other.txt",
7672+
b"Dummy file content",
7673+
content_type="text/plain",
7674+
),
7675+
)
7676+
7677+
self._create_submissions(
7678+
self.challenge_phase1,
7679+
{"finished": 2},
7680+
)
7681+
self._create_submissions(
7682+
other_phase,
7683+
{"finished": 10},
7684+
)
7685+
7686+
response = self.client.get(self.url)
7687+
self.assertEqual(response.status_code, status.HTTP_200_OK)
7688+
self.assertEqual(response.data["finished"], 2)
7689+
7690+
def test_query_count_is_constant(self):
7691+
"""
7692+
The endpoint should use a constant number of DB queries regardless
7693+
of how many statuses have submissions. The old code ran 10 separate
7694+
COUNT queries; the fix uses a single aggregated GROUP BY query.
7695+
"""
7696+
self.user.is_staff = True
7697+
self.user.save()
7698+
self.client.force_authenticate(user=self.user)
7699+
7700+
self._create_submissions(
7701+
self.challenge_phase1,
7702+
{
7703+
"submitted": 1,
7704+
"running": 1,
7705+
"failed": 1,
7706+
"finished": 1,
7707+
"cancelled": 1,
7708+
"queued": 1,
7709+
},
7710+
)
7711+
7712+
with CaptureQueriesContext(connection) as context:
7713+
response = self.client.get(self.url)
7714+
7715+
self.assertEqual(response.status_code, status.HTTP_200_OK)
7716+
# Auth (token check, user load) + challenge lookup + 1 aggregated query
7717+
# should be well under 10. The old code alone produced 10 COUNT
7718+
# queries.
7719+
self.assertLessEqual(
7720+
len(context.captured_queries),
7721+
8,
7722+
"Query count too high – possible N+1 regression in "
7723+
"get_challenge_submission_metrics_by_pk. "
7724+
f"Queries: {[q['sql'] for q in context.captured_queries]}",
7725+
)

0 commit comments

Comments
 (0)