هذه المقالة متاحة حاليًا باللغة الإنجليزية فقط. الترجمة قريبا.
By the end of this recipe, you will have a custom Odoo PDF report that uses the standard web.external_layout (so it inherits the company logo, address block, and page numbering for free) and renders the data of any Odoo model you choose. Skill required: developer comfortable with Python and QWeb XML. Time required: 2 hours from blank module to printable report. ECOSIRE has built hundreds of custom reports — invoices, delivery slips, audit certificates, regulatory submissions — and the recipe below is the deduplicated playbook.
The reason custom reports trip up new developers: Odoo has three different layout systems (web.external_layout, web.internal_layout, and the legacy report.external_layout) and they are NOT interchangeable. The recipe below uses web.external_layout because it is the modern v17/18/19 standard and reads the company branding from the res.company model. Get this wrong and your report shows the literal string ${user.company_id.logo} instead of an actual logo.
What you will need
- Odoo version: 17, 18, or 19. The external_layout API changed slightly in v17 (the
addressblock is now a sub-template). - Skill: Python class definition, QWeb XML, basic CSS.
- Module skeleton: a custom module with
__manifest__.py,__init__.py, and anmodels/folder. - Model: an existing Odoo model you want to print. We will use
sale.orderin this example, but the pattern is identical foraccount.move,stock.picking, or any custom model. - Time: 2 hours including iteration on layout. Add 1 hour if you are designing the layout from scratch versus copying an existing one.
Step-by-step
1. Declare the report in the manifest
Edit __manifest__.py:
{
'name': 'Custom Sales Order Report',
'version': '19.0.1.0.0',
'depends': ['sale', 'web'],
'data': [
'reports/sale_order_report.xml',
'reports/sale_order_template.xml',
'reports/paperformat.xml',
],
'installable': True,
}
Verification: install the module — it should load even though the XML files don't exist yet (Odoo will fail loudly if you reference them before creating them). Create the empty stub files first so the module installs cleanly.
2. Define a custom paper format (optional but recommended)
Many countries have non-US-Letter paper. Create reports/paperformat.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="paperformat_a4_custom" model="report.paperformat">
<field name="name">A4 Custom Margins</field>
<field name="format">A4</field>
<field name="page_height">0</field>
<field name="page_width">0</field>
<field name="orientation">Portrait</field>
<field name="margin_top">42</field>
<field name="margin_bottom">23</field>
<field name="margin_left">7</field>
<field name="margin_right">7</field>
<field name="header_line">False</field>
<field name="header_spacing">35</field>
<field name="dpi">90</field>
</record>
</odoo>
Verification: Settings > Technical > Reports > Paper Formats shows your new format. The 35 mm header spacing leaves room for the logo block.
3. Define the report action
Create reports/sale_order_report.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_sale_order_custom_report" model="ir.actions.report">
<field name="name">Custom Sales Order</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">custom_report.sale_order_template</field>
<field name="report_file">custom_report.sale_order_template</field>
<field name="paperformat_id" ref="paperformat_a4_custom"/>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
</record>
</odoo>
Verification: open any Sales Order, click the Print menu, and you should see "Custom Sales Order" listed (even though it will fail to render until step 4 is done).
4. Build the QWeb template using external_layout
Create reports/sale_order_template.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="sale_order_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div class="page">
<h2 class="mt-3">
Order <span t-field="o.name"/>
</h2>
<div class="row mt-4">
<div class="col-6">
<strong>Customer</strong>
<div t-field="o.partner_id"
t-options='{"widget": "contact", "fields": ["address","name","phone"]}'/>
</div>
<div class="col-6">
<strong>Order Date:</strong>
<span t-field="o.date_order" t-options='{"widget": "date"}'/><br/>
<strong>Sales Person:</strong> <span t-field="o.user_id.name"/><br/>
<strong>Payment Terms:</strong> <span t-field="o.payment_term_id.name"/>
</div>
</div>
<table class="table table-sm o_main_table mt-3">
<thead>
<tr>
<th>Description</th>
<th class="text-end">Qty</th>
<th class="text-end">Unit Price</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody>
<t t-foreach="o.order_line" t-as="line">
<tr>
<td><span t-field="line.name"/></td>
<td class="text-end"><span t-field="line.product_uom_qty"/></td>
<td class="text-end"><span t-field="line.price_unit"/></td>
<td class="text-end"><span t-field="line.price_subtotal"
t-options='{"widget": "monetary",
"display_currency": o.currency_id}'/></td>
</tr>
</t>
</tbody>
</table>
<div class="row mt-4">
<div class="col-6 offset-6">
<table class="table table-sm">
<tr><td>Subtotal</td>
<td class="text-end"><span t-field="o.amount_untaxed"
t-options='{"widget": "monetary",
"display_currency": o.currency_id}'/></td></tr>
<tr><td>Tax</td>
<td class="text-end"><span t-field="o.amount_tax"
t-options='{"widget": "monetary",
"display_currency": o.currency_id}'/></td></tr>
<tr class="border-black"><td><strong>Total</strong></td>
<td class="text-end"><strong><span t-field="o.amount_total"
t-options='{"widget": "monetary",
"display_currency": o.currency_id}'/></strong></td></tr>
</table>
</div>
</div>
<p class="mt-5" t-if="o.note">
<strong>Notes:</strong><br/>
<span t-field="o.note"/>
</p>
</div>
</t>
</t>
</t>
</template>
</odoo>
Verification: upgrade the module (odoo-bin -u custom_report), open any Sales Order, click Print > Custom Sales Order. The PDF downloads and shows your company logo (from external_layout) plus the order data.
5. Customize the external layout (optional)
If you want to override the page header or footer for this specific report only, use template inheritance. Add to reports/sale_order_template.xml:
<template id="external_layout_custom" inherit_id="web.external_layout_standard" primary="True">
<xpath expr="//div[@class='footer']" position="replace">
<div class="footer o_background_footer">
<div class="text-center" style="border-top: 1px solid black;">
<strong>Page <span class="page"/> / <span class="topage"/></strong>
| <span t-field="company.name"/>
| VAT: <span t-field="company.vat"/>
</div>
</div>
</xpath>
</template>
Then in your report template, replace <t t-call="web.external_layout"> with <t t-call="custom_report.external_layout_custom">. Verification: the new footer shows your VAT number and a custom black border.
6. Add a Python report class for computed data
Sometimes you need data that isn't directly a field on the model. Create reports/sale_order_report.py:
from odoo import models, api
class CustomSaleReport(models.AbstractModel):
_name = 'report.custom_report.sale_order_template'
_description = 'Custom Sale Order Report'
@api.model
def _get_report_values(self, docids, data=None):
docs = self.env['sale.order'].browse(docids)
return {
'doc_ids': docids,
'doc_model': 'sale.order',
'docs': docs,
'days_open': lambda o: (fields.Date.today() - o.date_order.date()).days,
'discount_total': sum(l.price_unit * l.product_uom_qty * l.discount / 100 for o in docs for l in o.order_line),
}
Add from . import sale_order_report to reports/__init__.py. The dict keys are now available as variables in the QWeb template, so you can use <t t-esc="discount_total"/>. Verification: the printed report shows the computed discount total in your XML.
7. Test multi-record printing
Select multiple sales orders in the list view and trigger the print action. Odoo will render one PDF with each order on a separate page (handled automatically by the t-foreach over docs in the template). Verification: the multi-record PDF has the correct page numbering across orders.
8. Add translation strings
Wrap any literal text in <t t-call="custom_report.label_quantity"/> style sub-templates, OR use the t-translation="on" attribute on text nodes. Then run odoo-bin --i18n-export=fr.po -l fr -d production to generate the PO file.
Common mistakes
- Calling
web.external_layoutoutside ofweb.html_container. The layout depends on the html_container providing thecompanyvariable in scope. - Forgetting the
report_filefield. Ifreport_nameandreport_filediffer, Odoo can render to PDF but cannot export to HTML, breaking the preview. - Hardcoded styles instead of Bootstrap classes. The Odoo report system uses Bootstrap 5 classes (
row,col-6,text-end). Hardcoded styles look fine in HTML preview but break in PDF rendering via wkhtmltopdf. - Using
<i class="fa fa-...">for icons. wkhtmltopdf cannot load Font Awesome reliably — use SVG or PNG icons embedded as base64. - Currency formatting via
{:,.2f}. Always uset-options='{"widget": "monetary", "display_currency": ...}'so multi-currency reports render correctly.
Going further
Conditional sections: use <t t-if="o.partner_shipping_id != o.partner_id"> to show a separate shipping address block only when it differs from billing. Add similar conditionals for early-payment discount, tax-exempt notes, and warranty terms.
Per-company branding: create different layout templates and select them based on o.company_id so a multi-company database can have brand-specific report styles. Holding companies often need this — parent and subsidiaries each have their own logo/letterhead.
Embedded barcodes: add <img t-att-src="'data:image/png;base64,%s' % o.barcode_base64"/> after computing barcode in the Python report class using python-barcode. Useful for warehouse pick-lists where workers scan rather than read.
Page break control: add <div style="page-break-after: always;"></div> between major sections, or use <div class="o_page_break"></div> in v18+ for the Odoo-native page break helper. Critical for multi-page contracts where sections must start on fresh pages.
Watermarks: add a CSS-positioned div with low opacity for "DRAFT", "VOID", "PAID" watermarks. Conditional on o.state for automatic application.
Dynamic header per page: Odoo's header block supports t-foreach over computed page numbers. Use for "Page X of Y of Document Z" footers.
QR code with payment link: embed a QR code that links to the Stripe-hosted payment URL. Customers scan with their phone and pay instantly. Reduces collection cycle by days for some segments.
Multi-language reports: pair with t-set="lang" t-value="o.partner_id.lang" to render the report in the customer's language. Critical for international B2B.
Print-on-demand customizations: customers who want their logo on outbound packing slips. Build a per-partner template field that overrides the company-default for that customer.
Variable-row logic: handle cases where line item count exceeds one page — first page shows summary, subsequent pages show line continuations with running totals.
Embedded Excel attachment: use xlsxwriter to generate an Excel companion to the PDF and attach it as a second download option. Some customers need both.
Audit-stamped PDFs: append a final page with the user, IP address, and timestamp who generated the report. Required for some financial-controls audits.
A4 vs Letter handling: different countries default to different paper. Detect from o.company_id.country_id.code and pick the right paperformat. Saves the "report cuts off at the bottom" support tickets.
For brand-critical reports like signed contracts, regulatory submissions, or audit certificates, ECOSIRE custom report development ships pixel-perfect layouts including ECOSIRE-letterhead PDFs with exact font matching. Pair this recipe with how to override an existing method in Odoo when you need to extend the data shown in a stock Odoo report.
Frequently Asked Questions
Why not use the Studio report editor?
Studio's report editor is great for tweaking existing reports — moving fields, changing column order, swapping logos. For brand-new layouts, especially regulatory-grade ones, the QWeb XML approach is faster, version-controlled, and reviewable. Studio outputs are also a custom module under the hood, but the generated XML is harder to read than something written by hand.
How do I print the report from a button?
Add a button to your form view: <button name="action_print_custom_report" type="object" string="Print Custom"/> and define the Python method: def action_print_custom_report(self): return self.env.ref('custom_report.action_sale_order_custom_report').report_action(self).
What is the difference between qweb-pdf and qweb-html?
qweb-pdf runs the QWeb output through wkhtmltopdf to produce a PDF. qweb-html returns the rendered HTML directly, useful for in-app preview. You can switch between them by toggling the report_type field on the report action.
How do I email the report instead of downloading it?
Configure an email template (mail.template) with the report_template field set to your report action. Then call template.send_mail(record_id) from a Python method or button. The PDF gets attached automatically. This is how Odoo emails invoices to customers.
For complex reports with multi-page tables of contents, conditional sections, and per-customer branding, our Odoo developers can build the report on a fixed-price engagement starting at $1,500.
بقلم
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
قم بتحويل أعمالك باستخدام Odoo ERP
تنفيذ وتخصيص ودعم خبير Odoo لتبسيط عملياتك.
مقالات ذات صلة
كيفية إضافة زر مخصص إلى عرض نموذج Odoo (2026)
إضافة أزرار إجراءات مخصصة إلى طرق عرض نموذج Odoo 19: طريقة إجراء Python، وعرض الميراث، والرؤية المشروطة، ومربعات حوار التأكيد. تم اختبار الإنتاج.
كيفية إضافة حقل مخصص في Odoo بدون الاستوديو (2026)
قم بإضافة حقول مخصصة عبر وحدة مخصصة في Odoo 19: وراثة النموذج، وامتداد العرض، والحقول المحسوبة، وقرارات المتجر/غير المتجر. الكود أولاً، يتم التحكم في الإصدار.
كيفية عمل نسخة احتياطية واستعادة قاعدة بيانات Odoo (بدون توقف)
دليل الإنتاج: pg_dump + filestore tarball، ودورة حياة S3، والاسترداد في الوقت المناسب، واستعادة اختبار cron، وRTO في أقل من 30 دقيقة. تم اختباره بواسطة ECOSIRE.