diff --git a/cms/db/__init__.py b/cms/db/__init__.py index 13da220332..c228ec654f 100644 --- a/cms/db/__init__.py +++ b/cms/db/__init__.py @@ -54,7 +54,7 @@ "FSObject", "LargeObject", # contest "Contest", "Announcement", "ContestFolder", "TrainingProgram", "Student", - "TrainingDay", "TrainingDayGroup", + "TrainingDay", "TrainingDayGroup", "StudentTask", # user "User", "Team", "Participation", "Message", "Question", "DelayRequest", # admin @@ -109,6 +109,7 @@ from .training_day import TrainingDay from .training_day_group import TrainingDayGroup from .student import Student +from .student_task import StudentTask from .user import User, Team, Participation, Message, Question, DelayRequest from .task import Task, Statement, Attachment, Dataset, Manager, Testcase, \ Generator diff --git a/cms/db/student.py b/cms/db/student.py index ba4fa13324..04c0c7fc59 100644 --- a/cms/db/student.py +++ b/cms/db/student.py @@ -32,7 +32,7 @@ from . import Base if typing.TYPE_CHECKING: - from . import TrainingProgram, Participation + from . import TrainingProgram, Participation, StudentTask class Student(Base): @@ -80,3 +80,10 @@ class Student(Base): "Participation", back_populates="student", ) + + student_tasks: list["StudentTask"] = relationship( + "StudentTask", + back_populates="student", + cascade="all, delete-orphan", + passive_deletes=True, + ) diff --git a/cms/db/student_task.py b/cms/db/student_task.py new file mode 100644 index 0000000000..f8e6400de0 --- /dev/null +++ b/cms/db/student_task.py @@ -0,0 +1,96 @@ +#!/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 . + +"""StudentTask model for tracking which tasks each student has access to. + +A StudentTask represents a task that has been assigned to a student, +either automatically when they start a training day, or manually by an admin. +This controls which tasks appear in the student's task archive and are +included in their score calculations. +""" + +import typing +from datetime import datetime + +from sqlalchemy.orm import relationship +from sqlalchemy.schema import Column, ForeignKey, UniqueConstraint +from sqlalchemy.types import DateTime, Integer + +from . import Base + +if typing.TYPE_CHECKING: + from . import Student, Task, TrainingDay + + +class StudentTask(Base): + """A task assigned to a student. + + Tracks which tasks each student has access to in the task archive. + Tasks can be assigned automatically when a student starts a training day + (source_training_day_id is set), or manually by an admin + (source_training_day_id is NULL). + """ + __tablename__ = "student_tasks" + __table_args__ = ( + UniqueConstraint("student_id", "task_id", + name="student_tasks_student_id_task_id_key"), + ) + + id: int = Column(Integer, primary_key=True) + + student_id: int = Column( + Integer, + ForeignKey("students.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + task_id: int = Column( + Integer, + ForeignKey("tasks.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # If set, the task was assigned when the student started this training day. + # If NULL, the task was manually assigned by an admin. + source_training_day_id: int | None = Column( + Integer, + ForeignKey("training_days.id", onupdate="CASCADE", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + # When the task was assigned to the student. + assigned_at: datetime = Column( + DateTime, + nullable=False, + ) + + student: "Student" = relationship( + "Student", + back_populates="student_tasks", + ) + + task: "Task" = relationship( + "Task", + back_populates="student_tasks", + ) + + source_training_day: "TrainingDay | None" = relationship( + "TrainingDay", + ) diff --git a/cms/db/task.py b/cms/db/task.py index dcdc4877b6..55e45f04e6 100644 --- a/cms/db/task.py +++ b/cms/db/task.py @@ -50,6 +50,7 @@ from . import Submission, UserTest, TrainingDay from .scorecache import ParticipationTaskScore from .modelsolution import ModelSolutionMeta + from .student_task import StudentTask class Task(Base): @@ -324,6 +325,12 @@ class Task(Base): passive_deletes=True, back_populates="task") + student_tasks: list["StudentTask"] = relationship( + "StudentTask", + cascade="all, delete-orphan", + passive_deletes=True, + back_populates="task") + def get_allowed_languages(self) -> list[str]: """Get the list of allowed languages for this task. diff --git a/cms/server/admin/handlers/__init__.py b/cms/server/admin/handlers/__init__.py index 8109c17010..d987fac6a4 100644 --- a/cms/server/admin/handlers/__init__.py +++ b/cms/server/admin/handlers/__init__.py @@ -175,6 +175,10 @@ TrainingProgramQuestionsHandler, \ StudentHandler, \ StudentTagsHandler, \ + StudentTasksHandler, \ + AddStudentTaskHandler, \ + RemoveStudentTaskHandler, \ + BulkAssignTaskHandler, \ TrainingProgramTrainingDaysHandler, \ AddTrainingDayHandler, \ RemoveTrainingDayHandler, \ @@ -334,6 +338,10 @@ (r"/training_program/([0-9]+)/student/([0-9]+)/remove", RemoveTrainingProgramStudentHandler), (r"/training_program/([0-9]+)/student/([0-9]+)/edit", StudentHandler), (r"/training_program/([0-9]+)/student/([0-9]+)/tags", StudentTagsHandler), + (r"/training_program/([0-9]+)/student/([0-9]+)/tasks", StudentTasksHandler), + (r"/training_program/([0-9]+)/student/([0-9]+)/tasks/add", AddStudentTaskHandler), + (r"/training_program/([0-9]+)/student/([0-9]+)/task/([0-9]+)/remove", RemoveStudentTaskHandler), + (r"/training_program/([0-9]+)/bulk_assign_task", BulkAssignTaskHandler), (r"/training_program/([0-9]+)/tasks", TrainingProgramTasksHandler), (r"/training_program/([0-9]+)/tasks/add", AddTrainingProgramTaskHandler), (r"/training_program/([0-9]+)/task/([0-9]+)/remove", RemoveTrainingProgramTaskHandler), diff --git a/cms/server/admin/handlers/trainingprogram.py b/cms/server/admin/handlers/trainingprogram.py index 579daa7dd7..b15a908053 100644 --- a/cms/server/admin/handlers/trainingprogram.py +++ b/cms/server/admin/handlers/trainingprogram.py @@ -28,9 +28,9 @@ from sqlalchemy import func from cms.db import Contest, TrainingProgram, Participation, Submission, \ - User, Task, Question, Announcement, Student, Team, TrainingDay, \ - TrainingDayGroup -from cms.server.util import get_all_student_tags, deduplicate_preserving_order + User, Task, Question, Announcement, Student, StudentTask, Team, \ + TrainingDay, TrainingDayGroup +from cms.server.util import get_all_student_tags, deduplicate_preserving_order, calculate_task_archive_progress from cmscommon.datetime import make_datetime from .base import BaseHandler, SimpleHandler, require_permission @@ -373,13 +373,22 @@ def get(self, training_program_id: str): .filter(~User.username.like(r'\_\_%', escape='\\'))\ .all() + # Calculate task archive progress for each student using shared utility + student_progress = {} + for student in training_program.students: + student_progress[student.id] = calculate_task_archive_progress( + student, student.participation, managing_contest + ) + + self.r_params["student_progress"] = student_progress + self.render("training_program_students.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, "students") - training_program = self.safe_get_item(TrainingProgram, training_program_id) + self.safe_get_item(TrainingProgram, training_program_id) try: user_id = self.get_argument("user_id") @@ -1719,3 +1728,281 @@ def post(self, contest_id: str, group_id: str): self.sql_session.delete(group) self.try_commit() self.redirect(self.url("contest", contest_id)) + + +class StudentTasksHandler(BaseHandler): + """View and manage tasks assigned to a student in a training program.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str, user_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + participation: Participation | None = ( + self.sql_session.query(Participation) + .filter(Participation.contest_id == managing_contest.id) + .filter(Participation.user_id == user_id) + .first() + ) + + if participation is None: + raise tornado.web.HTTPError(404) + + student: Student | None = ( + self.sql_session.query(Student) + .filter(Student.participation == participation) + .filter(Student.training_program == training_program) + .first() + ) + + if student is None: + raise tornado.web.HTTPError(404) + + # Get all tasks in the training program for the "add task" dropdown + all_tasks = managing_contest.get_tasks() + 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] + + self.r_params = self.render_params() + self.r_params["training_program"] = training_program + self.r_params["participation"] = participation + self.r_params["student"] = student + self.r_params["selected_user"] = participation.user + self.r_params["student_tasks"] = sorted( + student.student_tasks, key=lambda st: st.assigned_at, reverse=True + ) + self.r_params["available_tasks"] = available_tasks + self.r_params["unanswered"] = self.sql_session.query(Question)\ + .join(Participation)\ + .filter(Participation.contest_id == managing_contest.id)\ + .filter(Question.reply_timestamp.is_(None))\ + .filter(Question.ignored.is_(False))\ + .count() + self.render("student_tasks.html", **self.r_params) + + +class AddStudentTaskHandler(BaseHandler): + """Add a task to a student's task archive.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str, user_id: str): + fallback_page = self.url( + "training_program", training_program_id, "student", user_id, "tasks" + ) + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + participation: Participation | None = ( + self.sql_session.query(Participation) + .filter(Participation.contest_id == managing_contest.id) + .filter(Participation.user_id == user_id) + .first() + ) + + if participation is None: + raise tornado.web.HTTPError(404) + + student: Student | None = ( + self.sql_session.query(Student) + .filter(Student.participation == participation) + .filter(Student.training_program == training_program) + .first() + ) + + if student is None: + raise tornado.web.HTTPError(404) + + try: + task_id = self.get_argument("task_id") + if task_id == "null": + raise ValueError("Please select a task") + + task = self.safe_get_item(Task, task_id) + + # Check if task is already assigned + existing = ( + self.sql_session.query(StudentTask) + .filter(StudentTask.student_id == student.id) + .filter(StudentTask.task_id == task.id) + .first() + ) + if existing is not None: + raise ValueError("Task is already assigned to this student") + + # Create the StudentTask record (manual assignment, no training day) + # Note: CMS Base.__init__ skips foreign key columns, so we must + # set them as attributes after creating the object + student_task = StudentTask(assigned_at=make_datetime()) + student_task.student_id = student.id + student_task.task_id = task.id + student_task.source_training_day_id = None + self.sql_session.add(student_task) + + except Exception as error: + self.service.add_notification( + make_datetime(), "Invalid field(s)", repr(error) + ) + self.redirect(fallback_page) + return + + if self.try_commit(): + self.service.add_notification( + make_datetime(), + "Task assigned", + f"Task '{task.name}' has been assigned to {participation.user.username}" + ) + + self.redirect(fallback_page) + + +class RemoveStudentTaskHandler(BaseHandler): + """Remove a task from a student's task archive.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str, user_id: str, task_id: str): + fallback_page = self.url( + "training_program", training_program_id, "student", user_id, "tasks" + ) + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + participation: Participation | None = ( + self.sql_session.query(Participation) + .filter(Participation.contest_id == managing_contest.id) + .filter(Participation.user_id == user_id) + .first() + ) + + if participation is None: + raise tornado.web.HTTPError(404) + + student: Student | None = ( + self.sql_session.query(Student) + .filter(Student.participation == participation) + .filter(Student.training_program == training_program) + .first() + ) + + if student is None: + raise tornado.web.HTTPError(404) + + student_task: StudentTask | None = ( + self.sql_session.query(StudentTask) + .filter(StudentTask.student_id == student.id) + .filter(StudentTask.task_id == task_id) + .first() + ) + + if student_task is None: + raise tornado.web.HTTPError(404) + + task = student_task.task + self.sql_session.delete(student_task) + + if self.try_commit(): + self.service.add_notification( + make_datetime(), + "Task removed", + f"Task '{task.name}' has been removed from {participation.user.username}'s archive" + ) + + self.redirect(fallback_page) + + +class BulkAssignTaskHandler(BaseHandler): + """Bulk assign a task to all students with a given tag.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def get(self, training_program_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + # Get all tasks in the training program + all_tasks = managing_contest.get_tasks() + + # Get all unique student tags + all_student_tags = get_all_student_tags(training_program) + + self.r_params = self.render_params() + self.r_params["training_program"] = training_program + self.r_params["all_tasks"] = all_tasks + self.r_params["all_student_tags"] = all_student_tags + self.r_params["unanswered"] = self.sql_session.query(Question)\ + .join(Participation)\ + .filter(Participation.contest_id == managing_contest.id)\ + .filter(Question.reply_timestamp.is_(None))\ + .filter(Question.ignored.is_(False))\ + .count() + self.render("bulk_assign_task.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, "bulk_assign_task" + ) + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + + try: + task_id = self.get_argument("task_id") + if task_id == "null": + raise ValueError("Please select a task") + + tag_name = self.get_argument("tag", "").strip().lower() + if not tag_name: + raise ValueError("Please enter a tag") + + task = self.safe_get_item(Task, task_id) + + # Find all students with the given tag + matching_students = ( + self.sql_session.query(Student) + .filter(Student.training_program == training_program) + .filter(Student.student_tags.any(tag_name)) + .all() + ) + + if not matching_students: + raise ValueError(f"No students found with tag '{tag_name}'") + + # We want to know which of these specific students already have this task. + student_ids = [s.id for s in matching_students] + + already_assigned_ids = set( + row[0] + for row in self.sql_session.query(StudentTask.student_id) + .filter(StudentTask.task_id == task.id) + .filter(StudentTask.student_id.in_(student_ids)) + .all() + ) + + # Assign task to each matching student (if not already assigned) + assigned_count = 0 + for student_id in student_ids: + if student_id not in already_assigned_ids: + # Note: CMS Base.__init__ skips foreign key columns, so we must + # set them as attributes after creating the object + student_task = StudentTask(assigned_at=make_datetime()) + student_task.student_id = student_id + student_task.task_id = task.id + student_task.source_training_day_id = None + self.sql_session.add(student_task) + assigned_count += 1 + + except Exception as error: + self.service.add_notification( + make_datetime(), "Invalid field(s)", repr(error) + ) + self.redirect(fallback_page) + return + + if self.try_commit(): + self.service.add_notification( + make_datetime(), + "Bulk assignment complete", + f"Task '{task.name}' assigned to {assigned_count} students with tag '{tag_name}'", + ) + + self.redirect(fallback_page) diff --git a/cms/server/admin/templates/bulk_assign_task.html b/cms/server/admin/templates/bulk_assign_task.html new file mode 100644 index 0000000000..323a2dd6ab --- /dev/null +++ b/cms/server/admin/templates/bulk_assign_task.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} + +{% block core %} +

+ Assign Task to a Group in {{ training_program.name }} +

+ +

+ Back to students list +

+ + + + +

Assign Task to a Group

+
+

+ This will assign the selected task to all students who have the specified tag. + Students who already have the task assigned will be skipped. +

+
+ {{ xsrf_form_html|safe }} + + + + + + + + + +
+ + Task + + +
+ + Student Tag + + +
+ +
+
+
+ +

Available Tags

+
+ {% if all_student_tags %} +

The following tags are currently in use:

+ + {% else %} +

No student tags have been defined yet.

+ {% endif %} +
+
+ +{% endblock core %} diff --git a/cms/server/admin/templates/student.html b/cms/server/admin/templates/student.html index 6fbd67f12d..ce7643c343 100644 --- a/cms/server/admin/templates/student.html +++ b/cms/server/admin/templates/student.html @@ -25,6 +25,10 @@

Student {{ selected_user.username }} in {{ training_program.name }}

+

+ View/Manage Task Archive ({{ student.student_tasks|length }} tasks) +

+

Submissions

diff --git a/cms/server/admin/templates/student_tasks.html b/cms/server/admin/templates/student_tasks.html new file mode 100644 index 0000000000..f1ec73193e --- /dev/null +++ b/cms/server/admin/templates/student_tasks.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} + +{% block core %} +

+ Task Archive for {{ selected_user.username }} in {{ training_program.name }} +

+ +

+ Back to student details +

+ +

Add Task

+
+
+ {{ xsrf_form_html|safe }} + + + + + +
+ + Task + + +
+ +
+
+
+ +

Assigned Tasks ({{ student_tasks|length }})

+
+ {% if student_tasks %} + + + + + + + + + + + {% for st in student_tasks %} + + + + + + + {% endfor %} + +
TaskSourceAssigned AtActions
+ {{ st.task.name }} - {{ st.task.title }} + + {% if st.source_training_day %} + {{ st.source_training_day.contest.description }} + {% else %} + Manual assignment + {% endif %} + {{ st.assigned_at }} +
+ {{ xsrf_form_html|safe }} + +
+
+ {% else %} +

No tasks assigned to this student yet.

+ {% endif %} +
+
+ +{% endblock core %} diff --git a/cms/server/admin/templates/training_program_students.html b/cms/server/admin/templates/training_program_students.html index 97b03b6df5..0ef07c5164 100644 --- a/cms/server/admin/templates/training_program_students.html +++ b/cms/server/admin/templates/training_program_students.html @@ -11,6 +11,10 @@

Students list

{% include "fragments/overload_warning.html" %} +

+ Bulk Assign Task by Tag +

+
{{ xsrf_form_html|safe }} Add a new student: @@ -44,14 +48,16 @@

Students list

Username - First name - Last name + First Name + Last Name Student Tags + Task Archive Progress {% for student in training_program.students|sort(attribute="participation.user.username") %} {% set u = student.participation.user %} + {% set progress = student_progress.get(student.id, {}) %} @@ -66,6 +72,13 @@

Students list

No tags {% endif %} + + {% if progress.task_count > 0 %} + {{ "%.1f"|format(progress.percentage) }}% ({{ "%.1f"|format(progress.total_score) }}/{{ "%.1f"|format(progress.max_score) }}) [details] + {% else %} + No tasks [details] + {% endif %} + {% endfor %} diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index a70b353b4b..73d949ae70 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -50,11 +50,21 @@ import tornado.web from cms import config, TOKEN_MODE_MIXED -from cms.db import Contest, Student, Submission, Task, TrainingDayGroup, TrainingProgram, UserTest, contest +from cms.db import ( + Contest, + Student, + StudentTask, + Submission, + Task, + TrainingDayGroup, + TrainingProgram, + UserTest, +) from cms.locale import filter_language_codes from cms.server import FileHandlerMixin from cms.server.contest.authentication import authenticate_request from cmscommon.datetime import get_timezone +from sqlalchemy import exists, and_ from .base import BaseHandler, add_ip_to_list from ..phase_management import compute_actual_phase, compute_effective_times @@ -412,6 +422,9 @@ def can_access_task(self, task: Task) -> bool: - The task has no visible_to_tags (empty list = visible to all) - The student has at least one tag matching the task's visible_to_tags + For training programs (managing contests), tasks are only accessible + if the student has an associated StudentTask record. + For non-training-day contests, all tasks are accessible. task: the task to check access for. @@ -423,6 +436,23 @@ def can_access_task(self, task: Task) -> bool: if self.current_user is None: return not task.visible_to_tags + # For training programs, check if student has a StudentTask record + if self.training_program is not None: + task_access_exists = self.sql_session.query( + exists().where( + and_( + StudentTask.task_id == task.id, + StudentTask.student_id == Student.id, + Student.participation_id == Participation.id, + Participation.contest_id == self.contest.id, + Participation.user_id == self.current_user.user_id, + Student.training_program_id == self.training_program.id, + ) + ) + ).scalar() + + return task_access_exists + return can_access_task( self.sql_session, task, self.current_user, self.contest.training_day ) diff --git a/cms/server/contest/handlers/main.py b/cms/server/contest/handlers/main.py index 78bd2519c4..f48966998a 100644 --- a/cms/server/contest/handlers/main.py +++ b/cms/server/contest/handlers/main.py @@ -56,7 +56,7 @@ from sqlalchemy.orm.exc import NoResultFound from cms import config -from cms.db import PrintJob, User, Participation, Team +from cms.db import PrintJob, User, Participation, Team, Student, StudentTask from cms.grading.languagemanager import get_language from cms.grading.steps import COMPILATION_MESSAGES, EVALUATION_MESSAGES from cms.server import multi_contest @@ -345,10 +345,65 @@ def post(self): participation.starting_ip_addresses, client_ip ) + # For training day contests, add visible tasks to the student's task archive + training_day = self.contest.training_day + if training_day is not None: + self._add_training_day_tasks_to_student(training_day, participation) + self.sql_session.commit() self.redirect(self.contest_url()) + def _add_training_day_tasks_to_student(self, training_day, participation: Participation): + """Add visible tasks from a training day to the student's task archive. + + When a student starts a training day, all tasks visible to them + (based on their tags) are added to their StudentTask records. + + training_day: the training day being started. + participation: the user's participation in the training day contest. + + """ + # Find the student record for this user in the training program + training_program = training_day.training_program + managing_contest = training_program.managing_contest + + student = ( + self.sql_session.query(Student) + .join(Participation, Student.participation_id == Participation.id) + .filter(Participation.contest_id == managing_contest.id) + .filter(Participation.user_id == participation.user_id) + .filter(Student.training_program_id == training_program.id) + .first() + ) + + if student is None: + logger.warning( + "User %s started training day but has no student record", + participation.user.username + ) + return + + # Get the visible tasks for this student + visible_tasks = self.get_visible_tasks() + + # Add each visible task to the student's task archive if not already present + existing_task_ids = {st.task_id for st in student.student_tasks} + + for task in visible_tasks: + if task.id not in existing_task_ids: + # Note: CMS Base.__init__ skips foreign key columns, so we must + # set them as attributes after creating the object + student_task = StudentTask(assigned_at=self.timestamp) + student_task.student_id = student.id + student_task.task_id = task.id + student_task.source_training_day_id = training_day.id + self.sql_session.add(student_task) + logger.info( + "Added task %s to student %s from training day %s", + task.name, participation.user.username, training_day.contest.name + ) + class LogoutHandler(ContestHandler): """Logout handler. diff --git a/cms/server/contest/handlers/trainingprogram.py b/cms/server/contest/handlers/trainingprogram.py index 84d28b89d2..1b6ca2dac7 100644 --- a/cms/server/contest/handlers/trainingprogram.py +++ b/cms/server/contest/handlers/trainingprogram.py @@ -25,10 +25,10 @@ import tornado.web -from cms.db import Participation, Submission, SubmissionResult +from cms.db import Participation, Student 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 +from cms.server.util import check_training_day_eligibility, calculate_task_archive_progress from .contest import ContestHandler @@ -54,46 +54,31 @@ def get(self): # This URL only makes sense for training programs; return 404 raise tornado.web.HTTPError(404) - # Calculate total score and max score - total_score = 0.0 - max_score = 0.0 - task_scores = [] - - for task in contest.get_tasks(): - max_task_score = task.active_dataset.score_type_object.max_score \ - if task.active_dataset else 100.0 - max_score += max_task_score - - # Get best submission score for this task (only official submissions) - best_score = 0.0 - submissions = ( - self.sql_session.query(Submission) - .filter(Submission.participation == participation) - .filter(Submission.task == task) - .filter(Submission.official.is_(True)) - .all() - ) - - for submission in submissions: - if task.active_dataset: - result = ( - self.sql_session.query(SubmissionResult) - .filter(SubmissionResult.submission == submission) - .filter(SubmissionResult.dataset == task.active_dataset) - .first() - ) - if result and result.score is not None: - best_score = max(best_score, result.score) - - total_score += best_score - task_scores.append({ - "task": task, - "score": best_score, - "max_score": max_task_score, - }) + # Find the student record for this user in the training program + 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() + ) - # Calculate percentage - percentage = (total_score / max_score * 100) if max_score > 0 else 0.0 + # Calculate task archive progress using shared utility + if student is not None: + progress = calculate_task_archive_progress( + student, participation, contest, include_task_details=True + ) + total_score = progress["total_score"] + max_score = progress["max_score"] + percentage = progress["percentage"] + task_scores = progress["task_scores"] + else: + # No student record - show no tasks + total_score = 0.0 + max_score = 0.0 + percentage = 0.0 + task_scores = [] # Get upcoming training days for this user upcoming_training_days = [] diff --git a/cms/server/contest/templates/contest.html b/cms/server/contest/templates/contest.html index fec1bac5e9..d9844f722d 100644 --- a/cms/server/contest/templates/contest.html +++ b/cms/server/contest/templates/contest.html @@ -180,7 +180,7 @@

- {% if (actual_phase >= 0 and participation.starting_time is not none) or participation.unrestricted %} + {% 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 %}