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)