diff --git a/cms/server/admin/handlers/archive.py b/cms/server/admin/handlers/archive.py index 6f58f033c8..04b32cc50b 100644 --- a/cms/server/admin/handlers/archive.py +++ b/cms/server/admin/handlers/archive.py @@ -15,29 +15,20 @@ # 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. +"""Admin handler for Training Day Archive. -These handlers manage the archiving of training days and display of -attendance and combined ranking data across archived training days. +This module contains the handler for archiving training days. +Analytics handlers (attendance, ranking) are in training_analytics.py. +Excel export handlers are in excel.py. """ -import io -import json -import re -from datetime import datetime as dt, timedelta -from typing import Any -from urllib.parse import urlencode +from datetime import timedelta import tornado.web -from openpyxl import Workbook -from openpyxl.styles import Font, Alignment, PatternFill, Border, Side -from openpyxl.utils import get_column_letter -from openpyxl.worksheet.worksheet import Worksheet from cms.db import ( Contest, TrainingProgram, - Participation, Submission, Student, StudentTask, @@ -49,14 +40,8 @@ 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, - get_student_for_user_in_program, -) +from cms.server.util import can_access_task, check_training_day_eligibility +from cms.server.admin.handlers.utils import build_user_to_student_map from cmscommon.datetime import make_datetime from .base import BaseHandler, require_permission @@ -65,309 +50,46 @@ get_participation_main_group, ) - -EXCEL_ZEBRA_COLORS = [ - ("4472C4", "D9E2F3"), - ("70AD47", "E2EFDA"), - ("ED7D31", "FCE4D6"), - ("7030A0", "E4DFEC"), - ("00B0F0", "DAEEF3"), - ("FFC000", "FFF2CC"), -] - -EXCEL_HEADER_FONT = Font(bold=True) -EXCEL_HEADER_FONT_WHITE = Font(bold=True, color="FFFFFF") -EXCEL_THIN_BORDER = Border( - left=Side(style="thin"), - right=Side(style="thin"), - top=Side(style="thin"), - bottom=Side(style="thin"), +from .training_analytics import ( + build_attendance_data, + build_ranking_data, + TrainingProgramFilterMixin, + TrainingProgramAttendanceHandler, + TrainingProgramCombinedRankingHandler, + TrainingProgramCombinedRankingHistoryHandler, + TrainingProgramCombinedRankingDetailHandler, + UpdateAttendanceHandler, ) -EXCEL_DEFAULT_HEADER_FILL = PatternFill( - start_color="4472C4", end_color="4472C4", fill_type="solid" +from .excel import ( + ExportAttendanceHandler, + ExportCombinedRankingHandler, + excel_build_filename, + excel_setup_student_tags_headers, + excel_build_training_day_title, + excel_get_zebra_fills, + excel_write_student_row, + excel_write_training_day_header, ) - -def _excel_safe(value: str) -> str: - if value and value[0] in ("=", "+", "-", "@"): - return "'" + value - return value - - -def excel_build_filename( - program_name: str, - export_type: str, - start_date: Any, - end_date: Any, - training_day_types: list[str] | None, - student_tags: list[str] | None, -) -> str: - """Build a filename for Excel export based on filters.""" - program_slug = re.sub(r"[^A-Za-z0-9_-]+", "_", program_name) - filename_parts = [program_slug, export_type] - - if start_date: - filename_parts.append(f"from_{start_date.strftime('%Y%m%d')}") - if end_date: - filename_parts.append(f"to_{end_date.strftime('%Y%m%d')}") - if training_day_types: - types_slug = re.sub( - r"[^A-Za-z0-9_-]+", "_", "_".join(training_day_types) - ) - filename_parts.append(f"types_{types_slug}") - if student_tags: - tags_slug = re.sub(r"[^A-Za-z0-9_-]+", "_", "_".join(student_tags)) - filename_parts.append(f"tags_{tags_slug}") - - return "_".join(filename_parts) + ".xlsx" - - -def excel_setup_student_tags_headers( - ws: Worksheet, - default_fill: PatternFill, -) -> None: - """Set up Student and Tags column headers (merged across rows 1-2).""" - ws.cell(row=1, column=1, value="Student") - ws.cell(row=1, column=1).font = EXCEL_HEADER_FONT_WHITE - ws.cell(row=1, column=1).fill = default_fill - ws.cell(row=1, column=1).border = EXCEL_THIN_BORDER - ws.cell(row=1, column=1).alignment = Alignment( - horizontal="center", vertical="center" - ) - ws.merge_cells(start_row=1, start_column=1, end_row=2, end_column=1) - - ws.cell(row=1, column=2, value="Tags") - ws.cell(row=1, column=2).font = EXCEL_HEADER_FONT_WHITE - ws.cell(row=1, column=2).fill = default_fill - ws.cell(row=1, column=2).border = EXCEL_THIN_BORDER - ws.cell(row=1, column=2).alignment = Alignment( - horizontal="center", vertical="center" - ) - ws.merge_cells(start_row=1, start_column=2, end_row=2, end_column=2) - - -def excel_build_training_day_title(td: Any) -> str: - """Build a title string for a training day including types.""" - title = td.description or td.name or "Session" - if td.start_time: - title += f" ({td.start_time.strftime('%b %d')})" - if td.training_day_types: - title += f" [{'; '.join(td.training_day_types)}]" - return title - - -def excel_get_zebra_fills(color_idx: int) -> tuple[PatternFill, PatternFill]: - """Get header and subheader fills for zebra coloring.""" - header_color, subheader_color = EXCEL_ZEBRA_COLORS[ - color_idx % len(EXCEL_ZEBRA_COLORS) - ] - header_fill = PatternFill( - start_color=header_color, end_color=header_color, fill_type="solid" - ) - subheader_fill = PatternFill( - start_color=subheader_color, end_color=subheader_color, fill_type="solid" - ) - return header_fill, subheader_fill - - -def excel_write_student_row( - ws: Worksheet, - row: int, - student: Any, -) -> None: - """Write student name and tags to columns 1 and 2.""" - if student.participation: - user = student.participation.user - student_name = f"{user.first_name} {user.last_name} ({user.username})" - else: - student_name = "(Unknown)" - - ws.cell(row=row, column=1, value=_excel_safe(student_name)) - ws.cell(row=row, column=1).border = EXCEL_THIN_BORDER - - tags_str = "" - if student.student_tags: - tags_str = "; ".join(student.student_tags) - ws.cell(row=row, column=2, value=_excel_safe(tags_str)) - ws.cell(row=row, column=2).border = EXCEL_THIN_BORDER - - -def excel_write_training_day_header( - ws: Worksheet, - col: int, - td: Any, - td_idx: int, - num_columns: int, -) -> None: - """Write a training day header row with zebra coloring and merge cells. - - ws: the worksheet to write to. - col: the starting column for this training day header. - td: the training day object. - td_idx: the index of the training day (for zebra coloring). - num_columns: the number of columns to merge for this training day. - """ - title = excel_build_training_day_title(td) - header_fill, _ = excel_get_zebra_fills(td_idx) - - ws.cell(row=1, column=col, value=title) - ws.cell(row=1, column=col).font = EXCEL_HEADER_FONT_WHITE - ws.cell(row=1, column=col).fill = header_fill - ws.cell(row=1, column=col).border = EXCEL_THIN_BORDER - ws.cell(row=1, column=col).alignment = Alignment( - horizontal="center", vertical="center" - ) - ws.merge_cells( - start_row=1, start_column=col, - end_row=1, end_column=col + num_columns - 1 - ) - - -def build_attendance_data( - archived_training_days: list[Any], - student_tags: list[str], - current_tag_student_ids: set[int], -) -> tuple[dict[int, dict[int, ArchivedAttendance]], dict[int, Student], list[Student]]: - """Build attendance data structure from archived training days. - - archived_training_days: list of archived TrainingDay objects. - student_tags: list of student tags to filter by (empty = no filter). - current_tag_student_ids: set of student IDs that have the filter tags. - - return: tuple of (attendance_data, all_students, sorted_students) where: - - attendance_data: {student_id: {training_day_id: ArchivedAttendance}} - - all_students: {student_id: Student} - - sorted_students: list of Student objects sorted by username - """ - 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 - if student_tags and student_id not in current_tag_student_ids: - continue - 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 - - sorted_students = sorted( - all_students.values(), - key=lambda s: s.participation.user.username if s.participation else "" - ) - - return attendance_data, all_students, sorted_students - - -def build_ranking_data( - sql_session: Any, - archived_training_days: list[Any], - student_tags: list[str], - student_tags_mode: str, - current_tag_student_ids: set[int], - tags_match_fn: Any, -) -> tuple[ - dict[int, dict[int, ArchivedStudentRanking]], - dict[int, Student], - dict[int, list[dict]], - list[Any], - dict[int, set[int]], -]: - """Build ranking data structure from archived training days. - - sql_session: the database session. - archived_training_days: list of archived TrainingDay objects. - student_tags: list of student tags to filter by (empty = no filter). - student_tags_mode: "current" or "historical" for tag filtering. - current_tag_student_ids: set of student IDs that have the filter tags. - tags_match_fn: function to check if item_tags contains all filter_tags. - - return: tuple of (ranking_data, all_students, training_day_tasks, - filtered_training_days, active_students_per_td) where: - - ranking_data: {student_id: {training_day_id: ArchivedStudentRanking}} - - all_students: {student_id: Student} - - training_day_tasks: {training_day_id: [task_info_dict, ...]} - - filtered_training_days: list of TrainingDay objects with data - - active_students_per_td: {training_day_id: set of active student IDs} - """ - ranking_data: dict[int, dict[int, ArchivedStudentRanking]] = {} - all_students: dict[int, Student] = {} - training_day_tasks: dict[int, list[dict]] = {} - filtered_training_days: list[Any] = [] - active_students_per_td: dict[int, set[int]] = {} - - for td in archived_training_days: - active_students_per_td[td.id] = set() - visible_tasks_by_id: dict[int, dict] = {} - - for ranking in td.archived_student_rankings: - student_id = ranking.student_id - student = ranking.student - - if student.participation and student.participation.hidden: - continue - - if student_tags: - if student_tags_mode == "current": - if student_id not in current_tag_student_ids: - continue - else: - if not tags_match_fn(ranking.student_tags, student_tags): - continue - - 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 - - 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: - 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: - task = 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, - } - - if not active_students_per_td[td.id]: - continue - - filtered_training_days.append(td) - 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 - - return ( - ranking_data, - all_students, - training_day_tasks, - filtered_training_days, - active_students_per_td, - ) +__all__ = [ + "ArchiveTrainingDayHandler", + "ExportAttendanceHandler", + "ExportCombinedRankingHandler", + "TrainingProgramAttendanceHandler", + "TrainingProgramCombinedRankingDetailHandler", + "TrainingProgramCombinedRankingHandler", + "TrainingProgramCombinedRankingHistoryHandler", + "TrainingProgramFilterMixin", + "UpdateAttendanceHandler", + "build_attendance_data", + "build_ranking_data", + "excel_build_filename", + "excel_build_training_day_title", + "excel_get_zebra_fills", + "excel_setup_student_tags_headers", + "excel_write_student_row", + "excel_write_training_day_header", +] class ArchiveTrainingDayHandler(BaseHandler): @@ -537,13 +259,14 @@ def _archive_attendance_data( """Extract and store attendance data for all students.""" training_program = training_day.training_program + # Build user_id -> Student map for O(1) lookups instead of repeated queries + user_to_student = build_user_to_student_map(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 = get_student_for_user_in_program( - self.sql_session, training_program, participation.user_id - ) + student = user_to_student.get(participation.user_id) if student is None: continue @@ -665,13 +388,14 @@ def _archive_ranking_data( } training_day.archived_tasks_data = archived_tasks_data + # Build user_id -> Student map for O(1) lookups instead of repeated queries + user_to_student = build_user_to_student_map(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 = get_student_for_user_in_program( - self.sql_session, training_program, participation.user_id - ) + student = user_to_student.get(participation.user_id) if student is None: continue @@ -828,875 +552,3 @@ def _archive_ranking_data( 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) - - attendance_data, _, sorted_students = build_attendance_data( - archived_training_days, student_tags, current_tag_student_ids - ) - - self.render_params_for_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) - - # Build training days with pending delays from notification data - training_days_with_pending_delays: list[dict] = [] - td_notifications = self.r_params.get("training_day_notifications", {}) - for td in training_program.training_days: - if td.contest is None: - continue - td_notif = td_notifications.get(td.id, {}) - pending_count = td_notif.get("pending_delay_requests", 0) - if pending_count > 0: - training_days_with_pending_delays.append({ - "contest_id": td.contest_id, - "name": td.contest.name, - "pending_count": pending_count, - }) - self.r_params["training_days_with_pending_delays"] = \ - training_days_with_pending_delays - - 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, - all_students, - training_day_tasks, - filtered_training_days, - active_students_per_td, - ) = build_ranking_data( - self.sql_session, - archived_training_days, - student_tags, - student_tags_mode, - current_tag_student_ids, - self._tags_match, - ) - - # Build attendance lookup for all training days - attendance_data: dict[int, dict[int, ArchivedAttendance]] = {} - for td in archived_training_days: - 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 - - sorted_students = sorted( - all_students.values(), - key=lambda s: s.participation.user.username if s.participation else "" - ) - - self.render_params_for_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.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.render_params_for_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.render("training_program_combined_ranking_detail.html", **self.r_params) - - -class UpdateAttendanceHandler(BaseHandler): - """Update attendance record (justified status, comment, and recorded).""" - - @require_permission(BaseHandler.PERMISSION_ALL) - def post(self, training_program_id: str, attendance_id: str): - """Update an attendance record's justified status, comment, and/or recorded.""" - training_program = self.safe_get_item(TrainingProgram, training_program_id) - attendance = self.safe_get_item(ArchivedAttendance, attendance_id) - - # Verify the attendance belongs to this training program - if attendance.training_day.training_program_id != training_program.id: - self.set_status(403) - self.write({"success": False, "error": "Attendance not in this program"}) - return - - try: - data = json.loads(self.request.body) - except json.JSONDecodeError: - self.set_status(400) - self.write({"success": False, "error": "Invalid JSON"}) - return - - # Update justified status if provided - if "justified" in data: - justified = data["justified"] - if not isinstance(justified, bool): - self.set_status(400) - self.write({"success": False, "error": "Invalid justified flag"}) - return - if justified and attendance.status != "missed": - self.set_status(400) - self.write( - { - "success": False, - "error": "Only missed attendances can be justified", - } - ) - return - attendance.justified = justified - - # Update comment if provided - if "comment" in data: - comment = data["comment"] - if comment is not None: - comment = str(comment).strip() - if not comment: - comment = None - attendance.comment = comment - - # Update recorded status if provided - if "recorded" in data: - recorded = data["recorded"] - if not isinstance(recorded, bool): - self.set_status(400) - self.write({"success": False, "error": "Invalid recorded flag"}) - return - if recorded and attendance.status == "missed": - self.set_status(400) - self.write( - { - "success": False, - "error": "Only non-missed attendances can be marked as recorded", - } - ) - return - attendance.recorded = recorded - - if self.try_commit(): - # Return JSON success, let JavaScript handle page reload - self.write({"success": True}) - else: - self.set_status(500) - self.write({"success": False, "error": "Failed to save changes"}) - - -class ExportAttendanceHandler(TrainingProgramFilterMixin, BaseHandler): - """Export attendance data to Excel format.""" - - @require_permission(BaseHandler.AUTHENTICATED) - def get(self, training_program_id: str): - """Export filtered attendance data to Excel.""" - 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) - - if not archived_training_days: - self.redirect(self.url( - "training_program", training_program_id, "attendance" - )) - return - - attendance_data, _, sorted_students = build_attendance_data( - archived_training_days, student_tags, current_tag_student_ids - ) - - wb = Workbook() - ws = wb.active - ws.title = "Attendance" - - subcolumns = ["Status", "Location", "Recorded", "Delay Reasons", "Comments"] - num_subcolumns = len(subcolumns) - - excel_setup_student_tags_headers(ws, EXCEL_DEFAULT_HEADER_FILL) - - col = 3 - for td_idx, td in enumerate(archived_training_days): - excel_write_training_day_header(ws, col, td, td_idx, num_subcolumns) - _, subheader_fill = excel_get_zebra_fills(td_idx) - - for i, subcol_name in enumerate(subcolumns): - cell = ws.cell(row=2, column=col + i, value=subcol_name) - cell.font = EXCEL_HEADER_FONT - cell.fill = subheader_fill - cell.border = EXCEL_THIN_BORDER - cell.alignment = Alignment(horizontal="center") - - col += num_subcolumns - - row = 3 - for student in sorted_students: - excel_write_student_row(ws, row, student) - - col = 3 - for td in archived_training_days: - att = attendance_data.get(student.id, {}).get(td.id) - - if att: - if att.status == "missed": - if att.justified: - status = "Justified Absent" - else: - status = "Missed" - elif att.delay_time: - delay_minutes = att.delay_time.total_seconds() / 60 - if delay_minutes < 60: - status = f"Delayed ({delay_minutes:.0f}m)" - else: - status = f"Delayed ({delay_minutes / 60:.1f}h)" - else: - status = "On Time" - - location = "" - if att.status != "missed" and att.location: - location_map = { - "class": "Class", - "home": "Home", - "both": "Both", - } - location = location_map.get(att.location, att.location) - - recorded = "" - if att.status != "missed": - recorded = "Yes" if att.recorded else "No" - - delay_reasons = att.delay_reasons or "" - comment = att.comment or "" - else: - status = "" - location = "" - recorded = "" - delay_reasons = "" - comment = "" - - values = [status, location, recorded, delay_reasons, comment] - for i, value in enumerate(values): - cell = ws.cell(row=row, column=col + i, value=_excel_safe(value)) - cell.border = EXCEL_THIN_BORDER - - col += num_subcolumns - - row += 1 - - ws.column_dimensions["A"].width = 30 - ws.column_dimensions["B"].width = 20 - for col_idx in range(3, 3 + len(archived_training_days) * num_subcolumns): - col_letter = get_column_letter(col_idx) - ws.column_dimensions[col_letter].width = 15 - - output = io.BytesIO() - wb.save(output) - output.seek(0) - - filename = excel_build_filename( - training_program.name, "attendance", - start_date, end_date, training_day_types, student_tags - ) - - self.set_header( - "Content-Type", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - ) - self.set_header( - "Content-Disposition", - f'attachment; filename="{filename}"' - ) - self.write(output.getvalue()) - self.finish() - - -class ExportCombinedRankingHandler(TrainingProgramFilterMixin, BaseHandler): - """Export combined ranking data to Excel format.""" - - @require_permission(BaseHandler.AUTHENTICATED) - def get(self, training_program_id: str): - """Export filtered combined ranking data to Excel.""" - 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) - - if not archived_training_days: - self.redirect(self.url( - "training_program", training_program_id, "combined_ranking" - )) - return - - ( - ranking_data, - all_students, - training_day_tasks, - filtered_training_days, - _, - ) = build_ranking_data( - self.sql_session, - archived_training_days, - student_tags, - student_tags_mode, - current_tag_student_ids, - self._tags_match, - ) - - if not filtered_training_days: - self.redirect(self.url( - "training_program", training_program_id, "combined_ranking" - )) - return - - sorted_students = sorted( - all_students.values(), - key=lambda s: s.participation.user.username if s.participation else "" - ) - - wb = Workbook() - ws = wb.active - ws.title = "Combined Ranking" - - excel_setup_student_tags_headers(ws, EXCEL_DEFAULT_HEADER_FILL) - - col = 3 - for td_idx, td in enumerate(filtered_training_days): - tasks = training_day_tasks.get(td.id, []) - num_task_cols = len(tasks) + 1 - - excel_write_training_day_header(ws, col, td, td_idx, num_task_cols) - _, subheader_fill = excel_get_zebra_fills(td_idx) - - for i, task in enumerate(tasks): - cell = ws.cell(row=2, column=col + i, value=task["name"]) - cell.font = EXCEL_HEADER_FONT - cell.fill = subheader_fill - cell.border = EXCEL_THIN_BORDER - cell.alignment = Alignment(horizontal="center") - - total_cell = ws.cell(row=2, column=col + len(tasks), value="Total") - total_cell.font = EXCEL_HEADER_FONT - total_cell.fill = subheader_fill - total_cell.border = EXCEL_THIN_BORDER - total_cell.alignment = Alignment(horizontal="center") - - col += num_task_cols - - global_header_fill = PatternFill( - start_color="808080", end_color="808080", fill_type="solid" - ) - ws.cell(row=1, column=col, value="Global") - ws.cell(row=1, column=col).font = EXCEL_HEADER_FONT_WHITE - ws.cell(row=1, column=col).fill = global_header_fill - ws.cell(row=1, column=col).border = EXCEL_THIN_BORDER - ws.cell(row=1, column=col).alignment = Alignment( - horizontal="center", vertical="center" - ) - ws.merge_cells(start_row=1, start_column=col, end_row=2, end_column=col) - - row = 3 - for student in sorted_students: - excel_write_student_row(ws, row, student) - - col = 3 - global_total = 0.0 - - for td in filtered_training_days: - tasks = training_day_tasks.get(td.id, []) - ranking = ranking_data.get(student.id, {}).get(td.id) - - td_total = 0.0 - for task in tasks: - score_val = None - if ranking and ranking.task_scores: - score_val = ranking.task_scores.get(str(task["id"])) - - cell = ws.cell(row=row, column=col) - if score_val is not None: - cell.value = score_val - td_total += score_val - else: - cell.value = "" - cell.border = EXCEL_THIN_BORDER - col += 1 - - total_cell = ws.cell(row=row, column=col) - if ranking and ranking.task_scores: - total_cell.value = td_total - global_total += td_total - else: - total_cell.value = "" - total_cell.border = EXCEL_THIN_BORDER - col += 1 - - global_cell = ws.cell(row=row, column=col) - global_cell.value = global_total if global_total > 0 else "" - global_cell.border = EXCEL_THIN_BORDER - global_cell.font = Font(bold=True) - - row += 1 - - ws.column_dimensions["A"].width = 30 - ws.column_dimensions["B"].width = 20 - - total_cols = 3 - for td in filtered_training_days: - total_cols += len(training_day_tasks.get(td.id, [])) + 1 - total_cols += 1 - - for col_idx in range(3, total_cols): - col_letter = get_column_letter(col_idx) - ws.column_dimensions[col_letter].width = 10 - - output = io.BytesIO() - wb.save(output) - output.seek(0) - - filename = excel_build_filename( - training_program.name, "ranking", - start_date, end_date, training_day_types, student_tags - ) - - self.set_header( - "Content-Type", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - ) - self.set_header( - "Content-Disposition", - f'attachment; filename="{filename}"' - ) - self.write(output.getvalue()) - self.finish() diff --git a/cms/server/admin/handlers/base.py b/cms/server/admin/handlers/base.py index 0689d7137d..de72c2191d 100644 --- a/cms/server/admin/handlers/base.py +++ b/cms/server/admin/handlers/base.py @@ -55,6 +55,7 @@ DelayRequest, Participation, Question, + Student, Submission, SubmissionResult, Task, @@ -69,9 +70,10 @@ from cms.grading.scoretypes import get_score_type_class from cms.grading.tasktypes import get_task_type_class from cms.server import CommonRequestHandler, FileHandlerMixin -from cms.server.util import ( - exclude_internal_contests, +from cms.server.util import exclude_internal_contests, calculate_task_archive_progress +from cms.server.admin.handlers.utils import ( count_unanswered_questions, + get_all_student_tags, get_all_training_day_notifications, ) from cmscommon.crypto import hash_password, parse_authentication @@ -553,6 +555,55 @@ def render_params_for_training_program( return self.r_params + def render_params_for_students_page( + self, training_program: "TrainingProgram" + ) -> dict: + """Prepare render params for the training program students page. + + This is a convenience method that sets up all the params needed + for the students page, including unassigned users, student progress, + and task/tag lists for the bulk assign modal. + + Must be called after render_params_for_training_program(). + + Args: + training_program: The training program being viewed. + + Returns: + The updated r_params dict. + """ + managing_contest = training_program.managing_contest + + 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.sql_session + ) + # Commit to release any advisory locks taken by get_cached_score_entry + self.sql_session.commit() + + self.r_params["student_progress"] = student_progress + + # For bulk assign task modal + self.r_params["all_tasks"] = managing_contest.get_tasks() + self.r_params["all_student_tags"] = get_all_student_tags( + self.sql_session, training_program + ) + + return self.r_params + def write_error(self, status_code, **kwargs): if "exc_info" in kwargs and kwargs["exc_info"][0] != tornado.web.HTTPError: exc_info = kwargs["exc_info"] @@ -904,6 +955,74 @@ def get_login_url(self) -> str: return self.url("login") +class StudentBaseHandler(BaseHandler): + """Base handler for student-related pages in a training program. + + This handler provides common functionality for looking up a student's + context (training_program, managing_contest, participation, student) + and raises 404 if the student is not found. + + Subclasses should call setup_student_context() at the start of their + get/post methods to populate self.training_program, self.managing_contest, + self.participation, and self.student. + """ + + training_program: TrainingProgram + managing_contest: Contest + participation: Participation + student: Student + + def setup_student_context( + self, training_program_id: str, user_id: str + ) -> None: + """Look up and set the student context for this request. + + This method looks up the training program, managing contest, + participation, and student for the given IDs. It raises a 404 + error if the participation or student is not found. + + Args: + training_program_id: The training program ID from the URL. + user_id: The user ID from the URL. + + Raises: + tornado.web.HTTPError(404): If participation or student not found. + """ + try: + user_id_int = int(user_id) + except ValueError: + raise tornado.web.HTTPError(404) + + self.training_program = self.safe_get_item( + TrainingProgram, training_program_id + ) + self.managing_contest = self.training_program.managing_contest + self.contest = self.managing_contest + + participation: Participation | None = ( + self.sql_session.query(Participation) + .filter(Participation.contest_id == self.managing_contest.id) + .filter(Participation.user_id == user_id_int) + .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 == self.training_program) + .first() + ) + + if student is None: + raise tornado.web.HTTPError(404) + + self.participation = participation + self.student = student + + class FileHandler(BaseHandler, FileHandlerMixin): pass diff --git a/cms/server/admin/handlers/contest.py b/cms/server/admin/handlers/contest.py index 500f5b273c..e4c1063325 100644 --- a/cms/server/admin/handlers/contest.py +++ b/cms/server/admin/handlers/contest.py @@ -38,7 +38,7 @@ from cmscommon.datetime import make_datetime from sqlalchemy.orm import joinedload from sqlalchemy import func -from cms.server.util import get_all_student_tags +from cms.server.admin.handlers.utils import get_all_student_tags from .base import BaseHandler, SimpleContestHandler, SimpleHandler, \ require_permission @@ -158,7 +158,7 @@ def get(self, contest_id: str): training_day = self.contest.training_day if training_day is not None: training_program = training_day.training_program - all_student_tags = get_all_student_tags(training_program) + all_student_tags = get_all_student_tags(self.sql_session, training_program) self.r_params["all_student_tags"] = all_student_tags self.render("contest.html", **self.r_params) diff --git a/cms/server/admin/handlers/contestannouncement.py b/cms/server/admin/handlers/contestannouncement.py index 6293001f6c..3fbb3070be 100644 --- a/cms/server/admin/handlers/contestannouncement.py +++ b/cms/server/admin/handlers/contestannouncement.py @@ -36,7 +36,7 @@ import tornado.web from cms.db import Contest, Announcement -from cms.server.util import get_all_student_tags, parse_tags +from cms.server.admin.handlers.utils import get_all_student_tags, parse_tags from cmscommon.datetime import make_datetime from .base import BaseHandler, require_permission @@ -56,7 +56,9 @@ def get(self, contest_id: str): training_day = self.contest.training_day if training_day is not None: training_program = training_day.training_program - self.r_params["all_student_tags"] = get_all_student_tags(training_program) + self.r_params["all_student_tags"] = get_all_student_tags( + self.sql_session, training_program + ) self.r_params["is_training_day"] = True else: self.r_params["all_student_tags"] = [] diff --git a/cms/server/admin/handlers/contestdelayrequest.py b/cms/server/admin/handlers/contestdelayrequest.py index 47aff7329c..17f13ca427 100644 --- a/cms/server/admin/handlers/contestdelayrequest.py +++ b/cms/server/admin/handlers/contestdelayrequest.py @@ -38,7 +38,8 @@ from cms.db import Contest, DelayRequest, Participation from cms.server.contest.phase_management import compute_actual_phase from cmscommon.datetime import make_datetime -from cms.server.util import check_training_day_eligibility, get_all_student_tags +from cms.server.util import check_training_day_eligibility +from cms.server.admin.handlers.utils import get_all_student_tags from .base import BaseHandler, require_permission @@ -211,7 +212,9 @@ def get(self, contest_id): self.r_params["ineligible_training_program"] = training_program # Collect all unique student tags for autocomplete (using shared utility) - self.r_params["all_student_tags"] = get_all_student_tags(training_program) + self.r_params["all_student_tags"] = get_all_student_tags( + self.sql_session, training_program + ) # Find students with 0 or >1 main group tags ineligible = [] diff --git a/cms/server/admin/handlers/contestranking.py b/cms/server/admin/handlers/contestranking.py index 656ee9f098..91d3f90377 100644 --- a/cms/server/admin/handlers/contestranking.py +++ b/cms/server/admin/handlers/contestranking.py @@ -40,7 +40,8 @@ Submission, SubmissionResult, Task from cms.grading.scorecache import get_cached_score_entry, ensure_valid_history -from cms.server.util import can_access_task, get_all_student_tags, get_student_for_user_in_program +from cms.server.util import can_access_task, get_student_for_user_in_program +from cms.server.admin.handlers.utils import get_all_student_tags from .base import BaseHandler, require_permission logger = logging.getLogger(__name__) @@ -398,7 +399,9 @@ def get_group_score(p, tasks=accessible_tasks): ) # Get all student tags for display - self.r_params["all_student_tags"] = get_all_student_tags(training_program) + self.r_params["all_student_tags"] = get_all_student_tags( + self.sql_session, training_program + ) self.r_params["main_groups_data"] = main_groups_data self.r_params["student_tags_by_participation"] = student_tags_by_participation diff --git a/cms/server/admin/handlers/contesttask.py b/cms/server/admin/handlers/contesttask.py index f7199e1b3c..237998a4c8 100644 --- a/cms/server/admin/handlers/contesttask.py +++ b/cms/server/admin/handlers/contesttask.py @@ -26,7 +26,7 @@ """ from cms.db import Contest, Task -from cms.server.util import get_all_student_tags, deduplicate_preserving_order +from cms.server.admin.handlers.utils import get_all_student_tags, deduplicate_preserving_order from cmscommon.datetime import make_datetime from .base import BaseHandler, require_permission @@ -76,7 +76,9 @@ def get(self, contest_id): self.r_params["program_task_ids"] = [t.id for t in program_tasks] # Get all student tags for autocomplete (for task visibility tags) - self.r_params["all_student_tags"] = get_all_student_tags(training_program) + self.r_params["all_student_tags"] = get_all_student_tags( + self.sql_session, training_program + ) else: # For regular contests, show all unassigned tasks self.r_params["unassigned_tasks"] = \ @@ -354,7 +356,9 @@ def post(self, contest_id, task_id): # Get allowed tags from training program training_program = training_day.training_program - allowed_tags = set(get_all_student_tags(training_program)) + allowed_tags = set(get_all_student_tags( + self.sql_session, training_program + )) # Validate and filter tags against allowed set invalid_tags = [tag for tag in incoming_tags if tag not in allowed_tags] diff --git a/cms/server/admin/handlers/contestuser.py b/cms/server/admin/handlers/contestuser.py index cfec1ec7fd..0e184083eb 100644 --- a/cms/server/admin/handlers/contestuser.py +++ b/cms/server/admin/handlers/contestuser.py @@ -44,7 +44,7 @@ from cms.db import Contest, Message, Participation, Submission, User, Team, TrainingDay from cms.db.training_day import get_managing_participation -from cms.server.util import parse_usernames_from_file +from cms.server.admin.handlers.utils import parse_usernames_from_file from cmscommon.crypto import validate_password_strength from cmscommon.datetime import make_datetime from .base import BaseHandler, require_permission diff --git a/cms/server/admin/handlers/excel.py b/cms/server/admin/handlers/excel.py new file mode 100644 index 0000000000..2d20e61b22 --- /dev/null +++ b/cms/server/admin/handlers/excel.py @@ -0,0 +1,506 @@ +#!/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 . + +"""Excel export utilities and handlers for Training Programs. + +This module contains Excel formatting utilities and export handlers for +attendance and combined ranking data. + +Functions: +- excel_safe: Escape potentially dangerous Excel values +- excel_build_filename: Build filename for Excel exports +- excel_setup_student_tags_headers: Set up Student/Tags column headers +- excel_build_training_day_title: Build title string for training day +- excel_get_zebra_fills: Get header fills for zebra coloring +- excel_write_student_row: Write student name and tags to row +- excel_write_training_day_header: Write training day header with merge + +Handlers: +- ExportAttendanceHandler: Export attendance data to Excel +- ExportCombinedRankingHandler: Export combined ranking to Excel +""" + +import io +import re +from typing import Any + +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, PatternFill, Border, Side +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.worksheet import Worksheet + +from cms.db import TrainingProgram + +from .base import BaseHandler, require_permission +from .training_analytics import TrainingProgramFilterMixin, build_attendance_data, build_ranking_data + + +EXCEL_ZEBRA_COLORS = [ + ("4472C4", "D9E2F3"), + ("70AD47", "E2EFDA"), + ("ED7D31", "FCE4D6"), + ("7030A0", "E4DFEC"), + ("00B0F0", "DAEEF3"), + ("FFC000", "FFF2CC"), +] + +EXCEL_HEADER_FONT = Font(bold=True) +EXCEL_HEADER_FONT_WHITE = Font(bold=True, color="FFFFFF") +EXCEL_THIN_BORDER = Border( + left=Side(style="thin"), + right=Side(style="thin"), + top=Side(style="thin"), + bottom=Side(style="thin"), +) +EXCEL_DEFAULT_HEADER_FILL = PatternFill( + start_color="4472C4", end_color="4472C4", fill_type="solid" +) + + +def excel_safe(value: str) -> str: + """Escape potentially dangerous Excel values.""" + if value and value[0] in ("=", "+", "-", "@"): + return "'" + value + return value + + +def excel_build_filename( + program_name: str, + export_type: str, + start_date: Any, + end_date: Any, + training_day_types: list[str] | None, + student_tags: list[str] | None, +) -> str: + """Build a filename for Excel export based on filters.""" + program_slug = re.sub(r"[^A-Za-z0-9_-]+", "_", program_name) + filename_parts = [program_slug, export_type] + + if start_date: + filename_parts.append(f"from_{start_date.strftime('%Y%m%d')}") + if end_date: + filename_parts.append(f"to_{end_date.strftime('%Y%m%d')}") + if training_day_types: + types_slug = re.sub( + r"[^A-Za-z0-9_-]+", "_", "_".join(training_day_types) + ) + filename_parts.append(f"types_{types_slug}") + if student_tags: + tags_slug = re.sub(r"[^A-Za-z0-9_-]+", "_", "_".join(student_tags)) + filename_parts.append(f"tags_{tags_slug}") + + return "_".join(filename_parts) + ".xlsx" + + +def excel_setup_student_tags_headers( + ws: Worksheet, + default_fill: PatternFill, +) -> None: + """Set up Student and Tags column headers (merged across rows 1-2).""" + ws.cell(row=1, column=1, value="Student") + ws.cell(row=1, column=1).font = EXCEL_HEADER_FONT_WHITE + ws.cell(row=1, column=1).fill = default_fill + ws.cell(row=1, column=1).border = EXCEL_THIN_BORDER + ws.cell(row=1, column=1).alignment = Alignment( + horizontal="center", vertical="center" + ) + ws.merge_cells(start_row=1, start_column=1, end_row=2, end_column=1) + + ws.cell(row=1, column=2, value="Tags") + ws.cell(row=1, column=2).font = EXCEL_HEADER_FONT_WHITE + ws.cell(row=1, column=2).fill = default_fill + ws.cell(row=1, column=2).border = EXCEL_THIN_BORDER + ws.cell(row=1, column=2).alignment = Alignment( + horizontal="center", vertical="center" + ) + ws.merge_cells(start_row=1, start_column=2, end_row=2, end_column=2) + + +def excel_build_training_day_title(td: Any) -> str: + """Build a title string for a training day including types.""" + title = td.description or td.name or "Session" + if td.start_time: + title += f" ({td.start_time.strftime('%b %d')})" + if td.training_day_types: + title += f" [{'; '.join(td.training_day_types)}]" + return title + + +def excel_get_zebra_fills(color_idx: int) -> tuple[PatternFill, PatternFill]: + """Get header and subheader fills for zebra coloring.""" + header_color, subheader_color = EXCEL_ZEBRA_COLORS[ + color_idx % len(EXCEL_ZEBRA_COLORS) + ] + header_fill = PatternFill( + start_color=header_color, end_color=header_color, fill_type="solid" + ) + subheader_fill = PatternFill( + start_color=subheader_color, end_color=subheader_color, fill_type="solid" + ) + return header_fill, subheader_fill + + +def excel_write_student_row( + ws: Worksheet, + row: int, + student: Any, +) -> None: + """Write student name and tags to columns 1 and 2.""" + if student.participation: + user = student.participation.user + student_name = f"{user.first_name} {user.last_name} ({user.username})" + else: + student_name = "(Unknown)" + + ws.cell(row=row, column=1, value=excel_safe(student_name)) + ws.cell(row=row, column=1).border = EXCEL_THIN_BORDER + + tags_str = "" + if student.student_tags: + tags_str = "; ".join(student.student_tags) + ws.cell(row=row, column=2, value=excel_safe(tags_str)) + ws.cell(row=row, column=2).border = EXCEL_THIN_BORDER + + +def excel_write_training_day_header( + ws: Worksheet, + col: int, + td: Any, + td_idx: int, + num_columns: int, +) -> None: + """Write a training day header row with zebra coloring and merge cells. + + ws: the worksheet to write to. + col: the starting column for this training day header. + td: the training day object. + td_idx: the index of the training day (for zebra coloring). + num_columns: the number of columns to merge for this training day. + """ + title = excel_build_training_day_title(td) + safe_title = excel_safe(title) + header_fill, _ = excel_get_zebra_fills(td_idx) + + ws.cell(row=1, column=col, value=safe_title) + ws.cell(row=1, column=col).font = EXCEL_HEADER_FONT_WHITE + ws.cell(row=1, column=col).fill = header_fill + ws.cell(row=1, column=col).border = EXCEL_THIN_BORDER + ws.cell(row=1, column=col).alignment = Alignment( + horizontal="center", vertical="center" + ) + ws.merge_cells( + start_row=1, start_column=col, + end_row=1, end_column=col + num_columns - 1 + ) + + +class ExportAttendanceHandler(TrainingProgramFilterMixin, BaseHandler): + """Export attendance data to Excel format.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + """Export filtered attendance data to Excel.""" + 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) + + if not archived_training_days: + self.redirect(self.url( + "training_program", training_program_id, "attendance" + )) + return + + attendance_data, _, sorted_students = build_attendance_data( + archived_training_days, student_tags, current_tag_student_ids + ) + + wb = Workbook() + ws = wb.active + ws.title = "Attendance" + + subcolumns = ["Status", "Location", "Recorded", "Delay Reasons", "Comments"] + num_subcolumns = len(subcolumns) + + excel_setup_student_tags_headers(ws, EXCEL_DEFAULT_HEADER_FILL) + + col = 3 + for td_idx, td in enumerate(archived_training_days): + excel_write_training_day_header(ws, col, td, td_idx, num_subcolumns) + _, subheader_fill = excel_get_zebra_fills(td_idx) + + for i, subcol_name in enumerate(subcolumns): + cell = ws.cell(row=2, column=col + i, value=subcol_name) + cell.font = EXCEL_HEADER_FONT + cell.fill = subheader_fill + cell.border = EXCEL_THIN_BORDER + cell.alignment = Alignment(horizontal="center") + + col += num_subcolumns + + row = 3 + for student in sorted_students: + excel_write_student_row(ws, row, student) + + col = 3 + for td in archived_training_days: + att = attendance_data.get(student.id, {}).get(td.id) + + if att: + if att.status == "missed": + if att.justified: + status = "Justified Absent" + else: + status = "Missed" + elif att.delay_time: + delay_minutes = att.delay_time.total_seconds() / 60 + if delay_minutes < 60: + status = f"Delayed ({delay_minutes:.0f}m)" + else: + status = f"Delayed ({delay_minutes / 60:.1f}h)" + else: + status = "On Time" + + location = "" + if att.status != "missed" and att.location: + location_map = { + "class": "Class", + "home": "Home", + "both": "Both", + } + location = location_map.get(att.location, att.location) + + recorded = "" + if att.status != "missed": + recorded = "Yes" if att.recorded else "No" + + delay_reasons = att.delay_reasons or "" + comment = att.comment or "" + else: + status = "" + location = "" + recorded = "" + delay_reasons = "" + comment = "" + + values = [status, location, recorded, delay_reasons, comment] + for i, value in enumerate(values): + cell = ws.cell(row=row, column=col + i, value=excel_safe(value)) + cell.border = EXCEL_THIN_BORDER + + col += num_subcolumns + + row += 1 + + ws.column_dimensions["A"].width = 30 + ws.column_dimensions["B"].width = 20 + for col_idx in range(3, 3 + len(archived_training_days) * num_subcolumns): + col_letter = get_column_letter(col_idx) + ws.column_dimensions[col_letter].width = 15 + + output = io.BytesIO() + wb.save(output) + output.seek(0) + + filename = excel_build_filename( + training_program.name, "attendance", + start_date, end_date, training_day_types, student_tags + ) + + self.set_header( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + self.set_header( + "Content-Disposition", + f'attachment; filename="{filename}"' + ) + self.write(output.getvalue()) + self.finish() + + +class ExportCombinedRankingHandler(TrainingProgramFilterMixin, BaseHandler): + """Export combined ranking data to Excel format.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + """Export filtered combined ranking data to Excel.""" + 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) + + if not archived_training_days: + self.redirect(self.url( + "training_program", training_program_id, "combined_ranking" + )) + return + + ( + ranking_data, + all_students, + training_day_tasks, + filtered_training_days, + _, + ) = build_ranking_data( + self.sql_session, + archived_training_days, + student_tags, + student_tags_mode, + current_tag_student_ids, + self._tags_match, + ) + + if not filtered_training_days: + self.redirect(self.url( + "training_program", training_program_id, "combined_ranking" + )) + return + + sorted_students = sorted( + all_students.values(), + key=lambda s: s.participation.user.username if s.participation else "" + ) + + wb = Workbook() + ws = wb.active + ws.title = "Combined Ranking" + + excel_setup_student_tags_headers(ws, EXCEL_DEFAULT_HEADER_FILL) + + col = 3 + for td_idx, td in enumerate(filtered_training_days): + tasks = training_day_tasks.get(td.id, []) + num_task_cols = len(tasks) + 1 + + excel_write_training_day_header(ws, col, td, td_idx, num_task_cols) + _, subheader_fill = excel_get_zebra_fills(td_idx) + + for i, task in enumerate(tasks): + cell = ws.cell(row=2, column=col + i, value=excel_safe(task["name"])) + cell.font = EXCEL_HEADER_FONT + cell.fill = subheader_fill + cell.border = EXCEL_THIN_BORDER + cell.alignment = Alignment(horizontal="center") + + total_cell = ws.cell(row=2, column=col + len(tasks), value="Total") + total_cell.font = EXCEL_HEADER_FONT + total_cell.fill = subheader_fill + total_cell.border = EXCEL_THIN_BORDER + total_cell.alignment = Alignment(horizontal="center") + + col += num_task_cols + + global_header_fill = PatternFill( + start_color="808080", end_color="808080", fill_type="solid" + ) + ws.cell(row=1, column=col, value="Global") + ws.cell(row=1, column=col).font = EXCEL_HEADER_FONT_WHITE + ws.cell(row=1, column=col).fill = global_header_fill + ws.cell(row=1, column=col).border = EXCEL_THIN_BORDER + ws.cell(row=1, column=col).alignment = Alignment( + horizontal="center", vertical="center" + ) + ws.merge_cells(start_row=1, start_column=col, end_row=2, end_column=col) + + row = 3 + for student in sorted_students: + excel_write_student_row(ws, row, student) + + col = 3 + global_total = 0.0 + + for td in filtered_training_days: + tasks = training_day_tasks.get(td.id, []) + ranking = ranking_data.get(student.id, {}).get(td.id) + + td_total = 0.0 + for task in tasks: + score_val = None + if ranking and ranking.task_scores: + score_val = ranking.task_scores.get(str(task["id"])) + + cell = ws.cell(row=row, column=col) + if score_val is not None: + cell.value = score_val + td_total += score_val + else: + cell.value = "" + cell.border = EXCEL_THIN_BORDER + col += 1 + + total_cell = ws.cell(row=row, column=col) + if ranking and ranking.task_scores: + total_cell.value = td_total + global_total += td_total + else: + total_cell.value = "" + total_cell.border = EXCEL_THIN_BORDER + col += 1 + + global_cell = ws.cell(row=row, column=col) + global_cell.value = global_total if global_total > 0 else "" + global_cell.border = EXCEL_THIN_BORDER + global_cell.font = Font(bold=True) + + row += 1 + + ws.column_dimensions["A"].width = 30 + ws.column_dimensions["B"].width = 20 + + total_cols = 3 + for td in filtered_training_days: + total_cols += len(training_day_tasks.get(td.id, [])) + 1 + total_cols += 1 + + for col_idx in range(3, total_cols): + col_letter = get_column_letter(col_idx) + ws.column_dimensions[col_letter].width = 10 + + output = io.BytesIO() + wb.save(output) + output.seek(0) + + filename = excel_build_filename( + training_program.name, "ranking", + start_date, end_date, training_day_types, student_tags + ) + + self.set_header( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + self.set_header( + "Content-Disposition", + f'attachment; filename="{filename}"' + ) + self.write(output.getvalue()) + self.finish() diff --git a/cms/server/admin/handlers/student.py b/cms/server/admin/handlers/student.py index b0993e0613..59598d3899 100644 --- a/cms/server/admin/handlers/student.py +++ b/cms/server/admin/handlers/student.py @@ -19,6 +19,9 @@ Students are users enrolled in a training program with additional metadata like student tags and task assignments. + +This module contains core student management handlers. Task-related handlers +are in studenttask.py. """ import tornado.web @@ -26,26 +29,41 @@ from cms.db import ( TrainingProgram, Participation, - Submission, User, - Task, - Question, Student, - StudentTask, Team, - ArchivedStudentRanking, + Submission, ) -from cms.server.util import ( +from cms.server.admin.handlers.utils import ( get_all_student_tags, - calculate_task_archive_progress, - get_student_archive_scores, - get_submission_counts_by_task, parse_tags, parse_usernames_from_file, ) from cmscommon.datetime import make_datetime -from .base import BaseHandler, require_permission +from .base import BaseHandler, StudentBaseHandler, require_permission + +from .studenttask import ( + StudentTasksHandler, + StudentTaskSubmissionsHandler, + AddStudentTaskHandler, + RemoveStudentTaskHandler, + BulkAssignTaskHandler, +) + +__all__ = [ + "AddStudentTaskHandler", + "AddTrainingProgramStudentHandler", + "BulkAddTrainingProgramStudentsHandler", + "BulkAssignTaskHandler", + "RemoveStudentTaskHandler", + "RemoveTrainingProgramStudentHandler", + "StudentHandler", + "StudentTagsHandler", + "StudentTaskSubmissionsHandler", + "StudentTasksHandler", + "TrainingProgramStudentsHandler", +] class TrainingProgramStudentsHandler(BaseHandler): @@ -55,37 +73,11 @@ class TrainingProgramStudentsHandler(BaseHandler): @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.render_params_for_training_program(training_program) - - 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.sql_session - ) - # Commit to release any advisory locks taken by get_cached_score_entry - self.sql_session.commit() - - self.r_params["student_progress"] = student_progress + self.render_params_for_students_page(training_program) self.r_params["bulk_add_results"] = None - # For bulk assign task modal - self.r_params["all_tasks"] = managing_contest.get_tasks() - self.r_params["all_student_tags"] = get_all_student_tags(training_program) - self.render("training_program_students.html", **self.r_params) @require_permission(BaseHandler.PERMISSION_ALL) @@ -128,7 +120,8 @@ def post(self, training_program_id: str): try: user_id: str = self.get_argument("user_id") - assert user_id != "", "Please select a valid user" + if not user_id or user_id.strip() == "": + raise ValueError("Please select a valid user") except Exception as error: self.service.add_notification( make_datetime(), "Invalid field(s)", repr(error)) @@ -261,31 +254,9 @@ def post(self, training_program_id: str): return self.render_params_for_training_program(training_program) - - 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() - ) - - student_progress = {} - for student in training_program.students: - student_progress[student.id] = calculate_task_archive_progress( - student, student.participation, managing_contest, self.sql_session - ) - # Commit to release any advisory locks taken by get_cached_score_entry - self.sql_session.commit() - - self.r_params["student_progress"] = student_progress + self.render_params_for_students_page(training_program) self.r_params["bulk_add_results"] = results self.r_params["students_added"] = students_added - self.r_params["all_tasks"] = managing_contest.get_tasks() - self.r_params["all_student_tags"] = get_all_student_tags(training_program) self.render("training_program_students.html", **self.r_params) except Exception as error: @@ -399,7 +370,7 @@ def delete(self, training_program_id: str, user_id: str): self.write("../../students") -class StudentHandler(BaseHandler): +class StudentHandler(StudentBaseHandler): """Shows and edits details of a single student in a training program. Similar to ParticipationHandler but includes student tags. @@ -407,45 +378,25 @@ class StudentHandler(BaseHandler): @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) + self.setup_student_context(training_program_id, user_id) submission_query = self.sql_session.query(Submission).filter( - Submission.participation == participation + Submission.participation == self.participation ) page = int(self.get_query_argument("page", "0")) # render_params_for_training_program sets training_program, contest, unanswered - self.render_params_for_training_program(training_program) + self.render_params_for_training_program(self.training_program) self.render_params_for_submissions(submission_query, page) - self.r_params["participation"] = participation - self.r_params["student"] = student - self.r_params["selected_user"] = participation.user + self.r_params["participation"] = self.participation + self.r_params["student"] = self.student + self.r_params["selected_user"] = self.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["all_student_tags"] = get_all_student_tags( + self.sql_session, self.training_program + ) self.render("student.html", **self.r_params) @require_permission(BaseHandler.PERMISSION_ALL) @@ -454,38 +405,11 @@ def post(self, training_program_id: str, user_id: str): "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) + self.setup_student_context(training_program_id, user_id) try: - attrs = participation.get_attrs() - self.get_password(attrs, participation.password, True) + attrs = self.participation.get_attrs() + self.get_password(attrs, self.participation.password, True) self.get_ip_networks(attrs, "ip") self.get_datetime(attrs, "starting_time") self.get_timedelta_sec(attrs, "delay_time") @@ -496,15 +420,15 @@ def post(self, training_program_id: str, user_id: str): # Get the new hidden status before applying new_hidden = attrs.get("hidden", False) - participation.set_attrs(attrs) + self.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: + user = self.participation.user + for training_day in self.training_program.training_days: if training_day.contest is None: continue td_participation = self.sql_session.query(Participation)\ @@ -522,12 +446,12 @@ def post(self, training_program_id: str, user_id: str): ) if team is None: raise ValueError(f"Team with code '{team_code}' does not exist") - participation.team = team + self.participation.team = team else: - participation.team = None + self.participation.team = None tags_str = self.get_argument("student_tags", "") - student.student_tags = parse_tags(tags_str) + self.student.student_tags = parse_tags(tags_str) except Exception as error: self.service.add_notification( @@ -541,7 +465,7 @@ def post(self, training_program_id: str, user_id: str): self.redirect(fallback_page) -class StudentTagsHandler(BaseHandler): +class StudentTagsHandler(StudentBaseHandler): """Handler for updating student tags via AJAX.""" @require_permission(BaseHandler.PERMISSION_ALL) @@ -549,414 +473,23 @@ 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: + try: + self.setup_student_context(training_program_id, user_id) + except tornado.web.HTTPError: self.set_status(404) - self.write({"error": "Participation not found"}) + self.write({"error": "Student 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) + self.student.student_tags = parse_tags(tags_str) if self.try_commit(): - self.write({"success": True, "tags": student.student_tags}) + self.write({"success": True, "tags": self.student.student_tags}) else: self.set_status(500) - self.write({"error": "Failed to save"}) + return 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 using get_student_archive_scores for fresh cache values - # This avoids stale entries in participation.task_scores - home_scores = get_student_archive_scores( - self.sql_session, student, participation, managing_contest - ) - # Commit to release advisory locks from cache rebuilds - self.sql_session.commit() - - # 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] - - # Get submission counts for each task (batch query for efficiency) - submission_counts = get_submission_counts_by_task( - self.sql_session, participation.id, assigned_task_ids - ) - - self.render_params_for_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["submission_counts"] = submission_counts - self.render("student_tasks.html", **self.r_params) - - -class StudentTaskSubmissionsHandler(BaseHandler): - """View submissions for a specific task in a student's archive.""" - - @require_permission(BaseHandler.AUTHENTICATED) - def get(self, training_program_id: str, user_id: str, task_id: str): - training_program = self.safe_get_item(TrainingProgram, training_program_id) - managing_contest = training_program.managing_contest - task = self.safe_get_item(Task, task_id) - - # Validate task belongs to the training program - if task.contest_id != managing_contest.id: - raise tornado.web.HTTPError(404) - - 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) - - # Verify student is assigned this specific task - student_task = ( - self.sql_session.query(StudentTask) - .filter(StudentTask.student == student) - .filter(StudentTask.task == task) - .first() - ) - - if student_task is None: - raise tornado.web.HTTPError(404) - - # Filter submissions by task - self.contest = managing_contest - submission_query = ( - self.sql_session.query(Submission) - .filter(Submission.participation == participation) - .filter(Submission.task_id == task.id) - ) - page = int(self.get_query_argument("page", "0")) - - self.render_params_for_training_program(training_program) - self.render_params_for_submissions(submission_query, page) - - self.r_params["participation"] = participation - self.r_params["student"] = student - self.r_params["selected_user"] = participation.user - self.r_params["task"] = task - self.render("student_task_submissions.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. - - Note: The GET method was removed as the bulk assign task functionality - is now handled via a modal dialog on the students page. - """ - - @require_permission(BaseHandler.PERMISSION_ALL) - def post(self, training_program_id: str): - # Redirect to students page (modal is now on that page) - fallback_page = self.url( - "training_program", training_program_id, "students" - ) - - 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/studenttask.py b/cms/server/admin/handlers/studenttask.py new file mode 100644 index 0000000000..61866e92ec --- /dev/null +++ b/cms/server/admin/handlers/studenttask.py @@ -0,0 +1,337 @@ +#!/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 Student Task management. + +This module contains handlers for managing task assignments to students +in training programs, including viewing, adding, and removing tasks +from student archives. + +Handlers: +- StudentTasksHandler: View and manage tasks assigned to a student +- StudentTaskSubmissionsHandler: View submissions for a specific task +- AddStudentTaskHandler: Add a task to a student's archive +- RemoveStudentTaskHandler: Remove a task from a student's archive +- BulkAssignTaskHandler: Bulk assign a task to students with a tag +""" + +import tornado.web + +from cms.db import ( + TrainingProgram, + Submission, + Task, + Student, + StudentTask, + ArchivedStudentRanking, +) +from cms.server.util import get_student_archive_scores, get_submission_counts_by_task +from cmscommon.datetime import make_datetime + +from .base import BaseHandler, StudentBaseHandler, require_permission + + +class StudentTasksHandler(StudentBaseHandler): + """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): + self.setup_student_context(training_program_id, user_id) + + # Get all tasks in the training program for the "add task" dropdown + all_tasks = self.managing_contest.get_tasks() + assigned_task_ids = {st.task_id for st in self.student.student_tasks} + available_tasks = [t for t in all_tasks if t.id not in assigned_task_ids] + + # Build home scores using get_student_archive_scores for fresh cache values + # This avoids stale entries in participation.task_scores + home_scores = get_student_archive_scores( + self.sql_session, self.student, self.participation, self.managing_contest + ) + # Commit to release advisory locks from cache rebuilds + self.sql_session.commit() + + # Build training scores from archived student rankings (batch query) + training_scores = {} + source_training_day_ids = { + st.source_training_day_id + for st in self.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 == self.student.id) + .all() + ) + } + + for st in self.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] + + # Get submission counts for each task (batch query for efficiency) + submission_counts = get_submission_counts_by_task( + self.sql_session, self.participation.id, assigned_task_ids + ) + + self.render_params_for_training_program(self.training_program) + self.r_params["participation"] = self.participation + self.r_params["student"] = self.student + self.r_params["selected_user"] = self.participation.user + self.r_params["student_tasks"] = sorted( + self.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["submission_counts"] = submission_counts + self.render("student_tasks.html", **self.r_params) + + +class StudentTaskSubmissionsHandler(StudentBaseHandler): + """View submissions for a specific task in a student's archive.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str, user_id: str, task_id: str): + task = self.safe_get_item(Task, task_id) + self.setup_student_context(training_program_id, user_id) + + # Validate task belongs to the training program + if task.contest_id != self.managing_contest.id: + raise tornado.web.HTTPError(404) + + # Verify student is assigned this specific task + student_task = ( + self.sql_session.query(StudentTask) + .filter(StudentTask.student == self.student) + .filter(StudentTask.task == task) + .first() + ) + + if student_task is None: + raise tornado.web.HTTPError(404) + + # Filter submissions by task + submission_query = ( + self.sql_session.query(Submission) + .filter(Submission.participation == self.participation) + .filter(Submission.task_id == task.id) + ) + page = int(self.get_query_argument("page", "0")) + + self.render_params_for_training_program(self.training_program) + self.render_params_for_submissions(submission_query, page) + + self.r_params["participation"] = self.participation + self.r_params["student"] = self.student + self.r_params["selected_user"] = self.participation.user + self.r_params["task"] = task + self.render("student_task_submissions.html", **self.r_params) + + +class AddStudentTaskHandler(StudentBaseHandler): + """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" + ) + + self.setup_student_context(training_program_id, user_id) + + 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 != self.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 == self.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 = self.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 {self.participation.user.username}" + ) + + self.redirect(fallback_page) + + +class RemoveStudentTaskHandler(StudentBaseHandler): + """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" + ) + + # Validate and convert task_id to integer + try: + task_id_int = int(task_id) + except (ValueError, TypeError): + raise tornado.web.HTTPError(404) + + # Verify the task exists + task = self.safe_get_item(Task, task_id_int) + + self.setup_student_context(training_program_id, user_id) + + student_task: StudentTask | None = ( + self.sql_session.query(StudentTask) + .filter(StudentTask.student_id == self.student.id) + .filter(StudentTask.task_id == task_id_int) + .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 {self.participation.user.username}'s archive" + ) + + self.redirect(fallback_page) + + +class BulkAssignTaskHandler(BaseHandler): + """Bulk assign a task to all students with a given tag. + + Note: The GET method was removed as the bulk assign task functionality + is now handled via a modal dialog on the students page. + """ + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str): + # Redirect to students page (modal is now on that page) + fallback_page = self.url( + "training_program", training_program_id, "students" + ) + + 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/task.py b/cms/server/admin/handlers/task.py index ba8fb8f504..6bea25dbc0 100644 --- a/cms/server/admin/handlers/task.py +++ b/cms/server/admin/handlers/task.py @@ -42,7 +42,7 @@ from cms.db import Attachment, Dataset, Session, Statement, Submission, Task from cms.grading.scoretypes import ScoreTypeGroup -from cms.server.util import parse_tags +from cms.server.admin.handlers.utils import parse_tags from cmscommon.datetime import make_datetime from .base import BaseHandler, SimpleHandler, require_permission from cms.grading.subtask_validation import get_running_validator_ids diff --git a/cms/server/admin/handlers/training_analytics.py b/cms/server/admin/handlers/training_analytics.py new file mode 100644 index 0000000000..3d20a18287 --- /dev/null +++ b/cms/server/admin/handlers/training_analytics.py @@ -0,0 +1,784 @@ +#!/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 Program Analytics. + +This module contains handlers for displaying attendance and combined ranking +analytics across archived training days. + +Functions: +- build_attendance_data: Build attendance data structure from archived days +- build_ranking_data: Build ranking data structure from archived days + +Classes: +- TrainingProgramFilterMixin: Mixin for filtering training days +- TrainingProgramAttendanceHandler: Display attendance data +- TrainingProgramCombinedRankingHandler: Display combined ranking +- TrainingProgramCombinedRankingHistoryHandler: Return score history data +- TrainingProgramCombinedRankingDetailHandler: Show detailed progress +- UpdateAttendanceHandler: Update attendance records +""" + +import json +from datetime import datetime as dt, timedelta +from typing import Any +from urllib.parse import urlencode + +import tornado.web + +from cms.db import ( + TrainingProgram, + Student, + Task, + TrainingDay, + ArchivedAttendance, + ArchivedStudentRanking, +) +from cms.server.admin.handlers.utils import ( + get_all_student_tags, + get_all_training_day_types, + parse_tags, +) + +from .base import BaseHandler, require_permission + + +def build_attendance_data( + archived_training_days: list[Any], + student_tags: list[str], + current_tag_student_ids: set[int], +) -> tuple[dict[int, dict[int, ArchivedAttendance]], dict[int, Student], list[Student]]: + """Build attendance data structure from archived training days. + + archived_training_days: list of archived TrainingDay objects. + student_tags: list of student tags to filter by (empty = no filter). + current_tag_student_ids: set of student IDs that have the filter tags. + + return: tuple of (attendance_data, all_students, sorted_students) where: + - attendance_data: {student_id: {training_day_id: ArchivedAttendance}} + - all_students: {student_id: Student} + - sorted_students: list of Student objects sorted by username + """ + 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 + if student_tags and student_id not in current_tag_student_ids: + continue + 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 + + sorted_students = sorted( + all_students.values(), + key=lambda s: s.participation.user.username if s.participation else "" + ) + + return attendance_data, all_students, sorted_students + + +def build_ranking_data( + sql_session: Any, + archived_training_days: list[Any], + student_tags: list[str], + student_tags_mode: str, + current_tag_student_ids: set[int], + tags_match_fn: Any, +) -> tuple[ + dict[int, dict[int, ArchivedStudentRanking]], + dict[int, Student], + dict[int, list[dict]], + list[Any], + dict[int, set[int]], +]: + """Build ranking data structure from archived training days. + + sql_session: the database session. + archived_training_days: list of archived TrainingDay objects. + student_tags: list of student tags to filter by (empty = no filter). + student_tags_mode: "current" or "historical" for tag filtering. + current_tag_student_ids: set of student IDs that have the filter tags. + tags_match_fn: function to check if item_tags contains all filter_tags. + + return: tuple of (ranking_data, all_students, training_day_tasks, + filtered_training_days, active_students_per_td) where: + - ranking_data: {student_id: {training_day_id: ArchivedStudentRanking}} + - all_students: {student_id: Student} + - training_day_tasks: {training_day_id: [task_info_dict, ...]} + - filtered_training_days: list of TrainingDay objects with data + - active_students_per_td: {training_day_id: set of active student IDs} + """ + ranking_data: dict[int, dict[int, ArchivedStudentRanking]] = {} + all_students: dict[int, Student] = {} + training_day_tasks: dict[int, list[dict]] = {} + filtered_training_days: list[Any] = [] + active_students_per_td: dict[int, set[int]] = {} + + for td in archived_training_days: + active_students_per_td[td.id] = set() + visible_tasks_by_id: dict[int, dict] = {} + + for ranking in td.archived_student_rankings: + student_id = ranking.student_id + student = ranking.student + + if student.participation and student.participation.hidden: + continue + + if student_tags: + if student_tags_mode == "current": + if student_id not in current_tag_student_ids: + continue + else: + if not tags_match_fn(ranking.student_tags, student_tags): + continue + + 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 + + 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: + 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: + task = 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, + } + + if not active_students_per_td[td.id]: + continue + + filtered_training_days.append(td) + 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 + + return ( + ranking_data, + all_students, + training_day_tasks, + filtered_training_days, + active_students_per_td, + ) + + +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, training_program_id: int, filter_tags: list[str] + ) -> set[int]: + """Return IDs of students that have all filter_tags. + + Uses GIN index on student_tags for efficient querying. + """ + if not filter_tags: + return set() + + query = ( + self.sql_session.query(Student.id) + .filter(Student.training_program_id == training_program_id) + .filter(Student.student_tags.contains(filter_tags)) + ) + return {row[0] for row in query.all()} + + 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 using GIN index + current_tag_student_ids = self._get_student_ids_with_tags( + training_program.id, 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) + + attendance_data, _, sorted_students = build_attendance_data( + archived_training_days, student_tags, current_tag_student_ids + ) + + self.render_params_for_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( + self.sql_session, training_program + ) + + # Build training days with pending delays from notification data + training_days_with_pending_delays: list[dict] = [] + td_notifications = self.r_params.get("training_day_notifications", {}) + for td in training_program.training_days: + if td.contest is None: + continue + td_notif = td_notifications.get(td.id, {}) + pending_count = td_notif.get("pending_delay_requests", 0) + if pending_count > 0: + training_days_with_pending_delays.append({ + "contest_id": td.contest_id, + "name": td.contest.name, + "pending_count": pending_count, + }) + self.r_params["training_days_with_pending_delays"] = \ + training_days_with_pending_delays + + 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, + all_students, + training_day_tasks, + filtered_training_days, + active_students_per_td, + ) = build_ranking_data( + self.sql_session, + archived_training_days, + student_tags, + student_tags_mode, + current_tag_student_ids, + self._tags_match, + ) + + # Build attendance lookup for all training days + attendance_data: dict[int, dict[int, ArchivedAttendance]] = {} + for td in archived_training_days: + 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 + + sorted_students = sorted( + all_students.values(), + key=lambda s: s.participation.user.username if s.participation else "" + ) + + self.render_params_for_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( + self.sql_session, training_program, include_historical=True + ) + 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: + 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: + # 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.render_params_for_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.render("training_program_combined_ranking_detail.html", **self.r_params) + + +class UpdateAttendanceHandler(BaseHandler): + """Update attendance record (justified status, comment, and recorded).""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str, attendance_id: str): + """Update an attendance record's justified status, comment, and/or recorded.""" + training_program = self.safe_get_item(TrainingProgram, training_program_id) + attendance = self.safe_get_item(ArchivedAttendance, attendance_id) + + # Verify the attendance belongs to this training program + if attendance.training_day.training_program_id != training_program.id: + self.set_status(403) + self.write({"success": False, "error": "Attendance not in this program"}) + return + + try: + data = json.loads(self.request.body) + except json.JSONDecodeError: + self.set_status(400) + self.write({"success": False, "error": "Invalid JSON"}) + return + + if "justified" in data: + justified = data["justified"] + if not isinstance(justified, bool): + self.set_status(400) + self.write({"success": False, "error": "Invalid justified flag"}) + return + if justified and attendance.status != "missed": + self.set_status(400) + self.write( + { + "success": False, + "error": "Only missed attendances can be justified", + } + ) + return + attendance.justified = justified + + if "comment" in data: + comment = data["comment"] + if comment is not None: + comment = str(comment).strip() + if not comment: + comment = None + attendance.comment = comment + + if "recorded" in data: + recorded = data["recorded"] + if not isinstance(recorded, bool): + self.set_status(400) + self.write({"success": False, "error": "Invalid recorded flag"}) + return + if recorded and attendance.status == "missed": + self.set_status(400) + self.write( + { + "success": False, + "error": "Only non-missed attendances can be marked as recorded", + } + ) + return + attendance.recorded = recorded + + if self.try_commit(): + self.write( + { + "success": True, + "justified": attendance.justified, + "comment": attendance.comment, + "recorded": attendance.recorded, + } + ) + else: + self.set_status(500) + self.write({"success": False, "error": "Failed to save changes"}) diff --git a/cms/server/admin/handlers/trainingday.py b/cms/server/admin/handlers/trainingday.py index 70e3e73357..5ccb5f7601 100644 --- a/cms/server/admin/handlers/trainingday.py +++ b/cms/server/admin/handlers/trainingday.py @@ -40,10 +40,7 @@ TrainingDay, TrainingDayGroup, ) -from cms.server.util import ( - get_all_training_day_types, - parse_tags, -) +from cms.server.admin.handlers.utils 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 diff --git a/cms/server/admin/handlers/trainingprogram.py b/cms/server/admin/handlers/trainingprogram.py index ab02193312..a63d644b97 100644 --- a/cms/server/admin/handlers/trainingprogram.py +++ b/cms/server/admin/handlers/trainingprogram.py @@ -24,12 +24,11 @@ split into separate modules: - trainingday.py: Training day management handlers - student.py: Student management handlers +- trainingprogramtask.py: Task management and ranking handlers - archive.py: Archive, attendance, and combined ranking handlers """ from datetime import datetime as dt -import json -import logging import tornado.web @@ -43,60 +42,41 @@ Task, Question, Announcement, - Student, - StudentTask, - DelayRequest, ) -from cms.server.util import ( +from cms.server.admin.handlers.utils import ( get_all_student_tags, parse_tags, - calculate_task_archive_progress, get_training_day_notifications, - get_student_tags_by_participation, ) from cmscommon.datetime import make_datetime from .base import BaseHandler, SimpleHandler, require_permission -from .contestranking import RankingCommonMixin - - -def _shift_task_nums( - sql_session, - filter_attr, - filter_value, - num_attr, - threshold: int, - delta: int -) -> None: - """Shift task numbers after insertion or removal. - - This utility function handles the common pattern of incrementing or - decrementing task numbers when a task is added or removed from a - sequence (e.g., contest tasks or training day tasks). - - sql_session: The SQLAlchemy session. - filter_attr: The attribute to filter by (e.g., Task.contest, Task.training_day). - filter_value: The value to filter for. - num_attr: The num attribute to shift (e.g., Task.num, Task.training_day_num). - threshold: The threshold value - tasks with num > threshold will be shifted. - delta: The amount to shift by (+1 for insertion, -1 for removal). - """ - if delta > 0: - # For insertion, process in descending order to avoid conflicts - order = num_attr.desc() - condition = num_attr >= threshold - else: - # For removal, process in ascending order - order = num_attr - condition = num_attr > threshold - - for t in sql_session.query(Task)\ - .filter(filter_attr == filter_value)\ - .filter(condition)\ - .order_by(order)\ - .all(): - setattr(t, num_attr.key, getattr(t, num_attr.key) + delta) - sql_session.flush() + +from .trainingprogramtask import ( + TrainingProgramTasksHandler, + AddTrainingProgramTaskHandler, + RemoveTrainingProgramTaskHandler, + TrainingProgramRankingHandler, + _shift_task_nums, +) + +__all__ = [ + "AddTrainingProgramHandler", + "AddTrainingProgramTaskHandler", + "RemoveTrainingProgramHandler", + "RemoveTrainingProgramTaskHandler", + "TrainingProgramAnnouncementHandler", + "TrainingProgramAnnouncementsHandler", + "TrainingProgramHandler", + "TrainingProgramListHandler", + "TrainingProgramOverviewRedirectHandler", + "TrainingProgramQuestionsHandler", + "TrainingProgramRankingHandler", + "TrainingProgramResourcesListRedirectHandler", + "TrainingProgramSubmissionsHandler", + "TrainingProgramTasksHandler", + "_shift_task_nums", +] class TrainingProgramListHandler(BaseHandler): @@ -126,7 +106,11 @@ def get(self): training_day_notifications: dict[int, dict] = {} for tp in training_programs: - total_students += len(tp.managing_contest.participations) + total_students += ( + self.sql_session.query(func.count(Participation.id)) + .filter(Participation.contest_id == tp.managing_contest_id) + .scalar() + ) # Count active training days (those with a contest) active_tds = [td for td in tp.training_days if td.contest is not None] active_training_days += len(active_tds) @@ -476,347 +460,6 @@ def delete(self, training_program_id: str): self.try_commit() self.write("../../training_programs") -class TrainingProgramTasksHandler(BaseHandler): - """Manage tasks in a training program.""" - - @require_permission(BaseHandler.AUTHENTICATED) - def get(self, training_program_id: str): - training_program = self.safe_get_item(TrainingProgram, training_program_id) - - self.render_params_for_training_program(training_program) - self.r_params["unassigned_tasks"] = \ - self.sql_session.query(Task)\ - .filter(Task.contest_id.is_(None))\ - .all() - - self.render("training_program_tasks.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, "tasks") - - training_program = self.safe_get_item(TrainingProgram, training_program_id) - managing_contest = training_program.managing_contest - - try: - operation: str = self.get_argument("operation") - - # Handle detach operation for archived training day tasks - if operation.startswith("detach_"): - task_id = operation.split("_", 1)[1] - task = self.safe_get_item(Task, task_id) - # Validate task belongs to this training program - if task.contest != managing_contest: - raise ValueError("Task does not belong to this training program") - self._detach_task_from_training_day(task) - if self.try_commit(): - self.service.proxy_service.reinitialize() - self.redirect(fallback_page) - return - - # Handle reorder operation from drag-and-drop - if operation == "reorder": - reorder_data = self.get_argument("reorder_data", "") - if reorder_data: - self._reorder_tasks(managing_contest, reorder_data) - if self.try_commit(): - self.service.proxy_service.reinitialize() - self.redirect(fallback_page) - return - - except Exception as error: - self.service.add_notification( - make_datetime(), "Invalid field(s)", repr(error)) - self.redirect(fallback_page) - return - - self.redirect(fallback_page) - - def _reorder_tasks(self, contest: Contest, reorder_data: str) -> None: - """Reorder tasks based on drag-and-drop data. - - reorder_data: JSON string with list of {task_id, new_num} objects. - """ - try: - order_list = json.loads(reorder_data) - except json.JSONDecodeError as e: - logging.warning( - "Failed to parse reorder data: %s. Payload: %s", - e.msg, - reorder_data[:500], - ) - raise ValueError(f"Invalid JSON in reorder data: {e.msg}") - - if not isinstance(order_list, list): - raise ValueError("Reorder data must be a list") - - expected_ids = {t.id for t in contest.tasks} - received_ids = {int(item.get("task_id")) for item in order_list} - if received_ids != expected_ids: - raise ValueError("Reorder data must include each task exactly once") - - # Validate new_num for each entry (0-based indices) - num_tasks = len(contest.tasks) - expected_nums = set(range(0, num_tasks)) - received_nums = set() - - for item in order_list: - if "new_num" not in item: - raise ValueError("Missing 'new_num' in reorder data entry") - raw_num = item["new_num"] - try: - new_num = int(raw_num) - except (TypeError, ValueError): - raise ValueError( - f"Invalid 'new_num' value: {raw_num!r} is not an integer" - ) - if new_num < 0 or new_num >= num_tasks: - raise ValueError( - f"'new_num' {new_num} is out of range [0, {num_tasks - 1}]" - ) - received_nums.add(new_num) - - if received_nums != expected_nums: - raise ValueError( - "Reorder data must include each task number exactly once " - f"(expected {sorted(expected_nums)}, got {sorted(received_nums)})" - ) - - # First, set all task nums to None to avoid unique constraint issues - task_updates = [] - for item in order_list: - task = self.safe_get_item(Task, item["task_id"]) - new_num = int(item["new_num"]) - if task.contest == contest: - task_updates.append((task, new_num)) - task.num = None - self.sql_session.flush() - - # Then set the new nums - for task, new_num in task_updates: - task.num = new_num - self.sql_session.flush() - - def _detach_task_from_training_day(self, task: Task) -> None: - """Detach a task from its training day. - - This removes the training_day association from the task, making it - available for assignment to new training days. The task remains in - the training program. - - task: the task to detach. - """ - if task.training_day is None: - return - - training_day = task.training_day - training_day_num = task.training_day_num - - task.training_day = None - task.training_day_num = None - - self.sql_session.flush() - - # Reorder remaining tasks in the training day (only if there was a valid position) - if training_day_num is not None: - _shift_task_nums( - self.sql_session, - Task.training_day, - training_day, - Task.training_day_num, - training_day_num, - -1, - ) - - -class AddTrainingProgramTaskHandler(BaseHandler): - """Add a task 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, "tasks") - - training_program = self.safe_get_item(TrainingProgram, training_program_id) - managing_contest = training_program.managing_contest - - try: - task_id: str = self.get_argument("task_id") - assert task_id != "null", "Please select a valid task" - except Exception as error: - self.service.add_notification( - make_datetime(), "Invalid field(s)", repr(error)) - self.redirect(fallback_page) - return - - task = self.safe_get_item(Task, task_id) - - task.num = len(managing_contest.tasks) - task.contest = managing_contest - - if self.try_commit(): - self.service.proxy_service.reinitialize() - - self.redirect(fallback_page) - - -class RemoveTrainingProgramTaskHandler(BaseHandler): - """Remove a task from a training program. - - The confirmation is now handled via a modal in the tasks page. - """ - - @require_permission(BaseHandler.PERMISSION_ALL) - def delete(self, training_program_id: str, task_id: str): - training_program = self.safe_get_item(TrainingProgram, training_program_id) - managing_contest = training_program.managing_contest - task = self.safe_get_item(Task, task_id) - task_num = task.num - - # Remove from training day if assigned - if task.training_day is not None: - training_day = task.training_day - training_day_num = task.training_day_num - task.training_day = None - task.training_day_num = None - - self.sql_session.flush() - - # Reorder remaining tasks in the training day - _shift_task_nums( - self.sql_session, Task.training_day, training_day, - Task.training_day_num, training_day_num, -1 - ) - - # Remove from training program - task.contest = None - task.num = None - - self.sql_session.flush() - - # Reorder remaining tasks in the training program - _shift_task_nums( - self.sql_session, Task.contest, managing_contest, - Task.num, task_num, -1 - ) - - if self.try_commit(): - self.service.proxy_service.reinitialize() - - # Return absolute path to tasks page - self.write(f"../../../training_program/{training_program_id}/tasks") - - -class TrainingProgramRankingHandler(RankingCommonMixin, BaseHandler): - """Show ranking for a training program.""" - - @require_permission(BaseHandler.AUTHENTICATED) - def get(self, training_program_id: str, format: str = "online"): - training_program = self.safe_get_item(TrainingProgram, training_program_id) - managing_contest = training_program.managing_contest - - self.contest = self._load_contest_data(managing_contest.id) - - # Build a dict of (participation_id, task_id) -> bool for tasks that students can access - # A student can access a task if they have a StudentTask record for it - # Default is False since we're whitelisting access via StudentTask - can_access_by_pt = {} - for p in self.contest.participations: - for task in self.contest.get_tasks(): - can_access_by_pt[(p.id, task.id)] = False - - participation_ids = [p.id for p in self.contest.participations] - if participation_ids: - rows = ( - self.sql_session.query(Student.participation_id, StudentTask.task_id) - .join(StudentTask, Student.id == StudentTask.student_id) - .filter(Student.training_program_id == training_program.id) - .filter(Student.participation_id.in_(participation_ids)) - .all() - ) - for participation_id, task_id in rows: - can_access_by_pt[(participation_id, task_id)] = True - - show_teams = self._calculate_scores(self.contest, can_access_by_pt) - - # Store participation data before commit (SQLAlchemy expires attributes on commit) - participation_data = {} - for p in self.contest.participations: - if hasattr(p, "task_statuses"): - participation_data[p.id] = (p.task_statuses, p.total_score) - - # Build student tags lookup for each participation (batch query) - student_tags_by_participation = get_student_tags_by_participation( - self.sql_session, - training_program, - [p.id for p in self.contest.participations] - ) - - # Calculate task archive progress for this training program - task_archive_progress_by_participation = {} - students_query = ( - self.sql_session.query(Student) - .filter(Student.training_program_id == training_program.id) - .all() - ) - student_by_participation_id = {s.participation_id: s for s in students_query} - - for p in self.contest.participations: - student = student_by_participation_id.get(p.id) - if student: - progress = calculate_task_archive_progress( - student, p, self.contest, self.sql_session - ) - task_archive_progress_by_participation[p.id] = progress - - # Commit to release any advisory locks taken during score calculation - self.sql_session.commit() - - # Re-assign task_statuses after commit (SQLAlchemy expired them) - if "participation_data" in locals(): - for p in self.contest.participations: - if p.id in participation_data: - p.task_statuses, p.total_score = participation_data[p.id] - - self.render_params_for_training_program(training_program) - 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["task_archive_progress_by_participation"] = ( - task_archive_progress_by_participation - ) - - if format == "txt": - self.set_header("Content-Type", "text/plain") - filename = f"{training_program.name}_home_ranking.txt".replace( - " ", "_" - ).replace("/", "_") - self.set_header("Content-Disposition", f'attachment; filename="{filename}"') - self.render("ranking.txt", **self.r_params) - elif format == "csv": - self.set_header("Content-Type", "text/csv") - filename = f"{training_program.name}_home_ranking.csv".replace( - " ", "_" - ).replace("/", "_") - self.set_header("Content-Disposition", f'attachment; filename="{filename}"') - - export_participations = sorted( - [p for p in self.contest.participations if not p.hidden], - key=lambda p: p.total_score, - reverse=True, - ) - - csv_content = self._write_csv( - self.contest, - export_participations, - list(self.contest.get_tasks()), - student_tags_by_participation, - show_teams, - include_partial=True, - task_archive_progress_by_participation=task_archive_progress_by_participation, - ) - self.finish(csv_content) - else: - self.render("ranking.html", **self.r_params) class TrainingProgramSubmissionsHandler(BaseHandler): @@ -850,7 +493,9 @@ def get(self, training_program_id: str): self.contest = training_program.managing_contest self.render_params_for_training_program(training_program) - self.r_params["all_student_tags"] = get_all_student_tags(training_program) + self.r_params["all_student_tags"] = get_all_student_tags( + self.sql_session, training_program + ) self.render("announcements.html", **self.r_params) diff --git a/cms/server/admin/handlers/trainingprogramtask.py b/cms/server/admin/handlers/trainingprogramtask.py new file mode 100644 index 0000000000..466745f001 --- /dev/null +++ b/cms/server/admin/handlers/trainingprogramtask.py @@ -0,0 +1,442 @@ +#!/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 Program Tasks and Rankings. + +This module contains handlers for managing tasks within training programs +and displaying training program rankings. + +Handlers: +- TrainingProgramTasksHandler: Manage tasks in a training program +- AddTrainingProgramTaskHandler: Add a task to a training program +- RemoveTrainingProgramTaskHandler: Remove a task from a training program +- TrainingProgramRankingHandler: Show ranking for a training program +""" + +import json +import logging + +from cms.db import ( + Contest, + TrainingProgram, + Task, + Student, + StudentTask, +) +from cms.server.util import calculate_task_archive_progress +from cms.server.admin.handlers.utils import get_student_tags_by_participation +from cmscommon.datetime import make_datetime + +from .base import BaseHandler, require_permission +from .contestranking import RankingCommonMixin + + +def _shift_task_nums( + sql_session, + filter_attr, + filter_value, + num_attr, + threshold: int, + delta: int +) -> None: + """Shift task numbers after insertion or removal. + + This utility function handles the common pattern of incrementing or + decrementing task numbers when a task is added or removed from a + sequence (e.g., contest tasks or training day tasks). + + sql_session: The SQLAlchemy session. + filter_attr: The attribute to filter by (e.g., Task.contest, Task.training_day). + filter_value: The value to filter for. + num_attr: The num attribute to shift (e.g., Task.num, Task.training_day_num). + threshold: The threshold value - tasks with num > threshold will be shifted. + delta: The amount to shift by (+1 for insertion, -1 for removal). + """ + if delta > 0: + # For insertion, process in descending order to avoid conflicts + order = num_attr.desc() + condition = num_attr >= threshold + else: + # For removal, process in ascending order + order = num_attr + condition = num_attr > threshold + + for t in sql_session.query(Task)\ + .filter(filter_attr == filter_value)\ + .filter(condition)\ + .order_by(order)\ + .all(): + setattr(t, num_attr.key, getattr(t, num_attr.key) + delta) + sql_session.flush() + + +class TrainingProgramTasksHandler(BaseHandler): + """Manage tasks in a training program.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + + self.render_params_for_training_program(training_program) + self.r_params["unassigned_tasks"] = \ + self.sql_session.query(Task)\ + .filter(Task.contest_id.is_(None))\ + .all() + + self.render("training_program_tasks.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, "tasks") + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + try: + operation: str = self.get_argument("operation") + + # Handle detach operation for archived training day tasks + if operation.startswith("detach_"): + task_id = operation.split("_", 1)[1] + task = self.safe_get_item(Task, task_id) + # Validate task belongs to this training program + if task.contest != managing_contest: + raise ValueError("Task does not belong to this training program") + self._detach_task_from_training_day(task) + if self.try_commit(): + self.service.proxy_service.reinitialize() + self.redirect(fallback_page) + return + + # Handle reorder operation from drag-and-drop + if operation == "reorder": + reorder_data = self.get_argument("reorder_data", "") + if reorder_data: + self._reorder_tasks(managing_contest, reorder_data) + if self.try_commit(): + self.service.proxy_service.reinitialize() + self.redirect(fallback_page) + return + + except Exception as error: + self.service.add_notification( + make_datetime(), "Invalid field(s)", repr(error)) + self.redirect(fallback_page) + return + + self.redirect(fallback_page) + + def _reorder_tasks(self, contest: Contest, reorder_data: str) -> None: + """Reorder tasks based on drag-and-drop data. + + reorder_data: JSON string with list of {task_id, new_num} objects. + """ + try: + order_list = json.loads(reorder_data) + except json.JSONDecodeError as e: + logging.warning( + "Failed to parse reorder data: %s. Payload: %s", + e.msg, + reorder_data[:500], + ) + raise ValueError(f"Invalid JSON in reorder data: {e.msg}") from e + + if not isinstance(order_list, list): + raise ValueError("Reorder data must be a list") + + expected_ids = {t.id for t in contest.tasks} + received_ids = {int(item.get("task_id")) for item in order_list} + if received_ids != expected_ids: + raise ValueError("Reorder data must include each task exactly once") + + # Validate new_num for each entry (0-based indices) + num_tasks = len(contest.tasks) + expected_nums = set(range(0, num_tasks)) + received_nums = set() + + for item in order_list: + if "new_num" not in item: + raise ValueError("Missing 'new_num' in reorder data entry") + raw_num = item["new_num"] + try: + new_num = int(raw_num) + except (TypeError, ValueError): + raise ValueError( + f"Invalid 'new_num' value: {raw_num!r} is not an integer" + ) + if new_num < 0 or new_num >= num_tasks: + raise ValueError( + f"'new_num' {new_num} is out of range [0, {num_tasks - 1}]" + ) + received_nums.add(new_num) + + if received_nums != expected_nums: + raise ValueError( + "Reorder data must include each task number exactly once " + f"(expected {sorted(expected_nums)}, got {sorted(received_nums)})" + ) + + # First, set all task nums to None to avoid unique constraint issues + task_updates = [] + for item in order_list: + task = self.safe_get_item(Task, item["task_id"]) + new_num = int(item["new_num"]) + if task.contest == contest: + task_updates.append((task, new_num)) + task.num = None + self.sql_session.flush() + + # Then set the new nums + for task, new_num in task_updates: + task.num = new_num + self.sql_session.flush() + + def _detach_task_from_training_day(self, task: Task) -> None: + """Detach a task from its training day. + + This removes the training_day association from the task, making it + available for assignment to new training days. The task remains in + the training program. + + task: the task to detach. + """ + if task.training_day is None: + return + + training_day = task.training_day + training_day_num = task.training_day_num + + task.training_day = None + task.training_day_num = None + + self.sql_session.flush() + + # Reorder remaining tasks in the training day (only if there was a valid position) + if training_day_num is not None: + _shift_task_nums( + self.sql_session, + Task.training_day, + training_day, + Task.training_day_num, + training_day_num, + -1, + ) + + +class AddTrainingProgramTaskHandler(BaseHandler): + """Add a task 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, "tasks") + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + try: + task_id: str = self.get_argument("task_id") + if task_id is None or task_id == "null" or task_id.strip() == "": + raise ValueError("Please select a valid task") + except Exception as error: + self.service.add_notification( + make_datetime(), "Invalid field(s)", repr(error)) + self.redirect(fallback_page) + return + + task = self.safe_get_item(Task, task_id) + + # Verify task is either unassigned or already belongs to this contest + if task.contest is not None and task.contest != managing_contest: + self.service.add_notification( + make_datetime(), + "Invalid field(s)", + "Task already assigned to another contest", + ) + self.redirect(fallback_page) + return + + task.num = len(managing_contest.tasks) + task.contest = managing_contest + + if self.try_commit(): + self.service.proxy_service.reinitialize() + + self.redirect(fallback_page) + + +class RemoveTrainingProgramTaskHandler(BaseHandler): + """Remove a task from a training program. + + The confirmation is now handled via a modal in the tasks page. + """ + + @require_permission(BaseHandler.PERMISSION_ALL) + def delete(self, training_program_id: str, task_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + task = self.safe_get_item(Task, task_id) + task_num = task.num + + # Remove from training day if assigned + if task.training_day is not None: + training_day = task.training_day + training_day_num = task.training_day_num + task.training_day = None + task.training_day_num = None + + self.sql_session.flush() + + # Reorder remaining tasks in the training day + if training_day_num is not None: + _shift_task_nums( + self.sql_session, + Task.training_day, + training_day, + Task.training_day_num, + training_day_num, + -1, + ) + + # Remove from training program + task.contest = None + task.num = None + + self.sql_session.flush() + + # Reorder remaining tasks in the training program + if task_num is not None: + _shift_task_nums( + self.sql_session, Task.contest, managing_contest, Task.num, task_num, -1 + ) + + if self.try_commit(): + self.service.proxy_service.reinitialize() + + # Return absolute path to tasks page + self.write(f"../../../training_program/{training_program_id}/tasks") + + +class TrainingProgramRankingHandler(RankingCommonMixin, BaseHandler): + """Show ranking for a training program.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str, format: str = "online"): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + self.contest = self._load_contest_data(managing_contest.id) + + # Build a dict of (participation_id, task_id) -> bool for tasks that students can access + # A student can access a task if they have a StudentTask record for it + # Default is False since we're whitelisting access via StudentTask + can_access_by_pt = {} + for p in self.contest.participations: + for task in self.contest.get_tasks(): + can_access_by_pt[(p.id, task.id)] = False + + participation_ids = [p.id for p in self.contest.participations] + if participation_ids: + rows = ( + self.sql_session.query(Student.participation_id, StudentTask.task_id) + .join(StudentTask, Student.id == StudentTask.student_id) + .filter(Student.training_program_id == training_program.id) + .filter(Student.participation_id.in_(participation_ids)) + .all() + ) + for participation_id, task_id in rows: + can_access_by_pt[(participation_id, task_id)] = True + + show_teams = self._calculate_scores(self.contest, can_access_by_pt) + + # Store participation data before commit (SQLAlchemy expires attributes on commit) + participation_data = {} + for p in self.contest.participations: + if hasattr(p, "task_statuses"): + participation_data[p.id] = (p.task_statuses, p.total_score) + + # Build student tags lookup for each participation (batch query) + student_tags_by_participation = get_student_tags_by_participation( + self.sql_session, + training_program, + [p.id for p in self.contest.participations] + ) + + # Calculate task archive progress for this training program + task_archive_progress_by_participation = {} + students_query = ( + self.sql_session.query(Student) + .filter(Student.training_program_id == training_program.id) + .all() + ) + student_by_participation_id = {s.participation_id: s for s in students_query} + + for p in self.contest.participations: + student = student_by_participation_id.get(p.id) + if student: + progress = calculate_task_archive_progress( + student, p, self.contest, self.sql_session + ) + task_archive_progress_by_participation[p.id] = progress + + # Commit to release any advisory locks taken during score calculation + self.sql_session.commit() + + # Re-assign task_statuses after commit (SQLAlchemy expired them) + for p in self.contest.participations: + if p.id in participation_data: + p.task_statuses, p.total_score = participation_data[p.id] + + self.render_params_for_training_program(training_program) + 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["task_archive_progress_by_participation"] = ( + task_archive_progress_by_participation + ) + + if format == "txt": + self.set_header("Content-Type", "text/plain") + filename = f"{training_program.name}_home_ranking.txt".replace( + " ", "_" + ).replace("/", "_") + self.set_header("Content-Disposition", f'attachment; filename="{filename}"') + self.render("ranking.txt", **self.r_params) + elif format == "csv": + self.set_header("Content-Type", "text/csv") + filename = f"{training_program.name}_home_ranking.csv".replace( + " ", "_" + ).replace("/", "_") + self.set_header("Content-Disposition", f'attachment; filename="{filename}"') + + export_participations = sorted( + [p for p in self.contest.participations if not p.hidden], + key=lambda p: p.total_score, + reverse=True, + ) + + csv_content = self._write_csv( + self.contest, + export_participations, + list(self.contest.get_tasks()), + student_tags_by_participation, + show_teams, + include_partial=True, + task_archive_progress_by_participation=task_archive_progress_by_participation, + ) + self.finish(csv_content) + else: + self.render("ranking.html", **self.r_params) diff --git a/cms/server/admin/handlers/utils.py b/cms/server/admin/handlers/utils.py new file mode 100644 index 0000000000..19c63faa9e --- /dev/null +++ b/cms/server/admin/handlers/utils.py @@ -0,0 +1,204 @@ +#!/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-only utilities for training programs and related handlers.""" + +import typing + +from sqlalchemy import func, union + +from cms.db import ( + Session, + Student, + Participation, + Question, + DelayRequest, + ArchivedStudentRanking, + TrainingDay, +) + +if typing.TYPE_CHECKING: + from cms.db import TrainingProgram + + +def get_all_student_tags( + sql_session: Session, + training_program: "TrainingProgram", + include_historical: bool = False, +) -> list[str]: + """Get all unique student tags from a training program's students. + + Uses GIN index on student_tags for efficient querying. + + sql_session: The database session. + training_program: The training program to get tags from. + include_historical: If True, also include tags from archived rankings. + + return: Sorted list of unique tags. + """ + current_tags_query = ( + sql_session.query(func.unnest(Student.student_tags).label("tag")) + .filter(Student.training_program_id == training_program.id) + ) + + if include_historical: + training_day_ids = [td.id for td in training_program.training_days] + if training_day_ids: + historical_tags_query = ( + sql_session.query( + func.unnest(ArchivedStudentRanking.student_tags).label("tag") + ) + .filter(ArchivedStudentRanking.training_day_id.in_(training_day_ids)) + ) + combined_query = union(current_tags_query, historical_tags_query) + rows = sql_session.execute(combined_query).fetchall() + return sorted({row[0] for row in rows if row[0]}) + + rows = current_tags_query.distinct().all() + return sorted([row.tag for row in rows if row.tag]) + + +def get_all_training_day_types(training_program: "TrainingProgram") -> list[str]: + """Get all unique training day types from a training program's training days.""" + all_types_set: set[str] = set() + for training_day in training_program.training_days: + if training_day.training_day_types: + all_types_set.update(training_day.training_day_types) + return sorted(all_types_set) + + +def build_user_to_student_map( + training_program: "TrainingProgram", +) -> dict[int, "Student"]: + """Build a mapping of user_id -> Student for efficient lookups.""" + user_to_student: dict[int, "Student"] = {} + for student in training_program.students: + user_to_student[student.participation.user_id] = student + return user_to_student + + +def get_student_tags_by_participation( + sql_session: Session, + training_program: "TrainingProgram", + participation_ids: list[int], +) -> dict[int, list[str]]: + """Get student tags for multiple participations in a training program.""" + result = {pid: [] for pid in participation_ids} + if not participation_ids: + return result + + rows = ( + sql_session.query(Student.participation_id, Student.student_tags) + .filter(Student.training_program_id == training_program.id) + .filter(Student.participation_id.in_(participation_ids)) + .all() + ) + for participation_id, tags in rows: + result[participation_id] = tags or [] + + return result + + +def count_unanswered_questions(sql_session: Session, contest_id: int) -> int: + """Count unanswered questions for a contest.""" + return ( + sql_session.query(Question) + .join(Participation) + .filter(Participation.contest_id == contest_id) + .filter(Question.reply_timestamp.is_(None)) + .filter(Question.ignored.is_(False)) + .count() + ) + + +def count_pending_delay_requests(sql_session: Session, contest_id: int) -> int: + """Count pending delay requests for a contest.""" + return ( + sql_session.query(DelayRequest) + .join(Participation) + .filter(Participation.contest_id == contest_id) + .filter(DelayRequest.status == "pending") + .count() + ) + + +def get_training_day_notifications( + sql_session: Session, + training_day: "TrainingDay", +) -> dict: + """Get notification counts for a training day.""" + if training_day.contest is None: + return {} + + return { + "unanswered_questions": count_unanswered_questions( + sql_session, training_day.contest_id + ), + "pending_delay_requests": count_pending_delay_requests( + sql_session, training_day.contest_id + ), + } + + +def get_all_training_day_notifications( + sql_session: Session, + training_program: "TrainingProgram", +) -> tuple[dict[int, dict], int, int]: + """Get notification counts for all training days in a program.""" + notifications: dict[int, dict] = {} + total_unanswered = 0 + total_pending = 0 + + for td in training_program.training_days: + if td.contest is None: + continue + + td_notifications = get_training_day_notifications(sql_session, td) + notifications[td.id] = td_notifications + total_unanswered += td_notifications.get("unanswered_questions", 0) + total_pending += td_notifications.get("pending_delay_requests", 0) + + return notifications, total_unanswered, total_pending + + +def deduplicate_preserving_order(items: list[str]) -> list[str]: + """Remove duplicates from a list while preserving order.""" + seen: set[str] = set() + unique: list[str] = [] + for item in items: + if item not in seen: + seen.add(item) + unique.append(item) + return unique + + +def parse_tags(tags_str: str) -> list[str]: + """Parse a comma-separated string of tags into a list of normalized tags.""" + if not tags_str: + return [] + + tags = [tag.strip().lower() for tag in tags_str.split(",") if tag.strip()] + return deduplicate_preserving_order(tags) + + +def parse_usernames_from_file(file_content: str) -> list[str]: + """Parse whitespace-separated usernames from file content.""" + if not file_content: + return [] + + usernames = [u.strip() for u in file_content.split() if u.strip()] + return deduplicate_preserving_order(usernames) diff --git a/cms/server/contest/handlers/trainingprogram.py b/cms/server/contest/handlers/trainingprogram.py index e70af33eed..f407d7f7aa 100644 --- a/cms/server/contest/handlers/trainingprogram.py +++ b/cms/server/contest/handlers/trainingprogram.py @@ -21,7 +21,7 @@ including the overview page and training days page. """ -from datetime import timedelta +from datetime import datetime, timedelta import tornado.web from sqlalchemy.orm import joinedload @@ -29,10 +29,85 @@ from cms.db import Participation, Student, ArchivedStudentRanking, Task, TrainingDay from cms.grading.scorecache import get_cached_score_entry from cms.server import multi_contest -from cms.server.util import calculate_task_archive_progress, get_student_for_user_in_program, get_training_day_timing_info, get_submission_counts_by_task +from cms.server.util import ( + calculate_task_archive_progress, + check_training_day_eligibility, + get_student_for_user_in_program, + get_submission_counts_by_task, +) from .contest import ContestHandler +def get_training_day_timing_info( + sql_session, + td_contest, + user, + training_day, + timestamp: datetime, +) -> dict | None: + """Get participation and timing info for a user in a training day contest.""" + from cms.server.contest.phase_management import ( + compute_actual_phase, + compute_effective_times, + ) + + td_participation = ( + sql_session.query(Participation) + .filter(Participation.contest == td_contest) + .filter(Participation.user == user) + .first() + ) + + if td_participation is None: + return None + + is_eligible, main_group, _ = check_training_day_eligibility( + sql_session, td_participation, training_day + ) + if not is_eligible: + return None + + main_group_start = main_group.start_time if main_group else None + main_group_end = main_group.end_time if main_group else None + contest_start, contest_stop = compute_effective_times( + td_contest.start, + td_contest.stop, + td_participation.delay_time, + main_group_start, + main_group_end, + ) + + actual_phase, _, _, _, _ = compute_actual_phase( + timestamp, + contest_start, + contest_stop, + td_contest.analysis_start if td_contest.analysis_enabled else None, + td_contest.analysis_stop if td_contest.analysis_enabled else None, + td_contest.per_user_time, + td_participation.starting_time, + td_participation.delay_time, + td_participation.extra_time, + ) + + user_start_time = contest_start + td_participation.delay_time + + duration = ( + td_contest.per_user_time + if td_contest.per_user_time is not None + else contest_stop - contest_start + ) + + return { + "participation": td_participation, + "main_group": main_group, + "contest_start": contest_start, + "contest_stop": contest_stop, + "actual_phase": actual_phase, + "user_start_time": user_start_time, + "duration": duration, + } + + class TrainingProgramOverviewHandler(ContestHandler): """Training program overview page handler. diff --git a/cms/server/util.py b/cms/server/util.py index 0eef9ec8c4..32428fd46c 100644 --- a/cms/server/util.py +++ b/cms/server/util.py @@ -27,7 +27,7 @@ """ import logging -from datetime import date, datetime, timedelta +from datetime import date, timedelta from functools import wraps from urllib.parse import quote, urlencode @@ -42,7 +42,7 @@ from tornado.web import RequestHandler -from cms.db import Session, Contest, Student, Task, Participation, StudentTask, Question, DelayRequest, Submission +from cms.db import Session, Contest, Student, Task, Participation, StudentTask, Submission from sqlalchemy import func from sqlalchemy.orm import joinedload from cms.grading.scorecache import get_cached_score_entry @@ -73,68 +73,6 @@ def exclude_internal_contests(query): ) -def get_all_student_tags(training_program: "TrainingProgram") -> list[str]: - """Get all unique student tags from a training program's students. - - This is a shared utility to avoid duplicating tag collection logic - across multiple handlers. - - training_program: the training program to get tags from. - - return: sorted list of unique student tags. - - """ - all_tags_set: set[str] = set() - for student in training_program.students: - if student.student_tags: - all_tags_set.update(student.student_tags) - return sorted(all_tags_set) - - -def get_all_student_tags_with_historical( - training_program: "TrainingProgram" -) -> list[str]: - """Get all unique student tags including historical tags from archived rankings. - - This includes both current student tags and tags that students had during - past training days (stored in ArchivedStudentRanking.student_tags). - - training_program: the training program to get tags from. - - return: sorted list of unique student tags (current + historical). - - """ - all_tags_set: set[str] = set() - # Collect current tags - for student in training_program.students: - if student.student_tags: - all_tags_set.update(student.student_tags) - # Collect historical tags from archived rankings - for training_day in training_program.training_days: - for ranking in training_day.archived_student_rankings: - if ranking.student_tags: - all_tags_set.update(ranking.student_tags) - return sorted(all_tags_set) - - -def get_all_training_day_types(training_program: "TrainingProgram") -> list[str]: - """Get all unique training day types from a training program's training days. - - This is a shared utility to avoid duplicating tag collection logic - across multiple handlers. - - training_program: the training program to get types from. - - return: sorted list of unique training day types. - - """ - all_types_set: set[str] = set() - for training_day in training_program.training_days: - if training_day.training_day_types: - all_types_set.update(training_day.training_day_types) - return sorted(all_types_set) - - def get_student_for_training_day( sql_session: Session, participation: "Participation", @@ -210,91 +148,6 @@ def check_training_day_eligibility( return False, None, matching_tags -def get_training_day_timing_info( - sql_session: Session, - td_contest: "Contest", - user: "User", - training_day: "TrainingDay", - timestamp: datetime -) -> dict | None: - """Get participation and timing info for a user in a training day contest. - - This is a common pattern used to check if a user can access a training day - and compute the effective timing information. - - sql_session: the database session. - td_contest: the training day's contest. - user: the user to check. - training_day: the training day. - timestamp: current timestamp for computing actual phase. - - return: dict with timing info, or None if user is not eligible. - - participation: the user's Participation in the training day contest - - main_group: the TrainingDayGroup if applicable - - contest_start: effective contest start time - - contest_stop: effective contest stop time - - actual_phase: the computed actual phase - - user_start_time: user-specific start time (contest_start + delay) - - duration: contest duration - - """ - from cms.server.contest.phase_management import ( - compute_actual_phase, compute_effective_times - ) - - td_participation = ( - sql_session.query(Participation) - .filter(Participation.contest == td_contest) - .filter(Participation.user == user) - .first() - ) - - if td_participation is None: - return None - - is_eligible, main_group, _ = check_training_day_eligibility( - sql_session, td_participation, training_day - ) - if not is_eligible: - return None - - main_group_start = main_group.start_time if main_group else None - main_group_end = main_group.end_time if main_group else None - contest_start, contest_stop = compute_effective_times( - td_contest.start, td_contest.stop, - td_participation.delay_time, - main_group_start, main_group_end - ) - - actual_phase, _, _, _, _ = compute_actual_phase( - timestamp, - contest_start, - contest_stop, - td_contest.analysis_start if td_contest.analysis_enabled else None, - td_contest.analysis_stop if td_contest.analysis_enabled else None, - td_contest.per_user_time, - td_participation.starting_time, - td_participation.delay_time, - td_participation.extra_time, - ) - - user_start_time = contest_start + td_participation.delay_time - - duration = td_contest.per_user_time \ - if td_contest.per_user_time is not None else \ - contest_stop - contest_start - - return { - "participation": td_participation, - "main_group": main_group, - "contest_start": contest_start, - "contest_stop": contest_stop, - "actual_phase": actual_phase, - "user_start_time": user_start_time, - "duration": duration, - } - - def can_access_task(sql_session: Session, task: "Task", participation: "Participation", training_day: "TrainingDay | None") -> bool: """Check if a participation can access the given task. @@ -475,39 +328,6 @@ def get_student_for_user_in_program( ).first() -def get_student_tags_by_participation( - sql_session: Session, - training_program: "TrainingProgram", - participation_ids: list[int] -) -> dict[int, list[str]]: - """Get student tags for multiple participations in a training program. - - This is a batch query utility that efficiently fetches student tags - for multiple participations at once, avoiding N+1 query patterns. - - sql_session: the database session. - training_program: the training program to search in. - participation_ids: list of participation IDs to look up. - - return: dict mapping participation_id to list of student tags. - - """ - result = {pid: [] for pid in participation_ids} - if not participation_ids: - return result - - rows = ( - sql_session.query(Student.participation_id, Student.student_tags) - .filter(Student.training_program_id == training_program.id) - .filter(Student.participation_id.in_(participation_ids)) - .all() - ) - for participation_id, tags in rows: - result[participation_id] = tags or [] - - return result - - def get_submission_counts_by_task( sql_session: Session, participation_id: int, @@ -541,106 +361,6 @@ def get_submission_counts_by_task( return dict(counts) -def count_unanswered_questions(sql_session: Session, contest_id: int) -> int: - """Count unanswered questions for a contest. - - This counts questions that have not been replied to and are not ignored. - - sql_session: the database session. - contest_id: the contest ID to count questions for. - - return: count of unanswered questions. - - """ - return ( - sql_session.query(Question) - .join(Participation) - .filter(Participation.contest_id == contest_id) - .filter(Question.reply_timestamp.is_(None)) - .filter(Question.ignored.is_(False)) - .count() - ) - - -def count_pending_delay_requests(sql_session: Session, contest_id: int) -> int: - """Count pending delay requests for a contest. - - sql_session: the database session. - contest_id: the contest ID to count delay requests for. - - return: count of pending delay requests. - - """ - return ( - sql_session.query(DelayRequest) - .join(Participation) - .filter(Participation.contest_id == contest_id) - .filter(DelayRequest.status == "pending") - .count() - ) - - -def get_training_day_notifications( - sql_session: Session, - training_day: "TrainingDay" -) -> dict: - """Get notification counts for a training day. - - Returns a dict with unanswered_questions and pending_delay_requests counts. - - sql_session: the database session. - training_day: the training day to get notifications for. - - return: dict with notification counts, or empty dict if training day has no contest. - - """ - if training_day.contest is None: - return {} - - return { - "unanswered_questions": count_unanswered_questions( - sql_session, training_day.contest_id - ), - "pending_delay_requests": count_pending_delay_requests( - sql_session, training_day.contest_id - ), - } - - -def get_all_training_day_notifications( - sql_session: Session, - training_program: "TrainingProgram" -) -> tuple[dict[int, dict], int, int]: - """Get notification counts for all training days in a program. - - Returns notification counts for each active training day (those with a contest), - plus totals across all training days. - - sql_session: the database session. - training_program: the training program to get notifications for. - - return: tuple of (notifications_by_td_id, total_unanswered, total_pending) - - notifications_by_td_id: dict mapping training_day.id to notification dict - - total_unanswered: total unanswered questions across all training days - - total_pending: total pending delay requests across all training days - - """ - notifications: dict[int, dict] = {} - total_unanswered = 0 - total_pending = 0 - - for td in training_program.training_days: - if td.contest is None: - continue - - td_notifications = get_training_day_notifications(sql_session, td) - notifications[td.id] = td_notifications - total_unanswered += td_notifications.get("unanswered_questions", 0) - total_pending += td_notifications.get("pending_delay_requests", 0) - - return notifications, total_unanswered, total_pending - - # TODO: multi_contest is only relevant for CWS def multi_contest(f): """Return decorator swallowing the contest name if in multi contest mode. @@ -843,66 +563,3 @@ def finish(self, *args, **kwargs): @property def service(self): return self.application.service - - -def deduplicate_preserving_order(items: list[str]) -> list[str]: - """Remove duplicates from a list while preserving order. - - Args: - items: List of strings that may contain duplicates - - Returns: - List of strings with duplicates removed, preserving original order - """ - seen: set[str] = set() - unique: list[str] = [] - for item in items: - if item not in seen: - seen.add(item) - unique.append(item) - return unique - - -def parse_tags(tags_str: str) -> list[str]: - """Parse a comma-separated string of tags into a list of normalized tags. - - This utility handles: - - Splitting by comma - - Stripping whitespace - - converting to lowercase - - Removing empty tags - - Deduplicating while preserving order - - Args: - tags_str: Comma-separated string of tags - - Returns: - List of unique, normalized tags - """ - if not tags_str: - return [] - - tags = [tag.strip().lower() for tag in tags_str.split(",") if tag.strip()] - return deduplicate_preserving_order(tags) - - -def parse_usernames_from_file(file_content: str) -> list[str]: - """Parse whitespace-separated usernames from file content. - - This utility handles: - - Splitting by whitespace (spaces, newlines, tabs) - - Stripping whitespace from each username - - Removing empty entries - - Deduplicating while preserving order - - Args: - file_content: String content of the uploaded file - - Returns: - List of unique usernames in order of first appearance - """ - if not file_content: - return [] - - usernames = [u.strip() for u in file_content.split() if u.strip()] - return deduplicate_preserving_order(usernames)