Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion cms/db/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion cms/db/training_day.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -88,3 +88,8 @@ class TrainingDay(Base):
back_populates="training_day",
cascade="all, delete-orphan",
)

submissions: list["Submission"] = relationship(
"Submission",
back_populates="training_day",
)
6 changes: 5 additions & 1 deletion cms/server/admin/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@
from .submissiondownload import \
DownloadTaskSubmissionsHandler, \
DownloadUserContestSubmissionsHandler, \
DownloadContestSubmissionsHandler
DownloadContestSubmissionsHandler, \
DownloadTrainingProgramSubmissionsHandler, \
DownloadTrainingProgramStudentSubmissionsHandler
from .task import (
AddTaskHandler,
TaskHandler,
Expand Down Expand Up @@ -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),
Expand Down
17 changes: 12 additions & 5 deletions cms/server/admin/handlers/contestsubmission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
191 changes: 159 additions & 32 deletions cms/server/admin/handlers/submissiondownload.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

import tornado.web

from cms.db import Contest, Participation, Submission, Task
from cms.db import Contest, Participation, Submission, Task, TrainingProgram
from cms.grading.languagemanager import safe_get_lang_filename
from .base import BaseHandler, require_permission

Expand All @@ -35,12 +35,12 @@

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:
Expand All @@ -60,25 +60,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]

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])

try:
file_content = file_cacher.get_file_content(file_obj.digest)
zip_file.writestr(file_path, file_content)
Expand All @@ -89,20 +89,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


Expand All @@ -123,32 +123,49 @@ 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()


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
Expand All @@ -157,34 +174,144 @@ 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()


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)
.all()
)

def base_path_builder(submission):
# Determine the source folder name
if submission.training_day_id is not None:
source_folder = submission.training_day.contest.description
else:
source_folder = "task_archive"
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)
.all()
)

username = participation.user.username

def base_path_builder(submission):
# Determine the source folder name
if submission.training_day_id is not None:
source_folder = submission.training_day.contest.description
else:
source_folder = "task_archive"
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()
Loading
Loading