diff --git a/cms/db/__init__.py b/cms/db/__init__.py
index c228ec654f..001e8bafa1 100644
--- a/cms/db/__init__.py
+++ b/cms/db/__init__.py
@@ -55,6 +55,7 @@
# contest
"Contest", "Announcement", "ContestFolder", "TrainingProgram", "Student",
"TrainingDay", "TrainingDayGroup", "StudentTask",
+ "ArchivedAttendance", "ArchivedStudentRanking",
# user
"User", "Team", "Participation", "Message", "Question", "DelayRequest",
# admin
@@ -110,6 +111,8 @@
from .training_day_group import TrainingDayGroup
from .student import Student
from .student_task import StudentTask
+from .archived_attendance import ArchivedAttendance
+from .archived_student_ranking import ArchivedStudentRanking
from .user import User, Team, Participation, Message, Question, DelayRequest
from .task import Task, Statement, Attachment, Dataset, Manager, Testcase, \
Generator
diff --git a/cms/db/archived_attendance.py b/cms/db/archived_attendance.py
new file mode 100644
index 0000000000..a0ae2d6d6a
--- /dev/null
+++ b/cms/db/archived_attendance.py
@@ -0,0 +1,98 @@
+#!/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 .
+
+"""Archived attendance model for training days.
+
+ArchivedAttendance stores attendance data for students after a training day
+is archived. This includes participation status, location (class/home),
+delay time, and delay reasons.
+"""
+
+import typing
+from datetime import timedelta
+
+from sqlalchemy.orm import relationship
+from sqlalchemy.schema import Column, ForeignKey, UniqueConstraint
+from sqlalchemy.types import Integer, Unicode, Interval
+
+from . import Base
+
+if typing.TYPE_CHECKING:
+ from . import TrainingDay, Student
+
+
+class ArchivedAttendance(Base):
+ """Archived attendance data for a student in a training day.
+
+ This stores immutable attendance information after a training day is
+ archived, including whether the student participated, their location
+ (class or home), delay time, and delay reasons.
+ """
+ __tablename__ = "archived_attendances"
+ __table_args__ = (
+ UniqueConstraint("training_day_id", "student_id",
+ name="archived_attendances_training_day_id_student_id_key"),
+ )
+
+ id: int = Column(Integer, primary_key=True)
+
+ training_day_id: int = Column(
+ Integer,
+ ForeignKey("training_days.id", onupdate="CASCADE", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+
+ student_id: int = Column(
+ Integer,
+ ForeignKey("students.id", onupdate="CASCADE", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+
+ # "participated" if starting_time exists, "missed" otherwise
+ status: str = Column(
+ Unicode,
+ nullable=False,
+ )
+
+ # "class", "home", or "both"
+ location: str | None = Column(
+ Unicode,
+ nullable=True,
+ )
+
+ # Delay time copied from participation
+ delay_time: timedelta | None = Column(
+ Interval,
+ nullable=True,
+ )
+
+ # Concatenated reasons from all delay requests
+ delay_reasons: str | None = Column(
+ Unicode,
+ nullable=True,
+ )
+
+ training_day: "TrainingDay" = relationship(
+ "TrainingDay",
+ back_populates="archived_attendances",
+ )
+
+ student: "Student" = relationship(
+ "Student",
+ )
diff --git a/cms/db/archived_student_ranking.py b/cms/db/archived_student_ranking.py
new file mode 100644
index 0000000000..352381cbd9
--- /dev/null
+++ b/cms/db/archived_student_ranking.py
@@ -0,0 +1,106 @@
+#!/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 .
+
+"""Archived student ranking model for training days.
+
+ArchivedStudentRanking stores ranking data for students after a training day
+is archived. This includes the student's tags, task scores, and score history
+for rendering ranking graphs.
+"""
+
+import typing
+
+from sqlalchemy.dialects.postgresql import ARRAY, JSONB
+from sqlalchemy.orm import relationship
+from sqlalchemy.schema import Column, ForeignKey, Index, UniqueConstraint
+from sqlalchemy.types import Integer, Unicode
+
+from . import Base
+
+if typing.TYPE_CHECKING:
+ from . import TrainingDay, Student
+
+
+class ArchivedStudentRanking(Base):
+ """Archived ranking data for a student in a training day.
+
+ This stores immutable ranking information after a training day is
+ archived, including the student's tags during the training day,
+ their final scores for each task, and their score history in the
+ format expected by the JavaScript ranking graph renderer.
+ """
+ __tablename__ = "archived_student_rankings"
+ __table_args__ = (
+ UniqueConstraint("training_day_id", "student_id",
+ name="archived_student_rankings_training_day_id_student_id_key"),
+ Index("ix_archived_student_rankings_student_tags_gin", "student_tags",
+ postgresql_using="gin"),
+ )
+
+ id: int = Column(Integer, primary_key=True)
+
+ training_day_id: int = Column(
+ Integer,
+ ForeignKey("training_days.id", onupdate="CASCADE", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+
+ student_id: int = Column(
+ Integer,
+ ForeignKey("students.id", onupdate="CASCADE", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+
+ # All tags the student had during this training day (stored as array for efficient filtering)
+ student_tags: list[str] = Column(
+ ARRAY(Unicode),
+ nullable=False,
+ default=list,
+ )
+
+ # Final scores for each task: {task_id: score}
+ # Includes all visible tasks (even with 0 score), not just non-zero scores.
+ # The presence of a task_id key indicates the task was visible to this student.
+ task_scores: dict | None = Column(
+ JSONB,
+ nullable=True,
+ )
+
+ # Submissions for each task: {task_id: [{task, time, score, token, extra}, ...]}
+ # Format matches RWS submission format for rendering in UserDetail.js
+ submissions: dict | None = Column(
+ JSONB,
+ nullable=True,
+ )
+
+ # Score history in RWS format: [[user_id, task_id, time, score], ...]
+ # This is the format expected by HistoryStore.js for rendering graphs
+ history: list | None = Column(
+ JSONB,
+ nullable=True,
+ )
+
+ training_day: "TrainingDay" = relationship(
+ "TrainingDay",
+ back_populates="archived_student_rankings",
+ )
+
+ student: "Student" = relationship(
+ "Student",
+ )
diff --git a/cms/db/training_day.py b/cms/db/training_day.py
index bf6751c6b1..027b803778 100644
--- a/cms/db/training_day.py
+++ b/cms/db/training_day.py
@@ -23,14 +23,17 @@
import typing
+from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship, Session
from sqlalchemy.schema import Column, ForeignKey, UniqueConstraint
-from sqlalchemy.types import Integer
+from sqlalchemy.types import DateTime, Integer, Interval, Unicode
from . import Base
if typing.TYPE_CHECKING:
+ from datetime import datetime, timedelta
from . import Contest, TrainingProgram, Task, TrainingDayGroup, Submission, Participation, User
+ from . import ArchivedAttendance, ArchivedStudentRanking
def get_managing_participation(
@@ -81,10 +84,10 @@ class TrainingDay(Base):
index=True,
)
- contest_id: int = Column(
+ contest_id: int | None = Column(
Integer,
- ForeignKey("contests.id", onupdate="CASCADE", ondelete="CASCADE"),
- nullable=False,
+ ForeignKey("contests.id", onupdate="CASCADE", ondelete="SET NULL"),
+ nullable=True,
unique=True,
index=True,
)
@@ -94,12 +97,47 @@ class TrainingDay(Base):
nullable=True,
)
+ # Name and description are synced with contest while contest exists.
+ # After archiving (when contest is deleted), these fields preserve the values.
+ name: str | None = Column(
+ Unicode,
+ nullable=True,
+ )
+
+ description: str | None = Column(
+ Unicode,
+ nullable=True,
+ )
+
+ # Start time is synced with contest while contest exists.
+ # After archiving (when contest is deleted), this field preserves the value.
+ start_time: "datetime | None" = Column(
+ DateTime,
+ nullable=True,
+ )
+
+ # Task metadata at archive time: {task_id: {name, short_name, max_score, score_precision, extra_headers}}
+ # Preserves the scoring scheme as it was during the training day.
+ # Stored at training day level (not per-student) since it's the same for all students.
+ archived_tasks_data: dict | None = Column(
+ JSONB,
+ nullable=True,
+ )
+
+ # Duration of the training day at archive time.
+ # Calculated as the max training duration among main groups (if any),
+ # or the training day duration (if no main groups).
+ duration: "timedelta | None" = Column(
+ Interval,
+ nullable=True,
+ )
+
training_program: "TrainingProgram" = relationship(
"TrainingProgram",
back_populates="training_days",
)
- contest: "Contest" = relationship(
+ contest: "Contest | None" = relationship(
"Contest",
back_populates="training_day",
)
@@ -121,3 +159,15 @@ class TrainingDay(Base):
back_populates="training_day",
passive_deletes=True,
)
+
+ archived_attendances: list["ArchivedAttendance"] = relationship(
+ "ArchivedAttendance",
+ back_populates="training_day",
+ cascade="all, delete-orphan",
+ )
+
+ archived_student_rankings: list["ArchivedStudentRanking"] = relationship(
+ "ArchivedStudentRanking",
+ back_populates="training_day",
+ cascade="all, delete-orphan",
+ )
diff --git a/cms/server/admin/handlers/__init__.py b/cms/server/admin/handlers/__init__.py
index d987fac6a4..833c8f9dc8 100644
--- a/cms/server/admin/handlers/__init__.py
+++ b/cms/server/admin/handlers/__init__.py
@@ -184,7 +184,12 @@
RemoveTrainingDayHandler, \
AddTrainingDayGroupHandler, \
UpdateTrainingDayGroupsHandler, \
- RemoveTrainingDayGroupHandler
+ RemoveTrainingDayGroupHandler, \
+ ArchiveTrainingDayHandler, \
+ TrainingProgramAttendanceHandler, \
+ TrainingProgramCombinedRankingHandler, \
+ TrainingProgramCombinedRankingHistoryHandler, \
+ TrainingProgramCombinedRankingDetailHandler
HANDLERS = [
@@ -356,6 +361,11 @@
(r"/training_program/([0-9]+)/training_days", TrainingProgramTrainingDaysHandler),
(r"/training_program/([0-9]+)/training_days/add", AddTrainingDayHandler),
(r"/training_program/([0-9]+)/training_day/([0-9]+)/remove", RemoveTrainingDayHandler),
+ (r"/training_program/([0-9]+)/training_day/([0-9]+)/archive", ArchiveTrainingDayHandler),
+ (r"/training_program/([0-9]+)/attendance", TrainingProgramAttendanceHandler),
+ (r"/training_program/([0-9]+)/combined_ranking", TrainingProgramCombinedRankingHandler),
+ (r"/training_program/([0-9]+)/combined_ranking/history", TrainingProgramCombinedRankingHistoryHandler),
+ (r"/training_program/([0-9]+)/student/([0-9]+)/combined_ranking_detail", TrainingProgramCombinedRankingDetailHandler),
# Training day groups (main groups configuration on contest page)
(r"/contest/([0-9]+)/training_day_group/add", AddTrainingDayGroupHandler),
diff --git a/cms/server/admin/handlers/submissiondownload.py b/cms/server/admin/handlers/submissiondownload.py
index 58d1ef5878..d3c10b86e1 100644
--- a/cms/server/admin/handlers/submissiondownload.py
+++ b/cms/server/admin/handlers/submissiondownload.py
@@ -67,7 +67,14 @@ def sanitize_path_component(name: str) -> str:
def get_source_folder(submission):
"""Get the source folder name for a submission."""
if submission.training_day_id is not None:
- return sanitize_path_component(submission.training_day.contest.description)
+ training_day = submission.training_day
+ if training_day.contest is not None:
+ return sanitize_path_component(training_day.contest.description)
+ else:
+ # Archived training day - use stored description or name
+ return sanitize_path_component(
+ training_day.description or training_day.name or "archived_training_day"
+ )
return "task_archive"
diff --git a/cms/server/admin/handlers/trainingprogram.py b/cms/server/admin/handlers/trainingprogram.py
index b15a908053..e8a5fd3942 100644
--- a/cms/server/admin/handlers/trainingprogram.py
+++ b/cms/server/admin/handlers/trainingprogram.py
@@ -21,16 +21,33 @@
Each training program has a managing contest that handles all submissions.
"""
-from datetime import datetime as dt
+from datetime import datetime as dt, timedelta
import tornado.web
from sqlalchemy import func
-from cms.db import Contest, TrainingProgram, Participation, Submission, \
- User, Task, Question, Announcement, Student, StudentTask, Team, \
- TrainingDay, TrainingDayGroup
-from cms.server.util import get_all_student_tags, deduplicate_preserving_order, calculate_task_archive_progress
+from cms.db import (
+ Contest,
+ TrainingProgram,
+ Participation,
+ Submission,
+ User,
+ Task,
+ Question,
+ Announcement,
+ Student,
+ StudentTask,
+ Team,
+ TrainingDay,
+ TrainingDayGroup,
+ ArchivedAttendance,
+ ArchivedStudentRanking,
+ ScoreHistory,
+ DelayRequest,
+)
+from cms.db.training_day import get_managing_participation
+from cms.server.util import get_all_student_tags, deduplicate_preserving_order, calculate_task_archive_progress, can_access_task
from cmscommon.datetime import make_datetime
from .base import BaseHandler, SimpleHandler, require_permission
@@ -445,6 +462,9 @@ def post(self, training_program_id: str):
# Also add the student to all existing training days
for training_day in training_program.training_days:
+ # Skip training days that don't have a contest yet
+ if training_day.contest is None:
+ continue
td_participation = Participation(
contest=training_day.contest,
user=user
@@ -1520,17 +1540,22 @@ def get(self, training_program_id: str, training_day_id: str):
# Stats for warning message
self.r_params["task_count"] = len(training_day.tasks)
- self.r_params["participation_count"] = (
- self.sql_session.query(Participation)
- .filter(Participation.contest_id == training_day.contest_id)
- .count()
- )
- self.r_params["submission_count"] = (
- self.sql_session.query(Submission)
- .join(Participation)
- .filter(Participation.contest_id == training_day.contest_id)
- .count()
- )
+ # For archived training days, contest_id is None so counts are 0
+ if training_day.contest_id is not None:
+ self.r_params["participation_count"] = (
+ self.sql_session.query(Participation)
+ .filter(Participation.contest_id == training_day.contest_id)
+ .count()
+ )
+ self.r_params["submission_count"] = (
+ self.sql_session.query(Submission)
+ .join(Participation)
+ .filter(Participation.contest_id == training_day.contest_id)
+ .count()
+ )
+ else:
+ self.r_params["participation_count"] = 0
+ self.r_params["submission_count"] = 0
self.render("training_day_remove.html", **self.r_params)
@@ -1562,7 +1587,8 @@ def delete(self, training_program_id: str, training_day_id: str):
self.sql_session.flush()
self.sql_session.delete(training_day)
- self.sql_session.delete(contest)
+ if contest is not None:
+ self.sql_session.delete(contest)
self.sql_session.flush()
@@ -2006,3 +2032,783 @@ def post(self, training_program_id: str):
)
self.redirect(fallback_page)
+
+
+class ArchiveTrainingDayHandler(BaseHandler):
+ """Archive a training day, extracting attendance and ranking data."""
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def get(self, training_program_id: str, training_day_id: str):
+ """Show the archive confirmation page with IP selection."""
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ training_day = self.safe_get_item(TrainingDay, training_day_id)
+
+ if training_day.training_program_id != training_program.id:
+ raise tornado.web.HTTPError(404, "Training day not in this program")
+
+ if training_day.contest is None:
+ raise tornado.web.HTTPError(400, "Training day is already archived")
+
+ contest = training_day.contest
+
+ # Get all participations with their starting IPs
+ # Count students per IP (only IPs with more than one student)
+ ip_counts: dict[str, int] = {}
+ for participation in contest.participations:
+ if participation.starting_ip_addresses:
+ # Parse comma-separated IP addresses
+ ips = [ip.strip() for ip in participation.starting_ip_addresses.split(",") if ip.strip()]
+ for ip in ips:
+ ip_counts[ip] = ip_counts.get(ip, 0) + 1
+
+ # Filter to only IPs with more than one student
+ shared_ips = {ip: count for ip, count in ip_counts.items() if count > 1}
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["training_day"] = training_day
+ self.r_params["contest"] = contest
+ self.r_params["shared_ips"] = shared_ips
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == training_program.managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+ self.render("archive_training_day.html", **self.r_params)
+
+ @require_permission(BaseHandler.PERMISSION_ALL)
+ def post(self, training_program_id: str, training_day_id: str):
+ """Perform the archiving operation."""
+ fallback_page = self.url(
+ "training_program", training_program_id, "training_days"
+ )
+
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ training_day = self.safe_get_item(TrainingDay, training_day_id)
+
+ if training_day.training_program_id != training_program.id:
+ raise tornado.web.HTTPError(404, "Training day not in this program")
+
+ if training_day.contest is None:
+ self.service.add_notification(
+ make_datetime(), "Error", "Training day is already archived"
+ )
+ self.redirect(fallback_page)
+ return
+
+ contest = training_day.contest
+
+ # Get selected class IPs from form
+ class_ips = set(self.get_arguments("class_ips"))
+
+ try:
+ # Save name, description, and start_time from contest before archiving
+ training_day.name = contest.name
+ training_day.description = contest.description
+ training_day.start_time = contest.start
+
+ # Calculate and store the training day duration
+ # Use max duration among main groups (if any), or training day duration
+ training_day.duration = self._calculate_training_day_duration(
+ training_day, contest
+ )
+
+ # Archive attendance data for each student
+ self._archive_attendance_data(training_day, contest, class_ips)
+
+ # Archive ranking data for each student
+ self._archive_ranking_data(training_day, contest)
+
+ # Delete the contest (this will cascade delete participations)
+ self.sql_session.delete(contest)
+
+ except Exception as error:
+ self.service.add_notification(
+ make_datetime(), "Archive failed", repr(error)
+ )
+ self.redirect(fallback_page)
+ return
+
+ if self.try_commit():
+ self.service.add_notification(
+ make_datetime(),
+ "Training day archived",
+ f"Training day '{training_day.name}' has been archived successfully"
+ )
+
+ self.redirect(fallback_page)
+
+ def _calculate_training_day_duration(
+ self,
+ training_day: TrainingDay,
+ contest: Contest
+ ) -> timedelta | None:
+ """Calculate the training day duration for archiving.
+
+ Returns the max training duration among main groups (if any),
+ or the training day duration (if no main groups).
+
+ training_day: the training day being archived.
+ contest: the contest associated with the training day.
+
+ return: the duration as a timedelta, or None if not calculable.
+ """
+ # Check if there are main groups with custom timing
+ main_groups = training_day.groups
+ if main_groups:
+ # Calculate max duration among main groups
+ max_duration: timedelta | None = None
+ for group in main_groups:
+ if group.start_time is not None and group.end_time is not None:
+ group_duration = group.end_time - group.start_time
+ if max_duration is None or group_duration > max_duration:
+ max_duration = group_duration
+ if max_duration is not None:
+ return max_duration
+
+ # Fall back to training day (contest) duration
+ if contest.start is not None and contest.stop is not None:
+ return contest.stop - contest.start
+
+ return None
+
+ def _archive_attendance_data(
+ self,
+ training_day: TrainingDay,
+ contest: Contest,
+ class_ips: set[str]
+ ) -> None:
+ """Extract and store attendance data for all students."""
+ training_program = training_day.training_program
+
+ for participation in contest.participations:
+ # Find the student for this user in the training program
+ # Note: Student.participation_id points to the managing contest participation,
+ # not the training day participation, so we need to look up by user_id
+ student = (
+ self.sql_session.query(Student)
+ .join(Participation)
+ .filter(Participation.user_id == participation.user_id)
+ .filter(Student.training_program_id == training_program.id)
+ .first()
+ )
+
+ if student is None:
+ continue
+
+ # Determine status
+ if participation.starting_time is None:
+ status = "missed"
+ else:
+ status = "participated"
+
+ # Determine location based on starting IPs
+ # If no class IPs were selected, everyone who participated is considered "home"
+ location = None
+ if status == "participated":
+ if not class_ips:
+ # No class IPs selected means everyone is at home
+ location = "home"
+ elif participation.starting_ip_addresses:
+ # Parse comma-separated IP addresses
+ ips = [ip.strip() for ip in participation.starting_ip_addresses.split(",") if ip.strip()]
+ if ips:
+ has_class_ip = any(ip in class_ips for ip in ips)
+ has_home_ip = any(ip not in class_ips for ip in ips)
+
+ if has_class_ip and has_home_ip:
+ location = "both"
+ elif has_class_ip:
+ location = "class"
+ elif has_home_ip:
+ location = "home"
+ else:
+ # Participated but no IP recorded - assume home
+ location = "home"
+ else:
+ # Participated but no IP recorded - assume home
+ location = "home"
+
+ # Get delay time
+ delay_time = participation.delay_time
+
+ # Concatenate delay reasons from all delay requests
+ delay_requests = (
+ self.sql_session.query(DelayRequest)
+ .filter(DelayRequest.participation_id == participation.id)
+ .order_by(DelayRequest.request_timestamp)
+ .all()
+ )
+ delay_reasons = None
+ if delay_requests:
+ reasons = [dr.reason for dr in delay_requests if dr.reason]
+ if reasons:
+ delay_reasons = "; ".join(reasons)
+
+ # Create archived attendance record
+ archived_attendance = ArchivedAttendance(
+ status=status,
+ location=location,
+ delay_time=delay_time,
+ delay_reasons=delay_reasons,
+ )
+ archived_attendance.training_day_id = training_day.id
+ archived_attendance.student_id = student.id
+ self.sql_session.add(archived_attendance)
+
+ def _archive_ranking_data(
+ self,
+ training_day: TrainingDay,
+ contest: Contest
+ ) -> None:
+ """Extract and store ranking data for all students.
+
+ Stores on TrainingDay:
+ - archived_tasks_data: task metadata including extra_headers for submission table
+
+ Stores on ArchivedStudentRanking (per student):
+ - task_scores: scores for ALL visible tasks (including 0 scores)
+ The presence of a task_id key indicates the task was visible.
+ - submissions: submission data for each task in RWS format
+ - history: score history in RWS format
+ """
+ from cms.grading.scorecache import get_cached_score_entry
+
+ training_program = training_day.training_program
+
+ # Get the tasks assigned to this training day
+ training_day_tasks = training_day.tasks
+ training_day_task_ids = {task.id for task in training_day_tasks}
+
+ # Build and store tasks_data on the training day (same for all students)
+ # This preserves the scoring scheme as it was during the training day
+ archived_tasks_data: dict[str, dict] = {}
+ for task in training_day_tasks:
+ max_score = 100.0
+ extra_headers: list[str] = []
+ score_precision = task.score_precision
+ if task.active_dataset:
+ try:
+ score_type = task.active_dataset.score_type_object
+ max_score = score_type.max_score
+ extra_headers = score_type.ranking_headers
+ except (KeyError, TypeError, AttributeError):
+ pass
+
+ archived_tasks_data[str(task.id)] = {
+ "name": task.title,
+ "short_name": task.name,
+ "max_score": max_score,
+ "score_precision": score_precision,
+ "extra_headers": extra_headers,
+ "training_day_num": task.training_day_num,
+ }
+ training_day.archived_tasks_data = archived_tasks_data
+
+ for participation in contest.participations:
+ # Find the student for this user in the training program
+ # Note: Student.participation_id points to the managing contest participation,
+ # not the training day participation, so we need to look up by user_id
+ student = (
+ self.sql_session.query(Student)
+ .join(Participation)
+ .filter(Participation.user_id == participation.user_id)
+ .filter(Student.training_program_id == training_program.id)
+ .first()
+ )
+
+ if student is None:
+ continue
+
+ # Get all student tags (as list for array storage)
+ student_tags = list(student.student_tags) if student.student_tags else []
+
+ # Determine which tasks should be visible to this student based on their tags
+ # This uses the same logic as _add_training_day_tasks_to_student in StartHandler
+ # A task is visible if:
+ # - The task has no visible_to_tags (empty list = visible to all)
+ # - The student has at least one tag matching the task's visible_to_tags
+ visible_tasks: list[Task] = []
+ for task in training_day_tasks:
+ if can_access_task(self.sql_session, task, participation, training_day):
+ visible_tasks.append(task)
+
+ # Add visible tasks to student's StudentTask records if not already present
+ # This allows students who missed the training to still submit from home
+ existing_task_ids = {st.task_id for st in student.student_tasks}
+ for task in visible_tasks:
+ if task.id not in existing_task_ids:
+ student_task = StudentTask(assigned_at=make_datetime())
+ student_task.student_id = student.id
+ student_task.task_id = task.id
+ student_task.source_training_day_id = training_day.id
+ self.sql_session.add(student_task)
+
+ # Skip students who missed the training
+ if participation.starting_time is None:
+ continue
+
+ # Get the managing participation for this user
+ # Submissions are stored with the managing contest participation, not the
+ # training day participation
+ managing_participation = get_managing_participation(
+ self.sql_session, training_day, participation.user
+ )
+ if managing_participation is None:
+ continue
+
+ # Get task scores for ALL visible tasks (including 0 scores)
+ # The presence of a task_id key indicates the task was visible
+ task_scores: dict[str, float] = {}
+ submissions: dict[str, list[dict]] = {}
+
+ for task in visible_tasks:
+ task_id = task.id
+
+ # Get score from the training day participation (for cache lookup)
+ cache_entry = get_cached_score_entry(
+ self.sql_session, participation, task
+ )
+ task_scores[str(task_id)] = cache_entry.score
+
+ # Get official submissions for this task from the managing participation
+ task_submissions = (
+ self.sql_session.query(Submission)
+ .filter(Submission.participation_id == managing_participation.id)
+ .filter(Submission.task_id == task_id)
+ .filter(Submission.training_day_id == training_day.id)
+ .filter(Submission.official.is_(True))
+ .order_by(Submission.timestamp)
+ .all()
+ )
+
+ submissions[str(task_id)] = []
+ for sub in task_submissions:
+ result = sub.get_result()
+ if result is None or not result.scored():
+ continue
+
+ if sub.timestamp is not None:
+ time_offset = int(
+ (
+ sub.timestamp - participation.starting_time
+ ).total_seconds()
+ )
+ else:
+ time_offset = 0
+
+ submissions[str(task_id)].append({
+ "task": str(task_id),
+ "time": time_offset,
+ "score": result.score,
+ "token": sub.tokened(),
+ "extra": result.ranking_score_details or [],
+ })
+
+ # Get score history in RWS format: [[user_id, task_id, time, score], ...]
+ # Score history is stored on the training day participation
+ history: list[list] = []
+ score_histories = (
+ self.sql_session.query(ScoreHistory)
+ .filter(ScoreHistory.participation_id == participation.id)
+ .filter(ScoreHistory.task_id.in_(training_day_task_ids))
+ .order_by(ScoreHistory.timestamp)
+ .all()
+ )
+ for sh in score_histories:
+ if sh.timestamp is not None:
+ time_offset = (
+ sh.timestamp - participation.starting_time
+ ).total_seconds()
+ else:
+ time_offset = 0
+ history.append([
+ participation.user_id,
+ sh.task_id,
+ time_offset,
+ sh.score
+ ])
+
+ # Create archived ranking record
+ archived_ranking = ArchivedStudentRanking(
+ student_tags=student_tags,
+ task_scores=task_scores if task_scores else None,
+ submissions=submissions if submissions else None,
+ history=history if history else None,
+ )
+ archived_ranking.training_day_id = training_day.id
+ archived_ranking.student_id = student.id
+ self.sql_session.add(archived_ranking)
+
+
+class TrainingProgramDateFilterMixin:
+ """Mixin for filtering training days by date range."""
+
+ 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 _get_archived_training_days(
+ self, training_program_id: int, start_date: dt | None, end_date: dt | None
+ ) -> list[TrainingDay]:
+ """Query archived training days with optional date 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))
+ return query.order_by(TrainingDay.start_time).all()
+
+
+class TrainingProgramAttendanceHandler(TrainingProgramDateFilterMixin, 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 = self._parse_date_range()
+ archived_training_days = self._get_archived_training_days(
+ training_program.id, start_date, end_date
+ )
+
+ # Build attendance data structure
+ # {student_id: {training_day_id: attendance_record}}
+ attendance_data: dict[int, dict[int, ArchivedAttendance]] = {}
+ all_students: dict[int, Student] = {}
+
+ for td in archived_training_days:
+ for attendance in td.archived_attendances:
+ student_id = attendance.student_id
+ if student_id not in attendance_data:
+ attendance_data[student_id] = {}
+ all_students[student_id] = attendance.student
+ attendance_data[student_id][td.id] = attendance
+
+ # Sort students by username
+ sorted_students = sorted(
+ all_students.values(),
+ key=lambda s: s.participation.user.username if s.participation else ""
+ )
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["archived_training_days"] = archived_training_days
+ self.r_params["attendance_data"] = attendance_data
+ self.r_params["sorted_students"] = sorted_students
+ self.r_params["start_date"] = start_date
+ self.r_params["end_date"] = end_date
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == training_program.managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+ self.render("training_program_attendance.html", **self.r_params)
+
+
+class TrainingProgramCombinedRankingHandler(
+ TrainingProgramDateFilterMixin, 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 = self._parse_date_range()
+ archived_training_days = self._get_archived_training_days(
+ training_program.id, start_date, end_date
+ )
+
+ ranking_data: dict[int, dict[int, ArchivedStudentRanking]] = {}
+ all_students: dict[int, Student] = {}
+ training_day_tasks: dict[int, list[dict]] = {}
+
+ for td in archived_training_days:
+ # Collect all tasks that were visible to at least one student
+ # Use archived_tasks_data from training day (preserves original scoring scheme)
+ visible_tasks_by_id: dict[int, dict] = {}
+ for ranking in td.archived_student_rankings:
+ student_id = ranking.student_id
+ if student_id not in ranking_data:
+ ranking_data[student_id] = {}
+ all_students[student_id] = ranking.student
+ ranking_data[student_id][td.id] = ranking
+
+ # Collect all visible tasks from this student's task_scores keys
+ if ranking.task_scores:
+ for task_id_str in ranking.task_scores.keys():
+ task_id = int(task_id_str)
+ if task_id not in visible_tasks_by_id:
+ # Get task info from archived_tasks_data on training day
+ if td.archived_tasks_data and task_id_str in td.archived_tasks_data:
+ task_info = td.archived_tasks_data[task_id_str]
+ visible_tasks_by_id[task_id] = {
+ "id": task_id,
+ "name": task_info.get("short_name", ""),
+ "title": task_info.get("name", ""),
+ "training_day_num": task_info.get("training_day_num"),
+ }
+ else:
+ # Fallback to live task data
+ task = self.sql_session.query(Task).get(task_id)
+ if task:
+ visible_tasks_by_id[task_id] = {
+ "id": task_id,
+ "name": task.name,
+ "title": task.title,
+ "training_day_num": task.training_day_num,
+ }
+
+ # Sort tasks by training_day_num for stable ordering
+ sorted_tasks = sorted(
+ visible_tasks_by_id.values(),
+ key=lambda t: (t.get("training_day_num") or 0, t["id"])
+ )
+ training_day_tasks[td.id] = sorted_tasks
+
+ sorted_students = sorted(
+ all_students.values(),
+ key=lambda s: s.participation.user.username if s.participation else ""
+ )
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["archived_training_days"] = archived_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["start_date"] = start_date
+ self.r_params["end_date"] = end_date
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == training_program.managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+ self.render("training_program_combined_ranking.html", **self.r_params)
+
+
+class TrainingProgramCombinedRankingHistoryHandler(
+ TrainingProgramDateFilterMixin, BaseHandler
+):
+ """Return score history for archived training days as JSON."""
+
+ @require_permission(BaseHandler.AUTHENTICATED)
+ def get(self, training_program_id: str):
+ import json
+
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+
+ start_date, end_date = self._parse_date_range()
+ archived_training_days = self._get_archived_training_days(
+ training_program.id, start_date, end_date
+ )
+
+ result = []
+ for td in archived_training_days:
+ for ranking in td.archived_student_rankings:
+ 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(
+ TrainingProgramDateFilterMixin, BaseHandler
+):
+ """Show detailed score/rank progress for a student across archived training days."""
+
+ @require_permission(BaseHandler.AUTHENTICATED)
+ def get(self, training_program_id: str, student_id: str):
+ training_program = self.safe_get_item(TrainingProgram, training_program_id)
+ student = self.safe_get_item(Student, student_id)
+
+ start_date, end_date = self._parse_date_range()
+ archived_training_days = self._get_archived_training_days(
+ training_program.id, start_date, end_date
+ )
+
+ users_data = {}
+ for s in training_program.students:
+ if s.participation and s.participation.user:
+ 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 all students' task_scores keys
+ for ranking in td.archived_student_rankings:
+ if ranking.task_scores:
+ task_ids_in_contest.update(int(k) for k in ranking.task_scores.keys())
+
+ # Get archived_tasks_data from training day
+ archived_tasks_data = td.archived_tasks_data or {}
+
+ # Sort task IDs by training_day_num for stable ordering
+ def get_training_day_num(task_id: int) -> tuple[int, int]:
+ task_key = str(task_id)
+ if task_key in archived_tasks_data:
+ num = archived_tasks_data[task_key].get("training_day_num")
+ return (num if num is not None else 0, task_id)
+ return (0, task_id)
+
+ sorted_task_ids = sorted(task_ids_in_contest, key=get_training_day_num)
+
+ contest_tasks = []
+ contest_max_score = 0.0
+ for task_id in sorted_task_ids:
+ task_key = str(task_id)
+
+ # Use archived_tasks_data if available (preserves original scoring scheme)
+ if task_key in archived_tasks_data:
+ task_info = archived_tasks_data[task_key]
+ max_score = task_info.get("max_score", 100.0)
+ extra_headers = task_info.get("extra_headers", [])
+ score_precision = task_info.get("score_precision", 2)
+ task_name = task_info.get("name", "")
+ task_short_name = task_info.get("short_name", "")
+ else:
+ # Fallback to live task data
+ task = self.sql_session.query(Task).get(task_id)
+ if not task:
+ continue
+ max_score = 100.0
+ extra_headers = []
+ score_precision = task.score_precision
+ task_name = task.title
+ task_short_name = task.name
+ if task.active_dataset:
+ try:
+ score_type = task.active_dataset.score_type_object
+ max_score = score_type.max_score
+ extra_headers = score_type.ranking_headers
+ except (KeyError, TypeError, AttributeError):
+ pass
+
+ tasks_data[task_key] = {
+ "key": task_key,
+ "name": task_name,
+ "short_name": task_short_name,
+ "contest": contest_key,
+ "max_score": max_score,
+ "score_precision": score_precision,
+ "extra_headers": extra_headers,
+ }
+ contest_tasks.append(tasks_data[task_key])
+ contest_max_score += max_score
+
+ # Get submissions for this task from the student's ranking
+ student_ranking = student_rankings.get(td.id)
+ if student_ranking and student_ranking.submissions:
+ task_submissions = student_ranking.submissions.get(task_key, [])
+ submissions_data[task_key] = task_submissions
+
+ td_name = td.description or td.name or "Training Day"
+ if td.start_time:
+ td_name += f" ({td.start_time.strftime('%Y-%m-%d')})"
+
+ # Calculate contest duration
+ # History times are stored as offsets from contest start, so we need
+ # begin=0 and end=duration for the graph scale to be correct
+ if td.duration:
+ end_time = int(td.duration.total_seconds())
+ else:
+ end_time = 18000 # Default 5 hours
+
+ contests_data[contest_key] = {
+ "key": contest_key,
+ "name": td_name,
+ "begin": 0,
+ "end": end_time,
+ "max_score": contest_max_score,
+ "score_precision": 2,
+ "tasks": contest_tasks,
+ }
+ total_max_score += contest_max_score
+
+ contest_list = [contests_data[f"td_{td.id}"] for td in archived_training_days
+ if f"td_{td.id}" in contests_data]
+
+ history_url = self.url(
+ "training_program", training_program_id, "combined_ranking", "history"
+ )
+ if start_date or end_date:
+ params = []
+ if start_date:
+ params.append(f"start_date={start_date.isoformat()}")
+ if end_date:
+ params.append(f"end_date={end_date.isoformat()}")
+ history_url += "?" + "&".join(params)
+
+ self.r_params = self.render_params()
+ self.r_params["training_program"] = training_program
+ self.r_params["student"] = student
+ self.r_params["user_id"] = str(student.participation.user_id) if student.participation else "0"
+ self.r_params["user_count"] = user_count
+ self.r_params["users_data"] = users_data
+ self.r_params["tasks_data"] = tasks_data
+ self.r_params["submissions_data"] = submissions_data
+ self.r_params["contests_data"] = contests_data
+ self.r_params["contest_list"] = contest_list
+ self.r_params["total_max_score"] = total_max_score
+ self.r_params["history_url"] = history_url
+ self.r_params["start_date"] = start_date
+ self.r_params["end_date"] = end_date
+ self.r_params["unanswered"] = self.sql_session.query(Question)\
+ .join(Participation)\
+ .filter(Participation.contest_id == training_program.managing_contest.id)\
+ .filter(Question.reply_timestamp.is_(None))\
+ .filter(Question.ignored.is_(False))\
+ .count()
+ self.render("training_program_combined_ranking_detail.html", **self.r_params)
diff --git a/cms/server/admin/static/aws_style.css b/cms/server/admin/static/aws_style.css
index 512661eb2e..b40e72a72c 100644
--- a/cms/server/admin/static/aws_style.css
+++ b/cms/server/admin/static/aws_style.css
@@ -1,4 +1,4 @@
-html {
+html {
font-size: 100%;
}
@@ -1219,3 +1219,378 @@ table.diff-open th.diff-only, table.diff-open td.diff-only {
color: #64748B; /* Improved contrast for WCAG AA compliance */
font-style: italic;
}
+
+/* --- Training Program Views (Attendance & Ranking) --- */
+
+/* Layout & Container */
+.tp-page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+ gap: 15px;
+}
+
+.core_title h1 {
+ margin: 0;
+ font-size: 1.75rem;
+ color: #111827;
+}
+
+/* Filter Bar */
+.tp-filter-card {
+ background-color: #f9fafb;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ padding: 12px 16px;
+ margin-bottom: 24px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.tp-filter-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.tp-filter-label {
+ font-weight: 600;
+ font-size: 0.85rem;
+ color: #4b5563;
+}
+
+.tp-filter-input {
+ padding: 6px 10px;
+ border: 1px solid #cbd5e1;
+ border-radius: 4px;
+ font-size: 0.9rem;
+ color: #334155;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.tp-filter-input:focus {
+ border-color: #0F766E;
+ box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.1);
+ outline: none;
+}
+
+
+.tp-btn-primary {
+ background-color: #0F766E;
+ color: white;
+ border: none;
+ padding: 6px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: 500;
+ font-size: 0.9rem;
+ font-family: inherit;
+ transition: all 0.2s;
+ text-decoration: none;
+ display: inline-block;
+ line-height: 1.5;
+ box-sizing: border-box;
+ vertical-align: middle;
+}
+
+.tp-btn-primary:hover {
+ background-color: #0d655e;
+ color: white;
+}
+
+.tp-btn-text {
+ color: #6b7280;
+ text-decoration: underline;
+ font-size: 0.85rem;
+ margin-left: auto;
+}
+
+.tp-btn-secondary {
+ background-color: white;
+ color: #0F766E;
+ border: 1px solid #cbd5e1;
+ padding: 6px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: 500;
+ font-size: 0.9rem;
+ font-family: inherit;
+ transition: all 0.2s;
+ text-decoration: none;
+ display: inline-block;
+ line-height: 1.5;
+ box-sizing: border-box;
+ vertical-align: middle;
+}
+
+/* Data Table Wrapper */
+.tp-table-container {
+ overflow-x: auto;
+ max-height: 75vh;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ background: white;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+ position: relative;
+ /* Ensure the container width is handled correctly */
+ width: 100%;
+}
+
+/* Common Table Styles */
+.attendance-table,
+.ranking-table {
+ width: auto;
+ border-collapse: separate; /* Required for sticky headers */
+ border-spacing: 0;
+ font-size: 0.9rem;
+ white-space: nowrap;
+}
+
+/* Force border-box for consistency */
+.ranking-table *, .attendance-table * {
+ box-sizing: border-box;
+}
+
+.attendance-table th,
+.attendance-table td,
+.ranking-table th,
+.ranking-table td {
+ padding: 0; /* Reset padding, inner divs handle it */
+ border-bottom: 1px solid #e2e8f0;
+ border-right: 1px solid #e2e8f0;
+ background-clip: padding-box;
+ vertical-align: middle;
+ height: 1px;
+}
+
+/* --- Sticky Headers & Columns --- */
+
+/* 1. Header Rows */
+.attendance-table thead th,
+.ranking-table thead th {
+ position: sticky;
+ top: 0;
+ background-color: #f8fafc;
+ z-index: 10;
+ color: #475569;
+ font-weight: 600;
+ text-align: left;
+ box-shadow: 0 1px 0 #cbd5e1; /* Visual bottom border */
+ padding: 10px 12px; /* Headers get direct padding */
+}
+
+.ranking-table thead th {
+ text-align: center;
+}
+
+.ranking-table thead th.student-header {
+ text-align: left;
+ vertical-align: middle;
+}
+
+/* 2. First Column (Students) */
+.attendance-table tbody th,
+.ranking-table tbody th {
+ position: sticky;
+ left: 0;
+ background-color: white;
+ z-index: 5;
+ text-align: left;
+ min-width: 180px;
+ max-width: 250px;
+ box-shadow: 1px 0 0 #e2e8f0; /* Visual right border */
+ border-right: none;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: #1e293b;
+ padding: 0; /* Inner div handles padding */
+}
+
+/* 3. Top-Left Corner */
+.attendance-table thead tr:first-child th:first-child,
+.ranking-table thead tr:first-child th:first-child {
+ left: 0;
+ z-index: 20;
+ background-color: #f8fafc;
+ box-shadow: 1px 1px 0 #cbd5e1;
+}
+
+/* --- Attendance Table Specifics --- */
+.date-header-main { font-size: 0.95rem; color: #111827; }
+.date-header-sub { font-size: 0.75rem; color: #6b7280; font-weight: normal; margin-top: 2px; }
+
+/* Wrapper for cell content */
+.cell-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 140px;
+ padding: 10px 12px;
+}
+
+/* Modern Badges */
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 9999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ width: fit-content;
+}
+
+.status-on-time { background-color: #d1fae5; color: #065f46; }
+.status-delayed { background-color: #fef3c7; color: #92400e; }
+.status-missed { background-color: #fee2e2; color: #991b1b; }
+.status-unknown { background-color: #f3f4f6; color: #4b5563; }
+
+.location-row {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.75rem;
+ color: #4b5563;
+}
+
+.location-icon { font-size: 0.85rem; }
+
+.reason-text {
+ font-size: 0.75rem;
+ color: #6b7280;
+ background: #f9fafb;
+ border-left: 2px solid #d1d5db;
+ padding: 2px 6px;
+ margin-top: 2px;
+ white-space: normal;
+ max-width: 200px;
+}
+
+.attendance-table .empty-cell { color: #d1d5db; font-size: 1.5rem; line-height: 0.5; padding: 10px; text-align: center;}
+
+
+/* --- Combined Ranking Specifics --- */
+.training-day-header {
+ background-color: #f1f5f9 !important;
+ text-align: center;
+ border-bottom: 1px solid #cbd5e1 !important;
+ pointer-events: none;
+}
+
+.training-day-header-main {
+ font-size: 0.95rem;
+ color: #334155;
+ font-weight: 700;
+}
+
+.training-day-header-sub {
+ font-size: 0.75rem;
+ color: #64748b;
+ font-weight: normal;
+ margin-top: 2px;
+}
+
+.task-header {
+ background-color: #ffffff !important;
+ font-size: 0.8rem;
+ color: #475569;
+ vertical-align: bottom;
+}
+
+/* Total Column specific styling */
+.total-col {
+ border-left: 2px solid #cbd5e1 !important;
+ background-color: #f8fafc;
+}
+
+.ranking-table thead th.total-col {
+ background-color: #f1f5f9;
+ color: #0f172a;
+ font-weight: 800;
+}
+
+.ranking-table tbody td.total-col {
+ font-weight: 700;
+ color: #0f172a;
+}
+
+/* Content wrapper for Ranking cells */
+.cell-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ padding: 10px 12px;
+ min-height: 40px;
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
+ font-weight: 500;
+}
+
+/* Student name wrapper */
+.student-name-wrapper {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 10px 16px;
+ width: 100%;
+ height: 100%;
+}
+
+.student-name-link {
+ color: #0f172a;
+ text-decoration: none;
+ font-weight: 600;
+}
+.student-name-link:hover {
+ color: #0F766E;
+ text-decoration: underline;
+}
+.student-username {
+ display: block;
+ font-size: 0.75rem;
+ color: #64748b;
+ font-weight: normal;
+}
+
+.ranking-table .empty-cell-content {
+ color: #cbd5e1;
+ text-align: center;
+ font-weight: normal;
+}
+
+.inaccessible-cell {
+ background: repeating-linear-gradient(
+ 45deg,
+ #f3f4f6,
+ #f3f4f6 10px,
+ #e5e7eb 10px,
+ #e5e7eb 20px
+ );
+ color: #9ca3af;
+ font-size: 0.75rem;
+ font-style: italic;
+}
+
+.history-link {
+ font-size: 0.75rem;
+ color: #6b7280;
+ text-decoration: none;
+ margin-left: 4px;
+}
+
+.history-link:hover {
+ color: #374151;
+ text-decoration: underline;
+}
+
+/* Hover effects */
+.ranking-table tbody tr:hover td {
+ filter: brightness(0.97);
+}
+.ranking-table tbody tr:hover th {
+ background-color: #f8fafc;
+}
diff --git a/cms/server/admin/templates/archive_training_day.html b/cms/server/admin/templates/archive_training_day.html
new file mode 100644
index 0000000000..eea7ee9ea8
--- /dev/null
+++ b/cms/server/admin/templates/archive_training_day.html
@@ -0,0 +1,73 @@
+{% extends "base.html" %}
+
+{% block core %}
+
+
Archive Training Day: {{ contest.name }}
+
+
+
+ ← Back to training days
+
+
+
+
Warning: Archiving a training day will:
+
+ - Extract and save attendance data (participation status, location, delay time, delay reasons)
+ - Extract and save ranking data (scores, score history)
+ - Save the training day name and description
+ - Delete the contest and all participations permanently
+
+ This action cannot be undone.
+
+
+
+{% endblock core %}
diff --git a/cms/server/admin/templates/base.html b/cms/server/admin/templates/base.html
index 85b11104f2..5dfe0c22bd 100644
--- a/cms/server/admin/templates/base.html
+++ b/cms/server/admin/templates/base.html
@@ -310,7 +310,7 @@