Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
bb17b1a
Add a Score counter to task overview
pxsit Jul 23, 2025
6653f15
Merge branch 'main' into add-counter
pxsit Jul 23, 2025
3d62b5d
Merge branch 'main' into add-counter
pxsit Jul 23, 2025
6b52ff7
Merge branch 'main' into add-counter
pxsit Jul 28, 2025
8428046
Merge branch 'main' into add-counter
pxsit Aug 1, 2025
4ae18e9
Restore Formatting, Make it toggleable
pxsit Aug 1, 2025
cbe5f8e
Fix color not displaying
pxsit Aug 1, 2025
8055bf6
Fix
pxsit Aug 3, 2025
fc50113
Fix the Fix
pxsit Aug 4, 2025
251c8b5
Fix the Fix that Fix the Fix
pxsit Aug 4, 2025
527bdea
Merge branch 'main' into add-counter
pxsit Aug 5, 2025
95cc100
Update cws_style.css
pxsit Aug 5, 2025
e60f328
Merge branch 'main' into add-counter
pxsit Aug 5, 2025
13c491e
Add credit for original owner?
pxsit Aug 5, 2025
95349b0
Merge branch 'main' into add-counter
pxsit Aug 6, 2025
28c1ae1
Merge branch 'main' into add-counter
pxsit Aug 6, 2025
e0840f6
Update update_from_1.5.sql
pxsit Aug 6, 2025
a958b48
Merge branch 'main' into add-counter
pxsit Aug 8, 2025
7a3b7b7
Merge branch 'main' into add-counter
pxsit Aug 11, 2025
fa4e7ca
Merge branch 'main' into add-counter
pxsit Aug 15, 2025
4b0bd83
Merge branch 'main' into add-counter
pxsit Aug 16, 2025
d12ba11
Merge branch 'cms-dev:main' into add-counter
pxsit Sep 21, 2025
85d0eb8
Merge branch 'cms-dev:main' into add-counter
pxsit Sep 30, 2025
3181514
Merge branch 'cms-dev:main' into add-counter
pxsit Oct 8, 2025
003a7f9
Merge branch 'cms-dev:main' into add-counter
pxsit Oct 23, 2025
7aac12b
Remove unused PrintJob import from main.py
pxsit Nov 17, 2025
1134711
Merge branch 'main' into add-counter
pxsit Nov 17, 2025
6411ec3
Merge branch 'main' into add-counter
pxsit Jan 1, 2026
44822d6
Optimize contest data loading
pxsit Jan 13, 2026
98a11b7
Merge branch 'main' into add-counter
pxsit Feb 17, 2026
b84d5f8
Add sidebar task scores for contests
pxsit Apr 5, 2026
56a4f96
Update update_from_1.5.sql
pxsit Apr 5, 2026
e4a1295
Show tokened task scores in sidebar
pxsit Apr 5, 2026
7760b48
Enable task scores in sidebar by default
pxsit Apr 5, 2026
b2e8b21
Unify task score computation into _compute_task_scores
pxsit Apr 5, 2026
7271662
Show full scores in analysis mode and minor fixes
pxsit Apr 5, 2026
2ff708e
Reuse preloaded participation and show N/A scores
pxsit Apr 5, 2026
f36ca31
Update contest.html
pxsit Apr 5, 2026
ba352bb
Update contest.py
pxsit Apr 5, 2026
1b0b0a1
Added an option to display task scores in the contest sidebar
pxsit Apr 5, 2026
000e7e8
Merge branch 'main' into add-counter
pxsit Apr 5, 2026
ef00a6a
Merge branch 'main' into add-counter
pxsit Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cms/db/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ class Contest(Base):
nullable=False,
default=False)

# Whether to show task scores in the overview page
show_task_scores_in_overview: bool = Column(Boolean, nullable=False, default=True)

# Whether to show task scores in the sidebar task list.
show_task_scores_in_sidebar: bool = Column(Boolean, nullable=False, default=True)

# Whether to prevent hidden participations to log in.
block_hidden_participations: bool = Column(
Boolean,
Expand Down
2 changes: 2 additions & 0 deletions cms/server/admin/handlers/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ def post(self, contest_id: str):
self.get_bool(attrs, "allow_questions")
self.get_bool(attrs, "allow_user_tests")
self.get_bool(attrs, "allow_unofficial_submission_before_analysis_mode")
self.get_bool(attrs, "show_task_scores_in_overview")
self.get_bool(attrs, "show_task_scores_in_sidebar")
self.get_bool(attrs, "block_hidden_participations")
self.get_bool(attrs, "allow_password_authentication")
self.get_bool(attrs, "allow_registration")
Expand Down
15 changes: 15 additions & 0 deletions cms/server/admin/templates/contest.html
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,21 @@ <h1>Contest configuration</h1>
</tr>
<tr>
<td>
<span class="info" title="Whether to show task scores in the overview page for contestants."></span>
<label for="show_task_scores_in_overview">Show task scores in overview page</label>
</td>
<td>
<input type="checkbox" id="show_task_scores_in_overview" name="show_task_scores_in_overview" {{ "checked" if contest.show_task_scores_in_overview else "" }}/>
</td>
</tr>
<tr>
<td>
<span class="info" title="Whether to show task scores next to task names in the sidebar for contestants."></span>
<label for="show_task_scores_in_sidebar">Show task scores in sidebar</label>
</td>
<td>
<input type="checkbox" id="show_task_scores_in_sidebar" name="show_task_scores_in_sidebar" {{ "checked" if contest.show_task_scores_in_sidebar else "" }}/>
</td>
<span class="info" title="Timezone of the server, used to display start, end times and the current server time to contestants.
Example: 'Europe/Rome', 'America/New_York', ..."></span>
Timezone
Expand Down
106 changes: 106 additions & 0 deletions cms/server/contest/handlers/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@
collections.MutableMapping = collections.abc.MutableMapping

import tornado.web
from sqlalchemy.orm import joinedload, selectinload

from cms import config, TOKEN_MODE_MIXED
from cms.db import Contest, Submission, Task, UserTest
from cms.grading.scoring import task_score
from cms.locale import filter_language_codes
from cms.server import FileHandlerMixin
from cms.server.contest.authentication import authenticate_request
Expand Down Expand Up @@ -193,6 +195,93 @@ def get_current_user(self) -> Participation | None:
self.impersonated_by_admin = impersonated
return participation

def _load_participation_for_scores(
self, participation: Participation
) -> Participation | None:
"""Load participation with relationships needed for task score computation."""
return (
self.sql_session.query(Participation)
.filter(Participation.id == participation.id)
.options(
joinedload(Participation.user),
joinedload(Participation.contest)
.joinedload(Contest.tasks)
.joinedload(Task.active_dataset),
selectinload(Participation.submissions).joinedload(Submission.token),
selectinload(Participation.submissions).joinedload(Submission.results),
)
.first()
)

def _compute_task_scores(
self,
participation: Participation,
*,
actual_phase: int,
hide_zero_max_public: bool = True,
) -> dict[int, tuple[float, float, str]]:
"""Compute per-task scores for UI task lists.

By default, this shows public scores. If a token has been played on a
task (or we're in analysis mode), it shows the tokened/total score for
that task instead.
"""
task_scores: dict[int, tuple[float, float, str]] = {}
tokened_task_ids = {
s.task_id for s in participation.submissions if s.official and s.tokened()
}

for task in participation.contest.tasks:
score_type = task.active_dataset.score_type_object

has_tokened_submission = task.id in tokened_task_ids
show_tokened_total = (
score_type.max_public_score < score_type.max_score
and (has_tokened_submission or actual_phase == 3)
)

if show_tokened_total:
if actual_phase == 3:
# In analysis mode users can see full scores, so do not
# restrict to tokened submissions.
score_value, _ = task_score(participation, task, rounded=True)
else:
score_value, _ = task_score(
participation, task, only_tokened=True, rounded=True
)
max_score_value = round(score_type.max_score, task.score_precision)
score_message = score_type.format_score(
score_value,
score_type.max_score,
None,
task.score_precision,
translation=self.translation,
)
else:
max_public_score = round(
score_type.max_public_score, task.score_precision
)

# Optionally hide entries with no public score.
if hide_zero_max_public and max_public_score <= 0:
continue

score_value, _ = task_score(
participation, task, public=True, rounded=True
)
max_score_value = max_public_score
score_message = score_type.format_score(
score_value,
score_type.max_public_score,
None,
task.score_precision,
translation=self.translation,
)

task_scores[task.id] = (score_value, max_score_value, score_message)

return task_scores

def render_params(self):
ret = super().render_params()

Expand Down Expand Up @@ -233,6 +322,23 @@ def render_params(self):
# set the timezone used to format timestamps
ret["timezone"] = get_timezone(participation.user, self.contest)

if self.contest.show_task_scores_in_sidebar and (
ret["actual_phase"] >= 0 or participation.unrestricted
):
loaded_participation = self._load_participation_for_scores(participation)
if loaded_participation is not None:
# Keep references synchronized with the fully loaded objects.
participation = loaded_participation
self.contest = participation.contest
ret["contest"] = self.contest
ret["participation"] = participation
ret["user"] = participation.user
ret["sidebar_task_scores"] = self._compute_task_scores(
participation,
actual_phase=ret["actual_phase"],
hide_zero_max_public=True,
)

# some information about token configuration
ret["tokens_contest"] = self.contest.token_mode

Expand Down
33 changes: 33 additions & 0 deletions cms/server/contest/handlers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
# Copyright © 2014 Fabian Gundlach <320pointsguy@gmail.com>
# Copyright © 2015-2018 William Di Luigi <williamdiluigi@gmail.com>
# Copyright © 2021 Grace Hawkins <amoomajid99@gmail.com>
# Copyright © 2025 Pasit Sangprachathanarak <ouipingpasit@gmail.com>
# Copyright © 2025 kk@cscmu-cnx
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
Expand Down Expand Up @@ -76,6 +78,37 @@ class MainHandler(ContestHandler):
def get(self):
self.render("overview.html", **self.r_params)

def render_params(self):
ret = super().render_params()

if self.current_user is not None:
participation = ret["participation"]

# ContestHandler may have already loaded a fully-joined participation
# while computing sidebar scores. Reuse it to avoid a duplicate query.
already_preloaded_for_scores = "sidebar_task_scores" in ret
if self.contest.show_task_scores_in_overview:
if not already_preloaded_for_scores:
loaded_participation = self._load_participation_for_scores(
participation
)
if loaded_participation is None:
return ret
participation = loaded_participation

self.contest = participation.contest
# Ensure the template sees this fully-loaded version.
ret["contest"] = self.contest
ret["participation"] = participation
ret["user"] = participation.user

ret["task_scores"] = self._compute_task_scores(
participation,
actual_phase=ret["actual_phase"],
hide_zero_max_public=False,
)

return ret

class RegistrationHandler(ContestHandler):
"""Registration handler.
Expand Down
15 changes: 11 additions & 4 deletions cms/server/contest/handlers/tasksubmission.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,9 @@ def add_task_score(self, participation: Participation, task: Task, data: dict):
task: task for which we want the score.
data: where to put the data; all fields will start with "task",
followed by "public" if referring to the public scores, or
"tokened" if referring to the total score (always limited to
tokened submissions); for both public and tokened, the fields are:
"tokened" if referring to the total score (limited to tokened
submissions during contest, full score in analysis mode); for both
public and tokened, the fields are:
"score" and "score_message"; in addition we have
"task_is_score_partial" as partial info is the same for both.

Expand All @@ -222,8 +223,14 @@ def add_task_score(self, participation: Participation, task: Task, data: dict):
.all()
data["task_public_score"], public_score_is_partial = \
task_score(participation, task, public=True, rounded=True)
data["task_tokened_score"], tokened_score_is_partial = \
task_score(participation, task, only_tokened=True, rounded=True)
if self.r_params["actual_phase"] == 3:
data["task_tokened_score"], tokened_score_is_partial = task_score(
participation, task, rounded=True
)
else:
data["task_tokened_score"], tokened_score_is_partial = task_score(
participation, task, only_tokened=True, rounded=True
)
# These two should be the same, anyway.
data["task_score_is_partial"] = \
public_score_is_partial or tokened_score_is_partial
Expand Down
33 changes: 27 additions & 6 deletions cms/server/contest/static/cws_style.css
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,21 @@ td.token_rules p:last-child {
background-color: hsla(120, 100%, 50%, 0.4);
}

.nav-list .nav-header .task_score_badge {
float: right;
margin-right: 5px;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
line-height: 14px;
font-weight: bold;
color: #333;
text-transform: none;
}
.nav-list .nav-header .task_score_badge.undefined {
color: #888;
background-color: transparent;
}
/*** Submit a solution */

#submit_solution {
Expand Down Expand Up @@ -559,27 +574,33 @@ td.token_rules p:last-child {
color: #AAA;
}

.submission_list td.public_score.score_0 {
.submission_list td.public_score.score_0,
.main_task_list td.public_score.score_0 {
background-color: hsla(0, 100%, 50%, 0.4);
}

.submission_list tr:hover td.public_score.score_0 {
.submission_list tr:hover td.public_score.score_0,
.main_task_list tr:hover td.public_score.score_0 {
background-color: hsla(0, 100%, 50%, 0.5);
}

.submission_list td.public_score.score_0_100 {
.submission_list td.public_score.score_0_100,
.main_task_list td.public_score.score_0_100 {
background-color: hsla(60, 100%, 50%, 0.4);
}

.submission_list tr:hover td.public_score.score_0_100 {
.submission_list tr:hover td.public_score.score_0_100,
.main_task_list tr:hover td.public_score.score_0_100 {
background-color: hsla(60, 100%, 50%, 0.5);
}

.submission_list td.public_score.score_100 {
.submission_list td.public_score.score_100,
.main_task_list td.public_score.score_100 {
background-color: hsla(120, 100%, 50%, 0.4);
}

.submission_list tr:hover td.public_score.score_100 {
.submission_list tr:hover td.public_score.score_100,
.main_task_list tr:hover td.public_score.score_100 {
background-color: hsla(120, 100%, 50%, 0.5);
}

Expand Down
13 changes: 11 additions & 2 deletions cms/server/contest/templates/contest.html
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,17 @@ <h3 id="countdown_box">
</li>
{% if actual_phase >= 0 or participation.unrestricted %}
{% for t_iter in contest.tasks %}
<li class="nav-header">
{{ t_iter.name }}
<li class="nav-header" data-task-name="{{ t_iter.name }}">
<span>{{ t_iter.name }}</span>
{% if contest.show_task_scores_in_sidebar %}
{% if sidebar_task_scores is defined and t_iter.id in sidebar_task_scores %}
<span class="task_score_badge task_score {{ get_score_class(sidebar_task_scores[t_iter.id][0], sidebar_task_scores[t_iter.id][1], t_iter.score_precision) }}">
{{ sidebar_task_scores[t_iter.id][2] }}
</span>
{% else %}
<span class="task_score_badge task_score score_0">0 / 0</span>
{% endif %}
{% endif %}
</li>
<li{% if page == "task_description" and task == t_iter %} class="active"{% endif %}>
<a href="{{ contest_url("tasks", t_iter.name, "description") }}">{% trans %}Statement{% endtrans %}</a>
Expand Down
8 changes: 7 additions & 1 deletion cms/server/contest/templates/overview.html
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ <h2>{% trans %}General information{% endtrans %}</h2>
{% if actual_phase >= 0 or participation.unrestricted %}
<h2>{% trans %}Task overview{% endtrans %}</h2>

<table class="table table-bordered table-striped">
<table class="main_task_list table table-bordered table-striped">
<!-- <colgroup>
<col class="task"/>
<col class="time_limit"/>
Expand All @@ -193,6 +193,9 @@ <h2>{% trans %}Task overview{% endtrans %}</h2>
</colgroup> -->
<thead>
<tr>
{% if contest.show_task_scores_in_overview and task_scores is defined%}
<th>{% trans %}Score{% endtrans %}</th>
{% endif %}
<th>{% trans %}Task{% endtrans %}</th>
<th>{% trans %}Name{% endtrans %}</th>
<th>{% trans %}Time limit{% endtrans %}</th>
Expand All @@ -209,6 +212,9 @@ <h2>{% trans %}Task overview{% endtrans %}</h2>
{% set task_allowed_languages = t_iter.get_allowed_languages() %}
{% set extensions = "[%s]"|format(task_allowed_languages|map("to_language")|map(attribute="source_extension")|unique|join("|")) %}
<tr>
{% if contest.show_task_scores_in_overview and task_scores is defined %}
<td class="public_score {{ get_score_class(task_scores[t_iter.id][0], task_scores[t_iter.id][1], t_iter.score_precision) }}">{{ task_scores[t_iter.id][2] }}</td>
{% endif %}
<th>{{ t_iter.name }}</th>
<td>{{ t_iter.title }}</td>
<td>
Expand Down
Loading
Loading