EMS

Staff absences (hr_holidays EMS extension)

Replaces the Google Form + Sheet + Apps Script application the centre used to manage staff absences, with three parallel copies (VET/CCFF, ESO/BTX, ASP) that differed only in who approved them. EMS builds on Odoo’s native hr_holidays rather than a bespoke model: the request workflow, approval, hour computation against the employee’s resource.calendar, attachments, calendar event and reporting all come from the framework.

Current scope: the hr_holidays dependency, the absence type catalogue, the derived approver, the access model (cycle 1), and the EMS request fields, hour rules and health allowance (cycle 2). The guard-duty integration and the notification rework land in later cycles — see plans/absence_management.md.

Who approves: derived, never configured

The Google sheet held a Config tab mapping each department to its “Gestor d’absencies”. EMS already models that relationship: the approver is the Area Manager of the employee’s top-level department, which the role hierarchy keeps current on its own (see role_hierarchy.md and department.md).

flowchart TD
    E["hr.employee"] --> D["department_id"]
    D --> W{"is_top_level?"}
    W -- no --> P["parent_id"]
    P --> W
    W -- yes --> M["manager_id (Area Manager)"]
    M --> LM["employee.leave_manager_id"]
    LM --> A["Approves hr.leave<br/>(leave_validation_type = 'manager')"]
Top-level department top_level_role EMS role Approves absences for
VET dhos Deputy head of studies Vocational training staff
ESO / BTX hos Head of studies Secondary and baccalaureate staff
ASP secretary Secretary Administrative and services staff

hr.department._top_level_department() walks parent_id up to the is_top_level ancestor. ems_employee_base._compute_leave_manager() overrides the native compute — which follows parent_id.user_id, i.e. the Seminar Chief or Department Chief, the wrong person here — and resolves that department’s manager_id.user_id instead. Like _compute_parent_id(), it depends only on department_id and is re-triggered explicitly from ems_department._cascade_department_heads(), so replacing an Area Manager re-points every affected employee in one write.

The field stays readonly=False (a stored editable compute, as Odoo defines it), so an administrator can override a single employee’s approver by hand; the override holds until the next recompute — that employee changing department, or their Area Manager being replaced.

The approver is a res.users, not an hr.employee. An Area Manager with no user account therefore leaves every employee in their area with an empty leave_manager_id, which Odoo degrades to “approved by an officer” — the Head of Studies chain — rather than failing. Worth checking when an area’s absences appear to have no approver.

Absence type catalogue

data/cat/hr.leave.type.csv — Catalonia-specific, because the catalogue follows the Generalitat’s own staff-absence regulations (ATRI is its personnel portal), same rationale as data/cat/hr.job.csv.

The nine names are the original form’s own option texts, verbatim. They are long sentences rather than short labels because several carry the legal wording the employee is declaring when they pick one (“I was absent from my workplace for health reasons, a circumstance I reported immediately to the centre’s director”). English is the source language; i18n/ca_ES.po holds the Catalan exactly as the Google form had it, character for character.

No type is preselected. Odoo ticks the first available one on a new request; here the type is the legal ground the employee is declaring, so it has to be a deliberate choice. Turned off through hr_holidays’ own holiday_status_display_name context switch rather than by picking the default apart afterwards.

The form shows them as a radio list, not a dropdown (widget="radio" on holiday_status_id, which Odoo supports on many2one fields), exactly as the Google form did. That is not decoration: several of the nine options are the legal wording the employee is declaring by choosing them, so they have to be readable in full at the moment of choosing.

Everywhere else shows a short name. hr.leave.type.ems_short_name is the text up to the colon - Salut, ATRI, Assistència a Consulta mèdica - which is what the Apps Script itself displayed in the calendar and its emails (type.substring(0, colonIndex)). The absence list shows it through the related hr.leave.ems_type_short_name, and hr.leave._compute_display_name substitutes it into whatever Odoo builds, the calendar chip above all. Deliberately not done by shortening the type’s own display_name: the radio widget reads exactly that field, so shortening it there would empty out the one place the full text matters. ems_short_name is not stored, because name is translatable and a stored copy would freeze one language.

Odoo’s own five types (Paid Time Off, Sick Time Off, Unpaid, Compensatory Days, and Extra Hours from hr_holidays_attendance) are archived by hr.leave.type._ems_deactivate_native_types(). That has to be code, not a data file: all five carry ir_model_data.noupdate = True, and it is that stored flag - not the loading file’s own context - that decides whether an existing record is written.

Type Supporting document Counts in monthly report Notes
Baixa laboral yes no Formal sick leave; excluded from the monthly hours report (2 of 53 real rows counted)
Salut no yes Self-declared health absence; the only type consuming the 15 h/course allowance
Assistència a consulta mèdica yes yes  
Prova mèdica invasiva yes yes Whole day by default
Flexibilitat per menstruació o climateri no yes Its own legal cap (8 h/month) is out of scope
Formació no yes Courses, Erasmus+
Absència justificada no yes  
Encàrrec de serveis no yes Field trips, official travel
ATRI no yes Filed by the employee on the Generalitat portal; Direction confirms it

All nine share request_unit = 'hour', requires_allocation = 'no' (the 15 h cap warns, it never blocks — see the plan) and leave_validation_type = 'manager', which routes approval to leave_manager_id above.

Since issue #440, hr.employee.attendance_manager_id (hr_attendance’s own approver field, unrelated to leave requests) is a stored compute that always mirrors this same leave_manager_id — see My Profile restructuring for why and how.

The request: what EMS adds to hr.leave

Field Seeded from Who edits it
ems_full_day “Whole day” hr.leave.type.ems_full_day_default Employee, while the request is unapproved
ems_counts_hours “Adds the hours to the monthly report” hr.leave.type.ems_counts_hours The approver only
ems_needs_atri “Filed through ATRI” hr.leave.type.ems_needs_atri Employee, while unapproved
ems_responsible_declaration — Employee; required when the type demands it
ems_direction_state “Direction status” — Read by everyone, set by Direction only (its own buttons); hidden until the request exists
ems_head_state “Head status” computed from state read-only
ems_status “Overall status” computed read-only
ems_health_hours_used / ems_health_allowance_exceeded computed read-only

The first three are stored editable computes: picking an absence type proposes a value and any later manual change survives. That is deliberate, and it reproduces what the Apps Script did — it ticked Suma Hores? on submit and left the manager free to correct it afterwards, which they do, because employees miscategorise their own absences.

The last three are the two approvals and where the request stands between them - see Two approvals, in either order below.

The employee never picks the ATRI flag or the monthly-report flag. Both are derived from the absence type and shown only to the approver, who corrects them when the employee chose the wrong type - which is also why holiday_status_id itself stays editable for the approver after approval, overriding Odoo’s own readonly (is_absence_manager drives that).

Two approvals, in either order

Every absence is approved twice: by the Head - the Area Manager who is the employee’s leave_manager_id (see above) - and by Direction (ems.group_director), whose own review is the supporting document and, for ATRI absences, that the request was really filed on the Generalitat’s portal. Either can come first.

stateDiagram-v2
    [*] --> Pending
    Pending --> PendingHead: Direction done
    Pending --> PendingDirection: Head approves
    PendingDirection --> PendingDocument: Direction - missing document
    PendingDocument --> Approved: Direction done
    PendingDirection --> Approved: Direction done
    PendingHead --> Approved: Head approves
    Pending --> Refused: Head or Direction refuses
    PendingHead --> Refused
    PendingDirection --> Refused
    PendingDocument --> Refused
    Pending --> Cancelled: employee cancels
    PendingDirection --> Cancelled

Three fields, one per column of the list:

Field Values Source
ems_head_state “Head status” Pending / Approved / Refused Stored compute over Odoo’s own state
ems_direction_state “Direction status” Pending / Missing document / Done / Refused Direction’s buttons
ems_status “Overall status” see below Stored compute over the other two and state
Head \ Direction Pending Missing document Done
Pending Pending Pending Pending Head
Approved Pending Direction Pending Document Approved

Plus Refused whenever state == 'refuse', whoever refused, and Cancelled when the employee cancelled their own request.

Odoo’s own state is left exactly as it is, and stays the Head’s decision. No values are added to it: hr_holidays hangs everything off it - validate creates the resource.calendar.leaves that the hour balance, the auto check-out and the guard duty board all read, and validate1 already means a second approval with a fixed order (manager first, then an officer), which is not this. So an absence takes effect when the Head approves it, as before; Direction’s review never holds back the calendar. The three columns are built on top of it.

ems_head_state follows state (confirm → Pending, validate/validate1 → Approved, refuse → Refused), with two exceptions where it keeps the value it had: a refusal that was Direction’s, and the employee cancelling. Direction’s refusal is recognised because action_ems_direction_refuse() sets ems_direction_state = 'refused' before calling the native action_refuse(), and the compute reads it without depending on it (the Head’s column must not move when only Direction acts). Being stored computes, both new fields filled themselves in for every existing request when the columns were created on upgrade - no migration.

Direction’s buttons are action_ems_direction_done, _missing_doc, _reset (back to Pending) and _refuse, all through one helper that is a plain write(): the existing guard in hr.leave.write() is what keeps them Direction’s, for these buttons and any other way in. They sit in the form header and, as icons with their label as tooltip, beside the Direction column in the list; they are hidden in the employee’s own list.

A decision taken from the form goes back to the list. The form is js_class="ems_absence_form" (static/src/js/backend/absence_form.js): after any of the Head’s or Direction’s decision buttons, its afterExecuteActionButton() calls the web client’s own historyBack() - the same call Odoo makes after deleting a record. That hook runs whether or not the button raised, so success is read from the record instead: state and ems_direction_state are captured before the click and compared after the reload, and only a change goes back. A refused confirmation or an error leaves the reader on the form, as does a form opened on its own (no breadcrumb to return to, where historyBack() would load the home screen). Refusing is final - the whole request, exactly like the Head’s refusal - and confirms first.

Direction does not approve on the Head’s behalf. Direction holds the officer group through Head of Studies, so Odoo would offer it Approve/Refuse on every request - right beside the Head’s column, where clicking decides for the Head instead of recording Direction’s review. _compute_can_approve() is overridden to be False for ems.group_director except where the Director is the employee’s leave_manager_id (an Area Manager’s own absence). The list and kanban Approve/Refuse buttons, which natively look at state only, now also require can_approve; the form’s already did. It is a screen rule only: server-side, Direction keeps its officer rights.

Direction’s “Waiting For Me” (ems_waiting_for_direction, groups="ems.group_director") lists every live request (state not refused/cancelled) whose ems_direction_state is still Pending or Missing document - whether or not the Head has decided, since either can go first - plus the requests Direction approves as the Head (state = 'confirm' and leave_manager_id = uid). Odoo’s officer filter waiting_for_me_manager, which Direction would otherwise get and which lists the Head’s pending work, is hidden from it (groups="hr_holidays.group_hr_holidays_user,!ems.group_director"). The Management action (hr_leave_action_action_approve_department) opens with both search_default_s set; a filter the reader’s groups hide is simply not applied, so each role lands on its own. The search panel on the left filters on ems_status instead of state.

Covered by TestAbsenceRequest (the whole status matrix in both orders, Direction’s refusal before and after the Head’s approval, can_approve for Director / Head of Studies / Director-as-approver, who gets subscribed, and the filter’s domain) and by three tours: ems_absence_direction_review (Direction’s default list, row and header buttons, kanban), ems_absence_head_approval (Head of Studies approving from the row, kanban) and ems_absence_request (Direction approving before the Head).

The request form

Three deliberate departures from how Odoo would lay this out, all of them to match the form the centre already knew:

The first state is “Pending”

Odoo calls it “To Approve”, which reads as an instruction to whoever is looking at it; from the employee’s own list it is simply the state their request is in, and the spreadsheet this replaces called it Pendent. Relabelled with selection_add=[('confirm', 'Pending')] - for a value that already exists, fields.py merges the added label over the inherited one, so this renames just that one state and leaves the rest in Odoo’s hands.

The responsible declaration

Carried over verbatim, both halves: the paragraph naming the absence types it covers, and the sentence the employee signs. Required for every absence type, not only the ones the intro enumerates - it is the employee asserting that the reason they gave is true, which applies whatever they picked. Enforced by required= in the view and by the same @api.constrains that checks the request was sent.

“Whole day?” is the only control over how an absence is entered

The original form asked for Data inici and Data final as full datetimes and carried a Dia Sencer? column. EMS reproduces exactly that, with one checkbox:

ems_full_day The employee gives Counted as
unticked (the usual case) one day, a start time and an end time the real time missed, rounded to 15 minutes
ticked a start date and an end date a full working day per working day

Odoo expresses the same thing the other way round and in two fields, both of which are dropped from the form: request_unit_hours (“Custom Hours”) is now derived from ems_full_day (request_unit_hours = not ems_full_day), and request_unit_half (“Half Day”) has no use at the centre. Both stay in the view as invisible fields rather than being removed, because other parts of the form still reference them in <label for="..."> and invisible= expressions, and Odoo refuses to validate a view whose label points at a field it does not contain.

Ticking the box copies the start date into the end date (_onchange_ems_full_day_dates), since a whole-day absence is usually a single day - the employee only touches the end date when they want several.

The type still seeds the flag: Salut and Prova mèdica invasiva start ticked (ems_full_day_default), reproducing the Apps Script’s flat 7.5 h, and the employee unticks it when they were only away for part of the day.

Absences hang from Employee Attendances, not a root app menu of their own: staff attendance and staff absence are the same subject at the centre, and that menu already gathers the guard schedule and (under its own “Attendance” submenu) the correction requests. ems.group_secretary is added to that parent menu, because administrative and services staff hold neither the teacher nor the attendance officer group and would otherwise not be able to reach their own absences at all.

“Absences” carries the My Time Off action itself, and every entry under it is restricted to absence managers. That is not tidiness: Odoo renders a menu entry as a clickable link only when it has no children the reader can see (web.NavBar.SectionsMenu, t-if="!section.childrenTree.length"). So an employee, who can see none of the sub-entries, gets a single click straight to their own list; a manager sees the sub-entries and therefore the usual dropdown - which is why My Time Off keeps its own entry there, restricted, rather than being archived.

Entry Who sees it Why
(the “Absences” entry itself) everyone Carries the My Time Off action, so one click lands an employee on their own list
My Time Off group_hr_holidays_responsible Only a manager needs it as an entry: for them the parent is a dropdown header, not a link
Overview group_hr_holidays_responsible The centre-wide absence calendar: what an absence manager uses to see who is missing. Meaningless to an employee, whose record rules would empty it anyway
Management, Reporting, Configuration native groups Unchanged

The action behind My Time Off also drops Odoo’s default search_default_group_date_from: the centre’s own list is short and already sorted by date, so grouping it by month only buries a handful of requests under a fold each.

Odoo’s own employee dashboard (hr_leave_menu_new_request) is archived: it shows the same records as My Time Off in a calendar, and the centre works from the list. Its parent level (“My Time”) is archived with it, since the dashboard and the allocations were all it held.

The supporting document is asked for on every request, at any time

Odoo shows the attachment only when the absence type carries support_document, and only while the request is confirm or validate1. EMS drops both conditions from the inherited form (invisible="0" on the label and the field alike).

The second one needed a matching change in security, because two different mechanisms were hiding it:

security/rules/attendance.xml’s rule_absence_own_request_write widens that (rules of the same group are OR-ed) to the employee’s own requests whatever their state, and hr.leave._ems_check_own_approved_write() closes it back down to the attachment fields above - the field-level half an ir.rule cannot express. Everything else about an approved request stays the approver’s to change, and raises an AccessError naming the justification as the one thing still open.

The chatter entry an employee sees after filing

hr_holidays schedules an approval activity (mail_act_leave_approval) on every request awaiting a decision, and the chatter prints it above everything else. Two things about it were wrong for this centre, and neither could be fixed with a data file or a translation:

The activity’s deadline is Odoo’s, untouched: date_from minus the type’s delay_count (15 days), floored at today - which is why a request filed well in advance shows “Finalitza en N dies” and one filed for tomorrow shows today.

Removing a justification asks first

The stock many2many_binary widget drops an attachment the moment its “x” is clicked - no confirmation, no undo. On an absence that file is the justification itself, often the only copy of a certificate the employee handed in, and the people most likely to click it are working through dozens of requests in a row. ems_attachment_confirm (static/src/js/backend/absence_attachment_field.js) is the same widget with a confirmation dialog in front of the removal; the form uses it in place of the stock one.

Covered by the ems_absence_justification tour, which checks both halves: that the dialog appears at all, and that cancelling it really leaves the file alone. It runs on a request that is approved and of a type requiring no document, so it doubles as the browser-side proof that neither condition hides the field any more.

Refusing asks first too, because almost nobody can undo it

action_reset_confirm puts a refused request back to Pending, but Odoo reserves that to its Time Off Manager group: _check_approval_update raises “Only a Time Off Manager can reset a refused leave” for anybody else, an officer included. res.users._ems_sync_time_off_groups() takes that group back from everyone, deliberately, since it also grants read access to every colleague’s absence reason and attachment - with one explicit exception, base.user_admin itself, kept out of the revocation (protected in that method) because the account needs to stay a genuine Time Off Administrator. In practice a refused request is final for the Head, for Direction and for the employee: only that one administrator account can reopen it, and the employee should normally expect to file a new request instead. The confirmation dialogs say exactly that, rather than claiming the button doesn’t exist at all - it does, for that one account, right there on the same screen (found 2026-09-22, issue #501).

EmsAbsenceLeave.action_reset_confirm() overrides the native method so that reset actually leaves the request clean: on its own, Odoo’s version only ever touches state, so a request Direction had refused (ems_direction_state = 'refused') would come back as Pending overall while Direction’s own column kept showing Refused, with ems_status stuck unable to reflect either. The override clears ems_direction_state back to not_done for exactly those leaves, via sudo() - reaching this method at all already required the wider Time Off Manager group, so clearing a now-stale refusal is not a fresh Direction decision needing its own check.

The button that causes it sits next to Approve on three different screens, and on two of them it is a bare icon in a row. All three confirm first, through Odoo’s own confirm attribute rather than any code:

View Attributes
hr_leave_view_form (header) confirm, confirm-title, confirm-label
hr_leave_view_kanban confirm, confirm-title, confirm-label
hr_leave_view_tree confirm only

The list gets only confirm on purpose. A list view is validated against a RelaxNG schema (base/rng/list_view.rng; there is no form_view.rng at all, which is why form and kanban are unconstrained here), and its button definition allows confirm but neither confirm-title nor confirm-label. Adding them makes the whole view invalid and the module upgrade fails.

Direction’s own Refuse (action_ems_direction_refuse, see Two approvals) carries the same confirmation, in the list and in the form header.

Covered by the ems_absence_refuse_confirm tour, which cancels the dialog from the list button and confirms it from the form one, and by test_refusing_is_not_reversible_at_the_centre, which asserts the rule the wording rests on for a regular officer. test_resetting_a_head_refusal_ clears_it and test_resetting_a_direction_refusal_also_clears_the_direction_check cover what the one account that can reach action_reset_confirm actually gets back.

Allocations and accrual plans are hidden

Odoo’s allocations grant an employee a quota of a type up front and refuse requests once it runs out. Every EMS type sets requires_allocation = 'no' deliberately - the health allowance warns, it never blocks - so allocations can only confuse here, and accrual plans (rules that grow an allocation over time) are meaningless without them. Both menus are archived in views/attendance/absence/menu.xml, and the dashboard’s own “Pending Requests / New Allocation Request” card is removed by an OWL template inherit (static/src/xml/backend/absence_dashboard.xml) - it was the last remaining way into them. The same file renames the dashboard’s create button from a bare “New” to “Absence request”.

Both changes are covered by the ems_absence_dashboard tour: an OWL template inheritance error surfaces only in a browser, never in ./upgrade.sh, which merely checks the XML parses.

Hour computation

Odoo counts a leave against the employee’s resource_calendar_id — in EMS, their real teaching timetable. That is the wrong measure here: a teacher with a single lesson on a Tuesday who misses the whole day would be credited one hour. The centre counts the opposite way, so hr.leave._get_durations() (the method hr_holidays itself factored out to be hooked) is overridden with one rule and no per-type exception:

flowchart TD
    A["Absence"] --> B{"More than one day,<br/>or 'Whole day' ticked?"}
    B -- yes --> C["7.5 h per Mon-Fri day in the range"]
    B -- no --> D["Real clock time missed,<br/>rounded to 15-minute steps"]

Salut is not special-cased: the Apps Script’s flat 7.5 h for it was a shortcut, which is why the manager hand-corrects it down to 0.5-3 h in 14 of the 41 real rows, whenever the employee did come in for part of the day. It simply defaults to ems_full_day_default = True.

Both quantities are settings, not constants: res.company.ems_full_day_hours (7.5) and res.company.ems_health_allowance_hours (15). Read them through _ems_full_day_hours() / _ems_health_allowance_hours(), which fall back to the field default — a zero would make every absence worth nothing and divide by zero in the duration computation.

Public holidays are not deducted from a multi-day range.

The health allowance

Only types flagged ems_counts_health_allowance (just Salut) consume it. ems_health_hours_used sums that employee’s hours over the current course’s window — ems.course.date_range(), 1 September to 31 August — and ems_health_allowance_exceeded flags going over.

It warns, it never blocks. An @api.onchange tells the employee they are over the limit and the request is flagged in the approver’s list view, but nothing refuses the request: going over is the centre’s problem to resolve with the employee, not the software’s to decide. This is also why no type uses an hr.leave.allocation, whose whole semantics are the block we do not want.

Translating this feature’s JavaScript and Python strings

Worth knowing before adding any string here, because nothing warns you when it is wrong: Odoo only serves a translation to the browser, or to Python’s _(), when its .po block carries the right kind marker (odoo/tools/translate.py::_load_web_translations, whose filter is JAVASCRIPT_TRANSLATION_COMMENT in row['comments']).

#. module: ems
#. odoo-javascript                 <- required for code:addons/ems/static/... references
#: code:addons/ems/static/src/js/backend/absence_submit_widget.js:0
msgid "Send request"
msgstr "Envia la sol·licitud"

A block with the reference and the translation but no marker loads into the database, exports cleanly, and still renders in English. Use #. odoo-python for code: references outside static/. To check rather than hope:

CodeTranslations().get_web_translations('ems', 'ca_ES')      # what the browser receives
CodeTranslations().get_python_translations('ems', 'ca_ES')   # what _() resolves

The per-employee report

Odoo’s “Time Off Analysis” (hr_holidays.action_hr_available_holidays_report), reshaped into the spreadsheet’s own Total per profe tab: one line per employee with the hours that count against the health allowance.

Change Why
ems_health_hours column, with sum= The figure the centre has to keep under the yearly allowance. A stored field of its own so the list totals it per employee group - ems_health_hours_used on the request is a per-request running total and cannot be aggregated
“Current Course” filter, default Odoo’s own “Current Year” is the calendar year, which cuts a school year in half: a September absence and a February one would never be counted together
create="0" Filing an absence belongs on the employee’s own screen, which is where the send button and its confirmation live

The filter needs a field to work on, so hr.leave.ems_course_id resolves each absence to the ems.course whose window (date_range(), 1 September to 31 August) contains its start date. It is stored, which also makes it groupable. A course created later does not retro-assign old absences, which is correct: they already carry the course they were filed in.

The monthly report

Absences > Reporting > Monthly totals (ems.action_absence_monthly_report), the spreadsheet’s Totals per mes tab: absences grouped by month, with the hours that count towards what each Area Manager reports summed on the column and the number of absences coming from the group itself.

Its domain is ems_counts_hours = True plus a state that is neither refused nor cancelled. That last part is a deliberate departure: the spreadsheet’s SUMIFS ignored the status column entirely, so a cancelled request still contributed hours nobody was ever absent for.

ems_counted_hours is the summable counterpart of the flag - the absence’s hours when it counts, zero when it does not - for the same reason ems_health_hours exists on the other report: a per-request figure has to be a stored column of its own before a list can total it per group.

One compute per field, on purpose

ems_counts_hours, ems_needs_atri and ems_full_day all derive from the absence type and were once a single compute method. They are three now: Odoo skips a compute method entirely for a record whose create() values mention any one of the fields it assigns. Creating a request with ems_full_day set - an import, an API client, the guard-duty automation to come - therefore left ems_counts_hours false and quietly dropped that absence out of the monthly report. Nothing fails; the number is just wrong.

Who gets told

Most of this is Odoo’s, and deliberately left alone:

When Who Mechanism
A request is sent The approver Native activity scheduled on the request (activity_update → _get_responsible_for_approval)
Approved or refused The employee Native message on the request
Approved or refused The employee’s own department chief EMS: _ems_inform_department_chief() subscribes them just before the state change
Approved by the Head Direction (the company’s director_id) EMS: _ems_direction_partners() subscribes it in action_approve() - its own review starts there. Not when the Director is the absent employee or is the one approving
Approved or refused Everyone following the request EMS: _ems_post_outcome() posts a summary - who, which type, the dates, the hours, and the overall status (ems_status, e.g. Pending Direction)

Odoo’s own note on validation (“Your <type> planned on <date> has been accepted”, with the type’s full legal wording dropped mid-sentence and nothing else) is suppressed and replaced by that summary. It cannot be reworded through translation: _() resolves against the module the string is emitted from, so an entry in EMS’s catalogue is never consulted for a sentence hr_holidays prints. Overriding _validate_leave_request() outright would mean copying its calendar-meeting logic, so instead the note alone is stopped, through a context flag read by a message_post override and set only for the duration of that one call.

The department chief is the one piece Odoo has no notion of. It is also the reason the Google form asked every employee which department they belonged to: purely to look up who to copy, the Informat d'absencies rows of its Config tab. EMS already knows the employee’s chief, so the question left the form and the answer is derived.

They are informed, not given access: private_name still masks the written reason for anyone who is not the employee, their approver or an officer. The chief learns that a colleague is away and of what kind - what covering a department needs - without the reason behind it.

Mail leaves through the company’s configured server, which is the other half of what this replaces: the Apps Script sent from the personal Google account of whoever last ran its “Identificar-me com a remitent d’emails” menu entry, and stopped working when that person left.

Public holidays

Public holidays are native resource.calendar.leaves rows with no resource_id, managed from Employee Attendances > Absences > Configuration > Public Holidays (Time Off Administrator, which in practice is only admin, see Access control). Odoo does not ship any holiday calendar: national, Catalan, local and the centre’s own closing days (free-disposal days, Christmas, Easter…) are all entered by hand. EMS extends the model in models/employees/public_holiday.py.

flowchart TD
    A[Public holiday created or edited] --> B{resource_id set?}
    B -- no --> C[calendar_id forced empty:<br/>applies to every schedule]
    B -- yes --> D[personal leave, calendar kept]
    C --> E[native: overtime recomputed<br/>for every affected employee/day]
    D --> E
    E --> F[EMS: technical attendances on days<br/>left with no expected hours are deleted]

A public holiday always applies to every schedule

Natively, a public holiday may be tied to one working schedule (calendar_id, “Working Hours”) and then only applies to employees on exactly that schedule. That never fits EMS: every teacher has a personal schedule (one resource.calendar per employee, recreated at every course transition), so a holiday tied to one of them misses everybody else. The trap is easy to fall into, because the “Public Time Off” smart button on a schedule’s form opens the list with default_calendar_id set to that schedule: the first Diada entered this way was tied to the default schedule framework, which has no employees, and applied to nobody.

So create() and write() always leave calendar_id empty on a row without resource_id (defaults from the context included), and the “Working Hours” column is hidden from the Public Holidays list (view_public_holiday_list_ems). Personal leaves (resource_id set, e.g. an approved hr.leave) keep their calendar untouched.

Cleaning up the “absence” attendances a holiday makes obsolete

With res.company.absence_management on, Odoo’s hr.attendance._cron_absence_detection() creates every night a one-second technical attendance (in_mode/out_mode 'technical', shown in red) for each employee who didn’t check in the day before, so the missed hours count as negative overtime. It deletes it right away when that day had no expected hours - a holiday entered in advance therefore never produces one.

A holiday (or absence) entered afterwards is only half handled natively: creating, editing or deleting a resource.calendar.leaves recomputes the overtime of the affected employees and days (hr_attendance/models/resource_calendar_leaves.py), but the red technical attendance stays. EMS’s _unlink_technical_attendances_without_expected_hours(), run after create() and write(), deletes the technical attendances on the affected days that are now left with no expected hours (hr.employee._get_expected_attendances(), the same computation the native cron uses). It only ever touches technical attendances, and only on days nothing is expected any more: a real check-in on a holiday, or a technical attendance on a partial holiday, is kept. The same applies to a personal leave, so approving a whole-day absence after the fact also clears that day’s red row.

migrations/18.0.0.30.1/post-migrate.py detaches the public holidays already tied to a schedule through the same write(), which also clears the technical attendances they had left behind.

Access control

Group hr.leave records visible Reason and attachment Can
Any employee Own requests; colleagues’ via the native calendar/dashboard Own only — private_name renders as ***** for everyone else Create, edit and cancel their own while unapproved; file the supporting document at any time, approved included
Area Manager (hr_holidays.group_hr_holidays_responsible) Only employees whose leave_manager_id is them, via the native rule [('employee_id.leave_manager_id', '=', user.id)] Yes, for those employees Approve, refuse
Head of Studies, academic admin (hr_holidays.group_hr_holidays_user) All, centre-wide Yes Approve, refuse, manage the catalogue
Director (ems.group_director, implies the row above) All, centre-wide Yes Direction’s own approval (done / missing document / pending / refuse) on any request; approve/refuse as the Head only where it is the employee’s leave_manager_id (screens only, see Two approvals)

Two native mechanisms carry most of this:

EMS adds exactly one record rule of its own here, rule_absence_own_request_write (see the supporting document section above), paired with a field-level check in write().

Who holds it is derived from the approval relation, not from a group chain. The three approvers sit in two different EMS chains (group_head_of_studies for VET and ESO/BTX, the Secretary for ASP), and the ASP one is a single person - not the secretariat as a body, whose members are ordinary employees as far as absences go. There is no group that means “approves absences”, so res.users._ems_sync_time_off_groups() grants it to whoever is currently named as some employee’s leave_manager_id and takes it back from everyone else. That stays exact on its own as Area Managers change.

Two things about that method are easy to get wrong, and both were:

hr_holidays grants the approver group behind EMS’s back

Not only on upgrade: hr.employee.write() in hr_holidays grants group_hr_holidays_responsible to whoever a written parent_id names, and takes it back only from users who were previously somebody’s leave_manager_id. In EMS parent_id is the Department or Seminar Chief, a stored compute that _cascade_department_heads() refreshes whenever an Area Manager, a Chief or a department’s shape changes - and it does so before _compute_leave_manager() has said who really approves. The Chief was therefore left holding a group nothing would ever take back: four of them still had it on the development database, which means read access to every absence reason and supporting document in their area - the exact confidentiality rule this feature exists to enforce.

_cascade_department_heads() now finishes by calling Odoo’s own res.users._clean_leave_responsible_users() on the users that write could have touched, which removes the group from anyone who is nobody’s leave_manager_id. Covered by test_a_department_chief_never_keeps_the_approver_group, which fails without it.

hr_holidays leaks a restricted field into the Teachers screen through a widget

current_leave_id (the type of absence an employee is on right now) is declared groups="hr.group_hr_user" on hr.employee. hr_holidays also swaps the presence icon on hr.hr_kanban_view_employees to its own hr_presence_status_private widget, and that widget’s JavaScript declares current_leave_id as a field dependency:

Object.assign(hrPresenceStatusPrivate, {
    fieldDependencies: [..., { name: "current_leave_id", type: "many2one" }],
});

A widget’s field dependencies go straight into the read specification the client sends. They are never filtered by the group-based node stripping the view postprocessor applies to the arch, so the field is requested even for a user the arch correctly hid it from. In stock Odoo nothing notices, because a non-HR user is never shown hr.employee at all - they get hr.employee.public, a different model with a different kanban. EMS does show it: its own “Educational Community > Teachers” and “> ASP” screens are hr.employee, and security/ir.model.access.csv grants ems.group_teacher read on it. From the moment hr_holidays became an EMS dependency, every teacher opening either screen got

You do not have enough rights to access the fields “current_leave_id” on Employee (hr.employee)

instead of the screen.

views/community/employee/kanban.xml keeps the private widget for hr.group_hr_user and renders hr’s plain hr_presence_status - same icon, no leave type, no extra field read - for everyone else, via a negated group (groups="!hr.group_hr_user", supported since Odoo 17). That inherited view carries priority=20 so it applies after hr_holidays’ own inherit of the same view (priority 16), whose widget swap it depends on being already in place.

Widening current_leave_id’s own groups would have been the shorter fix and is deliberately not what happened: what a teacher may know about a colleague’s absence is the fact and the interval, never its type - the same line ems.guard.duty.board._get_guard_duty_absence_intervals draws.

Covered by tests/test_employee_presence_widget.py (the arch, per group) and tests/test_employee_teacher_kanban_tour.py (what the browser actually asks the server for - the backend half cannot see this bug at all).