Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1f3ada8
Add archive training day feature
devin-ai-integration[bot] Jan 15, 2026
9f519f1
Store all student tags as array instead of single tag
devin-ai-integration[bot] Jan 15, 2026
5f813ef
Fix schema_diff_test: remove DEFAULT clause from student_tags
devin-ai-integration[bot] Jan 15, 2026
0ce8e65
Fix IP detection to use starting_ip_addresses instead of participatio…
devin-ai-integration[bot] Jan 15, 2026
8409bbb
Fix bugs and add start_time to archived training days
devin-ai-integration[bot] Jan 15, 2026
c86c90d
Fix ScoreHistory.time attribute error and sidebar training days
devin-ai-integration[bot] Jan 15, 2026
4394286
Improve attendance table UI with badges and fix date filtering
devin-ai-integration[bot] Jan 15, 2026
7b6ee72
improve GUI
ronryv Jan 15, 2026
b92617a
Add combined ranking page for archived training days
devin-ai-integration[bot] Jan 15, 2026
f3a1323
small bug fixes
ronryv Jan 15, 2026
657d919
Fix archiving to use training_day.tasks and task_score function
devin-ai-integration[bot] Jan 15, 2026
f0dbc8b
Use get_cached_score_entry instead of task_score for archiving
devin-ai-integration[bot] Jan 15, 2026
e9faf39
Improve UI
ronryv Jan 16, 2026
75b2769
Improve archive training day: store all visible tasks, submissions, a…
devin-ai-integration[bot] Jan 16, 2026
05a5c25
Fix missing ARRAY import in archived_student_ranking.py
devin-ai-integration[bot] Jan 16, 2026
f051afe
Archive visible tasks based on student tags, not just existing Studen…
devin-ai-integration[bot] Jan 16, 2026
a708f47
Add StudentTask records during archiving for visible tasks
devin-ai-integration[bot] Jan 16, 2026
88f6397
Fix graph scale and submission time display in combined ranking history
devin-ai-integration[bot] Jan 16, 2026
69e1dbb
Improve UI
ronryv Jan 17, 2026
cdb74fe
Share date filtering logic
ronryv Jan 17, 2026
9e73203
Fix submission fetching and handle archived training days
devin-ai-integration[bot] Jan 17, 2026
bbd464b
Small fixes
ronryv Jan 17, 2026
581e35c
Update cms/server/admin/static/aws_style.css
ronryv Jan 17, 2026
f1fd141
Fix unstable task sorting by saving and using training_day_num
devin-ai-integration[bot] Jan 17, 2026
54b7893
Add global score column
ronryv Jan 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cms/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
# contest
"Contest", "Announcement", "ContestFolder", "TrainingProgram", "Student",
"TrainingDay", "TrainingDayGroup", "StudentTask",
"ArchivedAttendance", "ArchivedStudentRanking",
# user
"User", "Team", "Participation", "Message", "Question", "DelayRequest",
# admin
Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions cms/db/archived_attendance.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

"""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",
)
106 changes: 106 additions & 0 deletions cms/db/archived_student_ranking.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

"""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",
)
60 changes: 55 additions & 5 deletions cms/db/training_day.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
)
Expand All @@ -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",
)
Expand All @@ -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",
)
12 changes: 11 additions & 1 deletion cms/server/admin/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,12 @@
RemoveTrainingDayHandler, \
AddTrainingDayGroupHandler, \
UpdateTrainingDayGroupsHandler, \
RemoveTrainingDayGroupHandler
RemoveTrainingDayGroupHandler, \
ArchiveTrainingDayHandler, \
TrainingProgramAttendanceHandler, \
TrainingProgramCombinedRankingHandler, \
TrainingProgramCombinedRankingHistoryHandler, \
TrainingProgramCombinedRankingDetailHandler


HANDLERS = [
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 8 additions & 1 deletion cms/server/admin/handlers/submissiondownload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
Loading
Loading