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 @@
No submissions found.
@@ -39,6 +41,9 @@