EMS

Technical Reference: Teacher Working Schedules & Schedule Frameworks

Overview

Every teacher’s weekly timetable is their hr.employee.resource_calendar_id (a resource.calendar, extended by EMS), whose weekly slots live in resource.calendar.attendance rows (also extended). The whole system exists to let admins visually build/edit that timetable from the employee’s own Schedule tab, instead of hand-editing raw attendance lines, while still supporting bulk XML import for centres that already export data from an external planner.

graph TD
    C["resource.calendar (ems_working_schedule)"] -->|attendance_ids| A["resource.calendar.attendance (ems_working_schedule_assignation)"]
    C -->|source_framework_id| F["resource.calendar, is_framework=True"]
    E["hr.employee"] -->|resource_calendar_id| C
    A -->|subject_id, group_ids| S["ems.subject / ems.group"]
    A -->|non_teaching| NT["ems.non_teaching_type (code, name, is_break, is_fixed)"]

Model extensions

resource.calendar (models/employees/working_schedule.py, class ems_working_schedule):

resource.calendar.attendance (ems_working_schedule_assignation):

ems.non_teaching_type (models/employees/non_teaching_type.py) — a configurable vocabulary of non-teaching activities, replacing what used to be a hardcoded Selection so an admin can add a new code from Configuration → Teachers → Non-teaching types without a developer deploying code (the planner app that feeds the XML importer is outside our control and can introduce new codes at any time):

Seeded by data/main/ems.non_teaching_type.csv with the original 12 codes (AC, BR, CM, CT, G, MM, MT, R, S, SC, TT, WIC), ems.non_teaching_<lowercase code>-prefixed xmlids.

hr.employee (models/employees/employee.py, ems_employee_base): schedule_attendance_ids — a related One2many to resource_calendar_id.attendance_ids, used purely so the “Schedule” tab’s widget field can be declared on the employee form (Odoo view archs can’t reference a dotted many2one.one2many path directly).

The empty-slot rule: nothing unassigned is ever stored

A resource.calendar.attendance row only exists if it is real — a subject assignment, or a non-teaching commitment (patio, a meeting…). An empty/unassigned period is never written to the database, even though the “Schedule” tab visually shows it as an editable gap. This was a deliberate correction after an earlier version did persist blank “Free” placeholder rows: they collided (Odoo’s own resource.calendar._check_overlap constraint) with genuinely different times added by hand for the same teacher on the same day, and there was no clean way to tell “a real but still-unassigned slot” apart from “nothing is supposed to happen here”.

Since unassigned slots aren’t stored, the “Schedule” tab’s grid widget re-derives them on every Edit/New by merging two sources:

flowchart LR
    B["Framework's own periods (source_framework_id.attendance_ids)"] -->|baseline: blank or non_teaching| M["Merged buffer"]
    R["Teacher's own saved rows (resource_calendar_id.attendance_ids)"] -->|always wins for the same day+period| M
    M --> G["Grid widget shows: assigned slots + gaps to fill in"]

Server methods (models/employees/working_schedule.py)

sequenceDiagram
    participant W as Schedule tab widget
    participant C as resource.calendar
    participant T as ems.teaching
    participant AT as ems.attendance_template
    W->>C: apply_schedule_changes(cells, source_framework_id?)
    C->>C: unlink Mon-Fri rows, recreate from cells
    C->>T: sync_from_schedule(teacher, entries)
    C->>AT: sync_from_schedule(teacher, entries, start_date=today)

Employee lifecycle hooks (models/employees/employee.py, ems_employee)

Schedule frameworks & the default-framework setting

A framework is just a resource.calendar with is_framework=True and an optional level_id — reusing the model rather than inventing a parallel one. Frameworks are managed like any other working schedule (Configuration → Teachers → Schedule frameworks, views/community/working_schedules/menu.xml), editing their attendance_ids with the same base Odoo list Odoo already ships for resource.calendar.

res.company.default_schedule_framework_id (models/settings/company.py, required, domain is_framework=True) is the framework every new teacher is seeded from. Exposed in Settings → Employees as res.config.settings.schedule_framework_id — note the settings-side field can’t be named default_*, since res.config.settings treats that prefix as a special “set an ir.default value” field (requiring a default_model attribute), not a plain related field.

data/main/ems.schedule_framework_default.xml ships a generic default framework (hourly 8–14h/15–21h blocks, noupdate="1" since its child resource.calendar.attendance rows are (0,0,...) create commands — reloading them on every upgrade would duplicate/overlap). data/custom/resource.calendar[.attendance].csv ships the centre’s real per-level frameworks (ESO, BTX, CFGM/CFGS/CFGB/EFPS/PFI), __import__-prefixed per the data-folder convention.

Auto-fill pitfall: resource.calendar._compute_attendance_ids (base Odoo, resource_calendar.py) auto-fills a brand-new calendar’s attendance_ids from company.resource_calendar_id whenever create() doesn’t include attendance_ids in the same call. Since our frameworks are seeded via two separate CSV files (parent record, then child attendance rows), the parent’s own create() call has no inline attendance_ids and gets contaminated. Every legitimate row we create carries a real xmlid (CSV id or, for the default framework’s XML, individually-id‘d <record> elements — never inline eval tuples, which are anonymous and indistinguishable from the auto-fill); the module’s post_init_hook (fresh installs) and migrations/18.0.0.20.0/post-migrate.py (upgrades) both purge any framework attendance row that has no matching ir_model_data entry.

The “Schedule” tab widget

static/src/js/backend/schedule_grid_field.js (OWL field widget, widget="schedule_grid", registered on schedule_attendance_ids) + static/src/xml/backend/schedule_grid_field.xml + static/src/css/backend/schedule_grid.css.

PDF report (ems.report_working_schedule)

reports/employees/report_working_schedule.xml — a qweb-pdf ir.actions.report on hr.employee, bound (binding_type="report") so it also appears in the employee form’s native Print menu, not just the Schedule tab’s own PDF button.

Co-teaching (ems.attendance_template.teacher_ids)

ems.attendance_template.teacher_ids is a Many2many to hr.employee (relation table ems_attendance_template_teacher_rel), not a Many2one — two teachers can genuinely co-teach the same class (same subject, same group(s), same room, same time) and share one template, one set of attendance_schedule_ids, and therefore the same jointly-visible, jointly-editable ems.attendance_session_header records (any co-teacher can mark attendance; see “Access control” below). hr.employee.attendance_template_ids is the matching Many2many on the other side, pointing at the same relation table.

A template’s identity is therefore (subject_id, group_ids, teacher_ids) — the same (subject, group) combination can have several active templates simultaneously, one per distinct exact set of co-teachers, split at the exact (weekday, hour_from, hour_to) slot level. Example: teacher A teaches “Programació”/DAW1A on Monday and Wednesday; teacher B joins only for the exact same Wednesday slot. The result is two templates: a shared A+B template for Wednesday, and A’s own solo template for Monday — not one shared template covering both days.

ems.attendance_template._reconcile_teacher_groups(self, teacher_entries) is the single algorithm behind this, used by both sync_from_schedule (one teacher, the Schedule tab’s live editor) and sync_from_schedule_batch (several teachers, the XML importer’s normal case — sync_from_schedule just wraps its one pair and delegates to the batch version):

flowchart LR
    TE["teacher_entries: [(teacher, entries), ...] submitted NOW"] --> R["_reconcile_teacher_groups"]
    DB["Existing active templates for the same (subject, group) combos"] --> R
    R --> M["merged: [(teachers, entries), ...] — one per exact resulting teacher-set"]
    R --> V["vacated: templates whose teacher-set doesn't survive as any resulting group — archived outright"]

For every (subject_id, group_ids) combination touched by the submitting teacher(s) — including combos they used to teach but dropped entirely this call — it merges each exact time slot across:

Slots are then grouped by their final teacher set. A template whose current exact teacher-set matches one of these groups survives (its schedule lines get refreshed in place, same archive-then-recreate pattern as the rest of the sync). A template whose teacher-set does not match any resulting group — because it shrank (a co-teacher dropped out), grew, split, or vanished entirely — is archived outright (vacated) and superseded by whichever new/updated template(s) now cover its slots.

This is what lets a solo live edit correctly reclassify another teacher’s data: if A already has a Monday+Wednesday template and B (submitting alone) starts teaching that exact Wednesday slot, Wednesday splits out of A’s template into a new shared A+B template, while Monday stays solo-A, untouched — without any special-casing between the live editor and the batch importer.

find_external_conflicts (room-collision detection against teachers outside the current batch) and check_overlap’s same_teacher check (ems.attendance_schedule.py) both moved from equality to set intersection on teacher_ids for the same reason.

Import wizard (ems.working_schedules_import_wizard)

Parses a planner XML export (<TeacherNode name="email ..."><DayNode name="N ..."><HourNode name="N HH:MM"><Subject>/<NonTeaching>/<Students> children) via _create_schedule(), then calls the same ems.teaching.sync_from_schedule/ems.attendance_template.sync_from_schedule used by the widget’s save path. A teacher_id field (set via context from the widget’s “Import” button) makes the by-email loop in both _onchange_file and create() a no-op — the file’s single node is used directly for that employee.

Teaching vs. non-teaching is decided by code, not by tag or by the Students sibling: both <Subject> and <NonTeaching> are accepted as the hour’s activity node; whichever one is present, its name attribute’s leading code is looked up against ems.non_teaching_type.code — a hit means the hour is non-teaching (NonTeaching is only kept for older exports; some planner apps outside our control send non-teaching hours as a <Subject> node too, whose only observable difference from a real subject is the missing <Students> sibling). A miss falls through to the ems.subject lookup by code. <Students> is unrelated to this decision — it’s always just a third, independent sibling that attaches group_ids when present, regardless of which activity node it sits next to. An unrecognized code (neither a known ems.non_teaching_type.code nor an ems.subject.code) raises a ValidationError — the fix, when the planner introduces a genuinely new non-teaching activity, is adding it once from Configuration → Teachers → Non-teaching types, not a code change.

Two upload modes, switched by whether teacher_id is set (the form shows one or the other via invisible="teacher_id"/invisible="not teacher_id"):

Reinforcement groups (ems.group.group_type)

An ems.group (models/contacts/group.py) is one of two kinds, distinguished by group_type:

graph TD
    G["ems.group"] -->|group_type = 'main'| M["Main: tutor_id, delegate_id, level_id, study_id, course, acronym all required. Students via main_group_id (res.partner, one per student)."]
    G -->|group_type = 'reinforcement'| R["Reinforcement: tutor_id, delegate_id, level_id, study_id all forbidden. Students via reinforcement_student_ids (Many2many) — can mix students from different main groups/studies. name is free-form, not computed."]

A reinforcement group still appears in a teacher’s schedule exactly like a main group — it’s referenced the same way by resource.calendar.attendance.group_ids, resolved the same way by the XML importer’s exact-name lookup (_parse_schedule_entries), and still needs space_id set (checked by _groups_without_space, same as any group). The only differences are:

Student membership in a reinforcement group is entirely manual (reinforcement_student_ids) — it does not touch res.partner.main_group_id, which keeps pointing at the student’s real group.

Access control

Action base.group_user (default) ems.group_department_chief and above (ems.group_head_of_studies, ems.group_director, ems.group_academic_admin)
Read a resource.calendar/resource.calendar.attendance ✅ (base Odoo ACL)
Export a schedule to PDF (PDF button / native Print menu)
Write/create/unlink resource.calendar.attendance (Edit/New/Add period, all writes through apply_schedule_changes) ✅ (security/ir.model.access.csv, access_resource_calendar_attendance_admin)
Write resource.calendar (needed for source_framework_id, set by Edit/New) ✅ (access_resource_calendar_write_department_chief)
Manage schedule frameworks inherited from the above
Import wizard ❌ (no ACL row) ✅ (access_ems_working_schedules_import_wizard_admin)
Read ems.non_teaching_type (needed to display non_teaching’s label anywhere it’s read) ✅ (ems.group_teacher/ems.group_secretary explicit read-only rows)
Manage ems.non_teaching_type (add/edit/deactivate a code) ✅ (access_ems_non_teaching_type_admin, ems.group_department_chief)

hr.employee.can_edit_schedule (a non-stored compute_sudo boolean, self.env.user.has_group('ems.group_department_chief')) is what the Schedule tab’s toolbar itself reads to show/hide Edit/Import/New (schedule_grid_field.js’s canEdit getter) — the ACL rows above are the actual enforcement, this field only drives the widget’s own visibility so a lower role never sees buttons it can’t use. PDF is deliberately not gated by it: every role that can already read a schedule (i.e. everyone, per the table above) can also export it, including from the employee form’s native Print menu.

Any other role currently only sees a teacher’s schedule read-only (their own, via the employee record they can already open) — nobody below Department Chief can edit it.

ems.attendance_template’s own rule_attendance_template_teacher_own and ems.attendance_session_header’s rule_attendance_session_teacher_own (security/rules/attendance.xml) both traverse teacher_ids.user_id.id/template_teacher_ids.user_id.id (Many2many, not Many2one) — so any co-teacher on a shared template can read/write its schedule and every attendance session created from it, not just one designated “owner”.