From f62e7b80dcefc423cfcb70b0e095bfbf439879df Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:11:14 +0000 Subject: [PATCH 1/5] Fix broken exports for training programs 1. Add 'Export all tasks' button to training program general page - Added export link that exports the managing contest's tasks 2. Fix training day export to use get_tasks() instead of contest.tasks - Training days have tasks separate from contest.tasks - Updated _export_contest_to_yaml_format to use get_tasks() 3. Rename managing contest to match training program name - Changed from '__' + name to just name - Updated contest filtering to check if contest is a managing contest - Updated exclude_internal_contests() to filter managing contests - Updated CWS and AWS handlers to check training_program relationship Co-Authored-By: Ron Ryvchin --- cms/server/admin/handlers/base.py | 9 +++------ cms/server/admin/handlers/export_handlers.py | 8 +++++--- cms/server/admin/handlers/trainingprogram.py | 6 ++++-- cms/server/admin/templates/training_program.html | 1 + cms/server/contest/handlers/contest.py | 5 ++++- cms/server/util.py | 12 ++++++++++-- 6 files changed, 27 insertions(+), 14 deletions(-) diff --git a/cms/server/admin/handlers/base.py b/cms/server/admin/handlers/base.py index 95877a7954..990df9f9ea 100644 --- a/cms/server/admin/handlers/base.py +++ b/cms/server/admin/handlers/base.py @@ -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 = { diff --git a/cms/server/admin/handlers/export_handlers.py b/cms/server/admin/handlers/export_handlers.py index b6fee9cf1b..ca9f9690e9 100644 --- a/cms/server/admin/handlers/export_handlers.py +++ b/cms/server/admin/handlers/export_handlers.py @@ -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) diff --git a/cms/server/admin/handlers/trainingprogram.py b/cms/server/admin/handlers/trainingprogram.py index 7066a8f2b2..3342d3e546 100644 --- a/cms/server/admin/handlers/trainingprogram.py +++ b/cms/server/admin/handlers/trainingprogram.py @@ -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, } @@ -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() ) diff --git a/cms/server/admin/templates/training_program.html b/cms/server/admin/templates/training_program.html index d00baab061..7e5f350f54 100644 --- a/cms/server/admin/templates/training_program.html +++ b/cms/server/admin/templates/training_program.html @@ -33,6 +33,7 @@

Training Program

+ ⇩ Export all tasks
diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index 6a8f669548..4286c63481 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -174,10 +174,13 @@ def choose_contest(self): # 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: + if self.contest.training_program is not None 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() + if self.contest.name.startswith("__") and self.training_program is None: + # Block direct access to legacy internal contests + self._raise_404_for_internal_contest() else: # Select the contest specified on the command line self.contest = Contest.get_from_id( diff --git a/cms/server/util.py b/cms/server/util.py index 12362f6fb2..93886623e2 100644 --- a/cms/server/util.py +++ b/cms/server/util.py @@ -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]: From 1fafa00b985f3db0a393e1dfd758ba6520e0e4e6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:37:31 +0000 Subject: [PATCH 2/5] Fix export button URL and add training program export route Co-Authored-By: Ron Ryvchin --- cms/server/admin/handlers/__init__.py | 4 +- cms/server/admin/handlers/export_handlers.py | 55 ++++++++++++++++++- .../admin/templates/training_program.html | 2 +- cms/server/contest/handlers/contest.py | 34 ++++++------ 4 files changed, 74 insertions(+), 21 deletions(-) diff --git a/cms/server/admin/handlers/__init__.py b/cms/server/admin/handlers/__init__.py index 27580d37df..fb74bac2e6 100644 --- a/cms/server/admin/handlers/__init__.py +++ b/cms/server/admin/handlers/__init__.py @@ -170,7 +170,8 @@ RemoveFolderHandler from .export_handlers import \ ExportTaskHandler, \ - ExportContestHandler + ExportContestHandler, \ + ExportTrainingProgramHandler from .trainingprogram import \ TrainingProgramListHandler, \ TrainingProgramHandler, \ @@ -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), diff --git a/cms/server/admin/handlers/export_handlers.py b/cms/server/admin/handlers/export_handlers.py index ca9f9690e9..9e673a26d6 100644 --- a/cms/server/admin/handlers/export_handlers.py +++ b/cms/server/admin/handlers/export_handlers.py @@ -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 @@ -578,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) diff --git a/cms/server/admin/templates/training_program.html b/cms/server/admin/templates/training_program.html index 7e5f350f54..ad6fed9b4c 100644 --- a/cms/server/admin/templates/training_program.html +++ b/cms/server/admin/templates/training_program.html @@ -33,7 +33,7 @@

Training Program

- ⇩ Export all tasks + ⇩ Export all tasks
diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index 4286c63481..aa6c43c3a8 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -157,30 +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.training_program is not None 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() - if self.contest.name.startswith("__") and self.training_program is None: - # Block direct access to legacy internal contests - 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( From 27824f4d57ba9848ce6d323f68cc57a6f8b8a9f4 Mon Sep 17 00:00:00 2001 From: Ron Ryvchin Date: Thu, 29 Jan 2026 18:06:40 +0200 Subject: [PATCH 3/5] review comments --- cms/server/admin/handlers/export_handlers.py | 71 +++++++------------- cms/server/contest/handlers/contest.py | 5 +- 2 files changed, 30 insertions(+), 46 deletions(-) diff --git a/cms/server/admin/handlers/export_handlers.py b/cms/server/admin/handlers/export_handlers.py index 9e673a26d6..d3e5f9813e 100644 --- a/cms/server/admin/handlers/export_handlers.py +++ b/cms/server/admin/handlers/export_handlers.py @@ -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. @@ -500,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) @@ -551,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) @@ -604,21 +598,8 @@ def get(self, training_program_id): ) 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() + _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) diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index aa6c43c3a8..3ef6eb47f3 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -176,8 +176,11 @@ def choose_contest(self): # 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() + # 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) - if self.contest.name.startswith("__"): + elif self.contest.name.startswith("__"): self._raise_404_for_internal_contest() else: # Select the contest specified on the command line From 662a5adbf7f257322a3d3fc84921a3cb4c318fcd Mon Sep 17 00:00:00 2001 From: Ron Ryvchin Date: Thu, 29 Jan 2026 18:14:42 +0200 Subject: [PATCH 4/5] Add pictures to dump exporter --- cms/db/util.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cms/db/util.py b/cms/db/util.py index 99b4436c1e..00b41bce5a 100644 --- a/cms/db/util.py +++ b/cms/db/util.py @@ -55,6 +55,7 @@ UserTestExecutable, PrintJob, Session, + User, ) @@ -407,6 +408,10 @@ def enumerate_files( .join(Participation.printjobs) .with_entities(PrintJob.digest)) + if not skip_users: + queries.append(session.query(User.picture) + .filter(User.picture.isnot(None))) + # union(...).execute() would be executed outside of the session. digests = set(r[0] for r in session.execute(union(*queries))) digests.discard(Digest.TOMBSTONE) From 261d48629482d7ddd4b9f17f6e54af53cac9d7fc Mon Sep 17 00:00:00 2001 From: Ron Ryvchin Date: Thu, 29 Jan 2026 18:31:32 +0200 Subject: [PATCH 5/5] export fix --- cms/db/util.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cms/db/util.py b/cms/db/util.py index 00b41bce5a..aa50e96653 100644 --- a/cms/db/util.py +++ b/cms/db/util.py @@ -409,8 +409,10 @@ def enumerate_files( .with_entities(PrintJob.digest)) if not skip_users: - queries.append(session.query(User.picture) - .filter(User.picture.isnot(None))) + 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)))