Every document Odoo prints - sales orders, invoices, delivery slips, manufacturing work orders - is a QWeb template that Odoo compiles to HTML and passes to wkhtmltopdf for PDF rendering. Custom report development follows the same inheritance model as website templates but adds a reporting context, paper formats, and subreport composition.
Report definition#
Reports are defined in two parts: an ir.actions.report record and a QWeb template.
<!-- mymodule/report/sale_order_report.xml -->
<odoo>
<record id="action_report_sale_order_custom" model="ir.actions.report">
<field name="name">Custom Sale Order</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">mymodule.report_sale_order_custom_document</field>
<field name="report_file">mymodule.report_sale_order_custom_document</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
</record>
</odoo>The binding_model_id and binding_type make the report appear in the "Print" action menu on the model's form view.
QWeb report template structure#
Every PDF report wraps its content in a standard structure:
<template id="report_sale_order_custom_document">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<div class="page">
<!-- report content here -->
<h2 t-field="doc.name"/>
<table class="table">
<thead>
<tr>
<th>Product</th>
<th>Quantity</th>
<th>Unit Price</th>
<th>Subtotal</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.order_line" t-as="line">
<tr>
<td><span t-field="line.product_id.name"/></td>
<td><span t-field="line.product_uom_qty"/></td>
<td><span t-field="line.price_unit"/></td>
<td><span t-field="line.price_subtotal"/></td>
</tr>
</t>
</tbody>
</table>
<div class="row mt32">
<div class="col-4 offset-8">
<table class="table table-sm">
<tr>
<td>Subtotal</td>
<td class="text-end"><span t-field="doc.amount_untaxed"/></td>
</tr>
<tr>
<td>Tax</td>
<td class="text-end"><span t-field="doc.amount_tax"/></td>
</tr>
<tr class="fw-bold">
<td>Total</td>
<td class="text-end"><span t-field="doc.amount_total"/></td>
</tr>
</table>
</div>
</div>
</div>
</t>
</t>
</t>
</template>Key elements:
web.html_container- sets the HTML document structure.web.external_layout- adds the company header and footer.docs- the recordset passed to the report (can be multiple records).t-field- renders the field value with Odoo's field widget (handles currencies, dates, many2one names).
Context variables available in reports#
| Variable | Description |
|---|---|
docs | Recordset of records being printed |
doc_ids | List of record IDs |
doc_model | Model name string |
user | Current user |
company | Current company |
time | Python time module |
datetime | Python datetime module |
lang | Current language code |
Access related fields directly: doc.partner_id.country_id.name, doc.company_id.logo.
Injecting custom context#
Override the _get_report_values method in a custom report parser to add variables:
from odoo import models, api
class SaleOrderReport(models.AbstractModel):
_name = 'report.mymodule.report_sale_order_custom_document'
_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,
'company': self.env.company,
'custom_footer': 'All sales subject to our standard terms.',
'show_internal_notes': self.env.user.has_group('mymodule.group_internal'),
}In the template, use the injected variable:
<p t-if="show_internal_notes" t-field="doc.note"/>
<p class="text-muted"><t t-esc="custom_footer"/></p>Subreport composition#
For shared blocks (signatures, terms and conditions, payment details) across multiple reports, extract them into subreport templates and call them with t-call:
<template id="report_payment_terms_block">
<div class="terms-block">
<h5>Payment Terms</h5>
<p t-field="doc.payment_term_id.note"/>
<p>Bank: <t t-esc="doc.company_id.bank_ids[:1].acc_number"/></p>
</div>
</template>
<!-- In the main report -->
<t t-call="mymodule.report_payment_terms_block"/>Pass variables into subreports:
<t t-call="mymodule.report_payment_terms_block">
<t t-set="show_iban" t-value="True"/>
</t>Paper format configuration#
Paper formats are defined in Settings → Technical → Reporting → Paper Formats. Create one for a non-standard document:
<record id="paperformat_a5_landscape" model="report.paperformat">
<field name="name">A5 Landscape</field>
<field name="default" eval="False"/>
<field name="format">custom</field>
<field name="page_width">210</field>
<field name="page_height">148</field>
<field name="orientation">Landscape</field>
<field name="margin_top">10</field>
<field name="margin_bottom">10</field>
<field name="margin_left">10</field>
<field name="margin_right">10</field>
<field name="header_line" eval="False"/>
<field name="header_spacing">10</field>
</record>Link the paper format to the report action:
<field name="paperformat_id" ref="mymodule.paperformat_a5_landscape"/>Common wkhtmltopdf issues#
Page breaks: Force a page break with CSS: . This is more reliable than CSS page-break-after on block elements in wkhtmltopdf.
Images not rendering: t-att-src on must use the base64 data URI for images stored in Odoo binary fields. Use t-att-src="'data:image/png;base64,' + doc.image_1920.decode()" - or use the web.image helper template.
Fonts not loading: wkhtmltopdf is a headless browser with its own font rendering. Install fonts system-wide on the Odoo server: apt install fonts-noto. Web fonts loaded from external URLs (Google Fonts) fail in offline environments.
Page numbers: In the header/footer, use the page and topage JavaScript variables that wkhtmltopdf injects:
<t t-call="web.internal_layout">
<t t-set="o" t-value="docs[0]"/>
<!-- Page number in footer: uses --footer-html -->
<div class="footer">
Page <span class="page"/> of <span class="topage"/>
</div>
</t>The web.internal_layout template wires up the header and footer HTML files that wkhtmltopdf processes.

