Skip to content

Commit 6755a30

Browse files
Nils Barsnbars
authored andcommitted
Add admin/user view toggle to scoreboard
- Admin submissions endpoint at /api/scoreboard/submissions/admin - Public endpoint filters out admin users and pre-start submissions - Toggle in scoreboard header switches between views (admin only)
1 parent ea73810 commit 6755a30

3 files changed

Lines changed: 105 additions & 31 deletions

File tree

spa-frontend/src/api/scoreboard.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,7 @@ export function getScoreboardConfig(): Promise<ScoreboardConfig> {
3939
export function getScoreboardSubmissions(): Promise<SubmissionsByChallenge> {
4040
return apiGet<SubmissionsByChallenge>('/api/scoreboard/submissions');
4141
}
42+
43+
export function getScoreboardSubmissionsAdmin(): Promise<SubmissionsByChallenge> {
44+
return apiGet<SubmissionsByChallenge>('/api/scoreboard/submissions/admin');
45+
}

spa-frontend/src/pages/Scoreboard.vue

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import {
99
import {
1010
getScoreboardConfig,
1111
getScoreboardSubmissions,
12+
getScoreboardSubmissionsAdmin,
1213
type ScoreboardConfig,
1314
type SubmissionsByChallenge,
1415
type ChallengeCfg,
1516
} from '../api/scoreboard';
17+
import { useAuthStore } from '../stores/auth';
1618
import { strategy } from '../ranking';
1719
import {
1820
computeAssignmentStartTimes,
@@ -27,6 +29,9 @@ import Countdown from '../components/scoreboard/Countdown.vue';
2729
2830
const POLL_INTERVAL_MS = 5000;
2931
32+
const auth = useAuthStore();
33+
const adminView = ref(false);
34+
3035
const config = ref<ScoreboardConfig | null>(null);
3136
const submissions = ref<SubmissionsByChallenge>({});
3237
const error = ref<string | null>(null);
@@ -40,9 +45,12 @@ let pollTimer: number | undefined;
4045
4146
async function refresh() {
4247
try {
48+
const fetchSubs = adminView.value
49+
? getScoreboardSubmissionsAdmin()
50+
: getScoreboardSubmissions();
4351
const [cfg, subs] = await Promise.all([
4452
getScoreboardConfig(),
45-
getScoreboardSubmissions(),
53+
fetchSubs,
4654
]);
4755
config.value = cfg;
4856
submissions.value = subs;
@@ -57,6 +65,11 @@ async function refresh() {
5765
}
5866
}
5967
68+
function toggleAdminView() {
69+
adminView.value = !adminView.value;
70+
refresh();
71+
}
72+
6073
onMounted(async () => {
6174
await refresh();
6275
pollTimer = window.setInterval(refresh, POLL_INTERVAL_MS);
@@ -127,7 +140,7 @@ const assignmentBoundaries = computed(() => {
127140
if (!config.value) return [];
128141
const now = Date.now();
129142
return computeAssignmentStartTimes(assignments.value).filter(
130-
(d) => d.getTime() <= now,
143+
(b) => b.date.getTime() <= now,
131144
);
132145
});
133146
@@ -193,6 +206,11 @@ const challengeRanking = computed(() => {
193206
<span class="term-live-dot" />LIVE
194207
</span>
195208
<span class="term-eyebrow term-hot">{{ config.course_name }}</span>
209+
<a
210+
v-if="auth.isAdmin"
211+
class="term-view-toggle"
212+
@click.prevent="toggleAdminView"
213+
>{{ adminView ? '[ admin view ]' : '[ user view ]' }}</a>
196214
</div>
197215
<h1
198216
class="term-display term-hot-glow"
@@ -292,3 +310,20 @@ const challengeRanking = computed(() => {
292310
</section>
293311
</div>
294312
</template>
313+
314+
<style scoped>
315+
.term-view-toggle {
316+
margin-left: auto;
317+
font-family: var(--term-font-mono);
318+
font-size: 0.75rem;
319+
letter-spacing: 0.15em;
320+
color: rgb(var(--v-theme-secondary));
321+
cursor: pointer;
322+
user-select: none;
323+
transition: color 150ms ease;
324+
}
325+
.term-view-toggle:hover {
326+
color: rgb(var(--v-theme-primary));
327+
text-shadow: 0 0 10px rgba(var(--v-theme-hot-glow), var(--v-hot-glow-alpha));
328+
}
329+
</style>

webapp/ref/frontend_api/scoreboard.py

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -154,35 +154,16 @@ def api_scoreboard_config():
154154
)
155155

156156

157-
@refbp.route("/api/scoreboard/submissions", methods=("GET",))
158-
@limiter.limit("20 per minute")
159-
def api_scoreboard_submissions():
160-
"""Team-grouped submission scores with per-task breakdown.
161-
162-
Response shape::
157+
def _collect_submissions(*, include_admins: bool) -> dict:
158+
"""Build the challenge -> team -> entries mapping.
163159
164-
{
165-
"<short_name>": {
166-
"<team label>": [
167-
{
168-
"ts": "DD/MM/YYYY HH:MM:SS",
169-
"score": <float>,
170-
"tasks": {"<task_name>": <float | null>, ...}
171-
},
172-
...
173-
]
174-
}
175-
}
176-
177-
``tasks`` values are ``null`` for tasks whose underlying raw score was
178-
``None`` (bool-returning tests). Such tasks contribute 0 to ``score``.
160+
When *include_admins* is ``False``, submissions by admin users are
161+
silently skipped and submissions before the assignment start time
162+
are excluded so the public scoreboard only shows student work
163+
within the configured window.
179164
"""
180-
_scoreboard_enabled_or_abort()
181-
182165
scores: dict[str, dict[str, list[dict]]] = defaultdict(lambda: defaultdict(list))
183166

184-
# Eager-load test results; we always read them now that multi-task
185-
# submissions are supported.
186167
submissions = Submission.query.options(
187168
selectinload(Submission.submission_test_results)
188169
).all()
@@ -191,25 +172,32 @@ def api_scoreboard_submissions():
191172
instance = submission.origin_instance
192173
if instance is None:
193174
continue
175+
user = instance.user
176+
if user is None:
177+
continue
178+
if not include_admins and user.is_admin:
179+
continue
194180
exercise = instance.exercise
195181
if exercise is None:
196182
continue
197183
cfg = exercise.config
198184
if cfg is None or cfg.category is None:
199185
continue
200186

187+
if not include_admins and cfg.submission_deadline_start:
188+
if submission.submission_ts < cfg.submission_deadline_start:
189+
continue
190+
201191
if not submission.submission_test_results:
202-
# Nothing was tested — no meaningful score to plot.
203192
continue
204193

205194
total, breakdown = score_submission(
206195
submission.submission_test_results,
207196
cfg.per_task_scoring_policies,
208197
)
209198
if not breakdown:
210-
# Every task was discarded; nothing to show for this submission.
211199
continue
212-
team = team_identity(instance.user)
200+
team = team_identity(user)
213201
scores[exercise.short_name][team].append(
214202
{
215203
"ts": datetime_to_string(submission.submission_ts),
@@ -222,4 +210,51 @@ def api_scoreboard_submissions():
222210
for entries in challenge.values():
223211
entries.sort(key=lambda e: e["ts"])
224212

225-
return jsonify(scores)
213+
return scores
214+
215+
216+
@refbp.route("/api/scoreboard/submissions", methods=("GET",))
217+
@limiter.limit("120 per minute")
218+
def api_scoreboard_submissions():
219+
"""Team-grouped submission scores with per-task breakdown.
220+
221+
Response shape::
222+
223+
{
224+
"<short_name>": {
225+
"<team label>": [
226+
{
227+
"ts": "DD/MM/YYYY HH:MM:SS",
228+
"score": <float>,
229+
"tasks": {"<task_name>": <float | null>, ...}
230+
},
231+
...
232+
]
233+
}
234+
}
235+
236+
``tasks`` values are ``null`` for tasks whose underlying raw score was
237+
``None`` (bool-returning tests). Such tasks contribute 0 to ``score``.
238+
239+
Submissions by admin users are excluded from the public scoreboard.
240+
"""
241+
_scoreboard_enabled_or_abort()
242+
return jsonify(_collect_submissions(include_admins=False))
243+
244+
245+
@refbp.route("/api/scoreboard/submissions/admin", methods=("GET",))
246+
@limiter.limit("120 per minute")
247+
def api_scoreboard_submissions_admin():
248+
"""Admin variant: includes submissions by admin users.
249+
250+
Requires an authenticated admin session; returns 403 otherwise.
251+
Same response shape as the public endpoint.
252+
"""
253+
from flask_login import current_user
254+
255+
_scoreboard_enabled_or_abort()
256+
257+
if not current_user.is_authenticated or not current_user.is_admin:
258+
abort(403)
259+
260+
return jsonify(_collect_submissions(include_admins=True))

0 commit comments

Comments
 (0)