
Frappe Syntax Jinja
- 68 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Helps with ai & agent building tasks.
About
frappe-syntax-jinja is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- frappe-syntax-jinja
- AI & Agent Building
- AI-coding skill
Frappe Syntax Jinja by the numbers
- 68 all-time installs (skills.sh)
- Ranked #5,828 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-syntax-jinjaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 68 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Helps with ai & agent building tasks.
Files
Frappe Jinja Templates Syntax
Deterministic Jinja reference for Print Formats, Email Templates, Notification Templates, and Portal Pages in Frappe v14/v15/v16.
---
When to Use This Skill
USE when:
- Creating or modifying Print Formats (Jinja-based)
- Writing Email Templates with dynamic fields
- Building Portal Pages (
www/*.html) with Python controllers - Writing Notification Templates (system/email/SMS)
- Registering custom Jinja methods or filters via
hooks.py
DO NOT USE for:
- Report Print Formats — they use JavaScript templating (
{%= %}), NOT Jinja - Client Scripts — see
frappe-syntax-clientscripts - Server Scripts — see
frappe-syntax-serverscripts
---
Decision Tree: Which Template Type?
Need a printable document?
├─ YES → Is it for a Query/Script Report?
│ ├─ YES → Use JS Template ({%= %}), NOT Jinja
│ └─ NO → Use Jinja Print Format
└─ NO → Is it for email?
├─ YES → Is it triggered by workflow/notification?
│ ├─ YES → Notification Template (Jinja)
│ └─ NO → Email Template (Jinja)
└─ NO → Is it a web page?
├─ YES → Portal Page (www/*.html + .py controller)
└─ NO → frappe.render_template() for ad-hoc rendering---
Quick Reference: Jinja Syntax
| Syntax | Purpose | Example |
|---|---|---|
{{ }} | Output expression | {{ doc.name }} |
{% %} | Control statement | {% if doc.status == "Paid" %} |
{# #} | Comment | {# This is a comment #} |
{{ _("text") }} | Translation | {{ _("Invoice") }} |
| `{{ val \ | filter }}` | Filter |
CRITICAL: Jinja vs JS Template Syntax
| Aspect | Jinja (Print Formats) | JS Template (Report Print Formats) |
|---|---|---|
| Output | {{ expression }} | {%= expression %} |
| Code block | {% statement %} | {% js_code %} |
| Language | Python | JavaScript |
| Context | doc, frappe | data, filters |
NEVER use Jinja syntax in Report Print Formats. NEVER use `{%= %}` in standard Print Formats.
---
Context Objects by Template Type
Print Formats
| Object | Description |
|---|---|
doc | The document being printed (full Document object) |
frappe | Frappe module (whitelisted methods only) |
frappe.utils | Utility functions |
_() | Translation function |
doc.items, doc.taxes | Child table accessors (by fieldname) |
Email Templates
| Object | Description |
|---|---|
doc | The linked document (when triggered from a DocType) |
frappe | Frappe module (limited) |
_() | Translation function |
Notification Templates
| Object | Description |
|---|---|
doc | The document that triggered the notification |
frappe | Frappe module |
_() | Translation function |
Portal Pages (www/*.html)
| Object | Description |
|---|---|
frappe | Frappe module |
frappe.session.user | Current authenticated user |
frappe.form_dict | Query parameters from URL |
frappe.lang | Current language code |
| Custom context | Set via get_context(context) in .py controller |
Full details: references/context-objects.md---
Essential Methods (Whitelisted in Jinja)
Formatting: ALWAYS Use for Display
{# ALWAYS use get_formatted() for fields in Print Formats #}
{{ doc.get_formatted("posting_date") }}
{{ doc.get_formatted("grand_total") }}
{# Child table rows — ALWAYS pass parent doc for currency context #}
{% for row in doc.items %}
{{ row.get_formatted("rate", doc) }}
{{ row.get_formatted("amount", doc) }}
{% endfor %}
{# General formatting with explicit fieldtype #}
{{ frappe.format(value, {'fieldtype': 'Currency'}) }}
{{ frappe.format_date(doc.posting_date) }}Document Retrieval
{# Full document — use only when multiple fields needed #}
{% set customer = frappe.get_doc("Customer", doc.customer) %}
{# Single field — ALWAYS prefer over get_doc for one field #}
{% set abbr = frappe.db.get_value("Company", doc.company, "abbr") %}
{# List of records (no permission check) #}
{% set tasks = frappe.get_all("Task",
filters={"status": "Open"},
fields=["title", "due_date"],
order_by="due_date asc",
page_length=10) %}
{# List with permission check (portal pages) #}
{% set orders = frappe.get_list("Sales Order",
filters={"customer": doc.customer},
fields=["name", "grand_total"]) %}Translation: REQUIRED for All User-Facing Strings
<h1>{{ _("Invoice") }}</h1>
<p>{{ _("Total: {0}").format(doc.get_formatted("grand_total")) }}</p>System & Session
{{ frappe.get_url() }}
{{ frappe.get_fullname() }}
{{ frappe.get_fullname(doc.owner) }}
{{ frappe.db.get_single_value("System Settings", "time_zone") }}
{% if frappe.session.user != "Guest" %}...{% endif %}Full method reference: references/methods-reference.md---
Control Structures
Conditionals
{% if doc.status == "Paid" %}
<span class="paid">{{ _("Paid") }}</span>
{% elif doc.status == "Overdue" %}
<span class="overdue">{{ _("Overdue") }}</span>
{% else %}
<span>{{ doc.status }}</span>
{% endif %}Loops with Child Tables
{% for item in doc.items %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ item.item_name }}</td>
<td>{{ item.get_formatted("amount", doc) }}</td>
</tr>
{% else %}
<tr><td colspan="3">{{ _("No items") }}</td></tr>
{% endfor %}Loop Variables
| Variable | Description |
|---|---|
loop.index | 1-indexed position |
loop.index0 | 0-indexed position |
loop.first | True on first iteration |
loop.last | True on last iteration |
loop.length | Total number of items |
Variables
{% set total = 0 %}
{% set name = doc.customer_name | default("Unknown") %}---
Filters
| Filter | Example | Notes |
|---|---|---|
default | `{{ val \ | default("N/A") }}` |
length | `{{ items \ | length }}` |
join | `{{ names \ | join(", ") }}` |
truncate | `{{ text \ | truncate(100) }}` |
escape | `{{ input \ | escape }}` |
safe | `{{ html \ | safe }}` |
round | `{{ num \ | round(2) }}` |
lower / upper | `{{ text \ | upper }}` |
Full filter reference: references/filters-reference.md---
Custom Jinja Methods & Filters via hooks.py
hooks.py Registration
# hooks.py
jenv = {
"methods": [
"myapp.jinja.methods" # Module with callable functions
],
"filters": [
"myapp.jinja.filters" # Module with filter functions
]
}Custom Method
# myapp/jinja/methods.py
import frappe
def get_company_logo(company):
"""Returns company logo URL. Called as get_company_logo() in Jinja."""
return frappe.db.get_value("Company", company, "company_logo") or ""<img src="{{ get_company_logo(doc.company) }}" alt="Logo">Custom Filter
# myapp/jinja/filters.py
def nl2br(text):
"""Convert newlines to <br> tags. Used as {{ text | nl2br }}."""
return (text or "").replace("\n", "<br>"){{ doc.notes | nl2br | safe }}Details: references/methods.md---
Print Format Patterns
Minimal Print Format Template
<style>
.print-header { background: #f5f5f5; padding: 15px; }
.item-table { width: 100%; border-collapse: collapse; }
.item-table th, .item-table td { border: 1px solid #ddd; padding: 8px; }
.text-right { text-align: right; }
</style>
<div class="print-header">
<h1>{{ doc.select_print_heading or _("Invoice") }}</h1>
<p>{{ doc.name }} — {{ doc.get_formatted("posting_date") }}</p>
</div>
<table class="item-table">
<thead>
<tr>
<th>#</th>
<th>{{ _("Item") }}</th>
<th class="text-right">{{ _("Qty") }}</th>
<th class="text-right">{{ _("Amount") }}</th>
</tr>
</thead>
<tbody>
{% for row in doc.items %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ row.item_name }}</td>
<td class="text-right">{{ row.qty }}</td>
<td class="text-right">{{ row.get_formatted("amount", doc) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><strong>{{ _("Grand Total") }}: {{ doc.get_formatted("grand_total") }}</strong></p>Page Breaks
/* v14/v15 (wkhtmltopdf) */
.page-break { page-break-before: always; }
/* v16 (Chrome PDF) — ALWAYS prefer break-* in v16 */
.page-break { break-before: page; }Full examples:references/examples.md| Patterns:references/patterns.md
---
V16: Chrome PDF Rendering
| Aspect | v14/v15 (wkhtmltopdf) | v16 (Chrome) |
|---|---|---|
| CSS Support | Limited CSS3 | Full modern CSS |
| Flexbox/Grid | Partial | Full support |
| Page breaks | page-break-* | break-* preferred |
| Fonts | System fonts only | Web fonts supported |
V16 Configuration
// site_config.json
{
"pdf_engine": "chrome",
"chrome_path": "/usr/bin/chromium"
}---
Portal Page Pattern
www/projects/index.html
{% extends "templates/web.html" %}
{% block title %}{{ _("Projects") }}{% endblock %}
{% block page_content %}
<h1>{{ _("Projects") }}</h1>
{% for project in projects %}
<h3>{{ project.title }}</h3>
<p>{{ project.description | default("") | truncate(150) }}</p>
{% else %}
<p>{{ _("No projects found.") }}</p>
{% endfor %}
{% endblock %}www/projects/index.py
import frappe
def get_context(context):
context.title = "Projects"
context.no_cache = True
context.projects = frappe.get_all("Project",
filters={"is_public": 1},
fields=["name", "title", "description"],
order_by="creation desc")
return contextFull structure:references/structure.md| Templates:references/templates.md
---
Critical Rules
ALWAYS
1. Use _() for ALL user-facing strings 2. Use get_formatted() for currency, date, and numeric fields 3. Use default() filter for optional/nullable fields 4. Pass parent doc to child row get_formatted("field", doc) 5. Use frappe.db.get_value() when you need only one field 6. Keep calculations in Python controllers, not Jinja templates
NEVER
1. Execute database queries inside loops (N+1 problem) 2. Use | safe on user-supplied input (XSS vulnerability) 3. Use Jinja syntax in Report Print Formats (they require JS {%= %}) 4. Use frappe.get_doc() when frappe.db.get_value() suffices 5. Hardcode strings without _() translation wrapper 6. Disable safe_render without security review
Anti-patterns with fixes: references/anti-patterns.md---
Reference Files
| File | Contents |
|---|---|
references/syntax.md | Jinja syntax reference (tags, filters, tests, loops) |
references/methods.md | Custom Jinja methods/filters via hooks |
references/context-objects.md | Available objects per template type |
references/filters-reference.md | All standard and custom Frappe filters |
references/methods-reference.md | All frappe.* methods available in Jinja |
references/examples.md | Complete Print Format, Email, Portal examples |
references/anti-patterns.md | Common mistakes and correct alternatives |
references/templates.md | Template structure patterns |
references/patterns.md | Conditional rendering, loops, child tables |
references/structure.md | File structure for template types |
---
See Also
frappe-syntax-hooks— jenv configuration in hooks.pyfrappe-impl-printformat— Print Format implementation patternsfrappe-errors-serverscripts— Server-side error handling
Anti-Patterns: Jinja Mistakes in Frappe
Common mistakes in Frappe Jinja templates and their correct alternatives. Each anti-pattern includes the problem, why it fails, and the deterministic fix.
---
AP-01: Query in Loop (N+1 Problem)
WRONG
{% for item in doc.items %}
{% set stock = frappe.db.get_value("Bin", {"item_code": item.item_code}, "actual_qty") %}
<p>{{ item.item_name }}: {{ stock }} in stock</p>
{% endfor %}Problem: 100 items = 100+ database queries. Causes timeouts on large documents.
CORRECT
Pre-fetch all data in the Python controller:
# Controller or custom print format script
def get_context(context):
item_codes = [item.item_code for item in doc.items]
bins = frappe.get_all("Bin",
filters={"item_code": ["in", item_codes]},
fields=["item_code", "actual_qty"])
context.stock_qty = {b.item_code: b.actual_qty for b in bins}{% for item in doc.items %}
<p>{{ item.item_name }}: {{ stock_qty.get(item.item_code, 0) }} in stock</p>
{% endfor %}Rule: NEVER execute frappe.get_doc(), frappe.db.get_value(), or frappe.get_all() inside a {% for %} loop.
---
AP-02: Unescaped User Input (XSS)
WRONG
{{ user_comment | safe }}
{{ frappe.form_dict.search | safe }}
{{ doc.custom_html_field | safe }}Problem: User-supplied content can contain <script> tags. Marking as safe disables Jinja's auto-escaping, allowing script injection.
CORRECT
{# Auto-escaped by default — safe for user input #}
{{ user_comment }}
{{ frappe.form_dict.search }}
{# ONLY use safe for admin-controlled content #}
{{ doc.terms | safe }}
{{ doc.address_display | safe }}Rule: NEVER use | safe on any value that originates from user input, URL parameters, or external data.
---
AP-03: Heavy Calculations in Templates
WRONG
{% set total = 0 %}
{% for item in doc.items %}
{% set discount = item.rate * (item.discount_percentage / 100) %}
{% set tax = (item.rate - discount) * 0.21 %}
{% set item_total = (item.rate - discount + tax) * item.qty %}
{# NOTE: This does NOT work — set is block-scoped in for loops #}
{% set total = total + item_total %}
{% endfor %}
<p>Total: {{ total }}</p>Problem: Complex calculations in Jinja are slow, hard to test, and the {% set %} scoping means total stays at 0.
CORRECT
# In Python controller
def get_context(context):
total = 0
for item in doc.items:
discount = item.rate * (item.discount_percentage / 100)
tax = (item.rate - discount) * 0.21
total += (item.rate - discount + tax) * item.qty
context.calculated_total = total<p>{{ _("Total") }}: {{ calculated_total }}</p>Rule: ALWAYS do calculations in Python. Templates are for display only.
---
AP-04: Hardcoded Strings (Not Translatable)
WRONG
<th>Invoice Number</th>
<th>Amount</th>
<p>Thank you for your business!</p>Problem: These strings will NEVER be translated for non-English users.
CORRECT
<th>{{ _("Invoice Number") }}</th>
<th>{{ _("Amount") }}</th>
<p>{{ _("Thank you for your business!") }}</p>
{# With variables — use {0} placeholder #}
<p>{{ _("Total: {0}").format(doc.get_formatted("grand_total")) }}</p>Rule: ALWAYS wrap every user-facing string with _(). NEVER translate field values (they use Frappe's translation system automatically).
---
AP-05: Missing Default Values
WRONG
{{ doc.customer_group }}
{{ doc.notes | truncate(100) }}
{{ doc.description | lower }}Problem: If a field is None, the output shows "None" as text. Applying filters to None can cause errors.
CORRECT
{{ doc.customer_group | default("") }}
{{ doc.notes | default("") | truncate(100) }}
{# Or guard with a conditional #}
{% if doc.description %}
{{ doc.description | lower }}
{% endif %}Rule: ALWAYS use | default() for fields that may be None or empty, especially before chaining other filters.
---
AP-06: Raw Field Values Instead of Formatted
WRONG
<p>{{ doc.grand_total }}</p>
<p>{{ doc.posting_date }}</p>
<p>{{ "%.2f" | format(doc.grand_total) }}</p>Problem: Outputs raw database values like 1234.56 and 2024-01-15 without currency symbols, locale-specific number formatting, or date format preferences.
CORRECT
<p>{{ doc.get_formatted("grand_total") }}</p>
<p>{{ doc.get_formatted("posting_date") }}</p>
{# For child table rows — pass parent doc #}
{% for row in doc.items %}
<td>{{ row.get_formatted("amount", doc) }}</td>
{% endfor %}Rule: ALWAYS use get_formatted() for currency, date, number, and percentage fields in Print Formats.
---
AP-07: get_doc for Single Field Lookup
WRONG
{% set customer = frappe.get_doc("Customer", doc.customer) %}
<p>{{ customer.customer_group }}</p>Problem: get_doc loads the entire document with all child tables. For one field, this wastes memory and database queries.
CORRECT
{% set group = frappe.db.get_value("Customer", doc.customer, "customer_group") %}
<p>{{ group }}</p>Rule: ALWAYS use frappe.db.get_value() when you need 1-3 fields. Use frappe.get_doc() ONLY when you need the full document or many fields.
---
AP-08: Jinja Syntax in Report Print Formats
WRONG
<!-- This does NOT work in Report Print Formats -->
{% for item in data %}
<tr><td>{{ item.name }}</td></tr>
{% endfor %}Problem: Report Print Formats use JavaScript templating, NOT Jinja. The {{ }} syntax will not render.
CORRECT
<!-- JS Template syntax for Report Print Formats -->
{% for(var i=0; i < data.length; i++) { %}
<tr>
<td>{%= data[i].name %}</td>
</tr>
{% } %}Rule: NEVER use {{ }} in Report Print Formats. ALWAYS use {%= %} for output and JavaScript for logic.
---
AP-09: Disabling safe_render Without Reason
WRONG
def get_context(context):
context.safe_render = False # "To make things work"Problem: safe_render blocks templates containing .__ to prevent access to Python internals. Disabling it opens the template to code injection.
CORRECT
def get_context(context):
# NEVER disable safe_render unless you have reviewed ALL template
# inputs and confirmed they cannot contain user-controlled data.
# If a template fails with safe_render, fix the template instead.
passRule: NEVER disable safe_render without a documented security review. If a template breaks, fix the template — do not weaken security.
---
AP-10: Forgetting Child Table Parent Doc
WRONG
{% for row in doc.items %}
<td>{{ row.get_formatted("rate") }}</td>
<td>{{ row.get_formatted("amount") }}</td>
{% endfor %}Problem: Without the parent doc, get_formatted() cannot determine the correct currency from the parent document's currency field.
CORRECT
{% for row in doc.items %}
<td>{{ row.get_formatted("rate", doc) }}</td>
<td>{{ row.get_formatted("amount", doc) }}</td>
{% endfor %}Rule: ALWAYS pass the parent doc as second argument to get_formatted() on child table rows.
---
Summary
| # | Anti-Pattern | Rule |
|---|---|---|
| AP-01 | Query in loop | NEVER query inside {% for %} |
| AP-02 | `\ | safe` on user input |
| AP-03 | Calculations in Jinja | ALWAYS calculate in Python |
| AP-04 | Hardcoded strings | ALWAYS use _() for text |
| AP-05 | Missing defaults | ALWAYS use `\ |
| AP-06 | Raw field values | ALWAYS use get_formatted() |
| AP-07 | get_doc for one field | ALWAYS prefer db.get_value() |
| AP-08 | Jinja in Report formats | ALWAYS use {%= %} for reports |
| AP-09 | Disabling safe_render | NEVER disable without review |
| AP-10 | Missing parent doc | ALWAYS pass doc to child get_formatted() |
Context Objects Reference
Available objects per Jinja template type in Frappe v14/v15/v16.
---
Print Formats
| Object | Type | Description |
|---|---|---|
doc | Document | The document being printed (full object with all fields) |
frappe | Module | Frappe module with whitelisted methods |
frappe.utils | Module | Utility functions (date, number formatting) |
_() | Function | Translation function |
doc.meta | Meta | DocType metadata |
doc.items | List | Child table rows (by table fieldname) |
Accessing Document Fields
{# Standard fields #}
{{ doc.name }}
{{ doc.doctype }}
{{ doc.docstatus }}
{{ doc.owner }}
{{ doc.creation }}
{{ doc.modified }}
{# DocType-specific fields #}
{{ doc.customer_name }}
{{ doc.posting_date }}
{{ doc.grand_total }}
{# Child tables — access by fieldname #}
{% for row in doc.items %}
{{ row.item_code }}
{{ row.qty }}
{{ row.rate }}
{% endfor %}
{# Formatted values — ALWAYS use for display #}
{{ doc.get_formatted("posting_date") }}
{{ doc.get_formatted("grand_total") }}Print-Specific Context
{# Print heading override #}
{{ doc.select_print_heading or _("Invoice") }}
{# Language for this print #}
{{ doc.language or "en" }}
{# Letter head (if configured) #}
{{ doc.letter_head }}---
Email Templates
| Object | Type | Description |
|---|---|---|
doc | Document | The linked document (when template is used with a DocType) |
frappe | Module | Frappe module (limited access) |
_() | Function | Translation function |
Email Context Specifics
{# Document fields available directly #}
{{ doc.name }}
{{ doc.customer_name }}
{{ doc.get_formatted("grand_total") }}
{# Frappe methods available #}
{{ frappe.format_date(doc.posting_date) }}
{{ frappe.get_url() }}
{{ frappe.db.get_value("Company", doc.company, "company_name") }}NOTE: Email templates render at send-time. The doc object reflects the document state at the moment the email is triggered.
---
Notification Templates
| Object | Type | Description |
|---|---|---|
doc | Document | The document that triggered the notification |
frappe | Module | Frappe module |
_() | Function | Translation function |
Notification Context
{# Document that triggered the notification #}
{{ doc.name }}
{{ doc.doctype }}
{{ doc.modified_by }}
{# Common notification patterns #}
{{ _("{0} has been {1}").format(doc.name, doc.docstatus) }}
{{ frappe.get_fullname(doc.modified_by) }}---
Portal Pages (www/*.html)
| Object | Type | Description |
|---|---|---|
frappe | Module | Full Frappe module |
frappe.session.user | String | Current authenticated user |
frappe.session.csrf_token | String | CSRF token for forms |
frappe.form_dict | Dict | URL query parameters |
frappe.lang | String | Current language code (e.g., "en") |
| Custom context | Varies | Set via get_context(context) in .py controller |
Standard Context Keys (Set in Controller)
| Key | Type | Effect |
|---|---|---|
title | String | Page <title> and heading |
description | String | Meta description |
image | String | Meta image URL |
no_cache | Boolean | Disable page caching |
no_breadcrumbs | Boolean | Hide breadcrumbs |
no_header | Boolean | Hide page header |
show_sidebar | Boolean | Show web sidebar |
sitemap | Boolean | Include in sitemap |
add_breadcrumbs | Boolean | Auto-generate breadcrumbs |
add_next_prev_links | Boolean | Show prev/next navigation |
safe_render | Boolean | Enable/disable safe render mode |
Portal Context Examples
{# Authentication check #}
{% if frappe.session.user != "Guest" %}
<p>{{ _("Welcome") }}, {{ frappe.get_fullname() }}</p>
{% else %}
<a href="/login">{{ _("Log In") }}</a>
{% endif %}
{# CSRF token for forms #}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ frappe.session.csrf_token }}">
...
</form>
{# Query parameters #}
{% if frappe.form_dict.search %}
<p>{{ _("Search results for") }}: {{ frappe.form_dict.search }}</p>
{% endif %}
{# Custom context from controller #}
{% for project in projects %}
<h3>{{ project.title }}</h3>
{% endfor %}Setting Context via Frontmatter
Portal pages support YAML frontmatter:
---
title: My Page
no_cache: 1
sitemap: 1
---
<h1>{{ title }}</h1>Setting Context via HTML Comments
<!-- add-breadcrumbs -->
<!-- no-header -->
<!-- no-cache -->---
Report Print Formats (NOT Jinja)
Report Print Formats use JavaScript templating, NOT Jinja.
| Object | Type | Description |
|---|---|---|
data | Array | Report data rows |
filters | Object | Applied report filters |
report | Object | Report configuration |
{# JS Template — NOT Jinja #}
{% for(var i=0; i < data.length; i++) { %}
<tr>
<td>{%= data[i].name %}</td>
<td>{%= format_currency(data[i].amount) %}</td>
</tr>
{% } %}NEVER use `{{ }}` in Report Print Formats. ALWAYS use `{%= %}`.
---
frappe.render_template() Context
When using frappe.render_template() in Python, you control the context entirely:
html = frappe.render_template(
"templates/includes/invoice_row.html",
{"doc": doc, "row": row, "company": company_doc}
)The template receives exactly the variables you pass. frappe and _() are ALWAYS available automatically.
Complete Jinja Examples
Working examples for Print Formats, Email Templates, Notification Templates, and Portal Pages.
---
Print Format: Sales Invoice
<style>
.invoice-header { background: #f5f5f5; padding: 15px; margin-bottom: 20px; }
.text-right { text-align: right; }
.item-table { width: 100%; border-collapse: collapse; margin: 20px 0; }
.item-table th, .item-table td { border: 1px solid #ddd; padding: 8px; }
.item-table th { background: #f9f9f9; }
.totals { margin-top: 20px; }
.terms { margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; }
</style>
<div class="invoice-header">
<div class="row">
<div class="col-md-6">
<h1>{{ doc.select_print_heading or _("Invoice") }}</h1>
<p><strong>{{ doc.name }}</strong></p>
</div>
<div class="col-md-6 text-right">
<p><strong>{{ _("Date") }}:</strong> {{ doc.get_formatted("posting_date") }}</p>
<p><strong>{{ _("Due Date") }}:</strong> {{ doc.get_formatted("due_date") }}</p>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<h4>{{ _("Bill To") }}</h4>
<p><strong>{{ doc.customer_name }}</strong></p>
{% if doc.address_display %}
{{ doc.address_display | safe }}
{% endif %}
</div>
<div class="col-md-6 text-right">
{% set company_name = frappe.db.get_value("Company", doc.company, "company_name") %}
<h4>{{ _("From") }}</h4>
<p><strong>{{ company_name }}</strong></p>
</div>
</div>
<table class="item-table">
<thead>
<tr>
<th style="width: 5%">#</th>
<th style="width: 35%">{{ _("Item") }}</th>
<th style="width: 25%">{{ _("Description") }}</th>
<th class="text-right" style="width: 10%">{{ _("Qty") }}</th>
<th class="text-right" style="width: 12%">{{ _("Rate") }}</th>
<th class="text-right" style="width: 13%">{{ _("Amount") }}</th>
</tr>
</thead>
<tbody>
{%- for row in doc.items -%}
<tr>
<td>{{ loop.index }}</td>
<td>
{{ row.item_name }}
{% if row.item_code != row.item_name -%}
<br><small>{{ _("Item Code") }}: {{ row.item_code }}</small>
{%- endif %}
</td>
<td>{{ row.description | default("") | striptags | truncate(100) }}</td>
<td class="text-right">{{ row.qty }} {{ row.uom | default(row.stock_uom) }}</td>
<td class="text-right">{{ row.get_formatted("rate", doc) }}</td>
<td class="text-right">{{ row.get_formatted("amount", doc) }}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
<div class="row totals">
<div class="col-md-6"></div>
<div class="col-md-6">
<table class="item-table">
<tr>
<td><strong>{{ _("Net Total") }}</strong></td>
<td class="text-right">{{ doc.get_formatted("net_total") }}</td>
</tr>
{% if doc.total_taxes_and_charges %}
<tr>
<td><strong>{{ _("Taxes") }}</strong></td>
<td class="text-right">{{ doc.get_formatted("total_taxes_and_charges") }}</td>
</tr>
{% endif %}
{% if doc.discount_amount %}
<tr>
<td><strong>{{ _("Discount") }}</strong></td>
<td class="text-right">-{{ doc.get_formatted("discount_amount") }}</td>
</tr>
{% endif %}
<tr style="font-size: 1.2em;">
<td><strong>{{ _("Grand Total") }}</strong></td>
<td class="text-right"><strong>{{ doc.get_formatted("grand_total") }}</strong></td>
</tr>
</table>
</div>
</div>
{% if doc.terms %}
<div class="terms">
<h4>{{ _("Terms and Conditions") }}</h4>
{{ doc.terms | safe }}
</div>
{% endif %}
<div class="row" style="margin-top: 50px;">
<div class="col-md-6">
<p>{{ _("Prepared by") }}: {{ frappe.get_fullname(doc.owner) }}</p>
</div>
<div class="col-md-6 text-right">
<p>{{ _("Printed on") }}: {{ frappe.format_date(frappe.utils.nowdate()) }}</p>
</div>
</div>---
Print Format: Delivery Note with Page Breaks
<style>
.page-break { page-break-before: always; break-before: page; }
</style>
{# Page 1: Header and items #}
<h1>{{ _("Delivery Note") }} — {{ doc.name }}</h1>
<p>{{ _("Date") }}: {{ doc.get_formatted("posting_date") }}</p>
<table class="item-table">
<thead>
<tr>
<th>#</th>
<th>{{ _("Item") }}</th>
<th class="text-right">{{ _("Qty") }}</th>
</tr>
</thead>
<tbody>
{% for row in doc.items %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ row.item_name }}</td>
<td class="text-right">{{ row.qty }} {{ row.uom | default("") }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{# Page 2: Signature block #}
<div class="page-break"></div>
<h2>{{ _("Acknowledgement") }}</h2>
<p>{{ _("Received by") }}: ____________________</p>
<p>{{ _("Date") }}: ____________________</p>
<p>{{ _("Signature") }}: ____________________</p>---
Email Template: Payment Reminder
<p>{{ _("Dear") }} {{ doc.customer_name }},</p>
<p>{{ _("This is a friendly reminder that invoice") }} <strong>{{ doc.name }}</strong>
{{ _("for") }} {{ doc.get_formatted("grand_total") }} {{ _("is now due for payment.") }}</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr>
<td style="padding: 8px; border: 1px solid #ddd;">
<strong>{{ _("Invoice Number") }}</strong>
</td>
<td style="padding: 8px; border: 1px solid #ddd;">{{ doc.name }}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd;">
<strong>{{ _("Due Date") }}</strong>
</td>
<td style="padding: 8px; border: 1px solid #ddd;">
{{ frappe.format_date(doc.due_date) }}
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd;">
<strong>{{ _("Amount Due") }}</strong>
</td>
<td style="padding: 8px; border: 1px solid #ddd;">
<strong>{{ doc.get_formatted("outstanding_amount") }}</strong>
</td>
</tr>
</table>
{% if doc.items %}
<p><strong>{{ _("Items") }}:</strong></p>
<ul>
{% for item in doc.items %}
<li>{{ item.item_name }} — {{ item.qty }} x {{ item.get_formatted("rate", doc) }}</li>
{% endfor %}
</ul>
{% endif %}
<p>{{ _("Please make payment at your earliest convenience.") }}</p>
<p>{{ _("Best regards") }},<br>
{{ frappe.db.get_value("Company", doc.company, "company_name") }}</p>---
Email Template: Welcome New Customer
<p>{{ _("Dear") }} {{ doc.customer_name }},</p>
<p>{{ _("Welcome! Your account has been created.") }}</p>
<p>{{ _("Your customer ID is") }}: <strong>{{ doc.name }}</strong></p>
{% set contact = frappe.db.get_value("Dynamic Link",
{"link_doctype": "Customer", "link_name": doc.name, "parenttype": "Contact"},
"parent") %}
{% if contact %}
{% set email = frappe.db.get_value("Contact", contact, "email_id") %}
<p>{{ _("Contact email") }}: {{ email | default(_("Not set")) }}</p>
{% endif %}
<p><a href="{{ frappe.get_url() }}/me">{{ _("Log in to your portal") }}</a></p>
<p>{{ _("Best regards") }},<br>
{{ frappe.db.get_value("Company", doc.company, "company_name") }}</p>---
Notification Template: Task Overdue
<p>{{ _("Task") }} <strong>{{ doc.name }}</strong> {{ _("is overdue.") }}</p>
<table style="border-collapse: collapse; margin: 10px 0;">
<tr>
<td style="padding: 5px 10px;"><strong>{{ _("Subject") }}:</strong></td>
<td style="padding: 5px 10px;">{{ doc.subject }}</td>
</tr>
<tr>
<td style="padding: 5px 10px;"><strong>{{ _("Due Date") }}:</strong></td>
<td style="padding: 5px 10px;">{{ frappe.format_date(doc.exp_end_date) }}</td>
</tr>
<tr>
<td style="padding: 5px 10px;"><strong>{{ _("Assigned To") }}:</strong></td>
<td style="padding: 5px 10px;">{{ frappe.get_fullname(doc.owner) }}</td>
</tr>
</table>
<p><a href="{{ frappe.get_url() }}/app/task/{{ doc.name }}">{{ _("View Task") }}</a></p>---
Portal Page: Customer Orders
www/orders/index.html
{% extends "templates/web.html" %}
{% block title %}{{ _("My Orders") }}{% endblock %}
{% block page_content %}
<div class="container">
<h1>{{ _("My Orders") }}</h1>
{% if frappe.session.user == "Guest" %}
<p>{{ _("Please log in to view your orders.") }}</p>
<a href="/login" class="btn btn-primary">{{ _("Log In") }}</a>
{% else %}
<p class="text-muted">{{ _("Welcome") }}, {{ frappe.get_fullname() }}</p>
{% if orders %}
<table class="table">
<thead>
<tr>
<th>{{ _("Order") }}</th>
<th>{{ _("Date") }}</th>
<th>{{ _("Status") }}</th>
<th class="text-right">{{ _("Total") }}</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td><a href="/orders/{{ order.name }}">{{ order.name }}</a></td>
<td>{{ frappe.format_date(order.transaction_date) }}</td>
<td>
<span class="badge badge-{{ 'success' if order.status == 'Completed' else 'warning' }}">
{{ order.status }}
</span>
</td>
<td class="text-right">{{ frappe.format(order.grand_total, {"fieldtype": "Currency"}) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">{{ _("No orders found.") }}</p>
{% endif %}
{% endif %}
</div>
{% endblock %}www/orders/index.py
import frappe
def get_context(context):
context.title = "My Orders"
context.no_cache = True
if frappe.session.user == "Guest":
context.orders = []
return context
customer = frappe.db.get_value("Customer",
{"email_id": frappe.session.user}, "name")
if customer:
context.orders = frappe.get_list("Sales Order",
filters={"customer": customer},
fields=["name", "transaction_date", "status", "grand_total"],
order_by="transaction_date desc",
limit_page_length=20)
else:
context.orders = []
return contextJinja Filters Reference
Standard Jinja2 and custom Frappe filters available in templates (v14/v15/v16).
---
String Filters
| Filter | Syntax | Output |
|---|---|---|
lower | `{{ "HELLO" \ | lower }}` |
upper | `{{ "hello" \ | upper }}` |
title | `{{ "hello world" \ | title }}` |
capitalize | `{{ "hello" \ | capitalize }}` |
trim | `{{ " text " \ | trim }}` |
striptags | `{{ "<b>text</b>" \ | striptags }}` |
truncate | `{{ text \ | truncate(100) }}` |
wordwrap | `{{ text \ | wordwrap(60) }}` |
center | `{{ "text" \ | center(20) }}` |
replace | `{{ "foo" \ | replace("o", "0") }}` |
String Examples
{{ doc.customer_name | upper }}
{{ doc.description | truncate(150) }}
{{ doc.notes | trim }}
{{ doc.status | lower | title }}---
HTML Filters
| Filter | Syntax | Effect |
|---|---|---|
escape / e | `{{ val \ | escape }}` |
safe | `{{ html \ | safe }}` |
striptags | `{{ html \ | striptags }}` |
urlize | `{{ text \ | urlize }}` |
HTML Safety Rules
- Jinja auto-escapes output by default in Frappe
- ALWAYS use
| escapeor rely on auto-escaping for user input - NEVER use
| safeon user-supplied content (XSS risk) - ONLY use
| safefor admin-controlled content (e.g.,doc.terms)
{# Safe — auto-escaped #}
{{ doc.description }}
{# Safe — explicitly escaped #}
{{ user_input | escape }}
{# ONLY for trusted admin content #}
{{ doc.terms | safe }}
{# DANGEROUS — NEVER do this #}
{# {{ frappe.form_dict.search | safe }} #}---
List / Array Filters
| Filter | Syntax | Output |
|---|---|---|
length | `{{ items \ | length }}` |
first | `{{ items \ | first }}` |
last | `{{ items \ | last }}` |
join | `{{ items \ | join(", ") }}` |
sort | `{{ items \ | sort }}` |
reverse | `{{ items \ | reverse \ |
unique | `{{ items \ | unique \ |
reject | `{{ items \ | reject("none") \ |
select | `{{ items \ | select("string") \ |
map | `{{ items \ | map(attribute="name") \ |
selectattr | `{{ items \ | selectattr("qty", "gt", 0) \ |
groupby | `{{ items \ | groupby("category") }}` |
batch | `{{ items \ | batch(3) }}` |
List Examples
{# Count items #}
<p>{{ doc.items | length }} {{ _("items") }}</p>
{# Extract and join names #}
{% set names = doc.items | map(attribute="item_name") | list %}
<p>{{ names | join(", ") }}</p>
{# Filter items with qty > 0 #}
{% set active = doc.items | selectattr("qty", "gt", 0) | list %}
{% for item in active %}
{{ item.item_name }}
{% endfor %}
{# Group items by category #}
{% for group in doc.items | groupby("item_group") %}
<h3>{{ group.grouper }}</h3>
{% for item in group.list %}
<p>{{ item.item_name }}</p>
{% endfor %}
{% endfor %}
{# Batch into rows of 3 (grid layout) #}
{% for row in doc.items | batch(3) %}
<div class="row">
{% for item in row %}
<div class="col-md-4">{{ item.item_name }}</div>
{% endfor %}
</div>
{% endfor %}---
Number Filters
| Filter | Syntax | Output |
|---|---|---|
round | `{{ 3.14159 \ | round(2) }}` |
int | `{{ "42" \ | int }}` |
float | `{{ "3.14" \ | float }}` |
abs | `{{ -5 \ | abs }}` |
Number Examples
{{ doc.discount_percentage | round(2) }}
{{ doc.qty | int }}
{{ doc.balance | abs }}NOTE: For currency and date fields, ALWAYS use doc.get_formatted() or frappe.format() instead of raw number filters. These respect the system locale and currency settings.
---
Default Value Filter
{# String default #}
{{ doc.customer_group | default("Not Set") }}
{# Numeric default #}
{{ doc.discount_percentage | default(0) }}
{# Empty string default (prevents "None" output) #}
{{ doc.notes | default("") }}
{# Chained with other filters #}
{{ doc.description | default("") | truncate(100) }}ALWAYS use | default() for fields that may be None or empty, especially before applying other filters like truncate, lower, or length.
---
Filter Chaining
Filters chain left-to-right. Each filter receives the output of the previous:
{# Clean and format text #}
{{ doc.description | default("") | trim | truncate(100) }}
{# Newlines to HTML breaks (requires custom nl2br filter or safe) #}
{{ doc.notes | default("") | replace("\n", "<br>") | safe }}
{# Extract, sort, and join #}
{{ doc.items | map(attribute="item_name") | sort | join(", ") }}
{# Case conversion chain #}
{{ doc.customer_name | lower | title }}---
Custom Filters via hooks.py
Register custom filters in hooks.py:
jenv = {
"filters": ["myapp.jinja.filters"]
}All public functions in the module become available as filters:
# myapp/jinja/filters.py
def nl2br(text):
"""{{ doc.notes | nl2br | safe }}"""
return (text or "").replace("\n", "<br>")
def format_currency_custom(value, currency="EUR"):
"""{{ amount | format_currency_custom("USD") }}"""
if value is None:
return ""
return f"{currency} {value:,.2f}"See references/methods.md for complete custom filter/method documentation.
Frappe Methods Reference for Jinja
All whitelisted frappe.* methods available in Jinja templates (v14/v15/v16).
---
Formatting Methods
doc.get_formatted(fieldname, parent_doc=None)
RECOMMENDED — ALWAYS use this for displaying field values in Print Formats.
{# Parent document fields #}
{{ doc.get_formatted("posting_date") }}
{{ doc.get_formatted("grand_total") }}
{{ doc.get_formatted("status") }}
{# Child table rows — ALWAYS pass parent doc #}
{% for row in doc.items %}
{{ row.get_formatted("rate", doc) }}
{{ row.get_formatted("amount", doc) }}
{% endfor %}Why: get_formatted() respects system number format, currency, date format, and field options. Raw field access (doc.grand_total) outputs the database value without formatting.
frappe.format(value, df, doc=None)
Formats a raw value using an explicit field definition.
{{ frappe.format(1234.56, {"fieldtype": "Currency"}) }}
{# Output: "$ 1,234.56" (depends on system settings) #}
{{ frappe.format("2024-01-15", {"fieldtype": "Date"}) }}
{# Output: "01-15-2024" (depends on date format setting) #}
{{ frappe.format(value, {"fieldtype": "Currency", "options": "currency"}) }}frappe.format_date(date_string)
Formats a date to human-readable long format.
{{ frappe.format_date(doc.posting_date) }}
{# Output: "January 15, 2024" #}
{# v15+ with custom format string #}
{{ frappe.utils.format_date(doc.posting_date, "d MMMM, YYYY") }}
{# Output: "15 January, 2024" #}---
Document Retrieval Methods
frappe.get_doc(doctype, name)
Retrieves a complete document object. Use ONLY when you need multiple fields.
{% set customer = frappe.get_doc("Customer", doc.customer) %}
<p>{{ customer.customer_name }}</p>
<p>{{ customer.territory }}</p>
<p>{{ customer.customer_group }}</p>NEVER use `get_doc` when you need only one field — use frappe.db.get_value() instead.
frappe.get_all(doctype, filters, fields, order_by, start, page_length, pluck)
Returns list of records. Does NOT check user permissions.
{% set tasks = frappe.get_all("Task",
filters={"status": "Open"},
fields=["title", "due_date"],
order_by="due_date asc",
page_length=10) %}
{% for task in tasks %}
<p>{{ task.title }} — {{ frappe.format_date(task.due_date) }}</p>
{% endfor %}Parameters:
| Parameter | Type | Description |
|---|---|---|
doctype | String | DocType name |
filters | Dict | Filter conditions |
fields | List | Fields to return |
order_by | String | Sort clause |
start | Int | Offset for pagination |
page_length | Int | Limit results |
pluck | String | Return flat list of single field values |
frappe.get_list(doctype, ...)
Same as get_all but respects current user's permissions. ALWAYS use in portal pages.
{% set orders = frappe.get_list("Sales Order",
filters={"customer": doc.customer},
fields=["name", "grand_total", "transaction_date"]) %}---
Database Methods
frappe.db.get_value(doctype, name, fieldname)
Retrieves specific field value(s). ALWAYS prefer over get_doc for single fields.
{# Single value #}
{% set abbr = frappe.db.get_value("Company", doc.company, "abbr") %}
<p>{{ doc.company }} ({{ abbr }})</p>
{# Multiple values (returns tuple) #}
{% set name, group = frappe.db.get_value("Customer", doc.customer,
["customer_name", "customer_group"]) %}frappe.db.get_single_value(doctype, fieldname)
Retrieves a field value from a Single DocType (e.g., System Settings).
{% set timezone = frappe.db.get_single_value("System Settings", "time_zone") %}
{% set country = frappe.db.get_single_value("System Settings", "country") %}---
System & Utility Methods
frappe.get_system_settings(fieldname)
Shortcut for frappe.db.get_single_value("System Settings", fieldname).
{% if frappe.get_system_settings("country") == "India" %}
<p>GST: {{ doc.get_formatted("gst_amount") }}</p>
{% endif %}frappe.get_meta(doctype)
Returns DocType metadata (field definitions, properties).
{% set meta = frappe.get_meta("Task") %}
<p>{{ meta.fields | length }} fields defined</p>
{% if meta.get_field("priority") %}
<p>Priority field exists</p>
{% endif %}frappe.get_fullname(user=None)
Returns the full name of a user. Defaults to current session user.
{# Current user #}
<p>{{ _("Prepared by") }}: {{ frappe.get_fullname() }}</p>
{# Specific user #}
<p>{{ _("Owner") }}: {{ frappe.get_fullname(doc.owner) }}</p>frappe.get_url()
Returns the site URL (e.g., https://mysite.frappe.cloud).
<a href="{{ frappe.get_url() }}/app/sales-invoice/{{ doc.name }}">
{{ _("View Invoice") }}
</a>frappe.render_template(template, context)
Renders another Jinja template with a given context.
{# Render a template file #}
{{ frappe.render_template("templates/includes/footer.html", {}) }}
{# Render a string template #}
{{ frappe.render_template("Hello {{ name }}", {"name": "World"}) }}_() — Translation Function
Translates a string to the current language.
<h1>{{ _("Invoice") }}</h1>
<p>{{ _("Total: {0}").format(doc.get_formatted("grand_total")) }}</p>
<p>{{ _("Dear {0}").format(doc.customer_name) }}</p>ALWAYS wrap user-facing strings with _(). NEVER translate field values — they are already translatable via the Frappe translation framework.
---
Session & Request Methods
frappe.session.user
{% if frappe.session.user != "Guest" %}
<p>{{ _("Logged in as") }}: {{ frappe.session.user }}</p>
{% endif %}frappe.session.csrf_token
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ frappe.session.csrf_token }}">
</form>frappe.form_dict
Query parameters dictionary. Available in web requests only.
{# URL: /page?status=Open&limit=10 #}
{% if frappe.form_dict %}
{% set status = frappe.form_dict.status | default("All") %}
{% set limit = frappe.form_dict.limit | default(20) | int %}
{% endif %}frappe.lang
Current language code (two-letter lowercase).
{% if frappe.lang == "ar" %}
<div dir="rtl">...</div>
{% endif %}---
Method Availability by Template Type
| Method | Print Format | Notification | Portal | |
|---|---|---|---|---|
doc.get_formatted() | Yes | Yes | Yes | N/A |
frappe.format() | Yes | Yes | Yes | Yes |
frappe.format_date() | Yes | Yes | Yes | Yes |
frappe.get_doc() | Yes | Yes | Yes | Yes |
frappe.get_all() | Yes | Yes | Yes | Yes |
frappe.get_list() | Yes | Yes | Yes | Yes |
frappe.db.get_value() | Yes | Yes | Yes | Yes |
frappe.get_fullname() | Yes | Yes | Yes | Yes |
frappe.get_url() | Yes | Yes | Yes | Yes |
frappe.session.user | Yes | N/A | N/A | Yes |
frappe.form_dict | N/A | N/A | N/A | Yes |
frappe.render_template() | Yes | Yes | Yes | Yes |
_() | Yes | Yes | Yes | Yes |
Custom Jinja Methods & Filters via Hooks
How to register and implement custom Jinja methods and filters in Frappe v14/v15/v16.
---
Hook Registration
hooks.py — jenv Hook
# hooks.py
jenv = {
"methods": [
"myapp.jinja.methods" # All public functions become Jinja globals
],
"filters": [
"myapp.jinja.filters" # All public functions become Jinja filters
]
}How it works:
- Frappe imports the specified Python module
- ALL public functions (no leading
_) in that module are registered - Methods become global functions callable as
{{ function_name(args) }} - Filters become pipe-able as
{{ value | filter_name(args) }}
Module Structure
myapp/
├── hooks.py
└── jinja/
├── __init__.py # REQUIRED — can be empty
├── methods.py # Custom global functions
└── filters.py # Custom filtersALWAYS create __init__.py in the jinja directory. Without it, Python cannot import the module.
---
Writing Custom Methods
Custom methods are called as global functions in Jinja templates.
Method File
# myapp/jinja/methods.py
import frappe
def get_company_logo(company):
"""Get company logo URL for use in templates."""
return frappe.db.get_value("Company", company, "company_logo") or ""
def get_outstanding_invoices(customer, limit=5):
"""Get outstanding invoices for a customer."""
return frappe.get_all(
"Sales Invoice",
filters={
"customer": customer,
"docstatus": 1,
"outstanding_amount": [">", 0]
},
fields=["name", "posting_date", "grand_total", "outstanding_amount"],
order_by="posting_date desc",
limit_page_length=limit
)
def format_address(address_name):
"""Format an Address document to a display string."""
if not address_name:
return ""
address = frappe.get_doc("Address", address_name)
parts = filter(None, [
address.address_line1,
address.address_line2,
address.city,
address.pincode,
address.country
])
return ", ".join(parts)Usage in Templates
<img src="{{ get_company_logo(doc.company) }}" alt="Logo">
{% set invoices = get_outstanding_invoices(doc.customer) %}
{% for inv in invoices %}
<p>{{ inv.name }}: {{ inv.outstanding_amount }}</p>
{% endfor %}
<p>{{ format_address(doc.customer_address) }}</p>---
Writing Custom Filters
Custom filters receive the piped value as the first argument.
Filter File
# myapp/jinja/filters.py
def nl2br(text):
"""Convert newlines to <br> tags. Usage: {{ text | nl2br }}"""
if not text:
return ""
return text.replace("\n", "<br>")
def currency_words(amount, currency="EUR"):
"""Format amount with currency prefix. Usage: {{ 100 | currency_words("USD") }}"""
if amount is None:
return ""
return f"{currency} {amount:,.2f}"
def phone_format(phone):
"""Format phone number. Usage: {{ phone | phone_format }}"""
if not phone:
return ""
digits = "".join(c for c in phone if c.isdigit())
if len(digits) == 10:
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
return phone
def wrap_text(text, width=80):
"""Wrap text at specified width. Usage: {{ text | wrap_text(60) }}"""
if not text:
return ""
import textwrap
return textwrap.fill(text, width=width)Usage in Templates
{{ doc.notes | nl2br | safe }}
{{ doc.grand_total | currency_words("USD") }}
{{ doc.phone | phone_format }}
{{ doc.description | wrap_text(60) }}---
Rules for Custom Methods/Filters
ALWAYS
1. Handle None and empty string inputs gracefully 2. Return a string (or value that converts to string) — Jinja renders the return value 3. Keep functions pure — NEVER modify documents or database state 4. Use frappe.db.get_value() over frappe.get_doc() for single-field lookups 5. Create __init__.py in the jinja module directory
NEVER
1. Perform write operations (frappe.db.set_value, doc.save()) in Jinja methods 2. Raise exceptions — return empty string or fallback value instead 3. Use print() or frappe.log_error() for debugging in production 4. Name functions starting with _ — they will NOT be registered 5. Import heavy libraries at module level — use lazy imports if needed
---
Debugging Custom Methods
# Bench console test
bench console
>>> from myapp.jinja.methods import get_company_logo
>>> get_company_logo("My Company")
"/files/logo.png"
# Verify hook registration
>>> import frappe
>>> frappe.get_jenv() # Returns the Jinja environment
>>> frappe.get_jenv().globals.keys() # Lists all registered globals---
Version Notes
| Feature | v14 | v15 | v16 |
|---|---|---|---|
jenv.methods hook | Yes | Yes | Yes |
jenv.filters hook | Yes | Yes | Yes |
| Module-level registration | Yes | Yes | Yes |
| Individual function registration | No | No | No |
Common Jinja Patterns in Frappe
Reusable patterns for conditional rendering, loops, child tables, grouping, and data display.
---
Conditional Rendering
Status-Based Styling
{% set status_class = {
"Paid": "success",
"Unpaid": "warning",
"Overdue": "danger",
"Cancelled": "default"
} %}
<span class="label label-{{ status_class.get(doc.status, 'default') }}">
{{ doc.status }}
</span>Show/Hide Sections
{# Show section only if data exists #}
{% if doc.terms %}
<div class="terms-section">
<h4>{{ _("Terms and Conditions") }}</h4>
{{ doc.terms | safe }}
</div>
{% endif %}
{# Show section only for specific doctypes #}
{% if doc.doctype == "Sales Invoice" and doc.is_return %}
<div class="return-notice" style="color: red;">
<strong>{{ _("CREDIT NOTE") }}</strong>
</div>
{% endif %}Permission-Based Content (Portal Pages)
{% if frappe.session.user != "Guest" %}
<div class="user-content">
<p>{{ _("Welcome") }}, {{ frappe.get_fullname() }}</p>
</div>
{% else %}
<a href="/login">{{ _("Log in to continue") }}</a>
{% endif %}---
Child Table Iteration
Basic Child Table Loop
{% for row in doc.items %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ row.item_name }}</td>
<td class="text-right">{{ row.qty }}</td>
<td class="text-right">{{ row.get_formatted("rate", doc) }}</td>
<td class="text-right">{{ row.get_formatted("amount", doc) }}</td>
</tr>
{% else %}
<tr>
<td colspan="5">{{ _("No items") }}</td>
</tr>
{% endfor %}Alternating Row Colors
{% for row in doc.items %}
<tr style="background: {{ '#f9f9f9' if loop.index is even else '#ffffff' }};">
<td>{{ row.item_name }}</td>
</tr>
{% endfor %}First/Last Row Styling
{% for row in doc.items %}
<tr class="{% if loop.first %}first-row{% endif %}{% if loop.last %} last-row{% endif %}">
<td>{{ row.item_name }}</td>
</tr>
{% endfor %}Multiple Child Tables
{# Items table #}
<h3>{{ _("Items") }}</h3>
{% for row in doc.items %}
<p>{{ row.item_name }}: {{ row.get_formatted("amount", doc) }}</p>
{% endfor %}
{# Taxes table #}
<h3>{{ _("Taxes") }}</h3>
{% for tax in doc.taxes %}
<p>{{ tax.description }}: {{ tax.get_formatted("tax_amount", doc) }}</p>
{% endfor %}
{# Payment schedule #}
{% if doc.payment_schedule %}
<h3>{{ _("Payment Schedule") }}</h3>
{% for ps in doc.payment_schedule %}
<p>{{ frappe.format_date(ps.due_date) }}: {{ ps.get_formatted("payment_amount", doc) }}</p>
{% endfor %}
{% endif %}---
Accumulation Patterns
Running Total with Namespace
{% set ns = namespace(total=0, qty_total=0) %}
{% for row in doc.items %}
<tr>
<td>{{ row.item_name }}</td>
<td>{{ row.qty }}</td>
<td>{{ row.get_formatted("amount", doc) }}</td>
</tr>
{% set ns.total = ns.total + row.amount %}
{% set ns.qty_total = ns.qty_total + row.qty %}
{% endfor %}
<tr>
<td><strong>{{ _("Total") }}</strong></td>
<td><strong>{{ ns.qty_total }}</strong></td>
<td><strong>{{ frappe.format(ns.total, {"fieldtype": "Currency"}) }}</strong></td>
</tr>NOTE: ALWAYS use namespace() for accumulation. Plain {% set total = total + x %} does NOT work inside loops due to Jinja scoping.
---
Grouping Patterns
Group Items by Category
{% set groups = {} %}
{% for row in doc.items %}
{% if row.item_group not in groups %}
{% set _ = groups.update({row.item_group: []}) %}
{% endif %}
{% set _ = groups[row.item_group].append(row) %}
{% endfor %}
{% for group_name, items in groups.items() %}
<h4>{{ group_name }}</h4>
<table class="item-table">
{% for row in items %}
<tr>
<td>{{ row.item_name }}</td>
<td class="text-right">{{ row.get_formatted("amount", doc) }}</td>
</tr>
{% endfor %}
</table>
{% endfor %}Using groupby Filter
{% for group in doc.items | groupby("item_group") %}
<h4>{{ group.grouper | default(_("Uncategorized")) }}</h4>
<ul>
{% for row in group.list %}
<li>{{ row.item_name }} — {{ row.get_formatted("amount", doc) }}</li>
{% endfor %}
</ul>
{% endfor %}---
Data Display Patterns
Key-Value Table
{% macro kv_row(label, value) %}
{% if value %}
<tr>
<td style="width: 40%; padding: 5px;"><strong>{{ _(label) }}</strong></td>
<td style="padding: 5px;">{{ value }}</td>
</tr>
{% endif %}
{% endmacro %}
<table style="width: 100%;">
{{ kv_row("Customer", doc.customer_name) }}
{{ kv_row("Date", doc.get_formatted("posting_date")) }}
{{ kv_row("Status", doc.status) }}
{{ kv_row("Total", doc.get_formatted("grand_total")) }}
{{ kv_row("Notes", doc.notes | default("")) }}
</table>Two-Column Layout
<div class="row">
<div class="col-md-6">
<h4>{{ _("Bill To") }}</h4>
<p>{{ doc.customer_name }}</p>
{% if doc.address_display %}
{{ doc.address_display | safe }}
{% endif %}
</div>
<div class="col-md-6 text-right">
<h4>{{ _("Ship To") }}</h4>
{% if doc.shipping_address %}
{{ doc.shipping_address | safe }}
{% else %}
<p>{{ _("Same as billing address") }}</p>
{% endif %}
</div>
</div>Grid Layout with batch Filter
{# Display items in 3-column grid #}
{% for row_items in doc.items | batch(3) %}
<div class="row" style="margin-bottom: 10px;">
{% for item in row_items %}
<div class="col-md-4">
<div style="border: 1px solid #ddd; padding: 10px;">
<strong>{{ item.item_name }}</strong>
<p>{{ item.get_formatted("rate", doc) }}</p>
</div>
</div>
{% endfor %}
</div>
{% endfor %}---
Date & Number Patterns
Date Comparisons
{% if doc.due_date and doc.due_date < frappe.utils.nowdate() %}
<span style="color: red;">{{ _("OVERDUE") }}</span>
{% endif %}Number Formatting
{# ALWAYS prefer get_formatted for display #}
{{ doc.get_formatted("grand_total") }}
{# For calculated values not in a field #}
{{ frappe.format(calculated_value, {"fieldtype": "Currency"}) }}
{# Percentage display #}
{{ doc.discount_percentage | round(1) }}%---
Link Patterns
Document Links (Portal)
<a href="{{ frappe.get_url() }}/app/sales-invoice/{{ doc.name }}">
{{ _("View Invoice") }}
</a>Portal Page Links
<a href="/orders/{{ order.name }}">{{ order.name }}</a>Conditional Links
{% if doc.customer %}
{% set customer_url = frappe.get_url() ~ "/app/customer/" ~ doc.customer %}
<a href="{{ customer_url }}">{{ doc.customer_name }}</a>
{% else %}
{{ _("No customer linked") }}
{% endif %}File Structure for Frappe Templates
Directory structure and file organization for each template type in Frappe v14/v15/v16.
---
Portal Pages (www/)
Portal pages live in the www/ directory of your app. The directory structure maps directly to URL routes.
Single Page
myapp/
└── www/
├── about.html → /about
├── about.py → Controller for /about
├── about.css → Auto-loaded CSS
└── about.js → Auto-loaded JSNested Pages
myapp/
└── www/
└── projects/
├── index.html → /projects
├── index.py → Controller for /projects
├── index.css → Auto-loaded CSS
├── index.js → Auto-loaded JS
└── detail.html → /projects/detailFile Naming Rules
- ALWAYS use lowercase filenames
- ALWAYS use hyphens for multi-word names:
my-page.html(NOTmy_page.html) - The
.pycontroller filename MUST match the.htmlfilename exactly .cssand.jsfiles with matching names are auto-included
Controller Pattern
# www/projects/index.py
import frappe
def get_context(context):
context.title = "Projects"
context.no_cache = True
context.projects = frappe.get_all("Project",
filters={"is_public": 1},
fields=["name", "title", "description"])
return contextOverriding Standard Pages
To override Frappe's built-in pages (e.g., /about, /contact), place a file with the same name in your app's www/ folder. Your app's version takes precedence if it is listed AFTER frappe in sites/apps.txt.
---
Print Formats
Custom Print Format (Jinja)
Custom Print Formats are stored in the database (DocType: Print Format). They are created via:
- Setup > Print > Print Format in the UI
- Or as fixtures in your app
Standard Print Format (App-Level)
myapp/
└── myapp/
└── module_name/
└── print_format/
└── my_invoice_format/
├── my_invoice_format.json → Print Format metadata
└── my_invoice_format.html → Jinja templatePrint Format JSON
{
"doctype": "Print Format",
"name": "My Invoice Format",
"doc_type": "Sales Invoice",
"module": "My Module",
"print_format_type": "Jinja",
"raw_printing": 0,
"custom_format": 1
}Key fields:
| Field | Description |
|---|---|
doc_type | The DocType this format applies to |
print_format_type | ALWAYS "Jinja" for custom templates |
custom_format | 1 for fully custom HTML |
raw_printing | 1 for raw text (thermal printers) |
---
Email Templates
Email Templates are stored in the database (DocType: Email Template).
Structure
{
"doctype": "Email Template",
"name": "Payment Reminder",
"subject": "Payment Reminder for {{ doc.name }}",
"response": "<p>Dear {{ doc.customer_name }},</p>..."
}As App Fixtures
myapp/
└── myapp/
└── module_name/
└── email_template/
└── payment_reminder/
└── payment_reminder.jsonThe subject and response fields contain Jinja templates rendered with the doc context.
---
Notification Templates
Notification Templates are configured in the database (DocType: Notification).
Structure
| Field | Purpose |
|---|---|
subject | Jinja template for notification subject |
message | Jinja template for notification body |
document_type | DocType that triggers the notification |
event | Trigger event (Save, Submit, Days After, etc.) |
Both subject and message receive the doc object in their Jinja context.
---
Custom Jinja Methods/Filters
myapp/
├── hooks.py → Register via jenv hook
└── myapp/
└── jinja/
├── __init__.py → REQUIRED (can be empty)
├── methods.py → Global Jinja functions
└── filters.py → Jinja filtershooks.py Registration
jenv = {
"methods": ["myapp.jinja.methods"],
"filters": ["myapp.jinja.filters"]
}---
Template Include Files
Shared template fragments go in the templates/includes/ directory:
myapp/
└── myapp/
└── templates/
├── includes/
│ ├── company_header.html
│ ├── item_table.html
│ └── footer.html
└── pages/
└── custom_page.htmlUsage
{% include "myapp/templates/includes/company_header.html" %}ALWAYS use the full dotted path from the app root when including templates.
---
Letter Head
Letter Heads are stored in the database (DocType: Letter Head).
| Field | Description |
|---|---|
content | HTML/Jinja content for the header |
footer | HTML/Jinja content for the footer |
image | Image URL for the letterhead |
is_default | Whether this is the default letterhead |
Letter Heads are automatically rendered above and below Print Formats. They support Jinja syntax.
---
Version Differences
| Feature | v14 | v15 | v16 |
|---|---|---|---|
| www/ portal pages | Yes | Yes | Yes |
| Print Format (Jinja) | Yes | Yes | Yes |
| Email Template | Yes | Yes | Yes |
| Notification Template | Yes | Yes | Yes |
| Custom jenv methods | Yes | Yes | Yes |
| Markdown portal pages (.md) | Yes | Yes | Yes |
| YAML frontmatter in pages | Yes | Yes | Yes |
| Chrome PDF rendering | No | No | Yes |
Jinja Syntax Reference
Complete Jinja2 syntax reference for Frappe templates (v14/v15/v16).
---
Delimiters
| Delimiter | Purpose | Example |
|---|---|---|
{{ }} | Output expression | {{ doc.name }} |
{% %} | Statement (control flow) | {% if doc.paid %} |
{# #} | Comment (not rendered) | {# TODO: fix this #} |
{%- -%} | Strip whitespace | {%- if True -%} |
Whitespace Control
Add - inside delimiters to strip leading/trailing whitespace:
{# Normal — produces blank lines #}
{% if doc.items %}
content
{% endif %}
{# Stripped — no extra blank lines #}
{%- if doc.items -%}
content
{%- endif -%}---
Tags (Statements)
Conditionals
{% if condition %}
...
{% elif other_condition %}
...
{% else %}
...
{% endif %}ALWAYS close with {% endif %}. Nesting is supported.
Truthiness Rules
| Value | Truthy? |
|---|---|
| Non-empty string | Yes |
| Non-zero number | Yes |
| Non-empty list | Yes |
None | No |
0 | No |
"" (empty string) | No |
[] (empty list) | No |
For Loops
{% for item in doc.items %}
{{ loop.index }}. {{ item.item_name }}
{% else %}
No items found.
{% endfor %}ALWAYS close with {% endfor %}. The {% else %} block runs when the iterable is empty.
Loop Variables
| Variable | Type | Description |
|---|---|---|
loop.index | int | Current iteration (1-indexed) |
loop.index0 | int | Current iteration (0-indexed) |
loop.revindex | int | Iterations remaining (1-indexed) |
loop.first | bool | True on first iteration |
loop.last | bool | True on last iteration |
loop.length | int | Total number of items |
loop.cycle | func | Cycle through values: loop.cycle("odd", "even") |
Variable Assignment
{% set total = 0 %}
{% set customer_name = doc.customer_name | default("Unknown") %}
{% set items_list = doc.items | list %}NOTE: {% set %} inside a {% for %} block creates a block-scoped variable. To accumulate values across loop iterations, use namespace:
{% set ns = namespace(total=0) %}
{% for row in doc.items %}
{% set ns.total = ns.total + row.amount %}
{% endfor %}
Total: {{ ns.total }}Macros (Reusable Snippets)
{% macro render_field(label, value) %}
<div class="field">
<label>{{ _(label) }}</label>
<span>{{ value | default("—") }}</span>
</div>
{% endmacro %}
{{ render_field("Customer", doc.customer_name) }}
{{ render_field("Date", doc.get_formatted("posting_date")) }}Include
{% include "templates/includes/address.html" %}
{% include "templates/includes/footer.html" ignore missing %}ignore missing prevents errors if the included file does not exist.
Extends / Blocks
{# Base template: templates/web.html #}
{% extends "templates/web.html" %}
{% block title %}{{ _("My Page") }}{% endblock %}
{% block page_content %}
<h1>{{ _("Content here") }}</h1>
{% endblock %}ALWAYS use {% extends %} as the FIRST tag in portal page templates.
---
Expressions
Attribute Access
{{ doc.name }} {# Dot notation #}
{{ doc["name"] }} {# Bracket notation #}
{{ doc.items[0].qty }} {# Nested access #}Operators
| Operator | Example |
|---|---|
+ - * / // % ** | {{ 10 + 5 }} |
== != > < >= <= | {% if qty > 0 %} |
and or not | {% if a and b %} |
in | {% if "Open" in statuses %} |
is | {% if value is defined %} |
~ (string concat) | {{ "Hello " ~ name }} |
Ternary (Inline If)
{{ "Paid" if doc.status == "Paid" else "Unpaid" }}
{{ doc.discount_amount if doc.discount_amount else 0 }}---
Tests
| Test | Example | Description |
|---|---|---|
defined | {% if var is defined %} | Variable exists |
undefined | {% if var is undefined %} | Variable does not exist |
none | {% if val is none %} | Value is None |
string | {% if val is string %} | Value is a string |
number | {% if val is number %} | Value is numeric |
even / odd | {% if loop.index is even %} | Even/odd number |
divisibleby | {% if loop.index is divisibleby(3) %} | Divisibility check |
---
Escaping
Jinja auto-escapes HTML by default in Frappe. To output raw HTML from trusted sources:
{# Auto-escaped (safe for user input) #}
{{ doc.description }}
{# Raw HTML — NEVER use for user input #}
{{ doc.terms | safe }}
{# Explicit escape #}
{{ value | escape }}
{{ value | e }}
{# Escape Jinja delimiters in output #}
{{ "{{ this is literal }}" }}
{% raw %}
{{ this will not be parsed }}
{% endraw %}Template Structure Patterns
Structural patterns for Frappe Jinja templates: base templates, blocks, includes, and macros.
---
Portal Page Base Template
ALL portal pages MUST extend templates/web.html:
{% extends "templates/web.html" %}
{% block title %}{{ _("Page Title") }}{% endblock %}
{% block page_content %}
{# Your page content here #}
{% endblock %}Available Blocks in web.html
| Block | Purpose |
|---|---|
title | Page <title> tag |
page_content | Main content area |
header | Page header section |
script | Additional JavaScript |
style | Additional CSS |
Extending with Additional Blocks
{% extends "templates/web.html" %}
{% block title %}{{ _("Dashboard") }}{% endblock %}
{% block style %}
<style>
.dashboard-card { border: 1px solid #ddd; padding: 15px; margin: 10px 0; }
</style>
{% endblock %}
{% block page_content %}
<div class="dashboard-card">
<h2>{{ _("Overview") }}</h2>
</div>
{% endblock %}
{% block script %}
<script>
// Page-specific JavaScript
frappe.ready(function() {
console.log("Dashboard loaded");
});
</script>
{% endblock %}---
Print Format Template Structure
Print Formats do NOT use {% extends %}. They are standalone HTML fragments:
<style>
/* Scoped styles for this print format */
.print-container { font-family: Arial, sans-serif; }
.header { margin-bottom: 20px; }
.item-table { width: 100%; border-collapse: collapse; }
.item-table th, .item-table td { border: 1px solid #ccc; padding: 6px; }
.text-right { text-align: right; }
.footer { margin-top: 30px; font-size: 0.9em; color: #666; }
/* Page break control */
.page-break { page-break-before: always; break-before: page; }
/* Print-only styles */
@media print {
.no-print { display: none; }
}
</style>
<div class="print-container">
{# Header section #}
<div class="header">
<h1>{{ doc.select_print_heading or _("Document Title") }}</h1>
<p>{{ doc.name }} — {{ doc.get_formatted("posting_date") }}</p>
</div>
{# Body section #}
<table class="item-table">
<thead>...</thead>
<tbody>
{% for row in doc.items %}
<tr>...</tr>
{% endfor %}
</tbody>
</table>
{# Totals section #}
<div class="totals">...</div>
{# Footer section #}
<div class="footer">
<p>{{ _("Prepared by") }}: {{ frappe.get_fullname(doc.owner) }}</p>
</div>
</div>---
Include Pattern
Use {% include %} to reuse template fragments:
{# Main template #}
<div class="header">
{% include "templates/includes/company_header.html" %}
</div>
<div class="content">
{% include "templates/includes/item_table.html" %}
</div>
{# With ignore missing (no error if file absent) #}
{% include "templates/includes/optional_footer.html" ignore missing %}Included Template
{# templates/includes/item_table.html #}
{# Receives the same context as the parent template #}
<table class="item-table">
<thead>
<tr>
<th>#</th>
<th>{{ _("Item") }}</th>
<th class="text-right">{{ _("Amount") }}</th>
</tr>
</thead>
<tbody>
{% for row in doc.items %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ row.item_name }}</td>
<td class="text-right">{{ row.get_formatted("amount", doc) }}</td>
</tr>
{% endfor %}
</tbody>
</table>---
Macro Pattern
Macros define reusable template functions within a single file:
{# Define macros at the top of the template #}
{% macro field_row(label, value) %}
<tr>
<td style="padding: 5px 10px;"><strong>{{ _(label) }}</strong></td>
<td style="padding: 5px 10px;">{{ value | default("—") }}</td>
</tr>
{% endmacro %}
{% macro status_badge(status) %}
<span class="badge badge-{{ 'success' if status == 'Completed' else 'primary' if status == 'Open' else 'default' }}">
{{ status }}
</span>
{% endmacro %}
{# Use macros in the template body #}
<table>
{{ field_row("Customer", doc.customer_name) }}
{{ field_row("Date", doc.get_formatted("posting_date")) }}
{{ field_row("Status", status_badge(doc.status)) }}
{{ field_row("Total", doc.get_formatted("grand_total")) }}
</table>Macro with Caller Block
{% macro card(title) %}
<div class="card" style="border: 1px solid #ddd; padding: 15px; margin: 10px 0;">
<h3>{{ title }}</h3>
<div class="card-body">
{{ caller() }}
</div>
</div>
{% endmacro %}
{% call card(_("Summary")) %}
<p>{{ _("Total Items") }}: {{ doc.items | length }}</p>
<p>{{ _("Grand Total") }}: {{ doc.get_formatted("grand_total") }}</p>
{% endcall %}---
Email Template Structure
Email templates are HTML fragments (no {% extends %}). ALWAYS use inline styles for email compatibility:
{# Greeting #}
<p style="font-size: 14px;">{{ _("Dear") }} {{ doc.customer_name }},</p>
{# Body with inline styles #}
<div style="margin: 20px 0; padding: 15px; background: #f9f9f9; border-radius: 4px;">
<p style="margin: 0;">
<strong>{{ _("Invoice") }}:</strong> {{ doc.name }}<br>
<strong>{{ _("Amount") }}:</strong> {{ doc.get_formatted("grand_total") }}<br>
<strong>{{ _("Due Date") }}:</strong> {{ frappe.format_date(doc.due_date) }}
</p>
</div>
{# Call to action #}
<p>
<a href="{{ frappe.get_url() }}/app/sales-invoice/{{ doc.name }}"
style="background: #5e64ff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;">
{{ _("View Invoice") }}
</a>
</p>
{# Sign-off #}
<p style="color: #666; font-size: 12px;">
{{ _("Best regards") }},<br>
{{ frappe.db.get_value("Company", doc.company, "company_name") }}
</p>Rule: ALWAYS use inline CSS in email templates. External stylesheets and <style> blocks are stripped by most email clients.
---
Notification Template Structure
Notification templates are short HTML fragments:
<p>{{ _("{0} has been updated").format(doc.name) }}</p>
<table style="border-collapse: collapse;">
<tr>
<td style="padding: 4px 8px;"><strong>{{ _("Status") }}:</strong></td>
<td style="padding: 4px 8px;">{{ doc.status }}</td>
</tr>
<tr>
<td style="padding: 4px 8px;"><strong>{{ _("Modified by") }}:</strong></td>
<td style="padding: 4px 8px;">{{ frappe.get_fullname(doc.modified_by) }}</td>
</tr>
</table>
<p><a href="{{ frappe.get_url() }}/app/{{ doc.doctype | lower | replace(' ', '-') }}/{{ doc.name }}">
{{ _("View Document") }}
</a></p>