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 topic field (issue #428)

Some subjects are actually split into several distinct topics, each taught by a different teacher in a different slot — the motivating case is FP Basica’s MP 3161 “Comunicacio i ciencies socials I”, split into Castella/Catala/Angles. topic (Char, optional, resource.calendar.attendance) lets a schedule block record which topic it is, so a “teachers by subject” grouping can become a “teachers by subject+topic” one wherever that distinction matters.

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"]

Room granularity (2026-08-05)

The room a class meets in no longer has to be a single value derived purely from its group. Three fields, three different roles:

flowchart LR
    G["ems.group.space_id\n(the group's own 'home' room)"] -->|default at creation only| RC["resource.calendar.attendance.space_id\n(the weekly block - plain stored field)"]
    RC -->|entry['space_id'], via _sync_from_schedule*| AS["ems.attendance_schedule.space_id\n(the recurring line - AUTHORITATIVE)"]
    AT["ems.attendance_template.space_id\n('Session's default space' - seed only)"] -.->|default for a manually-created line| AS
    AS -->|_compute_space_id| ASH["ems.attendance_session_header.space_id\n(read here for attendance-taking/reporting)"]

Not yet built: an actual UI path for setting a per-line room override (the wizard’s planned “reasignar aulas” resolution, plans/working_schedule_import_redesign.md) and the “Nueva versión” lock/clone-archive mechanism (plans/attendance_template_multi_study.md) that will let an admin safely correct an already-used template/line’s fields without disturbing historical attendance. This section only covers the model-level foundation those two features will build on.

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)
    AT->>AT: _link_calendar_attendance(teacher_entries)
    AT->>C: writes attendance_schedule_id on each freshly-created attendance row

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

Backfill for employees that predate the create() auto-calendar (2026-08-11). The override above only started running with commit bc29e04b (18.0.0.20.0, 2026-07-12) — any employee_type='teacher' row already in the database before that (or one whose employee_type only became 'teacher' later, which write() has no equivalent logic for) can have resource_calendar_id falsy. _write_teacher_schedule() (the import wizard, above) silently no-ops on an empty resource_calendar_id.write(...) — no exception, ems.teaching. _sync_from_schedule() runs independently right after and correctly creates the teaching rows regardless, which is what let this go unnoticed until a real import for a real teacher (Óscar Bagan, this dev DB) left an empty Schedule tab despite real ems.teaching rows. post_init_hook (__init__.py) and migrations/18.0.0.22.0/post-migrate.py’s _backfill_missing_teacher_ calendars both create a personal calendar (mirroring create()’s own logic exactly) for every hr.employee with employee_type='teacher' and no calendar, active or archived — the same one-time-setup-needs-both-paths rule as every other backfill in this codebase (see the module’s own README/CLAUDE.md “Migrations” section for why both are required).

The “Working Schedules” list: history browsing (2026-08-06, phase 8 of plans/course_transition_teacher_schedule_archival.md)

Personal calendars (is_framework=False) are listed under Configuration → Teachers → Working schedules (action_working_schedules_tree). Since this action never overrides search_view_id, it resolves resource.calendar’s own default search view — Odoo core’s resource.view_resource_calendar_search, which already ships an <filter name="inactive" string="Archived"> — so browsing an archived calendar (a past course’s, rolled over by _apply_calendar_rollover()) already worked with no EMS code at all, unlike ems.attendance_template/ems.attendance_session_header (see their own docs) which needed the filter added by hand.

What genuinely didn’t exist: a way to search/group by the historical fields phases 3+ added. views/community/working_schedules/search.xml (new) inherits the native search view, adding employee_id/course_id as searchable fields and a “Course” group-by option — this is what actually exposes the “who taught, in which course” query (see decision 4 of the plan) from the UI. views/community/working_schedules/list.xml also gained the same two fields as optional="hide" columns (name already encodes both for a quick glance).

The Schedule tab’s grid widget rendering an archived calendar was verified, not (as it turned out) broken: the widget is only ever bound to hr.employee.schedule_attendance_ids (the employee’s current calendar) — the only realistic way to see it render an archived one is an archived employee whose calendar was never rolled over (a course transition rolls calendars, leaving mid-course doesn’t). At the time this was written, that was still a live scenario — since 2026-09-06 hr.employee.action_archive() cascades to the employee’s own calendar (see “Employee lifecycle hooks” above), so this widget now renders an archived calendar precisely in this case by design, not as a leftover gap. employee_archived_reason_tour.js/test_employee_archived_reason_tour.py extended to seed a real attendance row and open the Schedule tab — passed on the first try, no fix needed.

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.

Mid-course subject handoff: date-scoped calendar blocks (2026-08-11)

See plans/calendar_driven_attendance_templates.md’s own section on this. Real scenario: a weekday/time/room slot is a regular module until February, then becomes the end-of-course project for the rest of the year - both known and set up on the calendar UPFRONT (in September), rather than requiring someone to remember to edit the calendar on the actual handoff day in March.

Reuses core Odoo’s own date_from/date_to fields on resource.calendar.attendance (odoo/addons/resource/models/resource_calendar_attendance.py) - not new EMS fields. Found the hard way: a first draft added EMS-specific start_date/end_date fields instead, and hit core’s own resource.calendar._check_overlap() (_check_attendance, an @api.constrains('attendance_ids') that runs BEFORE any EMS-level validation ever gets a chance) raising "Attendances can't overlap." - that check’s own scan explicitly excludes any attendance row that already has date_from/date_to set (core’s own way of marking a row as an exception to the recurring-pattern overlap rule), so a genuinely dated split only avoids the “can’t overlap” error when using core’s real fields, not a parallel EMS-only pair.

How it flows through to the derived templates:

The “Schedule” tab widget’s own UI for this (per-day CARDS, 2026-08-11 redesign): edit mode is 5 independent day columns (Monday-Friday), each holding its own list of self-contained CARDS - one per real or still-blank slot, each carrying its own start/end time, its own optional date_from/ date_to (two <input type="date">, blank = whole course year), its own subject-or-non-teaching reason, group and room (space_id, newly exposed as its own editable field on the card - previously only ever inferred server-side, never shown/editable in this widget at all). A day column’s own “+ Add” button (addCard()) appends a blank card to that day; each card has its own remove button (removeCard()). There is no drag-and-drop between cards/days, deliberately - developer’s own call: “creo que hacer drag-n-drop de las tarjetas complica demasiado y solo serviria para moverlas de columna, no creo que compense” (moving a card to a different weekday only ever needs a remove + re-add, not a dedicated gesture). This replaces an earlier design (still described by the git history around this date) where periods were shared rows across all 5 weekdays at once and a “Split” button added a second such row at the same time - abandoned after developer feedback that a date-scoped block only ever applies to ONE weekday, so forcing it into an all-days-shared row read as confusing and cramped in practice: “no me gusta el widget, porque las fechas de inicio/final son para toda la fila y queda raro. Además todo queda demasiado apretado.”

Two cards on the SAME weekday can share the exact same time (that’s what actually implements the mid-course handoff - no dedicated “split” action anymore, just add a second card and set its start/ end time to match the first one’s) - cards within a day sort by (hourFrom, hourTo, startDate) (_sortCards), matching the developer’s own spec: “se ordenan por hora de inicio y hora de final, si dos se hacen a la misma hora, sale una seguida de la otra (el orden es por fecha)”. One consequence worth knowing when writing a tour against this: giving one of two time-tied cards a startDate while its sibling still has none re-triggers this sort immediately (an empty date sorts before any real one), so the two cards can swap DOM position mid-edit - a tour must not assume a card’s position stays fixed once it starts touching dates (see static/tests/tours/working_schedule_split_period_tour.js’s own date-setting step, which looks a card up by which subject its own select currently holds instead of by position, for exactly this reason). save()’s cell-building loop iterates each day’s cards, carrying each card’s own dates (date_from/date_to, only when set) and room (space_id, only when explicitly picked - otherwise still server-auto-derived from the group, unchanged) into the written vals. Read-only VIEW mode is unchanged by this redesign: two entries sharing the exact same (hour_from, hour_to) still render side by side (entriesForDay() groups by slot and assigns a column/columnCount, entryStyle() splits left/width accordingly) instead of one silently hiding the other underneath it - the weekly grid still looks exactly as before, only the edit-mode layout underneath “Edit”/”New” changed.

Framework scaffolding (baseline blank cards from the calendar’s source_framework_id, including its own non-teaching rows like patio/meetings) is still merged in on every “Edit”/”New” session, same as before the redesign - kept deliberately rather than starting edit mode from a blank slate, developer’s own call: “eso hará más fácil mostrar los patios en el modo edición” (a future feature, not yet built). A baseline slot matched by more than one real entry (a date-split pair sharing the exact same hour_from/hour_to) becomes one card per real entry (_seedBufferFromEntries), each keeping its own date range.

Not implemented in this pass: the XML planner import format has no per-entry date concept at all (_parse_schedule_entries builds one flat weekly grid, no date attribute in the source <TeacherNode>/<DayNode>/<HourNode> structure) - a date-scoped slot can currently only be set up via the live “Schedule” tab grid, not through a bulk file import. The read-only aggregation widget (schedule_grid_readonly_field.js/.xml, shared by the group and student Schedule tabs - see Student schedule) does not share any of the CSS classes touched here, but does incidentally render a split slot side by side too, as a side effect of the generic overlap column-split (layoutOverlappingBlocks) added for a student’s own schedule - two entries at the exact same hour_from/hour_to are just one more case of “genuinely overlapping blocks” to that algorithm, not a scenario it special-cases.

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.

ems.attendance_template.classify_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.

_reconcile_teacher_groups is only correct when entries is the teacher’s ENTIRE current schedule (the live editor’s own assumption, baked into its touched_templates step: any of a submitting teacher’s existing active templates not re-submitted this call is treated as deliberately dropped and folded/archived accordingly). The XML importer’s entries is never that — a file only ever describes one slice of the centre’s schedule (e.g. one department), imported incrementally alongside other files over time. Reusing _reconcile_teacher_groups (via _sync_from_schedule_batch) for the importer was tried and found (2026-08-01) to silently archive a shared teacher’s already-imported other department the moment a second department’s file mentioning that same teacher was imported — zero error raised, pure data loss, since from touched_templates’ point of view the first department’s combo simply “wasn’t resubmitted this call” and was there for the taking.

Fix: _reconcile_fresh_import + sync_from_schedule_batch_fresh_import — the importer’s own entry point, structurally identical to _reconcile_teacher_groups/_sync_from_schedule_batch but without the touched_templates pre-scan: it only ever reconciles a (subject, group-set) key that is actually present in the batch’s own submitted entries, never a submitting teacher’s untouched other combos. vacated is still computed (needed for a co-teaching merge to correctly archive an external teacher’s now-superseded solo template), but scoped to those same keys only. The identical “full replace” assumption was independently found in ems.teaching.sync_from_ schedule too (it unconditionally unlinked any teaching pair not in entries) — fixed by adding a replace parameter: True (default) for the live editor, False for the importer’s create(). _sync_from_schedule_batch/_reconcile_teacher_groups themselves are untouched — the live editor keeps relying on their “this call = the whole schedule” semantics, which is correct there.

Import wizard (ems.working_schedules_import_wizard)

_write_teacher_schedule simplified (2026-08-06, phase 6 of plans/course_transition_teacher_schedule_archival.md): it used to search for an existing resource.calendar by a "<teacher> (<course>)" name string, minting a new one if none matched — the exact mechanism that silently orphaned a teacher’s previous calendar every time the name string changed (decision 5 of that plan). It now just writes onto teacher.resource_calendar_id directly, full stop — every teacher already has one (auto-created at hr.employee.create() time, see employee.md), and rolling it onto a fresh calendar for a new course is the transition wizard’s own job now (ems.course_transition_wizard._apply_calendar_rollover(), see course_transition_wizard.md), not the importer’s. The importer (and the live Schedule-tab editor, which never had this logic to begin with) simply trust whatever resource_calendar_id currently is.

Redesigned 2026-08-01 to remove complexity that only existed to reconcile against an already-populated, still-current schedule. The key fact that makes this possible: ems.group records are permanent and reused across academic years — a course transition (see models/settings/course_transition_wizard.py) never recreates them, it only archives the outgoing ems.attendance_templates for the studies it transitions (per-study/department, not all at once). So by the time a study’s groups are due for a fresh import, there is genuinely nothing active left to reconcile against for that scope — an active overlap found during import is always either legitimate co-teaching or a real problem, never something to silently resolve.

Parses a planner XML export (<TeacherNode name="email ..."> → <DayNode name="N ..."> → <HourNode name="N HH:MM"> → <Subject>/<NonTeaching>/<Students>/optional <Space>/optional <Topic> children) via _parse_schedule_entries(), writes the calendar (_write_teacher_schedule, see “import_mode” below), then calls ems.teaching._sync_from_schedule/ems.attendance_template._sync_from_schedule_batch reading straight off each affected teacher’s calendar — the SAME entry points the Schedule tab’s own live-edit grid widget uses (unified 2026-09-02, see below; there used to be a separate, importer-only pair here).

Optional <Topic name="..."> sibling (issue #428, added 2026-09-11): free text, read as-is into topic — no resolution/validation step (unlike <Subject>/<Space>), since there’s no ambiguity to resolve. See “The topic field” below for what it’s for and where it’s read.

Explicit per-entry classroom override (<Space name="<ems.space code>">, added 2026-09-06): found live-debugging a real merge-mode import - some groups share their own permanent space_id because they normally attend as one class, but occasionally split (“desdoblen”) into two physical rooms for a specific session. The group’s own space_id has to stay the shared default (it’s correct for every other session), so a one-off room for a specific hour has to live in the file itself, per entry, not on the group. _parse_schedule_entries reads an optional <Space> sibling of <Subject>/<Students> inside <Hour>, resolves its name against ems.space.code (raising a clear ValidationError immediately if not found, same treatment as an unresolvable <Subject> code), and sets it as the entry’s own space_id. Nothing new needed on the write side: an entry carrying its own space_id already took priority over the group-derived default everywhere a room actually gets used or written - _entry_default_space_id (conflict detection, room-conflict line pre-fill) and ems.attendance_template._schedule_line_vals (the final schedule sync, originally built for the wizard’s own “Reassign rooms” resolution) - this is the new READ side of that same, already-existing preference, not a new mechanism. Two groups that would otherwise collide on their shared default room simply stop colliding at all once both sides carry their own distinct <Space> override, since _find_internal_conflicts’s room-based slot key differs.

import_mode: combine vs. replace (2026-09-02, see plans/calendar_pipeline_simplification.md)

Chosen once on the Welcome screen (ems_working_schedules_import_wizard.import_mode, Selection combine/replace, default combine), applying uniformly to every teacher this run touches. Developer’s own framing: “cuando se importa el calendario de un docente… eso debe sustituir a su antiguo horario. Si se quieren hacer ajustes, se deben hacer a mano” (replace) - but a teacher genuinely shared between two departments, each with their own file, imported at different times, must keep both (combine, confirmed with a concrete example the same day) - “en caso de conflicto, lo nuevo prevalece” either way.

_write_teacher_schedule(teacher, attendance_ids) does a per-weekday-slot diff against the teacher’s CURRENT calendar, not a blind wipe:

entries = [command[2] for command in attendance_ids if command != [5]]
existing = calendar.attendance_ids.filtered(lambda a: a.dayofweek in ('0', '1', '2', '3', '4'))
new_slots = {(e['dayofweek'], e['hour_from'], e['hour_to']) for e in entries}
superseded = existing.filtered(lambda a: (a.dayofweek, a.hour_from, a.hour_to) in new_slots)
(existing if self.import_mode == 'replace' else superseded).unlink()
calendar.write({'attendance_ids': [(0, 0, e) for e in entries]})

Any weekday slot this batch also describes is always superseded, in both modes - “lo nuevo prevalece” is unconditional, not gated by the mode. import_mode only decides what happens to a slot this batch does not mention at all: combine leaves it; replace removes it too.

Before this (2026-09-02), every call unconditionally emitted a leading (5,) (“unlink everything on this calendar”) before creating this batch’s own rows - correct for a teacher mentioned in exactly one file, ever, but silently wiped a DIFFERENT, earlier, unrelated import’s own contribution the moment the SAME teacher was imported again in a separate wizard run (found while investigating a broader pipeline-simplification effort - see the plan file for the full history, including a genuine within-batch variant of the same bug, fixed the same day: two XML nodes resolving to the same real teacher within ONE run - e.g. informática’s own file and administración’s own file, uploaded together, both mentioning a teacher who teaches in both - must have their attendance_ids MERGED into a single call (_apply_import’s own teacher_attendance_ids dict), never written per node; a per-node replace write would let the second node’s own “replace everything” wipe the first node’s just-written rows).

A genuine slot clash against a DIFFERENT, earlier import (the same teacher, same weekday/time, different subject/group, from a file imported at another time) is resolved interactively, BEFORE _write_teacher_schedule ever runs, by the existing “Existing schedule conflicts” screen (Screen 6 below, _find_external_conflicts’s own self_candidates branch) - defaulting to “left prevails” (the new import wins), same as it already did before this change; nothing about that screen needed to change. ems.attendance_template.find_self_conflicts, called once more at _apply_import time, is a last-resort safety net only for a genuine race (the DB changed after that screen ran) - it should essentially never fire in normal use.

sync_from_schedule_batch_fresh_import/_reconcile_fresh_import (the importer’s former, separate reconciliation pair) are deleted - their whole reason to exist was that the calendar couldn’t be trusted to reflect the true current state between separate import runs; now that _write_teacher_schedule gets that right, the importer reads the calendar back exactly like a live Schedule-tab edit already does, through the same _sync_from_schedule_batch (attendance_template.md) - one reconciliation method for both callers instead of two near-duplicates.

Every screen gets its own one-sentence intro paragraph (2026-08-10, developer feedback: “Quiero una breve introducción o resumen de lo que sucede en cada paso del asistente… quiero que el usuario lo tenga muy claro”). Before this, only the Welcome screen had a plain <p> explaining itself - every later screen jumped straight into either a success alert or a resolution list, with nothing telling the admin why that screen exists or what to do with it. Since this is a long, strictly-linear flow with no way to go back once past a step, each screen now opens with a plain <p> (same style as Welcome’s own, no alert coloring) stating what that screen checks and what to do about it - added as a static, hardcoded sentence per state, not a computed field, since the text is generic per-step guidance, not data-dependent (the actual data still renders below, in the existing success-alert-or-list pattern).

The developer also asked to look at simplifying Welcome itself, moving out whatever content fits better elsewhere (“la sección wellcome se pueda simplificar… si parte de su contenido se reparte entre los otros”). Welcome’s original paragraph did two things at once: explained what the wizard does, and warned that running it against a course already in progress (rather than right after a course transition) can produce conflicts needing manual resolution “in the following steps” - a forward-reference that only actually pays off once the admin reaches the conflict screens. Split accordingly: Welcome keeps the general framing (what this import does, when to run it, and - new - the reassurance that nothing is written until the final Import click, useful context for the whole flow, stated once up front rather than implied); the specific “why a DB conflict can happen” explanation moved into the “Existing schedule conflicts” screen’s own new intro, where it’s actually relevant. views/community/working_schedules/import_wizard.xml’s comment above the Welcome <div> updated to reflect this (no longer references the old plan file by name or step numbers, which had already drifted from the current state order per the screen-reordering above).

Screen 2 — “Resolve groups” (2026-08-05) — deferred group-name resolution

Decision confirmed with the developer 2026-08-05, resolving a real conflict found before implementing: the intro-screen redesign above says an unresolvable group/subject code “still blocks leaving the welcome screen, since there is no later step (yet) that could resolve it” — phrasing that could mean either “permanently, by design” or “only until step 2 exists”. Several already-passing tests (test_import_group_still_not_found_after_fallback_raises, test_continue_from_intro_placeholder_code_unresolved_group_raises) asserted the permanent reading. Asked explicitly rather than guessing which reading was intended (per this repo’s “full-scenario exploration before implementing” rule) — confirmed: build screen 2 as originally designed in plans/working_schedule_import_redesign.md, an unresolved group name (not subject code — there is still no resolution screen for that) is deferred instead of blocking, and the tests above are adapted to the new behavior rather than treated as authoritative.

Mechanism: _parse_schedule_entries()’s group-name lookup is unchanged (same three-heuristic match: exact full name, acronym + trailing letter, unique acronym-prefix match — extracted into its own _resolve_group_name() for reuse), but a name that still doesn’t resolve no longer raises. Instead the entry is tagged with pending_group_names (the raw, unresolved <Students> text) and carries whatever groups it) did resolve in group_ids, deferring the rest. This tag is a transient, JSON-cache-only marker — it must never survive into the (0, 0, {...}) command dicts actually passed to resource.calendar.attendance.create() (those dict keys must all be real model fields), so _continue_from_groups() strips it from every entry before advancing past the groups state; nothing reaches _apply_import() with the marker still attached.

“Continue” renders enabled/disabled instead of appearing/disappearing (2026-08-05, developer feedback): “¿Se puede hacer que no te deje continuar hasta que no se hayan seleccionado grupos?… Creo que quedará más claro si los botones de continuar, en lugar de aparecer u ocultarse, aparecen como ‘enabled’ o ‘disabled’.” Odoo’s form-view buttons have no native, domain-bindable “disabled” attribute the way fields have readonly/required (confirmed by reading view_button.js’s own disabled getter — it only ever reads a static props.disabled boolean prop, never a per-record expression; a literal disabled="..." in the arch is compiled as a static STRING prop via BUTTON_STRING_PROPS, not evaluated per-record). Rather than risk an unproven t-att-disabled="..." passthrough, this reuses the exact invisible= mechanism already proven everywhere else in this wizard, applied to two buttons occupying the same spot:

Since invisible fully removes a node from the DOM (not just CSS-hides it), only one of the two is ever actually present — from the user’s point of view “Continue” never disappears from that spot while state != 'summary', it just visually toggles enabled/disabled (Bootstrap’s own .btn:disabled styling, no extra CSS needed). New computed continue_disabled (@api.depends ("state", "ready_to_import", "group_line_ids.group_id")): not ready_to_import at intro, “any group_line_ids row still missing a group_id” at groups, False for every other (placeholder) step. Verified in a real browser, not just by reasoning about the source: the resolve-group tour asserts button[name='action_continue_disabled'][disabled] is actually present before a group is picked, and button[name='action_continue']:not([disabled]) right after.

A group missing a classroom is caught here too (2026-08-12). Developer feedback, after hitting this exact error on a real import (SA2A had no space_id): “These groups have no classroom assigned, so their schedule cannot be imported: SA2A … Quiero que podamos arreglarlo desde ‘Resolve groups’” - ems.attendance_schedule.space_id is required, but ems.group.space_id (what it’s fed from) is optional, so a group missing one used to fail only at the very last “Import” click, with a generic-sounding error and no obvious way to fix it in place. Now surfaces as a SECOND list on this same screen, right below group_line_ids:

Screen 3 — “Resolve subjects” (2026-08-11) — subject/study mismatch resolution

Code-level disambiguation added 2026-09-06: ems.subject.code is no longer globally unique (see ems.subject’s own technical reference, “Code uniqueness: per-study, not global”) — the same code can now resolve to more than one ems.subject row. _parse_schedule_entries resolves groups (and therefore their study) before the subject code for exactly this reason: its _resolve_subject_code(code, groups) helper returns the single match directly when the code isn’t ambiguous (the common case, unchanged), and only when it is, narrows to the candidate(s) taught in the entry’s own groups’ study — same “don’t guess, refuse instead” rule as _resolve_group_name’s own prefix heuristic. An ambiguity that still can’t be resolved this way (no groups, or more than one candidate even after narrowing) falls through to the same “Subject with code ‘%s’ not found” error already shown for a genuinely unknown code — this screen (built for a different problem, see below) never sees a code-level ambiguity at all, only a resolved subject that turns out not to be taught in its own group’s study.

Real error the developer hit importing a real batch: "The subject 'MP C056: Català / Aranès professional' is not available in the following selected studies: AD (2024): Assistència a la direcció (template for group(s) AD1A, AD1B, teacher(s) Óscar Bagan)." — ems.attendance_template. _check_subject_valid_for_all_studies’s own constraint, only ever raised at the very end (import_planner_data()), with nothing earlier in the wizard checking for it. Unlike every other resolution screen in this wizard, the file’s subject CODE already resolves correctly to a real, existing ems.subject (_parse_schedule_entries already raises immediately, at the intro screen, for a genuinely unknown code) — the problem here is that the resolved subject isn’t taught in the entry’s own group(s)’ study, only discoverable once the group(s) are already resolved (hence this screen sitting right after “Resolve groups”, not before it).

Design, confirmed with the developer via two explicit questions before writing any code:

  1. Row granularity for a multi-group entry (the real example above has TWO groups, AD1A and AD1B, sharing one class): one row per mismatched entry (not one per group).
  2. Which entries appear: only genuine mismatches, matching the “only show what’s actually wrong” convention already established by “Resolve groups”/”Resolve teachers” (a batch with nothing to resolve shows a plain success message, not an empty list to scroll through).

The first version built from this made group_ids read-only (plain-text context, widget= "many2many_tags") with only subject_id editable, reasoning the groups were “already resolved on the previous screen.” Corrected the same day, after the developer actually used it on a real batch (“Resolve subject debería dejarme cambiar también los grupos. Me he encontrado las dos variantes durante las pruebas: el error era el (o los) grupo, o el error era la asignatura.”): either side of the pair can be the genuine mistake — a real “resolved” group can still be the WRONG one, distinct from “unresolved” (the previous screen’s own problem). group_ids is editable too now, same widget, defaulting to the file’s own value like subject_id already did.

Does either dropdown’s domain hide the file’s own (wrong) default? No — confirmed by how Odoo’s Many2one/Many2many domain actually works before writing any code, not assumed: a domain only restricts what’s searchable/selectable when the field is reopened to change it, it never hides an already-set value that happens to fall outside it (confirmed for subject_id per the developer’s own explicit question, “Si esto impide que el default sea el del fichero, dímelo” — group_ids has no domain of its own at all, so the question doesn’t even apply there).

ems.working_schedules_import_wizard.subject_line (new TransientModel): raw_group_ids (Many2many ems.group, readonly — the file’s own groups, kept as the correction’s own matching key, never edited itself), group_ids (Many2many ems.group, editable, no_create/ no_create_edit in the view — a mismatch means an already-resolved group was simply the WRONG one, unlike “Resolve groups”’ own group_id, which genuinely may need creating a brand-new record — defaults to raw_group_ids), raw_subject_id (Many2one ems.subject, readonly — the file’s own value, the other half of the matching key), subject_id (Many2one ems.subject, domain="[('id', 'in', allowed_subject_ids)]", editable — defaults to raw_subject_id), allowed_subject_ids (computed Many2many, @api.depends('group_ids.study_id.subject_ids') — note depends on the EDITABLE group_ids, not raw_group_ids, so correcting the group alone can make an already-correct file subject valid again with nothing else to touch — delegates to the new shared ems.study._subjects_common_to_all()).

ems.study._subjects_common_to_all() (new shared method, models/curriculum/study.py): the subject intersection across every study in a recordset — extracted out of ems.attendance_ template._compute_allowed_subject_ids (unchanged in behavior, same search()-based implementation preserved exactly, including the NewId/.ids handling a still-unsaved form needs) once this wizard needed the identical rule for a different input shape (a group’s own single study_id, not a template’s study_ids). DRY, not a new algorithm.

Detection (_build_subject_lines, called from _continue_from_groups right before advancing — this now builds subject_line_ids instead of teacher_line_ids directly; that responsibility moved to _continue_from_subjects below, one step later): for every teaching entry (skips non-teaching, and any entry with no subject_id at all) with a resolved group_ids, collects the distinct study_id values across those groups (a reinforcement group’s own empty study_id is simply not included — mapped() on a Many2one naturally skips empty values). No studies at all (every involved group is a reinforcement type) → nothing to validate against, matching _check_subject_valid_for_all_studies’s own skip-if-no-study rule exactly — no line. Otherwise, checks the entry’s subject_id against studies._subjects_common_to_all(); a genuine mismatch gets a line (raw_group_ids/group_ids both seeded to the same original set, same for the subject pair), deduped by (group_ids, subject_id) so the same real mismatch repeated across several days (a class meeting the same slot every day of the week) produces exactly one correction line, not one per occurrence — same convention as group_line/teacher_line.

Resolution (_continue_from_subjects): raises if any line’s subject_id is still outside its own (possibly already-corrected-by-group) allowed_subject_ids; otherwise builds a corrections dict keyed by each line’s ORIGINAL (raw_group_ids, raw_subject_id) pair — never the edited group_ids/subject_id, which are the correction’s own VALUE, not its matching key — mapping to whatever the admin actually picked, and substitutes every matching entry across the whole cached batch (_finalize_subject_correction, mirroring _finalize_pending_groups’s own dual-shape handling for entries list items vs. attendance_ids command dicts) with BOTH the corrected groups and corrected subject, rebuilding the cached name string from the CORRECTED groups/ subject’s own current records rather than string-splitting the old cached value — robust regardless of what characters a subject/group name happens to contain. Then builds teacher_line_ids exactly as _continue_from_groups used to (unchanged logic, just relocated one step later in the flow) and advances to “Resolve teachers”.

_STATE_SEQUENCE is now intro, groups, subjects, teachers, internal_conflicts, db_conflicts, summary — every downstream screen number in this doc shifted up by one (Resolve teachers: Screen 3 → Screen 4; File conflicts: Screen 4 → Screen 5; Existing schedule conflicts: Screen 5 → Screen 6; Overall summary: Screen 6 → Screen 7) to make room. Every pre-existing backend test/tour driving the wizard state by hand (action_continue() # groups -> teachers style comments, or a tour’s own click-through sequence) needed an extra step inserted for the new state — done via a scripted regex pass across both the backend test file and the tour file, mirroring the exact “two-call sequence” fallout-handling technique already established by the 2026-08-10 “Pending teachers” merge (see its own entry, further down this doc) — plus one tour (ems_working_schedules_import_ resolve_group) whose own differently-worded “Continue” step text didn’t match the scripted anchor and needed a manual, one-off fix.

New dedicated tour, ems_working_schedules_import_resolve_subject_mismatch, covers BOTH real variants in one run: teacher 1’s row (subject wrong — a deliberately unrelated subject with no study_ids at all) is fixed via the subject_id Many2one dropdown, searching for and selecting the correct subject (proving the domain genuinely offers it, not just that the field is editable); teacher 2’s row (group wrong — a genuinely correct subject assigned to a group belonging to an unrelated study) is fixed via the group_ids many2many_tags widget, removing the wrong group tag (.o_tag:has(.o_tag_badge_text:contains(...)) .o_delete) and adding the correct one, proving the group correction alone (subject left untouched) resolves that row. Completes the import, confirming the resulting ems.attendance_template records reflect the corrected values, not the file’s original wrong ones. The two scenarios deliberately use separate classrooms despite sharing the same weekday/time — otherwise the two different teachers would collide on “File conflicts” instead of exercising the “Resolve subjects” screen this tour is actually about.

Screen 4 — “Resolve teachers” (2026-08-05) — deferred e-mail resolution, merged with “Pending teachers” (2026-08-10)

Unlike screen 2, this one needed no developer check-in first: the plan already fully specified an unresolved e-mail as this screen’s job, distinct from a pending-identification code (no @, see _is_email_like) - originally handled silently, with only a separate, later preview screen (“Pending teachers”) showing what would happen. Building it does change what “an unknown e-mail” means for tests/tours written before this screen existed - it now surfaces as a resolvable line here instead of always reaching an error dialog at the final Import - adapted the same way screen 2’s pre-existing tests were, since this is an unambiguous, intended consequence of the screen actually existing now, not a design question to re-litigate.

“Pending teachers” merged into this screen (2026-08-10, developer feedback): “¿Ves factible fusionar los pasos ‘Resolve teachers’ y ‘Pending teachers’ en ‘Resolve teachers’? Todo funcionaría como en ‘Resolve teachers’: marcado el new por defecto, para crearlo como pending, pero que me deje asignarlo a mano.” Until this change, a bare placeholder code (no @) never got a correction row at all - it fell straight through to _get_or_create_pending_teacher() at Import, with no way to tell the wizard “actually, this code is the same real person as that other code/ e-mail” before the fact. The developer ran into exactly this: the same real teacher sent under two different raw identifiers (two different placeholder codes, or a code and a mistyped e-mail attempt), each independently minting its own new pending employee. Rather than add a dedicated “same person as” cross-reference field (analyzed and offered, but declined for now - “creo que de momento esto que pido es más sencillo y cubre el 99% de los casos”), the fix folds the old “Pending teachers” preview screen’s own identifiers into this screen’s correction rows: both e-mail-shaped identifiers and bare codes now get a teacher_line here, create_new defaulting to True either way, so a genuinely new hire needs no action while an admin who recognizes a code or e-mail as an already-known person can assign the SAME existing employee to two (or more) different rows by hand, right here - _continue_from_teachers()’s own identifier → employee map (below) has no uniqueness constraint on the employee side, so this already resolves correctly with no further code change (confirmed by a dedicated regression test, test_continue_from_teachers_same_employee_assigned_to_two_different_identifiers, not just by reading the code). The old, separate pending_info state, its pending_teachers_html preview field, and the now-unused _teacher_preview_html/_bullet_html helpers were all removed outright

Mechanism, deliberately mirroring screen 2’s shape:

“Create new” checkbox for a genuinely never-hired teacher (2026-08-05, developer feedback after using it for real): the original design above assumed every unresolved e-mail was a typo/mismatch of an already-existing teacher. In practice, some rows are a genuinely new hire whose e-mail simply doesn’t exist in EMS yet - forcing a pick from the (create-disabled) Many2one made no sense for those. teacher_line gains create_new (Boolean) - a checkbox shown before employee_id in the list, which the field’s own readonly="create_new" then locks once ticked (an @api.onchange also clears any already-picked employee_id, so the two can never disagree). A row is valid if either employee_id is set or create_new is ticked - never neither.

The resulting employee is created exactly like a placeholder-code teacher (same “Pending teacher (…)” naming, same schedule_import_code re-import-dedup guarantee - reusing the raw e-mail string as the code, so re-importing the same file before this teacher’s real identity is resolved finds and reuses the same record instead of creating a duplicate), plus one difference: the developer’s own framing - “esa dirección de correo no se puede dar por buena, porque quizás está ocupada, pero me gustaría intentarlo” - means the file’s e-mail IS worth trying, just not as an immutable, auto-generated value. google_ws_manual_email (the existing Google Workspace integration field, models/employees/google_workspace_integration.py - already means “edit Work Email by hand instead of letting EMS generate it”) is set True on creation, with work_email pre-filled to the attempted address - editable from the start, never silently overwritten by the normal auto-generation flow, exactly matching “intentarlo” (try it) without pretending it’s been confirmed.

Extracted _get_or_create_pending_teacher(identifier, manual_email=False) out of _apply_import’s previously-inline placeholder-code branch, so both paths (a placeholder code, and now a create-new-ticked e-mail) share the exact same get-or-create-by-schedule_import_code mechanism - manual_email is the only behavioral difference between the two callers.

Label shortened to “New” (2026-08-06, developer feedback): the field’s own string was originally “Create new teacher” - too wide for a narrow checkbox column, crowding the row. Odoo’s list renderer has no arch-level hook to center a column header’s text - getColumnClass (list_renderer.js) never consults the field’s own class attribute the way getCellClass does for data cells, only the cell content can be aligned that way - so centering just the checkbox while leaving “New” left-aligned above it would look mismatched, and centering the header instead would need bespoke CSS targeting this specific column by name (not the “Odoo way”: every other column in this codebase relies on Odoo’s own default alignment, matching field type). Left both the label and the checkbox at Odoo’s default left alignment instead - no class="text-center" on the field, simplest fix with no custom CSS.

Former Screen 5, “Pending teachers” — merged into Screen 4 (removed 2026-08-10)

This used to be its own screen, moved right after “Resolve teachers” earlier the same day (2026- 08-10, developer feedback: “El paso 6, no puede hacerse tras ‘resolve teachers’ o fusionarse con este?”) before being merged into it outright, later that same day - see Screen 4’s own section above for the merge itself and why it made resolving a duplicate-teacher mention finally possible. It was purely informational (nothing to resolve, no line model of its own - a plain Html field, pending_teachers_html, since deleted along with the now-unused _teacher_preview_html/ _bullet_html() helpers and the pending_info state) - all it ever did was preview, before dealing with any room conflicts, which teachers Import was about to create.

The underlying classification this screen previewed is still very much alive - it just lost its own dedicated screen. _classify_teacher_item(item) remains the single source of truth both the “Overall summary” screen’s own existing/pending-teacher blocks (below) and _apply_import (the real write path) branch on - unchanged in spirit, only fixed to keep telling a create_pending e-mail apart from a create_new-ticked bare code (both now reachable from the exact same “New” checkbox on Screen 4, where before only an e-mail could reach create_pending at all). Returns one of four fates per node_cache item:

_teacher_preview_items(node_cache, fates) (same “one correction/line per occurrence, not per mention” dedup convention every earlier screen already uses) and _teacher_preview_line(item) (one human-readable label per item, worded per its own fate) both survive unchanged, now used only by the “Overall summary” screen’s own blocks, below.

Screen 5 — “File conflicts” (2026-08-05, renamed from “Internal conflicts” 2026-08-06; renumbered to Screen 4 on 2026-08-10 once “Pending teachers” merged into “Resolve teachers”, then to Screen 5 on 2026-08-11 once “Resolve subjects” was inserted, see above) — within-batch room collisions

Genuinely new check, unlike screens 2/3 (which mainly relocated existing validation) - no prior single-screen wizard equivalent existed. Confirmed the UI/validation shape with the developer first, per the plan’s own “Complexity flag” section (which explicitly asked to be revisited here): a flat resolution Selection with all options always visible, validated server-side on Continue (same raised-ValidationError convention as every other step), rather than a fancier widget hiding invalid options per row - the plan itself offered both as equally valid (“Green-phase call, not a design one”), and the flat/validated shape needs no new client-side state machine.

What does NOT need building: two different teachers in this batch submitting the exact same (subject, group-set, slot) already merge into one shared co-teaching template automatically, today, via _reconcile_fresh_import’s own by_slot/by_teacher_set grouping (keyed on (subject_id, tuple(group_ids))) - nothing about that merge mechanism changes. What screen 4 adds is visibility: the developer’s explicit 2026-08-01 call was that this auto-merge should still be shown and confirmed, not silently assumed, since a same-subject/same-group collision can equally be a genuine typo in the source file coincidentally producing the same shape.

Detection (_find_internal_conflicts): groups every teaching entry (non_teaching ones are excluded - they carry no classroom, there’s no room concept to collide over) across every node_cache item by (space_id, dayofweek, hour_from, hour_to) - the room comes from _entry_default_space_id (the entry’s first group_id’s own space_id, same “first group wins” convention used everywhere else in this file; an entry lacking a resolvable room is skipped, that gap is caught elsewhere - see _groups_without_space). Within each occupied slot, every pairwise combination of entries from different items (a teacher can’t conflict with their own entry; find_self_conflicts at Import already covers a teacher double-booked against their own existing DB schedule - a distinct, unrelated case) becomes one conflict. Only pairwise combinations are handled - a slot with 3+ colliding entries produces multiple pairwise lines rather than one n-way line; not expected to matter in practice (a genuine 3-way room collision within one import batch would be a very unusual planning error), documented here as a known simplification rather than engineered for speculatively.

Classification (_classify_conflict_kind), shared by screen 4 (file-internal) and screen 5 (against an existing DB session):

kind’s own string was renamed from “Type” to “Conflict” (2026-08-06, developer feedback) - the column sits next to left_label/right_label describing the two colliding sides, so “Conflict” reads more naturally as “what kind of conflict is this” than the more generic “Type”.

desdoble_eligible (“Split session”) merged into plain_conflict, join_session added (2026-09-06, developer feedback while live-debugging a real merge-mode import stuck with no visible cause): the original rule above used to classify same-subject/no-shared-group as its own desdoble_eligible kind unconditionally, on the assumption that this shape reliably meant a genuine split session (one group’s class divided into two, needing two different rooms). The developer pushed back on that assumption directly: “no puedo garantizar si se trata ciertamente de eso o no (no siempre hacen la misma materia cuando se separan)” - a real split doesn’t reliably keep the same subject on both halves, so “same subject, no shared group” was never a trustworthy enough signal on its own; it just as easily describes two unrelated groups coincidentally scheduled into the same room for the same subject. Rather than try to guess harder, the two former outcomes were reduced to one: desdoble_eligible was deleted as a kind value and folded into plain_conflict (“Room conflict”) - both already shared the exact same allowed-resolution set (reassign_rooms/prevail_left/prevail_right) and the same reassign_rooms default at every call site, so the merge is a pure card/label consolidation, not a behavior change for that case.

A NEW join_session kind was added the same day for the one sub-case that IS a reliable, intentional signal: same subject, no shared group, but the same real teacher on both sides - one teacher deliberately running an identical session for two different groups at once (developer: “cuando dos grupos distintos hacen la misma materia en la misma aula con el mismo profe, lo mostraremos como un join session (lo contrario del split) y por defecto estará en confirmar (mismo tratamiento que el co-teaching)”). join_session reuses co_teaching_eligible’s own allowed-resolution set and default (co_teaching/”Confirm”, prevail_left, prevail_right, no reassign_rooms - there’s nothing to reassign, both entries are meant to coexist as-is). “Same teacher” is computed differently per screen since the two call sites have differently-shaped inputs

Per-row valid/invalid color coding (same day, same developer feedback session): added directly to EmsGroupedConflictLinesField (isRowValid(), mirroring _resolution_is_valid client-side) and grouped_conflict_lines_field.css (.ems_conflict_row_valid/_invalid, a soft green/red background tint) - a genuinely reactive win found live: while debugging a stuck import with 100 rows and no visible cause, 7 rows turned out to visually show two different-looking classroom names while their actual left_space_id/right_space_id were identical (see plans/working_schedule_room_picker_display_desync.md for that separate, still-open display/data desync bug) - reading the DOM alone could never have told the two states apart, but color-coding directly off record.data (the same reactive source every other binding in this widget already reads) would flag that row as invalid on sight regardless of what its text happens to display.

State renamed “Internal conflicts” → “File conflicts” (2026-08-06, developer feedback): clearer against screen 5’s “Existing schedule conflicts” - both entries colliding here come from the same imported file, not some internal/external EMS distinction. internal_conflict_line_ids’s own field string was renamed to match (the technical model name ems.working_schedules_import_wizard. internal_conflict_line and the state value internal_conflicts were deliberately left unchanged - renaming either would be an XML-ID/technical-identifier change needing a migration, far more than this ask called for; only the two display strings moved).

left_label/right_label/left_space_id/right_space_id headers overridden per screen (2026-08-06, developer feedback, refined same day after the first wording read ambiguously since both label columns said just “File”): on this screen, “File (left)”/”File (right)” (both sides are entries from the same imported file, disambiguated by side) and “Classroom (left)”/”Classroom (right)”; on screen 5 below, “File”/”Database” (no “(left)”/”(right)” needed there - the two sides are never ambiguous, one’s always the file and the other’s always the existing DB record) and “Classroom (file)”/”Classroom (DB)”. The shared mixin’s own field strings (“Left”/”Right”/”Left classroom”/”Right classroom”) stay the generic default, only ever overridden via string="..." on the <field> in this specific view (same <field string="..."> override pattern already used elsewhere in this arch, e.g. raw_identifier’s “E-mail found in file”) - no model-level split needed for a purely view-level label difference.

resolution’s co_teaching option renamed “It’s co-teaching (keep both)” → “Confirm” (2026-08-06, developer feedback: too long for the column) - paired with a genuinely necessary small custom widget (static/src/js/backend/conflict_resolution_selection_field.js, ems_conflict_resolution): “Confirm” only makes sense for a co_teaching_eligible line - showing it as pickable for e.g. a plain_conflict (“Room conflict”) line is confusing, even though picking it there was already rejected server-side via _resolution_is_valid. Odoo’s Selection field has no declarative, per-record way to vary its own options within the same list (the options list is defined once per field, not per row - confirmed by reading web/static/src/views/fields/selection/ selection_field.js), so this is a genuinely necessary widget override, not a shortcut around an existing mechanism: EmsConflictResolutionField extends SelectionField, overriding only get options() to filter out 'co_teaching' whenever this.props.record.data.kind !== 'co_teaching_eligible' - every other option stays available for every kind, unchanged. kind is already a plain visible column in the same list, so no extra relatedFields plumbing is needed to read it (contrast archived_reason_ribbon_field.js’s own color_field gotcha, which needed exactly that because its field isn’t otherwise rendered).

Column width rebalancing (2026-08-06, developer feedback): left_label/right_label (free text) were hogging space at the expense of kind/resolution/the two room pickers, which kept showing an ellipsis. <field class="..."> only ever lands on the data cell, never the header (getColumnClass in Odoo’s list_renderer.js never consults a column’s own class the way getCellClass does for <td> - confirmed by testing class="w-25" first, which had no visible effect at all), so static/src/css/backend/working_schedules_import_wizard.css sets a plain pixel max-width on the two label columns (ems_conflict_label class on the <field>) instead of a Bootstrap width utility - the browser’s table layout gives the freed-up space to the other columns. An initial version of this also set an explicit min-width on kind/resolution/the room pickers - removed the same day once the header-shortening above made it actively counter-productive once combined with a real classroom’s long display_name (developer-reported “columns look misaligned”); a max-width on just the two wide free-text columns turned out to be enough on its own.

Pre-existing i18n gap fixed while renaming these (2026-08-06): every kind/resolution option translation had only ever referenced internal_conflict_line’s own selection value, never external_conflict_line’s (the two models share the option VALUES via the conflict_mixin, but Odoo’s translation loader binds by exact #: reference, not shared value - see CLAUDE.md’s own “msgid diff alone is not enough” note) - screen 5 (“Existing schedule conflicts”) had been rendering every kind/resolution label in English regardless of app language since screen 5 was first built, undetected until this rename pass touched the same .po blocks and the gap became visible by inspection. Fixed by adding external_conflict_line’s own reference to all 7 existing blocks (3 kind options + 4 resolution options) rather than just the ones being renamed - verified via ir_model_fields_selection directly (both models’ rows now carry ca_ES/es_ES keys, not just en_US).

Positional references, not content matching: each ems.working_schedules_import_wizard. internal_conflict_line stores left_item_index/left_entry_index/right_item_index/ right_entry_index (plain integers into node_cache’s own list structure) rather than trying to re-match entries by content later - built once, leaving teachers, from the very node_cache _continue_from_internal_conflicts re-reads unchanged, so the indices stay valid. left_label/ right_label (prebuilt Char, reusing the existing teacher-name/subject/group/weekday/time formatting conventions from _conflict_lines) are what the view actually shows - no need to re-derive them from the raw entry at resolution time.

resolution (Selection: co_teaching/prevail_left/prevail_right/reassign_rooms), defaulted per kind at line-creation time: co_teaching for co-teaching-eligible, reassign_rooms for desdoble-eligible and for plain_conflict (changed 2026-08-06, developer feedback: every plain_conflict pair found here is, by construction, a genuine same-room clash - _find_internal_conflicts only ever pairs entries that already matched on space_id - so picking a different room is the actual fix, not an afterthought behind prevail_left/prevail_right; the plan’s original “left default” call for this kind is superseded by this change). left_space_id/ right_space_id (Many2one ems.space) are pre-filled with the colliding room - the group’s own currently-assigned classroom, the same value on both sides since that’s exactly why they collided in the first place - for every desdoble-eligible or plain-conflict line regardless of its current resolution, so they’re ready the moment “reasignar aulas” is picked.

_continue_from_internal_conflicts(): raises (naming every offending line’s labels) if any line’s resolution isn’t valid for its own kind, or if reassign_rooms has a blank or identical left/right room (picking the same room again wouldn’t actually resolve anything). Otherwise, re-reads node_cache fresh and applies every line:

View: same “editable list, or a success alert” shape as screens 2/3; the placeholder “not implemented yet” alert’s invisible grew a fourth excluded state ('internal_conflicts'). continue_disabled grew its own internal_conflicts branch (any line whose resolution isn’t currently valid for its kind) - same enabled/disabled “Continue” mechanism as before.

Row color by alert level (2026-08-06, developer feedback): not every conflict kind deserves the same visual weight - co_teaching_eligible/desdoble_eligible are only asking for a quick confirmation (the plan’s own defaults already handle them sensibly), while plain_conflict is a genuine decision the admin must actively make. decoration-warning="kind in ('co_teaching_eligible', 'desdoble_eligible')"/decoration-danger="kind == 'plain_conflict'" on the <list> (standard Odoo row-decoration attributes, same pattern as e.g. account.move’s own payment_state coloring) drive this - but decorations only ever produce a text-<color> class (getClassNameFromDecoration in Odoo’s own list_renderer.js/utils.js), i.e. colored text, not a background - too subtle across six columns of already-dense text. static/src/css/backend/ working_schedules_import_wizard.css adds a soft background tint on top, keyed off those same .text-warning/.text-danger classes Odoo already applies (.o_data_row.text-warning > td etc.), scoped to .o_field_widget[name="internal_conflict_line_ids"]/[name= "external_conflict_line_ids"] specifically (these two field names are unique to this wizard) so it can never affect any other decorated list elsewhere in EMS. Tried the built-in Bootstrap 5.3 .text-bg-warning/.text-bg-danger utility classes first (via decoration-bg-warning/ decoration-bg-danger, which getClassNameFromDecoration happily turns into those exact class names) - visually confirmed via a real browser screenshot that they render with no visible effect at all in this Odoo install (bundled/trimmed Bootstrap build likely doesn’t ship those specific utilities), which is why the small custom CSS file exists instead of a zero-CSS declarative-only fix.

SUPERSEDED 2026-08-10 — the flat editable <list> described in every paragraph above (row-color decorations, the dedicated working_schedules_import_wizard.css, the ems_conflict_resolution selection-field widget) was replaced outright by a grouped-cards widget, ems_grouped_conflict_ lines (static/src/js/backend/grouped_conflict_lines_field.js + static/src/xml/backend/ grouped_conflict_lines_field.xml), on both this screen and Screen 6 below. Developer feedback, resolving a large real batch by hand: “me iría bien que estuvieran agrupadas por tipo (co- teaching, etc) y por ‘left’, y que cada grupo me permitiera escoger el resolution que se aplica al grupo entero. Si escojo ese resolution, se aplica a todos los desplegables (aunque luego yo cambie uno a mano). Para agrupar, quizás el formato tarjetas que hemos usado en el resumen final nos pueda venir bien.” Everything the old paragraphs above describe about the underlying DATA (positional left_item_index/right_item_index references, resolution’s per-kind defaults, _continue_from_ internal_conflicts()’s own apply logic) is unchanged and still accurate - only the VIEW changed.

Same real gap that motivated the merge, from the SAME actual import (the “same person as” merge from earlier this session, see Screen 4 above): resolving dozens of conflicts one row at a time in a flat list, each needing its own click, was the developer’s own stated bottleneck (“tardo mucho en corregirlos todos a mano”) - directly prompted by hitting the new self_conflict kind (below) on a real, large planner file. Two related, developer-approved asks:

  1. Detect same-teacher-different-room double-booking within the batch (new self_conflict kind, this screen only - see below).
  2. Group + bulk-resolve (this widget, both screens).

Grouping (two levels, exactly matching the developer’s own words): the widget reads this.props.record.data[this.props.name].records (the o2m’s already-loaded sub-records - no new RPC) and groups client-side, in JS:

Left/right wording removed from the UI entirely (same follow-up round, same developer feedback session): “tampoco queda claro quien es left… quien es right. Habría que aclararlo, o cambiar left/right por otra cosa.” The technical field names (left_label/right_label/ left_space_id/right_space_id) are unchanged (renaming them would ripple through every _build_*_conflict_lines/_continue_from_*_conflicts call site and every existing test for no real benefit) - only what the WIDGET shows changed. rowText(record) branches on this.props. name (internal_conflict_line_ids vs external_conflict_line_ids, the only two callers of this widget):

Bulk-apply, per sub-section: a small <select> next to the sub-section’s own left_group_key header, filtered to the resolutions valid for that KIND (same allowedResolutionsByKind mapping kept in sync by hand with _resolution_is_valid’s own allowed_by_kind, server-side) - picking a value calls record.update({resolution: value}) on every record in that sub-section at once, then resets itself back to the placeholder ("— apply to all —") so it never looks like a bound field, matching “cada grupo me permitiera escoger el resolution que se aplica al grupo entero.” Every row keeps its OWN, independently-editable resolution <select> right below - “aunque luego yo cambie uno a mano” - a bulk pick is a starting point, never a lock. Verified with a dedicated tour (ems_working_schedules_import_bulk_apply_resolution) seeding 3 groups sharing one classroom so the anchor’s own entry collides with both others, forming a 2-row sub-section - proven functionally via “Continue” only enabling once every row (both bulk-applied at once) has a valid resolution, the same idiom every other tour in this file already uses, rather than inspecting each row’s own <select> value directly (fragile, since Owl sets the DOM .value PROPERTY on a <select>, not a value= HTML attribute a tour selector could match).

Room pickers (left_space_id/right_space_id, only shown once a row’s own resolution is 'reassign_rooms') use Odoo’s generic AutoComplete component directly (@web/core/ autocomplete/autocomplete - the same one the standard Many2one field widget itself is built on), driven by a plain ems.space name_search RPC, rather than the generic per-record Field component (@web/views/fields/field, used e.g. by Odoo’s own calendar event popover to render an arbitrary field of an arbitrary record outside of a list/form root). Confirmed empirically, not assumed: Field’s Many2one only renders as genuinely editable once record.isInEdition is true, which requires the record’s own config.mode === 'edit' (Record.isInEdition, web/static/src/model/relational_model/record.js) - a plain o2m load leaves every row’s mode at its default ('readonly'), unlike the flat list this replaces, which only ever put ONE row at a time into edition via its own editable="bottom" mechanism. Explicitly calling record. switchMode("edit") for every row on mount was tried first and visually confirmed (real screenshot) to render the Many2one as inert text regardless - not worth chasing further given AutoComplete has no such dependency at all: it is a fully self-contained input, and onSelect writes record.update({ [fieldName]: [id, label] }) - the exact [id, display_name] tuple shape Many2OneField’s own updateRecord() writes internally, confirmed by reading that file directly rather than guessing the payload shape.

ems_conflict_resolution (the old per-kind option-filtering Selection widget) and its own working_schedules_import_wizard.css (row-color decorations, column max-width) are both deleted outright, superseded respectively by resolutionOptions(kind) (the same filtering logic, now inline in the new widget) and by which CARD a row appears under (which kind it is is now conveyed by the grouping itself, not a per-row background tint). The <list> sub-arch inside each <field widget="ems_grouped_conflict_lines"> in import_wizard.xml is kept, stripped down to bare <field name="..."> declarations with no string=/class=/widget= attributes - it is never rendered directly any more (confirmed the exact same “keep a <list> purely for sub-field declaration” convention em_matrix_field.js/em_wizard.xml already established elsewhere in this codebase), so any view-level attribute on it would be silently ineffective, misleading to a future reader. Six now-fully-orphaned .po blocks ("File (left)"/"File (right)"/"Classroom (left)"/ "Classroom (right)"/"Classroom (file)"/"Classroom (DB)", plus a view_working_schedules_ import_wizard-only "Database" block) were removed from both ca_ES.po/es_ES.po rather than left dangling; a shared "File" block (still needed by ems.student_document.doc_file/ view_contact_form) had just this view’s own now-stale #: reference trimmed off, not the whole block. Every new JS-side label (_t() calls in the new widget: the 4 kind names, the 4 resolution names, the bulk placeholder, the room-picker placeholder) got its own #. odoo-javascript-tagged .po block in both languages - reusing the exact already-proven translated text from the pre-existing Python/view-arch entries for the 8 labels that already existed elsewhere (JS-sourced _t() and Python/view-sourced _()/arch strings are looked up from separate, source-type-tagged catalog entries even for byte-identical text - see CLAUDE.md’s own i18n note on this).

self_conflict — a teacher double-booked across two rooms, within the SAME batch (2026-08-10)

The actual gap, found against a real import: the developer manually merged two DIFFERENT raw identifiers (two placeholder codes) to the SAME existing employee (Screen 4’s “same person as” merge, confirmed working with no code change needed) - that one real teacher turned out to be double-booked at the same time in two DIFFERENT rooms. Neither this screen’s own room-based _find_internal_conflicts (which only ever pairs entries sharing a space_id) nor ems. attendance_template.find_self_conflicts (the pre-existing DB-side self-conflict check, whose own docstring already says “it does not catch two overlapping entries for the same teacher within the single batch being submitted right now”) could ever catch this - it surfaced as a raw, unworded check_overlap ValidationError at the final Import click instead of a resolvable line.

New _find_self_conflicts_in_batch(node_cache, excluded_pairs): pairs every two TEACHING entries from DIFFERENT node_cache items both explicitly resolved to the SAME employee_id (the only way two distinct identifiers can be confirmed as one physical teacher - a bare code left on “New”/a genuinely new e-mail always mints its own DISTINCT pending employee, never collides with another identifier this way) whose weekday/time overlap, regardless of room. excluded_pairs (built from _find_internal_conflicts’s own room-based pairs first) skips anything already surfaced as a room-based conflict of another kind, so the same collision is never listed twice. Called from _build_internal_conflict_lines() (Screen 4’s own _continue_from_teachers() handler

New kind option, self_conflict (“Same teacher, different room”), allows only prevail_left/ prevail_right (_resolution_is_valid’s allowed_by_kind, and _RESOLUTION_DEFAULTS['self_ conflict'] = 'prevail_left') - never co_teaching (there is no shared room to co-teach in) or reassign_rooms (a room swap fixes nothing when the real problem is one teacher needed in two places at once - the exact same reasoning already applied to the DB-side self-conflict case, now finally also applied here). The JS widget’s own allowedResolutionsByKind mirrors this by hand.

5 new backend tests (test_continue_from_teachers_builds_self_conflict_line_when_two_identifiers_ resolve_to_same_employee, a same-shape regression guard for two DIFFERENT employees, both prevail_left/prevail_right completing the import correctly, and reassign_rooms correctly rejected) plus a dedicated tour (ems_working_schedules_import_resolve_self_conflict, two placeholder codes merged into one seeded employee with entries in two different rooms). TestWorkingSchedulesImportWizard (94/94) and TestWorkingSchedulesImportWizardTour (10/10, incl. the bulk-apply tour above) both green.

Screen 6 — “Existing schedule conflicts” (2026-08-05; renumbered to Screen 5 on 2026-08-10 once “Pending teachers” merged into “Resolve teachers”, then to Screen 6 on 2026-08-11 once “Resolve subjects” was inserted, see above) — within-batch entries vs. already-active DB schedules

Same classification/resolution shape as screen 4 (_classify_conflict_kind, the flat resolution Selection, the enabled/disabled “Continue”), but left = a new entry from this import, right = an already-active ems.attendance_schedule DB record - no fresh check-in needed here, the plan had already fully speced this screen (including the has_sessions interaction, found and resolved in an earlier same-day session) before this pass began.

Deliberately reimplements its own detection rather than reusing ems.attendance_template. classify_external_conflicts/find_self_conflicts as black boxes (those two methods remain in place, unchanged, as _apply_import’s own pre-existing safety net - see its teacher_entries pass) - found while first building this screen, not planned upfront: those methods only ever return the aggregate colliding recordset (all they need for their original yes/no blocking-check purpose), and find_self_conflicts in particular matches purely on weekday/time overlap with no room restriction at all (the same teacher physically can’t be in two rooms at once, regardless of which rooms) - so trying to re-derive the (item, entry) pairing afterward by matching on room, the way screen 4 safely can (every one of its candidates was already room-matched by construction), would silently miss every genuine self-conflict whose colliding room differs from the new entry’s own. _find_external_conflicts tracks the (item_index, entry_index, candidate) pairing itself instead, via its own two ORM searches:

Known simplification, same spirit as screen 4’s pairwise-only detection: if the same existing DB record would collide with more than one new entry, only the first one found becomes a line - not engineered further for a scenario this unlikely.

View/continue_disabled: same shape as screen 4, internal_conflicts excluded state on the placeholder alert becomes db_conflicts, right_space_id/column visibility identical.

Dialog widened to extra-large (2026-08-06, developer feedback): these two conflict screens’ 6-column lists (left/right label, conflict, resolution, two room pickers) crowded badly at Odoo’s default dialog width. Odoo has no per-step dialog sizing - the wizard is one single target: "new" dialog reused across every statusbar step (content swapped via invisible, not separate dialogs), so the size is set once, for the whole flow, via context: {dialog_size: "extra-large"} on the doAction() call in import_planner_cog_menu.js (DIALOG_SIZES mapping in Odoo’s own action_service.js) - the same mechanism base.document.layout’s own wizard already uses in this database. The earlier, narrower steps (2/3, a two-column list) simply get some extra breathing room as a side effect, not a problem.

Screen 7 — “Overall summary” (2026-08-10; renamed from “Existing teachers”/override_info, expanded with a counts recap, per developer feedback - see below; renumbered from Screen 7 to Screen 6 later the same day once “Pending teachers” merged into “Resolve teachers”, then to Screen 7 again on 2026-08-11 once “Resolve subjects” was inserted, see above) — final preview + confirmation before Import

The last step, purely informational, immediately before the real “Import” click. One field, overall_summary_html, built by _continue_from_db_conflicts() right before advancing (the same point that already builds every other cached-batch derivation) - a category-by-category recap laid out as a responsive row of Bootstrap cards (_summary_blocks_html/_summary_block_html), not a flat list:

Why renamed and expanded (2026-08-10, developer feedback): the screen was originally just a plain existing-teacher preview, labelled “Existing teachers”. Feedback in order:

  1. “El último paso, ‘existing teachers’ creo que no es claro. ‘Override info’ me parece mejor.”
    • the existing-teacher list’s actual point is warning about an override, not just naming who already exists; “Override info” said that more directly.
  2. “¿… en el paso ‘Override info’ se permite al usuario escoger si mantener la versión actual o aplicar la nueva?” - no: confirmed there has never been a per-teacher keep-vs-override choice anywhere in this wizard; the screen (under any name) is purely informational. The underlying want behind the question was still valid though: with 6+ steps and no way to go back, a final, purely-informational recap of everything the run is about to do is genuinely useful before the irreversible “Import” click - recommended expanding this already-final screen to cover that, rather than adding an 8th step (avoids an extra click and content duplication with the existing-teacher preview already living here).
  3. “Vamos a ampliar Override info, pero lo cambiaremos por ‘Overall summary’… si crees que hay un título mejor” - confirmed: expand in place, renamed to “Overall summary” once it’s no longer only about overrides.
  4. “Quiero decir, que la clave técnica coincida con la etiqueta… Sino nos liaremos.” - unlike this wizard’s own established precedent of keeping the technical state key stable when only the label changes (e.g. “Internal conflicts” → “File conflicts” kept internal_conflicts, see screen 5 above), the developer explicitly asked for the technical key to track the label here too, to avoid confusion between the two names during this same conversation - override_info (the original key, matching the original label) was renamed to summary (an abbreviation of “Overall summary”, as explicitly permitted - “puede estar resumida, eso si”).

Why blocks instead of a flat count list (2026-08-10, same day, next round of feedback after actually seeing the counts-only version): “Creo que la última pantalla, la del resumen, no se entiende demasiado… Si se ha resuelto 1 grupo, quiero saber cómo. Si se han resuelto dos correos, quiero saber cómo. Entiendo que los profes que veo son los 26 afectados, pero no queda claro.” The first version of this screen showed only 6 plain count sentences ("1 unresolved group name(s) resolved", etc.) - accurate, but told the admin nothing about how each thing was resolved, and the pre-existing existing-teachers list sat disconnected from its own “N existing teacher(s) affected” count with nothing visually tying the two together (exactly the “no queda claro” the developer flagged). Fixed by making every count sentence a block’s own header, with that category’s concrete detail lines directly underneath it - a count and its “how” can no longer be read as unrelated facts. Also explicitly asked for clearly-separated blocks (“bloques horizontales, bien diferenciados”) rather than one more flat vertical list - Bootstrap’s own card class gave this for free, no custom CSS needed (this repo’s “Odoo way first” rule). The very first layout attempt at this packed all 6 cards into a wrapping horizontal row (several per line, via flex-wrap) - changed the same day, once actually seeing it rendered showed the first row’s 4 cards too cramped to read comfortably at the dialog’s real width; switched to one full-width card per row (flex-column) instead.

Multi-step wizard skeleton (2026-08-05) — all 6 screens now have real logic

Rebuilt into a guided flow (statusbar state field, one screen per step) per plans/ working_schedule_import_redesign.md’s “Multi-step wizard” design - originally 7 screens, then 6 once “Pending teachers” merged into “Resolve teachers” (2026-08-10, see above). As of the 2026-08-10 pass, every step has real behavior - what were then the last two still-placeholder steps (“Pending teachers”/”Existing teachers”) have since also been reordered, one merged into “Resolve teachers” and the other expanded into “Overall summary” (see “Former Screen 5” and Screen 6, above) - _STATE_SEQUENCE is currently ['intro', 'groups', 'teachers', 'internal_conflicts', 'db_conflicts', 'summary'].

The most important structural change: create() no longer does any real work. Before this pass, the wizard’s create() override parsed every attached file and wrote everything (resource.calendar, ems.teaching, ems.attendance_template) in one shot, since the old single-screen wizard only ever had one button (“Import”) and clicking it both saved the record and did the import. With a multi-step flow, the record gets saved on the very first “Continue” click (leaving the intro screen) - if create() still did the heavy lifting, that first click would already perform the entire import, defeating the point of the later resolution steps. So:

The intro screen shows no validation output at all (changed 2026-08-05, developer feedback right after actually using it): the very first version of this screen ported over the old single-screen wizard’s four banners (red “blocking issues” - unknown e-mail, unresolved group/subject, missing classroom, room conflict; blue “pending teachers”; yellow “already has a schedule”/”co-teaching”) verbatim, gated behind an @api.onchange('attachment_ids') that re-ran the full classification live as files were attached. The developer’s read, after trying it: “al cargar el fichero, no salga nada en esta primera ventana, solamente que se active el botón ‘Continue’… o que se pueda cancelar” - resolving an unresolved e-mail/group is what steps 2-3 exist for, so showing (or blocking on) that problem at the welcome screen pre-empts what those screens are for. Confirmed explicitly: ready_to_import (gating action_continue) should activate the moment any file is attached, full stop - every real problem (unresolved e-mail, missing classroom, room/schedule conflicts) is deferred all the way to _apply_import() at the final step; today, since steps 2-6 are still placeholders, that means the problem surfaces at the final “Import” click instead of whichever screen will eventually own it. Concretely, this removed:

One thing that still can’t be deferred: _classify_attachments() still catches (and blocks leaving the intro screen on) a ValidationError raised by _parse_schedule_entries() itself - an unresolved SUBJECT or GROUP code (as opposed to an unresolved e-mail, which is deferred) has no entries to cache in the first place, so there is genuinely nothing to carry forward to a later step; letting it through silently would just lose that teacher’s schedule data. Collected as unparseable_issues (folded into _continue_from_intro’s ValidationError message, same “also reachable from a direct ORM call with no banner to look at” reasoning as before). The “no current course configured”/”no file attached” checks stay at intro too, for the same reason - no later step could ever resolve either one.

**Gotcha worth knowing before touching any of this (found empirically 2026-08-05, cost real
debugging time):** a type="object" button whose Python method returns a falsy value (None, the
implicit return of a method with no explicit return) gets converted client-side into
{'type': 'ir.actions.act_window_close'} - see `odoo/addons/web/static/src/webclient/actions/
action_service.js's own doActionButton: action = action && typeof action === “object” ? action
{type: “ir.actions.act_window_close”}. For a wizard opened as target: “new” (this one, from the cog menu), that silently **closes the dialog** the moment it fires - which happened on the very first "Continue" click, since action_continue() had no explicit return at all. Chased down several wrong leads first (props.onSave/clickParams.closable, record.save({reload: true})'s own dialog-wrapping) before finding the real cause in doActionButton itself - confirmed with a diagnostic tour step logging document.querySelectorAll(“.modal”).length right after the click (came back 0). **Fixed by having action_continue() always return an explicit action dict** - _reopen_self_action() re-opens this exact wizard record (res_id: self.id) as a fresh target: “new” window action, so the same dialog stays open showing whatever state was just written. Any future step's own "Continue"/resolution method must do the same (return a real action dict, never fall through to an implicit None`) or it will silently close the wizard instead of advancing to the next screen.

Action buttons belong in <footer>, not <header>, or Odoo’s own generic Save/Discard shows up too (found 2026-08-05, developer feedback: “los botones Continue y Cancel deberían estar donde aparecen ahora Save y Discard”). The first cut of this arch put action_continue/ import_planner_data/cancel inside <header> alongside the state statusbar field - the buttons rendered fine there, but for a target: "new" dialog, web.FormView’s own template independently renders a layout-buttons slot that gets portaled straight into the modal’s .modal-footer (web/static/src/core/layout.xml: t-portal="'#' + env.dialogId + ' .modal-footer'"). That slot falls back to Odoo’s generic web.FormView.Buttons template (plain Save/Discard/Remove) whenever the arch has no <footer> element (footerArchInfo is falsy) - completely independent of whatever buttons <header> already has. So the dialog showed both: our own statusbar buttons and Odoo’s generic Save/Discard underneath, which reads as a duplicate control, not a replacement. An initial attempt at this same complaint misdiagnosed it as web.FormStatusIndicator (a different, unrelated small icon-pair shown in the control panel for dirty records) and tried hiding it via a className override + scoped CSS - reverted once testing showed zero visible change, since that component isn’t even what was rendering here. Fix: move the 3 buttons into a real <footer>, keep only <field name="state" widget="statusbar"/> in <header> - matches how every other target-new wizard in this codebase already does it (e.g. ems.grade_import_wizard’s import_wizard.xml). Browser-tour selectors for these buttons must therefore target .modal .modal-footer button[name='...'], not .modal .o_form_statusbar button[name='...'] (the latter is still correct for a field that’s genuinely only a statusbar with no header buttons, as in other EMS wizards that don’t need this dialog-footer distinction).

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.

Batch import only — no per-employee file upload. attachment_ids (a Many2many to ir.attachment, the “Working Schedules” list’s cog menu, import_planner_cog_menu.js) is the only way in: several files can be attached at once, each one free to describe several teachers by e-mail. A teacher joining mid-year gets their schedule via the Schedule tab’s own New panel (blank framework or copy from another teacher) or by hand — a single-file upload scoped to one employee used to exist (teacher_id/file fields, skipping the e-mail lookup) and was removed: onboarding one person doesn’t need a file format built for a whole department, and dropping it also removed the email_mismatch_warning/scoped-specific-error code paths entirely.

Overlap handling — two independent checks:

ems.attendance_template.classify_external_conflicts looks for other teachers (outside the current batch) occupying the same space/day/time as a new entry:

flowchart TD
    E["New entry overlaps an active session (same space, day, time),\nbelonging to a teacher NOT in this batch"] --> Q{"same subject AND shares a group?"}
    Q -->|yes| CT["Co-teaching - left alone, sync_from_schedule_batch_fresh_import's own\nreconciliation folds the new teacher into the shared template.\nSurfaced as a non-blocking banner (co_teaching_html) to confirm intent."]
    Q -->|no| SC["Genuine room conflict - blocking\n(blocking_issues_html in the onchange preview, ValidationError from create())."]

There is no third, automatic case for “same subject + same group but a different space” yet: the wizard itself doesn’t offer a room-reassignment resolution today (still planned — see plans/working_schedule_import_redesign.md’s “Room reassignment” section). The model-level foundation for it already exists (2026-08-05): a session’s space no longer has to match its group’s own space_id — ems.attendance_schedule.space_id (the weekly recurring line, not the template) is the actual authoritative room, defaulted from the group at creation but free to diverge afterwards, and _schedule_lines already prefers an entry’s own space_id over the group-derived default when syncing. attendance_template.space_id is no longer authoritative either — it’s just the “Session’s default space” seed value for a manually-created line. See “Room granularity” below for the full mechanism. _write_schedule_sync still re-derives a persisting template’s own space_id from the group’s current one on every sync (unchanged, since that field is just a default/seed, not read by anything downstream any more).

ems.attendance_template.find_self_conflicts catches a different, complementary case: the same teacher, submitted in this batch, double-booked against their own already-active schedule for a genuinely different (subject, group-set) combination — e.g. two departments’ files, imported separately over time, both scheduling that one teacher Monday 09:00, in two different rooms. classify_external_conflicts cannot see this at all (it only ever searches for other teachers sharing the same space); left unchecked, this surfaced only as Odoo’s raw check_overlap ValidationError (same_teacher set-intersection check on ems.attendance_schedule) — correct in that it stopped the import (no data loss), but with none of the wizard’s own naming/formatting. A candidate sharing a (subject, group-set) with one of that same teacher’s own submitted entries is never flagged — that’s just this exact combination being moved/updated in place, which _reconcile_fresh_import already handles by refreshing the existing template rather than creating a conflicting new one. Known limitation: this only compares new entries against already-written DB data (i.e. across separate imports) — two overlapping entries for the same teacher within the single file/batch being submitted right now (a malformed source export) still falls through to the raw check_overlap error instead of a named banner.

Loading feedback: the attachment_ids field uses a custom widget (ems_blocking_many2many_binary, static/src/js/backend/working_schedule_import_blocking_upload.js) instead of the plain many2many_binary one — a thin subclass wrapping the upload’s onFileUploaded() call with Odoo’s own env.services.ui.block()/.unblock(). The onchange this triggers (_onchange_attachment_ids) parses the whole XML server-side and was slow enough, with zero visual feedback otherwise, to look hung (reported 2026-08-01). No bespoke spinner: ui.block() is the same reference-counted overlay Odoo already uses for long button actions, just wired to the file input instead.

The “Continue”/”Import” buttons share this wizard’s own js_class (ems_working_schedules_import_wizard_form, static/src/js/backend/working_schedules_import_wizard_form_controller.js) — rewritten 2026-08-10 (developer feedback: “durante la importación… debería aparecer el loading modal que bloquea toda la ventana, el mismo que usamos durante la migración”) to reuse blockingActionFormView() (static/src/js/backend/blocking_action_form.js — the exact same factory ems_course_transition_form already uses for the course transition wizard) instead of a bespoke implementation. The earlier version blocked in beforeExecuteActionButton and unblocked immediately in a finally right after

Pending-identification teachers (a code with no @)

New timetables sometimes arrive before every teaching post is staffed — the external planner names those rows with a placeholder code (X1, X2…), or sometimes the not-yet-hired teacher’s own real, multi-word name, instead of a real e-mail. _teacher_identifier(name_attr) is what extracts the actual identifier from a <TeacherNode>’s raw name attribute: it searches the whole value for an e-mail-shaped substring and, if none is found, keeps the entire (stripped) value — deliberately not just its first whitespace-separated token, which would silently truncate a multi-word name like "Fulanito Menganito" down to "Fulanito" (a real bug, fixed 2026-08-01). _is_email_like(value) ("@" in value) then just tells the two resulting cases apart, in both the general path’s create() loop and _onchange_attachment_ids’s preview — the scoped (per-employee) path never does an e-mail lookup at all, so it’s unaffected.

flowchart TD
    N["TeacherNode name attribute"] --> D{"contains '@'?"}
    D -->|yes| L["search hr.employee.work_email"]
    L -->|found| OK["use that employee"]
    L -->|not found| ERR["ValidationError: real typo/unknown address — import fails"]
    D -->|no: e.g. 'X1'| C["search hr.employee.schedule_import_code = code"]
    C -->|found| OK
    C -->|not found| CREATE["create(name=_('Pending teacher (%s)') % code, employee_type='teacher', schedule_import_code=code)"]
    CREATE --> OK

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 ems.enrollment (group_id) — 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 via ems.enrollment (group_id pointing at the reinforcement group, same mechanism as any other group’s per-subject enrollment — see group.md’s “reinforcement_student_ids removed” note) — 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”.