Skip to content

Commit 1b0b0a1

Browse files
authored
Added an option to display task scores in the contest sidebar
Add an option to display task scores in the contest sidebar and for both the overview and the sidebard task scores, when a tokened score is availble it will use that score, if not it will use the public score, if there is no public score it will show 0/0 (considering to change to N/A)
2 parents 98a11b7 + ba352bb commit 1b0b0a1

10 files changed

Lines changed: 225 additions & 51 deletions

File tree

cms/db/contest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ class Contest(Base):
111111
# Whether to show task scores in the overview page
112112
show_task_scores_in_overview: bool = Column(Boolean, nullable=False, default=True)
113113

114+
# Whether to show task scores in the sidebar task list.
115+
show_task_scores_in_sidebar: bool = Column(Boolean, nullable=False, default=True)
116+
114117
# Whether to prevent hidden participations to log in.
115118
block_hidden_participations: bool = Column(
116119
Boolean,

cms/server/admin/handlers/contest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ def post(self, contest_id: str):
9898
self.get_bool(attrs, "allow_user_tests")
9999
self.get_bool(attrs, "allow_unofficial_submission_before_analysis_mode")
100100
self.get_bool(attrs, "show_task_scores_in_overview")
101+
self.get_bool(attrs, "show_task_scores_in_sidebar")
101102
self.get_bool(attrs, "block_hidden_participations")
102103
self.get_bool(attrs, "allow_password_authentication")
103104
self.get_bool(attrs, "allow_registration")

cms/server/admin/templates/contest.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ <h1>Contest configuration</h1>
9999
<input type="checkbox" id="show_task_scores_in_overview" name="show_task_scores_in_overview" {{ "checked" if contest.show_task_scores_in_overview else "" }}/>
100100
</td>
101101
</tr>
102+
<tr>
103+
<td>
104+
<span class="info" title="Whether to show task scores next to task names in the sidebar for contestants."></span>
105+
<label for="show_task_scores_in_sidebar">Show task scores in sidebar</label>
106+
</td>
107+
<td>
108+
<input type="checkbox" id="show_task_scores_in_sidebar" name="show_task_scores_in_sidebar" {{ "checked" if contest.show_task_scores_in_sidebar else "" }}/>
109+
</td>
110+
</tr>
102111

103112
<tr><td colspan=2><h2>Logging in</h2></td></tr>
104113
<tr>

cms/server/contest/handlers/contest.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,11 @@
4848
collections.MutableMapping = collections.abc.MutableMapping
4949

5050
import tornado.web
51+
from sqlalchemy.orm import joinedload, selectinload
5152

5253
from cms import config, TOKEN_MODE_MIXED
5354
from cms.db import Contest, Submission, Task, UserTest
55+
from cms.grading.scoring import task_score
5456
from cms.locale import filter_language_codes
5557
from cms.server import FileHandlerMixin
5658
from cms.server.contest.authentication import authenticate_request
@@ -193,6 +195,93 @@ def get_current_user(self) -> Participation | None:
193195
self.impersonated_by_admin = impersonated
194196
return participation
195197

198+
def _load_participation_for_scores(
199+
self, participation: Participation
200+
) -> Participation | None:
201+
"""Load participation with relationships needed for task score computation."""
202+
return (
203+
self.sql_session.query(Participation)
204+
.filter(Participation.id == participation.id)
205+
.options(
206+
joinedload(Participation.user),
207+
joinedload(Participation.contest)
208+
.joinedload(Contest.tasks)
209+
.joinedload(Task.active_dataset),
210+
selectinload(Participation.submissions).joinedload(Submission.token),
211+
selectinload(Participation.submissions).joinedload(Submission.results),
212+
)
213+
.first()
214+
)
215+
216+
def _compute_task_scores(
217+
self,
218+
participation: Participation,
219+
*,
220+
actual_phase: int,
221+
hide_zero_max_public: bool = True,
222+
) -> dict[int, tuple[float, float, str]]:
223+
"""Compute per-task scores for UI task lists.
224+
225+
By default, this shows public scores. If a token has been played on a
226+
task (or we're in analysis mode), it shows the tokened/total score for
227+
that task instead.
228+
"""
229+
task_scores: dict[int, tuple[float, float, str]] = {}
230+
tokened_task_ids = {
231+
s.task_id for s in participation.submissions if s.official and s.tokened()
232+
}
233+
234+
for task in participation.contest.tasks:
235+
score_type = task.active_dataset.score_type_object
236+
237+
has_tokened_submission = task.id in tokened_task_ids
238+
show_tokened_total = (
239+
score_type.max_public_score < score_type.max_score
240+
and (has_tokened_submission or actual_phase == 3)
241+
)
242+
243+
if show_tokened_total:
244+
if actual_phase == 3:
245+
# In analysis mode users can see full scores, so do not
246+
# restrict to tokened submissions.
247+
score_value, _ = task_score(participation, task, rounded=True)
248+
else:
249+
score_value, _ = task_score(
250+
participation, task, only_tokened=True, rounded=True
251+
)
252+
max_score_value = round(score_type.max_score, task.score_precision)
253+
score_message = score_type.format_score(
254+
score_value,
255+
score_type.max_score,
256+
None,
257+
task.score_precision,
258+
translation=self.translation,
259+
)
260+
else:
261+
max_public_score = round(
262+
score_type.max_public_score, task.score_precision
263+
)
264+
265+
# Optionally hide entries with no public score.
266+
if hide_zero_max_public and max_public_score <= 0:
267+
continue
268+
269+
score_value, _ = task_score(
270+
participation, task, public=True, rounded=True
271+
)
272+
max_score_value = max_public_score
273+
score_message = score_type.format_score(
274+
score_value,
275+
score_type.max_public_score,
276+
None,
277+
task.score_precision,
278+
translation=self.translation,
279+
)
280+
281+
task_scores[task.id] = (score_value, max_score_value, score_message)
282+
283+
return task_scores
284+
196285
def render_params(self):
197286
ret = super().render_params()
198287

@@ -230,6 +319,23 @@ def render_params(self):
230319
# set the timezone used to format timestamps
231320
ret["timezone"] = get_timezone(participation.user, self.contest)
232321

322+
if self.contest.show_task_scores_in_sidebar and (
323+
ret["actual_phase"] >= 0 or participation.unrestricted
324+
):
325+
loaded_participation = self._load_participation_for_scores(participation)
326+
if loaded_participation is not None:
327+
# Keep references synchronized with the fully loaded objects.
328+
participation = loaded_participation
329+
self.contest = participation.contest
330+
ret["contest"] = self.contest
331+
ret["participation"] = participation
332+
ret["user"] = participation.user
333+
ret["sidebar_task_scores"] = self._compute_task_scores(
334+
participation,
335+
actual_phase=ret["actual_phase"],
336+
hide_zero_max_public=True,
337+
)
338+
233339
# some information about token configuration
234340
ret["tokens_contest"] = self.contest.token_mode
235341

cms/server/contest/handlers/main.py

Lines changed: 23 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,11 @@
4848

4949
import tornado.web
5050
from sqlalchemy.orm.exc import NoResultFound
51-
from sqlalchemy.orm import joinedload
5251

5352
from cms import config
54-
from cms.db import User, Participation, Team, Submission, Task
53+
from cms.db import User, Participation, Team
5554
from cms.grading.languagemanager import get_language
5655
from cms.grading.steps import COMPILATION_MESSAGES, EVALUATION_MESSAGES
57-
from cms.grading.scoring import task_score
5856
from cms.server import multi_contest
5957
from cms.server.contest.authentication import validate_login
6058
from cms.server.contest.communication import get_communications
@@ -84,51 +82,31 @@ def render_params(self):
8482
ret = super().render_params()
8583

8684
if self.current_user is not None:
87-
# This massive joined load gets all the information which we will need
88-
participation = (
89-
self.sql_session.query(Participation)
90-
.filter(Participation.id == self.current_user.id)
91-
.options(
92-
joinedload(Participation.user),
93-
joinedload(Participation.contest)
94-
.joinedload(Contest.tasks)
95-
.joinedload(Task.active_dataset),
96-
joinedload(Participation.submissions).joinedload(Submission.token),
97-
joinedload(Participation.submissions).joinedload(
98-
Submission.results
99-
),
100-
)
101-
.first()
102-
)
103-
104-
self.contest = participation.contest
105-
# Ensure the template sees this fully-loaded version
106-
ret["contest"] = self.contest
107-
ret["participation"] = participation
85+
participation = ret["participation"]
10886

109-
# Compute public scores for all tasks only if they will be shown
87+
# ContestHandler may have already loaded a fully-joined participation
88+
# while computing sidebar scores. Reuse it to avoid a duplicate query.
89+
already_preloaded_for_scores = "sidebar_task_scores" in ret
11090
if self.contest.show_task_scores_in_overview:
111-
task_scores = {}
112-
for task in self.contest.tasks:
113-
score_type = task.active_dataset.score_type_object
114-
max_public_score = round(
115-
score_type.max_public_score, task.score_precision
91+
if not already_preloaded_for_scores:
92+
loaded_participation = self._load_participation_for_scores(
93+
participation
11694
)
117-
public_score, _ = task_score(
118-
participation, task, public=True, rounded=True
119-
)
120-
task_scores[task.id] = (
121-
public_score,
122-
max_public_score,
123-
score_type.format_score(
124-
public_score,
125-
score_type.max_public_score,
126-
None,
127-
task.score_precision,
128-
translation=self.translation,
129-
),
130-
)
131-
ret["task_scores"] = task_scores
95+
if loaded_participation is None:
96+
return ret
97+
participation = loaded_participation
98+
99+
self.contest = participation.contest
100+
# Ensure the template sees this fully-loaded version.
101+
ret["contest"] = self.contest
102+
ret["participation"] = participation
103+
ret["user"] = participation.user
104+
105+
ret["task_scores"] = self._compute_task_scores(
106+
participation,
107+
actual_phase=ret["actual_phase"],
108+
hide_zero_max_public=False,
109+
)
132110

133111
return ret
134112

cms/server/contest/handlers/tasksubmission.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,9 @@ def add_task_score(self, participation: Participation, task: Task, data: dict):
207207
task: task for which we want the score.
208208
data: where to put the data; all fields will start with "task",
209209
followed by "public" if referring to the public scores, or
210-
"tokened" if referring to the total score (always limited to
211-
tokened submissions); for both public and tokened, the fields are:
210+
"tokened" if referring to the total score (limited to tokened
211+
submissions during contest, full score in analysis mode); for both
212+
public and tokened, the fields are:
212213
"score" and "score_message"; in addition we have
213214
"task_is_score_partial" as partial info is the same for both.
214215
@@ -222,8 +223,14 @@ def add_task_score(self, participation: Participation, task: Task, data: dict):
222223
.all()
223224
data["task_public_score"], public_score_is_partial = \
224225
task_score(participation, task, public=True, rounded=True)
225-
data["task_tokened_score"], tokened_score_is_partial = \
226-
task_score(participation, task, only_tokened=True, rounded=True)
226+
if self.r_params["actual_phase"] == 3:
227+
data["task_tokened_score"], tokened_score_is_partial = task_score(
228+
participation, task, rounded=True
229+
)
230+
else:
231+
data["task_tokened_score"], tokened_score_is_partial = task_score(
232+
participation, task, only_tokened=True, rounded=True
233+
)
227234
# These two should be the same, anyway.
228235
data["task_score_is_partial"] = \
229236
public_score_is_partial or tokened_score_is_partial

cms/server/contest/static/cws_style.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,21 @@ td.token_rules p:last-child {
462462
background-color: hsla(120, 100%, 50%, 0.4);
463463
}
464464

465+
.nav-list .nav-header .task_score_badge {
466+
float: right;
467+
margin-right: 5px;
468+
padding: 1px 6px;
469+
border-radius: 4px;
470+
font-size: 10px;
471+
line-height: 14px;
472+
font-weight: bold;
473+
color: #333;
474+
text-transform: none;
475+
}
476+
.nav-list .nav-header .task_score_badge.undefined {
477+
color: #888;
478+
background-color: transparent;
479+
}
465480
/*** Submit a solution */
466481

467482
#submit_solution {

cms/server/contest/templates/contest.html

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,17 @@ <h3 id="countdown_box">
179179
</li>
180180
{% if actual_phase >= 0 or participation.unrestricted %}
181181
{% for t_iter in contest.tasks %}
182-
<li class="nav-header">
183-
{{ t_iter.name }}
182+
<li class="nav-header" data-task-name="{{ t_iter.name }}">
183+
<span>{{ t_iter.name }}</span>
184+
{% if contest.show_task_scores_in_sidebar %}
185+
{% if sidebar_task_scores is defined and t_iter.id in sidebar_task_scores %}
186+
<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) }}">
187+
{{ sidebar_task_scores[t_iter.id][2] }}
188+
</span>
189+
{% else %}
190+
<span class="task_score_badge task_score score_0">0 / 0</span>
191+
{% endif %}
192+
{% endif %}
184193
</li>
185194
<li{% if page == "task_description" and task == t_iter %} class="active"{% endif %}>
186195
<a href="{{ contest_url("tasks", t_iter.name, "description") }}">{% trans %}Statement{% endtrans %}</a>

cms/server/contest/templates/task_submissions.html

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
{% endfor %}
3636
};
3737

38+
// If any existing submission has a played token, sidebar should show tokened
39+
// total score for this task.
40+
var sidebar_use_tokened_score = {% if submissions|selectattr("token")|list|length > 0 %}true{% else %}false{% endif %};
41+
3842
$(document).on("click", ".submission_list tbody tr td.status .details", function (event) {
3943
var submission_id = $(this).parent().parent().attr("data-submission");
4044
var modal = $("#submission_detail");
@@ -117,6 +121,23 @@
117121
task_score_elem.addClass(get_score_class(task_score, max_score));
118122
};
119123

124+
update_sidebar_task_score = function(task_score, task_score_message, max_score) {
125+
var task_header = $('.nav-list li.nav-header[data-task-name="{{ task.name }}"]');
126+
if (task_header.length === 0) {
127+
return;
128+
}
129+
130+
var badge = task_header.find('.task_score_badge');
131+
if (badge.length === 0) {
132+
return;
133+
}
134+
135+
badge.removeClass('undefined score_0 score_0_100 score_100');
136+
badge.addClass(get_score_class(task_score, max_score));
137+
badge.text(task_score_message);
138+
badge.show();
139+
};
140+
120141
update_scores = function (submission_id, data) {
121142
var row = $(".submission_list tbody tr[data-submission=\"" + submission_id + "\"]");
122143
row.attr("data-status", data["status"]);
@@ -135,6 +156,29 @@
135156
data["public_score"], data["public_score_message"],
136157
data["task_public_score"], data["task_public_score_message"],
137158
data["task_score_is_partial"], data["max_public_score"]);
159+
160+
// If we can see full score for at least one submission (token played or
161+
// analysis), switch sidebar to tokened total score.
162+
if (data["score"] !== undefined) {
163+
sidebar_use_tokened_score = true;
164+
}
165+
166+
if (sidebar_use_tokened_score
167+
&& data["task_tokened_score"] !== undefined
168+
&& data["task_tokened_score_message"] !== undefined
169+
&& data["max_score"] !== undefined) {
170+
update_sidebar_task_score(
171+
data["task_tokened_score"],
172+
data["task_tokened_score_message"],
173+
data["max_score"]);
174+
} else if (data["task_public_score"] !== undefined
175+
&& data["task_public_score_message"] !== undefined
176+
&& data["max_public_score"] !== undefined) {
177+
update_sidebar_task_score(
178+
data["task_public_score"],
179+
data["task_public_score_message"],
180+
data["max_public_score"]);
181+
}
138182
{% if can_use_tokens %}
139183
update_score(
140184
row.children("td.total_score"), $("#task_score_tokened"),

cmscontrib/updaters/update_from_1.5.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ ALTER TABLE user_test_results DROP COLUMN evaluation_sandbox;
4444

4545
-- https://github.com/cms-dev/cms/pull/1476
4646
ALTER TABLE contests ADD COLUMN show_task_scores_in_overview boolean NOT NULL DEFAULT true;
47+
ALTER TABLE contests ADD COLUMN show_task_scores_in_sidebar boolean NOT NULL DEFAULT true;
4748
ALTER TABLE contests ALTER COLUMN show_task_scores_in_overview DROP DEFAULT;
49+
ALTER TABLE contests ALTER COLUMN show_task_scores_in_sidebar DROP DEFAULT;
4850

4951
-- https://github.com/cms-dev/cms/pull/1486
5052
ALTER TABLE public.tasks ADD COLUMN allowed_languages varchar[];

0 commit comments

Comments
 (0)