EMS

Technical Reference: ems.student.document

Overview

ems.student.document is the reviewable-submission model behind the “Document management” workflow: a student or family uploads (or the secretary/admin registers) a document — an ID card, a medical card, an IBAN, proof of a benefit/exemption — which then goes through a pending → approved/rejected/cancelled review cycle. Approving certain document types has real side effects elsewhere in the app: an approved IBAN updates the student’s bank account (res.partner.bank), an approved benefit document creates/refreshes an ems.student.benefit line.

Module file: models/contacts/student_document.py (EmsStudentDocument)


Status lifecycle

stateDiagram-v2
    [*] --> pending: create()
    pending --> approved: action_approve()
    pending --> rejected: action_reject()
    pending --> cancelled: action_cancel()
    approved --> pending: action_reset_to_pending()
    rejected --> pending: action_reset_to_pending()

Every transition drops any pending review activity_ids first (approve/reject/cancel all clear them; reset schedules a fresh one) and posts a chatter message. Creation and reset post an internal note (mail.mt_note — does not email followers, since the reviewer is already notified via their activity, see below); approve/reject post a comment (mail.mt_comment — does email the student, the only follower, see create() below).

Who is notified, and how

flowchart LR
    A[create pending document] --> B["message_subscribe(student only)"]
    A --> C["_schedule_review_activities()"]
    C --> D["mail.activity.type._ems_get_task_users(...)\n(Task Assignment config, not a security group)"]
    D --> E["one 'to-do' activity per reviewer"]
    C --> F["_unsubscribe_reviewers()\n(keeps them off message_partner_ids)"]

Two deliberately separate notification channels, to avoid double-emailing anyone:


Approval side effects

_apply_bank_account() — doc_type == 'iban'

flowchart TD
    A["action_approve() on an iban document"] --> B{"res.partner.bank with the\nsame acc_number already exists\nfor this student?"}
    B -- yes --> C["reactivate it, refresh holder name,\nallow_out_payment=True"]
    B -- no --> D["deactivate every other bank account\nof this student"]
    D --> E["create a new res.partner.bank,\nallow_out_payment=True"]
    C --> F["deactivate every OTHER bank account\nof this student"]

Only one bank account stays active per student at a time — approving a new IBAN always deactivates any other. allow_out_payment=True is set explicitly: the secretary has just validated the IBAN by approving it, and without this flag a direct-debit invoice referencing the account would be blocked (or silently drop the bank reference). _check_single_pending_iban (@api.constrains) additionally blocks a second pending IBAN submission for the same student — one at a time in the queue, though any number can be approved/rejected/cancelled historically.

_apply_benefit() — doc_type == 'benefit'

Removes any existing ems.student.benefit of the same benefit_type for this student (not all benefits — a student can hold several different benefit types at once, see contact.md), computes renewal_date by replaying ems.student.benefit’s own _onchange_benefit_type on a virtual (.new()) record rather than duplicating that date logic here, then creates the new benefit line carrying over the uploaded file as its supporting document.

action_approve()’s own document-level dedup

Independently of the two side effects above, approving a document also deletes any other already-approved ems.student.document of the same doc_type (+ benefit_type too, for benefit documents) for this student — e.g. approving a new DNI scan removes the previously-approved one, so the review queue/history doesn’t accumulate stale approved duplicates.


A non-stored Html field building a download link (/web/content/<attachment_id>?download=false) from the doc_file attachment, used in the read-only embed on the student’s own form (list columns can’t render a Binary field as a clickable link directly). Looks up the ir.attachment by (res_model, res_field, res_id) rather than reading doc_file’s own implicit attachment id, since the field itself only exposes the base64 content, not the attachment record.


_doc_label()

Small shared helper — the human-readable label for a document’s type, in the current language (self._fields['doc_type']._description_selection(self.env), the same translated labels fields_get() returns; reading _fields[...].selection directly would give the English source). A chatter message or review task built from it is stored as text, so it stays in the language of the user who triggered it (usually the secretary approving/rejecting), as is standard in Odoo.

name (the display name: breadcrumb, form title, notification e-mail subject) is computed on the fly, not stored, with @api.depends_context('lang'), so every reader sees it in their own language. _rec_names_search = ['partner_id'] keeps name search working (by student). Used by _compute_name, every chatter message, and _schedule_review_activities’s task summary, so the six near-identical message bodies across create()/action_approve()/action_reject()/action_cancel()/action_reset_to_pending() don’t each re-derive it.


Access Control

ir.model.access.csv

Role Create Read Write Delete
Academic admin ✓ ✓ ✓ ✓
Secretary ✓ ✓ ✓ ✓
Tutor (ems.group_tutor) — ✓ — —
TAC (ems.group_tac) — ✓ — —
Chiefs above a tutor (through the tutor rule) — ✓ — —
Portal (base.group_portal) — ✓ — —

security/rules/contacts.xml — tutor access to Google credentials

The tutor, TAC and Head of Studies rows exist only to read the Google Workspace credentials PDF (doc_type='google_credentials', created by _gw_deliver_credentials() in google_workspace_integration.py) from the Documentation section of the student form’s Secretary tab (shown only to whoever can read some document of that student, res.partner.can_see_documents): tutors for their own students, the TAC team for every student, since they reset those passwords (see google_workspace_student.md). Four rules:

Rule Group Domain
rule_ems_student_document_tutor ems.group_tutor doc_type = 'google_credentials' and partner_id.tutor_id.tutor_scope_user_ids = user (read only)
rule_ems_student_document_tac ems.group_tac doc_type = 'google_credentials' (read only)
rule_ems_student_document_secretary ems.group_secretary none (full access)
rule_ems_student_document_admin ems.group_academic_admin none (full access)

Rules of the groups a user belongs to are ORed, so the two unrestricted rules are what keep the tutor rule from narrowing staff who are also in group_tutor: the academic admin always is (admin → director → head of studies → department chief → tutor), and a secretary may also tutor a group. Through tutor_scope_user_ids, the tutor rule also gives every chief above a tutor (Seminar/Department Chief, Head of Studies, Director) the credentials of that tutor’s students (see Tutor scope). Every other document type (ID card, IBAN, medical card, benefit proof) stays invisible to tutors, their chiefs and TAC. The PDF download works through the normal ir.attachment check, which defers to read access on the owning ems.student.document record.

In the views, the Documentation page adds ems.group_tutor (which every chief implies) and ems.group_tac to its groups, and the Approve / Reject / Reset to pending buttons of the document list and form are restricted to ems.group_academic_admin,ems.group_secretary, so a tutor opening a credentials row gets a plain read-only form.

Bulk download: “Download Google credentials”

A server action (action_google_credentials_download_bulk, views/community/contact/google_credentials_download.xml) bound to both the res.partner list’s and form’s Actions menu, for academic admin, secretary, tutor (and so every chief) and TAC:

sequenceDiagram
    participant U as User (students list)
    participant P as res.partner
    participant C as /ems/google_credentials/download
    U->>P: action_download_google_credentials() on the selection
    P->>P: _get_google_credentials_documents()
    alt no readable credentials
        P-->>U: UserError
    else
        P-->>U: act_url (target download, partner_ids of the documents found)
        U->>C: GET ?partner_ids=...
        C->>P: _get_google_credentials_documents() again, as the user
        C-->>U: ZIP, one "<student name> - <file name>" PDF per student (404 if none)
    end

_get_google_credentials_documents() (models/contacts/google_workspace_integration.py) searches with the user’s own rights, so the record rules above decide what a tutor gets, and keeps only the most recent credentials document per student (by upload_date). The route repeats that search instead of trusting the ids in the URL, so a hand-edited partner_ids never yields more than the user can already read.

security/rules/portal.xml — rule_ems_student_document_portal

Domain: partner_id in [self, parent, children] — a portal user can only ever read their own, or their family’s, document submissions.

Fixed during this DTON pass (2026-07-28): the portal ir.model.access.csv row previously granted perm_write=1 with no matching ir.rule restriction on write (the rule explicitly had perm_write=False, meaning its domain never applied to write at all) — so, in principle, any authenticated portal user could have written to any student’s document record via a direct RPC call, not just their own. Not exploitable through the normal app (every real mutation for the portal flow goes through sudo() in controllers/portal_enrollment.py, which never relies on the portal user’s own ORM permissions), but a live, unused permission gap regardless — closed by setting perm_write=0 on the access row (create was already 0, matching the fact that the portal flow only ever creates via sudo()). The ir.model.access.csv fix propagates on a normal upgrade (not a noupdate file); the companion ir.rule cleanup (aligning its perm_create flag, itself already inert since ACL blocked create either way) lives in a noupdate="1" data file and therefore only takes effect on fresh installs — harmless, since the ACL row alone is what enforces the restriction on any already-upgraded environment.


Fixed (2026-07-30): portal IBAN renewal now always trusts the bank account

Root cause, confirmed against a real production backup (see plans/student_document_iban_renewal_allow_out_payment.md for the full investigation — plan file kept until the migration has run in production): controllers/portal_enrollment.py’s /my/documentacion/renew-iban route could create/renew an already-approved ems.student.document without ever calling _apply_bank_account() — unlike action_approve() (the review-queue path), which always sets allow_out_payment=True on the resulting res.partner.bank. The confirm-matrícula portal gate only checks the document’s status == 'approved', not the bank’s trust flag, so a family could satisfy “IBAN vàlid registrat” and confirm their enrollment while the bank stayed untrusted underneath. 100% of the 332 already-posted direct-debit invoices found affected in production went through this exact renewal path, never through action_approve().

A second, independent attempt (enrollment.py’s invoicing-time fallback, force-setting allow_out_payment=True right before posting) does not reliably work — Odoo’s own account_move validation (an anti-fraud check, res.partner.bank._user_can_trust()) strips an untrusted bank reference from the invoice under certain sudo/portal contexts regardless. Relying on it was fighting against a deliberate Odoo security check rather than a real fix.

Three-part fix:

  1. portal_documentation_renew_iban now calls _apply_bank_account() in both branches (new document, and bumping the expiry of an existing one) — an approved IBAN document via the portal now always trusts its bank, exactly like the review-queue path. Tested in tests/test_portal_enrollment.py (a genuine HttpCase hitting the real route).
  2. migrations/18.0.0.22.0/post-migrate.py::_backfill_iban_trust re-applies _apply_bank_account() for every already-approved IBAN document, fixing the historical gap (408 students in production were in this inconsistent state). Tested in tests/test_student_document.py.
  3. enrollment.py’s invoicing-time fallback no longer attempts to silently self-grant trust — it now raises a clear ValidationError if the bank isn’t approved yet, since points 1-2 mean this should no longer be reachable through normal use; if it is, the actual approval step was skipped and that should be surfaced, not papered over. See the “Billing” section of enrollment.md.

benefit_type

A Selection whose choices come from ems.student.benefit.benefit_type (_selection_benefit_type(), translated labels) - the same keys, since an approved benefit document becomes an ems.student.benefit (_apply_benefit). Stored as varchar like any selection, so the keys already in the database stay valid. It used to be a plain Char, which made the review list and form show the internal key (large_family_gen).

The review list’s Approve/Reject buttons are icon-only (label as tooltip): with the text as well, the two buttons don’t fit their column.

Portal page (/my/documentacion)

portal_documentation() passes the type, status and benefit-category labels to the template as dicts built from fields_get() (doc_type_labels, doc_status_labels, benefit_category_labels, next to the existing benefit_types). fields_get() returns selection labels translated into the visitor’s language; reading record._fields[...].selection directly in QWeb returns the English source instead, which is how the page used to show “Pending review”/”Passport” to a Catalan family. The upload modals take their titles from doc_type_labels too. Covered by TestPortalActions.test_documentation_page_translates_selection_labels.

Views

View File Notes
List/Form/Search views/community/contact/student_document.xml view_student_document_list/_form/_search — the only place Approve/Reject/Reset are reachable; header statusbar + buttons
Action same file action_student_document, context: {'search_default_pending': 1}
Menu views/academic_management/enrollment_configuration/menu.xml menu_student_documents, under Academic Management (not under the Educational Community / Students menu tree — easy to miss when looking for it there)
Read-only embed views/community/contact/form.xml, “Documentation” page document_ids one2many, create="0" delete="0", no action buttons — review only happens from the standalone screen above