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": "profit_loss_report", "name": "Profit & Loss", "model": "ReportWizard", "template": "profit_loss_template", "paper_format": "paper_a4", "orientation": "Landscape", "layout_template": "layout_minimal", "report_type": "PDF"}| 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, HTML, or Text |
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.
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
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:
{# modules/core/templates/base_layout.jinja #}<!DOCTYPE html><html><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”Layout templates define custom footers using CSS position: fixed elements. This approach provides full HTML support and reliable full-width layouts.
How Page Footers Work
Section titled “How Page Footers Work”WeasyPrint supports position: fixed elements that repeat on every page:
- Create a footer element with
position: fixedand negativebottompositioning - Position it within the page’s bottom margin area
- Use table layout for reliable left/right alignment
- Use CSS
::afterpseudo-elements withcounter(page)for page numbers
Defining a Footer
Section titled “Defining a Footer”Add CSS styles and HTML footer element to 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; }
/* Fixed footer appears on every page */ .page-footer { position: fixed; /* Dynamic position: evenly splits margin between gap-from-content and clearance-from-page-edge */ bottom: -{{ ((paper_format.margin_bottom + (paper_format.footer_height or 12)) / 2) | round | int if paper_format else 16 }}mm; left: 0; right: 0; height: {{ paper_format.footer_height if paper_format else 12 }}mm; font-size: 8pt; color: #6c757d; border-top: 1px solid #dee2e6; padding-top: 2mm; }
.page-footer table { width: 100%; border-collapse: collapse; }
.page-footer td { padding: 0; vertical-align: top; }
.page-footer-left { text-align: left; } .page-footer-left strong { color: #495057; } .page-footer-right { text-align: right; }
.page-footer .page-number::after { content: counter(page) " of " counter(pages); }</style>
<!-- Add before </body> --><div class="page-footer"> <table> <tr> <td class="page-footer-left"> <strong>{{ company.name }}</strong> {% if company.tax_number %} • Tax ID: {{ company.tax_number }}{% endif %} </td> <td class="page-footer-right"> {% if company.website %}{{ company.website }} • {% endif %} <span class="page-number"></span> </td> </tr> </table></div>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);}Footer Features
Section titled “Footer Features”The position: fixed footer approach supports:
- Full HTML/CSS - Use any HTML elements including images
- Table layout - Reliable left/right alignment without gaps
- Dynamic content - Jinja variables for company info
- Page counters - CSS counters for page numbers
- Full styling - Fonts, colors, borders, padding
Built-in Layout Footers
Section titled “Built-in Layout Footers”| Layout | Footer Style |
|---|---|
| Standard | Two-column: company info left, website + page numbers right |
| Boxed | Two-column, matches boxed aesthetic |
| Classic Left | Traditional serif typography, two-column |
| Minimal | Lighter colors (#adb5bd), simplified content |
| Modern Centered | Two-column with centered header aesthetic |
Footer Positioning
Section titled “Footer Positioning”The footer is positioned in the bottom margin area using dynamic values from PaperFormat:
bottom: -{{ ((paper_format.margin_bottom + (paper_format.footer_height or 12)) / 2) | round | int }}mm;height: {{ paper_format.footer_height if paper_format else 12 }}mm;The formula (margin_bottom + footer_height) / 2 evenly distributes the margin space between the gap from content and clearance from page edge.
Configurable fields:
| Field | Default | Description |
|---|---|---|
margin_bottom | 20mm | Bottom page margin |
footer_height | 12mm | Height of the footer element |
Example calculations:
| Bottom Margin | Footer Height | Position | Gap | Clearance |
|---|---|---|---|---|
| 20mm | 12mm | -16mm | 4mm | 4mm |
| 20mm | 18mm | -19mm | 1mm | 1mm |
| 25mm | 18mm | -22mm | 4mm | 3mm |
To add more content to the footer (e.g., bank details, legal text), increase footer_height in your PaperFormat.
Report Templates
Section titled “Report Templates”Report templates use standard HTML with CSS classes. No special attributes are required - extensions can target elements using CSS selectors, content matching, or XPath:
{# modules/invoicing/templates/invoice_template.jinja #}<div class="invoice"> <h1 class="invoice-header">Invoice {{ name }}</h1>
<div class="customer-info"> <p>{{ contact.name }}</p> <p>{{ contact.email }}</p> </div>
<table class="line-items"> <thead> <tr> <th>Product</th> <th>Qty</th> <th>Price</th> </tr> </thead> <tbody> {% for line in lines %} <tr> <td>{{ line.product.name }}</td> <td>{{ line.quantity }}</td> <td>{{ line.price }}</td> </tr> {% endfor %} </tbody> </table>
<div class="totals"> <p>Total: {{ total }}</p> </div></div>Register as a Report:
{ "data_type": "ReportTemplate", "identifier": "invoice_template", "name": "Invoice", "content": "invoice_template.jinja", "template_type": "Report"}Extension Templates
Section titled “Extension Templates”Extensions use special directives to modify the base template. Targets can be CSS selectors, content matches, or XPath expressions:
{# modules/crm/templates/invoice_crm_extension.jinja #}{% inherit "invoice_template" %}
{# Target by CSS selector #}{% add after=".customer-info" %}<div class="crm-data"> <p>Sales Rep: {{ sales_rep.name }}</p> <p>Lead Score: {{ lead_score }}</p></div>{% endadd %}
{# Target by content (finds element containing this text) #}{% replace name="{{ contact.name }}" %}<p>{{ contact.display_name }} ({{ contact.company }})</p>{% endreplace %}
{# Target by XPath #}{% remove name="xpath://div[@class='old-section']" %}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 |
Target Selector Types
Section titled “Target Selector Types”Extensions can target elements using three selector types:
| Type | Syntax | Example |
|---|---|---|
| CSS Selector | Starts with . # [ or prefix with css: | .customer-info, css:table.items thead |
| Content Match | Default, or prefix with content: | {{ contact.name }}, content:Invoice |
| XPath | Prefix with xpath: | xpath://div[@class='header'] |
Content Matching with Index
Section titled “Content Matching with Index”When multiple elements contain the same content, use an index suffix to target a specific occurrence:
{# Template has "{{ total }}" appearing multiple times #}{% add after="{{ total }}[0]" %}...{% endadd %} {# First occurrence #}{% add after="{{ total }}[1]" %}...{% endadd %} {# Second occurrence #}{% add after="{{ total }}" %}...{% endadd %} {# Defaults to [0] #}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.
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 settings
Wizard-Based Reports
Section titled “Wizard-Based Reports”report_data- Dictionary returned by wizardcompany- Current company informationpaper_format- Paper format settings
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 specific currency (dict with symbol, position, rounding) #}{{ amount | money(currency=custom_currency) }}The filter automatically:
- Uses the company currency’s
roundingsetting for decimal places (e.g., 0.01 = 2 decimals, 1 = 0 decimals) - Adds thousands separators
- Positions the currency symbol based on
position(Before/After)
Available in both PDF reports and email templates.
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") }}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.
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.
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
Common Utilities
Section titled “Common Utilities”| Utility | Description |
|---|---|
.text-start, .text-center, .text-end | Text alignment |
.text-truncate | Ellipsis for overflow text |
.fw-bold, .fw-normal, .fw-semibold | Font weight |
.lh-1, .lh-sm, .lh-base, .lh-lg | Line height |
.mb-1 through .mb-5 | Margin bottom |
.mt-1 through .mt-5 | Margin top |
.p-1 through .p-5 | Padding |
.bg-light, .bg-dark | Background colors |
.opacity-25, .opacity-50, .opacity-75 | Opacity |
.table, .table-bordered, .table-sm | Table styling |
.max-w-sm, .max-w-md, .max-w-lg | Max width (200/400/600px) |
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 |
.watermark | Large rotated background text (DRAFT, COPY) |
.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
Best Practices
Section titled “Best Practices”- Use CSS classes for extensibility - Other modules can target elements by class, content, or XPath
- 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