Reports
Reports generate PDF documents from Jinja2 templates using WeasyPrint. The report system supports multi-module template inheritance similar to views and models.
PDF Generation
Section titled “PDF Generation”Reports use WeasyPrint for high-performance PDF generation with:
- Native CSS Paged Media - Full support for
@pagerules, 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
Available Fonts
Section titled “Available Fonts”The following fonts are available for report templates:
| Font | Style | Weights |
|---|---|---|
| Inter | Sans-serif | 400, 500, 600, 700 |
| Roboto | Sans-serif | 400, 500, 700 |
| Open Sans | Sans-serif | 400, 600, 700 |
| Lato | Sans-serif | 400, 700 |
| Source Sans Pro | Sans-serif | 400, 600, 700 |
| Poppins | Sans-serif | 400, 500, 600, 700 |
| Libre Baskerville | Serif | 400, 700 |
| Merriweather | Serif | 400, 700 |
| System Default | System fonts | - |
Configure the report font in Settings → Configuration → Report Font.
Report Modes
Section titled “Report Modes”- Record-based reports - Render templates for existing database records (invoices, orders, etc.)
- Wizard-based reports - Compute aggregated data via a wizard (financial statements, aging reports)
Template Types
Section titled “Template Types”| Type | Purpose | Example |
|---|---|---|
| Layout | Header/footer wrapper with {% block content %} | Company branding, page structure |
| Report | Actual report content | Invoice, Aged Receivables |
Report Components
Section titled “Report Components”ReportTemplate
Section titled “ReportTemplate”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)ReportAction
Section titled “ReportAction”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}| Field | Description |
|---|---|
template | Report template (content) |
paper_format | Paper format for dimensions |
orientation | Optional orientation override (Portrait/Landscape) |
layout_template | Optional layout override (defaults to company Configuration) |
report_type | Output format: PDF (default) or HTML. HTML returns the rendered HTML directly (browser preview / portal embedding) instead of a PDF |
filename_pattern | Optional 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_method | Optional 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 |
visible | Optional Q-expression naming which records the report applies to. Omitted, the report is offered for every record — see Record conditions |
store_copy | When true, keep an immutable copy of the generated document on the record; reprinting returns the same file. Single-record only — see Stored copies |
draft_watermark | When 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.
Record conditions (visible)
Section titled “Record conditions (visible)”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:
| Surface | Behaviour |
|---|---|
| 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 record | Every 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.
PaperFormat
Section titled “PaperFormat”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}| Field | Default | Description |
|---|---|---|
paper_size | A4 | Page size (A4, Letter, Legal, etc.) |
orientation | Portrait | Default page orientation |
margin_* | varies | Page margins in mm |
footer_height | 12 | Height 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.
Template Inheritance
Section titled “Template Inheritance”Multiple modules can extend the same report template. Extensions are applied in module dependency order.
Layout Templates
Section titled “Layout Templates”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 }}">| Global | Example | Meaning |
|---|---|---|
document_lang | ar | The render language’s code — drives font selection and hyphenation |
document_dir | rtl | ltr 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"}PDF Page Footers
Section titled “PDF Page Footers”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? Afixedfooter 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.
Defining a Footer
Section titled “Defining a Footer”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 %} • Tax ID: {{ company.tax_number }}{% endif %}</div> <div class="page-footer-right">{% if company.website %}{{ company.website }} • {% endif %}<span class="page-number"></span></div>
...header / {% block content %} ...</body>CSS Paged Media gotchas (read before customising a footer)
Section titled “CSS Paged Media gotchas (read before customising a footer)”| Gotcha | Effect | Do this instead |
|---|---|---|
position: fixed footer | Stacks/blurs on bulk print (one per concatenated record) | position: running(name) + @page { @bottom-* { content: element(name) } } |
@bottom-center only | Shrink-wraps → left/right text bunched in the middle | Split into @bottom-left + @bottom-right |
Footer declared late in <body> | Missing on a record’s earlier pages | Declare it as the first element of <body> |
| Full-width divider line | Margin boxes can’t span a continuous rule | A 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.
Footer Variables
Section titled “Footer Variables”The footer template has access to the company context:
| Variable | Description |
|---|---|
company.name | Company name |
company.street | Street address |
company.city | City |
company.zip | Postal/ZIP code |
company.phone | Phone number |
company.email | Email address |
company.website | Website URL |
company.tax_number | Tax/VAT number |
company.font_family | Selected font family |
Page Number CSS Counters
Section titled “Page Number CSS Counters”Use CSS counters with ::after pseudo-elements for page numbers:
| Counter | Description |
|---|---|
counter(page) | Current page number |
counter(pages) | Total number of pages |
Example:
.page-number::after { content: counter(page) " of " counter(pages);}Built-in Layout Footers
Section titled “Built-in Layout Footers”All built-in letterhead layouts use the running-element footer above: company info bottom-left, website + page numbers bottom-right.
| Layout | Footer Style |
|---|---|
| Standard | Company info left, website + page numbers right |
| Boxed | Same, matches boxed aesthetic |
| Classic Left | Traditional serif typography |
| Minimal | Lighter colors (#adb5bd), simplified content |
| Modern Centered | Centered 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
Section titled “Report Templates”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.)
Extension Templates
Section titled “Extension Templates”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"}Extension Directives
Section titled “Extension Directives”| Directive | Syntax | Description |
|---|---|---|
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.
Attribute changes
Section titled “Attribute changes”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" %}Target Selector Types
Section titled “Target Selector Types”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:
| Type | Syntax | Example | When |
|---|---|---|---|
| Anchor / id | [data-anchor=name] or #id | [data-anchor=inv_totals] | Any structural node the base anchored |
| Field binding | field:<chain>[@<anchor>] | field:line.price, field:total@inv_totals | The 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.
Multi-Module Example
Section titled “Multi-Module Example”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.
Making a report extensible
Section titled “Making a report extensible”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 adata-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.
Extending emails
Section titled “Extending emails”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.
Record-Based Reports
Section titled “Record-Based Reports”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.
Wizard-Based Reports
Section titled “Wizard-Based Reports”For reports requiring user input and computed data:
Wizard Model
Section titled “Wizard Model”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.
Wizard Template
Section titled “Wizard Template”{# 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>Compilation Flow
Section titled “Compilation Flow”- Load layout - Company-selected layout from Configuration
- Load report template - From ReportAction
- Load extensions - All templates with
inherited_templatepointing to report - Sort by module order - Dependencies before dependents
- Apply operations - Process
add,replace,removedirectives - Insert into layout - Replace
{% block content %}with compiled report - Render with Jinja2 - Apply context data
- Generate PDF - WeasyPrint with CSS
Template Context
Section titled “Template Context”Record-Based Reports
Section titled “Record-Based Reports”- All record fields and prefetched relations
company- Current company informationpaper_format- Paper format settingsbase_url- The install’s public URL (scalar), for absolute links/QR back to the app or portal
Wizard-Based Reports
Section titled “Wizard-Based Reports”report_data- Dictionary returned by wizardcompany- Current company informationpaper_format- Paper format settingsbase_url- The install’s public URL (scalar), for absolute links/QR back to the app or portal
Company Context
Section titled “Company Context”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 #}Template Filters
Section titled “Template Filters”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.
Money Filter
Section titled “Money Filter”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
roundingsetting 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 reads1.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.
Date Filter
Section titled “Date Filter”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 #}Datetime Filter
Section titled “Datetime Filter”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") }}Translation (t)
Section titled “Translation (t)”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 Codes, Barcodes & Data Matrix
Section titled “QR Codes, Barcodes & Data Matrix”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).
Customer-Facing Language Priority
Section titled “Customer-Facing Language Priority”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:
| Priority | Source | Description |
|---|---|---|
| 1 | Contact’s parent language | Parent company’s language preference |
| 2 | Contact’s language | The customer’s own language setting |
| 3 | Current user’s language | Logged-in user’s language |
| 4 | Default | MM/DD/YYYY for emails, DD/MM/YYYY for reports |
This applies to:
- PDF reports — when
contact_idis provided or auto-detected from the model’scontactfield - Email templates — when the model instance has a
contactfield
The shared utility get_contact_language_settings(env, contact_id) in fullfinity/engine/jinja_filters.py implements this lookup.
Text Direction (right-to-left documents)
Section titled “Text Direction (right-to-left documents)”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); }Language Format Settings
Section titled “Language Format Settings”The Language model defines format settings that the filters use:
| Field | Default | Description |
|---|---|---|
date_format | DD/MM/YYYY | Date format (dayjs/moment format tokens) |
time_format | HH:mm | Time format (24-hour by default) |
Format tokens:
DD- Day with zero padding (01-31)MM- Month with zero padding (01-12)YYYY- 4-digit yearYY- 2-digit yearHH- Hour in 24-hour format (00-23)hh- Hour in 12-hour format (01-12)mm- Minutes (00-59)A- AM/PM uppercase
Example: Accounting Report
Section titled “Example: Accounting Report”<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>Report API
Section titled “Report API”The /api/generate_report/ endpoint accepts:
| Parameter | Type | Required | Description |
|---|---|---|---|
model | String | Yes | Model name |
report_id | Integer | Yes | ReportAction ID |
instance_ids | Array | No* | Record IDs for record-based reports |
report_data | Object | No* | Computed data for wizard-based reports |
contact_id | Integer | No | Contact ID for customer-facing language formatting |
*Either instance_ids or report_data must be provided.
Customer-Facing Language Detection
Section titled “Customer-Facing Language Detection”The contact_id parameter controls which language is used for |date and |datetime filters. The system resolves the contact in this order:
- Explicit
contact_idin the API request (top-level parameter) contact_idkey insidereport_datadict (for wizard-based reports)- Auto-detection from the model’s
contactfield (for record-based reports — checks if the model has acontactManyToOne field and reads the first instance’s contact) - User’s language — fallback when no contact is found
CSS Utilities
Section titled “CSS Utilities”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.)
Repeating the letterhead on every page
Section titled “Repeating the letterhead on every page”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.
Theming & Overriding Styles
Section titled “Theming & Overriding Styles”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!importantoutranks 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!importantif 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>| Token | Drives | Default |
|---|---|---|
--bs-primary | Accents, badges, table-header rule | the company brand colour (--brand-color), else #0d6efd |
--bs-border-color | Every divider (header rule, section underlines, table borders, page footer rule) | #dee2e6 |
--bs-body-color | Body text | #212529 |
--bs-secondary-color | Muted/secondary text | #6c757d |
--bs-tertiary-bg | Subtle backgrounds (table headers) | #f8f9fa |
--bs-success / --bs-danger / --bs-warning / --bs-info | Alerts, status badges | BS5 defaults |
--bs-primary follows each company’s configured brand colour automatically, so accents are themed per company out of the box.
Grid System
Section titled “Grid System”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
Semantic roles
Section titled “Semantic roles”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.
| Role | Markup | What 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.inline | same, plus inline | Folds 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-inline | on a <dl> | Older key-value list arrangements; prefer .field. |
.report-header .report-logo .report-title .report-subtitle | in a layout | Letterhead parts. Owned by the layout, not by a report body. |
.report-watermark | — | DRAFT/COPY overlay, injected automatically by the engine. Never write it yourself. |
Line tables — the row roles
Section titled “Line tables — the row roles”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.
| Role | Put it on | Effect |
|---|---|---|
.line-table | the <table>, beside .table | Rules come only from the roles below; quiet header band; figures right-aligned and non-wrapping. |
.section | a <tr> | Heading band inside the table (Assets, Revenue, a sales-order section). Uses the --heading-* tokens. |
.subsection | a <tr> | Labelled group under a section (Current Assets, Earnings). |
.total | a <tr> | Subtotal — hairline above, semibold. |
.grand | a <tr> | Total — full-strength rule above, bold. |
.grand.closing | a <tr> | …and the double underline that closes a statement. |
.level-1 .level-2 .level-3 | a <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.
Complete class list
Section titled “Complete class list”Everything report.css defines. Bootstrap 5 names throughout unless marked ✳ (a
Fullfinity extension). Utilities are !important; roles and components are not.
| Group | Classes |
|---|---|
| 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- |
| Scale | 0 = 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 |
| Rules | hr, 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
Alerts
Section titled “Alerts”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
Definition Lists
Section titled “Definition Lists”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)
Report Components
Section titled “Report Components”| Component | Description |
|---|---|
.bank-details | Styled box for bank/payment info |
.terms | Small text block for terms & conditions |
.blockquote | Quoted/legal text with left border |
.divider | Horizontal line with centered text |
.report-watermark | Large rotated background text (DRAFT, COPY) — injected automatically; see Watermarks |
.signature-line | Line for signatures |
.notes | Highlighted notes section |
WeasyPrint Limitations
Section titled “WeasyPrint Limitations”- 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
Dynamic Filenames
Section titled “Dynamic Filenames”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).
What the template can reach
Section titled “What the template can reach”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.
Computed Data (data_method)
Section titled “Computed Data (data_method)”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.)
Stored Copies (immutable documents)
Section titled “Stored Copies (immutable documents)”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 draftawait invalidate_stored_report("FinancialDocument", document.id, report_action.id)Watermarks
Section titled “Watermarks”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.
Best Practices
Section titled “Best Practices”- Anchor every structural node for extensibility - Other modules target elements by
data-anchor/#idor byfield:<chain>(never class/content/position); the base you ship must provide those handles (enforced bycheck --only templates) - Keep templates focused on presentation - Compute data in Python, render in Jinja2
- Use record-based for single-record documents - Invoices, quotes, orders
- Use wizard-based for aggregated data - Financial statements, aging reports
- Handle empty data gracefully - Check for empty lists in templates
- Pre-fetch relations - Use
prefetch_related()before building report data - Use
|moneyfor currency formatting - Instead of manual{{ symbol }} {{ "%.2f"|format(amount) }}, use{{ amount | money }}for proper symbol positioning and rounding - Pass
contact_idfor customer-facing reports - Ensures dates are formatted in the customer’s locale, not the logged-in user’s