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": "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"
}
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, 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.

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

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:

{# 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"
}

Layout templates define custom footers using CSS position: fixed elements. This approach provides full HTML support and reliable full-width layouts.

WeasyPrint supports position: fixed elements that repeat on every page:

  1. Create a footer element with position: fixed and negative bottom positioning
  2. Position it within the page’s bottom margin area
  3. Use table layout for reliable left/right alignment
  4. Use CSS ::after pseudo-elements with counter(page) for page numbers

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 %} &bull; Tax ID: {{ company.tax_number }}{% endif %}
</td>
<td class="page-footer-right">
{% if company.website %}{{ company.website }} &bull; {% endif %}
<span class="page-number"></span>
</td>
</tr>
</table>
</div>

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);
}

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
LayoutFooter Style
StandardTwo-column: company info left, website + page numbers right
BoxedTwo-column, matches boxed aesthetic
Classic LeftTraditional serif typography, two-column
MinimalLighter colors (#adb5bd), simplified content
Modern CenteredTwo-column with centered header aesthetic

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:

FieldDefaultDescription
margin_bottom20mmBottom page margin
footer_height12mmHeight of the footer element

Example calculations:

Bottom MarginFooter HeightPositionGapClearance
20mm12mm-16mm4mm4mm
20mm18mm-19mm1mm1mm
25mm18mm-22mm4mm3mm

To add more content to the footer (e.g., bank details, legal text), increase footer_height in your PaperFormat.

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"
}

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"
}
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

Extensions can target elements using three selector types:

TypeSyntaxExample
CSS SelectorStarts with . # [ or prefix with css:.customer-info, css:table.items thead
Content MatchDefault, or prefix with content:{{ contact.name }}, content:Invoice
XPathPrefix with xpath:xpath://div[@class='header']

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] #}

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.

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
  • report_data - Dictionary returned by wizard
  • company - Current company information
  • paper_format - Paper format settings

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 specific currency (dict with symbol, position, rounding) #}
{{ amount | money(currency=custom_currency) }}

The filter automatically:

  • Uses the company currency’s rounding setting 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.

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") }}

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 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 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

UtilityDescription
.text-start, .text-center, .text-endText alignment
.text-truncateEllipsis for overflow text
.fw-bold, .fw-normal, .fw-semiboldFont weight
.lh-1, .lh-sm, .lh-base, .lh-lgLine height
.mb-1 through .mb-5Margin bottom
.mt-1 through .mt-5Margin top
.p-1 through .p-5Padding
.bg-light, .bg-darkBackground colors
.opacity-25, .opacity-50, .opacity-75Opacity
.table, .table-bordered, .table-smTable styling
.max-w-sm, .max-w-md, .max-w-lgMax 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

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
.watermarkLarge rotated background text (DRAFT, COPY)
.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
  1. Use CSS classes for extensibility - Other modules can target elements by class, content, or XPath
  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