EMS

Technical Reference: Group Schedule (read-only aggregation, partially editable)

Overview

ems.group has no schedule of its own — every timetable slot lives on a teacher’s personal resource.calendar (see Teacher working schedules & schedule frameworks), tagged with group_ids (Many2many ems.group). The Schedule tab on the group form is mostly a read-only “photo” of that data, built by aggregating every resource.calendar.attendance row across every teacher’s calendar whose group_ids includes this group — plus, when derivable, the group’s break/patio period — with a PDF export.

Issue #446: ems.group_department_chief and above can additionally edit two fields per teaching block directly from this tab - topic and the classroom (space_id) - without leaving the group’s own form. Every other field (day, hour, subject, teacher(s), groups) stays read-only here; changing any of those still requires the teacher’s own Schedule tab. See “Editing from the group form” below.

The exact same rendering problem, one level down, is what a student’s own Schedule tab solves — see Student schedule (read-only aggregation), which shares the OWL widget, the PDF-building logic and the break-derivation helper with this one almost entirely unchanged; only the search that decides which resource.calendar.attendance rows belong on the schedule differs (a whole group’s teaching vs. one student’s own enrollment pairs). Read this doc first — the student one only calls out what’s actually different.

flowchart LR
    T1["Teacher A: resource.calendar"] -->|attendance_ids, group_ids includes Group X| ATT["resource.calendar.attendance (teaching)"]
    T2["Teacher B: resource.calendar"] -->|attendance_ids, group_ids includes Group X| ATT
    FW["Level framework (is_framework=True, level_id = Group X.level_id)"] -->|BR row, day_period = Group X.shift| BR["resource.calendar.attendance (break)"]
    ATT --> SCH["ems.group.schedule_attendance_ids (computed, not stored)"]
    BR --> SCH
    SCH --> GRID["ems.schedule_report_mixin.get_schedule_report_lines() -- one block per subject/break"]
    SCH --> SUM["ems.schedule_report_mixin.get_subject_teachers_summary() -- co-teaching = several teachers per row"]
    GRID --> W["OWL widget: readonly_schedule_grid (shared with the student's own tab)"]
    GRID --> PDF["QWeb PDF: ems.report_group_schedule"]
    SUM --> W
    SUM --> PDF

Model changes

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

models/shared/schedule_report_mixin.py (AbstractModel, ems.schedule_report_mixin) — holds everything genuinely shared between the group’s and the student’s own aggregation, not just PDF-coloring helpers as its original (pre-student) version did:

Both get_schedule_report_lines() and get_subject_teachers_summary() are inherited unchanged by both consumers — neither ems.group nor res.partner (student) overrides either; the whole aggregation-to-report pipeline is identical past the point where schedule_attendance_ids itself has been computed.

ems.group (models/contacts/group_schedule.py, _inherit = ['ems.group', 'ems.schedule_report_mixin']):

OWL widget

static/src/js/backend/schedule_grid_readonly_field.js (ReadonlyScheduleGridField, field widget readonly_schedule_grid, supportedTypes: ["many2many"]) + matching .xml template — shared, unmodified, by both the group’s and the student’s own Schedule tab (originally built for the group alone, under the name group_schedule_grid/ GroupScheduleGridField; renamed when the student tab was added, since reusing it as-is across two models is the whole point — see the student doc for exactly what changes between the two: nothing in this file, only the server-side search feeding schedule_attendance_ids and the view’s embedded <list>). Purely display: no edit buffer, no Edit/Import/New. It does not call get_schedule_report_lines()/get_subject_teachers_summary() over RPC (those return real recordsets in a 'entries'/'blocks' shape that isn’t JSON-serializable — they’re used server-side only, by the PDF template below) — instead it builds the grid and the “Subject → Teacher(s)” table entirely client-side from the record’s own prefetched schedule_attendance_ids sub-records (the sub-fields declared on the view’s embedded <list>: dayofweek, hour_from, hour_to, subject_id, non_teaching, employee_id, space_id), mirroring how the teacher’s own schedule_grid_field.js never calls get_schedule_report_lines() either. It reuses the teacher grid’s existing CSS classes as-is (static/src/css/backend/schedule_grid.css) — including .o_schedule_grid_entry_nonteaching for the break block — no new CSS was needed.

Shares only pure geometry helpers (PX_PER_HOUR, day labels, bounds/hours computation, time formatting, colour assignment) with the teacher’s schedule_grid_field.js, via a plain module static/src/js/backend/schedule_grid_geometry.js that both widgets import from — not a shared component, since the two widgets’ interactive surface (edit buffer vs. none) is different enough that forcing one component to cover both would leave a lot of dead code active in the read-only case. The PDF toolbar button is resolved per model (PDF_ACTION_BY_MODEL in the widget file, keyed by this.props.record.resModel) — the one place a new model reusing this widget needs to register itself, alongside defining its own schedule_attendance_ids field and Schedule-tab view.

Overlap handling: unlike a single teacher’s own calendar (which can’t have two genuinely simultaneous entries), a group’s aggregated schedule can — e.g. several elective subjects or co-teaching entries scheduled at the same time as the derived break — and a student’s own schedule can too, more routinely (a main-group class and an elective through a different group, genuinely overlapping, not just identical-slot co-teaching). blocksForDay() first merges entries sharing the exact same (hour_from, hour_to) and subject/non-teaching reason into one block (co-teaching never repeats a block per teacher); the resulting blocks are then run through layoutOverlappingBlocks() (schedule_grid_geometry.js) — a generic interval- overlap-clustering + greedy column assignment (the same shape of algorithm Google/Outlook-style day views use): blocks that genuinely overlap in time get split into side-by-side columns (left/width calculated inline, mirroring schedule_grid_field.js’s own narrower “identical slot” version of the same idea), instead of silently stacking on top of each other. A break block still keeps its own separate treatment on top of this: always rendered at its true, exact duration (never stretched to a minimum height like a short teaching block is) and always painted behind teaching blocks (z-index: 1 vs 2 in schedule_grid.css), so it never hides an overlapping subject even within its own column.

Break-only compact rendering: a break is also rendered as a single compact line (time + label together, .o_schedule_grid_entry_compact) instead of the normal time/label/room stack, since a short patio slot doesn’t have room for three lines. This must key off resource.calendar.attendance.non_teaching_is_break (a related="non_teaching.is_break", stored field added for exactly this) — not a plain non_teaching truthiness check, which would also catch a full-length (e.g. 1h) guard duty or coordination meeting and needlessly cram it into the compact layout too. The teacher’s own tab needs this distinction (it has real non-break non_teaching entries); the group’s and the student’s own schedule_attendance_ids structurally never contain anything but breaks in their non-teaching slice (_get_break_entries() already filters on is_break), but reads the same field for consistency and to not rely on that invariant holding forever.

Below the grid, a read-only “Subject → Teacher(s)” table, built the same client-side way. A single toolbar action, PDF, calling actionService.doAction(<the model's own action id>, { additionalContext: { active_ids: [...] } }) — not gated by any permission check, since read access to a group’s or a student’s own schedule is already universal for staff (see Access control below). The PDF itself is where get_schedule_report_lines()/get_subject_teachers_summary() actually run, server-side.

Editing from the group form (issue #446)

ems.group_department_chief and above (same check as hr.employee.can_edit_schedule, mirrored here as ems.group.can_edit_schedule, models/contacts/group.py) can edit a teaching block’s topic and classroom (space_id) directly from this tab, without opening the teacher’s own form. Every other field (weekday, hour, subject, teacher(s), groups) stays locked here — those still require the teacher’s own Schedule tab.

Backend: resource.calendar.attendance.update_topic_and_relocate(topic, space_id) (models/employees/working_schedule.py, right below relocate_or_flag_pending) — called with every calendar block making up ONE visual entry (more than one for a co-taught session, one row per co-teacher, since each carries its own resource.calendar.attendance row even though they share a single ems.attendance_schedule line):

No new wizard code was needed: the pre-existing ems.group_classroom_change_wizard already queries every space_pending_group_sync=True block for the group (_build_conflict_lines), regardless of which of the three flows flagged it (a group-wide reference-classroom change, issue #405; a teacher’s own calendar edit, issue #444; or this feature).

Real bug found and fixed while building this (2026-09-12): the group-scoped wizard’s own action_confirm() crashed with a MissingError when TWO conflict lines happened to share the same right_schedule_id — exactly the shape this feature makes common (a co-taught block colliding with the same already-active session flags BOTH co-teachers’ blocks independently, see ems.group._propagate_classroom_change’s own pre-existing per-block loop). Required Many2one fields default to ondelete='cascade' (odoo/fields.py’s Many2one.setup_nonrelated), so resolving the first line (e.g. prevail_left archiving the colliding session down to deletion) cascade-deleted the second, still-unprocessed wizard line right along with it. action_confirm() now guards with a plain line.exists() before calling _apply_resolution() — safe, since the vanishing line’s own calendar block is still correctly resolved by the first line’s own “clear siblings” pass (_apply_resolution, issue #444’s third follow-up). See tests/test_group_classroom_change.py’s test_group_wizard_with_co_teaching_conflict_builds_two_lines_confirm_does_not_crash.

The group’s own pending-classroom banner text (views/community/group/form.xml) was reworded to stay accurate for BOTH origins now possible on this model - it used to read “This group’s classroom changed, but…”, which would be misleading for a block edited directly from this tab without the group’s own space_id ever changing. Now origin-neutral: “N teaching block(s) couldn’t move to their requested classroom automatically because of a room collision.”

Frontend: ReadonlyScheduleGridField (schedule_grid_readonly_field.js + schedule_grid_readonly_field.xml) gains a card-based edit mode, visually mirroring the teacher’s own editable grid (schedule_grid_field.js/.xml) rather than an inline per-block popover — a first version used a pencil icon opening a small panel per block, but the developer found it too fiddly to click reliably (target size/position depend on the block’s own label text) and asked for the teacher-grid’s own card layout instead:

PDF report (ems.report_group_schedule)

reports/contacts/report_group_schedule.xml — a qweb-pdf ir.actions.report on ems.group, bound (binding_type="report") so it also appears in the group form’s native Print menu. Mirrors reports/employees/report_working_schedule.xml’s structure (grid table

The header shows the group’s tutor (when set) and, right below it, the group’s reference classroom (space_id, t-if="group.space_id") — both lines omitted when the corresponding field is empty (see test_report_group_schedule_shows_reference_classroom/ test_report_group_schedule_hides_reference_classroom_when_unset in tests/test_group_schedule.py).

The centre’s website links to every group’s timetable. Each active ems.group exposes a public, persistent, no-login URL serving a pre-rendered PDF of the same ems.report_group_schedule report:

<web.base.url>/ems/schedule/<public_schedule_slug>.pdf     e.g. /ems/schedule/eso1a.pdf

The PDF is never rendered on request - only when something it prints actually changes.

flowchart LR
    CH["A change that alters the printed schedule (see triggers)"] --> MARK["ems.group._mark_public_schedule_dirty(): public_schedule_dirty = True + ir.cron._trigger()"]
    MARK --> CRON["ir_cron_group_public_schedule (runs seconds later, batched)"]
    CRON --> GEN["ems.group._generate_public_schedule(): render ems.report_group_schedule in ca_ES"]
    GEN --> BIN["public_schedule_pdf (Binary, attachment) + public_schedule_dirty = False"]
    WEB["Anyone on the internet"] -->|"GET /ems/schedule/slug.pdf (auth=public)"| CTRL["controllers/group_public_schedule.py"]
    CTRL -->|sudo read, active groups only| BIN

Fields (models/contacts/group_schedule.py)

Field Type Notes
public_schedule_slug Char, computed from name, stored, indexed ir.http._slugify(name) (GA2Matí → ga2mati, Reforç Programació → reforc-programacio). Readable by design (developer choice): it changes if the group is renamed.
public_schedule_url Char, computed, not stored web.base.url + /ems/schedule/<slug>.pdf, shown on the group form with widget="CopyClipboardURL": a link opening the PDF in a new tab plus Odoo’s copy button. The copy button uses navigator.clipboard, which browsers only expose over HTTPS or localhost - over plain http:// (e.g. a dev box) it silently does nothing (Odoo’s CopyButton only logs a console warning).
public_schedule_pdf Binary, attachment=True, readonly The last rendered PDF.
public_schedule_dirty Boolean, default True “Needs re-rendering”. Default True means a brand-new group, or every existing group right after the upgrade that adds the column, gets its first PDF on the next cron run - no migration or post_init_hook needed.

Regeneration: mark + triggered cron

_mark_public_schedule_dirty() (sudo - a teacher editing their own calendar has no write access to ems.group) sets the flag and wakes ems.ir_cron_group_public_schedule up. A bulk operation (the working-schedules import touching hundreds of blocks) therefore renders one PDF per affected group, outside the user’s request.

Triggers (what marks which groups dirty)

Change Groups marked
resource.calendar.attendance create / unlink, or write of a printed field (_SYNC_TRIGGER_FIELDS + topic, day_period) every group in group_ids before and after the change; for a row on a schedule framework (calendar_id.is_framework), every group of that framework’s level_id (the break block)
ems.group write of name, tutor_id, space_id, level_id, shift or active that group
ems.subject write of name / acronym; ems.space write of name / code groups with a block using that subject / classroom, or (space) whose reference classroom it is
hr.employee write of name groups with a block taught by them, or tutored by them
res.company write of current_course_id (printed in the title) every group

A teacher calendar’s archiving cascades to its blocks (resource.calendar.action_archive()), so it is covered by the block active write above.

Controller (controllers/group_public_schedule.py)

GET /ems/schedule/<slug>.pdf, auth='public': looks the slug up among active groups (sudo) and streams public_schedule_pdf (application/pdf, inline). 404 when the slug is unknown, the group is archived, or its first PDF hasn’t been generated yet - the route never renders.

The centre’s website also links one PDF per study with every active group’s timetable (e.g. SMX: SMX1A…SMX1D followed by SMX2A…SMX2D), which follows whatever groups the study has each year without replacing the link:

<web.base.url>/ems/schedule/study/<ems.study.public_schedule_slug>.pdf     e.g. /ems/schedule/study/smx.pdf
flowchart LR
    WEB["Anyone on the internet"] -->|"GET /ems/schedule/study/slug.pdf (auth=public)"| CTRL["controllers/group_public_schedule.py"]
    CTRL -->|sudo, every study with that slug| ST["ems.study._get_public_schedule_pdf()"]
    ST -->|"active groups, order course, name; skip groups with no PDF yet"| BIN["ems.group.public_schedule_pdf (one per group)"]
    ST -->|odoo.tools.pdf.merge_pdf| RESP["one merged PDF, inline"]

Nothing new is rendered or stored. The study PDF is the groups’ own stored PDFs (public_schedule_pdf, above) merged at request time with odoo.tools.pdf.merge_pdf - a page-copying operation, milliseconds for a study’s handful of groups, not a wkhtmltopdf render. So it needs no flag, cron or trigger of its own: a schedule change reaches it as soon as the cron re-renders the affected group, and a group added to (or archived from) the study appears in (or disappears from) it on the next request.

models/curriculum/study_schedule.py (_inherit = 'ems.study'):

Field / method Notes
public_schedule_slug Char, computed from acronym, stored, indexed: ir.http._slugify(acronym) (SMX → smx). Changes if the acronym does.
public_schedule_url Char, computed, not stored: web.base.url + /ems/schedule/study/<slug>.pdf, False while the study has no active group (the link could only be a 404). Shown on the study form with widget="CopyClipboardURL", same as the group’s.
_get_public_schedule_groups() The studies’ active groups (sudo), order='course, name'.
_get_public_schedule_pdf() Merged PDF bytes of those groups that already have a PDF, or False when none does.

GET /ems/schedule/study/<slug>.pdf (auth='public') searches every study with that slug, not just one: two study records sharing an acronym (e.g. a new curriculum version for first year while second year finishes the old one) still publish a single PDF under the same link. 404 when no study matches or none of its active groups has a rendered PDF yet. The route can’t collide with the group one: werkzeug’s string converter never matches a /, so /ems/schedule/<slug>.pdf never sees study/....

Access control

Action base.group_user (teacher, secretary, tutor, …) ems.group_department_chief and above
Read a group’s aggregated schedule (schedule_attendance_ids, get_schedule_report_lines(), get_subject_teachers_summary()) Yes Yes
Export a group’s schedule to PDF Yes Yes
Edit a block’s topic/classroom from the group form (issue #446) No Yes
Edit day/hour/subject/teacher(s)/groups No (never possible from this tab — edit from the teacher’s own Schedule tab) No (same)

No new ACL rows are needed for reading: every internal user already has read access to resource.calendar/resource.calendar.attendance (base Odoo ACL) and, via ems.access_ems_group_teacher/ems.access_ems_group_secretary, to ems.group itself. Writing topic/space_id on resource.calendar.attendance from the group form reuses the existing ems.access_resource_calendar_attendance_admin ACL row (ems.group_department_chief+ already has full CRUD there, needed for apply_schedule_changes()/the classroom-change wizard) - a plain teacher has no write ACL on that model at all, so this is enforced the same way as every other schedule write already is, with no new security row. Portal users (families/students, base.group_portal) have no access to ems.group or resource.calendar* today — out of scope for this feature. See the student doc for the equivalent table on res.partner (same shape, same “staff-only, portal out of scope” conclusion).