diff --git a/cms/db/util.py b/cms/db/util.py index 99b4436c1e..aa50e96653 100644 --- a/cms/db/util.py +++ b/cms/db/util.py @@ -55,6 +55,7 @@ UserTestExecutable, PrintJob, Session, + User, ) @@ -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) 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/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..d3e5f9813e 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 @@ -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. @@ -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) @@ -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) @@ -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) @@ -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) 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..ad6fed9b4c 100644 --- a/cms/server/admin/templates/training_program.html +++ b/cms/server/admin/templates/training_program.html @@ -33,6 +33,7 @@