ems.course_transition_wizardems.course_transition_wizard is the end-of-year transition: the single operation that closes the outgoing course and opens the incoming one. It freezes the academic history, turns graduates into archived alumni, places the returning students into their destination groups from their confirmed enrollments, and wipes the operational records of the year that just ended.
It is deliberately scoped by study (study_ids), because studies do not finish at the same time: a CFGS may be closed in June while an ESO level is still evaluating. Each run transitions the studies it is given and marks them transitioned; the global course flip (which course is “current”) only happens on the run that leaves no active study behind.
The wizard is preview-first: nothing is written until a dry-run has listed the blockers, the warnings and the exact record counts, and the operator has ticked the backup checkbox. Step 8 is irreversible.
Module files: models/settings/course_transition_wizard.py, models/curriculum/study.py (transition_state), models/enrollment/enrollment.py (_ems_admit_student latecomer branch), views/settings/course_transition_wizard.xml, views/settings/form.xml, security/ir.model.access.csv, tests/test_course_transition.py, static/tests/tours/course_transition_tour.js
erDiagram
EMS_COURSE_TRANSITION_WIZARD ||--o{ EMS_COURSE_TRANSITION_WIZARD_LINE : "line_ids (preview)"
EMS_COURSE ||--o| EMS_COURSE_TRANSITION_WIZARD : "source_course_id / target_course_id"
EMS_STUDY }o--o{ EMS_COURSE_TRANSITION_WIZARD : "study_ids (scope)"
EMS_COURSE_TRANSITION_WIZARD_LINE }o--|| RES_PARTNER : "student_id"
EMS_COURSE_TRANSITION_WIZARD_LINE }o--o| EMS_GROUP : "destination_group_id"
RES_COMPANY ||--o| EMS_COURSE : "current_course_id (flipped)"
EMS_STUDY ||--o| EMS_STUDY : "transition_state active/transitioned"
Both models are TransientModel: the preview is a throwaway projection, never a stored plan.
ems.study.transition_statetransition_state = fields.Selection(
[('active', 'Active'), ('transitioned', 'Transitioned')],
default='active', copy=False)
A new field, so no migration script is needed (no XML ID is renamed). It is the switch that makes a latecomer work: sale.order._ems_admit_student() places a student immediately (group + subject enrollments) when the destination study is already transitioned, and leaves the placement to the bulk wizard while the study is still active. That branch already exists in enrollment.py, guarded by a getattr because the field did not exist yet; creating the field and dropping the getattr activates it.
Step 5 resets every study back to active when the flip happens, so the next course starts clean.
| Blocker | Rule |
|---|---|
| Target course | It exists and is different from source_course_id |
| Evaluation not closed | Every ems.grade_session of the last round (max existing round per group·subject) of the groups in scope is in state final, linking the offenders to ems.action_grade_session_state_wizard |
| Confirmed enrollment with no group | No sale.order of _incoming_orders() lacks ems_group_id (D13) |
| Student with no enrollment | No missing line whose study_id.uses_enrollment_flow is true (D17) |
| Origin study still evaluating | No study this run pulls a student out of — outside the scope and not yet transitioned — has an unfinalised last round (D11) |
| Warning | Note |
|---|---|
| Graduates continuing at the centre | action = 'graduate_continue', listed one by one: they keep the graduation but are neither converted nor archived (D10) |
| Students with no destination | transition_status = 'missing', listed one by one (D8), split by study.uses_enrollment_flow |
| Draft / sent enrollments in the target course | NOT cancelled — step 6 only touches the outgoing course. They stay open for a September confirmation |
| Incomplete evaluation | See the rule below (D9) |
| Students with no group at all | _orphan_students(), listed one by one (D18) |
| Attendance templates to archive | Including templates whose group_ids span studies both in and out of scope |
| Records to delete | Counts per model for step 8, grade sessions included |
The criterion depends on whether the group is in the last course of its study:
internal_is_complete on ems.grade_subject_line. Promotion to the next course is decided by the internal grade — the 90% awarded by the centre.has_final, which also demands the external (work placement) part.The reason is that the work placement (EM) only exists in the final course, when students actually go to a company. Since the centre’s ems.planning gives a 10% external_ponderation to first-course subjects as well, _final_from_parts() returns (0, False) for them — no final grade at all, not even a failing one. Using has_final everywhere would therefore report every first-course student as incompletely evaluated.
This mirrors the semantics already frozen in the academic history, where ems.student.year_record.subject.state is binary and determined only by the RAs: a pending or failed work placement never fails a subject, because the student repeats the placement, not the subject.
flowchart TD
S0["0 · Academic history<br/>generate_for_students(scope, source)"] --> GUARD{"Step 0 OK?"}
GUARD -- no --> ABORT["Abort the whole wizard"]
GUARD -- yes --> S0B["0b · Planning rollover<br/>_apply_planning_rollover()"]
S0B --> S1["1 · Graduates → alumni<br/>_ems_convert_to_ex_student()"]
S1 --> S2["2 · Revoke portal<br/>_ems_revoke_student_portal()"]
S2 --> S2B["2b · Archive the graduates<br/>active = False"]
S2B --> S7["7 · Archive attendance templates"]
S7 --> S8["8 · Operational cleanup<br/>IRREVERSIBLE"]
S8 --> S3["3 · Placement (group)"]
S3 --> S4["4 · Subject enrollments"]
S4 --> S4B["4b · Detach the unplaced<br/>main_group_id = False"]
S4B --> S5["5 · Mark transitioned<br/>+ conditional global flip"]
S5 --> S6["6 · Outgoing enrollments<br/>lock confirmed / cancel draft"]
S6 --> S9["9 · Audit: message_post + CSV"]
Steps 3 and 4 are a single bulk call to sale.order._ems_apply_destination_placement(), which is already idempotent and already ordered (group before subject enrollments). Every step is scoped to study_ids except the flip.
_apply_planning_rollover() copies every ems.planning of the
studies in scope from source_course_id to target_course_id, including its
planning_outcome_ids (which copy() does NOT duplicate on its own — a plain one2many
defaults to copy=False in this Odoo version, confirmed empirically while implementing this).
Idempotent: skips any study+subject that already has a target-course planning, so relaunching a
transition never duplicates one. Scoped to study_ids like every other step here, since studies
transition at different times — a study still pending never gets its planning rolled forward
until its own run.
The steps are numbered by the phase they belong to, not by the order they execute in: 7 and 8 run before 3 and 4, and that is load-bearing rather than cosmetic.
res.partner._ems_clear_operational_records() deletes every ems.enrollment of the student with no group or course filter. It was written for a withdrawal, where the student leaves the centre altogether and keeping any enrollment would be wrong. Running it after the placement would therefore delete the very enrollments steps 3-4 had just created, leaving every promoted student in their new group with no subjects at all.
tests/test_course_transition.py::test_apply_keeps_the_new_enrollments_after_the_cleanup pins this down: swapping the two blocks makes it fail, together with the two placement tests.
_templates_to_archive() (the scope minus any template whose group_ids span a study still out
of scope) is archived via action_archive(), not a bare write({'active': False}) — the
distinction matters: EmsAttendanceTemplate.action_archive() is overridden to also archive
attendance_schedule_ids, while a plain write() bypasses that override entirely. An earlier
version of this method used write() directly, which left every archived template’s schedule
lines active=True forever - invisible in the template’s own (now-archived) form, but still
matched by classify_external_conflicts/find_self_conflicts (ems.attendance_template, used by
the working-schedule import wizard’s own conflict screens), silently contradicting those methods’
own docstring assumption that a transitioned study has nothing active left to reconcile against.
test_apply_archives_the_schedule_lines_of_an_archived_template pins this down - the two older
template tests never caught it because their own _template() fixture creates a template with no
schedule lines at all.
_migrating_calendar_blocks() finds every active resource.calendar.attendance row, on any
teacher’s real (non-framework) personal calendar, whose own group_ids belongs to a study in
scope — read directly from the calendar block itself, which is simply the calendar-driven entry
point this whole module now works from (see
attendance_template.md’s “Bottom-up sync redesign”
section) rather than a hedge against drift the way it used to be. action_preview() counts these
blocks (calendar_block_count, shown alongside template_count) — read-only, same as every other
preview counter.
_apply_calendar_archival() (step 7a, called right after _templates_to_archive().action_archive()
in _apply_cleanup) archives them — full stop. Before Phase 7, this method also had to work out by
hand which ems.attendance_template/ems.attendance_schedule records that archival implied
(a direct attendance_schedule_id FK read, falling back to a fuzzy teacher+subject+group+time
match for a legacy block that predated the FK), then decide per template whether to archive it
outright or drop just the departing teacher(s) via _write_or_new_version(). None of that is
needed anymore: archiving a resource.calendar.attendance row is a write the automatic sync hook
(models/employees/working_schedule.py, Phase 4 of the redesign) reacts to on its own, resyncing
the affected teacher(s) exactly as it would for any other calendar change — the SAME decision
(archive the template outright if no teacher still supports it, else drop just the departing
teacher, cloning the template if it has_sessions) the general sync pipeline already makes and
already has its own test coverage for (tests/test_attendance_template.py), calendar-driven, not
specific to this wizard. Confirmed empirically before removing the hand-rolled version: temporarily
un-suppressing the hook here and re-running the full TestCourseTransition suite passed 124 of 127
tests completely unchanged; the 3 failures were fixtures built around a template that had drifted
from its own calendar (a co-teacher added to teacher_ids with no matching calendar block of their
own) — a state the sync hook makes structurally impossible to reach through any real write path
once it’s the only thing that ever creates or updates a template/schedule line. Those 3 tests were
deleted rather than adapted with calendar-driven fixtures (see their removal in git history and
tests/test_course_transition.py’s own comment at that spot) — two of them can’t be reconstructed
that way even in principle, since co-teachers only merge into one template when their calendar
blocks share the exact same (subject, group_ids) key (ems.attendance_template.
_reconcile_teacher_groups), so this wizard’s own group-scoped archival domain can never single out
just one of two co-teachers of an otherwise-identical slot.
Session archival is the one piece this method still does explicitly and unconditionally, right before it returns:
flowchart TD
B["migrating calendar block archived\n(resource.calendar.attendance.action_archive)"] --> H["automatic sync hook resyncs the\naffected teacher - archives/clones\nthe template as a natural consequence"]
U["UNSCOPED catch-up, always runs:\nevery archived ems.attendance_schedule\nline anywhere with active sessions"] --> S["archive its sessions too\n(the sync pipeline never touches\nattendance_session_ids on its own)"]
Deliberately unscoped (2026-08-10, found re-running a real transition): a teacher whose calendar
was already fully archived in an earlier run (zero active blocks left, so they never even enter
affected_teachers this time) can still have a stale line from back then whose session catch-up
was simply never reached. Real example that surfaced this: David Delgado’s own template/line had
already been archived by a previous run, his whole 2025-2026 calendar was already archived too
(fully rolled over already), yet 4 of his session headers stayed active because nothing ever
triggered a look at that specific, by-then-inactive line again. This is a plain data-integrity
invariant (“an archived line’s sessions are also archived”), correct precisely because there is no
legitimate scenario where an archived line should keep an active session, regardless of which run
(or how long ago) archived it.
Found auditing real dev data before a batch import: ems.attendance_justification and
ems.attendance_issue_status/_student/_tutor (“daily issues”) were never wired into this
wizard at all, in any of its earlier phases — confirmed by grep, zero references in this file
before this fix. res.partner._ems_clear_operational_records() (called later in _apply_cleanup,
see “Why the cleanup runs before the placement” above) already deletes issue records, but only
for a student still in _scope_students() at run time — captured by current main_group_id.
A student already detached from any group (stranded by an earlier run’s _apply_detach_unplaced(),
or a reinforcement-group student never captured by main_group_id at all) falls outside that scope
forever, exactly the gap that left 2 justifications and 27 “daily issue” rows (10 tutor + 17 status)
sitting active in real data, all referencing sessions that were themselves already correctly
archived.
_apply_attendance_records_archival() (step 7c, called right after _apply_calendar_archival() in
_apply_cleanup, so it sees both this run’s own newly-archived sessions and any pre-existing
leftover) fixes this with a condition keyed on the session’s own archived state instead of student
scope — correct regardless of which run originally archived the underlying session:
ems.attendance_justification: archived if it has at least one attendance_session_line_ids
entry and all of them point at an already-archived session.ems.attendance_issue_status: archived directly by a domain on
attendance_session_line_id.attendance_session_id.active = False. Its parent
attendance_issue_student/attendance_issue_tutor are then archived too, once each is left with
no active children — read via the model’s own default active-filtered relation (no extra
bookkeeping needed: “no active children left” and “the field reads empty” are the same
observable state once the children are archived).Archives, never deletes — unlike _ems_clear_operational_records()’s deletion, which is
specifically justified there for a student who has actually left the centre and whose stats are
already frozen in the year record. These records have no such freezing step, so archiving (keeping
them findable via the “Archived” filter, matching every other attendance model in this system) is
the safer default.
plans/course_transition_teacher_schedule_archival.md, phases 6-7)_apply_calendar_rollover(teachers) (step 7b, right after _apply_calendar_archival() — which
returns exactly the teacher set this needs, captured before their migrating blocks were
archived) is what actually makes a teacher’s resource_calendar_id track the current course over
time, closing the loop resource.calendar’s own employee_id/course_id fields
(working_schedule.md) were added for:
flowchart TD
T["for each teacher _apply_calendar_archival()\njust touched"] --> E{"calendar has zero\nactive TEACHING blocks left?\n(non-teaching doesn't count)"}
E -- no, teaching remains --> Z["leave completely untouched -\ne.g. another study hasn't\ntransitioned yet"]
E -- yes --> R{"a calendar for\n(teacher, target_course)\nalready exists?"}
R -- yes --> U["reactivate it\n(a previous transition cycle\nalready made + archived it)"]
R -- no --> N["create a fresh one,\nseeded from the outgoing\ncalendar's own framework"]
U --> A["archive the outgoing calendar,\nreassign resource_calendar_id"]
N --> A
calendar.attendance_ids.filtered(lambda a: a.active and not
a.non_teaching)) deliberately ignores non-teaching entries (a guard duty, a coordination
meeting…) — a teacher who’s done teaching but still has a leftover fixed commitment on their old
calendar still rolls over; only real teaching left blocks it. This is what lets phases 5 and 6
cooperate automatically without a manual trigger: a teacher only spanning studies that all
transition together empties out and rolls immediately; one who also teaches a still-pending
study keeps their current calendar untouched, exactly as before.active_test=False on (employee_id, course_id) —
a calendar for that exact pair can already exist, archived, from an earlier transition cycle
(a teacher who left and came back, or a centre re-running a transition). Reusing it instead of
minting a duplicate is what “one-per-(teacher, course), archived, never orphaned” (decision 5)
actually requires in practice, not just at first creation.source_framework_id — not unconditionally the company’s default — so a teacher
who’s been following e.g. the CFGS framework keeps following it across the transition; the
company default is only a fallback for a calendar that was never seeded from one to begin with._migrating_calendar_blocks() already excludes
their attendance rows from ever counting as “migrating” in the first place (see above), but the
is_framework guard here is kept anyway since this method’s own precondition (being in
_apply_calendar_archival()’s returned teacher set) is the only thing that would otherwise stop
it from ever reaching a framework calendar by accident.ems.teaching/tutor_id (2026-09-01, plans/course_transition_stale_teacher_assignments.md)Two gaps found via a real Guard Duty Board report (a departed/reassigned teacher still showing guard-duty slots), both stemming from the same root cause: the archival above is thorough for the calendar side of a teacher’s outgoing assignments, but nothing downstream of it reacted to the adjacent, loosely-coupled models mirroring the same real-world fact.
1. ems_working_schedule.action_archive() now cascades to its own attendance_ids (mirrors
ems.attendance_template.action_archive()’s cascade to its schedule lines). The emptying check
above deliberately never counts a non-teaching row as “teaching left” — which is correct for
whether to roll over, but previously meant those non-teaching rows (guard duty, a coordination
meeting) were simply abandoned, still active=True, on the calendar _apply_calendar_rollover()
was about to retire. Any screen reading resource.calendar.attendance directly without also
checking calendar_id.active (the Guard Duty Board did) kept surfacing them indefinitely. The
Guard Duty Board’s own query (guard_duty_board.py) now also filters calendar_id.active = True
as defense-in-depth — not redundant with the cascade, since a future path could in principle
archive a calendar through some other route without following the same convention.
2. _apply_teaching_resync(teachers) (step 7c, right after _apply_calendar_rollover())
resyncs ems.teaching from each teacher’s now-final calendar. ems.teaching was never touched
anywhere in this wizard before — a teacher’s stale (subject, group) links from before the
transition survived forever, since the working-schedule importer’s own incremental sync is
additive-only by design (replace=False, see working_schedule.md) and never removes them
either. The fix reuses hr.employee._teaching_entries_from_calendar() (the same entries dict
ems.attendance_template._regenerate_all_from_calendars() already builds for its own template
rebuild — extracted into a shared helper so both stay in sync with one calendar-reading
implementation) and calls ems.teaching._sync_from_schedule(teacher, entries) — the same
replace=True reconciliation the Schedule tab’s own live edit already uses
(ems_working_schedule.apply_schedule_changes), just triggered from the transition instead of a
manual save. _regenerate_all_from_calendars() itself gained the identical call, since it has the
exact same “rebuild from the calendar, but never touched ems.teaching” gap.
A group’s tutoring assignment is itself recorded as an ordinary ems.teaching row on the group’s
own tutoring subject (ems.subject.is_tutorship) — deliberately never a stored relation to
ems.group.tutor_id, which predates this model’s calendar-driven sync and is still set directly
on the group form. ems.teaching.unlink() now clears tutor_id whenever the teaching row it
loses is one of these, and only while tutor_id still matches the departing teacher (never
clobbering a reassignment that happened in between). Because this lives on unlink() itself —
the one choke point every removal path already goes through — it fires for the resync above and
for a plain manual Schedule-tab reset, with no group-emptiness heuristic anywhere in this wizard.
Groups themselves are never archived by any of this (they’re reused across academic years) — only
the now-stale tutoring/teaching references are.
3. _apply_detach_unplaced() now also clears a stranded student’s own group delegate, via
the same res.partner._ems_clear_stale_delegate() helper _ems_clear_operational_records()
already used for a student leaving the centre entirely — extracted so both paths share one
implementation instead of the check existing in only one of them.
All three models this section’s own steps archive can be found again afterwards via the search
bar’s Filters → Archived toggle: ems.attendance_template/ems.attendance_session_header
needed the filter added by hand (see their own dev docs — Odoo does not auto-add it), while
resource.calendar already had it natively. A new Course group-by option on the “Working
Schedules” list (working_schedule.md) is what actually exposes the “who taught, in which
course” historical query the plan’s phase 3-7 fields were built for.
Graduates are archived, not just converted, consistent with issue #357 (withdrawals and alumni are both archived, mirroring how archiving an hr.employee asks for a departure reason).
The archive runs after the portal revoke and lives in the wizard rather than in _ems_convert_to_ex_student(), for two reasons: the helper runs before the revoke, and res.partner.write() refuses to archive a contact still linked to an active portal user. A student whose revoke failed is reported and left active instead of raising, so one failure cannot roll back a batch of hundreds.
Finishing a study and enrolling into another one are independent facts, not a contradiction. A CFGM graduate moving up to a CFGS, or a CFGS graduate starting a second one — even in another family — is both at once, and the case is high volume: the 25-26 data has 28 SMX leavers enrolled into ASIX/DAM/DAW.
Earlier versions treated it as a blocker (“a student cannot leave and come back in the same run”), which refused the whole transition. It is now derived and split:
flowchart TD
G{"exit_type == 'graduation'<br/>and exit_course_id == source"} -- no --> OTHER["place / unplaced / missing"]
G -- yes --> E{"non-cancelled sale.order<br/>in the target course?"}
E -- no --> LEAVE["_leaving_graduates()<br/>step 2: alumni + portal revoke + archive"]
E -- "yes, confirmed" --> STAY["_continuing_graduates()<br/>step 2c: keep student, clear exit metadata"]
E -- "yes, draft/sent" --> PEND["_pending_graduates()<br/>step 2d: applicant, portal kept, not archived"]
Nobody marks graduate_continue. It is a computed preview label: the tutor only knows about the graduation, and the enrollment arrives on its own through the GEDAC assignment, so the wizard is the only place where the two facts meet.
The three cases are not two. A graduate holding an offer nobody has confirmed yet can be neither placed (there is nothing to place) nor turned into alumni, and the reason is a hard constraint rather than a preference:
#357 archives every alumnus, and
res.partner.write()refuses to archive a contact with an active portal user. An alumnus is therefore, by construction, someone without portal — and/my/gestion-matriculasisauth="user", so without portal the offer could never be confirmed._ems_revoke_student_portal()makes it worse: it revokes the family’s user too unless another child is still enrolled, cutting off both routes.
applicant is the state that already models this exact situation, and reusing it means no new machinery:
| Need | Already provided by applicant |
|---|---|
| Portal access | ems.portal.access.wizard domain is ('contact_type', 'in', ('student', 'applicant')), and an applicant gets its own login rather than the family’s |
| Sees the offer | get_portal_enrollment() filters by partner, state and course under sudo() — no contact_type check |
| Return path | sale.order._ems_admit_student() has always converted applicant → student on confirmation |
| Not archived | Applicants are never archived by the transition |
Conceptually it is not a workaround: an internal SMX graduate holding an ASIX offer is in the very same position as an outsider who preinscribed to ASIX. study_id/level_id follow the destination on the order so they read as an applicant of the study they are heading to; the exit metadata is cleared (they have not left, they are waiting to come in); has_graduated stays, which is what makes a later manual withdrawal land on alumni.
D3 reversed: the wizard used to offer an “archive applicants without a confirmed enrollment” checkbox. It never archived anything — the flag only ever reached a warning, no apply step consumed it — and the intent was wrong anyway: applicants with no enrollment in July are precisely the ones who may turn up in September, and now that a graduate holding an unconfirmed offer becomes an applicant itself, the sweep would have caught them too. Checkbox, counter, warning and _declined_applicants() are gone; summer clean-up stays manual.
Order is load-bearing: step 2c runs after _apply_history(), because year_record._generate_one() stamps how the student left the outgoing course by reading exit_course_id — which _ems_convert_to_student() clears. has_graduated is never touched: it is permanent (D2).
_ems_convert_to_student() also sets active = True, and sale.order._ems_admit_student() converts alumni/withdrawal as well as applicant, so the individual September path matches what the bulk _apply_placement() already did.
year_record._generate_one() reads student.main_group_id to stamp the group, study, level and tutor of the year that ends. Step 0 therefore only reaches the students the run still sees in its own groups — and _incoming_orders() reaches further than _scope_students(), so a run can place a student whose origin study it is not transitioning.
Run order does not solve it. The 25-26 data has students finishing SMX to start ASIX/DAM/DAW; the symmetric case (finish ASIX start DAM, finish DAM start ASIX in the same year) makes the dependency cyclic, so whichever study runs first strands the other:
sequenceDiagram
participant R1 as Run 1 (DAW)
participant S as Student (SMX2A)
participant R2 as Run 2 (SMX)
R1->>S: _incoming_orders() reaches it, places it
Note over S: main_group_id: SMX2A → DAW1A
R2->>S: _scope_students() no longer sees it
Note over S: no year record, and step 8 deletes<br/>the SMX grade sessions by group
So the freeze moved to sale.order._ems_apply_destination_placement() — the single choke point every placement goes through, bulk and individual — right before main_group_id is overwritten, passing the origin group explicitly through the new group= argument of generate_for_students()/_generate_one().
freeze_on_leaving() is a no-op when a record for (student, current_course) already exists (the normal case: step 0 got there first) and when the origin study is already transitioned (its own run froze everybody, and the current course may already be the incoming one).
Two consequences elsewhere:
_unclosed_origin_studies() checks the last round of every out-of-scope origin study this run would pull someone out of. _last_round_sessions() now takes an optional groups argument so both blockers share it.ems.enrollment by group as well as by student, for exactly the reason grade sessions already did: a student pulled out by another study’s run is no longer in _scope_students(), and its enrollments in the outgoing groups would linger forever.ems.group carries the course number but not the academic year, so groups are reused: a student left pointing at the outgoing group turns up next September as a member of the new cohort. Nothing used to clear it — leaving graduates lose it in step 1 and placed students have it overwritten in step 3, but everybody else kept it. On the 25-26 data that is ~107 students with no enrollment plus ~161 enrolled without a destination group.
The criterion is who was actually placed, not “who is still sitting in a group of the scope”:
stranded = (students - placed).filtered(lambda student: student.main_group_id)
_apply_placement() therefore returns the placed res.partner recordset instead of a count. Keying on the scope groups would detach a student promoted from 1st to 2nd year of the same study, since its destination group is in the scope too — test_apply_keeps_the_group_of_a_student_promoted_within_the_same_study pins that down.
study_id and level_id are deliberately kept: they record what the student was doing, which is what the “no destination” report and a late enrollment both read. Only the group goes.
A sale.order confirmed without ems_group_id used to be a warning: “they will be skipped”. It was skipped indeed — and then there was no way back:
# the probe that settled it
tras apply -> no group
tras escribir el grupo -> no group # write() had no side effect
tras action_confirm -> UserError: "Some orders are not in a state requiring confirmation"
_ems_apply_destination_placement() only ran from action_confirm() and from the wizard’s bulk pass, so filling the group in afterwards did nothing and the order could not be confirmed twice. The student was left with no group, no subject enrollments and no evaluation sessions, recoverable only by editing main_group_id by hand and creating every ems.enrollment one by one. On the 25-26 data that was 161 enrollments against 129 placeable ones.
Two changes, because one alone is not enough:
_ems_place_on_group_assignment(), called from write() when ems_group_id is filled in. It is deliberately narrow — state == 'sale', placement is individual (below), and the student has no group yet. That last guard matters: _ems_apply_destination_placement() creates the new group’s enrollments but does not remove the old ones, so re-pointing an already-placed student would leave it enrolled in two groups at once._ems_suggest_group() reads the destination course from sale_order_template_id.study_year. A repeater never goes through a template: they re-enrol only in what they failed, so the field is empty and the suggestion gave up before looking at a single group. On the 25-26 data that was 10 of the 51 confirmed enrollments with no destination group.
Matching their lines against a template as a whole does not work either — no template is ever a superset of them:
Zakariae Boukraa (SMX2D)
Seguretat informàtica
Muntatge i manteniment d'equips <- module pending from an earlier course
Matrícula El Puig Castellar <- economic item
Quota AMPA <- economic item
Tutoria 2n SMX <- the one reliable handle
_ems_course_from_tutorship() uses the tutorship instead: there is exactly one per enrollment and it is course-specific (Tutoria 2n SMX, T2_CFGS_ICB0_DAM), so whichever templates sell that product pin the year down. It resolves all 10.
Ambiguity returns nothing and the group stays empty — no tutorship line, more than one, or templates disagreeing on the year. If the centre ever stopped making tutorships course-specific, the rule would simply find no answer instead of guessing wrong.
It does not touch the other 41: those fail because no group exists at all in the destination study/course/shift (GA1B afternoon promoting to a 2nd year that only exists in the morning, AD with no 2nd-year group at all). That is missing data, not a rule the code can improve.
D15 fixed which group the order itself resolves to — Zakariae Boukraa’s own destination is correctly SMX2D. It did not fix where each individual subject on that order is actually taught. _ems_apply_destination_placement() stamped ems_group_id onto every subject found among the order’s line products, including Muntatge i manteniment d'equips — the module pending from an earlier course in that same worked example — which needs to be taught in a 1st-course group, not SMX2D.
Confirmed on this dev DB before the fix: 194 active ems.enrollment rows across 103 students had a subject placed in a group of the wrong course. Independent evidence it is real, not a template-data artifact: several of the rows were for a subject literally named Tutoria 1r AD (1st-course tutoring), enrolled under AD2A (2nd course).
ems.study._ems_subject_course(product) generalizes the lookup _ems_course_from_tutorship() already did for the tutorship product to any subject: which single course’s template (if exactly one) sells it. _ems_apply_destination_placement() now calls it per subject and, when it disagrees with the order’s own group, redirects that one subject’s ems.enrollment to ems.group._ems_equivalent_for_course() — an exact acronym+shift match in the target course, falling back to the first group of that study+course by the model’s own order (_order = "name") so a pending subject is never left unplaced (deliberately looser than D15’s own “no exact match → leave empty” rule: this only decides where a student attends one class, not their home group).
A subject sold by templates of both courses stays on the order’s own group — genuinely ambiguous, and a real case in this data (AIF’s Recursos humans i responsabilitat social corporativa / Sostenibilitat aplicada al sistema productiu are sold by both AIF-1 and AIF-2), so the rule declines to guess rather than pick one course over the other.
A one-time migration (migrations/18.0.0.23.1/post-migrate.py::_reassign_misplaced_subject_enrollments) reuses the same two methods to backfill every already-existing misplaced row, so it can never drift from the live logic. It reassigned exactly the 194 rows found during triage.
_ems_suggest_group() picked its strategy with contact_type == 'applicant'. The question it means to ask is “is there an origin group to copy the letter from?”, and the contact type looked equivalent — but it stops being true at exactly the wrong moment: confirming the enrollment runs _ems_admit_student(), which turns the applicant into a student. From then on a newcomer awaiting the bulk placement matched neither branch and got no suggestion at all.
The condition is now the absence of main_group_id, which is what the two strategies actually differ on. It surfaced on a single student — a GEDAC applicant admitted straight into 2nd year whose destination group did not exist when she enrolled — but 150 students were one manual step away from the same hole: student, no group, confirmed enrollment, waiting for the transition.
_ems_admit_student() keyed the individual placement on ems_study_id.transition_state == 'transitioned'. But step 5 puts every study back to active once nothing is pending, so in the normal end state — the whole centre transitioned — the branch was true for nobody and confirming an enrollment in September placed no one:
flip=True | transition_state after the flip='active' | group after confirming=NONE
_ems_placement_is_individual() now answers the real question, “has the bulk pass already happened for this enrollment?”, with two ways of being true:
| Condition | Situation |
|---|---|
transition_state == 'transitioned' |
Partial transition: that study is done, the centre still runs the outgoing course |
ems_course_id == company.current_course_id |
The course has already started, so the wizard is long past |
An enrollment for a course that has not started yet is still left to the wizard.
Making the placement individual after the flip immediately exposed a second, older
problem: from that moment every pending confirmation actually created ems.enrollment
rows, and ems.enrollment.default_get() refuses creation to anyone outside
ems.group_academic_admin. Confirming a pending 26-27 enrollment answered “Only admins
can create manual enrollments” and placed nobody.
The placement had run under sudo() since it was written, precisely to get past that
guard — but sudo() only sets env.su, it does not make env.user the superuser, so
the guard kept reading the real user: the student confirming on the portal, or the
secretary confirming in the backend. The guard now lets env.su through, which is the
only signal that separates a form opened by hand from a placement running on somebody’s
behalf. See contacts/enrollment.md.
D8 originally said the opposite: list them, never block, “in July there is no way to tell a student moving to another school from one who enrolls late”. That reasoning still holds for withdrawing them automatically, which the wizard still refuses to do. It does not hold for letting the run pass without anybody looking, because two things turned out to be irreversible:
graduation_wizard._is_last_course() needs main_group_id to tell whether the student is in the last course, and step 4b has just taken it away.Scoping is what makes it workable. study.uses_enrollment_flow — computed from the study having an active sale.order.template — separates the two worlds, and the 25-26 data shows why a blanket blocker would be unusable:
| Study | Uses the flow | Students | With no enrollment |
|---|---|---|---|
| ESO | no | 493 | 478 |
| BTX | no | 112 | 107 |
| ASIX | yes | 55 | 28 |
| SMX | yes | 130 | 9 |
ESO and BTX do not enroll through sale.order at all; their continuity arrives with the September Esfer@ re-import, so a missing enrollment there is the expected state and stays a warning. In a vocational cycle it is a blocker.
A draft or sent proposal is enough to clear it: those students are pending, not missing. The blocker only fires when there is nothing at all.
year_record.generate_for_students() is idempotent on (student_id, course_id): an existing record has its content replaced, subject lines unlinked and all. That is what makes it safe to call repeatedly — until the student has nothing left to read.
After a run, a student the transition did not place has no main_group_id (step 4b) and no live grade lines (step 8 deleted them, precisely because the record replaces them). Regenerating then rewrites the record from blanks:
before group=CTWS2A study="CTWS (2026)" 1 subject
after group=False study=False 0 subjects
And the record is the only surviving trace of that year — the grades it copied are gone. Only a backup restores it.
It is not hypothetical: ems.withdrawal_wizard.action_apply() regenerates on every exit, _current_course() stays on the outgoing course until the global flip, and the manual tells the operator to register the leavers after applying the transition. The window is exactly the summer.
The guard lives in _generate_one() rather than in the withdrawal wizard: three callers reach it (the transition, the withdrawal wizard and freeze_on_leaving), and a fourth would reintroduce the bug. An explicit group= argument still refreshes normally, which is what freeze_on_leaving() relies on.
_incoming_orders() filters by study_ids, so a run places into its own studies and nothing else. A CFGM graduate moving up to a CFGS the centre has not transitioned yet is therefore not placed by the CFGM run — their destination study’s run will do it — and step 4b detaches them in the meantime.
The preview showed the destination group anyway. On the first real SMX run all 22 graduate_continue lines promised ASIX1A / DAM1A / DAW1A / GA1C, and afterwards those 22 students had no group and no subject enrollments: correct behaviour, wrongly announced, on screen and in the audit CSV alike.
_destination_of(order) now returns the group only when the order’s study is in scope and the order is confirmed; otherwise the column stays empty, which is what it already means for graduate and missing. A warning names them so the information is not lost:
N student(s) are heading to a study this run is not transitioning, so they are not placed here: they keep their enrollment and join their group when that study transitions. Meanwhile they are left with no group.
Same defect class as place_count announcing 138 and moving 122 (D-pending): the preview is a promise, and every number in it has to be one the apply keeps.
_scope_students() captures through main_group_id, so an active student without one belongs
to no run at all, whatever studies are picked: step 0 freezes no year record for them and
step 8 cleans nothing. It is pre-existing data quality — an Esfer@ import that found no group, a
manual edit — but the transition is where it stops being recoverable: afterwards they sit among
the hundreds of students a run legitimately leaves group-less.
study_id is the discriminator. _apply_detach_unplaced() keeps it on purpose when detaching,
so no group and no study means nobody ever placed them. Measured on the rehearsal database
right after the full transition:
| Students | Attendance lines | Year records | |
|---|---|---|---|
| No group, with a study (detached by a run) | 646 | 0 | 653 |
| No group and no study | 8 | 197 | 0 |
Warning, not blocker: the run is not unsafe, and fixing the data is the operator’s call. Giving them a group or registering their withdrawal before applying is what the message asks for.
place_later, so the label matches the promise too (D17)Withholding the destination group was only half of it: the line kept the place action, whose
label reads “Joins its group for the next course” — which this run does not do. The audit CSV
inherits that word, and the CSV is the reference for undoing a case by hand, so it has to be
literally true.
Confirmed enrollments heading outside study_ids are now a distinct action, place_later
(“Joins when its own study transitions”), with its own counter. graduate_continue keeps its
label — those students do graduate, only the placement is deferred — and is still recognised
by its empty destination group.
Reproduced twice during the first full rehearsal: transitioning ESO/BTX/AO first listed 17
students as place, and all 17 ended the run with no group (their own studies placed them in
the second run, which is the intended flow).
flowchart LR
MARK["Mark study_ids as transitioned"] --> CHECK{"Any study left active?"}
CHECK -- yes --> PARTIAL["Partial transition:<br/>no flip, list pending studies"]
CHECK -- no --> FLIP["source.is_current = False<br/>target.is_current = True<br/>company.current_course_id = target<br/>every study back to active"]
ems.course enforces “only one current” and “only one enrollment default” through Python @api.constrains, not SQL, so the outgoing flag must be cleared before the incoming one is set.
is_enrollment_default (D16)It used to clear it on the incoming course, on the reasoning that the running course is
nobody’s “next course” any more. That was wrong: enrollments keep being processed all
through September for the course that has just started, and the field is not only a
default value — it is how the module answers “which course do new enrollments belong
to”. enrollment.py, enrollment_proposal_wizard, graduation_wizard._next_course(),
res.partner._compute_transition_status() and year_record._academic_result() all
resolve it with the same search([('is_enrollment_default', '=', True)], limit=1).
Clearing it left no course flagged at all, so every one of those returned an empty
recordset and the “students without destination” report stopped working — with no UI to
put the flag back (ems.course had no view until this same issue added one).
So the incoming course now stays both is_current and is_enrollment_default. Opening
the following year’s campaign is a deliberate act, not a side effect of the transition:
whoever starts it moves the flag from the course form.
Students with no confirmed enrollment when their study transitions do not block. Step 0 archives their history, step 8 cleans their operational records and steps 3-4 skip them, so they end up as student with no group and transition_status = 'missing'. If they enroll later, action_confirm() → _ems_admit_student() → the now-active transitioned branch places them on their own.
Marking a genuine leaver as withdrawn stays manual (ems.withdrawal_wizard), by design: in July there is no way to tell a student moving to another school from one who simply enrolls late. The D8 listing exists precisely so that list is on screen when the transition finishes.
| Group | Preview | Apply | Notes |
|---|---|---|---|
ems.group_academic_admin |
✅ | ✅ | Sole owner of the wizard and of the Settings button (D1); already owns the grade-session state wizard the preview links to |
ems.group_secretary |
❌ | ❌ | Manages enrollments, not the academic calendar |
ems.group_tutor / teachers |
❌ | ❌ | — |
| Portal / families | ❌ | ❌ | — |
The wizard writes through sudo() where the reused helpers already do (_ems_apply_destination_placement, _ems_clear_operational_records): the operator is an academic admin, not necessarily a grades or attendance manager.
UNIQUE(group_id, subject_id, round) carries no course; deleting them also resets is_locked naturally.has_graduated is permanent — never reset, and readonly on the contact form (D2).static/src/js/backend/blocking_action_form.js exposes blockingActionFormView(messages), a
form-view factory that blocks the UI on the named buttons and unblocks in
afterExecuteActionButton (which Odoo calls even when the action raised, so a failure cannot
leave the screen stuck — proven by the import wizard’s error-dialog tour). It backs this wizard
(action_apply), grade session creation, grade session state change, the Esfer@ grade import, the
working schedules import and the contact data request assistant.
No live counter anywhere, for the reason above: a single transaction publishes nothing until it commits.