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 %}
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+{% 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 @@
+
+ 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 %}
+
+
+
+ Back to student details
+
+
+
Add Task
+
+
+
Assigned Tasks ({{ student_tasks|length }})
+
+ {% if student_tasks %}
+
+ {% 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
+
+