Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
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: 5 additions & 1 deletion cms/server/admin/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@
TrainingProgramAttendanceHandler, \
TrainingProgramCombinedRankingHandler, \
TrainingProgramCombinedRankingHistoryHandler, \
TrainingProgramCombinedRankingDetailHandler
TrainingProgramCombinedRankingDetailHandler, \
TrainingProgramOverviewRedirectHandler, \
TrainingProgramResourcesListRedirectHandler


HANDLERS = [
Expand Down Expand Up @@ -366,6 +368,8 @@
(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),
(r"/training_program/([0-9]+)/overview", TrainingProgramOverviewRedirectHandler),
(r"/training_program/([0-9]+)/resourceslist", TrainingProgramResourcesListRedirectHandler),

# Training day groups (main groups configuration on contest page)
(r"/contest/([0-9]+)/training_day_group/add", AddTrainingDayGroupHandler),
Expand Down
9 changes: 6 additions & 3 deletions cms/server/admin/handlers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,13 +329,16 @@ def prepare(self):
contest_id = match.group(1)
remaining_path = match.group(2) or ""

# Don't redirect question/announcement/message actions - they should use
# the contest handlers directly since questions/announcements
# belong to the managing contest, and messages use the contest user
# Don't redirect certain actions - they should use the contest handlers
# directly since questions/announcements belong to the managing contest,
# messages use the contest user, and overview/resourceslist are contest-
# specific pages that training programs redirect to
if (
remaining_path.startswith("/question/")
or remaining_path.startswith("/announcement/")
or remaining_path.endswith("/message")
or remaining_path == "/overview"
or remaining_path == "/resourceslist"
):
return

Expand Down
38 changes: 30 additions & 8 deletions cms/server/admin/handlers/contesttask.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,28 @@ def get(self, contest_id):
self.r_params["is_training_day"] = training_day is not None

if training_day is not None:
# For training days, show tasks from the training program's
# managing contest that are not already assigned to any training day
# For training days, show all tasks that are not already assigned
# to any training day. Tasks in the training program are shown first,
# followed by tasks not in the training program.
training_program = training_day.training_program
self.r_params["unassigned_tasks"] = \
self.sql_session.query(Task)\
.filter(Task.contest_id == training_program.managing_contest_id)\
.filter(Task.training_day_id.is_(None))\
.order_by(Task.num)\
.all()

# Tasks in the training program (not assigned to any training day)
program_tasks = self.sql_session.query(Task)\
.filter(Task.contest_id == training_program.managing_contest_id)\
.filter(Task.training_day_id.is_(None))\
.order_by(Task.num)\
.all()

# All other tasks (not in any contest or training day)
other_tasks = self.sql_session.query(Task)\
.filter(Task.contest_id.is_(None))\
.filter(Task.training_day_id.is_(None))\
.order_by(Task.name)\
.all()

self.r_params["unassigned_tasks"] = program_tasks + other_tasks
# Track which task IDs are in the training program for the template
self.r_params["program_task_ids"] = [t.id for t in program_tasks]

# Get all student tags for autocomplete (for task visibility tags)
self.r_params["all_student_tags"] = get_all_student_tags(training_program)
Expand Down Expand Up @@ -282,6 +295,15 @@ def post(self, contest_id):
task = self.safe_get_item(Task, task_id)

if training_day is not None:
training_program = training_day.training_program

# Check if task is not in the training program
if task.contest_id != training_program.managing_contest_id:
# Add the task to the training program's managing contest first
managing_contest = training_program.managing_contest
task.num = len(managing_contest.tasks)
task.contest = managing_contest

# Assign the task to the training day.
# Task keeps its contest_id (managing contest) and gets training_day_id set.
# Use training_day_num for ordering within the training day.
Expand Down
7 changes: 6 additions & 1 deletion cms/server/admin/handlers/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,12 @@ class TaskHandler(BaseHandler):
@require_permission(BaseHandler.AUTHENTICATED)
def get(self, task_id):
task = self.safe_get_item(Task, task_id)
self.contest = task.contest
# If the task is assigned to an active training day (not archived),
# show the training day's contest sidebar instead of the training program sidebar
if task.training_day is not None and task.training_day.contest is not None:
self.contest = task.training_day.contest
else:
self.contest = task.contest

self.r_params = self.render_params()
self.r_params["task"] = task
Expand Down
120 changes: 98 additions & 22 deletions cms/server/admin/handlers/trainingprogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,10 @@ def get(self, training_program_id: str):
def post(self, training_program_id: str):
fallback = self.url("training_program", training_program_id)
training_program = self.safe_get_item(TrainingProgram, training_program_id)
contest = training_program.managing_contest

try:
# Update training program attributes
attrs = training_program.get_attrs()
self.get_string(attrs, "name")
self.get_string(attrs, "description")
Expand All @@ -122,28 +124,72 @@ def post(self, training_program_id: str):
training_program.set_attrs(attrs)

# Sync description to managing contest
training_program.managing_contest.description = attrs["description"]

# Parse and update start/stop times on managing contest
start_str = self.get_argument("start", "")
stop_str = self.get_argument("stop", "")

if start_str:
training_program.managing_contest.start = dt.strptime(
start_str, "%Y-%m-%dT%H:%M"
)

if stop_str:
training_program.managing_contest.stop = dt.strptime(
stop_str, "%Y-%m-%dT%H:%M"
)
contest.description = attrs["description"]

# Update managing contest configuration fields
contest_attrs = contest.get_attrs()

# Allowed localizations (comma-separated list)
allowed_localizations: str = self.get_argument("allowed_localizations", "")
if allowed_localizations:
contest_attrs["allowed_localizations"] = [
x.strip()
for x in allowed_localizations.split(",")
if len(x) > 0 and not x.isspace()
]
else:
contest_attrs["allowed_localizations"] = []

# Programming languages
contest_attrs["languages"] = self.get_arguments("languages")

# Boolean settings
self.get_bool(contest_attrs, "submissions_download_allowed")
self.get_bool(contest_attrs, "allow_questions")
self.get_bool(contest_attrs, "allow_user_tests")
self.get_bool(contest_attrs, "allow_unofficial_submission_before_analysis_mode")
self.get_bool(contest_attrs, "allow_delay_requests")

# Login section boolean settings
self.get_bool(contest_attrs, "block_hidden_participations")
self.get_bool(contest_attrs, "allow_password_authentication")
self.get_bool(contest_attrs, "allow_registration")
self.get_bool(contest_attrs, "ip_restriction")
self.get_bool(contest_attrs, "ip_autologin")

# Score precision
self.get_int(contest_attrs, "score_precision")

# Times
self.get_datetime(contest_attrs, "start")
self.get_datetime(contest_attrs, "stop")
self.get_string(contest_attrs, "timezone", empty=None)
self.get_timedelta_sec(contest_attrs, "per_user_time")

# Limits
self.get_int(contest_attrs, "max_submission_number")
self.get_int(contest_attrs, "max_user_test_number")
self.get_timedelta_sec(contest_attrs, "min_submission_interval")
self.get_timedelta_sec(contest_attrs, "min_submission_interval_grace_period")
self.get_timedelta_sec(contest_attrs, "min_user_test_interval")

# Token parameters
self.get_string(contest_attrs, "token_mode")
self.get_int(contest_attrs, "token_max_number")
self.get_timedelta_sec(contest_attrs, "token_min_interval")
self.get_int(contest_attrs, "token_gen_initial")
self.get_int(contest_attrs, "token_gen_number")
self.get_timedelta_min(contest_attrs, "token_gen_interval")
self.get_int(contest_attrs, "token_gen_max")

# Apply contest attributes
contest.set_attrs(contest_attrs)

# Validate that stop is not before start (only if both are set)
if (
training_program.managing_contest.start is not None
and training_program.managing_contest.stop is not None
and training_program.managing_contest.stop
< training_program.managing_contest.start
contest.start is not None
and contest.stop is not None
and contest.stop < contest.start
):
raise ValueError("End time must be after start time")

Expand All @@ -154,7 +200,9 @@ def post(self, training_program_id: str):
self.redirect(fallback)
return

self.try_commit()
if self.try_commit():
# Update the contest on RWS.
self.service.proxy_service.reinitialize()
self.redirect(fallback)


Expand Down Expand Up @@ -449,7 +497,13 @@ def post(self, training_program_id: str):

user = self.safe_get_item(User, user_id)

participation = Participation(contest=managing_contest, user=user)
# Set starting_time to now so the student can see everything immediately
# (training programs don't have a start button)
participation = Participation(
contest=managing_contest,
user=user,
starting_time=make_datetime()
)
self.sql_session.add(participation)
self.sql_session.flush()

Expand Down Expand Up @@ -1973,7 +2027,7 @@ def post(self, training_program_id: str):

try:
task_id = self.get_argument("task_id")
if task_id == "null":
if task_id in ("", "null"):
raise ValueError("Please select a task")

tag_name = self.get_argument("tag", "").strip().lower()
Expand Down Expand Up @@ -2812,3 +2866,25 @@ def get_training_day_num(task_id: int) -> tuple[int, int]:
.filter(Question.ignored.is_(False))\
.count()
self.render("training_program_combined_ranking_detail.html", **self.r_params)


class TrainingProgramOverviewRedirectHandler(BaseHandler):
"""Redirect /training_program/{id}/overview to the managing contest's overview page."""

@require_permission(BaseHandler.AUTHENTICATED)
def get(self, training_program_id: str):
training_program = self.safe_get_item(TrainingProgram, training_program_id)
self.redirect(
self.url("contest", training_program.managing_contest.id, "overview")
)


class TrainingProgramResourcesListRedirectHandler(BaseHandler):
"""Redirect /training_program/{id}/resourceslist to the managing contest's resourceslist page."""

@require_permission(BaseHandler.AUTHENTICATED)
def get(self, training_program_id: str):
training_program = self.safe_get_item(TrainingProgram, training_program_id)
self.redirect(
self.url("contest", training_program.managing_contest.id, "resourceslist")
)
33 changes: 30 additions & 3 deletions cms/server/admin/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// Pre-paint CSS injection to prevent flash of expanded menus
// Uses a hydration class on <html> so these rules only apply during initial render
(function() {
var validSectionKeys = ['training-programs', 'contests', 'tasks', 'users', 'teams'];
var validSectionKeys = ['training-programs', 'contests', 'tasks', 'users', 'teams', 'training-days'];
var folderKeyPattern = /^(folder|subfolder|training-program)-\d+$/;
var css = '';

Expand Down Expand Up @@ -284,9 +284,12 @@ <h1 class="sidebar-title"><a href="{{ url() }}">Administration</a></h1>
</div>
{% endif %}
<ul class="menu">
{% if contest is none or training_program is defined %}
{% if contest is none and training_program is not defined %}
<li class="menu_entry"><a class="menu_link" href="{{ url() }}">Overview</a></li>
<li class="menu_entry"><a class="menu_link" href="{{ url("resourceslist") }}">Resource usage</a></li>
{% elif training_program is defined %}
<li class="menu_entry"><a class="menu_link" href="{{ url("contest", training_program.managing_contest.id, "overview") }}">Overview</a></li>
<li class="menu_entry"><a class="menu_link" href="{{ url("contest", training_program.managing_contest.id, "resourceslist") }}">Resource usage</a></li>
{% else %}
<li class="menu_entry"><a class="menu_link" href="{{ url("contest", contest.id, "overview") }}">Overview</a></li>
<li class="menu_entry"><a class="menu_link" href="{{ url("contest", contest.id, "resourceslist") }}">Resource usage</a></li>
Expand Down Expand Up @@ -478,7 +481,6 @@ <h2 class="sidebar-section-title">
<li class="menu_entry"><a class="menu_link" href="{{ url("training_program", training_program.id, "submissions") }}">Submissions</a></li>
<li class="menu_entry"><a class="menu_link" href="{{ url("training_program", training_program.id, "students") }}">Students</a></li>
<li class="menu_entry"><a class="menu_link" href="{{ url("training_program", training_program.id, "tasks") }}">Tasks</a></li>
<li class="menu_entry"><a class="menu_link" href="{{ url("training_program", training_program.id, "training_days") }}">Training Days</a></li>
<li class="menu_entry"><a class="menu_link" href="{{ url("training_program", training_program.id, "attendance") }}">Attendance</a></li>
<li class="menu_entry"><a class="menu_link" href="{{ url("training_program", training_program.id, "combined_ranking") }}">Combined Ranking</a></li>
<li class="menu_entry"><a class="menu_link" href="{{ url("training_program", training_program.id, "announcements") }}">Announcements</a></li>
Expand All @@ -491,6 +493,31 @@ <h2 class="sidebar-section-title">
</li>
</ul>
<div class="hr"></div>
<div class="sidebar-section" data-section-key="training-days">
<h2 class="sidebar-section-title">
<span class="section-caret"></span>
<a href="{{ url("training_program", training_program.id, "training_days") }}">Training Days</a>
</h2>
<div class="sidebar-section-body">
{% set active_training_days = training_program.training_days | selectattr("contest") | list %}
{% if active_training_days|length > 0 %}
<ul class="sidebar-menu">
{% for td in active_training_days %}
<li class="sidebar-contest">
<a href="{{ url("contest", td.contest_id) }}" title="{{ td.contest.description }}">{{ td.contest.name }}</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="sidebar-empty">No active training days</p>
{% endif %}
{% if admin.permission_all %}
<div class="sidebar-actions">
<a class="sidebar-action-link" href="{{ url("training_program", training_program.id, "training_days", "add") }}">Add a training day</a>
</div>
{% endif %}
</div>
</div>
<div class="footer">
<span id="remaining_text"></span>
<span id="remaining_value"></span>
Expand Down
4 changes: 2 additions & 2 deletions cms/server/admin/templates/bulk_assign_task.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ <h2 id="title_assign_task_to_group" class="toggling_on">Assign Task to a Group</
Task
</td>
<td>
<select name="task_id">
<option value="null" selected>-- Select a task --</option>
<select name="task_id" class="searchable-select">
<option value="" selected disabled hidden>Select a task...</option>
{% for task in all_tasks %}
<option value="{{ task.id }}">{{ task.name }} - {{ task.title }}</option>
{% endfor %}
Expand Down
Loading
Loading