Attendance System¶
A dual-mode attendance system for K-12 (daily, section-based) and Higher Education (subject-based per semester). Trainers mark, learners view, admins see analytics, and parents give consent for face recognition. Recognition methods include face, face_group, manual, and QR.
This doc describes the system as built. The admin/teacher boundary it describes is being restructured — see Attendance: administration vs collection for why marking is moving off
/admin/attendanceand onto the teaching surface, and update this page when that lands.Retrofit, 2026-08-24. The Higher Ed half of this page described intent, not behaviour: until webapp PR
fix/higher-ed-subject-attendancethe subject dashboard read only the K-12 faculty table, so every university faculty member saw "No subjects assigned" the moment they opened Attendance. The cohort model below is what ships now. See the changelog at the end.
Why this exists¶
Who asked: reproduced on prod on 2026-08-24 in the "Kwilo Institutions" demo tenant (Kwilo University unit) by the founder while walking the sales demo; same structural gap already recorded for Exam Ops and Timetable in erp-higher-ed-scheduling.
The pain: the registrar assigns Anita Rao → Mathematics for BSC / CS / Semester 1 in University setup → Faculty. Anita opens Attendance Management and reads "You don't have any subjects assigned yet. Contact your administrator." The administrator did assign them. The product contradicts itself on the first screen a faculty member needs.
Cost of not doing it: attendance was unusable for every higher-ed unit — university, engineering college, degree college, PU college — which is the primary segment. Three of the four units in the demo tenant were affected; only the K-12 school worked. No workaround existed in the UI.
Validated or guess: validated by reproduction on prod data (12 teacher_level_subjects rows, zero subject_teachers rows for the unit).
How we know it worked: Anita (a real instructor user in a university org unit) opens Attendance and sees her semester subjects with their cohorts; she marks a Semester 1 roster; the student sees the record in their own attendance view. Sean Ellis counterfactual: remove it a week after shipping and every higher-ed faculty member is back to "no subjects" with a screenshot — yes, they would notice.
How it works¶
K-12 vs Higher Ed¶
| Aspect | K-12 | Higher Education |
|---|---|---|
| Cohort | Class → Section |
Program → Branch → Semester (→ optional batch) |
| Student placement | students.section_id |
student_levels row (students.section_id is NULL) |
| Faculty source | subject_teachers (subject × class/section) |
teacher_level_subjects (subject × semester) |
| Frequency | Daily session | Per subject per day |
| Trainer | Class trainer marks all | Subject faculty marks own |
| Learner view | Calendar + streaks | Subject-wise percentages |
The cohort rule (what attendance is keyed on)¶
Every attendance record belongs to one cohort: (academic_level_id, section_id | NULL).
- K-12 section —
section_idset, the section hangs off aClass. Unchanged. - Higher-ed batch — a
Sectionrow parented on the semester (academic_level_idset,class_idNULL). Batches are optional: a semester may have two, one, or none. - Semester-wide —
section_idNULL. Used when the semester has no batches, and also for the unbatched remainder of a semester that has some.
Every student belongs to exactly one cohort: a batched student (student_levels.section = "A") to batch A; a student whose student_levels.section is NULL, or names a batch that does not exist, to the semester-wide cohort. So a faculty member of "Maths, Sem 1" whose semester has batches A and B plus three unbatched students sees three cohorts: Semester 1 / A, Semester 1 / B, Semester 1. Nobody is counted twice and nobody is lost.
class_id wins when a section has both parents. Migration 00072 mirrors an academic_level_id onto K-12 sections, so branching on the level first would route every school to student_levels — where students enrolled before the K-12 dual-write have no rows — and silently empty their rosters. A section with no class is the only true higher-ed cohort. This is the same precedence exam-ops seat allocation uses; both now call one resolver, src/erp/cohort.py, so hall tickets and attendance sheets can never disagree about who sits in a section.
Faculty are assigned per semester, not per batch. teacher_level_subjects has no section_id: Anita teaching Maths to Sem 1 teaches all of Sem 1's batches. Decided 2026-08-24; per-batch faculty is a deliberate non-goal (see below).
Trainer marking¶
K-12: at /attendance (role-dispatched), pick the assigned section (pre-loaded from user context) and a date (defaults to today), then mark each learner Present / Absent / Late / Excused, with optional bulk actions. Submit posts to /attendance/section/{section_id}.
Higher Ed: the same page lists one entry per (subject, cohort) from GET /attendance/subject/dashboard/teacher — "Mathematics (MATH) — Semester 1 / A", "Mathematics (MATH) — Semester 1". Picking one loads that cohort's roster; marking posts to /attendance/subject/mark-bulk with section_id set for a batch and omitted for a semester-wide cohort. A student who is not in the chosen cohort is reported as a per-row failure ("Student is not in A"), never silently written into the wrong cohort.
Admins (unit manager / org admin) see every assignment in their unit or org scope on the same dashboard — a K-12 unit manager in the same organization does not see the university's subjects.
Learner dashboard (/attendance)¶
K-12 learners see a monthly calendar (green present, red absent, amber late, blue excused), a stats panel (attendance rate, days present/absent/late, streak), and month navigation. College learners get a smart-detection view that tries the college endpoint first and falls back to K-12: an enrollment selector (program, branch, semester tabs) and a subject-wise table (subject, total classes, present, absent, attendance %) with color coding (green ≥75%, yellow 50-75%, red <50%) and per-subject date-by-date detail. Subject history is keyed on the semester, so a batch record and a semester-wide record for the same student both count.
Face enrollment (/admin/face-enrollment)¶
Three tabs:
- Dashboard: total learners, consent pending/accepted/rejected, enrollment rate.
- Learners: table (name, roll number, class/semester, parent, consent status, enrolled) with status/search filters and a bulk "send consent requests" action.
- Enrollment wizard: pick a learner, capture a photo (camera) or upload, preview, submit to
/attendance/enroll-face, which returnsembedding_version,quality_score,enrolled_at.
Parental consent¶
Admin: select learners → "Send Consent Requests"
→ POST /attendance/consent/request { student_ids } → SMS/Email to parents
Parent: opens consent link → views learner info, school logo, consent text
(data collection, purpose, security, retention, rights)
→ Accept or Reject → POST /attendance/consent/{id}/accept (or /reject)
Admin: dashboard shows updated counts; enrolled learners can use face attendance
Consent requirements vary by institution: required for K-12 schools and PU colleges; universities and degree colleges may not require it.
Non-goals¶
- Merging
subject_teachersandteacher_level_subjects. Two tables for "who teaches what" are real debt, but the cohort layer is where attendance, exam-ops and timetable meet; unifying the faculty tables belongs to the enrolment-history unification track, not here. - Per-batch faculty assignment. A faculty member of a semester subject teaches every batch of that semester. If a university needs "Anita → Sem 1 A, Vikram → Sem 1 B" for the same subject, that is a nullable
section_idonteacher_level_subjects— small, and it slots into this cohort model — but it is not asked for yet. - Auto-creating a batch per semester. Rejected in erp-higher-ed-scheduling as Option C: it would enshrine two non-syncing representations of "semester". Batches are optional instead.
- Timetable-session-driven marking for higher ed. Faculty pick a cohort by hand today; the "mark from today's slot" flow stays as it is.
Rejected alternatives¶
- Auto-materialise a batch under every semester so
section_idcan stay NOT NULL. Cheapest, and exactly the workaround the scheduling doc rejected. It also produces "Semester 1 / A: 0 students" rows on tenants that never batched anyone, which is what the prod demo tenant looked like. - Union
teacher_level_subjectsinto the dashboard only, keep every read/write keyed onstudents.section_id. Fixes the empty dashboard and nothing else: rosters, registers, stats and marks all stay empty for higher ed, because college students have nosection_id. - Branch on
org_unit_type. Mirrored K-12 sections carry both parents, and PU college sits in both the higher-ed and the K-12 type sets. The section's own shape (class_idpresent or not) is the only discriminator that cannot lie.
Endpoints¶
K-12 attendance:
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /attendance/section/{section_id} |
Mark section attendance |
| GET | /attendance/my-calendar |
Learner calendar |
| GET | /attendance/my-stats |
Learner stats |
| GET | /attendance/dashboard/teacher |
Trainer dashboard |
| GET | /attendance/stats/{section_id} |
Section stats |
Subject (college-mode) attendance. section_id is a query/body parameter on every cohort-scoped call: pass it for a K-12 section or a higher-ed batch, omit it for the semester-wide cohort.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /attendance/subject/dashboard/teacher |
One row per (subject, cohort) the caller marks |
| GET | /attendance/subject/level/{level_id}/students?section_id= |
Roster of a cohort |
| GET | /attendance/subject/{subject_id}/level/{level_id}?section_id=&attendance_date= |
Register for a day |
| POST | /attendance/subject/mark-bulk |
Bulk mark a cohort |
| POST | /attendance/subject/mark |
Mark one student (section_id optional; falls back to the student's K-12 section, else the semester) |
| GET | /attendance/subject/stats/{subject_id}/level/{level_id}?section_id= |
Subject stats over a range |
| GET | /attendance/subject/stats/level/{level_id}/summary?section_id= |
All subjects of a cohort |
| GET | /attendance/subject/student/{student_id}/history?academic_level_id= |
Learner's subject history (semester-keyed) |
| GET | /attendance/subject/dashboard/student |
Learner dashboard |
Face enrollment:
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /attendance/enroll-face |
Enroll face |
| PUT | /attendance/enroll-face/{id} |
Update enrollment |
| DELETE | /attendance/enroll-face/{id} |
Delete enrollment |
Consent:
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /attendance/consent/check-required |
Check if needed |
| POST | /attendance/consent/request |
Request from parents |
| GET | /attendance/consent/dashboard |
Admin dashboard |
| POST | /attendance/consent/{id}/accept |
Parent accepts |
| POST | /attendance/consent/{id}/reject |
Parent rejects |
Data¶
erp.face_attendance_records carries the cohort: section_id (nullable since migration a7c2e9f4b310) and academic_level_id, with CHECK (section_id IS NOT NULL OR academic_level_id IS NOT NULL). The NULL-safe idempotency index uq_face_attendance_manual_active coalesces section_id and academic_level_id as well as subject_id / session_name, so a semester-wide re-mark updates in place. Reads filter with IS NOT DISTINCT FROM: a NULL section matches only NULL, never "any section".
The migration is backward compatible (the previous image never writes NULL) and lands before the code. Its downgrade refuses to run while semester-wide rows exist.
Where it lives¶
Routes: /attendance (role-dispatched), /admin/face-enrollment.
Backend: apps/backend/src/erp/cohort.py (resolver, shared with exam ops), apps/backend/src/erp/attendance/{router,service}.py.
Frontend: apps/web/src/erp/attendance/pages/CollegeAttendancePage — the subject key is ${subject}-${level}-${section ?? 'level'} so a semester-wide cohort never collides with a batch.
Open follow-ups¶
- The subject endpoints check tenant scope but not assignment scope: any instructor in the tenant can mark any cohort.
verify_section_accessexists and now knows about semester batches; wiring it in is a separate security change. - Campus home reuses "Not set up" for both module not entitled and no data yet; unrelated to attendance but found on the same day.
Changelog¶
- 2026-08-24 — retrofit + higher-ed cohorts. Doc rewritten to match behaviour. Webapp:
fix/higher-ed-subject-attendance(shared cohort resolver,teacher_level_subjectson the dashboard,section_idoptional everywhere, migrationa7c2e9f4b310). Decisions recorded: batches optional; faculty per semester. - 2026-08-14 — admin vs collection split (webapp #1560, docs #38): marking moved to the instructor surface; admins keep corrections.