Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion cms/server/admin/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@
RemoveFolderHandler
from .export_handlers import \
ExportTaskHandler, \
ExportContestHandler
ExportContestHandler, \
ExportTrainingProgramHandler
from .trainingprogram import \
TrainingProgramListHandler, \
TrainingProgramHandler, \
Expand Down Expand Up @@ -372,6 +373,7 @@
(r"/training_programs/([0-9]+)/remove", RemoveTrainingProgramHandler),
(r"/training_programs/add", AddTrainingProgramHandler),
(r"/training_program/([0-9]+)", TrainingProgramHandler),
(r"/training_program/([0-9]+)/export", ExportTrainingProgramHandler),

# Training Program tabs
(r"/training_program/([0-9]+)/students", TrainingProgramStudentsHandler),
Expand Down
9 changes: 3 additions & 6 deletions cms/server/admin/handlers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,12 +376,9 @@ def prepare(self):
.filter(Contest.id == int(contest_id))
.first()
)
if contest and contest.name.startswith("__"):
training_program = (
self.sql_session.query(TrainingProgram)
.filter(TrainingProgram.managing_contest_id == int(contest_id))
.first()
)
# Redirect managing contest URLs to training program URLs
if contest and contest.training_program is not None:
training_program = contest.training_program

if training_program:
url_mappings = {
Expand Down
63 changes: 59 additions & 4 deletions cms/server/admin/handlers/export_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

import yaml

from cms.db import Contest, Task
from cms.db import Contest, Task, TrainingProgram
from cms.grading.languagemanager import SOURCE_EXTS, get_language
from cms.grading.tasktypes.util import get_allowed_manager_basenames
from cmscommon.datetime import make_datetime
Expand Down Expand Up @@ -448,14 +448,16 @@ def _export_contest_to_yaml_format(contest, file_cacher, export_dir):
if contest.analysis_stop is not None:
contest_config['analysis_stop'] = contest.analysis_stop.timestamp()

if contest.tasks:
contest_config['tasks'] = [task.name for task in contest.tasks]
# Use get_tasks() to support training days which have tasks separate from contest.tasks
tasks = contest.get_tasks()
if tasks:
contest_config['tasks'] = [task.name for task in tasks]

contest_yaml_path = os.path.join(export_dir, "contest.yaml")
with open(contest_yaml_path, 'w', encoding='utf-8') as f:
yaml.dump(contest_config, f, default_flow_style=False, allow_unicode=True, sort_keys=False)

for task in contest.tasks:
for task in tasks:
task_dir = os.path.join(export_dir, task.name)
os.makedirs(task_dir, exist_ok=True)

Expand Down Expand Up @@ -576,3 +578,56 @@ def get(self, contest_id):
finally:
if temp_dir and os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)


class ExportTrainingProgramHandler(BaseHandler):
"""Handler for exporting a training program's managing contest to a zip file.

This exports all tasks from the training program's managing contest.
"""
@require_permission(BaseHandler.AUTHENTICATED)
def get(self, training_program_id):
training_program = self.safe_get_item(TrainingProgram, training_program_id)
contest = training_program.managing_contest

temp_dir = None
try:
temp_dir = tempfile.mkdtemp(prefix="cms_export_training_program_")

contest_dir = os.path.join(temp_dir, training_program.name)
os.makedirs(contest_dir)

_export_contest_to_yaml_format(
contest,
self.service.file_cacher,
contest_dir
)

zip_path = os.path.join(temp_dir, f"{training_program.name}.zip")
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(contest_dir):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, temp_dir)
zipf.write(file_path, arcname)

self.set_header('Content-Type', 'application/zip')
self.set_header('Content-Disposition',
f'attachment; filename="{training_program.name}.zip"')

with open(zip_path, 'rb') as f:
self.write(f.read())

self.finish()

except Exception as error:
logger.error("Training program export failed: %s", error, exc_info=True)
self.service.add_notification(
make_datetime(),
"Training program export failed",
str(error))
self.redirect(self.url("training_program", training_program_id))

finally:
if temp_dir and os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
6 changes: 4 additions & 2 deletions cms/server/admin/handlers/trainingprogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ def post(self):
stop_str = self.get_argument("stop", "")

contest_kwargs: dict = {
"name": "__" + name,
"name": name,
"description": description,
"allow_delay_requests": False,
}
Expand Down Expand Up @@ -403,12 +403,14 @@ def get(self, training_program_id: str):
)
self.r_params["task_count"] = len(managing_contest.tasks)

# Other contests available to move tasks into (excluding training day contests)
# Other contests available to move tasks into (excluding training day contests
# and managing contests for training programs)
self.r_params["other_contests"] = (
self.sql_session.query(Contest)
.filter(Contest.id != managing_contest.id)
.filter(~Contest.name.like(r'\_\_%', escape='\\'))
.filter(~Contest.training_day.has())
.filter(~Contest.training_program.has())
.order_by(Contest.name)
.all()
)
Expand Down
1 change: 1 addition & 0 deletions cms/server/admin/templates/training_program.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ <h1>Training Program</h1>
</table>
<input type="submit" value="Update"{% if not admin.permission_all %} disabled{% endif %}/>
<input type="reset" value="Reset">
<a href="{{ url("training_program", training_program.id, "export") }}"> ⇩ Export all tasks </a>
</form>

<form action="{{ url("training_programs") }}" method="POST" style="display:inline;">
Expand Down
31 changes: 16 additions & 15 deletions cms/server/contest/handlers/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,27 +157,28 @@ def choose_contest(self):
raw_path = self.path_args[0]
contest_name = raw_path.split('/')[-1]

# Select the correct contest or return an error
self.contest = self.sql_session.query(Contest)\
.filter(Contest.name == contest_name).first()
if self.contest is None:
# Try to find a training program with this name
training_program = self.sql_session.query(TrainingProgram)\
.filter(TrainingProgram.name == contest_name).first()
if training_program is not None:
self.contest = training_program.managing_contest
self.training_program = training_program
else:
# Try to find a training program with this name first, since managing
# contests now share the same name as their training program
training_program = self.sql_session.query(TrainingProgram)\
.filter(TrainingProgram.name == contest_name).first()
if training_program is not None:
self.contest = training_program.managing_contest
self.training_program = training_program
else:
# No training program found, try to find a regular contest
self.contest = self.sql_session.query(Contest)\
.filter(Contest.name == contest_name).first()
if self.contest is None:
# No contest found either, return 404
self.contest = Contest(
name=contest_name, description=contest_name)
# render_params in this class assumes the contest is loaded,
# so we cannot call it without a fully defined contest. Luckily
# the one from the base class is enough to display a 404 page.
self._raise_404_for_internal_contest()
if self.contest.name.startswith("__") and self.training_program is None:
# Block direct access to managing contests, but allow access
# via training program name
self._raise_404_for_internal_contest()
# Block direct access to legacy internal contests (__ prefix)
if self.contest.name.startswith("__"):
self._raise_404_for_internal_contest()
else:
# Select the contest specified on the command line
self.contest = Contest.get_from_id(
Expand Down
12 changes: 10 additions & 2 deletions cms/server/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,21 @@


def exclude_internal_contests(query):
"""Exclude contests with names starting with '__' (internal/system contests).
"""Exclude internal/system contests from a query.

This excludes:
- Contests with names starting with '__' (legacy internal contests)
- Contests that are managing contests for training programs

query: SQLAlchemy query object for Contest queries

return: Query object with internal contests filtered out
"""
return query.filter(~Contest.name.like(r'\_\_%', escape='\\'))
return query.filter(
~Contest.name.like(r'\_\_%', escape='\\')
).filter(
~Contest.training_program.has()
)


def get_all_student_tags(training_program: "TrainingProgram") -> list[str]:
Expand Down
Loading