Skip to content

Reports

Reports generate PDF documents from Jinja2 templates using WeasyPrint. The report system supports multi-module template inheritance similar to views and models.

Reports use WeasyPrint for high-performance PDF generation with:

  • Native CSS Paged Media - Full support for @page rules, margins, and page counters
  • Running Headers/Footers - HTML-based footers using CSS position: running()
  • Pre-loaded Fonts - Local Google Fonts for consistent rendering
  • CSS/Font Caching - Fast subsequent renders after initial warm-up

The following fonts are available for report templates:

FontStyleWeights
InterSans-serif400, 500, 600, 700
RobotoSans-serif400, 500, 700
Open SansSans-serif400, 600, 700
LatoSans-serif400, 700
Source Sans ProSans-serif400, 600, 700
PoppinsSans-serif400, 500, 600, 700
Libre BaskervilleSerif400, 700
MerriweatherSerif400, 700
System DefaultSystem fonts-

Configure the report font in Settings → Configuration → Report Font.

  1. Record-based reports - Render templates for existing database records (invoices, orders, etc.)
  2. Wizard-based reports - Compute aggregated data via a wizard (financial statements, aging reports)
TypePurposeExample
LayoutHeader/footer wrapper with {% block content %}Company branding, page structure
ReportActual report contentInvoice, Aged Receivables

Templates are stored in the database and use Jinja2 syntax:

class ReportTemplate(Model):
name = Char(max_length=255, required=True)
content = Text() # Jinja2 template content
template_type = Selection(choices=["Layout", "Report"], default="Report")
inherited_template = ManyToOne("ReportTemplate", related_name="extensions")
module = Char(max_length=255, required=True)

Links a template to a model and paper format:

{
"data_type": "ReportAction",
"identifier": "sale_order_print",
"name": "Print Quote/Order",
"model": "SaleOrder",
"template": "sale_order_template",
"paper_format": "paper_a4",
"orientation": "Landscape",
"layout_template": "layout_minimal",
"report_type": "PDF",
"filename_pattern": "Sale Order {{ name }}",
"visible": "Q(state__in=['Confirmed', 'Done'])",
"data_method": "build_report_data",
"store_copy": false,
"draft_watermark": true
}
FieldDescription
templateReport template (content)
paper_formatPaper format for dimensions
orientationOptional orientation override (Portrait/Landscape)
layout_templateOptional layout override (defaults to company Configuration)
report_typeOutput format: PDF (default) or HTML. HTML returns the rendered HTML directly (browser preview / portal embedding) instead of a PDF
filename_patternOptional Jinja template for the download filename, evaluated against the record — e.g. Sale Order {{ name }}Sale Order SO0042.pdf. The extension is added automatically. When omitted, or when it renders empty (e.g. a {{ name }} pattern on a model whose identity is a computed display_name rather than a stored name), the filename falls back to the record’s display_name, then to the report name. Multi-record prints always use the report name
data_methodOptional method name on the report’s model returning a dict of extra template data per record (computed totals, aggregates, a resolved URL). Its keys land in a report namespace — read them as {{ report.<key> }}, never as a bare {{ <key> }} — so a bare name in a template is always a real model field. Record-based reports only
visibleOptional Q-expression naming which records the report applies to. Omitted, the report is offered for every record — see Record conditions
store_copyWhen true, keep an immutable copy of the generated document on the record; reprinting returns the same file. Single-record only — see Stored copies
draft_watermarkWhen true, show a DRAFT watermark when the record’s state is Draft — see Watermarks

Orientation Override: Set orientation on the ReportAction to override the paper format’s default orientation. This allows the same paper format (e.g., A4) to be used for both portrait and landscape reports.

Layout Template Override: Set layout_template to override the company-wide default. Financial reports typically use layout_minimal for a cleaner look.

One model often needs more than one document, and not every document suits every record. A stock transfer is the standard case: an outbound one earns a customer-facing delivery slip, an internal shelf-to-shelf move never does, and both want an internal picking sheet. visible is how a report says which records it is for — a Q-expression evaluated against the record:

- data_type: ReportAction
identifier: transfer_delivery_print
name: Delivery Slip
model: Transfer
template: transfer_delivery_template
visible: "Q(transfer_type__direction='Outbound')"

It is evaluated as a query against the report’s model, so it is the same Q you would write in a filter — including relation paths (transfer_type__direction), __in, ~, &/|. The names uid, cid/company_id and cids are bound (the acting user, the company being acted for, the selected companies), for the occasional “my own” or “this company’s” document.

Where it applies:

SurfaceBehaviour
Form (one record)The print menu omits reports the record fails. One record, one answer
List (bulk print)Every report stays in the menu — a mixed selection has no single answer. The condition narrows the records instead: select 20 transfers of which 12 are outbound, print the delivery slip, get 12 documents. The user is told how many were skipped, and a selection where none match is refused with a message rather than an empty file
New / unsaved recordEvery report is offered — there is no saved record to test

Because the bulk path filters records rather than menu entries, the condition is meaningful even where a menu cannot express it. Printing a report the record does not satisfy is never silently rendered anyway: the print endpoint applies the same condition to the records it is handed, whatever route they arrived by.

It states relevance, not permission. Who may read a record is answered by ACLs and record rules on the model, which apply to a report render as to any other read. Do not use visible to hide privileged documents — restrict the model.

A condition that is not a valid Q-expression is refused when the report is saved, naming the report. Field paths inside it are checked by ./fullfinity-server check --only references, so a renamed field turns into a build failure instead of a menu entry that silently stops appearing.

Programmatic renders are not filtered. visible scopes the print menu and the selections it prints. A report generated from code for one named record — an email attachment, a portal download, a certificate — is a specific request and is answered as asked.

Defines page dimensions and margins:

{
"data_type": "PaperFormat",
"identifier": "paper_a4",
"name": "A4",
"paper_size": "A4",
"orientation": "Portrait",
"margin_top": 8,
"margin_right": 15,
"margin_bottom": 20,
"margin_left": 15,
"header_spacing": 10,
"footer_spacing": 10,
"footer_height": 12
}
FieldDefaultDescription
paper_sizeA4Page size (A4, Letter, Legal, etc.)
orientationPortraitDefault page orientation
margin_*variesPage margins in mm
footer_height12Height of footer area in mm

Built-in Paper Formats: paper_a4, paper_letter, paper_legal, paper_a3, paper_a5, paper_tabloid, paper_executive, paper_a4_full_bleed

paper_a4_full_bleed has a uniform 10mm margin and no footer reserve — pair it with the layout_standalone layout for a document that owns the whole sheet (a certificate, label, or badge). The engine builds its @page CSS from the paper format’s margins, so a full-bleed design needs a matching format; the template draws its own border/content on that margin edge.

Multiple modules can extend the same report template. Extensions are applied in module dependency order.

Layout templates define the page structure with header, footer, and content area:

A layout must declare the render language and its text direction on the root element. Both are globals, resolved from the language the document is actually being rendered in (see Customer-Facing Language Priority) — a layout that hardcodes lang="en" prints an Arabic customer’s invoice as Arabic text inside a left-to-right page:

<html lang="{{ document_lang }}" dir="{{ document_dir }}">
GlobalExampleMeaning
document_langarThe render language’s code — drives font selection and hyphenation
document_dirrtlltr or rtl, from the language’s direction
{# modules/core/templates/base_layout.jinja #}
<!DOCTYPE html>
<html lang="{{ document_lang }}" dir="{{ document_dir }}">
<head>
<title>{{ title or "Report" }}</title>
</head>
<body>
<header>
<img src="{{ company.image }}" alt="Logo">
<p>{{ company.name }}</p>
</header>
<section class="content">
{% block content %}
<!-- Report content goes here -->
{% endblock %}
</section>
<footer>
<p>{{ company.street }}, {{ company.city }}</p>
</footer>
</body>
</html>

Register as a Layout:

{
"data_type": "ReportTemplate",
"identifier": "company_standard_layout",
"name": "Standard Layout",
"content": "base_layout.jinja",
"template_type": "Layout"
}

Repeating page footers use CSS Paged Media running elements — the element is pulled out of the document flow with position: running(name) and placed into a page margin box with content: element(name). WeasyPrint then paints it in that margin on every page.

Why not position: fixed? A fixed footer also repeats per page, but when you bulk-print several records the document is a concatenation of N rendered documents — so there are N fixed footers, all painted on top of each other on every page. Overlapping text renders blurred/doubled. Running elements don’t stack: WeasyPrint uses only the most recent one per page. Always use running elements for footers.

Three pieces in your layout template:

<style>
@page {
size: {{ paper_format.paper_size if paper_format else 'A4' }};
margin-bottom: {{ paper_format.margin_bottom if paper_format else 20 }}mm;
/* Place the running elements into the bottom margin. Two boxes (left + right)
give a full-width spread; a single @bottom-center box would shrink-wrap and
bunch the text together. vertical-align: bottom + padding-bottom sits the
footer near the page edge. */
@bottom-left { content: element(pageFooterLeft); vertical-align: bottom; padding-bottom: 8mm; }
@bottom-right { content: element(pageFooterRight); vertical-align: bottom; padding-bottom: 8mm; }
}
.page-footer-left { position: running(pageFooterLeft); font-size: 8pt; color: #6c757d; }
.page-footer-right { position: running(pageFooterRight); font-size: 8pt; color: #6c757d; text-align: right; }
.page-footer-right .page-number::after { content: counter(page) " of " counter(pages); }
/* Full-width divider above the footer. A 1px solid fixed rule drawn over itself
doesn't blur (unlike text), so repeating it per record is safe. Its `bottom` is
content-box relative, so the offset tracks margin_bottom. */
.page-footer-rule {
position: fixed; left: 0; right: 0; height: 0;
bottom: {{ (12 - (paper_format.margin_bottom if paper_format else 20)) | round | int }}mm;
border-top: 1px solid #dee2e6;
}
</style>
<body>
<!-- Declare the footer FIRST so the running elements register from page 1.
Their source position only controls which page registers them, not where they paint. -->
<div class="page-footer-rule"></div>
<div class="page-footer-left"><strong>{{ company.name }}</strong>{% if company.tax_number %} &bull; Tax ID: {{ company.tax_number }}{% endif %}</div>
<div class="page-footer-right">{% if company.website %}{{ company.website }} &bull; {% endif %}<span class="page-number"></span></div>
...header / {% block content %} ...
</body>
Section titled “CSS Paged Media gotchas (read before customising a footer)”
GotchaEffectDo this instead
position: fixed footerStacks/blurs on bulk print (one per concatenated record)position: running(name) + @page { @bottom-* { content: element(name) } }
@bottom-center onlyShrink-wraps → left/right text bunched in the middleSplit into @bottom-left + @bottom-right
Footer declared late in <body>Missing on a record’s earlier pagesDeclare it as the first element of <body>
Full-width divider lineMargin boxes can’t span a continuous ruleA position: fixed 1px rule (solid lines don’t blur when stacked) with a content-box-relative bottom — see .page-footer-rule above

Multi-record page breaks are handled by the engine automatically — each record starts on a fresh page — so a custom layout gets correct bulk-print pagination for free.

The footer template has access to the company context:

VariableDescription
company.nameCompany name
company.streetStreet address
company.cityCity
company.zipPostal/ZIP code
company.phonePhone number
company.emailEmail address
company.websiteWebsite URL
company.tax_numberTax/VAT number
company.font_familySelected font family

Use CSS counters with ::after pseudo-elements for page numbers:

CounterDescription
counter(page)Current page number
counter(pages)Total number of pages

Example:

.page-number::after {
content: counter(page) " of " counter(pages);
}

All built-in letterhead layouts use the running-element footer above: company info bottom-left, website + page numbers bottom-right.

LayoutFooter Style
StandardCompany info left, website + page numbers right
BoxedSame, matches boxed aesthetic
Classic LeftTraditional serif typography
MinimalLighter colors (#adb5bd), simplified content
Modern CenteredCentered header aesthetic

The one exception is layout_standalone — a full-bleed pass-through with no letterhead, header, or footer, for a document that is the whole page (certificate, label, badge). The body template owns the page geometry; pair it with the paper_a4_full_bleed paper format (above).

The footer sits in the bottom margin, so give PaperFormat.margin_bottom enough room (default 20mm) for the footer content.

Report templates are standard HTML + Jinja. Because other modules extend your report, every targetable node is a public extension surface — so a base report you ship must make each one reachable by a stable handle, exactly the way a backend view carries anchors. The rule is total (no dead zones): every block/content element must either carry a data-anchor/id, or directly render a model field — a <td>{{ line.price }}</td> needs no anchor because it is reachable as field:line.price, but a wrapper <tr>, a static label, a section <div>, a <table>, and every {% for %}-wrapping element render no field of their own, so they must be anchored. This is enforced by ./fullfinity-server check --only templates (CI + pre-commit) and at report save/upgrade; a base body with an unreachable node is rejected. You don’t hand-anchor each one — run python3 scripts/seed_template_anchors.py --apply to seed stable anchors on every uncovered node (it only inserts attributes, changing nothing else), then rename any you want to read better. Because the whole body is covered, a consultant can target any part of your report, and if a later version moves or drops a node the extension that targeted it is reported at upgrade (preview --db) — never silently dropped.

The anchor set is also a contract guarded in CI across releases, and the guard is trustless: the reference is the immutable git release tag, not a committed baseline file a change could silently re-snapshot. check --only templates compares each report body’s anchors against its version at the last release (git show vX.Y:<body>); an anchor that shipped in that release but is gone now fails the build unless a rename_anchor/remove_anchor entry in schema_changes.yaml records the removal (recorded via resolve, which then carries any customization across the change on -u all). So renaming or removing a released report anchor without recording it is impossible to do silently — there is no baseline to re-snapshot, and the tag can’t be rewritten by a commit. Between releases anchors churn freely (nothing has shipped, so nothing is a contract yet); the set freezes at each tag.

{# modules/invoicing/templates/invoice_template.jinja #}
<div data-anchor="inv_header">
<h1>{{ t('Invoice') }} {{ name }}</h1>
</div>
<div data-anchor="inv_customer">
<p>{{ contact.name }}</p>
<p>{{ contact.email }}</p>
</div>
<table data-anchor="inv_lines_table">
<thead>
<tr data-anchor="inv_lines_head">
<th data-anchor="inv_col_product">{{ t('Product') }}</th>
<th data-anchor="inv_col_qty">{{ t('Qty') }}</th>
<th data-anchor="inv_col_price">{{ t('Price') }}</th>
</tr>
</thead>
<tbody data-anchor="inv_lines_body">
{% for line in lines %}
<tr data-anchor="inv_line_row"> {# wrapper row renders no field of its own → anchored #}
<td>{{ line.product.name }}</td> {# reachable as field:line.product.name #}
<td>{{ line.quantity }}</td> {# reachable as field:line.quantity #}
<td>{{ line.price }}</td> {# reachable as field:line.price #}
</tr>
{% endfor %}
</tbody>
</table>
<div data-anchor="inv_totals">
<p>{{ t('Total') }}: {{ total }}</p>
</div>

Register as a Report:

{
"data_type": "ReportTemplate",
"identifier": "invoice_template",
"name": "Invoice",
"content": "invoice_template.jinja",
"template_type": "Report"
}

content must reference a template file — never inline the markup in the YAML. A module- shipped ReportTemplate, EmailTemplate, or WebBlock body must point content at a <name>.jinja file (relative to the YAML); inline HTML is rejected by check --only templates. Inline bodies are a blind spot — the anchor seeder can’t reach them, translation extraction and diffs are harder, and the editor tooling assumes a file. (The content field is still Text because runtime customizations authored in Report Studio store their body inline in the database — that’s the DB record, not what a module ships.)

Extensions use directives to patch the base template. Every target must be a stable handle — a data-anchor/#id the base declares, or a field:<chain> binding — never a CSS class, a content string, or a positional path (those break the moment the base is retitled, restyled, or reflowed, so the gate rejects them; see Target Selector Types):

{# modules/crm/templates/invoice_crm_extension.jinja #}
{% inherit "invoice_template" %}
{# Add a whole section after an anchored block #}
{% add after="[data-anchor=inv_customer]" %}
<div data-anchor="inv_crm_data">
<p>{{ t('Sales Rep') }}: {{ sales_rep.name }}</p>
<p>{{ t('Lead Score') }}: {{ lead_score }}</p>
</div>
{% endadd %}
{# Add a column: a header cell after the anchored <th>, and a body cell after the field it
follows. field:line.price resolves to the <td> that renders {{ line.price }} — inside the
loop, so the added cell repeats on every line. #}
{% add after="[data-anchor=inv_col_price]" %}<th data-anchor="inv_col_margin">{{ t('Margin') }}</th>{% endadd %}
{% add after="field:line.price" %}<td>{{ line.margin | money }}</td>{% endadd %}

Register with inherited_template:

{
"data_type": "ReportTemplate",
"identifier": "invoice_crm_extension",
"name": "Invoice CRM Extension",
"content": "invoice_crm_extension.jinja",
"template_type": "Report",
"inherited_template": "invoice_template"
}
DirectiveSyntaxDescription
inherit{% inherit "template_identifier" %}Declare which template to extend
add{% add before|after|inside="target" %}...{% endadd %}Insert content relative to target
replace{% replace name="target" %}...{% endreplace %}Replace target element entirely
remove{% remove name="target" %}Remove target element
attributes{% attributes "target" %}...{% endattributes %}Change attributes of an existing element without restating its markup
move{% move "target" before|after|inside="destination" %}Relocate an existing element

Within a single extension, directives apply top to bottom, in the order you write them — regardless of type. This means a later directive can target an element (by its data-anchor/id) that an earlier one just introduced: {% add %} a section, then {% add %} another element beside it, or {% replace %} an element and then {% attributes %}/{% move %} what you inserted. Order your directives so every target already exists at the point it’s referenced.

When you only need to tweak an attribute (add a CSS class, set a style) rather than replace a whole element, use {% attributes %}. Each line inside the block is name OP "value", where OP is = (set/replace), += (add space-separated tokens), or -= (remove tokens):

{% inherit "invoice_template" %}
{% attributes ".totals" %}
class += "highlight" {# add a class #}
class -= "muted" {# remove a class #}
style = "border-top: 2px solid #000;"
data-id = "" {# set "" deletes the attribute #}
{% endattributes %}
{# Relocate the totals block to after the signature #}
{% move ".totals" after=".signature" %}

An extension directive may target a node only by a stable handle. Two kinds — mirroring backend view inheritance, where a node is addressed by its anchor or, if it’s model-bound, by its field name:

TypeSyntaxExampleWhen
Anchor / id[data-anchor=name] or #id[data-anchor=inv_totals]Any structural node the base anchored
Field bindingfield:<chain>[@<anchor>]field:line.price, field:total@inv_totalsThe element that renders a model field — no anchor needed

A field binding targets the element whose {{ }} expression reads that field chain, written exactly as it appears in the template (field:line.price for {{ line.price | money }}). It is stable for free — the schema gate already blocks renaming the field — and needs no authoring in the base. If a chain renders in more than one place, qualify it with @<anchor> (an enclosing data-anchor/id), e.g. field:total@inv_totals; an unqualified duplicate is rejected.

CSS classes, content-string matches, and positional paths are not valid extension targets — they key on styling, translated text, or position, all of which move under maintenance. The template gate (check --only templates, run in CI and pre-commit) and the report/email save-hooks reject an extension that uses them, and reject a base body whose structural nodes aren’t anchor-covered.

Multiple modules extending the same invoice template:

invoicing/invoice_template.jinja (base)
├── crm/invoice_crm_extension.jinja (adds sales rep info)
├── inventory/invoice_inventory_extension.jinja (adds warehouse column)
└── shipping/invoice_shipping_extension.jinja (adds tracking info)

Extensions are applied in module dependency order, resulting in a merged template.

A base report is a public extension surface: a third-party module can only reach the points you exposed, and it cannot edit your template to add one. So the anchor set is a contract, and it is enforced — the template gate (check --only templates) and the report save-hook require every structural node in a base body to be reachable:

  • Every <table> carries a data-anchor/id (so an add-on can add/replace columns or swap the table).
  • Every element wrapping a {% for %} loop (usually the <tbody>) carries one — that makes the repeated region targetable.
  • Every top-level section carries one, so an add-on can inject a whole block before/after it.
  • Field-bound cells need nothing — they’re reachable via field:<chain>.

Anchors are invisible attributes, so adding them never changes the rendered document. Name them <doc>_<section> / <doc>_col_<x> / <doc>_lines_body (see sale_order.jinja for the full convention). You may also ship an explicit empty seam for content that has no natural node:

{# base template — an explicit, named seam an add-on can fill #}
<div data-anchor="receipt_footer"></div>
{% inherit "pos_receipt_template" %}
{% add inside="[data-anchor=receipt_footer]" %}
{# loyalty points, a QR code, an l10n fiscal block … #}
{% endadd %}

The emailed / reprinted POS receipt (pos_receipt_template) is a shipped example: it exposes pos_receipt_header, pos_receipt_meta, pos_receipt_items, pos_receipt_totals, pos_receipt_payments, and pos_receipt_footer — so an add-on adds a promo footer, a loyalty summary, or a localization’s tax block without forking the core template.

Email bodies extend exactly like reports. Ship an EmailTemplate whose inherited_template points at the base and whose content is patch directives targeting the base’s anchors/field: bindings; the base’s structural nodes are anchor-covered and enforced the same way. Extensions are applied at send time in module-dependency order.

{
"data_type": "EmailTemplate",
"identifier": "order_confirmation_loyalty",
"name": "Order Confirmation — Loyalty Footer",
"inherited_template": "order_confirmation_email",
"content": "order_confirmation_loyalty.jinja"
}
{% inherit "order_confirmation_email" %}
{% add after="[data-anchor=eoc_totals]" %}
<p data-anchor="eoc_loyalty">{{ t('You earned {points} points', points=order.loyalty_points) }}</p>
{% endadd %}

The emailed / reprinted POS receipt (pos_receipt_template) is a shipped example: it exposes pos_receipt_header, pos_receipt_meta, pos_receipt_items, pos_receipt_totals, pos_receipt_payments, and pos_receipt_footer — so an add-on adds a promo footer, a loyalty summary, or a localization’s tax block without forking the core template.

Note the scope: this template is the digital receipt (email + backend PDF reprint), which renders server-side. The receipt physically printed at the register prints offline from a separate client-side renderer and does not use this template — its merchant-facing options (footer message, show tax id / cashier) are Configuration settings on the POS tab, not template extension points.

For reports that render existing database records:

class Invoice(Model):
async def action_print(self):
"""Print invoice PDF."""
report_action = await get_model("ReportAction").filter(
identifier="invoice_report"
).first()
return {
"type": "report",
"report_id": report_action.id,
"model": "Invoice",
"instance_ids": [self.id],
}
async def action_print_selected(self):
"""Print multiple invoices."""
report_action = await get_model("ReportAction").filter(
identifier="invoice_report"
).first()
# Get selected record IDs from context
active_ids = self._ctx.get("active_ids", [self.id])
return {
"type": "report",
"report_id": report_action.id,
"model": "Invoice",
"instance_ids": active_ids,
}

For record-based reports, contact_id is auto-detected if the model has a contact field. The report engine reads the first instance’s contact and uses their language for date formatting. No explicit contact_id is needed in the return dict.

For reports requiring user input and computed data:

class AgedReceivablesWizard(Model):
_transient = True
_verbose_name = "Aged Receivables Report"
as_of_date = Date(required=True, description="As of Date")
aging_intervals = Char(default="30,60,90,120", description="Aging Intervals")
contact = ManyToOne("Contact", description="Filter by Contact")
async def action_generate_report(self):
"""Generate the report PDF with computed data."""
data = await self._get_report_data()
report_action = await get_model("ReportAction").filter(
identifier="aged_receivables_report"
).first()
if not report_action:
raise UserError("Report template not found")
# For customer-facing wizard reports, pass contact_id for language formatting
await self.fetch_related("contact")
contact_id = self.contact.id if self.contact else None
return {
"type": "report",
"report_id": report_action.id,
"model": "AgedReceivablesWizard",
"report_data": data,
"contact_id": contact_id, # Uses customer's language for |date filter
}
async def _get_report_data(self):
"""Compute the report data."""
# Query and compute data...
return {
"title": "Aged Receivables Report",
"as_of_date": self.as_of_date.isoformat(),
"receivables": [...],
"totals": {...},
}

For wizard-based reports, pass contact_id in the return dict to use the customer’s language for date/time formatting. Alternatively, include a contact_id key in the report_data dict.

{# modules/invoicing/templates/aged_receivables.jinja #}
<div class="fy-report">
<h2 class="report-title">Aged Receivables Report</h2>
<div class="report-header">
<p><strong>Company:</strong> {{ report_data.company_name }}</p>
<p><strong>As of Date:</strong> {{ report_data.as_of_date }}</p>
</div>
<table class="report-table">
<thead>
<tr>
<th>Customer</th>
{% for bucket in report_data.bucket_names %}
<th>{{ bucket }}</th>
{% endfor %}
<th>Total</th>
</tr>
</thead>
<tbody>
{% for contact in report_data.contacts %}
<tr>
<td>{{ contact.contact_name }}</td>
{% for bucket in report_data.bucket_names %}
<td>{{ "{:,.2f}".format(contact.buckets[bucket]) }}</td>
{% endfor %}
<td>{{ "{:,.2f}".format(contact.total) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
  1. Load layout - Company-selected layout from Configuration
  2. Load report template - From ReportAction
  3. Load extensions - All templates with inherited_template pointing to report
  4. Sort by module order - Dependencies before dependents
  5. Apply operations - Process add, replace, remove directives
  6. Insert into layout - Replace {% block content %} with compiled report
  7. Render with Jinja2 - Apply context data
  8. Generate PDF - WeasyPrint with CSS
  • All record fields and prefetched relations
  • company - Current company information
  • paper_format - Paper format settings
  • base_url - The install’s public URL (scalar), for absolute links/QR back to the app or portal
  • report_data - Dictionary returned by wizard
  • company - Current company information
  • paper_format - Paper format settings
  • base_url - The install’s public URL (scalar), for absolute links/QR back to the app or portal

Always available:

{{ company.name }}
{{ company.street }}
{{ company.city }}
{{ company.zip }}
{{ company.phone }}
{{ company.email }}
{{ company.website }}
{{ company.image }}
{{ company.brand_color }} {# Primary color for report accents #}

Custom Jinja2 filters are available for formatting values in both PDF reports and email templates. These filters use the company currency and locale-aware date formatting.

Formats monetary values using the company currency’s rounding and symbol settings.

{# With currency symbol (default) #}
{{ amount | money }} {# Output: $1,234.56 #}
{# Without currency symbol #}
{{ amount | money(show_symbol=False) }} {# Output: 1,234.56 #}
{# With a specific currency — a Currency record, or a dict of its display fields #}
{{ amount | money(currency=custom_currency) }}

The filter automatically:

  • Uses the currency’s rounding setting for decimal places (e.g. 0.01 = 2 decimals, 1 = 0 decimals)
  • Groups thousands
  • Positions the currency symbol based on position (Before/After)
  • Groups and points the number with the currency’s own thousands_separator / decimal_separator, so EUR reads 1.234,56 € and USD $1,234.56 — the amount is a property of the money, not of who is reading it, and one invoice therefore reads identically to the customer and to the accountant

Always format an amount with this filter. {{ "%.2f"|format(amount) }} is printf: it cannot group thousands, it hardcodes two decimals (wrong for a 0- or 3-decimal currency) and it leaves you concatenating the symbol in front, which is wrong for every symbol-after currency. A shipped template that does it is rejected by the test suite.

Available on every rendering surface: PDF reports, email templates, the portal, and any module page template — install_translation_global(Environment()), which each module already calls to bind t(), installs money, number and date at the same time. Give the template a currency in its render context (a Currency record or its serialized dict) and a bare {{ amount | money }} resolves against it.

An interactive island on the same page should be handed the currency’s display fields in its data-props (symbol, position, rounding, both separators) and format with them, so the part the customer edits in place reads exactly like the part the server drew.

Formats date values using locale-aware date formatting.

{# Using locale date format (e.g., DD/MM/YYYY or MM/DD/YYYY) #}
{{ invoice_date | date }} {# Output: 15/01/2025 #}
{# With explicit format (strftime format) #}
{{ invoice_date | date("%d %B %Y") }} {# Output: 15 January 2025 #}

Formats datetime values using locale-aware date and time formatting.

{# Using locale date+time format #}
{{ created_at | datetime }} {# Output: 15/01/2025 14:30 #}
{# With explicit format (strftime format) #}
{{ created_at | datetime("%d/%m/%Y %H:%M:%S") }}

Report text is translated into the render language (the customer’s language for customer-facing documents, otherwise the user’s). The English source text is the key — exactly as in the rest of the framework.

{{ t('Subtotal') }}
{{ t('Saved {count} records', count=n) }}

Wrap every user-facing string in t(). Translation is explicit: the engine does no post-render substitution over the finished document, so a bare <th>Unit Price</th> prints in English no matter what language the reader’s copy is in. t() is what marks a string both translatable and translated.

Report template text is picked up by the translation extractor (./fullfinity-translate) just like view labels and field descriptions.

qr(), barcode() and datamatrix() return a data URI you can drop straight into an <img>:

{# QR code (PNG). Useful for portal links, payment URLs, EPC/SEPA strings #}
<img src="{{ qr(report.portal_url) }}" style="width: 28mm; height: 28mm;" />
{# 1D barcode (SVG). Default Code128; any python-barcode type works #}
<img src="{{ barcode(record.name, 'code128') }}" />
<img src="{{ barcode(product.ean, 'ean13') }}" />
{# 2D Data Matrix (SVG). Set gs1=True for a GS1 Data Matrix: pass the raw element #}
{# string with FNC1 (\x1d) separators between variable-length AIs — the leading #}
{# FNC1 is added for you, so one scan yields GTIN + lot + serial + qty. #}
<img src="{{ datamatrix(asset.tag) }}" />
<img src="{{ gs1_string | datamatrix(gs1=True) }}" style="height: 18mm;" />

All three are available as globals and filters — {{ report.portal_url | qr }}, {{ product.ean | barcode('ean13') }}, {{ value | datamatrix }}. An empty value yields an empty string (no image). When a field on the record shares a helper’s name (e.g. a model with a barcode field), use the filter form ({{ code | barcode }}) so the field value doesn’t shadow the global.

A QR encodes whatever string you hand it, so what you usually need is a value the record does not carry as a field — a customer-portal link, a signed payment URL. Produce it in the report’s data_method and read it back off the report global, as above. That is also the only correct place for one that has a side effect: issuing a portal access token is a write, and a calculated field would re-issue it on every read. Guard the block, because such a method returns None whenever there is nothing to link to:

{% if report and report.portal_url %}
<img src="{{ qr(report.portal_url) }}" style="width: 28mm; height: 28mm;" />
{% endif %}

Barcoding a record’s identity is safe: linear symbologies like Code128 accept only a limited character set, but a display_name or reference can contain an em dash, curly quote, or accented letter. Rather than fail the render, barcode() transliterates such values to a barcode-safe ASCII subset (and omits the image only if nothing encodable remains). So {{ barcode(display_name, 'code128') }} never crashes a report on an awkward name.

For direct-to-printer output, zpl(value, kind, gs1=False) returns a Zebra ZPL barcode field (code128, gs1-128, ean13, upca, qr, datamatrix, …), and zpl_label(...) wraps fields into a complete ^XA…^XZ label — the raw-ZPL path for networked label printers, alongside the SVG/PNG helpers for the PDF/browser path.

These require the segno, python-barcode and ppf-datamatrix packages (listed in requirements.txt).

For customer-facing documents (invoices, statements, etc.), the |date and |datetime filters use the customer’s language preference instead of the current user’s. This ensures customers receive documents formatted in their own locale.

Language priority for date formatting:

PrioritySourceDescription
1Contact’s parent languageParent company’s language preference
2Contact’s languageThe customer’s own language setting
3Current user’s languageLogged-in user’s language
4DefaultMM/DD/YYYY for emails, DD/MM/YYYY for reports

This applies to:

  • PDF reports — when contact_id is provided or auto-detected from the model’s contact field
  • Email templates — when the model instance has a contact field

The shared utility get_contact_language_settings(env, contact_id) in fullfinity/engine/jinja_filters.py implements this lookup.

The same resolved language also decides the document’s direction, published to layouts as document_dir (ltr / rtl). Declaring it is not cosmetic: a document whose strings are translated but whose layout is not mirrored reads as broken rather than as English, because the letterhead, table column order and every indent stay on their left-to-right sides while the text runs the other way.

Write your report CSS physically and mirror it under [dir="rtl"]. The PDF engine implements no logical box properties at all — margin-inline-start, padding-inline-start, border-inline-start and float: inline-start are parsed and then dropped, so a stylesheet written the modern way silently produces a document with no indents and no borders, in both directions, with no error anywhere:

/* Correct: physical base, mirrored explicitly. */
.terms { border-left: 3px solid #333; padding-left: 1rem; }
[dir="rtl"] .terms { border-left: 0; border-right: 3px solid #333; padding-left: 0; padding-right: 1rem; }

Two rules when you add a mirrored pair: always clear the side you flip away from (setting only the new side leaves both applied, which reads as a doubled indent), and match the !important of whatever you are overriding — an important declaration beats a non-important one at any specificity, so a missing !important fails silently.

text-align is the exception: start and end are resolved against the direction, so prefer them over left/right and no override is needed. The built-in utilities already follow all of this — text-start/text-end, ps-*/pe-*, ms-*/me-*, border-start/border-end, float-start/float-end mirror themselves.

Page margin boxes (@bottom-left, @bottom-right) are physical page corners, so a running footer stays in the corner you name it in. Pick the corner from the direction:

@bottom-{{ 'right' if document_dir == 'rtl' else 'left' }} { content: element(pageFooter); }

The Language model defines format settings that the filters use:

FieldDefaultDescription
date_formatDD/MM/YYYYDate format (dayjs/moment format tokens)
time_formatHH:mmTime format (24-hour by default)

Format tokens:

  • DD - Day with zero padding (01-31)
  • MM - Month with zero padding (01-12)
  • YYYY - 4-digit year
  • YY - 2-digit year
  • HH - Hour in 24-hour format (00-23)
  • hh - Hour in 12-hour format (01-12)
  • mm - Minutes (00-59)
  • A - AM/PM uppercase
<table class="fy-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th class="fy-text-right">Debit</th>
<th class="fy-text-right">Credit</th>
</tr>
</thead>
<tbody>
{% for entry in report_data.entries %}
<tr>
<td>{{ entry.date | date }}</td>
<td>{{ entry.description }}</td>
<td class="fy-text-right">{{ entry.debit | money(show_symbol=False) if entry.debit else "-" }}</td>
<td class="fy-text-right">{{ entry.credit | money(show_symbol=False) if entry.credit else "-" }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="fy-font-bold">
<td colspan="2">Total</td>
<td class="fy-text-right">{{ report_data.total_debit | money(show_symbol=False) }}</td>
<td class="fy-text-right">{{ report_data.total_credit | money(show_symbol=False) }}</td>
</tr>
</tfoot>
</table>

The /api/generate_report/ endpoint accepts:

ParameterTypeRequiredDescription
modelStringYesModel name
report_idIntegerYesReportAction ID
instance_idsArrayNo*Record IDs for record-based reports
report_dataObjectNo*Computed data for wizard-based reports
contact_idIntegerNoContact ID for customer-facing language formatting

*Either instance_ids or report_data must be provided.

The contact_id parameter controls which language is used for |date and |datetime filters. The system resolves the contact in this order:

  1. Explicit contact_id in the API request (top-level parameter)
  2. contact_id key inside report_data dict (for wizard-based reports)
  3. Auto-detection from the model’s contact field (for record-based reports — checks if the model has a contact ManyToOne field and reads the first instance’s contact)
  4. User’s language — fallback when no contact is found

Reports include a Bootstrap-like CSS utility framework at static/css/report.css. This provides common styling utilities optimized for WeasyPrint — the usual d-flex, text-*, fw-*, m*/p* spacing, col-* grid, table, card, alert, badge, border*, bg-*, etc. (Bootstrap 5 names. A few non-standard extensions exist — fs-7/fs-8 and max-w-* — and are labelled in the source.)

Utilities carry !important, exactly as Bootstrap’s do — that is what makes them usable inside a component. A <td class="ps-3"> must indent even though .table-sm > tbody > tr > td sets the cell’s padding, and a <td class="border-0"> must lose its border even though the table rule draws one; without !important the more specific component rule wins and the utility silently does nothing. (This is why the financial statements print with indented account rows under their section headings.)

A layout’s letterhead must repeat on every page of a multi-page document, and an ordinary <header> block does not — paged media lays a block out once, so page 2 of a long sales order would carry no company name or address at all. Wrap the letterhead and the body in a .page-frame: a thead repeats on every page by definition, at its own natural height.

<table class="page-frame">
<thead><tr><td>
<header class="report-header">…logo, company name, address…</header>
</td></tr></thead>
<tbody><tr><td>
<main>{% block content %}{% endblock %}</main>
</td></tr></tbody>
</table>

The frame contributes no padding, borders or spacing of its own, so page one looks exactly as it would without it. Footers work differently and already repeat — they are running elements (position: running(name)) pulled into an @bottom-* margin box. Don’t reach for the symmetric @top-* trick for the header: a margin box is only as tall as the page margin, so you would have to reserve a fixed band in millimetres, and a real letterhead varies from 15mm to 43mm depending on the company’s logo, address and contact details. Reserve for the tall case and short headers get a visible void above the logo; reserve for the short case and tall ones are clipped rather than reflowed.

report.css is the base layer; you never edit it. Override it from your layout or report template <style> block — your rules win with no !important needed for its component rules (.table, .report-header, .card, …) and its tokens.

The reason is cascade origin, not specificity: the framework hands report.css to the PDF engine as a user stylesheet, while your layout’s <style> is author origin, and author beats user at every specificity. So a bare tr { … } in your layout outranks even a long .page-frame > thead > tr selector in the base layer.

Two consequences worth knowing:

  • Utility classes are the exception — they are declared !important, and a user-origin !important outranks author, so a plain <style> rule cannot re-style an element carrying one. Change the element’s class in the template, or re-declare the utility with !important if you really mean to redefine it globally.
  • Be careful with bare element selectors. tr { page-break-inside: avoid } in a layout is a reasonable way to keep report lines intact — but it also matches the page frame’s own rows, which is why the base layer has to force those back with !important. Prefer scoping such rules (.table tr { … }).

Colours are driven by Bootstrap-style CSS variables, so re-theming is usually a matter of overriding a few tokens rather than many rules:

<!-- in a custom layout template's <style> -->
<style>
:root {
--bs-border-color: #c8c8c8; /* all dividers (header, sections, table, footer) */
--bs-primary: #6f42c1; /* accents/badges/links */
--bs-body-color: #1a1a1a; /* body text */
}
.report-header { border-bottom-width: 1px; } /* or override any rule directly */
</style>
TokenDrivesDefault
--bs-primaryAccents, badges, table-header rulethe company brand colour (--brand-color), else #0d6efd
--bs-border-colorEvery divider (header rule, section underlines, table borders, page footer rule)#dee2e6
--bs-body-colorBody text#212529
--bs-secondary-colorMuted/secondary text#6c757d
--bs-tertiary-bgSubtle backgrounds (table headers)#f8f9fa
--bs-success / --bs-danger / --bs-warning / --bs-infoAlerts, status badgesBS5 defaults

--bs-primary follows each company’s configured brand colour automatically, so accents are themed per company out of the box.

The grid uses display: inline-block (not flexbox) for reliable WeasyPrint rendering:

<div class="row">
<div class="col-6">
<p><strong>Company:</strong> {{ company.name }}</p>
</div>
<div class="col-6 text-end">
<p><strong>Date:</strong> {{ date | date }}</p>
</div>
</div>

Available columns: .col-1 through .col-12, .col-auto

A role says what a piece of markup is, so a layout can restyle every instance of it at once. Prefer a role over utilities whenever one exists — a <h6 class="section-heading"> re-themes with the company’s layout, a <p class="fw-bold text-muted text-uppercase"> never will. Roles read their appearance from the tokens in the table further down.

RoleMarkupWhat it is
.field<dl class="field"><dt>Label</dt><dd>Value</dd></dl>A label → value pair, stacked: label above, value below. Muted label, emphasized value. This is the default arrangement and what every shipped report uses — a scanned document is read by hunting for a label, and a stacked pair puts every value on its own predictable line.
.field.inlinesame, plus inlineFolds the pair onto one line as Label: value. Reserve it for somewhere genuinely tight (a dense footer, a repeated cell); it reads worse in a document body, which is why no shipped report uses it. The colon is injected by CSS — don’t put one in the <dt>.
.section-heading<h6 class="section-heading">Customer</h6>A heading above a block (Customer, Terms, Addresses).
.dl-horizontal / .dl-inlineon a <dl>Older key-value list arrangements; prefer .field.
.report-header .report-logo .report-title .report-subtitlein a layoutLetterhead parts. Owned by the layout, not by a report body.
.report-watermarkDRAFT/COPY overlay, injected automatically by the engine. Never write it yourself.

Most report tables are a ranked list rather than a flat grid: sections own lines, lines roll into subtotals, subtotals into a total. Declare the rank on the row and the stylesheet renders it — including the rules, which are earned by rank, never drawn under every row.

RolePut it onEffect
.line-tablethe <table>, beside .tableRules come only from the roles below; quiet header band; figures right-aligned and non-wrapping.
.sectiona <tr>Heading band inside the table (Assets, Revenue, a sales-order section). Uses the --heading-* tokens.
.subsectiona <tr>Labelled group under a section (Current Assets, Earnings).
.totala <tr>Subtotal — hairline above, semibold.
.granda <tr>Total — full-strength rule above, bold.
.grand.closinga <tr>…and the double underline that closes a statement.
.level-1 .level-2 .level-3a <td>/<th>Depth. One step is --line-indent-1/-2/-3.
<table class="table line-table">
<tr class="section"> <td>{{ t('Assets') }}</td><td></td></tr>
<tr class="subsection"> <td class="level-1">{{ t('Current Assets') }}</td><td></td></tr>
<tr> <td class="level-2">1010 - Bank</td><td>{{ x | money }}</td></tr>
<tr class="total"> <td class="level-1">{{ t('Total Current Assets') }}</td><td>{{ t1 | money }}</td></tr>
<tr class="grand closing"><td>{{ t('Total Assets') }}</td><td>{{ t2 | money }}</td></tr>
</table>

The row roles work in any .table, with or without .line-table — add .total to a payslip or purchase-order total row and it picks up the rule. .line-table is the separate opt-in for tables where a per-row hairline would drown the ranks. A grid of independent records (an aging report, a ledger listing) is usually better without it.

Everything report.css defines. Bootstrap 5 names throughout unless marked ✳ (a Fullfinity extension). Utilities are !important; roles and components are not.

GroupClasses
Text align.text-start .text-center .text-end .text-justify
Text transform.text-lowercase .text-uppercase .text-capitalize
Text wrap.text-wrap .text-nowrap .text-break .text-truncate
Text decoration.text-decoration-none .text-decoration-underline
Font weight.fw-light .fw-normal .fw-medium .fw-semibold .fw-bold
Font style.fst-italic .fst-normal (aliases .font-italic .font-normal)
Font size.small .h1 .fs-7.fs-8
Line height.lh-1 .lh-sm .lh-base .lh-lg
Letter spacing.tracking-tighter .tracking-tight .tracking-normal .tracking-wide .tracking-wider .tracking-widest
Text colour.text-primary .text-secondary .text-success .text-danger .text-warning .text-info .text-dark .text-muted .text-black .text-white
Background.bg-primary .bg-secondary .bg-success .bg-danger .bg-warning .bg-info .bg-light .bg-dark .bg-white .bg-transparent
Margin.m-0.m-5 .m-auto, and the same scale for .mt- .mb- .ms- .me- .mx- .my-
Padding.p-0.p-5, and the same scale for .pt- .pb- .ps- .pe- .px- .py-
Scale0 = 0, 1 = .25rem, 2 = .5rem, 3 = 1rem, 4 = 1.5rem, 5 = 3rem
Display.d-none .d-inline .d-inline-block .d-block .d-table .d-table-row .d-table-cell .d-flex .d-inline-flex
Flex direction/wrap.flex-row .flex-row-reverse .flex-column .flex-column-reverse .flex-wrap .flex-nowrap .flex-wrap-reverse
Flex child.flex-grow-0 .flex-grow-1 .flex-shrink-0 .flex-shrink-1 .flex-fill
Justify.justify-content-start .justify-content-end .justify-content-center .justify-content-between .justify-content-around .justify-content-evenly
Align items.align-items-start .align-items-end .align-items-center .align-items-baseline .align-items-stretch
Align self.align-self-start .align-self-end .align-self-center .align-self-baseline .align-self-stretch
Gap.gap-0.gap-5 — but see the WeasyPrint note below; prefer margins
Grid.row, .col .col-auto .col-1.col-12
Width/height.w-25 .w-50 .w-75 .w-100 .w-auto; .h-* (same); .mw-100 .mh-100
Min width.min-w-0 .min-w-25 .min-w-50 .min-w-75 .min-w-100
Max width.max-w-25 .max-w-50 .max-w-75 .max-w-100; .max-w-sm (200px) .max-w-md (400px) .max-w-lg (600px) .max-w-xl (800px)
Borders.border .border-0 .border-top .border-end .border-bottom .border-start .border-top-0 .border-end-0 .border-bottom-0 .border-start-0 .border-1 .border-2 .border-3
Border colour.border-primary .border-secondary .border-success .border-danger .border-warning .border-info .border-light .border-dark
Radius.rounded .rounded-0 .rounded-1 .rounded-2 .rounded-3 .rounded-circle .rounded-pill
Position.position-static .position-relative .position-absolute .position-fixed; .top-0/50/100 and the same scale for .bottom- .start- .end-
Float.float-start .float-end .float-none .clearfix
Overflow.overflow-auto .overflow-hidden .overflow-visible .overflow-scroll
Visibility.visible .invisible
Vertical align.align-baseline .align-top .align-middle .align-bottom .align-text-top .align-text-bottom
Opacity.opacity-0 .opacity-25 .opacity-50 .opacity-75 .opacity-100
Object fit.object-fit-contain .object-fit-cover .object-fit-fill .object-fit-scale-down .object-fit-none
Images.img-fluid .img-thumbnail
Lists.list-unstyled .list-inline .list-inline-item
Page breaks.page-break-before .page-break-after .page-break-inside-avoid .break-before .break-after .break-inside-avoid
Tables.table .table-sm .table-bordered .table-borderless .table-striped .table-hover
Table row tint.table-primary .table-secondary .table-success .table-danger .table-warning .table-info .table-light .table-dark
Line-table roles.line-table .section .subsection .total .grand .closing .level-1 .level-2 .level-3 — see above
Cards.card .card-body .card-header .card-footer .card-title .card-text; .card-primary .card-success .card-danger .card-warning
Alerts.alert .alert-primary .alert-secondary .alert-success .alert-danger .alert-warning .alert-info .alert-light .alert-dark
Badges.badge .badge-primary .badge-secondary .badge-success .badge-danger .badge-warning .badge-info
Blocks.info-box (tinted box) .summary-row (emphasized row) .notes .terms .bank-details .signature-line .amount (monospace right-aligned figure) .divider (rule with centered text) .watermark
Page frame.page-frame — a layout wraps its letterhead + body in this so the letterhead repeats on every page. See below.
Quotes.blockquote .blockquote-footer
Ruleshr, and .thick ✳ on an <hr> for a 2px rule
Icons.bi .bi-<name> — Bootstrap Icons, e.g. <i class="bi bi-telephone"></i>. Never inline SVG.

Every class here is pinned by core/tests/test_report_css_documented.py: adding one to report.css without adding it to this table fails the test.

Boxed content sections with optional header/footer:

<div class="card">
<div class="card-header">Payment Details</div>
<div class="card-body">
<p class="card-text">Bank: Example Bank</p>
</div>
</div>

Variants: .card-primary, .card-success, .card-danger, .card-warning

Contextual feedback messages:

<div class="alert alert-warning">
Payment is overdue by 30 days.
</div>

Variants: .alert-primary, .alert-success, .alert-danger, .alert-warning, .alert-info

For key-value pairs (common in invoices):

<dl class="dl-horizontal">
<dt>Invoice No.</dt>
<dd>INV-2025-001</dd>
<dt>Date</dt>
<dd>{{ date | date }}</dd>
</dl>

Styles: .dl-horizontal (side-by-side), .dl-inline (inline flow)

ComponentDescription
.bank-detailsStyled box for bank/payment info
.termsSmall text block for terms & conditions
.blockquoteQuoted/legal text with left border
.dividerHorizontal line with centered text
.report-watermarkLarge rotated background text (DRAFT, COPY) — injected automatically; see Watermarks
.signature-lineLine for signatures
.notesHighlighted notes section
  • Flexbox: Limited support - use tables or inline-block for layouts
  • CSS Grid: Not supported - use inline-block-based .row/.col-* system
  • CSS Variables: Supported
  • Web Fonts: Supported via @font-face

Optionally set filename_pattern on the ReportAction to a Jinja template evaluated against the record:

{ "data_type": "ReportAction", "identifier": "sale_order_print",
"filename_pattern": "Sale Order {{ name }}" }

SO0042 downloads as Sale Order SO0042.pdf. The extension is derived from report_type.

filename_pattern is optional. The report context always includes a computed display_name for the record, so when the pattern is omitted — or renders empty because the model has no stored name field — the filename falls back to the record’s display_name (then to the report name). Only set a pattern when you want a filename that differs from the record’s display name (e.g. Payslip {{ employee.display_name }} {{ date_to }}). The pattern is rendered against the full serialized record (every field and its relations, independent of what the report body shows) and can use the report filters — {{ date_from | date }}, {{ total | money }}, t(...). It is used only for single-record prints; bulk prints (one combined PDF) fall back to the report name. Filenames are sanitised (path separators and illegal characters removed).

A record-based report auto-serializes the record and exactly the relation paths its template references — the engine statically reads the template (resolving {% for %} loop variables and {% set %} aliases) and fetches only those paths, at whatever depth they go. So {{ line.product_variant.category.name }} inside a {% for line in lines %} loop just works: lines → product_variant → category is fetched for you. You don’t declare relations anywhere; the template is the spec. Relations the template never touches aren’t fetched, so a report only pays for the data it shows.

Only two things fall outside this: values that are computed rather than stored (see data_method below), and genuinely dynamic access like {{ record[some_var] }}, where the attribute name isn’t known statically — resolve those in a data_method or use direct attribute access in the template.

When a report needs extra computed data (custom totals, aggregates, values derived in Python) — not just more relations, which are fetched automatically — name a method on the model with data_method:

class SaleOrder(Model):
async def build_report_data(self):
await self.fetch_related("lines")
return {
"weight_total": sum(l.weight for l in self.lines),
"pallet_count": math.ceil(len(self.lines) / 12),
}
{ "data_type": "ReportAction", "identifier": "sale_order_print",
"data_method": "build_report_data" }

The returned dict is merged on top of the serialized record, so {{ weight_total }} and {{ pallet_count }} are available in the template alongside the record’s own fields. The method may be sync or async and must return a dict. (Wizard reports compute their data in _get_report_data() instead — see Wizard-Based Reports.)

Set store_copy: true to keep an immutable copy of a generated document on the record. Once stored, reprinting returns the same file — important for legal documents like invoices, where a reprint must reproduce exactly what the customer received even if the record or template later changes. The copy is saved as an Attachment linked to the record.

This applies to single-record reports only. Because the engine cannot know when a record becomes “final”, the owning module must invalidate the stored copy when the record changes (e.g. an invoice reset to draft), otherwise a stale copy would be reprinted:

from fullfinity.engine.reports import invalidate_stored_report
# e.g. when resetting a document to draft
await invalidate_stored_report("FinancialDocument", document.id, report_action.id)

Set draft_watermark: true to show a DRAFT watermark across every page when the record’s state is Draft. For custom watermark text (e.g. PRO-FORMA, COPY, or a model-specific draft state), expose a report_watermark field on the model — its value is used verbatim and always takes precedence:

class SaleOrder(Model):
report_watermark = Char(store=False, calculate="calc_report_watermark")
@Model.calculate("state")
async def calc_report_watermark(self):
self.report_watermark = "QUOTATION" if self.state == "Quote" else ""

The watermark is injected by the engine and styled by .report-watermark in report.css, so it works for every layout (including custom ones) without template edits.

  1. Anchor every structural node for extensibility - Other modules target elements by data-anchor/#id or by field:<chain> (never class/content/position); the base you ship must provide those handles (enforced by check --only templates)
  2. Keep templates focused on presentation - Compute data in Python, render in Jinja2
  3. Use record-based for single-record documents - Invoices, quotes, orders
  4. Use wizard-based for aggregated data - Financial statements, aging reports
  5. Handle empty data gracefully - Check for empty lists in templates
  6. Pre-fetch relations - Use prefetch_related() before building report data
  7. Use |money for currency formatting - Instead of manual {{ symbol }} {{ "%.2f"|format(amount) }}, use {{ amount | money }} for proper symbol positioning and rounding
  8. Pass contact_id for customer-facing reports - Ensures dates are formatted in the customer’s locale, not the logged-in user’s