📋 What You Will Learn
- How Odoo generates PDF reports end-to-end
- Creating a report action with
ir.actions.report - Writing a QWeb report template with loops and conditions
- Rendering field values with
t-field,t-out, andt-esc - Styling reports with Bootstrap and custom CSS
- Configuring paper format (A4, margins, landscape)
- Triggering a PDF download from Python code
How Odoo Generates PDF Reports
The report pipeline works in three stages:
- Action trigger: User clicks "Print" in a form/list view. Odoo finds the matching
ir.actions.reportrecord. - Template rendering: Odoo fetches the recordset, passes it as
docsto the QWeb template, and renders the template to HTML. - PDF conversion: The rendered HTML is sent to a headless Chromium instance (Odoo 17+), which applies CSS and produces the final PDF binary.
All report templates live in your module's report/ directory (by convention) and are loaded via your module manifest's data list.
Report Action (ir.actions.report)
Every report needs an ir.actions.report record that links the action to the model and the QWeb template name:
<odoo>
<record id="action_report_library_loan_receipt" model="ir.actions.report">
<field name="name">Loan Receipt</field>
<field name="model">library.loan</field>
<field name="report_type">qweb-pdf</field>
<!-- Must match the template's t-name exactly -->
<field name="report_name">library_management.report_loan_receipt</field>
<!-- Optional: filename for the downloaded PDF -->
<field name="report_file">library_management.report_loan_receipt</field>
<field name="print_report_name">'Loan Receipt - ' + object.name</field>
<!-- Bind to the model so it appears in the Print menu -->
<field name="binding_model_id" ref="model_library_loan"/>
<field name="binding_type">report</field>
<!-- Optional: paper format (A4, Letter, etc.) -->
<field name="paperformat_id" ref="base.paperformat_euro"/>
</record>
</odoo>
| Field | Purpose |
|---|---|
model | The model whose records are passed to the template |
report_type | qweb-pdf for PDF, qweb-html for HTML preview |
report_name | The t-name of the QWeb template (module.template_id format) |
print_report_name | Python expression for the filename (has access to object) |
binding_model_id | The model to bind to — shows the report in the Print button dropdown |
QWeb Report Template
A QWeb report template is an XML template with a specific structure. The outer template wraps a document template, which wraps individual page templates:
<odoo>
<template id="report_loan_receipt">
<!-- t-call loads the standard report layout (header/footer/logo) -->
<t t-call="web.html_container">
<t t-foreach="docs" t-as="loan">
<!-- One page per loan record -->
<t t-call="web.external_layout">
<div class="page">
<h2>Library Loan Receipt</h2>
<div class="row mt-4">
<div class="col-6">
<strong>Member:</strong>
<!-- t-field renders the value with proper ORM formatting -->
<span t-field="loan.borrower_id.name"/>
</div>
<div class="col-6">
<strong>Date:</strong>
<span t-field="loan.borrow_date"/>
</div>
</div>
<!-- Conditional section -->
<t t-if="loan.days_overdue > 0">
<div class="alert alert-warning mt-3">
This loan is <strong t-out="loan.days_overdue"/> days overdue.
</div>
</t>
<!-- Book details table -->
<table class="table table-sm mt-4">
<thead>
<tr>
<th>Book Title</th>
<th>ISBN</th>
<th>Due Date</th>
</tr>
</thead>
<tbody>
<tr>
<td t-field="loan.book_id.title"/>
<td t-field="loan.book_id.isbn"/>
<td t-field="loan.due_date"/>
</tr>
</tbody>
</table>
<p class="mt-4 text-muted">
Please return the book in good condition by the due date.
</p>
</div>
</t>
</t>
</t>
</template>
</odoo>
Accessing Record Data in Templates
The template receives a docs variable which is the recordset passed to the report. You loop over it with t-foreach and render field values with t-field or t-out:
| Directive | Usage | HTML escaping |
|---|---|---|
t-field="record.field" | Renders the field using Odoo's widget (dates formatted, currency symbol, etc.) | Yes — safe |
t-out="expression" | Renders a Python expression — escaped HTML | Yes — safe |
t-esc="expression" | Same as t-out (older syntax, still works) | Yes — safe |
t-raw="expression" | Renders HTML markup as-is (use only for trusted content) | No — unsafe |
<!-- Good: use t-field for ORM fields — gets proper date/currency formatting -->
<span t-field="loan.borrow_date"/>
<span t-field="loan.book_id.price" t-options='{"widget": "monetary", "display_currency": loan.currency_id}'/>
<!-- Good: use t-out for computed values and Python expressions -->
<span t-out="loan.days_overdue"/>
<span t-out="len(loan.book_ids)"/>
<!-- String formatting in the template -->
<p t-out="'Receipt #' + str(loan.id)"/>
<!-- Conditional rendering -->
<t t-if="loan.borrower_id.country_id">
<span t-field="loan.borrower_id.country_id.name"/>
</t>
Report Styling with Bootstrap
Odoo's report layout includes Bootstrap 4 CSS. You can use any Bootstrap utility class directly in your template. For module-specific styles, add a <style> block inside the template or reference a separate CSS asset:
<template id="report_loan_receipt">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="loan">
<t t-call="web.external_layout">
<div class="page">
<!-- Custom styles scoped to this report -->
<style>
.loan-header { border-bottom: 2px solid #714B67; margin-bottom: 20px; }
.overdue-badge { background-color: #dc3545; color: white;
padding: 4px 8px; border-radius: 4px; }
.signature-line { border-top: 1px solid #ccc; margin-top: 60px;
width: 200px; text-align: center; }
</style>
<div class="loan-header d-flex justify-content-between">
<h2>Loan Receipt</h2>
<t t-if="loan.days_overdue > 0">
<span class="overdue-badge">OVERDUE</span>
</t>
</div>
<!-- content... -->
</div>
</t>
</t>
</t>
</template>
Paper Format
Paper formats control the page dimensions, margins, orientation, and header/footer height. Built-in formats include base.paperformat_euro (A4) and base.paperformat_us_letter. You can define a custom one:
<odoo>
<record id="paperformat_library_receipt" model="report.paperformat">
<field name="name">Library Receipt (A5)</field>
<field name="default">False</field>
<field name="format">A5</field>
<field name="page_height">0</field>
<field name="page_width">0</field>
<field name="orientation">Portrait</field>
<field name="margin_top">15</field>
<field name="margin_bottom">10</field>
<field name="margin_left">10</field>
<field name="margin_right">10</field>
<field name="header_line">False</field>
<field name="header_spacing">35</field>
<field name="dpi">90</field>
</record>
</odoo>
Triggering Reports
You can trigger a PDF report download from Python code — useful for triggering from a button, wizard, or automated action:
def action_print_receipt(self):
"""Return an action that triggers the PDF download."""
self.ensure_one()
return self.env.ref(
'library_management.action_report_library_loan_receipt'
).report_action(self)
# Generate the PDF binary programmatically (e.g. to attach to an email)
def _generate_receipt_pdf(self):
report = self.env.ref('library_management.action_report_library_loan_receipt')
pdf_content, content_type = self.env['ir.actions.report']._render_qweb_pdf(
report,
res_ids=self.ids,
)
return pdf_content # bytes
# Attach a generated PDF to a record as an ir.attachment
def _attach_receipt_pdf(self):
self.ensure_one()
pdf_content = self._generate_receipt_pdf()
attachment = self.env['ir.attachment'].create({
'name': f'Receipt-{self.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': self._name,
'res_id': self.id,
'mimetype': 'application/pdf',
})
return attachment
Summary
📋 Key Points
- Reports need two things: an
ir.actions.reportrecord and a QWeb template report_namein the action must exactly match the template'sidinmodule.idformat- The template receives
docs— the recordset; loop witht-foreach="docs" t-as="doc" - Use
t-fieldfor ORM fields (proper date/currency formatting);t-outfor Python expressions - Bootstrap 4 is available in all report templates — use its grid and utility classes freely
- Define a custom
report.paperformatrecord to control page size, margins, and orientation - Call
report_action(self)from Python to trigger a download; use_render_qweb_pdfto get raw bytes
FAQ
The HTML preview renders in your browser's engine, while the PDF uses Chromium headless with a subset of CSS. Some CSS properties (flexbox, grid, certain transitions) may not render identically. Always test your report by actually downloading the PDF, not just the HTML preview. Using Bootstrap's table and grid system — which Odoo has already tested for PDF compatibility — helps avoid rendering issues.
Odoo uses the web.external_layout template which includes a footer. You can override the footer template or add a custom one. For page numbers, you need CSS counter-based content since QWeb runs in a browser engine: page { @bottom-center { content: counter(page) ' / ' counter(pages); } } — this is CSS Paged Media, supported by Chromium's print engine.
Yes. The docs variable in the template is a recordset — it may contain one or many records. The standard pattern is to loop with t-foreach="docs" t-as="doc" and wrap each iteration in t-call="web.external_layout" to create one page (or section) per record. If you select multiple records in a list and print, all are merged into a single PDF.
report_name is the technical name of the QWeb template (the t-name / XML id) — this is what Odoo uses to find and render the template. report_file is deprecated in newer Odoo versions and was historically used as a filename hint. Use print_report_name (a Python expression) to control the downloaded file's name dynamically.