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.
1. L'anatomie d'un rapport Odoo 19
Un rapport PDF en Odoo = deux records XML, pas un :
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 :
| Champ | Rô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. |
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.docsest la variable magique : Odoo y injectebrowse(res_ids)avant le rendu. Un rapport appelé sur 10 IDs →docscontient 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 quewkhtmltopdfutilise 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;">★</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 :
| Directive | Quand l'utiliser | Dé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. |
<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>
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 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
t-fieldsur<td>avec widget → encapsuler dans un<span>.t-escencore présent ? C'est du code v15-v16. Passer àt-out.- Oublier
report_file= même valeur quereport_name. Sans lui, certaines URLs de téléchargement direct crashent. - Template chargé APRÈS la vue qui référence l'action → External ID
not found. Dans le manifest,
report/*.xmlAVANTviews/*.xml. - Oublier
web.html_container→ pas de<html>, pas de CSS Bootstrap, rendu nu et moche. - Polices exotiques → wkhtmltopdf ne les embarque pas. Coller aux
fontes système ou inclure un
@font-facedans laweb.external_layouthéritée (pattern avancé). - Images distantes (
https://) → wkhtmltopdf doit avoir accès réseau depuis le serveur. En prod isolée, utiliser/web/image/<id>d'unir.attachment.
Voir aussi dans cette série
QWeb côté backend
xpath & position
TransientModel, modals
Prochain article — T21
On attaque les emails automatiques :
mail.template, variables dynamiques, mail.thread,
message_post, et envoi programmé.