
Frappe Impl Jinja
- 1 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Provides workflows for building Frappe Jinja templates including Print Formats, Email Templates, notification templates, portal pages, and custom methods.
About
An implementation skill for building Jinja templates in Frappe for print formats, emails, notifications, and portal pages. A developer uses it to create formatted document and email templates and avoid N+1 queries.
- Workflows for Print Formats, Email Templates, and Portal Pages
- Child table handling, conditional sections, and custom Jinja methods; avoids N+1 queries
Frappe Impl Jinja by the numbers
- 1 all-time installs (skills.sh)
- Ranked #3,836 of 4,347 Backend & APIs 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-impl-jinjaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Provides workflows for building Frappe Jinja templates including Print Formats, Email Templates, notification templates, portal pages, and custom methods.
Files
Frappe Jinja Templates Implementation Workflow
Step-by-step workflows for building Jinja templates. For syntax reference, see frappe-syntax-jinja.
Version: v14/v15/v16 (V16 Chrome PDF noted)
---
Master Decision: What Are You Creating?
WHAT IS YOUR OUTPUT?
│
├─► Printable PDF (invoice, PO, report)?
│ ├─► Standard DocType → Print Format (Jinja)
│ └─► Query/Script Report → Report Print Format (JAVASCRIPT!)
│ ⚠️ Uses {%= %} NOT {{ }}
│
├─► Automated email with dynamic content?
│ └─► Email Template (Jinja, linked to DocType)
│
├─► System notification?
│ └─► Notification (Setup > Notification, uses Jinja)
│
├─► Customer-facing web page?
│ └─► Portal Page (myapp/www/*.html + *.py)
│
└─► Reusable template functions/filters?
└─► Custom jenv methods in hooks.py---
Workflow 1: Create a Print Format
Step 1: Create via UI
Setup > Printing > Print Format > New
- Name: My Invoice Format
- DocType: Sales Invoice
- Module: Accounts
- Standard: No (custom)
- Print Format Type: JinjaStep 2: Write the Template
<style>
.print-format { font-family: Arial, sans-serif; font-size: 11px; }
.header { margin-bottom: 20px; }
.table { width: 100%; border-collapse: collapse; margin: 20px 0; }
.table th, .table td { border: 1px solid #ddd; padding: 8px; }
.table th { background: #f0f0f0; }
.text-right { text-align: right; }
</style>
<div class="header">
<h1>{{ doc.select_print_heading or _("Invoice") }}</h1>
<p><strong>{{ doc.name }}</strong> |
{{ doc.get_formatted("posting_date") }}</p>
</div>
<p><strong>{{ doc.customer_name }}</strong></p>
{% if doc.address_display %}
<p>{{ doc.address_display | safe }}</p>
{% endif %}
<table class="table">
<thead>
<tr>
<th>#</th>
<th>{{ _("Item") }}</th>
<th class="text-right">{{ _("Qty") }}</th>
<th class="text-right">{{ _("Rate") }}</th>
<th class="text-right">{{ _("Amount") }}</th>
</tr>
</thead>
<tbody>
{% for row in doc.items %}
<tr>
<td>{{ row.idx }}</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>
{% endfor %}
</tbody>
</table>
{% for tax in doc.taxes %}
<p class="text-right">{{ tax.description }}: {{ tax.get_formatted("tax_amount", doc) }}</p>
{% endfor %}
<p class="text-right">
<strong>{{ _("Grand Total") }}: {{ doc.get_formatted("grand_total") }}</strong>
</p>
{% if doc.terms %}
<div style="margin-top: 30px; border-top: 1px solid #ddd; padding-top: 10px;">
<strong>{{ _("Terms and Conditions") }}</strong>
{{ doc.terms | safe }}
</div>
{% endif %}Step 3: Test
1. Open a Sales Invoice 2. Menu > Print > Select "My Invoice Format" 3. Verify layout and formatting 4. ALWAYS test PDF download — wkhtmltopdf renders differently from browser
Critical Rules for Print Formats
- ALWAYS use
doc.get_formatted("field")for currency, dates, numbers - ALWAYS pass parent doc for child rows:
row.get_formatted("rate", doc) - ALWAYS wrap user-facing text with
_("text")for translation - ALWAYS put CSS in a
<style>block at the top (not external files) - NEVER use flexbox in v14/v15 (wkhtmltopdf does not support it) — V16 Chrome PDF does
- NEVER use
| safeon user-supplied input — only on trusted system HTML
---
Workflow 2: Create an Email Template
Step 1: Create via UI
Setup > Email > Email Template > New
- Name: Payment Reminder
- Subject: Invoice {{ doc.name }} - Payment Reminder
- DocType: Sales InvoiceStep 2: Write Email Content
ALWAYS use inline styles for emails — most clients strip <style> blocks.
<div style="font-family: Arial, sans-serif; max-width: 600px;">
<p>{{ _("Dear") }} {{ doc.customer_name }},</p>
<p>{{ _("Invoice") }} <strong>{{ doc.name }}</strong>
{{ _("for") }} {{ doc.get_formatted("grand_total") }}
{{ _("is due for payment.") }}</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="background: #f5f5f5;">
<td style="padding: 10px; border: 1px solid #ddd;">
<strong>{{ _("Due Date") }}</strong></td>
<td style="padding: 10px; border: 1px solid #ddd;">
{{ frappe.format_date(doc.due_date) }}</td>
</tr>
<tr>
<td style="padding: 10px; border: 1px solid #ddd;">
<strong>{{ _("Outstanding") }}</strong></td>
<td style="padding: 10px; border: 1px solid #ddd; color: #c00;">
{{ doc.get_formatted("outstanding_amount") }}</td>
</tr>
</table>
{% if doc.items %}
<p><strong>{{ _("Items") }}:</strong></p>
<ul>
{% for item in doc.items[:5] %}
<li>{{ item.item_name }} ({{ item.qty }})</li>
{% endfor %}
{% if doc.items | length > 5 %}
<li style="color: #666;">{{ _("and {0} more...").format(doc.items|length - 5) }}</li>
{% endif %}
</ul>
{% endif %}
<p>{{ _("Best regards") }},<br>
{{ frappe.db.get_value("Company", doc.company, "company_name") }}</p>
</div>Step 3: Use in Notification or Code
Option A: Auto-triggered Notification
Setup > Notification > New
- Channel: Email
- Document Type: Sales Invoice
- Send Alert On: Days After (7 days after due_date)
- Condition: doc.outstanding_amount > 0
- Email Template: Payment ReminderOption B: Send from code
template = frappe.get_doc("Email Template", "Payment Reminder")
frappe.sendmail(
recipients=[doc.contact_email],
subject=frappe.render_template(template.subject, {"doc": doc}),
message=frappe.render_template(template.response, {"doc": doc}),
reference_doctype=doc.doctype,
reference_name=doc.name
)---
Workflow 3: Create a Notification Template
Step 1: Create via UI
Setup > Notification > New
- Name: Low Stock Alert
- Channel: Email (or Slack, System Notification)
- Document Type: Stock Ledger Entry
- Send Alert On: Method (on change)
- Condition: doc.actual_qty < 10Step 2: Write Message (Jinja)
<h3>{{ _("Low Stock Alert") }}</h3>
<p>{{ _("Item") }}: <strong>{{ doc.item_code }}</strong></p>
<p>{{ _("Warehouse") }}: {{ doc.warehouse }}</p>
<p>{{ _("Current Stock") }}: {{ doc.actual_qty }}</p>
<p>{{ _("Please reorder.") }}</p>---
Workflow 4: Create a Portal Page
Step 1: Create directory structure
myapp/
└── www/
└── my-orders/
├── index.html # Jinja template
└── index.py # Python contextStep 2: Create context (index.py)
import frappe
def get_context(context):
if frappe.session.user == "Guest":
frappe.local.flags.redirect_location = "/login"
raise frappe.Redirect
context.title = "My Orders"
context.no_cache = True
customer = frappe.db.get_value("Contact",
{"user": frappe.session.user}, "link_name")
context.orders = frappe.get_all("Sales Order",
filters={"customer": customer, "docstatus": ["!=", 2]},
fields=["name", "transaction_date", "grand_total", "status"],
order_by="transaction_date desc",
limit=50
) if customer else []
return contextStep 3: Create template (index.html)
{% extends "templates/web.html" %}
{% block title %}{{ _("My Orders") }}{% endblock %}
{% block page_content %}
<div class="container my-4">
<h1>{{ _("My Orders") }}</h1>
{% if orders %}
<table class="table table-hover">
<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>{{ order.status }}</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 %}
</div>
{% endblock %}Step 4: Test at https://yoursite.com/my-orders
---
Workflow 5: Register Custom Jinja Methods
Step 1: Add to hooks.py
jenv = {
"methods": ["myapp.jinja_utils.methods"],
"filters": ["myapp.jinja_utils.filters"]
}Step 2: Create methods module
# myapp/jinja_utils/methods.py
import frappe
def get_company_logo(company):
"""Usage: {{ get_company_logo(doc.company) }}"""
return frappe.db.get_value("Company", company, "company_logo") or ""
def format_address(address_name):
"""Usage: {{ format_address(doc.customer_address) | safe }}"""
if not address_name:
return ""
return frappe.get_doc("Address", address_name).get_display()Step 3: Create filters module
# myapp/jinja_utils/filters.py
def phone_format(value):
"""Usage: {{ doc.phone | phone_format }}"""
if not value:
return ""
digits = ''.join(c for c in str(value) if c.isdigit())
if len(digits) == 10:
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
return valueStep 4: Deploy
bench --site sitename migrate
bench --site sitename clear-cacheCritical Rules for Custom Jinja Methods
- Custom methods should be READ-ONLY — NEVER write to database or commit
- ALWAYS handle None/empty input gracefully (return empty string)
- NEVER call slow external APIs — templates must render fast
---
Workflow 6: Debug a Template
Template Not Rendering?
<!-- Step 1: Check if doc is available -->
<!-- DEBUG: {{ doc.name if doc else 'NO DOC' }} -->
<!-- Step 2: Check child table -->
<!-- DEBUG: items count = {{ doc.items | length if doc.items else 0 }} -->
<!-- Step 3: Check specific field -->
<!-- DEBUG: grand_total = {{ doc.grand_total }} -->Common Debugging Steps
1. Check Error Log (Setup > Error Log) for template exceptions 2. Use frappe.render_template(template_string, {"doc": doc}) in bench console 3. For Print Formats: Menu > Print > check browser console for errors 4. For Portal Pages: check Python context — add frappe.logger().info(context) in get_context
Common Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Blank output | Wrong template type (Jinja in Report) | Reports use JS: {%= %} |
| "None" displayed | Field is null | Use `\ |
| Wrong currency format | Missing parent doc context | Use row.get_formatted("rate", doc) |
| HTML showing as text | Auto-escaping | Add `\ |
| Translations not working | Missing _() wrapper | Wrap all strings: {{ _("text") }} |
---
Quick Patterns: Child Tables, Conditionals, Translation
{# Child tables — ALWAYS pass parent doc for formatting context #}
{% for row in doc.items %}
{{ row.get_formatted("rate", doc) }} {# Correct: has currency context #}
{% endfor %}
{# Conditional sections #}
{% if doc.shipping_address_name %}
{{ doc.shipping_address | safe }}
{% endif %}
{# Translation — ALWAYS wrap user-facing text #}
{{ _("Invoice") }}
{{ _("Page {0} of {1}").format(page, total_pages) }}
{{ doc.get_formatted("grand_total") }} {# Auto-formats per locale #}---
Styling/CSS in Print Formats
@page { margin: 1.5cm; }
.avoid-break { page-break-inside: avoid; }
thead { display: table-header-group; } /* Repeat header on pages */
.page-break { page-break-before: always; }
/* V14/V15: NO flexbox (wkhtmltopdf). V16 Chrome PDF: flexbox OK */
.layout { display: table; width: 100%; }
.col { display: table-cell; vertical-align: top; }---
Context Variables Quick Reference
| Template Type | Available Objects |
|---|---|
| Print Format | doc, frappe, _(), frappe.format() |
| Email Template | doc, frappe (limited), _() |
| Notification | doc, frappe, event data |
| Portal Page | frappe.session, frappe.form_dict, custom context |
---
Version Differences
| Feature | V14 | V15 | V16 |
|---|---|---|---|
| Jinja templates | Yes | Yes | Yes |
| get_formatted() | Yes | Yes | Yes |
| jenv hooks | Yes | Yes | Yes |
| wkhtmltopdf PDF | Yes | Yes | Deprecated |
| Chrome PDF | No | No | Yes |
V16 Chrome PDF supports modern CSS (flexbox, grid, CSS variables). See frappe-syntax-jinja for details.---
Reference Files
| File | Contents |
|---|---|
| decision-tree.md | Complete template type selection flowcharts |
| print-format-decision.md | Jinja vs Print Designer vs JS Microtemplate decision tree |
| workflows.md | Step-by-step patterns for all template types |
| examples.md | Production-ready templates (invoice, email, portal) |
Jinja Templates - Anti-Patterns
Common mistakes and how to avoid them.
---
Anti-Pattern 1: Using Jinja Syntax in Report Print Formats
❌ Wrong
{# In a Query Report or Script Report Print Format #}
{% for row in data %}
<tr><td>{{ row.name }}</td></tr>
{% endfor %}Error: Blank output or template not rendering
✅ Correct
<!-- Report Print Formats use JAVASCRIPT templating -->
{% for (var i=0; i<data.length; i++) { %}
<tr><td>{%= data[i].name %}</td></tr>
{% } %}Rule
Report Print Formats (Query Reports, Script Reports) use JavaScript templating, NOT Jinja.
| Context | Syntax | Variables |
|---|---|---|
| Print Format (DocType) | {{ }} / {% %} | doc |
| Report Print Format | {%= %} / {% %} | data[], filters |
---
Anti-Pattern 2: Raw Value Display Without Formatting
❌ Wrong
<p>Total: {{ doc.grand_total }}</p>
<p>Date: {{ doc.posting_date }}</p>Result: Total: 12500.5 instead of Total: € 12,500.50
✅ Correct
<p>Total: {{ doc.get_formatted("grand_total") }}</p>
<p>Date: {{ doc.get_formatted("posting_date") }}</p>
{# Or for general formatting #}
<p>Total: {{ frappe.format(doc.grand_total, {'fieldtype': 'Currency'}) }}</p>
<p>Date: {{ frappe.format_date(doc.posting_date) }}</p>Rule
ALWAYS use `get_formatted()` or `frappe.format()` for user-facing values.
---
Anti-Pattern 3: Missing Parent Doc for Child Table Formatting
❌ Wrong
{% for item in doc.items %}
<td>{{ item.get_formatted("rate") }}</td>
<td>{{ item.get_formatted("amount") }}</td>
{% endfor %}Result: Currency symbol/format may be wrong or missing
✅ Correct
{% for item in doc.items %}
<td>{{ item.get_formatted("rate", doc) }}</td>
<td>{{ item.get_formatted("amount", doc) }}</td>
{% endfor %}Rule
Child table rows need the parent doc for currency context. Pass doc as second argument to get_formatted().
---
Anti-Pattern 4: N+1 Query Problem in Templates
❌ Wrong
{% for item in doc.items %}
{% set stock = frappe.db.get_value("Bin",
{"item_code": item.item_code, "warehouse": doc.warehouse},
"actual_qty") %}
<td>{{ stock }}</td>
{% endfor %}Result: 1 query per item = slow rendering for large tables
✅ Correct
Option A: Prefetch in Controller
# In controller or get_context
def before_print(self, settings=None):
items = [i.item_code for i in self.items]
self.stock_data = get_stock_for_items(items, self.warehouse){% for item in doc.items %}
<td>{{ doc.stock_data.get(item.item_code, 0) }}</td>
{% endfor %}Option B: Custom Jinja Method with Caching
# In jinja methods
def get_stock_batch(item_codes, warehouse):
# Single query for all items
result = frappe.db.sql("""...""")
return {r.item_code: r.qty for r in result}Rule
Never execute database queries inside loops. Prefetch data before template rendering.
---
Anti-Pattern 5: Using | safe for User Input
❌ Wrong
<div>{{ doc.custom_notes | safe }}</div>
<p>{{ user_comment | safe }}</p>Result: XSS vulnerability - users can inject malicious scripts
✅ Correct
{# For trusted system content only #}
<div>{{ doc.terms | safe }}</div>
{# For user input - let Jinja auto-escape #}
<p>{{ user_comment }}</p>
{# Or explicit escape #}
<p>{{ user_comment | e }}</p>Rule
Only use `| safe` for trusted HTML content (like system-generated Terms & Conditions). User input should be auto-escaped.
---
Anti-Pattern 6: Hardcoded Strings Without Translation
❌ Wrong
<h1>Invoice</h1>
<th>Amount</th>
<td>Total:</td>
<p>Thank you for your business!</p>Result: Cannot be translated for multi-language sites
✅ Correct
<h1>{{ _("Invoice") }}</h1>
<th>{{ _("Amount") }}</th>
<td>{{ _("Total") }}:</td>
<p>{{ _("Thank you for your business!") }}</p>Rule
Wrap ALL user-facing strings with `_()` for translation support.
---
Anti-Pattern 7: Missing Default Values
❌ Wrong
<p>Contact: {{ doc.contact_name }}</p>
<p>Phone: {{ doc.contact_phone }}</p>Result: Displays "None" or empty when field is null
✅ Correct
<p>Contact: {{ doc.contact_name | default('-') }}</p>
<p>Phone: {{ doc.contact_phone | default('N/A') }}</p>
{# Or with conditional #}
{% if doc.contact_name %}
<p>Contact: {{ doc.contact_name }}</p>
{% endif %}Rule
Always handle null/empty values with | default() or conditionals.
---
Anti-Pattern 8: Inline Styles in Email Templates (Wrong Way)
❌ Wrong
<style>
.email-header { background: #333; color: white; }
.button { background: blue; padding: 10px; }
</style>
<div class="email-header">...</div>Result: Most email clients ignore <style> blocks
✅ Correct
<div style="background: #333; color: white; padding: 20px;">
...
</div>
<a href="..." style="background: blue; color: white; padding: 10px 20px; text-decoration: none; display: inline-block;">
Button
</a>Rule
Email templates must use inline styles. CSS classes and <style> blocks are stripped by most email clients.
---
Anti-Pattern 9: Heavy Computations in Templates
❌ Wrong
{% set total = 0 %}
{% for order in frappe.get_all("Sales Order", filters={"customer": doc.customer}, fields=["grand_total"]) %}
{% set total = total + order.grand_total %}
{% endfor %}
{% for invoice in frappe.get_all("Sales Invoice", filters={"customer": doc.customer}, fields=["outstanding_amount"]) %}
{# More complex calculations #}
{% endfor %}
<p>Customer Lifetime Value: {{ total }}</p>Result: Slow template rendering, complex logic hard to maintain
✅ Correct
# In controller or context
def get_context(context):
context.customer_stats = calculate_customer_stats(customer)<p>Customer Lifetime Value: {{ customer_stats.lifetime_value }}</p>Rule
Templates are for presentation, not computation. Move complex logic to Python.
---
Anti-Pattern 10: Assuming Variable Existence
❌ Wrong
<p>{{ doc.custom_field.nested_value }}</p>
<img src="{{ company.company_logo }}">Result: AttributeError or UndefinedError if variable is None
✅ Correct
<p>{{ doc.custom_field.nested_value if doc.custom_field else '' }}</p>
{% if company and company.company_logo %}
<img src="{{ company.company_logo }}">
{% endif %}Rule
Always check variable existence before accessing nested properties.
---
Anti-Pattern 11: Breaking Page Layout in Print Formats
❌ Wrong
<table>
{% for item in doc.items %}
<tr>
<td>{{ item.item_name }}</td>
</tr>
{% endfor %}
</table>
{# Long description that might cause page break mid-row #}Result: Table rows split across pages, broken layout
✅ Correct
<style>
.avoid-break { page-break-inside: avoid; }
thead { display: table-header-group; }
</style>
<table>
<thead>
<tr><th>Item</th></tr>
</thead>
<tbody>
{% for item in doc.items %}
<tr class="avoid-break">
<td>{{ item.item_name }}</td>
</tr>
{% endfor %}
</tbody>
</table>Rule
Use CSS page-break properties to control print layout.
---
Anti-Pattern 12: Not Testing PDF Output
❌ Wrong
Developing print format only checking in browser view.
Result: PDF renders differently (wkhtmltopdf has CSS limitations)
✅ Correct
Development workflow:
1. Design in browser 2. Test PDF download after each significant change 3. Check on actual printer if critical
Rule
Always test PDF output - wkhtmltopdf (v14/v15) has limited CSS support (no flexbox, limited grid). V16 Chrome PDF is better.
---
Anti-Pattern 13: Committing in Custom Jinja Methods
❌ Wrong
# In jinja method
def update_counter(doc_name):
frappe.db.set_value("Counter", doc_name, "count", count + 1)
frappe.db.commit() # ❌ NEVER DO THIS
return count + 1Result: Can break transaction integrity, data corruption
✅ Correct
# Jinja methods should be READ-ONLY
def get_counter(doc_name):
return frappe.db.get_value("Counter", doc_name, "count") or 0Rule
Jinja methods should only READ data, never write. Side effects in templates are dangerous.
---
Anti-Pattern 14: Large Images Without Optimization
❌ Wrong
<img src="{{ doc.image }}">
{# Where doc.image is a 5MB full-resolution photo #}Result: Huge PDF files, slow loading
✅ Correct
{# Use thumbnail if available #}
{% set image_url = doc.image %}
{% if image_url and not image_url.startswith('http') %}
{% set image_url = "/api/method/frappe.utils.image.resize_image?image=" + image_url + "&height=200" %}
{% endif %}
<img src="{{ image_url }}" style="max-width: 200px;">Rule
Optimize images for templates - use thumbnails or resize on the fly.
---
Anti-Pattern 15: Ignoring Template Errors
❌ Wrong
Template shows blank or partial output, developer assumes "it's working".
✅ Correct
Debug steps:
{# 1. Add debug output #}
<!-- DEBUG: doc = {{ doc }} -->
<!-- DEBUG: doc.items length = {{ doc.items | length if doc.items else 'NONE' }} -->
{# 2. Check Error Log #}
{# Setup > Error Log #}
{# 3. Use try-except in custom methods #}def safe_method(arg):
try:
return do_something(arg)
except Exception as e:
frappe.log_error(f"Jinja method error: {e}")
return ""Rule
Check Error Log for template errors. Add debug output when troubleshooting.
---
Quick Reference: Anti-Pattern Summary
| Anti-Pattern | Fix |
|---|---|
| Jinja in Report Print | Use JS templating {%= %} |
| Raw values | Use get_formatted() |
| Missing parent doc | Pass doc to child get_formatted() |
| Queries in loops | Prefetch data |
| `\ | safe` on user input |
| Hardcoded strings | Use _("string") |
| Missing defaults | Use `\ |
<style> in email | Use inline styles |
| Heavy computation | Move to Python |
| Assuming variables exist | Check with if first |
| Bad page breaks | Use CSS page-break |
| No PDF testing | Always test PDF download |
| Commits in methods | Methods should be read-only |
| Large images | Optimize/resize |
| Ignoring errors | Check Error Log |
Jinja Templates - Complete Decision Trees
Detailed flowcharts for selecting the right template type and implementation approach.
---
Decision Tree: Template Type Selection
WHAT IS YOUR OUTPUT GOAL?
│
├─► Printable PDF document?
│ │
│ │ WHAT ARE YOU PRINTING?
│ │
│ ├─► Standard DocType document?
│ │ │ (Invoice, Quote, PO, etc.)
│ │ │
│ │ │ HOW COMPLEX IS THE LAYOUT?
│ │ │
│ │ ├─► Simple: fields in rows/columns
│ │ │ └── Print Format Builder (Setup > Print)
│ │ │ - No coding needed
│ │ │ - Drag-drop interface
│ │ │ - Limited customization
│ │ │
│ │ ├─► Medium: custom headers, conditional sections
│ │ │ └── Custom HTML Print Format (Jinja)
│ │ │ - Create via Setup > Print Format
│ │ │ - Full Jinja control
│ │ │ - Embedded CSS
│ │ │
│ │ └─► Complex: multi-page, signatures, images
│ │ └── Custom HTML Print Format (Jinja)
│ │ - May need @page CSS
│ │ - Consider V16 Chrome PDF benefits
│ │ - Test page breaks carefully
│ │
│ ├─► Query Report results?
│ │ └── Report Print Format (JAVASCRIPT!)
│ │ ⚠️ NOT Jinja!
│ │ ⚠️ Uses {%= %} and {% %}
│ │ - Create in Report DocType
│ │ - Access: data[], filters, report_summary
│ │
│ ├─► Script Report results?
│ │ └── Report Print Format (JAVASCRIPT!)
│ │ ⚠️ Same as Query Report
│ │ - Has access to columns[], data[]
│ │
│ └─► Standalone letter/certificate?
│ └── Letter Head + Print Format
│ - Letter Head: company logo, address
│ - Print Format: document content
│
├─► Email content?
│ │
│ │ IS IT LINKED TO A DOCTYPE?
│ │
│ ├─► Yes (e.g., invoice reminder)
│ │ └── Email Template with DocType
│ │ - Setup > Email > Email Template
│ │ - Link to specific DocType
│ │ - Access: doc object
│ │
│ ├─► No (standalone email)
│ │ └── Email Template without DocType
│ │ - Pass custom context when sending
│ │
│ └─► System notification?
│ └── Notification (Setup > Notification)
│ - Built-in Jinja in message field
│ - Auto-triggered on events
│
├─► Customer-facing web page?
│ │
│ │ AUTHENTICATION REQUIRED?
│ │
│ ├─► Public (anyone can view)
│ │ └── Portal Page (www/*.html)
│ │ - Check user != 'Guest' for sections
│ │ - No login required
│ │
│ ├─► Logged-in users only
│ │ └── Portal Page with permission check
│ │ - Add to context.py: if guest redirect
│ │ - Use frappe.session for user data
│ │
│ └─► Customer portal (view their orders, etc.)
│ └── Portal Page with DocType context
│ - Filter data by current user/customer
│ - Use permission_query patterns
│
└─► Reusable template logic?
│
│ WHAT KIND OF REUSE?
│
├─► Formatting function (e.g., phone formatter)
│ └── Custom Jinja filter (hooks.py jenv.filters)
│ - Usage: {{ value | my_filter }}
│
├─► Data retrieval (e.g., get company logo)
│ └── Custom Jinja method (hooks.py jenv.methods)
│ - Usage: {{ my_method(arg) }}
│
└─► Template snippet (e.g., address block)
└── Template include
- Save in templates/includes/
- Usage: {% include "path/to/snippet.html" %}---
Decision Tree: Print Format Creation Method
WHERE SHOULD THE PRINT FORMAT LIVE?
│
├─► Database (editable via UI)?
│ │
│ │ WHO WILL MAINTAIN IT?
│ │
│ ├─► End users/administrators
│ │ └── Create via UI (Setup > Print Format)
│ │ - Easy to modify
│ │ - Site-specific
│ │ - No deployment needed
│ │
│ └─► Developers (but stored in DB)
│ └── Create via UI, export as fixture
│ ```python
│ # hooks.py
│ fixtures = [
│ {"dt": "Print Format", "filters": [
│ ["name", "=", "My Invoice Format"]
│ ]}
│ ]
│ ```
│
└─► Code (version controlled)?
│
│ HOW TO STRUCTURE?
│
└─► Create Print Format record + HTML filemyapp/ ├── print_format/ │ └── my_invoice_format/ │ ├── my_invoice_format.json # DocType record │ └── my_invoice_format.html # Template content
---
Decision Tree: Portal Page Context
WHAT DATA DOES YOUR PAGE NEED?
│
├─► Static page (no dynamic data)?
│ └── HTML only, no .py file needed
│ ```
│ www/about.html # Just Jinja template
│ ```
│
├─► Dynamic data from database?
│ │
│ │ WHAT PERMISSION MODEL?
│ │
│ ├─► Public data (no login required)
│ │ └── get_context with public filters
│ │ ```python
│ │ def get_context(context):
│ │ context.items = frappe.get_all(
│ │ "Item",
│ │ filters={"is_public": 1}
│ │ )
│ │ ```
│ │
│ ├─► User-specific data
│ │ └── get_context with user filters
│ │ ```python
│ │ def get_context(context):
│ │ if frappe.session.user == "Guest":
│ │ frappe.throw("Login required")
│ │ context.orders = frappe.get_all(
│ │ "Sales Order",
│ │ filters={"owner": frappe.session.user}
│ │ )
│ │ ```
│ │
│ └─► Customer portal data
│ └── get_context with customer link
│ ```python
│ def get_context(context):
│ customer = get_customer_for_user()
│ context.invoices = frappe.get_all(
│ "Sales Invoice",
│ filters={"customer": customer}
│ )
│ ```
│
├─► Form submission handling?
│ │
│ │ WHAT TYPE OF FORM?
│ │
│ ├─► Simple contact form
│ │ └── Use frappe.form_dict + frappe.sendmail
│ │
│ ├─► Create document
│ │ └── Use web form (Setup > Web Form)
│ │ - Built-in CSRF protection
│ │ - Automatic validation
│ │
│ └─► Custom action
│ └── Whitelisted API + client JS
│ - @frappe.whitelist(allow_guest=True)
│ - AJAX call from portal
│
└─► URL parameters?
└── Access via frappe.form_dictURL: /page?id=123&type=invoice
def get_context(context): doc_id = frappe.form_dict.get("id") doc_type = frappe.form_dict.get("type")
---
Decision Tree: Jinja Custom Extensions
WHAT DO YOU WANT TO ADD TO JINJA?
│
├─► New function available in templates?
│ │
│ │ DOES IT TRANSFORM A VALUE?
│ │
│ ├─► Yes (input → output transformation)
│ │ └── Custom filter
│ │ ```python
│ │ # hooks.py
│ │ jenv = {"filters": ["myapp.jinja.filters"]}
│ │
│ │ # myapp/jinja/filters.py
│ │ def uppercase(value):
│ │ return str(value).upper()
│ │
│ │ # Template usage
│ │ {{ name | uppercase }}
│ │ ```
│ │
│ └─► No (retrieves data or performs action)
│ └── Custom method
│ ```python
│ # hooks.py
│ jenv = {"methods": ["myapp.jinja.methods"]}
│
│ # myapp/jinja/methods.py
│ def get_weather(city):
│ return fetch_weather_api(city)
│
│ # Template usage
│ {{ get_weather("London") }}
│ ```
│
├─► Reusable HTML snippet?
│ │
│ │ IS IT PARAMETERIZED?
│ │
│ ├─► Yes (accepts variables)
│ │ └── Macro
│ │ ```jinja
│ │ {% macro address_block(address) %}
│ │ <div class="address">
│ │ {{ address.address_line1 }}<br>
│ │ {{ address.city }}, {{ address.pincode }}
│ │ </div>
│ │ {% endmacro %}
│ │
│ │ {{ address_block(customer_address) }}
│ │ ```
│ │
│ └─► No (static content)
│ └── Include
│ ```jinja
│ {% include "templates/includes/footer.html" %}
│ ```
│
└─► Global variable?
└── Use extend_bootinfo hookhooks.py
extend_bootinfo = "myapp.boot.extend"
myapp/boot.py
def extend(bootinfo): bootinfo.company_settings = get_settings()
Accessible in JS and via frappe.boot
---
Decision Tree: Styling Approach
HOW SHOULD YOU STYLE YOUR TEMPLATE?
│
├─► Print Format?
│ │
│ │ WHAT'S YOUR TARGET?
│ │
│ ├─► PDF output (primary use)
│ │ └── Embedded <style> block
│ │ ```jinja
│ │ <style>
│ │ /* Use print-friendly CSS */
│ │ @page { margin: 1cm; }
│ │ .page-break { page-break-before: always; }
│ │
│ │ /* Avoid: flexbox (wkhtmltopdf), vh/vw units */
│ │ /* V16 Chrome PDF: flexbox OK */
│ │ </style>
│ │ ```
│ │
│ └─► Screen + PDF
│ └── Embedded styles with @media print
│ ```css
│ @media screen { .screen-only { display: block; } }
│ @media print { .screen-only { display: none; } }
│ ```
│
├─► Email Template?
│ └── Inline styles ONLY
│ ```jinja
│ {# Email clients ignore <style> blocks #}
│ <table style="width: 100%; border-collapse: collapse;">
│ <tr style="background: #f5f5f5;">
│ <td style="padding: 10px;">Content</td>
│ </tr>
│ </table>
│ ```
│
└─► Portal Page?
│
│ APP-SPECIFIC OR FRAPPE THEME?
│
├─► Match Frappe/ERPNext theme
│ └── Use Bootstrap classes
│ ```jinja
│ <div class="container">
│ <div class="row">
│ <div class="col-md-6">
│ <button class="btn btn-primary">
│ ```
│
└─► Custom styling
└── Add CSS via web_include_css hookhooks.py
web_include_css = ["/assets/myapp/css/portal.css"]
---
Decision Tree: Template Debugging
TEMPLATE NOT WORKING? WHAT'S THE SYMPTOM?
│
├─► Blank output?
│ │
│ ├─► Check: Is doc available?
│ │ ```jinja
│ │ <!-- Debug: {{ doc }} -->
│ │ <!-- Debug: {{ doc.name if doc else 'NO DOC' }} -->
│ │ ```
│ │
│ ├─► Check: Syntax error hiding exception?
│ │ - Look in Error Log
│ │ - Check browser console for API errors
│ │
│ └─► Check: Wrong template type?
│ - Report Print Format ≠ Jinja
│ - Uses {%= %} not {{ }}
│
├─► "Undefined" error?
│ │
│ ├─► Variable doesn't exist
│ │ ```jinja
│ │ {# Add default #}
│ │ {{ doc.custom_field | default('') }}
│ │ ```
│ │
│ ├─► Method not available
│ │ - Not all frappe.* methods work in Jinja
│ │ - Check available methods in syntax skill
│ │
│ └─► Context not passed
│ - Portal: Check get_context returns context
│ - Email: Check context dict in sendmail call
│
├─► Wrong formatting?
│ │
│ ├─► Currency showing raw number
│ │ ```jinja
│ │ {# ❌ Wrong #}
│ │ {{ doc.grand_total }}
│ │
│ │ {# ✅ Correct #}
│ │ {{ doc.get_formatted("grand_total") }}
│ │ ```
│ │
│ └─► Date in wrong format
│ ```jinja
│ {# Use format_date for display #}
│ {{ frappe.format_date(doc.posting_date) }}
│ ```
│
├─► HTML showing as text?
│ └── Missing safe filter (use carefully!)
│ ```jinja
│ {# Only for trusted HTML content #}
│ {{ doc.terms | safe }}
│ ```
│
└─► Translations not working?
│
├─► Missing _() wrapper
│ ```jinja
│ {{ _("Invoice") }} {# Not just "Invoice" #}
│ ```
│
└─► Translation not in system
- Add via Setup > Translations
- Or translations/*.csv in app---
Quick Reference: Template Type Summary
| Need | Template Type | Location | Key Objects |
|---|---|---|---|
| DocType PDF | Print Format (Jinja) | Setup > Print | doc, frappe |
| Report PDF | Report Print Format (JS!) | Report record | data[], filters |
| Email Template | Setup > Email | doc, frappe | |
| Notification | Notification | Setup > Notification | doc, event data |
| Portal | www/.html + .py | myapp/www/ | custom context |
| Snippet | Template include | templates/includes/ | passed variables |
Jinja Templates - Complete Examples
Production-ready examples for common template scenarios.
---
Example 1: Professional Sales Invoice Print Format
A complete, professional invoice suitable for business use.
<style>
/* Base styles */
.invoice { font-family: 'Segoe UI', Arial, sans-serif; font-size: 11px; color: #333; }
/* Header */
.invoice-header { display: table; width: 100%; margin-bottom: 30px; }
.company-section { display: table-cell; width: 60%; vertical-align: top; }
.company-logo { max-height: 60px; max-width: 180px; margin-bottom: 10px; }
.company-name { font-size: 18px; font-weight: bold; color: #2c3e50; }
.company-details { color: #666; line-height: 1.6; }
.invoice-title-section { display: table-cell; width: 40%; text-align: right; vertical-align: top; }
.invoice-title { font-size: 28px; font-weight: bold; color: #2c3e50; margin-bottom: 10px; }
.invoice-number { font-size: 14px; color: #666; }
/* Parties */
.parties { display: table; width: 100%; margin-bottom: 30px; }
.bill-to, .ship-to { display: table-cell; width: 50%; vertical-align: top; }
.party-label { font-weight: bold; color: #2c3e50; margin-bottom: 5px; text-transform: uppercase; font-size: 10px; }
.party-name { font-weight: bold; font-size: 13px; }
.party-address { color: #666; line-height: 1.5; }
/* Meta info */
.meta-table { width: 100%; margin-bottom: 20px; }
.meta-table td { padding: 5px 10px; }
.meta-table .label { background: #f5f5f5; font-weight: bold; width: 150px; }
/* Items table */
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
.items-table th { background: #2c3e50; color: white; padding: 10px; text-align: left; font-weight: normal; }
.items-table th.text-right { text-align: right; }
.items-table td { padding: 10px; border-bottom: 1px solid #eee; }
.items-table tr:nth-child(even) { background: #fafafa; }
.item-code { color: #999; font-size: 10px; }
/* Totals */
.totals-section { display: table; width: 100%; }
.totals-notes { display: table-cell; width: 50%; vertical-align: top; }
.totals-table-wrapper { display: table-cell; width: 50%; }
.totals-table { width: 100%; }
.totals-table td { padding: 8px; }
.totals-table .label { text-align: right; }
.totals-table .value { text-align: right; width: 150px; }
.totals-table .grand-total { font-size: 16px; font-weight: bold; background: #2c3e50; color: white; }
/* Footer */
.invoice-footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd; }
.terms { font-size: 10px; color: #666; }
/* Print adjustments */
@media print {
.invoice { padding: 0; }
}
</style>
{# Get company data #}
{% set company = frappe.get_doc("Company", doc.company) %}
<div class="invoice">
<!-- Header -->
<div class="invoice-header">
<div class="company-section">
{% if company.company_logo %}
<img src="{{ company.company_logo }}" class="company-logo" alt="">
{% endif %}
<div class="company-name">{{ company.company_name }}</div>
<div class="company-details">
{% if company.company_description %}{{ company.company_description }}<br>{% endif %}
{% if company.phone_no %}{{ _("Tel") }}: {{ company.phone_no }}<br>{% endif %}
{% if company.email %}{{ company.email }}<br>{% endif %}
{% if company.website %}{{ company.website }}{% endif %}
</div>
</div>
<div class="invoice-title-section">
<div class="invoice-title">{{ doc.select_print_heading or _("INVOICE") }}</div>
<div class="invoice-number">{{ doc.name }}</div>
</div>
</div>
<!-- Bill To / Ship To -->
<div class="parties">
<div class="bill-to">
<div class="party-label">{{ _("Bill To") }}</div>
<div class="party-name">{{ doc.customer_name }}</div>
<div class="party-address">
{{ doc.address_display | safe if doc.address_display else '' }}
</div>
</div>
{% if doc.shipping_address_name %}
<div class="ship-to">
<div class="party-label">{{ _("Ship To") }}</div>
<div class="party-address">
{{ doc.shipping_address | safe if doc.shipping_address else '' }}
</div>
</div>
{% endif %}
</div>
<!-- Invoice Meta -->
<table class="meta-table">
<tr>
<td class="label">{{ _("Invoice Date") }}</td>
<td>{{ doc.get_formatted("posting_date") }}</td>
<td class="label">{{ _("Due Date") }}</td>
<td>{{ doc.get_formatted("due_date") }}</td>
</tr>
{% if doc.po_no %}
<tr>
<td class="label">{{ _("Customer PO") }}</td>
<td>{{ doc.po_no }}</td>
<td class="label">{{ _("PO Date") }}</td>
<td>{{ doc.get_formatted("po_date") if doc.po_date else '' }}</td>
</tr>
{% endif %}
{% if doc.contact_display %}
<tr>
<td class="label">{{ _("Contact") }}</td>
<td colspan="3">{{ doc.contact_display }}</td>
</tr>
{% endif %}
</table>
<!-- Items -->
<table class="items-table">
<thead>
<tr>
<th style="width: 5%">#</th>
<th style="width: 35%">{{ _("Item") }}</th>
<th style="width: 20%">{{ _("Description") }}</th>
<th class="text-right" style="width: 10%">{{ _("Qty") }}</th>
<th class="text-right" style="width: 15%">{{ _("Rate") }}</th>
<th class="text-right" style="width: 15%">{{ _("Amount") }}</th>
</tr>
</thead>
<tbody>
{% for item in doc.items %}
<tr>
<td>{{ item.idx }}</td>
<td>
{{ item.item_name }}
{% if item.item_code != item.item_name %}
<div class="item-code">{{ item.item_code }}</div>
{% endif %}
</td>
<td>{{ (item.description | truncate(80)) if item.description else '' }}</td>
<td class="text-right">{{ item.qty }} {{ item.uom }}</td>
<td class="text-right">{{ item.get_formatted("rate", doc) }}</td>
<td class="text-right">{{ item.get_formatted("amount", doc) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Totals -->
<div class="totals-section">
<div class="totals-notes">
{% if doc.remarks %}
<p><strong>{{ _("Remarks") }}:</strong><br>{{ doc.remarks }}</p>
{% endif %}
</div>
<div class="totals-table-wrapper">
<table class="totals-table">
<tr>
<td class="label">{{ _("Subtotal") }}</td>
<td class="value">{{ doc.get_formatted("net_total") }}</td>
</tr>
{% if doc.discount_amount %}
<tr>
<td class="label">{{ _("Discount") }}</td>
<td class="value">-{{ doc.get_formatted("discount_amount") }}</td>
</tr>
{% endif %}
{% for tax in doc.taxes %}
<tr>
<td class="label">{{ tax.description }}</td>
<td class="value">{{ tax.get_formatted("tax_amount", doc) }}</td>
</tr>
{% endfor %}
<tr class="grand-total">
<td class="label">{{ _("Grand Total") }}</td>
<td class="value">{{ doc.get_formatted("grand_total") }}</td>
</tr>
{% if doc.outstanding_amount and doc.outstanding_amount != doc.grand_total %}
<tr>
<td class="label">{{ _("Paid Amount") }}</td>
<td class="value">{{ doc.get_formatted("paid_amount") if doc.paid_amount else doc.get_formatted("grand_total") }}</td>
</tr>
<tr>
<td class="label"><strong>{{ _("Balance Due") }}</strong></td>
<td class="value"><strong>{{ doc.get_formatted("outstanding_amount") }}</strong></td>
</tr>
{% endif %}
</table>
</div>
</div>
<!-- Footer -->
<div class="invoice-footer">
{% if doc.terms %}
<div class="terms">
<strong>{{ _("Terms and Conditions") }}</strong><br>
{{ doc.terms | safe }}
</div>
{% endif %}
</div>
</div>---
Example 2: Packing Slip Print Format
<style>
.packing-slip { font-family: Arial, sans-serif; font-size: 12px; }
.header { border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 20px; }
.title { font-size: 24px; font-weight: bold; }
.addresses { display: table; width: 100%; margin: 20px 0; }
.address-box { display: table-cell; width: 50%; padding: 10px; border: 1px solid #ddd; }
.address-label { font-weight: bold; margin-bottom: 5px; }
.items-table { width: 100%; border-collapse: collapse; margin: 20px 0; }
.items-table th { background: #333; color: white; padding: 10px; text-align: left; }
.items-table td { padding: 10px; border-bottom: 1px solid #ddd; }
.checkbox { width: 20px; height: 20px; border: 2px solid #333; display: inline-block; }
.signature-section { margin-top: 50px; }
.signature-line { width: 200px; border-bottom: 1px solid #333; margin-top: 40px; }
</style>
<div class="packing-slip">
<div class="header">
<div class="title">{{ _("PACKING SLIP") }}</div>
<p>{{ doc.name }} | {{ doc.get_formatted("posting_date") }}</p>
</div>
<div class="addresses">
<div class="address-box">
<div class="address-label">{{ _("Ship From") }}:</div>
{% set company = frappe.get_doc("Company", doc.company) %}
<strong>{{ company.company_name }}</strong><br>
{{ company.address or '' }}
</div>
<div class="address-box">
<div class="address-label">{{ _("Ship To") }}:</div>
<strong>{{ doc.customer_name }}</strong><br>
{{ doc.shipping_address | safe if doc.shipping_address else doc.address_display | safe }}
</div>
</div>
<table class="items-table">
<thead>
<tr>
<th style="width: 5%"><span class="checkbox"></span></th>
<th style="width: 15%">{{ _("Item Code") }}</th>
<th style="width: 40%">{{ _("Description") }}</th>
<th style="width: 15%">{{ _("Qty Ordered") }}</th>
<th style="width: 15%">{{ _("Qty Packed") }}</th>
<th style="width: 10%">{{ _("UOM") }}</th>
</tr>
</thead>
<tbody>
{% for item in doc.items %}
<tr>
<td><span class="checkbox"></span></td>
<td>{{ item.item_code }}</td>
<td>{{ item.item_name }}</td>
<td>{{ item.qty }}</td>
<td>________</td>
<td>{{ item.uom }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><strong>{{ _("Total Items") }}:</strong> {{ doc.items | length }}</p>
<p><strong>{{ _("Total Qty") }}:</strong> {{ doc.total_qty }}</p>
{% if doc.remarks %}
<p><strong>{{ _("Notes") }}:</strong> {{ doc.remarks }}</p>
{% endif %}
<div class="signature-section">
<table style="width: 100%;">
<tr>
<td style="width: 50%;">
<div class="signature-line"></div>
<p>{{ _("Packed By") }}</p>
</td>
<td style="width: 50%;">
<div class="signature-line"></div>
<p>{{ _("Checked By") }}</p>
</td>
</tr>
</table>
</div>
</div>---
Example 3: Email Template - Order Confirmation
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background: #f9f9f9; padding: 20px;">
<div style="background: white; padding: 30px; border-radius: 5px;">
<!-- Header -->
<div style="text-align: center; margin-bottom: 30px;">
{% set company = frappe.get_doc("Company", doc.company) %}
{% if company.company_logo %}
<img src="{{ company.company_logo }}" style="max-height: 50px;" alt="">
{% endif %}
<h1 style="color: #2c3e50; margin: 20px 0 10px;">{{ _("Order Confirmation") }}</h1>
<p style="color: #666;">{{ _("Thank you for your order!") }}</p>
</div>
<!-- Greeting -->
<p>{{ _("Dear") }} {{ doc.customer_name }},</p>
<p>{{ _("We have received your order and it is being processed. Here are your order details:") }}</p>
<!-- Order Info -->
<div style="background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
<table style="width: 100%;">
<tr>
<td style="padding: 5px 0;"><strong>{{ _("Order Number") }}:</strong></td>
<td style="text-align: right;">{{ doc.name }}</td>
</tr>
<tr>
<td style="padding: 5px 0;"><strong>{{ _("Order Date") }}:</strong></td>
<td style="text-align: right;">{{ frappe.format_date(doc.transaction_date) }}</td>
</tr>
<tr>
<td style="padding: 5px 0;"><strong>{{ _("Delivery Date") }}:</strong></td>
<td style="text-align: right;">{{ frappe.format_date(doc.delivery_date) if doc.delivery_date else _("To be confirmed") }}</td>
</tr>
</table>
</div>
<!-- Items -->
<h3 style="color: #2c3e50; border-bottom: 2px solid #eee; padding-bottom: 10px;">{{ _("Order Items") }}</h3>
<table style="width: 100%; border-collapse: collapse;">
{% for item in doc.items %}
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 10px 0;">
<strong>{{ item.item_name }}</strong>
{% if item.item_code != item.item_name %}
<br><small style="color: #999;">{{ item.item_code }}</small>
{% endif %}
</td>
<td style="padding: 10px 0; text-align: center;">× {{ item.qty }}</td>
<td style="padding: 10px 0; text-align: right;">{{ item.get_formatted("amount", doc) }}</td>
</tr>
{% endfor %}
</table>
<!-- Totals -->
<table style="width: 100%; margin-top: 20px;">
<tr>
<td style="padding: 5px 0;">{{ _("Subtotal") }}</td>
<td style="text-align: right;">{{ doc.get_formatted("net_total") }}</td>
</tr>
{% if doc.total_taxes_and_charges %}
<tr>
<td style="padding: 5px 0;">{{ _("Tax") }}</td>
<td style="text-align: right;">{{ doc.get_formatted("total_taxes_and_charges") }}</td>
</tr>
{% endif %}
<tr style="font-size: 18px; font-weight: bold;">
<td style="padding: 10px 0; border-top: 2px solid #333;">{{ _("Total") }}</td>
<td style="padding: 10px 0; border-top: 2px solid #333; text-align: right;">{{ doc.get_formatted("grand_total") }}</td>
</tr>
</table>
<!-- Shipping Address -->
{% if doc.shipping_address_name %}
<h3 style="color: #2c3e50; margin-top: 30px;">{{ _("Shipping Address") }}</h3>
<p style="background: #f5f5f5; padding: 15px; border-radius: 5px;">
{{ doc.shipping_address | safe }}
</p>
{% endif %}
<!-- Footer -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; text-align: center; color: #666;">
<p>{{ _("If you have any questions, please contact us.") }}</p>
{% if company.phone_no %}
<p>{{ _("Phone") }}: {{ company.phone_no }}</p>
{% endif %}
{% if company.email %}
<p>{{ _("Email") }}: {{ company.email }}</p>
{% endif %}
</div>
</div>
</div>---
Example 4: Portal Page - Customer Dashboard
www/customer-dashboard/index.py
import frappe
def get_context(context):
# Require login
if frappe.session.user == "Guest":
frappe.local.flags.redirect_location = "/login"
raise frappe.Redirect
context.title = "My Dashboard"
context.no_cache = True
# Get customer for this user
customer = get_customer()
if not customer:
context.error = "No customer account linked to your profile"
return context
context.customer_name = frappe.db.get_value("Customer", customer, "customer_name")
# Summary stats
context.stats = {
"total_orders": frappe.db.count("Sales Order", {"customer": customer, "docstatus": 1}),
"pending_orders": frappe.db.count("Sales Order", {"customer": customer, "docstatus": 1, "status": ["not in", ["Completed", "Closed"]]}),
"total_invoices": frappe.db.count("Sales Invoice", {"customer": customer, "docstatus": 1}),
"outstanding_amount": get_outstanding_amount(customer)
}
# Recent orders
context.recent_orders = frappe.get_all(
"Sales Order",
filters={"customer": customer, "docstatus": 1},
fields=["name", "transaction_date", "grand_total", "status"],
order_by="transaction_date desc",
limit_page_length=5
)
# Pending invoices
context.pending_invoices = frappe.get_all(
"Sales Invoice",
filters={"customer": customer, "docstatus": 1, "outstanding_amount": [">", 0]},
fields=["name", "posting_date", "grand_total", "outstanding_amount", "due_date"],
order_by="due_date asc",
limit_page_length=10
)
return context
def get_customer():
"""Get customer linked to current user"""
contact = frappe.db.get_value(
"Contact",
{"user": frappe.session.user},
"name"
)
if contact:
link = frappe.db.get_value(
"Dynamic Link",
{"parent": contact, "link_doctype": "Customer"},
"link_name"
)
return link
return None
def get_outstanding_amount(customer):
"""Get total outstanding amount"""
result = frappe.db.sql("""
SELECT COALESCE(SUM(outstanding_amount), 0)
FROM `tabSales Invoice`
WHERE customer = %s AND docstatus = 1
""", customer)
return result[0][0] if result else 0www/customer-dashboard/index.html
{% extends "templates/web.html" %}
{% block title %}{{ _("My Dashboard") }}{% endblock %}
{% block page_content %}
<div class="container py-4">
{% if error %}
<div class="alert alert-warning">{{ error }}</div>
{% else %}
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1>{{ _("Welcome") }}, {{ customer_name }}</h1>
<p class="text-muted">{{ frappe.get_fullname() }}</p>
</div>
<a href="/orders" class="btn btn-primary">{{ _("View All Orders") }}</a>
</div>
<!-- Stats Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h2 class="text-primary">{{ stats.total_orders }}</h2>
<p class="text-muted mb-0">{{ _("Total Orders") }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h2 class="text-warning">{{ stats.pending_orders }}</h2>
<p class="text-muted mb-0">{{ _("Pending Orders") }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h2 class="text-info">{{ stats.total_invoices }}</h2>
<p class="text-muted mb-0">{{ _("Total Invoices") }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h2 class="text-danger">{{ frappe.format(stats.outstanding_amount, {'fieldtype': 'Currency'}) }}</h2>
<p class="text-muted mb-0">{{ _("Outstanding") }}</p>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Recent Orders -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ _("Recent Orders") }}</h5>
</div>
<div class="card-body">
{% if recent_orders %}
<table class="table table-sm">
<thead>
<tr>
<th>{{ _("Order") }}</th>
<th>{{ _("Date") }}</th>
<th>{{ _("Status") }}</th>
<th class="text-right">{{ _("Total") }}</th>
</tr>
</thead>
<tbody>
{% for order in recent_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-{% if order.status == 'Completed' %}success{% elif order.status == 'To Deliver and Bill' %}primary{% else %}secondary{% endif %}">
{{ 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 yet") }}</p>
{% endif %}
</div>
</div>
</div>
<!-- Pending Invoices -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ _("Pending Invoices") }}</h5>
</div>
<div class="card-body">
{% if pending_invoices %}
<table class="table table-sm">
<thead>
<tr>
<th>{{ _("Invoice") }}</th>
<th>{{ _("Due") }}</th>
<th class="text-right">{{ _("Outstanding") }}</th>
</tr>
</thead>
<tbody>
{% for inv in pending_invoices %}
<tr>
<td><a href="/invoices/{{ inv.name }}">{{ inv.name }}</a></td>
<td>
{% set days_overdue = frappe.utils.date_diff(frappe.utils.nowdate(), inv.due_date) %}
{% if days_overdue > 0 %}
<span class="text-danger">{{ days_overdue }} {{ _("days overdue") }}</span>
{% else %}
{{ frappe.format_date(inv.due_date) }}
{% endif %}
</td>
<td class="text-right">{{ frappe.format(inv.outstanding_amount, {'fieldtype': 'Currency'}) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-success">{{ _("No pending invoices!") }}</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}---
Example 5: Custom Jinja Methods Library
myapp/jinja_utils/__init__.py
# Empty - just makes it a packagemyapp/jinja_utils/methods.py
"""
Custom Jinja methods available in all templates.
Register in hooks.py: jenv = {"methods": ["myapp.jinja_utils.methods"]}
"""
import frappe
from frappe.utils import flt, cint
def get_company_logo(company_name):
"""
Get company logo URL.
Usage: {{ get_company_logo(doc.company) }}
Returns: URL string or empty string
"""
if not company_name:
return ""
return frappe.db.get_value("Company", company_name, "company_logo") or ""
def get_company_info(company_name, field=None):
"""
Get company information.
Usage:
{{ get_company_info(doc.company) }} # Returns full doc
{{ get_company_info(doc.company, "phone_no") }} # Returns specific field
"""
if not company_name:
return "" if field else {}
if field:
return frappe.db.get_value("Company", company_name, field) or ""
return frappe.get_doc("Company", company_name).as_dict()
def format_address(address_name, format="html"):
"""
Format address for display.
Usage:
{{ format_address(doc.customer_address) | safe }}
{{ format_address(doc.customer_address, "text") }}
"""
if not address_name:
return ""
try:
address = frappe.get_doc("Address", address_name)
except frappe.DoesNotExistError:
return ""
parts = []
if address.address_line1:
parts.append(address.address_line1)
if address.address_line2:
parts.append(address.address_line2)
city_line = []
if address.city:
city_line.append(address.city)
if address.state:
city_line.append(address.state)
if address.pincode:
city_line.append(address.pincode)
if city_line:
parts.append(", ".join(city_line))
if address.country:
parts.append(address.country)
separator = "<br>" if format == "html" else "\n"
return separator.join(parts)
def get_item_image(item_code, size="medium"):
"""
Get item image URL.
Usage: {{ get_item_image(item.item_code) }}
"""
if not item_code:
return ""
image = frappe.db.get_value("Item", item_code, "image")
return image if image else ""
def get_customer_balance(customer):
"""
Get customer outstanding balance.
Usage: {{ get_customer_balance(doc.customer) }}
"""
if not customer:
return 0
result = frappe.db.sql("""
SELECT COALESCE(SUM(debit - credit), 0)
FROM `tabGL Entry`
WHERE party_type = 'Customer'
AND party = %s
AND is_cancelled = 0
""", customer)
return flt(result[0][0]) if result else 0
def get_stock_qty(item_code, warehouse=None):
"""
Get stock quantity for item.
Usage:
{{ get_stock_qty(item.item_code) }} # All warehouses
{{ get_stock_qty(item.item_code, "Main Warehouse") }}
"""
if not item_code:
return 0
filters = {"item_code": item_code}
if warehouse:
filters["warehouse"] = warehouse
result = frappe.db.sql("""
SELECT COALESCE(SUM(actual_qty), 0)
FROM `tabBin`
WHERE item_code = %s
{warehouse_filter}
""".format(
warehouse_filter="AND warehouse = %s" if warehouse else ""
), (item_code, warehouse) if warehouse else (item_code,))
return flt(result[0][0]) if result else 0
def number_to_words(number, currency=""):
"""
Convert number to words (simplified).
Usage: {{ number_to_words(doc.grand_total, doc.currency) }}
"""
# Simplified implementation - use a proper library in production
from frappe.utils import money_in_words
return money_in_words(number, currency)myapp/jinja_utils/filters.py
"""
Custom Jinja filters available in all templates.
Register in hooks.py: jenv = {"filters": ["myapp.jinja_utils.filters"]}
"""
import re
def phone_format(value, country="US"):
"""
Format phone number.
Usage: {{ doc.phone | phone_format }}
"""
if not value:
return ""
# Remove non-digits
digits = re.sub(r'\D', '', str(value))
# Format based on length
if len(digits) == 10:
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
elif len(digits) == 11 and digits[0] == '1':
return f"+1 ({digits[1:4]}) {digits[4:7]}-{digits[7:]}"
return value
def initials(value, max_chars=2):
"""
Get initials from name.
Usage: {{ doc.customer_name | initials }}
"""
if not value:
return ""
words = str(value).split()
return "".join(word[0].upper() for word in words[:max_chars])
def nl2br(value):
"""
Convert newlines to <br> tags.
Usage: {{ doc.description | nl2br | safe }}
"""
if not value:
return ""
return str(value).replace("\n", "<br>")
def pluralize(count, singular, plural=None):
"""
Return singular or plural based on count.
Usage: {{ items|length }} {{ items|length | pluralize("item", "items") }}
"""
if plural is None:
plural = singular + "s"
return singular if count == 1 else plural
def mask_email(value):
"""
Mask email for privacy.
Usage: {{ doc.email | mask_email }}
Returns: j***@example.com
"""
if not value or "@" not in value:
return value
local, domain = value.split("@", 1)
if len(local) > 1:
masked = local[0] + "***"
else:
masked = "***"
return f"{masked}@{domain}"
def highlight(value, term):
"""
Highlight search term in text.
Usage: {{ doc.description | highlight(search_term) | safe }}
"""
if not value or not term:
return value
pattern = re.compile(f'({re.escape(term)})', re.IGNORECASE)
return pattern.sub(r'<mark>\1</mark>', str(value))hooks.py Registration
# myapp/hooks.py
jenv = {
"methods": [
"myapp.jinja_utils.methods"
],
"filters": [
"myapp.jinja_utils.filters"
]
}---
Quick Reference: Example Usage
| Need | Example Code |
|---|---|
| Company logo | {{ get_company_logo(doc.company) }} |
| Format address | `{{ format_address(doc.customer_address) \ |
| Phone format | `{{ doc.phone \ |
| Customer balance | {{ get_customer_balance(doc.customer) }} |
| Initials | `{{ doc.customer_name \ |
| Newlines to BR | `{{ doc.notes \ |
| Mask email | `{{ doc.email \ |
Print Format: Jinja vs Print Designer vs JS Microtemplate
Decision tree for choosing the right print format technology in Frappe/ERPNext.
---
Master Decision: Which Print Technology?
Need a print format?
│
├─► Simple layout, standard fields
│ └── Standard Print Format (no code)
│ - Setup > Printing > Print Format Builder
│ - Drag-and-drop field placement
│ - Works on all versions (v14/v15/v16)
│ - No coding required
│
├─► Custom layout with logic/calculations
│ └── Jinja Print Format ✓ (THIS skill)
│ - Full template control with {{ }} and {% %}
│ - Server-side, Python-powered
│ - Works on v14+ (all versions)
│ - Conditional sections, computed values, child table loops
│ - CSS styling in <style> block
│ - PDF via wkhtmltopdf (v14/v15) or Chrome (v16)
│
├─► Visual drag-and-drop design [v15+]
│ └── Print Designer (separate Frappe app)
│ - Install: bench get-app print_designer
│ - No coding required — WYSIWYG editor
│ - Uses WeasyPrint for PDF rendering (not wkhtmltopdf)
│ - Dynamic fields via drag-and-drop binding
│ - v15+: stable; v14: NOT supported
│ - GitHub: https://github.com/frappe/print_designer
│
└─► Report print format (Query Report / Script Report)
└── JS Microtemplate ({%= %} syntax)
⚠️ NOT Jinja — completely different engine
- Client-side, JavaScript-powered
- Uses {%= %} for expressions, {% %} for logic
- Access: data[], filters, report_summary
- See frappe-syntax-print skill for full coverage---
Key Differences: Three Template Engines
| Feature | Jinja | Print Designer | JS Microtemplate |
|---|---|---|---|
| Syntax | {{ }} / {% %} | No code (visual) | {%= %} / {% %} |
| Execution | Server-side (Python) | Server-side (WeasyPrint) | Client-side (JavaScript) |
| Used for | Print Formats, Emails, Portal | Print Formats only | Report Print Formats only |
| Versions | v14/v15/v16 | v15+ (separate app) | v14/v15/v16 |
| PDF engine | wkhtmltopdf / Chrome (v16) | WeasyPrint | wkhtmltopdf / Chrome (v16) |
| Coding required | Yes (HTML + Jinja) | No (drag-and-drop) | Yes (HTML + JS) |
| Child tables | {% for row in doc.items %} | Visual table binding | {% for(var i=0; i<data.length; i++) %} |
| Formatting | doc.get_formatted("field") | Automatic | format_currency(value) |
---
Critical Rules
NEVER Mix Template Engines
⚠️ FATAL MISTAKE: Using Jinja syntax in a Report Print Format
Report Print Format uses JS microtemplate:
WRONG: {{ doc.grand_total }} ← Jinja syntax, will NOT render
RIGHT: {%= format_currency(row.grand_total) %} ← JS microtemplate
Jinja Print Format uses Jinja:
WRONG: {%= doc.grand_total %} ← JS syntax, will NOT render
RIGHT: {{ doc.get_formatted("grand_total") }} ← Jinja syntaxWhen to Choose What
| Scenario | Choose | Why |
|---|---|---|
| Invoice/Quote/PO with custom layout | Jinja | Full control, version-independent |
| Non-technical user designs prints | Print Designer | No code needed, visual editor |
| Query/Script Report output | JS Microtemplate | Only option for reports |
| Simple field rearrangement | Standard | Fastest, no code |
| Complex multi-page with calculations | Jinja | Most powerful, full Python access |
| Label/barcode printing | Print Designer | Better for precise positioning |
---
Print Designer Details (v15+)
Installation
bench get-app print_designer
bench --site sitename install-app print_designerHow It Works
1. Go to Print Designer in the sidebar 2. Create new format, select DocType 3. Drag fields onto the canvas 4. Bind dynamic data via field selector 5. Style visually (fonts, colors, borders, positioning) 6. Save — format appears in Print Format dropdown
Print Designer vs Jinja Print Format
Choose Print Designer when:
├─ Users need to modify layouts without developer help
├─ Precise pixel positioning is required (labels, certificates)
├─ No conditional logic needed (or very simple show/hide)
└─ Running v15 or later
Choose Jinja Print Format when:
├─ Complex business logic in the template (calculations, conditionals)
├─ Need to aggregate or transform data before display
├─ Must work on v14 (Print Designer requires v15+)
├─ Need programmatic control (loops with counters, custom grouping)
└─ Integration with custom Jinja methods/filtersLimitations of Print Designer
- No arbitrary Python logic — limited to field binding and simple expressions
- Requires WeasyPrint — different PDF output from wkhtmltopdf/Chrome
- No Email/Portal support — Print Designer is for print formats ONLY
- v15+ only — not available on v14 installations
---
Cross-References
| Skill | Covers |
|---|---|
frappe-impl-jinja (this skill) | Jinja Print Format creation workflow |
frappe-syntax-jinja | Jinja syntax reference (filters, tags, context) |
frappe-syntax-print | Full print system: all format types, JS microtemplate, PDF engines |
---
Version Compatibility Matrix
| Technology | v14 | v15 | v16 |
|---|---|---|---|
| Standard Print Format | Yes | Yes | Yes |
| Jinja Print Format | Yes | Yes | Yes |
| Print Designer | No | Yes | Yes |
| JS Microtemplate (Reports) | Yes | Yes | Yes |
| wkhtmltopdf | Yes | Yes | Deprecated |
| Chrome PDF | No | No | Yes |
| WeasyPrint (Print Designer) | No | Yes | Yes |
Jinja Templates - Implementation Workflows
Step-by-step implementation patterns for all template types.
---
Workflow 1: Custom Print Format (Basic)
Goal: Create a simple custom invoice format
Step 1: Create Print Format Record
Setup > Printing > Print Format > New
Fill in:
- Name: My Invoice Format
- DocType: Sales Invoice
- Module: Accounts (or your custom module)
- Standard: No (unchecked)
- Print Format Type: JinjaStep 2: Add Basic Structure
<style>
.print-format { font-family: Arial, sans-serif; font-size: 12px; }
.header { margin-bottom: 20px; }
.title { font-size: 24px; font-weight: bold; }
.meta { color: #666; }
.table { width: 100%; border-collapse: collapse; margin: 20px 0; }
.table th, .table td { border: 1px solid #ccc; padding: 8px; }
.table th { background: #f0f0f0; }
.text-right { text-align: right; }
</style>
<div class="header">
<div class="title">{{ doc.select_print_heading or _("Invoice") }}</div>
<div class="meta">
{{ doc.name }} | {{ doc.get_formatted("posting_date") }}
</div>
</div>
<p><strong>{{ doc.customer_name }}</strong></p>
<table class="table">
<thead>
<tr>
<th>#</th>
<th>{{ _("Item") }}</th>
<th class="text-right">{{ _("Qty") }}</th>
<th class="text-right">{{ _("Rate") }}</th>
<th class="text-right">{{ _("Amount") }}</th>
</tr>
</thead>
<tbody>
{% for row in doc.items %}
<tr>
<td>{{ row.idx }}</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>
{% endfor %}
</tbody>
</table>
<p class="text-right">
<strong>{{ _("Total") }}: {{ doc.get_formatted("grand_total") }}</strong>
</p>Step 3: Test
1. Open any Sales Invoice 2. Menu > Print 3. Select "My Invoice Format" 4. Verify layout 5. Test PDF download
---
Workflow 2: Print Format with Company Header
Goal: Professional format with company logo and address
Step 1: Create Print Format (as above)
Step 2: Add Company Header Section
<style>
.company-header { display: table; width: 100%; margin-bottom: 20px; }
.company-logo { display: table-cell; width: 150px; vertical-align: top; }
.company-logo img { max-width: 120px; max-height: 80px; }
.company-info { display: table-cell; vertical-align: top; text-align: right; }
.doc-header { display: table; width: 100%; margin: 20px 0; }
.doc-title { display: table-cell; width: 50%; }
.doc-meta { display: table-cell; width: 50%; text-align: right; }
</style>
{# Get company details #}
{% set company = frappe.get_doc("Company", doc.company) %}
{% set company_address = frappe.db.get_value("Dynamic Link",
{"link_doctype": "Company", "link_name": doc.company, "parenttype": "Address"},
"parent") %}
<div class="company-header">
<div class="company-logo">
{% if company.company_logo %}
<img src="{{ company.company_logo }}" alt="{{ company.company_name }}">
{% endif %}
</div>
<div class="company-info">
<strong>{{ company.company_name }}</strong><br>
{% if company_address %}
{% set addr = frappe.get_doc("Address", company_address) %}
{{ addr.address_line1 }}<br>
{% if addr.address_line2 %}{{ addr.address_line2 }}<br>{% endif %}
{{ addr.city }}{% if addr.pincode %}, {{ addr.pincode }}{% endif %}<br>
{% endif %}
{% if company.phone_no %}{{ _("Tel") }}: {{ company.phone_no }}<br>{% endif %}
{% if company.email %}{{ company.email }}{% endif %}
</div>
</div>
<div class="doc-header">
<div class="doc-title">
<h1>{{ doc.select_print_heading or _("Invoice") }}</h1>
<p><strong>{{ doc.name }}</strong></p>
</div>
<div class="doc-meta">
<p><strong>{{ _("Date") }}:</strong> {{ doc.get_formatted("posting_date") }}</p>
<p><strong>{{ _("Due Date") }}:</strong> {{ doc.get_formatted("due_date") }}</p>
</div>
</div>
{# Rest of invoice content... #}---
Workflow 3: Multi-Page Print Format
Goal: Format with proper page breaks and headers/footers
Step 1: Add Page Break CSS
<style>
@page {
margin: 1.5cm;
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
}
}
.page-break {
page-break-before: always;
}
.avoid-break {
page-break-inside: avoid;
}
/* Repeat table header on each page */
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
</style>Step 2: Structure Content for Page Breaks
{# Header on first page #}
<div class="first-page-header">
{# Company info, document info #}
</div>
{# Items table with repeating header #}
<table class="items-table">
<thead>
<tr>
<th>{{ _("Item") }}</th>
<th>{{ _("Qty") }}</th>
<th>{{ _("Amount") }}</th>
</tr>
</thead>
<tbody>
{% for row in doc.items %}
<tr class="avoid-break">
<td>{{ row.item_name }}</td>
<td>{{ row.qty }}</td>
<td>{{ row.get_formatted("amount", doc) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{# Force totals to new page if needed #}
<div class="avoid-break">
<table class="totals-table">
<tr><td>{{ _("Subtotal") }}</td><td>{{ doc.get_formatted("net_total") }}</td></tr>
<tr><td>{{ _("Tax") }}</td><td>{{ doc.get_formatted("total_taxes_and_charges") }}</td></tr>
<tr><td><strong>{{ _("Total") }}</strong></td><td><strong>{{ doc.get_formatted("grand_total") }}</strong></td></tr>
</table>
</div>
{# Signature section - always on its own space #}
<div class="page-break"></div>
<div class="signature-section">
<h3>{{ _("Terms and Conditions") }}</h3>
{{ doc.terms | safe if doc.terms else '' }}
<div style="margin-top: 50px;">
<div style="width: 200px; border-top: 1px solid #000;">
{{ _("Authorized Signature") }}
</div>
</div>
</div>---
Workflow 4: Email Template for Notifications
Goal: Payment reminder email linked to Sales Invoice
Step 1: Create Email Template
Setup > Email > Email Template > New
Fill in:
- Name: Payment Reminder
- Subject: Invoice {{ doc.name }} - Payment Reminder
- DocType: Sales Invoice
- Module: AccountsStep 2: Add Email Content
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<p>{{ _("Dear") }} {{ doc.customer_name }},</p>
<p>{{ _("This is a friendly reminder that the following invoice is due for payment:") }}</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="background: #f5f5f5;">
<td style="padding: 10px; border: 1px solid #ddd;"><strong>{{ _("Invoice Number") }}</strong></td>
<td style="padding: 10px; border: 1px solid #ddd;">{{ doc.name }}</td>
</tr>
<tr>
<td style="padding: 10px; border: 1px solid #ddd;"><strong>{{ _("Invoice Date") }}</strong></td>
<td style="padding: 10px; border: 1px solid #ddd;">{{ frappe.format_date(doc.posting_date) }}</td>
</tr>
<tr style="background: #f5f5f5;">
<td style="padding: 10px; border: 1px solid #ddd;"><strong>{{ _("Due Date") }}</strong></td>
<td style="padding: 10px; border: 1px solid #ddd;">{{ frappe.format_date(doc.due_date) }}</td>
</tr>
<tr>
<td style="padding: 10px; border: 1px solid #ddd;"><strong>{{ _("Amount Due") }}</strong></td>
<td style="padding: 10px; border: 1px solid #ddd; font-weight: bold; color: #c00;">
{{ doc.get_formatted("outstanding_amount") }}
</td>
</tr>
</table>
{% if doc.items %}
<p><strong>{{ _("Invoice Items") }}:</strong></p>
<ul style="padding-left: 20px;">
{% for item in doc.items[:5] %}
<li style="margin-bottom: 5px;">{{ item.item_name }} × {{ item.qty }}</li>
{% endfor %}
{% if doc.items | length > 5 %}
<li style="color: #666;">{{ _("and {0} more items...").format(doc.items | length - 5) }}</li>
{% endif %}
</ul>
{% endif %}
<p>{{ _("Please make payment at your earliest convenience.") }}</p>
<p>{{ _("If you have already made payment, please disregard this reminder.") }}</p>
<p style="margin-top: 30px;">
{{ _("Best regards") }},<br>
<strong>{{ frappe.db.get_value("Company", doc.company, "company_name") }}</strong>
</p>
</div>Step 3: Use in Notification or Code
Option A: Notification (Auto-triggered)
Setup > Notification > New
- Name: Payment Reminder
- Channel: Email
- Document Type: Sales Invoice
- Send Alert On: Days After (e.g., 7 days after due_date)
- Condition: doc.outstanding_amount > 0
- Message: Use Email Template > Payment ReminderOption B: Code (Manual trigger)
# In Server Script or Controller
def send_payment_reminder(doc):
template = frappe.db.get_single_value("Email Template", "Payment Reminder")
frappe.sendmail(
recipients=[doc.contact_email or get_customer_email(doc.customer)],
subject=frappe.render_template(template.subject, {"doc": doc}),
message=frappe.render_template(template.response, {"doc": doc}),
reference_doctype=doc.doctype,
reference_name=doc.name
)---
Workflow 5: Portal Page with User Data
Goal: Customer portal showing their orders
Step 1: Create Directory Structure
myapp/
└── www/
└── my-orders/
├── index.html
└── index.pyStep 2: Create Context (index.py)
# myapp/www/my-orders/index.py
import frappe
def get_context(context):
# Require login
if frappe.session.user == "Guest":
frappe.local.flags.redirect_location = "/login"
raise frappe.Redirect
context.title = "My Orders"
context.no_cache = True # Always fresh data
# Get customer linked to this user
customer = get_customer_for_user(frappe.session.user)
if not customer:
context.orders = []
context.message = "No customer account found"
return context
# Get orders
context.orders = frappe.get_all(
"Sales Order",
filters={
"customer": customer,
"docstatus": ["!=", 2] # Not cancelled
},
fields=[
"name",
"transaction_date",
"grand_total",
"status",
"delivery_status"
],
order_by="transaction_date desc",
limit_page_length=50
)
return context
def get_customer_for_user(user):
"""Get customer linked to portal user"""
return frappe.db.get_value(
"Contact",
{"user": user},
"link_name"
)Step 3: Create Template (index.html)
{% extends "templates/web.html" %}
{% block title %}{{ _("My Orders") }}{% endblock %}
{% block page_content %}
<div class="container my-4">
<h1>{{ _("My Orders") }}</h1>
<p class="text-muted">
{{ _("Welcome") }}, {{ frappe.get_fullname() }}
</p>
{% if message %}
<div class="alert alert-info">{{ message }}</div>
{% endif %}
{% if orders %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{{ _("Order") }}</th>
<th>{{ _("Date") }}</th>
<th>{{ _("Status") }}</th>
<th>{{ _("Delivery") }}</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 'primary' if order.status == 'To Deliver and Bill' else 'secondary' }}">
{{ order.status }}
</span>
</td>
<td>{{ order.delivery_status or '-' }}</td>
<td class="text-right">
{{ frappe.format(order.grand_total, {'fieldtype': 'Currency'}) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-secondary">
{{ _("You don't have any orders yet.") }}
</div>
{% endif %}
</div>
{% endblock %}Step 4: Test
1. Login as a portal user
2. Navigate to /my-orders
3. Verify orders display correctly
4. Test with user who has no customer link---
Workflow 6: Custom Jinja Methods
Goal: Add reusable functions available in all templates
Step 1: Register in hooks.py
# myapp/hooks.py
jenv = {
"methods": [
"myapp.jinja_utils.methods"
],
"filters": [
"myapp.jinja_utils.filters"
]
}Step 2: Create Methods
# myapp/jinja_utils/methods.py
import frappe
def get_company_logo(company_name):
"""
Get company logo URL.
Usage in template: {{ get_company_logo(doc.company) }}
"""
logo = frappe.db.get_value("Company", company_name, "company_logo")
return logo if logo else "/assets/myapp/images/default-logo.png"
def get_customer_balance(customer):
"""
Get outstanding balance for customer.
Usage: {{ get_customer_balance(doc.customer) }}
"""
result = frappe.db.sql("""
SELECT COALESCE(SUM(debit - credit), 0)
FROM `tabGL Entry`
WHERE party_type = 'Customer'
AND party = %s
AND is_cancelled = 0
""", customer)
return result[0][0] if result else 0
def get_item_image(item_code):
"""
Get item image URL.
Usage: {{ get_item_image(item.item_code) }}
"""
image = frappe.db.get_value("Item", item_code, "image")
return image if image else "/assets/myapp/images/no-image.png"
def format_address(address_name, separator="<br>"):
"""
Format address document to display string.
Usage: {{ format_address(doc.customer_address) | safe }}
"""
if not address_name:
return ""
try:
address = frappe.get_doc("Address", address_name)
parts = []
if address.address_line1:
parts.append(address.address_line1)
if address.address_line2:
parts.append(address.address_line2)
if address.city:
city_line = address.city
if address.state:
city_line += f", {address.state}"
if address.pincode:
city_line += f" {address.pincode}"
parts.append(city_line)
if address.country:
parts.append(address.country)
return separator.join(parts)
except Exception:
return ""Step 3: Create Filters
# myapp/jinja_utils/filters.py
def phone_format(value):
"""
Format phone number.
Usage: {{ doc.phone | phone_format }}
"""
if not value:
return ""
# Remove non-digits
digits = ''.join(c for c in str(value) if c.isdigit())
# Format based on length
if len(digits) == 10:
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
elif len(digits) == 11 and digits[0] == '1':
return f"+1 ({digits[1:4]}) {digits[4:7]}-{digits[7:]}"
return value
def initials(value):
"""
Get initials from name.
Usage: {{ doc.customer_name | initials }}
"""
if not value:
return ""
words = str(value).split()
return "".join(w[0].upper() for w in words[:2])
def nl2br(value):
"""
Convert newlines to <br> tags.
Usage: {{ doc.description | nl2br | safe }}
"""
if not value:
return ""
return str(value).replace("\n", "<br>")
def money_words(amount, currency="USD"):
"""
Simple number to words (for display only).
Usage: {{ doc.grand_total | money_words }}
"""
# Simplified - for production, use a proper library
return f"{currency} {amount:,.2f}"Step 4: Deploy
bench --site sitename migrate
bench --site sitename clear-cacheStep 5: Use in Templates
{# In Print Format or Email Template #}
{# Methods #}
<img src="{{ get_company_logo(doc.company) }}" alt="Logo">
<p>{{ _("Balance") }}: {{ get_customer_balance(doc.customer) }}</p>
<p>{{ format_address(doc.customer_address) | safe }}</p>
{% for item in doc.items %}
<img src="{{ get_item_image(item.item_code) }}" width="50">
{% endfor %}
{# Filters #}
<p>{{ _("Phone") }}: {{ doc.contact_phone | phone_format }}</p>
<p>{{ doc.customer_name | initials }}</p>
<p>{{ doc.notes | nl2br | safe }}</p>
<p>{{ doc.grand_total | money_words }}</p>---
Workflow 7: Letter Head Integration
Goal: Use Letter Head with Print Format
Step 1: Create Letter Head
Setup > Printing > Letter Head > New
- Name: Company Letter Head
- Is Default: Yes
- Source: HTMLStep 2: Letter Head HTML
<div style="padding: 20px 0; border-bottom: 2px solid #333;">
<table style="width: 100%;">
<tr>
<td style="width: 150px; vertical-align: top;">
<img src="/files/company-logo.png" style="max-width: 120px;">
</td>
<td style="text-align: right; vertical-align: top;">
<strong style="font-size: 18px;">My Company Name</strong><br>
123 Business Street<br>
City, State 12345<br>
Tel: (123) 456-7890<br>
www.mycompany.com
</td>
</tr>
</table>
</div>Step 3: Use in Print Format
{# Letter Head is automatically included if set as default #}
{# Your Print Format content starts below the letter head #}
<h1 style="margin-top: 20px;">{{ doc.select_print_heading or _("Invoice") }}</h1>
{# ... rest of document ... #}Step 4: Override Letter Head per Document
{# Access letter head in template if needed #}
{% set letter_head = frappe.db.get_value("Letter Head", doc.letter_head, "content") %}
{# Or dynamically select based on condition #}
{% if doc.company == "Subsidiary Co" %}
{% set letter_head_name = "Subsidiary Letter Head" %}
{% else %}
{% set letter_head_name = "Main Letter Head" %}
{% endif %}---
Workflow 8: Report Print Format (JavaScript)
Goal: Print format for Query/Script Report (NOT Jinja!)
Step 1: Create/Edit Report
Customize > Report > [Your Report] > Edit
Add to "Print Format" field or create separate Print Format linked to reportStep 2: JavaScript Template
<!-- ⚠️ THIS IS JAVASCRIPT TEMPLATING, NOT JINJA! -->
<style>
.report-print { font-family: Arial, sans-serif; }
.report-title { font-size: 18px; font-weight: bold; margin-bottom: 10px; }
.report-table { width: 100%; border-collapse: collapse; }
.report-table th, .report-table td { border: 1px solid #ddd; padding: 6px; }
.report-table th { background: #f0f0f0; }
</style>
<div class="report-print">
<div class="report-title">{%= __("Sales Report") %}</div>
<!-- Filters used -->
{% if (filters.from_date) { %}
<p>From: {%= frappe.datetime.str_to_user(filters.from_date) %}</p>
{% } %}
{% if (filters.to_date) { %}
<p>To: {%= frappe.datetime.str_to_user(filters.to_date) %}</p>
{% } %}
<table class="report-table">
<thead>
<tr>
{% for (var i=0; i<report_columns.length; i++) { %}
{% if (!report_columns[i].hidden) { %}
<th>{%= report_columns[i].label %}</th>
{% } %}
{% } %}
</tr>
</thead>
<tbody>
{% for (var j=0; j<data.length; j++) { %}
<tr>
{% for (var i=0; i<report_columns.length; i++) { %}
{% if (!report_columns[i].hidden) { %}
<td>
{% var value = data[j][report_columns[i].fieldname]; %}
{% if (report_columns[i].fieldtype === "Currency") { %}
{%= format_currency(value) %}
{% } else if (report_columns[i].fieldtype === "Date") { %}
{%= frappe.datetime.str_to_user(value) %}
{% } else { %}
{%= value %}
{% } %}
</td>
{% } %}
{% } %}
</tr>
{% } %}
</tbody>
</table>
<!-- Summary if available -->
{% if (report_summary && report_summary.length) { %}
<div style="margin-top: 20px;">
<strong>{%= __("Summary") %}:</strong>
{% for (var k=0; k<report_summary.length; k++) { %}
<p>{%= report_summary[k].label %}: {%= report_summary[k].value %}</p>
{% } %}
</div>
{% } %}
</div>Key Differences from Jinja
| Aspect | Jinja (Print Format) | JS (Report Print) |
|---|---|---|
| Output | {{ variable }} | {%= variable %} |
| Logic | {% if %} | {% if () { %} |
| Loop | {% for x in y %} | {% for (var i=0; i<y.length; i++) { %} |
| End | {% endif %} | {% } %} |
| Translation | _("text") | __("text") |
| Data source | doc object | data[] array |
---
Workflow 9: Notification Template
Goal: Create system notifications with dynamic Jinja content
Step 1: Create Notification
Setup > Notification > New
- Name: Low Stock Alert
- Channel: Email (or System Notification, Slack)
- Document Type: Stock Ledger Entry
- Send Alert On: Value Change
- Condition: doc.actual_qty < doc.reorder_level and doc.reorder_level > 0Step 2: Write Message (Jinja)
<h3>{{ _("Low Stock Alert") }}</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px; border: 1px solid #ddd;"><strong>{{ _("Item") }}</strong></td>
<td style="padding: 8px; border: 1px solid #ddd;">{{ doc.item_code }}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd;"><strong>{{ _("Warehouse") }}</strong></td>
<td style="padding: 8px; border: 1px solid #ddd;">{{ doc.warehouse }}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd;"><strong>{{ _("Current Qty") }}</strong></td>
<td style="padding: 8px; border: 1px solid #ddd; color: red;">{{ doc.actual_qty }}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd;"><strong>{{ _("Reorder Level") }}</strong></td>
<td style="padding: 8px; border: 1px solid #ddd;">{{ doc.reorder_level }}</td>
</tr>
</table>
<p>{{ _("Please create a purchase order to replenish stock.") }}</p>Step 3: Configure Recipients
- Set recipients to specific roles, users, or use dynamic owner field
---
Workflow 10: Debugging Templates
Goal: Diagnose and fix template rendering issues
Step 1: Add debug output
<!-- DEBUG START -->
<!-- doc exists: {{ 'YES' if doc else 'NO' }} -->
<!-- doc.name: {{ doc.name if doc else 'N/A' }} -->
<!-- items count: {{ doc.items | length if doc.items else 0 }} -->
<!-- DEBUG END -->Step 2: Check Error Log
Setup > Error Log
Search for "Jinja" or "template" errorsStep 3: Test in bench console
# Test a print format template
doc = frappe.get_doc("Sales Invoice", "INV-001")
template = """{{ doc.name }} - {{ doc.get_formatted("grand_total") }}"""
print(frappe.render_template(template, {"doc": doc}))
# Test an email template
template = frappe.get_doc("Email Template", "Payment Reminder")
rendered = frappe.render_template(template.response, {"doc": doc})
print(rendered)Step 4: Common fixes
| Problem | Diagnostic | Fix |
|---|---|---|
| Blank output | Check Error Log | Fix syntax error or missing variable |
| "None" in output | Field is empty | Use `\ |
| Wrong currency | Missing parent doc | Pass doc to get_formatted() |
| Slow rendering | N+1 queries in loop | Prefetch data in controller/context |
| PDF different from screen | wkhtmltopdf CSS limits | Avoid flexbox, test PDF after changes |
---
Anti-Patterns Quick Reference
| Anti-Pattern | Risk | Correct Approach |
|---|---|---|
| Jinja syntax in Report Print | Blank output | Use JS templating {%= %} |
Raw values {{ doc.amount }} | Wrong format | Use get_formatted() |
| Missing parent in child formatting | Wrong currency | row.get_formatted("rate", doc) |
| DB queries in template loops | N+1 performance | Prefetch in controller |
| `\ | safe` on user input | XSS vulnerability |
| Hardcoded strings | Not translatable | Use _("text") |
<style> in email | Stripped by clients | Inline styles only |
| Heavy computation in templates | Slow render | Move logic to Python |
| Writing to DB in jenv methods | Transaction corruption | Methods must be read-only |
| No PDF testing | Layout breaks in PDF | ALWAYS test PDF output |
---
Quick Reference: Workflow Checklist
| Template Type | Location | Context | Key Steps |
|---|---|---|---|
| Print Format | Setup > Print | doc, frappe | Create record, Add HTML, Test print + PDF |
| Email Template | Setup > Email | doc, frappe | Create record, Add HTML, Link to Notification |
| Notification | Setup > Notification | doc, event data | Create rule, Write message, Set recipients |
| Portal Page | myapp/www/ | Custom | Create .py, Create .html, Test URL |
| Custom Methods | myapp/jinja_utils/ | N/A | Add to hooks.py, Create module, Migrate |
| Report Print | Report record | data[], filters | Edit report, Add JS template |