Ce que tu vas apprendre
Le moteur natif
Pourquoi xlsxwriter dans un controller est la voie native en
Communauté, et non un report_type="xlsx".
Le code réutilisable
Une méthode modèle qui retourne les octets du classeur — appelable depuis un controller comme depuis un test.
Le déclencheur
Un bouton d'en-tête sur la commande de vente qui télécharge le fichier en un clic.
sale (les lignes de commande servent de jeu de données concret).
Aucune dépendance externe à installer : xlsxwriter est fourni avec Odoo.
Native ne veut pas dire « moteur de rapport »
Le réflexe habituel consiste à chercher un ir.actions.report avec un
report_type dédié. Pour le PDF, ce moteur existe — c'est le sujet de
générer des rapports PDF avec QWeb.
Pour le XLSX,
il n'existe pas en standard : le fameux report_type="xlsx"
provient du module communautaire OCA report_xlsx, pas du cœur d'Odoo.
La voie réellement native en Communauté est plus directe : un
controller HTTP qui construit le classeur avec xlsxwriter
et le renvoie en pièce jointe. C'est exactement le mécanisme qu'utilise le cœur d'Odoo,
par exemple pour l'export d'une liste de prix produit. Le développeur Odoo garde ainsi
un contrôle total sur la mise en forme, sans ajouter de dépendance.
sale.order.line → Bouton d'en-tête
act_url → Controller HTTP
xlsxwriter → Fichier .xlsx
téléchargé
1. Le manifeste
Un module minimal qui dépend de sale et charge une seule vue (le bouton).
{
'name': 'OdooSkills — Export XLSX natif (démo blog)',
'version': '19.0.1.0.0',
'category': 'Tools',
'summary': "Générer un rapport Excel (.xlsx) natif via xlsxwriter, sans module tiers",
'license': 'LGPL-3',
'depends': ['sale'],
'data': [
'views/sale_order_views.xml',
],
'installable': True,
'application': False,
}
Installe le module sur ta base de test :
odoo-bin -u odooskills_export_xlsx -d ma_base --stop-after-init
2. La logique de génération
Le cœur du sujet tient dans une méthode placée sur sale.order. Elle retourne
les octets du classeur. La placer sur le modèle — plutôt que dans le
controller — la rend réutilisable et, surtout, testable sans serveur HTTP
par un simple test automatisé.
import io
from odoo import models, _
class SaleOrder(models.Model):
_inherit = 'sale.order'
def _build_sale_lines_xlsx(self):
"""Construit le classeur Excel des lignes des commandes du recordset.
Retourne les octets (`bytes`) du fichier .xlsx — utilisable depuis un
controller HTTP comme depuis un test, sans dépendance OCA.
"""
import xlsxwriter # vendored par Odoo
buffer = io.BytesIO()
workbook = xlsxwriter.Workbook(buffer, {'in_memory': True})
sheet = workbook.add_worksheet(_('Lignes de vente'))
header_fmt = workbook.add_format({
'bold': True, 'bg_color': '#1D6F42', 'font_color': '#FFFFFF', 'border': 1,
})
money_fmt = workbook.add_format({'num_format': '#,##0.00'})
headers = [
_('Commande'), _('Client'), _('Produit'),
_('Quantité'), _('Prix unitaire'), _('Total HT'),
]
for col, label in enumerate(headers):
sheet.write(0, col, label, header_fmt)
widths = [len(h) for h in headers]
row = 1
for order in self:
for line in order.order_line.filtered(lambda l: not l.display_type):
cells = [
order.name,
order.partner_id.display_name,
line.product_id.display_name or line.name,
line.product_uom_qty,
line.price_unit,
line.price_subtotal,
]
sheet.write(row, 0, cells[0])
sheet.write(row, 1, cells[1])
sheet.write(row, 2, cells[2])
sheet.write(row, 3, cells[3])
sheet.write(row, 4, cells[4], money_fmt)
sheet.write(row, 5, cells[5], money_fmt)
for col, value in enumerate(cells):
widths[col] = max(widths[col], len(str(value)))
row += 1
for col, width in enumerate(widths):
sheet.set_column(col, col, min(width + 2, 50))
workbook.close()
data = buffer.getvalue()
buffer.close()
return data
Trois points méritent l'attention. Le tampon io.BytesIO — un fichier
en mémoire vive, sans rien écrire sur disque — reçoit le binaire ;
l'option {'in_memory': True} renforce ce comportement.
Les add_format portent la mise en forme — en-têtes verts, format monétaire
#,##0.00. Enfin, le filtre filtered(lambda l: not l.display_type)
écarte les lignes de section et de note, qui n'ont ni quantité ni prix.
3. Le controller qui sert le fichier
Le controller ne fait qu'emballer les octets dans une réponse HTTP avec le bon
Content-Type et un Content-Disposition qui force le téléchargement.
from odoo.http import Controller, request, route, content_disposition
class OdooSkillsXlsxExport(Controller):
@route('/odooskills/export/sale_lines/xlsx', type='http', auth='user', readonly=True)
def export_sale_lines_xlsx(self, order_ids=None, **kw):
ids = [int(x) for x in (order_ids or '').split(',') if x.strip().isdigit()]
orders = request.env['sale.order'].browse(ids).exists()
content = orders._build_sale_lines_xlsx()
headers = [
('Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
('Content-Disposition', content_disposition('rapport_ventes.xlsx')),
]
return request.make_response(content, headers)
Le type de route est http (pas json) car la réponse est un fichier
binaire. auth='user' impose une session authentifiée, et readonly=True
indique qu'aucune écriture en base n'a lieu — un signal utile pour l'ordonnancement des workers.
La fonction content_disposition() s'importe depuis odoo.http et gère
correctement l'échappement du nom de fichier.
4. Le déclencheur dans la vue
Une action act_url ouvre la route en passant les identifiants des commandes
sélectionnées. Côté modèle :
def action_export_lines_xlsx(self):
"""Bouton d'en-tête : redirige vers la route de téléchargement du rapport XLSX."""
ids = ','.join(str(i) for i in self.ids)
return {
'type': 'ir.actions.act_url',
'url': '/odooskills/export/sale_lines/xlsx?order_ids=%s' % ids,
'target': 'self',
}
Et le bouton lui-même, greffé dans l'en-tête du formulaire de commande :
<record id="view_order_form_xlsx_export" model="ir.ui.view">
<field name="name">sale.order.form.xlsx.export</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_export_lines_xlsx" type="object"
string="Exporter en Excel" class="btn-secondary"/>
</xpath>
</field>
</record>
Le résultat
Un clic, et le navigateur télécharge rapport_ventes.xlsx. Ouvert dans un
tableur, le fichier présente les en-têtes verts mis en forme, les colonnes
redimensionnées et les montants au format monétaire. Le même principe vaut pour
n'importe quel jeu de données — par exemple le chiffre d'affaires d'un
point de vente restaurant.
⚠️ Pièges à éviter
- Ne pas chercher un
report_type="xlsx"dans le cœur : il n'existe pas en standard, c'est une brique OCA. - Toujours fermer le classeur avec
workbook.close()avant de lirebuffer.getvalue()— sinon le fichier est tronqué. - Importer
content_dispositiondepuisodoo.http, et non le construire à la main : l'échappement des noms de fichiers y est géré. - Garder la logique de génération dans une méthode du modèle : elle devient
testable par un simple
TransactionCase, sans monter un serveur HTTP.
Référence des formats de cellule : documentation xlsxwriter.
À retenir
- Le XLSX natif en Communauté =
xlsxwriterdans un controller HTTP, pas de moteur de rapport dédié. - Une méthode modèle qui retourne des
bytessépare la génération de la diffusion — réutilisable et testable. - Un bouton
act_urlsuffit à déclencher le téléchargement depuis n'importe quelle vue.
Télécharge le Guide Technique Odoo
Un module Odoo 19 fonctionnel, 20+ articles techniques, environnements de dev et pipeline complet — le tout en PDF.
Télécharger le guide📘 Pour aller plus loin : nos formations Odoo 19
À lire également
- Générer des rapports PDF avec QWeb — l'autre face du reporting Odoo.
- Écrire des tests automatisés en Odoo 19 — pour couvrir la méthode de génération.
- Côté métier, voir comment exploiter ces ventes : configurer la gestion de restaurant et exporter son chiffre d'affaires.