Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e5ca527
Redesign tasks page and add release/reuse functionality for archived …
devin-ai-integration[bot] Jan 26, 2026
4777ff7
Rename to detach + style imp
ronryv Jan 26, 2026
0c3f657
Add drag-and-drop reordering, consolidate CSS, fix remove behavior, a…
devin-ai-integration[bot] Jan 26, 2026
16a2ea1
Use card-style add task UI and convert remove page to modal
devin-ai-integration[bot] Jan 26, 2026
c943ca8
Fix task removal redirect, remove deprecated page, and modernize noti…
devin-ai-integration[bot] Jan 26, 2026
66791b6
Add info note to combined ranking, redesign training days page with t…
devin-ai-integration[bot] Jan 26, 2026
03af5ab
Fix training_days page - keep original styling, add tasks column with…
devin-ai-integration[bot] Jan 26, 2026
a1d6791
Modernize training_days page with drag-and-drop, icons, and improved …
devin-ai-integration[bot] Jan 26, 2026
c30d79a
Add scoreboard sharing feature for archived training days
devin-ai-integration[bot] Jan 26, 2026
afd3d40
Fix: keep db version at 49, add schema migration for scoreboard_sharing
devin-ai-integration[bot] Jan 26, 2026
d15fcd2
share and fix common histogram logic
ronryv Jan 26, 2026
11933a4
UI improvements
ronryv Jan 26, 2026
b3a5dcc
Modernize training_programs page with card-based layout
devin-ai-integration[bot] Jan 26, 2026
b2e7dda
Refine training_programs page: remove header button/subtitle, add ind…
devin-ai-integration[bot] Jan 26, 2026
32864ad
Address review comments
ronryv Jan 27, 2026
364b04d
Enhance scoreboard sharing with everyone option, top_to_show limit, a…
devin-ai-integration[bot] Jan 27, 2026
32ed22a
UI improvements: modernize training cards, progress bar, add icons, a…
devin-ai-integration[bot] Jan 27, 2026
aa66109
Fix UI feedback: input widths, select box, LIVE badge, duration icon,…
devin-ai-integration[bot] Jan 27, 2026
0f7d34c
address review comments
ronryv Jan 28, 2026
6ff1f75
nits
ronryv Jan 28, 2026
0fd9bd4
Address PR review comments: validation, defensive parsing, and code c…
devin-ai-integration[bot] Jan 28, 2026
af1fcb8
Address additional CodeRabbit review comments
devin-ai-integration[bot] Jan 28, 2026
54ca7d1
Address additional CodeRabbit review comments
devin-ai-integration[bot] Jan 28, 2026
1424d5d
nits
ronryv Jan 28, 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
12 changes: 12 additions & 0 deletions cms/db/training_day.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import typing

from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import relationship, Session
from sqlalchemy.schema import Column, ForeignKey, Index, UniqueConstraint
from sqlalchemy.types import DateTime, Integer, Interval, Unicode
Expand Down Expand Up @@ -143,6 +144,17 @@ class TrainingDay(Base):
default=list,
)

# Scoreboard sharing settings for archived training days.
# Format: {"tag1": {"top_to_show": 10, "top_names": 5}, "__everyone__": {...}, ...}
# - Keys are student tags that the scoreboard is shared with, or "__everyone__" for all
# - top_to_show: number of top students to show in the scoreboard (or "all")
# - top_names: number of top students to show full names (others show rank only, or "all")
# Eligibility to view is based on student_tags during the training (from ArchivedStudentRanking)
scoreboard_sharing: dict | None = Column(
MutableDict.as_mutable(JSONB),
nullable=True,
)

training_program: "TrainingProgram" = relationship(
"TrainingProgram",
back_populates="training_days",
Expand Down
4 changes: 3 additions & 1 deletion cms/server/admin/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,8 @@
AddTrainingDayGroupHandler, \
UpdateTrainingDayGroupsHandler, \
RemoveTrainingDayGroupHandler, \
TrainingDayTypesHandler
TrainingDayTypesHandler, \
ScoreboardSharingHandler
from .student import \
TrainingProgramStudentsHandler, \
AddTrainingProgramStudentHandler, \
Expand Down Expand Up @@ -399,6 +400,7 @@
(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]+)/types", TrainingDayTypesHandler),
(r"/training_program/([0-9]+)/training_day/([0-9]+)/scoreboard_sharing", ScoreboardSharingHandler),
(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),
Expand Down
233 changes: 180 additions & 53 deletions cms/server/admin/handlers/trainingday.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"""

from datetime import datetime as dt, timedelta
import json

import tornado.web

Expand Down Expand Up @@ -156,9 +157,7 @@ def validate_group_times_within_contest(

class TrainingProgramTrainingDaysHandler(BaseHandler):
"""List and manage training days in a training program."""
REMOVE = "Remove"
MOVE_UP = "up by 1"
MOVE_DOWN = "down by 1"
REORDER = "reorder"

@require_permission(BaseHandler.AUTHENTICATED)
def get(self, training_program_id: str):
Expand All @@ -176,59 +175,79 @@ def post(self, training_program_id: str):

training_program = self.safe_get_item(TrainingProgram, training_program_id)

try:
training_day_id: str = self.get_argument("training_day_id")
operation: str = self.get_argument("operation")
assert operation in (
self.REMOVE,
self.MOVE_UP,
self.MOVE_DOWN,
), "Please select a valid operation"
except Exception as error:
self.service.add_notification(
make_datetime(), "Invalid field(s)", repr(error))
self.redirect(fallback_page)
return

training_day = self.safe_get_item(TrainingDay, training_day_id)

if training_day.training_program_id != training_program.id:
self.service.add_notification(
make_datetime(), "Invalid training day", "Training day does not belong to this program")
self.redirect(fallback_page)
return

if operation == self.REMOVE:
asking_page = self.url(
"training_program", training_program_id,
"training_day", training_day_id, "remove"
)
self.redirect(asking_page)
return

elif operation == self.MOVE_UP:
training_day2 = self.sql_session.query(TrainingDay)\
.filter(TrainingDay.training_program == training_program)\
.filter(TrainingDay.position == training_day.position - 1)\
.first()

if training_day2 is not None:
tmp_a, tmp_b = training_day.position, training_day2.position
training_day.position, training_day2.position = None, None
operation: str = self.get_argument("operation", "")

if operation == self.REORDER:
try:
reorder_data = self.get_argument("reorder_data", "")
if not reorder_data:
raise ValueError("No reorder data provided")

order_list = json.loads(reorder_data)

if not isinstance(order_list, list):
raise ValueError("Reorder data must be a list")

active_training_days = [
td for td in training_program.training_days
if td.contest is not None
]
td_by_id = {str(td.id): td for td in active_training_days}

# Validate that order_list contains each active td id exactly once
expected_ids = set(td_by_id.keys())
received_ids = set()
for item in order_list:
td_id = str(item.get("training_day_id", ""))
if td_id in received_ids:
raise ValueError(f"Duplicate training day id: {td_id}")
received_ids.add(td_id)

if received_ids != expected_ids:
missing = expected_ids - received_ids
extra = received_ids - expected_ids
raise ValueError(
f"Reorder data mismatch. Missing: {missing}, Extra: {extra}"
)

# Validate that new_position values form a complete 0-based permutation
num_active = len(active_training_days)
expected_positions = set(range(num_active))
received_positions = set()
for item in order_list:
new_pos = int(item.get("new_position", -1))
if new_pos < 0 or new_pos >= num_active:
raise ValueError(
f"Position {new_pos} out of range [0, {num_active - 1}]"
)
if new_pos in received_positions:
raise ValueError(f"Duplicate position: {new_pos}")
received_positions.add(new_pos)

if received_positions != expected_positions:
raise ValueError(
f"Positions must be 0 to {num_active - 1}, "
f"got {sorted(received_positions)}"
)

# Only clear positions for active training days
for td in active_training_days:
td.position = None
self.sql_session.flush()
training_day.position, training_day2.position = tmp_b, tmp_a

elif operation == self.MOVE_DOWN:
training_day2 = self.sql_session.query(TrainingDay)\
.filter(TrainingDay.training_program == training_program)\
.filter(TrainingDay.position == training_day.position + 1)\
.first()

if training_day2 is not None:
tmp_a, tmp_b = training_day.position, training_day2.position
training_day.position, training_day2.position = None, None
# Apply the new positions
for item in order_list:
td_id = str(item["training_day_id"])
new_pos = int(item["new_position"])
td_by_id[td_id].position = new_pos
self.sql_session.flush()
training_day.position, training_day2.position = tmp_b, tmp_a

except Exception as error:
self.service.add_notification(
make_datetime(), "Reorder failed", repr(error)
)
self.redirect(fallback_page)
return

self.try_commit()
self.redirect(fallback_page)
Expand Down Expand Up @@ -688,3 +707,111 @@ def post(self, training_program_id: str, training_day_id: str):
except Exception as error:
self.set_status(400)
self.write({"error": str(error)})


class ScoreboardSharingHandler(BaseHandler):
"""Handler for updating scoreboard sharing settings for archived training days.

The scoreboard_sharing format is:
{
"tag_name": {
"top_names": int or "all", # How many top students show full names
"top_to_show": int or "all" # How many students to show in total
},
...
"__everyone__": { # Special key for sharing with all students
"top_names": int or "all",
"top_to_show": int or "all"
}
}
"""

@require_permission(BaseHandler.PERMISSION_ALL)
def post(self, training_program_id: str, training_day_id: str):
self.set_header("Content-Type", "application/json")

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:
self.set_status(404)
self.write({"error": "Training day does not belong to this program"})
return

# Only allow for archived training days
if training_day.contest is not None:
self.set_status(400)
self.write({"error": "Scoreboard sharing is only available for archived training days"})
return

try:
sharing_data_str = self.get_argument("scoreboard_sharing", "")

if not sharing_data_str or sharing_data_str.strip() == "":
# Clear sharing settings
training_day.scoreboard_sharing = None
else:
sharing_data = json.loads(sharing_data_str)

# Validate the format
if not isinstance(sharing_data, dict):
raise ValueError("Invalid format: expected object")

seen_tags: set[str] = set()
for tag, settings in sharing_data.items():
# Allow special "__everyone__" key
if tag == "__everyone__":
normalized_tag = tag
else:
normalized_tag = tag.strip()
if not normalized_tag:
raise ValueError("Tag cannot be empty")
if normalized_tag != tag:
raise ValueError(f"Invalid tag '{tag}': remove leading/trailing spaces")

if normalized_tag in seen_tags:
raise ValueError(f"Duplicate tag '{tag}'")
seen_tags.add(normalized_tag)

if not isinstance(settings, dict):
raise ValueError(f"Invalid settings for tag '{tag}'")

# Validate top_names (required)
if "top_names" not in settings:
raise ValueError(f"Missing 'top_names' for tag '{tag}'")
top_names = settings["top_names"]
if top_names != "all":
if not isinstance(top_names, int) or top_names < 0:
raise ValueError(f"Invalid 'top_names' for tag '{tag}': must be non-negative integer or 'all'")

# Validate top_to_show (optional, defaults to "all")
top_to_show = settings.get("top_to_show", "all")
if top_to_show != "all":
if not isinstance(top_to_show, int) or top_to_show < 0:
raise ValueError(f"Invalid 'top_to_show' for tag '{tag}': must be non-negative integer or 'all'")

# Validate top_names <= top_to_show when both are integers
if top_names != "all" and top_to_show != "all":
if top_names > top_to_show:
raise ValueError(
f"Invalid settings for tag '{tag}': top_names ({top_names}) "
f"cannot exceed top_to_show ({top_to_show})"
)

training_day.scoreboard_sharing = sharing_data

if self.try_commit():
self.write({
"success": True,
"scoreboard_sharing": training_day.scoreboard_sharing
})
else:
self.set_status(500)
self.write({"error": "Failed to save"})

except json.JSONDecodeError as error:
self.set_status(400)
self.write({"error": f"Invalid JSON: {str(error)}"})
except Exception as error:
self.set_status(400)
self.write({"error": str(error)})
Loading
Loading