From f08ddb719b8c9b4135947feb340763d98d9c1b78 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:04:19 +0000 Subject: [PATCH 01/11] Refactor training day submissions to use managing contest participation This refactoring changes how submissions are handled in training programs: 1. Add training_day_id field to Submission model to track which training day a submission was made via (or None for task archive/regular contest submissions) 2. Update SubmitHandler to use managing contest participation for training day submissions instead of training day participation, while recording the training_day_id on the submission 3. Update score cache logic to update both managing contest participation cache AND training day participation cache when a training day submission is scored 4. Update CWS TaskSubmissionsHandler to: - Filter submissions by training_day_id for training day context - Group submissions by source (task archive vs training day) for task archive view 5. Update CWS task_submissions.html template to display submissions grouped by source in task archive view 6. Update AWS ContestSubmissionsHandler to: - Filter submissions by training_day_id for training day contests - Pass is_training_program flag to template 7. Update AWS submission macro and contest_submissions.html to show a 'Source' column for training program submissions indicating which training day the submission came from (or 'Task Archive') 8. Add database migration (update_from_1.5.sql and update_50.py) to add training_day_id column to submissions table Co-Authored-By: Ron Ryvchin --- cms/db/submission.py | 17 ++- cms/db/training_day.py | 7 +- cms/grading/scorecache.py | 141 ++++++++++++++++++ .../admin/handlers/contestsubmission.py | 17 ++- .../admin/templates/contest_submissions.html | 3 +- .../admin/templates/macro/submission.html | 22 ++- cms/server/contest/handlers/tasksubmission.py | 88 ++++++++++- .../contest/templates/task_submissions.html | 79 ++++++++++ cmscontrib/updaters/update_50.py | 43 ++++++ cmscontrib/updaters/update_from_1.5.sql | 10 ++ 10 files changed, 408 insertions(+), 19 deletions(-) create mode 100644 cmscontrib/updaters/update_50.py diff --git a/cms/db/submission.py b/cms/db/submission.py index 1b1daa2fb2..69cd1851a1 100644 --- a/cms/db/submission.py +++ b/cms/db/submission.py @@ -39,7 +39,7 @@ from cmscommon.datetime import make_datetime from . import Filename, FilenameSchema, Digest, Base, Participation, Task, \ - Dataset, Testcase + Dataset, Testcase, TrainingDay class Submission(Base): @@ -85,6 +85,21 @@ class Submission(Base): Task, back_populates="submissions") + # Training day (id and object) that this submission was submitted via. + # If None, the submission was made directly to the task archive or + # via a regular contest (not a training day). + # When set, the submission was made via a training day's contest interface, + # but the participation points to the managing contest's participation. + training_day_id: int | None = Column( + Integer, + ForeignKey(TrainingDay.id, + onupdate="CASCADE", ondelete="SET NULL"), + nullable=True, + index=True) + training_day: TrainingDay | None = relationship( + TrainingDay, + back_populates="submissions") + # Time of the submission. timestamp: datetime = Column( DateTime, diff --git a/cms/db/training_day.py b/cms/db/training_day.py index e347ed8eb8..e5117fa8be 100644 --- a/cms/db/training_day.py +++ b/cms/db/training_day.py @@ -30,7 +30,7 @@ from . import Base if typing.TYPE_CHECKING: - from . import Contest, TrainingProgram, Task, TrainingDayGroup + from . import Contest, TrainingProgram, Task, TrainingDayGroup, Submission class TrainingDay(Base): @@ -88,3 +88,8 @@ class TrainingDay(Base): back_populates="training_day", cascade="all, delete-orphan", ) + + submissions: list["Submission"] = relationship( + "Submission", + back_populates="training_day", + ) diff --git a/cms/grading/scorecache.py b/cms/grading/scorecache.py index 7719860f0c..c574c3ed40 100644 --- a/cms/grading/scorecache.py +++ b/cms/grading/scorecache.py @@ -222,6 +222,10 @@ def update_score_cache( pair of the given submission using O(1) incremental updates instead of recomputing from all submissions. + For training day submissions (where training_day_id is set), this also + updates the training day participation's cache so that training day + rankings reflect only submissions made via that training day. + IMPORTANT - Locking and Transaction Behavior: This function acquires a PostgreSQL advisory lock (pg_advisory_xact_lock) for the (participation_id, task_id) pair. The lock is transaction-scoped @@ -287,6 +291,13 @@ def update_score_cache( cache_entry.score ) + # For training day submissions, also update the training day participation's cache + # This ensures training day rankings reflect only submissions made via that training day + if submission.training_day_id is not None: + _update_training_day_participation_cache( + session, submission, submission_result, task + ) + def invalidate_score_cache( session: Session, @@ -851,3 +862,133 @@ def _rebuild_history( if new_score != current_score: _add_history_entry(session, participation, task, s, new_score) current_score = new_score + + +def _update_training_day_participation_cache( + session: Session, + submission: Submission, + submission_result: "SubmissionResult", + task: Task, +) -> None: + """Update the training day participation's cache for a training day submission. + + For submissions made via a training day, we need to update the cache for + the training day contest's participation (in addition to the managing + contest participation which is updated by the main update_score_cache). + + This ensures that training day rankings only reflect submissions made + via that specific training day. + + session: the database session. + submission: the submission that was just scored (must have training_day_id set). + submission_result: the scored submission result. + task: the task the submission is for. + + """ + from cms.db import SubmissionResult + + training_day = submission.training_day + if training_day is None: + return + + # Find the user's participation in the training day's contest + training_day_contest = training_day.contest + user = submission.participation.user + + training_day_participation = session.query(Participation).filter( + Participation.contest == training_day_contest, + Participation.user == user, + ).first() + + if training_day_participation is None: + return + + # Acquire advisory lock for the training day participation + _acquire_cache_lock(session, training_day_participation.id, task.id) + + cache_entry = _get_or_create_cache_entry( + session, training_day_participation, task + ) + old_score = cache_entry.score + + # For training day cache, we need to compute the score based only on + # submissions made via this training day. We can't use incremental update + # directly because the cache tracks all submissions, but we need to filter. + # Instead, we rebuild from training day submissions only. + _update_training_day_cache_entry_from_submissions( + session, cache_entry, training_day_participation, task, training_day + ) + + cache_entry.last_update = _utc_now() + + # For training day caches, we don't maintain history as it's less critical + # and would require filtering submissions by training_day_id + + +def _update_training_day_cache_entry_from_submissions( + session: Session, + cache_entry: ParticipationTaskScore, + participation: Participation, + task: Task, + training_day: "TrainingDay", +) -> None: + """Update a training day cache entry by recomputing from training day submissions. + + This is similar to _update_cache_entry_from_submissions but filters + submissions to only those made via the specified training day. + + """ + from cms.db import TrainingDay + + dataset = task.active_dataset + if dataset is None: + cache_entry.score = 0.0 + cache_entry.has_submissions = False + cache_entry.last_submission_timestamp = None + cache_entry.subtask_max_scores = {} + cache_entry.max_tokened_score = 0.0 + cache_entry.history_valid = True + cache_entry.created_at = _utc_now() + return + + # Get all official submissions for this task made via this training day + # The submission's participation is the managing contest participation, + # but we filter by training_day_id + submissions = ( + session.query(Submission) + .filter(Submission.task == task) + .filter(Submission.official.is_(True)) + .filter(Submission.training_day_id == training_day.id) + .filter(Submission.participation.has( + Participation.user_id == participation.user_id + )) + .order_by(Submission.timestamp) + .all() + ) + + accumulator = ScoreAccumulator() + + for s in submissions: + sr = s.get_result(dataset) + if sr is None or not sr.scored(): + continue + + score = sr.score + if score is None: + continue + + accumulator.process_submission( + score=score, + score_details=sr.score_details, + timestamp=s.timestamp, + tokened=s.tokened(), + score_mode=task.score_mode, + ) + + cache_entry.score = accumulator.compute_final_score(task) + cache_entry.has_submissions = accumulator.has_submissions + cache_entry.last_submission_timestamp = accumulator.last_submission_timestamp + cache_entry.subtask_max_scores = accumulator.subtask_max_scores + cache_entry.max_tokened_score = accumulator.max_tokened_score + cache_entry.history_valid = True + cache_entry.created_at = _utc_now() diff --git a/cms/server/admin/handlers/contestsubmission.py b/cms/server/admin/handlers/contestsubmission.py index 0ca3350a43..b617457dba 100644 --- a/cms/server/admin/handlers/contestsubmission.py +++ b/cms/server/admin/handlers/contestsubmission.py @@ -39,18 +39,25 @@ def get(self, contest_id): contest = self.safe_get_item(Contest, contest_id) self.contest = contest - # For training day contests, tasks have training_day_id set - # but contest_id points to the managing contest. - # We need to filter by training_day_id for training day contests. + # Determine if this is a training program managing contest + is_training_program = contest.training_program is not None + + # For training day contests, only show submissions made via that training day + # (submissions now have training_day_id set when submitted via a training day) if contest.training_day is not None: - query = self.sql_session.query(Submission).join(Task)\ - .filter(Task.training_day_id == contest.training_day.id) + query = self.sql_session.query(Submission)\ + .filter(Submission.training_day_id == contest.training_day.id) else: + # For regular contests and training program managing contests, + # show all submissions for tasks in this contest query = self.sql_session.query(Submission).join(Task)\ .filter(Task.contest == contest) page = int(self.get_query_argument("page", 0)) self.render_params_for_submissions(query, page) + # Pass flag to template to show training day column for training programs + self.r_params["is_training_program"] = is_training_program + self.render("contest_submissions.html", **self.r_params) diff --git a/cms/server/admin/templates/contest_submissions.html b/cms/server/admin/templates/contest_submissions.html index 03cbf973cf..7ec5c7492d 100644 --- a/cms/server/admin/templates/contest_submissions.html +++ b/cms/server/admin/templates/contest_submissions.html @@ -24,7 +24,8 @@

All Submissions

url["contest"][contest.id]["submissions"], submissions, submission_page, - submission_pages) }} + submission_pages, + show_training_day=is_training_program) }} {% endblock core %} diff --git a/cms/server/admin/templates/macro/submission.html b/cms/server/admin/templates/macro/submission.html index 88c930bc97..e874c912dd 100644 --- a/cms/server/admin/templates/macro/submission.html +++ b/cms/server/admin/templates/macro/submission.html @@ -2,7 +2,7 @@ {% import 'macro/pages.html' as macro_pages %} -{% macro rows(admin, url, page_url, submissions, page, pages, dataset=none) -%} +{% macro rows(admin, url, page_url, submissions, page, pages, dataset=none, show_training_day=false) -%} {# Render a table of submission data. @@ -15,6 +15,8 @@ pages (int): total number of pages. dataset (Dataset|None): the dataset to show results for, or if not defined use the active one. +show_training_day (bool): if true, show a column indicating which training day + the submission was made via (for training program submissions view). #} {% if pages == 0 %}

No submissions found.

@@ -39,6 +41,9 @@ Time User Task + {% if show_training_day %} + Source + {% endif %} Status Latency Files @@ -50,7 +55,7 @@ {% for s in submissions|sort(attribute="timestamp")|reverse %} - {{ row(admin, url, s, dataset) }} + {{ row(admin, url, s, dataset, show_training_day) }} {% endfor %} @@ -58,7 +63,7 @@ {%- endmacro %} -{% macro row(admin, url, s, dataset=None) -%} +{% macro row(admin, url, s, dataset=None, show_training_day=false) -%} {# Render a table's row containing a submission data. @@ -67,6 +72,8 @@ s (Submission): the submission to render. dataset (Dataset|None): the dataset to show results for, or if not defined use the active one. +show_training_day (bool): if true, show a column indicating which training day + the submission was made via (for training program submissions view). #} {% if dataset is none %} @@ -87,6 +94,15 @@ {{ s.timestamp }} {{ s.participation.user.username }} {{ s.task.name }} + {% if show_training_day %} + + {% if s.training_day is not none %} + {{ s.training_day.contest.description }} + {% else %} + Task Archive + {% endif %} + + {% endif %} {% if status == SubmissionResult.COMPILING %} Compiling... diff --git a/cms/server/contest/handlers/tasksubmission.py b/cms/server/contest/handlers/tasksubmission.py index dfbb11d1d9..5de05d3b51 100644 --- a/cms/server/contest/handlers/tasksubmission.py +++ b/cms/server/contest/handlers/tasksubmission.py @@ -102,13 +102,35 @@ def post(self, task_name): # analysis mode. official = self.r_params["actual_phase"] == 0 + # Determine the participation and training day for the submission. + # For training day submissions, we use the managing contest's participation + # but record which training day the submission was made via. + training_day = self.contest.training_day + submission_participation = participation + if training_day is not None: + # This is a training day submission - use managing contest participation + managing_contest = training_day.training_program.managing_contest + managing_participation = ( + self.sql_session.query(Participation) + .filter(Participation.contest == managing_contest) + .filter(Participation.user == participation.user) + .first() + ) + if managing_participation is None: + # User doesn't have a participation in the managing contest + raise tornado.web.HTTPError(403) + submission_participation = managing_participation + query_args = dict() try: submission = accept_submission( - self.sql_session, self.service.file_cacher, self.current_user, + self.sql_session, self.service.file_cacher, submission_participation, task, self.timestamp, self.request.files, self.get_argument("language", None), official) + # Set the training day reference if submitting via a training day + if training_day is not None: + submission.training_day = training_day self.sql_session.commit() except UnacceptableSubmission as e: logger.info("Sent error: `%s' - `%s'", e.subject, e.formatted_text) @@ -151,15 +173,62 @@ def get(self, task_name): if not self.can_access_task(task): raise tornado.web.HTTPError(404) - submissions: list[Submission] = ( - self.sql_session.query(Submission) - .filter(Submission.participation == participation) - .filter(Submission.task == task) - .options(joinedload(Submission.token)) - .options(joinedload(Submission.results)) - .all() + # Determine the context for filtering submissions + training_day = self.contest.training_day + is_task_archive = ( + self.training_program is not None and training_day is None ) + # For training day context: submissions are stored with managing contest + # participation, so we need to find that participation and filter by + # training_day_id + if training_day is not None: + # Get the managing contest participation for this user + managing_contest = training_day.training_program.managing_contest + managing_participation = ( + self.sql_session.query(Participation) + .filter(Participation.contest == managing_contest) + .filter(Participation.user == participation.user) + .first() + ) + if managing_participation is None: + submissions = [] + else: + # Only show submissions made via this training day + submissions: list[Submission] = ( + self.sql_session.query(Submission) + .filter(Submission.participation == managing_participation) + .filter(Submission.task == task) + .filter(Submission.training_day_id == training_day.id) + .options(joinedload(Submission.token)) + .options(joinedload(Submission.results)) + .all() + ) + else: + # Regular contest or task archive - show all submissions + submissions: list[Submission] = ( + self.sql_session.query(Submission) + .filter(Submission.participation == participation) + .filter(Submission.task == task) + .options(joinedload(Submission.token)) + .options(joinedload(Submission.results)) + .all() + ) + + # For task archive, group submissions by source + archive_submissions = [] + training_day_submissions = {} # training_day_id -> (training_day, submissions) + if is_task_archive: + for s in submissions: + if s.training_day_id is None: + archive_submissions.append(s) + else: + if s.training_day_id not in training_day_submissions: + training_day_submissions[s.training_day_id] = ( + s.training_day, [] + ) + training_day_submissions[s.training_day_id][1].append(s) + public_score, is_public_score_partial = task_score( participation, task, public=True, rounded=True) tokened_score, is_tokened_score_partial = task_score( @@ -196,6 +265,9 @@ def get(self, task_name): download_allowed = self.contest.submissions_download_allowed self.render("task_submissions.html", task=task, submissions=submissions, + archive_submissions=archive_submissions, + training_day_submissions=training_day_submissions, + is_task_archive=is_task_archive, public_score=public_score, tokened_score=tokened_score, is_score_partial=is_score_partial, diff --git a/cms/server/contest/templates/task_submissions.html b/cms/server/contest/templates/task_submissions.html index 9a4b0463e1..5db9d8b3c3 100644 --- a/cms/server/contest/templates/task_submissions.html +++ b/cms/server/contest/templates/task_submissions.html @@ -367,6 +367,83 @@

{% trans %}Previous submissions{% endtrans %} {% endif %} +{% if is_task_archive %} +{# Task archive view: show submissions grouped by source #} + +{# First show submissions from task archive #} +

{% trans %}Submissions from task archive{% endtrans %}

+{% if archive_submissions|rejectattr("official")|list|length > 0 %} +

{% trans %}Unofficial submissions{% endtrans %}

+ {{ macro_submission.rows( + url, + contest_url, + translation, + xsrf_form_html, + actual_phase, + task, + archive_submissions, + can_use_tokens, + can_play_token, + can_play_token_now, + submissions_download_allowed, + false) }} +

{% trans %}Official submissions{% endtrans %}

+{% endif %} + +{{ macro_submission.rows( + url, + contest_url, + translation, + xsrf_form_html, + actual_phase, + task, + archive_submissions, + can_use_tokens, + can_play_token, + can_play_token_now, + submissions_download_allowed, + true) }} + +{# Then show submissions from each training day #} +{% for td_id, td_data in training_day_submissions.items() %} +{% set training_day = td_data[0] %} +{% set td_submissions = td_data[1] %} +

{% trans td_desc=training_day.contest.description %}Submissions from training day: {{ td_desc }}{% endtrans %}

+{% if td_submissions|rejectattr("official")|list|length > 0 %} +

{% trans %}Unofficial submissions{% endtrans %}

+ {{ macro_submission.rows( + url, + contest_url, + translation, + xsrf_form_html, + actual_phase, + task, + td_submissions, + can_use_tokens, + can_play_token, + can_play_token_now, + submissions_download_allowed, + false) }} +

{% trans %}Official submissions{% endtrans %}

+{% endif %} + +{{ macro_submission.rows( + url, + contest_url, + translation, + xsrf_form_html, + actual_phase, + task, + td_submissions, + can_use_tokens, + can_play_token, + can_play_token_now, + submissions_download_allowed, + true) }} +{% endfor %} + +{% else %} +{# Regular contest or training day view: show all submissions together #} {% if submissions|rejectattr("official")|list|length > 0 %}

{% trans %}Unofficial submissions{% endtrans %}

@@ -400,6 +477,8 @@

{% trans %}Official submissions{% endtrans %}

submissions_download_allowed, true) }} +{% endif %} +