diff --git a/cms/db/__init__.py b/cms/db/__init__.py index 497da1216e..cfcec0792b 100644 --- a/cms/db/__init__.py +++ b/cms/db/__init__.py @@ -53,7 +53,9 @@ # fsobject "FSObject", "LargeObject", # contest - "Contest", "Announcement", "ContestFolder", + "Contest", "Announcement", "ContestFolder", "TrainingProgram", "Student", + "TrainingDay", "TrainingDayGroup", "StudentTask", + "ArchivedAttendance", "ArchivedStudentRanking", # user "User", "Team", "Participation", "Message", "Question", "DelayRequest", # admin @@ -87,7 +89,7 @@ # Instantiate or import these objects. -version = 48 +version = 49 engine = create_engine(config.database.url, echo=config.database.debug, pool_timeout=60, pool_recycle=120) @@ -104,6 +106,13 @@ from .admin import Admin from .contest import Contest, Announcement from .contest_folder import ContestFolder +from .training_program import TrainingProgram +from .training_day import TrainingDay +from .training_day_group import TrainingDayGroup +from .student import Student +from .student_task import StudentTask +from .archived_attendance import ArchivedAttendance +from .archived_student_ranking import ArchivedStudentRanking from .user import User, Team, Participation, Message, Question, DelayRequest from .task import Task, Statement, Attachment, Dataset, Manager, Testcase, \ Generator, SubtaskValidator, SubtaskValidationResult diff --git a/cms/db/archived_attendance.py b/cms/db/archived_attendance.py new file mode 100644 index 0000000000..25408f84d9 --- /dev/null +++ b/cms/db/archived_attendance.py @@ -0,0 +1,121 @@ +#!/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 . + +"""Archived attendance model for training days. + +ArchivedAttendance stores attendance data for students after a training day +is archived. This includes participation status, location (class/home), +delay time, and delay reasons. +""" + +import typing +from datetime import timedelta + +from sqlalchemy.orm import relationship +from sqlalchemy.schema import Column, ForeignKey, UniqueConstraint +from sqlalchemy.sql import text +from sqlalchemy.types import Boolean, Integer, Unicode, Interval + +from . import Base + +if typing.TYPE_CHECKING: + from . import TrainingDay, Student + + +class ArchivedAttendance(Base): + """Archived attendance data for a student in a training day. + + This stores immutable attendance information after a training day is + archived, including whether the student participated, their location + (class or home), delay time, and delay reasons. + """ + __tablename__ = "archived_attendances" + __table_args__ = ( + UniqueConstraint("training_day_id", "student_id", + name="archived_attendances_training_day_id_student_id_key"), + ) + + id: int = Column(Integer, primary_key=True) + + training_day_id: int = Column( + Integer, + ForeignKey("training_days.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + student_id: int = Column( + Integer, + ForeignKey("students.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # "participated" if starting_time exists, "missed" otherwise + status: str = Column( + Unicode, + nullable=False, + ) + + # "class", "home", or "both" + location: str | None = Column( + Unicode, + nullable=True, + ) + + # Delay time copied from participation + delay_time: timedelta | None = Column( + Interval, + nullable=True, + ) + + # Concatenated reasons from all delay requests + delay_reasons: str | None = Column( + Unicode, + nullable=True, + ) + + # Whether the absence was justified (e.g., sick leave) + justified: bool = Column( + Boolean, + nullable=False, + default=False, + server_default=text("false"), + ) + + # Admin comment for this attendance record + comment: str | None = Column( + Unicode, + nullable=True, + ) + + # Whether this students room and / or screen was recorded during this training + recorded: bool = Column( + Boolean, + nullable=False, + default=False, + server_default=text("false"), + ) + + training_day: "TrainingDay" = relationship( + "TrainingDay", + back_populates="archived_attendances", + ) + + student: "Student" = relationship( + "Student", + ) diff --git a/cms/db/archived_student_ranking.py b/cms/db/archived_student_ranking.py new file mode 100644 index 0000000000..352381cbd9 --- /dev/null +++ b/cms/db/archived_student_ranking.py @@ -0,0 +1,106 @@ +#!/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 . + +"""Archived student ranking model for training days. + +ArchivedStudentRanking stores ranking data for students after a training day +is archived. This includes the student's tags, task scores, and score history +for rendering ranking graphs. +""" + +import typing + +from sqlalchemy.dialects.postgresql import ARRAY, JSONB +from sqlalchemy.orm import relationship +from sqlalchemy.schema import Column, ForeignKey, Index, UniqueConstraint +from sqlalchemy.types import Integer, Unicode + +from . import Base + +if typing.TYPE_CHECKING: + from . import TrainingDay, Student + + +class ArchivedStudentRanking(Base): + """Archived ranking data for a student in a training day. + + This stores immutable ranking information after a training day is + archived, including the student's tags during the training day, + their final scores for each task, and their score history in the + format expected by the JavaScript ranking graph renderer. + """ + __tablename__ = "archived_student_rankings" + __table_args__ = ( + UniqueConstraint("training_day_id", "student_id", + name="archived_student_rankings_training_day_id_student_id_key"), + Index("ix_archived_student_rankings_student_tags_gin", "student_tags", + postgresql_using="gin"), + ) + + id: int = Column(Integer, primary_key=True) + + training_day_id: int = Column( + Integer, + ForeignKey("training_days.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + student_id: int = Column( + Integer, + ForeignKey("students.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # All tags the student had during this training day (stored as array for efficient filtering) + student_tags: list[str] = Column( + ARRAY(Unicode), + nullable=False, + default=list, + ) + + # Final scores for each task: {task_id: score} + # Includes all visible tasks (even with 0 score), not just non-zero scores. + # The presence of a task_id key indicates the task was visible to this student. + task_scores: dict | None = Column( + JSONB, + nullable=True, + ) + + # Submissions for each task: {task_id: [{task, time, score, token, extra}, ...]} + # Format matches RWS submission format for rendering in UserDetail.js + submissions: dict | None = Column( + JSONB, + nullable=True, + ) + + # Score history in RWS format: [[user_id, task_id, time, score], ...] + # This is the format expected by HistoryStore.js for rendering graphs + history: list | None = Column( + JSONB, + nullable=True, + ) + + training_day: "TrainingDay" = relationship( + "TrainingDay", + back_populates="archived_student_rankings", + ) + + student: "Student" = relationship( + "Student", + ) diff --git a/cms/db/base.py b/cms/db/base.py index 07fad987c3..5a5605135d 100644 --- a/cms/db/base.py +++ b/cms/db/base.py @@ -273,13 +273,16 @@ def set_attrs(self, attrs: typing.Mapping[str, object], fill_with_defaults: bool "argument '%s'" % prp.key) # We're setting the default ourselves, since we may # want to use the object before storing it in the DB. - # FIXME This code doesn't work with callable defaults. - # We can use the is_callable and is_scalar properties - # (and maybe the is_sequence and is_clause_element ones - # too) to detect the type. Note that callables require - # an ExecutionContext argument (which we don't have). if col.default is not None: - setattr(self, prp.key, col.default.arg) + if col.default.is_callable: + try: + # Try passing None (simulating a missing ExecutionContext) + setattr(self, prp.key, col.default.arg(None)) + except TypeError: + # Fallback for zero-argument callables (like simple lambdas) + setattr(self, prp.key, col.default.arg()) + else: + setattr(self, prp.key, col.default.arg) elif prp.key in attrs: val = attrs.pop(prp.key) diff --git a/cms/db/contest.py b/cms/db/contest.py index 562d6d9ac6..9894f89ac3 100644 --- a/cms/db/contest.py +++ b/cms/db/contest.py @@ -32,7 +32,7 @@ from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.orm import relationship -from sqlalchemy.schema import Column, ForeignKey, CheckConstraint +from sqlalchemy.schema import Column, ForeignKey, CheckConstraint, Index from sqlalchemy.types import Integer, Unicode, DateTime, Interval, Enum, \ Boolean, String @@ -41,7 +41,7 @@ from .contest_folder import ContestFolder import typing if typing.TYPE_CHECKING: - from . import Task, Participation + from . import Task, Participation, TrainingProgram, TrainingDay class Contest(Base): @@ -131,6 +131,12 @@ class Contest(Base): nullable=False, default=False) + # Whether contestants can submit delay requests. + allow_delay_requests: bool = Column( + Boolean, + nullable=False, + default=True) + # Whether to enforce that the IP address of the request matches # the IP address or subnet specified for the participation (if # present). @@ -310,6 +316,50 @@ class Contest(Base): ) folder: ContestFolder | None = relationship(ContestFolder, back_populates="contests") + # Optional training program that this contest manages. + # If set, this contest is the "managing contest" for a training program. + training_program: "TrainingProgram | None" = relationship( + "TrainingProgram", + back_populates="managing_contest", + uselist=False, + ) + + # Optional training day that this contest represents. + # If set, this contest is a training day within a training program. + training_day: "TrainingDay | None" = relationship( + "TrainingDay", + back_populates="contest", + uselist=False, + ) + + def get_tasks(self) -> list["Task"]: + """Return the tasks for this contest. + + If this contest is a training day, return the training day's tasks. + Otherwise, return the contest's own tasks. + + This allows training days to have their own task list separate from + the contest's task list, while still allowing contestants to see + the tasks when they enter the training day's contest. + """ + if self.training_day is not None: + return self.training_day.tasks + return self.tasks + + def task_belongs_here(self, task: "Task") -> bool: + """Check if a task belongs to this contest. + + For regular contests, the task must have contest_id == self.id. + For training day contests, the task must have training_day_id == self.training_day.id. + + This is used to validate that a task is accessible from this contest, + which is important for training days where tasks have contest_id pointing + to the managing contest but are served through the training day's contest. + """ + if self.training_day is not None: + return task.training_day_id == self.training_day.id + return task.contest_id == self.id + def phase(self, timestamp: datetime) -> int: """Return: -1 if contest isn't started yet at time timestamp, 0 if the contest is active at time timestamp, @@ -340,6 +390,10 @@ class Announcement(Base): """ __tablename__ = 'announcements' + __table_args__ = ( + Index("ix_announcements_visible_to_tags_gin", "visible_to_tags", + postgresql_using="gin"), + ) # Auto increment primary key. id: int = Column( @@ -379,3 +433,11 @@ class Announcement(Base): nullable=True, index=True) admin: Admin | None = relationship(Admin) + + # Tags that control which students can see this announcement. + # If empty, the announcement is visible to all students. + # If set, only students with at least one matching tag can see the announcement. + visible_to_tags: list[str] = Column( + ARRAY(Unicode), + nullable=False, + default=[]) diff --git a/cms/db/student.py b/cms/db/student.py new file mode 100644 index 0000000000..04c0c7fc59 --- /dev/null +++ b/cms/db/student.py @@ -0,0 +1,89 @@ +#!/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 . + +"""Student model for training program participation. + +A Student represents a user's participation in a training program. +It links a training program to a participation (in the managing contest) +and includes student tags for categorization. +""" + +import typing + +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.orm import relationship +from sqlalchemy.schema import Column, ForeignKey, Index +from sqlalchemy.types import Integer, Unicode + +from . import Base + +if typing.TYPE_CHECKING: + from . import TrainingProgram, Participation, StudentTask + + +class Student(Base): + """A student in a training program. + + Links a user's participation in the managing contest to the training + program, and stores student tags for categorization (e.g., "beginner", + "advanced"). + """ + __tablename__ = "students" + __table_args__ = ( + Index("ix_students_student_tags_gin", "student_tags", + postgresql_using="gin"), + ) + + id: int = Column(Integer, primary_key=True) + + training_program_id: int = Column( + Integer, + ForeignKey("training_programs.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + participation_id: int = Column( + Integer, + ForeignKey("participations.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + unique=True, + index=True, + ) + + student_tags: list[str] = Column( + ARRAY(Unicode), + nullable=False, + default=list, + ) + + training_program: "TrainingProgram" = relationship( + "TrainingProgram", + back_populates="students", + ) + + participation: "Participation" = relationship( + "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/submission.py b/cms/db/submission.py index 1b1daa2fb2..69cd1851a1 100644 --- a/cms/db/submission.py +++ b/cms/db/submission.py @@ -39,7 +39,7 @@ from cmscommon.datetime import make_datetime from . import Filename, FilenameSchema, Digest, Base, Participation, Task, \ - Dataset, Testcase + Dataset, Testcase, TrainingDay class Submission(Base): @@ -85,6 +85,21 @@ class Submission(Base): Task, back_populates="submissions") + # Training day (id and object) that this submission was submitted via. + # If None, the submission was made directly to the task archive or + # via a regular contest (not a training day). + # When set, the submission was made via a training day's contest interface, + # but the participation points to the managing contest's participation. + training_day_id: int | None = Column( + Integer, + ForeignKey(TrainingDay.id, + onupdate="CASCADE", ondelete="SET NULL"), + nullable=True, + index=True) + training_day: TrainingDay | None = relationship( + TrainingDay, + back_populates="submissions") + # Time of the submission. timestamp: datetime = Column( DateTime, diff --git a/cms/db/task.py b/cms/db/task.py index 2b21ed9b4c..32c97e0967 100644 --- a/cms/db/task.py +++ b/cms/db/task.py @@ -47,9 +47,10 @@ if typing.TYPE_CHECKING: from cms.grading.scoretypes import ScoreType from cms.grading.tasktypes import TaskType - from . import Submission, UserTest + from . import Submission, UserTest, TrainingDay from .scorecache import ParticipationTaskScore from .modelsolution import ModelSolutionMeta + from .student_task import StudentTask class Task(Base): @@ -60,6 +61,7 @@ class Task(Base): __table_args__ = ( UniqueConstraint('contest_id', 'num'), UniqueConstraint('contest_id', 'name'), + UniqueConstraint('training_day_id', 'training_day_num'), ForeignKeyConstraint( ("id", "active_dataset_id"), ("datasets.task_id", "datasets.id"), @@ -97,6 +99,35 @@ class Task(Base): Contest, back_populates="tasks") + # Training day (id and object) that this task is assigned to. + # Tasks belong to a Contest (via contest_id) which is the training program's + # managing contest. They can also be assigned to a TrainingDay (via + # training_day_id) to appear in that training day's contest. + # A task can be assigned to at most one training day at a time. + training_day_id: int | None = Column( + Integer, + ForeignKey("training_days.id", + onupdate="CASCADE", ondelete="SET NULL"), + nullable=True, + index=True) + training_day: "TrainingDay | None" = relationship( + "TrainingDay", + back_populates="tasks") + + # Number of the task within the training day for sorting. + # This is separate from 'num' which is used for contest ordering. + training_day_num: int | None = Column( + Integer, + nullable=True) + + # Tags that control which students can see this task. + # If empty, the task is visible to all students. + # If set, only students with at least one matching tag can see the task. + visible_to_tags: list[str] = Column( + ARRAY(Unicode), + nullable=False, + default=[]) + # Short name and long human readable title of the task. name: str = Column( Codename, @@ -294,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/db/training_day.py b/cms/db/training_day.py new file mode 100644 index 0000000000..58068ec74e --- /dev/null +++ b/cms/db/training_day.py @@ -0,0 +1,196 @@ +#!/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 . + +"""Training day model for training programs. + +A TrainingDay represents a single training session within a training program. +It wraps a Contest and includes its position within the training program. +""" + +import typing + +from sqlalchemy.dialects.postgresql import ARRAY, JSONB +from sqlalchemy.ext.mutable import MutableDict +from sqlalchemy.orm import relationship, Session +from sqlalchemy.schema import Column, ForeignKey, Index, UniqueConstraint +from sqlalchemy.types import DateTime, Integer, Interval, Unicode + +from . import Base, Codename + +if typing.TYPE_CHECKING: + from datetime import datetime, timedelta + from . import Contest, TrainingProgram, Task, TrainingDayGroup, Submission, Participation, User + from . import ArchivedAttendance, ArchivedStudentRanking + + +def get_managing_participation( + session: Session, + training_day: "TrainingDay", + user: "User", +) -> "Participation | None": + """Get the managing contest participation for a user in a training day. + + Training day submissions are stored with the managing contest's participation, + not the training day's participation. This helper finds the managing contest + participation for a given user. + + session: the database session. + training_day: the training day. + user: the user to look up. + + return: the Participation in the managing contest, or None if not found. + """ + from . import Participation + managing_contest = training_day.training_program.managing_contest + return ( + session.query(Participation) + .filter(Participation.contest_id == managing_contest.id) + .filter(Participation.user_id == user.id) + .first() + ) + + +class TrainingDay(Base): + """A training day in a training program. + + Each training day wraps a Contest and belongs to exactly one TrainingProgram. + The position field determines the order of training days within the program. + Training day types are tags for categorization (e.g., "online", "competition"). + """ + __tablename__ = "training_days" + __table_args__ = ( + UniqueConstraint("training_program_id", "position", + name="training_days_training_program_id_position_key"), + Index("ix_training_days_training_day_types_gin", "training_day_types", + postgresql_using="gin"), + ) + + id: int = Column(Integer, primary_key=True) + + training_program_id: int = Column( + Integer, + ForeignKey("training_programs.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + contest_id: int | None = Column( + Integer, + ForeignKey("contests.id", onupdate="CASCADE", ondelete="SET NULL"), + nullable=True, + unique=True, + index=True, + ) + + position: int | None = Column( + Integer, + nullable=True, + ) + + # Name and description are synced with contest while contest exists. + # After archiving (when contest is deleted), these fields preserve the values. + name: str | None = Column( + Codename, + nullable=True, + ) + + description: str | None = Column( + Unicode, + nullable=True, + ) + + # Start time is synced with contest while contest exists. + # After archiving (when contest is deleted), this field preserves the value. + start_time: "datetime | None" = Column( + DateTime, + nullable=True, + ) + + # Task metadata at archive time: {task_id: {name, short_name, max_score, score_precision, extra_headers}} + # Preserves the scoring scheme as it was during the training day. + # Stored at training day level (not per-student) since it's the same for all students. + archived_tasks_data: dict | None = Column( + JSONB, + nullable=True, + ) + + # Duration of the training day at archive time. + # Calculated as the max training duration among main groups (if any), + # or the training day duration (if no main groups). + duration: "timedelta | None" = Column( + Interval, + nullable=True, + ) + + # Training day types for categorization (e.g., "online", "onsite", "competition"). + # Used for filtering in attendance and combined ranking views. + training_day_types: list[str] = Column( + ARRAY(Unicode), + nullable=False, + default=list, + ) + + # Scoreboard sharing settings for archived training days. + # Format: {"tag1": {"top_to_show": 10, "top_names": 5}, "__everyone__": {...}, ...} + # - Keys are student tags that the scoreboard is shared with, or "__everyone__" for all + # - top_to_show: number of top students to show in the scoreboard (or "all") + # - top_names: number of top students to show full names (others show rank only, or "all") + # Eligibility to view is based on student_tags during the training (from ArchivedStudentRanking) + scoreboard_sharing: dict | None = Column( + MutableDict.as_mutable(JSONB), + nullable=True, + ) + + training_program: "TrainingProgram" = relationship( + "TrainingProgram", + back_populates="training_days", + ) + + contest: "Contest | None" = relationship( + "Contest", + back_populates="training_day", + ) + + tasks: list["Task"] = relationship( + "Task", + back_populates="training_day", + order_by="Task.training_day_num", + ) + + groups: list["TrainingDayGroup"] = relationship( + "TrainingDayGroup", + back_populates="training_day", + cascade="all, delete-orphan", + ) + + submissions: list["Submission"] = relationship( + "Submission", + back_populates="training_day", + passive_deletes=True, + ) + + archived_attendances: list["ArchivedAttendance"] = relationship( + "ArchivedAttendance", + back_populates="training_day", + cascade="all, delete-orphan", + ) + + archived_student_rankings: list["ArchivedStudentRanking"] = relationship( + "ArchivedStudentRanking", + back_populates="training_day", + cascade="all, delete-orphan", + ) diff --git a/cms/db/training_day_group.py b/cms/db/training_day_group.py new file mode 100644 index 0000000000..9b9f1d930a --- /dev/null +++ b/cms/db/training_day_group.py @@ -0,0 +1,82 @@ +#!/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 . + +"""Training day group model for per-group configuration. + +A TrainingDayGroup represents a main group (student tag) configuration +for a training day, with optional custom start/end times and task ordering. +""" + +import typing +from datetime import datetime + +from sqlalchemy.orm import relationship +from sqlalchemy.schema import Column, ForeignKey, UniqueConstraint +from sqlalchemy.types import Boolean, DateTime, Integer, Unicode + +from . import Base + +if typing.TYPE_CHECKING: + from . import TrainingDay + + +class TrainingDayGroup(Base): + """A main group configuration for a training day. + + Each group is identified by a tag name and can have custom timing + and task ordering settings. + """ + __tablename__ = "training_day_groups" + __table_args__ = ( + UniqueConstraint("training_day_id", "tag_name", + name="training_day_groups_training_day_id_tag_name_key"), + ) + + id: int = Column(Integer, primary_key=True) + + training_day_id: int = Column( + Integer, + ForeignKey("training_days.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + tag_name: str = Column( + Unicode, + nullable=False, + ) + + start_time: datetime | None = Column( + DateTime, + nullable=True, + ) + + end_time: datetime | None = Column( + DateTime, + nullable=True, + ) + + alphabetical_task_order: bool = Column( + Boolean, + nullable=False, + default=False, + ) + + training_day: "TrainingDay" = relationship( + "TrainingDay", + back_populates="groups", + ) diff --git a/cms/db/training_program.py b/cms/db/training_program.py new file mode 100644 index 0000000000..cc95b6482e --- /dev/null +++ b/cms/db/training_program.py @@ -0,0 +1,81 @@ +#!/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 . + +"""Training programs for organizing year-long training with multiple sessions. + +A TrainingProgram has a name, description, and a managing contest that handles +all submissions (both from training sessions and from the task archive). +""" + +import typing + +from sqlalchemy.orm import relationship +from sqlalchemy.schema import Column, ForeignKey +from sqlalchemy.types import Integer, Unicode + +from . import Base, Codename + +if typing.TYPE_CHECKING: + from . import Contest, Student, TrainingDay + + +class TrainingProgram(Base): + """A training program that manages multiple training sessions. + + The training program uses a "managing contest" to handle all submissions + and evaluations. Users are added to the training program by adding them + to the managing contest's participations. + """ + __tablename__ = "training_programs" + + # Auto increment primary key. + id: int = Column(Integer, primary_key=True) + + # Short name (codename) of the training program, unique across all programs. + name: str = Column(Codename, nullable=False, unique=True) + + # Human-readable description for UI. + description: str = Column(Unicode, nullable=False) + + # The managing contest that handles all submissions for this program. + # Each training program has exactly one managing contest, and each + # contest can manage at most one training program. + managing_contest_id: int = Column( + Integer, + ForeignKey("contests.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + unique=True, + index=True, + ) + + managing_contest: "Contest" = relationship( + "Contest", + back_populates="training_program", + ) + + students: list["Student"] = relationship( + "Student", + back_populates="training_program", + cascade="all, delete-orphan", + ) + + training_days: list["TrainingDay"] = relationship( + "TrainingDay", + back_populates="training_program", + cascade="all, delete-orphan", + order_by="TrainingDay.position", + ) diff --git a/cms/db/user.py b/cms/db/user.py index 8da9c86bb0..31c6e1b514 100644 --- a/cms/db/user.py +++ b/cms/db/user.py @@ -40,7 +40,7 @@ from . import CastingArray, Codename, Digest, Base, Admin, Contest import typing if typing.TYPE_CHECKING: - from . import PrintJob, Submission, UserTest + from . import PrintJob, Submission, UserTest, Student from .scorecache import ParticipationTaskScore, ScoreHistory class User(Base): @@ -348,6 +348,11 @@ class Participation(Base): passive_deletes=True, back_populates="participation") + student: "Student | None" = relationship( + "Student", + back_populates="participation", + uselist=False, + ) class Message(Base): """Class to store a private message from the managers to the diff --git a/cms/db/util.py b/cms/db/util.py index 99b4436c1e..aa50e96653 100644 --- a/cms/db/util.py +++ b/cms/db/util.py @@ -55,6 +55,7 @@ UserTestExecutable, PrintJob, Session, + User, ) @@ -407,6 +408,12 @@ def enumerate_files( .join(Participation.printjobs) .with_entities(PrintJob.digest)) + if not skip_users: + queries.append(contest_q.join(Contest.participations) + .join(Participation.user) + .filter(User.picture.isnot(None)) + .with_entities(User.picture)) + # union(...).execute() would be executed outside of the session. digests = set(r[0] for r in session.execute(union(*queries))) digests.discard(Digest.TOMBSTONE) diff --git a/cms/grading/scorecache.py b/cms/grading/scorecache.py index 7719860f0c..4eb72ac045 100644 --- a/cms/grading/scorecache.py +++ b/cms/grading/scorecache.py @@ -137,14 +137,13 @@ def _parse_subtask_scores(score_details, score: float) -> dict[str, float] | Non This is similar to the subtask parsing in cms/grading/scoring.py task_score(). """ - if score_details == [] and score == 0.0: + if score_details == [] and abs(score) < 1e-9: return None try: - subtask_scores = dict( - (str(subtask["idx"]), subtask["score"]) - for subtask in score_details - ) + subtask_scores = { + str(subtask["idx"]): subtask["score"] for subtask in score_details + } except (KeyError, TypeError): subtask_scores = None @@ -212,6 +211,41 @@ def _invalidate( ).delete(synchronize_session=False) +def _update_score_cache_for_participation( + session: Session, + participation: Participation, + submission: Submission, + submission_result, +) -> None: + """Update a single participation/task cache entry incrementally.""" + task = submission.task + + # Acquire advisory lock to serialize concurrent updates + _acquire_cache_lock(session, participation.id, task.id) + + cache_entry = _get_or_create_cache_entry(session, participation, task) + old_score = cache_entry.score + + # Incremental update based on score mode + _update_cache_entry_incremental(cache_entry, task, submission, submission_result) + + # Mark history as invalid if submission arrived out of order + if ( + cache_entry.last_submission_timestamp is not None + and submission.timestamp < cache_entry.last_submission_timestamp + ): + cache_entry.history_valid = False + + # Update has_submissions flag (partial is computed at render time) + cache_entry.has_submissions = True + + cache_entry.last_update = _utc_now() + + # Only add history entry if score changed and history is still valid + if cache_entry.score != old_score and cache_entry.history_valid: + _add_history_entry(session, participation, task, submission, cache_entry.score) + + def update_score_cache( session: Session, submission: Submission, @@ -222,6 +256,11 @@ def update_score_cache( pair of the given submission using O(1) incremental updates instead of recomputing from all submissions. + For training day submissions, it also updates the cache for the + training day's participation (which is distinct from the managing + contest participation) so training day rankings stay current without + full invalidation. + IMPORTANT - Locking and Transaction Behavior: This function acquires a PostgreSQL advisory lock (pg_advisory_xact_lock) for the (participation_id, task_id) pair. The lock is transaction-scoped @@ -258,33 +297,28 @@ def update_score_cache( if score is None: return - # Acquire advisory lock to serialize concurrent updates participation = submission.participation - _acquire_cache_lock(session, participation.id, task.id) - - cache_entry = _get_or_create_cache_entry(session, participation, task) - old_score = cache_entry.score - - # Incremental update based on score mode - _update_cache_entry_incremental( - cache_entry, task, submission, submission_result + _update_score_cache_for_participation( + session, participation, submission, submission_result ) - # Mark history as invalid if submission arrived out of order - if (cache_entry.last_submission_timestamp is not None and - submission.timestamp < cache_entry.last_submission_timestamp): - cache_entry.history_valid = False - - # Update has_submissions flag (partial is computed at render time) - cache_entry.has_submissions = True - - cache_entry.last_update = _utc_now() + training_day = submission.training_day + if training_day is None or training_day.contest_id is None: + return - # Only add history entry if score changed and history is still valid - if cache_entry.score != old_score and cache_entry.history_valid: - _add_history_entry( - session, participation, task, submission, - cache_entry.score + # If this is a training day submission, also update the training day + # participation cache (submissions are stored on the managing contest). + td_participation = ( + session.query(Participation) + .filter( + Participation.contest_id == training_day.contest_id, + Participation.user_id == participation.user_id, + ) + .one_or_none() + ) + if td_participation is not None: + _update_score_cache_for_participation( + session, td_participation, submission, submission_result ) @@ -690,13 +724,7 @@ def _update_cache_entry_incremental( cache_entry.last_submission_score = score cache_entry.last_submission_timestamp = submission.timestamp - # Update score based on score mode - if task.score_mode == SCORE_MODE_MAX: - # Simple max - just compare with current score - new_score = max(cache_entry.score or 0.0, score) - cache_entry.score = round(new_score, task.score_precision) - - elif task.score_mode == SCORE_MODE_MAX_SUBTASK: + if task.score_mode == SCORE_MODE_MAX_SUBTASK: # Update per-subtask max scores # Normalize keys to strings since JSONB stores keys as strings subtask_max_scores = { @@ -712,19 +740,18 @@ def _update_cache_entry_incremental( cache_entry.subtask_max_scores = subtask_max_scores if subtask_max_scores else None new_score = sum(subtask_max_scores.values()) if subtask_max_scores else 0.0 - cache_entry.score = round(new_score, task.score_precision) elif task.score_mode == SCORE_MODE_MAX_TOKENED_LAST: # Score is max of last submission score and max tokened score last_score = cache_entry.last_submission_score or 0.0 tokened_score = cache_entry.max_tokened_score or 0.0 new_score = max(last_score, tokened_score) - cache_entry.score = round(new_score, task.score_precision) else: # Default to max mode new_score = max(cache_entry.score or 0.0, score) - cache_entry.score = round(new_score, task.score_precision) + + cache_entry.score = round(new_score, task.score_precision) def _get_sorted_official_submissions( @@ -732,7 +759,41 @@ def _get_sorted_official_submissions( participation: Participation, task: Task, ) -> list[Submission]: - """Get official submissions for a task, sorted by timestamp.""" + """Get official submissions for a task, sorted by timestamp. + + For training day participations, submissions are stored with the managing + contest's participation, so we need to query from there and filter by + training_day_id. + + Raises: + ValueError: When managing participation is None for training days + """ + from cms.db.training_day import get_managing_participation + + training_day = participation.contest.training_day + if training_day is not None: + # This is a training day participation - submissions are stored with + # the managing contest's participation + managing_participation = get_managing_participation( + session, training_day, participation.user + ) + + if managing_participation is None: + # User doesn't have a participation in the managing contest + # This indicates a configuration or data integrity issue + raise ValueError( + f"User {participation.user_id} does not have participation in managing contest " + f"{training_day.training_program.managing_contest_id} for training day {training_day.id}" + ) + + return session.query(Submission).filter( + Submission.participation_id == managing_participation.id, + Submission.task_id == task.id, + Submission.training_day_id == training_day.id, + Submission.official.is_(True) + ).order_by(Submission.timestamp.asc()).all() + + # Regular contest - query submissions directly return session.query(Submission).filter( Submission.participation_id == participation.id, Submission.task_id == task.id, diff --git a/cms/grading/scoring.py b/cms/grading/scoring.py index 114f560e41..6565b44331 100644 --- a/cms/grading/scoring.py +++ b/cms/grading/scoring.py @@ -27,7 +27,7 @@ from sqlalchemy.orm import joinedload -from cms.db import Submission, Dataset, Participation, Task +from cms.db import Submission, Dataset, Participation, Task, TrainingDay from cmscommon.constants import \ SCORE_MODE_MAX, SCORE_MODE_MAX_SUBTASK, SCORE_MODE_MAX_TOKENED_LAST @@ -109,6 +109,7 @@ def task_score( public: bool = False, only_tokened: bool = False, rounded: bool = False, + training_day: TrainingDay | None = None, ) -> tuple[float, bool]: """Return the score of a contest's user on a task. @@ -123,6 +124,8 @@ def task_score( would obtain if all non-tokened submissions scored 0.0, or equivalently had not been scored yet). rounded: if True, round the score to the task's score_precision. + training_day: if provided, only consider submissions made via this + training day (filters by training_day_id). return: the score of user on task, and True if not all submissions of the participation in the task have been scored. @@ -144,6 +147,8 @@ def task_score( submissions = [s for s in participation.submissions if s.task is task and s.official] + if training_day is not None: + submissions = [s for s in submissions if s.training_day_id == training_day.id] if len(submissions) == 0: return 0.0, False @@ -244,7 +249,7 @@ def _task_score_max_subtask( if score is None: continue - if details == [] and score == 0.0: + if details == [] and abs(score) < 1e-9: # Submission did not compile, ignore it. continue diff --git a/cms/locale/cms.pot b/cms/locale/cms.pot index 954bfff2ad..79fad43725 100644 --- a/cms/locale/cms.pot +++ b/cms/locale/cms.pot @@ -1042,6 +1042,9 @@ msgstr "" msgid "Approved" msgstr "" +msgid "Admin Configured" +msgstr "" + msgid "Before you can view the tasks and participate in the contest, you must click the button below to register your participation." msgstr "" @@ -1260,3 +1263,149 @@ msgstr "" msgid "hours." msgstr "" + +msgid "All" +msgstr "" + +msgid "Assigned" +msgstr "" + +msgid "Attempted" +msgstr "" + +msgid "Back to Training Program" +msgstr "" + +msgid "Current start time:" +msgstr "" + +msgid "Date" +msgstr "" + +msgid "Delay request actions" +msgstr "" + +msgid "Description" +msgstr "" + +msgid "Duration:" +msgstr "" + +msgid "ENDED" +msgstr "" + +msgid "Enter Training" +msgstr "" + +msgid "Filter:" +msgstr "" + +msgid "Go to statement" +msgstr "" + +msgid "Go to submissions" +msgstr "" + +msgid "H:" +msgstr "" + +msgid "Home Score" +msgstr "" + +msgid "LIVE" +msgstr "" + +msgid "Manual" +msgstr "" + +msgid "No ongoing or upcoming trainings." +msgstr "" + +msgid "No past trainings yet." +msgstr "" + +msgid "No tasks" +msgstr "" + +msgid "No tasks available yet." +msgstr "" + +msgid "Not Attempted" +msgstr "" + +msgid "Ongoing and Upcoming Trainings" +msgstr "" + +msgid "Overall Completion Rate" +msgstr "" + +msgid "Partial" +msgstr "" + +msgid "Past Trainings" +msgstr "" + +msgid "Request a Delay" +msgstr "" + +msgid "Request a delay for this training day" +msgstr "" + +msgid "Scoreboard" +msgstr "" + +msgid "Solved" +msgstr "" + +msgid "Source" +msgstr "" + +msgid "Start:" +msgstr "" + +msgid "Starts in:" +msgstr "" + +msgid "Statistics" +msgstr "" + +#, python-format +msgid "Submissions from training day: %(td_desc)s" +msgstr "" + +msgid "Submissions from task archive" +msgstr "" + +msgid "T:" +msgstr "" + +msgid "TOTAL POINTS" +msgstr "" + +msgid "Task Archive" +msgstr "" + +msgid "Tasks" +msgstr "" + +msgid "Training Days" +msgstr "" + +msgid "Training Score" +msgstr "" + +msgid "Upcoming Training Days" +msgstr "" + +#, python-format +msgid "View %(count)s delay request(s)" +msgstr "" + +msgid "View Requests" +msgstr "" + +msgid "YOUR SCORE" +msgstr "" + +msgid "Your Delay Requests" +msgstr "" diff --git a/cms/locale/he/LC_MESSAGES/cms.po b/cms/locale/he/LC_MESSAGES/cms.po index 6bdd0af15e..c8822a8067 100644 --- a/cms/locale/he/LC_MESSAGES/cms.po +++ b/cms/locale/he/LC_MESSAGES/cms.po @@ -1043,6 +1043,9 @@ msgstr "מנהל מערכת צריך לאשר את איפוס הסיסמה של msgid "Approved" msgstr "מאושר" +msgid "Admin Configured" +msgstr "האדמין הגדיר" + msgid "Before you can view the tasks and participate in the contest, you must click the button below to register your participation." msgstr "לפני שתוכלו לצפות במשימות ולהשתתף בתחרות, עליכם ללחוץ על הכפתור למטה כדי להתחיל את השתתפותכם." @@ -1261,3 +1264,149 @@ msgstr "נשלחה." msgid "hours." msgstr "שעות." + +msgid "All" +msgstr "הכל" + +msgid "Assigned" +msgstr "הוקצתה" + +msgid "Attempted" +msgstr "נוסתה" + +msgid "Back to Training Program" +msgstr "חזרה לתוכנית האימונים" + +msgid "Current start time:" +msgstr "זמן התחלה נוכחי:" + +msgid "Date" +msgstr "תאריך" + +msgid "Delay request actions" +msgstr "פעולות בקשת דחייה" + +msgid "Description" +msgstr "תיאור" + +msgid "Duration:" +msgstr "משך:" + +msgid "ENDED" +msgstr "הסתיים" + +msgid "Enter Training" +msgstr "כניסה לאימון" + +msgid "Filter:" +msgstr "סינון:" + +msgid "Go to statement" +msgstr "מעבר לסטייטמנט" + +msgid "Go to submissions" +msgstr "מעבר להגשות" + +msgid "H:" +msgstr "ב:" + +msgid "Home Score" +msgstr "ניקוד בית" + +msgid "LIVE" +msgstr "פעיל" + +msgid "Manual" +msgstr "ידני" + +msgid "No ongoing or upcoming trainings." +msgstr "אין אימונים פעילים או עתידיים." + +msgid "No past trainings yet." +msgstr "אין עדיין אימונים קודמים." + +msgid "No tasks" +msgstr "אין משימות" + +msgid "No tasks available yet." +msgstr "אין עדיין משימות זמינות." + +msgid "Not Attempted" +msgstr "לא נוסתה" + +msgid "Ongoing and Upcoming Trainings" +msgstr "אימונים פעילים ועתידיים" + +msgid "Overall Completion Rate" +msgstr "אחוז השלמה כולל" + +msgid "Partial" +msgstr "חלקית" + +msgid "Past Trainings" +msgstr "אימונים קודמים" + +msgid "Request a Delay" +msgstr "בקשת דחייה" + +msgid "Request a delay for this training day" +msgstr "בקשת דחייה עבור יום אימון זה" + +msgid "Scoreboard" +msgstr "טבלת ניקוד" + +msgid "Solved" +msgstr "נפתר" + +msgid "Source" +msgstr "מקור" + +msgid "Start:" +msgstr "התחלה:" + +msgid "Starts in:" +msgstr "מתחיל בעוד:" + +msgid "Statistics" +msgstr "סטטיסטיקות" + +#, python-format +msgid "Submissions from training day: %(td_desc)s" +msgstr "הגשות מיום אימון: %(td_desc)s" + +msgid "Submissions from task archive" +msgstr "הגשות מארכיון המשימות" + +msgid "T:" +msgstr "א:" + +msgid "TOTAL POINTS" +msgstr "סה״כ נקודות" + +msgid "Task Archive" +msgstr "ארכיון משימות" + +msgid "Tasks" +msgstr "משימות" + +msgid "Training Days" +msgstr "ימי אימון" + +msgid "Training Score" +msgstr "ניקוד אימון" + +msgid "Upcoming Training Days" +msgstr "ימי אימון עתידיים" + +#, python-format +msgid "View %(count)s delay request(s)" +msgstr "צפייה ב-%(count)s בקשות דחייה" + +msgid "View Requests" +msgstr "צפייה בבקשות" + +msgid "YOUR SCORE" +msgstr "הניקוד שלכם" + +msgid "Your Delay Requests" +msgstr "בקשות הדחייה שלכם" diff --git a/cms/server/admin/handlers/__init__.py b/cms/server/admin/handlers/__init__.py index d1437ac56a..5a52488e22 100644 --- a/cms/server/admin/handlers/__init__.py +++ b/cms/server/admin/handlers/__init__.py @@ -37,6 +37,7 @@ from .contestannouncement import \ AddAnnouncementHandler, \ AnnouncementHandler, \ + ContestAnnouncementsHandler, \ EditAnnouncementHandler from .contestquestion import \ QuestionsHandler, \ @@ -63,7 +64,8 @@ ContestUserTestsHandler from .contesttask import \ ContestTasksHandler, \ - AddContestTaskHandler + AddContestTaskHandler, \ + TaskVisibilityHandler from .contestuser import \ ContestUsersHandler, \ RemoveParticipationHandler, \ @@ -116,7 +118,9 @@ from .submissiondownload import \ DownloadTaskSubmissionsHandler, \ DownloadUserContestSubmissionsHandler, \ - DownloadContestSubmissionsHandler + DownloadContestSubmissionsHandler, \ + DownloadTrainingProgramSubmissionsHandler, \ + DownloadTrainingProgramStudentSubmissionsHandler from .task import ( AddTaskHandler, TaskHandler, @@ -168,6 +172,45 @@ from .export_handlers import \ ExportTaskHandler, \ ExportContestHandler +from .trainingprogram import \ + TrainingProgramListHandler, \ + TrainingProgramHandler, \ + AddTrainingProgramHandler, \ + RemoveTrainingProgramHandler, \ + TrainingProgramTasksHandler, \ + AddTrainingProgramTaskHandler, \ + RemoveTrainingProgramTaskHandler, \ + TrainingProgramRankingHandler +from .trainingday import \ + TrainingProgramTrainingDaysHandler, \ + AddTrainingDayHandler, \ + RemoveTrainingDayHandler, \ + AddTrainingDayGroupHandler, \ + UpdateTrainingDayGroupsHandler, \ + RemoveTrainingDayGroupHandler, \ + TrainingDayTypesHandler, \ + ScoreboardSharingHandler +from .student import \ + TrainingProgramStudentsHandler, \ + AddTrainingProgramStudentHandler, \ + BulkAddTrainingProgramStudentsHandler, \ + RemoveTrainingProgramStudentHandler, \ + StudentHandler, \ + StudentTagsHandler, \ + StudentTasksHandler, \ + StudentTaskSubmissionsHandler, \ + AddStudentTaskHandler, \ + RemoveStudentTaskHandler, \ + BulkAssignTaskHandler +from .archive import \ + ArchiveTrainingDayHandler, \ + TrainingProgramAttendanceHandler, \ + TrainingProgramCombinedRankingHandler, \ + TrainingProgramCombinedRankingHistoryHandler, \ + TrainingProgramCombinedRankingDetailHandler, \ + UpdateAttendanceHandler, \ + ExportAttendanceHandler, \ + ExportCombinedRankingHandler HANDLERS = [ @@ -189,9 +232,9 @@ (r"/contests/add", AddContestHandler), (r"/contests/import", ImportContestHandler), (r"/contest/([0-9]+)", ContestHandler), - (r"/contest/([0-9]+)/export", ExportContestHandler), - (r"/contest/([0-9]+)/overview", OverviewHandler), - (r"/contest/([0-9]+)/resourceslist", ResourcesListHandler), + (r"/(contest|training_program)/([0-9]+)/export", ExportContestHandler), + (r"/(contest|training_program)/([0-9]+)/overview", OverviewHandler), + (r"/(contest|training_program)/([0-9]+)/resourceslist", ResourcesListHandler), # Contest's users @@ -208,24 +251,25 @@ (r"/contest/([0-9]+)/tasks", ContestTasksHandler), (r"/contest/([0-9]+)/tasks/add", AddContestTaskHandler), + (r"/contest/([0-9]+)/task_visibility/([0-9]+)", TaskVisibilityHandler), # Contest's submissions / user tests - (r"/contest/([0-9]+)/submissions", ContestSubmissionsHandler), + (r"/(contest|training_program)/([0-9]+)/submissions", ContestSubmissionsHandler), (r"/contest/([0-9]+)/submissions/download", DownloadContestSubmissionsHandler), (r"/contest/([0-9]+)/user/([0-9]+)/submissions/download", DownloadUserContestSubmissionsHandler), (r"/contest/([0-9]+)/user_tests", ContestUserTestsHandler), # Contest's announcements - (r"/contest/([0-9]+)/announcements", SimpleContestHandler("announcements.html")), + (r"/(contest|training_program)/([0-9]+)/announcements", ContestAnnouncementsHandler), (r"/contest/([0-9]+)/announcements/add", AddAnnouncementHandler), (r"/contest/([0-9]+)/announcements/edit/([0-9]+)", EditAnnouncementHandler), - (r"/contest/([0-9]+)/announcement/([0-9]+)", AnnouncementHandler), + (r"/(contest|training_program)/([0-9]+)/announcement/([0-9]+)", AnnouncementHandler), # Contest's questions - (r"/contest/([0-9]+)/questions", QuestionsHandler), + (r"/(contest|training_program)/([0-9]+)/questions", QuestionsHandler), (r"/contest/([0-9]+)/question/([0-9]+)/reply", QuestionReplyHandler), (r"/contest/([0-9]+)/question/([0-9]+)/ignore", QuestionIgnoreHandler), (r"/contest/([0-9]+)/question/([0-9]+)/claim", QuestionClaimHandler), @@ -322,6 +366,50 @@ (r"/folders/add", AddFolderHandler), (r"/folder/([0-9]+)", FolderHandler), + # Training Programs + (r"/training_programs", TrainingProgramListHandler), + (r"/training_programs/([0-9]+)/remove", RemoveTrainingProgramHandler), + (r"/training_programs/add", AddTrainingProgramHandler), + (r"/training_program/([0-9]+)", TrainingProgramHandler), + + # Training Program tabs + (r"/training_program/([0-9]+)/students", TrainingProgramStudentsHandler), + (r"/training_program/([0-9]+)/students/add", AddTrainingProgramStudentHandler), + (r"/training_program/([0-9]+)/students/bulk_add", BulkAddTrainingProgramStudentsHandler), + (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]+)/student/([0-9]+)/task/([0-9]+)/submissions", StudentTaskSubmissionsHandler), + (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), + (r"/training_program/([0-9]+)/ranking", TrainingProgramRankingHandler), + (r"/training_program/([0-9]+)/ranking/([a-z]+)", TrainingProgramRankingHandler), + (r"/training_program/([0-9]+)/submissions/download", DownloadTrainingProgramSubmissionsHandler), + (r"/training_program/([0-9]+)/student/([0-9]+)/submissions/download", DownloadTrainingProgramStudentSubmissionsHandler), + (r"/training_program/([0-9]+)/training_days", TrainingProgramTrainingDaysHandler), + (r"/training_program/([0-9]+)/training_days/add", AddTrainingDayHandler), + (r"/training_program/([0-9]+)/training_day/([0-9]+)/remove", RemoveTrainingDayHandler), + (r"/training_program/([0-9]+)/training_day/([0-9]+)/types", TrainingDayTypesHandler), + (r"/training_program/([0-9]+)/training_day/([0-9]+)/scoreboard_sharing", ScoreboardSharingHandler), + (r"/training_program/([0-9]+)/training_day/([0-9]+)/archive", ArchiveTrainingDayHandler), + (r"/training_program/([0-9]+)/attendance", TrainingProgramAttendanceHandler), + (r"/training_program/([0-9]+)/attendance/export", ExportAttendanceHandler), + (r"/training_program/([0-9]+)/attendance/([0-9]+)", UpdateAttendanceHandler), + (r"/training_program/([0-9]+)/combined_ranking", TrainingProgramCombinedRankingHandler), + (r"/training_program/([0-9]+)/combined_ranking/export", ExportCombinedRankingHandler), + (r"/training_program/([0-9]+)/combined_ranking/history", TrainingProgramCombinedRankingHistoryHandler), + (r"/training_program/([0-9]+)/student/([0-9]+)/combined_ranking_detail", TrainingProgramCombinedRankingDetailHandler), + + # Training day groups (main groups configuration on contest page) + (r"/contest/([0-9]+)/training_day_group/add", AddTrainingDayGroupHandler), + (r"/contest/([0-9]+)/training_day_groups/update", UpdateTrainingDayGroupsHandler), + (r"/contest/([0-9]+)/training_day_group/([0-9]+)/remove", RemoveTrainingDayGroupHandler), + # Admins (r"/admins", AdminsHandler), diff --git a/cms/server/admin/handlers/archive.py b/cms/server/admin/handlers/archive.py new file mode 100644 index 0000000000..88858160f2 --- /dev/null +++ b/cms/server/admin/handlers/archive.py @@ -0,0 +1,571 @@ +#!/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 . + +"""Admin handler for Training Day Archive. + +This module contains the handler for archiving training days. +Analytics handlers (attendance, ranking) are in training_analytics.py. +Excel export handlers are in excel.py. +""" + +import logging +import typing +from datetime import timedelta + +import tornado.web + +from cms.db import ( + Contest, + TrainingProgram, + Submission, + Student, + StudentTask, + Task, + TrainingDay, + TrainingDayGroup, + Participation, + ArchivedAttendance, + ArchivedStudentRanking, + ScoreHistory, + DelayRequest, +) +from cms.db.training_day import get_managing_participation +from cms.grading.scorecache import get_cached_score_entry +from cms.server.util import can_access_task, check_training_day_eligibility +from cms.server.admin.handlers.utils import ( + build_task_data_for_archive, + build_user_to_student_map, +) +from cmscommon.datetime import make_datetime + +from .base import BaseHandler, require_permission +from .contestdelayrequest import compute_participation_status + +from .training_analytics import ( + TrainingProgramFilterMixin, + get_attendance_view_data, + get_ranking_view_data, + FilterContext, + TrainingProgramAttendanceHandler, + TrainingProgramCombinedRankingHandler, + TrainingProgramCombinedRankingHistoryHandler, + TrainingProgramCombinedRankingDetailHandler, + UpdateAttendanceHandler, +) +from .excel import ( + ExportAttendanceHandler, + ExportCombinedRankingHandler, + build_filename, +) + +logger = logging.getLogger(__name__) + +__all__ = [ + "ArchiveTrainingDayHandler", + "ExportAttendanceHandler", + "ExportCombinedRankingHandler", + "TrainingProgramAttendanceHandler", + "TrainingProgramCombinedRankingDetailHandler", + "TrainingProgramCombinedRankingHandler", + "TrainingProgramCombinedRankingHistoryHandler", + "TrainingProgramFilterMixin", + "UpdateAttendanceHandler", + "get_attendance_view_data", + "get_ranking_view_data", + "FilterContext", + "build_filename", +] + + +class ArchiveTrainingDayHandler(BaseHandler): + """Archive a training day, extracting attendance and ranking data.""" + + @staticmethod + def _parse_ip_addresses(ip_string: str | None) -> list[str]: + """Parse a comma-separated string of IP addresses.""" + if not ip_string: + return [] + return [ip.strip() for ip in ip_string.split(",") if ip.strip()] + + @require_permission(BaseHandler.PERMISSION_ALL) + def get(self, training_program_id: str, training_day_id: str): + """Show the archive confirmation page with IP selection.""" + training_program = self.safe_get_item(TrainingProgram, training_program_id) + training_day = self.safe_get_item(TrainingDay, training_day_id) + + if training_day.training_program_id != training_program.id: + raise tornado.web.HTTPError(404, "Training day not in this program") + + if training_day.contest is None: + raise tornado.web.HTTPError(400, "Training day is already archived") + + contest = training_day.contest + + # Get all participations with their starting IPs + # Count students per IP (only IPs with more than one student) + ip_counts: dict[str, int] = {} + for participation in contest.participations: + ips = self._parse_ip_addresses(participation.starting_ip_addresses) + for ip in ips: + ip_counts[ip] = ip_counts.get(ip, 0) + 1 + + # Filter to only IPs with more than one student + shared_ips = {ip: count for ip, count in ip_counts.items() if count > 1} + + # Check if any participants can still start or are currently in contest + # This is used to show a warning on the archive confirmation page + users_not_finished = [] + for _, participation, main_group in self._iterate_eligible_students( + training_day, contest + ): + main_group_start = main_group.start_time if main_group else None + main_group_end = main_group.end_time if main_group else None + status_class, status_label = compute_participation_status( + contest, participation, self.timestamp, + main_group_start, main_group_end + ) + if status_class not in ('finished', 'missed'): + users_not_finished.append({ + 'participation': participation, + 'status_class': status_class, + 'status_label': status_label, + }) + + self.render_params_for_training_program(training_program) + self.r_params["training_day"] = training_day + self.r_params["contest"] = contest + self.r_params["shared_ips"] = shared_ips + self.r_params["users_not_finished"] = users_not_finished + self.render("archive_training_day.html", **self.r_params) + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str, training_day_id: str): + """Perform the archiving operation.""" + fallback_page = self.url( + "training_program", training_program_id, "training_days" + ) + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + training_day = self.safe_get_item(TrainingDay, training_day_id) + + if training_day.training_program_id != training_program.id: + raise tornado.web.HTTPError(404, "Training day not in this program") + + if training_day.contest is None: + self.service.add_notification( + make_datetime(), "Error", "Training day is already archived" + ) + self.redirect(fallback_page) + return + + contest = training_day.contest + + # Get selected class IPs from form + class_ips = set(self.get_arguments("class_ips")) + + try: + # Save name, description, and start_time from contest before archiving + training_day.name = contest.name + training_day.description = contest.description + training_day.start_time = contest.start + + # Calculate and store the training day duration + # Use max duration among main groups (if any), or training day duration + training_day.duration = self._calculate_training_day_duration( + training_day, contest + ) + + # Archive attendance data for each student + self._archive_attendance_data(training_day, contest, class_ips) + + # Archive ranking data for each student + self._archive_ranking_data(training_day, contest) + + # Delete the contest (this will cascade delete participations) + self.sql_session.delete(contest) + + except Exception as error: + self.sql_session.rollback() + self.service.add_notification( + make_datetime(), "Archive failed", repr(error) + ) + self.redirect(fallback_page) + return + + if self.try_commit(): + self.service.add_notification( + make_datetime(), + "Training day archived", + f"Training day '{training_day.name}' has been archived successfully" + ) + + self.redirect(fallback_page) + + def _calculate_training_day_duration( + self, + training_day: TrainingDay, + contest: Contest + ) -> timedelta | None: + """Calculate the training day duration for archiving. + + Returns the max training duration among main groups (if any), + or the training day duration (if no main groups). + + training_day: the training day being archived. + contest: the contest associated with the training day. + + return: the duration as a timedelta, or None if not calculable. + """ + # Check if there are main groups with custom timing + main_groups = training_day.groups + # Calculate max duration among main groups + max_duration: timedelta | None = None + for group in main_groups: + if group.start_time is not None and group.end_time is not None: + group_duration = group.end_time - group.start_time + if max_duration is None or group_duration > max_duration: + max_duration = group_duration + if max_duration is not None: + return max_duration + + # Fall back to training day (contest) duration + return contest.stop - contest.start + + def _iterate_eligible_students( + self, training_day: TrainingDay, contest: Contest + ) -> typing.Iterator[tuple[Student, Participation, TrainingDayGroup | None]]: + """Iterate over all non-hidden students eligible for the training day. + + Yields (student, participation, main_group) tuples for all students who: + 1. Have a user associated with a participation in the contest + 2. Are not hidden + 3. Are eligible for the training day (check_training_day_eligibility) + """ + training_program = training_day.training_program + user_to_student = build_user_to_student_map(training_program) + + for participation in contest.participations: + if participation.hidden: + continue + + # Find the student for this user in the training program + # Note: Student.participation_id points to the managing contest participation, + # not the training day participation, so we need to look up by user_id + student = user_to_student.get(participation.user_id) + + if student is None: + logger.warning( + "Participation %s (user %s) has no corresponding student record " + "in training program %s", + participation.id, + participation.user_id, + training_program.id, + ) + continue + + # Skip ineligible students (not in any main group) + # These students were never supposed to participate in this training day + is_eligible, main_group, _ = check_training_day_eligibility( + self.sql_session, participation, training_day, student=student + ) + if not is_eligible: + continue + + yield student, participation, main_group + + def _archive_attendance_data( + self, training_day: TrainingDay, contest: Contest, class_ips: set[str] + ) -> None: + """Extract and store attendance data for all students.""" + for student, participation, _ in self._iterate_eligible_students( + training_day, contest + ): + # Determine status + if participation.starting_time is None: + status = "missed" + location = None + else: + status = "participated" + # Determine location based on starting IPs + # If no class IPs were selected, everyone who participated is considered "home" + # Also if there are no IPs recorded, assume "home" + location = "home" + ips = self._parse_ip_addresses(participation.starting_ip_addresses) + if class_ips and ips: + has_class_ip = any(ip in class_ips for ip in ips) + has_home_ip = any(ip not in class_ips for ip in ips) + if has_class_ip and has_home_ip: + location = "both" + elif has_class_ip: + location = "class" + + # Get delay time + delay_time = participation.delay_time + + # Concatenate delay reasons from all delay requests + delay_requests = ( + self.sql_session.query(DelayRequest) + .filter(DelayRequest.participation_id == participation.id) + .order_by(DelayRequest.request_timestamp) + .all() + ) + delay_reasons = None + if delay_requests: + reasons = [dr.reason for dr in delay_requests if dr.reason] + if reasons: + delay_reasons = "; ".join(reasons) + + # Create archived attendance record + archived_attendance = ArchivedAttendance( + status=status, + location=location, + delay_time=delay_time, + delay_reasons=delay_reasons, + ) + archived_attendance.training_day_id = training_day.id + archived_attendance.student_id = student.id + self.sql_session.add(archived_attendance) + + def _get_visible_tasks( + self, + training_day: TrainingDay, + participation: Participation, + training_day_tasks: list[Task], + ) -> list[Task]: + """Determine which tasks should be visible to this student.""" + visible_tasks: list[Task] = [] + for task in training_day_tasks: + if can_access_task(self.sql_session, task, participation, training_day): + visible_tasks.append(task) + return visible_tasks + + def _ensure_student_tasks( + self, + student: Student, + visible_tasks: list[Task], + training_day: TrainingDay, + ) -> None: + """Add visible tasks to student's StudentTask records 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: + 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 = training_day.id + self.sql_session.add(student_task) + + def _collect_task_scores_and_submissions( + self, + training_day: TrainingDay, + participation: Participation, + managing_participation: Participation, + visible_tasks: list[Task], + student_missed: bool, + ) -> tuple[dict[str, float], dict[str, list[dict]]]: + """Collect scores and submissions for visible tasks.""" + task_scores: dict[str, float] = {} + submissions: dict[str, list[dict]] = {} + + for task in visible_tasks: + task_id = task.id + + if student_missed: + # Student missed the training - set score to 0 + task_scores[str(task_id)] = 0.0 + else: + # Get score from the training day participation (for cache lookup) + cache_entry = get_cached_score_entry( + self.sql_session, participation, task + ) + task_scores[str(task_id)] = cache_entry.score + + # Get official submissions for this task from the managing participation + task_submissions = ( + self.sql_session.query(Submission) + .filter(Submission.participation_id == managing_participation.id) + .filter(Submission.task_id == task_id) + .filter(Submission.training_day_id == training_day.id) + .filter(Submission.official.is_(True)) + .order_by(Submission.timestamp) + .all() + ) + + # If student missed but has submissions, this is an error + if student_missed and task_submissions: + raise ValueError( + f"User {participation.user.username} (id={participation.user_id}) " + f"has no starting_time but has {len(task_submissions)} submission(s) " + f"for task '{task.name}' in training day '{training_day.name}'" + ) + + submissions[str(task_id)] = [] + for sub in task_submissions: + result = sub.get_result() + if result is None or not result.scored(): + continue + + if sub.timestamp is not None: + time_offset = int( + (sub.timestamp - participation.starting_time).total_seconds() + ) + else: + time_offset = 0 + + submissions[str(task_id)].append( + { + "task": str(task_id), + "time": time_offset, + "score": result.score, + "token": sub.tokened(), + "extra": result.ranking_score_details or [], + } + ) + return task_scores, submissions + + def _collect_score_history( + self, + training_day: TrainingDay, + participation: Participation, + training_day_task_ids: set[int], + student_missed: bool, + ) -> list[list]: + """Collect score history for the student.""" + history: list[list] = [] + score_histories = ( + self.sql_session.query(ScoreHistory) + .filter(ScoreHistory.participation_id == participation.id) + .filter(ScoreHistory.task_id.in_(training_day_task_ids)) + .order_by(ScoreHistory.timestamp) + .all() + ) + + # If student missed but has score history, this is an error + if student_missed and score_histories: + raise ValueError( + f"User {participation.user.username} (id={participation.user_id}) " + f"has no starting_time but has {len(score_histories)} score history " + f"record(s) in training day '{training_day.name}'" + ) + + for sh in score_histories: + if sh.timestamp is not None: + time_offset = ( + sh.timestamp - participation.starting_time + ).total_seconds() + else: + time_offset = 0 + history.append([participation.user_id, sh.task_id, time_offset, sh.score]) + return history + + def _process_student_ranking( + self, + training_day: TrainingDay, + student: Student, + participation: Participation, + training_day_tasks: list[Task], + training_day_task_ids: set[int], + ) -> None: + """Process and store ranking data for a single student.""" + # Get all student tags (as list for array storage) + student_tags = list(student.student_tags) if student.student_tags else [] + + # Determine which tasks should be visible to this student based on their tags + visible_tasks = self._get_visible_tasks( + training_day, participation, training_day_tasks + ) + + # Add visible tasks to student's StudentTask records if not already present + self._ensure_student_tasks(student, visible_tasks, training_day) + + # Get the managing participation for this user + managing_participation = get_managing_participation( + self.sql_session, training_day, participation.user + ) + if managing_participation is None: + raise ValueError( + f"User {participation.user.username} (id={participation.user_id}) " + f"does not have a participation in the managing contest " + f"'{training_day.training_program.managing_contest.name}' " + f"for training day '{training_day.name}'" + ) + + # Check if student missed the training (no starting_time) + student_missed = participation.starting_time is None + + # Get task scores for ALL visible tasks + task_scores, submissions = self._collect_task_scores_and_submissions( + training_day, + participation, + managing_participation, + visible_tasks, + student_missed, + ) + + # Get score history + history = self._collect_score_history( + training_day, + participation, + training_day_task_ids, + student_missed, + ) + + # Create archived ranking record + archived_ranking = ArchivedStudentRanking( + student_tags=student_tags, + task_scores=task_scores if task_scores else None, + submissions=submissions if submissions else None, + history=history if history else None, + ) + archived_ranking.training_day_id = training_day.id + archived_ranking.student_id = student.id + self.sql_session.add(archived_ranking) + + def _archive_ranking_data( + self, training_day: TrainingDay, contest: Contest + ) -> None: + """Extract and store ranking data for all students. + + Stores on TrainingDay: + - archived_tasks_data: task metadata including extra_headers for submission table + + Stores on ArchivedStudentRanking (per student): + - task_scores: scores for ALL visible tasks (including 0 scores) + The presence of a task_id key indicates the task was visible. + - submissions: submission data for each task in RWS format + - history: score history in RWS format + """ + # Get the tasks assigned to this training day + training_day_tasks = training_day.tasks + training_day_task_ids = {task.id for task in training_day_tasks} + + # Build and store tasks_data on the training day + training_day.archived_tasks_data = { + str(task.id): build_task_data_for_archive(task) + for task in training_day_tasks + } + + for student, participation, _ in self._iterate_eligible_students( + training_day, contest + ): + self._process_student_ranking( + training_day, + student, + participation, + training_day_tasks, + training_day_task_ids, + ) diff --git a/cms/server/admin/handlers/base.py b/cms/server/admin/handlers/base.py index f542eac020..767688a99f 100644 --- a/cms/server/admin/handlers/base.py +++ b/cms/server/admin/handlers/base.py @@ -23,9 +23,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -"""Base class for all handlers in AWS, and some utility functions. - -""" +"""Base class for all handlers in AWS, and some utility functions.""" from collections.abc import Callable import ipaddress @@ -38,26 +36,46 @@ import collections import typing -from cms.db.session import Session try: collections.MutableMapping -except: +except AttributeError: # Monkey-patch: Tornado 4.5.3 does not work on Python 3.11 by default collections.MutableMapping = collections.abc.MutableMapping import tornado.web from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Query, subqueryload +from sqlalchemy.orm import Query, selectinload, subqueryload from cms import __version__, config -from cms.db import Session, Contest, ContestFolder, DelayRequest, Participation, \ - Question, Submission, SubmissionResult, Task, Team, User, UserTest, Admin +from cms.db import ( + Session, + Contest, + ContestFolder, + DelayRequest, + Participation, + Question, + Student, + Submission, + SubmissionResult, + Task, + Team, + TrainingDay, + TrainingProgram, + User, + UserTest, + Admin, +) import cms.db from cms.grading.scoretypes import get_score_type_class from cms.grading.tasktypes import get_task_type_class from cms.server import CommonRequestHandler, FileHandlerMixin -from cms.server.util import exclude_internal_contests +from cms.server.util import exclude_internal_contests, calculate_task_archive_progress +from cms.server.admin.handlers.utils import ( + count_unanswered_questions, + get_all_student_tags, + get_all_training_day_notifications, +) from cmscommon.crypto import hash_password, parse_authentication from cmscommon.datetime import make_datetime, get_timezone, local_to_utc, format_datetime_for_input, get_timezone_name if typing.TYPE_CHECKING: @@ -102,51 +120,60 @@ def helper( def parse_string_list(value: str) -> list[str]: """Parse a comma-separated list of strings.""" - return list(x.strip() for x in value.split(",") if x.strip()) + return [x.strip() for x in value.split(",") if x.strip()] def parse_int(value: str) -> int: """Parse and validate an integer.""" try: return int(value) - except: - raise ValueError("Can't cast %s to int." % value) + except (ValueError, TypeError) as err: + raise ValueError("Can't cast %s to int." % value) from err def parse_timedelta_sec(value: str) -> timedelta: """Parse and validate a timedelta (as number of seconds).""" try: return timedelta(seconds=float(value)) - except: - raise ValueError("Can't cast %s to timedelta." % value) + except (ValueError, TypeError) as err: + raise ValueError("Can't cast %s to timedelta." % value) from err def parse_timedelta_min(value: str) -> timedelta: """Parse and validate a timedelta (as number of minutes).""" try: return timedelta(minutes=float(value)) - except: - raise ValueError("Can't cast %s to timedelta." % value) + except (ValueError, TypeError) as err: + raise ValueError("Can't cast %s to timedelta." % value) from err def parse_datetime(value: str) -> datetime: """Parse and validate a datetime (in pseudo-ISO8601).""" - if '.' not in value: + if "." not in value: value += ".0" try: return datetime.strptime(value, "%Y-%m-%d %H:%M:%S.%f") - except: - raise ValueError("Can't cast %s to datetime." % value) + except (ValueError, TypeError) as err: + raise ValueError("Can't cast %s to datetime." % value) from err def parse_datetime_with_timezone(value: str, tz) -> datetime: """Parse a datetime in the given timezone and convert to UTC. - value: a datetime string in "YYYY-MM-DD HH:MM:SS" format. + value: a datetime string in "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DDTHH:MM" format. tz: the timezone the datetime is in. return: a naive datetime in UTC. """ + # Try HTML5 datetime-local format first (YYYY-MM-DDTHH:MM) + if 'T' in value and '.' not in value and len(value) == 16: + try: + local_dt = datetime.strptime(value, "%Y-%m-%dT%H:%M") + return local_to_utc(local_dt, tz) + except (ValueError, OverflowError): + pass # Fall through to try other formats + + # Standard format with optional microseconds if '.' not in value: value += ".0" try: @@ -193,9 +220,11 @@ def require_permission(permission: str = "authenticated", self_allowed: bool = F permission. """ - if permission not in [BaseHandler.PERMISSION_ALL, - BaseHandler.PERMISSION_MESSAGING, - BaseHandler.AUTHENTICATED]: + if permission not in [ + BaseHandler.PERMISSION_ALL, + BaseHandler.PERMISSION_MESSAGING, + BaseHandler.AUTHENTICATED, + ]: raise ValueError("Invalid permission level %s." % permission) _P = typing.ParamSpec("_P") @@ -205,15 +234,12 @@ def require_permission(permission: str = "authenticated", self_allowed: bool = F def decorator( func: Callable[typing.Concatenate[_T, _P], _R], ) -> Callable[typing.Concatenate[_T, _P], _R]: - """Decorator for requiring a permission level + """Decorator for requiring a permission level""" - """ @wraps(func) @tornado.web.authenticated def newfunc(self: _T, *args: _P.args, **kwargs: _P.kwargs): - """Check if the permission is present before calling the function. - - """ + """Check if the permission is present before calling the function.""" if permission == BaseHandler.AUTHENTICATED: return func(self, *args, **kwargs) @@ -242,6 +268,7 @@ class BaseHandler(CommonRequestHandler): child of this class. """ + PERMISSION_ALL = "all" PERMISSION_MESSAGING = "messaging" AUTHENTICATED = "authenticated" @@ -260,13 +287,11 @@ def try_commit(self) -> bool: self.sql_session.commit() except IntegrityError as error: self.service.add_notification( - make_datetime(), - "Operation failed.", "%s" % error) + make_datetime(), "Operation failed.", "%s" % error + ) return False else: - self.service.add_notification( - make_datetime(), - "Operation successful.", "") + self.service.add_notification(make_datetime(), "Operation successful.", "") return True def get_current_user(self) -> Admin | None: @@ -281,10 +306,12 @@ def get_current_user(self) -> Admin | None: return None # Load admin. - admin = self.sql_session.query(Admin)\ - .filter(Admin.id == admin_id)\ - .filter(Admin.enabled.is_(True))\ + admin = ( + self.sql_session.query(Admin) + .filter(Admin.id == admin_id) + .filter(Admin.enabled.is_(True)) .first() + ) if admin is None: self.service.auth_handler.clear() return None @@ -321,12 +348,74 @@ def safe_get_item( return entity def prepare(self): - """This method is executed at the beginning of each request. - - """ + """This method is executed at the beginning of each request.""" super().prepare() self.contest = None + import re + + path = self.request.path + match = re.match(r"^/contest/(\d+)(/.*)?$", path) + if match: + contest_id = match.group(1) + remaining_path = match.group(2) or "" + + # 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.endswith("/detail") + or remaining_path.endswith("/submissions") + or remaining_path.endswith("/ranking/history") + ): + return + + try: + contest = ( + self.sql_session.query(Contest) + .filter(Contest.id == int(contest_id)) + .first() + ) + # Redirect managing contest URLs to training program URLs + if contest and contest.training_program is not None: + training_program = contest.training_program + + if training_program: + url_mappings = { + "/users": "/students", + "/user/": "/student/", + } + + new_path = remaining_path + for contest_suffix, tp_suffix in url_mappings.items(): + if remaining_path.startswith( + contest_suffix + ) and not remaining_path.endswith("/detail"): + new_path = remaining_path.replace( + contest_suffix, tp_suffix, 1 + ) + if "/edit" not in new_path and tp_suffix == "/student/": + new_path = new_path.rstrip("/") + "/edit" + break + + tp_url = ( + self.url("training_program", training_program.id) + new_path + ) + self.redirect(tp_url) + self._finished = True + return + except Exception as e: + logger.exception( + "Error in prepare() while processing contest URL redirect for path %s: %s", + path, + str(e), + ) + # Continue with normal request processing after logging the error + def render(self, template_name: str, **params): t = self.service.jinja2_environment.get_template(template_name) for chunk in t.generate(**params): @@ -339,10 +428,15 @@ def render_params(self) -> dict: """ params = {} - params["rtd_version"] = "latest" if "dev" in __version__ \ - else "v" + __version__[:3] + params["rtd_version"] = ( + "latest" if "dev" in __version__ else "v" + __version__[:3] + ) params["timestamp"] = make_datetime() params["contest"] = self.contest + # If the contest is a managing contest for a training program, + # set training_program so the sidebar shows training program menu + if self.contest is not None and self.contest.training_program is not None: + params["training_program"] = self.contest.training_program params["url"] = self.url params["xsrf_form_html"] = self.xsrf_form_html() # FIXME These objects provide too broad an access: their usage @@ -352,54 +446,228 @@ def render_params(self) -> dict: if self.current_user is not None: params["admin"] = self.current_user if self.contest is not None: - params["unanswered"] = self.sql_session.query(Question)\ - .join(Participation)\ - .filter(Participation.contest_id == self.contest.id)\ - .filter(Question.reply_timestamp.is_(None))\ - .filter(Question.ignored.is_(False))\ + params["unanswered"] = ( + self.sql_session.query(Question) + .join(Participation) + .filter(Participation.contest_id == self.contest.id) + .filter(Question.reply_timestamp.is_(None)) + .filter(Question.ignored.is_(False)) .count() - params["unanswered_delay_requests"] = self.sql_session.query(DelayRequest)\ - .join(Participation)\ - .filter(Participation.contest_id == self.contest.id)\ - .filter(DelayRequest.status == 'pending')\ + ) + params["unanswered_delay_requests"] = ( + self.sql_session.query(DelayRequest) + .join(Participation) + .filter(Participation.contest_id == self.contest.id) + .filter(DelayRequest.status == "pending") .count() + ) # TODO: not all pages require all these data. # TODO: use a better sorting method. - params["contest_list"] = exclude_internal_contests( - self.sql_session.query(Contest) - ).order_by(Contest.name).all() - params["task_list"] = self.sql_session.query(Task)\ - .order_by(Task.name).all() - params["user_list"] = self.sql_session.query(User)\ - .filter(~User.username.like(r'\_\_%', escape='\\'))\ - .order_by(User.username).all() - params["team_list"] = self.sql_session.query(Team)\ - .order_by(Team.name).all() - params["folder_list"] = self.sql_session.query(ContestFolder)\ - .options(subqueryload(ContestFolder.contests))\ - .options(subqueryload(ContestFolder.children).subqueryload(ContestFolder.contests))\ - .order_by(ContestFolder.name).all() - params["root_contests"] = exclude_internal_contests( - self.sql_session.query(Contest).filter( - Contest.folder_id.is_(None) + params["contest_list"] = ( + exclude_internal_contests(self.sql_session.query(Contest)) + .outerjoin(TrainingDay, Contest.id == TrainingDay.contest_id) + .filter(TrainingDay.id.is_(None)) + .order_by(Contest.name) + .all() + ) + params["task_list"] = self.sql_session.query(Task).order_by(Task.name).all() + params["user_list"] = ( + self.sql_session.query(User) + .filter(~User.username.like(r"\_\_%", escape="\\")) + .order_by(User.username) + .all() + ) + params["team_list"] = self.sql_session.query(Team).order_by(Team.name).all() + params["folder_list"] = ( + self.sql_session.query(ContestFolder) + .options(subqueryload(ContestFolder.contests)) + .options( + subqueryload(ContestFolder.children).subqueryload( + ContestFolder.contests + ) + ) + .order_by(ContestFolder.name) + .all() + ) + params["root_contests"] = ( + exclude_internal_contests( + self.sql_session.query(Contest).filter(Contest.folder_id.is_(None)) ) - ).order_by(Contest.name).all() - params["pending_password_resets"] = self.sql_session.query(User)\ - .filter(User.password_reset_pending.is_(True))\ + .outerjoin(TrainingDay, Contest.id == TrainingDay.contest_id) + .filter(TrainingDay.id.is_(None)) + .order_by(Contest.name) + .all() + ) + params["pending_password_resets"] = ( + self.sql_session.query(User) + .filter(User.password_reset_pending.is_(True)) .count() + ) # Add timezone for datetime formatting (contest-specific if available) tz = get_timezone(None, self.contest) params["timezone"] = tz params["timezone_name"] = get_timezone_name(tz) + params["training_program_list"] = ( + self.sql_session.query(TrainingProgram) + .options( + selectinload(TrainingProgram.training_days).selectinload( + TrainingDay.contest + ) + ) + .order_by(TrainingProgram.name) + .all() + ) + return params + def render_params_for_training_program( + self, training_program: "TrainingProgram" + ) -> dict: + """Initialize render params for a training program page. + + This is a convenience method that combines render_params(), + setting training_program, contest, unanswered questions count, + and notification counts for training days in the sidebar. + + Args: + training_program: The training program being viewed. + + Returns: + The initialized r_params dict. + """ + managing_contest = training_program.managing_contest + self.r_params = self.render_params() + self.r_params["training_program"] = training_program + self.r_params["contest"] = managing_contest + + # Count unanswered questions for the managing contest (used in sidebar) + self.r_params["unanswered"] = count_unanswered_questions( + self.sql_session, managing_contest.id + ) + + # Add notification counts for training days + ( + training_day_notifications, + total_td_unanswered_questions, + total_td_pending_delay_requests, + ) = get_all_training_day_notifications(self.sql_session, training_program) + + self.r_params["training_day_notifications"] = training_day_notifications + self.r_params["total_td_unanswered_questions"] = \ + total_td_unanswered_questions + self.r_params["total_td_pending_delay_requests"] = \ + total_td_pending_delay_requests + + return self.r_params + + def setup_contest_or_training_program( + self, + entity_type: str | None, + entity_id: str | None, + allow_none: bool = False, + set_r_params: bool = True, + ) -> "TrainingProgram | None": + """Set up the context for handlers that support both Contests and TPs. + + This handles fetching the entity, setting self.contest, and + populating self.r_params with the correct sidebar data. + + Args: + entity_type: Either "contest" or "training_program". + entity_id: The ID of the entity. + allow_none: If True, allow entity_type to be None and fall back to + render_params() without setting self.contest. + set_r_params: If False, skip building r_params for callers that + don't render templates. + + Returns: + The TrainingProgram if entity_type is "training_program" or if + the contest is a managing contest for a training program, + otherwise None. + + Raises: + HTTPError 404: If entity_type is unknown. + """ + if entity_type is None: + if allow_none: + if set_r_params: + self.r_params = self.render_params() + return None + raise tornado.web.HTTPError(404, "Unknown entity type") + + if entity_type == "contest": + self.contest = self.safe_get_item(Contest, entity_id) + if set_r_params: + self.r_params = self.render_params() + return self.contest.training_program + elif entity_type == "training_program": + training_program = self.safe_get_item(TrainingProgram, entity_id) + self.contest = training_program.managing_contest + if set_r_params: + self.r_params = self.render_params_for_training_program( + training_program + ) + return training_program + else: + raise tornado.web.HTTPError(404, "Unknown entity type") + + def render_params_for_students_page( + self, training_program: "TrainingProgram" + ) -> dict: + """Prepare render params for the training program students page. + + This is a convenience method that sets up all the params needed + for the students page, including unassigned users, student progress, + and task/tag lists for the bulk assign modal. + + Must be called after render_params_for_training_program(). + + Args: + training_program: The training program being viewed. + + Returns: + The updated r_params dict. + """ + managing_contest = training_program.managing_contest + + assigned_user_ids_q = self.sql_session.query(Participation.user_id).filter( + Participation.contest == managing_contest + ) + + self.r_params["unassigned_users"] = ( + self.sql_session.query(User) + .filter(~User.id.in_(assigned_user_ids_q)) + .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.sql_session + ) + # Commit to release any advisory locks taken by get_cached_score_entry + self.sql_session.commit() + + self.r_params["student_progress"] = student_progress + + # For bulk assign task modal + self.r_params["all_tasks"] = managing_contest.get_tasks() + self.r_params["all_student_tags"] = get_all_student_tags( + self.sql_session, training_program + ) + + return self.r_params + def write_error(self, status_code, **kwargs): - if "exc_info" in kwargs and \ - kwargs["exc_info"][0] != tornado.web.HTTPError: + if "exc_info" in kwargs and kwargs["exc_info"][0] != tornado.web.HTTPError: exc_info = kwargs["exc_info"] logger.error( "Uncaught exception (%r) while processing a request: %s", - exc_info[1], ''.join(traceback.format_exception(*exc_info))) + exc_info[1], + "".join(traceback.format_exception(*exc_info)), + ) # Most of the handlers raise a 404 HTTP error before r_params # is defined. If r_params is not defined we try to define it @@ -407,7 +675,7 @@ def write_error(self, status_code, **kwargs): if self.r_params is None: try: self.r_params = self.render_params() - except: + except Exception: self.write("A critical error has occurred :-(") self.finish() return @@ -429,7 +697,7 @@ def get_bool(self, dest: dict, name: str): value = self.get_argument(name, False) try: dest[name] = bool(value) - except: + except (ValueError, TypeError): raise ValueError("Can't cast %s to bool." % value) get_int = argument_reader(parse_int) @@ -499,8 +767,8 @@ def get_time_limit(self, dest: dict, field: str): else: try: value = float(value) - except: - raise ValueError("Can't cast %s to float." % value) + except ValueError as err: + raise ValueError("Can't cast %s to float: %s" % (value, err)) from err if not 0 <= value < float("+inf"): raise ValueError("Time limit out of range.") dest["time_limit"] = value @@ -523,8 +791,8 @@ def get_memory_limit(self, dest: dict, field: str): else: try: value = int(value) - except: - raise ValueError("Can't cast %s to int." % value) + except ValueError as err: + raise ValueError("Can't cast %s to int: %s" % (value, err)) from err if not 0 < value: raise ValueError("Invalid memory limit.") # AWS displays the value as MiB, but it is stored as bytes. @@ -635,8 +903,11 @@ def get_password( # If the password was set and was hashed, and the admin kept # the method unchanged and didn't specify anything, they must # have meant to keep the old password unchanged. - elif old_method is not None and old_method != "plaintext" \ - and method == old_method: + elif ( + old_method is not None + and old_method != "plaintext" + and method == old_method + ): # Since the content of dest overwrites the current values # of the participation, by not adding anything to dest we # cause the current values to be kept. @@ -661,14 +932,18 @@ def render_params_for_submissions( page_size: the number of submissions per page. """ - query = query\ - .options(subqueryload(Submission.task))\ - .options(subqueryload(Submission.participation))\ - .options(subqueryload(Submission.files))\ - .options(subqueryload(Submission.token))\ - .options(subqueryload(Submission.results) - .subqueryload(SubmissionResult.evaluations))\ + query = ( + query.options(subqueryload(Submission.task)) + .options(subqueryload(Submission.participation)) + .options(subqueryload(Submission.files)) + .options(subqueryload(Submission.token)) + .options( + subqueryload(Submission.results).subqueryload( + SubmissionResult.evaluations + ) + ) .order_by(Submission.timestamp.desc()) + ) offset = page * page_size count = query.count() @@ -686,11 +961,9 @@ def render_params_for_submissions( # display in this page, index of the current page, total # number of pages. self.r_params["submission_count"] = count - self.r_params["submissions"] = \ - query.slice(offset, offset + page_size).all() + self.r_params["submissions"] = query.slice(offset, offset + page_size).all() self.r_params["submission_page"] = page - self.r_params["submission_pages"] = \ - (count + page_size - 1) // page_size + self.r_params["submission_pages"] = (count + page_size - 1) // page_size def render_params_for_user_tests( self, query: Query, page: int, page_size: int = 50 @@ -702,12 +975,13 @@ def render_params_for_user_tests( page_size: the number of submissions per page. """ - query = query\ - .options(subqueryload(UserTest.task))\ - .options(subqueryload(UserTest.participation))\ - .options(subqueryload(UserTest.files))\ - .options(subqueryload(UserTest.results))\ + query = ( + query.options(subqueryload(UserTest.task)) + .options(subqueryload(UserTest.participation)) + .options(subqueryload(UserTest.files)) + .options(subqueryload(UserTest.results)) .order_by(UserTest.timestamp.desc()) + ) offset = page * page_size count = query.count() @@ -721,11 +995,9 @@ def render_params_for_user_tests( self.r_params["timezone_name"] = get_timezone_name(tz) self.r_params["user_test_count"] = count - self.r_params["user_tests"] = \ - query.slice(offset, offset + page_size).all() + self.r_params["user_tests"] = query.slice(offset, offset + page_size).all() self.r_params["user_test_page"] = page - self.r_params["user_test_pages"] = \ - (count + page_size - 1) // page_size + self.r_params["user_test_pages"] = (count + page_size - 1) // page_size def render_params_for_remove_confirmation(self, query): count = query.count() @@ -735,10 +1007,76 @@ def render_params_for_remove_confirmation(self, query): self.r_params["submission_count"] = count def get_login_url(self) -> str: - """Return the URL unauthenticated users are redirected to. + """Return the URL unauthenticated users are redirected to.""" + return self.url("login") + + +class StudentBaseHandler(BaseHandler): + """Base handler for student-related pages in a training program. + This handler provides common functionality for looking up a student's + context (training_program, managing_contest, participation, student) + and raises 404 if the student is not found. + + Subclasses should call setup_student_context() at the start of their + get/post methods to populate self.training_program, self.managing_contest, + self.participation, and self.student. + """ + + training_program: TrainingProgram + managing_contest: Contest + participation: Participation + student: Student + + def setup_student_context( + self, training_program_id: str, user_id: str + ) -> None: + """Look up and set the student context for this request. + + This method looks up the training program, managing contest, + participation, and student for the given IDs. It raises a 404 + error if the participation or student is not found. + + Args: + training_program_id: The training program ID from the URL. + user_id: The user ID from the URL. + + Raises: + tornado.web.HTTPError(404): If participation or student not found. """ - return self.url("login") + try: + user_id_int = int(user_id) + except ValueError: + raise tornado.web.HTTPError(404) + + self.training_program = self.safe_get_item( + TrainingProgram, training_program_id + ) + self.managing_contest = self.training_program.managing_contest + self.contest = self.managing_contest + + participation: Participation | None = ( + self.sql_session.query(Participation) + .filter(Participation.contest_id == self.managing_contest.id) + .filter(Participation.user_id == user_id_int) + .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 == self.training_program) + .first() + ) + + if student is None: + raise tornado.web.HTTPError(404) + + self.participation = participation + self.student = student class FileHandler(BaseHandler, FileHandlerMixin): @@ -747,6 +1085,7 @@ class FileHandler(BaseHandler, FileHandlerMixin): class FileFromDigestHandler(FileHandler): """Return the file, using the given name, and as plain text.""" + @require_permission(BaseHandler.AUTHENTICATED) def get(self, digest, filename): # TODO: Accept a MIME type @@ -756,22 +1095,26 @@ def get(self, digest, filename): def SimpleHandler(page, authenticated=True, permission_all=False) -> type[BaseHandler]: if permission_all: + class Cls(BaseHandler): @require_permission(BaseHandler.PERMISSION_ALL) def get(self): self.r_params = self.render_params() self.render(page, **self.r_params) elif authenticated: + class Cls(BaseHandler): @require_permission(BaseHandler.AUTHENTICATED) def get(self): self.r_params = self.render_params() self.render(page, **self.r_params) else: + class Cls(BaseHandler): def get(self): self.r_params = self.render_params() self.render(page, **self.r_params) + return Cls diff --git a/cms/server/admin/handlers/contest.py b/cms/server/admin/handlers/contest.py index f6eb94512b..1045afa24f 100644 --- a/cms/server/admin/handlers/contest.py +++ b/cms/server/admin/handlers/contest.py @@ -30,11 +30,23 @@ from datetime import timedelta +import tornado.web + from cms import ServiceCoord, get_service_shards, get_service_address -from cms.db import Contest, Participation, Submission, Task, ContestFolder +from cms.db import ( + Contest, + Participation, + Submission, + Task, + ContestFolder, + TrainingDay, + TrainingProgram, +) from cms.server.util import exclude_internal_contests from cmscommon.datetime import make_datetime +from sqlalchemy.orm import joinedload from sqlalchemy import func +from cms.server.admin.handlers.utils import get_all_student_tags from .base import BaseHandler, SimpleContestHandler, SimpleHandler, \ require_permission @@ -42,9 +54,9 @@ def remove_contest_with_action(session, contest, action, target_contest=None): """Remove contest with specified action for tasks. - + This is a standalone helper function that can be called from tests. - + Args: session: SQLAlchemy session contest: Contest object to remove @@ -54,33 +66,42 @@ def remove_contest_with_action(session, contest, action, target_contest=None): if action == "move": if target_contest is None: raise ValueError("Target contest must be specified when moving tasks") - - max_num = session.query(func.max(Task.num))\ - .filter(Task.contest == target_contest)\ + + tasks = ( + session.query(Task) + .filter(Task.contest == contest) + .order_by(Task.num, Task.id) + .all() + ) + + # Phase 1: clear nums on moving tasks to avoid duplicate (contest_id, num). + for task in tasks: + task.num = None + session.flush() + + # Phase 2: append after current max num in target, preserving gaps. + max_num = ( + session.query(func.max(Task.num)) + .filter(Task.contest == target_contest) .scalar() + ) base_num = (max_num or -1) + 1 - - tasks = session.query(Task)\ - .filter(Task.contest == contest)\ - .order_by(Task.num)\ - .all() - + for i, task in enumerate(tasks): task.contest = target_contest task.num = base_num + i - session.flush() - + session.flush() + elif action == "detach": tasks = session.query(Task)\ .filter(Task.contest == contest)\ .all() - + for task in tasks: task.contest = None task.num = None - session.flush() - - + session.flush() + session.delete(contest) session.flush() @@ -125,15 +146,33 @@ def post(self): class ContestHandler(SimpleContestHandler("contest.html")): @require_permission(BaseHandler.AUTHENTICATED) def get(self, contest_id: str): - self.contest = self.safe_get_item(Contest, contest_id) + self.contest = self.sql_session.query(Contest)\ + .options( + joinedload(Contest.training_day) + .joinedload(TrainingDay.groups) + )\ + .filter(Contest.id == contest_id)\ + .one_or_none() + + if self.contest is None: + raise tornado.web.HTTPError(404) self.r_params = self.render_params() self.r_params["all_folders"] = ( - self.sql_session.query(ContestFolder) - .order_by(ContestFolder.name) - .all() + self.sql_session.query(ContestFolder).order_by(ContestFolder.name).all() ) + + all_student_tags: list[str] = [] + training_day = self.contest.training_day + if training_day is not None: + training_program = training_day.training_program + all_student_tags = get_all_student_tags( + self.sql_session, training_program + ) + self.r_params["all_student_tags"] = all_student_tags + self.render("contest.html", **self.r_params) + @require_permission(BaseHandler.PERMISSION_ALL) def post(self, contest_id: str): contest = self.safe_get_item(Contest, contest_id) @@ -148,15 +187,17 @@ def post(self, contest_id: str): self.get_string(attrs, "description") assert attrs.get("name") is not None, "No contest name specified." - assert not attrs.get("name").startswith("__"), \ - "Contest name cannot start with '__' " \ - "(reserved for system contests)." + assert not attrs.get("name").startswith("__"), ( + "Contest name cannot start with '__' (reserved for system contests)." + ) allowed_localizations: str = self.get_argument("allowed_localizations", "") if allowed_localizations: - attrs["allowed_localizations"] = \ - [x.strip() for x in allowed_localizations.split(",") - if len(x) > 0 and not x.isspace()] + attrs["allowed_localizations"] = [ + x.strip() + for x in allowed_localizations.split(",") + if len(x) > 0 and not x.isspace() + ] else: attrs["allowed_localizations"] = [] @@ -169,6 +210,7 @@ def post(self, contest_id: str): self.get_bool(attrs, "block_hidden_participations") self.get_bool(attrs, "allow_password_authentication") self.get_bool(attrs, "allow_registration") + self.get_bool(attrs, "allow_delay_requests") self.get_bool(attrs, "ip_restriction") self.get_bool(attrs, "ip_autologin") @@ -200,6 +242,26 @@ def post(self, contest_id: str): # Update the contest first contest.set_attrs(attrs) + # Validate training day times against main group times + training_day = contest.training_day + if training_day is not None and training_day.groups: + new_start = attrs.get("start") + new_stop = attrs.get("stop") + + for group in training_day.groups: + if group.start_time is not None and new_start is not None: + if new_start > group.start_time: + raise ValueError( + f"Training day start cannot be after main group " + f"'{group.tag_name}' start time" + ) + if group.end_time is not None and new_stop is not None: + if new_stop < group.end_time: + raise ValueError( + f"Training day end cannot be before main group " + f"'{group.tag_name}' end time" + ) + # Folder assignment (relationship) folder_id_str = self.get_argument("folder_id", None) if folder_id_str is None or folder_id_str == "" or folder_id_str == "none": @@ -217,7 +279,8 @@ def post(self, contest_id: str): except Exception as error: self.service.add_notification( - make_datetime(), "Invalid field(s).", repr(error)) + make_datetime(), "Invalid field(s).", repr(error) + ) self.redirect(self.url("contest", contest_id)) return @@ -228,30 +291,28 @@ def post(self, contest_id: str): class OverviewHandler(BaseHandler): - """Home page handler, with queue and workers statuses. + """Home page handler, with queue and workers statuses.""" - """ @require_permission(BaseHandler.AUTHENTICATED) - def get(self, contest_id: str | None = None): - if contest_id is not None: - self.contest = self.safe_get_item(Contest, contest_id) - - self.r_params = self.render_params() + def get(self, entity_type: str | None = None, entity_id: str | None = None): + self.setup_contest_or_training_program( + entity_type, entity_id, allow_none=True + ) self.render("overview.html", **self.r_params) class ResourcesListHandler(BaseHandler): @require_permission(BaseHandler.AUTHENTICATED) - def get(self, contest_id: str | None = None): - if contest_id is not None: - self.contest = self.safe_get_item(Contest, contest_id) - - self.r_params = self.render_params() + def get(self, entity_type: str | None = None, entity_id: str | None = None): + self.setup_contest_or_training_program( + entity_type, entity_id, allow_none=True + ) self.r_params["resource_addresses"] = {} services = get_service_shards("ResourceService") for i in range(services): self.r_params["resource_addresses"][i] = get_service_address( - ServiceCoord("ResourceService", i)).ip + ServiceCoord("ResourceService", i) + ).ip self.render("resourceslist.html", **self.r_params) @@ -274,7 +335,8 @@ def post(self): self.redirect(asking_page) else: self.service.add_notification( - make_datetime(), "Invalid operation %s" % operation, "") + make_datetime(), "Invalid operation %s" % operation, "" + ) self.redirect(self.url("contests")) @@ -287,55 +349,60 @@ class RemoveContestHandler(BaseHandler): @require_permission(BaseHandler.PERMISSION_ALL) def get(self, contest_id): contest = self.safe_get_item(Contest, contest_id) - submission_query = self.sql_session.query(Submission)\ - .join(Submission.participation)\ + submission_query = ( + self.sql_session.query(Submission) + .join(Submission.participation) .filter(Participation.contest == contest) + ) self.contest = contest self.render_params_for_remove_confirmation(submission_query) - + self.r_params["task_count"] = len(contest.tasks) self.r_params["other_contests"] = exclude_internal_contests( self.sql_session.query(Contest) .filter(Contest.id != contest.id) - ).order_by(Contest.name).all() - + ).filter(~Contest.training_day.has()).order_by(Contest.name).all() + self.render("contest_remove.html", **self.r_params) @require_permission(BaseHandler.PERMISSION_ALL) def delete(self, contest_id): """Handle DELETE request with task handling options.""" contest = self.safe_get_item(Contest, contest_id) - + try: action = self.get_argument("action", "detach") assert action in ["move", "detach", "delete_all"], \ "Invalid action specified" - + target_contest_id = None if action == "move": target_contest_id = self.get_argument("target_contest_id", None) - assert target_contest_id, \ + assert target_contest_id, ( "Target contest must be specified when moving tasks" - assert target_contest_id != str(contest_id), \ + ) + assert target_contest_id != str(contest_id), ( "Target contest cannot be the same as the contest being deleted" - + ) + self._remove_contest_with_action(contest, action, target_contest_id) - + except Exception as error: self.service.add_notification( - make_datetime(), "Error removing contest", repr(error)) + make_datetime(), "Error removing contest", repr(error) + ) self.write("error") return - + # Maybe they'll want to do this again (for another contest) self.write("../../contests") - + def _remove_contest_with_action(self, contest, action, target_contest_id): """Remove contest with specified action for tasks. - + This is a thin wrapper that calls the standalone helper function. - + contest: Contest object to remove action: One of "move", "detach", or "delete_all" target_contest_id: ID of target contest (required if action is "move") @@ -343,12 +410,13 @@ def _remove_contest_with_action(self, contest, action, target_contest_id): target_contest = None if action == "move": target_contest = self.safe_get_item(Contest, target_contest_id) - + remove_contest_with_action(self.sql_session, contest, action, target_contest) - + if self.try_commit(): self.service.proxy_service.reinitialize() self.service.add_notification( - make_datetime(), + make_datetime(), "Contest removed successfully", - f"Contest removed with action: {action}") + f"Contest removed with action: {action}", + ) diff --git a/cms/server/admin/handlers/contestannouncement.py b/cms/server/admin/handlers/contestannouncement.py index 32785945cf..5f4a1add78 100644 --- a/cms/server/admin/handlers/contestannouncement.py +++ b/cms/server/admin/handlers/contestannouncement.py @@ -29,17 +29,106 @@ import collections try: collections.MutableMapping -except: +except AttributeError: # Monkey-patch: Tornado 4.5.3 does not work on Python 3.11 by default collections.MutableMapping = collections.abc.MutableMapping import tornado.web -from cms.db import Contest, Announcement +from cms.db import Contest, Announcement, TrainingProgram +from cms.server.admin.handlers.utils import get_all_student_tags, parse_tags from cmscommon.datetime import make_datetime from .base import BaseHandler, require_permission +class ContestAnnouncementsHandler(BaseHandler): + """Display announcements for a contest or training program. + + Supports both contest and training_program entity types via URL pattern: + - /contest/{id}/announcements + - /training_program/{id}/announcements + + For training day contests and training programs, also passes all_student_tags + for the tagify box. + """ + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, entity_type: str, entity_id: str): + training_program = self.setup_contest_or_training_program( + entity_type, entity_id + ) + + # For training day contests, get training_program from the training day + training_day = self.contest.training_day + if training_day is not None and training_program is None: + training_program = training_day.training_program + + if training_program is not None: + self.r_params["all_student_tags"] = get_all_student_tags( + self.sql_session, training_program + ) + else: + self.r_params["all_student_tags"] = [] + + self.r_params["is_training_day"] = training_day is not None + + self.render("announcements.html", **self.r_params) + + @require_permission(BaseHandler.PERMISSION_MESSAGING) + def post(self, entity_type: str, entity_id: str): + """Handle adding/editing announcements for training programs. + + For contests, use AddAnnouncementHandler and EditAnnouncementHandler instead. + This POST method is only used for training programs which have a combined + add/edit form. + """ + if entity_type != "training_program": + # Contests use separate add/edit handlers + raise tornado.web.HTTPError(405) + + training_program = self.safe_get_item(TrainingProgram, entity_id) + managing_contest = training_program.managing_contest + + fallback_page = self.url("training_program", entity_id, "announcements") + + subject = self.get_argument("subject", "") + text = self.get_argument("text", "") + announcement_id = self.get_argument("announcement_id", None) + + # Parse visible_to_tags from comma-separated string + visible_to_tags_str = self.get_argument("visible_to_tags", "") + visible_to_tags = parse_tags(visible_to_tags_str) + + if not subject: + self.application.service.add_notification("error", "Subject is required") + self.redirect(fallback_page) + return + + if announcement_id is not None: + # Edit existing announcement + announcement = self.safe_get_item(Announcement, announcement_id) + if announcement.contest_id != managing_contest.id: + raise tornado.web.HTTPError(404) + announcement.subject = subject + announcement.visible_to_tags = visible_to_tags + # Only update text when non-empty (leave existing text otherwise) + if text: + announcement.text = text + else: + # Add new announcement + announcement = Announcement( + timestamp=make_datetime(), + subject=subject, + text=text, + contest=managing_contest, + admin=self.current_user, + visible_to_tags=visible_to_tags, + ) + self.sql_session.add(announcement) + self.try_commit() + + self.redirect(fallback_page) + + class AddAnnouncementHandler(BaseHandler): """Called to actually add an announcement @@ -50,9 +139,20 @@ def post(self, contest_id: str): subject: str = self.get_argument("subject", "") text: str = self.get_argument("text", "") + + # Parse visible_to_tags from comma-separated string + visible_to_tags_str = self.get_argument("visible_to_tags", "") + visible_to_tags = parse_tags(visible_to_tags_str) + if len(subject) > 0: - ann = Announcement(make_datetime(), subject, text, - contest=self.contest, admin=self.current_user) + ann = Announcement( + make_datetime(), + subject, + text, + contest=self.contest, + admin=self.current_user, + visible_to_tags=visible_to_tags, + ) self.sql_session.add(ann) self.try_commit() else: @@ -60,6 +160,7 @@ def post(self, contest_id: str): make_datetime(), "Subject is mandatory.", "") self.redirect(self.url("contest", contest_id, "announcements")) + class EditAnnouncementHandler(BaseHandler): """Called to edit an announcement""" @@ -74,9 +175,15 @@ def post(self, contest_id: str, ann_id: str): subject: str = self.get_argument("subject", "") text: str = self.get_argument("text", "") + + # Parse visible_to_tags from comma-separated string + visible_to_tags_str = self.get_argument("visible_to_tags", "") + visible_to_tags = parse_tags(visible_to_tags_str) + if len(subject) > 0: original_ann.subject = subject original_ann.text = text + original_ann.visible_to_tags = visible_to_tags self.try_commit() else: self.service.add_notification(make_datetime(), "Subject is mandatory.", "") @@ -90,9 +197,11 @@ class AnnouncementHandler(BaseHandler): # No page to show a single attachment. @require_permission(BaseHandler.PERMISSION_MESSAGING) - def delete(self, contest_id: str, ann_id: str): + def delete(self, entity_type: str, entity_id: str, ann_id: str): ann = self.safe_get_item(Announcement, ann_id) - self.contest = self.safe_get_item(Contest, contest_id) + self.setup_contest_or_training_program( + entity_type, entity_id, set_r_params=False + ) # Protect against URLs providing incompatible parameters. if self.contest is not ann.contest: diff --git a/cms/server/admin/handlers/contestdelayrequest.py b/cms/server/admin/handlers/contestdelayrequest.py index b9937d9198..a75b94f85f 100644 --- a/cms/server/admin/handlers/contestdelayrequest.py +++ b/cms/server/admin/handlers/contestdelayrequest.py @@ -24,7 +24,7 @@ import io import logging import re -from datetime import datetime, timedelta +from datetime import timedelta import collections try: @@ -37,21 +37,50 @@ from cms.db import Contest, DelayRequest, Participation from cms.server.contest.phase_management import compute_actual_phase -from cmscommon.datetime import make_datetime, local_to_utc, get_timezone +from cmscommon.datetime import make_datetime +from cms.server.util import check_training_day_eligibility +from cms.server.admin.handlers.utils import get_all_student_tags from .base import BaseHandler, require_permission logger = logging.getLogger(__name__) -def compute_participation_status(contest, participation, timestamp): +def get_participation_main_group(sql_session, contest, participation): + """Get the main group for a participation in a training day contest. + + Args: + sql_session: The database session + contest: The Contest object + participation: The Participation object (in the training day contest) + + Returns: + TrainingDayGroup or None: The main group if found, None otherwise + """ + training_day = contest.training_day + if training_day is None: + return None + + # Use the shared eligibility utility for consistent, case-insensitive matching + is_eligible, main_group, _ = check_training_day_eligibility( + sql_session, participation, training_day + ) + + # Return the main group if exactly one match was found (eligible) + return main_group if is_eligible else None + + +def compute_participation_status(contest, participation, timestamp, + main_group_start=None, main_group_end=None): """Compute the status class and label for a participation. - + Args: contest: The Contest object participation: The Participation object timestamp: The current timestamp - + main_group_start: Optional per-group start time for training days + main_group_end: Optional per-group end time for training days + Returns: tuple: (status_class, status_label) """ @@ -65,8 +94,10 @@ def compute_participation_status(contest, participation, timestamp): participation.starting_time, participation.delay_time, participation.extra_time, + main_group_start, + main_group_end, ) - + if participation.starting_time is None: if actual_phase == -2: status_class = "pre-contest" @@ -83,7 +114,7 @@ def compute_participation_status(contest, participation, timestamp): else: status_class = "finished" status_label = "Finished" - + return status_class, status_label @@ -96,32 +127,106 @@ def get(self, contest_id): self.contest = self.safe_get_item(Contest, contest_id) self.r_params = self.render_params() - participations = self.sql_session.query(Participation)\ .filter(Participation.contest_id == contest_id)\ .filter(not_(Participation.hidden))\ .order_by(Participation.id)\ .all() - + # Compute status for each participation participation_statuses = [] for participation in participations: + # For training day contests, check eligibility and skip ineligible students + if self.contest.training_day is not None: + is_eligible, _, _ = check_training_day_eligibility( + self.sql_session, participation, self.contest.training_day + ) + if not is_eligible: + continue # Skip ineligible students + + main_group = get_participation_main_group( + self.sql_session, self.contest, participation + ) + main_group_start = main_group.start_time if main_group else None + main_group_end = main_group.end_time if main_group else None + status_class, status_label = compute_participation_status( - self.contest, participation, self.timestamp + self.contest, participation, self.timestamp, + main_group_start, main_group_end ) - + participation_statuses.append({ 'participation': participation, 'status_class': status_class, 'status_label': status_label, + 'main_group': main_group, }) - + self.r_params["participation_statuses"] = participation_statuses - self.r_params["delay_requests"] = self.sql_session.query(DelayRequest)\ + + # Check if all participants are in stage ≥1 (finished or missed) + # This is used to show the "Archive Training" button on training day attendance pages + all_finished_or_missed = all( + item['status_class'] in ('finished', 'missed') + for item in participation_statuses + ) if participation_statuses else False + self.r_params["all_finished_or_missed"] = all_finished_or_missed + + delay_requests = self.sql_session.query(DelayRequest)\ .join(Participation)\ .filter(Participation.contest_id == contest_id)\ .order_by(DelayRequest.request_timestamp.desc())\ .all() + + # Compute warnings for delay requests where requested start is earlier than group start + delay_request_warnings = {} + for req in delay_requests: + if req.status == 'pending': + main_group = get_participation_main_group( + self.sql_session, self.contest, req.participation + ) + if main_group and main_group.start_time: + if req.requested_start_time < main_group.start_time: + delay_request_warnings[req.id] = { + 'group_name': main_group.tag_name, + 'group_start': main_group.start_time, + } + + self.r_params["delay_requests"] = delay_requests + self.r_params["delay_request_warnings"] = delay_request_warnings + + # For training day contests, compute ineligible students + # Note: We use "ineligible_training_program" instead of "training_program" to avoid + # conflicting with base.html's sidebar logic which shows training program sidebar + # when "training_program" is defined. We want to show the contest sidebar for + # training day contests. + self.r_params["ineligible_students"] = [] + self.r_params["all_student_tags"] = [] + self.r_params["ineligible_training_program"] = None + training_day = self.contest.training_day + if training_day is not None and len(training_day.groups) > 0: + main_group_tags = {g.tag_name for g in training_day.groups} + training_program = training_day.training_program + self.r_params["ineligible_training_program"] = training_program + + # Collect all unique student tags for autocomplete (using shared utility) + self.r_params["all_student_tags"] = get_all_student_tags( + self.sql_session, training_program + ) + + # Find students with 0 or >1 main group tags + ineligible = [] + for student in training_program.students: + student_tags = set(student.student_tags or []) + matching_tags = student_tags & main_group_tags + if len(matching_tags) != 1: + ineligible.append({ + 'student': student, + 'matching_tags': sorted(matching_tags), + 'reason': 'no main group' if len(matching_tags) == 0 else 'multiple main groups', + }) + self.r_params["ineligible_students"] = ineligible + self.render("delays_and_extra_times.html", **self.r_params) @@ -159,12 +264,12 @@ def process_delay_request(self, delay_request): participation = delay_request.participation contest_start = participation.contest.start requested_start = delay_request.requested_start_time - + delay_seconds = (requested_start - contest_start).total_seconds() - + if delay_seconds > 0: participation.delay_time = timedelta(seconds=delay_seconds) - + delay_request.status = 'approved' delay_request.processed_timestamp = make_datetime() delay_request.admin = self.current_user @@ -242,36 +347,45 @@ def get(self, contest_id): output = io.StringIO() writer = csv.writer(output) - + writer.writerow([ 'User', 'Username', 'Delay Time (seconds)', - 'Planned Start Time (UTC)', - 'Actual Start Time (UTC)', + 'Planned Start Time', + 'Actual Start Time', 'IP Address', 'Extra Time (seconds)', 'Status' ]) - + for participation in participations: starting_time = participation.starting_time.strftime('%Y-%m-%d %H:%M:%S') if participation.starting_time else '-' delay_seconds = int(participation.delay_time.total_seconds()) - + + main_group = get_participation_main_group( + self.sql_session, self.contest, participation + ) + main_group_start = main_group.start_time if main_group else None + main_group_end = main_group.end_time if main_group else None + if participation.delay_time.total_seconds() > 0: planned_start = self.contest.start + participation.delay_time planned_start_str = planned_start.strftime('%Y-%m-%d %H:%M:%S') + elif main_group_start: + planned_start_str = main_group_start.strftime('%Y-%m-%d %H:%M:%S') else: planned_start_str = self.contest.start.strftime('%Y-%m-%d %H:%M:%S') - + extra_seconds = int(participation.extra_time.total_seconds()) ip_addresses = participation.starting_ip_addresses if participation.starting_ip_addresses else '-' - + # Compute status for this participation _, status_label = compute_participation_status( - self.contest, participation, self.timestamp + self.contest, participation, self.timestamp, + main_group_start, main_group_end ) - + writer.writerow([ f"{participation.user.first_name} {participation.user.last_name}", participation.user.username, @@ -282,13 +396,13 @@ def get(self, contest_id): extra_seconds, status_label ]) - + start_date = self.contest.start.strftime('%Y%m%d') contest_slug = re.sub(r'[^A-Za-z0-9_-]+', '_', self.contest.name) filename = f"{start_date}_{contest_slug}_attendance.csv" - + self.set_header('Content-Type', 'text/csv') - self.set_header('Content-Disposition', + self.set_header('Content-Disposition', f'attachment; filename="{filename}"') self.write(output.getvalue()) self.finish() @@ -301,26 +415,26 @@ class RemoveAllDelaysAndExtraTimesHandler(BaseHandler): @require_permission(BaseHandler.PERMISSION_MESSAGING) def post(self, contest_id): ref = self.url("contest", contest_id, "delays_and_extra_times") - + self.contest = self.safe_get_item(Contest, contest_id) - + participations = self.sql_session.query(Participation)\ .filter(Participation.contest_id == contest_id)\ .all() - + count = 0 for participation in participations: if participation.delay_time.total_seconds() > 0 or participation.extra_time.total_seconds() > 0: participation.delay_time = timedelta() participation.extra_time = timedelta() count += 1 - + if self.try_commit(): logger.info("All delays and extra times removed for contest %s by admin %s (%d participations affected)", self.contest.name, self.current_user.name, count) - + self.redirect(ref) @@ -331,25 +445,25 @@ class EraseAllStartTimesHandler(BaseHandler): @require_permission(BaseHandler.PERMISSION_MESSAGING) def post(self, contest_id): ref = self.url("contest", contest_id, "delays_and_extra_times") - + self.contest = self.safe_get_item(Contest, contest_id) - + participations = self.sql_session.query(Participation)\ .filter(Participation.contest_id == contest_id)\ .all() - + count = 0 for participation in participations: if participation.starting_time is not None: participation.starting_time = None count += 1 - + if self.try_commit(): logger.info("All starting times erased for contest %s by admin %s (%d participations affected)", self.contest.name, self.current_user.name, count) - + self.redirect(ref) @@ -360,25 +474,25 @@ class ResetAllIPAddressesHandler(BaseHandler): @require_permission(BaseHandler.PERMISSION_MESSAGING) def post(self, contest_id): ref = self.url("contest", contest_id, "delays_and_extra_times") - + self.contest = self.safe_get_item(Contest, contest_id) - + participations = self.sql_session.query(Participation)\ .filter(Participation.contest_id == contest_id)\ .all() - + count = 0 for participation in participations: if participation.starting_ip_addresses is not None: participation.starting_ip_addresses = None count += 1 - + if self.try_commit(): logger.info("All IP addresses reset for contest %s by admin %s (%d participations affected)", self.contest.name, self.current_user.name, count) - + self.redirect(ref) @@ -395,10 +509,14 @@ def post(self, contest_id): self.contest = self.safe_get_item(Contest, contest_id) participation_id = self.get_argument("participation_id", "") - requested_start_time_str = self.get_argument("requested_start_time", "") reason = self.get_argument("reason", "").strip() - if not participation_id or not requested_start_time_str or not reason: + # Parse datetime using the built-in handler method + datetime_args = {} + self.get_datetime_with_timezone(datetime_args, "requested_start_time") + requested_start_time = datetime_args.get("requested_start_time") + + if not participation_id or not requested_start_time or not reason: self.service.add_notification( make_datetime(), "Missing fields", @@ -421,30 +539,6 @@ def post(self, contest_id): if participation.contest_id != self.contest.id: raise tornado.web.HTTPError(404) - try: - # Parse HTML5 datetime-local format: YYYY-MM-DDTHH:MM - # The time is entered in contest timezone, so convert to UTC - local_dt = datetime.strptime( - requested_start_time_str, "%Y-%m-%dT%H:%M" - ) - tz = get_timezone(None, self.contest) - requested_start_time = local_to_utc(local_dt, tz) - except Exception as e: - # Catch ValueError, TypeError, and pytz DST exceptions - # (AmbiguousTimeError, NonExistentTimeError) - logger.warning( - "Failed to parse admin-configured start time '%s' for " - "contest %s: %s", - requested_start_time_str, self.contest.name, e - ) - self.service.add_notification( - make_datetime(), - "Invalid date", - "The start time is invalid or falls during a DST transition." - ) - self.redirect(ref) - return - # contest.start is already in UTC contest_start = self.contest.start delay_seconds = (requested_start_time - contest_start).total_seconds() diff --git a/cms/server/admin/handlers/contestquestion.py b/cms/server/admin/handlers/contestquestion.py index 42583581c3..2622543c31 100644 --- a/cms/server/admin/handlers/contestquestion.py +++ b/cms/server/admin/handlers/contestquestion.py @@ -38,6 +38,7 @@ import tornado.web from cms.db import Contest, Question, Participation +from cms.server.admin.handlers.utils import get_training_day_notifications from cmscommon.datetime import make_datetime from .base import BaseHandler, require_permission @@ -48,17 +49,49 @@ class QuestionsHandler(BaseHandler): """Page to see and send messages to all the contestants. + Supports both contest and training_program entity types via URL pattern: + - /contest/{id}/questions + - /training_program/{id}/questions """ @require_permission(BaseHandler.AUTHENTICATED) - def get(self, contest_id): - self.contest = self.safe_get_item(Contest, contest_id) + def get(self, entity_type: str, entity_id: str): + training_program = self.setup_contest_or_training_program( + entity_type, entity_id + ) - self.r_params = self.render_params() self.r_params["questions"] = self.sql_session.query(Question)\ .join(Participation)\ - .filter(Participation.contest_id == contest_id)\ + .filter(Participation.contest_id == self.contest.id)\ .order_by(Question.question_timestamp.desc())\ .order_by(Question.id).all() + + # Build training days with unanswered questions from notification data + # (only for training programs) + training_days_with_unanswered: list[dict] = [] + if training_program is not None: + td_notifications = self.r_params.get("training_day_notifications", {}) + # If not already computed, compute notifications for each training day + if not td_notifications: + for td in training_program.training_days: + if td.contest is None: + continue + td_notifications[td.id] = get_training_day_notifications( + self.sql_session, td + ) + for td in training_program.training_days: + if td.contest is None: + continue + td_notif = td_notifications.get(td.id, {}) + unanswered_count = td_notif.get("unanswered_questions", 0) + if unanswered_count > 0: + training_days_with_unanswered.append({ + "contest_id": td.contest_id, + "name": td.contest.name, + "unanswered_count": unanswered_count, + }) + self.r_params["training_days_with_unanswered_questions"] = \ + training_days_with_unanswered + self.render("questions.html", **self.r_params) @@ -75,10 +108,6 @@ def process_question(self, question: Question): @require_permission(BaseHandler.PERMISSION_MESSAGING) def post(self, contest_id, question_id): user_id = self.get_argument("user_id", None) - if user_id is not None: - ref = self.url("contest", contest_id, "user", user_id, "edit") - else: - ref = self.url("contest", contest_id, "questions") question = self.safe_get_item(Question, question_id) self.contest = self.safe_get_item(Contest, contest_id) @@ -87,6 +116,18 @@ def post(self, contest_id, question_id): if self.contest is not question.participation.contest: raise tornado.web.HTTPError(404) + # Determine redirect URL after processing + if user_id is not None: + ref = self.url("contest", contest_id, "user", user_id, "edit") + else: + # If this is a managing contest for a training program, + # redirect back to the training program questions page + tp = getattr(self.contest, "training_program", None) + if tp is not None: + ref = self.url("training_program", tp.id, "questions") + else: + ref = self.url("contest", contest_id, "questions") + self.process_question(question) self.redirect(ref) diff --git a/cms/server/admin/handlers/contestranking.py b/cms/server/admin/handlers/contestranking.py index d7c0ab3a33..b7bcc84ae1 100644 --- a/cms/server/admin/handlers/contestranking.py +++ b/cms/server/admin/handlers/contestranking.py @@ -36,33 +36,47 @@ from sqlalchemy import and_, or_, func from sqlalchemy.orm import joinedload -from cms.db import Contest, Participation, ScoreHistory, \ +from cms.db import Contest, Participation, ScoreHistory, Student, \ Submission, SubmissionResult, Task from cms.grading.scorecache import get_cached_score_entry, ensure_valid_history +from cms.server.util import can_access_task, get_student_for_user_in_program +from cms.server.admin.handlers.utils import ( + get_all_student_tags, + build_task_data_for_detail_view, +) from .base import BaseHandler, require_permission logger = logging.getLogger(__name__) TaskStatus = namedtuple( - "TaskStatus", ["score", "partial", "has_submissions", "has_opened"] + "TaskStatus", ["score", "partial", "has_submissions", "has_opened", "can_access"] ) -class RankingHandler(BaseHandler): - """Shows the ranking for a contest. +class RankingCommonMixin: + """Mixin for handlers that need ranking logic (calculation and export).""" - """ - @require_permission(BaseHandler.AUTHENTICATED) - def get(self, contest_id, format="online"): + def _load_contest_data(self, contest_id: str) -> Contest: + """Load a contest with all necessary data for ranking. + + This method loads the contest with tasks, participations, and related + entities needed for ranking calculation and display. + + Args: + contest_id: The ID of the contest to load. + + Returns: + The fully loaded Contest object. + """ # This validates the contest id. self.safe_get_item(Contest, contest_id) # Load contest with tasks, participations, and statement views. # We use the score cache to get score and has_submissions. # partial is computed at render time via SQL aggregation for correctness. - self.contest: Contest = ( + contest: Contest = ( self.sql_session.query(Contest) .filter(Contest.id == contest_id) .options(joinedload("tasks")) @@ -73,15 +87,39 @@ def get(self, contest_id, format="online"): .options(joinedload("participations.statement_views")) .first() ) + return contest + + def _calculate_scores(self, contest, can_access_by_pt): + """Calculate scores for all participations in the contest. + This method uses the efficient approach from RankingHandler: + 1. SQL aggregation for partial flags. + 2. Score cache for scores and submission existence. + 3. Two-phase commit to handle cache rebuilds safely. + + contest: The contest object (with participations and tasks loaded). + can_access_by_pt: A dict (participation_id, task_id) -> bool indicating + if a participant can access a task. + + Returns: + show_teams (bool): Whether any participation has a team. + """ # SQL aggregation to compute t_partial for all participation/task pairs. # t_partial is True when there's an official submission that is not yet scored. # has_submissions is retrieved from the cache instead. # We join with Participation and filter by contest_id instead of using # an IN clause with participation IDs for better query plan efficiency. + # For training days, participations are in the managing contest, not the + # training day's contest, so we filter by managing_contest_id. + training_day = contest.training_day + if training_day is not None: + participation_contest_id = training_day.training_program.managing_contest_id + else: + participation_contest_id = contest.id + partial_flags_query = ( self.sql_session.query( - Submission.participation_id, + Participation.user_id, Submission.task_id, func.bool_or( and_( @@ -93,9 +131,9 @@ def get(self, contest_id, format="online"): SubmissionResult.public_score.is_(None), SubmissionResult.public_score_details.is_(None), SubmissionResult.ranking_score_details.is_(None), - ) + ), ) - ).label('t_partial') + ).label("t_partial"), ) .join(Participation, Submission.participation_id == Participation.id) .join(Task, Submission.task_id == Task.id) @@ -103,23 +141,21 @@ def get(self, contest_id, format="online"): SubmissionResult, and_( SubmissionResult.submission_id == Submission.id, - SubmissionResult.dataset_id == Task.active_dataset_id - ) + SubmissionResult.dataset_id == Task.active_dataset_id, + ), ) - .filter(Participation.contest_id == contest_id) + .filter(Participation.contest_id == participation_contest_id) .filter(Submission.official.is_(True)) - .group_by(Submission.participation_id, Submission.task_id) + .group_by(Participation.user_id, Submission.task_id) ) - # Build lookup dict: (participation_id, task_id) -> t_partial - partial_by_pt = {} + # Build lookup dict: (user_id, task_id) -> t_partial + partial_by_user_task = {} for row in partial_flags_query.all(): - partial_by_pt[(row.participation_id, row.task_id)] = ( - row.t_partial or False - ) + partial_by_user_task[(row.user_id, row.task_id)] = row.t_partial or False statement_views_set = set() - for p in self.contest.participations: + for p in contest.participations: for sv in p.statement_views: statement_views_set.add((sv.participation_id, sv.task_id)) @@ -134,32 +170,34 @@ def get(self, contest_id, format="online"): # which would clear any dynamically added attributes like task_statuses. show_teams = False participation_data = {} # p.id -> (task_statuses, total_score) - for p in self.contest.participations: + for p in contest.participations: show_teams = show_teams or p.team_id task_statuses = [] total_score = 0.0 partial = False - for task in self.contest.tasks: + for task in contest.get_tasks(): # Get the cache entry with score and has_submissions cache_entry = get_cached_score_entry(self.sql_session, p, task) t_score = round(cache_entry.score, task.score_precision) has_submissions = cache_entry.has_submissions # Get t_partial from SQL aggregation (not from cache) - t_partial = partial_by_pt.get((p.id, task.id), False) + t_partial = partial_by_user_task.get((p.user_id, task.id), False) has_opened = (p.id, task.id) in statement_views_set + can_access = can_access_by_pt.get((p.id, task.id), True) task_statuses.append( TaskStatus( score=t_score, partial=t_partial, has_submissions=has_submissions, has_opened=has_opened, + can_access=can_access, ) ) total_score += t_score partial = partial or t_partial - total_score = round(total_score, self.contest.score_precision) + total_score = round(total_score, contest.score_precision) participation_data[p.id] = (task_statuses, (total_score, partial)) # Commit to persist any cache rebuilds and release advisory locks. @@ -168,83 +206,279 @@ def get(self, contest_id, format="online"): # Now attach transient attributes after commit (so they aren't cleared # by SQLAlchemy's expire-on-commit behavior). - for p in self.contest.participations: + for p in contest.participations: p.task_statuses, p.total_score = participation_data[p.id] - self.r_params = self.render_params() - self.r_params["show_teams"] = show_teams - - date_str = self.contest.start.strftime("%Y%m%d") - contest_name = self.contest.name.replace(" ", "_") + return show_teams - if format == "txt": - filename = f"{date_str}_{contest_name}_ranking.txt" - self.set_header("Content-Type", "text/plain") - self.set_header("Content-Disposition", - f"attachment; filename=\"{filename}\"") - self.render("ranking.txt", **self.r_params) - elif format == "csv": - filename = f"{date_str}_{contest_name}_ranking.csv" - self.set_header("Content-Type", "text/csv") - self.set_header("Content-Disposition", - f"attachment; filename=\"{filename}\"") - - output = io.StringIO() # untested - writer = csv.writer(output) - - include_partial = True + @staticmethod + def _status_indicator(status: TaskStatus) -> str: + star = "*" if status.partial else "" + if not status.can_access: + return "N/A" + if not status.has_submissions: + return "X" if not status.has_opened else "-" + if not status.has_opened: + return "!" + star + return star - contest: Contest = self.r_params["contest"] + def _write_csv( + self, + contest, + participations, + tasks, + student_tags_by_participation, + show_teams, + include_partial=True, + task_archive_progress_by_participation=None, + ): + output = io.StringIO() + writer = csv.writer(output) + + # Build header row + row = ["Username", "User"] + if student_tags_by_participation: + row.append("Tags") + if task_archive_progress_by_participation: + row.append("Task Archive Progress") + if show_teams: + row.append("Team") + for task in tasks: + row.append(task.name) + if include_partial: + row.append("P") - row = ["Username", "User"] + row.append("Global") + if include_partial: + row.append("P") + + writer.writerow(row) + + # Build task index lookup for task_statuses. + # We assume p.task_statuses follows the order of contest.get_tasks(). + all_tasks = list(contest.get_tasks()) + task_index = {task.id: i for i, task in enumerate(all_tasks)} + + for p in participations: + row = [p.user.username, "%s %s" % (p.user.first_name, p.user.last_name)] + if student_tags_by_participation: + tags = student_tags_by_participation.get(p.id, []) + row.append(", ".join(tags)) + if task_archive_progress_by_participation: + progress = task_archive_progress_by_participation.get(p.id, {}) + row.append( + "%.1f%% (%.1f/%.1f)" + % ( + progress.get("percentage", 0), + progress.get("total_score", 0), + progress.get("max_score", 0), + ) + ) if show_teams: - row.append("Team") - for task in contest.tasks: - row.append(task.name) - if include_partial: - row.append("P") + row.append(p.team.name if p.team else "") + + # Calculate total score for exported tasks only + total_score = 0.0 + partial = False + for task in tasks: + idx = task_index.get(task.id) + if idx is not None and idx < len(p.task_statuses): + status = p.task_statuses[idx] + row.append(status.score) + if include_partial: + row.append(self._status_indicator(status)) + total_score += status.score + partial = partial or status.partial + else: + # Should not happen if data is consistent + row.append(0) + if include_partial: + row.append("") - row.append("Global") + total_score = round(total_score, contest.score_precision) + row.append(total_score) if include_partial: - row.append("P") + row.append("*" if partial else "") writer.writerow(row) - for p in sorted(contest.participations, - key=lambda p: p.total_score, reverse=True): + return output.getvalue() + + +class RankingHandler(RankingCommonMixin, BaseHandler): + """Shows the ranking for a contest.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, contest_id, format="online"): + self.contest = self._load_contest_data(contest_id) + + # Build lookup for task accessibility based on visibility tags. + training_day = self.contest.training_day + can_access_by_pt = {} # (participation_id, task_id) -> bool + for p in self.contest.participations: + for task in self.contest.get_tasks(): + can_access_by_pt[(p.id, task.id)] = can_access_task( + self.sql_session, task, p, training_day + ) + + show_teams = self._calculate_scores(self.contest, can_access_by_pt) + + self.r_params = self.render_params() + self.r_params["show_teams"] = show_teams + self.r_params["task_archive_progress_by_participation"] = ( + None # Only for training programs + ) + + # Check if this is a training day with main groups + training_day = self.contest.training_day + main_groups_data = [] + student_tags_by_participation = {} # participation_id -> list of tags + + # For training days, always build student tags lookup (batch query) + if training_day: + training_program = training_day.training_program + # Batch query: fetch all students for this training program's participations + participation_user_ids = {p.user_id for p in self.contest.participations} + students = ( + self.sql_session.query(Student, Participation.user_id) + .join(Participation, Student.participation_id == Participation.id) + .filter(Student.training_program_id == training_program.id) + .filter(Participation.user_id.in_(participation_user_ids)) + .all() + ) + student_by_user_id = {uid: student for student, uid in students} + + for p in self.contest.participations: + student = student_by_user_id.get(p.user_id) + if student: + student_tags_by_participation[p.id] = student.student_tags or [] + else: + student_tags_by_participation[p.id] = [] + + if training_day and training_day.groups: + # Get main group tag names + main_group_tags = {g.tag_name for g in training_day.groups} + + # Organize participations by main group + # A participation belongs to a main group if it has that tag + participations_by_group = {mg: [] for mg in sorted(main_group_tags)} + tasks_by_group = {mg: [] for mg in sorted(main_group_tags)} + + for p in self.contest.participations: if p.hidden: continue + p_tags = set(student_tags_by_participation.get(p.id, [])) + p_main_groups = p_tags & main_group_tags + for mg in p_main_groups: + participations_by_group[mg].append(p) + + # Build task index lookup for computing group-specific scores + all_tasks = list(self.contest.get_tasks()) + task_index = {task.id: i for i, task in enumerate(all_tasks)} + + # For each group, determine which tasks are accessible to at least one member + for mg in sorted(main_group_tags): + group_participations = participations_by_group[mg] + if not group_participations: + continue - row = [p.user.username, - "%s %s" % (p.user.first_name, p.user.last_name)] - if show_teams: - row.append(p.team.name if p.team else "") - assert len(contest.tasks) == len(p.task_statuses) - for status in p.task_statuses: - row.append(status.score) - if include_partial: - row.append(self._status_indicator(status)) + # Find tasks accessible to at least one member of this group + accessible_tasks = [] + for task in self.contest.get_tasks(): + for p in group_participations: + if can_access_by_pt.get((p.id, task.id), True): + accessible_tasks.append(task) + break + + tasks_by_group[mg] = accessible_tasks - total_score, partial = p.total_score - row.append(total_score) - if include_partial: - row.append("*" if partial else "") + # Sort participations by group-specific total score (sum of accessible tasks only) + # Capture accessible_tasks in closure to avoid late binding issues + def get_group_score(p, tasks=accessible_tasks): + return sum(p.task_statuses[task_index[t.id]].score for t in tasks) + + sorted_participations = sorted( + group_participations, key=get_group_score, reverse=True + ) - writer.writerow(row) + main_groups_data.append( + { + "name": mg, + "participations": sorted_participations, + "tasks": accessible_tasks, + } + ) + + # Get all student tags for display + self.r_params["all_student_tags"] = get_all_student_tags( + self.sql_session, training_program + ) + + self.r_params["main_groups_data"] = main_groups_data + self.r_params["student_tags_by_participation"] = student_tags_by_participation + self.r_params["training_day"] = training_day + + date_str = self.contest.start.strftime("%Y%m%d") + contest_name = self.contest.name.replace(" ", "_") + + # Handle main_group filter for exports + main_group_filter = self.get_argument("main_group", None) + + # If main_group filter is specified for export, find the group data + export_group_data = None + if main_group_filter and main_groups_data: + for gd in main_groups_data: + if gd["name"] == main_group_filter: + export_group_data = gd + break + + if format == "txt": + if export_group_data: + group_slug = main_group_filter.replace(" ", "_").lower() + filename = f"{date_str}_{contest_name}_ranking_{group_slug}.txt" + else: + filename = f"{date_str}_{contest_name}_ranking.txt" + self.set_header("Content-Type", "text/plain") + self.set_header("Content-Disposition", f'attachment; filename="{filename}"') + self.render("ranking.txt", **self.r_params) + elif format == "csv": + if export_group_data: + group_slug = main_group_filter.replace(" ", "_").lower() + filename = f"{date_str}_{contest_name}_ranking_{group_slug}.csv" + else: + filename = f"{date_str}_{contest_name}_ranking.csv" + self.set_header("Content-Type", "text/csv") + self.set_header("Content-Disposition", f'attachment; filename="{filename}"') - self.finish(output.getvalue()) + contest: Contest = self.r_params["contest"] + + # Determine which participations and tasks to export + if export_group_data: + export_participations = export_group_data["participations"] + export_tasks = export_group_data["tasks"] + else: + export_participations = sorted( + [p for p in contest.participations if not p.hidden], + key=lambda p: p.total_score, + reverse=True, + ) + export_tasks = list(contest.get_tasks()) + + csv_content = self._write_csv( + contest, + export_participations, + export_tasks, + student_tags_by_participation, + show_teams, + include_partial=True, + task_archive_progress_by_participation=self.r_params.get( + "task_archive_progress_by_participation" + ), + ) + self.finish(csv_content) else: self.render("ranking.html", **self.r_params) - @staticmethod - def _status_indicator(status: TaskStatus) -> str: - star = "*" if status.partial else "" - if not status.has_submissions: - return "X" if not status.has_opened else "-" - if not status.has_opened: - return "!" + star - return star - class ScoreHistoryHandler(BaseHandler): """Returns the score history for a contest as JSON. @@ -258,6 +492,9 @@ class ScoreHistoryHandler(BaseHandler): By default, excludes hidden participations to match ranking page behavior. Use ?include_hidden=1 to include hidden participations. + For training days with main groups, use ?main_group_user_ids=id1,id2,... + to filter history to only include users from a specific main group. + Before returning history data, this handler checks for any cache entries with history_valid=False and rebuilds their history to ensure correctness. @@ -268,6 +505,18 @@ def get(self, contest_id): self.safe_get_item(Contest, contest_id) include_hidden = self.get_argument("include_hidden", "0") == "1" + main_group_user_ids_param = self.get_argument("main_group_user_ids", None) + + main_group_user_ids = None + if main_group_user_ids_param: + try: + main_group_user_ids = set( + int(uid) for uid in main_group_user_ids_param.split(",") if uid + ) + except ValueError as err: + raise tornado.web.HTTPError( + 400, "Invalid main_group_user_ids parameter" + ) from err # Ensure all score history for the contest is valid before querying if ensure_valid_history(self.sql_session, int(contest_id)): @@ -283,6 +532,9 @@ def get(self, contest_id): if not include_hidden: query = query.filter(Participation.hidden.is_(False)) + if main_group_user_ids is not None: + query = query.filter(Participation.user_id.in_(main_group_user_ids)) + history = query.order_by(ScoreHistory.timestamp).all() result = [ @@ -307,6 +559,9 @@ class ParticipationDetailHandler(BaseHandler): It includes global and per-task score/rank charts, a navigator table, and a submission table for each task. + For training days with main groups, the ranking is computed relative to + the user's main group only, not all participants. + """ @require_permission(BaseHandler.AUTHENTICATED) def get(self, contest_id, user_id): @@ -334,6 +589,45 @@ def get(self, contest_id, user_id): visible_participations = [ p for p in self.contest.participations if not p.hidden ] + + training_day = self.contest.training_day + main_group_user_ids = None + if training_day and training_day.groups: + training_program = training_day.training_program + main_group_tags = {g.tag_name for g in training_day.groups} + + user_student = get_student_for_user_in_program( + self.sql_session, training_program, user_id + ) + if user_student: + user_tags = set(user_student.student_tags or []) + user_main_groups = user_tags & main_group_tags + if user_main_groups: + # Use deterministic selection (sorted first) instead of arbitrary + user_main_group = sorted(user_main_groups)[0] + + # Batch query: fetch all Student rows for visible participations + visible_user_ids = {p.user_id for p in visible_participations} + students = ( + self.sql_session.query(Student, Participation.user_id) + .join(Participation, Student.participation_id == Participation.id) + .filter(Student.training_program_id == training_program.id) + .filter(Participation.user_id.in_(visible_user_ids)) + .all() + ) + + # Build main_group_user_ids from batch results + main_group_user_ids = set() + for student, uid in students: + p_tags = set(student.student_tags or []) + if user_main_group in p_tags: + main_group_user_ids.add(uid) + + if main_group_user_ids is not None: + visible_participations = [ + p for p in visible_participations if p.user_id in main_group_user_ids + ] + user_count = len(visible_participations) users_data = {} @@ -345,28 +639,11 @@ def get(self, contest_id, user_id): tasks_data = {} total_max_score = 0.0 - for task in self.contest.tasks: - max_score = 100.0 - extra_headers = [] - if task.active_dataset: - try: - score_type = task.active_dataset.score_type_object - max_score = score_type.max_score - extra_headers = score_type.ranking_headers - except (KeyError, TypeError, AttributeError) as e: - logger.warning( - "Failed to get score type for task %s: %s", task.id, e - ) - tasks_data[str(task.id)] = { - "key": str(task.id), - "name": task.title, - "short_name": task.name, - "contest": str(self.contest.id), - "max_score": max_score, - "score_precision": task.score_precision, - "extra_headers": extra_headers, - } - total_max_score += max_score + contest_key = str(self.contest.id) + for task in self.contest.get_tasks(): + task_data = build_task_data_for_detail_view(task, contest_key) + tasks_data[str(task.id)] = task_data + total_max_score += task_data["max_score"] contest_data = { "key": str(self.contest.id), @@ -384,9 +661,12 @@ def get(self, contest_id, user_id): self.r_params["users_data"] = users_data self.r_params["tasks_data"] = tasks_data self.r_params["contest_data"] = contest_data - self.r_params["history_url"] = self.url( - "contest", contest_id, "ranking", "history" - ) + history_url = self.url("contest", contest_id, "ranking", "history") + if main_group_user_ids is not None: + history_url += "?main_group_user_ids=" + ",".join( + str(uid) for uid in main_group_user_ids + ) + self.r_params["history_url"] = history_url self.r_params["submissions_url"] = self.url( "contest", contest_id, "user", user_id, "submissions" ) @@ -424,7 +704,7 @@ def get(self, contest_id, user_id): ) dataset_by_task_id = { - task.id: task.active_dataset for task in self.contest.tasks + task.id: task.active_dataset for task in self.contest.get_tasks() } result = [] diff --git a/cms/server/admin/handlers/contestsubmission.py b/cms/server/admin/handlers/contestsubmission.py index 3d373c4839..b06e2d1d00 100644 --- a/cms/server/admin/handlers/contestsubmission.py +++ b/cms/server/admin/handlers/contestsubmission.py @@ -31,19 +31,40 @@ class ContestSubmissionsHandler(BaseHandler): - """Shows all submissions for this contest. + """Shows all submissions for this contest or training program. + Supports both contest and training_program entity types via URL pattern: + - /contest/{id}/submissions + - /training_program/{id}/submissions """ @require_permission(BaseHandler.AUTHENTICATED) - def get(self, contest_id): - contest = self.safe_get_item(Contest, contest_id) - self.contest = contest + def get(self, entity_type: str, entity_id: str): + training_program = self.setup_contest_or_training_program( + entity_type, entity_id + ) + contest = self.contest - query = self.sql_session.query(Submission).join(Task)\ - .filter(Task.contest == contest) + # Determine if this is a training program managing contest + is_training_program = training_program is not None + + # For training day contests, only show submissions made via that training day + # (submissions now have training_day_id set when submitted via a training day) + if contest.training_day is not None: + query = self.sql_session.query(Submission)\ + .filter(Submission.training_day_id == contest.training_day.id) + else: + # For regular contests and training program managing contests, + # show all submissions for tasks in this contest + query = self.sql_session.query(Submission).join(Task)\ + .filter(Task.contest == contest) page = int(self.get_query_argument("page", 0)) self.render_params_for_submissions(query, page) + # Pass flag and training_program to template for training programs + self.r_params["is_training_program"] = is_training_program + if is_training_program: + self.r_params["training_program"] = training_program + self.render("contest_submissions.html", **self.r_params) @@ -56,8 +77,15 @@ def get(self, contest_id): contest = self.safe_get_item(Contest, contest_id) self.contest = contest - query = self.sql_session.query(UserTest).join(Task)\ - .filter(Task.contest == contest) + # For training day contests, tasks have training_day_id set + # but contest_id points to the managing contest. + # We need to filter by training_day_id for training day contests. + if contest.training_day is not None: + query = self.sql_session.query(UserTest).join(Task)\ + .filter(Task.training_day_id == contest.training_day.id) + else: + query = self.sql_session.query(UserTest).join(Task)\ + .filter(Task.contest == contest) page = int(self.get_query_argument("page", 0)) self.render_params_for_user_tests(query, page) diff --git a/cms/server/admin/handlers/contesttask.py b/cms/server/admin/handlers/contesttask.py index 516b502bd9..1df89bea91 100644 --- a/cms/server/admin/handlers/contesttask.py +++ b/cms/server/admin/handlers/contesttask.py @@ -26,6 +26,7 @@ """ from cms.db import Contest, Task +from cms.server.admin.handlers.utils import get_all_student_tags, deduplicate_preserving_order from cmscommon.datetime import make_datetime from .base import BaseHandler, require_permission @@ -33,6 +34,7 @@ class ContestTasksHandler(BaseHandler): REMOVE_FROM_CONTEST = "Remove from contest" + REMOVE_FROM_TRAINING_DAY = "Remove from training day" MOVE_UP = "up by 1" MOVE_DOWN = "down by 1" MOVE_TOP = "to the top" @@ -44,10 +46,46 @@ def get(self, contest_id): self.r_params = self.render_params() self.r_params["contest"] = self.contest - self.r_params["unassigned_tasks"] = \ - self.sql_session.query(Task)\ + + # Check if this contest is a training day + training_day = self.contest.training_day + self.r_params["is_training_day"] = training_day is not None + + if training_day is not None: + # 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 + + # 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( + self.sql_session, training_program + ) + else: + # For regular contests, show all unassigned tasks + self.r_params["unassigned_tasks"] = \ + self.sql_session.query(Task)\ + .filter(Task.contest_id.is_(None))\ + .filter(Task.training_day_id.is_(None))\ + .all() self.render("contest_tasks.html", **self.r_params) @require_permission(BaseHandler.PERMISSION_ALL) @@ -55,17 +93,20 @@ def post(self, contest_id): fallback_page = self.url("contest", contest_id, "tasks") self.contest = self.safe_get_item(Contest, contest_id) + training_day = self.contest.training_day try: task_id: str = self.get_argument("task_id") operation: str = self.get_argument("operation") - assert operation in ( + valid_operations = [ self.REMOVE_FROM_CONTEST, + self.REMOVE_FROM_TRAINING_DAY, self.MOVE_UP, self.MOVE_DOWN, self.MOVE_TOP, self.MOVE_BOTTOM - ), "Please select a valid operation" + ] + assert operation in valid_operations, "Please select a valid operation" except Exception as error: self.service.add_notification( make_datetime(), "Invalid field(s)", repr(error)) @@ -73,76 +114,108 @@ def post(self, contest_id): return task = self.safe_get_item(Task, task_id) - task2 = None - - # Save the current task_num (position in the contest). - task_num = task.num + num_key = "training_day_num" if training_day is not None else "num" + rel_key = "training_day" if training_day is not None else "contest" + scope_filter = ( + Task.training_day == training_day + if training_day is not None + else Task.contest == self.contest + ) + scope_name = "training day" if training_day is not None else "contest" + scope_id = training_day.id if training_day is not None else self.contest.id + task_scope_id = ( + task.training_day_id if training_day is not None else task.contest_id + ) + if task_scope_id != scope_id: + self.service.add_notification( + make_datetime(), + "Invalid task", + f"Task does not belong to this {scope_name}", + ) + self.redirect(fallback_page) + return - if operation == self.REMOVE_FROM_CONTEST: - # Unassign the task to the contest. - task.contest = None - task.num = None # not strictly necessary + task_num = getattr(task, num_key) + num_field = getattr(Task, num_key) + task2 = None + if operation in (self.REMOVE_FROM_CONTEST, self.REMOVE_FROM_TRAINING_DAY): + setattr(task, rel_key, None) + setattr(task, num_key, None) self.sql_session.flush() - # Decrease by 1 the num of every subsequent task. - for t in self.sql_session.query(Task)\ - .filter(Task.contest == self.contest)\ - .filter(Task.num > task_num)\ - .order_by(Task.num)\ - .all(): - t.num -= 1 + for t in ( + self.sql_session.query(Task) + .filter(scope_filter) + .filter(num_field > task_num) + .order_by(num_field) + .all() + ): + setattr(t, num_key, getattr(t, num_key) - 1) self.sql_session.flush() elif operation == self.MOVE_UP: - task2 = self.sql_session.query(Task)\ - .filter(Task.contest == self.contest)\ - .filter(Task.num == task.num - 1)\ - .first() + task2 = ( + self.sql_session.query(Task) + .filter(scope_filter) + .filter(num_field == task_num - 1) + .first() + ) elif operation == self.MOVE_DOWN: - task2 = self.sql_session.query(Task)\ - .filter(Task.contest == self.contest)\ - .filter(Task.num == task.num + 1)\ - .first() + task2 = ( + self.sql_session.query(Task) + .filter(scope_filter) + .filter(num_field == task_num + 1) + .first() + ) elif operation == self.MOVE_TOP: - task.num = None + setattr(task, num_key, None) self.sql_session.flush() - # Increase by 1 the num of every previous task. - for t in self.sql_session.query(Task)\ - .filter(Task.contest == self.contest)\ - .filter(Task.num < task_num)\ - .order_by(Task.num.desc())\ - .all(): - t.num += 1 + for t in ( + self.sql_session.query(Task) + .filter(scope_filter) + .filter(num_field < task_num) + .order_by(num_field.desc()) + .all() + ): + setattr(t, num_key, getattr(t, num_key) + 1) self.sql_session.flush() - task.num = 0 + setattr(task, num_key, 0) elif operation == self.MOVE_BOTTOM: - task.num = None + new_index = ( + self.sql_session.query(Task) + .filter(scope_filter) + .filter(Task.id != task.id) + .count() + ) + + setattr(task, num_key, None) self.sql_session.flush() - # Decrease by 1 the num of every subsequent task. - for t in self.sql_session.query(Task)\ - .filter(Task.contest == self.contest)\ - .filter(Task.num > task_num)\ - .order_by(Task.num)\ - .all(): - t.num -= 1 + for t in ( + self.sql_session.query(Task) + .filter(scope_filter) + .filter(num_field > task_num) + .order_by(num_field) + .all() + ): + setattr(t, num_key, getattr(t, num_key) - 1) self.sql_session.flush() - self.sql_session.flush() - task.num = len(self.contest.tasks) - 1 + setattr(task, num_key, new_index) - # Swap task.num and task2.num, if needed if task2 is not None: - tmp_a, tmp_b = task.num, task2.num - task.num, task2.num = None, None + tmp_a, tmp_b = getattr(task, num_key), getattr(task2, num_key) + setattr(task, num_key, None) + setattr(task2, num_key, None) self.sql_session.flush() - task.num, task2.num = tmp_b, tmp_a + setattr(task, num_key, tmp_b) + setattr(task2, num_key, tmp_a) if self.try_commit(): # Create the user on RWS. @@ -158,6 +231,7 @@ def post(self, contest_id): fallback_page = self.url("contest", contest_id, "tasks") self.contest = self.safe_get_item(Contest, contest_id) + training_day = self.contest.training_day try: task_id: str = self.get_argument("task_id") @@ -171,9 +245,25 @@ def post(self, contest_id): task = self.safe_get_item(Task, task_id) - # Assign the task to the contest. - task.num = len(self.contest.tasks) - task.contest = self.contest + 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. + task.training_day_num = len(training_day.tasks) + task.training_day = training_day + else: + # Assign the task to the contest. + task.num = len(self.contest.tasks) + task.contest = self.contest if self.try_commit(): # Create the user on RWS. @@ -181,3 +271,77 @@ def post(self, contest_id): # Maybe they'll want to do this again (for another task) self.redirect(fallback_page) + + +class TaskVisibilityHandler(BaseHandler): + """Handler for updating task visibility tags via AJAX.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, contest_id, task_id): + self.contest = self.safe_get_item(Contest, contest_id) + task = self.safe_get_item(Task, task_id) + + # Verify this contest is a training day + training_day = self.contest.training_day + if training_day is None: + self.set_status(400) + self.write({"error": "This contest is not a training day"}) + return + + # Verify the task belongs to this training day + if task.training_day_id != training_day.id: + self.set_status(400) + self.write({"error": "Task does not belong to this training day"}) + return + + # Capture original tags before modifying to return correct state on error + original_tags = task.visible_to_tags or [] + + try: + visible_to_tags_str = self.get_argument("visible_to_tags", "") + incoming_tags = [ + tag.strip() for tag in visible_to_tags_str.split(",") if tag.strip() + ] + + # Get allowed tags from training program + training_program = training_day.training_program + allowed_tags = set(get_all_student_tags( + self.sql_session, training_program + )) + + # Validate and filter tags against allowed set + invalid_tags = [tag for tag in incoming_tags if tag not in allowed_tags] + valid_tags = [tag for tag in incoming_tags if tag in allowed_tags] + + # Return error if there are invalid tags + if invalid_tags: + self.set_status(400) + self.write( + { + "error": f"Invalid tags: {', '.join(invalid_tags)}", + "tags": task.visible_to_tags or [], + "invalid_tags": invalid_tags, + } + ) + return + + # Remove duplicates while preserving order + unique_tags = deduplicate_preserving_order(valid_tags) + + task.visible_to_tags = unique_tags + + if self.try_commit(): + response_data = { + "success": True, + "tags": unique_tags, + } + self.write(response_data) + else: + self.set_status(500) + self.write( + {"error": "Failed to save", "tags": original_tags} + ) + + except (ValueError, KeyError) as error: + self.set_status(400) + self.write({"error": str(error), "tags": original_tags}) diff --git a/cms/server/admin/handlers/contestuser.py b/cms/server/admin/handlers/contestuser.py index c27fa7f44b..0e184083eb 100644 --- a/cms/server/admin/handlers/contestuser.py +++ b/cms/server/admin/handlers/contestuser.py @@ -42,7 +42,9 @@ from sqlalchemy import and_, exists -from cms.db import Contest, Message, Participation, Submission, User, Team +from cms.db import Contest, Message, Participation, Submission, User, Team, TrainingDay +from cms.db.training_day import get_managing_participation +from cms.server.admin.handlers.utils import parse_usernames_from_file from cmscommon.crypto import validate_password_strength from cmscommon.datetime import make_datetime from .base import BaseHandler, require_permission @@ -192,7 +194,7 @@ def post(self, contest_id): file_data = self.request.files["users_file"][0] file_content = file_data["body"].decode("utf-8") - usernames = file_content.split() + usernames = parse_usernames_from_file(file_content) if not usernames: raise ValueError("File is empty or contains no usernames") @@ -201,10 +203,6 @@ def post(self, contest_id): users_added = 0 for username in usernames: - username = username.strip() - if not username: - continue - user = self.sql_session.query(User).filter( User.username == username).first() @@ -284,8 +282,21 @@ def get(self, contest_id, user_id): if participation is None: raise tornado.web.HTTPError(404) - submission_query = self.sql_session.query(Submission)\ - .filter(Submission.participation == participation) + training_day: TrainingDay | None = self.contest.training_day + if training_day is not None: + managing_participation = get_managing_participation( + self.sql_session, training_day, participation.user) + if managing_participation is not None: + submission_query = self.sql_session.query(Submission)\ + .filter(Submission.participation == managing_participation)\ + .filter(Submission.training_day_id == training_day.id) + else: + submission_query = self.sql_session.query(Submission)\ + .filter(False) + else: + submission_query = self.sql_session.query(Submission)\ + .filter(Submission.participation == participation) + page = int(self.get_query_argument("page", 0)) self.render_params_for_submissions(submission_query, page) diff --git a/cms/server/admin/handlers/excel.py b/cms/server/admin/handlers/excel.py new file mode 100644 index 0000000000..92c3bd7d5d --- /dev/null +++ b/cms/server/admin/handlers/excel.py @@ -0,0 +1,394 @@ +#!/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 . + +"""Excel export utilities and handlers for Training Programs. + +This module contains Excel formatting utilities and export handlers for +attendance and combined ranking data. + +""" + +import io +import re +from typing import Any + +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, PatternFill, Border, Side +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.worksheet import Worksheet + +from cms.db import TrainingProgram, Student +from .base import BaseHandler, require_permission +from .training_analytics import ( + TrainingProgramFilterMixin, + get_attendance_view_data, + get_ranking_view_data, + FilterContext, +) + + +# ---------------------------------------------------------------------------- +# Styles & Constants +# ---------------------------------------------------------------------------- + +SLUG_REGEX = r"[^A-Za-z0-9_-]+" + +EXCEL_ZEBRA_COLORS = [ + ("4472C4", "D9E2F3"), + ("70AD47", "E2EFDA"), + ("ED7D31", "FCE4D6"), + ("7030A0", "E4DFEC"), + ("00B0F0", "DAEEF3"), + ("FFC000", "FFF2CC"), +] + +STYLE_HEADER_FONT = Font(bold=True) +STYLE_HEADER_FONT_WHITE = Font(bold=True, color="FFFFFF") +STYLE_BORDER_THIN = Border( + left=Side(style="thin"), + right=Side(style="thin"), + top=Side(style="thin"), + bottom=Side(style="thin"), +) +STYLE_FILL_BLUE = PatternFill( + start_color="4472C4", end_color="4472C4", fill_type="solid" +) +STYLE_FILL_GREY = PatternFill( + start_color="808080", end_color="808080", fill_type="solid" +) +ALIGN_CENTER = Alignment(horizontal="center", vertical="center") + + +# ---------------------------------------------------------------------------- +# Utilities +# ---------------------------------------------------------------------------- + + +def excel_safe(value: Any) -> Any: + """Escape potentially dangerous Excel values.""" + if isinstance(value, str) and value and re.match(r"^\s*[=+\-@]", value): + return "'" + value + return value + + +def build_filename(program_name: str, export_type: str, ctx: FilterContext) -> str: + """Build a filename for Excel export based on context.""" + slug = re.sub(SLUG_REGEX, "_", program_name) + parts = [slug, export_type] + + if ctx.start_date: + parts.append(f"from_{ctx.start_date.strftime('%Y%m%d')}") + if ctx.end_date: + parts.append(f"to_{ctx.end_date.strftime('%Y%m%d')}") + if ctx.training_day_types: + t_slug = re.sub(SLUG_REGEX, "_", "_".join(ctx.training_day_types)) + parts.append(f"types_{t_slug}") + if ctx.student_tags: + t_slug = re.sub(SLUG_REGEX, "_", "_".join(ctx.student_tags)) + parts.append(f"tags_{t_slug}") + + return "_".join(parts) + ".xlsx" + + +class TrainingExcelWriter: + """Helper to manage worksheet writing and styling.""" + + def __init__(self, ws: Worksheet): + self.ws = ws + + def setup_static_headers(self): + """Write the Student and Tags columns.""" + for col, title in enumerate(["Student", "Tags"], start=1): + cell = self.ws.cell(row=1, column=col, value=title) + cell.font = STYLE_HEADER_FONT_WHITE + cell.fill = STYLE_FILL_BLUE + cell.border = STYLE_BORDER_THIN + cell.alignment = ALIGN_CENTER + self.ws.merge_cells( + start_row=1, start_column=col, end_row=2, end_column=col + ) + + def write_student_meta(self, row: int, student: Student): + """Write student name and tags.""" + if student.participation: + u = student.participation.user + name = f"{u.first_name} {u.last_name} ({u.username})" + else: + name = "(Unknown)" + + # Name + self.ws.cell( + row=row, column=1, value=excel_safe(name) + ).border = STYLE_BORDER_THIN + # Tags + tags = "; ".join(student.student_tags) if student.student_tags else "" + self.ws.cell( + row=row, column=2, value=excel_safe(tags) + ).border = STYLE_BORDER_THIN + + def write_td_header( + self, col: int, td: Any, width: int, idx: int + ) -> tuple[int, PatternFill]: + """Write a merged Training Day header and return the next column index.""" + title = td.description or td.name or "Session" + if td.start_time: + title += f" ({td.start_time.strftime('%b %d')})" + if td.training_day_types: + title += f" [{'; '.join(td.training_day_types)}]" + + # Colors + h_color, sub_color = EXCEL_ZEBRA_COLORS[idx % len(EXCEL_ZEBRA_COLORS)] + fill = PatternFill(start_color=h_color, end_color=h_color, fill_type="solid") + sub_fill = PatternFill( + start_color=sub_color, end_color=sub_color, fill_type="solid" + ) + + cell = self.ws.cell(row=1, column=col, value=excel_safe(title)) + cell.font = STYLE_HEADER_FONT_WHITE + cell.fill = fill + cell.border = STYLE_BORDER_THIN + cell.alignment = ALIGN_CENTER + + self.ws.merge_cells( + start_row=1, start_column=col, end_row=1, end_column=col + width - 1 + ) + return col + width, sub_fill + + def write_subheaders(self, col: int, headers: list[str], fill: PatternFill): + """Write the second row of headers.""" + for i, text in enumerate(headers): + cell = self.ws.cell(row=2, column=col + i, value=text) + cell.font = STYLE_HEADER_FONT + cell.fill = fill + cell.border = STYLE_BORDER_THIN + cell.alignment = Alignment(horizontal="center") + + def auto_size_columns(self, max_col: int): + """Apply basic widths.""" + self.ws.column_dimensions["A"].width = 30 + self.ws.column_dimensions["B"].width = 20 + for i in range(3, max_col + 1): + self.ws.column_dimensions[get_column_letter(i)].width = 12 + + +# ---------------------------------------------------------------------------- +# Sheet Generators +# ---------------------------------------------------------------------------- + +def generate_attendance_sheet(ws: Worksheet, view_data: dict, context: FilterContext): + """Populate the worksheet with attendance data.""" + writer = TrainingExcelWriter(ws) + writer.setup_static_headers() + + subcols = ["Status", "Location", "Recorded", "Delay Reasons", "Comments"] + width = len(subcols) + + # Write Headers + curr_col = 3 + for i, td in enumerate(context.archived_training_days): + _, sub_fill = writer.write_td_header(curr_col, td, width, i) + writer.write_subheaders(curr_col, subcols, sub_fill) + curr_col += width + + # Write Rows + row = 3 + attendance_map = view_data["attendance_data"] + + for student in view_data["sorted_students"]: + writer.write_student_meta(row, student) + + curr_col = 3 + for td in context.archived_training_days: + att = attendance_map.get(student.id, {}).get(td.id) + + if att: + # Status Logic + if att.status == "missed": + status = "Justified Absent" if att.justified else "Missed" + elif att.delay_time: + mins = att.delay_time.total_seconds() / 60 + status = ( + f"Delayed ({mins:.0f}m)" + if mins < 60 + else f"Delayed ({mins / 60:.1f}h)" + ) + else: + status = "On Time" + + # Location Logic + loc_map = {"class": "Class", "home": "Home", "both": "Both"} + loc = ( + loc_map.get(att.location, att.location) + if att.status != "missed" + else "" + ) + + if att.status == "missed": + rec = "" + else: + rec = "Yes" if att.recorded else "No" + + vals = [status, loc, rec, att.delay_reasons or "", att.comment or ""] + else: + vals = ["", "", "", "", ""] + + for i, val in enumerate(vals): + cell = ws.cell(row=row, column=curr_col + i, value=excel_safe(val)) + cell.border = STYLE_BORDER_THIN + + curr_col += width + row += 1 + + writer.auto_size_columns(curr_col) + + +def generate_ranking_sheet(ws: Worksheet, view_data: dict): + """Populate the worksheet with ranking data.""" + writer = TrainingExcelWriter(ws) + writer.setup_static_headers() + + td_list = view_data["filtered_training_days"] + tasks_map = view_data["training_day_tasks"] + ranking_map = view_data["ranking_data"] + + # Write Headers + curr_col = 3 + for i, td in enumerate(td_list): + tasks = tasks_map.get(td.id, []) + width = len(tasks) + 1 # +1 for Total + + _, sub_fill = writer.write_td_header(curr_col, td, width, i) + + subheaders = [t["name"] for t in tasks] + ["Total"] + writer.write_subheaders(curr_col, subheaders, sub_fill) + + curr_col += width + + # Global Total Header + ws.cell(row=1, column=curr_col, value="Global").fill = STYLE_FILL_GREY + ws.cell(row=1, column=curr_col).font = STYLE_HEADER_FONT_WHITE + ws.cell(row=1, column=curr_col).border = STYLE_BORDER_THIN + ws.cell(row=1, column=curr_col).alignment = ALIGN_CENTER + ws.merge_cells(start_row=1, start_column=curr_col, end_row=2, end_column=curr_col) + + # Write Rows + row = 3 + for student in view_data["sorted_students"]: + writer.write_student_meta(row, student) + + curr_col = 3 + global_total = 0.0 + + for td in td_list: + tasks = tasks_map.get(td.id, []) + rank = ranking_map.get(student.id, {}).get(td.id) + td_total = 0.0 + + # Task Scores + for task in tasks: + val = None + if rank and rank.task_scores: + val = rank.task_scores.get(str(task["id"])) + + cell = ws.cell(row=row, column=curr_col) + if val is not None: + cell.value = val + td_total += val + cell.border = STYLE_BORDER_THIN + curr_col += 1 + + # TD Total + cell = ws.cell(row=row, column=curr_col) + if rank and rank.task_scores: + cell.value = td_total + global_total += td_total + cell.border = STYLE_BORDER_THIN + curr_col += 1 + + # Global Total + cell = ws.cell(row=row, column=curr_col) + cell.value = global_total if global_total > 0 else "" + cell.border = STYLE_BORDER_THIN + cell.font = STYLE_HEADER_FONT + + row += 1 + + writer.auto_size_columns(curr_col) + + +# ---------------------------------------------------------------------------- +# Handlers +# ---------------------------------------------------------------------------- + +class ExportAttendanceHandler(TrainingProgramFilterMixin, BaseHandler): + """Export attendance data to Excel format.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + tp = self.safe_get_item(TrainingProgram, training_program_id) + ctx = self.get_filter_context(tp) + + if not ctx.archived_training_days: + self.redirect(self.url("training_program", tp.id, "attendance")) + return + + view_data = get_attendance_view_data(ctx) + + wb = Workbook() + ws = wb.active + ws.title = "Attendance" + + generate_attendance_sheet(ws, view_data, ctx) + + self._serve_excel(wb, build_filename(tp.name, "attendance", ctx)) + + def _serve_excel(self, wb: Workbook, filename: str): + output = io.BytesIO() + wb.save(output) + output.seek(0) + + self.set_header( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + self.set_header("Content-Disposition", f'attachment; filename="{filename}"') + self.write(output.getvalue()) + self.finish() + + +class ExportCombinedRankingHandler(ExportAttendanceHandler): + """Export combined ranking data to Excel format.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + tp = self.safe_get_item(TrainingProgram, training_program_id) + ctx = self.get_filter_context(tp) + + # Build Data + view_data = get_ranking_view_data(ctx) + + if not view_data["filtered_training_days"]: + self.redirect(self.url("training_program", tp.id, "combined_ranking")) + return + + wb = Workbook() + ws = wb.active + ws.title = "Combined Ranking" + + generate_ranking_sheet(ws, view_data) + + self._serve_excel(wb, build_filename(tp.name, "ranking", ctx)) diff --git a/cms/server/admin/handlers/export_handlers.py b/cms/server/admin/handlers/export_handlers.py index b6fee9cf1b..123e10ee3f 100644 --- a/cms/server/admin/handlers/export_handlers.py +++ b/cms/server/admin/handlers/export_handlers.py @@ -28,7 +28,7 @@ import yaml -from cms.db import Contest, Task +from cms.db import Contest, Task, TrainingProgram from cms.grading.languagemanager import SOURCE_EXTS, get_language from cms.grading.tasktypes.util import get_allowed_manager_basenames from cmscommon.datetime import make_datetime @@ -63,6 +63,26 @@ def _expand_codename_with_language(filename: str, language_name: str | None) -> return filename[:-3] + extension +def _zip_directory(src_dir: str, zip_path: str, base_dir: str) -> None: + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + for root, _dirs, files in os.walk(src_dir): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, base_dir) + zipf.write(file_path, arcname) + + +def _write_zip_response(handler: BaseHandler, zip_path: str, download_name: str) -> None: + handler.set_header('Content-Type', 'application/zip') + handler.set_header('Content-Disposition', + f'attachment; filename="{download_name}"') + + with open(zip_path, 'rb') as f: + handler.write(f.read()) + + handler.finish() + + def _export_task_to_yaml_format(task, dataset, file_cacher, export_dir): """Export a task to YamlLoader (Italian YAML) format. @@ -448,14 +468,16 @@ def _export_contest_to_yaml_format(contest, file_cacher, export_dir): if contest.analysis_stop is not None: contest_config['analysis_stop'] = contest.analysis_stop.timestamp() - if contest.tasks: - contest_config['tasks'] = [task.name for task in contest.tasks] + # Use get_tasks() to support training days which have tasks separate from contest.tasks + tasks = contest.get_tasks() + if tasks: + contest_config['tasks'] = [task.name for task in tasks] contest_yaml_path = os.path.join(export_dir, "contest.yaml") with open(contest_yaml_path, 'w', encoding='utf-8') as f: yaml.dump(contest_config, f, default_flow_style=False, allow_unicode=True, sort_keys=False) - for task in contest.tasks: + for task in tasks: task_dir = os.path.join(export_dir, task.name) os.makedirs(task_dir, exist_ok=True) @@ -498,21 +520,8 @@ def get(self, task_id): ) zip_path = os.path.join(temp_dir, f"{task.name}.zip") - with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: - for root, dirs, files in os.walk(task_dir): - for file in files: - file_path = os.path.join(root, file) - arcname = os.path.relpath(file_path, temp_dir) - zipf.write(file_path, arcname) - - self.set_header('Content-Type', 'application/zip') - self.set_header('Content-Disposition', - f'attachment; filename="{task.name}.zip"') - - with open(zip_path, 'rb') as f: - self.write(f.read()) - - self.finish() + _zip_directory(task_dir, zip_path, temp_dir) + _write_zip_response(self, zip_path, f"{task.name}.zip") except Exception as error: logger.error("Task export failed: %s", error, exc_info=True) @@ -528,18 +537,34 @@ def get(self, task_id): class ExportContestHandler(BaseHandler): - """Handler for exporting a contest to a zip file in YamlLoader format. + """Handler for exporting a contest or training program to a zip file. + Supports both contest and training_program entity types via URL pattern: + - /contest/{id}/export + - /training_program/{id}/export + + For training programs, exports all tasks from the managing contest. """ @require_permission(BaseHandler.AUTHENTICATED) - def get(self, contest_id): - contest = self.safe_get_item(Contest, contest_id) + def get(self, entity_type: str, entity_id: str): + # Determine the contest and export name based on entity type + if entity_type == "training_program": + training_program = self.safe_get_item(TrainingProgram, entity_id) + contest = training_program.managing_contest + export_name = training_program.name + fallback_url = self.url("training_program", entity_id) + error_prefix = "Training program" + else: + contest = self.safe_get_item(Contest, entity_id) + export_name = contest.name + fallback_url = self.url("contest", entity_id) + error_prefix = "Contest" temp_dir = None try: - temp_dir = tempfile.mkdtemp(prefix="cms_export_contest_") + temp_dir = tempfile.mkdtemp(prefix="cms_export_") - contest_dir = os.path.join(temp_dir, contest.name) + contest_dir = os.path.join(temp_dir, export_name) os.makedirs(contest_dir) _export_contest_to_yaml_format( @@ -548,30 +573,17 @@ def get(self, contest_id): contest_dir ) - zip_path = os.path.join(temp_dir, f"{contest.name}.zip") - with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: - for root, dirs, files in os.walk(contest_dir): - for file in files: - file_path = os.path.join(root, file) - arcname = os.path.relpath(file_path, temp_dir) - zipf.write(file_path, arcname) - - self.set_header('Content-Type', 'application/zip') - self.set_header('Content-Disposition', - f'attachment; filename="{contest.name}.zip"') - - with open(zip_path, 'rb') as f: - self.write(f.read()) - - self.finish() + zip_path = os.path.join(temp_dir, f"{export_name}.zip") + _zip_directory(contest_dir, zip_path, temp_dir) + _write_zip_response(self, zip_path, f"{export_name}.zip") except Exception as error: - logger.error("Contest export failed: %s", error, exc_info=True) + logger.error("%s export failed: %s", error_prefix, error, exc_info=True) self.service.add_notification( make_datetime(), - "Contest export failed", + f"{error_prefix} export failed", str(error)) - self.redirect(self.url("contest", contest_id)) + self.redirect(fallback_url) finally: if temp_dir and os.path.exists(temp_dir): diff --git a/cms/server/admin/handlers/folder.py b/cms/server/admin/handlers/folder.py index f461d8fc84..adc9000936 100644 --- a/cms/server/admin/handlers/folder.py +++ b/cms/server/admin/handlers/folder.py @@ -6,7 +6,7 @@ the contest page (dropdown). """ -from cms.db import ContestFolder, Contest +from cms.db import ContestFolder, Contest, TrainingDay from cms.server.util import exclude_internal_contests from cmscommon.datetime import make_datetime @@ -24,9 +24,10 @@ def get(self): ) self.r_params["root_contests"] = ( exclude_internal_contests( - self.sql_session.query(Contest) - .filter(Contest.folder_id.is_(None)) + self.sql_session.query(Contest).filter(Contest.folder_id.is_(None)) ) + .outerjoin(TrainingDay, Contest.id == TrainingDay.contest_id) + .filter(TrainingDay.id.is_(None)) .order_by(Contest.name) .all() ) diff --git a/cms/server/admin/handlers/student.py b/cms/server/admin/handlers/student.py new file mode 100644 index 0000000000..84dcd727a1 --- /dev/null +++ b/cms/server/admin/handlers/student.py @@ -0,0 +1,491 @@ +#!/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 . + +"""Admin handlers for Students in Training Programs. + +Students are users enrolled in a training program with additional metadata +like student tags and task assignments. + +This module contains core student management handlers. Task-related handlers +are in studenttask.py. +""" + +import tornado.web + +from cms.db import ( + TrainingProgram, + Participation, + User, + Student, + Team, + Submission, +) +from cms.server.admin.handlers.utils import ( + get_all_student_tags, + parse_tags, + parse_usernames_from_file, +) +from cmscommon.datetime import make_datetime + +from .base import BaseHandler, StudentBaseHandler, require_permission + +from .studenttask import ( + StudentTasksHandler, + StudentTaskSubmissionsHandler, + AddStudentTaskHandler, + RemoveStudentTaskHandler, + BulkAssignTaskHandler, +) + +__all__ = [ + "AddStudentTaskHandler", + "AddTrainingProgramStudentHandler", + "BulkAddTrainingProgramStudentsHandler", + "BulkAssignTaskHandler", + "RemoveStudentTaskHandler", + "RemoveTrainingProgramStudentHandler", + "StudentHandler", + "StudentTagsHandler", + "StudentTaskSubmissionsHandler", + "StudentTasksHandler", + "TrainingProgramStudentsHandler", +] + + +class TrainingProgramStudentsHandler(BaseHandler): + """List and manage students in a training program.""" + REMOVE_FROM_PROGRAM = "Remove from training program" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + + self.render_params_for_training_program(training_program) + self.render_params_for_students_page(training_program) + self.r_params["bulk_add_results"] = None + + 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") + + self.safe_get_item(TrainingProgram, training_program_id) + + try: + operation = self.get_argument("operation") + # Support both old format (radio button + "Remove from training program") + # and new format (button with value "remove_") + if operation == self.REMOVE_FROM_PROGRAM: + user_id = self.get_argument("user_id") + elif operation.startswith("remove_"): + user_id = operation.replace("remove_", "") + else: + raise ValueError("Please select a valid operation") + except Exception as error: + self.service.add_notification( + make_datetime(), "Invalid field(s)", repr(error)) + self.redirect(fallback_page) + return + + # Redirect to confirmation page + asking_page = \ + self.url("training_program", training_program_id, "student", user_id, "remove") + self.redirect(asking_page) + + +class AddTrainingProgramStudentHandler(BaseHandler): + """Add a student to a training program.""" + + @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) + managing_contest = training_program.managing_contest + + try: + user_id: str = self.get_argument("user_id") + if not user_id or user_id.strip() == "": + raise ValueError("Please select a valid user") + except Exception as error: + self.service.add_notification( + make_datetime(), "Invalid field(s)", repr(error)) + self.redirect(fallback_page) + return + + user = self.safe_get_item(User, user_id) + + # 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() + + student = Student( + training_program=training_program, + participation=participation, + student_tags=[] + ) + self.sql_session.add(student) + + # Also add the student to all existing training days + for training_day in training_program.training_days: + # Skip training days that don't have a contest yet + if training_day.contest is None: + continue + + # Check if participation already exists for this contest and user + existing_participation = ( + self.sql_session.query(Participation) + .filter(Participation.contest == training_day.contest) + .filter(Participation.user == user) + .first() + ) + + if existing_participation is None: + td_participation = Participation( + contest=training_day.contest, user=user + ) + self.sql_session.add(td_participation) + + if self.try_commit(): + self.service.proxy_service.reinitialize() + + self.redirect(fallback_page) + + +class BulkAddTrainingProgramStudentsHandler(BaseHandler): + """Bulk add students to a training program from a file.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + try: + if "students_file" not in self.request.files: + raise ValueError("No file uploaded") + + file_data = self.request.files["students_file"][0] + file_content = file_data["body"].decode("utf-8") + + usernames = parse_usernames_from_file(file_content) + + if not usernames: + raise ValueError("File is empty or contains no usernames") + + results = [] + students_added = 0 + + for username in usernames: + user = self.sql_session.query(User).filter( + User.username == username).first() + + if user is None: + results.append({ + "username": username, + "status": "not_found", + "message": "Username does not exist in the system" + }) + else: + existing_participation = ( + self.sql_session.query(Participation) + .filter(Participation.contest == managing_contest) + .filter(Participation.user == user) + .first() + ) + + if existing_participation is not None: + results.append({ + "username": username, + "status": "already_exists", + "message": "User is already a student in this program" + }) + else: + participation = Participation( + contest=managing_contest, + user=user, + starting_time=make_datetime() + ) + self.sql_session.add(participation) + self.sql_session.flush() + + student = Student( + training_program=training_program, + participation=participation, + student_tags=[] + ) + self.sql_session.add(student) + + for training_day in training_program.training_days: + if training_day.contest is None: + continue + + # Check if participation already exists for this contest and user + existing_participation = ( + self.sql_session.query(Participation) + .filter(Participation.contest == training_day.contest) + .filter(Participation.user == user) + .first() + ) + + if existing_participation is None: + td_participation = Participation( + contest=training_day.contest, user=user + ) + self.sql_session.add(td_participation) + + results.append({ + "username": username, + "status": "success", + "message": "Successfully added to training program" + }) + students_added += 1 + + if self.try_commit(): + if students_added > 0: + self.service.proxy_service.reinitialize() + else: + # Commit failed - redirect to avoid showing misleading results + self.redirect( + self.url("training_program", training_program_id, "students") + ) + return + + self.render_params_for_training_program(training_program) + self.render_params_for_students_page(training_program) + self.r_params["bulk_add_results"] = results + self.r_params["students_added"] = students_added + self.render("training_program_students.html", **self.r_params) + + except Exception as error: + self.service.add_notification( + make_datetime(), "Error processing file", repr(error)) + self.redirect(self.url("training_program", training_program_id, "students")) + + +class RemoveTrainingProgramStudentHandler(StudentBaseHandler): + """Confirm and remove a student from a training program.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def get(self, training_program_id: str, user_id: str): + self.setup_student_context(training_program_id, user_id) + training_program = self.training_program + participation = self.participation + user = participation.user + + # Use the helper to set up training program params first + # (this initializes r_params, so it must come before render_params_for_remove_confirmation) + self.render_params_for_training_program(training_program) + self.r_params["unanswered"] = 0 # Override for deletion confirmation page + self.r_params["user"] = user + + # Now add submission count (this adds to existing r_params) + submission_query = ( + self.sql_session.query(Submission) + .filter(Submission.participation == participation) + ) + self.render_params_for_remove_confirmation(submission_query) + + # Count submissions and participations from training days + training_day_contest_ids = [td.contest_id for td in training_program.training_days] + training_day_contest_ids = [ + cid for cid in training_day_contest_ids if cid is not None + ] + + if training_day_contest_ids: + training_day_participations = ( + self.sql_session.query(Participation) + .filter(Participation.contest_id.in_(training_day_contest_ids)) + .filter(Participation.user == user) + .count() + ) + training_day_submissions = ( + self.sql_session.query(Submission) + .join(Participation) + .filter(Participation.contest_id.in_(training_day_contest_ids)) + .filter(Participation.user == user) + .count() + ) + else: + training_day_participations = 0 + training_day_submissions = 0 + + self.r_params["training_day_submissions"] = training_day_submissions + self.r_params["training_day_participations"] = training_day_participations + self.render("training_program_student_remove.html", **self.r_params) + + @require_permission(BaseHandler.PERMISSION_ALL) + def delete(self, training_program_id: str, user_id: str): + self.setup_student_context(training_program_id, user_id) + training_program = self.training_program + participation = self.participation + user = participation.user + + # Delete the Student record first (it has a NOT NULL FK to participation) + self.sql_session.delete(self.student) + + self.sql_session.delete(participation) + + # Also delete participations from all training days + for training_day in training_program.training_days: + # Skip training days that don't have a contest yet + if training_day.contest is None: + continue + td_participation: Participation | None = ( + self.sql_session.query(Participation) + .filter(Participation.contest == training_day.contest) + .filter(Participation.user == user) + .first() + ) + if td_participation is not None: + self.sql_session.delete(td_participation) + + if self.try_commit(): + self.service.proxy_service.reinitialize() + + self.write("../../students") + + +class StudentHandler(StudentBaseHandler): + """Shows and edits details of a single student in a training program. + + Similar to ParticipationHandler but includes student tags. + """ + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str, user_id: str): + self.setup_student_context(training_program_id, user_id) + + submission_query = self.sql_session.query(Submission).filter( + Submission.participation == self.participation + ) + page = int(self.get_query_argument("page", "0")) + + # render_params_for_training_program sets training_program, contest, unanswered + self.render_params_for_training_program(self.training_program) + + self.render_params_for_submissions(submission_query, page) + + self.r_params["participation"] = self.participation + self.r_params["student"] = self.student + self.r_params["selected_user"] = self.participation.user + self.r_params["teams"] = self.sql_session.query(Team).all() + self.r_params["all_student_tags"] = get_all_student_tags( + self.sql_session, self.training_program + ) + self.render("student.html", **self.r_params) + + @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, "edit" + ) + + self.setup_student_context(training_program_id, user_id) + + try: + attrs = self.participation.get_attrs() + self.get_password(attrs, self.participation.password, True) + self.get_ip_networks(attrs, "ip") + self.get_datetime(attrs, "starting_time") + self.get_timedelta_sec(attrs, "delay_time") + self.get_timedelta_sec(attrs, "extra_time") + self.get_bool(attrs, "hidden") + self.get_bool(attrs, "unrestricted") + + # Get the new hidden status before applying + new_hidden = attrs.get("hidden", False) + + self.participation.set_attrs(attrs) + + # Check if admin wants to apply hidden status to existing training days + apply_to_existing = self.get_argument("apply_hidden_to_existing", None) is not None + + if apply_to_existing: + # Update hidden status in all existing training day participations + user = self.participation.user + for training_day in self.training_program.training_days: + if training_day.contest is None: + continue + td_participation = self.sql_session.query(Participation)\ + .filter(Participation.contest_id == training_day.contest_id)\ + .filter(Participation.user_id == user.id)\ + .first() + if td_participation: + td_participation.hidden = new_hidden + + self.get_string(attrs, "team") + team_code = attrs["team"] + if team_code: + team: Team | None = ( + self.sql_session.query(Team).filter(Team.code == team_code).first() + ) + if team is None: + raise ValueError(f"Team with code '{team_code}' does not exist") + self.participation.team = team + else: + self.participation.team = None + + tags_str = self.get_argument("student_tags", "") + self.student.student_tags = parse_tags(tags_str) + + 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.proxy_service.reinitialize() + self.redirect(fallback_page) + + +class StudentTagsHandler(StudentBaseHandler): + """Handler for updating student tags via AJAX.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str, user_id: str): + # Set JSON content type for all responses + self.set_header("Content-Type", "application/json") + + try: + self.setup_student_context(training_program_id, user_id) + except tornado.web.HTTPError: + self.set_status(404) + self.write({"error": "Student not found"}) + return + + try: + tags_str = self.get_argument("student_tags", "") + self.student.student_tags = parse_tags(tags_str) + + if self.try_commit(): + self.write({"success": True, "tags": self.student.student_tags}) + else: + self.set_status(500) + return + + except Exception as error: + self.set_status(400) + self.write({"error": str(error)}) diff --git a/cms/server/admin/handlers/studenttask.py b/cms/server/admin/handlers/studenttask.py new file mode 100644 index 0000000000..61866e92ec --- /dev/null +++ b/cms/server/admin/handlers/studenttask.py @@ -0,0 +1,337 @@ +#!/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 . + +"""Admin handlers for Student Task management. + +This module contains handlers for managing task assignments to students +in training programs, including viewing, adding, and removing tasks +from student archives. + +Handlers: +- StudentTasksHandler: View and manage tasks assigned to a student +- StudentTaskSubmissionsHandler: View submissions for a specific task +- AddStudentTaskHandler: Add a task to a student's archive +- RemoveStudentTaskHandler: Remove a task from a student's archive +- BulkAssignTaskHandler: Bulk assign a task to students with a tag +""" + +import tornado.web + +from cms.db import ( + TrainingProgram, + Submission, + Task, + Student, + StudentTask, + ArchivedStudentRanking, +) +from cms.server.util import get_student_archive_scores, get_submission_counts_by_task +from cmscommon.datetime import make_datetime + +from .base import BaseHandler, StudentBaseHandler, require_permission + + +class StudentTasksHandler(StudentBaseHandler): + """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): + self.setup_student_context(training_program_id, user_id) + + # Get all tasks in the training program for the "add task" dropdown + all_tasks = self.managing_contest.get_tasks() + assigned_task_ids = {st.task_id for st in self.student.student_tasks} + available_tasks = [t for t in all_tasks if t.id not in assigned_task_ids] + + # Build home scores using get_student_archive_scores for fresh cache values + # This avoids stale entries in participation.task_scores + home_scores = get_student_archive_scores( + self.sql_session, self.student, self.participation, self.managing_contest + ) + # Commit to release advisory locks from cache rebuilds + self.sql_session.commit() + + # Build training scores from archived student rankings (batch query) + training_scores = {} + source_training_day_ids = { + st.source_training_day_id + for st in self.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 == self.student.id) + .all() + ) + } + + for st in self.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] + + # Get submission counts for each task (batch query for efficiency) + submission_counts = get_submission_counts_by_task( + self.sql_session, self.participation.id, assigned_task_ids + ) + + self.render_params_for_training_program(self.training_program) + self.r_params["participation"] = self.participation + self.r_params["student"] = self.student + self.r_params["selected_user"] = self.participation.user + self.r_params["student_tasks"] = sorted( + self.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["submission_counts"] = submission_counts + self.render("student_tasks.html", **self.r_params) + + +class StudentTaskSubmissionsHandler(StudentBaseHandler): + """View submissions for a specific task in a student's archive.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str, user_id: str, task_id: str): + task = self.safe_get_item(Task, task_id) + self.setup_student_context(training_program_id, user_id) + + # Validate task belongs to the training program + if task.contest_id != self.managing_contest.id: + raise tornado.web.HTTPError(404) + + # Verify student is assigned this specific task + student_task = ( + self.sql_session.query(StudentTask) + .filter(StudentTask.student == self.student) + .filter(StudentTask.task == task) + .first() + ) + + if student_task is None: + raise tornado.web.HTTPError(404) + + # Filter submissions by task + submission_query = ( + self.sql_session.query(Submission) + .filter(Submission.participation == self.participation) + .filter(Submission.task_id == task.id) + ) + page = int(self.get_query_argument("page", "0")) + + self.render_params_for_training_program(self.training_program) + self.render_params_for_submissions(submission_query, page) + + self.r_params["participation"] = self.participation + self.r_params["student"] = self.student + self.r_params["selected_user"] = self.participation.user + self.r_params["task"] = task + self.render("student_task_submissions.html", **self.r_params) + + +class AddStudentTaskHandler(StudentBaseHandler): + """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" + ) + + self.setup_student_context(training_program_id, user_id) + + try: + task_id = self.get_argument("task_id") + if task_id in ("", "null"): + raise ValueError("Please select a task") + + task = self.safe_get_item(Task, task_id) + + # Validate task belongs to the student's training program + if task.contest_id != self.training_program.managing_contest_id: + raise ValueError("Task does not belong to the student's contest") + + # Check if task is already assigned + existing = ( + self.sql_session.query(StudentTask) + .filter(StudentTask.student_id == self.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 = self.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 {self.participation.user.username}" + ) + + self.redirect(fallback_page) + + +class RemoveStudentTaskHandler(StudentBaseHandler): + """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" + ) + + # Validate and convert task_id to integer + try: + task_id_int = int(task_id) + except (ValueError, TypeError): + raise tornado.web.HTTPError(404) + + # Verify the task exists + task = self.safe_get_item(Task, task_id_int) + + self.setup_student_context(training_program_id, user_id) + + student_task: StudentTask | None = ( + self.sql_session.query(StudentTask) + .filter(StudentTask.student_id == self.student.id) + .filter(StudentTask.task_id == task_id_int) + .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 {self.participation.user.username}'s archive" + ) + + self.redirect(fallback_page) + + +class BulkAssignTaskHandler(BaseHandler): + """Bulk assign a task to all students with a given tag. + + Note: The GET method was removed as the bulk assign task functionality + is now handled via a modal dialog on the students page. + """ + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str): + # Redirect to students page (modal is now on that page) + fallback_page = self.url( + "training_program", training_program_id, "students" + ) + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + + try: + task_id = self.get_argument("task_id") + if task_id in ("", "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) + + # Validate task belongs to the training program + if task.contest_id != training_program.managing_contest_id: + raise ValueError("Task does not belong to the student's contest") + + # 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/handlers/submissiondownload.py b/cms/server/admin/handlers/submissiondownload.py index 819c438808..e94d49808c 100644 --- a/cms/server/admin/handlers/submissiondownload.py +++ b/cms/server/admin/handlers/submissiondownload.py @@ -24,8 +24,17 @@ import zipfile import tornado.web - -from cms.db import Contest, Participation, Submission, Task +from sqlalchemy.orm import joinedload + +from cms.db import ( + Contest, + Participation, + Submission, + Task, + TrainingProgram, + TrainingDay, +) +from cms.db.training_day import get_managing_participation from cms.grading.languagemanager import safe_get_lang_filename from .base import BaseHandler, require_permission @@ -33,14 +42,51 @@ logger = logging.getLogger(__name__) +def sanitize_path_component(name: str) -> str: + """Sanitize a string to be safe for use as a path component in zip files. + + Replaces characters that could cause path traversal or other issues: + - Forward/backward slashes (path separators) + - Null bytes + - Other problematic characters like : * ? " < > | + + name: the string to sanitize + + return: sanitized string safe for use in file paths + """ + # Characters that are problematic in file paths + unsafe_chars = '/\\:*?"<>|\x00' + result = name + for char in unsafe_chars: + result = result.replace(char, '_') + # Also strip leading/trailing whitespace and dots + result = result.strip(' .') + # Return a default if the result is empty + return result if result else "unnamed" + + +def get_source_folder(submission): + """Get the source folder name for a submission.""" + if submission.training_day_id is not None: + training_day = submission.training_day + if training_day.contest is not None: + return sanitize_path_component(training_day.contest.description) + else: + # Archived training day - use stored description or name + return sanitize_path_component( + training_day.description or training_day.name or "archived_training_day" + ) + return "task_archive" + + def get_submission_status(submission, dataset): """Get the status string for a submission. - + submission: the Submission object dataset: the Dataset to evaluate against - + return: status string (e.g., "compiling", "95.0pts", "compilationFailed") - + """ result = submission.get_result(dataset) if result is None: @@ -60,25 +106,25 @@ def get_submission_status(submission, dataset): def write_submission_files(zip_file, submission, base_path_parts, file_cacher): """Write all files from a submission to the zip file. - + zip_file: ZipFile object to write to submission: the Submission object base_path_parts: list of path components (e.g., ["username", "taskname"]) file_cacher: FileCacher instance to retrieve file content - + """ dataset = submission.task.active_dataset status = get_submission_status(submission, dataset) timestamp = submission.timestamp.strftime("%Y%m%d_%H%M%S") official_folder = "official" if submission.official else "unofficial" - - path_parts = base_path_parts + [official_folder] - + + path_parts = [*base_path_parts, official_folder] + for filename, file_obj in submission.files.items(): real_filename = safe_get_lang_filename(submission.language, filename) prefixed_filename = f"{timestamp}_{status}_{real_filename}" - file_path = "/".join(path_parts + [prefixed_filename]) - + file_path = "/".join([*path_parts, prefixed_filename]) + try: file_content = file_cacher.get_file_content(file_obj.digest) zip_file.writestr(file_path, file_content) @@ -89,20 +135,20 @@ def write_submission_files(zip_file, submission, base_path_parts, file_cacher): def build_zip(submissions, base_path_builder, file_cacher): """Build a zip file containing all submissions. - + submissions: list of Submission objects base_path_builder: function that takes a submission and returns list of path parts file_cacher: FileCacher instance to retrieve file content - + return: BytesIO object containing the zip file - + """ zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: for submission in sorted(submissions, key=lambda s: s.timestamp): base_path_parts = base_path_builder(submission) write_submission_files(zip_file, submission, base_path_parts, file_cacher) - + return zip_buffer @@ -123,10 +169,11 @@ def base_path_builder(submission): return [submission.participation.user.username] zip_buffer = build_zip(submissions, base_path_builder, self.service.file_cacher) - + self.set_header("Content-Type", "application/zip") - self.set_header("Content-Disposition", - f'attachment; filename="{task.name}_submissions.zip"') + self.set_header( + "Content-Disposition", f'attachment; filename="{task.name}_submissions.zip"' + ) self.write(zip_buffer.getvalue()) self.finish() @@ -134,21 +181,45 @@ def base_path_builder(submission): class DownloadUserContestSubmissionsHandler(BaseHandler): """Download all submissions for a specific user in a contest as a zip file. + For training day contests, only downloads submissions made via that + training day (filtered by training_day_id). """ + @require_permission(BaseHandler.AUTHENTICATED) def get(self, contest_id, user_id): self.contest = self.safe_get_item(Contest, contest_id) - participation = self.sql_session.query(Participation)\ - .filter(Participation.contest_id == contest_id)\ - .filter(Participation.user_id == user_id)\ + participation = ( + self.sql_session.query(Participation) + .filter(Participation.contest_id == contest_id) + .filter(Participation.user_id == user_id) .first() + ) if participation is None: raise tornado.web.HTTPError(404) - submissions = self.sql_session.query(Submission)\ - .filter(Submission.participation_id == participation.id)\ - .all() + # For training day contests, submissions are stored with the managing + # contest's participation, not the training day's participation. + # We need to get the managing participation to find the submissions. + training_day = self.contest.training_day + if training_day is not None: + managing_participation = get_managing_participation( + self.sql_session, training_day, participation.user) + if managing_participation is not None: + submissions = ( + self.sql_session.query(Submission) + .filter(Submission.participation_id == managing_participation.id) + .filter(Submission.training_day_id == training_day.id) + .all() + ) + else: + submissions = [] + else: + submissions = ( + self.sql_session.query(Submission) + .filter(Submission.participation_id == participation.id) + .all() + ) username = participation.user.username contest_name = self.contest.name @@ -157,10 +228,12 @@ def base_path_builder(submission): return [submission.task.name] zip_buffer = build_zip(submissions, base_path_builder, self.service.file_cacher) - + self.set_header("Content-Type", "application/zip") - self.set_header("Content-Disposition", - f'attachment; filename="{username}_{contest_name}_submissions.zip"') + self.set_header( + "Content-Disposition", + f'attachment; filename="{username}_{contest_name}_submissions.zip"', + ) self.write(zip_buffer.getvalue()) self.finish() @@ -168,23 +241,129 @@ def base_path_builder(submission): class DownloadContestSubmissionsHandler(BaseHandler): """Download all submissions for a contest as a zip file. + For training day contests, only downloads submissions made via that + training day (filtered by training_day_id). """ + @require_permission(BaseHandler.AUTHENTICATED) def get(self, contest_id): self.contest = self.safe_get_item(Contest, contest_id) - submissions = self.sql_session.query(Submission)\ - .join(Task)\ - .filter(Task.contest_id == contest_id)\ - .all() + # For training day contests, only download submissions made via that training day + if self.contest.training_day is not None: + submissions = ( + self.sql_session.query(Submission) + .filter(Submission.training_day_id == self.contest.training_day.id) + .all() + ) + else: + # For regular contests and training program managing contests + submissions = ( + self.sql_session.query(Submission) + .join(Task) + .filter(Task.contest_id == contest_id) + .all() + ) def base_path_builder(submission): return [submission.participation.user.username, submission.task.name] zip_buffer = build_zip(submissions, base_path_builder, self.service.file_cacher) - + + self.set_header("Content-Type", "application/zip") + self.set_header( + "Content-Disposition", + f'attachment; filename="{self.contest.name}_all_submissions.zip"', + ) + self.write(zip_buffer.getvalue()) + self.finish() + + +class DownloadTrainingProgramSubmissionsHandler(BaseHandler): + """Download all submissions for a training program as a zip file. + + The folder structure is: user/task/source/official-unofficial/files + where source is either "task_archive" or the training day description. + """ + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + self.contest = managing_contest + + # Get all submissions for the managing contest + submissions = ( + self.sql_session.query(Submission) + .join(Task) + .filter(Task.contest_id == managing_contest.id) + .options( + joinedload(Submission.training_day).joinedload(TrainingDay.contest) + ) + .all() + ) + + def base_path_builder(submission): + source_folder = get_source_folder(submission) + return [ + submission.participation.user.username, + submission.task.name, + source_folder, + ] + + zip_buffer = build_zip(submissions, base_path_builder, self.service.file_cacher) + + self.set_header("Content-Type", "application/zip") + self.set_header( + "Content-Disposition", + f'attachment; filename="{training_program.name}_all_submissions.zip"', + ) + self.write(zip_buffer.getvalue()) + self.finish() + + +class DownloadTrainingProgramStudentSubmissionsHandler(BaseHandler): + """Download all submissions for a specific student in a training program. + + The folder structure is: task/source/official-unofficial/files + where source is either "task_archive" or the training day description. + """ + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id, user_id): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + self.contest = managing_contest + + participation = ( + 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) + + submissions = ( + self.sql_session.query(Submission) + .filter(Submission.participation_id == participation.id) + .options( + joinedload(Submission.training_day).joinedload(TrainingDay.contest) + ) + .all() + ) + + username = participation.user.username + + def base_path_builder(submission): + source_folder = get_source_folder(submission) + return [submission.task.name, source_folder] + + zip_buffer = build_zip(submissions, base_path_builder, self.service.file_cacher) + self.set_header("Content-Type", "application/zip") self.set_header("Content-Disposition", - f'attachment; filename="{self.contest.name}_all_submissions.zip"') + f'attachment; filename="{username}_{training_program.name}_submissions.zip"') self.write(zip_buffer.getvalue()) self.finish() diff --git a/cms/server/admin/handlers/task.py b/cms/server/admin/handlers/task.py index a63f141c11..7680d25f6e 100644 --- a/cms/server/admin/handlers/task.py +++ b/cms/server/admin/handlers/task.py @@ -42,6 +42,7 @@ from cms.db import Attachment, Dataset, Session, Statement, Submission, Task from cms.grading.scoretypes import ScoreTypeGroup +from cms.server.admin.handlers.utils import parse_tags from cmscommon.datetime import make_datetime from .base import BaseHandler, SimpleHandler, require_permission from cms.grading.subtask_validation import get_running_validator_ids @@ -113,7 +114,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 @@ -230,6 +236,13 @@ def post(self, task_id): self.get_string(attrs, "score_mode") + # Process visible_to_tags for training day tasks + # Only update if the parameter is explicitly present in the request + # (to avoid clobbering when editing from the general task page) + visible_to_tags_str = self.get_argument("visible_to_tags", None) + if visible_to_tags_str is not None: + attrs["visible_to_tags"] = parse_tags(visible_to_tags_str) + # Update the task. task.set_attrs(attrs) @@ -708,8 +721,17 @@ def post(self, task_id): try: task.set_default_output_only_submission_format() except Exception as e: - raise RuntimeError( - f"Couldn't create default submission format for task {task.id}") from e + logger.error( + "Couldn't create default submission format for task %s " + "(dataset %s, type %s)", + task.id, + task.active_dataset.id, + task.active_dataset.task_type, + exc_info=True, + ) + raise tornado.web.HTTPError( + 500, f"Couldn't create default submission format for task {task.id}" + ) from e if self.try_commit(): self.service.proxy_service.reinitialize() diff --git a/cms/server/admin/handlers/training_analytics.py b/cms/server/admin/handlers/training_analytics.py new file mode 100644 index 0000000000..1f6717d3f9 --- /dev/null +++ b/cms/server/admin/handlers/training_analytics.py @@ -0,0 +1,594 @@ +#!/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 . + +"""Admin handlers for Training Program Analytics. + +This module contains handlers for displaying attendance and combined ranking +analytics across archived training days. + +""" + +import json +import logging +from dataclasses import dataclass +from datetime import datetime as dt, timedelta +from typing import Optional, Literal +from urllib.parse import urlencode + +import tornado.web + +from cms.db import ( + TrainingProgram, + Student, + TrainingDay, + ArchivedAttendance, + ArchivedStudentRanking, +) +from cms.server.admin.handlers.utils import ( + get_all_student_tags, + get_all_training_day_types, + parse_tags, +) +from .base import BaseHandler, require_permission + +logger = logging.getLogger(__name__) + + +@dataclass +class FilterContext: + """Encapsulates all filter criteria and pre-fetched DB objects.""" + + start_date: Optional[dt] + end_date: Optional[dt] + training_day_types: list[str] + student_tags: list[str] + student_tags_mode: Literal["current", "historical"] + + # Pre-fetched data + archived_training_days: list[TrainingDay] + # IDs of students who currently possess the tags (used in 'current' mode) + current_tag_student_ids: set[int] + + def is_visible( + self, student_id: int, historical_tags: list[str] | None = None + ) -> bool: + """Determine if a student/record is visible based on filter mode.""" + if not self.student_tags: + return True + + if self.student_tags_mode == "current": + return student_id in self.current_tag_student_ids + + if historical_tags is None: + return False + + return all(tag in historical_tags for tag in self.student_tags) + + +def _sort_students(students: dict[int, Student]) -> list[Student]: + """Sort students by username safely.""" + return sorted( + students.values(), + key=lambda s: s.participation.user.username if s.participation else "", + ) + + +def get_attendance_view_data(ctx: FilterContext): + """Build data structures for the Attendance view.""" + attendance_data: dict[int, dict[int, ArchivedAttendance]] = {} + all_students: dict[int, Student] = {} + + for td in ctx.archived_training_days: + for att in td.archived_attendances: + if ctx.student_tags and att.student_id not in ctx.current_tag_student_ids: + continue + + if att.student.participation and att.student.participation.hidden: + continue + + if att.student_id not in attendance_data: + attendance_data[att.student_id] = {} + all_students[att.student_id] = att.student + + attendance_data[att.student_id][td.id] = att + + return { + "attendance_data": attendance_data, + "sorted_students": _sort_students(all_students), + } + + +def get_ranking_view_data(ctx: FilterContext): + """Build data structures for the Combined Ranking view.""" + ranking_data: dict[int, dict[int, ArchivedStudentRanking]] = {} + all_students: dict[int, Student] = {} + training_day_tasks: dict[int, list[dict]] = {} + filtered_training_days: list[TrainingDay] = [] + active_students_per_td: dict[int, set[int]] = {} + + for td in ctx.archived_training_days: + active_in_td = set() + visible_tasks_by_id = {} + + for rank in td.archived_student_rankings: + if rank.student.participation and rank.student.participation.hidden: + continue + + if not ctx.is_visible(rank.student_id, rank.student_tags): + continue + + active_in_td.add(rank.student_id) + + if rank.student_id not in ranking_data: + ranking_data[rank.student_id] = {} + all_students[rank.student_id] = rank.student + ranking_data[rank.student_id][td.id] = rank + + # Collect Task Metadata + if rank.task_scores: + archived_tasks = td.archived_tasks_data or {} + for task_id_str in rank.task_scores.keys(): + if task_id_str not in archived_tasks: + logger.warning( + "Missing archived task data: training_day_id=%s, task_id_str=%s, student_id=%s", + td.id, + task_id_str, + rank.student_id, + ) + continue + + task_id = int(task_id_str) + if task_id not in visible_tasks_by_id: + t_info = archived_tasks[task_id_str] + visible_tasks_by_id[task_id] = { + "id": task_id, + "name": t_info.get("short_name", ""), + "title": t_info.get("name", ""), + "training_day_num": t_info.get("training_day_num", 0), + } + + if active_in_td: + active_students_per_td[td.id] = active_in_td + filtered_training_days.append(td) + training_day_tasks[td.id] = sorted( + visible_tasks_by_id.values(), + key=lambda t: (t.get("training_day_num", 0), t["id"]), + ) + + return { + "ranking_data": ranking_data, + "training_day_tasks": training_day_tasks, + "filtered_training_days": filtered_training_days, + "active_students_per_td": active_students_per_td, + "sorted_students": _sort_students(all_students), + } + + +def get_history_json_data(ctx: FilterContext) -> list[list]: + """Build the JSON structure for the ranking history graph.""" + result = [] + for td in ctx.archived_training_days: + for rank in td.archived_student_rankings: + if ctx.is_visible(rank.student_id, rank.student_tags): + if rank.history: + for entry in rank.history: + # [user_id, task_id, time, score] + result.append( + [str(entry[0]), str(entry[1]), int(entry[2]), entry[3]] + ) + return result + + +def get_student_detail_data( + ctx: FilterContext, training_program: TrainingProgram, student: Student +): + """Build data for the detailed student view (graphs and tables).""" + + # 1. Build User List (Left sidebar) + # This logic is slightly complex: in historical mode, we show students + # if they were active in ANY of the filtered training days. + active_students_any_day = set() + if ctx.student_tags_mode == "historical": + for td in ctx.archived_training_days: + for rank in td.archived_student_rankings: + if ctx.is_visible(rank.student_id, rank.student_tags): + active_students_any_day.add(rank.student_id) + + users_data = {} + for s in training_program.students: + if not s.participation or s.participation.hidden: + continue + + if ctx.student_tags: + if ctx.student_tags_mode == "current": + if s.id not in ctx.current_tag_student_ids: + continue + elif s.id not in active_students_any_day: + continue + + users_data[str(s.participation.user_id)] = { + "f_name": s.participation.user.first_name or "", + "l_name": s.participation.user.last_name or "", + } + + # 2. Build Contest/Task Graph Data + # Pre-fetch this student's specific rankings to avoid O(N^2) lookups + student_rankings_map = { + r.training_day_id: r + for td in ctx.archived_training_days + for r in td.archived_student_rankings + if r.student_id == student.id + } + + graph_data = _build_contest_graph_data(ctx, student_rankings_map) + + return { + "users_data": users_data, + "user_count": len(users_data), + "contests_data": graph_data["contests"], + "tasks_data": graph_data["tasks"], + "submissions_data": graph_data["submissions"], + "total_max_score": graph_data["total_max"], + "contest_list": graph_data["contest_list"], + } + + +def _build_contest_graph_data( + ctx: FilterContext, student_rankings_map: dict[int, ArchivedStudentRanking] +) -> dict: + """Helper to construct the complex contest/task dictionaries for frontend graphs.""" + contests_data = {} + tasks_data = {} + submissions_data = {} + contest_list = [] + total_max_score = 0.0 + + for td in ctx.archived_training_days: + contest_key = f"td_{td.id}" + visible_task_ids = set() + + # Find all tasks active for *any* visible student in this TD + for rank in td.archived_student_rankings: + if ctx.is_visible(rank.student_id, rank.student_tags): + if rank.task_scores: + visible_task_ids.update(int(k) for k in rank.task_scores.keys()) + + if not visible_task_ids: + continue + + archived_tasks = td.archived_tasks_data or {} + + # Sort tasks by training_day_num + def _get_sort_key(t_id, archived_tasks=archived_tasks): + return (archived_tasks.get(str(t_id), {}).get("training_day_num", 0), t_id) + + sorted_ids = sorted(visible_task_ids, key=_get_sort_key) + + contest_tasks = [] + contest_max = 0.0 + + for t_id in sorted_ids: + t_key = str(t_id) + if t_key not in archived_tasks: + continue + + t_info = archived_tasks[t_key] + tasks_data[t_key] = { + **t_info, + "key": t_key, + "contest": contest_key, + } + contest_tasks.append(tasks_data[t_key]) + contest_max += t_info.get("max_score", 0) + + # Get student specific submissions + rank = student_rankings_map.get(td.id) + if rank and rank.submissions: + submissions_data[t_key] = rank.submissions.get(t_key, []) + + duration = int(td.duration.total_seconds()) if td.duration else 18000 + td_name = td.description or td.name or "Training Day" + if td.start_time: + td_name += f" ({td.start_time.strftime('%Y-%m-%d')})" + + c_data = { + "key": contest_key, + "name": td_name, + "begin": 0, + "end": duration, + "max_score": contest_max, + "score_precision": 2, + "tasks": contest_tasks, + } + contests_data[contest_key] = c_data + contest_list.append(c_data) + total_max_score += contest_max + + return { + "contests": contests_data, + "tasks": tasks_data, + "submissions": submissions_data, + "contest_list": contest_list, + "total_max": total_max_score, + } + + +class TrainingProgramFilterMixin: + """Mixin for parsing analytics filters and context.""" + + def get_filter_context(self, training_program: TrainingProgram) -> FilterContext: + """Parse request args and build the FilterContext.""" + + start_date, end_date = None, None + if s_str := self.get_argument("start_date", None): + try: + start_date = dt.fromisoformat(s_str) + except ValueError: + pass + if e_str := self.get_argument("end_date", None): + try: + end_date = dt.fromisoformat(e_str) + except ValueError: + pass + + types_str = self.get_argument("training_day_types", "") + td_types = parse_tags(types_str) if types_str else [] + + tags_str = self.get_argument("student_tags", "") + s_tags = parse_tags(tags_str) if tags_str else [] + + mode = self.get_argument("student_tags_mode", "current") + if mode not in ("current", "historical"): + mode = "current" + + query = ( + self.sql_session.query(TrainingDay) + .filter(TrainingDay.training_program_id == training_program.id) + .filter(TrainingDay.contest_id.is_(None)) + ) + if start_date: + query = query.filter(TrainingDay.start_time >= start_date) + if end_date: + query = query.filter(TrainingDay.start_time < end_date + timedelta(days=1)) + if td_types: + query = query.filter(TrainingDay.training_day_types.contains(td_types)) + + archived_days = query.order_by(TrainingDay.start_time).all() + + current_tag_ids = set() + if s_tags: + sq = ( + self.sql_session.query(Student.id) + .filter(Student.training_program_id == training_program.id) + .filter(Student.student_tags.contains(s_tags)) + ) + current_tag_ids = {row[0] for row in sq.all()} + + return FilterContext( + start_date=start_date, + end_date=end_date, + training_day_types=td_types, + student_tags=s_tags, + student_tags_mode=mode, + archived_training_days=archived_days, + current_tag_student_ids=current_tag_ids, + ) + + def set_common_params( + self, + training_program: TrainingProgram, + ctx: FilterContext, + include_historical_tags: bool = False, + training_days_override: list[TrainingDay] | None = None, + ): + """Set standard render parameters from context.""" + self.render_params_for_training_program(training_program) + + # Mirror context to template + self.r_params.update( + { + "start_date": ctx.start_date, + "end_date": ctx.end_date, + "training_day_types": ctx.training_day_types, + "student_tags": ctx.student_tags, + "student_tags_mode": ctx.student_tags_mode, + "archived_training_days": training_days_override + or ctx.archived_training_days, + # Helper lists for dropdowns + "all_training_day_types": get_all_training_day_types(training_program), + "all_student_tags": get_all_student_tags( + self.sql_session, + training_program, + include_historical=include_historical_tags, + ), + } + ) + + +class TrainingProgramAttendanceHandler(TrainingProgramFilterMixin, BaseHandler): + """Display attendance data for all archived training days.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + tp = self.safe_get_item(TrainingProgram, training_program_id) + ctx = self.get_filter_context(tp) + + view_data = get_attendance_view_data(ctx) + + self.set_common_params(tp, ctx, include_historical_tags=False) + self.r_params.update(view_data) + + # Pending delays logic (Notification check) + # Note: This relies on training_day_notifications set by base/decorators + pending_list = [] + td_notif = self.r_params.get("training_day_notifications", {}) + for td in tp.training_days: + if ( + td.contest + and td_notif.get(td.id, {}).get("pending_delay_requests", 0) > 0 + ): + pending_list.append( + { + "contest_id": td.contest_id, + "name": td.contest.name, + "pending_count": td_notif[td.id]["pending_delay_requests"], + } + ) + self.r_params["training_days_with_pending_delays"] = pending_list + + self.render("training_program_attendance.html", **self.r_params) + + +class TrainingProgramCombinedRankingHandler(TrainingProgramFilterMixin, BaseHandler): + """Display combined ranking data for all archived training days.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + tp = self.safe_get_item(TrainingProgram, training_program_id) + ctx = self.get_filter_context(tp) + + # We need ranking data AND basic attendance data (for student list cross-ref) + ranking_view = get_ranking_view_data(ctx) + attendance_view = get_attendance_view_data(ctx) + + self.set_common_params( + tp, + ctx, + include_historical_tags=True, + training_days_override=ranking_view["filtered_training_days"], + ) + self.r_params.update(ranking_view) + # Merge attendance data (overwrite sorted_students with the one from ranking if desired, + # but usually they should be similar. Ranking view prioritizes ranking students). + self.r_params["attendance_data"] = attendance_view["attendance_data"] + + self.render("training_program_combined_ranking.html", **self.r_params) + + +class TrainingProgramCombinedRankingHistoryHandler( + TrainingProgramFilterMixin, BaseHandler +): + """Return score history data for combined ranking graph (JSON).""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + tp = self.safe_get_item(TrainingProgram, training_program_id) + ctx = self.get_filter_context(tp) + + data = get_history_json_data(ctx) + + self.set_header("Content-Type", "application/json") + self.write(json.dumps(data)) + + +class TrainingProgramCombinedRankingDetailHandler( + TrainingProgramFilterMixin, BaseHandler +): + """Show detailed score/rank progress for a student.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str, student_id: str): + tp = self.safe_get_item(TrainingProgram, training_program_id) + student = self.safe_get_item(Student, student_id) + + if student.training_program_id != tp.id or ( + student.participation and student.participation.hidden + ): + raise tornado.web.HTTPError(404) + + ctx = self.get_filter_context(tp) + detail_data = get_student_detail_data(ctx, tp, student) + + # Build History URL + history_url = self.url("training_program", tp.id, "combined_ranking", "history") + params = {} + if ctx.start_date: + params["start_date"] = ctx.start_date.isoformat() + if ctx.end_date: + params["end_date"] = ctx.end_date.isoformat() + if ctx.training_day_types: + params["training_day_types"] = ",".join(ctx.training_day_types) + if ctx.student_tags: + params["student_tags"] = ",".join(ctx.student_tags) + params["student_tags_mode"] = ctx.student_tags_mode + if params: + history_url += "?" + urlencode(params) + + self.set_common_params(tp, ctx, include_historical_tags=True) + self.r_params.update(detail_data) + self.r_params["student"] = student + self.r_params["user_id"] = ( + str(student.participation.user_id) if student.participation else "0" + ) + self.r_params["history_url"] = history_url + + self.render("training_program_combined_ranking_detail.html", **self.r_params) + + +class UpdateAttendanceHandler(BaseHandler): + """Update attendance record (justified status, comment, and recorded).""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str, attendance_id: str): + tp = self.safe_get_item(TrainingProgram, training_program_id) + att = self.safe_get_item(ArchivedAttendance, attendance_id) + + if att.training_day.training_program_id != tp.id: + self.write_error_json(403, "Attendance not in this program") + return + + try: + data = json.loads(self.request.body) + except json.JSONDecodeError: + self.write_error_json(400, "Invalid JSON") + return + + # Validate and Apply + try: + if "justified" in data: + justified = bool(data["justified"]) + if justified and att.status != "missed": + raise ValueError("Only missed attendances can be justified") + att.justified = justified + + if "comment" in data: + comment = data["comment"] + att.comment = str(comment).strip() if comment else None + + if "recorded" in data: + recorded = bool(data["recorded"]) + if recorded and att.status == "missed": + raise ValueError("Only non-missed attendances can be recorded") + att.recorded = recorded + + except ValueError as e: + self.write_error_json(400, str(e)) + return + + if self.try_commit(): + self.write( + { + "success": True, + "justified": att.justified, + "comment": att.comment, + "recorded": att.recorded, + } + ) + + def write_error_json(self, status_code: int, message: str): + self.set_status(status_code) + self.write({"success": False, "error": message}) diff --git a/cms/server/admin/handlers/trainingday.py b/cms/server/admin/handlers/trainingday.py new file mode 100644 index 0000000000..df2a4f94bc --- /dev/null +++ b/cms/server/admin/handlers/trainingday.py @@ -0,0 +1,806 @@ +#!/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 . + +"""Admin handlers for Training Days. + +Training days are individual training sessions within a training program. +Each training day has its own contest for submissions and can have main groups +with custom timing configurations. +""" + +from datetime import datetime as dt, timedelta +import json + +import tornado.web + +from sqlalchemy import func + +from cms.db import ( + Contest, + TrainingProgram, + Participation, + Submission, + Question, + Student, + Task, + TrainingDay, + TrainingDayGroup, +) +from cms.server.admin.handlers.utils import get_all_training_day_types, parse_tags +from cmscommon.datetime import make_datetime, get_timezone, get_timezone_name + +from .base import BaseHandler, require_permission, parse_datetime_with_timezone + + +def parse_and_validate_duration( + hours_str: str, + minutes_str: str, + context: str = "" +) -> tuple[int, int]: + """Parse and validate duration hours and minutes. + + Args: + hours_str: String representation of hours (can be empty) + minutes_str: String representation of minutes (can be empty) + context: Optional context for error messages (e.g., "Group 'advanced'") + + Returns: + Tuple of (hours, minutes) as integers + + Raises: + ValueError: If validation fails + """ + hours_str = hours_str.strip() + minutes_str = minutes_str.strip() + hours = int(hours_str) if hours_str else 0 + minutes = int(minutes_str) if minutes_str else 0 + provided = bool(hours_str or minutes_str) + + prefix = f"{context} " if context else "" + + if hours < 0: + raise ValueError(f"{prefix}Duration hours cannot be negative") + if minutes < 0 or minutes >= 60: + raise ValueError(f"{prefix}Duration minutes must be between 0 and 59") + if provided and hours == 0 and minutes == 0: + raise ValueError(f"{prefix}Duration must be positive") + + return hours, minutes + + +def calculate_group_times( + start_str: str, + duration_hours_str: str, + duration_minutes_str: str, + tz, + context: str = "", +) -> tuple[dt | None, dt | None]: + """Parse start time and duration to calculate start and end times. + + Args: + start_str: String representation of start time + duration_hours_str: String representation of duration hours + duration_minutes_str: String representation of duration minutes + tz: Timezone for parsing start time + context: Optional context for error messages + + Returns: + Tuple of (start_time, end_time). Both can be None. + """ + start_time = None + if start_str and start_str.strip(): + start_time = parse_datetime_with_timezone(start_str.strip(), tz) + + duration_hours, duration_minutes = parse_and_validate_duration( + duration_hours_str, duration_minutes_str, context=context + ) + + end_time = None + if duration_hours > 0 or duration_minutes > 0: + if not start_time: + prefix = f"{context} " if context else "" + raise ValueError( + f"{prefix}Duration cannot be specified without a start time" + ) + + duration = timedelta(hours=duration_hours, minutes=duration_minutes) + end_time = start_time + duration + + return start_time, end_time + + +def validate_group_times_within_contest( + group_start: dt | None, + group_end: dt | None, + contest_start: dt | None, + contest_stop: dt | None, + context: str = "Group", +): + """Validate that group times are within contest bounds. + + Args: + group_start: Group start datetime + group_end: Group end datetime + contest_start: Contest start datetime + contest_stop: Contest stop datetime + context: Context string for error messages (e.g. "Group 'A'") + + Raises: + ValueError: If group times are outside contest bounds + """ + if group_start and contest_start and group_start < contest_start: + raise ValueError(f"{context} start time cannot be before training day start") + if group_end and contest_stop and group_end > contest_stop: + raise ValueError(f"{context} end time cannot be after training day end") + + +class TrainingProgramTrainingDaysHandler(BaseHandler): + """List and manage training days in a training program.""" + REORDER = "reorder" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + + self.render_params_for_training_program(training_program) + self.r_params["all_training_day_types"] = get_all_training_day_types( + training_program) + + self.render("training_program_training_days.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") + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + + operation: str = self.get_argument("operation", "") + + if operation == self.REORDER: + try: + reorder_data = self.get_argument("reorder_data", "") + if not reorder_data: + raise ValueError("No reorder data provided") + + order_list = json.loads(reorder_data) + + if not isinstance(order_list, list): + raise ValueError("Reorder data must be a list") + + active_training_days = [ + td for td in training_program.training_days + if td.contest is not None + ] + td_by_id = {str(td.id): td for td in active_training_days} + + # Validate that order_list contains each active td id exactly once + expected_ids = set(td_by_id.keys()) + received_ids = set() + for item in order_list: + td_id = str(item.get("training_day_id", "")) + if td_id in received_ids: + raise ValueError(f"Duplicate training day id: {td_id}") + received_ids.add(td_id) + + if received_ids != expected_ids: + missing = expected_ids - received_ids + extra = received_ids - expected_ids + raise ValueError( + f"Reorder data mismatch. Missing: {missing}, Extra: {extra}" + ) + + # Validate that new_position values form a complete 0-based permutation + num_active = len(active_training_days) + expected_positions = set(range(num_active)) + received_positions = set() + for item in order_list: + new_pos = int(item.get("new_position", -1)) + if new_pos < 0 or new_pos >= num_active: + raise ValueError( + f"Position {new_pos} out of range [0, {num_active - 1}]" + ) + if new_pos in received_positions: + raise ValueError(f"Duplicate position: {new_pos}") + received_positions.add(new_pos) + + if received_positions != expected_positions: + raise ValueError( + f"Positions must be 0 to {num_active - 1}, " + f"got {sorted(received_positions)}" + ) + + # Only clear positions for active training days + for td in active_training_days: + td.position = None + self.sql_session.flush() + + # Apply the new positions + for item in order_list: + td_id = str(item["training_day_id"]) + new_pos = int(item["new_position"]) + td_by_id[td_id].position = new_pos + self.sql_session.flush() + + except Exception as error: + self.service.add_notification( + make_datetime(), "Reorder failed", repr(error) + ) + self.redirect(fallback_page) + return + + self.try_commit() + self.redirect(fallback_page) + + +class AddTrainingDayHandler(BaseHandler): + """Add a new training day to a training program.""" + + @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 + + self.render_params_for_training_program(training_program) + + # Get all student tags for the tagify select dropdown + tags_query = self.sql_session.query( + func.unnest(Student.student_tags).label("tag") + ).filter( + Student.training_program_id == training_program.id + ).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") + if not name or not name.strip(): + raise ValueError("Name is required") + + description = self.get_argument("description", "") + if not description or not description.strip(): + description = name + + contest_kwargs: dict = { + "name": name, + "description": description, + } + + # Parse main group configuration (if any) + group_tags = self.get_arguments("group_tag_name[]") + group_starts = self.get_arguments("group_start_time[]") + group_duration_hours = self.get_arguments("group_duration_hours[]") + group_duration_minutes = self.get_arguments("group_duration_minutes[]") + + # Collect valid groups and their times for defaulting + groups_to_create = [] + earliest_group_start = None + latest_group_end = None + + for i, tag in enumerate(group_tags): + tag = tag.strip() + if not tag: + continue + + start_s = group_starts[i] if i < len(group_starts) else "" + hours_s = ( + group_duration_hours[i] if i < len(group_duration_hours) else "" + ) + mins_s = ( + group_duration_minutes[i] if i < len(group_duration_minutes) else "" + ) + + group_start, group_end = calculate_group_times( + start_s, hours_s, mins_s, tz, context=f"Group '{tag}'" + ) + + if group_start and ( + earliest_group_start is None or group_start < earliest_group_start + ): + earliest_group_start = group_start + if group_end: + if latest_group_end is None or group_end > latest_group_end: + latest_group_end = group_end + + alphabetical = self.get_argument(f"alphabetical_{i}", None) is not None + + groups_to_create.append({ + "tag_name": tag, + "start_time": group_start, + "end_time": group_end, + "alphabetical_task_order": alphabetical, + }) + + # Parse optional start time and duration from inputs + # Times are in the managing contest timezone + start_str = self.get_argument("start", "") + duration_hours_str = self.get_argument("duration_hours", "") + duration_minutes_str = self.get_argument("duration_minutes", "") + + s_time, e_time = calculate_group_times( + start_str, duration_hours_str, duration_minutes_str, tz + ) + + if s_time: + contest_kwargs["start"] = s_time + else: + # Default to after training program end year (so contestants can't start until configured) + program_end_year = managing_contest.stop.year + default_date = dt(program_end_year + 1, 1, 1, 0, 0) + contest_kwargs["start"] = ( + earliest_group_start if earliest_group_start else 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 e_time: + contest_kwargs["stop"] = e_time + else: + contest_kwargs["stop"] = ( + latest_group_end if latest_group_end else contest_kwargs["start"] + ) + + contest = Contest(**contest_kwargs) + self.sql_session.add(contest) + self.sql_session.flush() + + position = len(training_program.training_days) + training_day = TrainingDay( + training_program=training_program, + contest=contest, + position=position, + ) + self.sql_session.add(training_day) + + # Create main groups + seen_tags = set() + for group_data in groups_to_create: + if group_data["tag_name"] in seen_tags: + raise ValueError(f"Duplicate tag '{group_data['tag_name']}'") + seen_tags.add(group_data["tag_name"]) + + # Validate group times are within contest bounds + validate_group_times_within_contest( + group_data["start_time"], + group_data["end_time"], + contest_kwargs.get("start"), + contest_kwargs.get("stop"), + context=f"Group '{group_data['tag_name']}'", + ) + + group = TrainingDayGroup( + training_day=training_day, + **group_data + ) + self.sql_session.add(group) + + # 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 + hidden = student.participation.hidden + participation = Participation(contest=contest, user=user, hidden=hidden) + self.sql_session.add(participation) + + 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.redirect(self.url("training_program", training_program_id, "training_days")) + else: + self.redirect(fallback_page) + + +class RemoveTrainingDayHandler(BaseHandler): + """Confirm and remove a training day from a training program.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def get(self, training_program_id: str, training_day_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + training_day = self.safe_get_item(TrainingDay, training_day_id) + + if training_day.training_program_id != training_program.id: + raise tornado.web.HTTPError(404) + + self.render_params_for_training_program(training_program) + self.r_params["training_day"] = training_day + self.r_params["unanswered"] = 0 # Override for deletion confirmation page + + # Stats for warning message + self.r_params["task_count"] = len(training_day.tasks) + # For archived training days, contest_id is None so counts are 0 + if training_day.contest_id is not None: + self.r_params["participation_count"] = ( + self.sql_session.query(Participation) + .filter(Participation.contest_id == training_day.contest_id) + .count() + ) + self.r_params["submission_count"] = ( + self.sql_session.query(Submission) + .join(Participation) + .filter(Participation.contest_id == training_day.contest_id) + .count() + ) + else: + self.r_params["participation_count"] = 0 + self.r_params["submission_count"] = 0 + + self.render("training_day_remove.html", **self.r_params) + + @require_permission(BaseHandler.PERMISSION_ALL) + def delete(self, training_program_id: str, training_day_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + training_day = self.safe_get_item(TrainingDay, training_day_id) + + if training_day.training_program_id != training_program.id: + raise tornado.web.HTTPError(404) + + contest = training_day.contest + position = training_day.position + + # Always detach tasks from the training day - they stay in the training program. + # The database FK has ON DELETE SET NULL, but we also clear training_day_num + # explicitly to remove stale ordering metadata. + tasks = ( + self.sql_session.query(Task) + .filter(Task.training_day == training_day) + .order_by(Task.training_day_num) + .all() + ) + + for task in tasks: + task.training_day = None + task.training_day_num = None + + self.sql_session.flush() + + self.sql_session.delete(training_day) + if contest is not None: + self.sql_session.delete(contest) + + self.sql_session.flush() + + for td in training_program.training_days: + if td.position is not None and position is not None and td.position > position: + td.position -= 1 + + self.try_commit() + self.write("../../training_days") + + +class AddTrainingDayGroupHandler(BaseHandler): + """Add a main group to a training day.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, contest_id: str): + contest = self.safe_get_item(Contest, contest_id) + training_day = contest.training_day + + if training_day is None: + raise tornado.web.HTTPError(404, "Not a training day contest") + + 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(): + raise ValueError("Tag name is required") + + # Strip whitespace before duplicate check to avoid bypass + tag_name = tag_name.strip() + + # Check if tag is already used + existing = self.sql_session.query(TrainingDayGroup)\ + .filter(TrainingDayGroup.training_day == training_day)\ + .filter(TrainingDayGroup.tag_name == tag_name)\ + .first() + if existing: + raise ValueError(f"Tag '{tag_name}' is already a main group") + + # Parse optional start time and duration + start_str = self.get_argument("start_time", "") + duration_hours_str = self.get_argument("duration_hours", "") + duration_minutes_str = self.get_argument("duration_minutes", "") + + group_kwargs: dict = { + "training_day": training_day, + "tag_name": tag_name, + "alphabetical_task_order": self.get_argument("alphabetical_task_order", None) is not None, + } + + # Calculate start and end times + s_time, e_time = calculate_group_times( + start_str, duration_hours_str, duration_minutes_str, tz + ) + + if s_time: + group_kwargs["start_time"] = s_time + if e_time: + group_kwargs["end_time"] = e_time + + # Validate group times are within contest bounds + validate_group_times_within_contest( + group_kwargs.get("start_time"), + group_kwargs.get("end_time"), + contest.start, + contest.stop, + context="Group", + ) + + group = TrainingDayGroup(**group_kwargs) + self.sql_session.add(group) + + except Exception as error: + self.service.add_notification(make_datetime(), "Invalid field(s)", repr(error)) + self.redirect(fallback_page) + return + + self.try_commit() + self.redirect(fallback_page) + + +class UpdateTrainingDayGroupsHandler(BaseHandler): + """Update all main groups for a training day.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, contest_id: str): + contest = self.safe_get_item(Contest, contest_id) + training_day = contest.training_day + + if training_day is None: + raise tornado.web.HTTPError(404, "Not a training day contest") + + 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[]") + duration_hours_list = self.get_arguments("duration_hours[]") + duration_minutes_list = self.get_arguments("duration_minutes[]") + + 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") + + # Calculate start and end times + hours_str = ( + duration_hours_list[i] if i < len(duration_hours_list) else "" + ) + mins_str = ( + duration_minutes_list[i] if i < len(duration_minutes_list) else "" + ) + + group.start_time, group.end_time = calculate_group_times( + start_times[i], + hours_str, + mins_str, + tz, + context=f"Group '{group.tag_name}'", + ) + + # Validate group times are within contest bounds + validate_group_times_within_contest( + group.start_time, + group.end_time, + contest.start, + contest.stop, + context=f"Group '{group.tag_name}'", + ) + + # Update alphabetical task order (checkbox - present means checked) + group.alphabetical_task_order = self.get_argument(f"alphabetical_{group_id}", None) is not None + + except Exception as error: + self.service.add_notification(make_datetime(), "Invalid field(s)", repr(error)) + self.redirect(fallback_page) + return + + self.try_commit() + self.redirect(fallback_page) + + +class RemoveTrainingDayGroupHandler(BaseHandler): + """Remove a main group from a training day.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, contest_id: str, group_id: str): + contest = self.safe_get_item(Contest, contest_id) + training_day = contest.training_day + + if training_day is None: + raise tornado.web.HTTPError(404, "Not a training day contest") + + group = self.safe_get_item(TrainingDayGroup, group_id) + + if group.training_day_id != training_day.id: + raise tornado.web.HTTPError(404, "Group does not belong to this training day") + + self.sql_session.delete(group) + self.try_commit() + self.redirect(self.url("contest", contest_id)) + + +class TrainingDayTypesHandler(BaseHandler): + """Handler for updating training day types via AJAX.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str, training_day_id: str): + self.set_header("Content-Type", "application/json") + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + training_day = self.safe_get_item(TrainingDay, training_day_id) + + if training_day.training_program_id != training_program.id: + self.set_status(404) + self.write({"error": "Training day does not belong to this program"}) + return + + try: + types_str = self.get_argument("training_day_types", "") + training_day.training_day_types = parse_tags(types_str) + + if self.try_commit(): + self.write({ + "success": True, + "types": training_day.training_day_types + }) + else: + self.set_status(500) + self.write({"error": "Failed to save"}) + + except Exception as error: + self.set_status(400) + self.write({"error": str(error)}) + + +class ScoreboardSharingHandler(BaseHandler): + """Handler for updating scoreboard sharing settings for archived training days. + + The scoreboard_sharing format is: + { + "tag_name": { + "top_names": int or "all", # How many top students show full names + "top_to_show": int or "all" # How many students to show in total + }, + ... + "__everyone__": { # Special key for sharing with all students + "top_names": int or "all", + "top_to_show": int or "all" + } + } + """ + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str, training_day_id: str): + self.set_header("Content-Type", "application/json") + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + training_day = self.safe_get_item(TrainingDay, training_day_id) + + if training_day.training_program_id != training_program.id: + self.set_status(404) + self.write({"error": "Training day does not belong to this program"}) + return + + # Only allow for archived training days + if training_day.contest is not None: + self.set_status(400) + self.write({"error": "Scoreboard sharing is only available for archived training days"}) + return + + try: + sharing_data_str = self.get_argument("scoreboard_sharing", "") + + if not sharing_data_str or sharing_data_str.strip() == "": + # Clear sharing settings + training_day.scoreboard_sharing = None + else: + sharing_data = json.loads(sharing_data_str) + + # Validate the format + if not isinstance(sharing_data, dict): + raise ValueError("Invalid format: expected object") + + seen_tags: set[str] = set() + for tag, settings in sharing_data.items(): + # Allow special "__everyone__" key + if tag == "__everyone__": + normalized_tag = tag + else: + normalized_tag = tag.strip().lower() + if not normalized_tag: + raise ValueError("Tag cannot be empty") + if normalized_tag != tag: + raise ValueError(f"Invalid tag '{tag}': remove leading/trailing spaces") + + if normalized_tag in seen_tags: + raise ValueError(f"Duplicate tag '{tag}'") + seen_tags.add(normalized_tag) + + if not isinstance(settings, dict): + raise ValueError(f"Invalid settings for tag '{tag}'") + + # Validate top_names (required) + if "top_names" not in settings: + raise ValueError(f"Missing 'top_names' for tag '{tag}'") + top_names = settings["top_names"] + if top_names != "all": + if not isinstance(top_names, int) or top_names < 0: + raise ValueError(f"Invalid 'top_names' for tag '{tag}': must be non-negative integer or 'all'") + + # Validate top_to_show (optional, defaults to "all") + top_to_show = settings.get("top_to_show", "all") + if top_to_show != "all": + if not isinstance(top_to_show, int) or top_to_show < 0: + raise ValueError(f"Invalid 'top_to_show' for tag '{tag}': must be non-negative integer or 'all'") + + # Validate top_names <= top_to_show when both are integers + if top_names != "all" and top_to_show != "all": + if top_names > top_to_show: + raise ValueError( + f"Invalid settings for tag '{tag}': top_names ({top_names}) " + f"cannot exceed top_to_show ({top_to_show})" + ) + + training_day.scoreboard_sharing = sharing_data + + if self.try_commit(): + self.write({ + "success": True, + "scoreboard_sharing": training_day.scoreboard_sharing + }) + else: + self.set_status(500) + self.write({"error": "Failed to save"}) + + except json.JSONDecodeError as error: + self.set_status(400) + self.write({"error": f"Invalid JSON: {str(error)}"}) + except Exception as error: + self.set_status(400) + self.write({"error": str(error)}) diff --git a/cms/server/admin/handlers/trainingprogram.py b/cms/server/admin/handlers/trainingprogram.py new file mode 100644 index 0000000000..94bdea38eb --- /dev/null +++ b/cms/server/admin/handlers/trainingprogram.py @@ -0,0 +1,452 @@ +#!/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 . + +"""Admin handlers for Training Programs. + +Training programs organize year-long training with multiple sessions. +Each training program has a managing contest that handles all submissions. + +This module contains core training program handlers. Related handlers are +split into separate modules: +- trainingday.py: Training day management handlers +- student.py: Student management handlers +- trainingprogramtask.py: Task management and ranking handlers +- archive.py: Archive, attendance, and combined ranking handlers +""" + +from datetime import datetime as dt + +import tornado.web + +from sqlalchemy import func + +from cms.db import ( + Contest, + TrainingProgram, + Participation, + Submission, + Task, +) +from cms.server.admin.handlers.utils import get_training_day_notifications +from cmscommon.datetime import make_datetime + +from .base import BaseHandler, SimpleHandler, require_permission + +from .trainingprogramtask import ( + TrainingProgramTasksHandler, + AddTrainingProgramTaskHandler, + RemoveTrainingProgramTaskHandler, + TrainingProgramRankingHandler, + _shift_task_nums, +) + +__all__ = [ + "AddTrainingProgramHandler", + "AddTrainingProgramTaskHandler", + "RemoveTrainingProgramHandler", + "RemoveTrainingProgramTaskHandler", + "TrainingProgramHandler", + "TrainingProgramListHandler", + "TrainingProgramRankingHandler", + "TrainingProgramTasksHandler", + "_shift_task_nums", +] + + +class TrainingProgramListHandler(BaseHandler): + """List all training programs. + + GET returns the list of all training programs with stats. + POST handles operations on a specific training program (e.g., removing). + """ + REMOVE = "Remove" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self): + self.r_params = self.render_params() + training_programs = ( + self.sql_session.query(TrainingProgram) + .order_by(TrainingProgram.name) + .all() + ) + self.r_params["training_programs"] = training_programs + + # Calculate aggregate stats for the stats cards + total_students = 0 + active_programs = 0 + active_training_days = 0 + + # Calculate notifications for each training day (keyed by td.id) + training_day_notifications: dict[int, dict] = {} + + for tp in training_programs: + total_students += ( + self.sql_session.query(func.count(Participation.id)) + .filter(Participation.contest_id == tp.managing_contest_id) + .scalar() + ) + # Count active training days (those with a contest) + active_tds = [td for td in tp.training_days if td.contest is not None] + active_training_days += len(active_tds) + # A program is "active" if it has at least one active training day + if active_tds: + active_programs += 1 + + # Calculate notifications for each active training day + for td in active_tds: + training_day_notifications[td.id] = get_training_day_notifications( + self.sql_session, td + ) + + self.r_params["total_students"] = total_students + self.r_params["active_programs"] = active_programs + self.r_params["active_training_days"] = active_training_days + self.r_params["training_day_notifications"] = training_day_notifications + + self.render("training_programs.html", **self.r_params) + + @require_permission(BaseHandler.AUTHENTICATED) + def post(self): + training_program_id: str = self.get_argument("training_program_id") + operation: str = self.get_argument("operation") + + if operation == self.REMOVE: + asking_page = self.url("training_programs", training_program_id, "remove") + self.redirect(asking_page) + else: + self.service.add_notification( + make_datetime(), "Invalid operation %s" % operation, "" + ) + self.redirect(self.url("training_programs")) + + +class TrainingProgramHandler(BaseHandler): + """View/edit a single training program.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + self.render_params_for_training_program(training_program) + self.render("training_program.html", **self.r_params) + + @require_permission(BaseHandler.PERMISSION_ALL) + 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") + + if not attrs["name"] or not attrs["name"].strip(): + raise ValueError("Name is required") + + if not attrs["description"] or not attrs["description"].strip(): + attrs["description"] = attrs["name"] + + training_program.set_attrs(attrs) + + # Sync description to managing contest + 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 ( + 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") + + except Exception as error: + self.service.add_notification( + make_datetime(), "Invalid field(s)", repr(error) + ) + self.redirect(fallback) + return + + if self.try_commit(): + # Update the contest on RWS. + self.service.proxy_service.reinitialize() + self.redirect(fallback) + + +class AddTrainingProgramHandler( + SimpleHandler("add_training_program.html", permission_all=True) +): + """Add a new training program.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def get(self): + self.r_params = self.render_params() + self.render("add_training_program.html", **self.r_params) + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self): + fallback = self.url("training_programs", "add") + operation = self.get_argument("operation", "Create") + + try: + name = self.get_argument("name") + if not name or not name.strip(): + raise ValueError("Name is required") + + description = self.get_argument("description", "") + if not description or not description.strip(): + description = name + + # Parse optional start and stop times from datetime-local inputs + start_str = self.get_argument("start", "") + stop_str = self.get_argument("stop", "") + + contest_kwargs: dict = { + "name": name, + "description": description, + "allow_delay_requests": False, + } + + if start_str: + contest_kwargs["start"] = dt.strptime(start_str, "%Y-%m-%dT%H:%M") + + if stop_str: + contest_kwargs["stop"] = dt.strptime(stop_str, "%Y-%m-%dT%H:%M") + + # Validate that stop is not before start + if "start" in contest_kwargs and "stop" in contest_kwargs: + if contest_kwargs["stop"] < contest_kwargs["start"]: + raise ValueError("End time must be after start time") + + # Create the managing contest + managing_contest = Contest(**contest_kwargs) + self.sql_session.add(managing_contest) + + # Create the training program + training_program = TrainingProgram( + name=name, + description=description, + managing_contest=managing_contest, + ) + self.sql_session.add(training_program) + + except Exception as error: + self.service.add_notification(make_datetime(), "Invalid field(s)", repr(error)) + self.redirect(fallback) + return + + if self.try_commit(): + if operation == "Create and add another": + self.redirect(fallback) + else: + self.redirect(self.url("training_programs")) + else: + self.redirect(fallback) + + +class RemoveTrainingProgramHandler(BaseHandler): + """Confirm and remove a training program. + + On delete, the managing contest and all its data (participations, + submissions, tasks) will also be deleted due to CASCADE. + """ + + @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 + + self.render_params_for_training_program(training_program) + self.r_params["unanswered"] = 0 # Override for deletion confirmation page + + # Count related data that will be deleted + self.r_params["participation_count"] = ( + self.sql_session.query(Participation) + .filter(Participation.contest == managing_contest) + .count() + ) + training_day_contest_ids = [td.contest_id for td in training_program.training_days] + self.r_params["training_day_count"] = len(training_day_contest_ids) + self.r_params["training_day_participation_count"] = ( + self.sql_session.query(Participation) + .filter(Participation.contest_id.in_(training_day_contest_ids)) + .count() + if training_day_contest_ids else 0 + ) + self.r_params["submission_count"] = ( + self.sql_session.query(Submission) + .join(Participation) + .filter(Participation.contest == managing_contest) + .count() + ) + self.r_params["training_day_submission_count"] = ( + self.sql_session.query(Submission) + .join(Participation) + .filter(Participation.contest_id.in_(training_day_contest_ids)) + .count() + if training_day_contest_ids else 0 + ) + self.r_params["task_count"] = len(managing_contest.tasks) + + # Other contests available to move tasks into (excluding training day contests + # and managing contests for training programs) + self.r_params["other_contests"] = ( + self.sql_session.query(Contest) + .filter(Contest.id != managing_contest.id) + .filter(~Contest.name.like(r'\_\_%', escape='\\')) + .filter(~Contest.training_day.has()) + .filter(~Contest.training_program.has()) + .order_by(Contest.name) + .all() + ) + + self.render("training_program_remove.html", **self.r_params) + + @require_permission(BaseHandler.PERMISSION_ALL) + def delete(self, training_program_id: str): + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + action = self.get_argument("action", "delete_all") + target_contest_id = self.get_argument("target_contest_id", None) + + # Handle tasks before deleting the training program + tasks = ( + self.sql_session.query(Task) + .filter(Task.contest == managing_contest) + .order_by(Task.num) + .all() + ) + + if action == "move": + if not target_contest_id: + raise tornado.web.HTTPError(400, "Target contest is required") + target_contest = self.safe_get_item(Contest, target_contest_id) + + # Phase 1: clear nums on moving tasks (and detach training day links) + # so we can reassign without violating the unique constraint. + for task in tasks: + task.num = None + task.training_day = None + task.training_day_num = None + self.sql_session.flush() + + # Phase 2: append after current max num in target, preserving gaps. + max_num = ( + self.sql_session.query(func.max(Task.num)) + .filter(Task.contest == target_contest) + .scalar() + ) + base_num = (max_num or -1) + 1 + + for i, task in enumerate(tasks): + task.contest = target_contest + task.num = base_num + i + self.sql_session.flush() + + elif action == "detach": + for task in tasks: + task.contest = None + task.num = None + task.training_day = None + task.training_day_num = None + self.sql_session.flush() + + elif action == "delete_all": + for task in tasks: + self.sql_session.delete(task) + self.sql_session.flush() + else: + raise tornado.web.HTTPError(400, "Invalid action") + + # Delete all training days (and their contests/participations). + for training_day in training_program.training_days: + td_contest = training_day.contest + self.sql_session.delete(training_day) + # Only delete td_contest if it exists (archived days have None) + if td_contest is not None: + self.sql_session.delete(td_contest) + + # Delete the training program (tasks already handled above) + self.sql_session.delete(training_program) + + # Then delete the managing contest (this cascades to participations, + # submissions, etc. - tasks already handled above) + self.sql_session.delete(managing_contest) + + self.try_commit() + self.write("../../training_programs") diff --git a/cms/server/admin/handlers/trainingprogramtask.py b/cms/server/admin/handlers/trainingprogramtask.py new file mode 100644 index 0000000000..a3f8090918 --- /dev/null +++ b/cms/server/admin/handlers/trainingprogramtask.py @@ -0,0 +1,449 @@ +#!/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 . + +"""Admin handlers for Training Program Tasks and Rankings. + +This module contains handlers for managing tasks within training programs +and displaying training program rankings. + +Handlers: +- TrainingProgramTasksHandler: Manage tasks in a training program +- AddTrainingProgramTaskHandler: Add a task to a training program +- RemoveTrainingProgramTaskHandler: Remove a task from a training program +- TrainingProgramRankingHandler: Show ranking for a training program +""" + +import json +import logging + +import tornado.web + +from cms.db import ( + Contest, + TrainingProgram, + Task, + Student, + StudentTask, +) +from cms.server.util import calculate_task_archive_progress +from cms.server.admin.handlers.utils import ( + get_student_tags_by_participation, + build_user_to_student_map, +) +from cmscommon.datetime import make_datetime + +from .base import BaseHandler, require_permission +from .contestranking import RankingCommonMixin + + +def _shift_task_nums( + sql_session, + filter_attr, + filter_value, + num_attr, + threshold: int, + delta: int +) -> None: + """Shift task numbers after insertion or removal. + + This utility function handles the common pattern of incrementing or + decrementing task numbers when a task is added or removed from a + sequence (e.g., contest tasks or training day tasks). + + sql_session: The SQLAlchemy session. + filter_attr: The attribute to filter by (e.g., Task.contest, Task.training_day). + filter_value: The value to filter for. + num_attr: The num attribute to shift (e.g., Task.num, Task.training_day_num). + threshold: The threshold value - tasks with num > threshold will be shifted. + delta: The amount to shift by (+1 for insertion, -1 for removal). + """ + if delta > 0: + # For insertion, process in descending order to avoid conflicts + order = num_attr.desc() + condition = num_attr >= threshold + else: + # For removal, process in ascending order + order = num_attr + condition = num_attr > threshold + + for t in sql_session.query(Task)\ + .filter(filter_attr == filter_value)\ + .filter(condition)\ + .order_by(order)\ + .all(): + setattr(t, num_attr.key, getattr(t, num_attr.key) + delta) + sql_session.flush() + + +class TrainingProgramTasksHandler(BaseHandler): + """Manage tasks in a training program.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + + self.render_params_for_training_program(training_program) + self.r_params["unassigned_tasks"] = \ + self.sql_session.query(Task)\ + .filter(Task.contest_id.is_(None))\ + .all() + + self.render("training_program_tasks.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, "tasks") + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + try: + operation: str = self.get_argument("operation") + + # Handle detach operation for archived training day tasks + if operation.startswith("detach_"): + task_id = operation.split("_", 1)[1] + task = self.safe_get_item(Task, task_id) + # Validate task belongs to this training program + if task.contest != managing_contest: + raise ValueError("Task does not belong to this training program") + self._detach_task_from_training_day(task) + if self.try_commit(): + self.service.proxy_service.reinitialize() + self.redirect(fallback_page) + return + + # Handle reorder operation from drag-and-drop + if operation == "reorder": + reorder_data = self.get_argument("reorder_data", "") + if reorder_data: + self._reorder_tasks(managing_contest, reorder_data) + if self.try_commit(): + self.service.proxy_service.reinitialize() + self.redirect(fallback_page) + return + + except Exception as error: + self.service.add_notification( + make_datetime(), "Invalid field(s)", repr(error)) + self.redirect(fallback_page) + return + + self.redirect(fallback_page) + + def _reorder_tasks(self, contest: Contest, reorder_data: str) -> None: + """Reorder tasks based on drag-and-drop data. + + reorder_data: JSON string with list of {task_id, new_num} objects. + """ + try: + order_list = json.loads(reorder_data) + except json.JSONDecodeError as e: + logging.warning( + "Failed to parse reorder data: %s. Payload: %s", + e.msg, + reorder_data[:500], + ) + raise ValueError(f"Invalid JSON in reorder data: {e.msg}") from e + + if not isinstance(order_list, list): + raise ValueError("Reorder data must be a list") + + expected_ids = {t.id for t in contest.tasks} + received_ids = {int(item.get("task_id")) for item in order_list} + if received_ids != expected_ids: + raise ValueError("Reorder data must include each task exactly once") + + # Validate new_num for each entry (0-based indices) + num_tasks = len(contest.tasks) + expected_nums = set(range(0, num_tasks)) + received_nums = set() + + for item in order_list: + if "new_num" not in item: + raise ValueError("Missing 'new_num' in reorder data entry") + raw_num = item["new_num"] + try: + new_num = int(raw_num) + except (TypeError, ValueError): + raise ValueError( + f"Invalid 'new_num' value: {raw_num!r} is not an integer" + ) + if new_num < 0 or new_num >= num_tasks: + raise ValueError( + f"'new_num' {new_num} is out of range [0, {num_tasks - 1}]" + ) + received_nums.add(new_num) + + if received_nums != expected_nums: + raise ValueError( + "Reorder data must include each task number exactly once " + f"(expected {sorted(expected_nums)}, got {sorted(received_nums)})" + ) + + # First, set all task nums to None to avoid unique constraint issues + task_updates = [] + for item in order_list: + task = self.safe_get_item(Task, item["task_id"]) + new_num = int(item["new_num"]) + if task.contest == contest: + task_updates.append((task, new_num)) + task.num = None + self.sql_session.flush() + + # Then set the new nums + for task, new_num in task_updates: + task.num = new_num + self.sql_session.flush() + + def _detach_task_from_training_day(self, task: Task) -> None: + """Detach a task from its training day. + + This removes the training_day association from the task, making it + available for assignment to new training days. The task remains in + the training program. + + task: the task to detach. + """ + if task.training_day is None: + return + + training_day = task.training_day + training_day_num = task.training_day_num + + task.training_day = None + task.training_day_num = None + + self.sql_session.flush() + + # Reorder remaining tasks in the training day (only if there was a valid position) + if training_day_num is not None: + _shift_task_nums( + self.sql_session, + Task.training_day, + training_day, + Task.training_day_num, + training_day_num, + -1, + ) + + +class AddTrainingProgramTaskHandler(BaseHandler): + """Add a task to a training program.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str): + fallback_page = self.url("training_program", training_program_id, "tasks") + + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + try: + task_id: str = self.get_argument("task_id") + if task_id is None or task_id == "null" or task_id.strip() == "": + raise ValueError("Please select a valid task") + except Exception as error: + self.service.add_notification( + make_datetime(), "Invalid field(s)", repr(error)) + self.redirect(fallback_page) + return + + task = self.safe_get_item(Task, task_id) + + # Verify task is either unassigned or already belongs to this contest + if task.contest is not None and task.contest != managing_contest: + self.service.add_notification( + make_datetime(), + "Invalid field(s)", + "Task already assigned to another contest", + ) + self.redirect(fallback_page) + return + + task.num = len(managing_contest.tasks) + task.contest = managing_contest + + if self.try_commit(): + self.service.proxy_service.reinitialize() + + self.redirect(fallback_page) + + +class RemoveTrainingProgramTaskHandler(BaseHandler): + """Remove a task from a training program. + + The confirmation is now handled via a modal in the tasks page. + """ + + @require_permission(BaseHandler.PERMISSION_ALL) + def delete(self, training_program_id: str, task_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + task = self.safe_get_item(Task, task_id) + + # Validate task ownership - ensure task belongs to this training program's managing contest + if task.contest != managing_contest: + raise tornado.web.HTTPError( + 403, "Task does not belong to this training program" + ) + + task_num = task.num + + # Remove from training day if assigned + if task.training_day is not None: + training_day = task.training_day + training_day_num = task.training_day_num + task.training_day = None + task.training_day_num = None + + self.sql_session.flush() + + # Reorder remaining tasks in the training day + if training_day_num is not None: + _shift_task_nums( + self.sql_session, + Task.training_day, + training_day, + Task.training_day_num, + training_day_num, + -1, + ) + + # Remove from training program + task.contest = None + task.num = None + + self.sql_session.flush() + + # Reorder remaining tasks in the training program + if task_num is not None: + _shift_task_nums( + self.sql_session, Task.contest, managing_contest, Task.num, task_num, -1 + ) + + if self.try_commit(): + self.service.proxy_service.reinitialize() + + # Return absolute path to tasks page + self.write(f"../../../training_program/{training_program_id}/tasks") + + +class TrainingProgramRankingHandler(RankingCommonMixin, BaseHandler): + """Show ranking for a training program.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str, format: str = "online"): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + self.contest = self._load_contest_data(managing_contest.id) + + # Build a dict of (participation_id, task_id) -> bool for tasks that students can access + # A student can access a task if they have a StudentTask record for it + # Default is False since we're whitelisting access via StudentTask + can_access_by_pt = {} + for p in self.contest.participations: + for task in self.contest.get_tasks(): + can_access_by_pt[(p.id, task.id)] = False + + participation_ids = [p.id for p in self.contest.participations] + if participation_ids: + rows = ( + self.sql_session.query(Student.participation_id, StudentTask.task_id) + .join(StudentTask, Student.id == StudentTask.student_id) + .filter(Student.training_program_id == training_program.id) + .filter(Student.participation_id.in_(participation_ids)) + .all() + ) + for participation_id, task_id in rows: + can_access_by_pt[(participation_id, task_id)] = True + + show_teams = self._calculate_scores(self.contest, can_access_by_pt) + + # Store participation data before commit (SQLAlchemy expires attributes on commit) + participation_data = {} + for p in self.contest.participations: + if hasattr(p, "task_statuses"): + participation_data[p.id] = (p.task_statuses, p.total_score) + + # Build student tags lookup for each participation (batch query) + student_tags_by_participation = get_student_tags_by_participation( + self.sql_session, + training_program, + [p.id for p in self.contest.participations] + ) + + # Calculate task archive progress for this training program + task_archive_progress_by_participation = {} + user_to_student = build_user_to_student_map(training_program) + + for p in self.contest.participations: + student = user_to_student.get(p.user_id) + if student: + progress = calculate_task_archive_progress( + student, p, self.contest, self.sql_session + ) + task_archive_progress_by_participation[p.id] = progress + + # Commit to release any advisory locks taken during score calculation + self.sql_session.commit() + + # Re-assign task_statuses after commit (SQLAlchemy expired them) + for p in self.contest.participations: + if p.id in participation_data: + p.task_statuses, p.total_score = participation_data[p.id] + + self.render_params_for_training_program(training_program) + self.r_params["show_teams"] = show_teams + self.r_params["student_tags_by_participation"] = student_tags_by_participation + self.r_params["main_groups_data"] = None # Not used for training program ranking + self.r_params["task_archive_progress_by_participation"] = ( + task_archive_progress_by_participation + ) + + if format == "txt": + self.set_header("Content-Type", "text/plain") + filename = f"{training_program.name}_home_ranking.txt".replace( + " ", "_" + ).replace("/", "_") + self.set_header("Content-Disposition", f'attachment; filename="{filename}"') + self.render("ranking.txt", **self.r_params) + elif format == "csv": + self.set_header("Content-Type", "text/csv") + filename = f"{training_program.name}_home_ranking.csv".replace( + " ", "_" + ).replace("/", "_") + self.set_header("Content-Disposition", f'attachment; filename="{filename}"') + + export_participations = sorted( + [p for p in self.contest.participations if not p.hidden], + key=lambda p: p.total_score, + reverse=True, + ) + + csv_content = self._write_csv( + self.contest, + export_participations, + list(self.contest.get_tasks()), + student_tags_by_participation, + show_teams, + include_partial=True, + task_archive_progress_by_participation=task_archive_progress_by_participation, + ) + self.finish(csv_content) + else: + self.render("ranking.html", **self.r_params) diff --git a/cms/server/admin/handlers/user.py b/cms/server/admin/handlers/user.py index 5627471143..0f99d78369 100644 --- a/cms/server/admin/handlers/user.py +++ b/cms/server/admin/handlers/user.py @@ -34,7 +34,7 @@ from datetime import date from sqlalchemy import and_, exists -from cms.db import Contest, Participation, Submission, Team, User +from cms.db import Contest, Participation, Submission, Team, User, TrainingDay, TrainingProgram from cms.server.picture_utils import ( process_picture_upload, PictureValidationError ) @@ -193,11 +193,35 @@ def get(self, user_id): self.r_params = self.render_params() self.r_params["user"] = user - self.r_params["participations"] = ( - self.sql_session.query(Participation) - .filter(Participation.user == user) + + # Get all participations and separate them into categories + all_participations = self.sql_session.query(Participation)\ + .filter(Participation.user == user)\ .all() - ) + + # Separate participations into: + # 1. Training program participations (managing contest) + # 2. Training day participations (hidden from list) + # 3. Regular contest participations + training_program_participations = [] + regular_participations = [] + + for p in all_participations: + # Check if this is a training program's managing contest + if p.contest.training_program is not None: + training_program_participations.append(p) + # Check if this is a training day contest (hide from list) + elif p.contest.training_day is not None: + # Skip training day participations - they're managed via training program + pass + else: + regular_participations.append(p) + + self.r_params["participations"] = regular_participations + self.r_params["training_program_participations"] = training_program_participations + + # Filter out training day contests and managing contests from unassigned list + # (users should be added to training programs via the training program UI) self.r_params["unassigned_contests"] = exclude_internal_contests( self.sql_session.query(Contest).filter( ~exists().where( @@ -206,6 +230,12 @@ def get(self, user_id): Participation.user == user, ) ) + ).filter( + # Exclude training day contests + ~exists().where(TrainingDay.contest_id == Contest.id) + ).filter( + # Exclude managing contests (training program contests) + ~exists().where(TrainingProgram.managing_contest_id == Contest.id) ) ).all() self.render("user.html", **self.r_params) diff --git a/cms/server/admin/handlers/utils.py b/cms/server/admin/handlers/utils.py new file mode 100644 index 0000000000..c435396517 --- /dev/null +++ b/cms/server/admin/handlers/utils.py @@ -0,0 +1,292 @@ +#!/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 . + +"""Admin-only utilities for training programs and related handlers.""" + +import logging +import typing + +from sqlalchemy import func, union + +from cms.db import ( + Session, + Student, + Participation, + Question, + DelayRequest, + ArchivedStudentRanking, + TrainingDay, + Task, +) + +if typing.TYPE_CHECKING: + from cms.db import TrainingProgram + +logger = logging.getLogger(__name__) + + +def get_all_student_tags( + sql_session: Session, + training_program: "TrainingProgram", + include_historical: bool = False, +) -> list[str]: + """Get all unique student tags from a training program's students. + + Uses GIN index on student_tags for efficient querying. + + sql_session: The database session. + training_program: The training program to get tags from. + include_historical: If True, also include tags from archived rankings. + + return: Sorted list of unique tags. + """ + current_tags_query = ( + sql_session.query(func.unnest(Student.student_tags).label("tag")) + .filter(Student.training_program_id == training_program.id) + ) + + if include_historical: + training_day_ids = [td.id for td in training_program.training_days] + if training_day_ids: + historical_tags_query = ( + sql_session.query( + func.unnest(ArchivedStudentRanking.student_tags).label("tag") + ) + .filter(ArchivedStudentRanking.training_day_id.in_(training_day_ids)) + ) + combined_query = union(current_tags_query, historical_tags_query) + rows = sql_session.execute(combined_query).fetchall() + return sorted({row[0] for row in rows if row[0]}) + + rows = current_tags_query.distinct().all() + return sorted([row.tag for row in rows if row.tag]) + + +def get_all_training_day_types(training_program: "TrainingProgram") -> list[str]: + """Get all unique training day types from a training program's training days.""" + all_types_set: set[str] = set() + for training_day in training_program.training_days: + if training_day.training_day_types: + all_types_set.update(training_day.training_day_types) + return sorted(all_types_set) + + +def build_user_to_student_map( + training_program: "TrainingProgram", +) -> dict[int, "Student"]: + """Build a mapping of user_id -> Student for efficient lookups.""" + user_to_student: dict[int, "Student"] = {} + for student in training_program.students: + user_to_student[student.participation.user_id] = student + return user_to_student + + +def get_student_tags_by_participation( + sql_session: Session, + training_program: "TrainingProgram", + participation_ids: list[int], +) -> dict[int, list[str]]: + """Get student tags for multiple participations in a training program.""" + result = {pid: [] for pid in participation_ids} + if not participation_ids: + return result + + rows = ( + sql_session.query(Student.participation_id, Student.student_tags) + .filter(Student.training_program_id == training_program.id) + .filter(Student.participation_id.in_(participation_ids)) + .all() + ) + for participation_id, tags in rows: + result[participation_id] = tags or [] + + return result + + +def count_unanswered_questions(sql_session: Session, contest_id: int) -> int: + """Count unanswered questions for a contest.""" + return ( + sql_session.query(Question) + .join(Participation) + .filter(Participation.contest_id == contest_id) + .filter(Question.reply_timestamp.is_(None)) + .filter(Question.ignored.is_(False)) + .count() + ) + + +def count_pending_delay_requests(sql_session: Session, contest_id: int) -> int: + """Count pending delay requests for a contest.""" + return ( + sql_session.query(DelayRequest) + .join(Participation) + .filter(Participation.contest_id == contest_id) + .filter(DelayRequest.status == "pending") + .count() + ) + + +def get_training_day_notifications( + sql_session: Session, + training_day: "TrainingDay", +) -> dict: + """Get notification counts for a training day.""" + if training_day.contest is None: + return {} + + return { + "unanswered_questions": count_unanswered_questions( + sql_session, training_day.contest_id + ), + "pending_delay_requests": count_pending_delay_requests( + sql_session, training_day.contest_id + ), + } + + +def get_all_training_day_notifications( + sql_session: Session, + training_program: "TrainingProgram", +) -> tuple[dict[int, dict], int, int]: + """Get notification counts for all training days in a program.""" + notifications: dict[int, dict] = {} + total_unanswered = 0 + total_pending = 0 + + for td in training_program.training_days: + if td.contest is None: + continue + + td_notifications = get_training_day_notifications(sql_session, td) + notifications[td.id] = td_notifications + total_unanswered += td_notifications.get("unanswered_questions", 0) + total_pending += td_notifications.get("pending_delay_requests", 0) + + return notifications, total_unanswered, total_pending + + +def deduplicate_preserving_order(items: list[str]) -> list[str]: + """Remove duplicates from a list while preserving order.""" + seen: set[str] = set() + unique: list[str] = [] + for item in items: + if item not in seen: + seen.add(item) + unique.append(item) + return unique + + +def parse_tags(tags_str: str) -> list[str]: + """Parse a comma-separated string of tags into a list of normalized tags.""" + if not tags_str: + return [] + + tags = [tag.strip().lower() for tag in tags_str.split(",") if tag.strip()] + return deduplicate_preserving_order(tags) + + +def parse_usernames_from_file(file_content: str) -> list[str]: + """Parse whitespace-separated usernames from file content.""" + if not file_content: + return [] + + usernames = [u.strip() for u in file_content.split() if u.strip()] + return deduplicate_preserving_order(usernames) + + +class TaskScoreInfo(typing.NamedTuple): + """Score-related information extracted from a task's active dataset.""" + max_score: float + extra_headers: list[str] + score_precision: int + + +def get_task_score_info(task: Task) -> TaskScoreInfo: + """Extract score-related information from a task. + + This extracts max_score, extra_headers (ranking_headers), and score_precision + from a task's active dataset. If the task has no active dataset or the score + type cannot be determined, returns default values. + + task: The task to extract score info from. + + return: TaskScoreInfo with max_score, extra_headers, and score_precision. + """ + max_score = 100.0 + extra_headers: list[str] = [] + score_precision = task.score_precision + + if task.active_dataset: + try: + score_type = task.active_dataset.score_type_object + max_score = score_type.max_score + extra_headers = score_type.ranking_headers + except (KeyError, TypeError, AttributeError): + logger.debug( + "Could not extract score type info for task %s (id=%s)", + task.name, + task.id, + ) + + return TaskScoreInfo(max_score, extra_headers, score_precision) + + +def build_task_data_for_archive(task: Task) -> dict: + """Build task data dictionary for archiving. + + This creates the task data structure stored in archived_tasks_data + when archiving a training day. + + task: The task to build data for. + + return: Dictionary with task metadata for archiving. + """ + score_info = get_task_score_info(task) + return { + "name": task.title, + "short_name": task.name, + "max_score": score_info.max_score, + "score_precision": score_info.score_precision, + "extra_headers": score_info.extra_headers, + "training_day_num": task.training_day_num, + } + + +def build_task_data_for_detail_view( + task: Task, + contest_key: str, +) -> dict: + """Build task data dictionary for detail view pages. + + This creates the task data structure used by ParticipationDetailHandler + and similar detail views that show task information with contest context. + + task: The task to build data for. + contest_key: The contest identifier string. + + return: Dictionary with task metadata for detail views. + """ + score_info = get_task_score_info(task) + return { + "key": str(task.id), + "name": task.title, + "short_name": task.name, + "contest": contest_key, + "max_score": score_info.max_score, + "score_precision": score_info.score_precision, + "extra_headers": score_info.extra_headers, + } diff --git a/cms/server/admin/static/aws_form_utils.js b/cms/server/admin/static/aws_form_utils.js new file mode 100644 index 0000000000..5fa39f733f --- /dev/null +++ b/cms/server/admin/static/aws_form_utils.js @@ -0,0 +1,389 @@ +/* Contest Management System + * Copyright © 2012-2014 Stefano Maggiolo + * Copyright © 2012-2014 Luca Wehrstedt + * + * Form utilities for AWS. + * Extracted from aws_utils.js for better code organization. + */ + +"use strict"; + +window.CMS = window.CMS || {}; +var CMS = window.CMS; +CMS.AWSFormUtils = CMS.AWSFormUtils || {}; + + +/** + * Initialize password strength indicator for a password field. + * Uses zxcvbn library to calculate password strength and displays + * a colored bar with text feedback. + * + * fieldSelector (string): jQuery selector for the password input field. + * barSelector (string): jQuery selector for the strength bar element. + * textSelector (string): jQuery selector for the strength text element. + */ +CMS.AWSFormUtils.initPasswordStrength = function(fieldSelector, barSelector, textSelector) { + var strengthMessages = ["Very weak", "Weak", "Fair", "Strong", "Very strong"]; + var strengthColors = ["#dc3545", "#dc3545", "#ffc107", "#28a745", "#28a745"]; + var strengthWidths = ["20%", "40%", "60%", "80%", "100%"]; + + var $field = $(fieldSelector); + if (!$field.length) { + return; + } + + var $bar = $(barSelector); + var $text = $(textSelector); + + $field.on("input", function() { + var pwd = $(this).val(); + + if (!pwd) { + $bar.hide(); + $text.text(""); + return; + } + + if (typeof zxcvbn === "function") { + var result = zxcvbn(pwd); + var score = result.score; + + $bar.css({ + "background-color": strengthColors[score], + "width": strengthWidths[score] + }).show(); + $text.text("Password strength: " + strengthMessages[score]); + $text.css("color", strengthColors[score]); + } + }); +}; + + +/** + * Validates that end time is after start time for datetime-local inputs. + * Attaches to a form's submit event and prevents submission if invalid. + * + * formSelector (string): jQuery selector for the form element. + * startSelector (string): jQuery selector for the start datetime-local input. + * stopSelector (string): jQuery selector for the stop/end datetime-local input. + */ +CMS.AWSFormUtils.initDateTimeValidation = function(formSelector, startSelector, stopSelector) { + var form = document.querySelector(formSelector); + if (!form) return; + + form.addEventListener('submit', function(e) { + // Use form-scoped selectors to avoid matching inputs in other forms + var startInput = form.querySelector(startSelector); + var stopInput = form.querySelector(stopSelector); + if (!startInput || !stopInput) return; + + // Use valueAsNumber for reliable datetime-local comparison + var startValue = startInput.valueAsNumber; + var stopValue = stopInput.valueAsNumber; + if (!Number.isNaN(startValue) && !Number.isNaN(stopValue) && stopValue <= startValue) { + alert('End time must be after start time'); + e.preventDefault(); + } + }); +}; + + +/** + * Initializes a remove page with task handling options. + * Handles the radio button selection, dropdown enable/disable, and form submission. + * + * config (object): Configuration object with the following properties: + * - removeUrl (string): The base URL for the DELETE request. + * - hasTaskOptions (boolean): Whether task handling options are shown. + * - targetSelectId (string): ID of the target dropdown (e.g., 'target_contest_select'). + * - targetParamName (string): Query param name for target (e.g., 'target_contest_id'). + * - targetLabel (string): Label for validation alert (e.g., 'contest'). + */ +CMS.AWSFormUtils.initRemovePage = function(config) { + if (config.hasTaskOptions) { + // Cache DOM elements and check they exist + var targetSelectEl = document.getElementById(config.targetSelectId); + var moveRadioEl = document.getElementById('action_move'); + if (!targetSelectEl || !moveRadioEl) return; + + // Enable/disable the target dropdown based on the selected action + function syncTargetState() { + targetSelectEl.disabled = !moveRadioEl.checked; + } + document.querySelectorAll('input[name="action"]').forEach(function(radio) { + radio.addEventListener('change', syncTargetState); + }); + + // Initialize the dropdown state based on current selection + syncTargetState(); + } + + // Attach the remove function to CMS.AWSFormUtils namespace + // Also attach to window for backward compatibility with onclick handlers + CMS.AWSFormUtils.cmsDoRemove = function () { + var url = config.removeUrl; + + if (config.hasTaskOptions) { + var actionRadios = document.querySelectorAll('input[name="action"]'); + var selectedAction = null; + for (var i = 0; i < actionRadios.length; i++) { + if (actionRadios[i].checked) { + selectedAction = actionRadios[i].value; + break; + } + } + + if (!selectedAction) { + alert('Please select an option for handling tasks.'); + return; + } + + url += '?action=' + encodeURIComponent(selectedAction); + + if (selectedAction === 'move') { + var targetSelect = document.getElementById(config.targetSelectId); + if (targetSelect && targetSelect.value) { + url += '&' + config.targetParamName + '=' + encodeURIComponent(targetSelect.value); + } else { + alert('Please select a ' + config.targetLabel + ' to move tasks to.'); + return; + } + } + } + + if (confirm('Are you sure you want to remove this?')) { + CMS.AWSUtils.ajax_delete(url); + } + }; + // Backward compatibility alias + window.cmsDoRemove = CMS.AWSFormUtils.cmsDoRemove; +}; + + +/** + * Initializes read-only Tagify display on input element(s). + * Used to display tags in a visually consistent way without editing capability. + * + * inputSelector (string): CSS selector for the input element(s). + */ +CMS.AWSFormUtils.initReadOnlyTagify = function(inputSelector) { + // Defensive check for Tagify library + if (typeof Tagify === 'undefined') { + return; + } + + document.querySelectorAll(inputSelector).forEach(function(input) { + if (!input.value.trim()) return; + + new Tagify(input, { + delimiters: ",", + readonly: true, + editTags: false, + originalInputValueFormat: function(valuesArr) { + return valuesArr.map(function(item) { + return item.value; + }).join(', '); + } + }); + }); +}; + + +/** + * Initializes Tagify on input element(s) with confirmation dialogs and save-on-confirm. + * Provides a unified interface for tag inputs across the admin interface. + * + * All tag operations (add, edit, remove) require confirmation before saving. + * Automatic removals (like duplicate detection) do not require confirmation but still save. + * + * config (object): Configuration object with the following properties: + * - inputSelector (string): CSS selector for the input element(s). + * - whitelist (array): Array of existing tags for autocomplete suggestions. + * - getSaveUrl (function): Function that receives the input element and returns the save URL. + * - saveParamName (string): Parameter name for the save request (e.g., 'student_tags'). + * - xsrfSelector (string): CSS selector for the XSRF token input (default: 'input[name="_xsrf"]'). + * - placeholder (string): Placeholder text (default: 'Type tags'). + * - editable (boolean): Whether tags can be edited by double-clicking (default: false). + * - enforceWhitelist (boolean): Whether to only allow tags from whitelist (default: false). + * - pattern (RegExp): Pattern for tag validation (default: null). + * - invalidMessage (string): Message to show when pattern validation fails. + */ +CMS.AWSFormUtils.initTagify = function(config) { + var inputs = document.querySelectorAll(config.inputSelector); + if (!inputs.length) return; + + var xsrfSelector = config.xsrfSelector || 'input[name="_xsrf"]'; + + inputs.forEach(function(input) { + var tagifyOptions = { + delimiters: ",", + maxTags: 20, + placeholder: config.placeholder || "Type tags", + whitelist: config.whitelist || [], + dropdown: { + maxItems: 20, + classname: "tags-look", + enabled: 0, + closeOnSelect: true + }, + originalInputValueFormat: function(valuesArr) { + return valuesArr.map(function(item) { + return item.value; + }).join(', '); + } + }; + + tagifyOptions.editTags = config.editable ? { clicks: 2, keepInvalid: false } : false; + tagifyOptions.enforceWhitelist = !!config.enforceWhitelist; + if (config.pattern) tagifyOptions.pattern = config.pattern; + + // Flag to track if a save should happen on the next 'change' event + var pendingSave = false; + // Flag to track if we're rolling back a cancelled add (to skip confirmation) + var isRollback = false; + // Flag to prevent confirmations during initial page load + var armed = false; + + function saveTags(tagifyInstance) { + // Use tagify.value (canonical state) instead of input.value + // input.value may be stale if Tagify's debounced update() hasn't run yet + var tags = tagifyInstance.value.map(function(t) { return t.value; }).join(', '); + var formData = new FormData(); + formData.append(config.saveParamName, tags); + var xsrfInput = document.querySelector(xsrfSelector); + if (xsrfInput) { + formData.append('_xsrf', xsrfInput.value); + } + + var saveUrl = config.getSaveUrl(input); + fetch(saveUrl, { + method: 'POST', + body: formData + }).then(function(response) { + if (!response.ok) { + console.error('Failed to save tags'); + } + }).catch(function(error) { + console.error('Error saving tags:', error); + }); + } + + // Track user-initiated removals (X click or backspace) + var userRemovalTriggeredAt = 0; + + tagifyOptions.hooks = { + beforeRemoveTag: function(tags) { + return new Promise(function(resolve, reject) { + // If this is a rollback from cancelled add, skip confirmation + if (isRollback) { + resolve(); + return; + } + + var now = Date.now(); + var isUserInitiated = (now - userRemovalTriggeredAt) < 200; + userRemovalTriggeredAt = 0; + + // Auto-removals (duplicates, etc.) don't need confirmation + if (!isUserInitiated) { + pendingSave = true; + resolve(); + return; + } + + // User-initiated removal needs confirmation + var tagValue = tags[0].data.value; + if (confirm('Remove tag "' + tagValue + '"?')) { + pendingSave = true; + resolve(); + } else { + reject(new Error('User cancelled tag removal')); + } + }); + } + }; + + var tagify = new Tagify(input, tagifyOptions); + + // Detect X button clicks + tagify.DOM.scope.addEventListener('click', function(e) { + if (e.target.closest('.tagify__tag__removeBtn')) { + userRemovalTriggeredAt = Date.now(); + } + }, true); + + // Detect backspace/delete key presses + tagify.DOM.input.addEventListener('keydown', function(e) { + if (e.key === 'Backspace' || e.key === 'Delete') { + userRemovalTriggeredAt = Date.now(); + } + }, true); + + // Handle add confirmation + tagify.on('add', function(e) { + // Skip confirmation if not armed yet (initial page load) + if (!armed) return; + + var tagValue = e.detail.data.value; + if (confirm('Add tag "' + tagValue + '"?')) { + pendingSave = true; + } else { + // Roll back the add - use isRollback flag to skip beforeRemoveTag confirmation + // Use non-silent removal so Tagify properly updates its internal state + isRollback = true; + tagify.removeTags(e.detail.tag); + isRollback = false; + } + }); + + // Handle edit confirmation + if (config.editable) { + var editingTagValue = null; + + tagify.on('edit:start', function(e) { + editingTagValue = e.detail.data.value; + }); + + tagify.on('edit:beforeUpdate', function(e) { + var oldVal = editingTagValue; + var newVal = e.detail.data && e.detail.data.value; + + // No change, no confirmation needed + if (oldVal === newVal) { + return; + } + + if (confirm('Change tag "' + oldVal + '" to "' + newVal + '"?')) { + pendingSave = true; + } else { + // Revert to old value + e.detail.data.value = oldVal; + } + }); + } + + // Save on 'change' event - this fires AFTER Tagify updates its internal state + tagify.on('change', function() { + if (pendingSave) { + saveTags(tagify); + pendingSave = false; + } + }); + + if (config.pattern && config.invalidMessage) { + tagify.on('invalid', function(e) { + if (e.detail.message === 'pattern mismatch') { + alert(config.invalidMessage); + } + }); + } + + // Arm the confirmations after a short delay to skip initial load events + // The 100ms delay allows Tagify to finish processing pre-existing tags + // before we start showing confirmation dialogs for user-initiated changes + setTimeout(function() { + armed = true; + }, 100); + }); +}; + diff --git a/cms/server/admin/static/aws_style.css b/cms/server/admin/static/aws_style.css index 0c958b13ff..4fcf61c37b 100644 --- a/cms/server/admin/static/aws_style.css +++ b/cms/server/admin/static/aws_style.css @@ -1,7 +1,11 @@ -html { +html { font-size: 100%; } +:root { + --ranking-header-top: 60px; +} + .searchable-select { width: 240px; display: inline-block; @@ -73,10 +77,6 @@ em { color: #F31313; } -#global { - margin: 0; -} - a, a:visited { color: #426DC9; } @@ -95,10 +95,6 @@ hr { clear: both; } -.displayed { - display: block; -} - input, textarea { border: 1px solid #53637D; border-radius: 4px; @@ -113,12 +109,6 @@ input:focus, textarea:focus { border: 1px solid #FFD69C; } -ul.normal_list { - list-style: disc; - list-style-position: inside; - padding-left: 0.5em; -} - .inline { display: inline; } @@ -180,6 +170,12 @@ ul.normal_list { background-color: rgba(148, 163, 184, 0.55); } +/* Firefox scrollbar support */ +#sidebar { + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.35) transparent; /* Thumb Track */ +} + #sidebar_header { margin-bottom: 15px; } @@ -223,7 +219,7 @@ ul.normal_list { padding-top: 6px; border-top: 1px solid rgba(148, 163, 184, 0.08); } - + .sidebar-section:first-child { border-top: none; padding-top: 0; @@ -254,7 +250,7 @@ ul.normal_list { .sidebar-section-title a:hover { color: var(--sidebar-text-bright); } - + .sidebar-section-title:hover, .sidebar-section-title:focus { border-color: var(--sidebar-hover-border); @@ -312,9 +308,9 @@ ul.normal_list { .folder-header { display: flex; align-items: center; - padding: 4px 8px; + padding: 8px 12px; cursor: pointer; - border-radius: 4px; + border-radius: 6px; transition: background-color 0.2s; } @@ -326,7 +322,7 @@ ul.normal_list { outline: none; box-shadow: inset 0 0 0 1px var(--sidebar-focus-glow); background-color: var(--sidebar-hover-bg); - border-radius: 4px; + border-radius: 6px; } .folder-header a:focus { @@ -361,13 +357,15 @@ ul.normal_list { } .folder-header a { - color: #E5E7EB; + color: var(--sidebar-text); font-weight: 500; text-decoration: none; + margin-left: 12px; } -.folder-header a:hover { +.folder-header:hover a { color: var(--sidebar-text-bright); + background-color: transparent; } .folder-contents { @@ -393,6 +391,13 @@ ul.normal_list { .sidebar-contest, .sidebar-item { padding: 0; margin: 2px 0; + display: flex; + align-items: center; + padding-left: 10px; +} + +.sidebar-contest .unread { + flex-shrink: 0; } .sidebar-contest a, .sidebar-item a { @@ -403,6 +408,8 @@ ul.normal_list { padding: 6px 10px; border-radius: 6px; transition: background-color 0.2s, color 0.2s; + flex: 1; + min-width: 0; } body.admin .sidebar-contest a, body.admin .sidebar-item a { @@ -491,7 +498,7 @@ body.admin .sidebar-contest a, body.admin .sidebar-item a { line-height: 1.35; color: #dce3ec; } - + .menu { list-style: none; margin: 0; @@ -501,8 +508,15 @@ body.admin .sidebar-contest a, body.admin .sidebar-item a { .menu_entry { display: flex; align-items: center; - gap: 8px; margin: 2px 0; + padding: 8px 12px; + border-radius: 6px; + transition: background-color 0.2s ease; + cursor: pointer; +} + +.menu_entry:hover { + background-color: var(--sidebar-hover-bg); } .menu_entry .unread { @@ -515,18 +529,27 @@ body.admin .sidebar-contest a, body.admin .sidebar-item a { .menu_link { display: block; flex: 1 1 auto; - padding: 6px 10px; - border-radius: 6px; - transition: background-color 0.2s; + padding: 0; + border-radius: 0; + transition: color 0.2s ease; font-weight: 500; letter-spacing: 0.01em; + color: var(--sidebar-text); +} + +.menu_entry:hover .menu_link { + color: var(--sidebar-text-bright); + background-color: transparent; } #sidebar a:hover, #sidebar a:focus { - background-color: var(--sidebar-hover-bg); outline: 0; } +#sidebar .menu_link:hover { + background-color: transparent; +} + .footer { font-size: 0.875em; text-align: left; @@ -543,6 +566,10 @@ body.admin .sidebar-contest a, body.admin .sidebar-item a { text-shadow: none; } +.unread.unread-delay { + background-color: #FF8C00; +} + .secret_notice { font-size: 0.8em; line-height: 1.125em; @@ -1291,6 +1318,41 @@ a.button-link-danger:hover { background-color: #FBEAEA; } +button.button-link { + text-decoration: none; + margin-right: 25px; + padding: 8px; + border-radius: 8px; + border: 1px solid #0F766E; + background-color: white; + color: #0F766E; + cursor: pointer; + font-family: inherit; + font-size: inherit; +} + +button.button-link:hover { + background-color: #F0FDFA; +} + +button.button-link-secondary { + border-color: #888; + color: #444; +} + +button.button-link-secondary:hover { + background-color: #F5F5F5; +} + +button.button-link-danger { + border-color: #C75252; + color: #C75252; +} + +button.button-link-danger:hover { + background-color: #FBEAEA; +} + th.diff-only, td.diff-only { display: none; } @@ -1382,3 +1444,132 @@ table.diff-open th.diff-only, table.diff-open td.diff-only { padding: 0.4em 0.6em; min-width: 250px; } + +.core_title h1 { + margin: 0; + font-size: 1.75rem; + color: #111827; +} + +.sidebar-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; /* consistent width for single digits */ + height: 18px; + padding: 0 6px; + border-radius: 10px; /* Pill shape */ + font-size: 0.7em; + font-weight: 700; + line-height: 1; + color: #ffffff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + white-space: nowrap; + transition: transform 0.2s; +} + +/* 2. Color Variants */ +/* Urgent / Errors (Questions) */ +.sidebar-badge.is-red { + background-color: #EF4444; /* Modern Tailwind Red-500 */ + border: 1px solid #DC2626; +} + +/* Warnings / Pending (Delay Requests) */ +.sidebar-badge.is-amber { + background-color: #F59E0B; /* Modern Tailwind Amber-500 */ + border: 1px solid #D97706; +} + +/* 3. Layout Containers */ + +/* Container to group text on the left (caret + link) */ +.sidebar-link-group { + display: flex; + align-items: center; + flex: 1; /* Takes up remaining space */ + min-width: 0; /* Allows text truncation */ + gap: 10px; +} + +/* Container to group badges on the right */ +.sidebar-badge-container { + display: flex; + align-items: center; + gap: 6px; /* Space between multiple badges */ + margin-left: 8px; /* Space between text and badges */ + flex-shrink: 0; /* Prevent squishing */ +} + +/* Menu icons styling */ +.menu-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + color: var(--sidebar-slate); + transition: color 0.2s ease; +} + +.menu-icon svg { + width: 18px; + height: 18px; +} + +.menu-icon + .menu_link { + margin-left: 12px; +} + +.menu_entry:hover .menu-icon { + color: var(--sidebar-accent-light); +} + +/* SVG icon base styles for sprite usage */ +svg.icon { + display: inline-block; + vertical-align: middle; + fill: none; + stroke: currentColor; +} + +/* Folder icon with SVG (for Training Days section) */ +.folder-icon-svg { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin-right: 0; + flex-shrink: 0; + color: var(--sidebar-slate); + transition: color 0.2s; +} + +.folder-header:hover .folder-icon-svg { + color: var(--sidebar-accent-light); +} + +/* 4. Sidebar Element overrides for Flexbox alignment */ + +/* Section Titles (Training Days, Contests) */ +.sidebar-section-title { + display: flex; + align-items: center; + justify-content: space-between; + padding-right: 10px; +} + +/* Sub-items (Active Training Days inside folder) */ +.sidebar-contest, .sidebar-item { + display: flex; + align-items: center; + justify-content: space-between; + padding-right: 10px; +} + +/* Override existing link widths to work with flex */ +.sidebar-contest a, .sidebar-item a { + flex: 1; +} diff --git a/cms/server/admin/static/aws_table_utils.js b/cms/server/admin/static/aws_table_utils.js new file mode 100644 index 0000000000..6e93095430 --- /dev/null +++ b/cms/server/admin/static/aws_table_utils.js @@ -0,0 +1,223 @@ +/* Contest Management System + * Copyright © 2012-2014 Stefano Maggiolo + * Copyright © 2012-2014 Luca Wehrstedt + * + * Table sorting and filtering utilities for AWS. + * Extracted from aws_utils.js for better code organization. + */ + +"use strict"; + +window.CMS = window.CMS || {}; +var CMS = window.CMS; +CMS.AWSTableUtils = CMS.AWSTableUtils || {}; + + +/** + * Provides table row comparator for specified column and order. + * + * column_idx (int): Index of the column to sort by. + * numeric (boolean): Whether to sort numerically. + * ascending (boolean): Whether to sort in ascending order. + * return (function): Comparator function for Array.sort(). + */ +CMS.AWSTableUtils.getRowComparator = function(column_idx, numeric, ascending) { + return function(a, b) { + var cellA = $(a).children("td").eq(column_idx); + var cellB = $(b).children("td").eq(column_idx); + + // Use data-value if present, otherwise fallback to text + var valA = cellA.attr("data-value"); + if (typeof valA === "undefined" || valA === "") valA = cellA.text().trim(); + + var valB = cellB.attr("data-value"); + if (typeof valB === "undefined" || valB === "") valB = cellB.text().trim(); + + var result; + if (numeric) { + var numA = Number.parseFloat(valA); + var numB = Number.parseFloat(valB); + + // Treat non-numeric/empty values so they always sink to bottom regardless of sort direction + if (Number.isNaN(numA)) numA = ascending ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; + if (Number.isNaN(numB)) numB = ascending ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; + + result = numA - numB; + return ascending ? result : -result; + } else { + result = valA.localeCompare(valB); + return ascending ? result : -result; + } + }; +}; + + +/** + * Sorts specified table by specified column in specified order. + * + * table (jQuery): The table element to sort. + * column_idx (int): Index of the column to sort by. + * ascending (boolean): Whether to sort in ascending order. + * header_element (Element): Optional header element for the column. + */ +CMS.AWSTableUtils.sortTable = function(table, column_idx, ascending, header_element) { + var initial_column_idx = table.data("initial_sort_column_idx"); + var ranks_column = table.data("ranks_column"); + var data_column_idx = column_idx + (ranks_column ? 1 : 0); + var table_rows = table + .children("tbody") + .children("tr"); + + // Use provided header element if available, otherwise find by index + var column_header; + if (header_element) { + column_header = $(header_element); + } else { + column_header = table + .children("thead") + .children("tr") + .children("th") + .eq(data_column_idx); + } + var settings = (column_header.attr("data-sort-settings") || "").split(" "); + + var numeric = settings.indexOf("numeric") >= 0; + + // If specified, flip column's natural order, e.g. due to meaning of values. + if (settings.indexOf("reversed") >= 0) { + ascending = !ascending; + } + + // Normalize column index for data access, converting negative to positive from the end. + if (data_column_idx < 0) { + // For negative indices, calculate from the number of columns in data rows + var first_data_row = table_rows.first(); + var num_cols = first_data_row.children("td,th").length; + data_column_idx = num_cols + data_column_idx; + } + + // Reassign arrows to headers + table.find(".column-sort").html("↕"); + column_header.find(".column-sort").html(ascending ? "↑" : "↓"); + + // Do the sorting, by initial column and then by selected column. + table_rows + .sort(CMS.AWSTableUtils.getRowComparator(initial_column_idx, numeric, ascending)) + .sort(CMS.AWSTableUtils.getRowComparator(data_column_idx, numeric, ascending)) + .each(function(idx, row) { + table.children("tbody").append(row); + }); + + if (ranks_column) { + table_rows.each(function(idx, row) { + $(row).children("td").first().text(idx + 1); + }); + } +}; + + +/** + * Makes table sortable, adding ranks column and sorting buttons in header. + * + * table (jQuery): The table element to make sortable. + * ranks_column (boolean): Whether to add a ranks column. + * initial_column_idx (int): Index of the column to initially sort by. + * initial_ascending (boolean): Whether to initially sort in ascending order. + */ +CMS.AWSTableUtils.initTableSort = function(table, ranks_column, initial_column_idx, initial_ascending) { + table.addClass("sortable"); + var table_column_headers = table + .children("thead") + .children("tr"); + var table_rows = table + .children("tbody") + .children("tr"); + + // Normalize column index, converting negative to positive from the end. + initial_column_idx = table_column_headers + .children("th") + .eq(initial_column_idx) + .index(); + + table.data("ranks_column", ranks_column); + table.data("initial_sort_column_idx", initial_column_idx); + + // Declaring sort settings. + var previous_column_idx = initial_column_idx; + var ascending = initial_ascending; + + // Add sorting indicators to column headers + // Skip headers with the "no-sort" class + // Use data-sort-column attribute if present for correct column index + table_column_headers + .children("th") + .not(".no-sort") + .each(function(idx, header) { + var $header = $(header); + // Use data-sort-column if specified, otherwise use the header's index + var sortColumn = $header.data("sort-column"); + if (sortColumn === undefined) { + sortColumn = $header.index(); + } + $("", { + href: "#", + class: "column-sort", + click: function(e) { + e.preventDefault(); + ascending = !ascending && previous_column_idx == sortColumn; + previous_column_idx = sortColumn; + CMS.AWSTableUtils.sortTable(table, sortColumn, ascending, header); + } + }).appendTo(header); + }); + + // Add ranks column + if (ranks_column) { + table_column_headers.prepend("#"); + table_rows.prepend(""); + } + + // Do initial sorting + CMS.AWSTableUtils.sortTable(table, initial_column_idx, initial_ascending); +}; + + +/** + * Filters table rows based on search text. + * + * table_id (string): The id of the table to filter. + * search_text (string): The text to search for in table rows. + */ +CMS.AWSTableUtils.filterTable = function(table_id, search_text) { + var table = document.getElementById(table_id); + if (!table) { + return; + } + var rows = table.querySelectorAll("tbody tr"); + var search_lower = search_text.toLowerCase().trim(); + + rows.forEach(function(row) { + if (search_lower === "") { + row.style.display = ""; + return; + } + var text = row.textContent.toLowerCase(); + if (text.indexOf(search_lower) !== -1) { + row.style.display = ""; + } else { + row.style.display = "none"; + } + }); +}; + + +// Backward compatibility aliases on CMS.AWSUtils +// These will be set up after aws_utils.js loads +document.addEventListener('DOMContentLoaded', function () { + if (typeof CMS.AWSUtils !== 'undefined') { + // Alias the new functions to the old names for backward compatibility + CMS.AWSUtils.sort_table = CMS.AWSTableUtils.sortTable; + CMS.AWSUtils.init_table_sort = CMS.AWSTableUtils.initTableSort; + CMS.AWSUtils.filter_table = CMS.AWSTableUtils.filterTable; + } +}); diff --git a/cms/server/admin/static/aws_tp_styles.css b/cms/server/admin/static/aws_tp_styles.css new file mode 100644 index 0000000000..de8b6a7caa --- /dev/null +++ b/cms/server/admin/static/aws_tp_styles.css @@ -0,0 +1,2628 @@ +/** + * Training Program Styles (aws_tp_styles.css) + * + * This file contains styles specific to training program pages in the admin interface. + * It uses CSS custom properties (variables) for colors to enable easy theming. + */ + +/* ========================================================================== + Color Variables for Theming + ========================================================================== */ + +:root { + /* Primary colors (teal theme) */ + --tp-primary: #0F766E; + --tp-primary-hover: #0d655e; + --tp-primary-light: #14b8a6; + --tp-primary-gradient: linear-gradient(135deg, #0F766E 0%, #14b8a6 100%); + + /* Status colors */ + --tp-success: #059669; + --tp-success-light: #d1fae5; + --tp-success-dark: #065f46; + + --tp-warning: #d97706; + --tp-warning-light: #fef3c7; + --tp-warning-dark: #92400e; + + --tp-danger: #dc2626; + --tp-danger-light: #fef2f2; + --tp-danger-dark: #991b1b; + --tp-danger-border: #fecaca; + + /* Info colors (blue) */ + --tp-info: #3b82f6; + --tp-info-hover: #2563eb; + --tp-info-light: #f0f9ff; + --tp-info-border: #bae6fd; + --tp-info-dark: #0369a1; + + /* Neutral colors */ + --tp-text-primary: #1e293b; + --tp-text-secondary: #374151; + --tp-text-muted: #64748b; + --tp-text-light: #9ca3af; + --tp-text-lighter: #6b7280; + + /* Background colors */ + --tp-bg-white: #ffffff; + --tp-bg-light: #f8fafc; + --tp-bg-gray: #f3f4f6; + --tp-bg-hover: #f9fafb; + + /* Border colors */ + --tp-border: #e5e7eb; + --tp-border-light: #e2e8f0; + --tp-border-dark: #cbd5e1; + + /* Shadow */ + --tp-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --tp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --tp-shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.1); + --tp-shadow-xl: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +/* ========================================================================== + Page Layout + ========================================================================== */ + +.page-title { + margin-bottom: 24px; +} + +.page-title h1 { + margin: 0; + font-size: 1.75rem; + color: var(--tp-text-primary); +} + +.page-subtitle { + margin-top: 4px; + font-size: 0.9rem; + color: var(--tp-text-muted); +} + +.page-subtitle a { + color: #475569; + font-weight: 600; + text-decoration: none; +} + +.page-subtitle a:hover { + color: var(--tp-primary); +} + +/* ========================================================================== + Action Buttons Row + ========================================================================== */ + +.action-buttons-row { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + padding: 24px 0; + margin-bottom: 16px; +} + +/* Outline button style */ +.btn-outline { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: var(--tp-bg-white); + color: var(--tp-text-secondary); + border: 1px solid var(--tp-border); + border-radius: 8px; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + font-family: inherit; +} + +.btn-outline:hover { + background: var(--tp-bg-hover); + border-color: var(--tp-text-light); + color: var(--tp-text-primary); +} + +.btn-outline svg, +.btn-outline .btn-icon { + width: 18px; + height: 18px; +} + +/* ========================================================================== + Sortable Table Headers + ========================================================================== */ + +.sortable-header { + cursor: pointer; + user-select: none; + transition: color 0.2s; + white-space: nowrap; +} + +.sortable-header:hover { + color: var(--tp-text-primary); + background: var(--tp-bg-light); +} + +.sortable-header::after { + content: '\2195'; + font-size: 1.1em; + margin-left: 5px; + opacity: 0.3; + display: inline-block; + vertical-align: middle; +} + +.sortable-header.sort-asc::after { + content: '\2191'; + opacity: 1; + color: var(--tp-primary); +} + +.sortable-header.sort-desc::after { + content: '\2193'; + opacity: 1; + color: var(--tp-primary); +} + +/* ========================================================================== + Hidden File Input (for file upload buttons) + ========================================================================== */ + +.file-input-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + border: 0; +} + +/* ========================================================================== + Native Dialog Element Styles + ========================================================================== */ + +.tp-dialog { + border: none; + border-radius: 12px; + padding: 0; + box-shadow: var(--tp-shadow-lg); + max-width: 400px; + width: 90%; + overflow: visible; +} + +.tp-dialog::backdrop { + background: rgba(0, 0, 0, 0.5); +} + +.tp-dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--tp-border); +} + +.tp-dialog-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--tp-text-primary); +} + +.tp-dialog-close { + background: none; + border: none; + font-size: 1.5rem; + color: var(--tp-text-secondary); + cursor: pointer; + padding: 0; + line-height: 1; +} + +.tp-dialog-close:hover { + color: var(--tp-text-primary); +} + +.tp-dialog-body { + padding: 20px; +} + +.tp-dialog-body label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--tp-text-secondary); + margin-bottom: 8px; +} + +.tp-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid var(--tp-border); + background: var(--tp-bg-light); + border-radius: 0 0 12px 12px; +} + +/* ========================================================================== + Progress Link and Modern Progress Bar + ========================================================================== */ + +.progress-link { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; +} + +.progress-link.no-tasks { + color: #9ca3af; + font-size: 0.85rem; +} + +.progress-percentage { + font-weight: 600; + font-size: 0.9rem; + min-width: 45px; + /* Color based on percentage using CSS custom property */ + color: hsl(calc(var(--pct, 0) * 1.2), 70%, 35%); +} + +.progress-score { + font-size: 0.8rem; + color: #6b7280; + white-space: nowrap; +} + +/* ========================================================================== + Student Table Styles + ========================================================================== */ + +/* Page-specific table overrides */ +#students-table thead th { + position: static !important; + top: auto !important; + left: auto !important; + z-index: auto !important; + box-shadow: none !important; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--tp-text-muted); + background: var(--tp-bg-light); + padding: 14px 16px !important; +} + +#students-table td { + padding: 0; +} + +.student-name-wrapper { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + padding: 8px 10px; +} + +.student-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + background: var(--tp-border); +} + +.student-avatar-placeholder { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--tp-primary-gradient); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 0.85rem; + flex-shrink: 0; +} + +.student-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; +} + +.student-name-link { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--tp-text-primary); + font-weight: 600; + text-decoration: none; + max-width: 150px; + display: block; +} + +.student-name-link:hover { + color: var(--tp-primary); +} + +.student-username { + font-size: 0.8em; + color: var(--tp-text-muted); + white-space: nowrap; +} + +.student-tag-inline { + font-weight: normal; + font-size: 0.7em; +} + +/* ========================================================================== + Icon-only Button (for actions like delete) + ========================================================================== */ + +.btn-icon-only { + background: none; + border: none; + padding: 8px; + cursor: pointer; + color: var(--tp-text-light) !important; + border-radius: 50%; + transition: all 0.2s; +} + +.btn-icon-only:hover { + color: var(--tp-danger) !important; + background: var(--tp-danger-light) !important; +} + +/* Disabled state for both buttons and anchors */ +.btn-icon-only:disabled, +.btn-icon-only[aria-disabled="true"], +.btn-icon-only.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +/* ========================================================================== + List Cell Wrapper (for table cells) + ========================================================================== */ + +.list-cell-wrapper { + padding: 12px 16px; + display: flex; + align-items: center; +} + +.list-cell-wrapper.center-content { + justify-content: center; +} + +/* ========================================================================== + Form Groups (for modals and forms) + ========================================================================== */ + +.tp-form-group { + margin-bottom: 16px; +} + +.tp-form-group label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--tp-text-secondary); + margin-bottom: 6px; +} + +.tp-form-group input[type="text"], +.tp-form-group select { + width: 100%; + box-sizing: border-box; +} + +/* ========================================================================== + Bulk Assign Task Modal (specific styles) + ========================================================================== */ + +.bulk-assign-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; +} + +/* Ensure Tagify dropdown appears above the modal */ +.tagify__dropdown { + z-index: 10001 !important; +} + +.bulk-assign-modal-content { + background: var(--tp-bg-white); + border-radius: 12px; + padding: 24px; + max-width: 480px; + width: 90%; + box-shadow: var(--tp-shadow-xl); +} + +.bulk-assign-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--tp-border); +} + +.bulk-assign-modal-header h2 { + margin: 0; + font-size: 1.25rem; + color: var(--tp-text-primary); +} + +.bulk-assign-close-btn { + background: none; + border: none; + font-size: 28px; + cursor: pointer; + color: var(--tp-text-lighter); + line-height: 1; + padding: 0; +} + +.bulk-assign-close-btn:hover { + color: var(--tp-text-primary); +} + +.bulk-assign-description { + color: var(--tp-text-lighter); + font-size: 0.9rem; + margin: 0 0 20px 0; + padding: 0; +} + +.bulk-assign-form-group { + margin-bottom: 16px; +} + +.bulk-assign-form-group label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--tp-text-secondary); + margin-bottom: 6px; +} + +.bulk-assign-form-group input[type="text"] { + width: 100%; + box-sizing: border-box; +} + +.bulk-assign-form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--tp-border); +} + +/* ========================================================================== + Text Utilities + ========================================================================== */ + +.text-center { + text-align: center; +} + +.text-left { + text-align: left; +} + +.text-right { + text-align: right; +} + +.text-muted { + color: var(--tp-text-muted); +} + +.text-success { + color: var(--tp-success); +} + +.text-warning { + color: var(--tp-warning); +} + +.text-danger { + color: var(--tp-danger); +} + +/* ========================================================================== + Table Styles + ========================================================================== */ + +/* Common Table Styles */ +.attendance-table, +.ranking-table { + width: auto; + border-collapse: separate; /* Required for sticky headers */ + border-spacing: 0; + font-size: 0.9rem; + white-space: nowrap; +} + +/* Force border-box for consistency */ +.ranking-table *, .attendance-table * { + box-sizing: border-box; +} + +.attendance-table th, +.attendance-table td, +.ranking-table th, +.ranking-table td { + padding: 0; /* Reset padding, inner divs handle it */ + border-bottom: 1px solid var(--tp-border); + border-right: 1px solid var(--tp-border); + background-clip: padding-box; + vertical-align: middle; + height: 1px; +} + +.attendance-table th, +.attendance-table td { + vertical-align: top; +} + +/* --- Sticky Headers & Columns --- */ + +/* 1. Header Rows */ +.attendance-table thead th, +.ranking-table thead th { + position: sticky; + top: 0; + background-color: var(--tp-bg-light); + z-index: 10; + color: var(--tp-text-secondary); + font-weight: 600; + text-align: center; + box-shadow: 0 1px 0 var(--tp-border); /* Visual bottom border */ + padding: 10px 12px; /* Headers get direct padding */ +} + +.ranking-table thead th.student-header { + text-align: left; + vertical-align: middle; +} + +/* 2. First Column (Students) */ +.attendance-table tbody th, +.ranking-table tbody th { + position: sticky; + left: 0; + background-color: var(--tp-bg-white); + z-index: 8; + text-align: left; + min-width: 250px; + max-width: 300px; + box-shadow: 4px 0 8px -2px rgba(0, 0, 0, 0.1); + clip-path: inset(0px -10px 0px 0px); + border-right: 1px solid var(--tp-border); + border-bottom: 1px solid var(--tp-bg-gray); + overflow: hidden; + text-overflow: ellipsis; + color: var(--tp-text-primary); + padding: 0; +} + +/* 3. Top-Left Corner */ +.attendance-table thead tr:first-child th:first-child, +.ranking-table thead tr:first-child th:first-child { + left: 0; + z-index: 20; + background-color: var(--tp-bg-light); + box-shadow: 1px 1px 0 var(--tp-border); +} + +/* --- Attendance Table Specifics --- */ +.date-header-main { + font-size: 0.95rem; + color: var(--tp-text-primary); +} + +.date-header-sub { + font-size: 0.75rem; + color: var(--tp-text-muted); + font-weight: normal; + margin-top: 2px; +} + +/* Wrapper for cell content */ +.cell-wrapper { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 140px; + padding: 10px 12px; +} + +/* Modern Badges */ +.status-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + width: fit-content; +} + +.status-on-time { + background-color: var(--tp-success-light); + color: var(--tp-success-dark); +} + +.status-delayed { + background-color: var(--tp-warning-light); + color: var(--tp-warning-dark); +} + +.status-missed { + background-color: var(--tp-danger-light); + color: var(--tp-danger-dark); +} + +.status-unknown { + background-color: var(--tp-bg-gray); + color: var(--tp-text-light); +} + +.location-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + flex-wrap: wrap; + font-size: 0.75rem; + color: var(--tp-text-light); +} + +.location-icon { + font-size: 0.85rem; +} + +.reason-text { + font-size: 0.75rem; + color: var(--tp-text-muted); + background: var(--tp-bg-hover); + border-left: 2px solid var(--tp-border); + padding: 2px 6px; + margin-top: 2px; + white-space: normal; + max-width: 200px; +} + +.attendance-table .empty-cell { + color: var(--tp-border); + font-size: 1.5rem; + line-height: 0.5; + padding: 10px; + text-align: center; +} + +/* Content wrapper for Ranking cells */ +.cell-content { + display: flex; + align-items: center; + justify-content: center; + padding: 12px 8px; + font-size: 1.25rem; /* Much smaller than 1.5em */ + font-weight: 600; + border-radius: 6px; /* Soften the blockiness */ + margin: 4px; /* Give the cell breathing room inside the grid */ + height: calc(100% - 8px); + width: calc(100% - 8px); + color: var(--tp-text-primary); /* Dark Slate, not black */ + background-image: linear-gradient(rgba(255,255,255,0.3), rgba(255,255,255,0.3)); +} + +/* Score cell coloring using CSS custom properties + Uses --score (0-100) to calculate hue: 0 (Red) -> 60 (Yellow) -> 120 (Green) + The hue is clamped between 0 and 120 using min/max */ +.cell-content.score-cell { + --hue: calc(var(--score, 0) * 1.2); + background-color: hsl(min(120, max(0, var(--hue))), 75%, 90%); + color: hsl(min(120, max(0, var(--hue))), 90%, 20%); + background-image: none; +} + +/* Modern progress bar using CSS custom properties + Uses --pct (0-100) to set width and color automatically */ +.progress-bar-modern { + height: 6px; + background: var(--tp-border); + border-radius: 3px; + position: relative; + overflow: hidden; + width: 120px; +} + +.progress-bar-modern::after { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: calc(var(--pct, 0) * 1%); + /* HSL color transitions from red (0) to green (120) based on percentage */ + background: hsl(calc(var(--pct, 0) * 1.2), 70%, 45%); + border-radius: 3px; + transition: width 0.3s ease; +} + +/* Hover effects */ +.ranking-table tbody tr:hover td { + background-color: var(--tp-bg-hover); +} + +.ranking-table tbody tr:hover th { + background-color: var(--tp-bg-hover); +} + +.ranking-table tbody tr:nth-child(even) { + background-color: var(--tp-bg-light); /* Very light gray */ +} + +/* Badge Styles */ +.badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + font-size: 0.75em; + font-weight: 600; + line-height: 1.2; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 9999px; + margin-right: 4px; + margin-bottom: 2px; + border: 1px solid transparent; +} + +.badge-pill { + border-radius: 9999px; + padding-left: 8px; + padding-right: 8px; +} + +.badge-teal { + background-color: var(--tp-success-light); + color: var(--tp-success-dark); + border-color: #ccfbf1; +} + +.badge-slate { + background-color: var(--tp-bg-light); + color: var(--tp-text-secondary); + border-color: var(--tp-border); +} + +.ranking-table thead th { + position: sticky; +} + +.ranking-table thead tr:first-child th { + height: auto; + padding: 12px 8px; + background-color: var(--tp-bg-white); + border-bottom: 2px solid var(--tp-border); +} + +.ranking-table thead tr:nth-child(2) th { + top: var(--ranking-header-top); + z-index: 15; + box-shadow: 0 2px 4px -2px rgba(0,0,0,0.1); + background-color: var(--tp-bg-white); +} + +.ranking-table thead tr:first-child th[rowspan="2"] { + top: 0; + height: auto; + z-index: 30; + background-color: var(--tp-bg-light); + vertical-align: middle; +} + +.ranking-table thead tr:first-child th.student-header { + left: 0; + z-index: 40; + border-right: 2px solid var(--tp-border); + width: 250px; +} + +.ranking-table thead th.global-total-header { + z-index: 25; +} + +/* ========================================================================== + Training Program Layout & Components + ========================================================================== */ + +/* Page Constraint */ +.content-constraint { + max-width: 1200px; + margin: 0 0 0 20px; + padding-bottom: 40px; +} + +/* Page Header */ +.tp-page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 15px; +} + +/* Filter Bar */ +.tp-filter-card { + background-color: var(--tp-bg-light); + border: 1px solid var(--tp-border); + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.tp-filter-group { + display: flex; + align-items: center; + gap: 8px; +} + +.tp-filter-label { + font-weight: 600; + font-size: 0.85rem; + color: var(--tp-text-secondary); +} + +.tp-filter-input { + padding: 6px 10px; + border: 1px solid var(--tp-border-dark); + border-radius: 4px; + font-size: 0.9rem; + color: var(--tp-text-primary); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.tp-filter-input:focus { + border-color: var(--tp-primary); + box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.1); + outline: none; +} + +/* Buttons */ +.tp-btn-primary { + background-color: var(--tp-primary); + color: white !important; + border: none; + padding: 6px 16px; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + font-size: 0.9rem; + font-family: inherit; + transition: all 0.2s; + text-decoration: none; + display: inline-block; + line-height: 1.5; + box-sizing: border-box; + vertical-align: middle; +} + +.tp-btn-primary:hover { + background-color: var(--tp-primary-hover); + color: white; +} + +.tp-btn-text { + color: var(--tp-text-lighter); + text-decoration: underline; + font-size: 0.85rem; + margin-left: auto; +} + +.btn-filled-primary { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: var(--tp-primary); + color: white !important; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + font-family: inherit; +} + +.btn-filled-primary:hover { + background: var(--tp-primary-hover); + color: white; +} + +/* Data Table Wrapper */ +.tp-table-container { + overflow-x: auto; + max-height: 75vh; + border: 1px solid var(--tp-border-light); + border-radius: 8px; +} + +/* ========================================================================== + Training Day & Ranking Table Styles + ========================================================================== */ + +/* Training Day Headers */ +.training-day-header { + background-color: var(--tp-bg-light) !important; + text-align: center; + border-bottom: none !important; + pointer-events: none; +} + +.global-total-header { + background-color: var(--tp-bg-light) !important; + color: var(--tp-text-primary); + font-weight: 800; + border-left: none !important; + border-right: 1px solid var(--tp-border-light) !important; +} + +.ranking-table td.global-cell { + background-color: var(--tp-bg-gray) !important; + font-weight: 700; + border-left: none !important; + border-right: 1px solid var(--tp-border-light) !important; +} + +.global-total-header::after, +.ranking-table td.global-cell::after { + content: none !important; + display: none !important; +} + +.training-day-header-main { + font-size: 0.95rem; + color: var(--tp-text-primary); + font-weight: 700; +} + +.training-day-header-sub { + font-size: 0.75rem; + color: var(--tp-text-muted); + font-weight: normal; + margin-top: 2px; +} + +.task-header { + background-color: var(--tp-bg-white) !important; + font-size: 0.8rem; + color: var(--tp-text-secondary); + vertical-align: bottom; + border-left: 1px solid var(--tp-border-light) !important; + border-top: 3px solid var(--tp-border-light); +} + +/* Total Column specific styling */ +.total-col { + border-left: 2px solid var(--tp-border-light) !important; + background-color: var(--tp-bg-light); +} + +.ranking-table thead th.total-col { + background-color: var(--tp-bg-light) !important; + color: var(--tp-text-primary); + font-weight: 800; +} + +.ranking-table tbody td.total-col { + background-color: var(--tp-bg-light) !important; + font-weight: 700; + color: var(--tp-text-primary); +} + +.ranking-table th.training-day-header, +.ranking-table th.total-col, +.ranking-table td.total-col { + border-right: none !important; +} + +.ranking-table th.training-day-header, +.ranking-table th.total-col { + position: sticky; + z-index: 11; +} + +.ranking-table td.total-col { + position: relative; + z-index: 5; +} + +.ranking-table th.training-day-header::after, +.ranking-table th.total-col::after, +.ranking-table td.total-col::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 4px; + background-color: var(--tp-border-dark); + z-index: 10; +} + +.ranking-table .empty-cell-content { + color: var(--tp-border-dark); + text-align: center; + font-weight: normal; +} + +.inaccessible-cell { + background-color: var(--tp-bg-gray); + background-image: radial-gradient(var(--tp-border) 1px, transparent 1px); + background-size: 10px 10px; + color: var(--tp-text-light); +} + +/* Attendance badges for combined ranking */ +.attendance-badge { + margin-left: 4px; + font-size: 0.75em; + vertical-align: middle; +} + +.home-badge { + opacity: 0.7; +} + +.recorded-badge { + opacity: 0.8; + font-size: 1.1em !important; +} + +/* ========================================================================== + Attendance Modal Styles + ========================================================================== */ + +.attendance-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + justify-content: center; + align-items: center; +} + +.attendance-modal-content { + background: white; + border-radius: 8px; + width: 400px; + max-width: 90%; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +.attendance-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #e5e7eb; +} + +.attendance-modal-header h3 { + margin: 0; + font-size: 1.1rem; + color: #374151; +} + +.attendance-modal-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #6b7280; + padding: 0; + line-height: 1; +} + +.attendance-modal-close:hover { + color: #374151; +} + +.attendance-modal-body { + padding: 20px; +} + +.attendance-modal-row { + display: flex; + flex-direction: column; + margin-bottom: 16px; +} + +.attendance-modal-row label { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + color: #374151; + margin-bottom: 6px; +} + +.attendance-modal-row input[type="checkbox"] { + width: 16px; + height: 16px; +} + +.attendance-modal-row textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 0.9em; + resize: none; + box-sizing: border-box; +} + +.attendance-modal-row textarea:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + +.attendance-modal-hint { + color: #6b7280; + font-size: 0.85em; + margin-top: 4px; +} + +.attendance-modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 16px 20px; + border-top: 1px solid #e5e7eb; + background: #f9fafb; + border-radius: 0 0 8px 8px; +} + +.attendance-cell { + cursor: pointer; + transition: background-color 0.2s; +} + +.attendance-cell:hover { + background-color: var(--tp-border) !important; + box-shadow: 0 0 0 2px var(--tp-info); +} + +.attendance-table tbody tr:hover { + background-color: var(--tp-bg-gray); + cursor: pointer; +} + +.attendance-table tbody tr:hover th { + background-color: var(--tp-border); + font-weight: 600; +} + +.attendance-badges { + display: flex; + gap: 4px; + margin-top: 4px; + justify-content: center; + flex-wrap: wrap; +} + +.recorded-badge, .comment-indicator, .justified-badge { + font-size: 0.85em; + cursor: help; +} + +.comment-text { + font-size: 0.75rem; + color: var(--tp-text-muted); + margin-top: 4px; + line-height: 1.3; + word-break: break-word; + cursor: help; +} + + +.status-justified { + background-color: #fed7aa !important; + color: #92400e !important; +} + +/* ========================================================================== + Navigation & Header Components + ========================================================================== */ + +/* Breadcrumb Navigation */ +.nav-breadcrumb { + margin-bottom: 12px; +} + +.nav-back-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--tp-text-muted); + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + transition: color 0.2s; +} + +.nav-back-link:hover { + color: var(--tp-primary); +} + +/* ========================================================================== + Progress Card Styles + ========================================================================== */ + +.progress-card { + background: var(--tp-bg-white); + border: 1px solid var(--tp-border); + border-radius: 12px; + padding: 24px; + margin-bottom: 24px; + box-shadow: var(--tp-shadow-sm); +} + +.progress-card-header { + font-size: 0.85rem; + font-weight: 600; + color: var(--tp-text-lighter); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--tp-bg-gray); +} + +.progress-item { + padding: 16px 0; + border-bottom: 1px solid var(--tp-bg-gray); +} + +.progress-item:last-child { + border-bottom: none; +} + +/* ========================================================================== + Source Badges + ========================================================================== */ + +.source-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 4px; + font-size: 1em; + font-weight: 600; + line-height: 1; + white-space: nowrap; + width: fit-content; + text-decoration: none; +} + +.badge-training { + background: var(--tp-success-light); + color: var(--tp-success-dark); + border: 1px solid #bbf7d0; +} + +.badge-training:hover { + background: #dcfce7; +} + +.badge-archived { + background: var(--tp-warning-light); + color: var(--tp-warning-dark); + border: 1px solid #fde68a; +} + +.badge-shared { + background: var(--tp-info-light); + color: var(--tp-info-dark); + border: 1px solid var(--tp-info-border); +} + +.badge-manual { + background: var(--tp-bg-light); + color: var(--tp-text-muted); + border: 1px solid var(--tp-border); +} + +/* ========================================================================== + Histogram Modal Styles + ========================================================================== */ + +.histogram-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; +} + +.histogram-modal-content { + background: var(--tp-bg-white); + border-radius: 12px; + padding: 24px; + max-width: 800px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--tp-shadow-xl); +} + +.histogram-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid var(--tp-border); +} + +.histogram-modal-header h2 { + margin: 0; + font-size: 1.25rem; + color: var(--tp-text-primary); +} + +.histogram-close-btn { + background: none; + border: none; + font-size: 28px; + cursor: pointer; + color: var(--tp-text-lighter); + line-height: 1; + padding: 0; +} + +.histogram-close-btn:hover { + color: var(--tp-text-primary); +} + +.histogram-filter-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.histogram-filter-row label { + font-weight: 500; + color: var(--tp-text-secondary); + white-space: nowrap; +} + +.histogram-chart-container { + background: var(--tp-bg-light); + border-radius: 8px; + padding: 20px; + margin-bottom: 16px; +} + +.histogram-bars { + display: flex; + align-items: flex-end; + justify-content: space-between; + height: 200px; + gap: 4px; +} + +.histogram-bar-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; +} + +.histogram-bar-wrapper { + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-end; + width: 100%; +} + +.histogram-bar { + width: 100%; + min-height: 2px; + border-radius: 4px 4px 0 0; + transition: height 0.3s ease; +} + +.histogram-label { + font-size: 10px; + color: var(--tp-text-lighter); + margin-top: 4px; + text-align: center; +} + +.histogram-count { + font-size: 11px; + font-weight: 600; + color: var(--tp-text-secondary); + margin-top: 2px; +} + +.histogram-stats { + background: var(--tp-info-light); + border: 1px solid var(--tp-info-border); + border-radius: 6px; + padding: 12px 16px; + margin-bottom: 16px; + font-size: 0.9rem; + color: var(--tp-info-dark); +} + +.histogram-text-section { + border: 1px solid var(--tp-border); + border-radius: 8px; + overflow: hidden; +} + +.histogram-text-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + background: var(--tp-bg-light); + border-bottom: 1px solid var(--tp-border); +} + +.histogram-text-header span { + font-weight: 500; + color: var(--tp-text-secondary); +} + +.copy-btn { + background: var(--tp-info); + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; +} + +.copy-btn:hover { + background: var(--tp-info-hover); +} + +#histogramTextData { + width: 100%; + border: none; + padding: 12px 14px; + font-family: monospace; + font-size: 12px; + resize: vertical; + background: var(--tp-bg-white); +} + +.histogram-icon { + background: none; + border: none; + padding: 0; + margin: 0; + font-size: inherit; + line-height: inherit; + color: inherit; + font-family: inherit; + display: inline; + cursor: pointer; + margin-left: 4px; + opacity: 0.6; + transition: opacity 0.2s; + vertical-align: middle; +} + +.histogram-icon:hover { + opacity: 1; +} + +.histogram-icon:focus { + outline: 2px solid #007bff; + outline-offset: 1px; +} + + +.task-header .header-text, +.total-col-header .header-text { + vertical-align: middle; +} + +/* ========================================================================== + Combined Ranking Page Specific Styles + ========================================================================== */ + +.combined-ranking-student-wrapper { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + padding: 6px 10px; +} + +.combined-ranking-student-info { + flex: 1; + min-width: 0; +} + +.combined-ranking-name-row { + display: flex; + align-items: baseline; + gap: 6px; + max-width: 100%; +} + +.combined-ranking-name-link { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; + max-width: 100%; +} + +.combined-ranking-tags { + margin-top: 3px; + display: flex; + flex-wrap: wrap; + gap: 2px; +} + +.combined-ranking-tag { + font-weight: normal; + font-size: 0.7em; +} + +.history-link { + margin-left: auto; + text-decoration: none; +} + +.inaccessible-cell span { + opacity: 0.5; +} + +.empty-state-box { + text-align: center; + padding: 40px; + background: var(--tp-bg-light); + border-radius: 8px; + border: 1px dashed var(--tp-border-dark); + margin-top: 20px; +} + +.empty-state-box h3 { + color: var(--tp-text-secondary); + margin-bottom: 8px; + font-weight: 600; +} + +.empty-state-box p { + color: var(--tp-text-lighter); +} + +/* ========================================================================== + Tasks Page Styles + ========================================================================== */ + +/* Section header */ +.tp-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding: 0 4px; +} + +.tp-section-header h2 { + font-size: 1.25rem; + color: var(--tp-text-primary); + font-weight: 700; + margin: 0; +} + +/* Tasks table specific styles */ +.tp-tasks-table { + width: 100%; +} + +.tp-tasks-table thead th { + position: static !important; + top: auto !important; + left: auto !important; + z-index: auto !important; + box-shadow: none !important; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--tp-text-muted); + background: var(--tp-bg-light); + padding: 14px 16px !important; +} + +.tp-tasks-table td { + padding: 0; +} + +.tp-tasks-table tbody tr:hover { + background-color: var(--tp-bg-hover); +} + +/* Tagify inputs inside tables */ +.tp-tasks-table .tagify { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--tp-border-dark); + border-radius: 6px; + padding: 4px 6px; + min-height: 34px; + background: var(--tp-bg-white); + box-shadow: none; +} + +.tp-tasks-table .tagify--focus { + border-color: var(--tp-primary); + box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.08); +} + +.tp-tasks-table .tagify__input { + margin: 0; + min-width: 0; +} + +.tp-tasks-table .tagify__tag { + margin: 2px 4px 2px 0; +} + +/* Grip handle for drag and drop */ +.grip-column { + width: 40px; + text-align: center; +} + +.grip-cell { + width: 40px; + text-align: center; + vertical-align: middle; +} + +.grip-handle { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px; + cursor: grab; + color: var(--tp-text-light); + border-radius: 4px; + transition: all 0.2s; +} + +.grip-handle:hover { + color: var(--tp-text-secondary); + background: var(--tp-bg-gray); +} + +.grip-handle:active { + cursor: grabbing; +} + +/* Drag and drop states */ +.tp-tasks-table tbody tr[draggable="true"] { + transition: opacity 0.2s, background-color 0.2s; +} + +.tp-tasks-table tbody tr.dragging { + opacity: 0.5; + background: var(--tp-bg-gray); +} + +.drag-placeholder { + background: var(--tp-info-light); + border: 2px dashed var(--tp-info); +} + +.drag-placeholder td { + height: 50px; +} + +/* Detach button hover state */ +.btn-detach:hover { + color: var(--tp-info-hover) !important; + background: var(--tp-info-light) !important; +} + +/* Archive button hover state */ +.btn-archive:hover { + color: var(--tp-warning) !important; + background: var(--tp-warning-light) !important; +} + +/* ========================================================================== + Info Alert Component + ========================================================================== */ + +.tp-info-alert { + display: flex; + gap: 12px; + padding: 16px 20px; + border-radius: 8px; + margin-bottom: 20px; + border-left: 4px solid; +} + +.tp-info-alert-warning { + background: var(--tp-warning-light); + border-left-color: var(--tp-warning); +} + +.tp-info-alert-warning .tp-info-alert-icon { + color: var(--tp-warning); +} + +.tp-info-alert-warning .tp-info-alert-title { + color: var(--tp-warning-dark); +} + +.tp-info-alert-warning .tp-info-alert-message { + color: var(--tp-warning-dark); +} + +.tp-info-alert-info { + background: var(--tp-info-light); + border-left-color: var(--tp-info); +} + +.tp-info-alert-info .tp-info-alert-icon { + color: var(--tp-info); +} + +.tp-info-alert-info .tp-info-alert-title { + color: var(--tp-info-dark); +} + +.tp-info-alert-info .tp-info-alert-message { + color: var(--tp-info-dark); +} + +.tp-info-alert-icon { + flex-shrink: 0; + margin-top: 2px; +} + +.tp-info-alert-content { + flex: 1; +} + +.tp-info-alert-title { + font-weight: 600; + font-size: 0.95rem; + margin-bottom: 4px; +} + +.tp-info-alert-message { + font-size: 0.9rem; + opacity: 0.9; + line-height: 1.5; +} + +.tp-info-alert-items { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.tp-info-alert-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.6); + border-radius: 20px; + font-size: 0.85rem; + font-weight: 500; + text-decoration: none; + transition: all 0.2s; +} + +.tp-info-alert-warning .tp-info-alert-badge { + color: var(--tp-warning-dark); +} + +.tp-info-alert-warning .tp-info-alert-badge:hover { + background: rgba(255, 255, 255, 0.9); +} + +.tp-info-alert-info .tp-info-alert-badge { + color: var(--tp-info-dark); +} + +.tp-info-alert-info .tp-info-alert-badge:hover { + background: rgba(255, 255, 255, 0.9); +} + +.tp-info-alert-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + font-size: 0.75rem; + font-weight: 600; +} + +.tp-info-alert-warning .tp-info-alert-count { + background: var(--tp-warning); + color: white; +} + +.tp-info-alert-info .tp-info-alert-count { + background: var(--tp-info); + color: white; +} + +/* ========================================================================== + Training Programs Page - Card Layout + ========================================================================== */ + +/* Stats Row */ +.tp-stats-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-bottom: 32px; +} + +.tp-stat-card { + display: flex; + align-items: center; + gap: 16px; + background: var(--tp-bg-white); + border: 1px solid var(--tp-border); + border-radius: 12px; + padding: 20px 24px; + box-shadow: var(--tp-shadow-sm); +} + +.tp-stat-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.tp-stat-icon-purple { + background: #f3e8ff; + color: #7c3aed; +} + +.tp-stat-icon-green { + background: var(--tp-success-light); + color: var(--tp-success); +} + +.tp-stat-icon-orange { + background: var(--tp-warning-light); + color: var(--tp-warning); +} + +.tp-stat-content { + display: flex; + flex-direction: column; +} + +.tp-stat-label { + font-size: 0.85rem; + color: var(--tp-text-muted); + margin-bottom: 4px; +} + +.tp-stat-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--tp-text-primary); + line-height: 1; +} + +/* Cards Grid */ +.tp-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + gap: 24px; +} + +/* Program Card */ +.tp-program-card { + background: var(--tp-bg-white); + border: 1px solid var(--tp-border); + border-radius: 12px; + padding: 24px; + box-shadow: var(--tp-shadow-sm); + display: flex; + flex-direction: column; + transition: box-shadow 0.2s, border-color 0.2s; +} + +.tp-program-card:hover { + box-shadow: var(--tp-shadow-md); + border-color: var(--tp-border-dark); +} + +/* Card Header */ +.tp-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; +} + +.tp-card-title-section { + flex: 1; + min-width: 0; +} + +.tp-card-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--tp-text-primary); + text-decoration: none; + display: block; + margin-bottom: 4px; +} + +.tp-card-title:hover { + color: var(--tp-primary); +} + +.tp-card-description { + font-size: 0.9rem; + color: var(--tp-text-muted); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; +} + +/* Card Menu */ +.tp-card-menu { + position: relative; + margin-left: 8px; +} + +.tp-menu-btn { + background: none; + border: none; + padding: 8px; + cursor: pointer; + color: var(--tp-text-light); + border-radius: 8px; + transition: all 0.2s; +} + +.tp-menu-btn:hover { + background: var(--tp-bg-gray); + color: var(--tp-text-secondary); +} + +.tp-menu-dropdown { + display: none; + position: absolute; + top: 100%; + right: 0; + background: var(--tp-bg-white); + border: 1px solid var(--tp-border); + border-radius: 8px; + box-shadow: var(--tp-shadow-lg); + min-width: 180px; + z-index: 100; + padding: 8px 0; +} + +.tp-menu-dropdown.show { + display: block; +} + +.tp-menu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + color: var(--tp-text-secondary) !important; + text-decoration: none; + font-size: 0.9rem; + transition: background 0.15s; +} + +.tp-menu-item:hover { + background: var(--tp-bg-hover) !important; + color: var(--tp-text-primary) !important; +} + +.tp-menu-item svg { + flex-shrink: 0; +} + +.tp-menu-item-danger { + color: var(--tp-danger) !important; +} + +.tp-menu-item-danger:hover { + background: var(--tp-danger-light) !important; + color: var(--tp-danger-dark) !important; +} + +.tp-menu-divider { + height: 1px; + background: var(--tp-border); + margin: 8px 0; +} + +/* Card Stats */ +.tp-card-stats { + display: flex; + gap: 24px; + margin-bottom: 12px; +} + +.tp-card-stat { + display: flex; + align-items: center; + gap: 8px; + color: var(--tp-text-muted); + font-size: 0.9rem; +} + +.tp-card-stat svg { + color: var(--tp-text-light); +} + +/* Card Dates */ +.tp-card-dates { + display: flex; + align-items: center; + gap: 8px; + color: var(--tp-text-muted); + font-size: 0.9rem; + margin-bottom: 20px; +} + +.tp-card-dates svg { + color: var(--tp-text-light); +} + +/* Active Training Days Section */ +.tp-card-training-days { + background: var(--tp-bg-light); + border-radius: 8px; + padding: 16px; + margin-bottom: 20px; +} + +.tp-training-days-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.tp-training-days-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--tp-text-muted); + letter-spacing: 0.05em; +} + +.tp-training-days-link { + text-decoration: none; + transition: color 0.2s; +} + +.tp-training-days-link:hover { + color: var(--tp-primary); + text-decoration: underline; +} + +.tp-training-days-count { + background: var(--tp-primary); + color: white; + font-size: 0.75rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; +} + +.tp-training-days-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tp-training-day-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--tp-bg-white); + border-radius: 8px; + text-decoration: none; + transition: all 0.15s; + border: 1px solid transparent; +} + +.tp-training-day-item:hover { + border-color: var(--tp-border); + box-shadow: var(--tp-shadow-sm); +} + +.tp-training-day-icon { + width: 32px; + height: 32px; + background: var(--tp-info-light); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: var(--tp-info); + flex-shrink: 0; +} + +.tp-training-day-info { + flex: 1; + min-width: 0; +} + +.tp-training-day-name { + font-size: 0.9rem; + font-weight: 600; + color: var(--tp-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tp-training-day-date { + font-size: 0.8rem; + color: var(--tp-text-muted); +} + +.tp-training-day-tasks-badge { + background: var(--tp-danger); + color: white; + font-size: 0.7rem; + font-weight: 600; + padding: 2px 6px; + border-radius: 10px; + min-width: 18px; + text-align: center; +} + +.tp-training-day-arrow { + color: var(--tp-text-light); + flex-shrink: 0; +} + +.tp-training-day-more { + font-size: 0.85rem; + color: var(--tp-primary); + text-decoration: none; + padding: 8px 12px; + text-align: center; +} + +.tp-training-day-more:hover { + text-decoration: underline; +} + +/* Create New Program Card */ +.tp-create-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--tp-bg-white); + border: 2px dashed var(--tp-border-dark); + border-radius: 12px; + padding: 48px 24px; + text-decoration: none; + transition: all 0.2s; + min-height: 300px; +} + +.tp-create-card:hover { + border-color: var(--tp-primary); + background: var(--tp-bg-light); +} + +.tp-create-icon { + width: 64px; + height: 64px; + background: var(--tp-bg-gray); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--tp-text-light); + margin-bottom: 16px; + transition: all 0.2s; +} + +.tp-create-card:hover .tp-create-icon { + background: var(--tp-primary); + color: white; +} + +.tp-create-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--tp-primary); + margin-bottom: 4px; +} + +.tp-create-subtitle { + font-size: 0.9rem; + color: var(--tp-text-muted); +} + +/* Responsive adjustments */ +@media (max-width: 900px) { + .tp-stats-row { + grid-template-columns: 1fr; + } + + .tp-cards-grid { + grid-template-columns: 1fr; + } +} + +/* Training Day Indicators */ +.tp-training-day-indicators { + display: flex; + gap: 8px; + align-items: center; + margin-left: auto; +} + +.tp-indicator { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; +} + +.tp-indicator svg { + flex-shrink: 0; +} + +.tp-indicator-question { + background: var(--tp-info-light); + color: var(--tp-info); +} + +.tp-indicator-delay { + background: var(--tp-warning-light); + color: var(--tp-warning-dark); +} + +/* Bottom stats row (for archived days counter) */ +.tp-card-stats-bottom { + margin-top: auto; + padding-top: 16px; + border-top: 1px solid var(--tp-border); + margin-bottom: 0; +} + +/* ========================================================================== + Form Card Styles + ========================================================================== */ + +.tp-form-card { + background: var(--tp-bg-white); + border: 1px solid var(--tp-border); + border-radius: 12px; + padding: 32px; + box-shadow: var(--tp-shadow-sm); +} + +.tp-form-label { + display: block; + font-size: 0.9rem; + font-weight: 600; + color: var(--tp-text-primary); + margin-bottom: 8px; +} + +.tp-form-required { + color: var(--tp-danger); + margin-left: 2px; +} + +.tp-form-input { + width: 100%; + padding: 10px 14px; + font-size: 0.95rem; + border: 1px solid var(--tp-border); + border-radius: 8px; + background: var(--tp-bg-white); + color: var(--tp-text-primary); + transition: border-color 0.2s, box-shadow 0.2s; + box-sizing: border-box; +} + +.tp-form-input:focus { + outline: none; + border-color: var(--tp-primary); + box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.1); +} + +.tp-form-input::placeholder { + color: var(--tp-text-light); +} + +.tp-form-hint { + font-size: 0.8rem; + color: var(--tp-text-muted); + margin-top: 6px; +} + +.tp-form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +@media (max-width: 600px) { + .tp-form-row { + grid-template-columns: 1fr; + } +} + +.tp-form-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid var(--tp-border); +} + +.tp-form-submit-group { + display: flex; + gap: 12px; +} + +.tp-btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 16px; + font-size: 0.9rem; + font-weight: 600; + color: var(--tp-text-secondary); + background: var(--tp-bg-white); + border: 1px solid var(--tp-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + font-family: inherit; + line-height: 1.5; + box-sizing: border-box; + vertical-align: middle; +} + +.tp-btn-secondary:hover { + background: var(--tp-bg-gray); + border-color: var(--tp-border-dark); + color: var(--tp-text-primary); +} + +/* ========================================================================== + Training Day Form Styles (add/edit training day) + ========================================================================== */ + +.tp-form-section { + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 1px solid var(--tp-border); +} + +.tp-form-section:last-of-type { + border-bottom: none; + margin-bottom: 20px; +} + +.tp-form-section-title { + font-size: 16px; + font-weight: 600; + color: var(--tp-text-secondary); + margin: 0 0 8px 0; +} + +.tp-form-section-desc { + font-size: 13px; + color: var(--tp-text-lighter); + margin: 0 0 20px 0; +} + +.tp-form-info { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--tp-bg-gray); + color: var(--tp-text-lighter); + font-size: 11px; + font-weight: 600; + cursor: help; + margin-left: 4px; +} + +.tp-form-duration { + display: flex; + align-items: center; + gap: 8px; +} + +.tp-form-input-small { + width: 80px !important; +} + +.tp-form-duration-label { + font-size: 13px; + color: var(--tp-text-lighter); +} + +.tp-groups-table-container { + overflow-x: auto; + margin-bottom: 15px; +} + +.tp-groups-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.tp-groups-table th { + text-align: left; + padding: 8px 8px; + background: var(--tp-bg-light); + border-bottom: 2px solid var(--tp-border); + font-weight: 600; + color: var(--tp-text-secondary); + font-size: 13px; +} + +.tp-groups-table td { + padding: 8px 6px; + border-bottom: 1px solid var(--tp-border); + vertical-align: middle; +} + +.tp-groups-table tbody tr:hover { + background: var(--tp-bg-light); +} + +.tp-groups-table input[type="text"], +.tp-groups-table input[type="datetime-local"], +.tp-groups-table input[type="number"] { + padding: 8px 10px; + border: 1px solid var(--tp-border); + border-radius: 6px; + font-size: 13px; +} + +.tp-groups-table input[type="number"] { + width: 40px; + padding: 8px 6px; +} + +.tp-groups-table .tp-group-duration { + display: flex; + align-items: center; + gap: 2px; + white-space: nowrap; +} + +.tp-groups-table .tp-group-duration span { + font-size: 12px; + color: var(--tp-text-lighter); +} + +.tp-btn-add-group { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.tp-btn-remove { + padding: 4px 10px; + background: var(--tp-danger-light); + color: var(--tp-danger); + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.tp-btn-remove:hover { + background: var(--tp-danger-border); +} diff --git a/cms/server/admin/static/aws_utils.js b/cms/server/admin/static/aws_utils.js index f67de5f005..b4b0eb0ac6 100644 --- a/cms/server/admin/static/aws_utils.js +++ b/cms/server/admin/static/aws_utils.js @@ -403,148 +403,8 @@ CMS.AWSUtils.prototype.close_notification = function(item) { }; -/** - * Provides table row comparator for specified column and order. - */ -function get_table_row_comparator(column_idx, numeric, ascending) { - return function(a, b) { - var valA = $(a).children("td").eq(column_idx).text(); - var valB = $(b).children("td").eq(column_idx).text(); - var result = numeric - ? Number(valA) - Number(valB) - : valA.localeCompare(valB); - return ascending ? -result : result; - } -} - - -/** - * Sorts specified table by specified column in specified order. - */ -CMS.AWSUtils.sort_table = function(table, column_idx, ascending) { - var initial_column_idx = table.data("initial_sort_column_idx"); - var ranks_column = table.data("ranks_column"); - column_idx += ranks_column ? 1 : 0; - var table_rows = table - .children("tbody") - .children("tr"); - var column_header = table - .children("thead") - .children("tr") - .children("th") - .eq(column_idx); - var settings = (column_header.attr("data-sort-settings") || "").split(" "); - - var numeric = settings.indexOf("numeric") >= 0; - - // If specified, flip column's natural order, e.g. due to meaning of values. - if (settings.indexOf("reversed") >= 0) { - ascending = !ascending; - } - - // Normalize column index, converting negative to positive from the end. - column_idx = column_header.index(); - - // Reassign arrows to headers - table.find(".column-sort").html("↕"); - column_header.find(".column-sort").html(ascending ? "↑" : "↓"); - - // Do the sorting, by initial column and then by selected column. - table_rows - .sort(get_table_row_comparator(initial_column_idx, numeric, ascending)) - .sort(get_table_row_comparator(column_idx, numeric, ascending)) - .each(function(idx, row) { - table.children("tbody").append(row) - }); - - if (ranks_column) { - table_rows.each(function(idx, row) { - $(row).children("td").first().text(idx + 1) - }); - } -}; - - -/** - * Makes table sortable, adding ranks column and sorting buttons in header. - */ -CMS.AWSUtils.init_table_sort = function(table, ranks_column, - initial_column_idx, - initial_ascending) { - table.addClass("sortable"); - var table_column_headers = table - .children("thead") - .children("tr"); - var table_rows = table - .children("tbody") - .children("tr"); - - // Normalize column index, converting negative to positive from the end. - initial_column_idx = table_column_headers - .children("th") - .eq(initial_column_idx) - .index(); - - table.data("ranks_column", ranks_column); - table.data("initial_sort_column_idx", initial_column_idx); - - // Declaring sort settings. - var previous_column_idx = initial_column_idx; - var ascending = initial_ascending; - - // Add sorting indicators to column headers - table_column_headers - .children("th") - .each(function(column_idx, header) { - $("", { - href: "#", - class: "column-sort", - click: function() { - ascending = !ascending && previous_column_idx == column_idx; - previous_column_idx = column_idx; - CMS.AWSUtils.sort_table(table, column_idx, ascending); - } - }).appendTo(header); - }); - - // Add ranks column - if (ranks_column) { - table_column_headers.prepend("#"); - table_rows.prepend(""); - } - - // Do initial sorting - CMS.AWSUtils.sort_table(table, initial_column_idx, initial_ascending); -}; - - -/** - * Filters table rows based on search text. - * - * table_id (string): the id of the table to filter. - * search_text (string): the text to search for in table rows. - */ -CMS.AWSUtils.filter_table = function(table_id, search_text) { - var table = document.getElementById(table_id); - if (!table) { - return; - } - var rows = table.querySelectorAll("tbody tr"); - var search_lower = search_text.toLowerCase().trim(); - - rows.forEach(function(row) { - if (search_lower === "") { - row.style.display = ""; - return; - } - var text = row.textContent.toLowerCase(); - if (text.indexOf(search_lower) !== -1) { - row.style.display = ""; - } else { - row.style.display = "none"; - } - }); -}; +// Table utilities (get_table_row_comparator, sort_table, init_table_sort, filter_table) +// have been moved to aws_table_utils.js for better code organization. /** @@ -941,50 +801,7 @@ CMS.AWSUtils.ajax_post = function(url) { }; -/** - * Initialize password strength indicator for a password field. - * Uses zxcvbn library to calculate password strength and displays - * a colored bar with text feedback. - * - * fieldSelector (string): jQuery selector for the password input field. - * barSelector (string): jQuery selector for the strength bar element. - * textSelector (string): jQuery selector for the strength text element. - */ -CMS.AWSUtils.initPasswordStrength = function(fieldSelector, barSelector, textSelector) { - var strengthMessages = ["Very weak", "Weak", "Fair", "Strong", "Very strong"]; - var strengthColors = ["#dc3545", "#dc3545", "#ffc107", "#28a745", "#28a745"]; - var strengthWidths = ["20%", "40%", "60%", "80%", "100%"]; - - var $field = $(fieldSelector); - if (!$field.length) { - return; - } - - var $bar = $(barSelector); - var $text = $(textSelector); - - $field.on("input", function() { - var pwd = $(this).val(); - - if (!pwd) { - $bar.hide(); - $text.text(""); - return; - } - - if (typeof zxcvbn === "function") { - var result = zxcvbn(pwd); - var score = result.score; - - $bar.css({ - "background-color": strengthColors[score], - "width": strengthWidths[score] - }).show(); - $text.text("Password strength: " + strengthMessages[score]); - $text.css("color", strengthColors[score]); - } - }); -}; +// initPasswordStrength has been moved to aws_form_utils.js /** @@ -1011,6 +828,22 @@ CMS.AWSUtils.prototype.announcement_edit_toggle = function (event, invoker) { form.querySelector('input[name="subject"]').value = subjectText; form.querySelector('textarea[name="text"]').value = bodyText; + + // Populate visible_to_tags field if it exists + const visibleToTagsInput = form.querySelector('input[name="visible_to_tags"]'); + const rawVisibleToTags = notification.querySelector('.announcement_raw_visible_to_tags'); + if (visibleToTagsInput && rawVisibleToTags) { + const rawValue = rawVisibleToTags.value; + const tagify = visibleToTagsInput._tagify; + if (tagify) { + tagify.removeAllTags(); + const tags = rawValue.split(",").map(t => t.trim()).filter(Boolean); + if (tags.length) tagify.addTags(tags); + } else { + visibleToTagsInput.value = rawValue; + } + } + var obj = notification.querySelector(".reply_question"); if (obj.style.display != "block") { obj.style.display = "block"; @@ -1338,3 +1171,8 @@ CMS.AWSUtils.initModelSolutionSubtasks = function(options) { }); }); }; + + +// Form utilities (initDateTimeValidation, initRemovePage, initReadOnlyTagify, initTagify) +// have been moved to aws_form_utils.js for better code organization. +// Backward compatibility aliases are set up in aws_form_utils.js. diff --git a/cms/server/admin/static/js/admin_table_utils.js b/cms/server/admin/static/js/admin_table_utils.js new file mode 100644 index 0000000000..79c084f137 --- /dev/null +++ b/cms/server/admin/static/js/admin_table_utils.js @@ -0,0 +1,179 @@ +/** + * Shared table utilities for CMS Admin interface. + * Provides drag-and-drop reordering and Tagify filter initialization. + * + * Note: For table sorting, use CMS.AWSUtils.init_table_sort from aws_utils.js + * which provides a unified sorting solution across the admin interface. + */ + +/** + * Initialize drag-and-drop reordering for table rows. + * + * @param {Object} options - Configuration options. + * @param {string} options.tbodyId - The ID of the tbody element. + * @param {string} options.rowSelector - CSS selector for draggable rows. + * @param {string} options.rowIdAttr - Data attribute name for row ID (e.g., 'task-id' reads data-task-id). + * @param {number} options.colSpan - Number of columns for placeholder (default: 4). + * @param {string} options.confirmMessage - Confirmation message before saving (optional). + * @param {Function} options.onSave - Callback function(orderedIds) when order is saved. + * orderedIds is an array of {id: string, position: number} objects. + * @param {boolean} options.enabled - Whether drag-drop is enabled (default: true). + */ +function initDragDropReorder(options) { + if (options.enabled === false) return; + + var tbody = document.getElementById(options.tbodyId); + if (!tbody) return; + + var rowSelector = options.rowSelector || 'tr'; + var rowIdAttr = options.rowIdAttr || 'id'; + var colSpan = options.colSpan || 4; + var confirmMessage = options.confirmMessage; + + var draggedRow = null; + var placeholder = null; + var originalOrder = null; + + function createPlaceholder() { + var tr = document.createElement('tr'); + tr.className = 'drag-placeholder'; + tr.innerHTML = ''; + return tr; + } + + function getRows() { + return Array.from(tbody.querySelectorAll(rowSelector + '[data-' + rowIdAttr + ']')); + } + + tbody.addEventListener('dragstart', function(e) { + var targetRow = e.target.closest(rowSelector); + if (!targetRow || !targetRow.hasAttribute('data-' + rowIdAttr)) return; + + draggedRow = targetRow; + originalOrder = getRows(); + draggedRow.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', ''); + placeholder = createPlaceholder(); + }); + + tbody.addEventListener('dragend', function(e) { + if (draggedRow) { + draggedRow.classList.remove('dragging'); + if (placeholder && placeholder.parentNode) { + placeholder.parentNode.removeChild(placeholder); + } + draggedRow = null; + placeholder = null; + saveNewOrder(); + } + }); + + tbody.addEventListener('dragover', function(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + var targetRow = e.target.closest(rowSelector); + if (!targetRow || targetRow === draggedRow || targetRow === placeholder) return; + + var rect = targetRow.getBoundingClientRect(); + var midY = rect.top + rect.height / 2; + + if (e.clientY < midY) { + targetRow.parentNode.insertBefore(placeholder, targetRow); + } else { + targetRow.parentNode.insertBefore(placeholder, targetRow.nextSibling); + } + }); + + tbody.addEventListener('drop', function(e) { + e.preventDefault(); + if (placeholder && placeholder.parentNode && draggedRow) { + placeholder.parentNode.insertBefore(draggedRow, placeholder); + placeholder.parentNode.removeChild(placeholder); + } + }); + + function restoreOriginalOrder() { + if (!originalOrder) return; + originalOrder.forEach(function(row) { + tbody.appendChild(row); + }); + originalOrder = null; + } + + function saveNewOrder() { + var rows = getRows(); + + if (originalOrder && + rows.length === originalOrder.length && + rows.every(function(row, i) { return row === originalOrder[i]; })) { + originalOrder = null; + return; + } + + if (confirmMessage && !confirm(confirmMessage)) { + restoreOriginalOrder(); + return; + } + + var orderedIds = rows.map(function(row, index) { + return { + id: row.getAttribute('data-' + rowIdAttr), + position: index + }; + }); + + if (options.onSave) { + options.onSave(orderedIds); + } + + originalOrder = null; + } +} + +/** + * Initialize read-only Tagify filter inputs for training program filter forms. + * + * @param {Object} options - Configuration options. + * @param {string} options.trainingDayTypesSelector - CSS selector for training day types input. + * @param {Array} options.trainingDayTypesWhitelist - Whitelist for training day types. + * @param {string} options.studentTagsSelector - CSS selector for student tags input. + * @param {Array} options.studentTagsWhitelist - Whitelist for student tags. + */ +function initFilterTagify(options) { + if (typeof Tagify === 'undefined') return; + + var tagifyConfig = { + delimiters: ",", + enforceWhitelist: true, + editTags: false, + dropdown: { enabled: 0, maxItems: 20, closeOnSelect: true }, + originalInputValueFormat: function(valuesArr) { + return valuesArr.map(function(item) { return item.value; }).join(', '); + } + }; + + if (options.trainingDayTypesSelector) { + var filterInput = document.querySelector(options.trainingDayTypesSelector); + if (filterInput) { + new Tagify(filterInput, Object.assign({}, tagifyConfig, { + whitelist: options.trainingDayTypesWhitelist || [] + })); + } + } + + if (options.studentTagsSelector) { + var studentTagsInput = document.querySelector(options.studentTagsSelector); + if (studentTagsInput) { + new Tagify(studentTagsInput, Object.assign({}, tagifyConfig, { + whitelist: options.studentTagsWhitelist || [] + })); + } + } +} + +window.AdminTableUtils = { + initDragDropReorder: initDragDropReorder, + initFilterTagify: initFilterTagify +}; diff --git a/cms/server/admin/static/tagify/tagify.css b/cms/server/admin/static/tagify/tagify.css new file mode 100644 index 0000000000..10ec113d50 --- /dev/null +++ b/cms/server/admin/static/tagify/tagify.css @@ -0,0 +1 @@ +@charset "UTF-8";:root{--tagify-dd-color-primary:rgb(53,149,246);--tagify-dd-text-color:black;--tagify-dd-bg-color:white;--tagify-dd-item-pad:.3em .5em;--tagify-dd-max-height:300px}.tagify{--tags-disabled-bg:#F1F1F1;--tags-border-color:#DDD;--tags-hover-border-color:#CCC;--tags-focus-border-color:#3595f6;--tag-border-radius:3px;--tag-bg:#E5E5E5;--tag-hover:#D3E2E2;--tag-text-color:black;--tag-text-color--edit:black;--tag-pad:0.3em 0.5em;--tag-inset-shadow-size:1.2em;--tag-invalid-color:#D39494;--tag-invalid-bg:rgba(211, 148, 148, 0.5);--tag--min-width:1ch;--tag--max-width:100%;--tag-hide-transition:0.3s;--tag-remove-bg:rgba(211, 148, 148, 0.3);--tag-remove-btn-color:black;--tag-remove-btn-bg:none;--tag-remove-btn-bg--hover:#c77777;--input-color:inherit;--placeholder-color:rgba(0, 0, 0, 0.4);--placeholder-color-focus:rgba(0, 0, 0, 0.25);--loader-size:.8em;--readonly-striped:1;display:inline-flex;align-items:flex-start;align-content:baseline;flex-wrap:wrap;border:1px solid var(--tags-border-color);padding:0;line-height:0;outline:0;position:relative;box-sizing:border-box;transition:.1s}@keyframes tags--bump{30%{transform:scale(1.2)}}@keyframes rotateLoader{to{transform:rotate(1turn)}}.tagify:has([contenteditable=true]){cursor:text}.tagify:hover:not(.tagify--focus):not(.tagify--invalid){--tags-border-color:var(--tags-hover-border-color)}.tagify[disabled]{background:var(--tags-disabled-bg);filter:saturate(0);opacity:.5;pointer-events:none}.tagify[disabled].tagify--empty>.tagify__input::before{position:relative}.tagify[disabled].tagify--select,.tagify[readonly].tagify--select{pointer-events:none}.tagify[disabled]:not(.tagify--mix):not(.tagify--select):not(.tagify--empty),.tagify[readonly]:not(.tagify--mix):not(.tagify--select):not(.tagify--empty){cursor:default}.tagify[disabled]:not(.tagify--mix):not(.tagify--select):not(.tagify--empty)>.tagify__input,.tagify[readonly]:not(.tagify--mix):not(.tagify--select):not(.tagify--empty)>.tagify__input{visibility:hidden;width:0;margin:5px 0}.tagify[disabled]:not(.tagify--mix):not(.tagify--select):not(.tagify--empty) .tagify__tag>div,.tagify[readonly]:not(.tagify--mix):not(.tagify--select):not(.tagify--empty) .tagify__tag>div{padding:var(--tag-pad)}.tagify[disabled]:not(.tagify--mix):not(.tagify--select):not(.tagify--empty) .tagify__tag>div::before,.tagify[readonly]:not(.tagify--mix):not(.tagify--select):not(.tagify--empty) .tagify__tag>div::before{animation:readonlyStyles 1s calc(-1s * (var(--readonly-striped) - 1)) paused}@keyframes readonlyStyles{0%{background:linear-gradient(45deg,var(--tag-bg) 25%,transparent 25%,transparent 50%,var(--tag-bg) 50%,var(--tag-bg) 75%,transparent 75%,transparent) 0/5px 5px;box-shadow:none;filter:brightness(.95)}}.tagify[disabled] .tagify__tag__removeBtn,.tagify[readonly] .tagify__tag__removeBtn{display:none}.tagify--loading .tagify__input>br:last-child{display:none}.tagify--loading .tagify__input::before{content:none}.tagify--loading .tagify__input::after{content:"";vertical-align:middle;opacity:1;width:.7em;height:.7em;width:var(--loader-size);height:var(--loader-size);min-width:0;border:3px solid;border-color:#eee #bbb #888 transparent;border-radius:50%;animation:rotateLoader .4s infinite linear;content:""!important;margin:-2px 0 -2px .5em}.tagify--loading .tagify__input:empty::after{margin-left:0}.tagify+input,.tagify+textarea{position:absolute!important;left:-9999em!important;transform:scale(0)!important}.tagify__tag{display:inline-flex;align-items:center;max-width:var(--tag--max-width);margin-inline:5px 0;margin-block:5px;position:relative;z-index:1;outline:0;line-height:normal;cursor:default;transition:.13s ease-out}.tagify__tag>div{display:flex;flex:1;vertical-align:top;box-sizing:border-box;max-width:100%;padding:var(--tag-pad);color:var(--tag-text-color);line-height:inherit;border-radius:var(--tag-border-radius);white-space:nowrap;transition:.13s ease-out}.tagify__tag>div>*{white-space:pre-wrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;vertical-align:top;min-width:var(--tag--min-width);max-width:var(--tag--max-width);transition:.8s ease,.1s color}.tagify__tag>div>[contenteditable]{display:block;outline:0;-webkit-user-select:text;user-select:text;cursor:text;margin:-2px;padding:2px;max-width:350px}.tagify__tag>div>:only-child{width:100%}.tagify__tag>div::before{content:"";position:absolute;border-radius:inherit;inset:var(--tag-bg-inset,0);z-index:-1;pointer-events:none;transition:120ms ease;animation:tags--bump .3s ease-out 1;box-shadow:0 0 0 var(--tag-inset-shadow-size) var(--tag-bg) inset}.tagify__tag:focus div::before,.tagify__tag:hover:not([readonly]) div::before{--tag-bg-inset:-2.5px;--tag-bg:var(--tag-hover)}.tagify__tag--loading{pointer-events:none}.tagify__tag--loading .tagify__tag__removeBtn{display:none}.tagify__tag--loading::after{--loader-size:.4em;content:"";vertical-align:middle;opacity:1;width:.7em;height:.7em;width:var(--loader-size);height:var(--loader-size);min-width:0;border:3px solid;border-color:#eee #bbb #888 transparent;border-radius:50%;animation:rotateLoader .4s infinite linear;margin:0 .5em 0 -.1em}.tagify__tag--flash div::before{animation:none}.tagify__tag--hide{width:0!important;padding-left:0;padding-right:0;margin-left:0;margin-right:0;opacity:0;transform:scale(0);transition:var(--tag-hide-transition);pointer-events:none}.tagify__tag--hide>div>*{white-space:nowrap}.tagify__tag.tagify--noAnim>div::before{animation:none}.tagify__tag.tagify--notAllowed:not(.tagify__tag--editable) div>span{opacity:.5}.tagify__tag.tagify--notAllowed:not(.tagify__tag--editable) div::before{--tag-bg:var(--tag-invalid-bg);transition:.2s}.tagify__tag[readonly] .tagify__tag__removeBtn{display:none}.tagify__tag[readonly]>div::before{animation:readonlyStyles 1s calc(-1s * (var(--readonly-striped) - 1)) paused}@keyframes readonlyStyles{0%{background:linear-gradient(45deg,var(--tag-bg) 25%,transparent 25%,transparent 50%,var(--tag-bg) 50%,var(--tag-bg) 75%,transparent 75%,transparent) 0/5px 5px;box-shadow:none;filter:brightness(.95)}}.tagify__tag--editable>div{color:var(--tag-text-color--edit)}.tagify__tag--editable>div::before{box-shadow:0 0 0 2px var(--tag-hover) inset!important}.tagify__tag--editable>.tagify__tag__removeBtn{pointer-events:none;opacity:0;transform:translateX(100%) translateX(5px)}.tagify__tag--editable.tagify--invalid>div::before{box-shadow:0 0 0 2px var(--tag-invalid-color) inset!important}.tagify__tag__removeBtn{order:5;display:inline-flex;align-items:center;justify-content:center;border-radius:50px;cursor:pointer;font:14px/1 Arial;background:var(--tag-remove-btn-bg);color:var(--tag-remove-btn-color);width:14px;height:14px;margin-inline:auto 4.6666666667px;overflow:hidden;transition:.2s ease-out}.tagify__tag__removeBtn::after{content:"×";transition:.3s,color 0s}.tagify__tag__removeBtn:hover{color:#fff;background:var(--tag-remove-btn-bg--hover)}.tagify__tag__removeBtn:hover+div>span{opacity:.5}.tagify__tag__removeBtn:hover+div::before{box-shadow:0 0 0 var(--tag-inset-shadow-size) var(--tag-remove-bg,rgba(211,148,148,.3)) inset!important;transition:box-shadow .2s}.tagify:not(.tagify--mix) .tagify__input br{display:none}.tagify:not(.tagify--mix) .tagify__input *{display:inline;white-space:nowrap}.tagify__input{flex-grow:1;display:inline-block;margin:5px;padding:var(--tag-pad);line-height:normal;min-width:110px;min-height:1.5lh;position:relative;white-space:pre-wrap;color:var(--input-color);box-sizing:inherit;overflow:hidden}.tagify__input:focus{outline:0}.tagify__input:focus::before{transition:.2s ease-out;opacity:0;transform:translatex(6px)}@supports (-ms-ime-align:auto){.tagify__input:focus::before{display:none}}.tagify__input:focus:empty::before{transition:.2s ease-out;opacity:1;transform:none;color:rgba(0,0,0,.25);color:var(--placeholder-color-focus)}@-moz-document url-prefix(){.tagify__input:focus:empty::after{display:none}}.tagify__input::before{content:attr(data-placeholder);width:100%;height:100%;margin:auto 0;z-index:1;color:var(--placeholder-color);white-space:nowrap;text-overflow:ellipsis;overflow:hidden;pointer-events:none;opacity:0;position:absolute}.tagify__input::after{content:attr(data-suggest);display:inline-block;vertical-align:middle;position:absolute;min-width:calc(100% - 1.5em);text-overflow:ellipsis;overflow:hidden;white-space:pre;color:var(--tag-text-color);opacity:.3;pointer-events:none;max-width:100px}.tagify__input .tagify__tag{margin:0 1px}.tagify--mix{display:block}.tagify--mix .tagify__input{padding:5px;margin:0;width:100%;height:100%;line-height:1.5;display:block}.tagify--mix .tagify__input::before{height:auto;display:none;line-height:inherit}.tagify--mix .tagify__input::after{content:none}.tagify--select{cursor:default}.tagify--select::after{content:">";opacity:.5;position:absolute;top:50%;right:0;bottom:0;font:16px monospace;line-height:8px;height:8px;pointer-events:none;transform:translate(-150%,-50%) scaleX(1.2) rotate(90deg);transition:.2s ease-in-out}.tagify--select[aria-expanded=true]::after{transform:translate(-150%,-50%) rotate(270deg) scaleY(1.2)}.tagify--select[aria-expanded=true] .tagify__tag__removeBtn{pointer-events:none;opacity:0;transform:translateX(100%) translateX(5px)}.tagify--select .tagify__tag{flex:1;max-width:none;margin-inline-end:2em;margin-block:0;padding-block:5px;cursor:text}.tagify--select .tagify__tag div::before{display:none}.tagify--select .tagify__tag+.tagify__input{display:none}.tagify--empty .tagify__input::before{transition:.2s ease-out;opacity:1;transform:none;display:inline-block;width:auto}.tagify--mix .tagify--empty .tagify__input::before{display:inline-block}.tagify--focus{--tags-border-color:var(--tags-focus-border-color);transition:0s}.tagify--invalid{--tags-border-color:#D39494}.tagify__dropdown{position:absolute;z-index:9999;transform:translateY(-1px);border-top:1px solid var(--tagify-dd-color-primary);overflow:hidden}.tagify__dropdown[dir=rtl]{transform:translate(-100%,-1px)}.tagify__dropdown[placement=top]{margin-top:0;transform:translateY(-100%)}.tagify__dropdown[placement=top] .tagify__dropdown__wrapper{border-top-width:1.1px;border-bottom-width:0}.tagify__dropdown[position=text]{box-shadow:0 0 0 3px rgba(var(--tagify-dd-color-primary),.1);font-size:.9em}.tagify__dropdown[position=text] .tagify__dropdown__wrapper{border-width:1px}.tagify__dropdown__wrapper{scroll-behavior:auto;max-height:var(--tagify-dd-max-height);overflow:hidden;overflow-x:hidden;color:var(--tagify-dd-text-color);background:var(--tagify-dd-bg-color);border:1px solid;border-color:var(--tagify-dd-color-primary);border-bottom-width:1.5px;border-top-width:0;box-shadow:0 2px 4px -2px rgba(0,0,0,.2);transition:.3s cubic-bezier(.5,0,.3,1),transform .15s;animation:dd-wrapper-show 0s .3s forwards}@keyframes dd-wrapper-show{to{overflow-y:auto}}.tagify__dropdown__header:empty{display:none}.tagify__dropdown__footer{display:inline-block;margin-top:.5em;padding:var(--tagify-dd-item-pad);font-size:.7em;font-style:italic;opacity:.5}.tagify__dropdown__footer:empty{display:none}.tagify__dropdown--initial .tagify__dropdown__wrapper{max-height:20px;transform:translateY(-1em)}.tagify__dropdown--initial[placement=top] .tagify__dropdown__wrapper{transform:translateY(2em)}.tagify__dropdown__item{box-sizing:border-box;padding:var(--tagify-dd-item-pad);margin:1px;white-space:pre-wrap;cursor:pointer;border-radius:2px;position:relative;outline:0;max-height:60px;max-width:100%;line-height:normal;position:relative}.tagify__dropdown__item--active{background:var(--tagify-dd-color-primary);color:#fff}.tagify__dropdown__item:active{filter:brightness(105%)}.tagify__dropdown__item--hidden{padding-top:0;padding-bottom:0;margin:0 1px;pointer-events:none;overflow:hidden;max-height:0;transition:var(--tagify-dd-item--hidden-duration,.3s)!important}.tagify__dropdown__item--hidden>*{transform:translateY(-100%);opacity:0;transition:inherit}.tagify__dropdown__item--selected::before{content:"✓";font-family:monospace;position:absolute;inset-inline-start:6px;text-indent:0;line-height:1.1}.tagify__dropdown:has(.tagify__dropdown__item--selected) .tagify__dropdown__item{text-indent:1em} \ No newline at end of file diff --git a/cms/server/admin/static/tagify/tagify.min.js b/cms/server/admin/static/tagify/tagify.min.js new file mode 100644 index 0000000000..89ed7f42a6 --- /dev/null +++ b/cms/server/admin/static/tagify/tagify.min.js @@ -0,0 +1,35 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/@yaireo/tagify@4.35.6/dist/tagify.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/* +Tagify v4.35.6 - tags input component +By: Yair Even-Or +https://github.com/yairEO/tagify + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +This Software may not be rebranded and sold as a library under any other name +other than "Tagify" (by owner) or as part of another library. +*/ + +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Tagify=e()}(this,(function(){"use strict";var t="​";function e(t,e){(null==e||e>t.length)&&(e=t.length);for(var i=0,n=new Array(e);i/g,">").replace(/"/g,""").replace(/`|'/g,"'"):t}function c(t){var e=Object.prototype.toString.call(t).split(" ")[1].slice(0,-1);return t===Object(t)&&"Array"!=e&&"Function"!=e&&"RegExp"!=e&&"HTMLUnknownElement"!=e}function u(t,e,i){var n,s;function a(t,e){for(var i in e)if(e.hasOwnProperty(i)){if(c(e[i])){c(t[i])?a(t[i],e[i]):t[i]=Object.assign({},e[i]);continue}if(Array.isArray(e[i])){t[i]=Object.assign([],e[i]);continue}t[i]=e[i]}}return n=t,(null!=(s=Object)&&"undefined"!=typeof Symbol&&s[Symbol.hasInstance]?s[Symbol.hasInstance](n):n instanceof s)||(t={}),a(t,e),i&&a(t,i),t}function g(){var t=[],e={},i=!0,n=!1,s=void 0;try{for(var a,o=arguments[Symbol.iterator]();!(i=(a=o.next()).done);i=!0){var r=a.value,l=!0,d=!1,u=void 0;try{for(var g,h=r[Symbol.iterator]();!(l=(g=h.next()).done);l=!0){var p=g.value;c(p)?e[p.value]||(t.push(p),e[p.value]=1):t.includes(p)||t.push(p)}}catch(t){d=!0,u=t}finally{try{l||null==h.return||h.return()}finally{if(d)throw u}}}}catch(t){n=!0,s=t}finally{try{i||null==o.return||o.return()}finally{if(n)throw s}}return t}function h(t){return String.prototype.normalize?"string"==typeof t?t.normalize("NFD").replace(/[\u0300-\u036f]/g,""):void 0:t}var p=function(){return/(?=.*chrome)(?=.*android)/i.test(navigator.userAgent)};function f(){return([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,(function(t){return(t^crypto.getRandomValues(new Uint8Array(1))[0]&15>>t/4).toString(16)}))}function v(t){var e,i=b.call(this,t),n=null==t||null===(e=t.classList)||void 0===e?void 0:e.contains(this.settings.classNames.tag);return i&&n}function m(t){return b.call(this,t)&&(null==t?void 0:t.closest(this.settings.classNames.tagSelector))}function b(t){var e;return(null==t||null===(e=t.closest)||void 0===e?void 0:e.call(t,this.settings.classNames.namespaceSelector))===this.DOM.scope}function y(t,e){var i=window.getSelection();return e=e||i.getRangeAt(0),"string"==typeof t&&(t=document.createTextNode(t)),e&&(e.deleteContents(),e.insertNode(t)),t}function w(t,e,i){return t?(e&&(t.__tagifyTagData=i?e:u({},t.__tagifyTagData||{},e)),t.__tagifyTagData):(n.warn("tag element doesn't exist",{tagElm:t,data:e}),e)}function T(t){if(t&&t.parentNode){var e=t,i=window.getSelection(),n=i.getRangeAt(0);i.rangeCount&&(n.setStartAfter(e),n.collapse(!0),i.removeAllRanges(),i.addRange(n))}}function O(t,e){t.forEach((function(t){if(w(t.previousSibling)||!t.previousSibling){var i=document.createTextNode("​");t.before(i),e&&T(i)}}))}var D={delimiters:",",pattern:null,tagTextProp:"value",maxTags:1/0,callbacks:{},addTagOnBlur:!0,addTagOn:["blur","tab","enter"],onChangeAfterBlur:!0,duplicates:!1,whitelist:[],blacklist:[],enforceWhitelist:!1,userInput:!0,focusable:!0,focusInputOnRemove:!0,keepInvalidTags:!1,createInvalidTags:!0,mixTagsAllowedAfter:/,|\.|\:|\s/,mixTagsInterpolator:["[[","]]"],backspace:!0,skipInvalid:!1,pasteAsTags:!0,editTags:{clicks:2,keepInvalid:!0},transformTag:function(){},trim:!0,a11y:{focusableTags:!1},mixMode:{insertAfterTag:" "},autoComplete:{enabled:!0,rightKey:!1,tabKey:!1},classNames:{namespace:"tagify",mixMode:"tagify--mix",selectMode:"tagify--select",input:"tagify__input",focus:"tagify--focus",tagNoAnimation:"tagify--noAnim",tagInvalid:"tagify--invalid",tagNotAllowed:"tagify--notAllowed",scopeLoading:"tagify--loading",hasMaxTags:"tagify--hasMaxTags",hasNoTags:"tagify--noTags",empty:"tagify--empty",inputInvalid:"tagify__input--invalid",dropdown:"tagify__dropdown",dropdownWrapper:"tagify__dropdown__wrapper",dropdownHeader:"tagify__dropdown__header",dropdownFooter:"tagify__dropdown__footer",dropdownItem:"tagify__dropdown__item",dropdownItemActive:"tagify__dropdown__item--active",dropdownItemHidden:"tagify__dropdown__item--hidden",dropdownItemSelected:"tagify__dropdown__item--selected",dropdownInital:"tagify__dropdown--initial",tag:"tagify__tag",tagText:"tagify__tag-text",tagX:"tagify__tag__removeBtn",tagLoading:"tagify__tag--loading",tagEditing:"tagify__tag--editable",tagFlash:"tagify__tag--flash",tagHide:"tagify__tag--hide"},dropdown:{classname:"",enabled:2,maxItems:10,searchKeys:["value","searchBy"],fuzzySearch:!0,caseSensitive:!1,accentedSearch:!0,includeSelectedTags:!1,escapeHTML:!0,highlightFirst:!0,closeOnSelect:!0,clearOnSelect:!0,position:"all",appendTarget:null},hooks:{beforeRemoveTag:function(){return Promise.resolve()},beforePaste:function(){return Promise.resolve()},suggestionClick:function(){return Promise.resolve()},beforeKeyDown:function(){return Promise.resolve()}}};function x(t,e,i){return e in t?Object.defineProperty(t,e,{value:i,enumerable:!0,configurable:!0,writable:!0}):t[e]=i,t}function S(t){for(var e=1;e0&&void 0!==arguments[0])||arguments[0],e=this.dropdown.events.callbacks,i=this.listeners.dropdown=this.listeners.dropdown||{position:this.dropdown.position.bind(this,null),onKeyDown:e.onKeyDown.bind(this),onMouseOver:e.onMouseOver.bind(this),onMouseLeave:e.onMouseLeave.bind(this),onClick:e.onClick.bind(this),onScroll:e.onScroll.bind(this)},n=t?"addEventListener":"removeEventListener";"manual"!=this.settings.dropdown.position&&(document[n]("scroll",i.position,!0),window[n]("resize",i.position),window[n]("keydown",i.onKeyDown)),this.DOM.dropdown[n]("mouseover",i.onMouseOver),this.DOM.dropdown[n]("mouseleave",i.onMouseLeave),this.DOM.dropdown[n]("mousedown",i.onClick),this.DOM.dropdown.content[n]("scroll",i.onScroll)},callbacks:{onKeyDown:function(t){var e=this;if(this.state.hasFocus&&!this.state.composing){var i=this.settings,s=i.dropdown.includeSelectedTags,a=this.DOM.dropdown.querySelector(i.classNames.dropdownItemActiveSelector),o=this.dropdown.getSuggestionDataByNode(a),r="mix"==i.mode,l="select"==i.mode;i.hooks.beforeKeyDown(t,{tagify:this}).then((function(d){switch(t.key){case"ArrowDown":case"ArrowUp":case"Down":case"Up":t.preventDefault();var c=e.dropdown.getAllSuggestionsRefs(),u="ArrowUp"==t.key||"Up"==t.key;a&&(a=e.dropdown.getNextOrPrevOption(a,!u)),a&&a.matches(i.classNames.dropdownItemSelector)||(a=c[u?c.length-1:0]),e.dropdown.highlightOption(a,!0);break;case"PageUp":case"PageDown":var g;t.preventDefault();var h=e.dropdown.getAllSuggestionsRefs(),p=Math.floor(e.DOM.dropdown.content.clientHeight/(null===(g=h[0])||void 0===g?void 0:g.offsetHeight))||1,f="PageUp"===t.key;if(a){var v=h.indexOf(a),m=f?Math.max(0,v-p):Math.min(h.length-1,v+p);a=h[m]}else a=h[0];e.dropdown.highlightOption(a,!0);break;case"Home":case"End":t.preventDefault();var b=e.dropdown.getAllSuggestionsRefs();a=b["Home"===t.key?0:b.length-1],e.dropdown.highlightOption(a,!0);break;case"Escape":case"Esc":e.dropdown.hide();break;case"ArrowRight":if(e.state.actions.ArrowLeft||i.autoComplete.rightKey)return;case"Tab":var y=!i.autoComplete.rightKey||!i.autoComplete.tabKey;if(!r&&!l&&a&&y&&!e.state.editing&&o){t.preventDefault();var w=e.dropdown.getMappedValue(o);return e.state.autoCompleteData=o,e.input.autocomplete.set.call(e,w),!1}return!0;case"Enter":t.preventDefault(),e.state.actions.selectOption=!0,setTimeout((function(){return e.state.actions.selectOption=!1}),100),i.hooks.suggestionClick(t,{tagify:e,tagData:o,suggestionElm:a}).then((function(){if(a){var i=s?a:e.dropdown.getNextOrPrevOption(a,!u);e.dropdown.selectOption(a,t,(function(){if(i){var t=i.getAttribute("value");i=e.dropdown.getSuggestionNodeByValue(t),e.dropdown.highlightOption(i)}}))}else e.dropdown.hide(),r||e.addTags(e.state.inputText.trim(),!0)})).catch((function(t){return n.warn(t)}));break;case"Backspace":if(r||e.state.editing.scope)return;var T=e.input.raw.call(e);""!=T&&8203!=T.charCodeAt(0)||(!0===i.backspace?e.removeTags():"edit"==i.backspace&&setTimeout(e.editTag.bind(e),0))}}))}},onMouseOver:function(t){var e=t.target.closest(this.settings.classNames.dropdownItemSelector);this.dropdown.highlightOption(e)},onMouseLeave:function(t){this.dropdown.highlightOption()},onClick:function(t){var e=this;if(0==t.button&&t.target!=this.DOM.dropdown&&t.target!=this.DOM.dropdown.content){var i=t.target.closest(this.settings.classNames.dropdownItemSelector),s=this.dropdown.getSuggestionDataByNode(i);this.state.actions.selectOption=!0,setTimeout((function(){return e.state.actions.selectOption=!1}),100),this.settings.hooks.suggestionClick(t,{tagify:this,tagData:s,suggestionElm:i}).then((function(){i?e.dropdown.selectOption(i,t):e.dropdown.hide()})).catch((function(t){return n.warn(t)}))}},onScroll:function(t){var e=t.target,i=e.scrollTop/(e.scrollHeight-e.parentNode.clientHeight)*100;this.trigger("dropdown:scroll",{percentage:Math.round(i)})}}},refilter:function(t){t=t||this.state.dropdown.query||"",this.suggestedListItems=this.dropdown.filterListItems(t),this.dropdown.fill(),this.suggestedListItems.length||this.dropdown.hide(),this.trigger("dropdown:updated",this.DOM.dropdown)},getSuggestionDataByNode:function(t){for(var e,i=t&&t.getAttribute("value"),n=this.suggestedListItems.length;n--;){if(c(e=this.suggestedListItems[n])&&e.value==i)return e;if(e==i)return{value:e}}},getSuggestionNodeByValue:function(t){return this.dropdown.getAllSuggestionsRefs().find((function(e){return e.getAttribute("value")===t}))},getNextOrPrevOption:function(t){var e=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],i=this.dropdown.getAllSuggestionsRefs(),n=i.findIndex((function(e){return e===t}));return e?i[n+1]:i[n-1]},highlightOption:function(t,e){var i,n=this.settings.classNames.dropdownItemActive;if(this.state.ddItemElm&&(this.state.ddItemElm.classList.remove(n),this.state.ddItemElm.removeAttribute("aria-selected")),!t)return this.state.ddItemData=null,this.state.ddItemElm=null,void this.input.autocomplete.suggest.call(this);i=this.dropdown.getSuggestionDataByNode(t),this.state.ddItemData=i,this.state.ddItemElm=t,t.classList.add(n),t.setAttribute("aria-selected",!0),e&&(t.parentNode.scrollTop=t.clientHeight+t.offsetTop-t.parentNode.clientHeight),this.settings.autoComplete&&(this.input.autocomplete.suggest.call(this,i),this.dropdown.position())},selectOption:function(t,e,i){var n=this,s=this.settings,a=s.dropdown.includeSelectedTags,o=s.dropdown,r=o.clearOnSelect,l=o.closeOnSelect;if(!t)return this.addTags(this.state.inputText,!0),void(l&&this.dropdown.hide());e=e||{};var d=t.getAttribute("value"),c="noMatch"==d,g="mix"==s.mode,h=this.suggestedListItems.find((function(t){var e;return(null!==(e=t.value)&&void 0!==e?e:t)==d}));if(this.trigger("dropdown:select",{data:h,elm:t,event:e}),h||c){if(this.state.editing){var p=this.normalizeTags([h])[0];h=s.transformTag.call(this,p)||p,this.onEditTagDone(null,u({__isValid:!0},h))}else this[g?"addMixTags":"addTags"]([h||this.input.raw.call(this)],r);(g||this.DOM.input.parentNode)&&(setTimeout((function(){n.DOM.input.focus(),n.toggleFocusClass(!0)})),l&&setTimeout(this.dropdown.hide.bind(this)),a?i&&i():(t.addEventListener("transitionend",(function(){n.dropdown.fillHeaderFooter(),setTimeout((function(){t.remove(),n.dropdown.refilter(),i&&i()}),100)}),{once:!0}),t.classList.add(this.settings.classNames.dropdownItemHidden)))}else l&&setTimeout(this.dropdown.hide.bind(this))},selectAll:function(t){this.suggestedListItems.length=0,this.dropdown.hide(),this.dropdown.filterListItems("");var e=this.dropdown.filterListItems("");return t||(e=this.state.dropdown.suggestions),this.addTags(e,!0),this},filterListItems:function(t,e){var i,n,s,a,o,r,l=function(){var t,l,d=void 0,u=void 0;t=v[T],n=(null!=(l=Object)&&"undefined"!=typeof Symbol&&l[Symbol.hasInstance]?l[Symbol.hasInstance](t):t instanceof l)?v[T]:{value:v[T]};var m,b=!Object.keys(n).some((function(t){return w.includes(t)}))?["value"]:w;g.fuzzySearch&&!e.exact?(a=b.reduce((function(t,e){return t+" "+(n[e]||"")}),"").toLowerCase().trim(),g.accentedSearch&&(a=h(a),r=h(r)),d=0==a.indexOf(r),u=a===r,m=a,s=r.toLowerCase().split(" ").every((function(t){return m.includes(t.toLowerCase())}))):(d=!0,s=b.some((function(t){var i=""+(n[t]||"");return g.accentedSearch&&(i=h(i),r=h(r)),g.caseSensitive||(i=i.toLowerCase()),u=i===r,e.exact?i===r:0==i.indexOf(r)}))),o=!g.includeSelectedTags&&i.isTagDuplicate(c(n)?n.value:n),s&&!o&&(u&&d?f.push(n):"startsWith"==g.sortby&&d?p.unshift(n):p.push(n))},d=this,u=this.settings,g=u.dropdown,p=(e=e||{},[]),f=[],v=u.whitelist,m=g.maxItems>=0?g.maxItems:1/0,b=g.includeSelectedTags,y="function"==typeof g.sortby,w=g.searchKeys,T=0;if(!(t="select"==u.mode&&this.value.length&&this.value[0][u.tagTextProp]==t?"":t)||!w.length){p=b?v:v.filter((function(t){return!d.isTagDuplicate(c(t)?t.value:t)}));var O=y?g.sortby(p,r):p.slice(0,m);return this.state.dropdown.suggestions=O,O}for(r=g.caseSensitive?""+t:(""+t).toLowerCase();Tt.length)&&(e=t.length);for(var i=0,n=new Array(e);i[\r\n ]+\<").split(/>\s+<").trim():""},fillHeaderFooter:function(){var t=this.dropdown.filterListItems(this.state.dropdown.query),e=this.parseTemplate("dropdownHeader",[t]),i=this.parseTemplate("dropdownFooter",[t]),n=this.dropdown.getHeaderRef(),s=this.dropdown.getFooterRef();e&&(null==n||n.parentNode.replaceChild(e,n)),i&&(null==s||s.parentNode.replaceChild(i,s))},position:function(t){var e=this.settings.dropdown,i=this.dropdown.getAppendTarget();if("manual"!=e.position&&i){var n,s,a,o,r,l,d,c,u,g,h=this.DOM.dropdown,p=e.RTL,f=i===document.body,v=i===this.DOM.scope,m=f?window.pageYOffset:i.scrollTop,b=document.fullscreenElement||document.webkitFullscreenElement||document.documentElement,y=b.clientHeight,w=Math.max(b.clientWidth||0,window.innerWidth||0),T=w>480?e.position:"all",O=this.DOM["input"==T?"input":"scope"];if(t=t||h.clientHeight,this.state.dropdown.visible){if("text"==T?(a=(n=function(){var t=document.getSelection();if(t.rangeCount){var e,i,n=t.getRangeAt(0),s=n.startContainer,a=n.startOffset;if(a>0)return(i=document.createRange()).setStart(s,a-1),i.setEnd(s,a),{left:(e=i.getBoundingClientRect()).right,top:e.top,bottom:e.bottom};if(s.getBoundingClientRect)return s.getBoundingClientRect()}return{left:-9999,top:-9999}}()).bottom,s=n.top,o=n.left,r="auto"):(l=function(t){var e=0,i=0;for(t=t.parentNode;t&&t!=b;)e+=t.offsetTop||0,i+=t.offsetLeft||0,t=t.parentNode;return{top:e,left:i}}(i),n=O.getBoundingClientRect(),s=v?-1:n.top-l.top,a=(v?n.height:n.bottom-l.top)-1,o=v?-1:n.left-l.left,r=n.width+"px"),!f){var D=function(){for(var t=0,i=e.appendTarget.parentNode;i;)t+=i.scrollTop||0,i=i.parentNode;return t}();s+=D,a+=D}var x;s=Math.floor(s),a=Math.ceil(a),c=w-o<120,u=((d=null!==(x=e.placeAbove)&&void 0!==x?x:y-n.bottom\n ').concat(this.settings.templates.input.call(this),"\n ").concat(t,"\n ")},input:function(){var e=this.settings,i=e.placeholder||t;return"')},tag:function(t,e){var i=e.settings;return'\n \n
\n ').concat(t[i.tagTextProp]||t.value,"\n
\n
")},dropdown:function(t){var e=t.dropdown,i="manual"==e.position;return'
\n
\n
')},dropdownContent:function(t){var e=this.settings.templates,i=this.state.dropdown.suggestions;return"\n ".concat(e.dropdownHeader.call(this,i),"\n ").concat(t,"\n ").concat(e.dropdownFooter.call(this,i),"\n ")},dropdownItem:function(t){return"
').concat(t.mappedValue||t.value,"
")},dropdownHeader:function(t){return"
')},dropdownFooter:function(t){var e=t.length-this.settings.dropdown.maxItems;return e>0?"
\n ').concat(e," more items. Refine your search.\n
"):""},dropdownItemNoMatch:null};function R(t,e){(null==e||e>t.length)&&(e=t.length);for(var i=0,n=new Array(e);it.length)&&(e=t.length);for(var i=0,n=new Array(e);i0&&void 0!==arguments[0])||arguments[0],i=this.settings,n=this.events.callbacks,s=e?"addEventListener":"removeEventListener";if(!(this.state.mainEvents&&e||i.disabled||i.readonly)){for(var a in this.state.mainEvents=e,e&&!this.listeners.main&&(this.events.bindGlobal.call(this),this.settings.isJQueryPlugin&&jQuery(this.DOM.originalInput).on("tagify.removeAllTags",this.removeAllTags.bind(this))),t=this.listeners.main=this.listeners.main||{keydown:["input",n.onKeydown.bind(this)],click:["scope",n.onClickScope.bind(this)],dblclick:"select"!=i.mode&&["scope",n.onDoubleClickScope.bind(this)],paste:["input",n.onPaste.bind(this)],drop:["input",n.onDrop.bind(this)],compositionstart:["input",n.onCompositionStart.bind(this)],compositionend:["input",n.onCompositionEnd.bind(this)]})t[a]&&this.DOM[t[a][0]][s](a,t[a][1]);var o=this.listeners.main.inputMutationObserver||new MutationObserver(n.onInputDOMChange.bind(this));o.disconnect(),"mix"==i.mode&&o.observe(this.DOM.input,{childList:!0}),this.events.bindOriginaInputListener.call(this),e&&(this.listeners.main=void 0)}},bindOriginaInputListener:function(t){var e=(t||0)+500;this.listeners.main&&(clearInterval(this.listeners.main.originalInputValueObserverInterval),this.listeners.main.originalInputValueObserverInterval=setInterval(this.events.callbacks.observeOriginalInputValue.bind(this),e))},bindGlobal:function(t){var e,i=this.events.callbacks,n=t?"removeEventListener":"addEventListener";if(this.listeners&&(t||!this.listeners.global)){this.listeners.global=this.listeners.global||[{type:this.isIE?"keydown":"input",target:this.DOM.input,cb:i[this.isIE?"onInputIE":"onInput"].bind(this)},{type:"keydown",target:window,cb:i.onWindowKeyDown.bind(this)},{type:"focusin",target:this.DOM.scope,cb:i.onFocusBlur.bind(this)},{type:"focusout",target:this.DOM.scope,cb:i.onFocusBlur.bind(this)},{type:"click",target:document,cb:i.onClickAnywhere.bind(this),useCapture:!0}];var s=!0,a=!1,o=void 0;try{for(var r,l=this.listeners.global[Symbol.iterator]();!(s=(r=l.next()).done);s=!0)(e=r.value).target[n](e.type,e.cb,!!e.useCapture)}catch(t){a=!0,o=t}finally{try{s||null==l.return||l.return()}finally{if(a)throw o}}t&&(this.listeners.global=void 0)}},unbindGlobal:function(){this.events.bindGlobal.call(this,!0)},callbacks:{onFocusBlur:function(t){var e,i,n=this.settings,s=m.call(this,t.relatedTarget),a=v.call(this,t.target),o=t.target.classList.contains(n.classNames.tagX),r="focusin"==t.type,l="focusout"==t.type;o&&"mix"!=n.mode&&n.focusInputOnRemove&&this.DOM.input.focus(),s&&r&&!a&&!o&&this.toggleFocusClass(this.state.hasFocus=+new Date);var d=t.target?this.trim(this.DOM.input.textContent):"",c=null===(i=this.value)||void 0===i||null===(e=i[0])||void 0===e?void 0:e[n.tagTextProp],u=n.dropdown.enabled>=0,g={relatedTarget:t.relatedTarget},h=this.state.actions.selectOption&&(u||!n.dropdown.closeOnSelect),p=this.state.actions.addNew&&u;if(l){if(t.relatedTarget===this.DOM.scope)return this.dropdown.hide(),void this.DOM.input.focus();this.postUpdate(),n.onChangeAfterBlur&&this.triggerChangeEvent()}if(!(h||p||o))if(this.state.hasFocus=!(!r&&!s)&&+new Date,this.toggleFocusClass(this.state.hasFocus),"mix"!=n.mode){if(r){if(!n.focusable)return;var f=0===n.dropdown.enabled&&!this.state.dropdown.visible,b=this.DOM.scope.querySelector(this.settings.classNames.tagTextSelector);return this.trigger("focus",g),void(f&&!a&&(this.dropdown.show(this.value.length?"":void 0),"select"===n.mode&&this.setRangeAtStartEnd(!1,b)))}if(l){if(this.trigger("blur",g),this.loading(!1),"select"==n.mode){if(this.value.length){var y=this.getTagElms()[0];d=this.trim(y.textContent)}c===d&&(d="")}d&&!this.state.actions.selectOption&&n.addTagOnBlur&&n.addTagOn.includes("blur")&&this.addTags(d,!0)}s||(this.DOM.input.removeAttribute("style"),this.dropdown.hide())}else r?this.trigger("focus",g):l&&(this.trigger("blur",g),this.loading(!1),this.dropdown.hide(),this.state.dropdown.visible=void 0,this.setStateSelection())},onCompositionStart:function(t){this.state.composing=!0},onCompositionEnd:function(t){this.state.composing=!1},onWindowKeyDown:function(t){var e,i=this.settings,n=document.activeElement,s=m.call(this,n)&&this.DOM.scope.contains(n),a=n===this.DOM.input,o=s&&n.hasAttribute("readonly"),r=this.DOM.scope.querySelector(this.settings.classNames.tagTextSelector),l=this.state.dropdown.visible;if(("Tab"===t.key&&l||this.state.hasFocus||s&&!o)&&!a){e=n.nextElementSibling;var d=t.target.classList.contains(i.classNames.tagX);switch(t.key){case"Backspace":i.readonly||this.state.editing||(this.removeTags(n),(e||this.DOM.input).focus());break;case"Enter":if(d)return void this.removeTags(t.target.parentNode);i.a11y.focusableTags&&v.call(this,n)&&setTimeout(this.editTag.bind(this),0,n);break;case"ArrowDown":this.state.dropdown.visible||"mix"==i.mode||this.dropdown.show();break;case"Tab":null==r||r.focus()}}},onKeydown:function(t){var e=this,i=this.settings;if(!this.state.composing&&i.userInput){"select"==i.mode&&i.enforceWhitelist&&this.value.length&&"Tab"!=t.key&&t.preventDefault();var n=this.trim(t.target.textContent);this.trigger("keydown",{event:t}),i.hooks.beforeKeyDown(t,{tagify:this}).then((function(s){if("mix"==i.mode){switch(t.key){case"Left":case"ArrowLeft":e.state.actions.ArrowLeft=!0;break;case"Delete":case"Backspace":if(e.state.editing)return;var a=document.getSelection(),o="Delete"==t.key&&a.anchorOffset==(a.anchorNode.length||0),r=a.anchorNode.previousSibling,d=1==a.anchorNode.nodeType||!a.anchorOffset&&r&&1==r.nodeType&&a.anchorNode.previousSibling;!function(t){var e=document.createElement("div");t.replace(/\&#?[0-9a-z]+;/gi,(function(t){return e.innerHTML=t,e.innerText}))}(e.DOM.input.innerHTML);var c,u,g,h=e.getTagElms(),f=1===a.anchorNode.length&&a.anchorNode.nodeValue==String.fromCharCode(8203);if("edit"==i.backspace&&d)return c=1==a.anchorNode.nodeType?null:a.anchorNode.previousElementSibling,setTimeout(e.editTag.bind(e),0,c),void t.preventDefault();if(p()&&q(d,Element))return g=l(d),d.hasAttribute("readonly")||d.remove(),e.DOM.input.focus(),void setTimeout((function(){T(g),e.DOM.input.click()}));if("BR"==a.anchorNode.nodeName)return;if((o||d)&&1==a.anchorNode.nodeType?u=0==a.anchorOffset?o?h[0]:null:h[Math.min(h.length,a.anchorOffset)-1]:o?u=a.anchorNode.nextElementSibling:q(d,Element)&&(u=d),3==a.anchorNode.nodeType&&!a.anchorNode.nodeValue&&a.anchorNode.previousElementSibling&&t.preventDefault(),(d||o)&&!i.backspace)return void t.preventDefault();if("Range"!=a.type&&!a.anchorOffset&&a.anchorNode==e.DOM.input&&"Delete"!=t.key)return void t.preventDefault();if("Range"!=a.type&&u&&u.hasAttribute("readonly"))return void T(l(u));"Delete"==t.key&&f&&w(a.anchorNode.nextSibling)&&e.removeTags(a.anchorNode.nextSibling);break;case"Enter":if(t.preventDefault(),e.state.tag)return;var v=window.getSelection();v.getRangeAt(0).insertNode(document.createElement("br")),v.collapseToEnd()}return!0}var m="manual"==i.dropdown.position;switch(t.key){case"Backspace":"select"==i.mode&&i.enforceWhitelist&&e.value.length?e.removeTags():e.state.dropdown.visible&&"manual"!=i.dropdown.position||""!=t.target.textContent&&8203!=n.charCodeAt(0)||(!0===i.backspace?e.removeTags():"edit"==i.backspace&&setTimeout(e.editTag.bind(e),0));break;case"Esc":case"Escape":if(e.state.dropdown.visible)return;t.target.blur();break;case"Down":case"ArrowDown":e.state.dropdown.visible||e.dropdown.show();break;case"ArrowRight":var b=e.state.inputSuggestion||e.state.ddItemData;if(b&&i.autoComplete.rightKey)return void e.addTags([b],!0);break;case"Tab":if(!i.addTagOn.includes(t.key.toLowerCase()))break;case"Enter":if(e.state.dropdown.visible&&!m)return;var y=e.state.autoCompleteData||n;if(!y&&"Tab"===t.key)return!0;t.preventDefault(),setTimeout((function(){e.state.dropdown.visible&&!m||e.state.actions.selectOption||!i.addTagOn.includes(t.key.toLowerCase())||(e.addTags([y],!0),e.state.autoCompleteData=null)}))}})).catch((function(t){return t}))}},onInput:function(t){this.postUpdate();var e=this.settings;if("mix"==e.mode)return this.events.callbacks.onMixTagsInput.call(this,t);var i=this.input.normalize.call(this,void 0,{trim:!1}),n=i.length>=e.dropdown.enabled,s={value:i,inputElm:this.DOM.input},a=this.validateTag({value:i});"select"==e.mode&&this.toggleScopeValidation(a),s.isValid=a,this.state.inputText!=i&&(this.input.set.call(this,i,!1),-1!=i.search(e.delimiters)?this.addTags(i)&&this.input.set.call(this):e.dropdown.enabled>=0&&this.dropdown[n?"show":"hide"](i),this.trigger("input",s))},onMixTagsInput:function(t){var e,i,n,s,a,o,r,l,d=this,c=this.settings,g=this.value.length,h=this.getTagElms(),f=document.createDocumentFragment(),v=window.getSelection().getRangeAt(0),m=[].map.call(h,(function(t){return w(t).value}));if("deleteContentBackward"==t.inputType&&p()&&this.events.callbacks.onKeydown.call(this,{target:t.target,key:"Backspace"}),O(this.getTagElms()),this.value.slice().forEach((function(t){t.readonly&&!m.includes(t.value)&&f.appendChild(d.createTagElem(t))})),f.childNodes.length&&(v.insertNode(f),this.setRangeAtStartEnd(!1,f.lastChild)),h.length!=g)return this.value=[].map.call(this.getTagElms(),(function(t){return w(t)})),void this.update({withoutChangeEvent:!0});if(this.hasMaxTags())return!0;if(window.getSelection&&(o=window.getSelection()).rangeCount>0&&3==o.anchorNode.nodeType){if((v=o.getRangeAt(0).cloneRange()).collapse(!0),v.setStart(o.focusNode,0),n=(e=v.toString().slice(0,v.endOffset)).split(c.pattern).length-1,(i=e.match(c.pattern))&&(s=e.slice(e.lastIndexOf(i[i.length-1]))),s){if(this.state.actions.ArrowLeft=!1,this.state.tag={prefix:s.match(c.pattern)[0],value:s.replace(c.pattern,"")},this.state.tag.baseOffset=o.baseOffset-this.state.tag.value.length,l=this.state.tag.value.match(c.delimiters))return this.state.tag.value=this.state.tag.value.replace(c.delimiters,""),this.state.tag.delimiters=l[0],this.addTags(this.state.tag.value,c.dropdown.clearOnSelect),void this.dropdown.hide();a=this.state.tag.value.length>=c.dropdown.enabled;try{r=(r=this.state.flaggedTags[this.state.tag.baseOffset]).prefix==this.state.tag.prefix&&r.value[0]==this.state.tag.value[0],this.state.flaggedTags[this.state.tag.baseOffset]&&!this.state.tag.value&&delete this.state.flaggedTags[this.state.tag.baseOffset]}catch(t){}(r||n500||!e.focusable)?this.state.dropdown.visible?this.dropdown.hide():0===e.dropdown.enabled&&"mix"!=e.mode&&this.dropdown.show(this.value.length?"":void 0):"select"!=e.mode||0!==e.dropdown.enabled||this.state.dropdown.visible||(this.events.callbacks.onDoubleClickScope.call(this,U(function(t){for(var e=1;e=this.settings.dropdown.enabled&&(this.state.editing&&(this.state.editing.value=o),this.dropdown.show(o)),this.trigger("edit:input",{tag:n,index:s,data:u({},this.value[s],{newValue:o}),event:e})},onEditTagPaste:function(t,e){var i=(e.clipboardData||window.clipboardData).getData("Text");e.preventDefault();var n=y(i);this.setRangeAtStartEnd(!1,n)},onEditTagClick:function(t,e){this.events.callbacks.onClickScope.call(this,e)},onEditTagFocus:function(t){this.state.editing={scope:t,input:t.querySelector("[contenteditable]")}},onEditTagBlur:function(t,e){var i=v.call(this,e.relatedTarget);if("select"==this.settings.mode&&i&&e.relatedTarget.contains(e.target))this.dropdown.hide();else if(this.state.editing&&(this.state.hasFocus||this.toggleFocusClass(),this.DOM.scope.contains(document.activeElement)||this.trigger("blur",{}),this.DOM.scope.contains(t))){var n,s,a,o=this.settings,r=t.closest("."+o.classNames.tag),l=w(r),d=this.input.normalize.call(this,t),c=(W(n={},o.tagTextProp,d),W(n,"__tagId",l.__tagId),n),g=l.__originalData,h=this.editTagChangeDetected(u(l,c)),p=this.validateTag(c);if(d)if(h){var f;if(s=this.hasMaxTags(),a=u({},g,(W(f={},o.tagTextProp,this.trim(d)),W(f,"__isValid",p),f)),o.transformTag.call(this,a,g),!0!==(p=(!s||!0===g.__isValid)&&this.validateTag(a))){if(this.trigger("invalid",{data:a,tag:r,message:p}),o.editTags.keepInvalid)return;o.keepInvalidTags?a.__isValid=p:a=g}else o.keepInvalidTags&&(delete a.title,delete a["aria-invalid"],delete a.class);this.onEditTagDone(r,a)}else this.onEditTagDone(r,g);else this.onEditTagDone(r)}},onEditTagkeydown:function(t,e){if(!this.state.composing)switch(this.trigger("edit:keydown",{event:t}),t.key){case"Esc":case"Escape":this.state.editing=!1,!!e.__tagifyTagData.__originalData.value?e.parentNode.replaceChild(e.__tagifyTagData.__originalHTML,e):e.remove();break;case"Enter":case"Tab":t.preventDefault();setTimeout((function(){return t.target.blur()}),0)}},onDoubleClickScope:function(t){var e=t.target.closest("."+this.settings.classNames.tag);if(e){var i,n,s=w(e),a=this.settings;!1!==(null==s?void 0:s.editable)&&(i=e.classList.contains(this.settings.classNames.tagEditing),n=e.hasAttribute("readonly"),a.readonly||i||n||!this.settings.editTags||!a.userInput||(this.events.callbacks.onEditTagFocus.call(this,e),this.editTag(e)),this.toggleFocusClass(!0),"select"!=a.mode&&this.trigger("dblclick",{tag:e,index:this.getNodeIndex(e),data:w(e)}))}},onInputDOMChange:function(t){var e=this;t.forEach((function(t){t.addedNodes.forEach((function(t){if("

"==t.outerHTML)t.replaceWith(document.createElement("br"));else if(1==t.nodeType&&t.querySelector(e.settings.classNames.tagSelector)){var i,n=document.createTextNode("");3==t.childNodes[0].nodeType&&"BR"!=t.previousSibling.nodeName&&(n=document.createTextNode("\n")),(i=t).replaceWith.apply(i,K([n].concat(K(K(t.childNodes).slice(0,-1))))),T(n)}else if(v.call(e,t)){var s;if(3!=(null===(s=t.previousSibling)||void 0===s?void 0:s.nodeType)||t.previousSibling.textContent||t.previousSibling.remove(),t.previousSibling&&"BR"==t.previousSibling.nodeName){t.previousSibling.replaceWith("\n​");for(var a=t.nextSibling,o="";a;)o+=a.textContent,a=a.nextSibling;o.trim()&&T(t.previousSibling)}else t.previousSibling&&!w(t.previousSibling)||t.before("​")}})),t.removedNodes.forEach((function(t){t&&"BR"==t.nodeName&&v.call(e,i)&&(e.removeTags(i),e.fixFirefoxLastTagNoCaret())}))}));var i=this.DOM.input.lastChild;i&&""==i.nodeValue&&i.remove(),i&&"BR"==i.nodeName||this.DOM.input.appendChild(document.createElement("br"))}}};function X(t,e){(null==e||e>t.length)&&(e=t.length);for(var i=0,n=new Array(e);i");else{try{G(JSON.parse(t),Array)&&(t=JSON.parse(t))}catch(t){}this.addTags(t,!0).forEach((function(t){return t&&t.classList.add(i.classNames.tagNoAnimation)}))}else this.postUpdate();this.state.lastOriginalValueReported=i.mixMode.integrated?"":this.DOM.originalInput.value},cloneEvent:function(t){var e={};for(var i in t)"path"!=i&&(e[i]=t[i]);return e},loading:function(t){return this.state.isLoading=t,this.DOM.scope.classList[t?"add":"remove"](this.settings.classNames.scopeLoading),this},tagLoading:function(t,e){return t&&t.classList[e?"add":"remove"](this.settings.classNames.tagLoading),this},toggleClass:function(t,e){"string"==typeof t&&this.DOM.scope.classList.toggle(t,e)},toggleScopeValidation:function(t){var e=!0===t||void 0===t;!this.settings.required&&t&&t===this.TEXTS.empty&&(e=!0),this.toggleClass(this.settings.classNames.tagInvalid,!e),this.DOM.scope.title=e?"":t},toggleFocusClass:function(t){this.toggleClass(this.settings.classNames.focus,!!t)},setPlaceholder:function(t){var e=this;["data","aria"].forEach((function(i){return e.DOM.input.setAttribute("".concat(i,"-placeholder"),t)}))},triggerChangeEvent:function(){if(!this.settings.mixMode.integrated){var t=this.DOM.originalInput,e=this.state.lastOriginalValueReported!==t.value,i=new CustomEvent("change",{bubbles:!0});e&&(this.state.lastOriginalValueReported=t.value,i.simulated=!0,t._valueTracker&&t._valueTracker.setValue(Math.random()),t.dispatchEvent(i),this.trigger("change",this.state.lastOriginalValueReported),t.value=this.state.lastOriginalValueReported)}},events:z,fixFirefoxLastTagNoCaret:function(){},setRangeAtStartEnd:function(t,e){if(e){t="number"==typeof t?t:!!t,e=e.lastChild||e;var i=document.getSelection();if(G(i.focusNode,Element)&&!this.DOM.input.contains(i.focusNode))return!0;try{i.rangeCount>=1&&["Start","End"].forEach((function(n){return i.getRangeAt(0)["set"+n](e,t||e.length)}))}catch(t){console.warn(t)}}},insertAfterTag:function(t,e){if(e=e||this.settings.mixMode.insertAfterTag,t&&t.parentNode&&e)return e="string"==typeof e?document.createTextNode(e):e,t.parentNode.insertBefore(e,t.nextSibling),e},editTagChangeDetected:function(t){var e=t.__originalData;for(var i in e)if(!this.dataProps.includes(i)&&t[i]!=e[i])return!0;return!1},getTagTextNode:function(t){return t.querySelector(this.settings.classNames.tagTextSelector)},setTagTextNode:function(t,e){this.getTagTextNode(t).innerHTML=d(e)},editTag:function(t,e){var i=this;t=t||this.getLastTag(),e=e||{};var s=this.settings,a=this.getTagTextNode(t),o=this.getNodeIndex(t),r=w(t),l=this.events.callbacks,d=!0,c="select"==s.mode;if(!c&&this.dropdown.hide(),a){if(!G(r,Object)||!("editable"in r)||r.editable)return r=w(t,{__originalData:u({},r),__originalHTML:t.cloneNode(!0)}),w(r.__originalHTML,r.__originalData),a.setAttribute("contenteditable",!0),t.classList.add(s.classNames.tagEditing),this.events.callbacks.onEditTagFocus.call(this,t),a.addEventListener("click",l.onEditTagClick.bind(this,t)),a.addEventListener("blur",l.onEditTagBlur.bind(this,this.getTagTextNode(t))),a.addEventListener("input",l.onEditTagInput.bind(this,a)),a.addEventListener("paste",l.onEditTagPaste.bind(this,a)),a.addEventListener("keydown",(function(e){return l.onEditTagkeydown.call(i,e,t)})),a.addEventListener("compositionstart",l.onCompositionStart.bind(this)),a.addEventListener("compositionend",l.onCompositionEnd.bind(this)),e.skipValidation||(d=this.editTagToggleValidity(t)),a.originalIsValid=d,this.trigger("edit:start",{tag:t,index:o,data:r,isValid:d}),a.focus(),!c&&this.setRangeAtStartEnd(!1,a),0===s.dropdown.enabled&&!c&&this.dropdown.show(),this.state.hasFocus=!0,this}else n.warn("Cannot find element in Tag template: .",s.classNames.tagTextSelector)},editTagToggleValidity:function(t,e){var i;if(e=e||w(t))return(i=!("__isValid"in e)||!0===e.__isValid)||this.removeTagsFromValue(t),this.update(),t.classList.toggle(this.settings.classNames.tagNotAllowed,!i),e.__isValid=i,e.__isValid;n.warn("tag has no data: ",t,e)},onEditTagDone:function(t,e){t=t||this.state.editing.scope,e=e||{};var i,n,s=this.settings,a={tag:t,index:this.getNodeIndex(t),previousData:w(t),data:e};this.trigger("edit:beforeUpdate",a,{cloneData:!1}),this.state.editing=!1,delete e.__originalData,delete e.__originalHTML,t&&t.parentNode&&((void 0!==(n=e[s.tagTextProp])?null===(i=(n+="").trim)||void 0===i?void 0:i.call(n):s.tagTextProp in e?void 0:e.value)?(t=this.replaceTag(t,e),this.editTagToggleValidity(t,e),s.a11y.focusableTags?t.focus():"select"!=s.mode&&T(t)):this.removeTags(t)),this.trigger("edit:updated",a),s.dropdown.closeOnSelect&&this.dropdown.hide(),this.settings.keepInvalidTags&&this.reCheckInvalidTags()},replaceTag:function(t,e){e&&""!==e.value&&void 0!==e.value||(e=t.__tagifyTagData),e.__isValid&&1!=e.__isValid&&u(e,this.getInvalidTagAttrs(e,e.__isValid));var i=this.createTagElem(e);return t.parentNode.replaceChild(i,t),this.updateValueByDOMTags(),i},updateValueByDOMTags:function(){var t=this;this.value.length=0;var e=this.settings.classNames,i=[e.tagNotAllowed.split(" ")[0],e.tagHide];[].forEach.call(this.getTagElms(),(function(e){Q(e.classList).some((function(t){return i.includes(t)}))||t.value.push(w(e))})),this.update(),this.dropdown.refilter()},injectAtCaret:function(t,e){var i;if(e=e||(null===(i=this.state.selection)||void 0===i?void 0:i.range),"string"==typeof t&&(t=document.createTextNode(t)),!t)return this;var n=11===t.nodeType?Array.prototype.slice.call(t.childNodes):[t];if(!n.length)return this;if(!e)return this.appendMixTags(t),this;if(!this.DOM.scope.contains(null==e?void 0:e.startContainer))return this;y(t,e);var s=n[n.length-1]||t;return(null==s?void 0:s.parentNode)&&T(s),this.setStateSelection(),this.updateValueByDOMTags(),this.update(),this},input:{set:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",e=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],i=this.settings,n=i.dropdown.closeOnSelect;this.state.inputText=t,e&&(this.DOM.input.innerHTML=d(""+t),t&&this.toggleClass(i.classNames.empty,!this.DOM.input.innerHTML)),!t&&n&&this.dropdown.hide.bind(this),this.input.autocomplete.suggest.call(this),this.input.validate.call(this)},raw:function(){return this.DOM.input.textContent},validate:function(){var t=!this.state.inputText||!0===this.validateTag({value:this.state.inputText});return this.DOM.input.classList.toggle(this.settings.classNames.inputInvalid,!t),t},normalize:function(t,e){var i=t||this.DOM.input,n=[];i.childNodes.forEach((function(t){return 3==t.nodeType&&n.push(t.nodeValue)})),n=n.join("\n");try{n=n.replace(/(?:\r\n|\r|\n)/g,this.settings.delimiters.source.charAt(0))}catch(t){}return n=n.replace(/\s/g," "),(null==e?void 0:e.trim)?this.trim(n):n},autocomplete:{suggest:function(t){if(this.settings.autoComplete.enabled){"object"!=typeof(t=t||{value:""})&&(t={value:t});var e=this.dropdown.getMappedValue(t);if("number"!=typeof e){var i=this.state.inputText.toLowerCase(),n=e.substr(0,this.state.inputText.length).toLowerCase(),s=e.substring(this.state.inputText.length);e&&this.state.inputText&&n==i?(this.DOM.input.setAttribute("data-suggest",s),this.state.inputSuggestion=t):(this.DOM.input.removeAttribute("data-suggest"),delete this.state.inputSuggestion)}}},set:function(t){var e=this.DOM.input.getAttribute("data-suggest"),i=t||(e?this.state.inputText+e:null);return!!i&&("mix"==this.settings.mode?this.replaceTextWithNode(document.createTextNode(this.state.tag.prefix+i)):(this.input.set.call(this,i),this.setRangeAtStartEnd(!1,this.DOM.input)),this.input.autocomplete.suggest.call(this),this.dropdown.hide(),!0)}}},getTagIdx:function(t){return this.value.findIndex((function(e){return e.__tagId==(t||{}).__tagId}))},getNodeIndex:function(t){var e=0;if(t)for(;t=t.previousElementSibling;)e++;return e},getTagElms:function(){for(var t=arguments.length,e=new Array(t),i=0;i=this.settings.maxTags&&this.TEXTS.exceed},setReadonly:function(t,e){var i=this.settings;this.DOM.scope.contains(document.activeElement)&&document.activeElement.blur(),i[e||"readonly"]=t,this.DOM.scope[(t?"set":"remove")+"Attribute"](e||"readonly",!0),this.settings.userInput=!0,this.setContentEditable(!t),t||(this.events.binding.call(this,!0),this.events.binding.call(this))},setContentEditable:function(t){this.DOM.scope.querySelectorAll("[data-can-editable]").forEach((function(e){e.contentEditable=t,e.tabIndex=t?0:-1}))},setDisabled:function(t){this.setReadonly(t,"disabled")},normalizeTags:function(t){var e=this,i=this.settings,n=i.whitelist,s=i.delimiters,a=i.mode,o=i.tagTextProp,r=[],l=!!n&&G(n[0],Object),d=Array.isArray(t),g=d&&t[0].value,h=function(t){return(t+"").split(s).reduce((function(t,i){var n,s=e.trim(i);return s&&t.push((J(n={},o,s),J(n,"value",s),n)),t}),[])};if("number"==typeof t&&(t=t.toString()),"string"==typeof t){if(!t.trim())return[];t=h(t)}else d&&(t=t.reduce((function(t,i){if(c(i)){var n=u({},i);o in n||(o="value"),n[o]=e.trim(n[o]),(n[o]||0===n[o])&&t.push(n)}else if(null!=i&&""!==i&&void 0!==i){var s;(s=t).push.apply(s,Q(h(i)))}return t}),[]));return l&&!g&&(t.forEach((function(t){var i=r.map((function(t){return t.value})),n=e.dropdown.filterListItems.call(e,t[o],{exact:!0});e.settings.duplicates||(n=n.filter((function(t){return!i.includes(t.value)})));var s=n.length>1?e.getWhitelistItem(t[o],o,n):n[0];s&&G(s,Object)?r.push(s):"mix"!=a&&(null==t.value&&(t.value=t[o]),r.push(t))})),r.length&&(t=r)),t},parseMixTags:function(t){var e=this,i=this.settings,n=i.mixTagsInterpolator,s=i.duplicates,a=i.transformTag,o=i.enforceWhitelist,r=i.maxTags,l=i.tagTextProp,d=[];t=t.split(n[0]).map((function(t,i){var c,u,g,h=t.split(n[1]),p=h[0],f=d.length==r;try{if(p==+p)throw Error;u=JSON.parse(p)}catch(t){u=e.normalizeTags(p)[0]||{value:p}}if(a.call(e,u),f||!(h.length>1)||o&&!e.isTagWhitelisted(u.value)||!s&&e.isTagDuplicate(u.value)){if(t)return i?n[0]+t:t}else u[c=u[l]?l:"value"]=e.trim(u[c]),g=e.createTagElem(u),d.push(u),g.classList.add(e.settings.classNames.tagNoAnimation),h[0]=g.outerHTML,e.value.push(u);return h.join("")})).join(""),this.DOM.input.innerHTML=t,this.DOM.input.appendChild(document.createTextNode("")),this.DOM.input.normalize();var c=this.getTagElms();return c.forEach((function(t,e){return w(t,d[e])})),this.update({withoutChangeEvent:!0}),O(c,this.state.hasFocus),t},replaceTextWithNode:function(t,e){if(this.state.tag||e){e=e||this.state.tag.prefix+this.state.tag.value;var i,n,s=this.state.selection||window.getSelection(),a=s.anchorNode,o=this.state.tag.delimiters?this.state.tag.delimiters.length:0;return a.splitText(s.anchorOffset-o),-1==(i=a.nodeValue.lastIndexOf(e))?!0:(n=a.splitText(i),t&&a.parentNode.replaceChild(t,n),!0)}},prepareNewTagNode:function(t,e){e=e||{};var i=this.settings,n=[],s={},a=Object.assign({},t,{value:t.value+""});if(t=Object.assign({},a),i.transformTag.call(this,t),t.__isValid=this.hasMaxTags()||this.validateTag(t),!0!==t.__isValid){if(e.skipInvalid)return;if(u(s,this.getInvalidTagAttrs(t,t.__isValid),{__preInvalidData:a}),t.__isValid==this.TEXTS.duplicate&&this.flashTag(this.getTagElmByValue(t.value)),!i.createInvalidTags)return void n.push(t.value)}return"readonly"in t&&(t.readonly?s["aria-readonly"]=!0:delete t.readonly),{tagElm:this.createTagElem(t,s),tagData:t,aggregatedInvalidInput:n}},postProcessNewTagNode:function(t,e){var i=this,n=this.settings,s=e.__isValid;s&&!0===s?this.value.push(e):(this.trigger("invalid",{data:e,index:this.value.length,tag:t,message:s}),n.keepInvalidTags||setTimeout((function(){return i.removeTags(t,!0)}),1e3)),this.dropdown.position()},selectTag:function(t,e){var i=this;if(!this.settings.enforceWhitelist||this.isTagWhitelisted(e.value)){this.state.actions.selectOption&&setTimeout((function(){return i.setRangeAtStartEnd(!1,i.DOM.input)}));var n=this.getLastTag();return n?this.replaceTag(n,e):this.appendTag(t),this.value[0]=e,this.update(),this.trigger("add",{tag:t,data:e}),[t]}},addEmptyTag:function(t){var e=u({value:""},t||{}),i=this.createTagElem(e);w(i,e),this.appendTag(i),this.editTag(i,{skipValidation:!0}),this.toggleFocusClass(!0)},addTags:function(t,e,i){var n=this,s=[],a=this.settings,o=[],r=document.createDocumentFragment(),l=[];if(!t||0==t.length)return s;switch(t=this.normalizeTags(t),a.mode){case"mix":return this.addMixTags(t);case"select":e=!1,this.removeAllTags()}return this.DOM.input.removeAttribute("style"),t.forEach((function(t){var e=n.prepareNewTagNode(t,{skipInvalid:i||a.skipInvalid});if(e){var d=e.tagElm;if(t=e.tagData,o=e.aggregatedInvalidInput,s.push(d),"select"==a.mode)return n.selectTag(d,t);r.appendChild(d),n.postProcessNewTagNode(d,t),l.push({tagElm:d,tagData:t})}})),this.appendTag(r),l.forEach((function(t){var e=t.tagElm,i=t.tagData;return n.trigger("add",{tag:e,index:n.getTagIdx(i),data:i})})),this.update(),t.length&&e&&(this.input.set.call(this,a.createInvalidTags?"":o.join(a._delimiters)),this.setRangeAtStartEnd(!1,this.DOM.input)),this.dropdown.refilter(),s},addMixTags:function(t){var e=this;if((t=this.normalizeTags(t))[0].prefix||this.state.tag)return this.prefixedTextToTag(t[0]);var i=document.createDocumentFragment(),n=[];return t.forEach((function(t){var s=e.prepareNewTagNode(t);i.appendChild(s.tagElm),e.insertAfterTag(s.tagElm),e.postProcessNewTagNode(s.tagElm,s.tagData),n.push({tagElm:s.tagElm,tagData:s.tagData})})),this.appendMixTags(i,n),i.children},appendMixTags:function(t,e){var i,n=null===(i=this.state.selection)||void 0===i?void 0:i.range,s=!!n&&this.DOM.scope.contains(n.startContainer),a=t?11===t.nodeType?Array.prototype.slice.call(t.childNodes):[t]:[];if(s)this.injectAtCaret(t);else{this.DOM.input.focus();var o=this.setStateSelection();(null==o?void 0:o.range)&&(o.range.setStart(this.DOM.input,o.range.endOffset),o.range.setEnd(this.DOM.input,o.range.endOffset)),this.DOM.input.appendChild(t),this.updateValueByDOMTags(),this.update();var r=a[a.length-1];(null==r?void 0:r.parentNode)&&T(r),this.setStateSelection()}var l=(null==e?void 0:e.length)?e:a.filter((function(t){return 1===t.nodeType}));(null==l?void 0:l.length)&&this.trigger("add",{tags:l})},prefixedTextToTag:function(t){var e,i,n,s=this,a=this.settings,o=null===(e=this.state.tag)||void 0===e?void 0:e.delimiters;if(t.prefix=t.prefix||this.state.tag?this.state.tag.prefix:(a.pattern.source||a.pattern)[0],n=this.prepareNewTagNode(t),i=n.tagElm,this.replaceTextWithNode(i)||this.DOM.input.appendChild(i),setTimeout((function(){return i.classList.add(s.settings.classNames.tagNoAnimation)}),300),this.update(),!o){var r=this.insertAfterTag(i)||i;setTimeout(T,0,r)}return this.state.tag=null,this.postProcessNewTagNode(i,n.tagData),this.trigger("add",{tag:n,data:t}),i},appendTag:function(t){var e=this.DOM,i=e.input;e.scope.insertBefore(t,i)},createTagElem:function(t,e){t.__tagId=f();var i,n=u({},t,$({value:d(t.value+"")},e));return function(t){for(var e,i=document.createNodeIterator(t,NodeFilter.SHOW_TEXT,null,!1);e=i.nextNode();)e.textContent.trim()||e.parentNode.removeChild(e)}(i=this.parseTemplate("tag",[n,this])),w(i,t),i},reCheckInvalidTags:function(){var t=this,e=this.settings;this.getTagElms(e.classNames.tagNotAllowed).forEach((function(i,n){var s=w(i),a=t.hasMaxTags(),o=t.validateTag(s),r=!0===o&&!a;if("select"==e.mode&&t.toggleScopeValidation(o),r)return s=s.__preInvalidData?s.__preInvalidData:{value:s.value},t.replaceTag(i,s);i.title=a||o}))},removeTags:function(t,e,i){var n,s=this,a=this.settings;if(t=t&&G(t,HTMLElement)?[t]:G(t,Array)?t:t?[t]:[this.getLastTag()].filter((function(t){return t})),n=t.reduce((function(t,e){e&&"string"==typeof e&&(e=s.getTagElmByValue(e));var i=w(e);return e&&i&&!i.readonly&&t.push({node:e,idx:s.getTagIdx(i),data:w(e,{__removed:!0})}),t}),[]),i="number"==typeof i?i:this.CSSVars.tagHideTransition,"select"==a.mode&&(i=0,this.input.set.call(this)),1==n.length&&"select"!=a.mode&&n[0].node.classList.contains(a.classNames.tagNotAllowed)&&(e=!0),n.length)return a.hooks.beforeRemoveTag(n,{tagify:this}).then((function(){var t=function(t){t.node.parentNode&&(t.node.parentNode.removeChild(t.node),e?a.keepInvalidTags&&this.trigger("remove",{tag:t.node,index:t.idx}):(this.dropdown.refilter(),this.dropdown.position(),this.DOM.input.normalize(),a.keepInvalidTags&&this.reCheckInvalidTags(),this.trigger("remove",{tag:t.node,index:t.idx,data:t.data})))};e||(s.removeTagsFromValue(n.map((function(t){return t.node}))),s.update(),"select"==a.mode&&a.userInput&&s.setContentEditable(!0)),i&&i>10&&1==n.length?function(e){e.node.style.width=parseFloat(window.getComputedStyle(e.node).width)+"px",document.body.clientTop,e.node.classList.add(a.classNames.tagHide),setTimeout(t.bind(this),i,e)}.call(s,n[0]):n.forEach(t.bind(s))})).catch((function(t){}))},removeTagsFromDOM:function(){this.getTagElms().forEach((function(t){return t.remove()}))},removeTagsFromValue:function(t){var e=this;(t=Array.isArray(t)?t:[t]).forEach((function(t){var i=w(t),n=e.getTagIdx(i);n>-1&&e.value.splice(n,1)}))},removeAllTags:function(t){var e=this;t=t||{},this.value=[],"mix"==this.settings.mode?this.DOM.input.innerHTML="":this.removeTagsFromDOM(),this.dropdown.refilter(),this.dropdown.position(),this.state.dropdown.visible&&setTimeout((function(){e.DOM.input.focus()})),"select"==this.settings.mode&&(this.input.set.call(this),this.settings.userInput&&this.setContentEditable(!0));var i=this.state.blockChangeEvent?void 0:function(){!e.state.blockChangeEvent&&e.trigger("remove",{})};this.update(t,i)},postUpdate:function(){this.state.blockChangeEvent=!1;var t,e,i=this.settings,n=i.classNames,s="mix"==i.mode?i.mixMode.integrated?this.DOM.input.textContent:this.DOM.originalInput.value.trim():this.value.length+this.input.raw.call(this).length;(this.toggleClass(n.hasMaxTags,this.value.length>=i.maxTags),this.toggleClass(n.hasNoTags,!this.value.length),this.toggleClass(n.empty,!s),"select"==i.mode)&&this.toggleScopeValidation(null===(e=this.value)||void 0===e||null===(t=e[0])||void 0===t?void 0:t.__isValid)},setOriginalInputValue:function(t){var e=this.DOM.originalInput;this.settings.mixMode.integrated||(e.value=t,e.tagifyValue=e.value)},update:function(t,e){clearTimeout(this.debouncedUpdateTimeout),this.debouncedUpdateTimeout=setTimeout(function(){this.setPersistedData(i,"value"),this.settings.onChangeAfterBlur&&(t||{}).withoutChangeEvent||this.state.blockChangeEvent||this.triggerChangeEvent();this.postUpdate(),null==e||e()}.bind(this),100),this.events.bindOriginaInputListener.call(this,100);var i=this.getInputValue();this.setOriginalInputValue(i)},getInputValue:function(){var t=this.getCleanValue();return"mix"==this.settings.mode?this.getMixedTagsAsString(t):t.length?this.settings.originalInputValueFormat?this.settings.originalInputValueFormat(t):JSON.stringify(t):""},getCleanValue:function(t){return a(t||this.value,this.dataProps)},getMixedTagsAsString:function(){var t="",e=this,i=this.settings,n=i.originalInputValueFormat||JSON.stringify,s=i.mixTagsInterpolator;return function i(a){a.childNodes.forEach((function(a){if(1==a.nodeType){var r=w(a);if("BR"==a.tagName&&(t+="\r\n"),r&&v.call(e,a)){if(r.__removed)return;t+=s[0]+n(o(r,e.dataProps))+s[1]}else a.getAttribute("style")||["B","I","U"].includes(a.tagName)?t+=a.textContent:"DIV"!=a.tagName&&"P"!=a.tagName||(t+="\r\n",i(a))}else t+=a.textContent}))}(this.DOM.input),t}},Y.prototype.removeTag=Y.prototype.removeTags,Y})); +//# sourceMappingURL=tagify.js.map diff --git a/cms/server/admin/static/training_program.js b/cms/server/admin/static/training_program.js new file mode 100644 index 0000000000..e09f08f797 --- /dev/null +++ b/cms/server/admin/static/training_program.js @@ -0,0 +1,456 @@ +/* Contest Management System + * Copyright © 2024 IOI-ISR + * + * Training Program JavaScript Utilities + * Centralized JS for training program pages (histogram modal, etc.) + */ + +"use strict"; + +var CMS = window.CMS || {}; + +/** + * Training Program utilities namespace. + * Provides histogram modal functionality and other training program specific features. + */ +CMS.TrainingProgram = CMS.TrainingProgram || {}; + +// Module state (stored on namespace for access by methods) +CMS.TrainingProgram._histogramModal = null; +CMS.TrainingProgram._histogramTagify = null; +CMS.TrainingProgram._currentHistogramData = null; + +// Configuration (set via init) +CMS.TrainingProgram._config = { + allStudentTags: [], + tagsPerTrainingDay: {}, + historicalStudentTags: {}, + studentData: {}, + trainingDayTasks: {}, + studentAccessibleTasks: {}, + taskMaxScores: {}, + taskMaxScoresByTrainingDay: {} +}; + + +/** + * Initialize the training program module with data from templates. + * + * options (object): Configuration options containing: + * - allStudentTags (array): List of all student tags + * - tagsPerTrainingDay (object): Tags available per training day + * - historicalStudentTags (object): Historical tags per training day per student + * - studentData (object): Student information keyed by student ID + * - trainingDayTasks (object): Tasks per training day + * - studentAccessibleTasks (object): Accessible tasks per student per training day + * - taskMaxScores (object): Max scores per task + * - taskMaxScoresByTrainingDay (object): Max scores per task per training day + */ +CMS.TrainingProgram.init = function(options) { + if (options) { + var config = CMS.TrainingProgram._config; + Object.keys(options).forEach(function(key) { + if (config.hasOwnProperty(key)) { + config[key] = options[key]; + } + }); + } +}; + + +/** + * Initialize the histogram modal. + * Sets up Tagify for filtering and event listeners for closing. + */ +CMS.TrainingProgram.initHistogramModal = function() { + var modal = document.getElementById('histogramModal'); + if (!modal) return; + + CMS.TrainingProgram._histogramModal = modal; + + var histogramTagsInput = document.getElementById('histogramTagsFilter'); + if (histogramTagsInput && typeof Tagify !== 'undefined') { + CMS.TrainingProgram._histogramTagify = new Tagify(histogramTagsInput, { + delimiters: ",", + whitelist: CMS.TrainingProgram._config.allStudentTags, + enforceWhitelist: true, + editTags: false, + dropdown: { enabled: 0, maxItems: 20, closeOnSelect: true }, + originalInputValueFormat: function(valuesArr) { + return valuesArr.map(function(item) { return item.value; }).join(', '); + } + }); + CMS.TrainingProgram._histogramTagify.on('change', function() { + var data = CMS.TrainingProgram._currentHistogramData; + if (data) { + CMS.TrainingProgram._renderHistogram(data.scores, data.title, data.type); + } + }); + } + + modal.addEventListener('click', function(e) { + if (e.target === modal) { + CMS.TrainingProgram.closeHistogramModal(); + } + }); + + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && modal.style.display === 'flex') { + CMS.TrainingProgram.closeHistogramModal(); + } + }); +}; + + +/** + * Open the histogram modal with score data. + * + * scores (array): Array of {studentId, score} objects + * title (string): Title for the histogram + * type (string): Type of histogram ('task' or 'training_day') + * trainingDayId (number): ID of the training day + * maxPossibleScore (number): Maximum possible score + */ +CMS.TrainingProgram.openHistogramModal = function(scores, title, type, trainingDayId, maxPossibleScore) { + var modal = CMS.TrainingProgram._histogramModal; + if (!modal) return; + + CMS.TrainingProgram._currentHistogramData = { + scores: scores, + title: title, + type: type, + trainingDayId: trainingDayId, + maxPossibleScore: (maxPossibleScore === undefined || maxPossibleScore === null) ? 100 : maxPossibleScore + }; + + var titleEl = document.getElementById('histogramTitle'); + if (titleEl) { + titleEl.textContent = title + ' - Score Distribution'; + } + + var tagify = CMS.TrainingProgram._histogramTagify; + var config = CMS.TrainingProgram._config; + if (tagify && trainingDayId && config.tagsPerTrainingDay[trainingDayId]) { + tagify.settings.whitelist = config.tagsPerTrainingDay[trainingDayId]; + tagify.removeAllTags(); + } else if (tagify) { + tagify.settings.whitelist = config.allStudentTags; + tagify.removeAllTags(); + } + + modal.style.display = 'flex'; + CMS.TrainingProgram._renderHistogram(scores, title, type); +}; + + +/** + * Close the histogram modal. + */ +CMS.TrainingProgram.closeHistogramModal = function() { + var modal = CMS.TrainingProgram._histogramModal; + if (modal) { + modal.style.display = 'none'; + } + CMS.TrainingProgram._currentHistogramData = null; +}; + + +/** + * Copy histogram data to clipboard. + */ +CMS.TrainingProgram.copyHistogramData = function() { + var textArea = document.getElementById('histogramTextData'); + if (!textArea) return; + + var textToCopy = textArea.value; + var btn = document.querySelector('.copy-btn'); + var originalText = btn ? btn.textContent : 'Copy'; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(textToCopy).then(function() { + if (btn) { + btn.textContent = 'Copied!'; + setTimeout(function() { btn.textContent = originalText; }, 2000); + } + }).catch(function() { + CMS.TrainingProgram._fallbackCopy(textArea, btn, originalText); + }); + } else { + CMS.TrainingProgram._fallbackCopy(textArea, btn, originalText); + } +}; + + +/** + * Fallback copy method using execCommand. + * @private + */ +CMS.TrainingProgram._fallbackCopy = function(textArea, btn, originalText) { + textArea.select(); + document.execCommand('copy'); + if (btn) { + btn.textContent = 'Copied!'; + setTimeout(function() { btn.textContent = originalText; }, 2000); + } +}; + + +/** + * Get filtered scores based on selected tags. + * @private + */ +CMS.TrainingProgram._getFilteredScores = function(scores) { + var tagify = CMS.TrainingProgram._histogramTagify; + var filterTags = []; + + if (tagify) { + var tagifyValue = tagify.value; + if (tagifyValue && tagifyValue.length > 0) { + filterTags = tagifyValue.map(function(t) { return t.value; }); + } + } + + if (filterTags.length === 0) { + return scores; + } + + var data = CMS.TrainingProgram._currentHistogramData; + var trainingDayId = data ? data.trainingDayId : null; + var config = CMS.TrainingProgram._config; + + return scores.filter(function(item) { + var studentTags = []; + + if (trainingDayId && config.historicalStudentTags[trainingDayId] && + config.historicalStudentTags[trainingDayId][item.studentId]) { + studentTags = config.historicalStudentTags[trainingDayId][item.studentId]; + } else { + var studentInfo = config.studentData[item.studentId]; + if (studentInfo) { + studentTags = studentInfo.tags; + } + } + + if (!studentTags || studentTags.length === 0) return false; + return filterTags.every(function(tag) { + return studentTags.indexOf(tag) !== -1; + }); + }); +}; + + +/** + * Calculate the maximum score for filtered students. + * @private + */ +CMS.TrainingProgram._calculateFilteredMaxScore = function(filteredScores, trainingDayId, type) { + var data = CMS.TrainingProgram._currentHistogramData; + var config = CMS.TrainingProgram._config; + + if (type === 'task') { + return data ? data.maxPossibleScore : 100; + } + + if (type === 'training_day' && trainingDayId && config.trainingDayTasks[trainingDayId]) { + var accessibleTasksSet = new Set(); + filteredScores.forEach(function(item) { + var studentTasks = config.studentAccessibleTasks[trainingDayId] && + config.studentAccessibleTasks[trainingDayId][item.studentId]; + if (studentTasks) { + studentTasks.forEach(function(taskId) { + accessibleTasksSet.add(taskId); + }); + } + }); + + var maxScore = 0; + accessibleTasksSet.forEach(function(taskId) { + var taskMaxScore = 0; + if (trainingDayId && config.taskMaxScoresByTrainingDay[trainingDayId]) { + taskMaxScore = config.taskMaxScoresByTrainingDay[trainingDayId][taskId] || 0; + } else { + taskMaxScore = config.taskMaxScores[taskId] || 0; + } + maxScore += taskMaxScore; + }); + + return maxScore > 0 ? maxScore : (data ? data.maxPossibleScore : 100); + } + + return data ? data.maxPossibleScore : 100; +}; + + +/** + * Render the histogram with the given scores. + * @private + */ +CMS.TrainingProgram._renderHistogram = function(scores, title, type) { + var filteredScores = CMS.TrainingProgram._getFilteredScores(scores); + var scoreValues = filteredScores.map(function(s) { return s.score; }); + + scoreValues.sort(function(a, b) { return b - a; }); + + var data = CMS.TrainingProgram._currentHistogramData; + var trainingDayId = data ? data.trainingDayId : null; + var maxPossibleScore = CMS.TrainingProgram._calculateFilteredMaxScore(filteredScores, trainingDayId, type); + + var buckets = {}; + var bucketLabels = {}; + var bucketOrder = []; + + if (maxPossibleScore === 0) { + maxPossibleScore = 1; + } + + if (maxPossibleScore <= 15) { + var maxInt = Math.ceil(maxPossibleScore); + + for (var i = 0; i <= maxInt; i++) { + var key = i.toString(); + buckets[key] = 0; + bucketLabels[key] = key; + bucketOrder.push(key); + } + + scoreValues.forEach(function(score) { + var rounded = Math.round(score); + if (rounded > maxInt) rounded = maxInt; + if (rounded < 0) rounded = 0; + buckets[rounded.toString()]++; + }); + } else { + var bucketSize = maxPossibleScore / 10; + var lastBucketThreshold = maxPossibleScore * 0.9; + + buckets['0'] = 0; + bucketLabels['0'] = '0'; + bucketOrder.push('0'); + + for (var j = 1; j <= 9; j++) { + var upperBound = Math.round(j * bucketSize); + var lowerBound = Math.round((j - 1) * bucketSize); + var bucketKey = upperBound.toString(); + buckets[bucketKey] = 0; + bucketLabels[bucketKey] = '(' + lowerBound + ',' + upperBound + ']'; + bucketOrder.push(bucketKey); + } + + var lastKey = Math.round(maxPossibleScore).toString(); + buckets[lastKey] = 0; + bucketLabels[lastKey] = '>' + Math.round(lastBucketThreshold); + bucketOrder.push(lastKey); + + scoreValues.forEach(function(score) { + if (score === 0) { + buckets['0']++; + } else if (score > lastBucketThreshold) { + buckets[lastKey]++; + } else { + var bucketIndex = Math.ceil(score / bucketSize); + if (bucketIndex < 1) bucketIndex = 1; + if (bucketIndex > 9) bucketIndex = 9; + var bKey = Math.round(bucketIndex * bucketSize).toString(); + buckets[bKey]++; + } + }); + } + + var histogramBars = document.getElementById('histogramBars'); + if (!histogramBars) return; + + var maxCount = Math.max.apply(null, Object.values(buckets)) || 1; + var totalStudents = scoreValues.length; + + var barsHtml = ''; + bucketOrder.forEach(function(bucketKey, index) { + var count = buckets[bucketKey] || 0; + var percentage = totalStudents > 0 ? ((count / totalStudents) * 100).toFixed(1) : 0; + var barHeight = maxCount > 0 ? (count / maxCount) * 100 : 0; + var hue = bucketOrder.length > 1 ? (index / (bucketOrder.length - 1)) * 120 : 60; + + barsHtml += '
' + + '
' + + '
' + + '
' + + '
' + bucketLabels[bucketKey] + '
' + + '
' + count + '
' + + '
'; + }); + histogramBars.innerHTML = barsHtml; + + var median = 0; + if (scoreValues.length > 0) { + var sorted = scoreValues.slice().sort(function(a, b) { return a - b; }); + var mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + median = (sorted[mid - 1] + sorted[mid]) / 2; + } else { + median = sorted[mid]; + } + } + + var statsEl = document.getElementById('histogramStats'); + if (statsEl) { + statsEl.innerHTML = + 'Total students: ' + totalStudents + + ' | Max possible: ' + Math.round(maxPossibleScore) + + (scoreValues.length > 0 ? ' | Average: ' + (scoreValues.reduce(function(a, b) { return a + b; }, 0) / scoreValues.length).toFixed(1) + + ' | Median: ' + median.toFixed(1) + + ' | Max: ' + Math.max.apply(null, scoreValues).toFixed(1) + + ' | Min: ' + Math.min.apply(null, scoreValues).toFixed(1) : ''); + } + + var textData = title + ' - Score Distribution\n'; + textData += '================================\n\n'; + textData += 'Statistics:\n'; + textData += 'Total: ' + totalStudents + '\n'; + textData += 'Max possible score: ' + Math.round(maxPossibleScore) + '\n'; + if (scoreValues.length > 0) { + textData += 'Average: ' + (scoreValues.reduce(function(a, b) { return a + b; }, 0) / scoreValues.length).toFixed(1) + '\n'; + textData += 'Median: ' + median.toFixed(1) + '\n'; + textData += 'Max: ' + Math.max.apply(null, scoreValues).toFixed(1) + '\n'; + textData += 'Min: ' + Math.min.apply(null, scoreValues).toFixed(1) + '\n'; + } + textData += '\nScores (high to low):\n'; + + var scoreGroups = {}; + scoreValues.forEach(function(score) { + var roundedScore = score.toFixed(1); + scoreGroups[roundedScore] = (scoreGroups[roundedScore] || 0) + 1; + }); + + var sortedScoreKeys = Object.keys(scoreGroups).sort(function (a, b) { return Number.parseFloat(b) - Number.parseFloat(a); }); + sortedScoreKeys.forEach(function(score) { + var count = scoreGroups[score]; + var pct = totalStudents > 0 ? ((count / totalStudents) * 100).toFixed(1) : 0; + textData += score + ': ' + count + ' student' + (count !== 1 ? 's' : '') + ' (' + pct + '%)\n'; + }); + + textData += '\nHistogram buckets:\n'; + var reverseBucketOrder = bucketOrder.slice().reverse(); + reverseBucketOrder.forEach(function(bucketKey) { + var count = buckets[bucketKey] || 0; + var pct = totalStudents > 0 ? ((count / totalStudents) * 100).toFixed(1) : 0; + textData += bucketLabels[bucketKey] + ': ' + count + ' (' + pct + '%)\n'; + }); + + var textDataEl = document.getElementById('histogramTextData'); + if (textDataEl) { + textDataEl.value = textData; + } +}; + + +// Expose functions globally for backwards compatibility with onclick handlers +window.openHistogramModal = function(scores, title, type, trainingDayId, maxPossibleScore) { + CMS.TrainingProgram.openHistogramModal(scores, title, type, trainingDayId, maxPossibleScore); +}; +window.closeHistogramModal = CMS.TrainingProgram.closeHistogramModal; +window.copyHistogramData = CMS.TrainingProgram.copyHistogramData; + + +// Auto-initialize histogram modal on DOM ready +$(document).ready(function() { + CMS.TrainingProgram.initHistogramModal(); +}); diff --git a/cms/server/admin/templates/add_training_day.html b/cms/server/admin/templates/add_training_day.html new file mode 100644 index 0000000000..4e43b9b2e8 --- /dev/null +++ b/cms/server/admin/templates/add_training_day.html @@ -0,0 +1,206 @@ +{% extends "base.html" %} + +{% block core %} +
+ + + + +
+
+ {{ xsrf_form_html|safe }} + +
+

Basic Information

+ +
+ + +
A unique identifier for the training day contest
+
+ +
+ + +
Human-readable description (defaults to name if empty)
+
+ +
+
+ + +
Optional, defaults to earliest group time
+
+
+ +
+ + hours + + minutes +
+
Optional
+
+
+
+ +
+

Main Groups Configuration

+

Configure which student tags represent main groups for this training day. Students must have exactly one main group tag to participate. Leave empty if all students can participate.

+ +
+ + + + + + + + + + + + +
Tag NameStart Time ({{ timezone_name }})DurationAlphabetical Order
+
+ + +
+ +
+ Cancel + +
+
+
+
+ + + +{% endblock core %} diff --git a/cms/server/admin/templates/add_training_program.html b/cms/server/admin/templates/add_training_program.html new file mode 100644 index 0000000000..dc0e25ddef --- /dev/null +++ b/cms/server/admin/templates/add_training_program.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% block core %} +
+ +
+

Create Training Program

+
Set up a new training season with students and tasks
+
+ + +
+
+ {{ xsrf_form_html|safe }} + +
+ + +
A codename for the training program. A managing contest named "__name" will be created automatically.
+
+ +
+ + +
Human-readable description (defaults to name if empty)
+
+ +
+
+ + +
When the program starts
+
+
+ + +
When the program ends
+
+
+ +
+ Cancel +
+ + +
+
+
+
+
+ + + +{% endblock core %} diff --git a/cms/server/admin/templates/announcements.html b/cms/server/admin/templates/announcements.html index f4b31ea93f..58976f7e52 100644 --- a/cms/server/admin/templates/announcements.html +++ b/cms/server/admin/templates/announcements.html @@ -1,24 +1,62 @@ {% extends "base.html" %} {% import 'macro/markdown_input.html' as macro_markdown %} +{% import 'fragments/info_alert.html' as info_alert %} + +{% block js_init %} +{% if training_program is defined or is_training_day %} +// Initialize Tagify for announcement visibility tags (no auto-save, form submission only) +var whitelist = {{ all_student_tags | tojson }}; +var inputs = document.querySelectorAll('.announcement-visibility-tags'); +inputs.forEach(function(input) { + new Tagify(input, { + delimiters: ",", + maxTags: 20, + placeholder: "All students", + whitelist: whitelist, + enforceWhitelist: true, + editTags: false, + dropdown: { + maxItems: 20, + classname: "tags-look", + enabled: 0, + closeOnSelect: true + }, + originalInputValueFormat: function(valuesArr) { + return valuesArr.map(function(item) { + return item.value; + }).join(', '); + } + }); +}); +{% endif %} +{% endblock js_init %} {% block core %}

Announcements

+{% if training_program is defined %} +{{ info_alert.alert( + title="Training Program Announcements", + message="This page only handles announcements for the training program. For training day announcements, go to the appropriate training day's announcements page.", + type="info" +) }} +{% endif %} +

Announcements

{% if admin.permission_all or admin.permission_messaging %}
-
+ {{ xsrf_form_html|safe }}
- {% for task in contest.tasks %} + {% for task in contest.get_tasks() %} @@ -27,6 +65,12 @@

Announcements

{{ macro_markdown.markdown_input("text") }}
+ {% if training_program is defined or is_training_day %} +
+ + +
+ {% endif %} {{ macro_markdown.markdown_preview() }} @@ -39,26 +83,39 @@

Announcements

- Remove + {% if training_program is defined %} + + {% else %} + + {% endif %}
{{ msg.timestamp|format_datetime }}
{{ msg.subject }}
{{ msg.text | markdown }}
+ {% if (training_program is defined or is_training_day) and msg.visible_to_tags %} +
+ Visible to: {{ msg.visible_to_tags|join(', ') }} +
+ {% endif %} +
By: {{ msg.admin.name if msg.admin is not none else "" }}
- Edit +
- + {{ xsrf_form_html|safe }} + {% if training_program is defined %} + + {% endif %}
- {% for task in contest.tasks %} + {% for task in contest.get_tasks() %} @@ -67,6 +124,12 @@

Announcements

{{ macro_markdown.markdown_input("text") }}
+ {% if training_program is defined or is_training_day %} +
+ + +
+ {% endif %} {{ macro_markdown.markdown_preview() }} diff --git a/cms/server/admin/templates/archive_training_day.html b/cms/server/admin/templates/archive_training_day.html new file mode 100644 index 0000000000..574bd2c8f4 --- /dev/null +++ b/cms/server/admin/templates/archive_training_day.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} + +{% block core %} +
+

Archive Training Day: {{ contest.name }}

+
+ +

+ ← Back to training days +

+ +
+ Warning: Archiving a training day will: +
    +
  • Extract and save attendance data (participation status, location, delay time, delay reasons)
  • +
  • Extract and save ranking data (scores, score history)
  • +
  • Save the training day name and description
  • +
  • Delete the contest and all participations permanently
  • +
+ This action cannot be undone. +
+ + + {{ xsrf_form_html|safe }} + +

Select Class IPs

+

+ Select the IP addresses that correspond to the classroom. Students who participated from these IPs + will be marked as "class", others as "home". If a student used both class and non-class IPs, + they will be marked as "both". +

+ + {% if shared_ips %} + + + + + + + + + + {% for ip, count in shared_ips.items() | sort(attribute='1', reverse=true) %} + + + + + + {% endfor %} + +
SelectIP AddressStudent Count
+ + {{ ip }}{{ count }}
+ {% else %} +

No shared IP addresses found (no IP with more than one student).

+ {% endif %} + +

Confirm Archive

+ + {% if users_not_finished %} +
+

Warning: Some students can still start or are currently in the training!

+

The following {{ users_not_finished|length }} student(s) have not finished or missed the training yet:

+
    + {% for item in users_not_finished %} +
  • + {{ item.participation.user.first_name }} {{ item.participation.user.last_name }} + ({{ item.participation.user.username }}) - {{ item.status_label }} +
  • + {% endfor %} +
+

Archiving now will mark these students as "missed" and they will not be able to participate.

+
+ {% endif %} + +

+ +

+

+ +

+ +{% endblock core %} diff --git a/cms/server/admin/templates/base.html b/cms/server/admin/templates/base.html index a7f7fdd755..a9013a6121 100644 --- a/cms/server/admin/templates/base.html +++ b/cms/server/admin/templates/base.html @@ -6,8 +6,8 @@ // Pre-paint CSS injection to prevent flash of expanded menus // Uses a hydration class on so these rules only apply during initial render (function() { - var validSectionKeys = ['contests', 'tasks', 'users', 'teams']; - var folderKeyPattern = /^(folder|subfolder)-\d+$/; + var validSectionKeys = ['training-programs', 'contests', 'tasks', 'users', 'teams', 'training-days']; + var folderKeyPattern = /^(folder|subfolder|training-program)-\d+$/; var css = ''; // Add hydration class to gate these styles @@ -52,8 +52,11 @@ + + + @@ -64,6 +67,10 @@ + + + + {% if contest is none %} Admin @@ -227,20 +234,24 @@ + {% include 'fragments/svg_icons.html' %}
{% endblock core %} diff --git a/cms/server/contest/templates/task_submissions.html b/cms/server/contest/templates/task_submissions.html index ea5a08f2b8..fc4541f27d 100644 --- a/cms/server/contest/templates/task_submissions.html +++ b/cms/server/contest/templates/task_submissions.html @@ -185,6 +185,12 @@

{% trans name=task.title, short_name=task.name %}{{ name }} ({{ short_name }}) submissions{% endtrans %}

+{% if contest.training_program %} + +{% endif %} + {% if score_type is defined %} {% set two_task_scores = score_type.max_public_score > 0 and score_type.max_public_score < score_type.max_score %} @@ -361,6 +367,83 @@

{% trans %}Previous submissions{% endtrans %} {% endif %} +{% if is_task_archive %} +{# Task archive view: show submissions grouped by source #} + +{# First show submissions from task archive #} +

{% trans %}Submissions from task archive{% endtrans %}

+{% if archive_submissions|rejectattr("official")|list|length > 0 %} +

{% trans %}Unofficial submissions{% endtrans %}

+ {{ macro_submission.rows( + url, + contest_url, + translation, + xsrf_form_html, + actual_phase, + task, + archive_submissions, + can_use_tokens, + can_play_token, + can_play_token_now, + submissions_download_allowed, + false) }} +{% endif %} + +

{% trans %}Official submissions{% endtrans %}

+{{ macro_submission.rows( + url, + contest_url, + translation, + xsrf_form_html, + actual_phase, + task, + archive_submissions, + can_use_tokens, + can_play_token, + can_play_token_now, + submissions_download_allowed, + true) }} + +{# Then show submissions from each training day #} +{% for td_id, td_data in training_day_submissions.items() %} +{% set training_day = td_data[0] %} +{% set td_submissions = td_data[1] %} +

{% trans td_desc=(training_day.contest.description if training_day.contest else (training_day.description or training_day.name)) %}Submissions from training day: {{ td_desc }}{% endtrans %}

+{% if td_submissions|rejectattr("official")|list|length > 0 %} +

{% trans %}Unofficial submissions{% endtrans %}

+ {{ macro_submission.rows( + url, + contest_url, + translation, + xsrf_form_html, + actual_phase, + task, + td_submissions, + can_use_tokens, + can_play_token, + can_play_token_now, + submissions_download_allowed, + false) }} +{% endif %} + +

{% trans %}Official submissions{% endtrans %}

+{{ macro_submission.rows( + url, + contest_url, + translation, + xsrf_form_html, + actual_phase, + task, + td_submissions, + can_use_tokens, + can_play_token, + can_play_token_now, + submissions_download_allowed, + true) }} +{% endfor %} + +{% else %} +{# Regular contest or training day view: show all submissions together #} {% if submissions|rejectattr("official")|list|length > 0 %}

{% trans %}Unofficial submissions{% endtrans %}

@@ -377,9 +460,9 @@

{% trans %}Unofficial submissions{% endtrans %}

can_play_token_now, submissions_download_allowed, false) }} -

{% trans %}Official submissions{% endtrans %}

{% endif %} +

{% trans %}Official submissions{% endtrans %}

{{ macro_submission.rows( url, contest_url, @@ -394,6 +477,8 @@

{% trans %}Official submissions{% endtrans %}

submissions_download_allowed, true) }} +{% endif %} +