diff --git a/cms/server/admin/handlers/__init__.py b/cms/server/admin/handlers/__init__.py
index 77557e7b5c..1e589f78e4 100644
--- a/cms/server/admin/handlers/__init__.py
+++ b/cms/server/admin/handlers/__init__.py
@@ -176,9 +176,6 @@
TrainingProgramHandler, \
AddTrainingProgramHandler, \
RemoveTrainingProgramHandler, \
- TrainingProgramStudentsHandler, \
- AddTrainingProgramStudentHandler, \
- RemoveTrainingProgramStudentHandler, \
TrainingProgramTasksHandler, \
AddTrainingProgramTaskHandler, \
RemoveTrainingProgramTaskHandler, \
@@ -187,26 +184,32 @@
TrainingProgramAnnouncementsHandler, \
TrainingProgramAnnouncementHandler, \
TrainingProgramQuestionsHandler, \
- StudentHandler, \
- StudentTagsHandler, \
- StudentTasksHandler, \
- AddStudentTaskHandler, \
- RemoveStudentTaskHandler, \
- BulkAssignTaskHandler, \
+ TrainingProgramOverviewRedirectHandler, \
+ TrainingProgramResourcesListRedirectHandler
+from .trainingday import \
TrainingProgramTrainingDaysHandler, \
AddTrainingDayHandler, \
RemoveTrainingDayHandler, \
AddTrainingDayGroupHandler, \
UpdateTrainingDayGroupsHandler, \
RemoveTrainingDayGroupHandler, \
- TrainingDayTypesHandler, \
+ TrainingDayTypesHandler
+from .student import \
+ TrainingProgramStudentsHandler, \
+ AddTrainingProgramStudentHandler, \
+ RemoveTrainingProgramStudentHandler, \
+ StudentHandler, \
+ StudentTagsHandler, \
+ StudentTasksHandler, \
+ AddStudentTaskHandler, \
+ RemoveStudentTaskHandler, \
+ BulkAssignTaskHandler
+from .archive import \
ArchiveTrainingDayHandler, \
TrainingProgramAttendanceHandler, \
TrainingProgramCombinedRankingHandler, \
TrainingProgramCombinedRankingHistoryHandler, \
- TrainingProgramCombinedRankingDetailHandler, \
- TrainingProgramOverviewRedirectHandler, \
- TrainingProgramResourcesListRedirectHandler
+ TrainingProgramCombinedRankingDetailHandler
HANDLERS = [
diff --git a/cms/server/admin/handlers/archive.py b/cms/server/admin/handlers/archive.py
new file mode 100644
index 0000000000..bb251c0dce
--- /dev/null
+++ b/cms/server/admin/handlers/archive.py
@@ -0,0 +1,1125 @@
+#!/usr/bin/env python3
+
+# Contest Management System - http://cms-dev.github.io/
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Admin handlers for Training Day Archive, Attendance, and Combined Ranking.
+
+These handlers manage the archiving of training days and display of
+attendance and combined ranking data across archived training days.
+"""
+
+import json
+from datetime import datetime as dt, timedelta
+from urllib.parse import urlencode
+
+import tornado.web
+
+from cms.db import (
+ Contest,
+ TrainingProgram,
+ Participation,
+ Submission,
+ Question,
+ Student,
+ StudentTask,
+ Task,
+ TrainingDay,
+ ArchivedAttendance,
+ ArchivedStudentRanking,
+ ScoreHistory,
+ DelayRequest,
+)
+from cms.db.training_day import get_managing_participation
+from cms.server.util import (
+ get_all_student_tags,
+ get_all_training_day_types,
+ can_access_task,
+ check_training_day_eligibility,
+ parse_tags,
+)
+from cmscommon.datetime import make_datetime
+
+from .base import BaseHandler, require_permission
+from .contestdelayrequest import (
+ compute_participation_status,
+ get_participation_main_group,
+)
+
+
+class ArchiveTrainingDayHandler(BaseHandler):
+ """Archive a training day, extracting attendance and ranking data."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def get(self, training_program_id: str, training_day_id: str):
+ """Show the archive confirmation page with IP selection."""
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ training_day = self.safe_get_item(TrainingDay, training_day_id)
+
+ if training_day.training_program_id != training_program.id:
+ raise tornado.web.HTTPError(404, "Training day not in this program")
+
+ if training_day.contest is None:
+ raise tornado.web.HTTPError(400, "Training day is already archived")
+
+ contest = training_day.contest
+
+ # Get all participations with their starting IPs
+ # Count students per IP (only IPs with more than one student)
+ ip_counts: dict[str, int] = {}
+ for participation in contest.participations:
+ if participation.starting_ip_addresses:
+ # Parse comma-separated IP addresses
+ ips = [ip.strip() for ip in participation.starting_ip_addresses.split(",") if ip.strip()]
+ for ip in ips:
+ ip_counts[ip] = ip_counts.get(ip, 0) + 1
+
+ # Filter to only IPs with more than one student
+ shared_ips = {ip: count for ip, count in ip_counts.items() if count > 1}
+
+ # Check if any participants can still start or are currently in contest
+ # This is used to show a warning on the archive confirmation page
+ users_not_finished = []
+ for participation in contest.participations:
+ if participation.hidden:
+ continue
+ is_eligible, _, _ = check_training_day_eligibility(
+ self.sql_session, participation, training_day
+ )
+ if not is_eligible:
+ continue
+ main_group = get_participation_main_group(contest, participation)
+ main_group_start = main_group.start_time if main_group else None
+ main_group_end = main_group.end_time if main_group else None
+ status_class, status_label = compute_participation_status(
+ contest, participation, self.timestamp,
+ main_group_start, main_group_end
+ )
+ if status_class not in ('finished', 'missed'):
+ users_not_finished.append({
+ 'participation': participation,
+ 'status_class': status_class,
+ 'status_label': status_label,
+ })
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["training_day"] = training_day
+ self.r_params["contest"] = contest
+ self.r_params["shared_ips"] = shared_ips
+ self.r_params["users_not_finished"] = users_not_finished
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == training_program.managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+ self.render("archive_training_day.html", **self.r_params)
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, training_program_id: str, training_day_id: str):
+ """Perform the archiving operation."""
+ fallback_page = self.url(
+ "training_program", training_program_id, "training_days"
+ )
+
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ training_day = self.safe_get_item(TrainingDay, training_day_id)
+
+ if training_day.training_program_id != training_program.id:
+ raise tornado.web.HTTPError(404, "Training day not in this program")
+
+ if training_day.contest is None:
+ self.service.add_notification(
+ make_datetime(), "Error", "Training day is already archived"
+ )
+ self.redirect(fallback_page)
+ return
+
+ contest = training_day.contest
+
+ # Get selected class IPs from form
+ class_ips = set(self.get_arguments("class_ips"))
+
+ try:
+ # Save name, description, and start_time from contest before archiving
+ training_day.name = contest.name
+ training_day.description = contest.description
+ training_day.start_time = contest.start
+
+ # Calculate and store the training day duration
+ # Use max duration among main groups (if any), or training day duration
+ training_day.duration = self._calculate_training_day_duration(
+ training_day, contest
+ )
+
+ # Archive attendance data for each student
+ self._archive_attendance_data(training_day, contest, class_ips)
+
+ # Archive ranking data for each student
+ self._archive_ranking_data(training_day, contest)
+
+ # Delete the contest (this will cascade delete participations)
+ self.sql_session.delete(contest)
+
+ except Exception as error:
+ self.service.add_notification(
+ make_datetime(), "Archive failed", repr(error)
+ )
+ self.redirect(fallback_page)
+ return
+
+ if self.try_commit():
+ self.service.add_notification(
+ make_datetime(),
+ "Training day archived",
+ f"Training day '{training_day.name}' has been archived successfully"
+ )
+
+ self.redirect(fallback_page)
+
+ def _calculate_training_day_duration(
+ self,
+ training_day: TrainingDay,
+ contest: Contest
+ ) -> timedelta | None:
+ """Calculate the training day duration for archiving.
+
+ Returns the max training duration among main groups (if any),
+ or the training day duration (if no main groups).
+
+ training_day: the training day being archived.
+ contest: the contest associated with the training day.
+
+ return: the duration as a timedelta, or None if not calculable.
+ """
+ # Check if there are main groups with custom timing
+ main_groups = training_day.groups
+ if main_groups:
+ # Calculate max duration among main groups
+ max_duration: timedelta | None = None
+ for group in main_groups:
+ if group.start_time is not None and group.end_time is not None:
+ group_duration = group.end_time - group.start_time
+ if max_duration is None or group_duration > max_duration:
+ max_duration = group_duration
+ if max_duration is not None:
+ return max_duration
+
+ # Fall back to training day (contest) duration
+ if contest.start is not None and contest.stop is not None:
+ return contest.stop - contest.start
+
+ return None
+
+ def _archive_attendance_data(
+ self,
+ training_day: TrainingDay,
+ contest: Contest,
+ class_ips: set[str]
+ ) -> None:
+ """Extract and store attendance data for all students."""
+ training_program = training_day.training_program
+
+ for participation in contest.participations:
+ # Find the student for this user in the training program
+ # Note: Student.participation_id points to the managing contest participation,
+ # not the training day participation, so we need to look up by user_id
+ student = (
+ self.sql_session.query(Student)
+ .join(Participation)
+ .filter(Participation.user_id == participation.user_id)
+ .filter(Student.training_program_id == training_program.id)
+ .first()
+ )
+
+ if student is None:
+ continue
+
+ # Skip ineligible students (not in any main group)
+ # These students were never supposed to participate in this training day
+ is_eligible, _, _ = check_training_day_eligibility(
+ self.sql_session, participation, training_day
+ )
+ if not is_eligible:
+ continue
+
+ # Determine status
+ if participation.starting_time is None:
+ status = "missed"
+ else:
+ status = "participated"
+
+ # Determine location based on starting IPs
+ # If no class IPs were selected, everyone who participated is considered "home"
+ location = None
+ if status == "participated":
+ if not class_ips:
+ # No class IPs selected means everyone is at home
+ location = "home"
+ elif participation.starting_ip_addresses:
+ # Parse comma-separated IP addresses
+ ips = [ip.strip() for ip in participation.starting_ip_addresses.split(",") if ip.strip()]
+ if ips:
+ has_class_ip = any(ip in class_ips for ip in ips)
+ has_home_ip = any(ip not in class_ips for ip in ips)
+
+ if has_class_ip and has_home_ip:
+ location = "both"
+ elif has_class_ip:
+ location = "class"
+ elif has_home_ip:
+ location = "home"
+ else:
+ # Participated but no IP recorded - assume home
+ location = "home"
+ else:
+ # Participated but no IP recorded - assume home
+ location = "home"
+
+ # Get delay time
+ delay_time = participation.delay_time
+
+ # Concatenate delay reasons from all delay requests
+ delay_requests = (
+ self.sql_session.query(DelayRequest)
+ .filter(DelayRequest.participation_id == participation.id)
+ .order_by(DelayRequest.request_timestamp)
+ .all()
+ )
+ delay_reasons = None
+ if delay_requests:
+ reasons = [dr.reason for dr in delay_requests if dr.reason]
+ if reasons:
+ delay_reasons = "; ".join(reasons)
+
+ # Create archived attendance record
+ archived_attendance = ArchivedAttendance(
+ status=status,
+ location=location,
+ delay_time=delay_time,
+ delay_reasons=delay_reasons,
+ )
+ archived_attendance.training_day_id = training_day.id
+ archived_attendance.student_id = student.id
+ self.sql_session.add(archived_attendance)
+
+ def _archive_ranking_data(
+ self,
+ training_day: TrainingDay,
+ contest: Contest
+ ) -> None:
+ """Extract and store ranking data for all students.
+
+ Stores on TrainingDay:
+ - archived_tasks_data: task metadata including extra_headers for submission table
+
+ Stores on ArchivedStudentRanking (per student):
+ - task_scores: scores for ALL visible tasks (including 0 scores)
+ The presence of a task_id key indicates the task was visible.
+ - submissions: submission data for each task in RWS format
+ - history: score history in RWS format
+ """
+ from cms.grading.scorecache import get_cached_score_entry
+
+ training_program = training_day.training_program
+
+ # Get the tasks assigned to this training day
+ training_day_tasks = training_day.tasks
+ training_day_task_ids = {task.id for task in training_day_tasks}
+
+ # Build and store tasks_data on the training day (same for all students)
+ # This preserves the scoring scheme as it was during the training day
+ archived_tasks_data: dict[str, dict] = {}
+ for task in training_day_tasks:
+ max_score = 100.0
+ extra_headers: list[str] = []
+ score_precision = task.score_precision
+ if task.active_dataset:
+ try:
+ score_type = task.active_dataset.score_type_object
+ max_score = score_type.max_score
+ extra_headers = score_type.ranking_headers
+ except (KeyError, TypeError, AttributeError):
+ pass
+
+ archived_tasks_data[str(task.id)] = {
+ "name": task.title,
+ "short_name": task.name,
+ "max_score": max_score,
+ "score_precision": score_precision,
+ "extra_headers": extra_headers,
+ "training_day_num": task.training_day_num,
+ }
+ training_day.archived_tasks_data = archived_tasks_data
+
+ for participation in contest.participations:
+ # Find the student for this user in the training program
+ # Note: Student.participation_id points to the managing contest participation,
+ # not the training day participation, so we need to look up by user_id
+ student = (
+ self.sql_session.query(Student)
+ .join(Participation)
+ .filter(Participation.user_id == participation.user_id)
+ .filter(Student.training_program_id == training_program.id)
+ .first()
+ )
+
+ if student is None:
+ continue
+
+ # Skip ineligible students (not in any main group)
+ # These students were never supposed to participate in this training day
+ is_eligible, _, _ = check_training_day_eligibility(
+ self.sql_session, participation, training_day
+ )
+ if not is_eligible:
+ continue
+
+ # Get all student tags (as list for array storage)
+ student_tags = list(student.student_tags) if student.student_tags else []
+
+ # Determine which tasks should be visible to this student based on their tags
+ # This uses the same logic as _add_training_day_tasks_to_student in StartHandler
+ # A task is visible if:
+ # - The task has no visible_to_tags (empty list = visible to all)
+ # - The student has at least one tag matching the task's visible_to_tags
+ visible_tasks: list[Task] = []
+ for task in training_day_tasks:
+ if can_access_task(self.sql_session, task, participation, training_day):
+ visible_tasks.append(task)
+
+ # Add visible tasks to student's StudentTask records if not already present
+ # This allows students who missed the training to still submit from home
+ existing_task_ids = {st.task_id for st in student.student_tasks}
+ for task in visible_tasks:
+ if task.id not in existing_task_ids:
+ student_task = StudentTask(assigned_at=make_datetime())
+ student_task.student_id = student.id
+ student_task.task_id = task.id
+ student_task.source_training_day_id = training_day.id
+ self.sql_session.add(student_task)
+
+ # Get the managing participation for this user
+ # Submissions are stored with the managing contest participation, not the
+ # training day participation
+ managing_participation = get_managing_participation(
+ self.sql_session, training_day, participation.user
+ )
+ if managing_participation is None:
+ raise ValueError(
+ f"User {participation.user.username} (id={participation.user_id}) "
+ f"does not have a participation in the managing contest "
+ f"'{training_day.training_program.managing_contest.name}' "
+ f"for training day '{training_day.name}'"
+ )
+
+ # Check if student missed the training (no starting_time)
+ student_missed = participation.starting_time is None
+
+ # Get task scores for ALL visible tasks (including 0 scores)
+ # The presence of a task_id key indicates the task was visible
+ task_scores: dict[str, float] = {}
+ submissions: dict[str, list[dict]] = {}
+
+ for task in visible_tasks:
+ task_id = task.id
+
+ if student_missed:
+ # Student missed the training - set score to 0
+ task_scores[str(task_id)] = 0.0
+ else:
+ # Get score from the training day participation (for cache lookup)
+ cache_entry = get_cached_score_entry(
+ self.sql_session, participation, task
+ )
+ task_scores[str(task_id)] = cache_entry.score
+
+ # Get official submissions for this task from the managing participation
+ task_submissions = (
+ self.sql_session.query(Submission)
+ .filter(Submission.participation_id == managing_participation.id)
+ .filter(Submission.task_id == task_id)
+ .filter(Submission.training_day_id == training_day.id)
+ .filter(Submission.official.is_(True))
+ .order_by(Submission.timestamp)
+ .all()
+ )
+
+ # If student missed but has submissions, this is an error
+ if student_missed and task_submissions:
+ raise ValueError(
+ f"User {participation.user.username} (id={participation.user_id}) "
+ f"has no starting_time but has {len(task_submissions)} submission(s) "
+ f"for task '{task.name}' in training day '{training_day.name}'"
+ )
+
+ submissions[str(task_id)] = []
+ for sub in task_submissions:
+ result = sub.get_result()
+ if result is None or not result.scored():
+ continue
+
+ if sub.timestamp is not None:
+ time_offset = int(
+ (
+ sub.timestamp - participation.starting_time
+ ).total_seconds()
+ )
+ else:
+ time_offset = 0
+
+ submissions[str(task_id)].append({
+ "task": str(task_id),
+ "time": time_offset,
+ "score": result.score,
+ "token": sub.tokened(),
+ "extra": result.ranking_score_details or [],
+ })
+
+ # Get score history in RWS format: [[user_id, task_id, time, score], ...]
+ # Score history is stored on the training day participation
+ history: list[list] = []
+ score_histories = (
+ self.sql_session.query(ScoreHistory)
+ .filter(ScoreHistory.participation_id == participation.id)
+ .filter(ScoreHistory.task_id.in_(training_day_task_ids))
+ .order_by(ScoreHistory.timestamp)
+ .all()
+ )
+
+ # If student missed but has score history, this is an error
+ if student_missed and score_histories:
+ raise ValueError(
+ f"User {participation.user.username} (id={participation.user_id}) "
+ f"has no starting_time but has {len(score_histories)} score history "
+ f"record(s) in training day '{training_day.name}'"
+ )
+
+ for sh in score_histories:
+ if sh.timestamp is not None:
+ time_offset = (
+ sh.timestamp - participation.starting_time
+ ).total_seconds()
+ else:
+ time_offset = 0
+ history.append([
+ participation.user_id,
+ sh.task_id,
+ time_offset,
+ sh.score
+ ])
+
+ # Create archived ranking record
+ archived_ranking = ArchivedStudentRanking(
+ student_tags=student_tags,
+ task_scores=task_scores if task_scores else None,
+ submissions=submissions if submissions else None,
+ history=history if history else None,
+ )
+ archived_ranking.training_day_id = training_day.id
+ archived_ranking.student_id = student.id
+ self.sql_session.add(archived_ranking)
+
+
+class TrainingProgramFilterMixin:
+ """Mixin for filtering training days by date range, types, and student tags."""
+
+ def _parse_date_range(self) -> tuple[dt | None, dt | None]:
+ """Parse start_date and end_date query arguments."""
+ start_date = None
+ end_date = None
+ start_str = self.get_argument("start_date", None)
+ end_str = self.get_argument("end_date", None)
+
+ if start_str:
+ try:
+ start_date = dt.fromisoformat(start_str)
+ except ValueError:
+ pass
+
+ if end_str:
+ try:
+ end_date = dt.fromisoformat(end_str)
+ except ValueError:
+ pass
+
+ return start_date, end_date
+
+ def _parse_training_day_types(self) -> list[str]:
+ """Parse training_day_types query argument."""
+ types_str = self.get_argument("training_day_types", "")
+ if not types_str:
+ return []
+ return parse_tags(types_str)
+
+ def _parse_student_tags_filter(self) -> tuple[list[str], str]:
+ """Parse student_tags and student_tags_mode query arguments.
+
+ Returns:
+ tuple of (student_tags list, filter_mode string)
+ filter_mode is either "current" or "historical"
+ """
+ tags_str = self.get_argument("student_tags", "")
+ mode = self.get_argument("student_tags_mode", "current")
+ if mode not in ("current", "historical"):
+ mode = "current"
+ if not tags_str:
+ return [], mode
+ return parse_tags(tags_str), mode
+
+ def _get_archived_training_days(
+ self,
+ training_program_id: int,
+ start_date: dt | None,
+ end_date: dt | None,
+ training_day_types: list[str] | None = None,
+ ) -> list[TrainingDay]:
+ """Query archived training days with optional date and type filtering."""
+ query = (
+ self.sql_session.query(TrainingDay)
+ .filter(TrainingDay.training_program_id == training_program_id)
+ .filter(TrainingDay.contest_id.is_(None))
+ )
+ if start_date:
+ query = query.filter(TrainingDay.start_time >= start_date)
+ if end_date:
+ # Add one day to end_date to include the entire end day
+ query = query.filter(TrainingDay.start_time < end_date + timedelta(days=1))
+ if training_day_types:
+ # Filter training days that have ALL specified types
+ query = query.filter(
+ TrainingDay.training_day_types.contains(training_day_types)
+ )
+ return query.order_by(TrainingDay.start_time).all()
+
+ def _tags_match(self, item_tags: list[str] | None, filter_tags: list[str]) -> bool:
+ """Check if item_tags contains all filter_tags."""
+ return all(tag in (item_tags or []) for tag in filter_tags)
+
+ def _get_student_ids_with_tags(self, students, filter_tags: list[str]) -> set[int]:
+ """Return IDs of students that have all filter_tags."""
+ return {s.id for s in students if self._tags_match(s.student_tags, filter_tags)}
+
+ def _get_filtered_context(self, training_program):
+ """Parse common arguments and retrieve archived training days."""
+ start_date, end_date = self._parse_date_range()
+ training_day_types = self._parse_training_day_types()
+ student_tags, student_tags_mode = self._parse_student_tags_filter()
+
+ archived_training_days = self._get_archived_training_days(
+ training_program.id, start_date, end_date, training_day_types
+ )
+
+ # Build a set of students with matching current tags
+ current_tag_student_ids = self._get_student_ids_with_tags(
+ training_program.students, student_tags
+ )
+
+ return (
+ start_date,
+ end_date,
+ training_day_types,
+ student_tags,
+ student_tags_mode,
+ archived_training_days,
+ current_tag_student_ids,
+ )
+
+
+class TrainingProgramAttendanceHandler(TrainingProgramFilterMixin, BaseHandler):
+ """Display attendance data for all archived training days."""
+
+ @require_permission(BaseHandler.AUTHENTICATED)
+ def get(self, training_program_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+
+ (
+ start_date,
+ end_date,
+ training_day_types,
+ student_tags,
+ _,
+ archived_training_days,
+ current_tag_student_ids,
+ ) = self._get_filtered_context(training_program)
+
+ # Build attendance data structure
+ # {student_id: {training_day_id: attendance_record}}
+ attendance_data: dict[int, dict[int, ArchivedAttendance]] = {}
+ all_students: dict[int, Student] = {}
+
+ for td in archived_training_days:
+ for attendance in td.archived_attendances:
+ student_id = attendance.student_id
+ # Apply student tag filter (current tags only)
+ if student_tags and student_id not in current_tag_student_ids:
+ continue
+ # Skip hidden users
+ student = attendance.student
+ if student.participation and student.participation.hidden:
+ continue
+ if student_id not in attendance_data:
+ attendance_data[student_id] = {}
+ all_students[student_id] = student
+ attendance_data[student_id][td.id] = attendance
+
+ # Sort students by username
+ sorted_students = sorted(
+ all_students.values(),
+ key=lambda s: s.participation.user.username if s.participation else ""
+ )
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["archived_training_days"] = archived_training_days
+ self.r_params["attendance_data"] = attendance_data
+ self.r_params["sorted_students"] = sorted_students
+ self.r_params["start_date"] = start_date
+ self.r_params["end_date"] = end_date
+ self.r_params["training_day_types"] = training_day_types
+ self.r_params["student_tags"] = student_tags
+ self.r_params["all_training_day_types"] = get_all_training_day_types(
+ training_program)
+ self.r_params["all_student_tags"] = get_all_student_tags(training_program)
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == training_program.managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+ self.render("training_program_attendance.html", **self.r_params)
+
+
+class TrainingProgramCombinedRankingHandler(
+ TrainingProgramFilterMixin, BaseHandler
+):
+ """Display combined ranking data for all archived training days."""
+
+ @require_permission(BaseHandler.AUTHENTICATED)
+ def get(self, training_program_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+
+ (
+ start_date,
+ end_date,
+ training_day_types,
+ student_tags,
+ student_tags_mode,
+ archived_training_days,
+ current_tag_student_ids,
+ ) = self._get_filtered_context(training_program)
+
+ ranking_data: dict[int, dict[int, ArchivedStudentRanking]] = {}
+ all_students: dict[int, Student] = {}
+ training_day_tasks: dict[int, list[dict]] = {}
+ # Attendance data: {student_id: {training_day_id: ArchivedAttendance}}
+ attendance_data: dict[int, dict[int, ArchivedAttendance]] = {}
+ # Track which students are "active" (have matching tags) for each training day
+ # For historical mode: student had matching tags during that training
+ # For current mode: student has matching tags now AND participated in that training
+ active_students_per_td: dict[int, set[int]] = {}
+
+ filtered_training_days: list[TrainingDay] = []
+
+ for td in archived_training_days:
+ active_students_per_td[td.id] = set()
+
+ # Build attendance lookup for this training day
+ for attendance in td.archived_attendances:
+ student_id = attendance.student_id
+ if student_id not in attendance_data:
+ attendance_data[student_id] = {}
+ attendance_data[student_id][td.id] = attendance
+
+ # Collect all tasks that were visible to at least one filtered student
+ # Use archived_tasks_data from training day (preserves original scoring scheme)
+ visible_tasks_by_id: dict[int, dict] = {}
+ for ranking in td.archived_student_rankings:
+ student_id = ranking.student_id
+
+ # Skip hidden users
+ student = ranking.student
+ if student.participation and student.participation.hidden:
+ continue
+
+ # Apply student tag filter
+ if student_tags:
+ if student_tags_mode == "current":
+ # Filter by current tags: student must have matching tags now
+ if student_id not in current_tag_student_ids:
+ continue
+ else: # historical mode
+ # Filter by historical tags: student must have had matching tags
+ # during this specific training day
+ if not self._tags_match(ranking.student_tags, student_tags):
+ continue
+
+ # Student passes the filter for this training day
+ active_students_per_td[td.id].add(student_id)
+
+ if student_id not in ranking_data:
+ ranking_data[student_id] = {}
+ all_students[student_id] = student
+ ranking_data[student_id][td.id] = ranking
+
+ # Collect all visible tasks from this student's task_scores keys
+ if ranking.task_scores:
+ for task_id_str in ranking.task_scores.keys():
+ task_id = int(task_id_str)
+ if task_id not in visible_tasks_by_id:
+ # Get task info from archived_tasks_data on training day
+ if td.archived_tasks_data and task_id_str in td.archived_tasks_data:
+ task_info = td.archived_tasks_data[task_id_str]
+ visible_tasks_by_id[task_id] = {
+ "id": task_id,
+ "name": task_info.get("short_name", ""),
+ "title": task_info.get("name", ""),
+ "training_day_num": task_info.get("training_day_num"),
+ }
+ else:
+ # Fallback to live task data
+ task = self.sql_session.query(Task).get(task_id)
+ if task:
+ visible_tasks_by_id[task_id] = {
+ "id": task_id,
+ "name": task.name,
+ "title": task.title,
+ "training_day_num": task.training_day_num,
+ }
+
+ # Omit training days where no filtered students were eligible
+ if not active_students_per_td[td.id]:
+ continue
+
+ filtered_training_days.append(td)
+
+ # Sort tasks by training_day_num for stable ordering
+ sorted_tasks = sorted(
+ visible_tasks_by_id.values(),
+ key=lambda t: (t.get("training_day_num") or 0, t["id"])
+ )
+ training_day_tasks[td.id] = sorted_tasks
+
+ sorted_students = sorted(
+ all_students.values(),
+ key=lambda s: s.participation.user.username if s.participation else ""
+ )
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["archived_training_days"] = filtered_training_days
+ self.r_params["ranking_data"] = ranking_data
+ self.r_params["sorted_students"] = sorted_students
+ self.r_params["training_day_tasks"] = training_day_tasks
+ self.r_params["attendance_data"] = attendance_data
+ self.r_params["active_students_per_td"] = active_students_per_td
+ self.r_params["start_date"] = start_date
+ self.r_params["end_date"] = end_date
+ self.r_params["training_day_types"] = training_day_types
+ self.r_params["student_tags"] = student_tags
+ self.r_params["student_tags_mode"] = student_tags_mode
+ self.r_params["all_training_day_types"] = get_all_training_day_types(
+ training_program)
+ self.r_params["all_student_tags"] = get_all_student_tags(training_program)
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == training_program.managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+ self.render("training_program_combined_ranking.html", **self.r_params)
+
+
+class TrainingProgramCombinedRankingHistoryHandler(
+ TrainingProgramFilterMixin, BaseHandler
+):
+ """Return score history data for combined ranking graph."""
+
+ @require_permission(BaseHandler.AUTHENTICATED)
+ def get(self, training_program_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+
+ (
+ _,
+ _,
+ _,
+ student_tags,
+ student_tags_mode,
+ archived_training_days,
+ current_tag_student_ids,
+ ) = self._get_filtered_context(training_program)
+
+ # Build history data in RWS format: [[user_id, task_id, time, score], ...]
+ result: list[list] = []
+
+ for td in archived_training_days:
+ for ranking in td.archived_student_rankings:
+ # Apply student tag filter
+ if student_tags:
+ if student_tags_mode == "current":
+ if ranking.student_id not in current_tag_student_ids:
+ continue
+ else: # historical mode
+ if not self._tags_match(ranking.student_tags, student_tags):
+ continue
+
+ if ranking.history:
+ for entry in ranking.history:
+ result.append([
+ str(entry[0]),
+ str(entry[1]),
+ int(entry[2]),
+ entry[3]
+ ])
+
+ self.set_header("Content-Type", "application/json")
+ self.write(json.dumps(result))
+
+
+class TrainingProgramCombinedRankingDetailHandler(
+ TrainingProgramFilterMixin, BaseHandler
+):
+ """Show detailed score/rank progress for a student across archived training days."""
+
+ @require_permission(BaseHandler.AUTHENTICATED)
+ def get(self, training_program_id: str, student_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ student = self.safe_get_item(Student, student_id)
+ if student.training_program_id != training_program.id:
+ raise tornado.web.HTTPError(404)
+ if student.participation and student.participation.hidden:
+ raise tornado.web.HTTPError(404)
+
+ (
+ start_date,
+ end_date,
+ training_day_types,
+ student_tags,
+ student_tags_mode,
+ archived_training_days,
+ current_tag_student_ids,
+ ) = self._get_filtered_context(training_program)
+
+ # For historical mode, we need to track which students are active per training day
+ # to compute the correct user_count for relative ranks
+ active_students_per_td: dict[int, set[int]] = {}
+ if student_tags and student_tags_mode == "historical":
+ for td in archived_training_days:
+ active_students_per_td[td.id] = set()
+ for ranking in td.archived_student_rankings:
+ student_obj = ranking.student
+ if (
+ student_obj
+ and student_obj.participation
+ and student_obj.participation.hidden
+ ):
+ continue
+ if self._tags_match(ranking.student_tags, student_tags):
+ active_students_per_td[td.id].add(ranking.student_id)
+
+ # Build users_data for filtered students only
+ users_data = {}
+ filtered_student_ids: set[int] = set()
+ for s in training_program.students:
+ if s.participation and s.participation.user:
+ if s.participation.hidden:
+ continue
+ # Apply student tag filter for current mode
+ if student_tags and student_tags_mode == "current":
+ if s.id not in current_tag_student_ids:
+ continue
+ # For historical mode, include student if they appear in any training day
+ elif student_tags and student_tags_mode == "historical":
+ if not any(s.id in active_students_per_td.get(td.id, set())
+ for td in archived_training_days):
+ continue
+ filtered_student_ids.add(s.id)
+ users_data[str(s.participation.user_id)] = {
+ "f_name": s.participation.user.first_name or "",
+ "l_name": s.participation.user.last_name or "",
+ }
+
+ user_count = len(users_data)
+
+ contests_data: dict[str, dict] = {}
+ tasks_data: dict[str, dict] = {}
+ submissions_data: dict[str, list] = {}
+ total_max_score = 0.0
+
+ # Find the student's ranking records to get their submissions
+ student_rankings: dict[int, ArchivedStudentRanking] = {}
+ for td in archived_training_days:
+ for ranking in td.archived_student_rankings:
+ if ranking.student_id == student.id:
+ student_rankings[td.id] = ranking
+ break
+
+ for td in archived_training_days:
+ contest_key = f"td_{td.id}"
+ task_ids_in_contest: set[int] = set()
+
+ # Collect all visible task IDs from filtered students' task_scores keys
+ for ranking in td.archived_student_rankings:
+ student_obj = ranking.student
+ if (
+ student_obj
+ and student_obj.participation
+ and student_obj.participation.hidden
+ ):
+ continue
+ # Apply student tag filter
+ if student_tags:
+ if student_tags_mode == "current":
+ if ranking.student_id not in current_tag_student_ids:
+ continue
+ else: # historical mode
+ if not self._tags_match(ranking.student_tags, student_tags):
+ continue
+ if ranking.task_scores:
+ task_ids_in_contest.update(int(k) for k in ranking.task_scores.keys())
+
+ # Get archived_tasks_data from training day
+ archived_tasks_data = td.archived_tasks_data or {}
+
+ # Sort task IDs by training_day_num for stable ordering
+ # Use default argument to capture archived_tasks_data by value
+ def get_training_day_num(
+ task_id: int,
+ _tasks_data: dict = archived_tasks_data
+ ) -> tuple[int, int]:
+ task_key = str(task_id)
+ if task_key in _tasks_data:
+ num = _tasks_data[task_key].get("training_day_num")
+ return (num if num is not None else 0, task_id)
+ return (0, task_id)
+
+ sorted_task_ids = sorted(task_ids_in_contest, key=get_training_day_num)
+
+ contest_tasks = []
+ contest_max_score = 0.0
+ for task_id in sorted_task_ids:
+ task_key = str(task_id)
+
+ # Use archived_tasks_data if available (preserves original scoring scheme)
+ if task_key in archived_tasks_data:
+ task_info = archived_tasks_data[task_key]
+ max_score = task_info.get("max_score", 100.0)
+ extra_headers = task_info.get("extra_headers", [])
+ score_precision = task_info.get("score_precision", 2)
+ task_name = task_info.get("name", "")
+ task_short_name = task_info.get("short_name", "")
+ else:
+ # Fallback to live task data
+ task = self.sql_session.query(Task).get(task_id)
+ if not task:
+ continue
+ max_score = 100.0
+ extra_headers = []
+ score_precision = task.score_precision
+ task_name = task.title
+ task_short_name = task.name
+ if task.active_dataset:
+ try:
+ score_type = task.active_dataset.score_type_object
+ max_score = score_type.max_score
+ extra_headers = score_type.ranking_headers
+ except (KeyError, TypeError, AttributeError):
+ pass
+
+ tasks_data[task_key] = {
+ "key": task_key,
+ "name": task_name,
+ "short_name": task_short_name,
+ "contest": contest_key,
+ "max_score": max_score,
+ "score_precision": score_precision,
+ "extra_headers": extra_headers,
+ }
+ contest_tasks.append(tasks_data[task_key])
+ contest_max_score += max_score
+
+ # Get submissions for this task from the student's ranking
+ student_ranking = student_rankings.get(td.id)
+ if student_ranking and student_ranking.submissions:
+ task_submissions = student_ranking.submissions.get(task_key, [])
+ submissions_data[task_key] = task_submissions
+
+ td_name = td.description or td.name or "Training Day"
+ if td.start_time:
+ td_name += f" ({td.start_time.strftime('%Y-%m-%d')})"
+
+ # Calculate contest duration
+ # History times are stored as offsets from contest start, so we need
+ # begin=0 and end=duration for the graph scale to be correct
+ if td.duration:
+ end_time = int(td.duration.total_seconds())
+ else:
+ end_time = 18000 # Default 5 hours
+
+ contests_data[contest_key] = {
+ "key": contest_key,
+ "name": td_name,
+ "begin": 0,
+ "end": end_time,
+ "max_score": contest_max_score,
+ "score_precision": 2,
+ "tasks": contest_tasks,
+ }
+ total_max_score += contest_max_score
+
+ contest_list = [contests_data[f"td_{td.id}"] for td in archived_training_days
+ if f"td_{td.id}" in contests_data]
+
+ history_url = self.url(
+ "training_program", training_program_id, "combined_ranking", "history"
+ )
+ if start_date or end_date or training_day_types or student_tags:
+ params = {}
+ if start_date:
+ params["start_date"] = start_date.isoformat()
+ if end_date:
+ params["end_date"] = end_date.isoformat()
+ if training_day_types:
+ params["training_day_types"] = ",".join(training_day_types)
+ if student_tags:
+ params["student_tags"] = ",".join(student_tags)
+ params["student_tags_mode"] = student_tags_mode
+ history_url += "?" + urlencode(params)
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["student"] = student
+ self.r_params["user_id"] = str(student.participation.user_id) if student.participation else "0"
+ self.r_params["user_count"] = user_count
+ self.r_params["users_data"] = users_data
+ self.r_params["tasks_data"] = tasks_data
+ self.r_params["submissions_data"] = submissions_data
+ self.r_params["contests_data"] = contests_data
+ self.r_params["contest_list"] = contest_list
+ self.r_params["total_max_score"] = total_max_score
+ self.r_params["history_url"] = history_url
+ self.r_params["start_date"] = start_date
+ self.r_params["end_date"] = end_date
+ self.r_params["training_day_types"] = training_day_types
+ self.r_params["student_tags"] = student_tags
+ self.r_params["student_tags_mode"] = student_tags_mode
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == training_program.managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+ self.render("training_program_combined_ranking_detail.html", **self.r_params)
diff --git a/cms/server/admin/handlers/base.py b/cms/server/admin/handlers/base.py
index 4f5fae2d58..8057813338 100644
--- a/cms/server/admin/handlers/base.py
+++ b/cms/server/admin/handlers/base.py
@@ -154,11 +154,20 @@ def parse_datetime(value: str) -> datetime:
def parse_datetime_with_timezone(value: str, tz) -> datetime:
"""Parse a datetime in the given timezone and convert to UTC.
- value: a datetime string in "YYYY-MM-DD HH:MM:SS" format.
+ value: a datetime string in "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DDTHH:MM" format.
tz: the timezone the datetime is in.
return: a naive datetime in UTC.
"""
+ # Try HTML5 datetime-local format first (YYYY-MM-DDTHH:MM)
+ if 'T' in value and '.' not in value and len(value) == 16:
+ try:
+ local_dt = datetime.strptime(value, "%Y-%m-%dT%H:%M")
+ return local_to_utc(local_dt, tz)
+ except (ValueError, OverflowError):
+ pass # Fall through to try other formats
+
+ # Standard format with optional microseconds
if '.' not in value:
value += ".0"
try:
@@ -353,6 +362,9 @@ def prepare(self):
remaining_path.startswith("/question/")
or remaining_path.startswith("/announcement/")
or remaining_path.endswith("/message")
+ or remaining_path.endswith("/detail")
+ or remaining_path.endswith("/submissions")
+ or remaining_path.endswith("/ranking/history")
or remaining_path == "/overview"
or remaining_path == "/resourceslist"
):
@@ -379,7 +391,9 @@ def prepare(self):
new_path = remaining_path
for contest_suffix, tp_suffix in url_mappings.items():
- if remaining_path.startswith(contest_suffix):
+ if remaining_path.startswith(
+ contest_suffix
+ ) and not remaining_path.endswith("/detail"):
new_path = remaining_path.replace(
contest_suffix, tp_suffix, 1
)
diff --git a/cms/server/admin/handlers/contest.py b/cms/server/admin/handlers/contest.py
index ece9c4bf75..86153910b8 100644
--- a/cms/server/admin/handlers/contest.py
+++ b/cms/server/admin/handlers/contest.py
@@ -232,6 +232,26 @@ def post(self, contest_id: str):
# Update the contest first
contest.set_attrs(attrs)
+ # Validate training day times against main group times
+ training_day = contest.training_day
+ if training_day is not None and training_day.groups:
+ new_start = attrs.get("start")
+ new_stop = attrs.get("stop")
+
+ for group in training_day.groups:
+ if group.start_time is not None and new_start is not None:
+ if new_start > group.start_time:
+ raise ValueError(
+ f"Training day start cannot be after main group "
+ f"'{group.tag_name}' start time"
+ )
+ if group.end_time is not None and new_stop is not None:
+ if new_stop < group.end_time:
+ raise ValueError(
+ f"Training day end cannot be before main group "
+ f"'{group.tag_name}' end time"
+ )
+
# Folder assignment (relationship)
folder_id_str = self.get_argument("folder_id", None)
if folder_id_str is None or folder_id_str == "" or folder_id_str == "none":
diff --git a/cms/server/admin/handlers/contestdelayrequest.py b/cms/server/admin/handlers/contestdelayrequest.py
index 8aff6cac1c..47aff7329c 100644
--- a/cms/server/admin/handlers/contestdelayrequest.py
+++ b/cms/server/admin/handlers/contestdelayrequest.py
@@ -166,6 +166,15 @@ def get(self, contest_id):
})
self.r_params["participation_statuses"] = participation_statuses
+
+ # Check if all participants are in stage ≥1 (finished or missed)
+ # This is used to show the "Archive Training" button on training day attendance pages
+ all_finished_or_missed = all(
+ item['status_class'] in ('finished', 'missed')
+ for item in participation_statuses
+ ) if participation_statuses else False
+ self.r_params["all_finished_or_missed"] = all_finished_or_missed
+
delay_requests = self.sql_session.query(DelayRequest)\
.join(Participation)\
.filter(Participation.contest_id == contest_id)\
diff --git a/cms/server/admin/handlers/contestranking.py b/cms/server/admin/handlers/contestranking.py
index c3c6b01405..cba6e01036 100644
--- a/cms/server/admin/handlers/contestranking.py
+++ b/cms/server/admin/handlers/contestranking.py
@@ -36,11 +36,11 @@
from sqlalchemy import and_, or_, func
from sqlalchemy.orm import joinedload
-from cms.db import Contest, Participation, ScoreHistory, \
+from cms.db import Contest, Participation, ScoreHistory, Student, \
Submission, SubmissionResult, Task
from cms.grading.scorecache import get_cached_score_entry, ensure_valid_history
-from cms.server.util import can_access_task
+from cms.server.util import can_access_task, get_all_student_tags
from .base import BaseHandler, require_permission
logger = logging.getLogger(__name__)
@@ -186,32 +186,156 @@ def get(self, contest_id, format="online"):
self.r_params = self.render_params()
self.r_params["show_teams"] = show_teams
+ # Check if this is a training day with main groups
+ training_day = self.contest.training_day
+ main_groups_data = []
+ student_tags_by_participation = {} # participation_id -> list of tags
+
+ # For training days, always build student tags lookup (batch query)
+ if training_day:
+ training_program = training_day.training_program
+ # Batch query: fetch all students for this training program's participations
+ participation_user_ids = {p.user_id for p in self.contest.participations}
+ students = (
+ self.sql_session.query(Student, Participation.user_id)
+ .join(Participation, Student.participation_id == Participation.id)
+ .filter(Student.training_program_id == training_program.id)
+ .filter(Participation.user_id.in_(participation_user_ids))
+ .all()
+ )
+ student_by_user_id = {uid: student for student, uid in students}
+
+ for p in self.contest.participations:
+ student = student_by_user_id.get(p.user_id)
+ if student:
+ student_tags_by_participation[p.id] = student.student_tags or []
+ else:
+ student_tags_by_participation[p.id] = []
+
+ if training_day and training_day.groups:
+ # Get main group tag names
+ main_group_tags = {g.tag_name for g in training_day.groups}
+
+ # Organize participations by main group
+ # A participation belongs to a main group if it has that tag
+ participations_by_group = {mg: [] for mg in sorted(main_group_tags)}
+ tasks_by_group = {mg: [] for mg in sorted(main_group_tags)}
+
+ for p in self.contest.participations:
+ if p.hidden:
+ continue
+ p_tags = set(student_tags_by_participation.get(p.id, []))
+ p_main_groups = p_tags & main_group_tags
+ for mg in p_main_groups:
+ participations_by_group[mg].append(p)
+
+ # Build task index lookup for computing group-specific scores
+ all_tasks = list(self.contest.get_tasks())
+ task_index = {task.id: i for i, task in enumerate(all_tasks)}
+
+ # For each group, determine which tasks are accessible to at least one member
+ for mg in sorted(main_group_tags):
+ group_participations = participations_by_group[mg]
+ if not group_participations:
+ continue
+
+ # Find tasks accessible to at least one member of this group
+ accessible_tasks = []
+ for task in self.contest.get_tasks():
+ for p in group_participations:
+ if can_access_by_pt.get((p.id, task.id), True):
+ accessible_tasks.append(task)
+ break
+
+ tasks_by_group[mg] = accessible_tasks
+
+ # Sort participations by group-specific total score (sum of accessible tasks only)
+ # Capture accessible_tasks in closure to avoid late binding issues
+ def get_group_score(p, tasks=accessible_tasks):
+ return sum(
+ p.task_statuses[task_index[t.id]].score
+ for t in tasks
+ )
+
+ sorted_participations = sorted(
+ group_participations,
+ key=get_group_score,
+ reverse=True
+ )
+
+ main_groups_data.append({
+ "name": mg,
+ "participations": sorted_participations,
+ "tasks": accessible_tasks,
+ })
+
+ # Get all student tags for display
+ self.r_params["all_student_tags"] = get_all_student_tags(training_program)
+
+ self.r_params["main_groups_data"] = main_groups_data
+ self.r_params["student_tags_by_participation"] = student_tags_by_participation
+ self.r_params["training_day"] = training_day
+
date_str = self.contest.start.strftime("%Y%m%d")
contest_name = self.contest.name.replace(" ", "_")
+ # Handle main_group filter for exports
+ main_group_filter = self.get_argument("main_group", None)
+
+ # If main_group filter is specified for export, find the group data
+ export_group_data = None
+ if main_group_filter and main_groups_data:
+ for gd in main_groups_data:
+ if gd["name"] == main_group_filter:
+ export_group_data = gd
+ break
+
if format == "txt":
- filename = f"{date_str}_{contest_name}_ranking.txt"
+ if export_group_data:
+ group_slug = main_group_filter.replace(" ", "_").lower()
+ filename = f"{date_str}_{contest_name}_ranking_{group_slug}.txt"
+ else:
+ filename = f"{date_str}_{contest_name}_ranking.txt"
self.set_header("Content-Type", "text/plain")
self.set_header("Content-Disposition",
f"attachment; filename=\"{filename}\"")
self.render("ranking.txt", **self.r_params)
elif format == "csv":
- filename = f"{date_str}_{contest_name}_ranking.csv"
+ if export_group_data:
+ group_slug = main_group_filter.replace(" ", "_").lower()
+ filename = f"{date_str}_{contest_name}_ranking_{group_slug}.csv"
+ else:
+ filename = f"{date_str}_{contest_name}_ranking.csv"
self.set_header("Content-Type", "text/csv")
self.set_header("Content-Disposition",
f"attachment; filename=\"{filename}\"")
- output = io.StringIO() # untested
+ output = io.StringIO()
writer = csv.writer(output)
include_partial = True
contest: Contest = self.r_params["contest"]
+ # Determine which participations and tasks to export
+ if export_group_data:
+ export_participations = export_group_data["participations"]
+ export_tasks = export_group_data["tasks"]
+ else:
+ export_participations = sorted(
+ [p for p in contest.participations if not p.hidden],
+ key=lambda p: p.total_score,
+ reverse=True
+ )
+ export_tasks = list(contest.get_tasks())
+
+ # Build header row
row = ["Username", "User"]
+ if student_tags_by_participation:
+ row.append("Tags")
if show_teams:
row.append("Team")
- for task in contest.get_tasks():
+ for task in export_tasks:
row.append(task.name)
if include_partial:
row.append("P")
@@ -222,22 +346,32 @@ def get(self, contest_id, format="online"):
writer.writerow(row)
- for p in sorted(contest.participations,
- key=lambda p: p.total_score, reverse=True):
- if p.hidden:
- continue
+ # Build task index lookup for task_statuses
+ all_tasks = list(contest.get_tasks())
+ task_index = {task.id: i for i, task in enumerate(all_tasks)}
+ for p in export_participations:
row = [p.user.username,
"%s %s" % (p.user.first_name, p.user.last_name)]
+ if student_tags_by_participation:
+ tags = student_tags_by_participation.get(p.id, [])
+ row.append(", ".join(tags))
if show_teams:
row.append(p.team.name if p.team else "")
- assert len(contest.get_tasks()) == len(p.task_statuses)
- for status in p.task_statuses:
+
+ # Calculate total score for exported tasks only
+ total_score = 0.0
+ partial = False
+ for task in export_tasks:
+ idx = task_index[task.id]
+ status = p.task_statuses[idx]
row.append(status.score)
if include_partial:
row.append(self._status_indicator(status))
+ total_score += status.score
+ partial = partial or status.partial
- total_score, partial = p.total_score
+ total_score = round(total_score, contest.score_precision)
row.append(total_score)
if include_partial:
row.append("*" if partial else "")
@@ -272,6 +406,9 @@ class ScoreHistoryHandler(BaseHandler):
By default, excludes hidden participations to match ranking page behavior.
Use ?include_hidden=1 to include hidden participations.
+ For training days with main groups, use ?main_group_user_ids=id1,id2,...
+ to filter history to only include users from a specific main group.
+
Before returning history data, this handler checks for any cache entries
with history_valid=False and rebuilds their history to ensure correctness.
@@ -282,6 +419,16 @@ def get(self, contest_id):
self.safe_get_item(Contest, contest_id)
include_hidden = self.get_argument("include_hidden", "0") == "1"
+ main_group_user_ids_param = self.get_argument("main_group_user_ids", None)
+
+ main_group_user_ids = None
+ if main_group_user_ids_param:
+ try:
+ main_group_user_ids = set(
+ int(uid) for uid in main_group_user_ids_param.split(",") if uid
+ )
+ except ValueError:
+ raise tornado.web.HTTPError(400, "Invalid main_group_user_ids parameter")
# Ensure all score history for the contest is valid before querying
if ensure_valid_history(self.sql_session, int(contest_id)):
@@ -297,6 +444,9 @@ def get(self, contest_id):
if not include_hidden:
query = query.filter(Participation.hidden.is_(False))
+ if main_group_user_ids is not None:
+ query = query.filter(Participation.user_id.in_(main_group_user_ids))
+
history = query.order_by(ScoreHistory.timestamp).all()
result = [
@@ -321,6 +471,9 @@ class ParticipationDetailHandler(BaseHandler):
It includes global and per-task score/rank charts, a navigator table,
and a submission table for each task.
+ For training days with main groups, the ranking is computed relative to
+ the user's main group only, not all participants.
+
"""
@require_permission(BaseHandler.AUTHENTICATED)
def get(self, contest_id, user_id):
@@ -348,6 +501,49 @@ def get(self, contest_id, user_id):
visible_participations = [
p for p in self.contest.participations if not p.hidden
]
+
+ training_day = self.contest.training_day
+ main_group_user_ids = None
+ if training_day and training_day.groups:
+ training_program = training_day.training_program
+ main_group_tags = {g.tag_name for g in training_day.groups}
+
+ user_student = (
+ self.sql_session.query(Student)
+ .join(Participation, Student.participation_id == Participation.id)
+ .filter(Student.training_program_id == training_program.id)
+ .filter(Participation.user_id == user_id)
+ .first()
+ )
+ if user_student:
+ user_tags = set(user_student.student_tags or [])
+ user_main_groups = user_tags & main_group_tags
+ if user_main_groups:
+ # Use deterministic selection (sorted first) instead of arbitrary
+ user_main_group = sorted(user_main_groups)[0]
+
+ # Batch query: fetch all Student rows for visible participations
+ visible_user_ids = {p.user_id for p in visible_participations}
+ students = (
+ self.sql_session.query(Student, Participation.user_id)
+ .join(Participation, Student.participation_id == Participation.id)
+ .filter(Student.training_program_id == training_program.id)
+ .filter(Participation.user_id.in_(visible_user_ids))
+ .all()
+ )
+
+ # Build main_group_user_ids from batch results
+ main_group_user_ids = set()
+ for student, uid in students:
+ p_tags = set(student.student_tags or [])
+ if user_main_group in p_tags:
+ main_group_user_ids.add(uid)
+
+ if main_group_user_ids is not None:
+ visible_participations = [
+ p for p in visible_participations if p.user_id in main_group_user_ids
+ ]
+
user_count = len(visible_participations)
users_data = {}
@@ -359,7 +555,7 @@ def get(self, contest_id, user_id):
tasks_data = {}
total_max_score = 0.0
- for task in self.contest.tasks:
+ for task in self.contest.get_tasks():
max_score = 100.0
extra_headers = []
if task.active_dataset:
@@ -398,9 +594,12 @@ def get(self, contest_id, user_id):
self.r_params["users_data"] = users_data
self.r_params["tasks_data"] = tasks_data
self.r_params["contest_data"] = contest_data
- self.r_params["history_url"] = self.url(
- "contest", contest_id, "ranking", "history"
- )
+ history_url = self.url("contest", contest_id, "ranking", "history")
+ if main_group_user_ids is not None:
+ history_url += "?main_group_user_ids=" + ",".join(
+ str(uid) for uid in main_group_user_ids
+ )
+ self.r_params["history_url"] = history_url
self.r_params["submissions_url"] = self.url(
"contest", contest_id, "user", user_id, "submissions"
)
@@ -438,7 +637,7 @@ def get(self, contest_id, user_id):
)
dataset_by_task_id = {
- task.id: task.active_dataset for task in self.contest.tasks
+ task.id: task.active_dataset for task in self.contest.get_tasks()
}
result = []
diff --git a/cms/server/admin/handlers/contestuser.py b/cms/server/admin/handlers/contestuser.py
index c27fa7f44b..96373075d8 100644
--- a/cms/server/admin/handlers/contestuser.py
+++ b/cms/server/admin/handlers/contestuser.py
@@ -42,7 +42,8 @@
from sqlalchemy import and_, exists
-from cms.db import Contest, Message, Participation, Submission, User, Team
+from cms.db import Contest, Message, Participation, Submission, User, Team, TrainingDay
+from cms.db.training_day import get_managing_participation
from cmscommon.crypto import validate_password_strength
from cmscommon.datetime import make_datetime
from .base import BaseHandler, require_permission
@@ -284,8 +285,21 @@ def get(self, contest_id, user_id):
if participation is None:
raise tornado.web.HTTPError(404)
- submission_query = self.sql_session.query(Submission)\
- .filter(Submission.participation == participation)
+ training_day: TrainingDay | None = self.contest.training_day
+ if training_day is not None:
+ managing_participation = get_managing_participation(
+ self.sql_session, training_day, participation.user)
+ if managing_participation is not None:
+ submission_query = self.sql_session.query(Submission)\
+ .filter(Submission.participation == managing_participation)\
+ .filter(Submission.training_day_id == training_day.id)
+ else:
+ submission_query = self.sql_session.query(Submission)\
+ .filter(False)
+ else:
+ submission_query = self.sql_session.query(Submission)\
+ .filter(Submission.participation == participation)
+
page = int(self.get_query_argument("page", 0))
self.render_params_for_submissions(submission_query, page)
diff --git a/cms/server/admin/handlers/student.py b/cms/server/admin/handlers/student.py
new file mode 100644
index 0000000000..f7847f4fb3
--- /dev/null
+++ b/cms/server/admin/handlers/student.py
@@ -0,0 +1,790 @@
+#!/usr/bin/env python3
+
+# Contest Management System - http://cms-dev.github.io/
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Admin handlers for Students in Training Programs.
+
+Students are users enrolled in a training program with additional metadata
+like student tags and task assignments.
+"""
+
+import tornado.web
+
+from cms.db import (
+ TrainingProgram,
+ Participation,
+ Submission,
+ User,
+ Task,
+ Question,
+ Student,
+ StudentTask,
+ Team,
+ ArchivedStudentRanking,
+)
+from cms.server.util import (
+ get_all_student_tags,
+ calculate_task_archive_progress,
+ parse_tags,
+)
+from cmscommon.datetime import make_datetime
+
+from .base import BaseHandler, require_permission
+
+
+class TrainingProgramStudentsHandler(BaseHandler):
+ """List and manage students in a training program."""
+ REMOVE_FROM_PROGRAM = "Remove from training program"
+
+ @require_permission(BaseHandler.AUTHENTICATED)
+ def get(self, training_program_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["contest"] = managing_contest
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+
+ assigned_user_ids_q = self.sql_session.query(Participation.user_id).filter(
+ Participation.contest == managing_contest
+ )
+
+ self.r_params["unassigned_users"] = (
+ self.sql_session.query(User)
+ .filter(~User.id.in_(assigned_user_ids_q))
+ .filter(~User.username.like(r"\_\_%", escape="\\"))
+ .all()
+ )
+
+ # Calculate task archive progress for each student using shared utility
+ student_progress = {}
+ for student in training_program.students:
+ student_progress[student.id] = calculate_task_archive_progress(
+ student, student.participation, managing_contest
+ )
+
+ self.r_params["student_progress"] = student_progress
+
+ self.render("training_program_students.html", **self.r_params)
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, training_program_id: str):
+ fallback_page = self.url("training_program", training_program_id, "students")
+
+ self.safe_get_item(TrainingProgram, training_program_id)
+
+ try:
+ user_id = self.get_argument("user_id")
+ operation = self.get_argument("operation")
+ assert operation in (
+ self.REMOVE_FROM_PROGRAM,
+ ), "Please select a valid operation"
+ except Exception as error:
+ self.service.add_notification(
+ make_datetime(), "Invalid field(s)", repr(error))
+ self.redirect(fallback_page)
+ return
+
+ if operation == self.REMOVE_FROM_PROGRAM:
+ asking_page = \
+ self.url("training_program", training_program_id, "student", user_id, "remove")
+ self.redirect(asking_page)
+ return
+
+ self.redirect(fallback_page)
+
+
+class AddTrainingProgramStudentHandler(BaseHandler):
+ """Add a student to a training program."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, training_program_id: str):
+ fallback_page = self.url("training_program", training_program_id, "students")
+
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+
+ try:
+ user_id: str = self.get_argument("user_id")
+ assert user_id != "", "Please select a valid user"
+ except Exception as error:
+ self.service.add_notification(
+ make_datetime(), "Invalid field(s)", repr(error))
+ self.redirect(fallback_page)
+ return
+
+ user = self.safe_get_item(User, user_id)
+
+ # 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()
+
+ student = Student(
+ training_program=training_program,
+ participation=participation,
+ student_tags=[]
+ )
+ self.sql_session.add(student)
+
+ # Also add the student to all existing training days
+ for training_day in training_program.training_days:
+ # Skip training days that don't have a contest yet
+ if training_day.contest is None:
+ continue
+ td_participation = Participation(
+ contest=training_day.contest,
+ user=user
+ )
+ self.sql_session.add(td_participation)
+
+ if self.try_commit():
+ self.service.proxy_service.reinitialize()
+
+ self.redirect(fallback_page)
+
+
+class RemoveTrainingProgramStudentHandler(BaseHandler):
+ """Confirm and remove a student from a training program."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def get(self, training_program_id: str, user_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+ user = self.safe_get_item(User, user_id)
+
+ participation: Participation | None = (
+ self.sql_session.query(Participation)
+ .filter(Participation.contest == managing_contest)
+ .filter(Participation.user == user)
+ .first()
+ )
+
+ if participation is None:
+ raise tornado.web.HTTPError(404)
+
+ submission_query = self.sql_session.query(Submission)\
+ .filter(Submission.participation == participation)
+ self.render_params_for_remove_confirmation(submission_query)
+
+ # Count submissions and participations from training days
+ training_day_contest_ids = [td.contest_id for td in training_program.training_days]
+ training_day_contest_ids = [
+ cid for cid in training_day_contest_ids if cid is not None
+ ]
+
+ if training_day_contest_ids:
+ training_day_participations = (
+ self.sql_session.query(Participation)
+ .filter(Participation.contest_id.in_(training_day_contest_ids))
+ .filter(Participation.user == user)
+ .count()
+ )
+ training_day_submissions = (
+ self.sql_session.query(Submission)
+ .join(Participation)
+ .filter(Participation.contest_id.in_(training_day_contest_ids))
+ .filter(Participation.user == user)
+ .count()
+ )
+ else:
+ training_day_participations = 0
+ training_day_submissions = 0
+
+ self.r_params["user"] = user
+ self.r_params["training_program"] = training_program
+ self.r_params["contest"] = managing_contest
+ self.r_params["unanswered"] = 0
+ self.r_params["training_day_submissions"] = training_day_submissions
+ self.r_params["training_day_participations"] = training_day_participations
+ self.render("training_program_student_remove.html", **self.r_params)
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def delete(self, training_program_id: str, user_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+ user = self.safe_get_item(User, user_id)
+
+ participation: Participation | None = (
+ self.sql_session.query(Participation)
+ .filter(Participation.user == user)
+ .filter(Participation.contest == managing_contest)
+ .first()
+ )
+
+ if participation is None:
+ raise tornado.web.HTTPError(404)
+
+ # Delete the Student record first (it has a NOT NULL FK to participation)
+ student: Student | None = (
+ self.sql_session.query(Student)
+ .filter(Student.participation == participation)
+ .first()
+ )
+ if student is not None:
+ self.sql_session.delete(student)
+
+ self.sql_session.delete(participation)
+
+ # Also delete participations from all training days
+ for training_day in training_program.training_days:
+ # Skip training days that don't have a contest yet
+ if training_day.contest is None:
+ continue
+ td_participation: Participation | None = (
+ self.sql_session.query(Participation)
+ .filter(Participation.contest == training_day.contest)
+ .filter(Participation.user == user)
+ .first()
+ )
+ if td_participation is not None:
+ self.sql_session.delete(td_participation)
+
+ if self.try_commit():
+ self.service.proxy_service.reinitialize()
+
+ self.write("../../students")
+
+
+class StudentHandler(BaseHandler):
+ """Shows and edits details of a single student in a training program.
+
+ Similar to ParticipationHandler but includes student tags.
+ """
+
+ @require_permission(BaseHandler.AUTHENTICATED)
+ def get(self, training_program_id: str, user_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+ self.contest = managing_contest
+
+ participation: Participation | None = (
+ self.sql_session.query(Participation)
+ .filter(Participation.contest_id == managing_contest.id)
+ .filter(Participation.user_id == user_id)
+ .first()
+ )
+
+ if participation is None:
+ raise tornado.web.HTTPError(404)
+
+ student: Student | None = (
+ self.sql_session.query(Student)
+ .filter(Student.participation == participation)
+ .filter(Student.training_program == training_program)
+ .first()
+ )
+
+ if student is None:
+ raise tornado.web.HTTPError(404)
+
+ submission_query = self.sql_session.query(Submission).filter(
+ Submission.participation == participation
+ )
+ page = int(self.get_query_argument("page", "0"))
+ self.render_params_for_submissions(submission_query, page)
+
+ # Get all unique student tags from this training program for autocomplete
+ self.r_params["training_program"] = training_program
+ self.r_params["participation"] = participation
+ self.r_params["student"] = student
+ self.r_params["selected_user"] = participation.user
+ self.r_params["teams"] = self.sql_session.query(Team).all()
+ self.r_params["all_student_tags"] = get_all_student_tags(training_program)
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+ self.render("student.html", **self.r_params)
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, training_program_id: str, user_id: str):
+ fallback_page = self.url(
+ "training_program", training_program_id, "student", user_id, "edit"
+ )
+
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+ self.contest = managing_contest
+
+ participation: Participation | None = (
+ self.sql_session.query(Participation)
+ .filter(Participation.contest_id == managing_contest.id)
+ .filter(Participation.user_id == user_id)
+ .first()
+ )
+
+ if participation is None:
+ raise tornado.web.HTTPError(404)
+
+ student: Student | None = (
+ self.sql_session.query(Student)
+ .filter(Student.participation == participation)
+ .filter(Student.training_program == training_program)
+ .first()
+ )
+
+ if student is None:
+ student = Student(
+ training_program=training_program,
+ participation=participation,
+ student_tags=[],
+ )
+ self.sql_session.add(student)
+
+ try:
+ attrs = participation.get_attrs()
+ self.get_password(attrs, participation.password, True)
+ self.get_ip_networks(attrs, "ip")
+ self.get_datetime(attrs, "starting_time")
+ self.get_timedelta_sec(attrs, "delay_time")
+ self.get_timedelta_sec(attrs, "extra_time")
+ self.get_bool(attrs, "hidden")
+ self.get_bool(attrs, "unrestricted")
+
+ # Get the new hidden status before applying
+ new_hidden = attrs.get("hidden", False)
+
+ participation.set_attrs(attrs)
+
+ # Check if admin wants to apply hidden status to existing training days
+ apply_to_existing = self.get_argument("apply_hidden_to_existing", None) is not None
+
+ if apply_to_existing:
+ # Update hidden status in all existing training day participations
+ user = participation.user
+ for training_day in training_program.training_days:
+ if training_day.contest is None:
+ continue
+ td_participation = self.sql_session.query(Participation)\
+ .filter(Participation.contest_id == training_day.contest_id)\
+ .filter(Participation.user_id == user.id)\
+ .first()
+ if td_participation:
+ td_participation.hidden = new_hidden
+
+ self.get_string(attrs, "team")
+ team_code = attrs["team"]
+ if team_code:
+ team: Team | None = (
+ self.sql_session.query(Team).filter(Team.code == team_code).first()
+ )
+ if team is None:
+ raise ValueError(f"Team with code '{team_code}' does not exist")
+ participation.team = team
+ else:
+ participation.team = None
+
+ tags_str = self.get_argument("student_tags", "")
+ student.student_tags = parse_tags(tags_str)
+
+ except Exception as error:
+ self.service.add_notification(
+ make_datetime(), "Invalid field(s)", repr(error)
+ )
+ self.redirect(fallback_page)
+ return
+
+ if self.try_commit():
+ self.service.proxy_service.reinitialize()
+ self.redirect(fallback_page)
+
+
+class StudentTagsHandler(BaseHandler):
+ """Handler for updating student tags via AJAX."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, training_program_id: str, user_id: str):
+ # Set JSON content type for all responses
+ self.set_header("Content-Type", "application/json")
+
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+
+ participation: Participation | None = (
+ self.sql_session.query(Participation)
+ .filter(Participation.contest_id == managing_contest.id)
+ .filter(Participation.user_id == user_id)
+ .first()
+ )
+
+ if participation is None:
+ self.set_status(404)
+ self.write({"error": "Participation not found"})
+ return
+
+ student: Student | None = (
+ self.sql_session.query(Student)
+ .filter(Student.participation == participation)
+ .filter(Student.training_program == training_program)
+ .first()
+ )
+
+ if student is None:
+ student = Student(
+ training_program=training_program,
+ participation=participation,
+ student_tags=[]
+ )
+ self.sql_session.add(student)
+
+ try:
+ tags_str = self.get_argument("student_tags", "")
+ student.student_tags = parse_tags(tags_str)
+
+ if self.try_commit():
+ self.write({"success": True, "tags": student.student_tags})
+ else:
+ self.set_status(500)
+ self.write({"error": "Failed to save"})
+
+ except Exception as error:
+ self.set_status(400)
+ self.write({"error": str(error)})
+
+
+class StudentTasksHandler(BaseHandler):
+ """View and manage tasks assigned to a student in a training program."""
+
+ @require_permission(BaseHandler.AUTHENTICATED)
+ def get(self, training_program_id: str, user_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+
+ participation: Participation | None = (
+ self.sql_session.query(Participation)
+ .filter(Participation.contest_id == managing_contest.id)
+ .filter(Participation.user_id == user_id)
+ .first()
+ )
+
+ if participation is None:
+ raise tornado.web.HTTPError(404)
+
+ student: Student | None = (
+ self.sql_session.query(Student)
+ .filter(Student.participation == participation)
+ .filter(Student.training_program == training_program)
+ .first()
+ )
+
+ if student is None:
+ raise tornado.web.HTTPError(404)
+
+ # Get all tasks in the training program for the "add task" dropdown
+ all_tasks = managing_contest.get_tasks()
+ assigned_task_ids = {st.task_id for st in student.student_tasks}
+ available_tasks = [t for t in all_tasks if t.id not in assigned_task_ids]
+
+ # Build home scores from participation task_scores cache
+ home_scores = {}
+ for pts in participation.task_scores:
+ home_scores[pts.task_id] = pts.score
+
+ # Build training scores from archived student rankings (batch query)
+ training_scores = {}
+ source_training_day_ids = {
+ st.source_training_day_id
+ for st in student.student_tasks
+ if st.source_training_day_id is not None
+ }
+ archived_rankings = {}
+ if source_training_day_ids:
+ archived_rankings = {
+ r.training_day_id: r
+ for r in (
+ self.sql_session.query(ArchivedStudentRanking)
+ .filter(ArchivedStudentRanking.training_day_id.in_(source_training_day_ids))
+ .filter(ArchivedStudentRanking.student_id == student.id)
+ .all()
+ )
+ }
+
+ for st in student.student_tasks:
+ if st.source_training_day_id is None:
+ continue
+ archived_ranking = archived_rankings.get(st.source_training_day_id)
+ if archived_ranking and archived_ranking.task_scores:
+ task_id_str = str(st.task_id)
+ if task_id_str in archived_ranking.task_scores:
+ training_scores[st.task_id] = archived_ranking.task_scores[task_id_str]
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["participation"] = participation
+ self.r_params["student"] = student
+ self.r_params["selected_user"] = participation.user
+ self.r_params["student_tasks"] = sorted(
+ student.student_tasks, key=lambda st: st.assigned_at, reverse=True
+ )
+ self.r_params["available_tasks"] = available_tasks
+ self.r_params["home_scores"] = home_scores
+ self.r_params["training_scores"] = training_scores
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+ self.render("student_tasks.html", **self.r_params)
+
+
+class AddStudentTaskHandler(BaseHandler):
+ """Add a task to a student's task archive."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, training_program_id: str, user_id: str):
+ fallback_page = self.url(
+ "training_program", training_program_id, "student", user_id, "tasks"
+ )
+
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+
+ participation: Participation | None = (
+ self.sql_session.query(Participation)
+ .filter(Participation.contest_id == managing_contest.id)
+ .filter(Participation.user_id == user_id)
+ .first()
+ )
+
+ if participation is None:
+ raise tornado.web.HTTPError(404)
+
+ student: Student | None = (
+ self.sql_session.query(Student)
+ .filter(Student.participation == participation)
+ .filter(Student.training_program == training_program)
+ .first()
+ )
+
+ if student is None:
+ raise tornado.web.HTTPError(404)
+
+ try:
+ task_id = self.get_argument("task_id")
+ if task_id in ("", "null"):
+ raise ValueError("Please select a task")
+
+ task = self.safe_get_item(Task, task_id)
+
+ # Validate task belongs to the student's training program
+ if task.contest_id != training_program.managing_contest_id:
+ raise ValueError("Task does not belong to the student's contest")
+
+ # Check if task is already assigned
+ existing = (
+ self.sql_session.query(StudentTask)
+ .filter(StudentTask.student_id == student.id)
+ .filter(StudentTask.task_id == task.id)
+ .first()
+ )
+ if existing is not None:
+ raise ValueError("Task is already assigned to this student")
+
+ # Create the StudentTask record (manual assignment, no training day)
+ # Note: CMS Base.__init__ skips foreign key columns, so we must
+ # set them as attributes after creating the object
+ student_task = StudentTask(assigned_at=make_datetime())
+ student_task.student_id = student.id
+ student_task.task_id = task.id
+ student_task.source_training_day_id = None
+ self.sql_session.add(student_task)
+
+ except Exception as error:
+ self.service.add_notification(
+ make_datetime(), "Invalid field(s)", repr(error)
+ )
+ self.redirect(fallback_page)
+ return
+
+ if self.try_commit():
+ self.service.add_notification(
+ make_datetime(),
+ "Task assigned",
+ f"Task '{task.name}' has been assigned to {participation.user.username}"
+ )
+
+ self.redirect(fallback_page)
+
+
+class RemoveStudentTaskHandler(BaseHandler):
+ """Remove a task from a student's task archive."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, training_program_id: str, user_id: str, task_id: str):
+ fallback_page = self.url(
+ "training_program", training_program_id, "student", user_id, "tasks"
+ )
+
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+
+ participation: Participation | None = (
+ self.sql_session.query(Participation)
+ .filter(Participation.contest_id == managing_contest.id)
+ .filter(Participation.user_id == user_id)
+ .first()
+ )
+
+ if participation is None:
+ raise tornado.web.HTTPError(404)
+
+ student: Student | None = (
+ self.sql_session.query(Student)
+ .filter(Student.participation == participation)
+ .filter(Student.training_program == training_program)
+ .first()
+ )
+
+ if student is None:
+ raise tornado.web.HTTPError(404)
+
+ student_task: StudentTask | None = (
+ self.sql_session.query(StudentTask)
+ .filter(StudentTask.student_id == student.id)
+ .filter(StudentTask.task_id == task_id)
+ .first()
+ )
+
+ if student_task is None:
+ raise tornado.web.HTTPError(404)
+
+ task = student_task.task
+ self.sql_session.delete(student_task)
+
+ if self.try_commit():
+ self.service.add_notification(
+ make_datetime(),
+ "Task removed",
+ f"Task '{task.name}' has been removed from {participation.user.username}'s archive"
+ )
+
+ self.redirect(fallback_page)
+
+
+class BulkAssignTaskHandler(BaseHandler):
+ """Bulk assign a task to all students with a given tag."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def get(self, training_program_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+
+ # Get all tasks in the training program
+ all_tasks = managing_contest.get_tasks()
+
+ # Get all unique student tags
+ all_student_tags = get_all_student_tags(training_program)
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["all_tasks"] = all_tasks
+ self.r_params["all_student_tags"] = all_student_tags
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+ self.render("bulk_assign_task.html", **self.r_params)
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, training_program_id: str):
+ fallback_page = self.url(
+ "training_program", training_program_id, "bulk_assign_task"
+ )
+
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+
+ try:
+ task_id = self.get_argument("task_id")
+ if task_id in ("", "null"):
+ raise ValueError("Please select a task")
+
+ tag_name = self.get_argument("tag", "").strip().lower()
+ if not tag_name:
+ raise ValueError("Please enter a tag")
+
+ task = self.safe_get_item(Task, task_id)
+
+ # Validate task belongs to the training program
+ if task.contest_id != training_program.managing_contest_id:
+ raise ValueError("Task does not belong to the student's contest")
+
+ # Find all students with the given tag
+ matching_students = (
+ self.sql_session.query(Student)
+ .filter(Student.training_program == training_program)
+ .filter(Student.student_tags.any(tag_name))
+ .all()
+ )
+
+ if not matching_students:
+ raise ValueError(f"No students found with tag '{tag_name}'")
+
+ # We want to know which of these specific students already have this task.
+ student_ids = [s.id for s in matching_students]
+
+ already_assigned_ids = set(
+ row[0]
+ for row in self.sql_session.query(StudentTask.student_id)
+ .filter(StudentTask.task_id == task.id)
+ .filter(StudentTask.student_id.in_(student_ids))
+ .all()
+ )
+
+ # Assign task to each matching student (if not already assigned)
+ assigned_count = 0
+ for student_id in student_ids:
+ if student_id not in already_assigned_ids:
+ # Note: CMS Base.__init__ skips foreign key columns, so we must
+ # set them as attributes after creating the object
+ student_task = StudentTask(assigned_at=make_datetime())
+ student_task.student_id = student_id
+ student_task.task_id = task.id
+ student_task.source_training_day_id = None
+ self.sql_session.add(student_task)
+ assigned_count += 1
+
+ except Exception as error:
+ self.service.add_notification(
+ make_datetime(), "Invalid field(s)", repr(error)
+ )
+ self.redirect(fallback_page)
+ return
+
+ if self.try_commit():
+ self.service.add_notification(
+ make_datetime(),
+ "Bulk assignment complete",
+ f"Task '{task.name}' assigned to {assigned_count} students with tag '{tag_name}'",
+ )
+
+ self.redirect(fallback_page)
diff --git a/cms/server/admin/handlers/submissiondownload.py b/cms/server/admin/handlers/submissiondownload.py
index d3c10b86e1..e94d49808c 100644
--- a/cms/server/admin/handlers/submissiondownload.py
+++ b/cms/server/admin/handlers/submissiondownload.py
@@ -34,6 +34,7 @@
TrainingProgram,
TrainingDay,
)
+from cms.db.training_day import get_managing_participation
from cms.grading.languagemanager import safe_get_lang_filename
from .base import BaseHandler, require_permission
@@ -197,14 +198,22 @@ def get(self, contest_id, user_id):
if participation is None:
raise tornado.web.HTTPError(404)
- # For training day contests, only download submissions made via that training day
- if self.contest.training_day is not None:
- submissions = (
- self.sql_session.query(Submission)
- .filter(Submission.participation_id == participation.id)
- .filter(Submission.training_day_id == self.contest.training_day.id)
- .all()
- )
+ # For training day contests, submissions are stored with the managing
+ # contest's participation, not the training day's participation.
+ # We need to get the managing participation to find the submissions.
+ training_day = self.contest.training_day
+ if training_day is not None:
+ managing_participation = get_managing_participation(
+ self.sql_session, training_day, participation.user)
+ if managing_participation is not None:
+ submissions = (
+ self.sql_session.query(Submission)
+ .filter(Submission.participation_id == managing_participation.id)
+ .filter(Submission.training_day_id == training_day.id)
+ .all()
+ )
+ else:
+ submissions = []
else:
submissions = (
self.sql_session.query(Submission)
diff --git a/cms/server/admin/handlers/trainingday.py b/cms/server/admin/handlers/trainingday.py
new file mode 100644
index 0000000000..cd64b8a630
--- /dev/null
+++ b/cms/server/admin/handlers/trainingday.py
@@ -0,0 +1,709 @@
+#!/usr/bin/env python3
+
+# Contest Management System - http://cms-dev.github.io/
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Admin handlers for Training Days.
+
+Training days are individual training sessions within a training program.
+Each training day has its own contest for submissions and can have main groups
+with custom timing configurations.
+"""
+
+from datetime import datetime as dt, timedelta
+
+import tornado.web
+
+from sqlalchemy import func
+
+from cms.db import (
+ Contest,
+ TrainingProgram,
+ Participation,
+ Submission,
+ Question,
+ Student,
+ Task,
+ TrainingDay,
+ TrainingDayGroup,
+)
+from cms.server.util import (
+ get_all_training_day_types,
+ parse_tags,
+)
+from cmscommon.datetime import make_datetime, get_timezone, get_timezone_name
+
+from .base import BaseHandler, require_permission, parse_datetime_with_timezone
+
+
+def parse_and_validate_duration(
+ hours_str: str,
+ minutes_str: str,
+ context: str = ""
+) -> tuple[int, int]:
+ """Parse and validate duration hours and minutes.
+
+ Args:
+ hours_str: String representation of hours (can be empty)
+ minutes_str: String representation of minutes (can be empty)
+ context: Optional context for error messages (e.g., "Group 'advanced'")
+
+ Returns:
+ Tuple of (hours, minutes) as integers
+
+ Raises:
+ ValueError: If validation fails
+ """
+ hours_str = hours_str.strip()
+ minutes_str = minutes_str.strip()
+ hours = int(hours_str) if hours_str else 0
+ minutes = int(minutes_str) if minutes_str else 0
+ provided = bool(hours_str or minutes_str)
+
+ prefix = f"{context} " if context else ""
+
+ if hours < 0:
+ raise ValueError(f"{prefix}Duration hours cannot be negative")
+ if minutes < 0 or minutes >= 60:
+ raise ValueError(f"{prefix}Duration minutes must be between 0 and 59")
+ if provided and hours == 0 and minutes == 0:
+ raise ValueError(f"{prefix}Duration must be positive")
+
+ return hours, minutes
+
+
+def calculate_group_times(
+ start_str: str,
+ duration_hours_str: str,
+ duration_minutes_str: str,
+ tz,
+ context: str = "",
+) -> tuple[dt | None, dt | None]:
+ """Parse start time and duration to calculate start and end times.
+
+ Args:
+ start_str: String representation of start time
+ duration_hours_str: String representation of duration hours
+ duration_minutes_str: String representation of duration minutes
+ tz: Timezone for parsing start time
+ context: Optional context for error messages
+
+ Returns:
+ Tuple of (start_time, end_time). Both can be None.
+ """
+ start_time = None
+ if start_str and start_str.strip():
+ start_time = parse_datetime_with_timezone(start_str.strip(), tz)
+
+ duration_hours, duration_minutes = parse_and_validate_duration(
+ duration_hours_str, duration_minutes_str, context=context
+ )
+
+ end_time = None
+ if duration_hours > 0 or duration_minutes > 0:
+ if not start_time:
+ prefix = f"{context} " if context else ""
+ raise ValueError(
+ f"{prefix}Duration cannot be specified without a start time"
+ )
+
+ duration = timedelta(hours=duration_hours, minutes=duration_minutes)
+ end_time = start_time + duration
+
+ return start_time, end_time
+
+
+def validate_group_times_within_contest(
+ group_start: dt | None,
+ group_end: dt | None,
+ contest_start: dt | None,
+ contest_stop: dt | None,
+ context: str = "Group",
+):
+ """Validate that group times are within contest bounds.
+
+ Args:
+ group_start: Group start datetime
+ group_end: Group end datetime
+ contest_start: Contest start datetime
+ contest_stop: Contest stop datetime
+ context: Context string for error messages (e.g. "Group 'A'")
+
+ Raises:
+ ValueError: If group times are outside contest bounds
+ """
+ if group_start and contest_start:
+ if group_start < contest_start:
+ raise ValueError(
+ f"{context} start time cannot be before training day start"
+ )
+ if group_end and contest_stop:
+ if group_end > contest_stop:
+ raise ValueError(f"{context} end time cannot be after training day end")
+
+
+class TrainingProgramTrainingDaysHandler(BaseHandler):
+ """List and manage training days in a training program."""
+ REMOVE = "Remove"
+ MOVE_UP = "up by 1"
+ MOVE_DOWN = "down by 1"
+
+ @require_permission(BaseHandler.AUTHENTICATED)
+ def get(self, training_program_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["contest"] = managing_contest
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+ self.r_params["all_training_day_types"] = get_all_training_day_types(
+ training_program)
+
+ self.render("training_program_training_days.html", **self.r_params)
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, training_program_id: str):
+ fallback_page = self.url("training_program", training_program_id, "training_days")
+
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+
+ try:
+ training_day_id: str = self.get_argument("training_day_id")
+ operation: str = self.get_argument("operation")
+ assert operation in (
+ self.REMOVE,
+ self.MOVE_UP,
+ self.MOVE_DOWN,
+ ), "Please select a valid operation"
+ except Exception as error:
+ self.service.add_notification(
+ make_datetime(), "Invalid field(s)", repr(error))
+ self.redirect(fallback_page)
+ return
+
+ training_day = self.safe_get_item(TrainingDay, training_day_id)
+
+ if training_day.training_program_id != training_program.id:
+ self.service.add_notification(
+ make_datetime(), "Invalid training day", "Training day does not belong to this program")
+ self.redirect(fallback_page)
+ return
+
+ if operation == self.REMOVE:
+ asking_page = self.url(
+ "training_program", training_program_id,
+ "training_day", training_day_id, "remove"
+ )
+ self.redirect(asking_page)
+ return
+
+ elif operation == self.MOVE_UP:
+ training_day2 = self.sql_session.query(TrainingDay)\
+ .filter(TrainingDay.training_program == training_program)\
+ .filter(TrainingDay.position == training_day.position - 1)\
+ .first()
+
+ if training_day2 is not None:
+ tmp_a, tmp_b = training_day.position, training_day2.position
+ training_day.position, training_day2.position = None, None
+ self.sql_session.flush()
+ training_day.position, training_day2.position = tmp_b, tmp_a
+
+ elif operation == self.MOVE_DOWN:
+ training_day2 = self.sql_session.query(TrainingDay)\
+ .filter(TrainingDay.training_program == training_program)\
+ .filter(TrainingDay.position == training_day.position + 1)\
+ .first()
+
+ if training_day2 is not None:
+ tmp_a, tmp_b = training_day.position, training_day2.position
+ training_day.position, training_day2.position = None, None
+ self.sql_session.flush()
+ training_day.position, training_day2.position = tmp_b, tmp_a
+
+ self.try_commit()
+ self.redirect(fallback_page)
+
+
+class AddTrainingDayHandler(BaseHandler):
+ """Add a new training day to a training program."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def get(self, training_program_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["contest"] = managing_contest
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+
+ # Get all student tags for the tagify select dropdown
+ tags_query = self.sql_session.query(
+ func.unnest(Student.student_tags).label("tag")
+ ).filter(
+ Student.training_program_id == training_program.id
+ ).distinct()
+ self.r_params["all_student_tags"] = sorted([row.tag for row in tags_query.all()])
+
+ # Add timezone info for the form (use managing contest timezone)
+ tz = get_timezone(None, managing_contest)
+ self.r_params["timezone"] = tz
+ self.r_params["timezone_name"] = get_timezone_name(tz)
+
+ self.render("add_training_day.html", **self.r_params)
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, training_program_id: str):
+ fallback_page = self.url("training_program", training_program_id, "training_days", "add")
+
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ managing_contest = training_program.managing_contest
+
+ # Get timezone for parsing datetime inputs
+ tz = get_timezone(None, managing_contest)
+
+ try:
+ name = self.get_argument("name")
+ if not name or not name.strip():
+ raise ValueError("Name is required")
+
+ description = self.get_argument("description", "")
+ if not description or not description.strip():
+ description = name
+
+ contest_kwargs: dict = {
+ "name": name,
+ "description": description,
+ }
+
+ # Parse main group configuration (if any)
+ group_tags = self.get_arguments("group_tag_name[]")
+ group_starts = self.get_arguments("group_start_time[]")
+ group_duration_hours = self.get_arguments("group_duration_hours[]")
+ group_duration_minutes = self.get_arguments("group_duration_minutes[]")
+ group_alphabeticals = self.get_arguments("group_alphabetical[]")
+
+ # Collect valid groups and their times for defaulting
+ groups_to_create = []
+ earliest_group_start = None
+ latest_group_end = None
+
+ for i, tag in enumerate(group_tags):
+ tag = tag.strip()
+ if not tag:
+ continue
+
+ start_s = group_starts[i] if i < len(group_starts) else ""
+ hours_s = (
+ group_duration_hours[i] if i < len(group_duration_hours) else ""
+ )
+ mins_s = (
+ group_duration_minutes[i] if i < len(group_duration_minutes) else ""
+ )
+
+ group_start, group_end = calculate_group_times(
+ start_s, hours_s, mins_s, tz, context=f"Group '{tag}'"
+ )
+
+ if group_start:
+ if (
+ earliest_group_start is None
+ or group_start < earliest_group_start
+ ):
+ earliest_group_start = group_start
+ if group_end:
+ if latest_group_end is None or group_end > latest_group_end:
+ latest_group_end = group_end
+
+ alphabetical = str(i) in group_alphabeticals
+
+ groups_to_create.append({
+ "tag_name": tag,
+ "start_time": group_start,
+ "end_time": group_end,
+ "alphabetical_task_order": alphabetical,
+ })
+
+ # Parse optional start time and duration from inputs
+ # Times are in the managing contest timezone
+ start_str = self.get_argument("start", "")
+ duration_hours_str = self.get_argument("duration_hours", "")
+ duration_minutes_str = self.get_argument("duration_minutes", "")
+
+ s_time, e_time = calculate_group_times(
+ start_str, duration_hours_str, duration_minutes_str, tz
+ )
+
+ if s_time:
+ contest_kwargs["start"] = s_time
+ else:
+ # Default to after training program end year (so contestants can't start until configured)
+ program_end_year = managing_contest.stop.year
+ default_date = dt(program_end_year + 1, 1, 1, 0, 0)
+ contest_kwargs["start"] = (
+ earliest_group_start if earliest_group_start else default_date
+ )
+ # Also set analysis_start/stop to satisfy Contest check constraints
+ # (stop <= analysis_start and analysis_start <= analysis_stop)
+ contest_kwargs["analysis_start"] = default_date
+ contest_kwargs["analysis_stop"] = default_date
+
+ if e_time:
+ contest_kwargs["stop"] = e_time
+ else:
+ contest_kwargs["stop"] = (
+ latest_group_end if latest_group_end else contest_kwargs["start"]
+ )
+
+ contest = Contest(**contest_kwargs)
+ self.sql_session.add(contest)
+ self.sql_session.flush()
+
+ position = len(training_program.training_days)
+ training_day = TrainingDay(
+ training_program=training_program,
+ contest=contest,
+ position=position,
+ )
+ self.sql_session.add(training_day)
+
+ # Create main groups
+ seen_tags = set()
+ for group_data in groups_to_create:
+ if group_data["tag_name"] in seen_tags:
+ raise ValueError(f"Duplicate tag '{group_data['tag_name']}'")
+ seen_tags.add(group_data["tag_name"])
+
+ # Validate group times are within contest bounds
+ validate_group_times_within_contest(
+ group_data["start_time"],
+ group_data["end_time"],
+ contest_kwargs.get("start"),
+ contest_kwargs.get("stop"),
+ context=f"Group '{group_data['tag_name']}'",
+ )
+
+ group = TrainingDayGroup(
+ training_day=training_day,
+ **group_data
+ )
+ self.sql_session.add(group)
+
+ # Auto-add participations for all students in the training program
+ # Training days are for all students, so we create participations
+ # in the training day's contest for each student
+ # Pass the hidden property from the managing contest participation
+ for student in training_program.students:
+ user = student.participation.user
+ hidden = student.participation.hidden
+ participation = Participation(contest=contest, user=user, hidden=hidden)
+ self.sql_session.add(participation)
+
+ except Exception as error:
+ self.service.add_notification(make_datetime(), "Invalid field(s)", repr(error))
+ self.redirect(fallback_page)
+ return
+
+ if self.try_commit():
+ self.redirect(self.url("training_program", training_program_id, "training_days"))
+ else:
+ self.redirect(fallback_page)
+
+
+class RemoveTrainingDayHandler(BaseHandler):
+ """Confirm and remove a training day from a training program."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def get(self, training_program_id: str, training_day_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ training_day = self.safe_get_item(TrainingDay, training_day_id)
+ managing_contest = training_program.managing_contest
+
+ if training_day.training_program_id != training_program.id:
+ raise tornado.web.HTTPError(404)
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["training_day"] = training_day
+ self.r_params["contest"] = managing_contest
+ self.r_params["unanswered"] = 0
+
+ # Stats for warning message
+ self.r_params["task_count"] = len(training_day.tasks)
+ # For archived training days, contest_id is None so counts are 0
+ if training_day.contest_id is not None:
+ self.r_params["participation_count"] = (
+ self.sql_session.query(Participation)
+ .filter(Participation.contest_id == training_day.contest_id)
+ .count()
+ )
+ self.r_params["submission_count"] = (
+ self.sql_session.query(Submission)
+ .join(Participation)
+ .filter(Participation.contest_id == training_day.contest_id)
+ .count()
+ )
+ else:
+ self.r_params["participation_count"] = 0
+ self.r_params["submission_count"] = 0
+
+ self.render("training_day_remove.html", **self.r_params)
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def delete(self, training_program_id: str, training_day_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ training_day = self.safe_get_item(TrainingDay, training_day_id)
+
+ if training_day.training_program_id != training_program.id:
+ raise tornado.web.HTTPError(404)
+
+ contest = training_day.contest
+ position = training_day.position
+
+ # Always detach tasks from the training day - they stay in the training program.
+ # The database FK has ON DELETE SET NULL, but we also clear training_day_num
+ # explicitly to remove stale ordering metadata.
+ tasks = (
+ self.sql_session.query(Task)
+ .filter(Task.training_day == training_day)
+ .order_by(Task.training_day_num)
+ .all()
+ )
+
+ for task in tasks:
+ task.training_day = None
+ task.training_day_num = None
+
+ self.sql_session.flush()
+
+ self.sql_session.delete(training_day)
+ if contest is not None:
+ self.sql_session.delete(contest)
+
+ self.sql_session.flush()
+
+ for td in training_program.training_days:
+ if td.position is not None and position is not None and td.position > position:
+ td.position -= 1
+
+ self.try_commit()
+ self.write("../../training_days")
+
+
+class AddTrainingDayGroupHandler(BaseHandler):
+ """Add a main group to a training day."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, contest_id: str):
+ contest = self.safe_get_item(Contest, contest_id)
+ training_day = contest.training_day
+
+ if training_day is None:
+ raise tornado.web.HTTPError(404, "Not a training day contest")
+
+ fallback_page = self.url("contest", contest_id)
+
+ # Get timezone for parsing datetime inputs (use contest timezone)
+ tz = get_timezone(None, contest)
+
+ try:
+ tag_name = self.get_argument("tag_name")
+ if not tag_name or not tag_name.strip():
+ raise ValueError("Tag name is required")
+
+ # Strip whitespace before duplicate check to avoid bypass
+ tag_name = tag_name.strip()
+
+ # Check if tag is already used
+ existing = self.sql_session.query(TrainingDayGroup)\
+ .filter(TrainingDayGroup.training_day == training_day)\
+ .filter(TrainingDayGroup.tag_name == tag_name)\
+ .first()
+ if existing:
+ raise ValueError(f"Tag '{tag_name}' is already a main group")
+
+ # Parse optional start time and duration
+ start_str = self.get_argument("start_time", "")
+ duration_hours_str = self.get_argument("duration_hours", "")
+ duration_minutes_str = self.get_argument("duration_minutes", "")
+
+ group_kwargs: dict = {
+ "training_day": training_day,
+ "tag_name": tag_name,
+ "alphabetical_task_order": self.get_argument("alphabetical_task_order", None) is not None,
+ }
+
+ # Calculate start and end times
+ s_time, e_time = calculate_group_times(
+ start_str, duration_hours_str, duration_minutes_str, tz
+ )
+
+ if s_time:
+ group_kwargs["start_time"] = s_time
+ if e_time:
+ group_kwargs["end_time"] = e_time
+
+ # Validate group times are within contest bounds
+ validate_group_times_within_contest(
+ group_kwargs.get("start_time"),
+ group_kwargs.get("end_time"),
+ contest.start,
+ contest.stop,
+ context="Group",
+ )
+
+ group = TrainingDayGroup(**group_kwargs)
+ self.sql_session.add(group)
+
+ except Exception as error:
+ self.service.add_notification(make_datetime(), "Invalid field(s)", repr(error))
+ self.redirect(fallback_page)
+ return
+
+ self.try_commit()
+ self.redirect(fallback_page)
+
+
+class UpdateTrainingDayGroupsHandler(BaseHandler):
+ """Update all main groups for a training day."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, contest_id: str):
+ contest = self.safe_get_item(Contest, contest_id)
+ training_day = contest.training_day
+
+ if training_day is None:
+ raise tornado.web.HTTPError(404, "Not a training day contest")
+
+ fallback_page = self.url("contest", contest_id)
+
+ # Get timezone for parsing datetime inputs (use contest timezone)
+ tz = get_timezone(None, contest)
+
+ try:
+ group_ids = self.get_arguments("group_id[]")
+ start_times = self.get_arguments("start_time[]")
+ duration_hours_list = self.get_arguments("duration_hours[]")
+ duration_minutes_list = self.get_arguments("duration_minutes[]")
+
+ if len(group_ids) != len(start_times):
+ raise ValueError("Mismatched form data")
+
+ for i, group_id in enumerate(group_ids):
+ group = self.safe_get_item(TrainingDayGroup, group_id)
+ if group.training_day_id != training_day.id:
+ raise ValueError(f"Group {group_id} does not belong to this training day")
+
+ # Calculate start and end times
+ hours_str = (
+ duration_hours_list[i] if i < len(duration_hours_list) else ""
+ )
+ mins_str = (
+ duration_minutes_list[i] if i < len(duration_minutes_list) else ""
+ )
+
+ group.start_time, group.end_time = calculate_group_times(
+ start_times[i],
+ hours_str,
+ mins_str,
+ tz,
+ context=f"Group '{group.tag_name}'",
+ )
+
+ # Validate group times are within contest bounds
+ validate_group_times_within_contest(
+ group.start_time,
+ group.end_time,
+ contest.start,
+ contest.stop,
+ context=f"Group '{group.tag_name}'",
+ )
+
+ # Update alphabetical task order (checkbox - present means checked)
+ group.alphabetical_task_order = self.get_argument(f"alphabetical_{group_id}", None) is not None
+
+ except Exception as error:
+ self.service.add_notification(make_datetime(), "Invalid field(s)", repr(error))
+ self.redirect(fallback_page)
+ return
+
+ self.try_commit()
+ self.redirect(fallback_page)
+
+
+class RemoveTrainingDayGroupHandler(BaseHandler):
+ """Remove a main group from a training day."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, contest_id: str, group_id: str):
+ contest = self.safe_get_item(Contest, contest_id)
+ training_day = contest.training_day
+
+ if training_day is None:
+ raise tornado.web.HTTPError(404, "Not a training day contest")
+
+ group = self.safe_get_item(TrainingDayGroup, group_id)
+
+ if group.training_day_id != training_day.id:
+ raise tornado.web.HTTPError(404, "Group does not belong to this training day")
+
+ self.sql_session.delete(group)
+ self.try_commit()
+ self.redirect(self.url("contest", contest_id))
+
+
+class TrainingDayTypesHandler(BaseHandler):
+ """Handler for updating training day types via AJAX."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, training_program_id: str, training_day_id: str):
+ self.set_header("Content-Type", "application/json")
+
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ training_day = self.safe_get_item(TrainingDay, training_day_id)
+
+ if training_day.training_program_id != training_program.id:
+ self.set_status(404)
+ self.write({"error": "Training day does not belong to this program"})
+ return
+
+ try:
+ types_str = self.get_argument("training_day_types", "")
+ training_day.training_day_types = parse_tags(types_str)
+
+ if self.try_commit():
+ self.write({
+ "success": True,
+ "types": training_day.training_day_types
+ })
+ else:
+ self.set_status(500)
+ self.write({"error": "Failed to save"})
+
+ except Exception as error:
+ self.set_status(400)
+ self.write({"error": str(error)})
diff --git a/cms/server/admin/handlers/trainingprogram.py b/cms/server/admin/handlers/trainingprogram.py
index 2a69fd4d13..e8704544f3 100644
--- a/cms/server/admin/handlers/trainingprogram.py
+++ b/cms/server/admin/handlers/trainingprogram.py
@@ -19,10 +19,15 @@
Training programs organize year-long training with multiple sessions.
Each training program has a managing contest that handles all submissions.
+
+This module contains core training program handlers. Related handlers are
+split into separate modules:
+- trainingday.py: Training day management handlers
+- student.py: Student management handlers
+- archive.py: Archive, attendance, and combined ranking handlers
"""
-from datetime import datetime as dt, timedelta
-from urllib.parse import urlencode
+from datetime import datetime as dt
import tornado.web
@@ -38,30 +43,14 @@
Question,
Announcement,
Student,
- StudentTask,
- Team,
- TrainingDay,
- TrainingDayGroup,
- ArchivedAttendance,
- ArchivedStudentRanking,
- ScoreHistory,
- DelayRequest,
)
-from cms.db.training_day import get_managing_participation
from cms.server.util import (
get_all_student_tags,
- get_all_student_tags_with_historical,
- get_all_training_day_types,
- calculate_task_archive_progress,
- can_access_task,
- check_training_day_eligibility,
parse_tags,
)
from cmscommon.datetime import make_datetime
from .base import BaseHandler, SimpleHandler, require_permission
-
-
class TrainingProgramListHandler(SimpleHandler("training_programs.html")):
"""List all training programs.
@@ -417,406 +406,6 @@ def delete(self, training_program_id: str):
self.try_commit()
self.write("../../training_programs")
-
-
-class TrainingProgramStudentsHandler(BaseHandler):
- """List and manage students in a training program."""
- REMOVE_FROM_PROGRAM = "Remove from training program"
-
- @require_permission(BaseHandler.AUTHENTICATED)
- def get(self, training_program_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
-
- self.r_params = self.render_params()
- self.r_params["training_program"] = training_program
- self.r_params["contest"] = managing_contest
- self.r_params["unanswered"] = self.sql_session.query(Question)\
- .join(Participation)\
- .filter(Participation.contest_id == managing_contest.id)\
- .filter(Question.reply_timestamp.is_(None))\
- .filter(Question.ignored.is_(False))\
- .count()
-
- self.r_params["unassigned_users"] = \
- self.sql_session.query(User)\
- .filter(User.id.notin_(
- self.sql_session.query(Participation.user_id)
- .filter(Participation.contest == managing_contest)
- .all()))\
- .filter(~User.username.like(r'\_\_%', escape='\\'))\
- .all()
-
- # Calculate task archive progress for each student using shared utility
- student_progress = {}
- for student in training_program.students:
- student_progress[student.id] = calculate_task_archive_progress(
- student, student.participation, managing_contest
- )
-
- self.r_params["student_progress"] = student_progress
-
- self.render("training_program_students.html", **self.r_params)
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, training_program_id: str):
- fallback_page = self.url("training_program", training_program_id, "students")
-
- self.safe_get_item(TrainingProgram, training_program_id)
-
- try:
- user_id = self.get_argument("user_id")
- operation = self.get_argument("operation")
- assert operation in (
- self.REMOVE_FROM_PROGRAM,
- ), "Please select a valid operation"
- except Exception as error:
- self.service.add_notification(
- make_datetime(), "Invalid field(s)", repr(error))
- self.redirect(fallback_page)
- return
-
- if operation == self.REMOVE_FROM_PROGRAM:
- asking_page = \
- self.url("training_program", training_program_id, "student", user_id, "remove")
- self.redirect(asking_page)
- return
-
- self.redirect(fallback_page)
-
-
-class AddTrainingProgramStudentHandler(BaseHandler):
- """Add a student to a training program."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, training_program_id: str):
- fallback_page = self.url("training_program", training_program_id, "students")
-
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
-
- try:
- user_id: str = self.get_argument("user_id")
- assert user_id != "", "Please select a valid user"
- except Exception as error:
- self.service.add_notification(
- make_datetime(), "Invalid field(s)", repr(error))
- self.redirect(fallback_page)
- return
-
- user = self.safe_get_item(User, user_id)
-
- # 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()
-
- student = Student(
- training_program=training_program,
- participation=participation,
- student_tags=[]
- )
- self.sql_session.add(student)
-
- # Also add the student to all existing training days
- for training_day in training_program.training_days:
- # Skip training days that don't have a contest yet
- if training_day.contest is None:
- continue
- td_participation = Participation(
- contest=training_day.contest,
- user=user
- )
- self.sql_session.add(td_participation)
-
- if self.try_commit():
- self.service.proxy_service.reinitialize()
-
- self.redirect(fallback_page)
-
-
-class RemoveTrainingProgramStudentHandler(BaseHandler):
- """Confirm and remove a student from a training program."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def get(self, training_program_id: str, user_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
- user = self.safe_get_item(User, user_id)
-
- participation: Participation | None = (
- self.sql_session.query(Participation)
- .filter(Participation.contest == managing_contest)
- .filter(Participation.user == user)
- .first()
- )
-
- if participation is None:
- raise tornado.web.HTTPError(404)
-
- submission_query = self.sql_session.query(Submission)\
- .filter(Submission.participation == participation)
- self.render_params_for_remove_confirmation(submission_query)
-
- # Count submissions and participations from training days
- training_day_contest_ids = [td.contest_id for td in training_program.training_days]
- training_day_participations = (
- self.sql_session.query(Participation)
- .filter(Participation.contest_id.in_(training_day_contest_ids))
- .filter(Participation.user == user)
- .count()
- )
-
- training_day_submissions = (
- self.sql_session.query(Submission)
- .join(Participation)
- .filter(Participation.contest_id.in_(training_day_contest_ids))
- .filter(Participation.user == user)
- .count()
- )
-
- self.r_params["user"] = user
- self.r_params["training_program"] = training_program
- self.r_params["contest"] = managing_contest
- self.r_params["unanswered"] = 0
- self.r_params["training_day_submissions"] = training_day_submissions
- self.r_params["training_day_participations"] = training_day_participations
- self.render("training_program_student_remove.html", **self.r_params)
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def delete(self, training_program_id: str, user_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
- user = self.safe_get_item(User, user_id)
-
- participation: Participation | None = (
- self.sql_session.query(Participation)
- .filter(Participation.user == user)
- .filter(Participation.contest == managing_contest)
- .first()
- )
-
- if participation is None:
- raise tornado.web.HTTPError(404)
-
- # Delete the Student record first (it has a NOT NULL FK to participation)
- student: Student | None = (
- self.sql_session.query(Student)
- .filter(Student.participation == participation)
- .first()
- )
- if student is not None:
- self.sql_session.delete(student)
-
- self.sql_session.delete(participation)
-
- # Also delete participations from all training days
- for training_day in training_program.training_days:
- td_participation: Participation | None = (
- self.sql_session.query(Participation)
- .filter(Participation.contest == training_day.contest)
- .filter(Participation.user == user)
- .first()
- )
- if td_participation is not None:
- self.sql_session.delete(td_participation)
-
- if self.try_commit():
- self.service.proxy_service.reinitialize()
-
- self.write("../../students")
-
-
-class StudentHandler(BaseHandler):
- """Shows and edits details of a single student in a training program.
-
- Similar to ParticipationHandler but includes student tags.
- """
-
- @require_permission(BaseHandler.AUTHENTICATED)
- def get(self, training_program_id: str, user_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
- self.contest = managing_contest
-
- participation: Participation | None = (
- self.sql_session.query(Participation)
- .filter(Participation.contest_id == managing_contest.id)
- .filter(Participation.user_id == user_id)
- .first()
- )
-
- if participation is None:
- raise tornado.web.HTTPError(404)
-
- student: Student | None = (
- self.sql_session.query(Student)
- .filter(Student.participation == participation)
- .filter(Student.training_program == training_program)
- .first()
- )
-
- if student is None:
- student = Student(
- training_program=training_program,
- participation=participation,
- student_tags=[]
- )
- self.sql_session.add(student)
- self.try_commit()
-
- submission_query = self.sql_session.query(Submission).filter(
- Submission.participation == participation
- )
- page = int(self.get_query_argument("page", "0"))
- self.render_params_for_submissions(submission_query, page)
-
- # Get all unique student tags from this training program for autocomplete
- self.r_params["training_program"] = training_program
- self.r_params["participation"] = participation
- self.r_params["student"] = student
- self.r_params["selected_user"] = participation.user
- self.r_params["teams"] = self.sql_session.query(Team).all()
- self.r_params["all_student_tags"] = get_all_student_tags(training_program)
- self.r_params["unanswered"] = self.sql_session.query(Question)\
- .join(Participation)\
- .filter(Participation.contest_id == managing_contest.id)\
- .filter(Question.reply_timestamp.is_(None))\
- .filter(Question.ignored.is_(False))\
- .count()
- self.render("student.html", **self.r_params)
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, training_program_id: str, user_id: str):
- fallback_page = self.url(
- "training_program", training_program_id, "student", user_id, "edit"
- )
-
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
- self.contest = managing_contest
-
- participation: Participation | None = (
- self.sql_session.query(Participation)
- .filter(Participation.contest_id == managing_contest.id)
- .filter(Participation.user_id == user_id)
- .first()
- )
-
- if participation is None:
- raise tornado.web.HTTPError(404)
-
- student: Student | None = (
- self.sql_session.query(Student)
- .filter(Student.participation == participation)
- .filter(Student.training_program == training_program)
- .first()
- )
-
- if student is None:
- student = Student(
- training_program=training_program,
- participation=participation,
- student_tags=[],
- )
- self.sql_session.add(student)
-
- try:
- attrs = participation.get_attrs()
- self.get_password(attrs, participation.password, True)
- self.get_ip_networks(attrs, "ip")
- self.get_datetime(attrs, "starting_time")
- self.get_timedelta_sec(attrs, "delay_time")
- self.get_timedelta_sec(attrs, "extra_time")
- self.get_bool(attrs, "hidden")
- self.get_bool(attrs, "unrestricted")
- participation.set_attrs(attrs)
-
- self.get_string(attrs, "team")
- team_code = attrs["team"]
- if team_code:
- team: Team | None = (
- self.sql_session.query(Team).filter(Team.code == team_code).first()
- )
- if team is None:
- raise ValueError(f"Team with code '{team_code}' does not exist")
- participation.team = team
- else:
- participation.team = None
-
- tags_str = self.get_argument("student_tags", "")
- student.student_tags = parse_tags(tags_str)
-
- except Exception as error:
- self.service.add_notification(
- make_datetime(), "Invalid field(s)", repr(error)
- )
- self.redirect(fallback_page)
- return
-
- if self.try_commit():
- self.service.proxy_service.reinitialize()
- self.redirect(fallback_page)
-
-
-class StudentTagsHandler(BaseHandler):
- """Handler for updating student tags via AJAX."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, training_program_id: str, user_id: str):
- # Set JSON content type for all responses
- self.set_header("Content-Type", "application/json")
-
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
-
- participation: Participation | None = (
- self.sql_session.query(Participation)
- .filter(Participation.contest_id == managing_contest.id)
- .filter(Participation.user_id == user_id)
- .first()
- )
-
- if participation is None:
- self.set_status(404)
- self.write({"error": "Participation not found"})
- return
-
- student: Student | None = (
- self.sql_session.query(Student)
- .filter(Student.participation == participation)
- .filter(Student.training_program == training_program)
- .first()
- )
-
- if student is None:
- student = Student(
- training_program=training_program,
- participation=participation,
- student_tags=[]
- )
- self.sql_session.add(student)
-
- try:
- tags_str = self.get_argument("student_tags", "")
- student.student_tags = parse_tags(tags_str)
-
- if self.try_commit():
- self.write({"success": True, "tags": student.student_tags})
- else:
- self.set_status(500)
- self.write({"error": "Failed to save"})
-
- except Exception as error:
- self.set_status(400)
- self.write({"error": str(error)})
-
-
class TrainingProgramTasksHandler(BaseHandler):
"""Manage tasks in a training program."""
REMOVE_FROM_PROGRAM = "Remove from training program"
@@ -1106,10 +695,28 @@ def get(self, training_program_id: str, format: str = "online"):
total_score = round(total_score, self.contest.score_precision)
p.total_score = (total_score, partial)
+ # Build student tags lookup for each participation (batch query)
+ student_tags_by_participation = {p.id: [] for p in self.contest.participations}
+ if student_tags_by_participation:
+ rows = (
+ self.sql_session.query(Student.participation_id, Student.student_tags)
+ .filter(Student.training_program_id == training_program.id)
+ .filter(
+ Student.participation_id.in_(
+ list(student_tags_by_participation.keys())
+ )
+ )
+ .all()
+ )
+ for participation_id, tags in rows:
+ student_tags_by_participation[participation_id] = tags or []
+
self.r_params = self.render_params()
self.r_params["training_program"] = training_program
self.r_params["contest"] = self.contest
self.r_params["show_teams"] = show_teams
+ self.r_params["student_tags_by_participation"] = student_tags_by_participation
+ self.r_params["main_groups_data"] = None # Not used for training program ranking
self.r_params["unanswered"] = self.sql_session.query(Question)\
.join(Participation)\
.filter(Participation.contest_id == self.contest.id)\
@@ -1133,6 +740,8 @@ def get(self, training_program_id: str, format: str = "online"):
include_partial = True
row = ["Username", "User"]
+ if student_tags_by_participation:
+ row.append("Tags")
if show_teams:
row.append("Team")
for task in self.contest.tasks:
@@ -1153,6 +762,9 @@ def get(self, training_program_id: str, format: str = "online"):
row = [p.user.username,
"%s %s" % (p.user.first_name, p.user.last_name)]
+ if student_tags_by_participation:
+ tags = student_tags_by_participation.get(p.id, [])
+ row.append(", ".join(tags))
if show_teams:
row.append(p.team.name if p.team else "")
assert len(self.contest.tasks) == len(p.task_statuses)
@@ -1177,12 +789,14 @@ def _status_indicator(status) -> str:
"""Return a status indicator string for CSV export.
status: a TaskStatus namedtuple with score, partial, has_submissions,
- has_opened fields.
+ has_opened, can_access fields.
return: a string indicator for the status.
"""
star = "*" if status.partial else ""
+ if not status.can_access:
+ return "N/A"
if not status.has_submissions:
return "X" if not status.has_opened else "-"
if not status.has_opened:
@@ -1330,1856 +944,6 @@ def get(self, training_program_id: str):
self.render("questions.html", **self.r_params)
-class TrainingProgramTrainingDaysHandler(BaseHandler):
- """List and manage training days in a training program."""
- REMOVE = "Remove"
- MOVE_UP = "up by 1"
- MOVE_DOWN = "down by 1"
-
- @require_permission(BaseHandler.AUTHENTICATED)
- def get(self, training_program_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
-
- self.r_params = self.render_params()
- self.r_params["training_program"] = training_program
- self.r_params["contest"] = managing_contest
- self.r_params["unanswered"] = self.sql_session.query(Question)\
- .join(Participation)\
- .filter(Participation.contest_id == managing_contest.id)\
- .filter(Question.reply_timestamp.is_(None))\
- .filter(Question.ignored.is_(False))\
- .count()
- self.r_params["all_training_day_types"] = get_all_training_day_types(
- training_program)
-
- self.render("training_program_training_days.html", **self.r_params)
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, training_program_id: str):
- fallback_page = self.url("training_program", training_program_id, "training_days")
-
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
-
- try:
- training_day_id: str = self.get_argument("training_day_id")
- operation: str = self.get_argument("operation")
- assert operation in (
- self.REMOVE,
- self.MOVE_UP,
- self.MOVE_DOWN,
- ), "Please select a valid operation"
- except Exception as error:
- self.service.add_notification(
- make_datetime(), "Invalid field(s)", repr(error))
- self.redirect(fallback_page)
- return
-
- training_day = self.safe_get_item(TrainingDay, training_day_id)
-
- if training_day.training_program_id != training_program.id:
- self.service.add_notification(
- make_datetime(), "Invalid training day", "Training day does not belong to this program")
- self.redirect(fallback_page)
- return
-
- if operation == self.REMOVE:
- asking_page = self.url(
- "training_program", training_program_id,
- "training_day", training_day_id, "remove"
- )
- self.redirect(asking_page)
- return
-
- elif operation == self.MOVE_UP:
- training_day2 = self.sql_session.query(TrainingDay)\
- .filter(TrainingDay.training_program == training_program)\
- .filter(TrainingDay.position == training_day.position - 1)\
- .first()
-
- if training_day2 is not None:
- tmp_a, tmp_b = training_day.position, training_day2.position
- training_day.position, training_day2.position = None, None
- self.sql_session.flush()
- training_day.position, training_day2.position = tmp_b, tmp_a
-
- elif operation == self.MOVE_DOWN:
- training_day2 = self.sql_session.query(TrainingDay)\
- .filter(TrainingDay.training_program == training_program)\
- .filter(TrainingDay.position == training_day.position + 1)\
- .first()
-
- if training_day2 is not None:
- tmp_a, tmp_b = training_day.position, training_day2.position
- training_day.position, training_day2.position = None, None
- self.sql_session.flush()
- training_day.position, training_day2.position = tmp_b, tmp_a
-
- self.try_commit()
- self.redirect(fallback_page)
-
-
-class AddTrainingDayHandler(BaseHandler):
- """Add a new training day to a training program."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def get(self, training_program_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
-
- self.r_params = self.render_params()
- self.r_params["training_program"] = training_program
- self.r_params["contest"] = managing_contest
- self.r_params["unanswered"] = self.sql_session.query(Question)\
- .join(Participation)\
- .filter(Participation.contest_id == managing_contest.id)\
- .filter(Question.reply_timestamp.is_(None))\
- .filter(Question.ignored.is_(False))\
- .count()
-
- # Get all student tags for the tagify select dropdown
- tags_query = self.sql_session.query(
- func.unnest(Student.student_tags).label("tag")
- ).filter(
- Student.training_program_id == training_program.id
- ).distinct()
- self.r_params["all_student_tags"] = sorted([row.tag for row in tags_query.all()])
-
- self.render("add_training_day.html", **self.r_params)
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, training_program_id: str):
- fallback_page = self.url("training_program", training_program_id, "training_days", "add")
-
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
-
- try:
- name = self.get_argument("name")
- if not name or not name.strip():
- raise ValueError("Name is required")
-
- description = self.get_argument("description", "")
- if not description or not description.strip():
- description = name
-
- # Parse optional start and stop times from datetime-local inputs
- # Format from HTML5 datetime-local: YYYY-MM-DDTHH:MM
- start_str = self.get_argument("start", "")
- stop_str = self.get_argument("stop", "")
-
- contest_kwargs: dict = {
- "name": name,
- "description": description,
- }
-
- if start_str:
- # Convert from datetime-local format (YYYY-MM-DDTHH:MM) to datetime
- contest_kwargs["start"] = dt.strptime(start_str, "%Y-%m-%dT%H:%M")
- else:
- # Default to after training program end year (so contestants can't start until configured)
- program_end_year = training_program.managing_contest.stop.year
- default_date = dt(program_end_year + 1, 1, 1, 0, 0)
- contest_kwargs["start"] = default_date
- # Also set analysis_start/stop to satisfy Contest check constraints
- # (stop <= analysis_start and analysis_start <= analysis_stop)
- contest_kwargs["analysis_start"] = default_date
- contest_kwargs["analysis_stop"] = default_date
-
- if stop_str:
- contest_kwargs["stop"] = dt.strptime(stop_str, "%Y-%m-%dT%H:%M")
- else:
- # Default stop to same as start when not specified
- program_end_year = training_program.managing_contest.stop.year
- contest_kwargs["stop"] = dt(program_end_year + 1, 1, 1, 0, 0)
-
- # Parse main group configuration (if any)
- group_tags = self.get_arguments("group_tag_name[]")
- group_starts = self.get_arguments("group_start_time[]")
- group_ends = self.get_arguments("group_end_time[]")
- group_alphabeticals = self.get_arguments("group_alphabetical[]")
-
- # Collect valid groups and their times for defaulting
- groups_to_create = []
- group_start_times = []
- group_end_times = []
-
- for i, tag in enumerate(group_tags):
- tag = tag.strip()
- if not tag:
- continue
-
- group_start = None
- group_end = None
-
- if i < len(group_starts) and group_starts[i].strip():
- group_start = dt.strptime(group_starts[i].strip(), "%Y-%m-%dT%H:%M")
- group_start_times.append(group_start)
-
- if i < len(group_ends) and group_ends[i].strip():
- group_end = dt.strptime(group_ends[i].strip(), "%Y-%m-%dT%H:%M")
- group_end_times.append(group_end)
-
- # Validate group end is not before start
- if group_start and group_end and group_end < group_start:
- raise ValueError(f"End time must be after start time for group '{tag}'")
-
- alphabetical = str(i) in group_alphabeticals
-
- groups_to_create.append({
- "tag_name": tag,
- "start_time": group_start,
- "end_time": group_end,
- "alphabetical_task_order": alphabetical,
- })
-
- # Default training start/end from group times if not specified
- if not start_str and group_start_times:
- contest_kwargs["start"] = min(group_start_times)
- if not stop_str and group_end_times:
- contest_kwargs["stop"] = max(group_end_times)
-
- contest = Contest(**contest_kwargs)
- self.sql_session.add(contest)
- self.sql_session.flush()
-
- position = len(training_program.training_days)
- training_day = TrainingDay(
- training_program=training_program,
- contest=contest,
- position=position,
- )
- self.sql_session.add(training_day)
-
- # Create main groups
- seen_tags = set()
- for group_data in groups_to_create:
- if group_data["tag_name"] in seen_tags:
- raise ValueError(f"Duplicate tag '{group_data['tag_name']}'")
- seen_tags.add(group_data["tag_name"])
-
- # Validate group times are within contest bounds
- if group_data["start_time"] and contest_kwargs.get("start"):
- if group_data["start_time"] < contest_kwargs["start"]:
- raise ValueError(f"Group '{group_data['tag_name']}' start time cannot be before training day start")
- if group_data["end_time"] and contest_kwargs.get("stop"):
- if group_data["end_time"] > contest_kwargs["stop"]:
- raise ValueError(f"Group '{group_data['tag_name']}' end time cannot be after training day end")
-
- group = TrainingDayGroup(
- training_day=training_day,
- **group_data
- )
- self.sql_session.add(group)
-
- # Auto-add participations for all students in the training program
- # Training days are for all students, so we create participations
- # in the training day's contest for each student
- for student in training_program.students:
- user = student.participation.user
- participation = Participation(contest=contest, user=user)
- self.sql_session.add(participation)
-
- except Exception as error:
- self.service.add_notification(make_datetime(), "Invalid field(s)", repr(error))
- self.redirect(fallback_page)
- return
-
- if self.try_commit():
- self.redirect(self.url("training_program", training_program_id, "training_days"))
- else:
- self.redirect(fallback_page)
-
-
-class RemoveTrainingDayHandler(BaseHandler):
- """Confirm and remove a training day from a training program."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def get(self, training_program_id: str, training_day_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- training_day = self.safe_get_item(TrainingDay, training_day_id)
- managing_contest = training_program.managing_contest
-
- if training_day.training_program_id != training_program.id:
- raise tornado.web.HTTPError(404)
-
- self.r_params = self.render_params()
- self.r_params["training_program"] = training_program
- self.r_params["training_day"] = training_day
- self.r_params["contest"] = managing_contest
- self.r_params["unanswered"] = 0
-
- # Stats for warning message
- self.r_params["task_count"] = len(training_day.tasks)
- # For archived training days, contest_id is None so counts are 0
- if training_day.contest_id is not None:
- self.r_params["participation_count"] = (
- self.sql_session.query(Participation)
- .filter(Participation.contest_id == training_day.contest_id)
- .count()
- )
- self.r_params["submission_count"] = (
- self.sql_session.query(Submission)
- .join(Participation)
- .filter(Participation.contest_id == training_day.contest_id)
- .count()
- )
- else:
- self.r_params["participation_count"] = 0
- self.r_params["submission_count"] = 0
-
- self.render("training_day_remove.html", **self.r_params)
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def delete(self, training_program_id: str, training_day_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- training_day = self.safe_get_item(TrainingDay, training_day_id)
-
- if training_day.training_program_id != training_program.id:
- raise tornado.web.HTTPError(404)
-
- contest = training_day.contest
- position = training_day.position
-
- # Always detach tasks from the training day - they stay in the training program.
- # The database FK has ON DELETE SET NULL, but we also clear training_day_num
- # explicitly to remove stale ordering metadata.
- tasks = (
- self.sql_session.query(Task)
- .filter(Task.training_day == training_day)
- .order_by(Task.training_day_num)
- .all()
- )
-
- for task in tasks:
- task.training_day = None
- task.training_day_num = None
-
- self.sql_session.flush()
-
- self.sql_session.delete(training_day)
- if contest is not None:
- self.sql_session.delete(contest)
-
- self.sql_session.flush()
-
- for td in training_program.training_days:
- if td.position is not None and position is not None and td.position > position:
- td.position -= 1
-
- self.try_commit()
- self.write("../../training_days")
-
-
-class AddTrainingDayGroupHandler(BaseHandler):
- """Add a main group to a training day."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, contest_id: str):
- contest = self.safe_get_item(Contest, contest_id)
- training_day = contest.training_day
-
- if training_day is None:
- raise tornado.web.HTTPError(404, "Not a training day contest")
-
- fallback_page = self.url("contest", contest_id)
-
- try:
- tag_name = self.get_argument("tag_name")
- if not tag_name or not tag_name.strip():
- raise ValueError("Tag name is required")
-
- # Strip whitespace before duplicate check to avoid bypass
- tag_name = tag_name.strip()
-
- # Check if tag is already used
- existing = self.sql_session.query(TrainingDayGroup)\
- .filter(TrainingDayGroup.training_day == training_day)\
- .filter(TrainingDayGroup.tag_name == tag_name)\
- .first()
- if existing:
- raise ValueError(f"Tag '{tag_name}' is already a main group")
-
- # Parse optional start and end times
- start_str = self.get_argument("start_time", "")
- end_str = self.get_argument("end_time", "")
-
- group_kwargs: dict = {
- "training_day": training_day,
- "tag_name": tag_name,
- "alphabetical_task_order": self.get_argument("alphabetical_task_order", None) is not None,
- }
-
- if start_str:
- group_kwargs["start_time"] = dt.strptime(start_str, "%Y-%m-%dT%H:%M")
-
- if end_str:
- group_kwargs["end_time"] = dt.strptime(end_str, "%Y-%m-%dT%H:%M")
-
- # Validate that end is not before start
- if "start_time" in group_kwargs and "end_time" in group_kwargs:
- if group_kwargs["end_time"] < group_kwargs["start_time"]:
- raise ValueError("End time must be after start time")
-
- # Validate group times are within contest bounds
- if "start_time" in group_kwargs and contest.start:
- if group_kwargs["start_time"] < contest.start:
- raise ValueError(f"Group start time cannot be before training day start ({contest.start})")
- if "end_time" in group_kwargs and contest.stop:
- if group_kwargs["end_time"] > contest.stop:
- raise ValueError(f"Group end time cannot be after training day end ({contest.stop})")
-
- group = TrainingDayGroup(**group_kwargs)
- self.sql_session.add(group)
-
- except Exception as error:
- self.service.add_notification(make_datetime(), "Invalid field(s)", repr(error))
- self.redirect(fallback_page)
- return
-
- self.try_commit()
- self.redirect(fallback_page)
-
-
-class UpdateTrainingDayGroupsHandler(BaseHandler):
- """Update all main groups for a training day."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, contest_id: str):
- contest = self.safe_get_item(Contest, contest_id)
- training_day = contest.training_day
-
- if training_day is None:
- raise tornado.web.HTTPError(404, "Not a training day contest")
-
- fallback_page = self.url("contest", contest_id)
-
- try:
- group_ids = self.get_arguments("group_id[]")
- start_times = self.get_arguments("start_time[]")
- end_times = self.get_arguments("end_time[]")
-
- if len(group_ids) != len(start_times) or len(group_ids) != len(end_times):
- raise ValueError("Mismatched form data")
-
- for i, group_id in enumerate(group_ids):
- group = self.safe_get_item(TrainingDayGroup, group_id)
- if group.training_day_id != training_day.id:
- raise ValueError(f"Group {group_id} does not belong to this training day")
-
- # Parse start time
- start_str = start_times[i].strip()
- if start_str:
- group.start_time = dt.strptime(start_str, "%Y-%m-%dT%H:%M")
- else:
- group.start_time = None
-
- # Parse end time
- end_str = end_times[i].strip()
- if end_str:
- group.end_time = dt.strptime(end_str, "%Y-%m-%dT%H:%M")
- else:
- group.end_time = None
-
- # Validate end is not before start
- if group.start_time and group.end_time:
- if group.end_time < group.start_time:
- raise ValueError(f"End time must be after start time for group '{group.tag_name}'")
-
- # Validate group times are within contest bounds
- if group.start_time and contest.start:
- if group.start_time < contest.start:
- raise ValueError(f"Group '{group.tag_name}' start time cannot be before training day start")
- if group.end_time and contest.stop:
- if group.end_time > contest.stop:
- raise ValueError(f"Group '{group.tag_name}' end time cannot be after training day end")
-
- # Update alphabetical task order (checkbox - present means checked)
- group.alphabetical_task_order = self.get_argument(f"alphabetical_{group_id}", None) is not None
-
- except Exception as error:
- self.service.add_notification(make_datetime(), "Invalid field(s)", repr(error))
- self.redirect(fallback_page)
- return
-
- self.try_commit()
- self.redirect(fallback_page)
-
-
-class RemoveTrainingDayGroupHandler(BaseHandler):
- """Remove a main group from a training day."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, contest_id: str, group_id: str):
- contest = self.safe_get_item(Contest, contest_id)
- training_day = contest.training_day
-
- if training_day is None:
- raise tornado.web.HTTPError(404, "Not a training day contest")
-
- group = self.safe_get_item(TrainingDayGroup, group_id)
-
- if group.training_day_id != training_day.id:
- raise tornado.web.HTTPError(404, "Group does not belong to this training day")
-
- self.sql_session.delete(group)
- self.try_commit()
- self.redirect(self.url("contest", contest_id))
-
-
-class TrainingDayTypesHandler(BaseHandler):
- """Handler for updating training day types via AJAX."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, training_program_id: str, training_day_id: str):
- self.set_header("Content-Type", "application/json")
-
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- training_day = self.safe_get_item(TrainingDay, training_day_id)
-
- if training_day.training_program_id != training_program.id:
- self.set_status(404)
- self.write({"error": "Training day does not belong to this program"})
- return
-
- try:
- types_str = self.get_argument("training_day_types", "")
- training_day.training_day_types = parse_tags(types_str)
-
- if self.try_commit():
- self.write({
- "success": True,
- "types": training_day.training_day_types
- })
- else:
- self.set_status(500)
- self.write({"error": "Failed to save"})
-
- except Exception as error:
- self.set_status(400)
- self.write({"error": str(error)})
-
-
-class StudentTasksHandler(BaseHandler):
- """View and manage tasks assigned to a student in a training program."""
-
- @require_permission(BaseHandler.AUTHENTICATED)
- def get(self, training_program_id: str, user_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
-
- participation: Participation | None = (
- self.sql_session.query(Participation)
- .filter(Participation.contest_id == managing_contest.id)
- .filter(Participation.user_id == user_id)
- .first()
- )
-
- if participation is None:
- raise tornado.web.HTTPError(404)
-
- student: Student | None = (
- self.sql_session.query(Student)
- .filter(Student.participation == participation)
- .filter(Student.training_program == training_program)
- .first()
- )
-
- if student is None:
- raise tornado.web.HTTPError(404)
-
- # Get all tasks in the training program for the "add task" dropdown
- all_tasks = managing_contest.get_tasks()
- assigned_task_ids = {st.task_id for st in student.student_tasks}
- available_tasks = [t for t in all_tasks if t.id not in assigned_task_ids]
-
- # Build home scores from participation task_scores cache
- home_scores = {}
- for pts in participation.task_scores:
- home_scores[pts.task_id] = pts.score
-
- # Build training scores from archived student rankings (batch query)
- training_scores = {}
- source_training_day_ids = {
- st.source_training_day_id
- for st in student.student_tasks
- if st.source_training_day_id is not None
- }
- archived_rankings = {}
- if source_training_day_ids:
- archived_rankings = {
- r.training_day_id: r
- for r in (
- self.sql_session.query(ArchivedStudentRanking)
- .filter(ArchivedStudentRanking.training_day_id.in_(source_training_day_ids))
- .filter(ArchivedStudentRanking.student_id == student.id)
- .all()
- )
- }
-
- for st in student.student_tasks:
- if st.source_training_day_id is None:
- continue
- archived_ranking = archived_rankings.get(st.source_training_day_id)
- if archived_ranking and archived_ranking.task_scores:
- task_id_str = str(st.task_id)
- if task_id_str in archived_ranking.task_scores:
- training_scores[st.task_id] = archived_ranking.task_scores[task_id_str]
-
- self.r_params = self.render_params()
- self.r_params["training_program"] = training_program
- self.r_params["participation"] = participation
- self.r_params["student"] = student
- self.r_params["selected_user"] = participation.user
- self.r_params["student_tasks"] = sorted(
- student.student_tasks, key=lambda st: st.assigned_at, reverse=True
- )
- self.r_params["available_tasks"] = available_tasks
- self.r_params["home_scores"] = home_scores
- self.r_params["training_scores"] = training_scores
- self.r_params["unanswered"] = self.sql_session.query(Question)\
- .join(Participation)\
- .filter(Participation.contest_id == managing_contest.id)\
- .filter(Question.reply_timestamp.is_(None))\
- .filter(Question.ignored.is_(False))\
- .count()
- self.render("student_tasks.html", **self.r_params)
-
-
-class AddStudentTaskHandler(BaseHandler):
- """Add a task to a student's task archive."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, training_program_id: str, user_id: str):
- fallback_page = self.url(
- "training_program", training_program_id, "student", user_id, "tasks"
- )
-
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
-
- participation: Participation | None = (
- self.sql_session.query(Participation)
- .filter(Participation.contest_id == managing_contest.id)
- .filter(Participation.user_id == user_id)
- .first()
- )
-
- if participation is None:
- raise tornado.web.HTTPError(404)
-
- student: Student | None = (
- self.sql_session.query(Student)
- .filter(Student.participation == participation)
- .filter(Student.training_program == training_program)
- .first()
- )
-
- if student is None:
- raise tornado.web.HTTPError(404)
-
- try:
- task_id = self.get_argument("task_id")
- if task_id in ("", "null"):
- raise ValueError("Please select a task")
-
- task = self.safe_get_item(Task, task_id)
-
- # Check if task is already assigned
- existing = (
- self.sql_session.query(StudentTask)
- .filter(StudentTask.student_id == student.id)
- .filter(StudentTask.task_id == task.id)
- .first()
- )
- if existing is not None:
- raise ValueError("Task is already assigned to this student")
-
- # Create the StudentTask record (manual assignment, no training day)
- # Note: CMS Base.__init__ skips foreign key columns, so we must
- # set them as attributes after creating the object
- student_task = StudentTask(assigned_at=make_datetime())
- student_task.student_id = student.id
- student_task.task_id = task.id
- student_task.source_training_day_id = None
- self.sql_session.add(student_task)
-
- except Exception as error:
- self.service.add_notification(
- make_datetime(), "Invalid field(s)", repr(error)
- )
- self.redirect(fallback_page)
- return
-
- if self.try_commit():
- self.service.add_notification(
- make_datetime(),
- "Task assigned",
- f"Task '{task.name}' has been assigned to {participation.user.username}"
- )
-
- self.redirect(fallback_page)
-
-
-class RemoveStudentTaskHandler(BaseHandler):
- """Remove a task from a student's task archive."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, training_program_id: str, user_id: str, task_id: str):
- fallback_page = self.url(
- "training_program", training_program_id, "student", user_id, "tasks"
- )
-
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
-
- participation: Participation | None = (
- self.sql_session.query(Participation)
- .filter(Participation.contest_id == managing_contest.id)
- .filter(Participation.user_id == user_id)
- .first()
- )
-
- if participation is None:
- raise tornado.web.HTTPError(404)
-
- student: Student | None = (
- self.sql_session.query(Student)
- .filter(Student.participation == participation)
- .filter(Student.training_program == training_program)
- .first()
- )
-
- if student is None:
- raise tornado.web.HTTPError(404)
-
- student_task: StudentTask | None = (
- self.sql_session.query(StudentTask)
- .filter(StudentTask.student_id == student.id)
- .filter(StudentTask.task_id == task_id)
- .first()
- )
-
- if student_task is None:
- raise tornado.web.HTTPError(404)
-
- task = student_task.task
- self.sql_session.delete(student_task)
-
- if self.try_commit():
- self.service.add_notification(
- make_datetime(),
- "Task removed",
- f"Task '{task.name}' has been removed from {participation.user.username}'s archive"
- )
-
- self.redirect(fallback_page)
-
-
-class BulkAssignTaskHandler(BaseHandler):
- """Bulk assign a task to all students with a given tag."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def get(self, training_program_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- managing_contest = training_program.managing_contest
-
- # Get all tasks in the training program
- all_tasks = managing_contest.get_tasks()
-
- # Get all unique student tags
- all_student_tags = get_all_student_tags(training_program)
-
- self.r_params = self.render_params()
- self.r_params["training_program"] = training_program
- self.r_params["all_tasks"] = all_tasks
- self.r_params["all_student_tags"] = all_student_tags
- self.r_params["unanswered"] = self.sql_session.query(Question)\
- .join(Participation)\
- .filter(Participation.contest_id == managing_contest.id)\
- .filter(Question.reply_timestamp.is_(None))\
- .filter(Question.ignored.is_(False))\
- .count()
- self.render("bulk_assign_task.html", **self.r_params)
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, training_program_id: str):
- fallback_page = self.url(
- "training_program", training_program_id, "bulk_assign_task"
- )
-
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
-
- try:
- task_id = self.get_argument("task_id")
- if task_id in ("", "null"):
- raise ValueError("Please select a task")
-
- tag_name = self.get_argument("tag", "").strip().lower()
- if not tag_name:
- raise ValueError("Please enter a tag")
-
- task = self.safe_get_item(Task, task_id)
-
- # Find all students with the given tag
- matching_students = (
- self.sql_session.query(Student)
- .filter(Student.training_program == training_program)
- .filter(Student.student_tags.any(tag_name))
- .all()
- )
-
- if not matching_students:
- raise ValueError(f"No students found with tag '{tag_name}'")
-
- # We want to know which of these specific students already have this task.
- student_ids = [s.id for s in matching_students]
-
- already_assigned_ids = set(
- row[0]
- for row in self.sql_session.query(StudentTask.student_id)
- .filter(StudentTask.task_id == task.id)
- .filter(StudentTask.student_id.in_(student_ids))
- .all()
- )
-
- # Assign task to each matching student (if not already assigned)
- assigned_count = 0
- for student_id in student_ids:
- if student_id not in already_assigned_ids:
- # Note: CMS Base.__init__ skips foreign key columns, so we must
- # set them as attributes after creating the object
- student_task = StudentTask(assigned_at=make_datetime())
- student_task.student_id = student_id
- student_task.task_id = task.id
- student_task.source_training_day_id = None
- self.sql_session.add(student_task)
- assigned_count += 1
-
- except Exception as error:
- self.service.add_notification(
- make_datetime(), "Invalid field(s)", repr(error)
- )
- self.redirect(fallback_page)
- return
-
- if self.try_commit():
- self.service.add_notification(
- make_datetime(),
- "Bulk assignment complete",
- f"Task '{task.name}' assigned to {assigned_count} students with tag '{tag_name}'",
- )
-
- self.redirect(fallback_page)
-
-
-class ArchiveTrainingDayHandler(BaseHandler):
- """Archive a training day, extracting attendance and ranking data."""
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def get(self, training_program_id: str, training_day_id: str):
- """Show the archive confirmation page with IP selection."""
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- training_day = self.safe_get_item(TrainingDay, training_day_id)
-
- if training_day.training_program_id != training_program.id:
- raise tornado.web.HTTPError(404, "Training day not in this program")
-
- if training_day.contest is None:
- raise tornado.web.HTTPError(400, "Training day is already archived")
-
- contest = training_day.contest
-
- # Get all participations with their starting IPs
- # Count students per IP (only IPs with more than one student)
- ip_counts: dict[str, int] = {}
- for participation in contest.participations:
- if participation.starting_ip_addresses:
- # Parse comma-separated IP addresses
- ips = [ip.strip() for ip in participation.starting_ip_addresses.split(",") if ip.strip()]
- for ip in ips:
- ip_counts[ip] = ip_counts.get(ip, 0) + 1
-
- # Filter to only IPs with more than one student
- shared_ips = {ip: count for ip, count in ip_counts.items() if count > 1}
-
- self.r_params = self.render_params()
- self.r_params["training_program"] = training_program
- self.r_params["training_day"] = training_day
- self.r_params["contest"] = contest
- self.r_params["shared_ips"] = shared_ips
- self.r_params["unanswered"] = self.sql_session.query(Question)\
- .join(Participation)\
- .filter(Participation.contest_id == training_program.managing_contest.id)\
- .filter(Question.reply_timestamp.is_(None))\
- .filter(Question.ignored.is_(False))\
- .count()
- self.render("archive_training_day.html", **self.r_params)
-
- @require_permission(BaseHandler.PERMISSION_ALL)
- def post(self, training_program_id: str, training_day_id: str):
- """Perform the archiving operation."""
- fallback_page = self.url(
- "training_program", training_program_id, "training_days"
- )
-
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- training_day = self.safe_get_item(TrainingDay, training_day_id)
-
- if training_day.training_program_id != training_program.id:
- raise tornado.web.HTTPError(404, "Training day not in this program")
-
- if training_day.contest is None:
- self.service.add_notification(
- make_datetime(), "Error", "Training day is already archived"
- )
- self.redirect(fallback_page)
- return
-
- contest = training_day.contest
-
- # Get selected class IPs from form
- class_ips = set(self.get_arguments("class_ips"))
-
- try:
- # Save name, description, and start_time from contest before archiving
- training_day.name = contest.name
- training_day.description = contest.description
- training_day.start_time = contest.start
-
- # Calculate and store the training day duration
- # Use max duration among main groups (if any), or training day duration
- training_day.duration = self._calculate_training_day_duration(
- training_day, contest
- )
-
- # Archive attendance data for each student
- self._archive_attendance_data(training_day, contest, class_ips)
-
- # Archive ranking data for each student
- self._archive_ranking_data(training_day, contest)
-
- # Delete the contest (this will cascade delete participations)
- self.sql_session.delete(contest)
-
- except Exception as error:
- self.service.add_notification(
- make_datetime(), "Archive failed", repr(error)
- )
- self.redirect(fallback_page)
- return
-
- if self.try_commit():
- self.service.add_notification(
- make_datetime(),
- "Training day archived",
- f"Training day '{training_day.name}' has been archived successfully"
- )
-
- self.redirect(fallback_page)
-
- def _calculate_training_day_duration(
- self,
- training_day: TrainingDay,
- contest: Contest
- ) -> timedelta | None:
- """Calculate the training day duration for archiving.
-
- Returns the max training duration among main groups (if any),
- or the training day duration (if no main groups).
-
- training_day: the training day being archived.
- contest: the contest associated with the training day.
-
- return: the duration as a timedelta, or None if not calculable.
- """
- # Check if there are main groups with custom timing
- main_groups = training_day.groups
- if main_groups:
- # Calculate max duration among main groups
- max_duration: timedelta | None = None
- for group in main_groups:
- if group.start_time is not None and group.end_time is not None:
- group_duration = group.end_time - group.start_time
- if max_duration is None or group_duration > max_duration:
- max_duration = group_duration
- if max_duration is not None:
- return max_duration
-
- # Fall back to training day (contest) duration
- if contest.start is not None and contest.stop is not None:
- return contest.stop - contest.start
-
- return None
-
- def _archive_attendance_data(
- self,
- training_day: TrainingDay,
- contest: Contest,
- class_ips: set[str]
- ) -> None:
- """Extract and store attendance data for all students."""
- training_program = training_day.training_program
-
- for participation in contest.participations:
- # Find the student for this user in the training program
- # Note: Student.participation_id points to the managing contest participation,
- # not the training day participation, so we need to look up by user_id
- student = (
- self.sql_session.query(Student)
- .join(Participation)
- .filter(Participation.user_id == participation.user_id)
- .filter(Student.training_program_id == training_program.id)
- .first()
- )
-
- if student is None:
- continue
-
- # Skip ineligible students (not in any main group)
- # These students were never supposed to participate in this training day
- is_eligible, _, _ = check_training_day_eligibility(
- self.sql_session, participation, training_day
- )
- if not is_eligible:
- continue
-
- # Determine status
- if participation.starting_time is None:
- status = "missed"
- else:
- status = "participated"
-
- # Determine location based on starting IPs
- # If no class IPs were selected, everyone who participated is considered "home"
- location = None
- if status == "participated":
- if not class_ips:
- # No class IPs selected means everyone is at home
- location = "home"
- elif participation.starting_ip_addresses:
- # Parse comma-separated IP addresses
- ips = [ip.strip() for ip in participation.starting_ip_addresses.split(",") if ip.strip()]
- if ips:
- has_class_ip = any(ip in class_ips for ip in ips)
- has_home_ip = any(ip not in class_ips for ip in ips)
-
- if has_class_ip and has_home_ip:
- location = "both"
- elif has_class_ip:
- location = "class"
- elif has_home_ip:
- location = "home"
- else:
- # Participated but no IP recorded - assume home
- location = "home"
- else:
- # Participated but no IP recorded - assume home
- location = "home"
-
- # Get delay time
- delay_time = participation.delay_time
-
- # Concatenate delay reasons from all delay requests
- delay_requests = (
- self.sql_session.query(DelayRequest)
- .filter(DelayRequest.participation_id == participation.id)
- .order_by(DelayRequest.request_timestamp)
- .all()
- )
- delay_reasons = None
- if delay_requests:
- reasons = [dr.reason for dr in delay_requests if dr.reason]
- if reasons:
- delay_reasons = "; ".join(reasons)
-
- # Create archived attendance record
- archived_attendance = ArchivedAttendance(
- status=status,
- location=location,
- delay_time=delay_time,
- delay_reasons=delay_reasons,
- )
- archived_attendance.training_day_id = training_day.id
- archived_attendance.student_id = student.id
- self.sql_session.add(archived_attendance)
-
- def _archive_ranking_data(
- self,
- training_day: TrainingDay,
- contest: Contest
- ) -> None:
- """Extract and store ranking data for all students.
-
- Stores on TrainingDay:
- - archived_tasks_data: task metadata including extra_headers for submission table
-
- Stores on ArchivedStudentRanking (per student):
- - task_scores: scores for ALL visible tasks (including 0 scores)
- The presence of a task_id key indicates the task was visible.
- - submissions: submission data for each task in RWS format
- - history: score history in RWS format
- """
- from cms.grading.scorecache import get_cached_score_entry
-
- training_program = training_day.training_program
-
- # Get the tasks assigned to this training day
- training_day_tasks = training_day.tasks
- training_day_task_ids = {task.id for task in training_day_tasks}
-
- # Build and store tasks_data on the training day (same for all students)
- # This preserves the scoring scheme as it was during the training day
- archived_tasks_data: dict[str, dict] = {}
- for task in training_day_tasks:
- max_score = 100.0
- extra_headers: list[str] = []
- score_precision = task.score_precision
- if task.active_dataset:
- try:
- score_type = task.active_dataset.score_type_object
- max_score = score_type.max_score
- extra_headers = score_type.ranking_headers
- except (KeyError, TypeError, AttributeError):
- pass
-
- archived_tasks_data[str(task.id)] = {
- "name": task.title,
- "short_name": task.name,
- "max_score": max_score,
- "score_precision": score_precision,
- "extra_headers": extra_headers,
- "training_day_num": task.training_day_num,
- }
- training_day.archived_tasks_data = archived_tasks_data
-
- for participation in contest.participations:
- # Find the student for this user in the training program
- # Note: Student.participation_id points to the managing contest participation,
- # not the training day participation, so we need to look up by user_id
- student = (
- self.sql_session.query(Student)
- .join(Participation)
- .filter(Participation.user_id == participation.user_id)
- .filter(Student.training_program_id == training_program.id)
- .first()
- )
-
- if student is None:
- continue
-
- # Skip ineligible students (not in any main group)
- # These students were never supposed to participate in this training day
- is_eligible, _, _ = check_training_day_eligibility(
- self.sql_session, participation, training_day
- )
- if not is_eligible:
- continue
-
- # Get all student tags (as list for array storage)
- student_tags = list(student.student_tags) if student.student_tags else []
-
- # Determine which tasks should be visible to this student based on their tags
- # This uses the same logic as _add_training_day_tasks_to_student in StartHandler
- # A task is visible if:
- # - The task has no visible_to_tags (empty list = visible to all)
- # - The student has at least one tag matching the task's visible_to_tags
- visible_tasks: list[Task] = []
- for task in training_day_tasks:
- if can_access_task(self.sql_session, task, participation, training_day):
- visible_tasks.append(task)
-
- # Add visible tasks to student's StudentTask records if not already present
- # This allows students who missed the training to still submit from home
- existing_task_ids = {st.task_id for st in student.student_tasks}
- for task in visible_tasks:
- if task.id not in existing_task_ids:
- student_task = StudentTask(assigned_at=make_datetime())
- student_task.student_id = student.id
- student_task.task_id = task.id
- student_task.source_training_day_id = training_day.id
- self.sql_session.add(student_task)
-
- # Get the managing participation for this user
- # Submissions are stored with the managing contest participation, not the
- # training day participation
- managing_participation = get_managing_participation(
- self.sql_session, training_day, participation.user
- )
- if managing_participation is None:
- raise ValueError(
- f"User {participation.user.username} (id={participation.user_id}) "
- f"does not have a participation in the managing contest "
- f"'{training_day.training_program.managing_contest.name}' "
- f"for training day '{training_day.name}'"
- )
-
- # Check if student missed the training (no starting_time)
- student_missed = participation.starting_time is None
-
- # Get task scores for ALL visible tasks (including 0 scores)
- # The presence of a task_id key indicates the task was visible
- task_scores: dict[str, float] = {}
- submissions: dict[str, list[dict]] = {}
-
- for task in visible_tasks:
- task_id = task.id
-
- if student_missed:
- # Student missed the training - set score to 0
- task_scores[str(task_id)] = 0.0
- else:
- # Get score from the training day participation (for cache lookup)
- cache_entry = get_cached_score_entry(
- self.sql_session, participation, task
- )
- task_scores[str(task_id)] = cache_entry.score
-
- # Get official submissions for this task from the managing participation
- task_submissions = (
- self.sql_session.query(Submission)
- .filter(Submission.participation_id == managing_participation.id)
- .filter(Submission.task_id == task_id)
- .filter(Submission.training_day_id == training_day.id)
- .filter(Submission.official.is_(True))
- .order_by(Submission.timestamp)
- .all()
- )
-
- # If student missed but has submissions, this is an error
- if student_missed and task_submissions:
- raise ValueError(
- f"User {participation.user.username} (id={participation.user_id}) "
- f"has no starting_time but has {len(task_submissions)} submission(s) "
- f"for task '{task.name}' in training day '{training_day.name}'"
- )
-
- submissions[str(task_id)] = []
- for sub in task_submissions:
- result = sub.get_result()
- if result is None or not result.scored():
- continue
-
- if sub.timestamp is not None:
- time_offset = int(
- (
- sub.timestamp - participation.starting_time
- ).total_seconds()
- )
- else:
- time_offset = 0
-
- submissions[str(task_id)].append({
- "task": str(task_id),
- "time": time_offset,
- "score": result.score,
- "token": sub.tokened(),
- "extra": result.ranking_score_details or [],
- })
-
- # Get score history in RWS format: [[user_id, task_id, time, score], ...]
- # Score history is stored on the training day participation
- history: list[list] = []
- score_histories = (
- self.sql_session.query(ScoreHistory)
- .filter(ScoreHistory.participation_id == participation.id)
- .filter(ScoreHistory.task_id.in_(training_day_task_ids))
- .order_by(ScoreHistory.timestamp)
- .all()
- )
-
- # If student missed but has score history, this is an error
- if student_missed and score_histories:
- raise ValueError(
- f"User {participation.user.username} (id={participation.user_id}) "
- f"has no starting_time but has {len(score_histories)} score history "
- f"record(s) in training day '{training_day.name}'"
- )
-
- for sh in score_histories:
- if sh.timestamp is not None:
- time_offset = (
- sh.timestamp - participation.starting_time
- ).total_seconds()
- else:
- time_offset = 0
- history.append([
- participation.user_id,
- sh.task_id,
- time_offset,
- sh.score
- ])
-
- # Create archived ranking record
- archived_ranking = ArchivedStudentRanking(
- student_tags=student_tags,
- task_scores=task_scores if task_scores else None,
- submissions=submissions if submissions else None,
- history=history if history else None,
- )
- archived_ranking.training_day_id = training_day.id
- archived_ranking.student_id = student.id
- self.sql_session.add(archived_ranking)
-
-
-class TrainingProgramFilterMixin:
- """Mixin for filtering training days by date range, types, and student tags."""
-
- def _parse_date_range(self) -> tuple[dt | None, dt | None]:
- """Parse start_date and end_date query arguments."""
- start_date = None
- end_date = None
- start_str = self.get_argument("start_date", None)
- end_str = self.get_argument("end_date", None)
-
- if start_str:
- try:
- start_date = dt.fromisoformat(start_str)
- except ValueError:
- pass
-
- if end_str:
- try:
- end_date = dt.fromisoformat(end_str)
- except ValueError:
- pass
-
- return start_date, end_date
-
- def _parse_training_day_types(self) -> list[str]:
- """Parse training_day_types query argument."""
- types_str = self.get_argument("training_day_types", "")
- if not types_str:
- return []
- return parse_tags(types_str)
-
- def _parse_student_tags_filter(self) -> tuple[list[str], str]:
- """Parse student_tags and student_tags_mode query arguments.
-
- Returns:
- tuple of (student_tags list, filter_mode string)
- filter_mode is either "current" or "historical"
- """
- tags_str = self.get_argument("student_tags", "")
- mode = self.get_argument("student_tags_mode", "current")
- if mode not in ("current", "historical"):
- mode = "current"
- if not tags_str:
- return [], mode
- return parse_tags(tags_str), mode
-
- def _get_archived_training_days(
- self,
- training_program_id: int,
- start_date: dt | None,
- end_date: dt | None,
- training_day_types: list[str] | None = None,
- ) -> list[TrainingDay]:
- """Query archived training days with optional date and type filtering."""
- query = (
- self.sql_session.query(TrainingDay)
- .filter(TrainingDay.training_program_id == training_program_id)
- .filter(TrainingDay.contest_id.is_(None))
- )
- if start_date:
- query = query.filter(TrainingDay.start_time >= start_date)
- if end_date:
- # Add one day to end_date to include the entire end day
- query = query.filter(TrainingDay.start_time < end_date + timedelta(days=1))
- if training_day_types:
- # Filter training days that have ALL specified types
- query = query.filter(
- TrainingDay.training_day_types.contains(training_day_types)
- )
- return query.order_by(TrainingDay.start_time).all()
-
- def _tags_match(self, item_tags: list[str] | None, filter_tags: list[str]) -> bool:
- """Check if item_tags contains all filter_tags."""
- return all(tag in (item_tags or []) for tag in filter_tags)
-
- def _get_student_ids_with_tags(self, students, filter_tags: list[str]) -> set[int]:
- """Return IDs of students that have all filter_tags."""
- return {s.id for s in students if self._tags_match(s.student_tags, filter_tags)}
-
- def _get_filtered_context(self, training_program):
- """Parse common arguments and retrieve archived training days."""
- start_date, end_date = self._parse_date_range()
- training_day_types = self._parse_training_day_types()
- student_tags, student_tags_mode = self._parse_student_tags_filter()
-
- archived_training_days = self._get_archived_training_days(
- training_program.id, start_date, end_date, training_day_types
- )
-
- # Build a set of students with matching current tags
- current_tag_student_ids = self._get_student_ids_with_tags(
- training_program.students, student_tags
- )
-
- return (
- start_date,
- end_date,
- training_day_types,
- student_tags,
- student_tags_mode,
- archived_training_days,
- current_tag_student_ids,
- )
-
-
-class TrainingProgramAttendanceHandler(TrainingProgramFilterMixin, BaseHandler):
- """Display attendance data for all archived training days."""
-
- @require_permission(BaseHandler.AUTHENTICATED)
- def get(self, training_program_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
-
- (
- start_date,
- end_date,
- training_day_types,
- student_tags,
- _,
- archived_training_days,
- current_tag_student_ids,
- ) = self._get_filtered_context(training_program)
-
- # Build attendance data structure
- # {student_id: {training_day_id: attendance_record}}
- attendance_data: dict[int, dict[int, ArchivedAttendance]] = {}
- all_students: dict[int, Student] = {}
-
- for td in archived_training_days:
- for attendance in td.archived_attendances:
- student_id = attendance.student_id
- # Apply student tag filter (current tags only)
- if student_tags and student_id not in current_tag_student_ids:
- continue
- if student_id not in attendance_data:
- attendance_data[student_id] = {}
- all_students[student_id] = attendance.student
- attendance_data[student_id][td.id] = attendance
-
- # Sort students by username
- sorted_students = sorted(
- all_students.values(),
- key=lambda s: s.participation.user.username if s.participation else ""
- )
-
- self.r_params = self.render_params()
- self.r_params["training_program"] = training_program
- self.r_params["archived_training_days"] = archived_training_days
- self.r_params["attendance_data"] = attendance_data
- self.r_params["sorted_students"] = sorted_students
- self.r_params["start_date"] = start_date
- self.r_params["end_date"] = end_date
- self.r_params["training_day_types"] = training_day_types
- self.r_params["student_tags"] = student_tags
- self.r_params["all_training_day_types"] = get_all_training_day_types(
- training_program)
- self.r_params["all_student_tags"] = get_all_student_tags(training_program)
- self.r_params["unanswered"] = self.sql_session.query(Question)\
- .join(Participation)\
- .filter(Participation.contest_id == training_program.managing_contest.id)\
- .filter(Question.reply_timestamp.is_(None))\
- .filter(Question.ignored.is_(False))\
- .count()
- self.render("training_program_attendance.html", **self.r_params)
-
-
-class TrainingProgramCombinedRankingHandler(
- TrainingProgramFilterMixin, BaseHandler
-):
- """Display combined ranking data for all archived training days."""
-
- @require_permission(BaseHandler.AUTHENTICATED)
- def get(self, training_program_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
-
- (
- start_date,
- end_date,
- training_day_types,
- student_tags,
- student_tags_mode,
- archived_training_days,
- current_tag_student_ids,
- ) = self._get_filtered_context(training_program)
-
- ranking_data: dict[int, dict[int, ArchivedStudentRanking]] = {}
- all_students: dict[int, Student] = {}
- training_day_tasks: dict[int, list[dict]] = {}
- # Attendance data: {student_id: {training_day_id: ArchivedAttendance}}
- attendance_data: dict[int, dict[int, ArchivedAttendance]] = {}
- # Track which students are "active" (have matching tags) for each training day
- # For historical mode: student had matching tags during that training
- # For current mode: student has matching tags now AND participated in that training
- active_students_per_td: dict[int, set[int]] = {}
-
- filtered_training_days: list[TrainingDay] = []
-
- for td in archived_training_days:
- active_students_per_td[td.id] = set()
-
- # Build attendance lookup for this training day
- for attendance in td.archived_attendances:
- student_id = attendance.student_id
- if student_id not in attendance_data:
- attendance_data[student_id] = {}
- attendance_data[student_id][td.id] = attendance
-
- # Collect all tasks that were visible to at least one filtered student
- # Use archived_tasks_data from training day (preserves original scoring scheme)
- visible_tasks_by_id: dict[int, dict] = {}
- for ranking in td.archived_student_rankings:
- student_id = ranking.student_id
-
- # Apply student tag filter
- if student_tags:
- if student_tags_mode == "current":
- # Filter by current tags: student must have matching tags now
- if student_id not in current_tag_student_ids:
- continue
- else: # historical mode
- # Filter by historical tags: student must have had matching tags
- # during this specific training day
- if not self._tags_match(ranking.student_tags, student_tags):
- continue
-
- # Student passes the filter for this training day
- active_students_per_td[td.id].add(student_id)
-
- if student_id not in ranking_data:
- ranking_data[student_id] = {}
- all_students[student_id] = ranking.student
- ranking_data[student_id][td.id] = ranking
-
- # Collect all visible tasks from this student's task_scores keys
- if ranking.task_scores:
- for task_id_str in ranking.task_scores.keys():
- task_id = int(task_id_str)
- if task_id not in visible_tasks_by_id:
- # Get task info from archived_tasks_data on training day
- if td.archived_tasks_data and task_id_str in td.archived_tasks_data:
- task_info = td.archived_tasks_data[task_id_str]
- visible_tasks_by_id[task_id] = {
- "id": task_id,
- "name": task_info.get("short_name", ""),
- "title": task_info.get("name", ""),
- "training_day_num": task_info.get("training_day_num"),
- }
- else:
- # Fallback to live task data
- task = self.sql_session.query(Task).get(task_id)
- if task:
- visible_tasks_by_id[task_id] = {
- "id": task_id,
- "name": task.name,
- "title": task.title,
- "training_day_num": task.training_day_num,
- }
-
- # Omit training days where no filtered students were eligible
- if not active_students_per_td[td.id]:
- continue
-
- filtered_training_days.append(td)
-
- # Sort tasks by training_day_num for stable ordering
- sorted_tasks = sorted(
- visible_tasks_by_id.values(),
- key=lambda t: (t.get("training_day_num") or 0, t["id"])
- )
- training_day_tasks[td.id] = sorted_tasks
-
- sorted_students = sorted(
- all_students.values(),
- key=lambda s: s.participation.user.username if s.participation else ""
- )
-
- self.r_params = self.render_params()
- self.r_params["training_program"] = training_program
- self.r_params["archived_training_days"] = filtered_training_days
- self.r_params["ranking_data"] = ranking_data
- self.r_params["sorted_students"] = sorted_students
- self.r_params["training_day_tasks"] = training_day_tasks
- self.r_params["attendance_data"] = attendance_data
- self.r_params["active_students_per_td"] = active_students_per_td
- self.r_params["start_date"] = start_date
- self.r_params["end_date"] = end_date
- self.r_params["training_day_types"] = training_day_types
- self.r_params["student_tags"] = student_tags
- self.r_params["student_tags_mode"] = student_tags_mode
- self.r_params["all_training_day_types"] = get_all_training_day_types(
- training_program)
- self.r_params["all_student_tags"] = get_all_student_tags_with_historical(
- training_program)
- self.r_params["unanswered"] = self.sql_session.query(Question)\
- .join(Participation)\
- .filter(Participation.contest_id == training_program.managing_contest.id)\
- .filter(Question.reply_timestamp.is_(None))\
- .filter(Question.ignored.is_(False))\
- .count()
- self.render("training_program_combined_ranking.html", **self.r_params)
-
-
-class TrainingProgramCombinedRankingHistoryHandler(
- TrainingProgramFilterMixin, BaseHandler
-):
- """Return score history for archived training days as JSON."""
-
- @require_permission(BaseHandler.AUTHENTICATED)
- def get(self, training_program_id: str):
- import json
-
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
-
- (
- _start_date,
- _end_date,
- _training_day_types,
- student_tags,
- student_tags_mode,
- archived_training_days,
- current_tag_student_ids,
- ) = self._get_filtered_context(training_program)
-
- result = []
- for td in archived_training_days:
- for ranking in td.archived_student_rankings:
- # Apply student tag filter
- if student_tags:
- if student_tags_mode == "current":
- if ranking.student_id not in current_tag_student_ids:
- continue
- else: # historical mode
- if not self._tags_match(ranking.student_tags, student_tags):
- continue
-
- if ranking.history:
- for entry in ranking.history:
- result.append([
- str(entry[0]),
- str(entry[1]),
- int(entry[2]),
- entry[3]
- ])
-
- self.set_header("Content-Type", "application/json")
- self.write(json.dumps(result))
-
-
-class TrainingProgramCombinedRankingDetailHandler(
- TrainingProgramFilterMixin, BaseHandler
-):
- """Show detailed score/rank progress for a student across archived training days."""
-
- @require_permission(BaseHandler.AUTHENTICATED)
- def get(self, training_program_id: str, student_id: str):
- training_program = self.safe_get_item(TrainingProgram, training_program_id)
- student = self.safe_get_item(Student, student_id)
-
- (
- start_date,
- end_date,
- training_day_types,
- student_tags,
- student_tags_mode,
- archived_training_days,
- current_tag_student_ids,
- ) = self._get_filtered_context(training_program)
-
- # For historical mode, we need to track which students are active per training day
- # to compute the correct user_count for relative ranks
- active_students_per_td: dict[int, set[int]] = {}
- if student_tags and student_tags_mode == "historical":
- for td in archived_training_days:
- active_students_per_td[td.id] = set()
- for ranking in td.archived_student_rankings:
- if self._tags_match(ranking.student_tags, student_tags):
- active_students_per_td[td.id].add(ranking.student_id)
-
- # Build users_data for filtered students only
- users_data = {}
- filtered_student_ids: set[int] = set()
- for s in training_program.students:
- if s.participation and s.participation.user:
- # Apply student tag filter for current mode
- if student_tags and student_tags_mode == "current":
- if s.id not in current_tag_student_ids:
- continue
- # For historical mode, include student if they appear in any training day
- elif student_tags and student_tags_mode == "historical":
- if not any(s.id in active_students_per_td.get(td.id, set())
- for td in archived_training_days):
- continue
- filtered_student_ids.add(s.id)
- users_data[str(s.participation.user_id)] = {
- "f_name": s.participation.user.first_name or "",
- "l_name": s.participation.user.last_name or "",
- }
-
- user_count = len(users_data)
-
- contests_data: dict[str, dict] = {}
- tasks_data: dict[str, dict] = {}
- submissions_data: dict[str, list] = {}
- total_max_score = 0.0
-
- # Find the student's ranking records to get their submissions
- student_rankings: dict[int, ArchivedStudentRanking] = {}
- for td in archived_training_days:
- for ranking in td.archived_student_rankings:
- if ranking.student_id == student.id:
- student_rankings[td.id] = ranking
- break
-
- for td in archived_training_days:
- contest_key = f"td_{td.id}"
- task_ids_in_contest: set[int] = set()
-
- # Collect all visible task IDs from filtered students' task_scores keys
- for ranking in td.archived_student_rankings:
- # Apply student tag filter
- if student_tags:
- if student_tags_mode == "current":
- if ranking.student_id not in current_tag_student_ids:
- continue
- else: # historical mode
- if not self._tags_match(ranking.student_tags, student_tags):
- continue
- if ranking.task_scores:
- task_ids_in_contest.update(int(k) for k in ranking.task_scores.keys())
-
- # Get archived_tasks_data from training day
- archived_tasks_data = td.archived_tasks_data or {}
-
- # Sort task IDs by training_day_num for stable ordering
- def get_training_day_num(task_id: int) -> tuple[int, int]:
- task_key = str(task_id)
- if task_key in archived_tasks_data:
- num = archived_tasks_data[task_key].get("training_day_num")
- return (num if num is not None else 0, task_id)
- return (0, task_id)
-
- sorted_task_ids = sorted(task_ids_in_contest, key=get_training_day_num)
-
- contest_tasks = []
- contest_max_score = 0.0
- for task_id in sorted_task_ids:
- task_key = str(task_id)
-
- # Use archived_tasks_data if available (preserves original scoring scheme)
- if task_key in archived_tasks_data:
- task_info = archived_tasks_data[task_key]
- max_score = task_info.get("max_score", 100.0)
- extra_headers = task_info.get("extra_headers", [])
- score_precision = task_info.get("score_precision", 2)
- task_name = task_info.get("name", "")
- task_short_name = task_info.get("short_name", "")
- else:
- # Fallback to live task data
- task = self.sql_session.query(Task).get(task_id)
- if not task:
- continue
- max_score = 100.0
- extra_headers = []
- score_precision = task.score_precision
- task_name = task.title
- task_short_name = task.name
- if task.active_dataset:
- try:
- score_type = task.active_dataset.score_type_object
- max_score = score_type.max_score
- extra_headers = score_type.ranking_headers
- except (KeyError, TypeError, AttributeError):
- pass
-
- tasks_data[task_key] = {
- "key": task_key,
- "name": task_name,
- "short_name": task_short_name,
- "contest": contest_key,
- "max_score": max_score,
- "score_precision": score_precision,
- "extra_headers": extra_headers,
- }
- contest_tasks.append(tasks_data[task_key])
- contest_max_score += max_score
-
- # Get submissions for this task from the student's ranking
- student_ranking = student_rankings.get(td.id)
- if student_ranking and student_ranking.submissions:
- task_submissions = student_ranking.submissions.get(task_key, [])
- submissions_data[task_key] = task_submissions
-
- td_name = td.description or td.name or "Training Day"
- if td.start_time:
- td_name += f" ({td.start_time.strftime('%Y-%m-%d')})"
-
- # Calculate contest duration
- # History times are stored as offsets from contest start, so we need
- # begin=0 and end=duration for the graph scale to be correct
- if td.duration:
- end_time = int(td.duration.total_seconds())
- else:
- end_time = 18000 # Default 5 hours
-
- contests_data[contest_key] = {
- "key": contest_key,
- "name": td_name,
- "begin": 0,
- "end": end_time,
- "max_score": contest_max_score,
- "score_precision": 2,
- "tasks": contest_tasks,
- }
- total_max_score += contest_max_score
-
- contest_list = [contests_data[f"td_{td.id}"] for td in archived_training_days
- if f"td_{td.id}" in contests_data]
-
- history_url = self.url(
- "training_program", training_program_id, "combined_ranking", "history"
- )
- if start_date or end_date or training_day_types or student_tags:
- params = {}
- if start_date:
- params["start_date"] = start_date.isoformat()
- if end_date:
- params["end_date"] = end_date.isoformat()
- if training_day_types:
- params["training_day_types"] = ",".join(training_day_types)
- if student_tags:
- params["student_tags"] = ",".join(student_tags)
- params["student_tags_mode"] = student_tags_mode
- history_url += "?" + urlencode(params)
-
- self.r_params = self.render_params()
- self.r_params["training_program"] = training_program
- self.r_params["student"] = student
- self.r_params["user_id"] = str(student.participation.user_id) if student.participation else "0"
- self.r_params["user_count"] = user_count
- self.r_params["users_data"] = users_data
- self.r_params["tasks_data"] = tasks_data
- self.r_params["submissions_data"] = submissions_data
- self.r_params["contests_data"] = contests_data
- self.r_params["contest_list"] = contest_list
- self.r_params["total_max_score"] = total_max_score
- self.r_params["history_url"] = history_url
- self.r_params["start_date"] = start_date
- self.r_params["end_date"] = end_date
- self.r_params["training_day_types"] = training_day_types
- self.r_params["student_tags"] = student_tags
- self.r_params["student_tags_mode"] = student_tags_mode
- self.r_params["unanswered"] = self.sql_session.query(Question)\
- .join(Participation)\
- .filter(Participation.contest_id == training_program.managing_contest.id)\
- .filter(Question.reply_timestamp.is_(None))\
- .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."""
diff --git a/cms/server/admin/static/aws_style.css b/cms/server/admin/static/aws_style.css
index 67c03b9e26..06b948f9cf 100644
--- a/cms/server/admin/static/aws_style.css
+++ b/cms/server/admin/static/aws_style.css
@@ -3,7 +3,7 @@
}
:root {
- --ranking-header-top: 90px;
+ --ranking-header-top: 60px;
}
.searchable-select {
diff --git a/cms/server/admin/templates/add_training_day.html b/cms/server/admin/templates/add_training_day.html
index 39433f1b6a..18ea8aacaa 100644
--- a/cms/server/admin/templates/add_training_day.html
+++ b/cms/server/admin/templates/add_training_day.html
@@ -21,18 +21,21 @@
Add Training Day to
|
- Start time (UTC)
+ Start time ({{ timezone_name }})
|
|
When the training day starts (optional, defaults to earliest group time) |
|
-
- End time (UTC)
+
+ Duration
|
- |
- When the training day ends (optional, defaults to latest group time) |
+
+ hours
+ minutes
+ |
+ Duration of the training day (optional) |
@@ -43,8 +46,8 @@ Main Groups Configuration
| Tag Name |
- Start Time (UTC) |
- End Time (UTC) |
+ Start Time ({{ timezone_name }}) |
+ Duration |
Alphabetical Task Order |
Actions |
@@ -84,12 +87,25 @@ Main Groups Configuration
startCell.appendChild(startInput);
row.appendChild(startCell);
- var endCell = document.createElement('td');
- var endInput = document.createElement('input');
- endInput.type = 'datetime-local';
- endInput.name = 'group_end_time[]';
- endCell.appendChild(endInput);
- row.appendChild(endCell);
+ var durationCell = document.createElement('td');
+ var hoursInput = document.createElement('input');
+ hoursInput.type = 'number';
+ hoursInput.name = 'group_duration_hours[]';
+ hoursInput.min = '0';
+ hoursInput.style.width = '60px';
+ hoursInput.placeholder = '0';
+ durationCell.appendChild(hoursInput);
+ durationCell.appendChild(document.createTextNode(' h '));
+ var minutesInput = document.createElement('input');
+ minutesInput.type = 'number';
+ minutesInput.name = 'group_duration_minutes[]';
+ minutesInput.min = '0';
+ minutesInput.max = '59';
+ minutesInput.style.width = '60px';
+ minutesInput.placeholder = '0';
+ durationCell.appendChild(minutesInput);
+ durationCell.appendChild(document.createTextNode(' m'));
+ row.appendChild(durationCell);
var alphaCell = document.createElement('td');
alphaCell.style.textAlign = 'center';
@@ -142,11 +158,14 @@ Main Groups Configuration
document.getElementById('add-group-btn').addEventListener('click', addGroupRow);
-CMS.AWSUtils.initDateTimeValidation(
- 'form[name="add_training_day"]',
- 'input[name="start"]',
- 'input[name="stop"]'
-);
+// Re-index checkbox values before form submission to match visual order
+document.querySelector('form[name="add_training_day"]').addEventListener('submit', function() {
+ var checkboxes = document.querySelectorAll('#groups-tbody input[name="group_alphabetical[]"]');
+ checkboxes.forEach(function(checkbox, index) {
+ checkbox.value = index;
+ });
+});
+
diff --git a/cms/server/admin/templates/archive_training_day.html b/cms/server/admin/templates/archive_training_day.html
index eea7ee9ea8..574bd2c8f4 100644
--- a/cms/server/admin/templates/archive_training_day.html
+++ b/cms/server/admin/templates/archive_training_day.html
@@ -56,6 +56,23 @@
Select Class IPs
{% endif %}
Confirm Archive
+
+ {% if users_not_finished %}
+
+
Warning: Some students can still start or are currently in the training!
+
The following {{ users_not_finished|length }} student(s) have not finished or missed the training yet:
+
+ {% for item in users_not_finished %}
+ -
+ {{ item.participation.user.first_name }} {{ item.participation.user.last_name }}
+ ({{ item.participation.user.username }}) - {{ item.status_label }}
+
+ {% endfor %}
+
+
Archiving now will mark these students as "missed" and they will not be able to participate.
+
+ {% endif %}
+