diff --git a/cms/server/admin/handlers/__init__.py b/cms/server/admin/handlers/__init__.py index 833c8f9dc8..d371dfea26 100644 --- a/cms/server/admin/handlers/__init__.py +++ b/cms/server/admin/handlers/__init__.py @@ -189,7 +189,9 @@ TrainingProgramAttendanceHandler, \ TrainingProgramCombinedRankingHandler, \ TrainingProgramCombinedRankingHistoryHandler, \ - TrainingProgramCombinedRankingDetailHandler + TrainingProgramCombinedRankingDetailHandler, \ + TrainingProgramOverviewRedirectHandler, \ + TrainingProgramResourcesListRedirectHandler HANDLERS = [ @@ -366,6 +368,8 @@ (r"/training_program/([0-9]+)/combined_ranking", TrainingProgramCombinedRankingHandler), (r"/training_program/([0-9]+)/combined_ranking/history", TrainingProgramCombinedRankingHistoryHandler), (r"/training_program/([0-9]+)/student/([0-9]+)/combined_ranking_detail", TrainingProgramCombinedRankingDetailHandler), + (r"/training_program/([0-9]+)/overview", TrainingProgramOverviewRedirectHandler), + (r"/training_program/([0-9]+)/resourceslist", TrainingProgramResourcesListRedirectHandler), # Training day groups (main groups configuration on contest page) (r"/contest/([0-9]+)/training_day_group/add", AddTrainingDayGroupHandler), diff --git a/cms/server/admin/handlers/base.py b/cms/server/admin/handlers/base.py index a9ce94c3a7..9b71563d26 100644 --- a/cms/server/admin/handlers/base.py +++ b/cms/server/admin/handlers/base.py @@ -329,13 +329,16 @@ def prepare(self): contest_id = match.group(1) remaining_path = match.group(2) or "" - # Don't redirect question/announcement/message actions - they should use - # the contest handlers directly since questions/announcements - # belong to the managing contest, and messages use the contest user + # Don't redirect certain actions - they should use the contest handlers + # directly since questions/announcements belong to the managing contest, + # messages use the contest user, and overview/resourceslist are contest- + # specific pages that training programs redirect to if ( remaining_path.startswith("/question/") or remaining_path.startswith("/announcement/") or remaining_path.endswith("/message") + or remaining_path == "/overview" + or remaining_path == "/resourceslist" ): return diff --git a/cms/server/admin/handlers/contesttask.py b/cms/server/admin/handlers/contesttask.py index 110b4759b7..f7199e1b3c 100644 --- a/cms/server/admin/handlers/contesttask.py +++ b/cms/server/admin/handlers/contesttask.py @@ -52,15 +52,28 @@ def get(self, contest_id): self.r_params["is_training_day"] = training_day is not None if training_day is not None: - # For training days, show tasks from the training program's - # managing contest that are not already assigned to any training day + # For training days, show all tasks that are not already assigned + # to any training day. Tasks in the training program are shown first, + # followed by tasks not in the training program. training_program = training_day.training_program - self.r_params["unassigned_tasks"] = \ - self.sql_session.query(Task)\ - .filter(Task.contest_id == training_program.managing_contest_id)\ - .filter(Task.training_day_id.is_(None))\ - .order_by(Task.num)\ - .all() + + # Tasks in the training program (not assigned to any training day) + program_tasks = self.sql_session.query(Task)\ + .filter(Task.contest_id == training_program.managing_contest_id)\ + .filter(Task.training_day_id.is_(None))\ + .order_by(Task.num)\ + .all() + + # All other tasks (not in any contest or training day) + other_tasks = self.sql_session.query(Task)\ + .filter(Task.contest_id.is_(None))\ + .filter(Task.training_day_id.is_(None))\ + .order_by(Task.name)\ + .all() + + self.r_params["unassigned_tasks"] = program_tasks + other_tasks + # Track which task IDs are in the training program for the template + self.r_params["program_task_ids"] = [t.id for t in program_tasks] # Get all student tags for autocomplete (for task visibility tags) self.r_params["all_student_tags"] = get_all_student_tags(training_program) @@ -282,6 +295,15 @@ def post(self, contest_id): task = self.safe_get_item(Task, task_id) if training_day is not None: + training_program = training_day.training_program + + # Check if task is not in the training program + if task.contest_id != training_program.managing_contest_id: + # Add the task to the training program's managing contest first + managing_contest = training_program.managing_contest + task.num = len(managing_contest.tasks) + task.contest = managing_contest + # Assign the task to the training day. # Task keeps its contest_id (managing contest) and gets training_day_id set. # Use training_day_num for ordering within the training day. diff --git a/cms/server/admin/handlers/task.py b/cms/server/admin/handlers/task.py index 6882f34f6a..f7fdb6380e 100644 --- a/cms/server/admin/handlers/task.py +++ b/cms/server/admin/handlers/task.py @@ -112,7 +112,12 @@ class TaskHandler(BaseHandler): @require_permission(BaseHandler.AUTHENTICATED) def get(self, task_id): task = self.safe_get_item(Task, task_id) - self.contest = task.contest + # If the task is assigned to an active training day (not archived), + # show the training day's contest sidebar instead of the training program sidebar + if task.training_day is not None and task.training_day.contest is not None: + self.contest = task.training_day.contest + else: + self.contest = task.contest self.r_params = self.render_params() self.r_params["task"] = task diff --git a/cms/server/admin/handlers/trainingprogram.py b/cms/server/admin/handlers/trainingprogram.py index e8a5fd3942..a86c445c3a 100644 --- a/cms/server/admin/handlers/trainingprogram.py +++ b/cms/server/admin/handlers/trainingprogram.py @@ -107,8 +107,10 @@ def get(self, training_program_id: str): def post(self, training_program_id: str): fallback = self.url("training_program", training_program_id) training_program = self.safe_get_item(TrainingProgram, training_program_id) + contest = training_program.managing_contest try: + # Update training program attributes attrs = training_program.get_attrs() self.get_string(attrs, "name") self.get_string(attrs, "description") @@ -122,28 +124,72 @@ def post(self, training_program_id: str): training_program.set_attrs(attrs) # Sync description to managing contest - training_program.managing_contest.description = attrs["description"] - - # Parse and update start/stop times on managing contest - start_str = self.get_argument("start", "") - stop_str = self.get_argument("stop", "") - - if start_str: - training_program.managing_contest.start = dt.strptime( - start_str, "%Y-%m-%dT%H:%M" - ) - - if stop_str: - training_program.managing_contest.stop = dt.strptime( - stop_str, "%Y-%m-%dT%H:%M" - ) + contest.description = attrs["description"] + + # Update managing contest configuration fields + contest_attrs = contest.get_attrs() + + # Allowed localizations (comma-separated list) + allowed_localizations: str = self.get_argument("allowed_localizations", "") + if allowed_localizations: + contest_attrs["allowed_localizations"] = [ + x.strip() + for x in allowed_localizations.split(",") + if len(x) > 0 and not x.isspace() + ] + else: + contest_attrs["allowed_localizations"] = [] + + # Programming languages + contest_attrs["languages"] = self.get_arguments("languages") + + # Boolean settings + self.get_bool(contest_attrs, "submissions_download_allowed") + self.get_bool(contest_attrs, "allow_questions") + self.get_bool(contest_attrs, "allow_user_tests") + self.get_bool(contest_attrs, "allow_unofficial_submission_before_analysis_mode") + self.get_bool(contest_attrs, "allow_delay_requests") + + # Login section boolean settings + self.get_bool(contest_attrs, "block_hidden_participations") + self.get_bool(contest_attrs, "allow_password_authentication") + self.get_bool(contest_attrs, "allow_registration") + self.get_bool(contest_attrs, "ip_restriction") + self.get_bool(contest_attrs, "ip_autologin") + + # Score precision + self.get_int(contest_attrs, "score_precision") + + # Times + self.get_datetime(contest_attrs, "start") + self.get_datetime(contest_attrs, "stop") + self.get_string(contest_attrs, "timezone", empty=None) + self.get_timedelta_sec(contest_attrs, "per_user_time") + + # Limits + self.get_int(contest_attrs, "max_submission_number") + self.get_int(contest_attrs, "max_user_test_number") + self.get_timedelta_sec(contest_attrs, "min_submission_interval") + self.get_timedelta_sec(contest_attrs, "min_submission_interval_grace_period") + self.get_timedelta_sec(contest_attrs, "min_user_test_interval") + + # Token parameters + self.get_string(contest_attrs, "token_mode") + self.get_int(contest_attrs, "token_max_number") + self.get_timedelta_sec(contest_attrs, "token_min_interval") + self.get_int(contest_attrs, "token_gen_initial") + self.get_int(contest_attrs, "token_gen_number") + self.get_timedelta_min(contest_attrs, "token_gen_interval") + self.get_int(contest_attrs, "token_gen_max") + + # Apply contest attributes + contest.set_attrs(contest_attrs) # Validate that stop is not before start (only if both are set) if ( - training_program.managing_contest.start is not None - and training_program.managing_contest.stop is not None - and training_program.managing_contest.stop - < training_program.managing_contest.start + contest.start is not None + and contest.stop is not None + and contest.stop < contest.start ): raise ValueError("End time must be after start time") @@ -154,7 +200,9 @@ def post(self, training_program_id: str): self.redirect(fallback) return - self.try_commit() + if self.try_commit(): + # Update the contest on RWS. + self.service.proxy_service.reinitialize() self.redirect(fallback) @@ -440,7 +488,7 @@ def post(self, training_program_id: str): try: user_id: str = self.get_argument("user_id") - assert user_id != "null", "Please select a valid user" + assert user_id != "", "Please select a valid user" except Exception as error: self.service.add_notification( make_datetime(), "Invalid field(s)", repr(error)) @@ -449,7 +497,13 @@ def post(self, training_program_id: str): user = self.safe_get_item(User, user_id) - participation = Participation(contest=managing_contest, user=user) + # Set starting_time to now so the student can see everything immediately + # (training programs don't have a start button) + participation = Participation( + contest=managing_contest, + user=user, + starting_time=make_datetime() + ) self.sql_session.add(participation) self.sql_session.flush() @@ -1841,7 +1895,7 @@ def post(self, training_program_id: str, user_id: str): try: task_id = self.get_argument("task_id") - if task_id == "null": + if task_id in ("", "null"): raise ValueError("Please select a task") task = self.safe_get_item(Task, task_id) @@ -1973,7 +2027,7 @@ def post(self, training_program_id: str): try: task_id = self.get_argument("task_id") - if task_id == "null": + if task_id in ("", "null"): raise ValueError("Please select a task") tag_name = self.get_argument("tag", "").strip().lower() @@ -2812,3 +2866,25 @@ def get_training_day_num(task_id: int) -> tuple[int, int]: .filter(Question.ignored.is_(False))\ .count() self.render("training_program_combined_ranking_detail.html", **self.r_params) + + +class TrainingProgramOverviewRedirectHandler(BaseHandler): + """Redirect /training_program/{id}/overview to the managing contest's overview page.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + self.redirect( + self.url("contest", training_program.managing_contest.id, "overview") + ) + + +class TrainingProgramResourcesListRedirectHandler(BaseHandler): + """Redirect /training_program/{id}/resourceslist to the managing contest's resourceslist page.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + self.redirect( + self.url("contest", training_program.managing_contest.id, "resourceslist") + ) diff --git a/cms/server/admin/handlers/user.py b/cms/server/admin/handlers/user.py index ff9617bff6..fb50f1bd9f 100644 --- a/cms/server/admin/handlers/user.py +++ b/cms/server/admin/handlers/user.py @@ -32,7 +32,7 @@ import re from sqlalchemy import and_, exists -from cms.db import Contest, Participation, Submission, Team, User +from cms.db import Contest, Participation, Submission, Team, User, TrainingDay, TrainingProgram from cms.server.util import exclude_internal_contests from cmscommon.crypto import (parse_authentication, hash_password, validate_password_strength) @@ -48,10 +48,35 @@ def get(self, user_id): self.r_params = self.render_params() self.r_params["user"] = user - self.r_params["participations"] = \ - self.sql_session.query(Participation)\ - .filter(Participation.user == user)\ - .all() + + # Get all participations and separate them into categories + all_participations = self.sql_session.query(Participation)\ + .filter(Participation.user == user)\ + .all() + + # Separate participations into: + # 1. Training program participations (managing contest) + # 2. Training day participations (hidden from list) + # 3. Regular contest participations + training_program_participations = [] + regular_participations = [] + + for p in all_participations: + # Check if this is a training program's managing contest + if p.contest.training_program is not None: + training_program_participations.append(p) + # Check if this is a training day contest (hide from list) + elif p.contest.training_day is not None: + # Skip training day participations - they're managed via training program + pass + else: + regular_participations.append(p) + + self.r_params["participations"] = regular_participations + self.r_params["training_program_participations"] = training_program_participations + + # Filter out training day contests and managing contests from unassigned list + # (users should be added to training programs via the training program UI) self.r_params["unassigned_contests"] = exclude_internal_contests( self.sql_session.query(Contest).filter( ~exists().where( @@ -60,6 +85,12 @@ def get(self, user_id): Participation.user == user ) ) + ).filter( + # Exclude training day contests + ~exists().where(TrainingDay.contest_id == Contest.id) + ).filter( + # Exclude managing contests (training program contests) + ~exists().where(TrainingProgram.managing_contest_id == Contest.id) ) ).all() self.render("user.html", **self.r_params) diff --git a/cms/server/admin/templates/base.html b/cms/server/admin/templates/base.html index 5dfe0c22bd..55500200a4 100644 --- a/cms/server/admin/templates/base.html +++ b/cms/server/admin/templates/base.html @@ -6,7 +6,7 @@ // Pre-paint CSS injection to prevent flash of expanded menus // Uses a hydration class on so these rules only apply during initial render (function() { - var validSectionKeys = ['training-programs', 'contests', 'tasks', 'users', 'teams']; + var validSectionKeys = ['training-programs', 'contests', 'tasks', 'users', 'teams', 'training-days']; var folderKeyPattern = /^(folder|subfolder|training-program)-\d+$/; var css = ''; @@ -284,9 +284,12 @@