EMS

Technical Reference: ems.attendance_schedule

Overview

ems.attendance_schedule is a single weekly slot (weekday + start/end time + room) owned by an ems.attendance_template. A template groups everything about “who teaches what, to whom, where” into one record; its attendance_schedule_ids are the actual concrete weekday/time entries that make up that weekly timetable — e.g. a template for “Maths, group A” might have two schedule rows: Monday 9:00–10:00 and Wednesday 9:00–10:00, both in the same room.

This doc covers the model’s own fields/logic. The pipeline that creates, archives and rewrites these rows from a teacher’s live-edited or imported timetable (_sync_from_schedule/_sync_from_schedule_batch) is documented in attendance_template.md — not repeated here.

Module file: models/attendance/attendance_schedule.py (EmsAttendanceSchedule)


Fields

Field Type Notes
weekday Selection (“0”=Monday…”6”=Sunday) Do not renumber — matches Python’s date.weekday() values exactly, several computes rely on this.
name computed + stored "{template} \| {weekday} \| {time_range}", purely for the session form’s dropdown sort order (SQL sort on a non-stored field wouldn’t work).
start_time/end_time Float Hours as a decimal (e.g. 9.5 = 9:30).
start_date/end_date Datetime, computed + stored The template’s own start_date/end_date (a plain date) combined with this schedule’s start_time/end_time, converted local→UTC via ems.datetime_utils — stored as full datetimes because timezone-correct comparisons need a real date, not a bare time-of-day float.
time_range Char, computed + stored "HH:MM - HH:MM", derived from start_date/end_date converted back to local time.
teacher_ids Many2many, related='attendance_template_id.teacher_ids' Read-only mirror, used only for ir.rule permission filtering (security/rules/attendance.xml) — not for any business logic in this file.
has_sessions Boolean, computed True once this line has a real attendance_session_ids entry — see “Locking” below for what this used to gate (now superseded by an unconditional lock).

Locking (the manual “Edit” button was removed 2026-08-11; extended to an unconditional field lock the same day)

As of 2026-08-11, every field on this model except student_ids is unconditionally locked via a write() guard (_LOCKED_FIELDS = {'active', 'weekday', 'start_time', 'end_time', 'space_id', 'attendance_template_id', 'notes'}) - the same EMS_BYPASS_TEMPLATE_LOCK_KEY mechanism ems.attendance_template uses (see that doc’s “Access control” section). security/ ir.model.access.csv also revokes create/unlink for every group (matching the template’s own 1,1,0,0) - a line can now only ever come into existence, change, or go away as a consequence of the calendar-sync pipeline, never a direct edit, admin included. This widens what used to be a narrower, has_sessions-gated lock (below, kept for history): changing where/when a class actually happened after real roll-calls were taken against it would misrepresent that history (ems.attendance_session_header’s own space_id/weekday/etc. are related+store=True mirrors of these, see attendance_session.md) - the developer’s own call was that a direct edit is never legitimate regardless of session history, since it risks drifting the line out of sync with the calendar even before any attendance was ever taken.

Until 2026-08-11, a per-row “Edit” button (action_new_version()) let an admin/teacher unlock one line by hand - it archived this one line and cloned it under the same template, leaving the template itself and every other line untouched (archive-before-copy, same reasoning as the template-level version below: copying while the original line is still active would momentarily collide with the clone via check_overlap). Removed as part of plans/calendar_driven_attendance_templates.md’s point 3 (developer’s own call: “Este mecanismo que hicimos para ‘editar’ templates o schedules ha quedado obsoleto”) - now that the teacher’s calendar is the only legitimate source of change for a template’s identity, a manual per-line “Edit” escape hatch no longer fits; a room/time correction happens by editing the calendar instead and letting the sync pipeline reconcile it.

The underlying shared mechanism, ems.attendance_mixin._write_or_new_version(vals) (models/shared/attendance_mixin.py), was NOT removed - it’s still used internally by the schedule-sync pipeline (ems.attendance_template._archive_stale_schedule_sync/ _write_schedule_sync, see that model’s “CRUD flow”, which shares the exact same has_sessions predicate for its own per-line decisions). Only the direct, button-driven entry point on this model and ems.attendance_template is gone. The import wizard’s own room-reassignment conflict resolution (working_schedule.py’s _continue_from_db_conflicts) used to call line.right_schedule_id._write_or_new_version(...) directly too - the bottom-up sync redesign’s Phase 6 (2026-09-08) replaced that with two new shared methods on this model, _relocate_via_calendar_blocks(space)/_archive_via_calendar_blocks() (see their own docstrings in models/attendance/attendance_schedule.py), which move/archive the calendar block instead and let the automatic sync hook keep this model in sync as a consequence - see docs/en/developers/attendance/attendance_template.md’s “Bottom-up sync redesign” section for the full picture.


student_ids: the roster, and who keeps it current

The roster is this specific weekly slot’s own (a given day/time can genuinely differ - someone sitting in, someone excused), which is why it lives here rather than on the template. Three things write it, and only the third is manual:


Archiving never cascades to sessions, in either direction (settled 2026-08-06)

A same-day attempt added an action_archive() override here that cascaded to attendance_session_ids - reverted the same day on developer feedback: a schedule line can be archived for reasons that have nothing to do with a session’s own relevance. Concretely, a line’s logistics fields (weekday/space_id/start_time/end_time) are locked (see “Locking” above) - so a routine correction (fixing a room, say) doesn’t edit the line at all, it silently archives the old one and creates a new version via _write_or_new_version() above (now only reachable through the calendar-driven sync pipeline or the import wizard’s own conflict resolution, not a direct button - see “Locking” above), entirely as a side effect of what looks like a normal in-place edit from the calendar side. If that archive cascaded to sessions, every such routine correction would make the line’s whole attendance history disappear from a teacher’s default view - exactly the opposite of the intent (the sessions are still perfectly valid, current-course history; only the room/time bookkeeping changed). ems.attendance_session_header (attendance_session.md) carries its own active (via the ems.base mixin, not something this model needs to populate) but nothing in this codebase ever flips it automatically as a side effect of the schedule/template that originally scheduled it being archived - test_action_archive_does_not_cascade_to_sessions/ test_action_archive_on_template_does_not_cascade_to_sessions (tests/test_attendance_template.py) pin this down going forward. Sessions are never unlink()‘d either (see unlink() below) - they’re an independent historical record, managed on their own terms, not a dependent of either model. A teacher’s session views are therefore not scoped to the current course: past courses’ sessions stay listed, and archived ones are reached through the list’s own “Archived” filter (see attendance_session.md’s “Search view” section). Scoping them to the current course, if ever wanted, must be a view filter, not this cascade.


check_overlap: double-booking guard with a co-teaching exception

flowchart TD
    A["check_overlap()\n@api.constrains(weekday, start_time, end_time, space_id)"] --> B{"template active AND\nhas start/end date?"}
    B -- no --> Z["skip — an inactive/incomplete\ntemplate can't conflict"]
    B -- yes --> C["search other schedules:\nsame weekday, active template,\ndate ranges overlap,\nsame teacher OR same space"]
    C --> D{"any candidate's time range\nactually overlaps?"}
    D -- no --> Z
    D -- yes --> E{"same teacher on both?"}
    E -- yes --> G["ValidationError:\n'...for the same teacher'"]
    E -- no --> F{"is_co_teaching_with(other)?\nsame subject AND shares\nat least one group"}
    F -- yes --> Z2["allowed — legitimate\nco-taught session"]
    F -- no --> H["ValidationError:\n'...for the same space'"]

The co-teaching exception exists because a single class session can genuinely have more than one teacher assigned (support/co-teaching), each represented by their own ems.attendance_template (since a template’s teacher_ids is itself a set, but two separate templates — one per teacher pairing — is how co-teaching is modeled here) — both templates’ schedules legitimately share the same room/time/subject/group without being a real double-booking. is_co_teaching_with() is the shared predicate for this — also reused by ems.attendance_template.find_external_conflicts() (see attendance_template.md) against a not-yet-created entry dict, since one side isn’t a real record yet there.

find_room_conflicts()’s own candidate search runs with sudo() (issue #444, 2026-09-12) — whether a room/teacher is already double-booked is a fact about the whole school’s schedule, not something that should depend on the acting user’s own ir.rule visibility (security/rules/ attendance.xml’s “own data” restriction for group_teacher). See attendance_template.md for the full incident this fixed — a Head of Studies/Deputy Head of Studies editing a colleague’s schedule couldn’t see that colleague’s own already-existing session here, so the sync created a duplicate that then collided with it.

find_schedule_lines_for_teaching: reverse lookup from a calendar block to a schedule line

ems.attendance_mixin.find_schedule_lines_for_teaching(teacher, subject, groups, weekday, start_time, end_time) (models/shared/attendance_mixin.py) — given a teacher + subject + a set of groups + weekday + start/end time, returns every currently active line for that exact teaching slot: subject match, ANY group overlap (not exact set equality — mirrors the same “same teaching assignment” convention ems.working_schedules_import_wizard._classify_conflict_kind already uses), and weekday/time overlap. Deliberately NOT scoped by room (renamed from find_schedule_lines_for_slot and reworked 2026-08-10, after a real bug: the original version also matched on room, and a real teacher’s calendar block had drifted to a different room than the schedule line’s own — “el aula no es normal que cambie, [pero] no deberíamos usarla para las búsquedas”, since a teacher can freely change the room while taking attendance for an unplanned reason (e.g. an unscheduled workshop) — matching on it silently broke this exact lookup the moment that drift happened, leaving 4 real session headers stranded active in this dev DB despite their own template/schedule already being correctly archived). If more than one line matches (e.g. a stale leftover left behind by an earlier edit, alongside a newer one), every match is returned — the caller decides what to do with each, not this lookup.

Called on the model itself, not a specific record — there is no natural self for a lookup like this: self.env['ems.attendance_schedule'].find_schedule_lines_for_teaching(...).

Why it exists: resource.calendar.attendance (a teacher’s weekly calendar block) and ems.attendance_schedule (the recurring class-session line) have no direct FK between them by default — the link is inferred by matching (subject, group, weekday, time), which is exactly what this lookup does. Historically, course_transition_wizard._apply_calendar_archival() was this method’s main external caller — that’s no longer true (see below); its only remaining caller is ems.attendance_template._link_calendar_attendance(), internal to the sync pipeline itself.

The genuinely bigger structural fix this inferred-link problem called for — moving student_ids down to this model, locking template creation/archival to be calendar-driven only, and adding a real FK — was proposed by the developer the same day, written up in plans/calendar_driven_attendance_templates.md, and is now fully built (the FK below, 2026-08-11; the calendar-driven-only lock, via the bottom-up sync redesign’s automatic hook, 2026-09-08 — see attendance_template.md’s “Bottom-up sync redesign” section for the whole arc). This method itself is not obsolete - it’s still the general (teacher, subject, groups, weekday, time) → line lookup the sync pipeline’s own internal linking step reuses - but external callers reaching for it as a “the FK might be missing” fallback should not exist anymore; if one turns up, that’s a bug (a gap in the invariant), not a normal case to code around.

resource.calendar.attendance.attendance_schedule_id: the real FK (2026-08-11)

resource.calendar.attendance (ems_working_schedule_assignation, models/employees/working_schedule.py) carries attendance_schedule_id (Many2one, this model) — a genuine, always-correct link, replacing the inferred lookup above for every calendar block written from now on. Cardinality is many-to-one, not one-to-one: co-teaching means each co-teacher’s own personal calendar gets its own resource.calendar.attendance row for the same shared class, and all of them point at the same single ems.attendance_schedule line — the same reason ems.attendance_template.teacher_ids is a Many2many rather than one template per teacher (see attendance_template.md’s “Co-teaching” section).

Captured by ems.attendance_template._link_calendar_attendance(teacher_entries), called at the end of _sync_from_schedule_batch (right after _run_schedule_sync_plans finishes writing the schedule lines for this same call — see attendance_template.md’s “CRUD flow”). For every (teacher, entries) pair, it matches each entry’s own (dayofweek, hour_from, hour_to) against that teacher’s own resource_calendar_id. attendance_ids (freshly rewritten by the SAME import/edit call, immediately before this runs) to find the calendar row, then reuses find_schedule_lines_for_teaching (above) to find the schedule line it now maps to — writing the FK only when exactly one line matches. Reusing the same inference here, rather than avoiding it entirely, is deliberate: at THIS point in the pipeline the inference is trustworthy in a way a later, independent call (e.g. from a course transition run months afterward) never can be — it runs inside the very same transaction that just archived every stale/duplicate line for these exact entries (_archive_stale_schedule_sync’s survivor-picking, see attendance_template.md), so there is no accumulated drift left to be ambiguous about. If a match is still ambiguous (!= 1 line) the FK is simply left unset on that row rather than guessed — the same “leave it blank rather than guess” convention _backfill_calendar_employee_and_course (migrations/18.0.0.22.0/post-migrate.py) already established for this same model’s employee_id/ course_id backfill.

Historical backfill, done (migrations/18.0.0.24.0/post-migrate.py’s _backfill_calendar_to_schedule_link, 2026-09-08). Every resource.calendar.attendance row written before this FK existed (or before the automatic sync hook did) originally kept attendance_schedule_id empty, by design at the time — populating it retroactively would have meant re-running the exact broad, ambiguity-prone inference this FK exists to stop needing, on data that had already had time to drift. The bottom-up sync redesign’s own design invariant (every active line always has a real calendar block behind it, see attendance_template.md) made that acceptable to finally do: the migration reruns ems.attendance_template._regenerate_all_from_calendars() (already existing since 2026-08-11) once more, which rebuilds every active template/line straight from each teacher’s current calendar and links the FK as a natural consequence - no new matching logic needed, and no unresolved room conflicts turned up doing it. Every calendar write since goes through the automatic hook, which keeps the FK correctly set going forward without any further backfill ever being needed.

course_transition_wizard._apply_calendar_archival() no longer touches this FK, or these two models, at all (bottom-up sync redesign, Phase 7, 2026-09-08) — see course_transition_wizard.md. It simply archives the migrating resource.calendar.attendance rows and lets the automatic sync hook (Phase 4 of the same redesign) resync the affected teacher(s), the same way any other calendar change does. This replaced the FK-then-fallback lookup this section used to describe (added 2026-09-02) - kept here in git history only, not as a still-current design.

A schedule that already has attendance_session_ids (a real roll-call was taken against it) cannot be deleted — the error message directs the user to archive the whole template instead. This mirrors ems.attendance_template’s own archive-not-delete discipline (see that doc’s “History preservation” note) at the schedule-row level.

Views

views/attendance/attendance_schedule/form.xml — embedded as a one2many tab on the template’s own form; no standalone list/menu of its own (schedules are always created and edited in the context of their template).

Fixed in this pass (2026-07-28)

Class renamed ems_attendance_schedule → EmsAttendanceSchedule (has its own _name, not an _inherit-only extension). Whole file was tab-indented — normalized to spaces. Loop variable rec → schedule throughout. attendance_session.py imported this class directly by its old name (to reuse weekdays_selection on ems.attendance_session_header.weekday) — updated to the new name. New TestAttendanceScheduleLogic test class (10 tests: computed fields, check_overlap’s same-teacher/same-space/co-teaching/different-weekday branches, the unlink() guard) — the existing TestAttendanceScheduleAccess class only covered the ir.rule fix from an earlier session, not this model’s own logic. No bugs found; no O work needed (_order/constraints already correct).

Changed in this pass (2026-08-05)

action_new_version() refactored to call the new shared ems.attendance_mixin._write_or_ new_version() instead of inlining its own archive+copy (the button itself was later removed 2026-08-11 - see “Locking” above).