Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
529b8fe
Add timezone support, duration inputs, and hidden user filtering for …
devin-ai-integration[bot] Jan 22, 2026
1a756f5
Remove redundant timezone info - already provided by BaseHandler.rend…
devin-ai-integration[bot] Jan 22, 2026
58fde7e
Add option to apply hidden status changes to existing training days
devin-ai-integration[bot] Jan 22, 2026
da418ed
Split training day ranking by main groups with separate tables and ex…
devin-ai-integration[bot] Jan 22, 2026
3071a0a
Fix tag retrieval, add tags to ranking exports, and fix remove main g…
devin-ai-integration[bot] Jan 22, 2026
3c2638e
Fix main_groups_data undefined error in training program ranking
devin-ai-integration[bot] Jan 22, 2026
1714a2f
Redesign group header styling and add inaccessible indicator to exports
devin-ai-integration[bot] Jan 22, 2026
e1b4ce2
Move export links next to group title and fix table sorting
devin-ai-integration[bot] Jan 22, 2026
ef052d8
Fix task score sorting by adding data-value attribute
devin-ai-integration[bot] Jan 22, 2026
537ba47
Fix table styling by using td-ranking-table class
devin-ai-integration[bot] Jan 22, 2026
5944009
Fix 7 UI issues in training program/day interface
devin-ai-integration[bot] Jan 22, 2026
70666e7
Add source column to submission table in student page
devin-ai-integration[bot] Jan 22, 2026
2c97c37
Fix submission display in training day participation page
devin-ai-integration[bot] Jan 22, 2026
d61e731
Fix training program ranking history link 404
devin-ai-integration[bot] Jan 22, 2026
658fdd3
Fix 7 code issues and refactor trainingprogram.py into modules
devin-ai-integration[bot] Jan 22, 2026
cbc3e81
Fix training day time validation and combined ranking error
devin-ai-integration[bot] Jan 22, 2026
9aceecb
Add archive training button on attendance page and warning on archive…
devin-ai-integration[bot] Jan 22, 2026
80514eb
Restore missing training_day_tasks and other variables in TrainingPro…
devin-ai-integration[bot] Jan 22, 2026
e026750
Add validation, fix N+1 queries, and code improvements
devin-ai-integration[bot] Jan 23, 2026
314a910
Update removeMainGroup() to use hidden input pattern for XSRF token
devin-ai-integration[bot] Jan 23, 2026
c3c4bca
PR review comments
ronryv Jan 23, 2026
0c1d039
Refactor datetime parsing to use get_datetime_with_timezone and add v…
devin-ai-integration[bot] Jan 23, 2026
650c536
share logic, fix contest stop bug
ronryv Jan 23, 2026
867d8c0
Fix checkbox re-indexing issue when rows are deleted
devin-ai-integration[bot] Jan 23, 2026
60e2416
nits
ronryv Jan 23, 2026
87119ad
fix table headers
ronryv Jan 23, 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
6 changes: 6 additions & 0 deletions cms/server/admin/handlers/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ def get(self, contest_id: str):
all_student_tags = get_all_student_tags(training_program)
self.r_params["all_student_tags"] = all_student_tags

# Add timezone info for training day groups form
from cmscommon.datetime import get_timezone, get_timezone_name
tz = get_timezone(None, self.contest)
self.r_params["timezone"] = tz
self.r_params["timezone_name"] = get_timezone_name(tz)

self.render("contest.html", **self.r_params)

@require_permission(BaseHandler.PERMISSION_ALL)
Expand Down
143 changes: 94 additions & 49 deletions cms/server/admin/handlers/trainingprogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@
check_training_day_eligibility,
parse_tags,
)
from cmscommon.datetime import make_datetime
from cmscommon.datetime import make_datetime, get_timezone, local_to_utc, get_timezone_name

from .base import BaseHandler, SimpleHandler, require_permission
from .base import BaseHandler, SimpleHandler, require_permission, parse_datetime_with_timezone


class TrainingProgramListHandler(SimpleHandler("training_programs.html")):
Expand Down Expand Up @@ -1445,13 +1445,22 @@ def get(self, training_program_id: str):
).distinct()
self.r_params["all_student_tags"] = sorted([row.tag for row in tags_query.all()])

# Add timezone info for the form (use managing contest timezone)
tz = get_timezone(None, managing_contest)
self.r_params["timezone"] = tz
self.r_params["timezone_name"] = get_timezone_name(tz)

self.render("add_training_day.html", **self.r_params)

@require_permission(BaseHandler.PERMISSION_ALL)
def post(self, training_program_id: str):
fallback_page = self.url("training_program", training_program_id, "training_days", "add")

training_program = self.safe_get_item(TrainingProgram, training_program_id)
managing_contest = training_program.managing_contest

# Get timezone for parsing datetime inputs
tz = get_timezone(None, managing_contest)

try:
name = self.get_argument("name")
Expand All @@ -1462,40 +1471,47 @@ def post(self, training_program_id: str):
if not description or not description.strip():
description = name

# Parse optional start and stop times from datetime-local inputs
# Format from HTML5 datetime-local: YYYY-MM-DDTHH:MM
# Parse optional start time and duration from inputs
# Times are in the managing contest timezone
start_str = self.get_argument("start", "")
stop_str = self.get_argument("stop", "")
duration_hours_str = self.get_argument("duration_hours", "")
duration_minutes_str = self.get_argument("duration_minutes", "")

contest_kwargs: dict = {
"name": name,
"description": description,
}

if start_str:
# Convert from datetime-local format (YYYY-MM-DDTHH:MM) to datetime
contest_kwargs["start"] = dt.strptime(start_str, "%Y-%m-%dT%H:%M")
# Parse datetime in timezone and convert to UTC
local_start = dt.strptime(start_str, "%Y-%m-%dT%H:%M")
contest_kwargs["start"] = local_to_utc(local_start, tz)
else:
# Default to after training program end year (so contestants can't start until configured)
program_end_year = training_program.managing_contest.stop.year
program_end_year = managing_contest.stop.year
default_date = dt(program_end_year + 1, 1, 1, 0, 0)
contest_kwargs["start"] = default_date
# Also set analysis_start/stop to satisfy Contest check constraints
# (stop <= analysis_start and analysis_start <= analysis_stop)
contest_kwargs["analysis_start"] = default_date
contest_kwargs["analysis_stop"] = default_date

if stop_str:
contest_kwargs["stop"] = dt.strptime(stop_str, "%Y-%m-%dT%H:%M")
# Calculate stop time from start + duration
duration_hours = int(duration_hours_str) if duration_hours_str.strip() else 0
duration_minutes = int(duration_minutes_str) if duration_minutes_str.strip() else 0

if duration_hours > 0 or duration_minutes > 0:
duration = timedelta(hours=duration_hours, minutes=duration_minutes)
contest_kwargs["stop"] = contest_kwargs["start"] + duration
else:
# Default stop to same as start when not specified
program_end_year = training_program.managing_contest.stop.year
contest_kwargs["stop"] = dt(program_end_year + 1, 1, 1, 0, 0)
# Default stop to same as start when no duration specified
contest_kwargs["stop"] = contest_kwargs["start"]

# Parse main group configuration (if any)
group_tags = self.get_arguments("group_tag_name[]")
group_starts = self.get_arguments("group_start_time[]")
group_ends = self.get_arguments("group_end_time[]")
group_duration_hours = self.get_arguments("group_duration_hours[]")
group_duration_minutes = self.get_arguments("group_duration_minutes[]")
group_alphabeticals = self.get_arguments("group_alphabetical[]")

# Collect valid groups and their times for defaulting
Expand All @@ -1512,17 +1528,23 @@ def post(self, training_program_id: str):
group_end = None

if i < len(group_starts) and group_starts[i].strip():
group_start = dt.strptime(group_starts[i].strip(), "%Y-%m-%dT%H:%M")
local_group_start = dt.strptime(group_starts[i].strip(), "%Y-%m-%dT%H:%M")
group_start = local_to_utc(local_group_start, tz)
group_start_times.append(group_start)

if i < len(group_ends) and group_ends[i].strip():
group_end = dt.strptime(group_ends[i].strip(), "%Y-%m-%dT%H:%M")
# Calculate group end from start + duration
g_duration_hours = 0
g_duration_minutes = 0
if i < len(group_duration_hours) and group_duration_hours[i].strip():
g_duration_hours = int(group_duration_hours[i].strip())
if i < len(group_duration_minutes) and group_duration_minutes[i].strip():
g_duration_minutes = int(group_duration_minutes[i].strip())

if group_start and (g_duration_hours > 0 or g_duration_minutes > 0):
group_duration = timedelta(hours=g_duration_hours, minutes=g_duration_minutes)
group_end = group_start + group_duration
group_end_times.append(group_end)

# Validate group end is not before start
if group_start and group_end and group_end < group_start:
raise ValueError(f"End time must be after start time for group '{tag}'")

alphabetical = str(i) in group_alphabeticals

groups_to_create.append({
Expand All @@ -1535,7 +1557,7 @@ def post(self, training_program_id: str):
# Default training start/end from group times if not specified
if not start_str and group_start_times:
contest_kwargs["start"] = min(group_start_times)
if not stop_str and group_end_times:
if (duration_hours == 0 and duration_minutes == 0) and group_end_times:
contest_kwargs["stop"] = max(group_end_times)

contest = Contest(**contest_kwargs)
Expand Down Expand Up @@ -1574,9 +1596,11 @@ def post(self, training_program_id: str):
# Auto-add participations for all students in the training program
# Training days are for all students, so we create participations
# in the training day's contest for each student
# Pass the hidden property from the managing contest participation
for student in training_program.students:
user = student.participation.user
participation = Participation(contest=contest, user=user)
hidden = student.participation.hidden
participation = Participation(contest=contest, user=user, hidden=hidden)
self.sql_session.add(participation)

except Exception as error:
Expand Down Expand Up @@ -1683,6 +1707,9 @@ def post(self, contest_id: str):

fallback_page = self.url("contest", contest_id)

# Get timezone for parsing datetime inputs (use contest timezone)
tz = get_timezone(None, contest)

try:
tag_name = self.get_argument("tag_name")
if not tag_name or not tag_name.strip():
Expand All @@ -1699,9 +1726,10 @@ def post(self, contest_id: str):
if existing:
raise ValueError(f"Tag '{tag_name}' is already a main group")

# Parse optional start and end times
# Parse optional start time and duration
start_str = self.get_argument("start_time", "")
end_str = self.get_argument("end_time", "")
duration_hours_str = self.get_argument("duration_hours", "")
duration_minutes_str = self.get_argument("duration_minutes", "")

group_kwargs: dict = {
"training_day": training_day,
Expand All @@ -1710,23 +1738,24 @@ def post(self, contest_id: str):
}

if start_str:
group_kwargs["start_time"] = dt.strptime(start_str, "%Y-%m-%dT%H:%M")
local_start = dt.strptime(start_str, "%Y-%m-%dT%H:%M")
group_kwargs["start_time"] = local_to_utc(local_start, tz)

if end_str:
group_kwargs["end_time"] = dt.strptime(end_str, "%Y-%m-%dT%H:%M")
# Calculate end time from start + duration
duration_hours = int(duration_hours_str) if duration_hours_str.strip() else 0
duration_minutes = int(duration_minutes_str) if duration_minutes_str.strip() else 0

# Validate that end is not before start
if "start_time" in group_kwargs and "end_time" in group_kwargs:
if group_kwargs["end_time"] < group_kwargs["start_time"]:
raise ValueError("End time must be after start time")
if "start_time" in group_kwargs and (duration_hours > 0 or duration_minutes > 0):
duration = timedelta(hours=duration_hours, minutes=duration_minutes)
group_kwargs["end_time"] = group_kwargs["start_time"] + duration

# Validate group times are within contest bounds
if "start_time" in group_kwargs and contest.start:
if group_kwargs["start_time"] < contest.start:
raise ValueError(f"Group start time cannot be before training day start ({contest.start})")
raise ValueError(f"Group start time cannot be before training day start")
if "end_time" in group_kwargs and contest.stop:
if group_kwargs["end_time"] > contest.stop:
raise ValueError(f"Group end time cannot be after training day end ({contest.stop})")
raise ValueError(f"Group end time cannot be after training day end")

group = TrainingDayGroup(**group_kwargs)
self.sql_session.add(group)
Expand All @@ -1753,38 +1782,45 @@ def post(self, contest_id: str):

fallback_page = self.url("contest", contest_id)

# Get timezone for parsing datetime inputs (use contest timezone)
tz = get_timezone(None, contest)

try:
group_ids = self.get_arguments("group_id[]")
start_times = self.get_arguments("start_time[]")
end_times = self.get_arguments("end_time[]")
duration_hours_list = self.get_arguments("duration_hours[]")
duration_minutes_list = self.get_arguments("duration_minutes[]")

if len(group_ids) != len(start_times) or len(group_ids) != len(end_times):
if len(group_ids) != len(start_times):
raise ValueError("Mismatched form data")

for i, group_id in enumerate(group_ids):
group = self.safe_get_item(TrainingDayGroup, group_id)
if group.training_day_id != training_day.id:
raise ValueError(f"Group {group_id} does not belong to this training day")

# Parse start time
# Parse start time in timezone and convert to UTC
start_str = start_times[i].strip()
if start_str:
group.start_time = dt.strptime(start_str, "%Y-%m-%dT%H:%M")
local_start = dt.strptime(start_str, "%Y-%m-%dT%H:%M")
group.start_time = local_to_utc(local_start, tz)
else:
group.start_time = None

# Parse end time
end_str = end_times[i].strip()
if end_str:
group.end_time = dt.strptime(end_str, "%Y-%m-%dT%H:%M")
# Calculate end time from start + duration
duration_hours = 0
duration_minutes = 0
if i < len(duration_hours_list) and duration_hours_list[i].strip():
duration_hours = int(duration_hours_list[i].strip())
if i < len(duration_minutes_list) and duration_minutes_list[i].strip():
duration_minutes = int(duration_minutes_list[i].strip())

if group.start_time and (duration_hours > 0 or duration_minutes > 0):
duration = timedelta(hours=duration_hours, minutes=duration_minutes)
group.end_time = group.start_time + duration
else:
group.end_time = None

# Validate end is not before start
if group.start_time and group.end_time:
if group.end_time < group.start_time:
raise ValueError(f"End time must be after start time for group '{group.tag_name}'")

# Validate group times are within contest bounds
if group.start_time and contest.start:
if group.start_time < contest.start:
Expand Down Expand Up @@ -2754,9 +2790,13 @@ def get(self, training_program_id: str):
# Apply student tag filter (current tags only)
if student_tags and student_id not in current_tag_student_ids:
continue
# Skip hidden users
student = attendance.student
if student.participation and student.participation.hidden:
continue
if student_id not in attendance_data:
attendance_data[student_id] = {}
all_students[student_id] = attendance.student
all_students[student_id] = student
attendance_data[student_id][td.id] = attendance

# Sort students by username
Expand Down Expand Up @@ -2833,6 +2873,11 @@ def get(self, training_program_id: str):
for ranking in td.archived_student_rankings:
student_id = ranking.student_id

# Skip hidden users
student = ranking.student
if student.participation and student.participation.hidden:
continue

# Apply student tag filter
if student_tags:
if student_tags_mode == "current":
Expand All @@ -2850,7 +2895,7 @@ def get(self, training_program_id: str):

if student_id not in ranking_data:
ranking_data[student_id] = {}
all_students[student_id] = ranking.student
all_students[student_id] = student
ranking_data[student_id][td.id] = ranking

# Collect all visible tasks from this student's task_scores keys
Expand Down
47 changes: 29 additions & 18 deletions cms/server/admin/templates/add_training_day.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,21 @@ <h1>Add Training Day to <a href="{{ url("training_program", training_program.id)
<tr>
<td>
<span class="info" title="When the training day starts. If not specified and groups have start times, defaults to the earliest group start time."></span>
Start time (UTC)
Start time ({{ timezone_name }})
</td>
<td><input type="datetime-local" name="start"/></td>
<td class="hint">When the training day starts (optional, defaults to earliest group time)</td>
</tr>
<tr>
<td>
<span class="info" title="When the training day ends. If not specified and groups have end times, defaults to the latest group end time."></span>
End time (UTC)
<span class="info" title="Duration of the training day. If not specified and groups have durations, defaults to cover all group times."></span>
Duration
</td>
<td><input type="datetime-local" name="stop"/></td>
<td class="hint">When the training day ends (optional, defaults to latest group time)</td>
<td>
<input type="number" name="duration_hours" min="0" style="width: 60px;" placeholder="0"/> hours
<input type="number" name="duration_minutes" min="0" max="59" style="width: 60px;" placeholder="0"/> minutes
</td>
<td class="hint">Duration of the training day (optional)</td>
</tr>
</table>

Expand All @@ -43,8 +46,8 @@ <h2>Main Groups Configuration</h2>
<thead>
<tr>
<th>Tag Name</th>
<th>Start Time (UTC)</th>
<th>End Time (UTC)</th>
<th>Start Time ({{ timezone_name }})</th>
<th>Duration</th>
<th>Alphabetical Task Order</th>
<th>Actions</th>
</tr>
Expand Down Expand Up @@ -84,12 +87,25 @@ <h2>Main Groups Configuration</h2>
startCell.appendChild(startInput);
row.appendChild(startCell);

var endCell = document.createElement('td');
var endInput = document.createElement('input');
endInput.type = 'datetime-local';
endInput.name = 'group_end_time[]';
endCell.appendChild(endInput);
row.appendChild(endCell);
var durationCell = document.createElement('td');
var hoursInput = document.createElement('input');
hoursInput.type = 'number';
hoursInput.name = 'group_duration_hours[]';
hoursInput.min = '0';
hoursInput.style.width = '60px';
hoursInput.placeholder = '0';
durationCell.appendChild(hoursInput);
durationCell.appendChild(document.createTextNode(' h '));
var minutesInput = document.createElement('input');
minutesInput.type = 'number';
minutesInput.name = 'group_duration_minutes[]';
minutesInput.min = '0';
minutesInput.max = '59';
minutesInput.style.width = '60px';
minutesInput.placeholder = '0';
durationCell.appendChild(minutesInput);
durationCell.appendChild(document.createTextNode(' m'));
row.appendChild(durationCell);

var alphaCell = document.createElement('td');
alphaCell.style.textAlign = 'center';
Expand Down Expand Up @@ -142,11 +158,6 @@ <h2>Main Groups Configuration</h2>

document.getElementById('add-group-btn').addEventListener('click', addGroupRow);

CMS.AWSUtils.initDateTimeValidation(
'form[name="add_training_day"]',
'input[name="start"]',
'input[name="stop"]'
);
</script>

<p>
Expand Down
Loading
Loading