Skip to content
Draft
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
27ebfd7
Add Training Programs feature (Phase 1) with admin interface improvem…
devin-ai-integration[bot] Nov 29, 2025
f5d561e
Add name and description editing fields to training program general p…
ronryv Nov 29, 2025
2fbbf04
fix contest html (#58)
ronryv Nov 30, 2025
922516f
Merge main branch updates into training_program with ranking fix (#76)
ronryv Dec 21, 2025
113c258
Add TrainingDay model and admin interface for managing training days …
ronryv Jan 10, 2026
d3e91fa
Refactor training day submissions to use managing contest participati…
devin-ai-integration[bot] Jan 10, 2026
bd10e39
Add StudentTask model for task archive feature (#87)
devin-ai-integration[bot] Jan 15, 2026
c0f534c
Add archive training day feature with combined ranking (#91)
devin-ai-integration[bot] Jan 17, 2026
5cc9ee1
Add Training Days page to contest server with scores and sorting (#94)
devin-ai-integration[bot] Jan 17, 2026
dc26422
Fix training program sidebar issues and add contest configuration (#93)
devin-ai-integration[bot] Jan 17, 2026
43240d9
Add visible_to_tags field to announcements for student tag filtering …
devin-ai-integration[bot] Jan 18, 2026
6a63deb
Add training day types, student tag filtering, and score histograms (…
devin-ai-integration[bot] Jan 21, 2026
1c73220
Add timezone support, duration inputs, hidden user filtering, main gr…
devin-ai-integration[bot] Jan 23, 2026
a287ad2
Improve notifications for training day attendance and questions (#106)
ronryv Jan 24, 2026
98848f7
Split unsolved tasks into attempted/not attempted and add sortable su…
ronryv Jan 25, 2026
1574f3c
Redesign tasks page and add scoreboard sharing for archived training …
ronryv Jan 28, 2026
d9a15dd
Add justified absences, comments, recorded status, and Excel export t…
ronryv Jan 29, 2026
efff339
Fix broken exports for training programs (#110)
devin-ai-integration[bot] Jan 29, 2026
29e7d6e
Add Hebrew translations for training program trans blocks (#112)
devin-ai-integration[bot] Jan 29, 2026
4e0cdba
Add common training program utilities to reduce code duplication (#114)
devin-ai-integration[bot] Jan 31, 2026
f73cfd4
Refactor training program UI: CSS improvements and centralized JS (#116)
devin-ai-integration[bot] Jan 31, 2026
9097614
Add training program statement view when reading statements in traini…
devin-ai-integration[bot] Feb 1, 2026
2546207
re-use existing handlers
ronryv Feb 1, 2026
d6c6145
Consolidate training program handlers with contest handlers
devin-ai-integration[bot] Feb 1, 2026
ab95444
manual improvements
ronryv Feb 2, 2026
cf8b241
Extract shared task data utilities and remove fallback logic (#117)
devin-ai-integration[bot] Feb 3, 2026
a1b1331
improve sidebar
ronryv Feb 3, 2026
806b1e4
Refactor SVG icons to use sprite pattern and improve sidebar icons
devin-ai-integration[bot] Feb 3, 2026
4aa11e9
Improve sidebar menu styling: better icon alignment and hover effects
devin-ai-integration[bot] Feb 3, 2026
f9cba48
review comments
ronryv Feb 3, 2026
dff26a8
Fix partial_flags_query to work for training days
devin-ai-integration[bot] Feb 3, 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
13 changes: 11 additions & 2 deletions cms/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@
# fsobject
"FSObject", "LargeObject",
# contest
"Contest", "Announcement", "ContestFolder",
"Contest", "Announcement", "ContestFolder", "TrainingProgram", "Student",
"TrainingDay", "TrainingDayGroup", "StudentTask",
"ArchivedAttendance", "ArchivedStudentRanking",
# user
"User", "Team", "Participation", "Message", "Question", "DelayRequest",
# admin
Expand Down Expand Up @@ -87,7 +89,7 @@

# Instantiate or import these objects.

version = 48
version = 49

engine = create_engine(config.database.url, echo=config.database.debug,
pool_timeout=60, pool_recycle=120)
Expand All @@ -104,6 +106,13 @@
from .admin import Admin
from .contest import Contest, Announcement
from .contest_folder import ContestFolder
from .training_program import TrainingProgram
from .training_day import TrainingDay
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, SubtaskValidator, SubtaskValidationResult
Expand Down
121 changes: 121 additions & 0 deletions cms/db/archived_attendance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/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.sql import text
from sqlalchemy.types import Boolean, 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,
)

# Whether the absence was justified (e.g., sick leave)
justified: bool = Column(
Boolean,
nullable=False,
default=False,
server_default=text("false"),
)

# Admin comment for this attendance record
comment: str | None = Column(
Unicode,
nullable=True,
)

# Whether this students room and / or screen was recorded during this training
recorded: bool = Column(
Boolean,
nullable=False,
default=False,
server_default=text("false"),
)

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",
)
15 changes: 9 additions & 6 deletions cms/db/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,13 +273,16 @@ def set_attrs(self, attrs: typing.Mapping[str, object], fill_with_defaults: bool
"argument '%s'" % prp.key)
# We're setting the default ourselves, since we may
# want to use the object before storing it in the DB.
# FIXME This code doesn't work with callable defaults.
# We can use the is_callable and is_scalar properties
# (and maybe the is_sequence and is_clause_element ones
# too) to detect the type. Note that callables require
# an ExecutionContext argument (which we don't have).
if col.default is not None:
setattr(self, prp.key, col.default.arg)
if col.default.is_callable:
try:
# Try passing None (simulating a missing ExecutionContext)
setattr(self, prp.key, col.default.arg(None))
except TypeError:
# Fallback for zero-argument callables (like simple lambdas)
setattr(self, prp.key, col.default.arg())
else:
setattr(self, prp.key, col.default.arg)
elif prp.key in attrs:
val = attrs.pop(prp.key)

Expand Down
66 changes: 64 additions & 2 deletions cms/db/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import relationship
from sqlalchemy.schema import Column, ForeignKey, CheckConstraint
from sqlalchemy.schema import Column, ForeignKey, CheckConstraint, Index
from sqlalchemy.types import Integer, Unicode, DateTime, Interval, Enum, \
Boolean, String

Expand All @@ -41,7 +41,7 @@
from .contest_folder import ContestFolder
import typing
if typing.TYPE_CHECKING:
from . import Task, Participation
from . import Task, Participation, TrainingProgram, TrainingDay


class Contest(Base):
Expand Down Expand Up @@ -131,6 +131,12 @@ class Contest(Base):
nullable=False,
default=False)

# Whether contestants can submit delay requests.
allow_delay_requests: bool = Column(
Boolean,
nullable=False,
default=True)

# Whether to enforce that the IP address of the request matches
# the IP address or subnet specified for the participation (if
# present).
Expand Down Expand Up @@ -310,6 +316,50 @@ class Contest(Base):
)
folder: ContestFolder | None = relationship(ContestFolder, back_populates="contests")

# Optional training program that this contest manages.
# If set, this contest is the "managing contest" for a training program.
training_program: "TrainingProgram | None" = relationship(
"TrainingProgram",
back_populates="managing_contest",
uselist=False,
)

# Optional training day that this contest represents.
# If set, this contest is a training day within a training program.
training_day: "TrainingDay | None" = relationship(
"TrainingDay",
back_populates="contest",
uselist=False,
)

def get_tasks(self) -> list["Task"]:
"""Return the tasks for this contest.

If this contest is a training day, return the training day's tasks.
Otherwise, return the contest's own tasks.

This allows training days to have their own task list separate from
the contest's task list, while still allowing contestants to see
the tasks when they enter the training day's contest.
"""
if self.training_day is not None:
return self.training_day.tasks
return self.tasks

def task_belongs_here(self, task: "Task") -> bool:
"""Check if a task belongs to this contest.

For regular contests, the task must have contest_id == self.id.
For training day contests, the task must have training_day_id == self.training_day.id.

This is used to validate that a task is accessible from this contest,
which is important for training days where tasks have contest_id pointing
to the managing contest but are served through the training day's contest.
"""
if self.training_day is not None:
return task.training_day_id == self.training_day.id
return task.contest_id == self.id

def phase(self, timestamp: datetime) -> int:
"""Return: -1 if contest isn't started yet at time timestamp,
0 if the contest is active at time timestamp,
Expand Down Expand Up @@ -340,6 +390,10 @@ class Announcement(Base):

"""
__tablename__ = 'announcements'
__table_args__ = (
Index("ix_announcements_visible_to_tags_gin", "visible_to_tags",
postgresql_using="gin"),
)

# Auto increment primary key.
id: int = Column(
Expand Down Expand Up @@ -379,3 +433,11 @@ class Announcement(Base):
nullable=True,
index=True)
admin: Admin | None = relationship(Admin)

# Tags that control which students can see this announcement.
# If empty, the announcement is visible to all students.
# If set, only students with at least one matching tag can see the announcement.
visible_to_tags: list[str] = Column(
ARRAY(Unicode),
nullable=False,
default=[])
Loading
Loading