hr.employee (EMS extension)models/employees/employee.py extends hr.employee.base (abstract, shared with hr.employee.public) and hr.employee themselves with everything EMS needs: employee type (teacher/ASP), roles, tutorships, department leadership, a personal weekly schedule, and photo-visibility rules. It is one of the largest, most mature files in the module — most of its behaviour already has dedicated tests and docs from earlier work; this doc is primarily a map tying those together, plus coverage for the pieces that were still gaps before this DTON pass.
Module file: models/employees/employee.py — two classes: ems_employee_base (_inherit = ["hr.employee.base"], shared with the lighter hr.employee.public) and ems_employee (_inherit = ["hr.employee"], the full model).
| Area | Fields/methods | Doc |
|---|---|---|
| Role → security group sync | role_ids, _onchange_role_ids, update_*_role(), _sync_security_groups() |
Academic role hierarchy |
parent_id cascade (Department Chief / Seminar Chief / Director) |
_compute_parent_id |
Department cascade |
| Personal calendar lifecycle | create()/write()/unlink()’s resource_calendar_id handling, _personal_calendar_name() |
Working schedules |
| Weekly schedule derived breaks | _get_derived_break_entries, get_derived_break_attendance_data |
Working schedules |
| Profile picture sync | write_photo(), write()’s photo guard, _refresh_stale_avatar_placeholder() |
Photo visibility |
| Google Workspace / EMS user creation | action_create_google_account, action_create_ems_user, OAuth pre-link |
Google Workspace staff integration |
Each of these areas already has thorough TransactionCase coverage (test_employee_role_group_sync.py, test_employee_schedule_lifecycle.py, test_employee_photo_visibility.py, test_employee_ems_user.py, test_working_schedule.py) — 60+ test methods between them. This DTON pass didn’t need to add to those; it filled the remaining gaps below instead.
read_only — fixed a real bug: compute_sudo was silently defeating its own security checkread_only = fields.Boolean(compute="_compute_read_only", compute_sudo=True, store=False)
Intended to mark “this user cannot edit this record” so a view can gate the whole form on it (per the field’s own comment) — but it has zero current view/JS consumers anywhere in the module (confirmed by a full-codebase search). It was still worth fixing rather than deleting: it’s cheap to get right, and a future consumer wiring it up would otherwise inherit a broken field silently.
The bug: compute_sudo=True runs the entire compute — including self.check_access_rights('write', ...) — as superuser, so the write-access check always saw full rights and read_only was always False, for every user, unconditionally. Fixed by re-checking against a recordset explicitly bound back to the real calling user (self.env.user itself is unaffected by compute_sudo, so self.with_user(self.env.user).check_access_rights(...) restores the real per-user answer):
flowchart TD
A[_compute_read_only runs, self is sudo'd] --> B["self.with_user(self.env.user)\n— rebind to the real caller"]
B --> C[check_access_rights('write') on that rebound recordset]
C --> D[read_only = NOT can_write]
Covered by tests/test_employee_display_fields.py (test_read_only_false_for_admin / test_read_only_true_for_teacher).
roles / tutorships — display-only computed stringsroles = fields.Char(compute="_compute_roles_str", store=True)
tutorships = fields.Char(compute="_compute_tutorships_str", store=True)
Comma-joined role_ids/tutorship_ids names, used only for display (the employee kanban card, views/community/employee/kanban.xml). No bug found; just untested until this pass — covered by tests/test_employee_display_fields.py.
get_report_role_lines() — only the Director branch was testedOne display line per role_ids entry for the working-schedule PDF header, appending role-specific context (tutored group(s), headed department(s), etc.) for seven roles: tutor, department chief, seminar chief, HoS, DHoS, secretary, director. Before this pass, test_company_director.py only exercised the director branch (test_get_report_role_lines_director_shows_company) — the other six were logic that had never actually run in a test. Added one test per remaining branch to the same file (it already has the department/employee creation helpers these needed).
ems.group.create() didn’t sync the tutor role — fixed 2026-07-27Found while writing the roles/tutorships tests: creating an ems.group with tutor_id set at creation time did not add ems.role_tutor to the employee — only a later write({'tutor_id': ...}) on an existing group did (ems.group.write() explicitly called update_tutor_role()/_sync_security_groups(); create() did not). Initially left for ems.group’s own DTON pass to avoid a drive-by change to a model that hadn’t had its D/T/O/N cycle yet — but per the project’s own DTON trigger rule (apply Testing at minimum when a change is requested to an un-DTON’d model, rather than deferring), the user asked for it to be fixed immediately once the gap was confirmed. Both create() and write() now share a _sync_tutor_role() helper (models/contacts/group.py); full D/O/N for the rest of that model — it doesn’t have its own dev doc yet — still waits for its own DTON phase.
archived_reason_label / archived_reason_color — added 2026-08-01Feed the shared ems_archived_reason_ribbon field widget (static/src/js/backend/
archived_reason_ribbon_field.js, also used by res.partner — see contact.md) on both views/community/employee/{form,kanban}.xml. Unlike res.partner’s
equivalent (a real compute, since only 3 of 6 contact_type values are ribbon-worthy), these
are plain one-line related= fields —
archived_reason_label = fields.Char(related="departure_reason_id.name",
groups="hr.group_hr_user,ems.group_teacher")
archived_reason_color = fields.Char(related="departure_reason_id.color",
groups="hr.group_hr_user,ems.group_teacher")
— because every hr.departure.reason is ribbon-worthy here: there’s no subset to filter down
to the way contact_type needs. color (models/employees/departure_reason.py, _inherit =
["hr.departure.reason", "ems.hex_color_mixin"]) is a new EMS addition to the native model, using
the same hex color-picker widget already established for ems.attendance_status/ems.role
(widget="color" class="ems_color_swatch", added to hr.departure.reason’s own native list/form
via views/community/employee/departure_reason.xml).
groups= matters here in a way it doesn’t for res.partner: the native
departure_reason_id field is itself restricted to hr.group_hr_user (hr/models/
hr_employee.py), and Odoo’s own view-loading validation (ir_ui_view.py) raises an “Access
Rights Inconsistency” warning if a widget/field references a group-restricted field in its
invisible= condition without matching that same restriction — confirmed empirically
(2026-08-01: the warning appeared in ./upgrade.sh’s log the first time these fields were added
without matching groups= on the ribbon elements themselves). Fixed by mirroring the exact
groups="hr.group_hr_user,ems.group_teacher" pattern this file already uses elsewhere (e.g.
employee_type, activity_ids) on both the field and the (adjusted, not duplicated) native
“Archived” ribbon.
The native hr.view_employee_form already ships its own generic “Archived” ribbon
(hr_employee_views.xml, invisible="active") — this was missed on a first pass (assumed no
ribbon existed there, by analogy with the kanban, which genuinely has none), which would have
stacked two “Archived” ribbons on an archived employee with no departure reason set. Fixed by
adjusting the native ribbon’s invisible condition in place (invisible="active or
pending_identification or archived_reason_label") via <xpath expr="//widget[@name='web_ribbon']"
position="attributes">, the same “adjust, don’t duplicate” approach res.partner’s form already
uses — always re-check the native base view for an existing ribbon before adding a new one,
not just this addon’s own inherited views.
| View | File | Notes |
|---|---|---|
| Form | views/community/employee/form.xml |
Heavily inherits hr.view_employee_form; adds the Google Workspace header buttons, the Schedule tab (schedule_grid widget), the Teaching tab (tutorships/coordination/subjects) |
| Kanban | views/community/employee/kanban.xml |
Renders roles/tutorships; also splits the presence icon’s widget per group - see absence.md’s “hr_holidays leaks a restricted field into the Teachers screen through a widget” |
| List | views/community/employee/list.xml |
— |
| Menu | views/community/employee/menu.xml |
action_employee_kanban, already covered by employee_google_workspace_tour.js’s navigation, but that tour never opens the employee’s own form — see the new employee_tour.js added in this pass for that gap |
| Settings tab extension | views/settings/hr_employees_form.xml |
Out of scope here — extends the Employees app’s own Settings tab, not EMS’s |
Defined in security/ir.model.access.csv (lines 2–3).
| Role | Create | Read | Write | Delete | Group XML ID |
|---|---|---|---|---|---|
| Administrator | ✓ | ✓ | ✓ | ✓ | ems.group_academic_admin |
| Teacher | — | ✓ | — | — | ems.group_teacher |
Plus Odoo’s own hr.group_hr_user/hr.group_hr_manager access, unchanged by EMS. Several individual fields carry their own groups= restriction (e.g. activity_* fields limited to hr.group_hr_user,ems.group_teacher) rather than being gated at the model level.
hr.employee must declare groups=Not a style preference - it is the rule stated in Odoo’s own hr.employee class docstring, and breaking it produces an AccessError far away from the field that caused it.
A user without hr.group_hr_user (and without the base.group_system read ACL) has no access to hr.employee at all: hr.employee.fetch() redirects the read to the hr.employee.public mirror and raises over any requested field that mirror does not have. Reading one field in Python does not request one field - _fetch_field() prefetches every field of the same prefetch group that the user may access, so a private field with no groups= is silently added to that batch and fails the whole read:
AccessError: The fields “…”, which you are trying to read,
are not available for employee public profiles.
Views are not affected (the web client requests an explicit field list, which never expands), which is why this only ever surfaces server-side, in an unrelated feature. Issue #492 is the worked example: eight EMS fields (schedule_import_code, pending_identification and the six stored google_ws_* ones) had no groups=, and a secretary registering a student’s withdrawal hit the error through ems.student.year_record._generate_one()’s group.tutor_id.name - a screen with no connection to Google Workspace or schedule imports.
| Applies to | every field declared on hr.employee and not on hr.employee.public. A field added to hr.employee.base instead (models/employees/employee.py’s first class) lands on both models and needs nothing. |
| Value to use | base.group_system,hr.group_hr_user,ems.group_teacher - the trio already used by employee_type. Every EMS role that can read hr.employee holds one of them, and base.group_system carries its own read ACL (hr/security/ir.model.access.csv), so it never reaches the public profile. |
| Enforced by | tests/test_employee_staff_permissions.py::test_ems_hr_employee_only_fields_declare_groups, which fails listing any EMS field that regresses. |
Keep the field’s groups= consistent with the groups= of any view element whose invisible/readonly expression reads it, or ./upgrade.sh reports an “Access Rights Inconsistency” warning for that element.
Both live in an “Identification” group of the “Private Information” tab, and who gets what depends on hr.group_hr_user:
| Viewer | What they get |
|---|---|
HR officers: Head of Studies/Deputy and above, TAC, the secretariat (all imply hr.group_hr_user) |
The native identification_id and ssnid, editable, on any employee they can read. Writing follows security/rules/employees.xml: the Head of Studies and TAC write teachers only (issue #391), the secretariat writes every staff member, nobody but the administrators deletes. |
Department Chief (and Seminar Chief), who lack hr.group_hr_user |
Read-only copies (scoped_identification_id, scoped_ssnid) of the employees in their own chain of command. No tab at all on anyone else. |
| Anyone else with the teacher form (plain teachers, tutors) | No tab. |
Why copies for the Department Chief: the native fields carry groups="hr.group_hr_user", which is a per-field gate, not a per-record one. Giving that group to a Department Chief would open every employee’s private data centre-wide. Instead, three computed fields on hr.employee scope the data by record:
| Field | Value |
|---|---|
can_view_identity |
True when the viewer is in the employee’s tutor_scope_user_ids (the employee, every chief above them through parent_id and the Director - see role_hierarchy.md) or holds hr.group_hr_user. It is the tab’s invisible condition. |
scoped_identification_id |
identification_id when can_view_identity, blank otherwise. |
scoped_ssnid |
ssnid when can_view_identity, blank otherwise. |
View mechanics (view_employee_form):
groups becomes hr.group_hr_user,ems.group_department_chief, and its “Private Contact” and EMS “Emergency” groups get groups="hr.group_hr_user", so a Department Chief sees the “Identification” group alone.groups="hr.group_hr_user" and the copies groups="!hr.group_hr_user", so each viewer gets exactly one pair. The native pair duplicates the fields of the hidden “Citizenship” group; both nodes are bound to the same field.compute_sudo=True reads the native fields as superuser, and @api.depends_context('uid') keeps the value per viewer. The compute checks self.env.user, which is still the real viewer under compute_sudo.hr.employee, so the whole form, copies included, is read-only for them.tests/test_employee_identity_visibility.py (scope per viewer, and who may write) and tests/test_employee_identity_visibility_tour.py (a Department Chief’s browser, and a secretariat edit on an ASP).