arcshukla commited on
Commit
170e99a
·
1 Parent(s): dcc9e4f

fixing attendance

Browse files
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) -> AttendanceSummary:
 
 
 
 
 
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 = max(0, enrolled - present - late)
 
 
 
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
- if not is_qr_available(s.date, s.slot_id):
 
 
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=None,
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 == "absent":
437
- # Remove existing record if any (absent = no record in table)
438
- existing = await _get_attendance_record(
439
- session_id, entry.student_id, ATTENDEE_TYPE_STUDENT, db
440
- )
441
- if existing:
442
- await db.delete(existing)
443
- await db.commit()
444
- else:
445
- if entry.status not in (ATTENDANCE_STATUS_PRESENT, ATTENDANCE_STATUS_LATE):
446
- continue
447
- await _upsert_attendance(
448
- db,
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 is_past:
 
 
 
250
  past_count += 1
251
- att_status = att_map.get(s.id)
252
- if att_status in (ATTENDANCE_STATUS_PRESENT, ATTENDANCE_STATUS_LATE):
253
- attendance = att_status
254
- present_count += 1
255
- if att_status == ATTENDANCE_STATUS_LATE:
256
- late_count += 1
257
- else:
258
- attendance = "absent"
259
- absent_count += 1
 
 
 
 
 
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-outline-primary mt-1 me-1';
264
- qrBtn.textContent = 'QR';
265
- qrBtn.style.fontSize = '0.65rem';
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-outline-secondary mt-1';
276
- manualBtn.textContent = 'Mark';
277
- manualBtn.style.fontSize = '0.65rem';
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
- cell.innerHTML = entries.map(s => {
86
- const color = allMode ? mentorColor(s.mentor_name ?? '') : null;
 
 
 
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 = (s.present_count || 0) + (s.late_count || 0);
92
- const lateNote = s.late_count ? ` · ${s.late_count} late` : '';
93
- const pct = s.enrolled_count > 0 ? Math.round(scanned / s.enrolled_count * 100) : 0;
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
- return `<div style="${border}">
99
- <strong style="${color ? 'color:'+color : ''}">${escHtml(s.batch_name ?? '')}</strong><br>
100
- <small>${escHtml(s.mentor_name ?? '')}</small>
101
- ${s.room_name ? `<br><small class="text-muted">${escHtml(s.room_name)}</small>` : ''}
102
- ${s.is_locked ? ' <span class="badge badge-locked">Locked</span>' : ''}
103
- ${attHtml}
104
- </div>`;
105
- }).join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- async function loadStudents() {
356
- const rows = await apiFetch('/students');
 
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=15"></script>
578
- <script src="/static/js/crud.js?v=14"></script>
579
- <script src="/static/js/calendar.js?v=8"></script>
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]]})