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 a sessdate (Unix epoch), a duration, and a statusset integer naming which status set applies to it.
  • The status definitions (mod_attendance_get_sessionstatuses) list, for that set, each status’s id, acronym (P, L, A, E, or anything an instructor typed), description, grade, and setnumber.
  • The taken record links a studentid to the statusid the instructor chose, not to an acronym — so you resolve statusid → acronym → canonical rather 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.

Resolving Moodle attendance statuses to canonical states A resolution pipeline. On the left, a Moodle taken record points to a status id rather than a letter. That status id is resolved inside the correct per-course status set, selected by the session's statusset number, into an acronym such as P, L, A, E or a custom acronym like AA. Each acronym is looked up in a per-course mapping table that also considers the status description and points. The lookup coerces the acronym to one of four canonical states: present, tardy, absent or excused. Any acronym missing from the table is routed to a quarantine queue instead of being coerced to a default. The canonical events flow onward to anomaly detection. taken record student_token statusid → not a letter per-course status set chosen by session.statusset P · Present L · Late A · Absent E · Excused AA · custom acronym per-course mapping table acronym + description → canonical state unmapped → quarantine never default-coerce canonical PRESENT TARDY ABSENT EXCUSED → anomaly detection

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

python
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 a for course 900 and course 902 and assert they yield ABSENT and EXCUSED respectively — proof that the per-course table, not a global default, decided the state.
  • Custom acronyms resolve. Feed statusid 13 (AA) and assert Canonical.EXCUSED; feed statusid 14 (UA) and assert Canonical.ABSENT, confirming instructor-defined codes are honored.
  • The right set is used. Point a session at statusset 1 while only setnumber 0 is indexed and confirm resolve_acronym raises rather than returning a status from the wrong set.
  • Unmapped acronyms quarantine. Pass an acronym absent from the course table and assert a ValueError is raised — nothing is silently coerced to ABSENT.
  • Token and timestamp. event.student_token is 64 hex chars, the raw student_id appears nowhere, and event.session_ts.tzinfo is UTC.

Troubleshooting

  • Every event in some sessions is mislabeled. The session’s statusset did not match the setnumber you indexed, so statusid resolved against the wrong set. Key statuses by (setnumber, status_id) and select with the session’s statusset, as the script does.
  • A resolves to ABSENT in a course where it means “Authorized.” You reused a global map. Curate a COURSE_MAPS entry per course and let the description confirm the intent, mirroring the acronym-collision warning in Attendance State Normalization Rules.
  • KeyError on statusid during 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 from mod_attendance_get_session before normalizing, and quarantine ids that remain unresolved.
  • Late arrivals disappear into PRESENT. Your table mapped L to PRESENT to simplify reporting, erasing the punctuality signal. Map L (and custom late codes) to TARDY so Attendance Anomaly Detection can still see tardiness.
  • Session times land a day off. sessdate is a Unix epoch, not an ISO string; parsing it as local time shifts the date. Convert with datetime.fromtimestamp(epoch, tz=timezone.utc) so the session key is UTC-correct.
  • A student id shows up in logs. Something logged the raw studentid before tokenization. Hash at the boundary with tokenize() and log only the student_token, keeping the identity vault join out of the analytics store per the Moodle Course & User Schema mapping.

Part of: Attendance State Normalization Rules