Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
35 changes: 35 additions & 0 deletions cms/server/admin/handlers/trainingprogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -1789,6 +1789,39 @@ def get(self, training_program_id: str, user_id: str):
assigned_task_ids = {st.task_id for st in student.student_tasks}
available_tasks = [t for t in all_tasks if t.id not in assigned_task_ids]

# Build home scores from participation task_scores cache
home_scores = {}
for pts in participation.task_scores:
home_scores[pts.task_id] = pts.score

# Build training scores from archived student rankings (batch query)
training_scores = {}
source_training_day_ids = {
st.source_training_day_id
for st in student.student_tasks
if st.source_training_day_id is not None
}
archived_rankings = {}
if source_training_day_ids:
archived_rankings = {
r.training_day_id: r
for r in (
self.sql_session.query(ArchivedStudentRanking)
.filter(ArchivedStudentRanking.training_day_id.in_(source_training_day_ids))
.filter(ArchivedStudentRanking.student_id == student.id)
.all()
)
}

for st in student.student_tasks:
if st.source_training_day_id is None:
continue
archived_ranking = archived_rankings.get(st.source_training_day_id)
if archived_ranking and archived_ranking.task_scores:
task_id_str = str(st.task_id)
if task_id_str in archived_ranking.task_scores:
training_scores[st.task_id] = archived_ranking.task_scores[task_id_str]

self.r_params = self.render_params()
self.r_params["training_program"] = training_program
self.r_params["participation"] = participation
Expand All @@ -1798,6 +1831,8 @@ def get(self, training_program_id: str, user_id: str):
student.student_tasks, key=lambda st: st.assigned_at, reverse=True
)
self.r_params["available_tasks"] = available_tasks
self.r_params["home_scores"] = home_scores
self.r_params["training_scores"] = training_scores
self.r_params["unanswered"] = self.sql_session.query(Question)\
.join(Participation)\
.filter(Participation.contest_id == managing_contest.id)\
Expand Down
16 changes: 16 additions & 0 deletions cms/server/admin/templates/student_tasks.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ <h2 id="title_assigned_tasks" class="toggling_on">Assigned Tasks ({{ student_tas
<th>Task</th>
<th>Source</th>
<th>Assigned At</th>
<th>Training Score</th>
<th>Home Score</th>
<th>Actions</th>
</tr>
</thead>
Expand All @@ -64,6 +66,20 @@ <h2 id="title_assigned_tasks" class="toggling_on">Assigned Tasks ({{ student_tas
{% endif %}
</td>
<td>{{ st.assigned_at }}</td>
<td>
{% if st.task.id in training_scores %}
{{ "%.2f"|format(training_scores[st.task.id]) }}
{% else %}
-
{% endif %}
</td>
<td>
{% if st.task.id in home_scores %}
{{ "%.2f"|format(home_scores[st.task.id]) }}
{% else %}
-
{% endif %}
</td>
<td>
<form action="{{ url("training_program", training_program.id, "student", selected_user.id, "task", st.task.id, "remove") }}" method="POST" style="display: inline;">
{{ xsrf_form_html|safe }}
Expand Down
4 changes: 3 additions & 1 deletion cms/server/contest/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
from .delayrequest import \
DelayRequestHandler
from .trainingprogram import \
TrainingProgramOverviewHandler
TrainingProgramOverviewHandler, \
TrainingDaysHandler
from .api import \
ApiLoginHandler, \
ApiSubmissionListHandler, \
Expand Down Expand Up @@ -116,6 +117,7 @@
# Training Programs

(r"/training_overview", TrainingProgramOverviewHandler),
(r"/training_days", TrainingDaysHandler),

# API
(r"/api/login", ApiLoginHandler),
Expand Down
203 changes: 199 additions & 4 deletions cms/server/contest/handlers/trainingprogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@
# 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/>.

"""Training program overview handler for CWS.
"""Training program handlers for CWS.

This handler provides a custom overview page for training programs,
showing total score, percentage, task archive, and upcoming training days.
This module provides handlers for training programs in the contest web server,
including the overview page and training days page.
"""

from datetime import timedelta

import tornado.web

from cms.db import Participation, Student
from cms.db import Participation, Student, ArchivedStudentRanking
from cms.server import multi_contest
from cms.server.contest.phase_management import compute_actual_phase, compute_effective_times
from cms.server.util import check_training_day_eligibility, calculate_task_archive_progress
Expand Down Expand Up @@ -169,3 +169,198 @@ def get(self):
server_timestamp=self.timestamp,
**self.r_params
)


class TrainingDaysHandler(ContestHandler):
"""Training days page handler.

Shows all training days for a training program, including:
- Ongoing and upcoming trainings (non-archived)
- Past trainings with scores (training score, home score, total)
"""

@tornado.web.authenticated
@multi_contest
def get(self):
participation: Participation = self.current_user
contest = self.contest

training_program = self.training_program
if training_program is None:
raise tornado.web.HTTPError(404)

student = (
self.sql_session.query(Student)
.join(Participation, Student.participation_id == Participation.id)
.filter(Participation.contest_id == contest.id)
.filter(Participation.user_id == participation.user_id)
.filter(Student.training_program_id == training_program.id)
.first()
)

ongoing_upcoming_trainings = []
past_trainings = []

# Collect past training day IDs for batch query
past_training_day_ids = [
td.id for td in training_program.training_days if td.contest is None
]

# Batch fetch all ArchivedStudentRanking records for the student
archived_rankings_map = {}
if student is not None and past_training_day_ids:
archived_rankings = (
self.sql_session.query(ArchivedStudentRanking)
.filter(ArchivedStudentRanking.training_day_id.in_(past_training_day_ids))
.filter(ArchivedStudentRanking.student_id == student.id)
.all()
)
archived_rankings_map = {r.training_day_id: r for r in archived_rankings}

for training_day in training_program.training_days:
td_contest = training_day.contest

if td_contest is None:
past_trainings.append(self._build_past_training_info(
training_day, student, participation, archived_rankings_map
))
continue

td_participation = (
self.sql_session.query(Participation)
.filter(Participation.contest == td_contest)
.filter(Participation.user == participation.user)
.first()
)

if td_participation is None:
continue

is_eligible, main_group, _ = check_training_day_eligibility(
self.sql_session, td_participation, training_day
)
if not is_eligible:
continue

main_group_start = main_group.start_time if main_group else None
main_group_end = main_group.end_time if main_group else None
contest_start, contest_stop = compute_effective_times(
td_contest.start, td_contest.stop,
td_participation.delay_time,
main_group_start, main_group_end)

actual_phase, _, _, _, _ = compute_actual_phase(
self.timestamp,
contest_start,
contest_stop,
td_contest.analysis_start if td_contest.analysis_enabled else None,
td_contest.analysis_stop if td_contest.analysis_enabled else None,
td_contest.per_user_time,
td_participation.starting_time,
td_participation.delay_time,
td_participation.extra_time,
)

user_start_time = contest_start + td_participation.delay_time

duration = td_contest.per_user_time \
if td_contest.per_user_time is not None else \
contest_stop - contest_start

six_hours_from_now = self.timestamp + timedelta(hours=6)
has_started = actual_phase >= -1
can_enter_soon = not has_started and user_start_time <= six_hours_from_now

ongoing_upcoming_trainings.append({
"training_day": training_day,
"contest": td_contest,
"participation": td_participation,
"has_started": has_started,
"has_ended": actual_phase >= 1,
"user_start_time": user_start_time,
"duration": duration,
"can_enter_soon": can_enter_soon,
})

ongoing_upcoming_trainings.sort(key=lambda x: x["user_start_time"])
past_trainings.sort(
key=lambda x: x["start_time"] if x["start_time"] else self.timestamp,
reverse=True
)

self.render(
"training_days.html",
ongoing_upcoming_trainings=ongoing_upcoming_trainings,
past_trainings=past_trainings,
server_timestamp=self.timestamp,
**self.r_params
)

def _build_past_training_info(
self,
training_day,
student: Student | None,
participation: Participation,
archived_rankings_map: dict
) -> dict:
"""Build info dict for a past (archived) training day."""
training_score = 0.0
home_score = 0.0
max_score = 0.0
tasks_info = []

archived_tasks_data = training_day.archived_tasks_data or {}

if student is not None:
archived_ranking = archived_rankings_map.get(training_day.id)

archived_task_scores = {}
if archived_ranking and archived_ranking.task_scores:
archived_task_scores = archived_ranking.task_scores

cached_scores = {}
for pts in participation.task_scores:
cached_scores[pts.task_id] = pts.score

for task_id_str, task_data in archived_tasks_data.items():
task_max_score = task_data.get("max_score", 100.0)
max_score += task_max_score

task_training_score = archived_task_scores.get(task_id_str, 0.0)
training_score += task_training_score

task_id = int(task_id_str)
task_home_score = cached_scores.get(task_id, 0.0)
home_score += task_home_score

tasks_info.append({
"task_id": task_id,
"name": task_data.get("short_name", ""),
"title": task_data.get("name", ""),
"training_score": task_training_score,
"home_score": task_home_score,
"max_score": task_max_score,
})
else:
for task_id_str, task_data in archived_tasks_data.items():
task_max_score = task_data.get("max_score", 100.0)
max_score += task_max_score
tasks_info.append({
"task_id": int(task_id_str),
"name": task_data.get("short_name", ""),
"title": task_data.get("name", ""),
"training_score": 0.0,
"home_score": 0.0,
"max_score": task_max_score,
})

return {
"training_day": training_day,
"name": training_day.name,
"description": training_day.description,
"start_time": training_day.start_time,
"training_score": training_score,
"home_score": home_score,
"max_score": max_score,
"tasks": tasks_info,
}
17 changes: 17 additions & 0 deletions cms/server/contest/static/cws_style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1054,3 +1054,20 @@ td.token_rules p:last-child {
background-color: white;
border: 1px solid #e5e5e5;
}

.progress-track {
background: rgba(255,255,255,0.2);
border-radius: 10px;
height: 30px;
overflow: hidden;
}

.progress-fill {
height: 100%;
border-radius: 10px 0 0 10px;
transition: width 0.5s ease;
}

.progress-fill.high { background-color: #28a745; }
.progress-fill.med { background-color: #ffc107; }
.progress-fill.low { background-color: #ff6b6b; }
9 changes: 9 additions & 0 deletions cms/server/contest/templates/contest.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,14 @@
</p>
{% endif %}
{% if participation is defined %}
{% if contest.training_day is not none %}
<a href="/{{ contest.training_day.training_program.name }}/training_overview" class="btn btn-warning navbar-btn pull-right" style="margin-left: 10px;">{% trans %}Back to Training Program{% endtrans %}</a>
{% else %}
<form action="{{ contest_url("logout") }}" method="POST" class="navbar-form pull-right">
{{ xsrf_form_html|safe }}
<button type="submit" class="btn btn-warning">{% trans %}Logout{% endtrans %}</button>
</form>
{% endif %}
<p class="navbar-text pull-right">
{% trans first_name=user.first_name,
last_name=user.last_name,
Expand Down Expand Up @@ -180,6 +184,11 @@ <h3 id="countdown_box">
<span id="unread_count" class="label label-warning no_unread"></span>
</a>
</li>
{% if training_program is not none %}
<li{% if page == "training_days" %} class="active"{% endif %}>
<a href="{{ contest_url("training_days") }}">{% trans %}Training Days{% endtrans %}</a>
</li>
{% endif %}
{% if training_program is none and ((actual_phase >= 0 and participation.starting_time is not none) or participation.unrestricted) %}
{% for t_iter in visible_tasks %}
<li class="nav-header">
Expand Down
Loading
Loading