diff --git a/cms/db/archived_attendance.py b/cms/db/archived_attendance.py index a0ae2d6d6a..b44e16466f 100644 --- a/cms/db/archived_attendance.py +++ b/cms/db/archived_attendance.py @@ -27,7 +27,8 @@ from sqlalchemy.orm import relationship from sqlalchemy.schema import Column, ForeignKey, UniqueConstraint -from sqlalchemy.types import Integer, Unicode, Interval +from sqlalchemy.sql import text +from sqlalchemy.types import Boolean, Integer, Unicode, Interval from . import Base @@ -88,6 +89,26 @@ class ArchivedAttendance(Base): nullable=True, ) + # Whether the absence was justified (e.g., sick leave) + justified: bool = Column( + Boolean, + nullable=False, + server_default=text("false"), + ) + + # Admin comment for this attendance record + comment: str | None = Column( + Unicode, + nullable=True, + ) + + # Whether this students room and / or screen was recorded during this training + recorded: bool = Column( + Boolean, + nullable=False, + server_default=text("false"), + ) + training_day: "TrainingDay" = relationship( "TrainingDay", back_populates="archived_attendances", diff --git a/cms/server/admin/handlers/__init__.py b/cms/server/admin/handlers/__init__.py index 27580d37df..6774d0d386 100644 --- a/cms/server/admin/handlers/__init__.py +++ b/cms/server/admin/handlers/__init__.py @@ -212,7 +212,10 @@ TrainingProgramAttendanceHandler, \ TrainingProgramCombinedRankingHandler, \ TrainingProgramCombinedRankingHistoryHandler, \ - TrainingProgramCombinedRankingDetailHandler + TrainingProgramCombinedRankingDetailHandler, \ + UpdateAttendanceHandler, \ + ExportAttendanceHandler, \ + ExportCombinedRankingHandler HANDLERS = [ @@ -403,7 +406,10 @@ (r"/training_program/([0-9]+)/training_day/([0-9]+)/scoreboard_sharing", ScoreboardSharingHandler), (r"/training_program/([0-9]+)/training_day/([0-9]+)/archive", ArchiveTrainingDayHandler), (r"/training_program/([0-9]+)/attendance", TrainingProgramAttendanceHandler), + (r"/training_program/([0-9]+)/attendance/export", ExportAttendanceHandler), + (r"/training_program/([0-9]+)/attendance/([0-9]+)", UpdateAttendanceHandler), (r"/training_program/([0-9]+)/combined_ranking", TrainingProgramCombinedRankingHandler), + (r"/training_program/([0-9]+)/combined_ranking/export", ExportCombinedRankingHandler), (r"/training_program/([0-9]+)/combined_ranking/history", TrainingProgramCombinedRankingHistoryHandler), (r"/training_program/([0-9]+)/student/([0-9]+)/combined_ranking_detail", TrainingProgramCombinedRankingDetailHandler), (r"/training_program/([0-9]+)/overview", TrainingProgramOverviewRedirectHandler), diff --git a/cms/server/admin/handlers/archive.py b/cms/server/admin/handlers/archive.py index 9e844b77a2..15005944fb 100644 --- a/cms/server/admin/handlers/archive.py +++ b/cms/server/admin/handlers/archive.py @@ -21,18 +21,24 @@ attendance and combined ranking data across archived training days. """ +import io import json +import re from datetime import datetime as dt, timedelta +from typing import Any from urllib.parse import urlencode import tornado.web +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, PatternFill, Border, Side +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.worksheet import Worksheet from cms.db import ( Contest, TrainingProgram, Participation, Submission, - Question, Student, StudentTask, Task, @@ -59,6 +65,132 @@ ) +EXCEL_ZEBRA_COLORS = [ + ("4472C4", "D9E2F3"), + ("70AD47", "E2EFDA"), + ("ED7D31", "FCE4D6"), + ("7030A0", "E4DFEC"), + ("00B0F0", "DAEEF3"), + ("FFC000", "FFF2CC"), +] + +EXCEL_HEADER_FONT = Font(bold=True) +EXCEL_HEADER_FONT_WHITE = Font(bold=True, color="FFFFFF") +EXCEL_THIN_BORDER = Border( + left=Side(style="thin"), + right=Side(style="thin"), + top=Side(style="thin"), + bottom=Side(style="thin"), +) +EXCEL_DEFAULT_HEADER_FILL = PatternFill( + start_color="4472C4", end_color="4472C4", fill_type="solid" +) + + +def _excel_safe(value: str) -> str: + if value and value[0] in ("=", "+", "-", "@"): + return "'" + value + return value + + +def excel_build_filename( + program_name: str, + export_type: str, + start_date: Any, + end_date: Any, + training_day_types: list[str] | None, + student_tags: list[str] | None, +) -> str: + """Build a filename for Excel export based on filters.""" + program_slug = re.sub(r"[^A-Za-z0-9_-]+", "_", program_name) + filename_parts = [program_slug, export_type] + + if start_date: + filename_parts.append(f"from_{start_date.strftime('%Y%m%d')}") + if end_date: + filename_parts.append(f"to_{end_date.strftime('%Y%m%d')}") + if training_day_types: + types_slug = re.sub( + r"[^A-Za-z0-9_-]+", "_", "_".join(training_day_types) + ) + filename_parts.append(f"types_{types_slug}") + if student_tags: + tags_slug = re.sub(r"[^A-Za-z0-9_-]+", "_", "_".join(student_tags)) + filename_parts.append(f"tags_{tags_slug}") + + return "_".join(filename_parts) + ".xlsx" + + +def excel_setup_student_tags_headers( + ws: Worksheet, + default_fill: PatternFill, +) -> None: + """Set up Student and Tags column headers (merged across rows 1-2).""" + ws.cell(row=1, column=1, value="Student") + ws.cell(row=1, column=1).font = EXCEL_HEADER_FONT_WHITE + ws.cell(row=1, column=1).fill = default_fill + ws.cell(row=1, column=1).border = EXCEL_THIN_BORDER + ws.cell(row=1, column=1).alignment = Alignment( + horizontal="center", vertical="center" + ) + ws.merge_cells(start_row=1, start_column=1, end_row=2, end_column=1) + + ws.cell(row=1, column=2, value="Tags") + ws.cell(row=1, column=2).font = EXCEL_HEADER_FONT_WHITE + ws.cell(row=1, column=2).fill = default_fill + ws.cell(row=1, column=2).border = EXCEL_THIN_BORDER + ws.cell(row=1, column=2).alignment = Alignment( + horizontal="center", vertical="center" + ) + ws.merge_cells(start_row=1, start_column=2, end_row=2, end_column=2) + + +def excel_build_training_day_title(td: Any) -> str: + """Build a title string for a training day including types.""" + title = td.description or td.name or "Session" + if td.start_time: + title += f" ({td.start_time.strftime('%b %d')})" + if td.training_day_types: + title += f" [{'; '.join(td.training_day_types)}]" + return title + + +def excel_get_zebra_fills(color_idx: int) -> tuple[PatternFill, PatternFill]: + """Get header and subheader fills for zebra coloring.""" + header_color, subheader_color = EXCEL_ZEBRA_COLORS[ + color_idx % len(EXCEL_ZEBRA_COLORS) + ] + header_fill = PatternFill( + start_color=header_color, end_color=header_color, fill_type="solid" + ) + subheader_fill = PatternFill( + start_color=subheader_color, end_color=subheader_color, fill_type="solid" + ) + return header_fill, subheader_fill + + +def excel_write_student_row( + ws: Worksheet, + row: int, + student: Any, +) -> None: + """Write student name and tags to columns 1 and 2.""" + if student.participation: + user = student.participation.user + student_name = f"{user.first_name} {user.last_name} ({user.username})" + else: + student_name = "(Unknown)" + + ws.cell(row=row, column=1, value=_excel_safe(student_name)) + ws.cell(row=row, column=1).border = EXCEL_THIN_BORDER + + tags_str = "" + if student.student_tags: + tags_str = "; ".join(student.student_tags) + ws.cell(row=row, column=2, value=_excel_safe(tags_str)) + ws.cell(row=row, column=2).border = EXCEL_THIN_BORDER + + class ArchiveTrainingDayHandler(BaseHandler): """Archive a training day, extracting attendance and ranking data.""" @@ -1113,3 +1245,465 @@ def get_training_day_num( self.r_params["student_tags"] = student_tags self.r_params["student_tags_mode"] = student_tags_mode self.render("training_program_combined_ranking_detail.html", **self.r_params) + + +class UpdateAttendanceHandler(BaseHandler): + """Update attendance record (justified status, comment, and recorded).""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str, attendance_id: str): + """Update an attendance record's justified status, comment, and/or recorded.""" + training_program = self.safe_get_item(TrainingProgram, training_program_id) + attendance = self.safe_get_item(ArchivedAttendance, attendance_id) + + # Verify the attendance belongs to this training program + if attendance.training_day.training_program_id != training_program.id: + self.set_status(403) + self.write({"success": False, "error": "Attendance not in this program"}) + return + + try: + data = json.loads(self.request.body) + except json.JSONDecodeError: + self.set_status(400) + self.write({"success": False, "error": "Invalid JSON"}) + return + + # Update justified status if provided + if "justified" in data: + justified = data["justified"] + if not isinstance(justified, bool): + self.set_status(400) + self.write({"success": False, "error": "Invalid justified flag"}) + return + if justified and attendance.status != "missed": + self.set_status(400) + self.write( + { + "success": False, + "error": "Only missed attendances can be justified", + } + ) + return + attendance.justified = justified + + # Update comment if provided + if "comment" in data: + comment = data["comment"] + if comment is not None: + comment = str(comment).strip() + if not comment: + comment = None + attendance.comment = comment + + # Update recorded status if provided + if "recorded" in data: + recorded = data["recorded"] + if not isinstance(recorded, bool): + self.set_status(400) + self.write({"success": False, "error": "Invalid recorded flag"}) + return + if recorded and attendance.status == "missed": + self.set_status(400) + self.write( + { + "success": False, + "error": "Only non-missed attendances can be marked as recorded", + } + ) + return + attendance.recorded = recorded + + if self.try_commit(): + # Return JSON success, let JavaScript handle page reload + self.write({"success": True}) + else: + self.set_status(500) + self.write({"success": False, "error": "Failed to save changes"}) + + +class ExportAttendanceHandler(TrainingProgramFilterMixin, BaseHandler): + """Export attendance data to Excel format.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + """Export filtered attendance data to Excel.""" + training_program = self.safe_get_item(TrainingProgram, training_program_id) + + ( + start_date, + end_date, + training_day_types, + student_tags, + _, + archived_training_days, + current_tag_student_ids, + ) = self._get_filtered_context(training_program) + + if not archived_training_days: + self.redirect(self.url( + "training_program", training_program_id, "attendance" + )) + return + + attendance_data: dict[int, dict[int, ArchivedAttendance]] = {} + all_students: dict[int, Student] = {} + + for td in archived_training_days: + for attendance in td.archived_attendances: + student_id = attendance.student_id + if student_tags and student_id not in current_tag_student_ids: + continue + student = attendance.student + if student.participation and student.participation.hidden: + continue + if student_id not in attendance_data: + attendance_data[student_id] = {} + all_students[student_id] = student + attendance_data[student_id][td.id] = attendance + + sorted_students = sorted( + all_students.values(), + key=lambda s: s.participation.user.username if s.participation else "" + ) + + wb = Workbook() + ws = wb.active + ws.title = "Attendance" + + subcolumns = ["Status", "Location", "Recorded", "Delay Reasons", "Comments"] + num_subcolumns = len(subcolumns) + + excel_setup_student_tags_headers(ws, EXCEL_DEFAULT_HEADER_FILL) + + col = 3 + for td_idx, td in enumerate(archived_training_days): + title = excel_build_training_day_title(td) + header_fill, subheader_fill = excel_get_zebra_fills(td_idx) + + ws.cell(row=1, column=col, value=title) + ws.cell(row=1, column=col).font = EXCEL_HEADER_FONT_WHITE + ws.cell(row=1, column=col).fill = header_fill + ws.cell(row=1, column=col).border = EXCEL_THIN_BORDER + ws.cell(row=1, column=col).alignment = Alignment( + horizontal="center", vertical="center" + ) + ws.merge_cells( + start_row=1, start_column=col, + end_row=1, end_column=col + num_subcolumns - 1 + ) + + for i, subcol_name in enumerate(subcolumns): + cell = ws.cell(row=2, column=col + i, value=subcol_name) + cell.font = EXCEL_HEADER_FONT + cell.fill = subheader_fill + cell.border = EXCEL_THIN_BORDER + cell.alignment = Alignment(horizontal="center") + + col += num_subcolumns + + row = 3 + for student in sorted_students: + excel_write_student_row(ws, row, student) + + col = 3 + for td in archived_training_days: + att = attendance_data.get(student.id, {}).get(td.id) + + if att: + if att.status == "missed": + if att.justified: + status = "Justified Absent" + else: + status = "Missed" + elif att.delay_time: + delay_minutes = att.delay_time.total_seconds() / 60 + if delay_minutes < 60: + status = f"Delayed ({delay_minutes:.0f}m)" + else: + status = f"Delayed ({delay_minutes / 60:.1f}h)" + else: + status = "On Time" + + location = "" + if att.status != "missed" and att.location: + location_map = { + "class": "Class", + "home": "Home", + "both": "Both", + } + location = location_map.get(att.location, att.location) + + recorded = "" + if att.status != "missed": + recorded = "Yes" if att.recorded else "No" + + delay_reasons = att.delay_reasons or "" + comment = att.comment or "" + else: + status = "" + location = "" + recorded = "" + delay_reasons = "" + comment = "" + + values = [status, location, recorded, delay_reasons, comment] + for i, value in enumerate(values): + cell = ws.cell(row=row, column=col + i, value=_excel_safe(value)) + cell.border = EXCEL_THIN_BORDER + + col += num_subcolumns + + row += 1 + + ws.column_dimensions["A"].width = 30 + ws.column_dimensions["B"].width = 20 + for col_idx in range(3, 3 + len(archived_training_days) * num_subcolumns): + col_letter = get_column_letter(col_idx) + ws.column_dimensions[col_letter].width = 15 + + output = io.BytesIO() + wb.save(output) + output.seek(0) + + filename = excel_build_filename( + training_program.name, "attendance", + start_date, end_date, training_day_types, student_tags + ) + + self.set_header( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + self.set_header( + "Content-Disposition", + f'attachment; filename="{filename}"' + ) + self.write(output.getvalue()) + self.finish() + + +class ExportCombinedRankingHandler(TrainingProgramFilterMixin, BaseHandler): + """Export combined ranking data to Excel format.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str): + """Export filtered combined ranking data to Excel.""" + training_program = self.safe_get_item(TrainingProgram, training_program_id) + + ( + start_date, + end_date, + training_day_types, + student_tags, + student_tags_mode, + archived_training_days, + current_tag_student_ids, + ) = self._get_filtered_context(training_program) + + if not archived_training_days: + self.redirect(self.url( + "training_program", training_program_id, "combined_ranking" + )) + return + + ranking_data: dict[int, dict[int, ArchivedStudentRanking]] = {} + all_students: dict[int, Student] = {} + training_day_tasks: dict[int, list[dict]] = {} + filtered_training_days: list = [] + + for td in archived_training_days: + visible_tasks_by_id: dict[int, dict] = {} + + for ranking in td.archived_student_rankings: + student_id = ranking.student_id + student = ranking.student + + if student.participation and student.participation.hidden: + continue + + if student_tags: + if student_tags_mode == "current": + if student_id not in current_tag_student_ids: + continue + else: + if not self._tags_match(ranking.student_tags, student_tags): + continue + + if student_id not in ranking_data: + ranking_data[student_id] = {} + all_students[student_id] = student + ranking_data[student_id][td.id] = ranking + + if ranking.task_scores: + for task_id_str in ranking.task_scores.keys(): + task_id = int(task_id_str) + if task_id not in visible_tasks_by_id: + if (td.archived_tasks_data and + task_id_str in td.archived_tasks_data): + task_info = td.archived_tasks_data[task_id_str] + visible_tasks_by_id[task_id] = { + "id": task_id, + "name": task_info.get("short_name", ""), + "title": task_info.get("name", ""), + "training_day_num": task_info.get( + "training_day_num" + ), + } + else: + task = self.sql_session.query(Task).get(task_id) + if task: + visible_tasks_by_id[task_id] = { + "id": task_id, + "name": task.name, + "title": task.title, + "training_day_num": task.training_day_num, + } + + if not visible_tasks_by_id: + continue + + filtered_training_days.append(td) + sorted_tasks = sorted( + visible_tasks_by_id.values(), + key=lambda t: (t.get("training_day_num") or 0, t["id"]) + ) + training_day_tasks[td.id] = sorted_tasks + + if not filtered_training_days: + self.redirect(self.url( + "training_program", training_program_id, "combined_ranking" + )) + return + + sorted_students = sorted( + all_students.values(), + key=lambda s: s.participation.user.username if s.participation else "" + ) + + wb = Workbook() + ws = wb.active + ws.title = "Combined Ranking" + + excel_setup_student_tags_headers(ws, EXCEL_DEFAULT_HEADER_FILL) + + col = 3 + for td_idx, td in enumerate(filtered_training_days): + tasks = training_day_tasks.get(td.id, []) + num_task_cols = len(tasks) + 1 + + title = excel_build_training_day_title(td) + header_fill, subheader_fill = excel_get_zebra_fills(td_idx) + + ws.cell(row=1, column=col, value=title) + ws.cell(row=1, column=col).font = EXCEL_HEADER_FONT_WHITE + ws.cell(row=1, column=col).fill = header_fill + ws.cell(row=1, column=col).border = EXCEL_THIN_BORDER + ws.cell(row=1, column=col).alignment = Alignment( + horizontal="center", vertical="center" + ) + ws.merge_cells( + start_row=1, start_column=col, + end_row=1, end_column=col + num_task_cols - 1 + ) + + for i, task in enumerate(tasks): + cell = ws.cell(row=2, column=col + i, value=task["name"]) + cell.font = EXCEL_HEADER_FONT + cell.fill = subheader_fill + cell.border = EXCEL_THIN_BORDER + cell.alignment = Alignment(horizontal="center") + + total_cell = ws.cell(row=2, column=col + len(tasks), value="Total") + total_cell.font = EXCEL_HEADER_FONT + total_cell.fill = subheader_fill + total_cell.border = EXCEL_THIN_BORDER + total_cell.alignment = Alignment(horizontal="center") + + col += num_task_cols + + global_header_fill = PatternFill( + start_color="808080", end_color="808080", fill_type="solid" + ) + ws.cell(row=1, column=col, value="Global") + ws.cell(row=1, column=col).font = EXCEL_HEADER_FONT_WHITE + ws.cell(row=1, column=col).fill = global_header_fill + ws.cell(row=1, column=col).border = EXCEL_THIN_BORDER + ws.cell(row=1, column=col).alignment = Alignment( + horizontal="center", vertical="center" + ) + ws.merge_cells(start_row=1, start_column=col, end_row=2, end_column=col) + + row = 3 + for student in sorted_students: + excel_write_student_row(ws, row, student) + + col = 3 + global_total = 0.0 + + for td in filtered_training_days: + tasks = training_day_tasks.get(td.id, []) + ranking = ranking_data.get(student.id, {}).get(td.id) + + td_total = 0.0 + for task in tasks: + score_val = None + if ranking and ranking.task_scores: + score_val = ranking.task_scores.get(str(task["id"])) + + cell = ws.cell(row=row, column=col) + if score_val is not None: + cell.value = score_val + td_total += score_val + else: + cell.value = "" + cell.border = EXCEL_THIN_BORDER + col += 1 + + total_cell = ws.cell(row=row, column=col) + if ranking and ranking.task_scores: + total_cell.value = td_total + global_total += td_total + else: + total_cell.value = "" + total_cell.border = EXCEL_THIN_BORDER + col += 1 + + global_cell = ws.cell(row=row, column=col) + global_cell.value = global_total if global_total > 0 else "" + global_cell.border = EXCEL_THIN_BORDER + global_cell.font = Font(bold=True) + + row += 1 + + ws.column_dimensions["A"].width = 30 + ws.column_dimensions["B"].width = 20 + + total_cols = 3 + for td in filtered_training_days: + total_cols += len(training_day_tasks.get(td.id, [])) + 1 + total_cols += 1 + + for col_idx in range(3, total_cols): + col_letter = get_column_letter(col_idx) + ws.column_dimensions[col_letter].width = 10 + + output = io.BytesIO() + wb.save(output) + output.seek(0) + + filename = excel_build_filename( + training_program.name, "ranking", + start_date, end_date, training_day_types, student_tags + ) + + self.set_header( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + self.set_header( + "Content-Disposition", + f'attachment; filename="{filename}"' + ) + self.write(output.getvalue()) + self.finish() diff --git a/cms/server/admin/static/aws_tp_styles.css b/cms/server/admin/static/aws_tp_styles.css index e65052a3b5..74c0ddcec8 100644 --- a/cms/server/admin/static/aws_tp_styles.css +++ b/cms/server/admin/static/aws_tp_styles.css @@ -693,7 +693,9 @@ .location-row { display: flex; align-items: center; - gap: 4px; + gap: 6px; + margin-top: 4px; + flex-wrap: wrap; font-size: 0.75rem; color: var(--tp-text-light); } @@ -1084,6 +1086,168 @@ opacity: 0.7; } +.recorded-badge { + opacity: 0.8; + font-size: 1.1em !important; +} + +/* ========================================================================== + Attendance Modal Styles + ========================================================================== */ + +.attendance-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + justify-content: center; + align-items: center; +} + +.attendance-modal-content { + background: white; + border-radius: 8px; + width: 400px; + max-width: 90%; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +.attendance-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #e5e7eb; +} + +.attendance-modal-header h3 { + margin: 0; + font-size: 1.1rem; + color: #374151; +} + +.attendance-modal-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #6b7280; + padding: 0; + line-height: 1; +} + +.attendance-modal-close:hover { + color: #374151; +} + +.attendance-modal-body { + padding: 20px; +} + +.attendance-modal-row { + display: flex; + flex-direction: column; + margin-bottom: 16px; +} + +.attendance-modal-row label { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + color: #374151; + margin-bottom: 6px; +} + +.attendance-modal-row input[type="checkbox"] { + width: 16px; + height: 16px; +} + +.attendance-modal-row textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 0.9em; + resize: none; + box-sizing: border-box; +} + +.attendance-modal-row textarea:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + +.attendance-modal-hint { + color: #6b7280; + font-size: 0.85em; + margin-top: 4px; +} + +.attendance-modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 16px 20px; + border-top: 1px solid #e5e7eb; + background: #f9fafb; + border-radius: 0 0 8px 8px; +} + +.attendance-cell { + cursor: pointer; + transition: background-color 0.2s; +} + +.attendance-cell:hover { + background-color: var(--tp-border) !important; + box-shadow: 0 0 0 2px var(--tp-info); +} + +.attendance-table tbody tr:hover { + background-color: var(--tp-bg-gray); + cursor: pointer; +} + +.attendance-table tbody tr:hover th { + background-color: var(--tp-border); + font-weight: 600; +} + +.attendance-badges { + display: flex; + gap: 4px; + margin-top: 4px; + justify-content: center; + flex-wrap: wrap; +} + +.recorded-badge, .comment-indicator, .justified-badge { + font-size: 0.85em; + cursor: help; +} + +.comment-text { + font-size: 0.75rem; + color: var(--tp-text-muted); + margin-top: 4px; + line-height: 1.3; + word-break: break-word; + cursor: help; +} + + +.status-justified { + background-color: #fed7aa !important; + color: #92400e !important; +} + /* ========================================================================== Navigation & Header Components ========================================================================== */ diff --git a/cms/server/admin/templates/training_program_attendance.html b/cms/server/admin/templates/training_program_attendance.html index df16fb7f83..42288ddfd5 100644 --- a/cms/server/admin/templates/training_program_attendance.html +++ b/cms/server/admin/templates/training_program_attendance.html @@ -7,7 +7,7 @@ if (filterInput && typeof Tagify !== 'undefined') { new Tagify(filterInput, { delimiters: ",", - whitelist: {{ all_training_day_types | tojson }}, + whitelist: {{ all_training_day_types | default([]) | tojson }}, enforceWhitelist: true, editTags: false, dropdown: { enabled: 0, maxItems: 20, closeOnSelect: true }, @@ -88,6 +88,14 @@

Attendance: {{ +{% if archived_training_days %} +
+ + 💾 Export to Excel + +
+{% endif %} + {% if archived_training_days %}
@@ -145,21 +153,21 @@

Attendance: {{ {% for td in archived_training_days %} {% set att = attendance_data.get(student.id, {}).get(td.id) %} {% if att %} - {% if att.status == "missed" %} -

{% else %} @@ -216,10 +233,215 @@

Attendance: {{ }); + +
+
+
+

Edit Attendance

+ +
+
+
+ + Check this if the absence was justified (e.g., sick leave) +
+
+ + Check this if the student was recorded (screen recording) +
+
+ + +
+
+ +
+
+ {% else %}

No Data Available

No archived training days found matching your filter.

{% endif %} + + + + {% endblock core %} diff --git a/cms/server/admin/templates/training_program_combined_ranking.html b/cms/server/admin/templates/training_program_combined_ranking.html index 8bc88396d7..4c7bda0e6d 100644 --- a/cms/server/admin/templates/training_program_combined_ranking.html +++ b/cms/server/admin/templates/training_program_combined_ranking.html @@ -7,7 +7,7 @@ if (filterInput && typeof Tagify !== 'undefined') { new Tagify(filterInput, { delimiters: ",", - whitelist: {{ all_training_day_types | tojson }}, + whitelist: {{ all_training_day_types | default([]) | tojson }}, enforceWhitelist: true, editTags: false, dropdown: { enabled: 0, maxItems: 20, closeOnSelect: true }, @@ -235,6 +235,11 @@

Combined Ranking: + + 💾 Export to Excel + +

- {% elif att.delay_time %} - - {% else %} - - {% endif %} + {% set data_value = 3 if att.status == 'missed' and not att.justified else (2.5 if att.status == 'missed' else (2 if att.delay_time else 1)) %} +
{% if att.status == "missed" %} -
- ⛔ Missed +
+ {% if att.justified %}📝 Justified{% else %}⛔ Missed{% endif %}
{% elif att.delay_time %}
- ⏱ Delayed ({{ "%.1f"|format(att.delay_time.total_seconds() / 3600) }}h) + {% set delay_minutes = att.delay_time.total_seconds() / 60 %} + {% if delay_minutes < 60 %} + ⏱ Delayed ({{ "%.0f"|format(delay_minutes) }}m) + {% else %} + ⏱ Delayed ({{ "%.1f"|format(delay_minutes / 60) }}h) + {% endif %}
{% else %} {% endif %} @@ -188,6 +199,12 @@

Attendance: {{ {{ att.delay_reasons }}

{% endif %} + + {% if att.comment %} +
+ 💬 {{ att.comment }} +
+ {% endif %}
@@ -364,17 +369,27 @@

Combined Ranking: ❌📝 + {% elif attendance and attendance.status == 'missed' %} {% elif attendance and attendance.location == 'home' %} 🏠 {% endif %} + {% if attendance and attendance.recorded %} + 🎥 + {% endif %} {% else %} - {% if attendance and attendance.status == 'missed' %} + {% if attendance and attendance.status == 'missed' and attendance.justified %} + ❌📝 + {% elif attendance and attendance.status == 'missed' %} {% else %} - {% endif %} + {% if attendance and attendance.recorded %} + 🎥 + {% endif %} {% endif %} diff --git a/cms/server/admin/templates/training_program_training_days.html b/cms/server/admin/templates/training_program_training_days.html index 3f9fc2caf6..4bb3751f6f 100644 --- a/cms/server/admin/templates/training_program_training_days.html +++ b/cms/server/admin/templates/training_program_training_days.html @@ -5,7 +5,7 @@ {% for td in training_program.training_days %} CMS.AWSUtils.initTagify({ inputSelector: 'input[name="training_day_types_{{ td.id }}"]', - whitelist: {{ all_training_day_types | tojson }}, + whitelist: {{ all_training_day_types | default([]) | tojson }}, getSaveUrl: function() { return '{{ url("training_program", training_program.id, "training_day", td.id, "types") }}'; }, diff --git a/cmscontrib/updaters/update_from_1.5.sql b/cmscontrib/updaters/update_from_1.5.sql index 02348b7289..b5c0828db8 100644 --- a/cmscontrib/updaters/update_from_1.5.sql +++ b/cmscontrib/updaters/update_from_1.5.sql @@ -663,7 +663,10 @@ CREATE TABLE public.archived_attendances ( status character varying NOT NULL, location character varying, delay_time interval, - delay_reasons character varying + delay_reasons character varying, + justified boolean NOT NULL DEFAULT false, + comment character varying, + recorded boolean NOT NULL DEFAULT false ); CREATE SEQUENCE public.archived_attendances_id_seq diff --git a/pyproject.toml b/pyproject.toml index 8cf81b2193..90addd0507 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,9 @@ dependencies = [ # Only for user profile pictures "Pillow>=10.0,<13.0", # https://pillow.readthedocs.io/en/stable/releasenotes/ + + # Only for Excel exports + "openpyxl>=3.1,<4.0", # https://openpyxl.readthedocs.io/ ] [project.urls]