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..bf6751c6b1 100644 --- a/cms/db/training_day.py +++ b/cms/db/training_day.py @@ -23,14 +23,41 @@ import typing -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, Session from sqlalchemy.schema import Column, ForeignKey, UniqueConstraint from sqlalchemy.types import Integer from . import Base if typing.TYPE_CHECKING: - from . import Contest, TrainingProgram, Task, TrainingDayGroup + from . import Contest, TrainingProgram, Task, TrainingDayGroup, Submission, Participation, User + + +def get_managing_participation( + session: Session, + training_day: "TrainingDay", + user: "User", +) -> "Participation | None": + """Get the managing contest participation for a user in a training day. + + Training day submissions are stored with the managing contest's participation, + not the training day's participation. This helper finds the managing contest + participation for a given user. + + session: the database session. + training_day: the training day. + user: the user to look up. + + return: the Participation in the managing contest, or None if not found. + """ + from . import Participation + managing_contest = training_day.training_program.managing_contest + return ( + session.query(Participation) + .filter(Participation.contest_id == managing_contest.id) + .filter(Participation.user_id == user.id) + .first() + ) class TrainingDay(Base): @@ -88,3 +115,9 @@ class TrainingDay(Base): back_populates="training_day", cascade="all, delete-orphan", ) + + submissions: list["Submission"] = relationship( + "Submission", + back_populates="training_day", + passive_deletes=True, + ) diff --git a/cms/grading/scorecache.py b/cms/grading/scorecache.py index 7719860f0c..e9738dd63f 100644 --- a/cms/grading/scorecache.py +++ b/cms/grading/scorecache.py @@ -732,7 +732,41 @@ def _get_sorted_official_submissions( participation: Participation, task: Task, ) -> list[Submission]: - """Get official submissions for a task, sorted by timestamp.""" + """Get official submissions for a task, sorted by timestamp. + + For training day participations, submissions are stored with the managing + contest's participation, so we need to query from there and filter by + training_day_id. + + Raises: + ValueError: When managing participation is None for training days + """ + from cms.db.training_day import get_managing_participation + + training_day = participation.contest.training_day + if training_day is not None: + # This is a training day participation - submissions are stored with + # the managing contest's participation + managing_participation = get_managing_participation( + session, training_day, participation.user + ) + + if managing_participation is None: + # User doesn't have a participation in the managing contest + # This indicates a configuration or data integrity issue + raise ValueError( + f"User {participation.user_id} does not have participation in managing contest " + f"{training_day.training_program.managing_contest_id} for training day {training_day.id}" + ) + + return session.query(Submission).filter( + Submission.participation_id == managing_participation.id, + Submission.task_id == task.id, + Submission.training_day_id == training_day.id, + Submission.official.is_(True) + ).order_by(Submission.timestamp.asc()).all() + + # Regular contest - query submissions directly return session.query(Submission).filter( Submission.participation_id == participation.id, Submission.task_id == task.id, diff --git a/cms/grading/scoring.py b/cms/grading/scoring.py index 114f560e41..77f4c04ea0 100644 --- a/cms/grading/scoring.py +++ b/cms/grading/scoring.py @@ -27,7 +27,7 @@ from sqlalchemy.orm import joinedload -from cms.db import Submission, Dataset, Participation, Task +from cms.db import Submission, Dataset, Participation, Task, TrainingDay from cmscommon.constants import \ SCORE_MODE_MAX, SCORE_MODE_MAX_SUBTASK, SCORE_MODE_MAX_TOKENED_LAST @@ -109,6 +109,7 @@ def task_score( public: bool = False, only_tokened: bool = False, rounded: bool = False, + training_day: TrainingDay | None = None, ) -> tuple[float, bool]: """Return the score of a contest's user on a task. @@ -123,6 +124,8 @@ def task_score( would obtain if all non-tokened submissions scored 0.0, or equivalently had not been scored yet). rounded: if True, round the score to the task's score_precision. + training_day: if provided, only consider submissions made via this + training day (filters by training_day_id). return: the score of user on task, and True if not all submissions of the participation in the task have been scored. @@ -144,6 +147,8 @@ def task_score( submissions = [s for s in participation.submissions if s.task is task and s.official] + if training_day is not None: + submissions = [s for s in submissions if s.training_day_id == training_day.id] if len(submissions) == 0: return 0.0, False diff --git a/cms/server/admin/handlers/__init__.py b/cms/server/admin/handlers/__init__.py index 72722f6502..8109c17010 100644 --- a/cms/server/admin/handlers/__init__.py +++ b/cms/server/admin/handlers/__init__.py @@ -104,7 +104,9 @@ from .submissiondownload import \ DownloadTaskSubmissionsHandler, \ DownloadUserContestSubmissionsHandler, \ - DownloadContestSubmissionsHandler + DownloadContestSubmissionsHandler, \ + DownloadTrainingProgramSubmissionsHandler, \ + DownloadTrainingProgramStudentSubmissionsHandler from .task import ( AddTaskHandler, TaskHandler, @@ -338,6 +340,8 @@ (r"/training_program/([0-9]+)/ranking", TrainingProgramRankingHandler), (r"/training_program/([0-9]+)/ranking/([a-z]+)", TrainingProgramRankingHandler), (r"/training_program/([0-9]+)/submissions", TrainingProgramSubmissionsHandler), + (r"/training_program/([0-9]+)/submissions/download", DownloadTrainingProgramSubmissionsHandler), + (r"/training_program/([0-9]+)/student/([0-9]+)/submissions/download", DownloadTrainingProgramStudentSubmissionsHandler), (r"/training_program/([0-9]+)/announcements", TrainingProgramAnnouncementsHandler), (r"/training_program/([0-9]+)/announcement/([0-9]+)", TrainingProgramAnnouncementHandler), (r"/training_program/([0-9]+)/questions", TrainingProgramQuestionsHandler), diff --git a/cms/server/admin/handlers/contestsubmission.py b/cms/server/admin/handlers/contestsubmission.py index 0ca3350a43..45a0c6dc76 100644 --- a/cms/server/admin/handlers/contestsubmission.py +++ b/cms/server/admin/handlers/contestsubmission.py @@ -39,18 +39,27 @@ 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 and training_program to template for training programs + self.r_params["is_training_program"] = is_training_program + if is_training_program: + self.r_params["training_program"] = contest.training_program + self.render("contest_submissions.html", **self.r_params) diff --git a/cms/server/admin/handlers/submissiondownload.py b/cms/server/admin/handlers/submissiondownload.py index 819c438808..58d1ef5878 100644 --- a/cms/server/admin/handlers/submissiondownload.py +++ b/cms/server/admin/handlers/submissiondownload.py @@ -24,8 +24,16 @@ import zipfile import tornado.web - -from cms.db import Contest, Participation, Submission, Task +from sqlalchemy.orm import joinedload + +from cms.db import ( + Contest, + Participation, + Submission, + Task, + TrainingProgram, + TrainingDay, +) from cms.grading.languagemanager import safe_get_lang_filename from .base import BaseHandler, require_permission @@ -33,14 +41,44 @@ logger = logging.getLogger(__name__) +def sanitize_path_component(name: str) -> str: + """Sanitize a string to be safe for use as a path component in zip files. + + Replaces characters that could cause path traversal or other issues: + - Forward/backward slashes (path separators) + - Null bytes + - Other problematic characters like : * ? " < > | + + name: the string to sanitize + + return: sanitized string safe for use in file paths + """ + # Characters that are problematic in file paths + unsafe_chars = '/\\:*?"<>|\x00' + result = name + for char in unsafe_chars: + result = result.replace(char, '_') + # Also strip leading/trailing whitespace and dots + result = result.strip(' .') + # Return a default if the result is empty + return result if result else "unnamed" + + +def get_source_folder(submission): + """Get the source folder name for a submission.""" + if submission.training_day_id is not None: + return sanitize_path_component(submission.training_day.contest.description) + return "task_archive" + + def get_submission_status(submission, dataset): """Get the status string for a submission. - + submission: the Submission object dataset: the Dataset to evaluate against - + return: status string (e.g., "compiling", "95.0pts", "compilationFailed") - + """ result = submission.get_result(dataset) if result is None: @@ -60,25 +98,25 @@ def get_submission_status(submission, dataset): def write_submission_files(zip_file, submission, base_path_parts, file_cacher): """Write all files from a submission to the zip file. - + zip_file: ZipFile object to write to submission: the Submission object base_path_parts: list of path components (e.g., ["username", "taskname"]) file_cacher: FileCacher instance to retrieve file content - + """ dataset = submission.task.active_dataset status = get_submission_status(submission, dataset) timestamp = submission.timestamp.strftime("%Y%m%d_%H%M%S") official_folder = "official" if submission.official else "unofficial" - - path_parts = base_path_parts + [official_folder] - + + path_parts = [*base_path_parts, official_folder] + for filename, file_obj in submission.files.items(): real_filename = safe_get_lang_filename(submission.language, filename) prefixed_filename = f"{timestamp}_{status}_{real_filename}" - file_path = "/".join(path_parts + [prefixed_filename]) - + file_path = "/".join([*path_parts, prefixed_filename]) + try: file_content = file_cacher.get_file_content(file_obj.digest) zip_file.writestr(file_path, file_content) @@ -89,20 +127,20 @@ def write_submission_files(zip_file, submission, base_path_parts, file_cacher): def build_zip(submissions, base_path_builder, file_cacher): """Build a zip file containing all submissions. - + submissions: list of Submission objects base_path_builder: function that takes a submission and returns list of path parts file_cacher: FileCacher instance to retrieve file content - + return: BytesIO object containing the zip file - + """ zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: for submission in sorted(submissions, key=lambda s: s.timestamp): base_path_parts = base_path_builder(submission) write_submission_files(zip_file, submission, base_path_parts, file_cacher) - + return zip_buffer @@ -123,10 +161,11 @@ def base_path_builder(submission): return [submission.participation.user.username] zip_buffer = build_zip(submissions, base_path_builder, self.service.file_cacher) - + self.set_header("Content-Type", "application/zip") - self.set_header("Content-Disposition", - f'attachment; filename="{task.name}_submissions.zip"') + self.set_header( + "Content-Disposition", f'attachment; filename="{task.name}_submissions.zip"' + ) self.write(zip_buffer.getvalue()) self.finish() @@ -134,21 +173,37 @@ def base_path_builder(submission): class DownloadUserContestSubmissionsHandler(BaseHandler): """Download all submissions for a specific user in a contest as a zip file. + For training day contests, only downloads submissions made via that + training day (filtered by training_day_id). """ + @require_permission(BaseHandler.AUTHENTICATED) def get(self, contest_id, user_id): self.contest = self.safe_get_item(Contest, contest_id) - participation = self.sql_session.query(Participation)\ - .filter(Participation.contest_id == contest_id)\ - .filter(Participation.user_id == user_id)\ + participation = ( + self.sql_session.query(Participation) + .filter(Participation.contest_id == contest_id) + .filter(Participation.user_id == user_id) .first() + ) if participation is None: raise tornado.web.HTTPError(404) - submissions = self.sql_session.query(Submission)\ - .filter(Submission.participation_id == participation.id)\ - .all() + # For training day contests, only download submissions made via that training day + if self.contest.training_day is not None: + submissions = ( + self.sql_session.query(Submission) + .filter(Submission.participation_id == participation.id) + .filter(Submission.training_day_id == self.contest.training_day.id) + .all() + ) + else: + submissions = ( + self.sql_session.query(Submission) + .filter(Submission.participation_id == participation.id) + .all() + ) username = participation.user.username contest_name = self.contest.name @@ -157,10 +212,12 @@ def base_path_builder(submission): return [submission.task.name] zip_buffer = build_zip(submissions, base_path_builder, self.service.file_cacher) - + self.set_header("Content-Type", "application/zip") - self.set_header("Content-Disposition", - f'attachment; filename="{username}_{contest_name}_submissions.zip"') + self.set_header( + "Content-Disposition", + f'attachment; filename="{username}_{contest_name}_submissions.zip"', + ) self.write(zip_buffer.getvalue()) self.finish() @@ -168,23 +225,129 @@ def base_path_builder(submission): class DownloadContestSubmissionsHandler(BaseHandler): """Download all submissions for a contest as a zip file. + For training day contests, only downloads submissions made via that + training day (filtered by training_day_id). """ + @require_permission(BaseHandler.AUTHENTICATED) def get(self, contest_id): self.contest = self.safe_get_item(Contest, contest_id) - submissions = self.sql_session.query(Submission)\ - .join(Task)\ - .filter(Task.contest_id == contest_id)\ - .all() + # For training day contests, only download submissions made via that training day + if self.contest.training_day is not None: + submissions = ( + self.sql_session.query(Submission) + .filter(Submission.training_day_id == self.contest.training_day.id) + .all() + ) + else: + # For regular contests and training program managing contests + submissions = ( + self.sql_session.query(Submission) + .join(Task) + .filter(Task.contest_id == contest_id) + .all() + ) def base_path_builder(submission): return [submission.participation.user.username, submission.task.name] zip_buffer = build_zip(submissions, base_path_builder, self.service.file_cacher) - + + self.set_header("Content-Type", "application/zip") + self.set_header( + "Content-Disposition", + f'attachment; filename="{self.contest.name}_all_submissions.zip"', + ) + self.write(zip_buffer.getvalue()) + self.finish() + + +class DownloadTrainingProgramSubmissionsHandler(BaseHandler): + """Download all submissions for a training program as a zip file. + + The folder structure is: user/task/source/official-unofficial/files + where source is either "task_archive" or the training day description. + """ + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + self.contest = managing_contest + + # Get all submissions for the managing contest + submissions = ( + self.sql_session.query(Submission) + .join(Task) + .filter(Task.contest_id == managing_contest.id) + .options( + joinedload(Submission.training_day).joinedload(TrainingDay.contest) + ) + .all() + ) + + def base_path_builder(submission): + source_folder = get_source_folder(submission) + return [ + submission.participation.user.username, + submission.task.name, + source_folder, + ] + + zip_buffer = build_zip(submissions, base_path_builder, self.service.file_cacher) + + self.set_header("Content-Type", "application/zip") + self.set_header( + "Content-Disposition", + f'attachment; filename="{training_program.name}_all_submissions.zip"', + ) + self.write(zip_buffer.getvalue()) + self.finish() + + +class DownloadTrainingProgramStudentSubmissionsHandler(BaseHandler): + """Download all submissions for a specific student in a training program. + + The folder structure is: task/source/official-unofficial/files + where source is either "task_archive" or the training day description. + """ + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id, user_id): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + self.contest = managing_contest + + participation = ( + self.sql_session.query(Participation) + .filter(Participation.contest_id == managing_contest.id) + .filter(Participation.user_id == user_id) + .first() + ) + + if participation is None: + raise tornado.web.HTTPError(404) + + submissions = ( + self.sql_session.query(Submission) + .filter(Submission.participation_id == participation.id) + .options( + joinedload(Submission.training_day).joinedload(TrainingDay.contest) + ) + .all() + ) + + username = participation.user.username + + def base_path_builder(submission): + source_folder = get_source_folder(submission) + return [submission.task.name, source_folder] + + zip_buffer = build_zip(submissions, base_path_builder, self.service.file_cacher) + self.set_header("Content-Type", "application/zip") self.set_header("Content-Disposition", - f'attachment; filename="{self.contest.name}_all_submissions.zip"') + f'attachment; filename="{username}_{training_program.name}_submissions.zip"') self.write(zip_buffer.getvalue()) self.finish() diff --git a/cms/server/admin/handlers/trainingprogram.py b/cms/server/admin/handlers/trainingprogram.py index 9170780239..579daa7dd7 100644 --- a/cms/server/admin/handlers/trainingprogram.py +++ b/cms/server/admin/handlers/trainingprogram.py @@ -1124,6 +1124,9 @@ def get(self, training_program_id: str): page = int(self.get_query_argument("page", "0")) self.render_params_for_submissions(query, page) + # Show training day column for training program submissions + self.r_params["is_training_program"] = True + 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..e9109cddc8 100644 --- a/cms/server/admin/templates/contest_submissions.html +++ b/cms/server/admin/templates/contest_submissions.html @@ -15,7 +15,11 @@

All Submissions

url("contest", contest.id, "submissions"), contest_id=contest.id) }}
+ {% if is_training_program|default(false) %} + Download all submissions + {% else %} Download all submissions + {% endif %}

{{ macro_submission.rows( @@ -24,7 +28,8 @@

All Submissions

url["contest"][contest.id]["submissions"], submissions, submission_page, - submission_pages) }} + submission_pages, + show_training_day=is_training_program|default(false)) }} {% endblock core %} diff --git a/cms/server/admin/templates/macro/submission.html b/cms/server/admin/templates/macro/submission.html index 88c930bc97..7de1f697c3 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,19 @@ {{ s.timestamp }} {{ s.participation.user.username }} {{ s.task.name }} + {% if show_training_day %} + + {% if s.training_day is not none %} + {% if s.training_day.contest is not none %} + {{ s.training_day.contest.description }} + {% else %} + [Deleted Training Day] + {% endif %} + {% else %} + Task Archive + {% endif %} + + {% endif %} {% if status == SubmissionResult.COMPILING %} Compiling... diff --git a/cms/server/admin/templates/student.html b/cms/server/admin/templates/student.html index a80f287630..6fbd67f12d 100644 --- a/cms/server/admin/templates/student.html +++ b/cms/server/admin/templates/student.html @@ -31,10 +31,10 @@

Submissions

Reevaluate all {{ submission_count }} submissions for this student in this training program (for all datasets) {{ macro_reevaluation_buttons.reevaluation_buttons( admin.permission_all, - url("training_program", training_program.id, "student", participation.user_id, "edit"), + url("training_program", training_program.id, "student", selected_user.id, "edit"), participation_id=participation.id) }}
- Download all submissions + Download all submissions

{{ macro_submission.rows( diff --git a/cms/server/contest/handlers/base.py b/cms/server/contest/handlers/base.py index b512b0d2e1..1185d489ba 100644 --- a/cms/server/contest/handlers/base.py +++ b/cms/server/contest/handlers/base.py @@ -263,9 +263,12 @@ def _build_folder_tree(self) -> dict: .filter(not_(ContestFolder.hidden)) .all() ) - all_contests = exclude_internal_contests( - self.sql_session.query(Contest) - ).filter(Contest.training_day is None).order_by(Contest.name).all() + all_contests = ( + exclude_internal_contests(self.sql_session.query(Contest)) + .filter(~Contest.training_day.has()) + .order_by(Contest.name) + .all() + ) folder_map = {} for folder in all_folders: @@ -334,10 +337,13 @@ def get(self, path: str | None = None): .order_by(ContestFolder.name) .all() ) - contests = exclude_internal_contests( - self.sql_session.query(Contest) - .filter(Contest.folder_id.is_(None)) - ).filter(Contest.training_day is None).all() + contests = ( + exclude_internal_contests( + self.sql_session.query(Contest).filter(Contest.folder_id.is_(None)) + ) + .filter(~Contest.training_day.has()) + .all() + ) else: subfolders = ( self.sql_session.query(ContestFolder) @@ -346,10 +352,13 @@ def get(self, path: str | None = None): .order_by(ContestFolder.name) .all() ) - contests = exclude_internal_contests( - self.sql_session.query(Contest) - .filter(Contest.folder == cur_folder) - ).filter(Contest.training_day is None).all() + contests = ( + exclude_internal_contests( + self.sql_session.query(Contest).filter(Contest.folder == cur_folder) + ) + .filter(~Contest.training_day.has()) + .all() + ) # Query training programs (only at root level, not in folders) if cur_folder is None: diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index 938aedf210..a70b353b4b 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -460,11 +460,25 @@ def get_submission(self, task: Task, opaque_id: str | int) -> Submission | None: not found). """ - return self.sql_session.query(Submission) \ - .filter(Submission.participation == self.current_user) \ - .filter(Submission.task == task) \ - .filter(Submission.opaque_id == int(opaque_id)) \ + from cms.db.training_day import get_managing_participation + + participation = self.current_user + training_day = self.contest.training_day + + if training_day is not None: + managing_participation = get_managing_participation( + self.sql_session, training_day, participation.user + ) + if managing_participation is not None: + participation = managing_participation + + return ( + self.sql_session.query(Submission) + .filter(Submission.participation == participation) + .filter(Submission.task == task) + .filter(Submission.opaque_id == int(opaque_id)) .first() + ) def get_user_test(self, task: Task, user_test_num: int) -> UserTest | None: """Return the num-th contestant's test on the given task. diff --git a/cms/server/contest/handlers/tasksubmission.py b/cms/server/contest/handlers/tasksubmission.py index dfbb11d1d9..69438b663a 100644 --- a/cms/server/contest/handlers/tasksubmission.py +++ b/cms/server/contest/handlers/tasksubmission.py @@ -36,6 +36,7 @@ from cms.db.task import Task from cms.db.user import Participation +from cms.db.training_day import get_managing_participation, TrainingDay try: collections.MutableMapping @@ -102,13 +103,31 @@ 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_participation = get_managing_participation( + self.sql_session, training_day, participation.user + ) + 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,34 +170,109 @@ 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 + managing_participation = None + if training_day is not None: + # Get the managing contest participation for this user + managing_participation = get_managing_participation( + self.sql_session, training_day, participation.user + ) + if managing_participation is None: + # User doesn't have a participation in the managing contest - + # reject early to be consistent with SubmitHandler.post() + raise tornado.web.HTTPError(403) + 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) + .order_by(Submission.timestamp.desc()) + .options(joinedload(Submission.token)) + .options(joinedload(Submission.results)) + .options( + joinedload(Submission.training_day).joinedload( + TrainingDay.contest + ) + ) + .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) + .order_by(Submission.timestamp.desc()) + .options(joinedload(Submission.token)) + .options(joinedload(Submission.results)) + .options( + joinedload(Submission.training_day).joinedload(TrainingDay.contest) + ) + .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) + + # Use managing_participation for score/token/count calculations in + # training-day context, since submissions are stored there + score_participation = ( + managing_participation if managing_participation is not None + else participation ) public_score, is_public_score_partial = task_score( - participation, task, public=True, rounded=True) + score_participation, + task, + public=True, + rounded=True, + training_day=training_day, + ) tokened_score, is_tokened_score_partial = task_score( - participation, task, only_tokened=True, rounded=True) + score_participation, + task, + only_tokened=True, + rounded=True, + training_day=training_day, + ) # These two should be the same, anyway. is_score_partial = is_public_score_partial or is_tokened_score_partial submissions_left_contest = None if self.contest.max_submission_number is not None: submissions_c = \ - get_submission_count(self.sql_session, participation, + get_submission_count(self.sql_session, score_participation, contest=self.contest) submissions_left_contest = \ self.contest.max_submission_number - submissions_c submissions_left_task = None if task.max_submission_number is not None: - submissions_left_task = \ - task.max_submission_number - len(submissions) + submissions_t = get_submission_count( + self.sql_session, score_participation, task=task + ) + submissions_left_task = task.max_submission_number - submissions_t submissions_left = submissions_left_contest if submissions_left_task is not None and \ @@ -191,11 +285,14 @@ def get(self, task_name): if submissions_left is not None: submissions_left = max(0, submissions_left) - tokens_info = tokens_available(participation, task, self.timestamp) + tokens_info = tokens_available(score_participation, task, self.timestamp) 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..a2fe9c0518 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) }} +{% endif %} + +

{% trans %}Official 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, + 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) }} +{% endif %} + +

{% trans %}Official 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, + true) }} +{% endfor %} + +{% else %} +{# Regular contest or training day view: show all submissions together #} {% if submissions|rejectattr("official")|list|length > 0 %}

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

@@ -383,9 +460,9 @@

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

can_play_token_now, submissions_download_allowed, false) }} -

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

{% endif %} +

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

{{ macro_submission.rows( url, contest_url, @@ -400,6 +477,8 @@

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

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