Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions cms/db/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
UserTestExecutable,
PrintJob,
Session,
User,
)


Expand Down Expand Up @@ -407,6 +408,12 @@ def enumerate_files(
.join(Participation.printjobs)
.with_entities(PrintJob.digest))

if not skip_users:
queries.append(contest_q.join(Contest.participations)
.join(Participation.user)
.filter(User.picture.isnot(None))
.with_entities(User.picture))

# union(...).execute() would be executed outside of the session.
digests = set(r[0] for r in session.execute(union(*queries)))
digests.discard(Digest.TOMBSTONE)
Expand Down
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
104 changes: 70 additions & 34 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 @@ -63,6 +63,26 @@ def _expand_codename_with_language(filename: str, language_name: str | None) ->
return filename[:-3] + extension


def _zip_directory(src_dir: str, zip_path: str, base_dir: str) -> None:
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, _dirs, files in os.walk(src_dir):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, base_dir)
zipf.write(file_path, arcname)


def _write_zip_response(handler: BaseHandler, zip_path: str, download_name: str) -> None:
handler.set_header('Content-Type', 'application/zip')
handler.set_header('Content-Disposition',
f'attachment; filename="{download_name}"')

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

handler.finish()


def _export_task_to_yaml_format(task, dataset, file_cacher, export_dir):
"""Export a task to YamlLoader (Italian YAML) format.

Expand Down Expand Up @@ -448,14 +468,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 @@ -498,21 +520,8 @@ def get(self, task_id):
)

zip_path = os.path.join(temp_dir, f"{task.name}.zip")
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(task_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="{task.name}.zip"')

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

self.finish()
_zip_directory(task_dir, zip_path, temp_dir)
_write_zip_response(self, zip_path, f"{task.name}.zip")

except Exception as error:
logger.error("Task export failed: %s", error, exc_info=True)
Expand Down Expand Up @@ -549,21 +558,8 @@ def get(self, contest_id):
)

zip_path = os.path.join(temp_dir, f"{contest.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="{contest.name}.zip"')

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

self.finish()
_zip_directory(contest_dir, zip_path, temp_dir)
_write_zip_response(self, zip_path, f"{contest.name}.zip")

except Exception as error:
logger.error("Contest export failed: %s", error, exc_info=True)
Expand All @@ -576,3 +572,43 @@ 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")
_zip_directory(contest_dir, zip_path, temp_dir)
_write_zip_response(self, zip_path, f"{training_program.name}.zip")

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
34 changes: 19 additions & 15 deletions cms/server/contest/handlers/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,27 +157,31 @@ 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()
# Check if this contest is a managing contest for a training program
if self.contest.training_program is not None:
self.training_program = self.contest.training_program
# Block direct access to legacy internal contests (__ prefix)
elif 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