Skip to content

Commit d29213a

Browse files
Add public leaderboard data endpoint (#154)
New public endpoint that returns the full leaderboard with per-homework and per-project score breakdowns, including learning in public links. Usage: GET /data/<course_slug>/leaderboard.yaml Example: curl https://courses.datatalks.club/data/de-zoomcamp-2026/leaderboard.yaml No authentication required. Response is YAML with: - Position, display name, and total score for each participant - Per-homework breakdown: questions_score, faq_score, learning_in_public_score - Per-project breakdown: project_score, peer_review_score, LiP scores, passed - Learning in public links for each submission The response is cached for 1 hour and automatically invalidated whenever the leaderboard is recalculated (homework/project scoring). A link to the endpoint is shown at the bottom of the leaderboard page. Closes #154
1 parent a96f42e commit d29213a

6 files changed

Lines changed: 340 additions & 3 deletions

File tree

courses/scoring.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -346,9 +346,9 @@ def update_leaderboard(course: Course):
346346
["total_score", "position_on_leaderboard"],
347347
)
348348

349-
# Invalidate the leaderboard cache
350-
cache_key = f"leaderboard:{course.id}"
351-
cache.delete(cache_key)
349+
# Invalidate the leaderboard caches
350+
cache.delete(f"leaderboard:{course.id}")
351+
cache.delete(f"leaderboard_data:{course.id}")
352352
logger.info(f"Invalidated cache for leaderboard of course {course.id}")
353353

354354
t1 = time()

courses/templates/courses/leaderboard.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,10 @@ <h2>Your Record</h2>
3939
</div>
4040
{% endfor %}
4141

42+
<div class="mt-4 text-center text-muted">
43+
<small>
44+
<a href="{% url 'data_leaderboard' course_slug=course.slug %}">Leaderboard data (YAML)</a>
45+
</small>
46+
</div>
47+
4248
{% endblock %}

data/tests/test_leaderboard.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""Tests for the public leaderboard data endpoint."""
2+
3+
import yaml
4+
5+
from django.test import TestCase, Client
6+
from django.urls import reverse
7+
from django.utils import timezone
8+
from django.core.cache import cache
9+
10+
from accounts.models import CustomUser, Token
11+
from courses.models import (
12+
Course,
13+
Enrollment,
14+
Homework,
15+
Submission,
16+
Project,
17+
ProjectSubmission,
18+
)
19+
from courses.models.homework import HomeworkState
20+
from courses.models.project import ProjectState
21+
22+
23+
class LeaderboardDataViewTestCase(TestCase):
24+
25+
def setUp(self):
26+
self.client = Client()
27+
self.course = Course.objects.create(
28+
title="Test Course",
29+
slug="test-course",
30+
description="Test",
31+
)
32+
self.url = reverse(
33+
"data_leaderboard",
34+
kwargs={"course_slug": self.course.slug},
35+
)
36+
37+
self.user1 = CustomUser.objects.create(
38+
username="user1", email="user1@example.com", password="pw",
39+
)
40+
self.user2 = CustomUser.objects.create(
41+
username="user2", email="user2@example.com", password="pw",
42+
)
43+
self.enrollment1 = Enrollment.objects.create(
44+
student=self.user1,
45+
course=self.course,
46+
display_name="Alice",
47+
total_score=100,
48+
position_on_leaderboard=1,
49+
)
50+
self.enrollment2 = Enrollment.objects.create(
51+
student=self.user2,
52+
course=self.course,
53+
display_name="Bob",
54+
total_score=50,
55+
position_on_leaderboard=2,
56+
)
57+
58+
def tearDown(self):
59+
cache.clear()
60+
61+
def test_returns_yaml(self):
62+
response = self.client.get(self.url)
63+
self.assertEqual(response.status_code, 200)
64+
self.assertEqual(
65+
response["Content-Type"], "text/plain; charset=utf-8"
66+
)
67+
data = yaml.safe_load(response.content)
68+
self.assertEqual(data["course"], "test-course")
69+
70+
def test_no_auth_required(self):
71+
"""Endpoint is public, no token needed."""
72+
response = self.client.get(self.url)
73+
self.assertEqual(response.status_code, 200)
74+
75+
def test_leaderboard_ordering(self):
76+
response = self.client.get(self.url)
77+
data = yaml.safe_load(response.content)
78+
leaderboard = data["leaderboard"]
79+
self.assertEqual(len(leaderboard), 2)
80+
self.assertEqual(leaderboard[0]["display_name"], "Alice")
81+
self.assertEqual(leaderboard[0]["total_score"], 100)
82+
self.assertEqual(leaderboard[0]["position"], 1)
83+
self.assertEqual(leaderboard[1]["display_name"], "Bob")
84+
85+
def test_includes_scored_homework_submissions(self):
86+
hw = Homework.objects.create(
87+
course=self.course,
88+
title="HW1",
89+
slug="hw1",
90+
description="",
91+
due_date=timezone.now(),
92+
state=HomeworkState.SCORED.value,
93+
)
94+
Submission.objects.create(
95+
homework=hw,
96+
student=self.user1,
97+
enrollment=self.enrollment1,
98+
questions_score=5,
99+
faq_score=1,
100+
learning_in_public_score=2,
101+
total_score=8,
102+
)
103+
104+
response = self.client.get(self.url)
105+
data = yaml.safe_load(response.content)
106+
alice = data["leaderboard"][0]
107+
self.assertIn("homeworks", alice)
108+
self.assertEqual(len(alice["homeworks"]), 1)
109+
self.assertEqual(alice["homeworks"][0]["homework"], "HW1")
110+
self.assertEqual(alice["homeworks"][0]["questions_score"], 5)
111+
self.assertEqual(alice["homeworks"][0]["faq_score"], 1)
112+
self.assertEqual(alice["homeworks"][0]["learning_in_public_score"], 2)
113+
self.assertEqual(alice["homeworks"][0]["total_score"], 8)
114+
115+
def test_excludes_unscored_homework(self):
116+
hw = Homework.objects.create(
117+
course=self.course,
118+
title="Open HW",
119+
slug="open-hw",
120+
description="",
121+
due_date=timezone.now(),
122+
state=HomeworkState.OPEN.value,
123+
)
124+
Submission.objects.create(
125+
homework=hw,
126+
student=self.user1,
127+
enrollment=self.enrollment1,
128+
total_score=5,
129+
)
130+
131+
response = self.client.get(self.url)
132+
data = yaml.safe_load(response.content)
133+
alice = data["leaderboard"][0]
134+
self.assertNotIn("homeworks", alice)
135+
136+
def test_includes_completed_project_submissions(self):
137+
proj = Project.objects.create(
138+
course=self.course,
139+
title="Project 1",
140+
slug="project-1",
141+
description="",
142+
submission_due_date=timezone.now(),
143+
peer_review_due_date=timezone.now(),
144+
state=ProjectState.COMPLETED.value,
145+
)
146+
ProjectSubmission.objects.create(
147+
project=proj,
148+
student=self.user1,
149+
enrollment=self.enrollment1,
150+
project_score=80,
151+
peer_review_score=9,
152+
project_learning_in_public_score=3,
153+
peer_review_learning_in_public_score=1,
154+
project_faq_score=1,
155+
total_score=94,
156+
passed=True,
157+
)
158+
159+
response = self.client.get(self.url)
160+
data = yaml.safe_load(response.content)
161+
alice = data["leaderboard"][0]
162+
self.assertIn("projects", alice)
163+
self.assertEqual(len(alice["projects"]), 1)
164+
self.assertEqual(alice["projects"][0]["project_score"], 80)
165+
self.assertTrue(alice["projects"][0]["passed"])
166+
167+
def test_response_is_cached(self):
168+
self.client.get(self.url)
169+
170+
# Modify data - cached response should still be old
171+
self.enrollment1.display_name = "Alice Changed"
172+
self.enrollment1.save()
173+
174+
response = self.client.get(self.url)
175+
data = yaml.safe_load(response.content)
176+
self.assertEqual(data["leaderboard"][0]["display_name"], "Alice")
177+
178+
def test_cache_invalidation(self):
179+
self.client.get(self.url)
180+
181+
# Simulate what update_leaderboard does
182+
cache.delete(f"leaderboard_data:{self.course.id}")
183+
184+
self.enrollment1.display_name = "Alice Changed"
185+
self.enrollment1.save()
186+
187+
response = self.client.get(self.url)
188+
data = yaml.safe_load(response.content)
189+
self.assertEqual(data["leaderboard"][0]["display_name"], "Alice Changed")
190+
191+
def test_nonexistent_course(self):
192+
response = self.client.get("/data/nonexistent/leaderboard.yaml")
193+
self.assertEqual(response.status_code, 404)

data/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@
1818
name="course_criteria_yaml",
1919
),
2020

21+
# Leaderboard data (public, no auth required)
22+
path(
23+
"<slug:course_slug>/leaderboard.yaml",
24+
data_views.leaderboard_data_view,
25+
name="data_leaderboard",
26+
),
27+
2128
# Data API endpoints (require auth)
2229
path(
2330
"<slug:course_slug>/homework/<slug:homework_slug>",

data/views/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .course import course_criteria_yaml_view
1010
from .enrollment import graduates_data_view, update_enrollment_certificate_view
1111
from .health import health_view
12+
from .leaderboard import leaderboard_data_view
1213

1314
__all__ = [
1415
"homework_data_view",
@@ -17,4 +18,5 @@
1718
"graduates_data_view",
1819
"update_enrollment_certificate_view",
1920
"health_view",
21+
"leaderboard_data_view",
2022
]

data/views/leaderboard.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""
2+
Public leaderboard data API view.
3+
4+
Returns the full leaderboard with per-homework and per-project score breakdowns.
5+
Cached and invalidated when the leaderboard is recalculated.
6+
"""
7+
8+
import logging
9+
10+
import yaml
11+
12+
from django.http import HttpResponse
13+
from django.shortcuts import get_object_or_404
14+
from django.core.cache import cache
15+
from django.db.models import Prefetch
16+
from django.db.models.functions import Coalesce
17+
from django.db.models import Value
18+
19+
from courses.models import (
20+
Course,
21+
Enrollment,
22+
Submission,
23+
ProjectSubmission,
24+
)
25+
from courses.models.homework import HomeworkState
26+
from courses.models.project import ProjectState
27+
28+
logger = logging.getLogger(__name__)
29+
30+
LEADERBOARD_DATA_CACHE_TTL = 3600 # 1 hour
31+
32+
33+
def _build_leaderboard_data(course):
34+
"""Build the full leaderboard JSON structure with score breakdowns."""
35+
enrollments = (
36+
Enrollment.objects.filter(course=course)
37+
.select_related("student")
38+
.prefetch_related(
39+
Prefetch(
40+
"submission_set",
41+
queryset=Submission.objects.filter(
42+
homework__state=HomeworkState.SCORED.value,
43+
).select_related("homework").order_by("homework__id"),
44+
to_attr="scored_submissions",
45+
),
46+
Prefetch(
47+
"projectsubmission_set",
48+
queryset=ProjectSubmission.objects.filter(
49+
project__state=ProjectState.COMPLETED.value,
50+
).select_related("project").order_by("project__id"),
51+
to_attr="completed_project_submissions",
52+
),
53+
)
54+
.order_by(
55+
Coalesce("position_on_leaderboard", Value(999999)),
56+
"id",
57+
)
58+
)
59+
60+
results = []
61+
for enrollment in enrollments:
62+
hw_data = []
63+
for sub in enrollment.scored_submissions:
64+
hw_entry = {
65+
"homework": sub.homework.title,
66+
"homework_slug": sub.homework.slug,
67+
"total_score": sub.total_score,
68+
"questions_score": sub.questions_score,
69+
"faq_score": sub.faq_score,
70+
"learning_in_public_score": sub.learning_in_public_score,
71+
}
72+
if sub.learning_in_public_links:
73+
hw_entry["learning_in_public_links"] = sub.learning_in_public_links
74+
hw_data.append(hw_entry)
75+
76+
proj_data = []
77+
for sub in enrollment.completed_project_submissions:
78+
proj_entry = {
79+
"project": sub.project.title,
80+
"project_slug": sub.project.slug,
81+
"total_score": sub.total_score,
82+
"project_score": sub.project_score,
83+
"peer_review_score": sub.peer_review_score,
84+
"project_learning_in_public_score": sub.project_learning_in_public_score,
85+
"peer_review_learning_in_public_score": sub.peer_review_learning_in_public_score,
86+
"project_faq_score": sub.project_faq_score,
87+
"passed": sub.passed,
88+
}
89+
if sub.learning_in_public_links:
90+
proj_entry["learning_in_public_links"] = sub.learning_in_public_links
91+
proj_data.append(proj_entry)
92+
93+
entry = {
94+
"position": enrollment.position_on_leaderboard,
95+
"display_name": enrollment.display_name,
96+
"total_score": enrollment.total_score,
97+
}
98+
if hw_data:
99+
entry["homeworks"] = hw_data
100+
if proj_data:
101+
entry["projects"] = proj_data
102+
103+
results.append(entry)
104+
105+
return results
106+
107+
108+
def leaderboard_data_view(request, course_slug: str):
109+
"""Public endpoint returning the full leaderboard with score breakdowns."""
110+
course = get_object_or_404(Course, slug=course_slug)
111+
112+
cache_key = f"leaderboard_data:{course.id}"
113+
data = cache.get(cache_key)
114+
115+
if data is None:
116+
logger.info("Cache miss for leaderboard data of course %s", course.slug)
117+
data = _build_leaderboard_data(course)
118+
cache.set(cache_key, data, LEADERBOARD_DATA_CACHE_TTL)
119+
else:
120+
logger.info("Cache hit for leaderboard data of course %s", course.slug)
121+
122+
yaml_content = yaml.dump(
123+
{"course": course.slug, "leaderboard": data},
124+
default_flow_style=False,
125+
allow_unicode=True,
126+
sort_keys=False,
127+
)
128+
129+
return HttpResponse(yaml_content, content_type="text/plain; charset=utf-8")

0 commit comments

Comments
 (0)