Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion cms/db/archived_attendance.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

from sqlalchemy.orm import relationship
from sqlalchemy.schema import Column, ForeignKey, UniqueConstraint
from sqlalchemy.types import Integer, Unicode, Interval
from sqlalchemy.types import Boolean, Integer, Unicode, Interval

from . import Base

Expand Down Expand Up @@ -88,6 +88,24 @@ class ArchivedAttendance(Base):
nullable=True,
)

# Whether the absence was justified (e.g., sick leave)
justified: bool = Column(
Boolean,
nullable=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,
)

training_day: "TrainingDay" = relationship(
"TrainingDay",
back_populates="archived_attendances",
Expand Down
4 changes: 3 additions & 1 deletion cms/server/admin/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@
TrainingProgramAttendanceHandler, \
TrainingProgramCombinedRankingHandler, \
TrainingProgramCombinedRankingHistoryHandler, \
TrainingProgramCombinedRankingDetailHandler
TrainingProgramCombinedRankingDetailHandler, \
UpdateAttendanceHandler


HANDLERS = [
Expand Down Expand Up @@ -403,6 +404,7 @@
(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/([0-9]+)", UpdateAttendanceHandler),
(r"/training_program/([0-9]+)/combined_ranking", TrainingProgramCombinedRankingHandler),
(r"/training_program/([0-9]+)/combined_ranking/history", TrainingProgramCombinedRankingHistoryHandler),
(r"/training_program/([0-9]+)/student/([0-9]+)/combined_ranking_detail", TrainingProgramCombinedRankingDetailHandler),
Expand Down
49 changes: 49 additions & 0 deletions cms/server/admin/handlers/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,3 +1113,52 @@ 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."""
import json

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:
attendance.justified = bool(data["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:
attendance.recorded = bool(data["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"})
160 changes: 160 additions & 0 deletions cms/server/admin/static/aws_tp_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,166 @@
opacity: 0.7;
}

.justified-badge {
color: var(--tp-success);
}

.recorded-badge {
opacity: 0.8;
}

/* ==========================================================================
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: #f3f4f6 !important;
}

.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-indicator {
font-size: 1.1em !important;
}

.recorded-badge {
font-size: 1.1em !important;
}

.location-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
flex-wrap: wrap;
}

.status-justified {
background-color: #fed7aa !important;
color: #92400e !important;
}

/* ==========================================================================
Navigation & Header Components
========================================================================== */
Expand Down
Loading
Loading