From bb17b1aea0c8db8ef1bd3758b805c0e95ca7731b Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Wed, 23 Jul 2025 20:50:45 +0700 Subject: [PATCH 01/26] Add a Score counter to task overview Add a Score counter to task overview --- cms/server/contest/handlers/main.py | 48 ++- cms/server/contest/static/cws_style.css | 18 +- cms/server/contest/templates/overview.html | 452 +++++++++++---------- 3 files changed, 298 insertions(+), 220 deletions(-) diff --git a/cms/server/contest/handlers/main.py b/cms/server/contest/handlers/main.py index 4f34f81c16..98d3d0300c 100644 --- a/cms/server/contest/handlers/main.py +++ b/cms/server/contest/handlers/main.py @@ -10,6 +10,7 @@ # Copyright © 2014 Fabian Gundlach <320pointsguy@gmail.com> # Copyright © 2015-2018 William Di Luigi # Copyright © 2021 Grace Hawkins +# Copyright © 2025 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 @@ -49,11 +50,13 @@ except ImportError: import tornado.web as tornado_web from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.orm import joinedload from cms import config -from cms.db import PrintJob, User, Participation, Team +from cms.db import Contest, PrintJob, User, Participation, Team from cms.grading.languagemanager import get_language from cms.grading.steps import COMPILATION_MESSAGES, EVALUATION_MESSAGES +from cms.grading.scoring import task_score from cms.server import multi_contest from cms.server.contest.authentication import validate_login from cms.server.contest.communication import get_communications @@ -79,8 +82,49 @@ class MainHandler(ContestHandler): """ @multi_contest def get(self): + self.r_params = self.render_params() self.render("overview.html", **self.r_params) + def render_params(self): + ret = super().render_params() + + if self.current_user is not None: + # This massive joined load gets all the information which we will need + participation = self.sql_session.query(Participation)\ + .filter(Participation.id == self.current_user.id)\ + .options( + joinedload('user'), + joinedload('contest'), + joinedload('submissions').joinedload('token'), + joinedload('submissions').joinedload('results'), + )\ + .first() + + self.contest = self.sql_session.query(Contest)\ + .filter(Contest.id == participation.contest.id)\ + .options( + joinedload('tasks') + .joinedload('active_dataset') + )\ + .first() + + ret["participation"] = participation + + # Compute public scores for all tasks + task_scores = {} + for task in self.contest.tasks: + score_type = task.active_dataset.score_type_object + max_public_score = round( + score_type.max_public_score, task.score_precision) + public_score, _ = task_score( + participation, task, public=True, rounded=True) + public_score = round(public_score, task.score_precision) + task_scores[task.id] = (public_score, + max_public_score, + score_type.format_score(public_score, score_type.max_public_score, None, task.score_precision, translation=self.translation)) + ret["task_scores"] = task_scores + + return ret class RegistrationHandler(ContestHandler): """Registration handler. @@ -384,7 +428,7 @@ def get(self): language_docs = [] if config.docs_path is not None: for language in languages: - ext = language.source_extensions[0][1:] # remove dot + ext = language.source_extensions[0][1:] # remove dot path = os.path.join(config.docs_path, ext) if os.path.exists(path): language_docs.append((language.name, ext)) diff --git a/cms/server/contest/static/cws_style.css b/cms/server/contest/static/cws_style.css index b5786b443f..5baef55d6a 100644 --- a/cms/server/contest/static/cws_style.css +++ b/cms/server/contest/static/cws_style.css @@ -559,27 +559,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/overview.html b/cms/server/contest/templates/overview.html index 37fe202e3e..d2c31c81d6 100644 --- a/cms/server/contest/templates/overview.html +++ b/cms/server/contest/templates/overview.html @@ -2,188 +2,211 @@ {% set page = "overview" %} +{% block additional_js %} + +{% endblock additional_js %} + {% block core %}
- + -

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

-
-
-

-{% if phase == -1 %} - {% trans %}The contest hasn't started yet.{% endtrans %} -

-

- {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, - stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} - The contest will start at {{ start_time }} and will end at {{ stop_time }}. - {% endtrans %} -{% elif phase == 0 %} - {% trans %}The contest is currently running.{% endtrans %} -

-

- {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, - stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} - The contest started at {{ start_time }} and will end at {{ stop_time }}. - {% endtrans %} -{% elif phase >= +1 %} - {% trans %}The contest has already ended.{% endtrans %} -

-

- {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, - stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} - The contest started at {{ start_time }} and ended at {{ stop_time }}. - {% endtrans %} -{% endif %} -

-{% if contest.analysis_enabled %} -

- {% if phase == +1 %} - {% trans %}The analysis mode hasn't started yet.{% endtrans %} -

-

- {% trans start_time=contest.analysis_start|format_datetime_smart, - stop_time=contest.analysis_stop|format_datetime_smart %} - The analysis mode will start at {{ start_time }} and will end at {{ stop_time }}. - {% endtrans %} - {% elif phase == +2 %} - {% trans %}The analysis mode is currently running.{% endtrans %} -

-

- {% trans start_time=contest.analysis_start|format_datetime_smart, - stop_time=contest.analysis_stop|format_datetime_smart %} - The analysis mode started at {{ start_time }} and will end at {{ stop_time }}. - {% endtrans %} - {% elif phase == +3 %} - {% trans %}The analysis mode has already ended.{% endtrans %} -

-

- {% trans start_time=contest.analysis_start|format_datetime_smart, - stop_time=contest.analysis_stop|format_datetime_smart %} - The analysis mode started at {{ start_time }} and ended at {{ stop_time }}. - {% endtrans %} - {% endif %} -

- -{% endif %} - - - -{% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %} - {% if tokens_contest == TOKEN_MODE_INFINITE and tokens_tasks == TOKEN_MODE_INFINITE %} -

- {% trans %}You have an infinite number of tokens.{% endtrans %} -

- -

- {% trans %}You can see the detailed result of a submission by using a token on it.{% endtrans %} - {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last one.{% endtrans %} -

- {% elif tokens_contest == TOKEN_MODE_INFINITE %} -

- {% trans %}You have a distinct set of tokens for each task.{% endtrans %} - {%+ trans type_pl=_("tokens") %}You can find the rules for the {{ type_pl }} on each task's description page.{% endtrans %} -

- -

- {% trans %}You can see the detailed result of a submission by using a token on it.{% endtrans %} - {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last one.{% endtrans %} -

- {% elif tokens_tasks == TOKEN_MODE_INFINITE %} -

- {% trans %}You have a set of tokens shared among all tasks.{% endtrans %} - {{ contest|extract_token_params|format_token_rules }} -

- -

- {% trans %}You can see the detailed result of a submission by using a token on it.{% endtrans %} - {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last one.{% endtrans %} -

- {% else %} -

- {% trans %}You have two types of tokens: a set of contest-tokens shared among all tasks and a distinct set of task-tokens for each task.{% endtrans %} - {{ contest|extract_token_params|format_token_rules(t_type="contest") }} - {% trans type_pl=_("task-tokens") %}You can find the rules for the {{ type_pl }} on each task's description page.{% endtrans %} -

- -

- {% trans %}You can see the detailed result of a submission by using two tokens on it, one of each type.{% endtrans %} - {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last one.{% endtrans %} -

- {% endif %} -{% endif %} +

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

+
+
+

+ {% if phase == -1 %} + {% trans %}The contest hasn't started yet.{% endtrans %} +

+

+ {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, + stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} + The contest will start at {{ start_time }} and will end at {{ stop_time }}. + {% endtrans %} + {% elif phase == 0 %} + {% trans %}The contest is currently running.{% endtrans %} +

+

+ {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, + stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} + The contest started at {{ start_time }} and will end at {{ stop_time }}. + {% endtrans %} + {% elif phase >= +1 %} + {% trans %}The contest has already ended.{% endtrans %} +

+

+ {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, + stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} + The contest started at {{ start_time }} and ended at {{ stop_time }}. + {% endtrans %} + {% endif %} +

+ {% if contest.analysis_enabled %} +

+ {% if phase == +1 %} + {% trans %}The analysis mode hasn't started yet.{% endtrans %} +

+

+ {% trans start_time=contest.analysis_start|format_datetime_smart, + stop_time=contest.analysis_stop|format_datetime_smart %} + The analysis mode will start at {{ start_time }} and will end at {{ stop_time }}. + {% endtrans %} + {% elif phase == +2 %} + {% trans %}The analysis mode is currently running.{% endtrans %} +

+

+ {% trans start_time=contest.analysis_start|format_datetime_smart, + stop_time=contest.analysis_stop|format_datetime_smart %} + The analysis mode started at {{ start_time }} and will end at {{ stop_time }}. + {% endtrans %} + {% elif phase == +3 %} + {% trans %}The analysis mode has already ended.{% endtrans %} +

+

+ {% trans start_time=contest.analysis_start|format_datetime_smart, + stop_time=contest.analysis_stop|format_datetime_smart %} + The analysis mode started at {{ start_time }} and ended at {{ stop_time }}. + {% endtrans %} + {% endif %} +

-{% if contest.max_submission_number is not none %} -

- {% trans submissions=contest.max_submission_number %}You can submit at most {{ submissions }} solutions during this contest.{% endtrans %} -

-{% endif %} + {% endif %} -{% if contest.max_user_test_number is not none %} -

- {% trans user_tests=contest.max_user_test_number %}You can submit at most {{ user_tests }} user tests during this contest.{% endtrans %} -

-{% endif %} -
-{% if contest.per_user_time is not none %} -
-
-

- {# TODO would be very nice to write something like "just for 3 consecutive hours"... #} - {% trans per_user_time=contest.per_user_time|format_timedelta %}Every user is allowed to compete (i.e. submit solutions) for a uninterrupted time frame of {{ per_user_time }}.{% endtrans %} -

- -

- {% if actual_phase == -2 %} - {% trans %}As soon as the contest starts you can choose to start your time frame.{% endtrans %} - {%+ trans %}Once you start, you can submit solutions until the end of the time frame or until the end of the contest, whatever comes first.{% endtrans %} - {% elif actual_phase == -1 %} - {% trans %}By clicking on the button below you can start your time frame.{% endtrans %} - {%+ trans %}Once you start, you can submit solutions until the end of the time frame or until the end of the contest, whatever comes first.{% endtrans %} - {% elif actual_phase == 0 %} - {% trans start_time=participation.starting_time|format_datetime_smart %}You started your time frame at {{ start_time }}.{% endtrans %} - {%+ trans %}You can submit solutions until the end of the time frame or until the end of the contest, whatever comes first.{% endtrans %} - {% elif actual_phase == +1 %} - {% trans start_time=participation.starting_time|format_datetime_smart %}You started your time frame at {{ start_time }} and you already finished it.{% endtrans %} - {%+ trans %}There's nothing you can do now.{% endtrans %} - {% elif actual_phase >= +2 %} - {% if participation.starting_time is none %} - {% trans %}You never started your time frame. Now it's too late.{% endtrans %} - {% else %} - {% trans start_time=participation.starting_time|format_datetime_smart %}You started your time frame at {{ start_time }} and you already finished it.{% endtrans %} - {% endif %} - {% if actual_phase != +3 %} - {%+ trans %}There's nothing you can do now.{% endtrans %} - {% endif %} - {% endif %} + + {% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %} + {% if tokens_contest == TOKEN_MODE_INFINITE and tokens_tasks == TOKEN_MODE_INFINITE %} +

+ {% trans %}You have an infinite number of tokens.{% endtrans %}

- {% if actual_phase == -1 %} -
- {{ xsrf_form_html|safe }} - - -
- {% endif %} +

+ {% trans %}You can see the detailed result of a submission by using a token on it.{% endtrans %} + {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last + one.{% endtrans %} +

+ {% elif tokens_contest == TOKEN_MODE_INFINITE %} +

+ {% trans %}You have a distinct set of tokens for each task.{% endtrans %} + {%+ trans type_pl=_("tokens") %}You can find the rules for the {{ type_pl }} on each task's description + page.{% endtrans %} +

+ +

+ {% trans %}You can see the detailed result of a submission by using a token on it.{% endtrans %} + {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last + one.{% endtrans %} +

+ {% elif tokens_tasks == TOKEN_MODE_INFINITE %} +

+ {% trans %}You have a set of tokens shared among all tasks.{% endtrans %} + {{ contest|extract_token_params|format_token_rules }} +

+ +

+ {% trans %}You can see the detailed result of a submission by using a token on it.{% endtrans %} + {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last + one.{% endtrans %} +

+ {% else %} +

+ {% trans %}You have two types of tokens: a set of contest-tokens shared among all tasks and a + distinct set of task-tokens for each task.{% endtrans %} + {{ contest|extract_token_params|format_token_rules(t_type="contest") }} + {% trans type_pl=_("task-tokens") %}You can find the rules for the {{ type_pl }} on each task's + description page.{% endtrans %} +

+ +

+ {% trans %}You can see the detailed result of a submission by using two tokens on it, one of each + type.{% endtrans %} + {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last + one.{% endtrans %} +

+ {% endif %} + {% endif %} + + {% if contest.max_submission_number is not none %} +

+ {% trans submissions=contest.max_submission_number %}You can submit at most {{ submissions }} solutions + during this contest.{% endtrans %} +

+ {% endif %} + + {% if contest.max_user_test_number is not none %} +

+ {% trans user_tests=contest.max_user_test_number %}You can submit at most {{ user_tests }} user tests + during this contest.{% endtrans %} +

+ {% endif %}
+ {% if contest.per_user_time is not none %} +
+
+

+ {# TODO would be very nice to write something like "just for 3 consecutive hours"... #} + {% trans per_user_time=contest.per_user_time|format_timedelta %}Every user is allowed to compete + (i.e. submit solutions) for a uninterrupted time frame of {{ per_user_time }}.{% endtrans %} +

+ +

+ {% if actual_phase == -2 %} + {% trans %}As soon as the contest starts you can choose to start your time frame.{% endtrans %} + {%+ trans %}Once you start, you can submit solutions until the end of the time frame or until the + end of the contest, whatever comes first.{% endtrans %} + {% elif actual_phase == -1 %} + {% trans %}By clicking on the button below you can start your time frame.{% endtrans %} + {%+ trans %}Once you start, you can submit solutions until the end of the time frame or until the + end of the contest, whatever comes first.{% endtrans %} + {% elif actual_phase == 0 %} + {% trans start_time=participation.starting_time|format_datetime_smart %}You started your time frame + at {{ start_time }}.{% endtrans %} + {%+ trans %}You can submit solutions until the end of the time frame or until the end of the + contest, whatever comes first.{% endtrans %} + {% elif actual_phase == +1 %} + {% trans start_time=participation.starting_time|format_datetime_smart %}You started your time frame + at {{ start_time }} and you already finished it.{% endtrans %} + {%+ trans %}There's nothing you can do now.{% endtrans %} + {% elif actual_phase >= +2 %} + {% if participation.starting_time is none %} + {% trans %}You never started your time frame. Now it's too late.{% endtrans %} + {% else %} + {% trans start_time=participation.starting_time|format_datetime_smart %}You started your time frame + at {{ start_time }} and you already finished it.{% endtrans %} + {% endif %} + {% if actual_phase != +3 %} + {%+ trans %}There's nothing you can do now.{% endtrans %} + {% endif %} + {% endif %} +

+ + {% if actual_phase == -1 %} +
+ {{ xsrf_form_html|safe }} + + +
+ {% endif %} + +
+
+ {% endif %}
-{% endif %} -
-{% if actual_phase == 0 or actual_phase == 3 or participation.unrestricted or (0 <= actual_phase <= 3 and contest.allow_unofficial_submission_before_analysis_mode)%} -

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

+ {% if actual_phase == 0 or actual_phase == 3 or participation.unrestricted or (0 <= actual_phase <= 3 and contest.allow_unofficial_submission_before_analysis_mode)%} +

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

- - - - - - - - - - -{% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %} - -{% endif %} - - - -{% set extensions = "[%s]"|format(contest.languages|map("to_language")|map(attribute="source_extension")|unique|join("|")) %} -{% for t_iter in contest.tasks %} - - - - - - - - {% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %} - + + + + + + + + + + {% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %} + + {% endif %} + + + + {% set extensions = + "[%s]"|format(contest.languages|map("to_language")|map(attribute="source_extension")|unique|join("|")) %} + {% for t_iter in contest.tasks %} + + + + + + + + + {% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %} + + {% endif %} + + {% endfor %} + +
{% trans %}Task{% endtrans %}{% trans %}Name{% endtrans %}{% trans %}Time limit{% endtrans %}{% trans %}Memory limit{% endtrans %}{% trans %}Type{% endtrans %}{% trans %}Files{% endtrans %}{% trans %}Tokens{% endtrans %}
{{ t_iter.name }}{{ t_iter.title }} - {% if 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 %} - {{ t_iter.active_dataset.memory_limit|format_size }} - {% else %} - {% trans %}N/A{% endtrans %} - {% endif %} - {{ get_task_type(dataset=t_iter.active_dataset).name }}{{ t_iter.submission_format|map("replace", ".%l", extensions)|join(" ") }} - {% if t_iter.token_mode == TOKEN_MODE_FINITE or t_iter.token_mode == TOKEN_MODE_INFINITE %} - {% trans %}Yes{% endtrans %} - {% else %} - {% trans %}No{% endtrans %} - {% endif %} -
{% trans %}Score{% endtrans %}{% trans %}Task{% endtrans %}{% trans %}Name{% endtrans %}{% trans %}Time limit{% endtrans %}{% trans %}Memory limit{% endtrans %}{% trans %}Type{% endtrans %}{% trans %}Files{% endtrans %}{% trans %}Tokens{% endtrans %}
+ {{ task_scores[t_iter.id][2] }}{{ t_iter.name }}{{ t_iter.title }} + {% if 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 %} + {{ t_iter.active_dataset.memory_limit|format_size }} + {% else %} + {% trans %}N/A{% endtrans %} + {% endif %} + {{ get_task_type(dataset=t_iter.active_dataset).name }}{{ t_iter.submission_format|map("replace", ".%l", extensions)|join(" ") }} + {% if t_iter.token_mode == TOKEN_MODE_FINITE or t_iter.token_mode == TOKEN_MODE_INFINITE %} + {% trans %}Yes{% endtrans %} + {% else %} + {% trans %}No{% endtrans %} + {% endif %} +
{% endif %} - -{% endfor %} - - -{% endif %}
{% endblock core %} From 4ae18e9a95083ce13ea5d4dc28380aaf3609ca66 Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Fri, 1 Aug 2025 17:22:53 +0700 Subject: [PATCH 02/26] Restore Formatting, Make it toggleable --- cms/db/contest.py | 3 + cms/server/admin/handlers/contest.py | 1 + cms/server/admin/templates/contest.html | 9 + cms/server/contest/handlers/main.py | 37 +- cms/server/contest/templates/overview.html | 458 ++++++++++----------- cmscontrib/updaters/update_from_1.5.sql | 3 + 6 files changed, 258 insertions(+), 253 deletions(-) diff --git a/cms/db/contest.py b/cms/db/contest.py index c245a3534f..d3c0c231d5 100644 --- a/cms/db/contest.py +++ b/cms/db/contest.py @@ -108,6 +108,9 @@ 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 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 1a4c8e8ea6..1038c6ff51 100644 --- a/cms/server/admin/handlers/contest.py +++ b/cms/server/admin/handlers/contest.py @@ -97,6 +97,7 @@ 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, "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 43f57c0b60..e9cfbdfa58 100644 --- a/cms/server/admin/templates/contest.html +++ b/cms/server/admin/templates/contest.html @@ -90,6 +90,15 @@

Contest configuration

+ + + + + + + + +

Logging in

diff --git a/cms/server/contest/handlers/main.py b/cms/server/contest/handlers/main.py index df6c6f930d..dcbd2bf3bd 100644 --- a/cms/server/contest/handlers/main.py +++ b/cms/server/contest/handlers/main.py @@ -107,19 +107,30 @@ def render_params(self): ret["participation"] = participation - # Compute public scores for all tasks - task_scores = {} - for task in self.contest.tasks: - score_type = task.active_dataset.score_type_object - max_public_score = round( - score_type.max_public_score, task.score_precision) - public_score, _ = task_score( - participation, task, public=True, rounded=True) - public_score = round(public_score, task.score_precision) - task_scores[task.id] = (public_score, - max_public_score, - score_type.format_score(public_score, score_type.max_public_score, None, task.score_precision, translation=self.translation)) - ret["task_scores"] = task_scores + # Compute public scores for all tasks only if they will be shown + if self.contest.show_task_scores_in_overview: + task_scores = {} + for task in self.contest.tasks: + score_type = task.active_dataset.score_type_object + max_public_score = round( + score_type.max_public_score, task.score_precision + ) + public_score, _ = task_score( + participation, task, public=True, rounded=True + ) + public_score = round(public_score, task.score_precision) + task_scores[task.id] = ( + public_score, + max_public_score, + score_type.format_score( + public_score, + score_type.max_public_score, + None, + task.score_precision, + translation=self.translation, + ), + ) + ret["task_scores"] = task_scores return ret diff --git a/cms/server/contest/templates/overview.html b/cms/server/contest/templates/overview.html index d2c31c81d6..170658946e 100644 --- a/cms/server/contest/templates/overview.html +++ b/cms/server/contest/templates/overview.html @@ -2,211 +2,188 @@ {% set page = "overview" %} -{% block additional_js %} - -{% endblock additional_js %} - {% block core %}
- - -

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

-
-
-

- {% if phase == -1 %} - {% trans %}The contest hasn't started yet.{% endtrans %} -

-

- {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, - stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} - The contest will start at {{ start_time }} and will end at {{ stop_time }}. - {% endtrans %} - {% elif phase == 0 %} - {% trans %}The contest is currently running.{% endtrans %} -

-

- {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, - stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} - The contest started at {{ start_time }} and will end at {{ stop_time }}. - {% endtrans %} - {% elif phase >= +1 %} - {% trans %}The contest has already ended.{% endtrans %} -

-

- {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, - stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} - The contest started at {{ start_time }} and ended at {{ stop_time }}. - {% endtrans %} - {% endif %} -

- {% if contest.analysis_enabled %} -

- {% if phase == +1 %} - {% trans %}The analysis mode hasn't started yet.{% endtrans %} -

-

- {% trans start_time=contest.analysis_start|format_datetime_smart, - stop_time=contest.analysis_stop|format_datetime_smart %} - The analysis mode will start at {{ start_time }} and will end at {{ stop_time }}. - {% endtrans %} - {% elif phase == +2 %} - {% trans %}The analysis mode is currently running.{% endtrans %} -

-

- {% trans start_time=contest.analysis_start|format_datetime_smart, - stop_time=contest.analysis_stop|format_datetime_smart %} - The analysis mode started at {{ start_time }} and will end at {{ stop_time }}. - {% endtrans %} - {% elif phase == +3 %} - {% trans %}The analysis mode has already ended.{% endtrans %} -

-

- {% trans start_time=contest.analysis_start|format_datetime_smart, - stop_time=contest.analysis_stop|format_datetime_smart %} - The analysis mode started at {{ start_time }} and ended at {{ stop_time }}. - {% endtrans %} - {% endif %} -

- - {% endif %} - - - - {% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %} - {% if tokens_contest == TOKEN_MODE_INFINITE and tokens_tasks == TOKEN_MODE_INFINITE %} -

- {% trans %}You have an infinite number of tokens.{% endtrans %} -

- -

- {% trans %}You can see the detailed result of a submission by using a token on it.{% endtrans %} - {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last - one.{% endtrans %} -

- {% elif tokens_contest == TOKEN_MODE_INFINITE %} -

- {% trans %}You have a distinct set of tokens for each task.{% endtrans %} - {%+ trans type_pl=_("tokens") %}You can find the rules for the {{ type_pl }} on each task's description - page.{% endtrans %} -

- -

- {% trans %}You can see the detailed result of a submission by using a token on it.{% endtrans %} - {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last - one.{% endtrans %} -

- {% elif tokens_tasks == TOKEN_MODE_INFINITE %} -

- {% trans %}You have a set of tokens shared among all tasks.{% endtrans %} - {{ contest|extract_token_params|format_token_rules }} -

+ -

- {% trans %}You can see the detailed result of a submission by using a token on it.{% endtrans %} - {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last - one.{% endtrans %} -

- {% else %} -

- {% trans %}You have two types of tokens: a set of contest-tokens shared among all tasks and a - distinct set of task-tokens for each task.{% endtrans %} - {{ contest|extract_token_params|format_token_rules(t_type="contest") }} - {% trans type_pl=_("task-tokens") %}You can find the rules for the {{ type_pl }} on each task's - description page.{% endtrans %} -

+

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

+
+
+

+{% if phase == -1 %} + {% trans %}The contest hasn't started yet.{% endtrans %} +

+

+ {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, + stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} + The contest will start at {{ start_time }} and will end at {{ stop_time }}. + {% endtrans %} +{% elif phase == 0 %} + {% trans %}The contest is currently running.{% endtrans %} +

+

+ {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, + stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} + The contest started at {{ start_time }} and will end at {{ stop_time }}. + {% endtrans %} +{% elif phase >= +1 %} + {% trans %}The contest has already ended.{% endtrans %} +

+

+ {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, + stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} + The contest started at {{ start_time }} and ended at {{ stop_time }}. + {% endtrans %} +{% endif %} +

+{% if contest.analysis_enabled %} +

+ {% if phase == +1 %} + {% trans %}The analysis mode hasn't started yet.{% endtrans %} +

+

+ {% trans start_time=contest.analysis_start|format_datetime_smart, + stop_time=contest.analysis_stop|format_datetime_smart %} + The analysis mode will start at {{ start_time }} and will end at {{ stop_time }}. + {% endtrans %} + {% elif phase == +2 %} + {% trans %}The analysis mode is currently running.{% endtrans %} +

+

+ {% trans start_time=contest.analysis_start|format_datetime_smart, + stop_time=contest.analysis_stop|format_datetime_smart %} + The analysis mode started at {{ start_time }} and will end at {{ stop_time }}. + {% endtrans %} + {% elif phase == +3 %} + {% trans %}The analysis mode has already ended.{% endtrans %} +

+

+ {% trans start_time=contest.analysis_start|format_datetime_smart, + stop_time=contest.analysis_stop|format_datetime_smart %} + The analysis mode started at {{ start_time }} and ended at {{ stop_time }}. + {% endtrans %} + {% endif %} +

+ +{% endif %} + + + +{% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %} + {% if tokens_contest == TOKEN_MODE_INFINITE and tokens_tasks == TOKEN_MODE_INFINITE %} +

+ {% trans %}You have an infinite number of tokens.{% endtrans %} +

+ +

+ {% trans %}You can see the detailed result of a submission by using a token on it.{% endtrans %} + {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last one.{% endtrans %} +

+ {% elif tokens_contest == TOKEN_MODE_INFINITE %} +

+ {% trans %}You have a distinct set of tokens for each task.{% endtrans %} + {%+ trans type_pl=_("tokens") %}You can find the rules for the {{ type_pl }} on each task's description page.{% endtrans %} +

+ +

+ {% trans %}You can see the detailed result of a submission by using a token on it.{% endtrans %} + {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last one.{% endtrans %} +

+ {% elif tokens_tasks == TOKEN_MODE_INFINITE %} +

+ {% trans %}You have a set of tokens shared among all tasks.{% endtrans %} + {{ contest|extract_token_params|format_token_rules }} +

+ +

+ {% trans %}You can see the detailed result of a submission by using a token on it.{% endtrans %} + {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last one.{% endtrans %} +

+ {% else %} +

+ {% trans %}You have two types of tokens: a set of contest-tokens shared among all tasks and a distinct set of task-tokens for each task.{% endtrans %} + {{ contest|extract_token_params|format_token_rules(t_type="contest") }} + {% trans type_pl=_("task-tokens") %}You can find the rules for the {{ type_pl }} on each task's description page.{% endtrans %} +

+ +

+ {% trans %}You can see the detailed result of a submission by using two tokens on it, one of each type.{% endtrans %} + {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last one.{% endtrans %} +

+ {% endif %} +{% endif %} -

- {% trans %}You can see the detailed result of a submission by using two tokens on it, one of each - type.{% endtrans %} - {%+ trans %}Your score for each task will be the maximum among the tokened submissions and the last - one.{% endtrans %} -

- {% endif %} - {% endif %} +{% if contest.max_submission_number is not none %} +

+ {% trans submissions=contest.max_submission_number %}You can submit at most {{ submissions }} solutions during this contest.{% endtrans %} +

+{% endif %} - {% if contest.max_submission_number is not none %} -

- {% trans submissions=contest.max_submission_number %}You can submit at most {{ submissions }} solutions - during this contest.{% endtrans %} -

- {% endif %} +{% if contest.max_user_test_number is not none %} +

+ {% trans user_tests=contest.max_user_test_number %}You can submit at most {{ user_tests }} user tests during this contest.{% endtrans %} +

+{% endif %} - {% if contest.max_user_test_number is not none %} -

- {% trans user_tests=contest.max_user_test_number %}You can submit at most {{ user_tests }} user tests - during this contest.{% endtrans %} +

+{% if contest.per_user_time is not none %} +
+
+

+ {# TODO would be very nice to write something like "just for 3 consecutive hours"... #} + {% trans per_user_time=contest.per_user_time|format_timedelta %}Every user is allowed to compete (i.e. submit solutions) for a uninterrupted time frame of {{ per_user_time }}.{% endtrans %} +

+ +

+ {% if actual_phase == -2 %} + {% trans %}As soon as the contest starts you can choose to start your time frame.{% endtrans %} + {%+ trans %}Once you start, you can submit solutions until the end of the time frame or until the end of the contest, whatever comes first.{% endtrans %} + {% elif actual_phase == -1 %} + {% trans %}By clicking on the button below you can start your time frame.{% endtrans %} + {%+ trans %}Once you start, you can submit solutions until the end of the time frame or until the end of the contest, whatever comes first.{% endtrans %} + {% elif actual_phase == 0 %} + {% trans start_time=participation.starting_time|format_datetime_smart %}You started your time frame at {{ start_time }}.{% endtrans %} + {%+ trans %}You can submit solutions until the end of the time frame or until the end of the contest, whatever comes first.{% endtrans %} + {% elif actual_phase == +1 %} + {% trans start_time=participation.starting_time|format_datetime_smart %}You started your time frame at {{ start_time }} and you already finished it.{% endtrans %} + {%+ trans %}There's nothing you can do now.{% endtrans %} + {% elif actual_phase >= +2 %} + {% if participation.starting_time is none %} + {% trans %}You never started your time frame. Now it's too late.{% endtrans %} + {% else %} + {% trans start_time=participation.starting_time|format_datetime_smart %}You started your time frame at {{ start_time }} and you already finished it.{% endtrans %} + {% endif %} + {% if actual_phase != +3 %} + {%+ trans %}There's nothing you can do now.{% endtrans %} + {% endif %} + {% endif %}

- {% endif %} - -
- {% if contest.per_user_time is not none %} -
-
-

- {# TODO would be very nice to write something like "just for 3 consecutive hours"... #} - {% trans per_user_time=contest.per_user_time|format_timedelta %}Every user is allowed to compete - (i.e. submit solutions) for a uninterrupted time frame of {{ per_user_time }}.{% endtrans %} -

-

- {% if actual_phase == -2 %} - {% trans %}As soon as the contest starts you can choose to start your time frame.{% endtrans %} - {%+ trans %}Once you start, you can submit solutions until the end of the time frame or until the - end of the contest, whatever comes first.{% endtrans %} - {% elif actual_phase == -1 %} - {% trans %}By clicking on the button below you can start your time frame.{% endtrans %} - {%+ trans %}Once you start, you can submit solutions until the end of the time frame or until the - end of the contest, whatever comes first.{% endtrans %} - {% elif actual_phase == 0 %} - {% trans start_time=participation.starting_time|format_datetime_smart %}You started your time frame - at {{ start_time }}.{% endtrans %} - {%+ trans %}You can submit solutions until the end of the time frame or until the end of the - contest, whatever comes first.{% endtrans %} - {% elif actual_phase == +1 %} - {% trans start_time=participation.starting_time|format_datetime_smart %}You started your time frame - at {{ start_time }} and you already finished it.{% endtrans %} - {%+ trans %}There's nothing you can do now.{% endtrans %} - {% elif actual_phase >= +2 %} - {% if participation.starting_time is none %} - {% trans %}You never started your time frame. Now it's too late.{% endtrans %} - {% else %} - {% trans start_time=participation.starting_time|format_datetime_smart %}You started your time frame - at {{ start_time }} and you already finished it.{% endtrans %} - {% endif %} - {% if actual_phase != +3 %} - {%+ trans %}There's nothing you can do now.{% endtrans %} - {% endif %} - {% endif %} -

- - {% if actual_phase == -1 %} -
- {{ xsrf_form_html|safe }} - - -
- {% endif %} + {% if actual_phase == -1 %} +
+ {{ xsrf_form_html|safe }} + + +
+ {% endif %} -
- {% endif %}
+{% endif %} +
- {% if actual_phase == 0 or actual_phase == 3 or participation.unrestricted or (0 <= actual_phase <= 3 and contest.allow_unofficial_submission_before_analysis_mode)%} -

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

+{% if actual_phase == 0 or actual_phase == 3 or participation.unrestricted or (0 <= actual_phase <= 3 and contest.allow_unofficial_submission_before_analysis_mode)%} +

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

- - - - - - - - - - - - {% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %} - - {% endif %} - - - - {% set extensions = - "[%s]"|format(contest.languages|map("to_language")|map(attribute="source_extension")|unique|join("|")) %} - {% for t_iter in contest.tasks %} - - - - - - - - - {% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %} - - {% endif %} - - {% endfor %} - -
{% trans %}Score{% endtrans %}{% trans %}Task{% endtrans %}{% trans %}Name{% endtrans %}{% trans %}Time limit{% endtrans %}{% trans %}Memory limit{% endtrans %}{% trans %}Type{% endtrans %}{% trans %}Files{% endtrans %}{% trans %}Tokens{% endtrans %}
- {{ task_scores[t_iter.id][2] }}{{ t_iter.name }}{{ t_iter.title }} - {% if 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 %} - {{ t_iter.active_dataset.memory_limit|format_size }} - {% else %} - {% trans %}N/A{% endtrans %} - {% endif %} - {{ get_task_type(dataset=t_iter.active_dataset).name }}{{ t_iter.submission_format|map("replace", ".%l", extensions)|join(" ") }} - {% if t_iter.token_mode == TOKEN_MODE_FINITE or t_iter.token_mode == TOKEN_MODE_INFINITE %} - {% trans %}Yes{% endtrans %} - {% else %} - {% trans %}No{% endtrans %} - {% endif %} -
+ + +{% if contest.show_task_scores_in_overview %} + {% trans %}Score{% endtrans %} +{% endif %} + {% trans %}Task{% endtrans %} + {% trans %}Name{% endtrans %} + {% trans %}Time limit{% endtrans %} + {% trans %}Memory limit{% endtrans %} + {% trans %}Type{% endtrans %} + {% trans %}Files{% endtrans %} +{% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %} + {% trans %}Tokens{% endtrans %} +{% endif %} + + + +{% set extensions = "[%s]"|format(contest.languages|map("to_language")|map(attribute="source_extension")|unique|join("|")) %} +{% for t_iter in contest.tasks %} + +{% if contest.show_task_scores_in_overview and task_scores is defined %} + {{ task_scores[t_iter.id][2] }} +{% endif %} + {{ t_iter.name }} + {{ t_iter.title }} + + {% if 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 %} + {{ t_iter.active_dataset.memory_limit|format_size }} + {% else %} + {% trans %}N/A{% endtrans %} + {% endif %} + + {{ get_task_type(dataset=t_iter.active_dataset).name }} + {{ t_iter.submission_format|map("replace", ".%l", extensions)|join(" ") }} + {% if tokens_contest != TOKEN_MODE_DISABLED and tokens_tasks != TOKEN_MODE_DISABLED %} + + {% if t_iter.token_mode == TOKEN_MODE_FINITE or t_iter.token_mode == TOKEN_MODE_INFINITE %} + {% trans %}Yes{% endtrans %} + {% else %} + {% trans %}No{% endtrans %} + {% endif %} + {% endif %} + +{% endfor %} + + +{% endif %}
{% endblock core %} diff --git a/cmscontrib/updaters/update_from_1.5.sql b/cmscontrib/updaters/update_from_1.5.sql index c22b736c6a..10ddf0b4ce 100644 --- a/cmscontrib/updaters/update_from_1.5.sql +++ b/cmscontrib/updaters/update_from_1.5.sql @@ -42,4 +42,7 @@ 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; + COMMIT; From cbe5f8ea3a9ea4cce6a2bc88acd56299bbe9d128 Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Fri, 1 Aug 2025 17:37:57 +0700 Subject: [PATCH 03/26] Fix color not displaying --- cms/server/contest/templates/overview.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/server/contest/templates/overview.html b/cms/server/contest/templates/overview.html index 170658946e..850683da08 100644 --- a/cms/server/contest/templates/overview.html +++ b/cms/server/contest/templates/overview.html @@ -182,7 +182,7 @@

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

{% if actual_phase == 0 or actual_phase == 3 or participation.unrestricted or (0 <= actual_phase <= 3 and contest.allow_unofficial_submission_before_analysis_mode)%}

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

- +
-{% if contest.show_task_scores_in_overview %} +{% if contest.show_task_scores_in_overview and task_scores is defined%} {% endif %} From b84d5f8c6e2babadf606e90091dd1dbcdeb41bca Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Mon, 6 Apr 2026 01:03:58 +0800 Subject: [PATCH 12/26] Add sidebar task scores for contests Introduce an option to display per-task public scores in the contest sidebar. Adds a new DB column (show_task_scores_in_sidebar) and admin checkbox to toggle it, includes CSS badge styling and template changes to render the score next to each task. Backend changes add helpers to load participations with the relations required for scoring and to compute formatted public task scores (only computed when needed and hiding tasks with zero public max in the sidebar). Includes SQL updater to migrate existing databases. --- cms/db/contest.py | 3 + cms/server/admin/handlers/contest.py | 1 + cms/server/admin/templates/contest.html | 9 +++ cms/server/contest/handlers/contest.py | 68 +++++++++++++++++++++++ cms/server/contest/handlers/main.py | 51 ++++------------- cms/server/contest/static/cws_style.css | 11 ++++ cms/server/contest/templates/contest.html | 7 ++- cmscontrib/updaters/update_from_1.5.sql | 4 ++ 8 files changed, 112 insertions(+), 42 deletions(-) diff --git a/cms/db/contest.py b/cms/db/contest.py index e470e31e8b..c38c43bfb7 100644 --- a/cms/db/contest.py +++ b/cms/db/contest.py @@ -111,6 +111,9 @@ class Contest(Base): # 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=False) + # 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 1038c6ff51..d09b01ce33 100644 --- a/cms/server/admin/handlers/contest.py +++ b/cms/server/admin/handlers/contest.py @@ -98,6 +98,7 @@ def post(self, contest_id: str): 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 e9cfbdfa58..0d49bf1401 100644 --- a/cms/server/admin/templates/contest.html +++ b/cms/server/admin/templates/contest.html @@ -99,6 +99,15 @@

Contest configuration

+ + + + diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index 7ea473d2c9..88389449c6 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -48,9 +48,11 @@ collections.MutableMapping = collections.abc.MutableMapping import tornado.web +from sqlalchemy.orm import joinedload 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 @@ -193,6 +195,56 @@ 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.contest) + .joinedload(Contest.tasks) + .joinedload(Task.active_dataset), + joinedload(Participation.submissions).joinedload(Submission.token), + joinedload(Participation.submissions).joinedload(Submission.results), + ) + .first() + ) + + def _compute_public_task_scores( + self, + participation: Participation, + *, + hide_zero_max_public: bool, + ) -> dict[int, tuple[float, float, str]]: + """Compute per-task public scores for the given participation.""" + task_scores: dict[int, tuple[float, float, str]] = {} + for task in participation.contest.tasks: + score_type = task.active_dataset.score_type_object + max_public_score = round( + score_type.max_public_score, task.score_precision + ) + + if hide_zero_max_public and max_public_score <= 0: + continue + + public_score, _ = task_score( + participation, task, public=True, rounded=True + ) + task_scores[task.id] = ( + public_score, + max_public_score, + score_type.format_score( + public_score, + score_type.max_public_score, + None, + task.score_precision, + translation=self.translation, + ), + ) + return task_scores + def render_params(self): ret = super().render_params() @@ -230,6 +282,22 @@ 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_public_task_scores( + participation, + hide_zero_max_public=True, + ) + # some information about token configuration ret["tokens_contest"] = self.contest.token_mode diff --git a/cms/server/contest/handlers/main.py b/cms/server/contest/handlers/main.py index ba4e2d272d..a76d78f073 100644 --- a/cms/server/contest/handlers/main.py +++ b/cms/server/contest/handlers/main.py @@ -48,13 +48,11 @@ import tornado.web from sqlalchemy.orm.exc import NoResultFound -from sqlalchemy.orm import joinedload from cms import config -from cms.db import User, Participation, Team, Submission, Task +from cms.db import User, Participation, Team from cms.grading.languagemanager import get_language from cms.grading.steps import COMPILATION_MESSAGES, EVALUATION_MESSAGES -from cms.grading.scoring import task_score from cms.server import multi_contest from cms.server.contest.authentication import validate_login from cms.server.contest.communication import get_communications @@ -84,51 +82,22 @@ def render_params(self): ret = super().render_params() if self.current_user is not None: - # This massive joined load gets all the information which we will need - participation = ( - self.sql_session.query(Participation) - .filter(Participation.id == self.current_user.id) - .options( - joinedload(Participation.user), - joinedload(Participation.contest) - .joinedload(Contest.tasks) - .joinedload(Task.active_dataset), - joinedload(Participation.submissions).joinedload(Submission.token), - joinedload(Participation.submissions).joinedload( - Submission.results - ), - ) - .first() - ) + participation = self._load_participation_for_scores(self.current_user) + if participation is None: + return ret self.contest = participation.contest - # Ensure the template sees this fully-loaded version + # Ensure the template sees this fully-loaded version. ret["contest"] = self.contest ret["participation"] = participation + ret["user"] = participation.user # Compute public scores for all tasks only if they will be shown if self.contest.show_task_scores_in_overview: - task_scores = {} - for task in self.contest.tasks: - score_type = task.active_dataset.score_type_object - max_public_score = round( - score_type.max_public_score, task.score_precision - ) - public_score, _ = task_score( - participation, task, public=True, rounded=True - ) - task_scores[task.id] = ( - public_score, - max_public_score, - score_type.format_score( - public_score, - score_type.max_public_score, - None, - task.score_precision, - translation=self.translation, - ), - ) - ret["task_scores"] = task_scores + ret["task_scores"] = self._compute_public_task_scores( + participation, + hide_zero_max_public=False, + ) return ret diff --git a/cms/server/contest/static/cws_style.css b/cms/server/contest/static/cws_style.css index 4bfefa4dfb..24d107cc76 100644 --- a/cms/server/contest/static/cws_style.css +++ b/cms/server/contest/static/cws_style.css @@ -462,6 +462,17 @@ 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; +} /*** Submit a solution */ #submit_solution { diff --git a/cms/server/contest/templates/contest.html b/cms/server/contest/templates/contest.html index ba514771ba..5193fb5a0c 100644 --- a/cms/server/contest/templates/contest.html +++ b/cms/server/contest/templates/contest.html @@ -180,7 +180,12 @@

{% if actual_phase >= 0 or participation.unrestricted %} {% for t_iter in contest.tasks %} {% trans %}Statement{% endtrans %} diff --git a/cmscontrib/updaters/update_from_1.5.sql b/cmscontrib/updaters/update_from_1.5.sql index 4a55396bde..5f7e701bde 100644 --- a/cmscontrib/updaters/update_from_1.5.sql +++ b/cmscontrib/updaters/update_from_1.5.sql @@ -52,4 +52,8 @@ ALTER TABLE public.tasks ADD COLUMN allowed_languages varchar[]; -- https://github.com/cms-dev/cms/pull/1583 DROP TABLE public.printjobs; +-- Sidebar task score toggle +ALTER TABLE contests ADD COLUMN show_task_scores_in_sidebar boolean NOT NULL DEFAULT false; +ALTER TABLE contests ALTER COLUMN show_task_scores_in_sidebar DROP DEFAULT; + COMMIT; From 56a4f9667c4a4f47c303e9cc7290a09daa103b2b Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Mon, 6 Apr 2026 01:04:52 +0800 Subject: [PATCH 13/26] Update update_from_1.5.sql --- cmscontrib/updaters/update_from_1.5.sql | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmscontrib/updaters/update_from_1.5.sql b/cmscontrib/updaters/update_from_1.5.sql index 5f7e701bde..604a6a437d 100644 --- a/cmscontrib/updaters/update_from_1.5.sql +++ b/cmscontrib/updaters/update_from_1.5.sql @@ -44,7 +44,9 @@ 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[]; @@ -52,8 +54,5 @@ ALTER TABLE public.tasks ADD COLUMN allowed_languages varchar[]; -- https://github.com/cms-dev/cms/pull/1583 DROP TABLE public.printjobs; --- Sidebar task score toggle -ALTER TABLE contests ADD COLUMN show_task_scores_in_sidebar boolean NOT NULL DEFAULT false; -ALTER TABLE contests ALTER COLUMN show_task_scores_in_sidebar DROP DEFAULT; COMMIT; From e4a12953dcd4d9fa22737250f31f7bc7173f113c Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Mon, 6 Apr 2026 01:21:57 +0800 Subject: [PATCH 14/26] Show tokened task scores in sidebar Add logic to display tokened/total task scores in the sidebar when a token has been played on a task or when in analysis phase. Introduce _compute_sidebar_task_scores in the contest handler and switch render_params to use it (passing actual_phase) so sidebar values use tokened scores where appropriate while still hiding tasks with no public score. Templates updated: add data-task-name attribute to task nav headers and add JS to initialize sidebar state, update sidebar badges dynamically (sidebar_use_tokened_score, update_sidebar_task_score) and switch displayed score to tokened totals when submissions reveal full scores. This enables correct initial rendering and real-time updates of sidebar task scores based on token usage and scoring visibility. --- cms/server/contest/handlers/contest.py | 67 ++++++++++++++++++- cms/server/contest/templates/contest.html | 2 +- .../contest/templates/task_submissions.html | 43 ++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index 88389449c6..eb46301596 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -245,6 +245,69 @@ def _compute_public_task_scores( ) return task_scores + def _compute_sidebar_task_scores( + self, + participation: Participation, + *, + actual_phase: int, + ) -> dict[int, tuple[float, float, str]]: + """Compute per-task scores for the sidebar. + + By default the sidebar 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]] = {} + + for task in participation.contest.tasks: + score_type = task.active_dataset.score_type_object + + has_tokened_submission = any( + s.official and s.task_id == task.id and s.tokened() + for s in participation.submissions + ) + 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=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 + ) + + # Do not show a sidebar score if there is no public score. + if 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() @@ -293,9 +356,9 @@ def render_params(self): ret["contest"] = self.contest ret["participation"] = participation ret["user"] = participation.user - ret["sidebar_task_scores"] = self._compute_public_task_scores( + ret["sidebar_task_scores"] = self._compute_sidebar_task_scores( participation, - hide_zero_max_public=True, + actual_phase=ret["actual_phase"], ) # some information about token configuration diff --git a/cms/server/contest/templates/contest.html b/cms/server/contest/templates/contest.html index 5193fb5a0c..566f48adfb 100644 --- a/cms/server/contest/templates/contest.html +++ b/cms/server/contest/templates/contest.html @@ -179,7 +179,7 @@

{% if actual_phase >= 0 or participation.unrestricted %} {% for t_iter in contest.tasks %} - diff --git a/cms/server/contest/templates/task_submissions.html b/cms/server/contest/templates/task_submissions.html index 82ede7ef07..913d84fef4 100644 --- a/cms/server/contest/templates/task_submissions.html +++ b/cms/server/contest/templates/task_submissions.html @@ -132,9 +132,10 @@ return; } - badge.removeClass('score_0 score_0_100 score_100'); + badge.removeClass('undefined score_0 score_0_100 score_100'); badge.addClass(get_score_class(task_score, max_score)); badge.text(task_score_message); + badge.show(); }; update_scores = function (submission_id, data) { From f36ca31ee2b24aed1990acf03b057583bcf1be2a Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Mon, 6 Apr 2026 02:28:57 +0800 Subject: [PATCH 19/26] Update contest.html --- cms/server/contest/templates/contest.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/server/contest/templates/contest.html b/cms/server/contest/templates/contest.html index 3da6804d3b..9604fb51b7 100644 --- a/cms/server/contest/templates/contest.html +++ b/cms/server/contest/templates/contest.html @@ -187,7 +187,7 @@

{{ sidebar_task_scores[t_iter.id][2] }} {% else %} - {% trans %}N/A{% endtrans %} + 0 / 0 {% endif %} {% endif %} From ba352bb4536c5705dd19c8c4f623b8988743c091 Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Mon, 6 Apr 2026 02:31:38 +0800 Subject: [PATCH 20/26] Update contest.py --- cms/server/contest/handlers/contest.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index 41949790b1..bdadc02abc 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -48,7 +48,7 @@ collections.MutableMapping = collections.abc.MutableMapping import tornado.web -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, selectinload from cms import config, TOKEN_MODE_MIXED from cms.db import Contest, Submission, Task, UserTest @@ -207,8 +207,8 @@ def _load_participation_for_scores( joinedload(Participation.contest) .joinedload(Contest.tasks) .joinedload(Task.active_dataset), - joinedload(Participation.submissions).joinedload(Submission.token), - joinedload(Participation.submissions).joinedload(Submission.results), + selectinload(Participation.submissions).joinedload(Submission.token), + selectinload(Participation.submissions).joinedload(Submission.results), ) .first() ) From ea5ce65ae65a4acbd4f99223b3162559cc13dadf Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Thu, 9 Apr 2026 00:54:52 +0800 Subject: [PATCH 21/26] Fix bugs & Optimize --- cms/server/contest/handlers/main.py | 5 ++++- cms/server/contest/handlers/tasksubmission.py | 11 ++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cms/server/contest/handlers/main.py b/cms/server/contest/handlers/main.py index 2793d92b59..10c27685df 100644 --- a/cms/server/contest/handlers/main.py +++ b/cms/server/contest/handlers/main.py @@ -83,11 +83,14 @@ def render_params(self): if self.current_user is not None: participation = ret["participation"] + should_show_task_overview = ( + ret["actual_phase"] >= 0 or participation.unrestricted + ) # 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 self.contest.show_task_scores_in_overview and should_show_task_overview: if not already_preloaded_for_scores: loaded_participation = self._load_participation_for_scores( participation diff --git a/cms/server/contest/handlers/tasksubmission.py b/cms/server/contest/handlers/tasksubmission.py index e931b63686..aa3b78d767 100644 --- a/cms/server/contest/handlers/tasksubmission.py +++ b/cms/server/contest/handlers/tasksubmission.py @@ -144,9 +144,14 @@ def get(self, task_name): public_score, is_public_score_partial = task_score( participation, task, public=True, rounded=True) - tokened_score, is_tokened_score_partial = task_score( - participation, task, only_tokened=True, rounded=True) - # These two should be the same, anyway. + if self.r_params["actual_phase"] == 3: + tokened_score, is_tokened_score_partial = task_score( + participation, task, rounded=True + ) + else: + tokened_score, is_tokened_score_partial = task_score( + participation, task, only_tokened=True, rounded=True + ) is_score_partial = is_public_score_partial or is_tokened_score_partial submissions_left_contest = None From 9f0157ba52385d064347b6786c622826baaca2c5 Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Thu, 9 Apr 2026 01:04:56 +0800 Subject: [PATCH 22/26] Update contest.html --- cms/server/admin/templates/contest.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cms/server/admin/templates/contest.html b/cms/server/admin/templates/contest.html index c6ffc90e2e..da8ee8bfdf 100644 --- a/cms/server/admin/templates/contest.html +++ b/cms/server/admin/templates/contest.html @@ -107,6 +107,9 @@

Contest configuration

+ + + {% if contest.show_task_scores_in_overview and task_scores is defined %} - + {% endif %} From e045499c442e84d16f6d0c8495241ee92945fe9d Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Mon, 21 Sep 2026 05:41:34 +0000 Subject: [PATCH 26/26] Fix small bugs Optimizations : - Lazy loading - Optimize query efficiency - Eliminate redundant render_params --- cms/server/contest/handlers/contest.py | 64 ++---- cms/server/contest/handlers/main.py | 34 ---- cms/server/contest/handlers/tasksubmission.py | 9 +- cms/server/contest/templates/contest.html | 5 +- cms/server/contest/templates/overview.html | 13 +- .../contest/templates/task_submissions.html | 24 +-- .../server/contest/task_scores_test.py | 182 ++++++++++++++++++ 7 files changed, 231 insertions(+), 100 deletions(-) create mode 100644 cmstestsuite/unit_tests/server/contest/task_scores_test.py diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index c02b306711..475e87d579 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -51,7 +51,7 @@ from sqlalchemy.orm import joinedload, selectinload from cms import config, TOKEN_MODE_MIXED -from cms.db import Contest, Submission, Task, UserTest +from cms.db import Contest, Dataset, Submission, Task, UserTest from cms.grading.scoring import task_score from cms.locale import filter_language_codes from cms.server import FileHandlerMixin @@ -206,7 +206,8 @@ def _load_participation_for_scores( joinedload(Participation.user), joinedload(Participation.contest) .joinedload(Contest.tasks) - .joinedload(Task.active_dataset), + .joinedload(Task.active_dataset) + .selectinload(Dataset.testcases), selectinload(Participation.submissions).joinedload(Submission.token), selectinload(Participation.submissions).joinedload(Submission.results), ) @@ -218,7 +219,6 @@ def _compute_task_scores( 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. @@ -232,6 +232,8 @@ def _compute_task_scores( } 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 @@ -241,39 +243,30 @@ def _compute_task_scores( ) 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) - else: - score_value, _ = task_score(participation, task, only_tokened=True) + score_value, _ = task_score( + participation, task, only_tokened=actual_phase != 3) max_score_value = score_type.max_score - score_message = score_type.format_score( - score_value, - score_type.max_score, - None, - translation=self.translation, - ) else: - max_public_score = score_type.max_public_score - - # Optionally hide entries with no public score. - if hide_zero_max_public and max_public_score <= 0: + max_score_value = score_type.max_public_score + if max_score_value <= 0: continue - score_value, _ = task_score(participation, task, public=True) - max_score_value = max_public_score - score_message = score_type.format_score( - score_value, - score_type.max_public_score, - None, - translation=self.translation, - ) + 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() @@ -314,23 +307,6 @@ 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 diff --git a/cms/server/contest/handlers/main.py b/cms/server/contest/handlers/main.py index d6e3536c19..51590db3c5 100644 --- a/cms/server/contest/handlers/main.py +++ b/cms/server/contest/handlers/main.py @@ -79,40 +79,6 @@ 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"] - should_show_task_overview = ( - ret["actual_phase"] >= 0 or participation.unrestricted - ) - - # 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 and should_show_task_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. diff --git a/cms/server/contest/handlers/tasksubmission.py b/cms/server/contest/handlers/tasksubmission.py index 0c6915aa1f..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 @@ -212,6 +212,7 @@ def add_task_score(self, participation: Participation, task: Task, data: dict): 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. @@ -224,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/templates/contest.html b/cms/server/contest/templates/contest.html index c8b9322014..b4e67e634a 100644 --- a/cms/server/contest/templates/contest.html +++ b/cms/server/contest/templates/contest.html @@ -178,6 +178,9 @@

{% 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 %} diff --git a/cms/server/contest/templates/overview.html b/cms/server/contest/templates/overview.html index cc98803a40..c70aac048d 100644 --- a/cms/server/contest/templates/overview.html +++ b/cms/server/contest/templates/overview.html @@ -180,6 +180,9 @@

{% 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 %}

{% trans %}Score{% endtrans %}{% trans %}Task{% endtrans %}
+ + + + +

Logging in

Timezone From 44e6a95bd7a39b04aecee22381fd158c85f56933 Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Thu, 9 Apr 2026 01:12:27 +0800 Subject: [PATCH 23/26] Fix bugs --- cms/server/contest/submission/check.py | 2 +- cms/server/contest/templates/contest_list.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/server/contest/submission/check.py b/cms/server/contest/submission/check.py index 646d943852..dc5caffd94 100644 --- a/cms/server/contest/submission/check.py +++ b/cms/server/contest/submission/check.py @@ -221,7 +221,7 @@ def is_last_minutes(timestamp: datetime, participation: Participation): return False if participation.contest.per_user_time is None: - end_time = participation.contest.stop + end_time = participation.group.stop else: end_time = participation.starting_time + participation.contest.per_user_time diff --git a/cms/server/contest/templates/contest_list.html b/cms/server/contest/templates/contest_list.html index 4a097a94ec..003aff2782 100644 --- a/cms/server/contest/templates/contest_list.html +++ b/cms/server/contest/templates/contest_list.html @@ -8,7 +8,7 @@

{% trans %}Choose a contest{% endtrans %}

From 888daa918f9ca530a6434dd325cf6081c65f7425 Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Thu, 9 Apr 2026 02:24:20 +0800 Subject: [PATCH 24/26] Update contest.py --- cms/server/admin/handlers/contest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cms/server/admin/handlers/contest.py b/cms/server/admin/handlers/contest.py index f00032c074..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 From 2690129c49540f0d8c730a8412d0431cdc8b198a Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Sun, 17 May 2026 00:44:07 +0800 Subject: [PATCH 25/26] Fix template get_score_class calls to use new 2-arg signature --- cms/server/contest/handlers/contest.py | 18 +++++------------- cms/server/contest/templates/contest.html | 2 +- cms/server/contest/templates/overview.html | 2 +- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index 01f1bf72e6..c02b306711 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -244,37 +244,29 @@ def _compute_task_scores( 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) + score_value, _ = task_score(participation, task) else: - score_value, _ = task_score( - participation, task, only_tokened=True, rounded=True - ) - max_score_value = round(score_type.max_score, task.score_precision) + score_value, _ = task_score(participation, task, only_tokened=True) + max_score_value = score_type.max_score 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 - ) + max_public_score = score_type.max_public_score # 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 - ) + score_value, _ = task_score(participation, task, public=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, ) diff --git a/cms/server/contest/templates/contest.html b/cms/server/contest/templates/contest.html index 80f9e6fec6..c8b9322014 100644 --- a/cms/server/contest/templates/contest.html +++ b/cms/server/contest/templates/contest.html @@ -183,7 +183,7 @@

{{ t_iter.name }} {% if contest.show_task_scores_in_sidebar %} {% if sidebar_task_scores is defined and t_iter.id in sidebar_task_scores %} - + {{ sidebar_task_scores[t_iter.id][2] }} {% else %} diff --git a/cms/server/contest/templates/overview.html b/cms/server/contest/templates/overview.html index 26aa769b66..cc98803a40 100644 --- a/cms/server/contest/templates/overview.html +++ b/cms/server/contest/templates/overview.html @@ -213,7 +213,7 @@

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

{% set extensions = "[%s]"|format(task_allowed_languages|map("to_language")|map(attribute="source_extension")|unique|join("|")) %}

{{ task_scores[t_iter.id][2] }}{{ task_scores[t_iter.id][2] }}{{ t_iter.name }} {{ t_iter.title }}
@@ -213,25 +216,29 @@

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

{% 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 %}
{{ 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 33d7b51c08..22b6d508b1 100644 --- a/cms/server/contest/templates/task_submissions.html +++ b/cms/server/contest/templates/task_submissions.html @@ -35,10 +35,6 @@ {% endfor %} }; -// If any existing submission has a played token, sidebar should show tokened -// total score for this task. -var sidebar_use_tokened_score = {% if submissions|selectattr("token")|list|length > 0 %}true{% else %}false{% endif %}; - $(document).on("click", ".submission_list tbody tr td.status .details", function (event) { var submission_id = $(this).parent().parent().attr("data-submission"); var modal = $("#submission_detail"); @@ -133,7 +129,7 @@ } badge.removeClass('undefined score_0 score_0_100 score_100'); - badge.addClass(get_score_class(task_score, max_score)); + badge.addClass(task_score === undefined ? 'undefined' : get_score_class(task_score, max_score)); badge.text(task_score_message); badge.show(); }; @@ -157,13 +153,7 @@ data["task_public_score"], data["task_public_score_message"], data["task_score_is_partial"], data["max_public_score"]); - // If we can see full score for at least one submission (token played or - // analysis), switch sidebar to tokened total score. - if (data["score"] !== undefined) { - sidebar_use_tokened_score = true; - } - - if (sidebar_use_tokened_score + if (data["task_use_tokened_score"] && data["task_tokened_score"] !== undefined && data["task_tokened_score_message"] !== undefined && data["max_score"] !== undefined) { @@ -178,8 +168,10 @@ 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 %} +{% if can_use_tokens or actual_phase == 3 %} update_score( row.children("td.total_score"), $("#task_score_tokened"), data["score"], data["score_message"], @@ -258,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 %} @@ -268,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/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)