EMS

Technical Reference: Guard Duty Schedule Board

Overview

Every teacher’s own weekly working schedule can already carry a “Guard” slot — one configurable ems.non_teaching_type reason among others (see Non-teaching types), assigned to a period the same way as a break or a coordination meeting (see Teacher working schedules & schedule frameworks). What was missing was a way to consult that information across teachers: for a given weekday, who is teaching where, and who is on guard duty, in each time block. This board is a read-only, centre-wide aggregation answering exactly that — no new data is captured, it only re-reads what already lives on every teacher’s own resource.calendar.attendance rows.

flowchart LR
    NT["ems.non_teaching_type.is_guard"] -->|related, store=True| ATT["resource.calendar.attendance.non_teaching_is_guard"]
    T1["Teacher A: resource.calendar.attendance (teaching)"] --> AGG["ems.course._get_guard_duty_board_attendance_ids()"]
    T2["Teacher B: resource.calendar.attendance (guard)"] --> AGG
    AGG --> LINES["ems.course.get_guard_duty_board_lines(weekday, shift, day) -- rows = time blocks, columns = groups, + guards list"]
    ABS["hr.leave (approved or pending, covering 'day')"] --> LINES
    LINES --> DATA["ems.course.get_guard_duty_board_data(weekday, shift, day) -- same, JSON-safe, @api.model"]
    LINES --> PDF["QWeb PDF: ems.report_guard_duty_board (model = ems.course)"]
    DATA --> W["ir.actions.client 'ems_guard_duty_board' -- week picker + weekday tabs (Mon-Fri) x shift dropdown x two views (timetable / guard duty table)"]

No dedicated model. An earlier version introduced a ems.guard_duty_board TransientModel wizard, opened via a dynamic ir.actions.server. That was replaced (2026-08-31) once it caused a real, user-visible problem: the URL bar showed a raw ems.guard_duty_board/<id> instead of a stable action-<xmlid> like every other EMS screen, because Odoo can only put a real xmlid in the URL for a statically declared action — a server action that returns a dynamically-built act_window dict has no xmlid of its own to show. The board’s methods now live directly on ems.course instead (a real, always-existing model every teacher already has read access to — see “Access control” below), and the screen itself is a plain ir.actions.client, both addressable by a real, stable URL.

Model changes

ems.non_teaching_type (models/employees/non_teaching_type.py):

resource.calendar.attendance (ems_working_schedule_assignation, models/employees/working_schedule.py):

ems.course (extended, models/attendance/guard_duty_board.py, _inherit = ['ems.course', 'ems.schedule_report_mixin'] — same “extend in place” pattern as ems_working_schedule/resource.calendar and ems_group_schedule/ems.group):

Absences on the board

The problem this solves. A guard-duty period is only worth planning if you know which classes are actually going to be left without a teacher. That information lives in hr.leave (see Staff absences) and nothing joined the two: the board answered “who is on guard on a Tuesday”, never “who is missing on Tuesday the 15th”.

Weekday vs date — why the screen needed a week picker first. The timetable repeats by weekday; an absence happens on a real date. There is no way to resolve one from the other, so get_guard_duty_board_lines()/get_guard_duty_board_data() take an optional day, and the client action grew a date input and week navigation to supply it. Without a date the board behaves exactly as it did before — every absence structure comes back empty — which is what keeps every weekday-only caller working unchanged.

flowchart TD
    D["day (a real date)"] --> Q["hr.leave.search: employee in this shift's teachers,<br/>state in confirm/validate1/validate,<br/>request_date_from &lt;= day &lt;= request_date_to"]
    Q --> I["_get_guard_duty_absence_intervals()<br/>{employee.id: [(hour_from, hour_to, state)]}"]
    I --> S["_guard_duty_absence_state(intervals, employees, hour_from, hour_to)<br/>overlap test per board period"]
    S --> C["cell['absences'] -- who is away in this group's cell"]
    S --> G["line['guard_absences'] -- who is away in the guard column"]
    C --> R["line['absences'] -- one row per uncovered class (teacher + group/subject/room)"]

Two states, never one. ABSENCE_STATES maps hr_holidays’ states onto the only two distinctions that matter to whoever assigns guards: validate → 'approved' (a fact to plan around), confirm/validate1 → 'pending' (a request nobody has decided on yet). The two pre-approval states are collapsed deliberately — “waiting for the first approver” vs “the second” changes nothing here. Refused and cancelled requests are not in the mapping at all, which is what keeps them off the board entirely. When the same teacher has both an approved and a pending absence overlapping one period, approved wins: the period needs covering either way, and reporting it as merely requested would understate it.

Whole day vs part of one. The interval is read from request_unit_hours rather than ems_full_day: it is the field that actually decides whether request_hour_from/_to carry anything (see hr.leave._compute_request_unit_hours and EMS’s override of it), and a multi-day request is a whole day on each of its days however it was filled in. A whole day becomes (0.0, 24.0) so it compares against a board period exactly like a partial one does — no special case anywhere downstream. Arriving an hour late therefore marks the 9-10 lesson and leaves the 11-12 one alone.

An absent guard is a subtraction, not an addition. A teacher who is away during a period they were on guard for has no class of their own for anybody to cover — they are simply one fewer person available to cover somebody else’s. That is why guard_absences is reported separately from absences and never folded into it: the guard column marks them, and no row appears in the “what needs covering” list.

Confidentiality. The absence search runs sudo() — same justification ems.attendance_session_header.get_guard_sessions() already carries for reading schedules that are not the reader’s own: whoever reads this board legitimately needs to know a colleague is not coming. Only the fact and the interval are ever exposed. The absence type, its reason and its attachments never leave the model, so the confidentiality rule described in Staff absences is not weakened by this screen.

Access control

Action ems.group_teacher (every teacher) ems.group_department_chief and above
Open the board Yes Yes
Call get_guard_duty_board_lines() / get_guard_duty_board_data() Yes Yes
Export the board to PDF Yes Yes

No new ir.model.access.csv rows at all. ems.access_ems_course_teacher already grants ems.group_teacher read access to ems.course — since the board’s methods live there now (rather than on a dedicated model that would have needed its own ACL), every teacher can already call them. No sudo() is used anywhere in _get_guard_duty_board_attendance_ids() either, and none is needed: base Odoo’s own resource module already grants base.group_user (every internal user, so every teacher) plain read access to resource.calendar/resource.calendar.attendance, with no ir.rule narrowing it further — see Group Schedule’s own “Access control” section, which documents this same fact for its identical aggregation. Colleagues’ schedules were never actually access-restricted at the model level; only the “Working Schedules” menu (admin configuration screen, resource.calendar records with is_framework=False, write access still ems.group_department_chief-only) was.

views/attendance/guard_duty_board/menu.xml — action_guard_duty_board is a plain ir.actions.client (tag="ems_guard_duty_board"), not bound to any model or record — see the class docstring in models/attendance/guard_duty_board.py for why this replaced an earlier TransientModel + dynamic server-action design. <menuitem> sits directly under hr_attendance.menu_hr_attendance_root (“Employee Attendances”, already visible to ems.group_teacher, see views/attendance/menu.xml), a sibling of the “Attendance” submenu (which itself groups Overview/Correction Requests/Management) and “Time off” — deliberately not under “Working Schedules” (menu_work_locations), which only Head of Studies/Direction can see.

Client action

static/src/js/backend/guard_duty_board.js (GuardDutyBoard, registered as registry.category("actions").add("ems_guard_duty_board", GuardDutyBoard), static props = ["*"] — the standard shape for a top-level ir.actions.client component, same convention as attendance_session_view.js’s AttendanceSessionView) + matching .xml template (static/src/xml/backend/guard_duty_board.xml) and CSS (static/src/css/backend/guard_duty_board.css, o_guard_board_* classes). onWillStart first calls get_current_course_data() (to label the page and remember the course id for the PDF button), then loads the initially-active day/shift. useState({ activeDay, activeShift, board, loading, courseId, courseName }) drives the weekday tabs (Mon-Fri) plus a shift <select> (Morning/Afternoon) — a dropdown, not a second row of tabs, and not both shifts stacked on one page: morning and afternoon are different shifts (see ems.group.shift) and a school’s real bell schedule made the two stacked together too dense to read at a glance (developer feedback, 2026-08-31). Only one <table> (the active day + active shift) is ever rendered from state.board.

The <h1> title is a get title() getter, not a literal string in the template (issue #442, fixed 2026-09-11). The template used to build it inline (t-esc="state.courseName ? 'Guard duty schedule (' + state.courseName + ')' : 'Guard duty schedule'") — a plain JS string concatenation the i18n extractor never sees, so it always rendered in English regardless of the viewer’s own language, unlike every other label on this screen. get title() calls _t() instead (_t("Guard duty schedule (%s)", this.state.courseName) / _t("Guard duty schedule")), and the template just does t-esc="title".

A week, not just a weekday. state.weekStart holds the Monday of the shown week, and the weekday tabs render their own day of the month alongside their name, so the tab strip doubles as that week’s calendar. The toolbar carries ‹ › week navigation plus a native <input type="date">; picking any date moves the whole week and lands on that date’s own weekday tab. Three helpers at the top of the file keep this honest: toIsoDate() formats from the browser’s local calendar fields, deliberately not toISOString() (which converts to UTC first and so returns the previous day for anyone east of Greenwich during the evening — the board’s date is a calendar day, never an instant); fromIsoDate() parses one back; mondayOf() maps any date onto its week’s Monday, with a weekend belonging to the week it closes, which is also what makes picking a Saturday in the date input land on a real, showable weekday.

Two views of the same payload, no extra round trip. state.activeView switches between the timetable (schedule) and the absences table (table, labelled “Absences table” on screen - renamed from “Guard duty table” per issue #442, since the tab is about who’s missing, not the board as a whole), rendered as nav-pills in the toolbar rather than a second row of nav-tabs, so they never compete visually with the weekday tabs above them. Both read the same already-fetched state.board.lines — the server sends cells, guards and absences on every line (see “Absences on the board” above), so switching view is a pure re-render. Every teacher arrives as {name, absence} rather than a bare name, which is what lets both views mark absences the same way without matching names back against a separate list; absenceClass() maps that to o_guard_board_absent (bold red, an absence that is going to happen) or o_guard_board_absent_pending (lighter italic, still awaiting its approver). That red is the only colour left anywhere on the board — everything else was deliberately stripped back to plain text and thin borders (developer feedback, 2026-09-01), so nothing competes with it.

The guard duty table is not stretched across the window, unlike the timetable. It only ever has three columns, so Bootstrap’s own .table { width: 100% } spread them over the full width of a wide screen and left the content adrift in empty cell (developer feedback, 2026-09-08: “queda demasiado disperso en la pantalla”). .o_guard_board_duty_table overrides width to auto, so table-layout: fixed sizes the table to the literal sum of its <col> widths (100 + 300 + 230 = 750px), and margin: 0 auto centres that block. The wrapper keeps its own overflow-x: auto, so a narrow window scrolls rather than squeezing the columns. Each absence stays on one line — teacher and the class to cover side by side, never wrapped (developer feedback, 2026-09-08: “elimina el salto de línea aunque quede una columna un poco más ancha”). The absence column’s 420px comes from measuring the real worst case on this centre’s own data (a 22-character name plus a 33-character group/subject/room detail, ~315px rendered) and leaving room to spare; the text-overflow: ellipsis alongside white-space: nowrap is not expected to trigger, it is there so a longer name degrades by being clipped rather than by spilling over the guard-duty column. All three are regression-tested in guard_duty_board_tour.js, which asserts the table is narrower than its wrapper, centred within it, and that no absence row wraps onto a second line (measured against its own computed line-height, not a hardcoded pixel height).

Opens on today’s own day/shift, not always Monday/Morning. getDefaultDayAndShift() (top of the file) reads the browser’s own Date() — “now” here means the viewer’s wall-clock time, not the server’s — and maps Date.getDay() (0=Sunday..6=Saturday) onto the board’s own weekday index (0=Monday..4=Friday); a weekend defaults to Monday (the board has no weekend concept at all, every table is keyed to a Mon-Fri dayofweek). The shift threshold (>=15:00 → afternoon) is a hand-kept copy of SHIFT_HOURS’s own boundary in models/attendance/guard_duty_board.py — added per developer feedback (2026-09-01): “cuando entro en la sección… por defecto tendría que estar viendo el que toca”. Regression-tested in guard_duty_board_tour.js by computing the same expected day/shift independently in the test itself (against the real clock the test happens to run at) and asserting the board’s initial state matches — not a fixed “Monday” assumption, which the rest of that same tour still relies on for its own (date-independent) fixture-data assertions, reached by explicitly switching back to Monday/Morning right after this check.

The data is fetched via RPC (ems.course.get_guard_duty_board_data()), one weekday/shift at a time. An early version instead read a form field’s own prefetched sub-records client-side (the same approach schedule_grid_readonly_field.js uses for its own, much smaller, per-group/ per-student aggregation) — this broke in practice: the web client silently caps how many sub-records a relational field fetches for rendering, and a centre-wide aggregation easily exceeds that cap (several hundred rows for a real school), so only whichever weekday happened to load first (in practice, Monday) ever showed real data — every other tab rendered empty, even though the server-side PDF (which reads the same recordset directly over the ORM, no such cap) always showed the full week. setActiveDay()/onShiftChange() both call the same loadBoard(), which re-fetches exactly the currently active day+shift pair.

No cell colouring. An earlier version painted each occupied cell’s background with the same per-subject colour the teacher/group schedule PDFs use (ems.schedule_report_mixin.REPORT_COLOR_PALETTE), with white text on top (.o_guard_board_cell_occupied). Removed per developer feedback (2026-09-01): “quita los colores de background de la tabla… a ver como queda” — with every group already its own column and the subject spelled out as a short acronym, the colour wash was noise, not signal. The Guard duty column’s own light background tint went too, for the same “no background colour anywhere in the table” reading of that request. First pass deliberately kept one exception — the guard-duty badge itself (.o_guard_board_guard_badge, a fixed orange fill) — reasoning that it was the actual “who is on duty” signal the column exists to surface, not a whole-cell/ whole-column wash. The developer asked for that gone too on a second pass, same day: “quitar el color de fondo de los nombres de las personas que están de guardia” — so .o_guard_board_guard_badge now has no background-color/color overrides at all, just a thin 1px solid #ccc border (purely to keep several names in the same cell visually separated from each other, not a colour). Applied identically to the PDF (see “PDF report” below) — same reasoning throughout, verified against a real generated PDF at each pass.

One deliberate, narrower exception added 2026-09-11 (developer request): a break/”Patio” row’s own .o_guard_board_time cell gets a 4px border-left accent (#8a6240, the exact same brown schedule_grid.css’s .o_schedule_grid_entry_break already uses for a break block on the teacher’s own Schedule tab, so the same colour reads as “patio” on both screens). This does not reopen the decision above: it is a border, not a background fill; it lives on the time cell, not the guard badge; and it exists to distinguish a row type (this period is break time), not to recolour a person’s name or a subject the way the removed washes did. See “Level filter” below for what actually decides which rows get it.

Why a table, not the existing absolute-positioned grid: schedule_grid_field.js/ schedule_grid_readonly_field.js position entries by pixel offset within one weekday column, at most a handful of genuinely overlapping blocks side by side (see the read-only widget’s own layoutOverlappingBlocks column-split, added for a student’s own schedule — a single teacher still can’t be in two places at once, but a group or a student can have a handful of concurrent entries). A centre-wide board is a different scale of the same problem — many dozens of groups run in parallel at any given hour, not a handful — so instead of a 5-day-column grid, this renders weekday tabs (one day visible at a time) and, within a day, a genuine <table> whose columns are the groups taught in that shift and whose rows are time blocks — structurally the same shape get_guard_duty_board_data() already returns, rendered close to as-is.

Column widths are fixed, not content-driven — deliberately. Each group column shares one identical CSS width (.o_guard_board_col_group) via an explicit <colgroup> combined with table-layout: fixed on the <table>; the Time block (first) and Guard duty (last) columns get their own, wider fixed widths (denser content: a full time range; one badge per teacher on duty). Two things this fixes, both found during manual review (2026-08-31):

  1. A first attempt used table-layout: auto with min-width/max-width hints instead — this let each shift’s table auto-size its own columns from its own content, so Morning and Afternoon (or one weekday vs. another) never lined up the same way, and one abnormally long cell (e.g. an unlinked “Pending teacher (email@…)” placeholder) could still noticeably skew a single column’s width relative to the rest.
  2. Auto-layout sizing also under-reported the table’s true rendered width to the wrapping <div>’s own scrollWidth calculation in practice, so the scrollbar existed but couldn’t actually be dragged all the way to reveal the last column. table-layout: fixed with <col>-defined widths removes that ambiguity — the table’s total width is the literal sum of the declared column widths, so overflow-x: auto always has an exact width to scroll against. Regression-tested in guard_duty_board_tour.js (scrolls the wrapper to its reported max and asserts the last header cell is then fully within the wrapper’s visible bounds).

Per-day toolbar (shift <select> + PDF button) sits right under the weekday tabs, so switching days always shows that day’s own controls — matches “one PDF per day” (below), not a single page-wide PDF button for the whole week.

Page layout: the root needs an explicit height, or neither scroll axis works at all. This is a plain ir.actions.client page (no <sheet>/form chrome to inherit sizing from), and Odoo’s own action container clips whatever the mounted component doesn’t explicitly claim — an early version’s bare, height-less root div meant a table taller (or wider) than the viewport was just cut off with no scrollbar on either axis (developer feedback, 2026-08-31), not a squeezed-but-scrollable one. Fixed the same way attendance_session_view.css’s .ems-av-root already does it: .o_guard_board gets height: 100%; display: flex; flex-direction: column, the title/tabs/toolbar sit in a fixed-height .o_guard_board_header, and only .o_guard_board_content (flex: 1 1 auto; overflow-y: auto) scrolls vertically — with .o_guard_board_table_wrap’s own overflow-x: auto still handling horizontal scroll inside that, so a vertical drag can never also scroll sideways. Both axes are regression-tested in guard_duty_board_tour.js the same way: scroll to the reported max, assert the true end (rightmost column / bottom row) is then actually visible.

PDF report (ems.report_guard_duty_board)

reports/attendance/report_guard_duty_board.xml — a qweb-pdf ir.actions.report on ems.course (not the removed ems.guard_duty_board). Not bound to the generic Print menu (no binding_type/binding_model_id) — triggered only from the client action’s own PDF button, same as ems.report_working_schedule/ems.report_group_schedule.

Landscape A3, not the site default (portrait A4). paperformat_id points at a new, reusable ems.paperformat_a3_landscape (report.paperformat, same file) — same margins/dpi/ css_margins as base Odoo’s own default paperformat_euro, only format/orientation differ. Needed because this board is wide (one column per group, easily a dozen or more): under the site-default portrait A4, every group column was cramped even after the fixed-width pass below. Landscape A4 alone (the first attempt, 2026-09-01) still felt too tight in practice per developer feedback after seeing it rendered — moved to A3 (420×297mm usable vs. A4’s 297×210mm) for real breathing room. Not named guard-duty-specific, since any future EMS report facing the same “wide grid” shape (a school-wide timetable, say) can reference this same paperformat record instead of each defining its own — verified by generating a real PDF against this dev DB’s own data at each step, same way the column-width bug below was originally caught.

One PDF per day AND per shift — not the whole week, not both shifts. onPdfClick() calls actionService.doAction("ems.action_report_guard_duty_board", { additionalContext: { active_ids: [this.state.courseId], guard_duty_weekday: String(this.state.activeDay), guard_duty_shift: this.state.activeShift } }) — the extra guard_duty_weekday/guard_duty_shift context keys are what scope the report down to whichever day tab and shift dropdown value were active. The template reads both via course.env.context.get(...) (there is no bare context name bound inside a QWeb report template — additionalContext merges into the calling env’s own context, which every record obtained from docs already carries) and loops just that one day/shift; if a key is absent (defensive fallback only — no other caller omits either) it falls back to all 5 weekdays and/or both shifts, one <div class="page"> per weekday (standard QWeb pagination). For whichever day(s)/shift(s) it renders, the template calls course.get_guard_duty_board_lines(weekday, shift) directly (Python-side, unlike the client action’s own JSON RPC) to render the same groups-as-columns table (subject acronym, every co-teacher). No colour anywhere, same as the live screen — see the “No cell colouring” note above for the full history (including the guard-duty badge itself losing its fill on a second developer pass the same day).

Column widths are fixed here too — <colgroup> + table-layout: fixed, mirroring the live screen (.gdb-col-time/.gdb-col-group/.gdb-col-guard: 85/115/160px, sized for A3 landscape’s own usable width — see the class comment in the template for the exact figure — not simply carried over from the live screen’s 90/130/190px, nor from the original A4-sized 60/75/110px attempt). A bigger page alone does not widen a table-layout: fixed table — found switching this report from A4 to A3 (2026-09-01, developer feedback: “sigue saliendo todo muy apretado” even after landscape A4): the paperformat change alone did nothing visible, because the <col> widths are absolute pixel values independent of the page they’re printed on — a bigger page just adds blank margin around the same-sized table unless the column widths are also widened to use the extra room. One thing the live screen didn’t need but the PDF did: overflow-wrap/word-break: break-word on every cell’s text spans (.gdb-cell-subject/.gdb-cell-teacher/.gdb-cell-room/.gdb-guard-badge). Confirmed by generating a real PDF against this dev DB’s own data (2026-08-31): without it, one long unbroken string (an unlinked “Pending teacher (email@…)” placeholder — no spaces to wrap at) still forced that one column — and, via border-collapse, every row sharing it — visibly wider than the rest, even under table-layout: fixed. With word-breaking allowed, the same content wraps onto multiple lines within its declared column width instead.

The PDF prints what the screen shows, absences included. The board’s PDF button passes guard_duty_date alongside the guard_duty_weekday/guard_duty_shift context keys it already sent, and the template forwards it to get_guard_duty_board_lines(..., day=...) and marks absent teachers with .gdb-absent/.gdb-absent-pending — the same two-state distinction, and the same single accent colour, as the live screen. A cuadrante handed out on paper to assign the day’s guards is no use without them. Omitting the key (any other caller) still prints the plain timetable.

The PDF prints whichever of the two tabs is on screen (issue #442, fixed 2026-09-11) — previously it always printed the timetable regardless of the Absences table tab being active. onPdfClick() also forwards guard_duty_view: this.state.activeView ('schedule' or 'table'), and the template reads it as requested_view (falsy/absent = 'schedule', the original, still-default behaviour, same fallback convention as every other guard_duty_* context key here). Per shift, once data['lines'] is non-empty, the template branches on requested_view instead of always rendering the groups-as-columns table: 'table' renders a second, 3-column <table class="... gdb-duty-table"> (gdb-col-time/gdb-col-absences/ gdb-col-guard, the last a fixed width like the timetable’s own, the absences column left width: auto to take whatever the other two leave) off the very same line['absences']/ line['guards']/line['guard_absences'] the timetable branch already has - no second RPC/ method call, same data variable, just a different loop over it. The page’s own <h1> title also switches between “Guard Duty Schedule -“ and “Absences Table -“ so the printed heading never contradicts what’s actually below it.

Level filter (issue #390)

Spec, written before implementation — a level selector next to the existing shift <select>, letting a viewer narrow the board down to one or more ems.levels (e.g. “just my own vocational-training levels” while another colleague loads ESO/Batxillerat data in parallel). Design decisions confirmed with the developer 2026-09-07 (see plans/guard_duty_board_level_filter.md for the original design sketch/open questions this resolves):

flowchart TB
    LVLSEL["Level filter (multi-select, 'ems.level')"] --> TE["teaching_entries: keep only rows with >=1 group_ids.level_id in the selection"]
    TE --> GRP["groups: only the matching groups become columns"]
    TE --> ROWS["periods/rows: built ONLY from the filtered teaching_entries (not every entry, unlike the unfiltered 'All levels' view) - this is the ONLY thing the filter controls"]
    GUARD["guard_entries (never filtered by the guard's own teacher/level)"] -->|folded by containment into whichever row's range covers it| ROWS
    GUARD -->|left over, no row above contains it| BREAK{"Falls inside one of the selected level(s)' own framework break period?"}
    BREAK -->|yes| PATIO["Synthetic 'Patio'/break row (no group cells), added so a break-time guard stays visible"]
    BREAK -->|no| DROP["Dropped - no visible block for this level to attach to (still visible under 'All levels')"]

Backend (models/attendance/guard_duty_board.py):

Frontend (static/src/js/backend/guard_duty_board.js / static/src/xml/backend/guard_duty_board.xml): state.activeLevelIds (array, empty = “All levels”) + state.levels (fetched once onWillStart, alongside the existing course-data call). A Bootstrap dropdown-with-checkboxes next to the shift <select> (native multi-<select> would need ctrl/cmd-click, which is not discoverable) — o_guard_board_level_dropdown/ o_guard_board_level_menu. Toggling a checkbox re-runs loadBoard(), now passing state.activeLevelIds as the RPC’s third argument (this.activeDate, from the absences work below, is the fourth). The PDF button forwards the same selection via a guard_duty_level_ids context key, mirroring guard_duty_weekday/guard_duty_shift/guard_duty_date.

PDF report (reports/attendance/report_guard_duty_board.xml): reads guard_duty_level_ids from context the same way as the existing weekday/shift/date keys, passed through to get_guard_duty_board_lines().