Mapping Moodle Attendance Plugin States to Canonical
Moodle’s mod_attendance plugin does not ship a fixed attendance vocabulary. Each course defines its own set of statuses — an acronym, a description, a points value, and a setnumber that groups them — so the letter A might mean “Absent” in one course, “Authorized” in another, and “Attended (online)” in a third. A pipeline that hard-codes A → ABSENT will corrupt exactly the departments that customized their statuses, which are usually the ones that care most about the data. This guide shows how to load Moodle’s per-session status sets, resolve each session’s acronyms through a per-course mapping table, and coerce them into a canonical present/absent/tardy/excused model in Python. It implements the acronym-collision rule flagged in Attendance State Normalization Rules, draws its course and session keys from the Moodle Course & User Schema, and produces the clean canonical states that Attendance Anomaly Detection consumes.
The canonical target is four states — PRESENT, ABSENT, TARDY, EXCUSED — with an implicit UNRECORDED for sessions a student was never marked in. TARDY is the name this layer gives the “late” state in the parent attendance state machine; mapping to it (rather than folding late arrivals into PRESENT or ABSENT) is what preserves the punctuality signal downstream analytics need.
How Moodle models an attendance status
Three plugin objects have to be read together before a single event can be normalized:
- The session (
mod_attendance_get_sessions) carries asessdate(Unix epoch), aduration, and astatussetinteger naming which status set applies to it. - The status definitions (
mod_attendance_get_session→statuses) list, for that set, each status’sid,acronym(P,L,A,E, or anything an instructor typed),description,grade, andsetnumber. - The taken record links a
studentidto thestatusidthe instructor chose, not to an acronym — so you resolvestatusid → acronym → canonicalrather than reading a letter directly.
Because setnumber versions the status set, a course that revised its statuses mid-term has two sets, and a session points at exactly one of them through statusset. Resolving against the wrong set silently mislabels every event in the sessions that used the other one.
Prerequisites
Confirm each of these before running the mapping — the script assumes they are in place.
Step-by-step implementation
1. Group status definitions by (course, setnumber). Build one lookup per status set so a session can resolve its statusid against the exact set it used, because a mid-term status revision produces two sets and the wrong one mislabels everything.
2. Resolve statusid to an acronym, not the reverse. The taken record references a status id; look it up inside the correct set to recover the acronym, since two sets can reuse the same letter for different ids.
3. Consult a per-course mapping table keyed on the normalized acronym. Lowercase and strip the acronym, then map it through a table you curated per course, so A resolves to ABSENT where that is what it means and to EXCUSED where a department defined A as “Authorized.”
4. Use the status description as a tie-breaker, never a default. When an acronym is ambiguous, let the human-readable description disambiguate (“Authorized absence” → EXCUSED), but treat a description you do not recognize as a reason to quarantine, not to guess.
5. Route unmapped acronyms to a quarantine queue. Any acronym absent from the course table raises instead of coercing to a default, because a silent → ABSENT fallback is exactly the latent bug that corrupts customized courses.
6. Tokenize the student id and parse the session time to UTC. Hash studentid at the boundary and convert sessdate from epoch to an aware UTC datetime, so the emitted event is FERPA-safe and time-zone-correct before it reaches the warehouse.
7. Emit a canonical event carrying the raw acronym for audit. Keep the verbatim Moodle acronym alongside the canonical state so a retroactive correction or an appeal can trace exactly what the instructor recorded.
Complete runnable code block
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
class Canonical(str, Enum):
PRESENT = "PRESENT"
ABSENT = "ABSENT"
TARDY = "TARDY" # the parent state machine calls this LATE
EXCUSED = "EXCUSED"
@dataclass(frozen=True)
class MoodleStatus:
status_id: int
acronym: str
description: str
setnumber: int
@dataclass(frozen=True)
class MoodleSession:
session_id: int
course_id: int
sessdate_epoch: int
statusset: int # which setnumber this session's statuses belong to
@dataclass(frozen=True)
class CanonicalEvent:
student_token: str
course_id: int
session_ts: datetime
canonical_state: Canonical
raw_acronym: str # preserved verbatim for the audit trail
# Per-course mapping tables. Acronyms are course-defined, so `a` is ABSENT in
# course 900 but EXCUSED in course 902 where the department redefined it.
COURSE_MAPS: dict[int, dict[str, Canonical]] = {
900: {"p": Canonical.PRESENT, "l": Canonical.TARDY,
"a": Canonical.ABSENT, "e": Canonical.EXCUSED},
902: {"p": Canonical.PRESENT, "l": Canonical.TARDY,
"a": Canonical.EXCUSED, # "Authorized" here, not "Absent"
"ua": Canonical.ABSENT, # custom: Unauthorized Absence
"aa": Canonical.EXCUSED}, # custom: Authorized Absence
}
def tokenize(student_id: str, salt: str = "moodle-attend") -> str:
"""Salted SHA-256 — the raw Moodle studentid never leaves this boundary."""
return hashlib.sha256(f"{salt}:{student_id}".encode("utf-8")).hexdigest()
def index_statuses(statuses: list[MoodleStatus]) -> dict[tuple[int, int], MoodleStatus]:
"""Key each status by (setnumber, status_id) so a session hits the right set."""
return {(s.setnumber, s.status_id): s for s in statuses}
def resolve_acronym(session: MoodleSession, status_id: int,
status_index: dict[tuple[int, int], MoodleStatus]) -> MoodleStatus:
"""Recover the acronym for a taken record inside the session's own set."""
key = (session.statusset, status_id)
if key not in status_index:
raise KeyError(f"statusid {status_id} not in set {session.statusset} "
f"for course {session.course_id} -> quarantine")
return status_index[key]
def to_canonical(course_id: int, acronym: str) -> Canonical:
"""Map a normalized acronym to a canonical state; unmapped => quarantine."""
table = COURSE_MAPS.get(course_id)
if table is None:
raise KeyError(f"no mapping table curated for course {course_id}")
norm = acronym.strip().lower()
if norm not in table:
raise ValueError(f"unmapped acronym {acronym!r} in course {course_id} "
f"-> quarantine, never default-coerce")
return table[norm]
def normalize_event(session: MoodleSession, status_id: int, student_id: str,
status_index: dict[tuple[int, int], MoodleStatus]) -> CanonicalEvent:
"""Turn one Moodle taken record into an audited canonical attendance event."""
status = resolve_acronym(session, status_id, status_index)
canonical = to_canonical(session.course_id, status.acronym)
ts = datetime.fromtimestamp(session.sessdate_epoch, tz=timezone.utc)
return CanonicalEvent(
student_token=tokenize(student_id),
course_id=session.course_id,
session_ts=ts,
canonical_state=canonical,
raw_acronym=status.acronym,
)
if __name__ == "__main__":
statuses = [
MoodleStatus(11, "P", "Present", setnumber=0),
MoodleStatus(12, "L", "Late", setnumber=0),
MoodleStatus(13, "AA", "Authorized Absence", setnumber=0),
MoodleStatus(14, "UA", "Unauthorized Absence", setnumber=0),
]
index = index_statuses(statuses)
session = MoodleSession(session_id=5001, course_id=902,
sessdate_epoch=1_726_502_400, statusset=0)
# Instructor marked this student with statusid 13 ("AA" -> EXCUSED in course 902).
event = normalize_event(session, status_id=13,
student_id="stu-placeholder-3197", status_index=index)
print(f"token : {event.student_token[:16]}…")
print(f"session_ts : {event.session_ts.isoformat()}")
print(f"raw acronym: {event.raw_acronym}")
print(f"canonical : {event.canonical_state.value}")
assert event.canonical_state is Canonical.EXCUSED
print("mapped AA -> EXCUSED correctly for course 902")
Verification and output validation
Confirm the mapping before writing canonical events to the warehouse:
- Course-specific meaning holds. Map acronym
afor course900and course902and assert they yieldABSENTandEXCUSEDrespectively — proof that the per-course table, not a global default, decided the state. - Custom acronyms resolve. Feed
statusid 13(AA) and assertCanonical.EXCUSED; feedstatusid 14(UA) and assertCanonical.ABSENT, confirming instructor-defined codes are honored. - The right set is used. Point a session at
statusset 1while onlysetnumber 0is indexed and confirmresolve_acronymraises rather than returning a status from the wrong set. - Unmapped acronyms quarantine. Pass an acronym absent from the course table and assert a
ValueErroris raised — nothing is silently coerced toABSENT. - Token and timestamp.
event.student_tokenis 64 hex chars, the rawstudent_idappears nowhere, andevent.session_ts.tzinfois UTC.
Troubleshooting
- Every event in some sessions is mislabeled. The session’s
statussetdid not match thesetnumberyou indexed, sostatusidresolved against the wrong set. Key statuses by(setnumber, status_id)and select with the session’sstatusset, as the script does. Aresolves toABSENTin a course where it means “Authorized.” You reused a global map. Curate aCOURSE_MAPSentry per course and let thedescriptionconfirm the intent, mirroring the acronym-collision warning in Attendance State Normalization Rules.KeyErroronstatusidduring a bulk load. The taken record references a status from a set you did not fetch, common after a mid-term status revision. Pull every set for the course frommod_attendance_get_sessionbefore normalizing, and quarantine ids that remain unresolved.- Late arrivals disappear into
PRESENT. Your table mappedLtoPRESENTto simplify reporting, erasing the punctuality signal. MapL(and custom late codes) toTARDYso Attendance Anomaly Detection can still see tardiness. - Session times land a day off.
sessdateis a Unix epoch, not an ISO string; parsing it as local time shifts the date. Convert withdatetime.fromtimestamp(epoch, tz=timezone.utc)so the session key is UTC-correct. - A student id shows up in logs. Something logged the raw
studentidbefore tokenization. Hash at the boundary withtokenize()and log only thestudent_token, keeping the identity vault join out of the analytics store per the Moodle Course & User Schema mapping.
Related
- Attendance State Normalization Rules — the parent schema and state machine this Moodle mapping feeds into.
- Moodle Course & User Schema — defines the course, session, and user keys the taken records join on.
- Attendance Anomaly Detection — consumes the canonical present/absent/tardy/excused events this mapping emits.
Part of: Attendance State Normalization Rules