From e5ca52753ea81023d6bca400124d2c32208e0f0c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 06:03:30 +0000 Subject: [PATCH 01/24] Redesign tasks page and add release/reuse functionality for archived training days - Modernize the tasks page design to match the students page style - Add modern table with sortable columns - Add dropdown for adding tasks - Add release/reuse button for tasks in archived training days - Add remove button with confirmation for active training day tasks - Add move buttons (top, up, down, bottom) for reordering tasks Co-Authored-By: Ron Ryvchin --- cms/server/admin/handlers/trainingprogram.py | 95 +++- .../templates/training_program_tasks.html | 413 ++++++++++++++---- 2 files changed, 426 insertions(+), 82 deletions(-) diff --git a/cms/server/admin/handlers/trainingprogram.py b/cms/server/admin/handlers/trainingprogram.py index 37e2a34e55..f0929f4f1b 100644 --- a/cms/server/admin/handlers/trainingprogram.py +++ b/cms/server/admin/handlers/trainingprogram.py @@ -428,8 +428,40 @@ def post(self, training_program_id: str): managing_contest = training_program.managing_contest try: - task_id: str = self.get_argument("task_id") operation: str = self.get_argument("operation") + + # Handle operations that include task_id in the operation value + # (e.g., "release_123", "remove_123") + if operation.startswith("release_"): + task_id = operation.split("_", 1)[1] + task = self.safe_get_item(Task, task_id) + self._release_task_from_archived_training_day(task) + if self.try_commit(): + self.service.proxy_service.reinitialize() + self.redirect(fallback_page) + return + + if operation.startswith("remove_"): + task_id = operation.split("_", 1)[1] + task = self.safe_get_item(Task, task_id) + + # If the task is in an active training day, redirect to confirmation + if task.training_day is not None and task.training_day.contest is not None: + asking_page = self.url( + "training_program", training_program_id, "task", task_id, "remove" + ) + self.redirect(asking_page) + return + + # For archived training days or no training day, remove directly + self._remove_task_from_program(task, managing_contest) + if self.try_commit(): + self.service.proxy_service.reinitialize() + self.redirect(fallback_page) + return + + # For move operations, task_id comes from the form field + task_id = self.get_argument("task_id") assert operation in ( self.REMOVE_FROM_PROGRAM, self.MOVE_UP, @@ -522,6 +554,67 @@ def post(self, training_program_id: str): self.redirect(fallback_page) + def _release_task_from_archived_training_day(self, task: Task) -> None: + """Release a task from an archived 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 release. + """ + 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 + for t in self.sql_session.query(Task)\ + .filter(Task.training_day == training_day)\ + .filter(Task.training_day_num > training_day_num)\ + .order_by(Task.training_day_num)\ + .all(): + t.training_day_num -= 1 + self.sql_session.flush() + + def _remove_task_from_program( + self, task: Task, managing_contest: Contest + ) -> None: + """Remove a task from the training program. + + This removes the task from both the training day (if assigned) and + the training program's managing contest. + + task: the task to remove. + managing_contest: the training program's managing contest. + """ + task_num = task.num + + # Remove from training day if assigned + if task.training_day is not None: + self._release_task_from_archived_training_day(task) + + # Remove from training program + task.contest = None + task.num = None + + self.sql_session.flush() + + # Reorder remaining tasks in the training program + for t in self.sql_session.query(Task)\ + .filter(Task.contest == managing_contest)\ + .filter(Task.num > task_num)\ + .order_by(Task.num)\ + .all(): + t.num -= 1 + self.sql_session.flush() + class AddTrainingProgramTaskHandler(BaseHandler): """Add a task to a training program.""" diff --git a/cms/server/admin/templates/training_program_tasks.html b/cms/server/admin/templates/training_program_tasks.html index 1eebb0ffb5..5efe688191 100644 --- a/cms/server/admin/templates/training_program_tasks.html +++ b/cms/server/admin/templates/training_program_tasks.html @@ -1,87 +1,338 @@ {% extends "base.html" %} {% block core %} -
-

Tasks list of {{ training_program.name }}

+
+ +
+

Tasks

+ +
+ + {% include "fragments/overload_warning.html" %} + + +
+ +
+ +
+
+ {{ xsrf_form_html|safe }} + + + +
+
+
+
+ + +
+

All Tasks ({{ training_program.managing_contest.tasks|length }})

+
+ +
+ {{ xsrf_form_html|safe }} +
+ + + + + + + + + + + {% for t in training_program.managing_contest.tasks %} + {% set is_archived = t.training_day is not none and t.training_day.contest is none %} + + + + + + + + + + + + + + {% endfor %} + +
TaskTraining DayActions
+
+ +
+
+
+
+ + {{ t.name }} + + {{ t.title }} +
+
+
+
+ {% if t.training_day is not none and t.training_day.contest is not none %} + + {{ t.training_day.contest.name }} + + {% elif t.training_day is not none %} + + {{ t.training_day.name or "(archived)" }} + + {% else %} + Not assigned + {% endif %} +
+
+
+ {% if is_archived %} + + + {% endif %} + + +
+
+
+ + +
+ Move selected task: + + + + +
+
+ + {% if not training_program.managing_contest.tasks %} +
+
📋
+

No tasks in this program yet

+

Use the "Add Task" button above to add tasks.

+
+ {% endif %}
-
- {{ xsrf_form_html|safe }} - Add a new task: - - -
- -
- {{ xsrf_form_html|safe }} - Remove selected task from the training program: - - -
- - Move selected task: - - - - - - - - - - - - - - - {% for t in training_program.managing_contest.tasks %} - - - - - - - {% endfor %} - -
NameTitleTraining Day
- - {{ t.name }}{{ t.title }} - {% if t.training_day is not none and t.training_day.contest is not none %} - {{ t.training_day.contest.name }} - {% elif t.training_day is not none %} - {{ t.training_day.name or "(archived)" }} - {% endif %} -
-
+ + + {% endblock core %} From 4777ff765fbad7342f0f57247880e3d96420f7e5 Mon Sep 17 00:00:00 2001 From: Ron Ryvchin Date: Mon, 26 Jan 2026 09:10:06 +0200 Subject: [PATCH 02/24] Rename to detach + style imp --- cms/server/admin/handlers/trainingprogram.py | 14 ++++++------- cms/server/admin/static/aws_tp_styles.css | 4 ++-- .../templates/training_program_tasks.html | 20 +++++++------------ 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/cms/server/admin/handlers/trainingprogram.py b/cms/server/admin/handlers/trainingprogram.py index f0929f4f1b..566342def8 100644 --- a/cms/server/admin/handlers/trainingprogram.py +++ b/cms/server/admin/handlers/trainingprogram.py @@ -431,11 +431,11 @@ def post(self, training_program_id: str): operation: str = self.get_argument("operation") # Handle operations that include task_id in the operation value - # (e.g., "release_123", "remove_123") - if operation.startswith("release_"): + # (e.g., "detach_123", "remove_123") + if operation.startswith("detach_"): task_id = operation.split("_", 1)[1] task = self.safe_get_item(Task, task_id) - self._release_task_from_archived_training_day(task) + self._detach_task_from_archived_training_day(task) if self.try_commit(): self.service.proxy_service.reinitialize() self.redirect(fallback_page) @@ -554,14 +554,14 @@ def post(self, training_program_id: str): self.redirect(fallback_page) - def _release_task_from_archived_training_day(self, task: Task) -> None: - """Release a task from an archived training day. + def _detach_task_from_archived_training_day(self, task: Task) -> None: + """Detach a task from an archived 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 release. + task: the task to detach. """ if task.training_day is None: return @@ -598,7 +598,7 @@ def _remove_task_from_program( # Remove from training day if assigned if task.training_day is not None: - self._release_task_from_archived_training_day(task) + self._detach_task_from_archived_training_day(task) # Remove from training program task.contest = None diff --git a/cms/server/admin/static/aws_tp_styles.css b/cms/server/admin/static/aws_tp_styles.css index 2e4f5276e7..d8bf0ab960 100644 --- a/cms/server/admin/static/aws_tp_styles.css +++ b/cms/server/admin/static/aws_tp_styles.css @@ -369,10 +369,10 @@ .btn-icon-only { background: none; border: none; - padding: 6px; + padding: 8px; cursor: pointer; color: var(--tp-text-light); - border-radius: 4px; + border-radius: 50%; transition: all 0.2s; } diff --git a/cms/server/admin/templates/training_program_tasks.html b/cms/server/admin/templates/training_program_tasks.html index 5efe688191..343fa53a51 100644 --- a/cms/server/admin/templates/training_program_tasks.html +++ b/cms/server/admin/templates/training_program_tasks.html @@ -104,15 +104,14 @@

All
{% if is_archived %} - - {% endif %} @@ -302,12 +301,7 @@

All height: 16px; } -/* Release button styling */ -.btn-release { - color: var(--tp-info, #3b82f6) !important; -} - -.btn-release:hover { +.btn-detach:hover { color: var(--tp-info-hover, #2563eb) !important; background: var(--tp-info-light, #f0f9ff) !important; } From 0c3f657f7ddd67d32c30cca6b8fb2f3aa427f824 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 07:26:34 +0000 Subject: [PATCH 03/24] Add drag-and-drop reordering, consolidate CSS, fix remove behavior, add shared utility Co-Authored-By: Ron Ryvchin --- cms/server/admin/handlers/trainingprogram.py | 248 ++++++---------- cms/server/admin/static/aws_tp_styles.css | 106 +++++++ .../templates/training_program_tasks.html | 267 ++++++++---------- 3 files changed, 313 insertions(+), 308 deletions(-) diff --git a/cms/server/admin/handlers/trainingprogram.py b/cms/server/admin/handlers/trainingprogram.py index 566342def8..495ae22907 100644 --- a/cms/server/admin/handlers/trainingprogram.py +++ b/cms/server/admin/handlers/trainingprogram.py @@ -28,6 +28,8 @@ """ from datetime import datetime as dt +import json +from typing import Optional import tornado.web @@ -55,6 +57,45 @@ 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 TrainingProgramListHandler(SimpleHandler("training_programs.html")): """List all training programs. @@ -402,11 +443,6 @@ def delete(self, training_program_id: str): self.write("../../training_programs") class TrainingProgramTasksHandler(BaseHandler): """Manage tasks in a training program.""" - REMOVE_FROM_PROGRAM = "Remove from training program" - MOVE_UP = "up by 1" - MOVE_DOWN = "down by 1" - MOVE_TOP = "to the top" - MOVE_BOTTOM = "to the bottom" @require_permission(BaseHandler.AUTHENTICATED) def get(self, training_program_id: str): @@ -430,132 +466,61 @@ def post(self, training_program_id: str): try: operation: str = self.get_argument("operation") - # Handle operations that include task_id in the operation value - # (e.g., "detach_123", "remove_123") + # 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) - self._detach_task_from_archived_training_day(task) + self._detach_task_from_training_day(task) if self.try_commit(): self.service.proxy_service.reinitialize() self.redirect(fallback_page) return - if operation.startswith("remove_"): - task_id = operation.split("_", 1)[1] - task = self.safe_get_item(Task, task_id) - - # If the task is in an active training day, redirect to confirmation - if task.training_day is not None and task.training_day.contest is not None: - asking_page = self.url( - "training_program", training_program_id, "task", task_id, "remove" - ) - self.redirect(asking_page) - return - - # For archived training days or no training day, remove directly - self._remove_task_from_program(task, managing_contest) - if self.try_commit(): - self.service.proxy_service.reinitialize() + # 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 - # For move operations, task_id comes from the form field - task_id = self.get_argument("task_id") - assert operation in ( - self.REMOVE_FROM_PROGRAM, - self.MOVE_UP, - self.MOVE_DOWN, - self.MOVE_TOP, - self.MOVE_BOTTOM - ), "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 - task = self.safe_get_item(Task, task_id) - task2 = None - - task_num = task.num - - if operation == self.REMOVE_FROM_PROGRAM: - # If the task is in a training day, redirect to confirmation page - if task.training_day is not None: - asking_page = self.url( - "training_program", training_program_id, "task", task_id, "remove" - ) - self.redirect(asking_page) - return - - task.contest = None - task.num = None - - self.sql_session.flush() - - for t in self.sql_session.query(Task)\ - .filter(Task.contest == managing_contest)\ - .filter(Task.num > task_num)\ - .order_by(Task.num)\ - .all(): - t.num -= 1 - self.sql_session.flush() - - elif operation == self.MOVE_UP: - task2 = self.sql_session.query(Task)\ - .filter(Task.contest == managing_contest)\ - .filter(Task.num == task.num - 1)\ - .first() - - elif operation == self.MOVE_DOWN: - task2 = self.sql_session.query(Task)\ - .filter(Task.contest == managing_contest)\ - .filter(Task.num == task.num + 1)\ - .first() - - elif operation == self.MOVE_TOP: - task.num = None - self.sql_session.flush() - - for t in self.sql_session.query(Task)\ - .filter(Task.contest == managing_contest)\ - .filter(Task.num < task_num)\ - .order_by(Task.num.desc())\ - .all(): - t.num += 1 - self.sql_session.flush() - - task.num = 0 - - elif operation == self.MOVE_BOTTOM: - task.num = None - self.sql_session.flush() + self.redirect(fallback_page) - for t in self.sql_session.query(Task)\ - .filter(Task.contest == managing_contest)\ - .filter(Task.num > task_num)\ - .order_by(Task.num)\ - .all(): - t.num -= 1 - self.sql_session.flush() + def _reorder_tasks(self, contest: Contest, reorder_data: str) -> None: + """Reorder tasks based on drag-and-drop data. - self.sql_session.flush() - task.num = len(managing_contest.tasks) - 1 - - if task2 is not None: - tmp_a, tmp_b = task.num, task2.num - task.num, task2.num = None, None - self.sql_session.flush() - task.num, task2.num = tmp_b, tmp_a + reorder_data: JSON string with list of {task_id, new_num} objects. + """ + try: + order_list = json.loads(reorder_data) + except json.JSONDecodeError: + return - if self.try_commit(): - self.service.proxy_service.reinitialize() + # First, set all task nums to None to avoid unique constraint issues + task_map = {} + for item in order_list: + task = self.safe_get_item(Task, item["task_id"]) + if task.contest == contest: + task_map[item["task_id"]] = item["new_num"] + task.num = None + self.sql_session.flush() - self.redirect(fallback_page) + # Then set the new nums + for task_id, new_num in task_map.items(): + task = self.safe_get_item(Task, task_id) + task.num = new_num + self.sql_session.flush() - def _detach_task_from_archived_training_day(self, task: Task) -> None: - """Detach a task from an archived training day. + 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 @@ -575,45 +540,10 @@ def _detach_task_from_archived_training_day(self, task: Task) -> None: self.sql_session.flush() # Reorder remaining tasks in the training day - for t in self.sql_session.query(Task)\ - .filter(Task.training_day == training_day)\ - .filter(Task.training_day_num > training_day_num)\ - .order_by(Task.training_day_num)\ - .all(): - t.training_day_num -= 1 - self.sql_session.flush() - - def _remove_task_from_program( - self, task: Task, managing_contest: Contest - ) -> None: - """Remove a task from the training program. - - This removes the task from both the training day (if assigned) and - the training program's managing contest. - - task: the task to remove. - managing_contest: the training program's managing contest. - """ - task_num = task.num - - # Remove from training day if assigned - if task.training_day is not None: - self._detach_task_from_archived_training_day(task) - - # Remove from training program - task.contest = None - task.num = None - - self.sql_session.flush() - - # Reorder remaining tasks in the training program - for t in self.sql_session.query(Task)\ - .filter(Task.contest == managing_contest)\ - .filter(Task.num > task_num)\ - .order_by(Task.num)\ - .all(): - t.num -= 1 - self.sql_session.flush() + _shift_task_nums( + self.sql_session, Task.training_day, training_day, + Task.training_day_num, training_day_num, -1 + ) class AddTrainingProgramTaskHandler(BaseHandler): @@ -682,13 +612,10 @@ def delete(self, training_program_id: str, task_id: str): self.sql_session.flush() # Reorder remaining tasks in the training day - for t in self.sql_session.query(Task)\ - .filter(Task.training_day == training_day)\ - .filter(Task.training_day_num > training_day_num)\ - .order_by(Task.training_day_num)\ - .all(): - t.training_day_num -= 1 - self.sql_session.flush() + _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 @@ -697,13 +624,10 @@ def delete(self, training_program_id: str, task_id: str): self.sql_session.flush() # Reorder remaining tasks in the training program - for t in self.sql_session.query(Task)\ - .filter(Task.contest == managing_contest)\ - .filter(Task.num > task_num)\ - .order_by(Task.num)\ - .all(): - t.num -= 1 - self.sql_session.flush() + _shift_task_nums( + self.sql_session, Task.contest, managing_contest, + Task.num, task_num, -1 + ) if self.try_commit(): self.service.proxy_service.reinitialize() diff --git a/cms/server/admin/static/aws_tp_styles.css b/cms/server/admin/static/aws_tp_styles.css index d8bf0ab960..de8762acb1 100644 --- a/cms/server/admin/static/aws_tp_styles.css +++ b/cms/server/admin/static/aws_tp_styles.css @@ -381,9 +381,11 @@ background: var(--tp-danger-light); } +/* Disabled state for both buttons and anchors */ .btn-icon-only:disabled { opacity: 0.5; cursor: not-allowed; + pointer-events: none; } /* ========================================================================== @@ -1449,3 +1451,107 @@ .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); +} + +/* 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; +} diff --git a/cms/server/admin/templates/training_program_tasks.html b/cms/server/admin/templates/training_program_tasks.html index 343fa53a51..daa6f78d78 100644 --- a/cms/server/admin/templates/training_program_tasks.html +++ b/cms/server/admin/templates/training_program_tasks.html @@ -3,10 +3,10 @@ {% block core %}
-
+ @@ -42,43 +42,53 @@

Tasks

-
-

All Tasks ({{ training_program.managing_contest.tasks|length }})

+
+

All Tasks ({{ training_program.managing_contest.tasks|length }})

{{ xsrf_form_html|safe }} +
- +
- + - + - + {% for t in training_program.managing_contest.tasks %} {% set is_archived = t.training_day is not none and t.training_day.contest is none %} - - - @@ -87,7 +97,7 @@

All

@@ -115,15 +125,11 @@

All {% endif %} - +

Task Training DayActionsActions
-
- + data-is-archived="{{ 'true' if is_archived else 'false' }}" + draggable="true"> + +
+
+ + + + + + + +
-
-
- +
+
+ {{ t.name }} - {{ t.title }} + {{ t.title }}
{% if t.training_day is not none and t.training_day.contest is not none %} - + {{ t.training_day.contest.name }} {% elif t.training_day is not none %} @@ -95,7 +105,7 @@

All {{ t.training_day.name or "(archived)" }} {% else %} - Not assigned + Not assigned {% endif %}

- - -
- Move selected task: - - - - -
{% if not training_program.managing_contest.tasks %} -
-
📋
-

No tasks in this program yet

-

Use the "Add Task" button above to add tasks.

+
+

No tasks in this program yet

+

Use the "Add Task" button above to add tasks.

{% endif %}
@@ -200,6 +170,93 @@

All } }); +// Drag and drop reordering +(function() { + var tbody = document.getElementById('tasks-tbody'); + if (!tbody) return; + + var draggedRow = null; + var placeholder = null; + + function createPlaceholder() { + var tr = document.createElement('tr'); + tr.className = 'drag-placeholder'; + tr.innerHTML = ''; + return tr; + } + + tbody.addEventListener('dragstart', function(e) { + if (e.target.tagName !== 'TR') return; + draggedRow = e.target; + 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('tr'); + 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 saveNewOrder() { + var rows = tbody.querySelectorAll('tr[data-task-id]'); + var order = []; + rows.forEach(function(row, index) { + order.push({ + task_id: row.getAttribute('data-task-id'), + new_num: index + }); + }); + + // Submit via form + var form = document.getElementById('tasks-form'); + var reorderInput = document.getElementById('reorder-data'); + reorderInput.value = JSON.stringify(order); + + // Create a hidden operation input + var operationInput = document.createElement('input'); + operationInput.type = 'hidden'; + operationInput.name = 'operation'; + operationInput.value = 'reorder'; + form.appendChild(operationInput); + + form.submit(); + } +})(); + // Table sorting (function() { var table = document.getElementById('tasks-table'); @@ -247,86 +304,4 @@

All }); })(); - - {% endblock core %} From 16a2ea115046c353d2288be1e28898eb62ff46f6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:17:36 +0000 Subject: [PATCH 04/24] Use card-style add task UI and convert remove page to modal Co-Authored-By: Ron Ryvchin --- .../templates/training_program_tasks.html | 118 ++++++++++++------ 1 file changed, 80 insertions(+), 38 deletions(-) diff --git a/cms/server/admin/templates/training_program_tasks.html b/cms/server/admin/templates/training_program_tasks.html index daa6f78d78..569809ff6a 100644 --- a/cms/server/admin/templates/training_program_tasks.html +++ b/cms/server/admin/templates/training_program_tasks.html @@ -12,33 +12,23 @@

Tasks

{% include "fragments/overload_warning.html" %} - -
- -
- -
- - {{ xsrf_form_html|safe }} - - - - -
-
+
@@ -125,11 +115,10 @@

All Tasks ({{ training_program.managing_contest.tasks|length }})

{% endif %} - -

+ + + +{% endmacro %} diff --git a/cms/server/admin/templates/training_program_combined_ranking.html b/cms/server/admin/templates/training_program_combined_ranking.html index 59a66ac985..b6398775c3 100644 --- a/cms/server/admin/templates/training_program_combined_ranking.html +++ b/cms/server/admin/templates/training_program_combined_ranking.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% import 'fragments/info_alert.html' as info_alert %} {% block js_init %} // Initialize read-only filter tagify for training day types (no AJAX save) @@ -532,6 +533,12 @@

Combined Ranking: {{ training_program.name }}

+{{ info_alert.alert( + title="Archived Training Days", + message="This page shows ranking data from archived training days only. For active training day rankings, go to the appropriate training day's ranking page.", + type="info" +) }} +
diff --git a/cms/server/admin/templates/training_program_training_days.html b/cms/server/admin/templates/training_program_training_days.html index 4aa1fd95b0..6e0d3f3488 100644 --- a/cms/server/admin/templates/training_program_training_days.html +++ b/cms/server/admin/templates/training_program_training_days.html @@ -1,4 +1,6 @@ {% extends "base.html" %} +{% import 'fragments/info_alert.html' as info_alert %} +{% import 'fragments/histogram_modal.html' as histogram %} {% block js_init %} {% for td in training_program.training_days %} @@ -15,130 +17,212 @@ invalidMessage: 'Types cannot contain commas.' }); {% endfor %} + +// Store archived task scores for histograms +var archivedTaskScores = {}; +var archivedTaskMaxScores = {}; +{% set archived_training_days = training_program.training_days | rejectattr("contest") | list %} +{% for td in archived_training_days %} +{% if td.archived_tasks_data %} +{% for task_id_str, task_info in td.archived_tasks_data.items() %} +archivedTaskMaxScores['{{ td.id }}_{{ task_id_str }}'] = {{ task_info.get('max_score', 100) }}; +archivedTaskScores['{{ td.id }}_{{ task_id_str }}'] = []; +{% endfor %} +{% endif %} +{% endfor %} + +// Populate scores from archived ranking data +{% for td in archived_training_days %} +{% for ranking in td.archived_student_rankings %} +{% if ranking.task_scores %} +{% for task_id_str, score in ranking.task_scores.items() %} +if (archivedTaskScores['{{ td.id }}_{{ task_id_str }}']) { + archivedTaskScores['{{ td.id }}_{{ task_id_str }}'].push({ + studentId: {{ ranking.student_id }}, + score: {{ score if score is not none else 0 }} + }); +} +{% endfor %} +{% endif %} +{% endfor %} +{% endfor %} + +window.showArchivedTaskHistogram = function(trainingDayId, taskId, taskName) { + var key = trainingDayId + '_' + taskId; + var scores = archivedTaskScores[key] || []; + var maxScore = archivedTaskMaxScores[key] || 100; + openHistogramModal(scores, taskName, 'task', trainingDayId, maxScore); +}; {% endblock js_init %} {% block core %} -
-

Training Days of {{ training_program.name }}

+
+

Training Days: {{ training_program.name }}

+
+ + -

- Add a new training day -

+

Active Training Days

-

Active Training Days

+{{ info_alert.alert( + title="Active Training Days", + message="These training days have an associated contest and can accept student participation. Use the Archive action when a training day is complete.", + type="info" +) }} {{ xsrf_form_html|safe }} - Remove selected training day: - -
+
+ + + + + + + + + + + + + + {% set active_training_days = training_program.training_days | selectattr("contest") | list %} + {% for td in active_training_days %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
NameDescriptionTypesStartTasksActions
+ + + {{ td.contest.name }} + {{ td.contest.description or "-" }} + + + {% if td.contest.start.year <= training_program.managing_contest.stop.year %} + {{ td.contest.start.strftime("%Y-%m-%d %H:%M") }} + {% else %} + - + {% endif %} + + {% set tasks = td.contest.get_tasks() %} + {% if tasks %} +
+ {% for task in tasks %} + {{ task.name }} + {% endfor %} +
+ {% else %} + No tasks + {% endif %} +
+ Archive +
(no active training days)
+
- Move selected training day: - - + {% if active_training_days %} +
+ Selected training day: + + + +
+ {% endif %} + + +

Archived Training Days

+ +{{ info_alert.alert( + title="Archived Training Days", + message="These training days have been archived. Their contest data has been deleted, but attendance and ranking data is preserved. Click on task badges to view score histograms.", + type="info" +) }} - +
+
- - - - - - + + + + + - {% set active_training_days = training_program.training_days | selectattr("contest") | list %} - {% for td in active_training_days %} + {% set archived_training_days = training_program.training_days | rejectattr("contest") | list %} + {% for td in archived_training_days %} - - - - + + {% else %} - + {% endfor %}
NameDescriptionTypesStartActionsNameDescriptionTypesStartTasks
- - {{ td.contest.name }}{{ td.contest.description }} - + {{ td.name or "(no name)" }}{{ td.description or "(no description)" }} + - {% if td.contest.start.year <= training_program.managing_contest.stop.year %} - {{ td.contest.start.strftime("%Y-%m-%d %H:%M") }} + {% if td.start_time %} + {{ td.start_time.strftime("%Y-%m-%d %H:%M") }} {% else %} - - + - {% endif %} - Archive + {% if td.archived_tasks_data %} +
+ {% for task_id_str, task_info in td.archived_tasks_data.items() %} + + {{ task_info.get('name', 'Task ' ~ task_id_str) }} + 📊 + + {% endfor %} +
+ {% else %} + No tasks + {% endif %}
(no active training days)(no archived training days)
- - - - -

Archived Training Days

- - - - - - - - - - - - {% set archived_training_days = training_program.training_days | rejectattr("contest") | list %} - {% for td in archived_training_days %} - - - - - - - {% else %} - - - - {% endfor %} - -
NameDescriptionTypesStart
{{ td.name or "(no name)" }}{{ td.description or "(no description)" }} - - - {% if td.start_time %} - {{ td.start_time.strftime("%Y-%m-%d %H:%M") }} - {% else %} - - - {% endif %} -
(no archived training days)
+
+ +{{ histogram.histogram_styles() }} +{{ histogram.histogram_modal_html() }} +{{ histogram.histogram_scripts() }} + {% endblock core %} From 03af5abf891f752c75e247fffb1bb74a3fe729d7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:59:17 +0000 Subject: [PATCH 07/24] Fix training_days page - keep original styling, add tasks column with histogram support - Reverted to original page styling (not the modern redesign) - Added Tasks column to both active and archived training days tables - Active training days show task badges linking to task pages - Archived training days show task badges with histogram icons - Removed duplicated histogram_modal.html fragment - Use existing histogram CSS classes from aws_tp_styles.css - Histogram modal opens when clicking on archived task badges Co-Authored-By: Ron Ryvchin --- .../templates/fragments/histogram_modal.html | 531 ------------------ .../training_program_training_days.html | 484 +++++++++++----- 2 files changed, 358 insertions(+), 657 deletions(-) delete mode 100644 cms/server/admin/templates/fragments/histogram_modal.html diff --git a/cms/server/admin/templates/fragments/histogram_modal.html b/cms/server/admin/templates/fragments/histogram_modal.html deleted file mode 100644 index b97995b623..0000000000 --- a/cms/server/admin/templates/fragments/histogram_modal.html +++ /dev/null @@ -1,531 +0,0 @@ -{# - Histogram modal component for score distribution visualization. - - This fragment provides: - - Modal HTML structure - - CSS styles for the histogram - - JavaScript functions for rendering histograms - - Usage: - 1. Include this fragment in your template - 2. Call initHistogramModal() on document ready - 3. Call openHistogramModal(scores, title, type, contextId, maxPossibleScore) to show histogram - - Parameters for openHistogramModal: - - scores: Array of {studentId, score} objects - - title: Title for the histogram - - type: 'task' or 'training_day' (affects max score calculation) - - contextId: ID for context (training day ID for filtering) - - maxPossibleScore: Maximum possible score for the histogram -#} - -{% macro histogram_modal_html() %} - -{% endmacro %} - -{% macro histogram_styles() %} - -{% endmacro %} - -{% macro histogram_scripts(student_data_json='{}', enable_tag_filter=false, all_student_tags_json='[]') %} - -{% endmacro %} diff --git a/cms/server/admin/templates/training_program_training_days.html b/cms/server/admin/templates/training_program_training_days.html index 6e0d3f3488..ee2a88a9a1 100644 --- a/cms/server/admin/templates/training_program_training_days.html +++ b/cms/server/admin/templates/training_program_training_days.html @@ -1,6 +1,5 @@ {% extends "base.html" %} {% import 'fragments/info_alert.html' as info_alert %} -{% import 'fragments/histogram_modal.html' as histogram %} {% block js_init %} {% for td in training_program.training_days %} @@ -18,6 +17,10 @@ }); {% endfor %} +// Histogram modal functionality +var histogramModal = null; +var currentHistogramData = null; + // Store archived task scores for histograms var archivedTaskScores = {}; var archivedTaskMaxScores = {}; @@ -47,30 +50,239 @@ {% endfor %} {% endfor %} +function initHistogramModal() { + histogramModal = document.getElementById('histogramModal'); + if (!histogramModal) return; + + // Close modal on backdrop click + histogramModal.addEventListener('click', function(e) { + if (e.target === histogramModal) { + closeHistogramModal(); + } + }); + + // Close modal on Escape key + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && histogramModal.style.display === 'flex') { + closeHistogramModal(); + } + }); +} + +function openHistogramModal(scores, title, type, trainingDayId, maxPossibleScore) { + if (!histogramModal) return; + currentHistogramData = { scores: scores, title: title, type: type, trainingDayId: trainingDayId, maxPossibleScore: (maxPossibleScore === undefined || maxPossibleScore === null) ? 100 : maxPossibleScore }; + document.getElementById('histogramTitle').textContent = title + ' - Score Distribution'; + histogramModal.style.display = 'flex'; + renderHistogram(scores, title, type); +} + +window.closeHistogramModal = function() { + if (histogramModal) { + histogramModal.style.display = 'none'; + } + currentHistogramData = null; +} + +function renderHistogram(scores, title, type) { + var scoreValues = scores.map(function(s) { return s.score; }); + + // Sort scores high to low + scoreValues.sort(function(a, b) { return b - a; }); + + var maxPossibleScore = currentHistogramData ? currentHistogramData.maxPossibleScore : 100; + + // Build histogram buckets dynamically based on max possible score + var buckets = {}; + var bucketLabels = {}; + var bucketOrder = []; + + // Guard against division by zero when maxPossibleScore is 0 + 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 i = 1; i <= 9; i++) { + var upperBound = Math.round(i * bucketSize); + var lowerBound = Math.round((i - 1) * bucketSize); + var key = upperBound.toString(); + buckets[key] = 0; + bucketLabels[key] = '(' + lowerBound + ',' + upperBound + ']'; + bucketOrder.push(key); + } + + 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 bucketKey = Math.round(bucketIndex * bucketSize).toString(); + buckets[bucketKey]++; + } + }); + } + + // Render histogram bars + var histogramBars = document.getElementById('histogramBars'); + 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 = (index / (bucketOrder.length - 1)) * 120; + + barsHtml += '
' + + '
' + + '
' + + '
' + + '
' + bucketLabels[bucketKey] + '
' + + '
' + count + '
' + + '
'; + }); + histogramBars.innerHTML = barsHtml; + + // Calculate median + 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]; + } + } + + // Update stats + document.getElementById('histogramStats').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) : ''); + + // Build text data for copying + 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 parseFloat(b) - 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'; + }); + + document.getElementById('histogramTextData').value = textData; +} + +window.copyHistogramData = function() { + var textArea = document.getElementById('histogramTextData'); + var textToCopy = textArea.value; + var btn = document.querySelector('.copy-btn'); + var originalText = btn.textContent; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(textToCopy).then(function() { + btn.textContent = 'Copied!'; + setTimeout(function() { btn.textContent = originalText; }, 2000); + }).catch(function() { + textArea.select(); + document.execCommand('copy'); + btn.textContent = 'Copied!'; + setTimeout(function() { btn.textContent = originalText; }, 2000); + }); + } else { + textArea.select(); + document.execCommand('copy'); + btn.textContent = 'Copied!'; + setTimeout(function() { btn.textContent = originalText; }, 2000); + } +} + window.showArchivedTaskHistogram = function(trainingDayId, taskId, taskName) { var key = trainingDayId + '_' + taskId; var scores = archivedTaskScores[key] || []; var maxScore = archivedTaskMaxScores[key] || 100; openHistogramModal(scores, taskName, 'task', trainingDayId, maxScore); }; + +// Initialize histogram modal on document ready +$(document).ready(function() { + initHistogramModal(); +}); {% endblock js_init %} {% block core %} -
-

Training Days: {{ training_program.name }}

+
+

Training Days of {{ training_program.name }}

- +

+ Add a new training day +

-

Active Training Days

+

Active Training Days

{{ info_alert.alert( title="Active Training Days", @@ -80,149 +292,169 @@

Active Training Days

{{ xsrf_form_html|safe }} + Remove selected training day: + -
- - - - - - - - - - - - - - {% set active_training_days = training_program.training_days | selectattr("contest") | list %} - {% for td in active_training_days %} - - - - - - - - - - {% else %} - - - - {% endfor %} - -
NameDescriptionTypesStartTasksActions
- - - {{ td.contest.name }} - {{ td.contest.description or "-" }} - - - {% if td.contest.start.year <= training_program.managing_contest.stop.year %} - {{ td.contest.start.strftime("%Y-%m-%d %H:%M") }} - {% else %} - - - {% endif %} - - {% set tasks = td.contest.get_tasks() %} - {% if tasks %} -
- {% for task in tasks %} - {{ task.name }} - {% endfor %} -
- {% else %} - No tasks - {% endif %} -
- Archive -
(no active training days)
-
- - {% if active_training_days %} -
- Selected training day: - - - -
- {% endif %} -
- -

Archived Training Days

+
-{{ info_alert.alert( - title="Archived Training Days", - message="These training days have been archived. Their contest data has been deleted, but attendance and ranking data is preserved. Click on task badges to view score histograms.", - type="info" -) }} + Move selected training day: + + -
- +
- - - - - + + + + + + + - {% set archived_training_days = training_program.training_days | rejectattr("contest") | list %} - {% for td in archived_training_days %} + {% set active_training_days = training_program.training_days | selectattr("contest") | list %} + {% for td in active_training_days %} - - - + + + + {% else %} - + {% endfor %}
NameDescriptionTypesStartTasksNameDescriptionTypesStartTasksActions
{{ td.name or "(no name)" }}{{ td.description or "(no description)" }} - + + + {{ td.contest.name }}{{ td.contest.description }} + - {% if td.start_time %} - {{ td.start_time.strftime("%Y-%m-%d %H:%M") }} + {% if td.contest.start.year <= training_program.managing_contest.stop.year %} + {{ td.contest.start.strftime("%Y-%m-%d %H:%M") }} {% else %} - - + - {% endif %} - {% if td.archived_tasks_data %} -
- {% for task_id_str, task_info in td.archived_tasks_data.items() %} - - {{ task_info.get('name', 'Task ' ~ task_id_str) }} - 📊 - - {% endfor %} -
+ {% set tasks = td.contest.get_tasks() %} + {% if tasks %} + {% for task in tasks %} + {{ task.name }} + {% endfor %} {% else %} - No tasks + No tasks {% endif %}
+ Archive +
(no archived training days)(no active training days)
-
+ + + + +

Archived Training Days

+ +{{ info_alert.alert( + title="Archived Training Days", + message="These training days have been archived. Their contest data has been deleted, but attendance and ranking data is preserved. Click on task badges to view score histograms.", + type="info" +) }} + + + + + + + + + + + + + {% set archived_training_days = training_program.training_days | rejectattr("contest") | list %} + {% for td in archived_training_days %} + + + + + + + + {% else %} + + + + {% endfor %} + +
NameDescriptionTypesStartTasks
{{ td.name or "(no name)" }}{{ td.description or "(no description)" }} + + + {% if td.start_time %} + {{ td.start_time.strftime("%Y-%m-%d %H:%M") }} + {% else %} + - + {% endif %} + + {% if td.archived_tasks_data %} + {% for task_id_str, task_info in td.archived_tasks_data.items() %} + + {{ task_info.get('name', 'Task ' ~ task_id_str) }} + 📊 + + {% endfor %} + {% else %} + No tasks + {% endif %} +
(no archived training days)
-{{ histogram.histogram_styles() }} -{{ histogram.histogram_modal_html() }} -{{ histogram.histogram_scripts() }} + {% endblock core %} From a1d6791b66c2928a9ff73ad970424f61f045c53f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 09:11:18 +0000 Subject: [PATCH 08/24] Modernize training_days page with drag-and-drop, icons, and improved time display Co-Authored-By: Ron Ryvchin --- cms/server/admin/handlers/trainingday.py | 77 +-- cms/server/admin/static/aws_tp_styles.css | 6 + .../training_program_training_days.html | 605 +++++++++++++----- 3 files changed, 466 insertions(+), 222 deletions(-) diff --git a/cms/server/admin/handlers/trainingday.py b/cms/server/admin/handlers/trainingday.py index 1bfe48c62b..dc1f9bb51a 100644 --- a/cms/server/admin/handlers/trainingday.py +++ b/cms/server/admin/handlers/trainingday.py @@ -156,9 +156,7 @@ def validate_group_times_within_contest( class TrainingProgramTrainingDaysHandler(BaseHandler): """List and manage training days in a training program.""" - REMOVE = "Remove" - MOVE_UP = "up by 1" - MOVE_DOWN = "down by 1" + REORDER = "reorder" @require_permission(BaseHandler.AUTHENTICATED) def get(self, training_program_id: str): @@ -176,59 +174,38 @@ def post(self, training_program_id: str): training_program = self.safe_get_item(TrainingProgram, training_program_id) - try: - training_day_id: str = self.get_argument("training_day_id") - operation: str = self.get_argument("operation") - assert operation in ( - self.REMOVE, - self.MOVE_UP, - self.MOVE_DOWN, - ), "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 + operation: str = self.get_argument("operation", "") - training_day = self.safe_get_item(TrainingDay, training_day_id) + if operation == self.REORDER: + import json + try: + reorder_data = self.get_argument("reorder_data", "") + if not reorder_data: + raise ValueError("No reorder data provided") - if training_day.training_program_id != training_program.id: - self.service.add_notification( - make_datetime(), "Invalid training day", "Training day does not belong to this program") - self.redirect(fallback_page) - return + order_list = json.loads(reorder_data) - if operation == self.REMOVE: - asking_page = self.url( - "training_program", training_program_id, - "training_day", training_day_id, "remove" - ) - self.redirect(asking_page) - return - - elif operation == self.MOVE_UP: - training_day2 = self.sql_session.query(TrainingDay)\ - .filter(TrainingDay.training_program == training_program)\ - .filter(TrainingDay.position == training_day.position - 1)\ - .first() + 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} - if training_day2 is not None: - tmp_a, tmp_b = training_day.position, training_day2.position - training_day.position, training_day2.position = None, None + for td in active_training_days: + td.position = None self.sql_session.flush() - training_day.position, training_day2.position = tmp_b, tmp_a - elif operation == self.MOVE_DOWN: - training_day2 = self.sql_session.query(TrainingDay)\ - .filter(TrainingDay.training_program == training_program)\ - .filter(TrainingDay.position == training_day.position + 1)\ - .first() - - if training_day2 is not None: - tmp_a, tmp_b = training_day.position, training_day2.position - training_day.position, training_day2.position = None, None - self.sql_session.flush() - training_day.position, training_day2.position = tmp_b, tmp_a + for item in order_list: + td_id = str(item["training_day_id"]) + new_pos = int(item["new_position"]) + if td_id in td_by_id: + td_by_id[td_id].position = new_pos + + 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) diff --git a/cms/server/admin/static/aws_tp_styles.css b/cms/server/admin/static/aws_tp_styles.css index 75954de0d1..4fe28c690f 100644 --- a/cms/server/admin/static/aws_tp_styles.css +++ b/cms/server/admin/static/aws_tp_styles.css @@ -1556,6 +1556,12 @@ 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 ========================================================================== */ diff --git a/cms/server/admin/templates/training_program_training_days.html b/cms/server/admin/templates/training_program_training_days.html index ee2a88a9a1..203a190a1d 100644 --- a/cms/server/admin/templates/training_program_training_days.html +++ b/cms/server/admin/templates/training_program_training_days.html @@ -274,187 +274,448 @@ {% endblock js_init %} {% block core %} -
-

Training Days of {{ training_program.name }}

-
- -

- Add a new training day -

+
+ +
+

Training Days

+ +
-

Active Training Days

+ +
+ + + Add New Training Day + +
-{{ info_alert.alert( - title="Active Training Days", - message="These training days have an associated contest and can accept student participation. Use the Archive action when a training day is complete.", - type="info" -) }} + +
+

Active Training Days ({{ training_program.training_days | selectattr("contest") | list | length }})

+
-
- {{ xsrf_form_html|safe }} - Remove selected training day: - + {{ info_alert.alert( + title="Active Training Days", + message="These training days have an associated contest and can accept student participation. Drag to reorder. Use the Archive action when a training day is complete.", + type="info" + ) }} + + + {{ xsrf_form_html|safe }} + +
+ + + + + + + + + + + + + + {% set active_training_days = training_program.training_days | selectattr("contest") | list %} + {% for td in active_training_days %} + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
NameDescriptionTypesStartTasksActions
+
+ + + + + + + + +
+
+ + +
+ {{ td.contest.description }} +
+
+
+ +
+
+
+ {% if td.contest.start.year <= training_program.managing_contest.stop.year %} +
{{ td.contest.start.strftime('%Y-%m-%d') }}
+
{{ td.contest.start.strftime('%H:%M') }}
+ {% else %} + · + {% endif %} +
+
+
+ {% set tasks = td.contest.get_tasks() %} + {% if tasks %} + {% for task in tasks %} + {{ task.name }} + {% endfor %} + {% else %} + No tasks + {% endif %} +
+
+ +
+
+
+ + {% if not (training_program.training_days | selectattr("contest") | list) %} +
+

No active training days

+

Use the "Add New Training Day" button above to create one.

+
+ {% endif %} -
+ +
+

Archived Training Days ({{ training_program.training_days | rejectattr("contest") | list | length }})

+
- Move selected training day: - - - - - - - - - - - - - - - - - {% set active_training_days = training_program.training_days | selectattr("contest") | list %} - {% for td in active_training_days %} - - - - - - - - - - {% else %} - - - - {% endfor %} - -
NameDescriptionTypesStartTasksActions
- - {{ td.contest.name }}{{ td.contest.description }} - - - {% if td.contest.start.year <= training_program.managing_contest.stop.year %} - {{ td.contest.start.strftime("%Y-%m-%d %H:%M") }} - {% else %} - - - {% endif %} - - {% set tasks = td.contest.get_tasks() %} - {% if tasks %} - {% for task in tasks %} - {{ task.name }} - {% endfor %} - {% else %} - No tasks - {% endif %} - - Archive -
(no active training days)
- - - - -

Archived Training Days

- -{{ info_alert.alert( - title="Archived Training Days", - message="These training days have been archived. Their contest data has been deleted, but attendance and ranking data is preserved. Click on task badges to view score histograms.", - type="info" -) }} - - - - - - - - - - - - - {% set archived_training_days = training_program.training_days | rejectattr("contest") | list %} - {% for td in archived_training_days %} - - - - - - - - {% else %} - - - - {% endfor %} - -
NameDescriptionTypesStartTasks
{{ td.name or "(no name)" }}{{ td.description or "(no description)" }} - - - {% if td.start_time %} - {{ td.start_time.strftime("%Y-%m-%d %H:%M") }} - {% else %} - - - {% endif %} - - {% if td.archived_tasks_data %} - {% for task_id_str, task_info in td.archived_tasks_data.items() %} - - {{ task_info.get('name', 'Task ' ~ task_id_str) }} - 📊 - - {% endfor %} - {% else %} - No tasks - {% endif %} -
(no archived training days)
+ {{ info_alert.alert( + title="Archived Training Days", + message="These training days have been archived. Their contest data has been deleted, but attendance and ranking data is preserved. Click on task badges to view score histograms.", + type="info" + ) }} + +
+ + + + + + + + + + + + {% set archived_training_days = training_program.training_days | rejectattr("contest") | list %} + {% for td in archived_training_days %} + + + + + + + + + + + + + + + + + {% endfor %} + +
NameDescriptionTypesStartTasks
+
+ {{ td.name or "(no name)" }} +
+
+
+ {{ td.description or "(no description)" }} +
+
+
+ +
+
+
+ {% if td.start_time %} +
{{ td.start_time.strftime('%Y-%m-%d') }}
+
{{ td.start_time.strftime('%H:%M') }}
+ {% else %} + · + {% endif %} +
+
+
+ {% if td.archived_tasks_data %} + {% for task_id_str, task_info in td.archived_tasks_data.items() %} + + {{ task_info.get('name', 'Task ' ~ task_id_str) }} + 📊 + + {% endfor %} + {% else %} + No tasks + {% endif %} +
+
+
- + {% if not (training_program.training_days | rejectattr("contest") | list) %} +
+

No archived training days

+

Training days will appear here after being archived.

+
+ {% endif %} +
+ + + + +
+ + {% if td.scoreboard_sharing %} + + {{ td.scoreboard_sharing | length }} + + {% endif %} +
+ {% endfor %} @@ -541,6 +562,34 @@

Score Distribution

+ + + {% endblock core %} diff --git a/cms/server/contest/handlers/__init__.py b/cms/server/contest/handlers/__init__.py index 8d3a8ef7a7..c6e3b3a5ee 100644 --- a/cms/server/contest/handlers/__init__.py +++ b/cms/server/contest/handlers/__init__.py @@ -59,7 +59,8 @@ DelayRequestHandler from .trainingprogram import \ TrainingProgramOverviewHandler, \ - TrainingDaysHandler + TrainingDaysHandler, \ + ScoreboardDataHandler from .api import \ ApiLoginHandler, \ ApiSubmissionListHandler, \ @@ -118,6 +119,7 @@ (r"/training_overview", TrainingProgramOverviewHandler), (r"/training_days", TrainingDaysHandler), + (r"/training_days/scoreboard/([0-9]+)/([^/]+)", ScoreboardDataHandler), # API (r"/api/login", ApiLoginHandler), diff --git a/cms/server/contest/handlers/trainingprogram.py b/cms/server/contest/handlers/trainingprogram.py index 4725988e2f..8c4b87ea58 100644 --- a/cms/server/contest/handlers/trainingprogram.py +++ b/cms/server/contest/handlers/trainingprogram.py @@ -26,7 +26,7 @@ import tornado.web from sqlalchemy import func -from cms.db import Participation, Student, ArchivedStudentRanking, Submission, Task +from cms.db import Participation, Student, ArchivedStudentRanking, Submission, Task, TrainingDay from cms.grading.scorecache import get_cached_score_entry from cms.server import multi_contest from cms.server.contest.phase_management import compute_actual_phase, compute_effective_times @@ -397,6 +397,20 @@ def _build_past_training_info( "max_score": task_max_score, }) + # Determine eligible scoreboards based on student's tags during training + eligible_scoreboards = [] + scoreboard_sharing = training_day.scoreboard_sharing or {} + if student is not None and scoreboard_sharing: + archived_ranking = archived_rankings_map.get(training_day.id) + if archived_ranking and archived_ranking.student_tags: + student_tags_during_training = set(archived_ranking.student_tags) + for tag in scoreboard_sharing.keys(): + if tag in student_tags_during_training: + eligible_scoreboards.append({ + "tag": tag, + "top_names": scoreboard_sharing[tag].get("top_names", 5), + }) + return { "training_day": training_day, "name": training_day.name, @@ -406,4 +420,157 @@ def _build_past_training_info( "home_score": home_score, "max_score": max_score, "tasks": tasks_info, + "eligible_scoreboards": eligible_scoreboards, } + + +class ScoreboardDataHandler(ContestHandler): + """Handler for fetching scoreboard data for a specific training day and tag. + + Returns JSON data for the scoreboard modal, filtered by tag and with + appropriate anonymization based on the top_names setting. + """ + + @tornado.web.authenticated + @multi_contest + def get(self, training_day_id: str, tag: str): + self.set_header("Content-Type", "application/json") + + participation: Participation = self.current_user + training_program = self.training_program + if training_program is None: + self.set_status(404) + self.write({"error": "Training program not found"}) + return + + # Get the training day + training_day = ( + self.sql_session.query(TrainingDay) + .filter(TrainingDay.id == int(training_day_id)) + .filter(TrainingDay.training_program_id == training_program.id) + .first() + ) + + if training_day is None or training_day.contest is not None: + self.set_status(404) + self.write({"error": "Archived training day not found"}) + return + + # Check if scoreboard is shared for this tag + scoreboard_sharing = training_day.scoreboard_sharing or {} + if tag not in scoreboard_sharing: + self.set_status(403) + self.write({"error": "Scoreboard not shared for this tag"}) + return + + top_names = scoreboard_sharing[tag].get("top_names", 5) + + # Get the current student + student = ( + self.sql_session.query(Student) + .join(Participation, Student.participation_id == Participation.id) + .filter(Participation.contest_id == self.contest.id) + .filter(Participation.user_id == participation.user_id) + .filter(Student.training_program_id == training_program.id) + .first() + ) + + if student is None: + self.set_status(403) + self.write({"error": "Student not found"}) + return + + # Check if student had this tag during training + student_archived_ranking = ( + self.sql_session.query(ArchivedStudentRanking) + .filter(ArchivedStudentRanking.training_day_id == training_day.id) + .filter(ArchivedStudentRanking.student_id == student.id) + .first() + ) + + if student_archived_ranking is None: + self.set_status(403) + self.write({"error": "No ranking data for this student"}) + return + + student_tags_during_training = set(student_archived_ranking.student_tags or []) + if tag not in student_tags_during_training: + self.set_status(403) + self.write({"error": "Not eligible to view this scoreboard"}) + return + + # Get all archived rankings for students with this tag during training + all_rankings = ( + self.sql_session.query(ArchivedStudentRanking) + .filter(ArchivedStudentRanking.training_day_id == training_day.id) + .all() + ) + + # Filter to students who had this tag during training + tag_rankings = [ + r for r in all_rankings + if r.student_tags and tag in r.student_tags + ] + + # Get archived tasks data to filter by tag accessibility + archived_tasks_data = training_day.archived_tasks_data or {} + + # Filter tasks to those accessible to this tag during training + accessible_tasks = {} + for task_id_str, task_data in archived_tasks_data.items(): + task_tags = task_data.get("tags", []) + if not task_tags or tag in task_tags: + accessible_tasks[task_id_str] = task_data + + # Build scoreboard data + scoreboard_entries = [] + for ranking in tag_rankings: + task_scores = ranking.task_scores or {} + total_score = 0.0 + task_score_list = [] + + for task_id_str, task_data in accessible_tasks.items(): + score = task_scores.get(task_id_str, 0.0) + total_score += score + task_score_list.append({ + "task_id": task_id_str, + "score": score, + "max_score": task_data.get("max_score", 100.0), + }) + + scoreboard_entries.append({ + "student_id": ranking.student_id, + "student_name": ranking.student.participation.user.username if ranking.student else "Unknown", + "total_score": total_score, + "task_scores": task_score_list, + "is_current_student": ranking.student_id == student.id, + }) + + # Sort by total score descending + scoreboard_entries.sort(key=lambda x: x["total_score"], reverse=True) + + # Apply anonymization: only top N students show full names + for i, entry in enumerate(scoreboard_entries): + entry["rank"] = i + 1 + if i >= top_names and not entry["is_current_student"]: + entry["student_name"] = f"#{i + 1}" + + # Build tasks list for header + tasks_list = [ + { + "task_id": task_id_str, + "name": task_data.get("name", task_data.get("short_name", f"Task {task_id_str}")), + "max_score": task_data.get("max_score", 100.0), + } + for task_id_str, task_data in accessible_tasks.items() + ] + + self.write({ + "success": True, + "training_day_name": training_day.name, + "tag": tag, + "top_names": top_names, + "tasks": tasks_list, + "scoreboard": scoreboard_entries, + "current_student_id": student.id, + }) diff --git a/cms/server/contest/templates/training_days.html b/cms/server/contest/templates/training_days.html index c87e680f78..63147880a2 100644 --- a/cms/server/contest/templates/training_days.html +++ b/cms/server/contest/templates/training_days.html @@ -114,6 +114,7 @@

{% trans %}Past Trainin {% trans %}Tasks{% endtrans %} {% trans %}Training Score{% endtrans %} {% trans %}Home Score{% endtrans %} + {% trans %}Scoreboard{% endtrans %} @@ -147,6 +148,18 @@

{% trans %}Past Trainin {{ "%.0f"|format(pt.home_score) }} / {{ "%.0f"|format(pt.max_score) }} + + {% if pt.eligible_scoreboards %} + {% for sb in pt.eligible_scoreboards %} + + {% endfor %} + {% else %} + - + {% endif %} + {% endfor %} @@ -259,6 +272,55 @@

{% trans %}Past Trainin 0%, 100% { box-shadow: 0 2px 8px rgba(40, 167, 69, 0.3); } 50% { box-shadow: 0 2px 16px rgba(40, 167, 69, 0.6); } } +.scoreboard-badge { + margin: 2px; + font-size: 11px; + padding: 2px 8px; +} +.scoreboard-table { + font-size: 13px; +} +.scoreboard-table .rank-col { + width: 40px; + text-align: center; +} +.scoreboard-table .name-col { + min-width: 120px; +} +.scoreboard-table .task-col { + width: 60px; + text-align: center; +} +.scoreboard-table .total-col { + width: 70px; + text-align: center; +} +.scoreboard-table .score-full { + background-color: #d4edda; + color: #155724; +} +.scoreboard-table .score-partial { + background-color: #fff3cd; + color: #856404; +} +.scoreboard-table .score-zero { + color: #999; +} +.scoreboard-table .highlight-row { + background-color: #cce5ff !important; + font-weight: bold; +} +.scoreboard-table .highlight-row td { + background-color: #cce5ff !important; +} +.scoreboard-note { + margin-top: 10px; + font-size: 12px; + color: #666; +} +#scoreboard-modal .modal-body { + min-height: 200px; +} + + + {% endblock core %} From afd3d40179baec54a525a81a0dadf5068be97000 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:41:19 +0000 Subject: [PATCH 10/24] Fix: keep db version at 49, add schema migration for scoreboard_sharing Co-Authored-By: Ron Ryvchin --- cms/db/__init__.py | 2 +- cmscontrib/updaters/update_from_1.5.sql | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/cms/db/__init__.py b/cms/db/__init__.py index 9ed6ba1a04..cfcec0792b 100644 --- a/cms/db/__init__.py +++ b/cms/db/__init__.py @@ -89,7 +89,7 @@ # Instantiate or import these objects. -version = 50 +version = 49 engine = create_engine(config.database.url, echo=config.database.debug, pool_timeout=60, pool_recycle=120) diff --git a/cmscontrib/updaters/update_from_1.5.sql b/cmscontrib/updaters/update_from_1.5.sql index 7b1eb4fa89..f375993b77 100644 --- a/cmscontrib/updaters/update_from_1.5.sql +++ b/cmscontrib/updaters/update_from_1.5.sql @@ -763,4 +763,11 @@ ALTER TABLE public.training_days ALTER COLUMN training_day_types DROP DEFAULT; -- Add GIN index on training_day_types for efficient querying CREATE INDEX ix_training_days_training_day_types_gin ON public.training_days USING gin (training_day_types); +-- Add scoreboard_sharing column to training_days for configuring scoreboard sharing with students +-- Format: {"tag1": {"top_names": 5}, "tag2": {"top_names": 10}, ...} +-- - Keys are student tags that the scoreboard is shared with +-- - top_names: number of top students to show full names (others show rank only) +-- Eligibility to view is based on student_tags during the training (from ArchivedStudentRanking) +ALTER TABLE public.training_days ADD COLUMN scoreboard_sharing jsonb; + COMMIT; From d15fcd25f78eb1ff88e94e0987788bc1a0d144f3 Mon Sep 17 00:00:00 2001 From: Ron Ryvchin Date: Mon, 26 Jan 2026 23:12:06 +0200 Subject: [PATCH 11/24] share and fix common histogram logic --- cms/server/admin/handlers/trainingday.py | 4 +- cms/server/admin/static/aws_tp_styles.css | 6 +- .../templates/fragments/histogram_js.html | 353 ++++++++++++++++ .../templates/fragments/histogram_modal.html | 23 ++ .../training_program_combined_ranking.html | 381 +----------------- .../training_program_training_days.html | 379 +++++------------ 6 files changed, 491 insertions(+), 655 deletions(-) create mode 100644 cms/server/admin/templates/fragments/histogram_js.html create mode 100644 cms/server/admin/templates/fragments/histogram_modal.html diff --git a/cms/server/admin/handlers/trainingday.py b/cms/server/admin/handlers/trainingday.py index cbf5081f4b..fd0f3124cb 100644 --- a/cms/server/admin/handlers/trainingday.py +++ b/cms/server/admin/handlers/trainingday.py @@ -200,10 +200,12 @@ def post(self, training_program_id: str): new_pos = int(item["new_position"]) if td_id in td_by_id: 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)) + make_datetime(), "Reorder failed", repr(error) + ) self.redirect(fallback_page) return diff --git a/cms/server/admin/static/aws_tp_styles.css b/cms/server/admin/static/aws_tp_styles.css index ffe4612433..d8ae9eebd6 100644 --- a/cms/server/admin/static/aws_tp_styles.css +++ b/cms/server/admin/static/aws_tp_styles.css @@ -371,7 +371,7 @@ border: none; padding: 8px; cursor: pointer; - color: var(--tp-text-light); + color: var(--tp-text-light) !important; border-radius: 50%; transition: all 0.2s; } @@ -893,7 +893,7 @@ /* Buttons */ .tp-btn-primary { background-color: var(--tp-primary); - color: white; + color: white !important; border: none; padding: 6px 16px; border-radius: 6px; @@ -940,7 +940,7 @@ gap: 8px; padding: 10px 20px; background: var(--tp-primary); - color: white; + color: white !important; border: none; border-radius: 8px; font-size: 0.95rem; diff --git a/cms/server/admin/templates/fragments/histogram_js.html b/cms/server/admin/templates/fragments/histogram_js.html new file mode 100644 index 0000000000..a761929276 --- /dev/null +++ b/cms/server/admin/templates/fragments/histogram_js.html @@ -0,0 +1,353 @@ +// Histogram modal functionality +var histogramModal = null; +var histogramTagify = null; +var currentHistogramData = null; + +function initHistogramModal() { + histogramModal = document.getElementById('histogramModal'); + if (!histogramModal) return; + + // Initialize tagify for histogram filter + var histogramTagsInput = document.getElementById('histogramTagsFilter'); + if (histogramTagsInput && typeof Tagify !== 'undefined') { + histogramTagify = new Tagify(histogramTagsInput, { + delimiters: ",", + whitelist: allStudentTags, + enforceWhitelist: true, + editTags: false, + dropdown: { enabled: 0, maxItems: 20, closeOnSelect: true }, + originalInputValueFormat: function(valuesArr) { + return valuesArr.map(function(item) { return item.value; }).join(', '); + } + }); + histogramTagify.on('change', function() { + if (currentHistogramData) { + renderHistogram(currentHistogramData.scores, currentHistogramData.title, currentHistogramData.type); + } + }); + } + + // Close modal on backdrop click + histogramModal.addEventListener('click', function(e) { + if (e.target === histogramModal) { + closeHistogramModal(); + } + }); + + // Close modal on Escape key + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && histogramModal.style.display === 'flex') { + closeHistogramModal(); + } + }); +} + +function openHistogramModal(scores, title, type, trainingDayId, maxPossibleScore) { + if (!histogramModal) return; + currentHistogramData = { scores: scores, title: title, type: type, trainingDayId: trainingDayId, maxPossibleScore: (maxPossibleScore === undefined || maxPossibleScore === null) ? 100 : maxPossibleScore }; + document.getElementById('histogramTitle').textContent = title + ' - Score Distribution'; + + // Update the whitelist to only show tags that existed in this training day + if (histogramTagify && trainingDayId && tagsPerTrainingDay[trainingDayId]) { + histogramTagify.settings.whitelist = tagsPerTrainingDay[trainingDayId]; + histogramTagify.removeAllTags(); + } else if (histogramTagify) { + histogramTagify.settings.whitelist = allStudentTags; + histogramTagify.removeAllTags(); + } + + histogramModal.style.display = 'flex'; + renderHistogram(scores, title, type); +} + +window.closeHistogramModal = function() { + if (histogramModal) { + histogramModal.style.display = 'none'; + } + currentHistogramData = null; +} + +function getFilteredScores(scores) { + var filterTags = []; + if (histogramTagify) { + var tagifyValue = histogramTagify.value; + if (tagifyValue && tagifyValue.length > 0) { + filterTags = tagifyValue.map(function(t) { return t.value; }); + } + } + + if (filterTags.length === 0) { + return scores; + } + + // Get the training day ID from current histogram data + var trainingDayId = currentHistogramData ? currentHistogramData.trainingDayId : null; + + // Filter scores by student tags (use historical tags if available) + return scores.filter(function(item) { + var studentTags = []; + + // Try to get historical tags for this training day + if (trainingDayId && historicalStudentTags[trainingDayId] && historicalStudentTags[trainingDayId][item.studentId]) { + studentTags = historicalStudentTags[trainingDayId][item.studentId]; + } else { + // Fall back to current tags + var studentInfo = 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; + }); + }); +} + +function calculateFilteredMaxScore(filteredScores, trainingDayId, type) { + // For task histograms, the max score is fixed (the task's max score) + if (type === 'task') { + return currentHistogramData ? currentHistogramData.maxPossibleScore : 100; + } + + // For training day histograms, calculate max based on accessible tasks + if (type === 'training_day' && trainingDayId && trainingDayTasks[trainingDayId]) { + // Get the union of all accessible tasks across filtered students + var accessibleTasksSet = new Set(); + filteredScores.forEach(function(item) { + var studentTasks = studentAccessibleTasks[trainingDayId] && studentAccessibleTasks[trainingDayId][item.studentId]; + if (studentTasks) { + studentTasks.forEach(function(taskId) { + accessibleTasksSet.add(taskId); + }); + } + }); + + // Sum the max scores of accessible tasks + var maxScore = 0; + accessibleTasksSet.forEach(function(taskId) { + var taskMaxScore = 0; + if (trainingDayId && typeof taskMaxScoresByTrainingDay !== 'undefined' && + taskMaxScoresByTrainingDay[trainingDayId]) { + taskMaxScore = taskMaxScoresByTrainingDay[trainingDayId][taskId] || 0; + } else if (typeof taskMaxScores !== 'undefined') { + taskMaxScore = taskMaxScores[taskId] || 0; + } + maxScore += taskMaxScore; + }); + + return maxScore > 0 ? maxScore : (currentHistogramData ? currentHistogramData.maxPossibleScore : 100); + } + + return currentHistogramData ? currentHistogramData.maxPossibleScore : 100; +} + +function renderHistogram(scores, title, type) { + var filteredScores = getFilteredScores(scores); + var scoreValues = filteredScores.map(function(s) { return s.score; }); + + // Sort scores high to low + scoreValues.sort(function(a, b) { return b - a; }); + + // Calculate max possible score dynamically based on filtered students' accessible tasks + var trainingDayId = currentHistogramData ? currentHistogramData.trainingDayId : null; + var maxPossibleScore = calculateFilteredMaxScore(filteredScores, trainingDayId, type); + + // Build histogram buckets dynamically based on max possible score + var buckets = {}; + var bucketLabels = {}; + var bucketOrder = []; + + // Guard against division by zero when maxPossibleScore is 0 + if (maxPossibleScore === 0) { + maxPossibleScore = 1; // Avoid division by zero + } + + if (maxPossibleScore <= 15) { + var maxInt = Math.ceil(maxPossibleScore); + + // Create a bucket for every integer from 0 to max + for (var i = 0; i <= maxInt; i++) { + var key = i.toString(); + buckets[key] = 0; + bucketLabels[key] = key; // Label is just "1", "2", etc. + bucketOrder.push(key); + } + + // Fill buckets + scoreValues.forEach(function(score) { + // Round score to nearest integer to fit in bucket + // (e.g., 4.9 becomes 5, 4.1 becomes 4) + var rounded = Math.round(score); + + // Safety clamp: if score exceeds max (e.g. bonus points), put in last bucket + if (rounded > maxInt) rounded = maxInt; + if (rounded < 0) rounded = 0; + + buckets[rounded.toString()]++; + }); + } else { + // Calculate bucket size: divide max score into 10 equal parts + // For max=100: buckets are 0, (0,10], (10,20], ..., (80,90], >90 + // For max=200: buckets are 0, (0,20], (20,40], ..., (160,180], >180 + var bucketSize = maxPossibleScore / 10; + var lastBucketThreshold = maxPossibleScore * 0.9; // 90% of max + // First bucket: exactly 0 + buckets['0'] = 0; + bucketLabels['0'] = '0'; + bucketOrder.push('0'); + + // Middle buckets: (0, bucketSize], (bucketSize, 2*bucketSize], ..., up to 90% of max + for (var i = 1; i <= 9; i++) { + var upperBound = Math.round(i * bucketSize); + var lowerBound = Math.round((i - 1) * bucketSize); + var key = upperBound.toString(); + buckets[key] = 0; + bucketLabels[key] = '(' + lowerBound + ',' + upperBound + ']'; + bucketOrder.push(key); + } + + // Last bucket: >90% of max + 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 { + // For (0,bucketSize], (bucketSize,2*bucketSize], etc. - use ceiling to get the bucket + var bucketIndex = Math.ceil(score / bucketSize); + if (bucketIndex < 1) bucketIndex = 1; + if (bucketIndex > 9) bucketIndex = 9; + var bucketKey = Math.round(bucketIndex * bucketSize).toString(); + buckets[bucketKey]++; + } + }); + } + + // Render histogram bars + var histogramBars = document.getElementById('histogramBars'); + 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; + // Color based on bucket position (0=red, max=green) - use index for consistent coloring + var hue = (index / (bucketOrder.length - 1)) * 120; + + barsHtml += '
' + + '
' + + '
' + + '
' + + '
' + bucketLabels[bucketKey] + '
' + + '
' + count + '
' + + '
'; + }); + histogramBars.innerHTML = barsHtml; + + // Calculate median + 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]; + } + } + + // Update stats + document.getElementById('histogramStats').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) : ''); + + // Build text data for copying + 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'; + + // Group by score value + var scoreGroups = {}; + scoreValues.forEach(function(score) { + var roundedScore = score.toFixed(1); + scoreGroups[roundedScore] = (scoreGroups[roundedScore] || 0) + 1; + }); + + // Sort by score descending + var sortedScoreKeys = Object.keys(scoreGroups).sort(function(a, b) { return parseFloat(b) - 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'; + // Reverse order for text (high to low) + 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'; + }); + + document.getElementById('histogramTextData').value = textData; +} + +window.copyHistogramData = function() { + var textArea = document.getElementById('histogramTextData'); + var textToCopy = textArea.value; + var btn = document.querySelector('.copy-btn'); + var originalText = btn.textContent; + + // Use modern Clipboard API with fallback for older browsers + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(textToCopy).then(function() { + btn.textContent = 'Copied!'; + setTimeout(function() { + btn.textContent = originalText; + }, 2000); + }).catch(function() { + // Fallback to deprecated execCommand if Clipboard API fails + textArea.select(); + document.execCommand('copy'); + btn.textContent = 'Copied!'; + setTimeout(function() { + btn.textContent = originalText; + }, 2000); + }); + } else { + // Fallback for browsers without Clipboard API + textArea.select(); + document.execCommand('copy'); + btn.textContent = 'Copied!'; + setTimeout(function() { + btn.textContent = originalText; + }, 2000); + } +} + +$(document).ready(function() { + initHistogramModal(); +}); diff --git a/cms/server/admin/templates/fragments/histogram_modal.html b/cms/server/admin/templates/fragments/histogram_modal.html new file mode 100644 index 0000000000..a3e9085fb4 --- /dev/null +++ b/cms/server/admin/templates/fragments/histogram_modal.html @@ -0,0 +1,23 @@ + diff --git a/cms/server/admin/templates/training_program_combined_ranking.html b/cms/server/admin/templates/training_program_combined_ranking.html index b6398775c3..8bc88396d7 100644 --- a/cms/server/admin/templates/training_program_combined_ranking.html +++ b/cms/server/admin/templates/training_program_combined_ranking.html @@ -32,10 +32,7 @@ }); } -// Histogram modal functionality -var histogramModal = null; -var histogramTagify = null; -var currentHistogramData = null; +// Histogram modal data (shared with training days) var allStudentTags = {{ all_student_tags | default([]) | tojson }}; // Store student data for histogram filtering (current tags for reference) @@ -72,16 +69,17 @@ }); // Store max possible scores for tasks and training days -var taskMaxScores = {}; +var taskMaxScoresByTrainingDay = {}; var trainingDayMaxScores = {}; // Store which tasks belong to which training day var trainingDayTasks = {}; {% for td in archived_training_days %} {% if td.archived_tasks_data %} +taskMaxScoresByTrainingDay[{{ td.id }}] = {}; trainingDayMaxScores[{{ td.id }}] = 0; trainingDayTasks[{{ td.id }}] = []; {% for task_id_str, task_info in td.archived_tasks_data.items() %} -taskMaxScores[{{ task_id_str }}] = {{ task_info.get('max_score', 100) }}; +taskMaxScoresByTrainingDay[{{ td.id }}][{{ task_id_str }}] = {{ task_info.get('max_score', 100) }}; trainingDayMaxScores[{{ td.id }}] += {{ task_info.get('max_score', 100) }}; trainingDayTasks[{{ td.id }}].push({{ task_id_str }}); {% endfor %} @@ -105,343 +103,7 @@ {% endfor %} {% endfor %} -function initHistogramModal() { - histogramModal = document.getElementById('histogramModal'); - if (!histogramModal) return; - - // Initialize tagify for histogram filter - var histogramTagsInput = document.getElementById('histogramTagsFilter'); - if (histogramTagsInput && typeof Tagify !== 'undefined') { - histogramTagify = new Tagify(histogramTagsInput, { - delimiters: ",", - whitelist: allStudentTags, - enforceWhitelist: true, - editTags: false, - dropdown: { enabled: 0, maxItems: 20, closeOnSelect: true }, - originalInputValueFormat: function(valuesArr) { - return valuesArr.map(function(item) { return item.value; }).join(', '); - } - }); - histogramTagify.on('change', function() { - if (currentHistogramData) { - renderHistogram(currentHistogramData.scores, currentHistogramData.title, currentHistogramData.type); - } - }); - } - - // Close modal on backdrop click - histogramModal.addEventListener('click', function(e) { - if (e.target === histogramModal) { - closeHistogramModal(); - } - }); - - // Close modal on Escape key - document.addEventListener('keydown', function(e) { - if (e.key === 'Escape' && histogramModal.style.display === 'flex') { - closeHistogramModal(); - } - }); -} - -function openHistogramModal(scores, title, type, trainingDayId, maxPossibleScore) { - if (!histogramModal) return; - currentHistogramData = { scores: scores, title: title, type: type, trainingDayId: trainingDayId, maxPossibleScore: (maxPossibleScore === undefined || maxPossibleScore === null) ? 100 : maxPossibleScore }; - document.getElementById('histogramTitle').textContent = title + ' - Score Distribution'; - - // Update the whitelist to only show tags that existed in this training day - if (histogramTagify && trainingDayId && tagsPerTrainingDay[trainingDayId]) { - histogramTagify.settings.whitelist = tagsPerTrainingDay[trainingDayId]; - histogramTagify.removeAllTags(); - } else if (histogramTagify) { - histogramTagify.settings.whitelist = allStudentTags; - histogramTagify.removeAllTags(); - } - - histogramModal.style.display = 'flex'; - renderHistogram(scores, title, type); -} - -window.closeHistogramModal = function() { - if (histogramModal) { - histogramModal.style.display = 'none'; - } - currentHistogramData = null; -} - -function getFilteredScores(scores) { - var filterTags = []; - if (histogramTagify) { - var tagifyValue = histogramTagify.value; - if (tagifyValue && tagifyValue.length > 0) { - filterTags = tagifyValue.map(function(t) { return t.value; }); - } - } - - if (filterTags.length === 0) { - return scores; - } - - // Get the training day ID from current histogram data - var trainingDayId = currentHistogramData ? currentHistogramData.trainingDayId : null; - - // Filter scores by student tags (use historical tags if available) - return scores.filter(function(item) { - var studentTags = []; - - // Try to get historical tags for this training day - if (trainingDayId && historicalStudentTags[trainingDayId] && historicalStudentTags[trainingDayId][item.studentId]) { - studentTags = historicalStudentTags[trainingDayId][item.studentId]; - } else { - // Fall back to current tags - var studentInfo = 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; - }); - }); -} - -function calculateFilteredMaxScore(filteredScores, trainingDayId, type) { - // For task histograms, the max score is fixed (the task's max score) - if (type === 'task') { - return currentHistogramData ? currentHistogramData.maxPossibleScore : 100; - } - - // For training day histograms, calculate max based on accessible tasks - if (type === 'training_day' && trainingDayId && trainingDayTasks[trainingDayId]) { - // Get the union of all accessible tasks across filtered students - var accessibleTasksSet = new Set(); - filteredScores.forEach(function(item) { - var studentTasks = studentAccessibleTasks[trainingDayId] && studentAccessibleTasks[trainingDayId][item.studentId]; - if (studentTasks) { - studentTasks.forEach(function(taskId) { - accessibleTasksSet.add(taskId); - }); - } - }); - - // Sum the max scores of accessible tasks - var maxScore = 0; - accessibleTasksSet.forEach(function(taskId) { - maxScore += taskMaxScores[taskId] || 0; - }); - - return maxScore > 0 ? maxScore : (currentHistogramData ? currentHistogramData.maxPossibleScore : 100); - } - - return currentHistogramData ? currentHistogramData.maxPossibleScore : 100; -} - -function renderHistogram(scores, title, type) { - var filteredScores = getFilteredScores(scores); - var scoreValues = filteredScores.map(function(s) { return s.score; }); - - // Sort scores high to low - scoreValues.sort(function(a, b) { return b - a; }); - - // Calculate max possible score dynamically based on filtered students' accessible tasks - var trainingDayId = currentHistogramData ? currentHistogramData.trainingDayId : null; - var maxPossibleScore = calculateFilteredMaxScore(filteredScores, trainingDayId, type); - - // Build histogram buckets dynamically based on max possible score - var buckets = {}; - var bucketLabels = {}; - var bucketOrder = []; - - // Guard against division by zero when maxPossibleScore is 0 - if (maxPossibleScore === 0) { - maxPossibleScore = 1; // Avoid division by zero - } - - if (maxPossibleScore <= 15) { - var maxInt = Math.ceil(maxPossibleScore); - - // Create a bucket for every integer from 0 to max - for (var i = 0; i <= maxInt; i++) { - var key = i.toString(); - buckets[key] = 0; - bucketLabels[key] = key; // Label is just "1", "2", etc. - bucketOrder.push(key); - } - - // Fill buckets - scoreValues.forEach(function(score) { - // Round score to nearest integer to fit in bucket - // (e.g., 4.9 becomes 5, 4.1 becomes 4) - var rounded = Math.round(score); - - // Safety clamp: if score exceeds max (e.g. bonus points), put in last bucket - if (rounded > maxInt) rounded = maxInt; - if (rounded < 0) rounded = 0; - - buckets[rounded.toString()]++; - }); - } else { - // Calculate bucket size: divide max score into 10 equal parts - // For max=100: buckets are 0, (0,10], (10,20], ..., (80,90], >90 - // For max=200: buckets are 0, (0,20], (20,40], ..., (160,180], >180 - var bucketSize = maxPossibleScore / 10; - var lastBucketThreshold = maxPossibleScore * 0.9; // 90% of max - // First bucket: exactly 0 - buckets['0'] = 0; - bucketLabels['0'] = '0'; - bucketOrder.push('0'); - - // Middle buckets: (0, bucketSize], (bucketSize, 2*bucketSize], ..., up to 90% of max - for (var i = 1; i <= 9; i++) { - var upperBound = Math.round(i * bucketSize); - var lowerBound = Math.round((i - 1) * bucketSize); - var key = upperBound.toString(); - buckets[key] = 0; - bucketLabels[key] = '(' + lowerBound + ',' + upperBound + ']'; - bucketOrder.push(key); - } - - // Last bucket: >90% of max - 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 { - // For (0,bucketSize], (bucketSize,2*bucketSize], etc. - use ceiling to get the bucket - var bucketIndex = Math.ceil(score / bucketSize); - if (bucketIndex < 1) bucketIndex = 1; - if (bucketIndex > 9) bucketIndex = 9; - var bucketKey = Math.round(bucketIndex * bucketSize).toString(); - buckets[bucketKey]++; - } - }); - } - - // Render histogram bars - var histogramBars = document.getElementById('histogramBars'); - 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; - // Color based on bucket position (0=red, max=green) - use index for consistent coloring - var hue = (index / (bucketOrder.length - 1)) * 120; - - barsHtml += '
' + - '
' + - '
' + - '
' + - '
' + bucketLabels[bucketKey] + '
' + - '
' + count + '
' + - '
'; - }); - histogramBars.innerHTML = barsHtml; - - // Calculate median - 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]; - } - } - - // Update stats - document.getElementById('histogramStats').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) : ''); - - // Build text data for copying - 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'; - - // Group by score value - var scoreGroups = {}; - scoreValues.forEach(function(score) { - var roundedScore = score.toFixed(1); - scoreGroups[roundedScore] = (scoreGroups[roundedScore] || 0) + 1; - }); - - // Sort by score descending - var sortedScoreKeys = Object.keys(scoreGroups).sort(function(a, b) { return parseFloat(b) - 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'; - // Reverse order for text (high to low) - 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'; - }); - - document.getElementById('histogramTextData').value = textData; -} - -window.copyHistogramData = function() { - var textArea = document.getElementById('histogramTextData'); - var textToCopy = textArea.value; - var btn = document.querySelector('.copy-btn'); - var originalText = btn.textContent; - - // Use modern Clipboard API with fallback for older browsers - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(textToCopy).then(function() { - btn.textContent = 'Copied!'; - setTimeout(function() { - btn.textContent = originalText; - }, 2000); - }).catch(function() { - // Fallback to deprecated execCommand if Clipboard API fails - textArea.select(); - document.execCommand('copy'); - btn.textContent = 'Copied!'; - setTimeout(function() { - btn.textContent = originalText; - }, 2000); - }); - } else { - // Fallback for browsers without Clipboard API - textArea.select(); - document.execCommand('copy'); - btn.textContent = 'Copied!'; - setTimeout(function() { - btn.textContent = originalText; - }, 2000); - } -} +{% include 'fragments/histogram_js.html' %} window.showTaskHistogram = function(taskId, taskName, trainingDayId) { var scores = []; @@ -449,7 +111,11 @@ if (!table) return; // Get max possible score for this task - var maxPossibleScore = (taskMaxScores[taskId] === undefined) ? 100 : taskMaxScores[taskId]; + var maxPossibleScore = 100; + if (taskMaxScoresByTrainingDay[trainingDayId] && + taskMaxScoresByTrainingDay[trainingDayId][taskId] !== undefined) { + maxPossibleScore = taskMaxScoresByTrainingDay[trainingDayId][taskId]; + } // Collect scores from the table - only include students who had access to the task var rows = table.querySelectorAll('tbody tr'); @@ -503,9 +169,6 @@ openHistogramModal(scores, trainingDayName, 'training_day', trainingDayId, maxPossibleScore); } -$(document).ready(function() { - initHistogramModal(); -}); {% endblock js_init %} {% block core %} @@ -764,28 +427,6 @@

No Data Available

{% endif %} - +{% include 'fragments/histogram_modal.html' %} {% endblock core %} diff --git a/cms/server/admin/templates/training_program_training_days.html b/cms/server/admin/templates/training_program_training_days.html index 2c75cf3f58..d8e27818eb 100644 --- a/cms/server/admin/templates/training_program_training_days.html +++ b/cms/server/admin/templates/training_program_training_days.html @@ -17,260 +17,94 @@ }); {% endfor %} -// Histogram modal functionality -var histogramModal = null; -var currentHistogramData = null; - -// Store archived task scores for histograms +// Histogram modal data (shared with combined ranking) +var allStudentTagsSet = new Set(); +var allStudentTags = []; +var studentData = {}; +var historicalStudentTags = {}; +var tagsPerTrainingDay = {}; +var taskMaxScoresByTrainingDay = {}; +var trainingDayMaxScores = {}; +var trainingDayTasks = {}; +var studentAccessibleTasks = {}; var archivedTaskScores = {}; -var archivedTaskMaxScores = {}; +var participatedStudentsByTrainingDay = {}; + {% set archived_training_days = training_program.training_days | rejectattr("contest") | list %} {% for td in archived_training_days %} +participatedStudentsByTrainingDay[{{ td.id }}] = new Set(); +{% for attendance in td.archived_attendances %} +{% if attendance.status == 'participated' %} +participatedStudentsByTrainingDay[{{ td.id }}].add({{ attendance.student_id }}); +{% endif %} +{% endfor %} {% if td.archived_tasks_data %} +taskMaxScoresByTrainingDay[{{ td.id }}] = {}; +trainingDayMaxScores[{{ td.id }}] = 0; +trainingDayTasks[{{ td.id }}] = []; {% for task_id_str, task_info in td.archived_tasks_data.items() %} -archivedTaskMaxScores['{{ td.id }}_{{ task_id_str }}'] = {{ task_info.get('max_score', 100) }}; +taskMaxScoresByTrainingDay[{{ td.id }}][{{ task_id_str }}] = {{ task_info.get('max_score', 100) }}; +trainingDayMaxScores[{{ td.id }}] += {{ task_info.get('max_score', 100) }}; +trainingDayTasks[{{ td.id }}].push({{ task_id_str }}); archivedTaskScores['{{ td.id }}_{{ task_id_str }}'] = []; {% endfor %} {% endif %} -{% endfor %} - -// Populate scores from archived ranking data -{% for td in archived_training_days %} {% for ranking in td.archived_student_rankings %} -{% if ranking.task_scores %} -{% for task_id_str, score in ranking.task_scores.items() %} -if (archivedTaskScores['{{ td.id }}_{{ task_id_str }}']) { - archivedTaskScores['{{ td.id }}_{{ task_id_str }}'].push({ - studentId: {{ ranking.student_id }}, - score: {{ score if score is not none else 0 }} - }); +if (!historicalStudentTags[{{ td.id }}]) { + historicalStudentTags[{{ td.id }}] = {}; + tagsPerTrainingDay[{{ td.id }}] = new Set(); } -{% endfor %} +historicalStudentTags[{{ td.id }}][{{ ranking.student_id }}] = {{ ranking.student_tags | tojson if ranking.student_tags else '[]' }}; +{% if ranking.student_tags %} +{{ ranking.student_tags | tojson }}.forEach(function(tag) { + tagsPerTrainingDay[{{ td.id }}].add(tag); + allStudentTagsSet.add(tag); +}); {% endif %} -{% endfor %} -{% endfor %} - -function initHistogramModal() { - histogramModal = document.getElementById('histogramModal'); - if (!histogramModal) return; - - // Close modal on backdrop click - histogramModal.addEventListener('click', function(e) { - if (e.target === histogramModal) { - closeHistogramModal(); - } - }); - // Close modal on Escape key - document.addEventListener('keydown', function(e) { - if (e.key === 'Escape' && histogramModal.style.display === 'flex') { - closeHistogramModal(); - } - }); -} - -function openHistogramModal(scores, title, type, trainingDayId, maxPossibleScore) { - if (!histogramModal) return; - currentHistogramData = { scores: scores, title: title, type: type, trainingDayId: trainingDayId, maxPossibleScore: (maxPossibleScore === undefined || maxPossibleScore === null) ? 100 : maxPossibleScore }; - document.getElementById('histogramTitle').textContent = title + ' - Score Distribution'; - histogramModal.style.display = 'flex'; - renderHistogram(scores, title, type); -} +studentData[{{ ranking.student_id }}] = { + tags: {{ ranking.student_tags | tojson if ranking.student_tags else '[]' }}, + name: "(Archived)" +}; -window.closeHistogramModal = function() { - if (histogramModal) { - histogramModal.style.display = 'none'; - } - currentHistogramData = null; +if (!studentAccessibleTasks[{{ td.id }}]) { + studentAccessibleTasks[{{ td.id }}] = {}; } - -function renderHistogram(scores, title, type) { - var scoreValues = scores.map(function(s) { return s.score; }); - - // Sort scores high to low - scoreValues.sort(function(a, b) { return b - a; }); - - var maxPossibleScore = currentHistogramData ? currentHistogramData.maxPossibleScore : 100; - - // Build histogram buckets dynamically based on max possible score - var buckets = {}; - var bucketLabels = {}; - var bucketOrder = []; - - // Guard against division by zero when maxPossibleScore is 0 - 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 i = 1; i <= 9; i++) { - var upperBound = Math.round(i * bucketSize); - var lowerBound = Math.round((i - 1) * bucketSize); - var key = upperBound.toString(); - buckets[key] = 0; - bucketLabels[key] = '(' + lowerBound + ',' + upperBound + ']'; - bucketOrder.push(key); - } - - 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 bucketKey = Math.round(bucketIndex * bucketSize).toString(); - buckets[bucketKey]++; - } +studentAccessibleTasks[{{ td.id }}][{{ ranking.student_id }}] = []; +{% if ranking.task_scores %} +var participated = participatedStudentsByTrainingDay[{{ td.id }}] && participatedStudentsByTrainingDay[{{ td.id }}].has({{ ranking.student_id }}); +{% for task_id_str, score in ranking.task_scores.items() %} +if (participated) { + studentAccessibleTasks[{{ td.id }}][{{ ranking.student_id }}].push({{ task_id_str }}); + if (archivedTaskScores['{{ td.id }}_{{ task_id_str }}']) { + archivedTaskScores['{{ td.id }}_{{ task_id_str }}'].push({ + studentId: {{ ranking.student_id }}, + score: {{ score if score is not none else 0 }} }); } - - // Render histogram bars - var histogramBars = document.getElementById('histogramBars'); - 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 = (index / (bucketOrder.length - 1)) * 120; - - barsHtml += '
' + - '
' + - '
' + - '
' + - '
' + bucketLabels[bucketKey] + '
' + - '
' + count + '
' + - '
'; - }); - histogramBars.innerHTML = barsHtml; - - // Calculate median - 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]; - } - } - - // Update stats - document.getElementById('histogramStats').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) : ''); - - // Build text data for copying - 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 parseFloat(b) - 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'; - }); - - document.getElementById('histogramTextData').value = textData; } +{% endfor %} +{% endif %} +{% endfor %} +{% endfor %} -window.copyHistogramData = function() { - var textArea = document.getElementById('histogramTextData'); - var textToCopy = textArea.value; - var btn = document.querySelector('.copy-btn'); - var originalText = btn.textContent; - - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(textToCopy).then(function() { - btn.textContent = 'Copied!'; - setTimeout(function() { btn.textContent = originalText; }, 2000); - }).catch(function() { - textArea.select(); - document.execCommand('copy'); - btn.textContent = 'Copied!'; - setTimeout(function() { btn.textContent = originalText; }, 2000); - }); - } else { - textArea.select(); - document.execCommand('copy'); - btn.textContent = 'Copied!'; - setTimeout(function() { btn.textContent = originalText; }, 2000); - } -} +Object.keys(tagsPerTrainingDay).forEach(function(tdId) { + tagsPerTrainingDay[tdId] = Array.from(tagsPerTrainingDay[tdId]).sort(); +}); +allStudentTags = Array.from(allStudentTagsSet).sort(); + +{% include 'fragments/histogram_js.html' %} window.showArchivedTaskHistogram = function(trainingDayId, taskId, taskName) { var key = trainingDayId + '_' + taskId; var scores = archivedTaskScores[key] || []; - var maxScore = archivedTaskMaxScores[key] || 100; + var maxScore = 100; + if (taskMaxScoresByTrainingDay[trainingDayId] && + taskMaxScoresByTrainingDay[trainingDayId][taskId] !== undefined) { + maxScore = taskMaxScoresByTrainingDay[trainingDayId][taskId]; + } openHistogramModal(scores, taskName, 'task', trainingDayId, maxScore); }; - -// Initialize histogram modal on document ready -$(document).ready(function() { - initHistogramModal(); -}); {% endblock js_init %} {% block core %} @@ -283,14 +117,6 @@

Training Days

- -
- - - Add New Training Day - -
-

Active Training Days ({{ training_program.training_days | selectattr("contest") | list | length }})

@@ -367,7 +193,7 @@

Active Training Days ({{ training_program.training_days | selectattr("contes
{{ td.contest.start.strftime('%Y-%m-%d') }}
{{ td.contest.start.strftime('%H:%M') }}
{% else %} - · + Unscheduled {% endif %}

@@ -395,9 +221,10 @@

Active Training Days ({{ training_program.training_days | selectattr("contes {% if not admin.permission_all %}style="pointer-events: none; opacity: 0.5;"{% endif %} title="Archive training day"> - - - + + + + @@ -427,6 +254,15 @@

No active training days

Use the "Add New Training Day" button above to create one.

{% endif %} +
+ + + + + + Add New Training Day + +
@@ -496,7 +332,7 @@

Archived Training Days ({{ training_program.training_days | rejectattr("cont {% for task_id_str, task_info in td.archived_tasks_data.items() %} {{ task_info.get('name', 'Task ' ~ task_id_str) }} 📊 @@ -511,8 +347,8 @@

Archived Training Days ({{ training_program.training_days | rejectattr("cont
-
- - +{% include 'fragments/histogram_modal.html' %}