Odoo QWeb Reports: From Template to PDF

Abstract technical illustration for the article: Odoo QWeb Reports: From Template to PDF

Odoo QWeb Reports: From Template to PDF

Every invoice, sales order, and delivery slip in Odoo travels through one pipeline: a report action points at a QWeb template, Odoo renders it into HTML, and a tool called wkhtmltopdf converts the HTML into a PDF. When they break — blank PDFs, missing headers, cryptic tracebacks — you need to know which stage failed. This article walks the full path from the XML that declares a report to the PDF that lands on disk.

The two halves: report action and report template

An Odoo report always has two halves. The report action is a record on the model ir.actions.report, created from an XML data file: it names the report, picks the model, sets the output type, and points at the template. The report template is a QWeb template, also declared in XML, that decides what the document looks like. The action references the template through report_name, which must match the template's XML ID with the module prefix, like my_module.report_my_document.

There are three report types. qweb-pdf renders the template to HTML and passes it through wkhtmltopdf. qweb-html serves the rendered HTML directly in the browser — the best debugging tool in the reports toolbox. qweb-text returns plain text for POS receipts and labels.

Declaring the report action

<record id="action_report_shipment" model="ir.actions.report">    <field name="name">Shipment Report</field>    <field name="model">stock.picking</field>    <field name="report_type">qweb-pdf</field>    <field name="report_name">my_module.report_shipment</field>    <field name="report_file">my_module.report_shipment</field>    <field name="print_report_name">'Shipment - %s' % (object.name)</field>    <field name="binding_model_id" ref="model_stock_picking"/>    <field name="binding_type">report</field>    <field name="paperformat_id" ref="my_module.paperformat_a4_portrait"/></record>

binding_model_id plus binding_type="report" puts a Print entry in that model's action menu. print_report_name is a Python expression evaluated with each printed record exposed as object; it names the downloaded file. A <report> shortcut tag expands to the same record, but it cannot define the template, which you always write by hand.

Two fields change behavior rather than labels. When attachment_use is true, Odoo evaluates the attachment expression (again with object available), renders the PDF on first print, stores it as an attachment on the record, and serves the stored copy afterwards. That is how posted invoices freeze their legal content even if the template changes later — at the cost that regeneration requires deleting the attachment.

The report template: anatomy

<template id="report_shipment">    <t t-call="web.html_container">        <t t-foreach="docs" t-as="o">            <t t-call="web.external_layout">                <div class="page">                    <h2>Shipment <span t-field="o.name"/></h2>                    <p>Scheduled: <span t-field="o.scheduled_date"/></p>                </div>            </t>        </t>    </t></template>

Read from the outside in. web.html_container produces the outer HTML document and pulls in the report CSS assets (custom report styling belongs in the web.report_assets_common bundle). The t-foreach over docs handles batch printing: select ten records, hit print, and the loop renders one document per record into a single PDF. web.external_layout adds the company's branded header and footer, and your content goes inside <div class="page">, which marks a logical page start for the PDF converter.

Several variables are always available: docs (the recordset), doc_ids, doc_model, user (the person printing), res_company, time from the Python standard library, web_base_url, and context_timestamp, which converts UTC datetimes into the printing user's timezone. Anything beyond these — computed totals, wizard parameters, extra recordsets — needs a custom report model, covered below.

external_layout and its variants

web.external_layout is a dispatcher: it checks the company's external_report_layout_id and delegates to a variant — standard, boxed, bold, striped, folder, wave, or bubble. Users can switch the company's look under Settings > Companies > Configure Document Layout without touching any template. Treat external_layout and html_container as global infrastructure: never inherit or modify them to change one report; scope overrides to your own *_document sub-template. For internal printouts without branding, web.internal_layout gives a minimal header.

The directives you actually use

t-field renders a field with locale-aware formatting and accepts t-options for widgets, e.g. the monetary widget with an explicit currency. t-esc inserts a value with HTML escaping — the safe default for computed strings. t-out inserts raw HTML without escaping (replacing the deprecated t-raw) — use it only for content you generated yourself, never for user-supplied text. t-if, t-foreach with t-as, and t-set provide control flow; t-call includes another template by XML ID, the standard way to share document bodies.

For multilingual documents, put t-lang on the t-call: the common pattern is a main template looping over documents and calling the document template with t-lang="o.partner_id.lang", so each record prints in its partner's language.

Paper formats: report.paperformat

Page size, orientation, and margins come from a report.paperformat record on the action's paperformat_id, or the company's default. All dimensions are in millimeters:

<record id="paperformat_label" model="report.paperformat">    <field name="name">Label 60x40</field>    <field name="format">custom</field>    <field name="page_width">60</field>    <field name="page_height">40</field>    <field name="orientation">Portrait</field>    <field name="margin_top">2</field>    <field name="margin_bottom">2</field>    <field name="margin_left">2</field>    <field name="margin_right">2</field>    <field name="header_spacing">0</field>    <field name="dpi">300</field></record>

format takes presets like A4 or Letter, or custom with explicit width and height. header_spacing reserves a band at the top of the page for the wkhtmltopdf header: if the company header overlaps your content, increase header_spacing instead of fighting CSS. header_line toggles a separator rule; dpi defaults to 90, raised to 300 for label formats. The base module ships common formats (e.g. base.paperformat_euro), so define your own only when the stock ones do not fit.

How reports get triggered

The most visible trigger is the Print menu via binding_type="report". From code, you call report_action on the action record:

action = self.env.ref('my_module.action_report_shipment')return action.report_action(self, data={'extra_note': wizard.note})

This returns a client action dictionary the web client downloads. The optional data argument flows into the custom report model's _get_report_values, which is how wizard input reaches the template. self.env['ir.actions.report']._render_qweb_pdf(report_ref, res_ids) returns the raw PDF bytes — handy in tests, cron jobs, and email attachments that need a PDF without a browser round-trip.

The PDF engine: wkhtmltopdf

The final stage is wkhtmltopdf (WebKit HTML to PDF). Odoo renders the QWeb template to a complete HTML document, then shells out to wkhtmltopdf to convert it. This is still the official rendering path in Odoo 17 — the developer reference states it plainly: “The PDF rendering itself is performed by wkhtmltopdf.” No Odoo-native PDF library does this work; conversion is fully delegated to the external binary.

That delegation causes Odoo's most persistent reports headache: version pickiness. Some wkhtmltopdf builds silently drop page headers and footers — the classic symptom of a report whose body renders fine but whose header and footer are missing. Use the patched Qt builds from the wkhtmltopdf project; distro-packaged versions are frequently the wrong ones. Odoo keeps the current recommendation on its wiki at github.com/odoo/odoo/wiki/Wkhtmltopdf. “Blank header, body fine” is almost always a binary problem, not a template problem.

A second consequence: wkhtmltopdf fetches the report's assets (CSS, fonts, images) over HTTP from the Odoo server itself. If web.base.url or report.url point at a hostname the server cannot reach — common in containers or behind reverse proxies — conversion slows to a crawl and the PDF comes out with broken styling. Reports that take minutes and look unstyled are usually a self-reachability problem.

Debugging: when the report renders wrong or errors

Debug in layers, cheapest first: temporarily switch the action's report_type to qweb-html and view the rendered output in the browser. If the HTML looks right but the PDF does not, the fault is in the wkhtmltopdf stage. If the HTML is already wrong, the fault is in the template or its data.

For Python-side failures, developer mode surfaces template errors with file and line information, and the server log carries the underlying exception — wkhtmltopdf timeouts, access errors on records the printing user cannot read, or evaluation errors in print_report_name and attachment expressions.

The common failure catalog:

  • Missing headers and footers, body intact: wrong wkhtmltopdf build. Install the patched build Odoo recommends.
  • Slow generation plus broken styling: the server cannot reach its own web.base.url / report.url — fix the system parameters or the network.
  • Totally blank PDF: the template is missing the web.html_container call, or report_name does not match the template's XML ID.
  • Access error for some users: reports render with the printing user's permissions. Test as the actual user, not as admin.
  • PDF never regenerates after a template fix: attachment_use is serving the stored copy. Delete the attachment or print a record without one.
  • Content overlaps the header: increase header_spacing on the paper format instead of fighting CSS margins.
  • Wrong language on translated strings: the document template is not called with t-lang set to the partner's language.

Custom report models: when docs is not enough

When the template needs data beyond the docs recordset — wizard parameters, aggregated values, records from other models — define a custom report model: an abstract model named report. plus the action's report_name, implementing _get_report_values:

class ShipmentReport(models.AbstractModel):    _name = 'report.my_module.report_shipment'    _description = 'Shipment Report'    @api.model    def _get_report_values(self, docids, data=None):        docs = self.env['stock.picking'].browse(docids)        return {            'doc_ids': docids,            'doc_model': 'stock.picking',            'docs': docs,            'extra_note': (data or {}).get('extra_note'),        }

That dictionary becomes the template's rendering context. Keep it lean — it runs on every print.

The pipeline, end to end

That is the whole chain: action resolves template, QWeb renders HTML, wkhtmltopdf converts it with the paper format's geometry, Odoo names and delivers the PDF. Each stage has its own failure signature — a wrong stage in the pipeline produces a wrong diagnosis — so debugging always starts with one question: which stage broke? Answer that, and the fix is usually one XML field or one system parameter away.

Further reading

Similar Posts

Leave a Reply