diff --git a/cms/db/contest.py b/cms/db/contest.py index 0cdaadd540..060539265b 100644 --- a/cms/db/contest.py +++ b/cms/db/contest.py @@ -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, diff --git a/cms/server/admin/handlers/contest.py b/cms/server/admin/handlers/contest.py index b9dd7fa4ff..a762aef6b0 100644 --- a/cms/server/admin/handlers/contest.py +++ b/cms/server/admin/handlers/contest.py @@ -13,6 +13,7 @@ # Copyright © 2026 Tobias Lenz # Copyright © 2026 Chuyang Wang # Copyright © 2026 Jonathan Baumann +# Copyright © 2026 Pasit Sangprachathanarak # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -106,6 +107,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") diff --git a/cms/server/admin/templates/contest.html b/cms/server/admin/templates/contest.html index a82899da65..da8ee8bfdf 100644 --- a/cms/server/admin/templates/contest.html +++ b/cms/server/admin/templates/contest.html @@ -90,6 +90,24 @@

Contest configuration

+ + + + + + + + + + + + + + + + + + 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: + if task.active_dataset is None: + continue + 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: + score_value, _ = task_score( + participation, task, only_tokened=actual_phase != 3) + max_score_value = score_type.max_score + else: + max_score_value = score_type.max_public_score + if max_score_value <= 0: + continue + score_value, _ = task_score(participation, task, public=True) + + score_message = score_type.format_score( + score_value, max_score_value, None, translation=self.translation) + task_scores[task.id] = (score_value, max_score_value, score_message) + + return task_scores + + @functools.cached_property + def task_scores(self) -> dict[int, tuple[float, float, str]]: + """Load scores only when a template displays them, once per request.""" + participation = self._load_participation_for_scores(self.current_user) + if participation is None: + return {} + return self._compute_task_scores( + participation, actual_phase=self.r_params["actual_phase"]) + def render_params(self): ret = super().render_params() diff --git a/cms/server/contest/handlers/main.py b/cms/server/contest/handlers/main.py index 012b835fc2..51590db3c5 100644 --- a/cms/server/contest/handlers/main.py +++ b/cms/server/contest/handlers/main.py @@ -10,6 +10,8 @@ # Copyright © 2014 Fabian Gundlach <320pointsguy@gmail.com> # Copyright © 2015-2018 William Di Luigi # Copyright © 2021 Grace Hawkins +# Copyright © 2025 Pasit Sangprachathanarak +# 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 diff --git a/cms/server/contest/handlers/tasksubmission.py b/cms/server/contest/handlers/tasksubmission.py index 6288f45bd2..ecc0bb1a1e 100644 --- a/cms/server/contest/handlers/tasksubmission.py +++ b/cms/server/contest/handlers/tasksubmission.py @@ -145,7 +145,7 @@ def get(self, task_name): public_score, is_public_score_partial = task_score( participation, task, public=True) tokened_score, is_tokened_score_partial = task_score( - participation, task, only_tokened=True) + participation, task, only_tokened=self.r_params["actual_phase"] != 3) # These two should be the same, anyway. is_score_partial = is_public_score_partial or is_tokened_score_partial @@ -207,10 +207,12 @@ 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. + "task_use_tokened_score" selects the full score for task badges. """ # Just to preload all information required to compute the task score. @@ -223,7 +225,11 @@ def add_task_score(self, participation: Participation, task: Task, data: dict): data["task_public_score"], public_score_is_partial = \ task_score(participation, task, public=True) data["task_tokened_score"], tokened_score_is_partial = \ - task_score(participation, task, only_tokened=True) + task_score(participation, task, + only_tokened=self.r_params["actual_phase"] != 3) + data["task_use_tokened_score"] = self.r_params["actual_phase"] == 3 or any( + s.official and s.task_id == task.id and s.tokened() + for s in participation.submissions) # These two should be the same, anyway. data["task_score_is_partial"] = \ public_score_is_partial or tokened_score_is_partial diff --git a/cms/server/contest/static/cws_style.css b/cms/server/contest/static/cws_style.css index d896a00f7a..f41e75765d 100644 --- a/cms/server/contest/static/cws_style.css +++ b/cms/server/contest/static/cws_style.css @@ -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 { @@ -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); } diff --git a/cms/server/contest/templates/contest.html b/cms/server/contest/templates/contest.html index c9bf4b4870..b4e67e634a 100644 --- a/cms/server/contest/templates/contest.html +++ b/cms/server/contest/templates/contest.html @@ -178,9 +178,21 @@

{% if actual_phase >= 0 or participation.unrestricted %} + {% if contest.show_task_scores_in_sidebar %} + {% set sidebar_task_scores = handler.task_scores %} + {% endif %} {% for t_iter in contest.tasks %} - {% trans %}Statement{% endtrans %} diff --git a/cms/server/contest/templates/overview.html b/cms/server/contest/templates/overview.html index 2630009bae..c70aac048d 100644 --- a/cms/server/contest/templates/overview.html +++ b/cms/server/contest/templates/overview.html @@ -180,9 +180,12 @@

{% trans %}General information{% endtrans %}

{% if actual_phase >= 0 or participation.unrestricted %} + {% if contest.show_task_scores_in_overview %} + {% set task_scores = handler.task_scores %} + {% endif %}

{% trans %}Task overview{% endtrans %}

- +
+{% if contest.show_task_scores_in_overview and task_scores is defined%} + +{% endif %} @@ -209,23 +215,30 @@

{% trans %}Task overview{% endtrans %}

{% 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("|")) %} +{% if contest.show_task_scores_in_overview and task_scores is defined %} + {% if t_iter.id in task_scores %} + + {% else %} + + {% endif %} +{% endif %} - + {% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %}
{% trans %}Score{% endtrans %}{% trans %}Task{% endtrans %} {% trans %}Name{% endtrans %} {% trans %}Time limit{% endtrans %}
{{ task_scores[t_iter.id][2] }}{% trans %}N/A{% endtrans %}{{ t_iter.name }} {{ t_iter.title }} - {% if t_iter.active_dataset.time_limit is not none %} + {% if t_iter.active_dataset is not none and t_iter.active_dataset.time_limit is not none %} {{ t_iter.active_dataset.time_limit|format_duration(length="long") }} {% else %} {% trans %}N/A{% endtrans %} {% endif %} - {% if t_iter.active_dataset.memory_limit is not none %} + {% if t_iter.active_dataset is not none and t_iter.active_dataset.memory_limit is not none %} {{ t_iter.active_dataset.memory_limit|format_size }} {% else %} {% trans %}N/A{% endtrans %} {% endif %} {{ get_task_type(dataset=t_iter.active_dataset).name }}{% if t_iter.active_dataset is not none %}{{ get_task_type(dataset=t_iter.active_dataset).name }}{% else %}{% trans %}N/A{% endtrans %}{% endif %} {{ t_iter.submission_format|map("replace", ".%l", extensions)|join(" ") }} diff --git a/cms/server/contest/templates/task_submissions.html b/cms/server/contest/templates/task_submissions.html index 6ffc3d1ba3..22b6d508b1 100644 --- a/cms/server/contest/templates/task_submissions.html +++ b/cms/server/contest/templates/task_submissions.html @@ -117,6 +117,23 @@ task_score_elem.addClass(get_score_class(task_score, max_score)); }; +update_sidebar_task_score = function(task_score, task_score_message, max_score) { + var task_header = $('.nav-list li.nav-header[data-task-name="{{ task.name }}"]'); + if (task_header.length === 0) { + return; + } + + var badge = task_header.find('.task_score_badge'); + if (badge.length === 0) { + return; + } + + badge.removeClass('undefined score_0 score_0_100 score_100'); + badge.addClass(task_score === undefined ? 'undefined' : get_score_class(task_score, max_score)); + badge.text(task_score_message); + badge.show(); +}; + update_scores = function (submission_id, data) { var row = $(".submission_list tbody tr[data-submission=\"" + submission_id + "\"]"); row.attr("data-status", data["status"]); @@ -135,7 +152,26 @@ data["public_score"], data["public_score_message"], data["task_public_score"], data["task_public_score_message"], data["task_score_is_partial"], data["max_public_score"]); -{% if can_use_tokens %} + + if (data["task_use_tokened_score"] + && data["task_tokened_score"] !== undefined + && data["task_tokened_score_message"] !== undefined + && data["max_score"] !== undefined) { + update_sidebar_task_score( + data["task_tokened_score"], + data["task_tokened_score_message"], + data["max_score"]); + } else if (data["task_public_score"] !== undefined + && data["task_public_score_message"] !== undefined + && data["max_public_score"] !== undefined) { + update_sidebar_task_score( + data["task_public_score"], + data["task_public_score_message"], + data["max_public_score"]); + } else { + update_sidebar_task_score(undefined, {{ gettext("N/A")|tojson }}, undefined); + } +{% if can_use_tokens or actual_phase == 3 %} update_score( row.children("td.total_score"), $("#task_score_tokened"), data["score"], data["score_message"], @@ -214,9 +250,9 @@

{% trans name=task.title, short_name=task.name %}{{ name }} ({{ short_name } {% if score_type.max_public_score < score_type.max_score %} {# Show the tokened score (alone if everything is non-public, or together with the public score). #}
+ class="{{ "span6" if two_task_scores else "span12" }} well well-small task_score {{ get_score_class(tokened_score, score_type.max_score) if can_use_tokens or actual_phase == 3 else "undefined" }}"> - {% if can_use_tokens %} + {% if can_use_tokens and actual_phase != 3 %} {% trans %}Score of tokened submissions:{% endtrans %} {% else %} {% trans %}Total score:{% endtrans %} @@ -224,7 +260,7 @@

{% trans name=task.title, short_name=task.name %}{{ name }} ({{ short_name }
- {% if can_use_tokens %} + {% if can_use_tokens or actual_phase == 3 %} {{ score_type.format_score(tokened_score, score_type.max_score, none, translation=translation) }} {% if is_score_partial %} diff --git a/cmscontrib/updaters/update_from_1.5.sql b/cmscontrib/updaters/update_from_1.5.sql index 57931e7718..1014e0758c 100644 --- a/cmscontrib/updaters/update_from_1.5.sql +++ b/cmscontrib/updaters/update_from_1.5.sql @@ -42,6 +42,12 @@ ALTER TABLE user_test_results ADD COLUMN evaluation_sandbox_digests VARCHAR[]; UPDATE user_test_results SET evaluation_sandbox_paths = string_to_array(evaluation_sandbox, ':'); ALTER TABLE user_test_results DROP COLUMN evaluation_sandbox; +-- https://github.com/cms-dev/cms/pull/1476 +ALTER TABLE contests ADD COLUMN show_task_scores_in_overview boolean NOT NULL DEFAULT true; +ALTER TABLE contests ADD COLUMN show_task_scores_in_sidebar boolean NOT NULL DEFAULT true; +ALTER TABLE contests ALTER COLUMN show_task_scores_in_overview DROP DEFAULT; +ALTER TABLE contests ALTER COLUMN show_task_scores_in_sidebar DROP DEFAULT; + -- https://github.com/cms-dev/cms/pull/1486 ALTER TABLE public.tasks ADD COLUMN allowed_languages varchar[]; diff --git a/cmstestsuite/unit_tests/server/contest/task_scores_test.py b/cmstestsuite/unit_tests/server/contest/task_scores_test.py new file mode 100644 index 0000000000..b6e5ba84a3 --- /dev/null +++ b/cmstestsuite/unit_tests/server/contest/task_scores_test.py @@ -0,0 +1,182 @@ +"""Regression checks for task counters and their polling updates.""" + +from datetime import datetime, timedelta +import json +import shutil +import subprocess +from types import SimpleNamespace as NS +from unittest.mock import MagicMock, patch + +from bs4 import BeautifulSoup +import pytest + +from cms import TOKEN_MODE_DISABLED +from cms.db import SubmissionResult +from cms.locale import DEFAULT_TRANSLATION +from cms.server.contest.handlers.base import BaseHandler +from cms.server.contest.handlers.tasksubmission import SubmissionStatusHandler +from cms.server.contest.jinja2_toolbox import CWS_ENVIRONMENT +from cmscommon.datetime import utc + + +def make_handler(phase=0, public_max=20, tokened=False, official=True): + score_type = NS( + max_score=100, max_public_score=public_max, + format_score=lambda score, maximum, *a, **kw: f"{score:g} / {maximum:g}") + task = NS( + id=1, name="task1", title="Task one", score_mode="max", score_precision=2, + token_mode=TOKEN_MODE_DISABLED, submission_format=[], + get_allowed_languages=lambda: [], + active_dataset=NS(score_type_object=score_type, time_limit=1, + memory_limit=1024, task_type_object=NS(name="Batch"))) + result = NS(score=80, public_score=min(80, public_max), score_details=[], + public_score_details=[], scored=lambda: True) + submission = NS(task=task, task_id=1, official=official, timestamp=1, + tokened=lambda: tokened, get_result=lambda dataset: result) + now = datetime(2026, 1, 1, 12) + group = NS(start=now - timedelta(hours=1), stop=now + timedelta(hours=1), + analysis_enabled=False, per_user_time=None, phase=lambda t: 0) + contest = NS( + name="contest", description="Contest", tasks=[task], languages=[], + show_task_scores_in_sidebar=True, show_task_scores_in_overview=True, + token_mode=TOKEN_MODE_DISABLED, allow_questions=True, allow_user_tests=True, + max_submission_number=None, max_user_test_number=None, timezone="UTC") + participation = NS( + contest=contest, submissions=[submission], group=group, unrestricted=False, + starting_time=None, delay_time=timedelta(), extra_time=timedelta(), + user=NS(username="user", first_name="", last_name="", timezone=None)) + handler = SubmissionStatusHandler.__new__(SubmissionStatusHandler) + handler._current_user = participation + handler.contest = contest + handler.translation = DEFAULT_TRANSLATION + handler.timestamp = now + handler.contest_url = lambda *parts: "/" + "/".join(parts) + handler.sql_session = MagicMock() + handler.r_params = {"actual_phase": phase} + handler._load_participation_for_scores = MagicMock(return_value=participation) + return handler, task + + +def render_overview(handler): + p = handler.current_user + translation = DEFAULT_TRANSLATION + return CWS_ENVIRONMENT.get_template("overview.html").render( + handler=handler, contest=handler.contest, participation=p, user=p.user, + phase=0, actual_phase=handler.r_params["actual_phase"], now=handler.timestamp, + current_phase_begin=p.group.start, current_phase_end=p.group.stop, + utc=utc, timezone=utc, available_translations={}, + translation=translation, gettext=translation.gettext, + ngettext=translation.ngettext, testing_enabled=False, + tokens_contest=TOKEN_MODE_DISABLED, tokens_tasks=TOKEN_MODE_DISABLED, + xsrf_form_html="", url=handler.contest_url, contest_url=handler.contest_url, + static_url=handler.contest_url) + + +def poll(handler, task): + data = {} + # Stub only the ORM query construction; execute the real scoring functions. + with patch("cms.server.contest.handlers.tasksubmission.Submission"), \ + patch("cms.server.contest.handlers.tasksubmission.joinedload"): + handler.add_task_score(handler.current_user, task, data) + return data + + +def test_initial_and_polled_scores_agree(): + for mode in ("max", "max_subtask", "max_tokened_last"): + for phase in (0, 1, 2, 3, 4): + for public_max in (0, 20, 100): + for tokened, official in ((False, True), (True, True), (True, False)): + h, task = make_handler(phase, public_max, tokened, official) + task.score_mode = mode + data = poll(h, task) + full = phase == 3 or (tokened and official) + assert data["task_use_tokened_score"] == full + if not full and public_max == 0: + assert h.task_scores == {} + continue + use_full = full and public_max < 100 + key = "task_tokened_score" if use_full else "task_public_score" + expected = (80 if use_full else min(80, public_max)) if official else 0 + assert h.task_scores[1][0] == data[key] == expected + assert h.task_scores[1][2] == data[key + "_message"] + + +def test_unofficial_token_does_not_hide_official_public_score(): + h, task = make_handler(tokened=True, official=False) + h.current_user.submissions.append(NS( + task=task, task_id=1, official=True, timestamp=2, tokened=lambda: False, + get_result=h.current_user.submissions[0].get_result)) + data = poll(h, task) + assert data["task_use_tokened_score"] is False + assert h.task_scores[1][2] == data["task_public_score_message"] == "20 / 20" + + +def test_prepare_parameters_do_not_load_scores(): + h, _ = make_handler() + with patch.object(BaseHandler, "render_params", return_value={}): + h.r_params = h.render_params() + h._load_participation_for_scores.assert_not_called() + # Neither a JSON response nor a details fragment accesses the lazy property. + CWS_ENVIRONMENT.get_template("submission_details.html").render(sr=None, details=None) + h._load_participation_for_scores.assert_not_called() + + +def test_overview_loads_once_only_when_counters_are_visible(): + for sidebar, overview, phase in ((True, True, 0), (False, True, 0), + (True, False, 0), (False, False, 0), + (True, True, -1)): + h, _ = make_handler(phase) + h.contest.show_task_scores_in_sidebar = sidebar + h.contest.show_task_scores_in_overview = overview + html = render_overview(h) + assert h._load_participation_for_scores.call_count == int( + (sidebar or overview) and phase >= 0) + if (sidebar or overview) and phase >= 0: + assert "20 / 20" in html + + +def test_missing_dataset_and_hidden_scores_render_as_unavailable(): + for missing_dataset in (False, True): + h, task = make_handler(public_max=0) + if missing_dataset: + task.active_dataset = None + assert h.task_scores == {} + soup = BeautifulSoup(render_overview(h), "html.parser") + assert soup.select_one(".task_score_badge.undefined").get_text(strip=True) == "N/A" + assert soup.select_one(".main_task_list td.public_score.undefined").get_text(strip=True) == "N/A" + assert "0 / 0" not in str(soup) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="Node.js is needed to execute polling JavaScript") +def test_polling_javascript_preserves_analysis_and_official_scores(): + template = CWS_ENVIRONMENT.get_template("task_submissions.html") + for phase, official, status in ((3, True, SubmissionResult.SCORED), + (3, True, SubmissionResult.COMPILATION_FAILED), + (0, False, SubmissionResult.SCORED)): + h, task = make_handler(phase, tokened=not official, official=official) + if not official: + h.current_user.submissions.append(NS( + task=task, task_id=1, official=True, timestamp=2, tokened=lambda: False, + get_result=h.current_user.submissions[0].get_result)) + data = poll(h, task) + data.update(status=status, status_text="Evaluated", max_score=100, + max_public_score=20) + if status == SubmissionResult.SCORED: + data["score"] = 80 + context = template.new_context(dict( + task=task, actual_phase=phase, can_use_tokens=False, + static_url=h.contest_url, gettext=DEFAULT_TRANSLATION.gettext)) + script = "".join(template.blocks["additional_js"](context)) + expected = "80 / 100" if phase == 3 else "20 / 20" + harness = """ +const assert = require('node:assert/strict'); +const chain = new Proxy(function() {}, {get: () => chain, apply: () => chain}); +global.$ = () => chain; +global.document = {}; +""" + script + """ +update_score = () => {}; +let badge; +update_sidebar_task_score = (score, message) => { badge = message; }; +""" + f"update_scores(1, {json.dumps(data)});\nassert.equal(badge, {json.dumps(expected)});" + subprocess.run(["node"], input=harness, text=True, check=True, + capture_output=True)