Spaces:
Sleeping
Sleeping
fixing attendance
Browse files- app/models/attendance.py +1 -0
- app/routers/attendance.py +76 -29
- app/routers/me.py +20 -10
- app/services/qr_service.py +5 -0
- app/static/js/app.js +6 -6
- app/static/js/calendar.js +51 -13
- app/static/js/crud.js +26 -2
- app/templates/index.html +15 -3
- test_data/test_api.py +19 -0
app/models/attendance.py
CHANGED
|
@@ -19,6 +19,7 @@ from app.db.database import Base
|
|
| 19 |
# ── Status constants ───────────────────────────────────────────────────────────
|
| 20 |
ATTENDANCE_STATUS_PRESENT = "present"
|
| 21 |
ATTENDANCE_STATUS_LATE = "late"
|
|
|
|
| 22 |
|
| 23 |
# ── Attendee type constants ────────────────────────────────────────────────────
|
| 24 |
ATTENDEE_TYPE_STUDENT = "student"
|
|
|
|
| 19 |
# ── Status constants ───────────────────────────────────────────────────────────
|
| 20 |
ATTENDANCE_STATUS_PRESENT = "present"
|
| 21 |
ATTENDANCE_STATUS_LATE = "late"
|
| 22 |
+
ATTENDANCE_STATUS_ABSENT = "absent"
|
| 23 |
|
| 24 |
# ── Attendee type constants ────────────────────────────────────────────────────
|
| 25 |
ATTENDEE_TYPE_STUDENT = "student"
|
app/routers/attendance.py
CHANGED
|
@@ -34,6 +34,7 @@ from app.config import settings
|
|
| 34 |
from app.db.database import get_db
|
| 35 |
from app.logging_config import get_logger
|
| 36 |
from app.models.attendance import (
|
|
|
|
| 37 |
ATTENDANCE_STATUS_LATE,
|
| 38 |
ATTENDANCE_STATUS_PRESENT,
|
| 39 |
ATTENDEE_TYPE_MENTOR,
|
|
@@ -66,6 +67,7 @@ from app.services.qr_service import (
|
|
| 66 |
decode_device_token,
|
| 67 |
generate_qr_image_b64,
|
| 68 |
generate_qr_token,
|
|
|
|
| 69 |
is_attendance_window_open,
|
| 70 |
is_qr_available,
|
| 71 |
issue_device_token,
|
|
@@ -143,7 +145,12 @@ async def _get_attendance_record(session_id: str, attendee_id: str,
|
|
| 143 |
return result.scalar_one_or_none()
|
| 144 |
|
| 145 |
|
| 146 |
-
async def _build_summary(session_id: str, batch_id: str, db: AsyncSession
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
# Count enrolled students
|
| 148 |
enrolled_result = await db.execute(
|
| 149 |
select(func.count()).select_from(student_batch_table).where(
|
|
@@ -164,7 +171,10 @@ async def _build_summary(session_id: str, batch_id: str, db: AsyncSession) -> At
|
|
| 164 |
counts = {row.status: row.cnt for row in att_result}
|
| 165 |
present = counts.get(ATTENDANCE_STATUS_PRESENT, 0)
|
| 166 |
late = counts.get(ATTENDANCE_STATUS_LATE, 0)
|
| 167 |
-
absent =
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
return AttendanceSummary(
|
| 170 |
session_id=session_id,
|
|
@@ -175,6 +185,48 @@ async def _build_summary(session_id: str, batch_id: str, db: AsyncSession) -> At
|
|
| 175 |
)
|
| 176 |
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
async def _upsert_attendance(
|
| 179 |
db: AsyncSession,
|
| 180 |
*,
|
|
@@ -240,7 +292,9 @@ async def get_session_qr(
|
|
| 240 |
"""
|
| 241 |
s = await _get_session_or_404(session_id, db)
|
| 242 |
|
| 243 |
-
|
|
|
|
|
|
|
| 244 |
slot_start, _ = get_slot_times(s.slot_id)
|
| 245 |
raise HTTPException(
|
| 246 |
status.HTTP_403_FORBIDDEN,
|
|
@@ -291,7 +345,7 @@ async def get_session_summary(
|
|
| 291 |
) -> AttendanceSummary:
|
| 292 |
"""Live enrolled-vs-present summary for the mentor dashboard."""
|
| 293 |
s = await _get_session_or_404(session_id, db)
|
| 294 |
-
return await _build_summary(session_id, s.batch_id, db)
|
| 295 |
|
| 296 |
|
| 297 |
@router.get("/sessions/{session_id}/records", response_model=list[AttendanceRecordRead])
|
|
@@ -300,8 +354,9 @@ async def get_session_records(
|
|
| 300 |
db: AsyncSession = Depends(get_db),
|
| 301 |
user: UserContext = Depends(require_mentor_or_admin),
|
| 302 |
) -> list[AttendanceRecordRead]:
|
| 303 |
-
"""Full roster of who checked in (present/late), with is_manual flag."""
|
| 304 |
-
await _get_session_or_404(session_id, db)
|
|
|
|
| 305 |
|
| 306 |
result = await db.execute(
|
| 307 |
select(AttendanceRecord)
|
|
@@ -394,7 +449,7 @@ async def scan_attendance(
|
|
| 394 |
att_status=att_status,
|
| 395 |
qr_window=w,
|
| 396 |
is_manual=False,
|
| 397 |
-
marked_by=
|
| 398 |
)
|
| 399 |
|
| 400 |
logger.info(
|
|
@@ -433,33 +488,25 @@ async def manual_mark(
|
|
| 433 |
s = await _get_session_or_404(session_id, db)
|
| 434 |
|
| 435 |
for entry in body.entries:
|
| 436 |
-
if entry.status
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
session_id=session_id,
|
| 450 |
-
attendee_id=entry.student_id,
|
| 451 |
-
attendee_type=ATTENDEE_TYPE_STUDENT,
|
| 452 |
-
att_status=entry.status,
|
| 453 |
-
qr_window=None,
|
| 454 |
-
is_manual=True,
|
| 455 |
-
marked_by=user.email,
|
| 456 |
-
)
|
| 457 |
|
| 458 |
logger.info(
|
| 459 |
"attendance.manual_mark session=%s by=%s entries=%d",
|
| 460 |
session_id, user.email, len(body.entries),
|
| 461 |
)
|
| 462 |
-
return await _build_summary(session_id, s.batch_id, db)
|
| 463 |
|
| 464 |
|
| 465 |
# ── Student history ────────────────────────────────────────────────────────────
|
|
|
|
| 34 |
from app.db.database import get_db
|
| 35 |
from app.logging_config import get_logger
|
| 36 |
from app.models.attendance import (
|
| 37 |
+
ATTENDANCE_STATUS_ABSENT,
|
| 38 |
ATTENDANCE_STATUS_LATE,
|
| 39 |
ATTENDANCE_STATUS_PRESENT,
|
| 40 |
ATTENDEE_TYPE_MENTOR,
|
|
|
|
| 67 |
decode_device_token,
|
| 68 |
generate_qr_image_b64,
|
| 69 |
generate_qr_token,
|
| 70 |
+
has_session_ended,
|
| 71 |
is_attendance_window_open,
|
| 72 |
is_qr_available,
|
| 73 |
issue_device_token,
|
|
|
|
| 145 |
return result.scalar_one_or_none()
|
| 146 |
|
| 147 |
|
| 148 |
+
async def _build_summary(session_id: str, batch_id: str, db: AsyncSession,
|
| 149 |
+
session: "Session | None" = None) -> AttendanceSummary:
|
| 150 |
+
# Auto-finalize absent records once the class window has closed
|
| 151 |
+
if session is not None:
|
| 152 |
+
await _auto_finalize_absent(session, db)
|
| 153 |
+
|
| 154 |
# Count enrolled students
|
| 155 |
enrolled_result = await db.execute(
|
| 156 |
select(func.count()).select_from(student_batch_table).where(
|
|
|
|
| 171 |
counts = {row.status: row.cnt for row in att_result}
|
| 172 |
present = counts.get(ATTENDANCE_STATUS_PRESENT, 0)
|
| 173 |
late = counts.get(ATTENDANCE_STATUS_LATE, 0)
|
| 174 |
+
absent = counts.get(ATTENDANCE_STATUS_ABSENT, 0)
|
| 175 |
+
# Fallback for sessions not yet finalized (in-progress or window not passed)
|
| 176 |
+
if absent == 0 and session is None:
|
| 177 |
+
absent = max(0, enrolled - present - late)
|
| 178 |
|
| 179 |
return AttendanceSummary(
|
| 180 |
session_id=session_id,
|
|
|
|
| 185 |
)
|
| 186 |
|
| 187 |
|
| 188 |
+
async def _auto_finalize_absent(s: Session, db: AsyncSession) -> None:
|
| 189 |
+
"""Idempotent: once a session's slot end time has passed, insert 'absent' records
|
| 190 |
+
(marked_by='system') for every enrolled student who has no record yet."""
|
| 191 |
+
if not has_session_ended(s.date, s.slot_id):
|
| 192 |
+
return # class still in progress or future
|
| 193 |
+
|
| 194 |
+
enrolled_ids = (await db.execute(
|
| 195 |
+
select(student_batch_table.c.student_id)
|
| 196 |
+
.where(student_batch_table.c.batch_id == s.batch_id)
|
| 197 |
+
)).scalars().all()
|
| 198 |
+
if not enrolled_ids:
|
| 199 |
+
return
|
| 200 |
+
|
| 201 |
+
existing_ids = set((await db.execute(
|
| 202 |
+
select(AttendanceRecord.attendee_id)
|
| 203 |
+
.where(
|
| 204 |
+
AttendanceRecord.session_id == s.id,
|
| 205 |
+
AttendanceRecord.attendee_type == ATTENDEE_TYPE_STUDENT,
|
| 206 |
+
)
|
| 207 |
+
)).scalars().all())
|
| 208 |
+
|
| 209 |
+
missing = [sid for sid in enrolled_ids if sid not in existing_ids]
|
| 210 |
+
if not missing:
|
| 211 |
+
return
|
| 212 |
+
|
| 213 |
+
for sid in missing:
|
| 214 |
+
db.add(AttendanceRecord(
|
| 215 |
+
session_id = s.id,
|
| 216 |
+
attendee_id = sid,
|
| 217 |
+
attendee_type = ATTENDEE_TYPE_STUDENT,
|
| 218 |
+
status = ATTENDANCE_STATUS_ABSENT,
|
| 219 |
+
qr_window = None,
|
| 220 |
+
is_manual = False,
|
| 221 |
+
marked_by = "system",
|
| 222 |
+
))
|
| 223 |
+
try:
|
| 224 |
+
await db.commit()
|
| 225 |
+
logger.info("attendance.auto_finalize session=%s absent_count=%d", s.id, len(missing))
|
| 226 |
+
except Exception:
|
| 227 |
+
await db.rollback() # race condition / duplicate unique key — safe to ignore
|
| 228 |
+
|
| 229 |
+
|
| 230 |
async def _upsert_attendance(
|
| 231 |
db: AsyncSession,
|
| 232 |
*,
|
|
|
|
| 292 |
"""
|
| 293 |
s = await _get_session_or_404(session_id, db)
|
| 294 |
|
| 295 |
+
# Admin can generate QR at any time (covers for absent/late mentor).
|
| 296 |
+
# Mentors are restricted to the attendance window.
|
| 297 |
+
if user.role != "admin" and not is_qr_available(s.date, s.slot_id):
|
| 298 |
slot_start, _ = get_slot_times(s.slot_id)
|
| 299 |
raise HTTPException(
|
| 300 |
status.HTTP_403_FORBIDDEN,
|
|
|
|
| 345 |
) -> AttendanceSummary:
|
| 346 |
"""Live enrolled-vs-present summary for the mentor dashboard."""
|
| 347 |
s = await _get_session_or_404(session_id, db)
|
| 348 |
+
return await _build_summary(session_id, s.batch_id, db, session=s)
|
| 349 |
|
| 350 |
|
| 351 |
@router.get("/sessions/{session_id}/records", response_model=list[AttendanceRecordRead])
|
|
|
|
| 354 |
db: AsyncSession = Depends(get_db),
|
| 355 |
user: UserContext = Depends(require_mentor_or_admin),
|
| 356 |
) -> list[AttendanceRecordRead]:
|
| 357 |
+
"""Full roster of who checked in (present/late/absent), with is_manual flag."""
|
| 358 |
+
s = await _get_session_or_404(session_id, db)
|
| 359 |
+
await _auto_finalize_absent(s, db)
|
| 360 |
|
| 361 |
result = await db.execute(
|
| 362 |
select(AttendanceRecord)
|
|
|
|
| 449 |
att_status=att_status,
|
| 450 |
qr_window=w,
|
| 451 |
is_manual=False,
|
| 452 |
+
marked_by=user.email, # student/mentor email for audit trail
|
| 453 |
)
|
| 454 |
|
| 455 |
logger.info(
|
|
|
|
| 488 |
s = await _get_session_or_404(session_id, db)
|
| 489 |
|
| 490 |
for entry in body.entries:
|
| 491 |
+
if entry.status not in (ATTENDANCE_STATUS_PRESENT, ATTENDANCE_STATUS_LATE,
|
| 492 |
+
ATTENDANCE_STATUS_ABSENT):
|
| 493 |
+
continue
|
| 494 |
+
await _upsert_attendance(
|
| 495 |
+
db,
|
| 496 |
+
session_id = session_id,
|
| 497 |
+
attendee_id = entry.student_id,
|
| 498 |
+
attendee_type = ATTENDEE_TYPE_STUDENT,
|
| 499 |
+
att_status = entry.status,
|
| 500 |
+
qr_window = None,
|
| 501 |
+
is_manual = True,
|
| 502 |
+
marked_by = user.email,
|
| 503 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
|
| 505 |
logger.info(
|
| 506 |
"attendance.manual_mark session=%s by=%s entries=%d",
|
| 507 |
session_id, user.email, len(body.entries),
|
| 508 |
)
|
| 509 |
+
return await _build_summary(session_id, s.batch_id, db, session=s)
|
| 510 |
|
| 511 |
|
| 512 |
# ── Student history ────────────────────────────────────────────────────────────
|
app/routers/me.py
CHANGED
|
@@ -24,6 +24,7 @@ from app.constants import (
|
|
| 24 |
)
|
| 25 |
from app.db.database import get_db
|
| 26 |
from app.models.attendance import (
|
|
|
|
| 27 |
ATTENDANCE_STATUS_LATE,
|
| 28 |
ATTENDANCE_STATUS_PRESENT,
|
| 29 |
ATTENDEE_TYPE_STUDENT,
|
|
@@ -245,18 +246,27 @@ async def _student_dashboard(uid: str, db: AsyncSession) -> dict:
|
|
| 245 |
for s in sessions_raw:
|
| 246 |
enriched = enrich_session(s)
|
| 247 |
is_past = s.date < today
|
|
|
|
| 248 |
attendance = None
|
| 249 |
-
if
|
|
|
|
|
|
|
|
|
|
| 250 |
past_count += 1
|
| 251 |
-
|
| 252 |
-
if att_status
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
enriched_dict = enriched if isinstance(enriched, dict) else enriched.model_dump()
|
| 261 |
sessions_list.append({**enriched_dict, "attendance": attendance})
|
| 262 |
|
|
|
|
| 24 |
)
|
| 25 |
from app.db.database import get_db
|
| 26 |
from app.models.attendance import (
|
| 27 |
+
ATTENDANCE_STATUS_ABSENT,
|
| 28 |
ATTENDANCE_STATUS_LATE,
|
| 29 |
ATTENDANCE_STATUS_PRESENT,
|
| 30 |
ATTENDEE_TYPE_STUDENT,
|
|
|
|
| 246 |
for s in sessions_raw:
|
| 247 |
enriched = enrich_session(s)
|
| 248 |
is_past = s.date < today
|
| 249 |
+
att_status = att_map.get(s.id)
|
| 250 |
attendance = None
|
| 251 |
+
if att_status in (ATTENDANCE_STATUS_PRESENT, ATTENDANCE_STATUS_LATE):
|
| 252 |
+
# Recorded attendance (QR or manual) — show and count it,
|
| 253 |
+
# even for today's session (not strictly "past" yet).
|
| 254 |
+
attendance = att_status
|
| 255 |
past_count += 1
|
| 256 |
+
present_count += 1
|
| 257 |
+
if att_status == ATTENDANCE_STATUS_LATE:
|
| 258 |
+
late_count += 1
|
| 259 |
+
elif att_status == ATTENDANCE_STATUS_ABSENT:
|
| 260 |
+
# Explicit absent record (manual mark or auto-finalized by system)
|
| 261 |
+
attendance = "absent"
|
| 262 |
+
past_count += 1
|
| 263 |
+
absent_count += 1
|
| 264 |
+
elif is_past:
|
| 265 |
+
# Fallback: past session not yet auto-finalized → show absent
|
| 266 |
+
attendance = "absent"
|
| 267 |
+
past_count += 1
|
| 268 |
+
absent_count += 1
|
| 269 |
+
# else: upcoming session, no record → attendance stays None (shown as "–")
|
| 270 |
enriched_dict = enriched if isinstance(enriched, dict) else enriched.model_dump()
|
| 271 |
sessions_list.append({**enriched_dict, "attendance": attendance})
|
| 272 |
|
app/services/qr_service.py
CHANGED
|
@@ -122,6 +122,11 @@ def is_qr_available(session_date, slot_id: int) -> bool:
|
|
| 122 |
return open_at <= now <= end
|
| 123 |
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
def is_attendance_window_open(session_date, slot_id: int) -> bool:
|
| 126 |
"""True if scans are still accepted:
|
| 127 |
now ≤ min(start + qr_expires_after_start_minutes, end)
|
|
|
|
| 122 |
return open_at <= now <= end
|
| 123 |
|
| 124 |
|
| 125 |
+
def has_session_ended(session_date, slot_id: int) -> bool:
|
| 126 |
+
"""True if the slot end time has passed (class is over)."""
|
| 127 |
+
return _app_now() > _slot_dt(session_date, slot_id, "end")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
def is_attendance_window_open(session_date, slot_id: int) -> bool:
|
| 131 |
"""True if scans are still accepted:
|
| 132 |
now ≤ min(start + qr_expires_after_start_minutes, end)
|
app/static/js/app.js
CHANGED
|
@@ -260,9 +260,9 @@ function _paintMentorWeek() {
|
|
| 260 |
|
| 261 |
// QR Code button
|
| 262 |
const qrBtn = document.createElement('button');
|
| 263 |
-
qrBtn.className = 'btn btn-sm btn-
|
| 264 |
-
qrBtn.textContent = 'QR';
|
| 265 |
-
qrBtn.style.
|
| 266 |
qrBtn.onclick = (e) => {
|
| 267 |
e.stopPropagation();
|
| 268 |
openQrModal(s.id, s.batch_name || '', s.date || '', s.slot_start || '', s.slot_end || '');
|
|
@@ -272,9 +272,9 @@ function _paintMentorWeek() {
|
|
| 272 |
|
| 273 |
// Manual mark button
|
| 274 |
const manualBtn = document.createElement('button');
|
| 275 |
-
manualBtn.className = 'btn btn-sm btn-
|
| 276 |
-
manualBtn.textContent = 'Mark';
|
| 277 |
-
manualBtn.style.
|
| 278 |
manualBtn.onclick = (e) => {
|
| 279 |
e.stopPropagation();
|
| 280 |
openManualMarkModal(s.id, s.batch_name || '', s.date || '', s.slot_start || '', s.slot_end || '');
|
|
|
|
| 260 |
|
| 261 |
// QR Code button
|
| 262 |
const qrBtn = document.createElement('button');
|
| 263 |
+
qrBtn.className = 'btn btn-sm btn-primary mt-1 me-1 fw-semibold';
|
| 264 |
+
qrBtn.textContent = '⬛ QR';
|
| 265 |
+
qrBtn.style.cssText = 'font-size:0.7rem;border-width:2px;';
|
| 266 |
qrBtn.onclick = (e) => {
|
| 267 |
e.stopPropagation();
|
| 268 |
openQrModal(s.id, s.batch_name || '', s.date || '', s.slot_start || '', s.slot_end || '');
|
|
|
|
| 272 |
|
| 273 |
// Manual mark button
|
| 274 |
const manualBtn = document.createElement('button');
|
| 275 |
+
manualBtn.className = 'btn btn-sm btn-success mt-1 fw-semibold';
|
| 276 |
+
manualBtn.textContent = '✓ Mark';
|
| 277 |
+
manualBtn.style.cssText = 'font-size:0.7rem;border-width:2px;';
|
| 278 |
manualBtn.onclick = (e) => {
|
| 279 |
e.stopPropagation();
|
| 280 |
openManualMarkModal(s.id, s.batch_name || '', s.date || '', s.slot_start || '', s.slot_end || '');
|
app/static/js/calendar.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
| 1 |
/* calendar.js — 7-col × 4-row week grid */
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
const SLOT_LABELS = {
|
| 4 |
1: '09:00 – 11:30',
|
| 5 |
2: '11:30 – 14:00',
|
|
@@ -82,27 +88,59 @@ function _buildGrid(weekStart, sessions, allMode) {
|
|
| 82 |
|
| 83 |
const entries = map[key] || [];
|
| 84 |
if (entries.length) {
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
| 87 |
const border = color ? `border-left:3px solid ${color};padding-left:4px;margin-bottom:4px;` : '';
|
| 88 |
// Attendance pill
|
| 89 |
let attHtml = '';
|
| 90 |
if (s.enrolled_count != null) {
|
| 91 |
-
const scanned
|
| 92 |
-
const lateNote
|
| 93 |
-
const pct
|
| 94 |
const pillColor = pct >= 75 ? '#198754' : pct >= 40 ? '#fd7e14' : '#6c757d';
|
| 95 |
attHtml = `<br><small style="color:${pillColor};font-weight:600">`
|
| 96 |
+ `${scanned}/${s.enrolled_count} present${lateNote}</small>`;
|
| 97 |
}
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
${
|
| 103 |
-
${
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
cell.title = entries.map(s => {
|
| 107 |
const scanned = (s.present_count || 0) + (s.late_count || 0);
|
| 108 |
const attNote = s.enrolled_count != null
|
|
|
|
| 1 |
/* calendar.js — 7-col × 4-row week grid */
|
| 2 |
|
| 3 |
+
function _getRole() {
|
| 4 |
+
const tok = localStorage.getItem('ss_token');
|
| 5 |
+
if (!tok) return null;
|
| 6 |
+
try { return JSON.parse(atob(tok.split('.')[1])).role; } catch { return null; }
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
const SLOT_LABELS = {
|
| 10 |
1: '09:00 – 11:30',
|
| 11 |
2: '11:30 – 14:00',
|
|
|
|
| 88 |
|
| 89 |
const entries = map[key] || [];
|
| 90 |
if (entries.length) {
|
| 91 |
+
const role = _getRole();
|
| 92 |
+
const canMark = role === 'mentor' || role === 'admin';
|
| 93 |
+
|
| 94 |
+
entries.forEach(s => {
|
| 95 |
+
const color = allMode ? mentorColor(s.mentor_name ?? '') : null;
|
| 96 |
const border = color ? `border-left:3px solid ${color};padding-left:4px;margin-bottom:4px;` : '';
|
| 97 |
// Attendance pill
|
| 98 |
let attHtml = '';
|
| 99 |
if (s.enrolled_count != null) {
|
| 100 |
+
const scanned = (s.present_count || 0) + (s.late_count || 0);
|
| 101 |
+
const lateNote = s.late_count ? ` · ${s.late_count} late` : '';
|
| 102 |
+
const pct = s.enrolled_count > 0 ? Math.round(scanned / s.enrolled_count * 100) : 0;
|
| 103 |
const pillColor = pct >= 75 ? '#198754' : pct >= 40 ? '#fd7e14' : '#6c757d';
|
| 104 |
attHtml = `<br><small style="color:${pillColor};font-weight:600">`
|
| 105 |
+ `${scanned}/${s.enrolled_count} present${lateNote}</small>`;
|
| 106 |
}
|
| 107 |
+
|
| 108 |
+
const wrap = document.createElement('div');
|
| 109 |
+
wrap.style.cssText = border;
|
| 110 |
+
wrap.innerHTML =
|
| 111 |
+
`<strong style="${color ? 'color:' + color : ''}">${escHtml(s.batch_name ?? '')}</strong><br>`
|
| 112 |
+
+ `<small>${escHtml(s.mentor_name ?? '')}</small>`
|
| 113 |
+
+ (s.room_name ? `<br><small class="text-muted">${escHtml(s.room_name)}</small>` : '')
|
| 114 |
+
+ (s.is_locked ? ' <span class="badge badge-locked">Locked</span>' : '')
|
| 115 |
+
+ attHtml;
|
| 116 |
+
|
| 117 |
+
if (canMark) {
|
| 118 |
+
wrap.appendChild(document.createElement('br'));
|
| 119 |
+
|
| 120 |
+
const qrBtn = document.createElement('button');
|
| 121 |
+
qrBtn.className = 'btn btn-sm btn-primary mt-1 me-1 fw-semibold';
|
| 122 |
+
qrBtn.textContent = '⬛ QR';
|
| 123 |
+
qrBtn.style.cssText = 'font-size:0.7rem;border-width:2px;';
|
| 124 |
+
qrBtn.onclick = e => {
|
| 125 |
+
e.stopPropagation();
|
| 126 |
+
openQrModal(s.id, s.batch_name || '', s.date || '', s.slot_start || '', s.slot_end || '');
|
| 127 |
+
};
|
| 128 |
+
wrap.appendChild(qrBtn);
|
| 129 |
+
|
| 130 |
+
const markBtn = document.createElement('button');
|
| 131 |
+
markBtn.className = 'btn btn-sm btn-success mt-1 fw-semibold';
|
| 132 |
+
markBtn.textContent = '✓ Mark';
|
| 133 |
+
markBtn.style.cssText = 'font-size:0.7rem;border-width:2px;';
|
| 134 |
+
markBtn.onclick = e => {
|
| 135 |
+
e.stopPropagation();
|
| 136 |
+
openManualMarkModal(s.id, s.batch_name || '', s.date || '', s.slot_start || '', s.slot_end || '');
|
| 137 |
+
};
|
| 138 |
+
wrap.appendChild(markBtn);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
cell.appendChild(wrap);
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
cell.title = entries.map(s => {
|
| 145 |
const scanned = (s.present_count || 0) + (s.late_count || 0);
|
| 146 |
const attNote = s.enrolled_count != null
|
app/static/js/crud.js
CHANGED
|
@@ -352,8 +352,9 @@ document.getElementById('mdNextMonth')?.addEventListener('click', () => {
|
|
| 352 |
// ═══════════════════════════════════════════════════════════════════════════════
|
| 353 |
// STUDENTS
|
| 354 |
// ═══════════════════════════════════════════════════════════════════════════════
|
| 355 |
-
|
| 356 |
-
|
|
|
|
| 357 |
renderTable('studentsTbody', rows, [
|
| 358 |
{ key: 'name', label: 'Name', render: (v, row) =>
|
| 359 |
`<a href="#" class="fw-semibold text-decoration-none"
|
|
@@ -366,6 +367,29 @@ async function loadStudents() {
|
|
| 366 |
]);
|
| 367 |
}
|
| 368 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
const _studentFields = [
|
| 370 |
{ key: 'name', label: 'Name', placeholder: 'e.g. Priya Singh' },
|
| 371 |
{ key: 'email', label: 'Email', type: 'text', placeholder: 'student@example.com' },
|
|
|
|
| 352 |
// ═══════════════════════════════════════════════════════════════════════════════
|
| 353 |
// STUDENTS
|
| 354 |
// ═══════════════════════════════════════════════════════════════════════════════
|
| 355 |
+
let _allStudents = [];
|
| 356 |
+
|
| 357 |
+
function _renderStudentTable(rows) {
|
| 358 |
renderTable('studentsTbody', rows, [
|
| 359 |
{ key: 'name', label: 'Name', render: (v, row) =>
|
| 360 |
`<a href="#" class="fw-semibold text-decoration-none"
|
|
|
|
| 367 |
]);
|
| 368 |
}
|
| 369 |
|
| 370 |
+
function _applyStudentFilters() {
|
| 371 |
+
const name = (document.getElementById('sfName')?.value || '').toLowerCase();
|
| 372 |
+
const email = (document.getElementById('sfEmail')?.value || '').toLowerCase();
|
| 373 |
+
const filtered = _allStudents.filter(s =>
|
| 374 |
+
(!name || (s.name || '').toLowerCase().includes(name)) &&
|
| 375 |
+
(!email || (s.email || '').toLowerCase().includes(email))
|
| 376 |
+
);
|
| 377 |
+
_renderStudentTable(filtered);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
function _clearStudentFilters() {
|
| 381 |
+
const n = document.getElementById('sfName');
|
| 382 |
+
const e = document.getElementById('sfEmail');
|
| 383 |
+
if (n) n.value = '';
|
| 384 |
+
if (e) e.value = '';
|
| 385 |
+
_renderStudentTable(_allStudents);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
async function loadStudents() {
|
| 389 |
+
_allStudents = await apiFetch('/students');
|
| 390 |
+
_applyStudentFilters();
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
const _studentFields = [
|
| 394 |
{ key: 'name', label: 'Name', placeholder: 'e.g. Priya Singh' },
|
| 395 |
{ key: 'email', label: 'Email', type: 'text', placeholder: 'student@example.com' },
|
app/templates/index.html
CHANGED
|
@@ -181,6 +181,18 @@
|
|
| 181 |
<h4 class="fw-bold mb-0">Students</h4>
|
| 182 |
<button class="btn btn-primary btn-sm admin-action" onclick="addStudent()">+ Add Student</button>
|
| 183 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
<div class="table-responsive">
|
| 185 |
<table class="table table-sm table-hover">
|
| 186 |
<thead class="table-light"><tr><th>Name</th><th>Email</th><th>Mobile</th><th>Actions</th></tr></thead>
|
|
@@ -574,9 +586,9 @@
|
|
| 574 |
</main>
|
| 575 |
|
| 576 |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
| 577 |
-
<script src="/static/js/app.js?v=
|
| 578 |
-
<script src="/static/js/crud.js?v=
|
| 579 |
-
<script src="/static/js/calendar.js?v=
|
| 580 |
<script src="/static/js/ingest.js?v=7"></script>
|
| 581 |
<script src="/static/js/attendance.js?v=7"></script>
|
| 582 |
</body>
|
|
|
|
| 181 |
<h4 class="fw-bold mb-0">Students</h4>
|
| 182 |
<button class="btn btn-primary btn-sm admin-action" onclick="addStudent()">+ Add Student</button>
|
| 183 |
</div>
|
| 184 |
+
<!-- Filters -->
|
| 185 |
+
<div class="row g-2 mb-3">
|
| 186 |
+
<div class="col-sm-5">
|
| 187 |
+
<input id="sfName" type="text" class="form-control form-control-sm" placeholder="Filter by name…" oninput="_applyStudentFilters()">
|
| 188 |
+
</div>
|
| 189 |
+
<div class="col-sm-5">
|
| 190 |
+
<input id="sfEmail" type="text" class="form-control form-control-sm" placeholder="Filter by email…" oninput="_applyStudentFilters()">
|
| 191 |
+
</div>
|
| 192 |
+
<div class="col-sm-2">
|
| 193 |
+
<button class="btn btn-sm btn-outline-secondary w-100" onclick="_clearStudentFilters()">Clear</button>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
<div class="table-responsive">
|
| 197 |
<table class="table table-sm table-hover">
|
| 198 |
<thead class="table-light"><tr><th>Name</th><th>Email</th><th>Mobile</th><th>Actions</th></tr></thead>
|
|
|
|
| 586 |
</main>
|
| 587 |
|
| 588 |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
| 589 |
+
<script src="/static/js/app.js?v=16"></script>
|
| 590 |
+
<script src="/static/js/crud.js?v=15"></script>
|
| 591 |
+
<script src="/static/js/calendar.js?v=9"></script>
|
| 592 |
<script src="/static/js/ingest.js?v=7"></script>
|
| 593 |
<script src="/static/js/attendance.js?v=7"></script>
|
| 594 |
</body>
|
test_data/test_api.py
CHANGED
|
@@ -1076,6 +1076,25 @@ def test_adhoc_attendance(base: str):
|
|
| 1076 |
ok(f" after manual mark: present={mark_data.get('present_count')} "
|
| 1077 |
f"late={mark_data.get('late_count')} absent={mark_data.get('absent_count')}")
|
| 1078 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1079 |
# ── 8. QR dev-simulate ────────────────────────────────────────────────
|
| 1080 |
r = post(base, f"/api/v1/attendance/sessions/{created_session_id}/dev-simulate",
|
| 1081 |
{"student_ids": [created_student_ids[3]]})
|
|
|
|
| 1076 |
ok(f" after manual mark: present={mark_data.get('present_count')} "
|
| 1077 |
f"late={mark_data.get('late_count')} absent={mark_data.get('absent_count')}")
|
| 1078 |
|
| 1079 |
+
# ── 7b. Verify absent is stored as an explicit DB record ──────────────
|
| 1080 |
+
r = get(base, f"/api/v1/attendance/sessions/{created_session_id}/records")
|
| 1081 |
+
records = check("GET /api/v1/attendance/sessions/{id}/records (after manual mark)", r)
|
| 1082 |
+
if records is not None:
|
| 1083 |
+
recs = r.json() if r else []
|
| 1084 |
+
absent_recs = [x for x in recs if x.get("attendee_id") == created_student_ids[2]]
|
| 1085 |
+
if absent_recs and absent_recs[0].get("status") == "absent":
|
| 1086 |
+
ok(" absent stored as explicit record", f"marked_by={absent_recs[0].get('marked_by')}")
|
| 1087 |
+
else:
|
| 1088 |
+
fail(" absent stored as explicit record",
|
| 1089 |
+
f"no absent record found for student[2] (got {absent_recs})")
|
| 1090 |
+
# Verify marked_by is set (should be the admin's email, not None)
|
| 1091 |
+
for rec in recs:
|
| 1092 |
+
if rec.get("attendee_id") in created_student_ids[:3] and not rec.get("marked_by"):
|
| 1093 |
+
fail(" marked_by is set on manual records", f"record {rec.get('id')} has marked_by=None")
|
| 1094 |
+
break
|
| 1095 |
+
else:
|
| 1096 |
+
ok(" marked_by set on all manual records")
|
| 1097 |
+
|
| 1098 |
# ── 8. QR dev-simulate ────────────────────────────────────────────────
|
| 1099 |
r = post(base, f"/api/v1/attendance/sessions/{created_session_id}/dev-simulate",
|
| 1100 |
{"student_ids": [created_student_ids[3]]})
|