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. +
+ +
+ {{ xsrf_form_html|safe }} + +

Select Class IPs

+

+ Select the IP addresses that correspond to the classroom. Students who participated from these IPs + will be marked as "class", others as "home". If a student used both class and non-class IPs, + they will be marked as "both". +

+ + {% if shared_ips %} + + + + + + + + + + {% for ip, count in shared_ips.items() | sort(attribute='1', reverse=true) %} + + + + + + {% endfor %} + +
SelectIP AddressStudent Count
+ + {{ ip }}{{ count }}
+ {% else %} +

No shared IP addresses found (no IP with more than one student).

+ {% endif %} + +

Confirm Archive

+

+ +

+

+ +

+
+{% 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 @@