Se rendre au contenu

Rapports QWeb PDF en Odoo 19 : ir.actions.report, external_layout et wkhtmltopdf

Bloc 5 · Rapports et automatisations — Article 1/4 Rapports QWeb PDF en Odoo 19 Générer une fiche PDF imprimable depuis n'importe quel modèle — avec ir.actions.
26 avril 2026 par
Rapports QWeb PDF en Odoo 19 : ir.actions.report, external_layout et wkhtmltopdf
B.Mustapha

Bloc 5 · Rapports et automatisations — Article 1/4

Rapports QWeb PDF en Odoo 19

Générer une fiche PDF imprimable depuis n'importe quel modèle — avec ir.actions.report, un template QWeb qui appelle web.external_layout, et wkhtmltopdf en moteur de rendu.

~13 minutes de lecture

Fiche ticket PDF générée avec Odoo 19 — external_layout, tableau infos, description et résolution
Objectif — cette fiche PDF : en-tête external_layout avec logo et nom de la société, titre avec référence + badge d'état, tableau à 2 colonnes, description, zone de résolution colorée, pied de page custom. Tout en QWeb, zéro JavaScript.

Ce que tu vas apprendre

ir.actions.report

L'action serveur qui déclenche la génération du PDF.

Template QWeb

t-call, t-field, t-foreach, t-if

external_layout

Le wrapper natif qui ajoute header + footer company.

Impression en 1 clic

binding_model_id = bouton Imprimer automatique.

Prérequis

  • Module odooskills_helpdesk v19.0.1.11.0 (T19).
  • wkhtmltopdf 0.12.5+ installé (version with patched Qt) — vérifier avec wkhtmltopdf --version.
  • Notions QWeb (T17) et d'héritage de vues (T18).

1. L'anatomie d'un rapport Odoo 19

Un rapport PDF en Odoo = deux records XML, pas un :

1. ir.actions.report model = "helpdesk.ticket" report_type = "qweb-pdf" report_name → template 2. <template> t-call="web.html_container" t-call="web.external_layout" t-foreach="docs" t-as="doc" 3. wkhtmltopdf HTML rendu → PDF header/footer injectés pagination auto Les 3 étapes d'un rapport QWeb PDF Un record déclare l'action, un template décrit le contenu, wkhtmltopdf imprime.

L'ir.actions.report déclare :

  • sur quel modèle opérer (helpdesk.ticket)
  • quel template rendre (report_name)
  • comment nommer le fichier final (print_report_name)
  • où faire apparaître le bouton Imprimer (binding_model_id)

Le template QWeb, lui, ne fait qu'une seule chose : décrire l'HTML à produire pour chaque record passé en docs.

2. Déclarer l'ir.actions.report

On crée report/helpdesk_ticket_report.xml et on déclare l'action :

<?xml version="1.0" encoding="utf-8"?>
<odoo>

    <record id="action_report_helpdesk_ticket" model="ir.actions.report">
        <field name="name">Fiche ticket</field>
        <field name="model">helpdesk.ticket</field>
        <field name="report_type">qweb-pdf</field>
        <field name="report_name">odooskills_helpdesk.report_helpdesk_ticket_document</field>
        <field name="report_file">odooskills_helpdesk.report_helpdesk_ticket_document</field>
        <field name="print_report_name">
            'Ticket - %s' % (object.reference or object.name)
        </field>
        <field name="binding_model_id" ref="model_helpdesk_ticket"/>
        <field name="binding_type">report</field>
    </record>

</odoo>

Quatre champs méritent attention :

ChampRôle
report_type qweb-pdf pour PDF, qweb-html pour afficher en HTML sans impression (utile en dev).
report_name L'external ID du template QWeb à rendre. Préfixer par le module est obligatoire pour l'unicité.
print_report_name Expression Python évaluée sur chaque record pour forger le nom du fichier téléchargé. object = le record, donc object.reference, object.partner_id.name, etc.
binding_model_id Fait apparaître une entrée "Imprimer" dans le menu Actions des vues liste/form du modèle. C'est ce qui rend le rapport "cliquable" depuis l'UI.
Où placer le fichier ? Par convention, report/*.xml à la racine du module. Et dans le __manifest__.py, on le charge AVANT les vues qui y font référence (%(action_report_helpdesk_ticket)d dans un bouton).
# __manifest__.py
'data': [
    'security/ir.model.access.csv',
    'data/ir_sequence.xml',
    'report/helpdesk_ticket_report.xml',   # AVANT les vues
    'views/helpdesk_ticket_views.xml',
    # ...
],

3. Le template QWeb — structure obligatoire

Trois imbrications de t-call sont la norme :

<template id="report_helpdesk_ticket_document">
    <t t-call="web.html_container">           <!-- 1. html/head/body -->
        <t t-foreach="docs" t-as="doc">        <!-- 2. boucle records -->
            <t t-call="web.external_layout">  <!-- 3. header + footer -->
                <div class="page">
                    <!-- ICI : ton contenu -->
                </div>
            </t>
        </t>
    </t>
</template>
  • web.html_container — génère le <html>, <head>, charge Bootstrap 5 et les CSS de rapport d'Odoo.
  • docs est la variable magique : Odoo y injecte browse(res_ids) avant le rendu. Un rapport appelé sur 10 IDs → docs contient les 10 records.
  • web.external_layout — ajoute l'en-tête (logo, nom de la société, adresse, site web) et le pied de page standard. Odoo propose plusieurs variantes : Standard, Boxed, Clean (Paramètres > Paramètres généraux > Business Documents > Layout).
  • <div class="page"> — indispensable : c'est la classe que wkhtmltopdf utilise pour gérer les sauts de page et les marges internes.

Contenu du template — extrait clé

<div class="page">
    <!-- En-tête avec référence + badge d'état -->
    <div class="row mb-4">
        <div class="col-8">
            <h2 class="mb-1">Ticket <span t-field="doc.reference"/></h2>
            <p class="text-muted mb-0" t-field="doc.name"/>
        </div>
        <div class="col-4 text-end">
            <t t-if="doc.state == 'done'">
                <span class="badge rounded-pill"
                      style="background:#28a745;color:white;padding:6px 14px;">Résolu</span>
            </t>
            <p class="small text-muted mt-2 mb-0">
                Priorité :
                <t t-foreach="range(int(doc.priority or 0))" t-as="i">
                    <span style="color:#f39c12;">&#9733;</span>
                </t>
            </p>
        </div>
    </div>

    <!-- Tableau des infos -->
    <table class="table table-sm mb-4">
        <tr>
            <th style="background:#f6f8fa;">Client</th>
            <td><span t-field="doc.partner_id"/></td>
            <th style="background:#f6f8fa;">Assigné à</th>
            <td><span t-field="doc.user_id"/></td>
        </tr>
        <tr>
            <th style="background:#f6f8fa;">Créé le</th>
            <td><span t-field="doc.create_date" t-options='{"widget": "datetime"}'/></td>
            <!-- ... -->
        </tr>
    </table>

    <h4 class="mt-4 mb-2">Description</h4>
    <div class="border p-3 mb-4" style="background:#fafafa;">
        <span t-field="doc.description"/>
    </div>

    <!-- Bloc résolution conditionnel -->
    <div t-if="doc.state == 'done' and doc.resolution_note">
        <h4 style="color:#28a745;">Résolution</h4>
        <div class="border p-3" style="background:#e8f5e9;white-space:pre-line;">
            <span t-field="doc.resolution_note"/>
        </div>
    </div>
</div>

4. t-field vs t-out vs t-esc

Trois directives proches mais pas interchangeables :

DirectiveQuand l'utiliserDétails
t-field Champ d'un record (nom = chemin depuis doc). Formatage automatique (Date → locale, Monetary → symbole, Many2one → display_name). Respecte les t-options pour personnaliser le widget.
t-out Expression Python arbitraire (calcul, concat…). Échappe le HTML sauf si la valeur est marquée markup. Remplace l'ancien t-esc en v17+.
t-esc DEPRECATED en Odoo 19. Utiliser t-out à la place.
Le piège du widget sur <td> — QWeb en Odoo 19 refuse t-field avec t-options directement sur un <td> : AssertionError: QWeb widgets do not work correctly on 'td' elements.
La parade : toujours encapsuler dans un <span> à l'intérieur du <td>.
<!-- ❌ CRASH en v19 -->
<td t-field="doc.create_date" t-options='{"widget": "datetime"}'/>

<!-- ✅ OK -->
<td><span t-field="doc.create_date" t-options='{"widget": "datetime"}'/></td>

5. Bouton "Imprimer" direct dans le header

binding_model_id crée l'entrée dans le menu Actions, mais tu peux aussi ajouter un bouton dédié dans le <header> du form :

<header>
    <!-- ... autres boutons ... -->
    <button name="%(action_report_helpdesk_ticket)d" type="action"
            string="Imprimer la fiche" icon="fa-print"/>
    <field name="state" widget="statusbar"/>
</header>
Bouton Imprimer la fiche dans le header du formulaire ticket
Le bouton Imprimer la fiche apparaît à côté de Résoudre et Clôturer. Un clic → Odoo télécharge immédiatement le PDF.

type="action" est la clé : Odoo résout %(action_report_...)d en l'ID numérique de l'action puis la déclenche. Avec type="object" il chercherait une méthode Python du même nom, on ne veut pas ça ici.

6. Tester et déboguer sans passer par l'UI

Deux URLs qui sauvent des heures en dev :

# PDF final (passe par wkhtmltopdf)
http://localhost:9019/report/pdf/<report_name>/<id>

# HTML intermédiaire (saute wkhtmltopdf — dev only)
http://localhost:9019/report/html/<report_name>/<id>

Exemple concret :

http://localhost:9019/report/html/odooskills_helpdesk.report_helpdesk_ticket_document/6
http://localhost:9019/report/pdf/odooskills_helpdesk.report_helpdesk_ticket_document/6

La version html est ton meilleur allié : elle te montre le rendu Bootstrap 5 directement dans le navigateur, tu peux inspecter le DOM, tester des styles à la volée, etc. Quand ça s'affiche bien en HTML, le PDF suivra.

wkhtmltopdf vs Chromewkhtmltopdf 0.12.5 (with patched Qt) est un WebKit figé : il ne supporte pas tout le CSS moderne. Grid, flexbox gap, certaines pseudo-classes… fuient. Colle aux patterns Bootstrap 5 de base (row/col, d-flex, table) et tes PDF seront propres.

Les pièges à connaître

  1. t-field sur <td> avec widget → encapsuler dans un <span>.
  2. t-esc encore présent ? C'est du code v15-v16. Passer à t-out.
  3. Oublier report_file = même valeur que report_name. Sans lui, certaines URLs de téléchargement direct crashent.
  4. Template chargé APRÈS la vue qui référence l'actionExternal ID not found. Dans le manifest, report/*.xml AVANT views/*.xml.
  5. Oublier web.html_container → pas de <html>, pas de CSS Bootstrap, rendu nu et moche.
  6. Polices exotiques → wkhtmltopdf ne les embarque pas. Coller aux fontes système ou inclure un @font-face dans la web.external_layout héritée (pattern avancé).
  7. Images distantes (https://) → wkhtmltopdf doit avoir accès réseau depuis le serveur. En prod isolée, utiliser /web/image/<id> d'un ir.attachment.

Voir aussi dans cette série

T17 — Kanban, Graph, Pivot

QWeb côté backend

T18 — Héritage de vues

xpath & position

T19 — Wizards

TransientModel, modals

Prochain article — T21

On attaque les emails automatiques : mail.template, variables dynamiques, mail.thread, message_post, et envoi programmé.

Télécharger le guide technique Odoo 19 (PDF gratuit)
Wizards et assistants en Odoo 19 : TransientModel, target='new' et binding_model_id
Bloc 4 · Interface utilisateur — Article 4/4 · Fin du Bloc 4 Wizards et assistants en Odoo 19 Des formulaires modaux qui guident l'utilisateur et orchestrent…