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 %} +

All Participations

+{% if contest.training_day and all_finished_or_missed and participation_statuses|length > 0 %} +
+{% endif %} + diff --git a/cms/server/admin/templates/fragments/training_day_groups.html b/cms/server/admin/templates/fragments/training_day_groups.html index 8b3f1719c7..533da507ed 100644 --- a/cms/server/admin/templates/fragments/training_day_groups.html +++ b/cms/server/admin/templates/fragments/training_day_groups.html @@ -28,12 +28,17 @@

Main Groups Configuration

} }); -function removeMainGroup(url, xsrfToken) { +function removeMainGroup(url) { if (!confirm('Remove this main group?')) { return; } + var xsrfInput = document.querySelector('input[name="_xsrf"]'); + if (!xsrfInput) { + alert('Missing XSRF token'); + return; + } var formData = new FormData(); - formData.append('_xsrf', xsrfToken); + formData.append('_xsrf', xsrfInput.value); fetch(url, { method: 'POST', body: formData @@ -56,8 +61,8 @@

Main Groups Configuration

- - + + @@ -70,16 +75,20 @@

Main Groups Configuration

{% endfor %} @@ -92,8 +101,8 @@

Main Groups Configuration

- - + + @@ -119,17 +128,20 @@

Add Main Group

+ - - + {% if show_training_day %}
Tag NameStart Time (UTC)End Time (UTC)Start Time ({{ timezone_name }})Duration Alphabetical Task Order Actions
- + - + {% set duration_seconds = (group.end_time - group.start_time).total_seconds() if group.start_time and group.end_time else 0 %} + {% set duration_hours = (duration_seconds // 3600)|int %} + {% set duration_minutes = ((duration_seconds % 3600) // 60)|int %} + h + m - +
Tag NameStart Time (UTC)End Time (UTC)Start Time ({{ timezone_name }})Duration Alphabetical Task Order Actions
- - Start Time (UTC) + + Start Time ({{ timezone_name }})
- - End Time (UTC) + + Duration + + hours + minutes
diff --git a/cms/server/admin/templates/macro/submission.html b/cms/server/admin/templates/macro/submission.html index a01fb6552b..b59ea8d383 100644 --- a/cms/server/admin/templates/macro/submission.html +++ b/cms/server/admin/templates/macro/submission.html @@ -96,7 +96,14 @@ {{ s.timestamp|format_datetime_with_timezone(timezone) }}{{ s.participation.user.username }} + {# For training day submissions, link to the training day participation #} + {% if s.training_day is not none and s.training_day.contest is not none %} + {{ s.participation.user.username }} + {% else %} + {{ s.participation.user.username }} + {% endif %} + {{ s.task.name }} diff --git a/cms/server/admin/templates/participation.html b/cms/server/admin/templates/participation.html index cecb2474c7..92923b354c 100644 --- a/cms/server/admin/templates/participation.html +++ b/cms/server/admin/templates/participation.html @@ -8,7 +8,14 @@ {% block core %}

- Participation of {{ selected_user.username }} in {{ contest.name }} + Participation of + {# For training day participations, link to the student page in the training program #} + {% if contest.training_day is not none and contest.training_day.training_program is not none %} + {{ selected_user.username }} + {% else %} + {{ selected_user.username }} + {% endif %} + in {{ contest.name }}

Submissions

diff --git a/cms/server/admin/templates/ranking.html b/cms/server/admin/templates/ranking.html index 745952aa7f..73a2e8bb7c 100644 --- a/cms/server/admin/templates/ranking.html +++ b/cms/server/admin/templates/ranking.html @@ -17,20 +17,154 @@ {% block core %}

Ranking

+ +{% if main_groups_data %} +{# Training day with main groups - show separate tables per group #} + + +{# Build task index lookup once for all groups #} +{% set all_tasks = contest.get_tasks() | list %} +{% set task_index = {} %} +{% for task in all_tasks %} + {% set _ = task_index.update({task.id: loop.index0}) %} +{% endfor %} + +{% for group_data in main_groups_data %} +{% set group_name = group_data.name %} +{% set group_participations = group_data.participations %} +{% set group_tasks = group_data.tasks %} + +
+
+

{{ group_name }}

+ +
+ + + + + + + + {% if show_teams %} + + {% endif %} + {% for task in group_tasks %} + + {% endfor %} + + + + + {% for p in group_participations %} + + + + + {% if show_teams %} + {% if p.team %} + + {% else %} + + {% endif %} + {% endif %} + {% set total_score = namespace(value=0.0) %} + {% set partial = namespace(value=false) %} + {% for task in group_tasks %} + {% set idx = task_index[task.id] %} + {% set status = p.task_statuses[idx] %} + + {% set total_score.value = total_score.value + status.score %} + {% if status.partial %}{% set partial.value = true %}{% endif %} + {% endfor %} + + + {% else %} + + + + {% endfor %} + +
UsernameUserTagsTeam{{ task.name }}Global
{{ p.user.username }} [history]{{ "%s %s"|format(p.user.first_name, p.user.last_name) }} + {% for tag in student_tags_by_participation.get(p.id, []) %} + {{ tag }} + {% endfor %} + {{ p.team.name }}{{ task_html(status) }}{{ ("%%.%df"|format(contest.score_precision))|format(total_score.value) }}
No students in this group.
+
+{% endfor %} + + + +{% else %} +{# Regular contest or training day without groups - show single table #} Download as csv, text. - +
+ {% if student_tags_by_participation %} + + {% endif %} {% if show_teams %} {% endif %} @@ -49,6 +183,13 @@

Ranking

+ {% if student_tags_by_participation %} + + {% endif %} {% if show_teams %} {% if p.team %} @@ -57,7 +198,7 @@

Ranking

{% endif %} {% endif %} {% for status in p.task_statuses %} - + {% endfor %} {% set total_score, partial = p.total_score %} @@ -69,7 +210,7 @@

Ranking

+{% endif %} {% endblock core %} diff --git a/cms/server/admin/templates/ranking.txt b/cms/server/admin/templates/ranking.txt index b9f83078a0..6cac397398 100644 --- a/cms/server/admin/templates/ranking.txt +++ b/cms/server/admin/templates/ranking.txt @@ -1,5 +1,7 @@ {% macro task_indicator(status) -%} - {%- if not status.has_submissions -%} + {%- if not status.can_access -%} + N + {%- elif not status.has_submissions -%} {%- if not status.has_opened -%} X {%- else -%} @@ -12,10 +14,10 @@ {%- endif -%} {%- endmacro %} {% block core %} -{{ "%20s"|format("Username") }} {{ "%30s"|format("User") }}{% if show_teams %}{{ "%30s"|format("Team") }}{% endif %} {% for task in contest.tasks %}{{ "%14s"|format(task.name) }} {% endfor %}{{ "%8s"|format("Global") }} +{{ "%20s"|format("Username") }} {{ "%30s"|format("User") }}{% if student_tags_by_participation %}{{ "%30s"|format("Tags") }}{% endif %}{% if show_teams %}{{ "%30s"|format("Team") }}{% endif %} {% for task in contest.get_tasks() %}{{ "%14s"|format(task.name) }} {% endfor %}{{ "%8s"|format("Global") }} {% for p in contest.participations|sort(attribute="total_score")|reverse %} {% if not p.hidden %} -{{ "%20s"|format(p.user.username) }} {{ "%30s"|format("%s %s"|format(p.user.first_name, p.user.last_name)) }}{% if show_teams %}{{ "%30s"|format(p.team.name if p.team else "") }}{% endif %} {% for task in contest.tasks %}{% set status = p.task_statuses[loop.index0] %}{{ "%%13.%dlf"|format(task.score_precision)|format(status.score) }}{% set indicator = task_indicator(status) %}{% if indicator %}{{ indicator }}{% else %} {% endif %} {% endfor %}{% set total_score, partial = p.total_score %}{{ "%%7.%dlf"|format(contest.score_precision)|format(total_score) }}{% if partial %}*{% else %} {% endif %} +{{ "%20s"|format(p.user.username) }} {{ "%30s"|format("%s %s"|format(p.user.first_name, p.user.last_name)) }}{% if student_tags_by_participation %}{{ "%30s"|format(student_tags_by_participation.get(p.id, [])|join(", ")) }}{% endif %}{% if show_teams %}{{ "%30s"|format(p.team.name if p.team else "") }}{% endif %} {% for task in contest.get_tasks() %}{% set status = p.task_statuses[loop.index0] %}{{ "%%13.%dlf"|format(task.score_precision)|format(status.score) }}{% set indicator = task_indicator(status) %}{% if indicator %}{{ indicator }}{% else %} {% endif %} {% endfor %}{% set total_score, partial = p.total_score %}{{ "%%7.%dlf"|format(contest.score_precision)|format(total_score) }}{% if partial %}*{% else %} {% endif %} {% endif %} {% endfor %} diff --git a/cms/server/admin/templates/student.html b/cms/server/admin/templates/student.html index ce7643c343..48528a7349 100644 --- a/cms/server/admin/templates/student.html +++ b/cms/server/admin/templates/student.html @@ -47,7 +47,8 @@

Submissions

url["training_program"][training_program.id]["student"][selected_user.id]["edit"], submissions, submission_page, - submission_pages) }} + submission_pages, + show_training_day=true) }}
@@ -94,10 +95,27 @@

Student information

- + + + + +
Username UserTagsTeam
{{ p.user.username }} [history] {{ "%s %s"|format(p.user.first_name, p.user.last_name) }} + {% for tag in student_tags_by_participation.get(p.id, []) %} + {{ tag }} + {% endfor %} + {{ p.team.name }}{{ task_html(status) }}{{ task_html(status) }}{{ total_score }}
- + Hidden participation + + + (will apply to new training days) + +
+ + Apply to existing training days + + + + (update hidden status in all existing training days) + +
diff --git a/cms/server/admin/templates/training_program_combined_ranking.html b/cms/server/admin/templates/training_program_combined_ranking.html index 8c2b7e6e54..c1efaf6a5b 100644 --- a/cms/server/admin/templates/training_program_combined_ranking.html +++ b/cms/server/admin/templates/training_program_combined_ranking.html @@ -710,6 +710,28 @@

Combined Ranking: {% else %} diff --git a/cms/server/admin/templates/training_program_combined_ranking_detail.html b/cms/server/admin/templates/training_program_combined_ranking_detail.html index de746a43b6..f7b3221e3b 100644 --- a/cms/server/admin/templates/training_program_combined_ranking_detail.html +++ b/cms/server/admin/templates/training_program_combined_ranking_detail.html @@ -30,7 +30,15 @@ line-height: 1.5em; } -#UserDetail_face, +#UserDetail_face { + display: block; + max-width: 160px; + max-height: 240px; + border: 1px solid #ccc; + border-radius: 4px; + margin: 10px 0; +} + #UserDetail_flag { display: none; } @@ -245,7 +253,7 @@
{% if student.participation %}{{ student.participation.user.username }}{% endif %}
- Face + {% if student.participation and student.participation.user.picture %}Profile picture of {{ student.participation.user.first_name }} {{ student.participation.user.last_name }}{% else %}{% endif %}
Flag diff --git a/cms/server/admin/templates/user.html b/cms/server/admin/templates/user.html index edec231ed1..bb244eacf6 100644 --- a/cms/server/admin/templates/user.html +++ b/cms/server/admin/templates/user.html @@ -12,6 +12,7 @@

Training Program Participat + @@ -20,6 +21,7 @@

Training Program Participat

{% for p in training_program_participations %} +
Participation Training Program Description Actions
{{ p.user.username }} {{ p.contest.training_program.name }} {{ p.contest.training_program.description }}