
Frappe Impl Customapp
- 59 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Guides building a custom Frappe app from scratch including bench new-app, app structure, DocTypes, fixtures, patches, dev workflow, and packaging.
About
An implementation skill for building a custom Frappe app from scratch, covering scaffolding, structure, and dev workflow. A developer uses it when creating a new Frappe app and managing DocTypes, fixtures, patches, and packaging.
- bench new-app walkthrough and app structure decisions
- Fixtures, patches, dev workflow (migrate/build/clear-cache), packaging, and dependencies
Frappe Impl Customapp by the numbers
- 59 all-time installs (skills.sh)
- Ranked #3,167 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-customappAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 59 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Guides building a custom Frappe app from scratch including bench new-app, app structure, DocTypes, fixtures, patches, dev workflow, and packaging.
Files
Frappe Custom App - Implementation
Workflow for building a custom Frappe app from scratch. For exact syntax, see frappe-syntax-customapp.
Version: v14/v15/v16 compatible
---
Main Decision: Do You Need a Custom App?
WHAT CHANGES DO YOU NEED?
|
+-- Add fields to existing DocType?
| +-- NO APP NEEDED: Custom Field + Property Setter
|
+-- Simple automation/validation (<50 lines)?
| +-- NO APP NEEDED: Server Script or Client Script
|
+-- Complex business logic, new DocTypes, or Python code?
| +-- YES: Create custom app
|
+-- Integration with external system (needs imports)?
| +-- YES: Custom app REQUIRED (Server Scripts block imports)
|
+-- Custom reports with complex queries?
| +-- Script Report (no app) vs Query Report (app optional)Rule: ALWAYS start with the simplest solution. Server Scripts + Custom Fields solve 70% of needs without a custom app.
---
Step 1: Create App Structure
cd ~/frappe-bench
bench new-app my_app
# Prompts: Title, Description, Publisher, Email, LicenseALWAYS verify immediately:
# my_app/my_app/__init__.py MUST have:
__version__ = "0.0.1"---
Step 2: Configure pyproject.toml (v15+)
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "my_app"
authors = [{ name = "Your Company", email = "dev@example.com" }]
description = "Your app description"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
dependencies = [
"requests>=2.28.0" # Only PyPI packages here
]
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"
# erpnext = ">=15.0.0,<16.0.0" # Only if neededRule: NEVER put frappe or erpnext in [project].dependencies -- they are NOT on PyPI.
---
Step 3: Configure hooks.py
app_name = "my_app"
app_title = "My App"
app_publisher = "Your Company"
app_description = "Description"
app_email = "dev@example.com"
app_license = "MIT"
required_apps = ["frappe"] # Or ["frappe", "erpnext"]
fixtures = [] # Configured laterRule: ALWAYS declare required_apps with all dependencies.
---
Step 4: Define Modules
# my_app/my_app/modules.txt
My App| App Size | Module Strategy |
|---|---|
| 1-5 DocTypes | ONE module with app name |
| 6-15 DocTypes | 2-4 modules by functional area |
| 15+ DocTypes | Modules by business domain |
Rule: Each DocType belongs to EXACTLY one module. Module name in modules.txt maps to directory: My Custom App --> my_custom_app/.
Adding a Module
mkdir -p my_app/my_app/new_module/doctype
touch my_app/my_app/new_module/__init__.py
# Add "New Module" to modules.txt
bench --site mysite migrate---
Step 5: Install and Create DocTypes
# Install app on site
bench --site mysite install-app my_app
# Create DocType (via UI recommended, or CLI)
bench --site mysite new-doctype "My Document" --module "My App"This creates:
my_app/my_app/doctype/my_document/
+-- my_document.json # DocType definition
+-- my_document.py # Controller
+-- my_document.js # Client script
+-- test_my_document.py # Tests---
Step 6: Add Hooks
doc_events (v14/v15/v16)
doc_events = {
"Sales Invoice": {
"validate": "my_app.events.sales_invoice.validate",
"on_submit": "my_app.events.sales_invoice.on_submit"
}
}extend_doctype_class (v16 ONLY -- preferred)
extend_doctype_class = {
"Sales Invoice": "my_app.overrides.sales_invoice.CustomSalesInvoice"
}Rule: ALWAYS call super().method() when overriding lifecycle methods in v16.
Scheduler Events
scheduler_events = {
"daily": ["my_app.tasks.daily_cleanup"],
"cron": {"0 9 * * 1-5": ["my_app.tasks.morning_report"]}
}See frappe-impl-hooks and frappe-impl-scheduler for complete patterns.
---
Step 7: Add Patches
Create Patch File
mkdir -p my_app/my_app/patches/v1_0
touch my_app/my_app/patches/__init__.py
touch my_app/my_app/patches/v1_0/__init__.py# my_app/my_app/patches/v1_0/populate_defaults.py
import frappe
def execute():
if not frappe.db.has_column("My DocType", "target_field"):
return # Skip if not applicable
batch_size = 1000
offset = 0
while True:
records = frappe.get_all("My DocType",
limit_page_length=batch_size, limit_start=offset)
if not records:
break
for r in records:
frappe.db.set_value("My DocType", r.name,
"target_field", "default", update_modified=False)
frappe.db.commit()
offset += batch_sizeRegister in patches.txt
[pre_model_sync]
# Patches that run BEFORE schema changes (backup data from deleted fields)
[post_model_sync]
# Patches that run AFTER schema changes (populate new fields)
my_app.patches.v1_0.populate_defaultsRules:
- ALWAYS check if patch is needed (guard clause)
- ALWAYS batch process 1000+ records
- ALWAYS commit after each batch
- NEVER run untested patches on production
---
Step 8: Fixtures Management
Configure in hooks.py
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "My App"]]},
{"dt": "Property Setter", "filters": [["module", "=", "My App"]]},
{"dt": "Role", "filters": [["name", "in", ["My App User", "My App Manager"]]]},
{"dt": "Workflow", "filters": [["document_type", "=", "My DocType"]]},
"My Category", # All records of your own config DocType
]Export and Verify
bench --site mysite export-fixtures --app my_app
ls my_app/my_app/fixtures/
# custom_field.json, property_setter.json, etc.Rules:
- ALWAYS filter fixtures to YOUR app's customizations
- NEVER include transactional data (invoices, orders)
- NEVER export without filters for shared DocTypes (Custom Field, Workflow)
- Fixtures auto-import during
bench migrate
---
Step 9: Development Workflow
Essential Commands
# After schema changes (DocType fields, hooks.py, patches)
bench --site mysite migrate
# After JS/CSS changes
bench build --app my_app
# After Python changes (controllers, events)
bench --site mysite clear-cache
# Full restart (production)
bench restart
# Watch mode (development)
bench watch # Auto-rebuilds on file changesDevelopment Cycle
1. Edit code/DocType
2. bench --site mysite migrate (if schema changed)
3. bench build --app my_app (if JS/CSS changed)
4. bench --site mysite clear-cache (if Python changed)
5. Test in browser
6. Repeat---
Step 10: Testing the App
# Run all tests
bench --site mysite run-tests --app my_app
# Run specific test
bench --site mysite run-tests --module my_app.my_module.doctype.my_doctype.test_my_doctype
# Run with verbose output
bench --site mysite run-tests --app my_app -vSee frappe-testing-unit for writing test cases.
---
Step 11: Packaging for Distribution
Via Git (standard method)
cd apps/my_app
git init && git add . && git commit -m "Initial commit"
git remote add origin https://github.com/org/my_app.git
git push -u origin mainInstall on Another Site
# On target bench
bench get-app https://github.com/org/my_app.git
bench --site target-site install-app my_app
bench --site target-site migrateVersion Management
# my_app/my_app/__init__.py
__version__ = "1.0.0" # Semantic versioning: MAJOR.MINOR.PATCH| Change Type | Version Bump | Example |
|---|---|---|
| Breaking changes | MAJOR | 1.x -> 2.0.0 |
| New features | MINOR | 1.1.x -> 1.2.0 |
| Bug fixes | PATCH | 1.2.0 -> 1.2.1 |
---
Step 12: App Dependencies
Frappe/ERPNext Dependencies
# hooks.py
required_apps = ["frappe", "erpnext"] # Install order matters# pyproject.toml
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"
erpnext = ">=15.0.0,<16.0.0"Python Package Dependencies
[project]
dependencies = ["requests>=2.28.0", "pandas>=1.5.0"]Rule: NEVER create circular dependencies between apps.
---
Version-Specific Considerations
| Aspect | v14 | v15 | v16 |
|---|---|---|---|
| Build config | setup.py | pyproject.toml | pyproject.toml |
| DocType extension | doc_events | doc_events | extend_doctype_class preferred |
| Python minimum | 3.10 | 3.10 | 3.11 |
| Patch format | INI sections | INI sections | INI sections |
v16 Breaking Changes to Know
extend_doctype_classhook: Cleaner extension via mixins- Data masking: Field-level privacy configuration
- UUID naming: New naming rule option
- Chrome PDF: wkhtmltopdf deprecated
---
Critical Rules Summary
ALWAYS
1. Start with bench new-app - NEVER create structure manually 2. Define __version__ in __init__.py 3. Use dynamic = ["version"] in pyproject.toml 4. Test patches on database copy before production 5. Filter fixtures to your app's customizations only 6. Version your patches (v1_0, v2_0 directories) 7. Test installation on a fresh site
NEVER
1. Put frappe/erpnext in [project].dependencies 2. Include transactional data in fixtures 3. Hardcode site-specific values (use settings DocTypes) 4. Skip frappe.db.commit() in large patches 5. Delete fields without backup patch 6. Modify core ERPNext files directly
---
Reference Files
| File | Contents |
|---|---|
| workflows.md | 8 step-by-step implementation guides |
| decision-tree.md | Complete decision flowcharts |
| examples.md | 5 complete working app examples |
| anti-patterns.md | Common mistakes to avoid |
See Also
frappe-syntax-customapp- Exact syntax referencefrappe-syntax-hooks- Hooks configuration syntaxfrappe-impl-hooks- Hook implementation patternsfrappe-core-database- Database operations for patchesfrappe-impl-scheduler- Scheduled task implementationfrappe-ops-bench- Bench commands referencefrappe-ops-app-lifecycle- App versioning and release managementfrappe-testing-unit- Writing tests for your appfrappe-testing-cicd- CI/CD pipeline for app testing
Anti-Patterns - Custom App Implementation
Common mistakes to avoid when building Frappe/ERPNext custom apps.
---
1. Build Configuration Errors
❌ Missing __version__ in __init__.py
# my_app/my_app/__init__.py
# WRONG - Empty or missing __version__
# (file is empty or only has docstring)# CORRECT
__version__ = "1.0.0"Impact: Build fails with cryptic flit error. App cannot be installed.
---
❌ Frappe/ERPNext in PyPI Dependencies
# WRONG - pyproject.toml
[project]
dependencies = [
"frappe>=15.0.0", # NOT ON PyPI!
"erpnext>=15.0.0", # NOT ON PyPI!
"requests>=2.28.0"
]# CORRECT
[project]
dependencies = [
"requests>=2.28.0" # Only PyPI packages here
]
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"
erpnext = ">=15.0.0,<16.0.0"Impact: pip install fails because frappe/erpnext are not on PyPI.
---
❌ Missing dynamic = ["version"]
# WRONG
[project]
name = "my_app"
version = "1.0.0" # Hardcoded version# CORRECT
[project]
name = "my_app"
dynamic = ["version"] # Read from __init__.pyImpact: Version in pyproject.toml and __init__.py can get out of sync.
---
2. Module Organization Errors
❌ Missing __init__.py in Module Directory
my_app/
└── my_module/
└── doctype/ # Missing __init__.py!
└── my_doctype/# CORRECT
my_app/
└── my_module/
├── __init__.py # Required!
└── doctype/
└── my_doctype/Impact: Python cannot import from module; bench migrate fails.
---
❌ Module Not in modules.txt
# Created doctype in "Reports" module
# But modules.txt only has:
# My App# CORRECT - modules.txt
My App
ReportsImpact: DocType doesn't appear in UI; "Module not found" errors.
---
❌ Module Name Mismatch
# modules.txt
My Custom Reports
# Directory
my_app/my_reports/ # Should be my_custom_reports/!Rule: My Custom Reports → my_custom_reports/ (spaces → underscores, lowercase)
Impact: Module registration fails silently; DocTypes orphaned.
---
3. Patch Errors
❌ Patch Without Error Handling
# WRONG
def execute():
records = frappe.get_all("My DocType")
for r in records:
doc = frappe.get_doc("My DocType", r.name)
doc.new_field = calculate_value(doc)
doc.save() # Could fail, leaves partial migration!# CORRECT
def execute():
records = frappe.get_all("My DocType", pluck="name")
for name in records:
try:
frappe.db.set_value(
"My DocType",
name,
"new_field",
calculate_value_sql(name),
update_modified=False
)
except Exception as e:
frappe.log_error(
title=f"Patch failed for {name}",
message=str(e)
)
continue
frappe.db.commit()Impact: Partial migration; data inconsistency; hard to re-run.
---
❌ Large Dataset Without Batching
# WRONG - Loads ALL records into memory
def execute():
records = frappe.get_all("Sales Invoice") # Could be millions!
for r in records:
process(r)# CORRECT - Batch processing
def execute():
batch_size = 1000
offset = 0
while True:
records = frappe.get_all(
"Sales Invoice",
limit_page_length=batch_size,
limit_start=offset
)
if not records:
break
for r in records:
process(r)
frappe.db.commit() # Free memory after each batch
offset += batch_sizeImpact: Memory exhaustion; server crash; incomplete migration.
---
❌ Wrong Patch Timing Section
# WRONG - Trying to access field that doesn't exist yet
[pre_model_sync]
my_app.patches.populate_new_field # new_field added in this release!# CORRECT - Field exists after model sync
[post_model_sync]
my_app.patches.populate_new_fieldImpact: "Column not found" error; patch fails.
---
❌ Not Checking If Patch Needed
# WRONG - Runs every migrate even if not needed
def execute():
# Always runs, even on fresh installs
migrate_old_data()# CORRECT - Skip if not applicable
def execute():
# Skip on fresh install (old column doesn't exist)
if not frappe.db.has_column("My DocType", "old_field"):
return
# Skip if already migrated
if frappe.db.count("My DocType", {"new_field": ["is", "set"]}):
return
migrate_old_data()Impact: Wasted processing; potential data corruption on re-runs.
---
4. Fixture Errors
❌ Exporting All Records Without Filter
# WRONG - Exports ALL Custom Fields, including from other apps!
fixtures = [
"Custom Field"
]# CORRECT - Filter to YOUR app's customizations
fixtures = [
{
"dt": "Custom Field",
"filters": [["module", "=", "My App"]]
}
]Impact: Deploys other apps' customizations; fixture conflicts.
---
❌ Including Transactional Data
# WRONG - Exports actual invoices!
fixtures = [
"Sales Invoice" # NO! This is transactional data
]# CORRECT - Only configuration DocTypes
fixtures = [
"My Settings", # Config only
"My Category", # Lookup data only
]Impact: Data overwritten on migrate; privacy violations; bloated fixtures.
---
❌ Exporting User Data in Fixtures
# WRONG
fixtures = [
{
"dt": "Custom Field",
"filters": [["owner", "=", "Administrator"]] # Don't filter by owner!
}
]Impact: Fixtures tied to specific user; fail on other installations.
---
❌ Missing Filter for DocType-Specific Fixtures
# WRONG - Exports ALL workflows, including ERPNext's
fixtures = [
"Workflow"
]# CORRECT
fixtures = [
{
"dt": "Workflow",
"filters": [["document_type", "in", ["My DocType", "My Other DocType"]]]
}
]Impact: Overwrites standard ERPNext workflows; unexpected behavior.
---
5. Hook Errors
❌ Not Calling Super in extend_doctype_class (v16)
# WRONG
class CustomSalesInvoice(SalesInvoice):
def validate(self):
# Missing super()!
self.custom_validation()# CORRECT
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate() # Call parent first!
self.custom_validation()Impact: Standard ERPNext validation skipped; data integrity issues.
---
❌ Infinite Loop in doc_events
# WRONG - Triggers another validate!
def validate(doc, method):
doc.custom_field = "value"
doc.save() # Triggers validate again → infinite loop!# CORRECT - Just set value, don't save
def validate(doc, method):
doc.custom_field = "value"
# doc.save() is called automatically after validateImpact: Server hangs; maximum recursion error.
---
❌ Heavy Processing in Synchronous Hooks
# WRONG - Blocks user while sending emails
def on_submit(doc, method):
for customer in get_all_customers():
send_email(customer, doc) # Takes minutes!# CORRECT - Use background job
def on_submit(doc, method):
frappe.enqueue(
"my_app.tasks.notify_customers",
doc_name=doc.name,
queue="short"
)Impact: UI freezes; timeout errors; poor user experience.
---
6. Dependency Errors
❌ Missing required_apps in hooks.py
# WRONG - App uses ERPNext DocTypes but doesn't declare dependency
required_apps = ["frappe"]
# Then in code:
from erpnext.selling.doctype.sales_order import SalesOrder # Fails if ERPNext not installed!# CORRECT
required_apps = ["frappe", "erpnext"]Impact: Import errors on installations without ERPNext.
---
❌ Circular Dependencies
# my_app/hooks.py
required_apps = ["frappe", "other_app"]
# other_app/hooks.py
required_apps = ["frappe", "my_app"] # Circular!Impact: Installation fails; bench hangs.
---
7. Client-Side Errors
❌ Modifying Core ERPNext Files
// WRONG - Editing erpnext/selling/doctype/sales_order/sales_order.js directly
// This will be overwritten on ERPNext upgrade!// CORRECT - Use doctype_js in hooks.py
// my_app/hooks.py
doctype_js = {
"Sales Order": "public/js/sales_order.js"
}Impact: Changes lost on upgrade; merge conflicts.
---
❌ Not Building After JS Changes
# Changed my_app/public/js/my_script.js
# But forgot to run:
bench build --app my_appImpact: Old JS served from cache; "changes not working".
---
8. Permission Errors
❌ permission_query_conditions Blocking Administrators
# WRONG - Even System Managers can't see records!
def get_permission_query_conditions(user):
return f"owner = {frappe.db.escape(user)}"# CORRECT - Bypass for administrators
def get_permission_query_conditions(user):
if "System Manager" in frappe.get_roles(user):
return "" # No restrictions
return f"owner = {frappe.db.escape(user)}"Impact: Admins can't see/debug records; support nightmare.
---
❌ SQL Injection in Permission Query
# WRONG - User input not escaped!
def get_permission_query_conditions(user):
return f"owner = '{user}'" # SQL injection risk!# CORRECT - Always escape
def get_permission_query_conditions(user):
return f"owner = {frappe.db.escape(user)}"Impact: Security vulnerability; data breach risk.
---
9. Deployment Errors
❌ Hardcoded Site-Specific Values
# WRONG
API_URL = "https://production.example.com/api"
ADMIN_EMAIL = "admin@mycompany.com"# CORRECT - Use settings DocType or site_config
settings = frappe.get_single("My Settings")
API_URL = settings.api_url
# Or from site_config.json
API_URL = frappe.conf.get("my_app_api_url")Impact: Breaks on different environments; secrets in code.
---
❌ Not Testing on Fresh Site
# Developed on site with existing data
# Never tested fresh installation
bench --site newsite install-app my_app
# Fails because patch assumes existing data!Rule: Always test your app installation on a fresh site.
Impact: Customers can't install; support tickets.
---
10. Version Compatibility Errors
❌ Using v16 Features Without Version Check
# WRONG - Will fail on v14/v15
# hooks.py
extend_doctype_class = {
"Sales Invoice": "my_app.overrides.sales_invoice.CustomSalesInvoice"
}# CORRECT - Support both v14/v15 and v16
# hooks.py
# v16+
extend_doctype_class = {
"Sales Invoice": "my_app.overrides.sales_invoice.CustomSalesInvoice"
}
# v14/v15 fallback
doc_events = {
"Sales Invoice": {
"validate": "my_app.events.sales_invoice.validate"
}
}Or choose one version to support and document it clearly in README.
Impact: Installation fails on older Frappe versions.
---
Quick Reference: Validation Checklist
Before releasing your app:
Build
- [ ]
__version__defined in__init__.py - [ ]
dynamic = ["version"]in pyproject.toml - [ ] No frappe/erpnext in
[project].dependencies - [ ] Frappe deps in
[tool.bench.frappe-dependencies]
Modules
- [ ] All modules listed in
modules.txt - [ ] All module directories have
__init__.py - [ ] Module names match directory names (with underscores)
Patches
- [ ] All patches have error handling
- [ ] Large datasets use batch processing
- [ ] Patches check if migration needed
- [ ] Correct section (pre/post model sync)
Fixtures
- [ ] All fixtures have appropriate filters
- [ ] No transactional data in fixtures
- [ ] No user-specific filters
Hooks
- [ ]
required_appsincludes all dependencies - [ ] No infinite loops in event handlers
- [ ] Heavy processing uses background jobs
- [ ] v16 override classes call
super()
Permissions
- [ ] Administrators not blocked
- [ ] SQL properly escaped
Testing
- [ ] Tested on fresh site installation
- [ ] Tested on target Frappe version(s)
- [ ] Assets built (
bench build)
Decision Trees - Custom App Implementation
Complete decision flowcharts for building Frappe/ERPNext custom apps.
---
Decision 1: Custom App vs Alternative Solution
┌─────────────────────────────────────────────────────────────────────────┐
│ START: What do you want to achieve? │
└───────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Add or modify fields on existing DocType? │
├──────────────────────────┬──────────────────────────────────────────────┤
│ YES │ NO │
│ │ │
│ ► Custom Field via UI │ │ │
│ ► Property Setter via UI │ ▼ │
│ ► Export as fixtures │ ┌────────────────────────────────────────┐ │
│ │ │ Add validation/automation? │ │
│ NO CUSTOM APP NEEDED │ └────────────────┬───────────────────────┘ │
│ │ │ │
└──────────────────────────┘ ▼ │
┌─────────────────────────────────────────────┐
│ Simple logic? (< 50 lines, no complex deps) │
├──────────────────────────┬──────────────────┤
│ YES │ NO │
│ │ │
│ ► Server Script │ │ │
│ ► Client Script │ ▼ │
│ │ ┌────────────┐ │
│ NO CUSTOM APP NEEDED │ │ New DocType│ │
│ │ │ needed? │ │
└──────────────────────────┘ └─────┬──────┘ │
│ │
┌────────────────────┴──────┐ │
│ │ │
▼ ▼ │
┌─────────────────┐ ┌─────────────┐
│ YES │ │ NO │
│ │ │ │
│ CUSTOM APP │ │ Server │
│ REQUIRED │ │ Script + │
│ │ │ Fixtures │
└─────────────────┘ └─────────────┘Summary Table
| Requirement | Solution | Custom App? |
|---|---|---|
| Add fields to existing DocType | Custom Field | ❌ |
| Change field properties | Property Setter | ❌ |
| Simple validation (< 50 lines) | Server Script | ❌ |
| Simple UI logic | Client Script | ❌ |
| Complex validation | Controller + Custom App | ✅ |
| New DocType with logic | Custom App | ✅ |
| Python API integrations | Whitelisted methods + Custom App | ✅ |
| Scheduled background jobs | Custom App | ✅ |
| Custom reports (Script Report) | Server Script or Custom App | Depends |
---
Decision 2: Extension Strategy Flowchart
┌─────────────────────────────────────────────────────────────────────────┐
│ HOW TO EXTEND EXISTING ERPNext FUNCTIONALITY? │
└───────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ What type of extension? │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ │
│ │ A. Add fields │ │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Create Custom Field via: │ │
│ │ ► UI: Customize Form > Add Field │ │
│ │ ► Fixture: Add to fixtures list in hooks.py │ │
│ │ │ │
│ │ For behavior: Add Property Setter │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ B. Modify logic │ │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Which Frappe version? │ │
│ ├────────────────────┬────────────────────────────────────────┤ │
│ │ v16+ │ v14/v15 │ │
│ │ │ │ │
│ │ Use hook: │ Use hook: │ │
│ │ extend_doctype_ │ doc_events = { │ │
│ │ class = { │ "Sales Invoice": { │ │
│ │ "Sales Invoice": │ "validate": "myapp.events.fn" │ │
│ │ "myapp.si" │ } │ │
│ │ } │ } │ │
│ └────────────────────┴────────────────────────────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ C. Override UI │ │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Form UI → Client Script (form_script in fixtures) │ │
│ │ List UI → Client Script (list_script) │ │
│ │ Portal → Override template via hooks.py │ │
│ │ Print → Custom Print Format │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ D. Add related │ │
│ │ DocType │ │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 1. Create DocType in YOUR app's module │ │
│ │ 2. Add Link field to parent OR │ │
│ │ 3. Add child table to parent via Custom Field │ │
│ │ 4. Register link via links property (for Related section) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘---
Decision 3: Patch Timing Flowchart
┌─────────────────────────────────────────────────────────────────────────┐
│ WHEN SHOULD THE PATCH RUN? │
└───────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Are you deleting or renaming a field/DocType? │
├───────────────────────────────┬─────────────────────────────────────────┤
│ YES │ NO │
│ │ │
│ ┌───────────────────────┐ │ │ │
│ │ [pre_model_sync] │ │ ▼ │
│ │ │ │ ┌───────────────────────────────────┐ │
│ │ BACKUP DATA FIRST! │ │ │ Are you populating a NEW field? │ │
│ │ │ │ ├───────────────────┬───────────────┤ │
│ │ 1. Read old values │ │ │ YES │ NO │ │
│ │ 2. Store in new field │ │ │ │ │ │
│ │ or Custom Field │ │ │ [post_model_sync] │ │ │
│ │ 3. Commit │ │ │ │ │ │
│ │ │ │ │ Field must exist │ Data cleanup? │ │
│ │ Then delete field in │ │ │ before we can │ │ │
│ │ JSON/model │ │ │ populate it │ Either works │ │
│ └───────────────────────┘ │ └───────────────────┴───────────────┘ │
└───────────────────────────────┴─────────────────────────────────────────┘
EXECUTION ORDER
═══════════════
┌─────────────────────────────────────────┐
│ 1. [pre_model_sync] patches run │
│ (Old schema still present) │
└────────────────────┬────────────────────┘
▼
┌─────────────────────────────────────────┐
│ 2. Model sync (schema changes applied) │
│ (New fields added, old removed) │
└────────────────────┬────────────────────┘
▼
┌─────────────────────────────────────────┐
│ 3. [post_model_sync] patches run │
│ (New schema available) │
└────────────────────┬────────────────────┘
▼
┌─────────────────────────────────────────┐
│ 4. Fixtures imported │
│ (Custom Fields, Property Setters) │
└─────────────────────────────────────────┘Patch Timing Quick Reference
| Scenario | Section | Reason |
|---|---|---|
| Backup data from field being deleted | [pre_model_sync] | Field still exists |
| Migrate data between existing fields | Either | Fields exist in both |
| Populate newly added field | [post_model_sync] | Field must exist first |
| Data cleanup/validation | [post_model_sync] | After schema stable |
| Rename DocType | [pre_model_sync] | Preserve relationships |
| Transform data format | [post_model_sync] | On stable schema |
---
Decision 4: Module Organization Flowchart
┌─────────────────────────────────────────────────────────────────────────┐
│ HOW MANY MODULES FOR YOUR APP? │
└───────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ How many DocTypes will you have? │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 1-5 DocTypes │ │
│ ├───────────────────────────────────────────────────────────────────┤ │
│ │ ► ONE module with app name │ │
│ │ │ │
│ │ my_app/ │ │
│ │ └── my_app/ # Module "My App" │ │
│ │ └── doctype/ │ │
│ │ ├── doctype_a/ │ │
│ │ └── doctype_b/ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 6-15 DocTypes │ │
│ ├───────────────────────────────────────────────────────────────────┤ │
│ │ ► 2-4 modules by FUNCTIONAL AREA │ │
│ │ │ │
│ │ my_app/ │ │
│ │ ├── core/ # Module "Core" - main DocTypes │ │
│ │ │ └── doctype/ │ │
│ │ ├── settings/ # Module "Settings" - config │ │
│ │ │ └── doctype/ │ │
│ │ └── integrations/ # Module "Integrations" - external │ │
│ │ └── doctype/ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 15+ DocTypes │ │
│ ├───────────────────────────────────────────────────────────────────┤ │
│ │ ► Modules by BUSINESS DOMAIN │ │
│ │ │ │
│ │ my_erp/ │ │
│ │ ├── sales/ # Module "Sales" │ │
│ │ │ └── doctype/ │ │
│ │ ├── purchasing/ # Module "Purchasing" │ │
│ │ │ └── doctype/ │ │
│ │ ├── inventory/ # Module "Inventory" │ │
│ │ │ └── doctype/ │ │
│ │ └── settings/ # Module "Settings" │ │
│ │ └── doctype/ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘Module Naming Rules
| modules.txt Entry | Directory Name | Notes |
|---|---|---|
My Custom App | my_custom_app/ | Spaces → underscores |
Sales | sales/ | Simple lowercase |
API Integrations | api_integrations/ | Multi-word |
---
Decision 5: Dependency Strategy
┌─────────────────────────────────────────────────────────────────────────┐
│ WHAT DEPENDENCIES DOES YOUR APP HAVE? │
└───────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Does it need Frappe only or ERPNext too? │
├────────────────────────────────┬────────────────────────────────────────┤
│ FRAPPE ONLY │ FRAPPE + ERPNEXT │
│ │ │
│ # hooks.py │ # hooks.py │
│ required_apps = ["frappe"] │ required_apps = ["frappe", "erpnext"] │
│ │ │
│ # pyproject.toml │ # pyproject.toml │
│ [tool.bench.frappe-deps] │ [tool.bench.frappe-deps] │
│ frappe = ">=15.0.0" │ frappe = ">=15.0.0" │
│ │ erpnext = ">=15.0.0" │
└────────────────────────────────┴────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Does it need Python packages (requests, pandas, etc.)? │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ # pyproject.toml │
│ [project] │
│ dependencies = [ │
│ "requests>=2.28.0", # PyPI packages go here │
│ "pandas>=1.5.0", │
│ ] │
│ │
│ ⚠️ NEVER put frappe or erpnext in [project].dependencies! │
│ They are NOT on PyPI. │
│ │
└─────────────────────────────────────────────────────────────────────────┘---
Decision 6: Fixture Filter Strategy
┌─────────────────────────────────────────────────────────────────────────┐
│ HOW TO FILTER FIXTURES? │
└───────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ What are you exporting? │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Custom Fields created by your app │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ {"dt": "Custom Field", │ │
│ │ "filters": [["module", "=", "My Custom App"]]} │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Property Setters for specific DocTypes │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ {"dt": "Property Setter", │ │
│ │ "filters": [["doc_type", "in", ["Sales Invoice", "Customer"]]]}│ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Your own DocType records (lookup/master data) │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ {"dt": "My Settings", │ │
│ │ "filters": [["is_standard", "=", 1]]} │ │
│ │ │ │
│ │ OR all records if it's pure configuration: │ │
│ │ "My Category" # exports all records │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Workflows for specific DocTypes │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ {"dt": "Workflow", │ │
│ │ "filters": [["document_type", "=", "My DocType"]]} │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
⚠️ NEVER EXPORT:
- User DocType (user accounts)
- Communication (emails/notes)
- Any transactional data (invoices, orders, etc.)
- Versions (audit trail)
- Activity Log---
Decision 7: Release Strategy
┌─────────────────────────────────────────────────────────────────────────┐
│ HOW TO VERSION AND RELEASE YOUR APP? │
└───────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Version numbering (Semantic Versioning) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ MAJOR.MINOR.PATCH → 1.2.3 │
│ │
│ MAJOR (1.x.x → 2.0.0) │
│ ► Breaking changes │
│ ► Schema changes requiring data migration │
│ ► Removed features │
│ │
│ MINOR (1.1.x → 1.2.0) │
│ ► New features │
│ ► New DocTypes │
│ ► New fields (backward compatible) │
│ │
│ PATCH (1.2.0 → 1.2.1) │
│ ► Bug fixes │
│ ► Security fixes │
│ ► No schema changes │
│ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Patch organization by version │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ my_app/ │
│ └── patches/ │
│ ├── v1_0/ # Patches for v1.0.x │
│ │ ├── initial_data_setup.py │
│ │ └── populate_defaults.py │
│ ├── v1_1/ # Patches for v1.1.x │
│ │ └── add_new_field_values.py │
│ └── v2_0/ # Patches for v2.0.x (breaking) │
│ ├── migrate_old_structure.py │
│ └── cleanup_deprecated.py │
│ │
│ # patches.txt │
│ [pre_model_sync] │
│ my_app.patches.v2_0.migrate_old_structure │
│ │
│ [post_model_sync] │
│ my_app.patches.v1_0.initial_data_setup │
│ my_app.patches.v1_0.populate_defaults │
│ my_app.patches.v1_1.add_new_field_values │
│ my_app.patches.v2_0.cleanup_deprecated │
│ │
└─────────────────────────────────────────────────────────────────────────┘Examples - Custom App Implementation
Complete working examples for Frappe/ERPNext custom apps.
---
Example 1: Simple Integration App
Use Case
Integration with external shipping API to get tracking information.
File Structure
shipping_integration/
├── pyproject.toml
├── README.md
├── shipping_integration/
│ ├── __init__.py
│ ├── hooks.py
│ ├── modules.txt
│ ├── patches.txt
│ ├── shipping_integration/ # Module
│ │ ├── __init__.py
│ │ └── doctype/
│ │ └── shipping_settings/
│ │ ├── shipping_settings.json
│ │ └── shipping_settings.py
│ ├── api/
│ │ ├── __init__.py
│ │ └── tracking.py
│ └── fixtures/
│ └── custom_field.json__init__.py
# shipping_integration/shipping_integration/__init__.py
__version__ = "1.0.0"pyproject.toml
# shipping_integration/pyproject.toml
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "shipping_integration"
authors = [
{ name = "Your Company", email = "dev@example.com" }
]
description = "Shipping carrier API integration for ERPNext"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
dependencies = [
"requests>=2.28.0"
]
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"
erpnext = ">=15.0.0,<16.0.0"hooks.py
# shipping_integration/shipping_integration/hooks.py
app_name = "shipping_integration"
app_title = "Shipping Integration"
app_publisher = "Your Company"
app_description = "Shipping carrier API integration"
app_email = "dev@example.com"
app_license = "MIT"
required_apps = ["frappe", "erpnext"]
fixtures = [
{
"dt": "Custom Field",
"filters": [["module", "=", "Shipping Integration"]]
}
]
# Add tracking button to Delivery Note
doctype_js = {
"Delivery Note": "public/js/delivery_note.js"
}modules.txt
Shipping IntegrationSettings DocType
# shipping_integration/shipping_integration/doctype/shipping_settings/shipping_settings.py
import frappe
from frappe.model.document import Document
class ShippingSettings(Document):
def validate(self):
if self.enabled and not self.api_key:
frappe.throw("API Key is required when integration is enabled")API Module
# shipping_integration/shipping_integration/api/tracking.py
import frappe
import requests
@frappe.whitelist()
def get_tracking_info(tracking_number):
"""Get tracking information from shipping carrier.
Args:
tracking_number: The shipment tracking number
Returns:
dict: Tracking information with status and events
"""
settings = frappe.get_single("Shipping Settings")
if not settings.enabled:
frappe.throw("Shipping integration is not enabled")
try:
response = requests.get(
f"{settings.api_url}/track/{tracking_number}",
headers={"Authorization": f"Bearer {settings.get_password('api_key')}"},
timeout=30
)
response.raise_for_status()
data = response.json()
return {
"status": data.get("status"),
"location": data.get("current_location"),
"estimated_delivery": data.get("eta"),
"events": data.get("tracking_events", [])
}
except requests.RequestException as e:
frappe.log_error(
title="Shipping API Error",
message=f"Failed to get tracking for {tracking_number}: {str(e)}"
)
frappe.throw(f"Could not retrieve tracking information: {str(e)}")
@frappe.whitelist()
def update_delivery_note_tracking(delivery_note, tracking_number):
"""Update Delivery Note with tracking information.
Args:
delivery_note: Delivery Note name
tracking_number: Tracking number to save
"""
doc = frappe.get_doc("Delivery Note", delivery_note)
doc.custom_tracking_number = tracking_number
tracking_info = get_tracking_info(tracking_number)
doc.custom_shipping_status = tracking_info.get("status")
doc.custom_estimated_delivery = tracking_info.get("estimated_delivery")
doc.save()
return {"message": "Tracking updated successfully"}Client Script
// shipping_integration/shipping_integration/public/js/delivery_note.js
frappe.ui.form.on("Delivery Note", {
refresh(frm) {
if (frm.doc.docstatus === 1 && frm.doc.custom_tracking_number) {
frm.add_custom_button(__("Get Tracking Update"), function() {
frappe.call({
method: "shipping_integration.api.tracking.get_tracking_info",
args: {
tracking_number: frm.doc.custom_tracking_number
},
callback(r) {
if (r.message) {
show_tracking_dialog(frm, r.message);
}
}
});
}, __("Shipping"));
}
}
});
function show_tracking_dialog(frm, tracking) {
let events_html = tracking.events.map(e =>
`<tr><td>${e.date}</td><td>${e.location}</td><td>${e.description}</td></tr>`
).join("");
frappe.msgprint({
title: __("Tracking Information"),
indicator: "blue",
message: `
<p><strong>Status:</strong> ${tracking.status}</p>
<p><strong>Location:</strong> ${tracking.location}</p>
<p><strong>ETA:</strong> ${tracking.estimated_delivery || "Unknown"}</p>
<table class="table table-bordered">
<thead><tr><th>Date</th><th>Location</th><th>Event</th></tr></thead>
<tbody>${events_html}</tbody>
</table>
`
});
}---
Example 2: Multi-Module Business App
Use Case
Project management app with separate modules for projects, tasks, and reports.
File Structure
project_plus/
├── pyproject.toml
├── project_plus/
│ ├── __init__.py
│ ├── hooks.py
│ ├── modules.txt
│ ├── patches.txt
│ ├── patches/
│ │ └── v1_0/
│ │ └── setup_default_statuses.py
│ ├── projects/ # Module: Projects
│ │ ├── __init__.py
│ │ └── doctype/
│ │ ├── pp_project/
│ │ └── pp_milestone/
│ ├── tasks/ # Module: Tasks
│ │ ├── __init__.py
│ │ └── doctype/
│ │ ├── pp_task/
│ │ └── pp_task_category/
│ ├── reports/ # Module: Reports
│ │ ├── __init__.py
│ │ └── report/
│ │ └── project_summary/
│ └── settings/ # Module: Settings
│ ├── __init__.py
│ └── doctype/
│ └── project_plus_settings/hooks.py
# project_plus/project_plus/hooks.py
app_name = "project_plus"
app_title = "Project Plus"
app_publisher = "Your Company"
app_description = "Enhanced project management"
app_email = "dev@example.com"
app_license = "MIT"
required_apps = ["frappe"]
fixtures = [
"PP Task Category", # Export all categories
{
"dt": "Role",
"filters": [["name", "like", "Project Plus%"]]
}
]
# Scheduler for deadline reminders
scheduler_events = {
"daily": [
"project_plus.tasks.deadline_reminder.send_reminders"
]
}
# Permissions
permission_query_conditions = {
"PP Project": "project_plus.projects.doctype.pp_project.pp_project.get_permission_query_conditions",
"PP Task": "project_plus.tasks.doctype.pp_task.pp_task.get_permission_query_conditions"
}
has_permission = {
"PP Project": "project_plus.projects.doctype.pp_project.pp_project.has_permission",
"PP Task": "project_plus.tasks.doctype.pp_task.pp_task.has_permission"
}modules.txt
Projects
Tasks
Reports
SettingsProject Controller
# project_plus/project_plus/projects/doctype/pp_project/pp_project.py
import frappe
from frappe.model.document import Document
from frappe import _
class PPProject(Document):
def validate(self):
self.validate_dates()
self.calculate_progress()
def on_update(self):
self.update_task_dates()
def validate_dates(self):
if self.end_date and self.start_date:
if self.end_date < self.start_date:
frappe.throw(_("End Date cannot be before Start Date"))
def calculate_progress(self):
"""Calculate project progress from tasks."""
tasks = frappe.get_all(
"PP Task",
filters={"project": self.name},
fields=["status", "progress"]
)
if not tasks:
self.progress = 0
return
total_progress = sum(t.progress or 0 for t in tasks)
self.progress = total_progress / len(tasks)
def update_task_dates(self):
"""Update task constraints when project dates change."""
if self.has_value_changed("start_date") or self.has_value_changed("end_date"):
frappe.db.sql("""
UPDATE `tabPP Task`
SET
expected_start = GREATEST(expected_start, %s),
expected_end = LEAST(expected_end, %s)
WHERE project = %s
AND docstatus = 0
""", (self.start_date, self.end_date, self.name))
def get_permission_query_conditions(user):
"""Return SQL conditions for list view filtering."""
if "System Manager" in frappe.get_roles(user):
return ""
return f"""(
`tabPP Project`.owner = {frappe.db.escape(user)}
OR `tabPP Project`.name IN (
SELECT parent FROM `tabPP Project Team`
WHERE user = {frappe.db.escape(user)}
)
)"""
def has_permission(doc, user, permission_type=None):
"""Check if user has permission on specific document."""
if "System Manager" in frappe.get_roles(user):
return True
if doc.owner == user:
return True
# Check team membership
return frappe.db.exists(
"PP Project Team",
{"parent": doc.name, "user": user}
)Setup Patch
# project_plus/project_plus/patches/v1_0/setup_default_statuses.py
import frappe
def execute():
"""Create default task categories."""
categories = [
{"category_name": "Development", "color": "#3498db"},
{"category_name": "Design", "color": "#9b59b6"},
{"category_name": "Testing", "color": "#e74c3c"},
{"category_name": "Documentation", "color": "#2ecc71"},
{"category_name": "Meeting", "color": "#f39c12"}
]
for cat in categories:
if not frappe.db.exists("PP Task Category", cat["category_name"]):
doc = frappe.new_doc("PP Task Category")
doc.update(cat)
doc.insert(ignore_permissions=True)
frappe.db.commit()patches.txt
[post_model_sync]
project_plus.patches.v1_0.setup_default_statuses---
Example 3: ERPNext Extension App
Use Case
Add custom pricing rules and approval workflow to Sales Order.
File Structure
sales_customization/
├── pyproject.toml
├── sales_customization/
│ ├── __init__.py
│ ├── hooks.py
│ ├── modules.txt
│ ├── patches.txt
│ ├── sales_customization/
│ │ ├── __init__.py
│ │ └── doctype/
│ │ └── pricing_approval_settings/
│ ├── overrides/ # v16 style
│ │ ├── __init__.py
│ │ └── sales_order.py
│ ├── events/ # v14/v15 style
│ │ ├── __init__.py
│ │ └── sales_order.py
│ └── fixtures/
│ ├── custom_field.json
│ ├── property_setter.json
│ └── workflow.jsonhooks.py (v14/v15 Compatible)
# sales_customization/sales_customization/hooks.py
app_name = "sales_customization"
app_title = "Sales Customization"
app_publisher = "Your Company"
app_description = "Custom pricing and approval for Sales"
app_email = "dev@example.com"
app_license = "MIT"
required_apps = ["frappe", "erpnext"]
fixtures = [
{
"dt": "Custom Field",
"filters": [["module", "=", "Sales Customization"]]
},
{
"dt": "Property Setter",
"filters": [["module", "=", "Sales Customization"]]
},
{
"dt": "Workflow",
"filters": [["document_type", "=", "Sales Order"]]
},
{
"dt": "Workflow State",
"filters": [["name", "like", "SO %"]]
},
{
"dt": "Workflow Action Master",
"filters": [["name", "in", ["Approve", "Reject", "Request Discount"]]]
}
]
# v14/v15 style - works in all versions
doc_events = {
"Sales Order": {
"validate": "sales_customization.events.sales_order.validate",
"on_submit": "sales_customization.events.sales_order.on_submit"
}
}
# Uncomment for v16 only:
# extend_doctype_class = {
# "Sales Order": "sales_customization.overrides.sales_order.CustomSalesOrder"
# }Event Handlers (v14/v15)
# sales_customization/sales_customization/events/sales_order.py
import frappe
from frappe import _
def validate(doc, method):
"""Validate Sales Order with custom rules."""
check_discount_approval(doc)
calculate_profit_margin(doc)
def on_submit(doc, method):
"""Actions on Sales Order submit."""
notify_high_value_order(doc)
def check_discount_approval(doc):
"""Check if discount requires approval."""
settings = frappe.get_single("Pricing Approval Settings")
if not settings.enable_discount_approval:
return
max_discount = settings.max_discount_without_approval or 10
for item in doc.items:
if item.discount_percentage and item.discount_percentage > max_discount:
if not doc.custom_discount_approved:
doc.workflow_state = "Pending Discount Approval"
doc.custom_discount_approval_required = 1
frappe.msgprint(
_("Discount of {0}% on {1} requires approval").format(
item.discount_percentage, item.item_code
),
alert=True,
indicator="orange"
)
def calculate_profit_margin(doc):
"""Calculate and store profit margin."""
total_cost = 0
for item in doc.items:
valuation_rate = frappe.db.get_value(
"Item",
item.item_code,
"valuation_rate"
) or 0
total_cost += valuation_rate * item.qty
if doc.grand_total:
doc.custom_profit_margin = (
(doc.grand_total - total_cost) / doc.grand_total * 100
)
def notify_high_value_order(doc):
"""Send notification for high-value orders."""
settings = frappe.get_single("Pricing Approval Settings")
if not settings.notify_high_value_orders:
return
if doc.grand_total >= settings.high_value_threshold:
frappe.sendmail(
recipients=settings.notification_email,
subject=f"High Value Order: {doc.name}",
message=f"""
<p>A high-value order has been submitted:</p>
<p><strong>Order:</strong> {doc.name}</p>
<p><strong>Customer:</strong> {doc.customer_name}</p>
<p><strong>Total:</strong> {doc.currency} {doc.grand_total:,.2f}</p>
"""
)Override Class (v16)
# sales_customization/sales_customization/overrides/sales_order.py
import frappe
from frappe import _
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
class CustomSalesOrder(SalesOrder):
"""Extended Sales Order with custom pricing rules."""
def validate(self):
super().validate()
self.check_discount_approval()
self.calculate_profit_margin()
def on_submit(self):
super().on_submit()
self.notify_high_value_order()
def check_discount_approval(self):
"""Check if discount requires approval."""
settings = frappe.get_single("Pricing Approval Settings")
if not settings.enable_discount_approval:
return
max_discount = settings.max_discount_without_approval or 10
for item in self.items:
if item.discount_percentage and item.discount_percentage > max_discount:
if not self.custom_discount_approved:
self.workflow_state = "Pending Discount Approval"
self.custom_discount_approval_required = 1
def calculate_profit_margin(self):
"""Calculate and store profit margin."""
total_cost = sum(
(frappe.db.get_value("Item", item.item_code, "valuation_rate") or 0) * item.qty
for item in self.items
)
if self.grand_total:
self.custom_profit_margin = (
(self.grand_total - total_cost) / self.grand_total * 100
)
def notify_high_value_order(self):
"""Send notification for high-value orders."""
settings = frappe.get_single("Pricing Approval Settings")
if settings.notify_high_value_orders and self.grand_total >= settings.high_value_threshold:
frappe.sendmail(
recipients=settings.notification_email,
subject=f"High Value Order: {self.name}",
message=f"Order {self.name} for {self.customer_name}: {self.currency} {self.grand_total:,.2f}"
)---
Example 4: Data Migration Patch
Use Case
Migrate from old custom field structure to new DocType.
File Structure
my_app/
└── patches/
└── v2_0/
├── __init__.py
└── migrate_to_new_structure.pyMigration Patch
# my_app/my_app/patches/v2_0/migrate_to_new_structure.py
"""
Migrate custom fields on Sales Invoice to dedicated child table.
Before: Sales Invoice had custom fields for line-item notes
After: Sales Invoice has child table "SI Custom Note" for notes
This is a [pre_model_sync] patch because we need to read the old
custom field values before they are removed.
"""
import frappe
def execute():
# Check if migration needed
if not frappe.db.has_column("Sales Invoice", "custom_line_notes"):
print("Migration not needed: custom_line_notes column doesn't exist")
return
# Check if already migrated
if frappe.db.exists("SI Custom Note", {"parenttype": "Sales Invoice"}):
print("Migration already done: SI Custom Note records exist")
return
batch_size = 500
offset = 0
total_migrated = 0
while True:
# Get invoices with notes
invoices = frappe.db.sql("""
SELECT name, custom_line_notes
FROM `tabSales Invoice`
WHERE custom_line_notes IS NOT NULL
AND custom_line_notes != ''
LIMIT %s OFFSET %s
""", (batch_size, offset), as_dict=True)
if not invoices:
break
for inv in invoices:
try:
migrate_invoice_notes(inv)
total_migrated += 1
except Exception as e:
frappe.log_error(
title=f"Migration failed for {inv.name}",
message=str(e)
)
continue
frappe.db.commit()
offset += batch_size
print(f"Migrated {total_migrated} invoices...")
frappe.db.commit()
print(f"Migration complete: {total_migrated} invoices processed")
def migrate_invoice_notes(invoice):
"""Migrate notes from custom field to child table.
Old format (custom_line_notes):
"Item A: Check quality\nItem B: Rush order"
New format (SI Custom Note child table):
- item_code: "Item A", note: "Check quality"
- item_code: "Item B", note: "Rush order"
"""
notes_text = invoice.custom_line_notes
# Parse old format
for line in notes_text.split("\n"):
line = line.strip()
if not line or ":" not in line:
continue
item_code, note = line.split(":", 1)
item_code = item_code.strip()
note = note.strip()
# Create child record
frappe.get_doc({
"doctype": "SI Custom Note",
"parent": invoice.name,
"parenttype": "Sales Invoice",
"parentfield": "custom_notes",
"item_code": item_code,
"note": note
}).db_insert()patches.txt
[pre_model_sync]
my_app.patches.v2_0.migrate_to_new_structure---
Example 5: Fixture Export Configuration
Complete hooks.py with Fixtures
# my_app/my_app/hooks.py
fixtures = [
# 1. Custom Fields - filter by module
{
"dt": "Custom Field",
"filters": [["module", "=", "My App"]]
},
# 2. Property Setters - filter by module
{
"dt": "Property Setter",
"filters": [["module", "=", "My App"]]
},
# 3. Roles created by this app
{
"dt": "Role",
"filters": [["name", "in", ["My App User", "My App Manager"]]]
},
# 4. Workflows for our DocTypes
{
"dt": "Workflow",
"filters": [["document_type", "in", ["My DocType", "My Other DocType"]]]
},
# 5. Workflow States (if custom)
{
"dt": "Workflow State",
"filters": [["name", "like", "My App%"]]
},
# 6. Print Formats
{
"dt": "Print Format",
"filters": [["module", "=", "My App"]]
},
# 7. Report (Script Reports)
{
"dt": "Report",
"filters": [["module", "=", "My App"]]
},
# 8. Web Template (if any)
{
"dt": "Web Template",
"filters": [["module", "=", "My App"]]
},
# 9. Notification templates
{
"dt": "Notification",
"filters": [["module", "=", "My App"]]
},
# 10. Our own config DocType - all records
"My App Settings",
# 11. Lookup table - all records
"My Category",
# 12. Custom DocPerm for modified permissions
{
"dt": "Custom DocPerm",
"filters": [
["parent", "in", ["Sales Invoice", "Sales Order"]],
["role", "in", ["My App User", "My App Manager"]]
]
}
]Export and Verify
# Export
bench --site mysite export-fixtures --app my_app
# Check exported files
ls -la my_app/my_app/fixtures/
# custom_field.json
# property_setter.json
# role.json
# workflow.json
# my_app_settings.json
# my_category.json
# Verify a fixture file
cat my_app/my_app/fixtures/custom_field.json | python -m json.tool | head -50---
Quick Reference: App Creation Checklist
# 1. Create app
bench new-app my_app
# 2. Verify __init__.py
cat my_app/my_app/__init__.py
# Should show: __version__ = "0.0.1"
# 3. Configure pyproject.toml (v15+)
# Edit my_app/pyproject.toml
# 4. Configure hooks.py
# Edit my_app/my_app/hooks.py
# 5. Create modules
# Edit my_app/my_app/modules.txt
# Create module directories with __init__.py
# 6. Install on site
bench --site mysite install-app my_app
# 7. Create DocTypes
bench --site mysite new-doctype "My DocType" --module "My Module"
# 8. Build and migrate
bench --site mysite migrate
bench build --app my_app
# 9. Export fixtures
bench --site mysite export-fixtures --app my_app
# 10. Test on fresh site
bench new-site testsite
bench --site testsite install-app my_app
bench --site testsite migrateWorkflows - Custom App Implementation
Step-by-step implementation guides for Frappe/ERPNext custom apps.
---
Workflow 1: Create New Frappe App (From Scratch)
Prerequisites
- Bench installation with at least one site
- Target Frappe version (v14/v15/v16)
Steps
# Step 1: Create app structure
cd ~/frappe-bench
bench new-app my_custom_app
# Interactive prompts:
# - App Title: My Custom App
# - App Description: Description of your app
# - App Publisher: Your Company Name
# - App Email: dev@yourcompany.com
# - App License: MIT (or your choice)# Step 2: Verify __init__.py has version
# my_custom_app/my_custom_app/__init__.py
__version__ = "0.0.1"# Step 3: Configure pyproject.toml (v15+)
# my_custom_app/pyproject.toml
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "my_custom_app"
authors = [
{ name = "Your Company", email = "dev@yourcompany.com" }
]
description = "Description of your app"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
dependencies = []
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"
# Add if needed:
# erpnext = ">=15.0.0,<16.0.0"# Step 4: Configure minimal hooks.py
# my_custom_app/my_custom_app/hooks.py
app_name = "my_custom_app"
app_title = "My Custom App"
app_publisher = "Your Company"
app_description = "Description of your app"
app_email = "dev@yourcompany.com"
app_license = "MIT"
required_apps = ["frappe"] # Or ["frappe", "erpnext"]
fixtures = []# Step 5: Install on site
bench --site mysite install-app my_custom_app
# Step 6: Verify installation
bench --site mysite list-apps
# Should show: frappe, my_custom_appVerification Checklist
- [ ] App appears in
bench --site mysite list-apps - [ ]
__version__defined in__init__.py - [ ]
pyproject.tomlhasdynamic = ["version"] - [ ]
hooks.pyhasrequired_apps
---
Workflow 2: Add Module to Existing App
When to Add a Module
- App has 5+ DocTypes in one module
- DocTypes fall into distinct functional areas
- Need to organize code by business domain
Steps
# Step 1: Create module directory
mkdir -p my_custom_app/my_custom_app/new_module
mkdir -p my_custom_app/my_custom_app/new_module/doctype# Step 2: Add __init__.py (REQUIRED!)
# my_custom_app/my_custom_app/new_module/__init__.py
# (Can be empty file)# Step 3: Register in modules.txt
# my_custom_app/my_custom_app/modules.txt
My Custom App
New Module# Step 4: Migrate to register module
bench --site mysite migrate# Step 5: Verify module exists
bench --site mysite console
>>> frappe.get_all("Module Def", filters={"app_name": "my_custom_app"})
# Should show both modulesModule Naming Convention
| modules.txt | Directory | Example DocType |
|---|---|---|
My Custom App | my_custom_app/ | Core DocTypes |
New Module | new_module/ | Related DocTypes |
API Integrations | api_integrations/ | Integration DocTypes |
---
Workflow 3: Create DocType with Controller
Steps
# Step 1: Create DocType via UI or command
bench --site mysite new-doctype "My Document" --module "My Custom App"
# This creates:
# my_custom_app/my_custom_app/doctype/my_document/
# ├── my_document.json # DocType definition
# ├── my_document.py # Controller
# ├── my_document.js # Client script
# └── test_my_document.py # Tests# Step 2: Implement controller
# my_custom_app/my_custom_app/doctype/my_document/my_document.py
import frappe
from frappe.model.document import Document
class MyDocument(Document):
def validate(self):
"""Runs on save, before database write."""
self.validate_required_fields()
self.calculate_totals()
def before_save(self):
"""Runs after validate, before database write."""
self.set_defaults()
def on_submit(self):
"""Runs when document is submitted."""
self.create_related_records()
def validate_required_fields(self):
if not self.customer:
frappe.throw("Customer is required")
def calculate_totals(self):
self.total = sum(item.amount for item in self.items)
def set_defaults(self):
if not self.posting_date:
self.posting_date = frappe.utils.today()
def create_related_records(self):
# Example: Create linked record on submit
pass// Step 3: Implement client script
// my_custom_app/my_custom_app/doctype/my_document/my_document.js
frappe.ui.form.on("My Document", {
refresh(frm) {
if (!frm.is_new() && frm.doc.docstatus === 1) {
frm.add_custom_button(__("Create Invoice"), function() {
frm.trigger("create_invoice");
});
}
},
customer(frm) {
if (frm.doc.customer) {
frappe.call({
method: "frappe.client.get_value",
args: {
doctype: "Customer",
filters: { name: frm.doc.customer },
fieldname: ["customer_name", "territory"]
},
callback(r) {
if (r.message) {
frm.set_value("customer_name", r.message.customer_name);
frm.set_value("territory", r.message.territory);
}
}
});
}
},
create_invoice(frm) {
frappe.call({
method: "my_custom_app.my_custom_app.doctype.my_document.my_document.create_invoice",
args: { doc_name: frm.doc.name },
callback(r) {
if (r.message) {
frappe.set_route("Form", "Sales Invoice", r.message);
}
}
});
}
});# Step 4: Migrate to apply changes
bench --site mysite migrate
# Step 5: Build assets
bench build --app my_custom_app---
Workflow 4: Write Database Migration Patch
Scenario: Migrate data from old_field to new_field
Steps
# Step 1: Create patch directory structure
mkdir -p my_custom_app/my_custom_app/patches/v1_1
touch my_custom_app/my_custom_app/patches/__init__.py
touch my_custom_app/my_custom_app/patches/v1_1/__init__.py# Step 2: Write the patch
# my_custom_app/my_custom_app/patches/v1_1/migrate_field_data.py
import frappe
def execute():
"""Migrate data from old_field to new_field."""
# Check if migration needed
if not frappe.db.has_column("My Document", "old_field"):
return
batch_size = 1000
offset = 0
total_updated = 0
while True:
# Get batch of records needing migration
records = frappe.db.sql("""
SELECT name, old_field
FROM `tabMy Document`
WHERE old_field IS NOT NULL
AND (new_field IS NULL OR new_field = '')
LIMIT %s OFFSET %s
""", (batch_size, offset), as_dict=True)
if not records:
break
for record in records:
# Transform data if needed
new_value = transform_value(record.old_field)
# Update record
frappe.db.set_value(
"My Document",
record.name,
"new_field",
new_value,
update_modified=False
)
total_updated += 1
# Commit batch to free memory
frappe.db.commit()
offset += batch_size
# Log progress for large migrations
if total_updated % 10000 == 0:
frappe.publish_progress(
percent=offset,
title="Migrating field data",
description=f"Updated {total_updated} records"
)
frappe.db.commit()
print(f"Migration complete: {total_updated} records updated")
def transform_value(old_value):
"""Transform old field value to new format."""
if not old_value:
return None
# Example: Convert comma-separated to JSON array
# return frappe.as_json(old_value.split(","))
return old_value# Step 3: Register patch in patches.txt
# my_custom_app/my_custom_app/patches.txt
[post_model_sync]
my_custom_app.patches.v1_1.migrate_field_data# Step 4: Test on development site
bench --site devsite migrate
# Step 5: Verify migration
bench --site devsite console
>>> frappe.db.count("My Document", {"new_field": ["is", "set"]})Patch Error Handling Pattern
def execute():
"""Safe patch with error handling."""
try:
# Main migration logic
do_migration()
frappe.db.commit()
except Exception as e:
frappe.db.rollback()
frappe.log_error(
title="Patch failed: migrate_field_data",
message=str(e)
)
raise---
Workflow 5: Configure and Export Fixtures
Scenario: Export Custom Fields and Property Setters
Steps
# Step 1: Configure fixtures in hooks.py
# my_custom_app/my_custom_app/hooks.py
fixtures = [
# Custom Fields created by this app
{
"dt": "Custom Field",
"filters": [["module", "=", "My Custom App"]]
},
# Property Setters for DocTypes we modify
{
"dt": "Property Setter",
"filters": [
["module", "=", "My Custom App"]
]
},
# Our own configuration DocType (all records)
"My Settings Category",
# Workflows we created
{
"dt": "Workflow",
"filters": [["document_type", "in", ["My Document"]]]
},
# Custom roles
{
"dt": "Role",
"filters": [["name", "in", ["My Custom Role", "My Admin Role"]]]
}
]# Step 2: Make changes via UI
# - Add Custom Fields via Customize Form
# - Set Property Setters via field properties
# - Create Workflows via Workflow Builder# Step 3: Export fixtures
bench --site mysite export-fixtures --app my_custom_app
# Creates:
# my_custom_app/my_custom_app/fixtures/
# ├── custom_field.json
# ├── property_setter.json
# ├── my_settings_category.json
# ├── workflow.json
# └── role.json# Step 4: Verify JSON files
cat my_custom_app/my_custom_app/fixtures/custom_field.json
# Should show array of Custom Field documents# Step 5: Test import on fresh site
bench new-site testsite
bench --site testsite install-app my_custom_app
bench --site testsite migrate
# Fixtures auto-import during migrateFixture Verification Checklist
- [ ] JSON files created in fixtures/ directory
- [ ] Files contain only YOUR app's customizations
- [ ] No transactional data included
- [ ] Test import on fresh site works
---
Workflow 6: Extend Existing ERPNext DocType (v14/v15)
Scenario: Add validation to Sales Invoice
Steps
# Step 1: Create event handler
# my_custom_app/my_custom_app/events/sales_invoice.py
import frappe
def validate(doc, method):
"""Custom validation for Sales Invoice.
Args:
doc: The Sales Invoice document
method: Event name ("validate")
"""
validate_customer_credit(doc)
validate_item_availability(doc)
def validate_customer_credit(doc):
"""Check customer credit limit before save."""
if doc.is_return:
return
customer = frappe.get_doc("Customer", doc.customer)
if customer.credit_limit:
outstanding = get_customer_outstanding(doc.customer)
if outstanding + doc.grand_total > customer.credit_limit:
frappe.throw(
f"Credit limit exceeded. "
f"Outstanding: {outstanding}, "
f"Limit: {customer.credit_limit}"
)
def validate_item_availability(doc):
"""Check item stock for non-stock items."""
for item in doc.items:
if not frappe.db.get_value("Item", item.item_code, "is_stock_item"):
continue
available = get_available_qty(item.item_code, doc.set_warehouse)
if available < item.qty:
frappe.msgprint(
f"Low stock for {item.item_code}: "
f"Available {available}, Requested {item.qty}",
alert=True
)
def get_customer_outstanding(customer):
"""Get total outstanding for customer."""
return frappe.db.sql("""
SELECT COALESCE(SUM(outstanding_amount), 0)
FROM `tabSales Invoice`
WHERE customer = %s
AND docstatus = 1
AND outstanding_amount > 0
""", customer)[0][0]
def get_available_qty(item_code, warehouse):
"""Get available quantity from Bin."""
return frappe.db.get_value(
"Bin",
{"item_code": item_code, "warehouse": warehouse},
"actual_qty"
) or 0# Step 2: Register in hooks.py
# my_custom_app/my_custom_app/hooks.py
doc_events = {
"Sales Invoice": {
"validate": "my_custom_app.events.sales_invoice.validate"
}
}# Step 3: Clear cache and test
bench --site mysite clear-cache
# Create/save Sales Invoice to test validation---
Workflow 7: Extend Existing ERPNext DocType (v16)
Scenario: Same as above, but using v16 extend_doctype_class
Steps
# Step 1: Create extension class
# my_custom_app/my_custom_app/overrides/sales_invoice.py
import frappe
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
class CustomSalesInvoice(SalesInvoice):
"""Extended Sales Invoice with custom validation."""
def validate(self):
# Call parent validation first
super().validate()
# Add custom validation
self.validate_customer_credit()
self.validate_item_availability()
def validate_customer_credit(self):
"""Check customer credit limit before save."""
if self.is_return:
return
customer = frappe.get_doc("Customer", self.customer)
if customer.credit_limit:
outstanding = self.get_customer_outstanding()
if outstanding + self.grand_total > customer.credit_limit:
frappe.throw(
f"Credit limit exceeded. "
f"Outstanding: {outstanding}, "
f"Limit: {customer.credit_limit}"
)
def validate_item_availability(self):
"""Check item stock."""
for item in self.items:
if not frappe.db.get_value("Item", item.item_code, "is_stock_item"):
continue
available = self.get_available_qty(item.item_code)
if available < item.qty:
frappe.msgprint(
f"Low stock for {item.item_code}",
alert=True
)
def get_customer_outstanding(self):
"""Get total outstanding for customer."""
return frappe.db.sql("""
SELECT COALESCE(SUM(outstanding_amount), 0)
FROM `tabSales Invoice`
WHERE customer = %s
AND docstatus = 1
AND outstanding_amount > 0
""", self.customer)[0][0]
def get_available_qty(self, item_code):
"""Get available quantity from Bin."""
return frappe.db.get_value(
"Bin",
{"item_code": item_code, "warehouse": self.set_warehouse},
"actual_qty"
) or 0# Step 2: Register in hooks.py (v16 syntax)
# my_custom_app/my_custom_app/hooks.py
extend_doctype_class = {
"Sales Invoice": "my_custom_app.overrides.sales_invoice.CustomSalesInvoice"
}# Step 3: Clear cache and test
bench --site mysite clear-cachev16 vs v14/v15 Comparison
| Aspect | v14/v15 (doc_events) | v16 (extend_doctype_class) |
|---|---|---|
| Inheritance | No class inheritance | Full class inheritance |
| Access to self | Via doc parameter | Via self directly |
| Override methods | Hook into events | Override any method |
| Call parent | Not applicable | super().method() |
| Recommended | Still works | Preferred approach |
---
Workflow 8: App Version Upgrade (v14 to v15/v16)
Steps
# Step 1: Backup current state
git checkout -b v14-backup
git push origin v14-backup
git checkout main# Step 2: Convert setup.py to pyproject.toml
# Create my_custom_app/pyproject.toml
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "my_custom_app"
authors = [
{ name = "Your Company", email = "dev@yourcompany.com" }
]
description = "Your app description"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
# Copy dependencies from requirements.txt
dependencies = [
"requests>=2.28.0"
]
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"
erpnext = ">=15.0.0,<16.0.0"# Step 3: Verify __init__.py has version
# my_custom_app/my_custom_app/__init__.py
__version__ = "2.0.0" # Bump major version for breaking change# Step 4: Remove old files (optional, keep for v14 compatibility)
# rm my_custom_app/setup.py
# rm my_custom_app/requirements.txt# Step 5: Update hooks.py for v16 features (optional)
# my_custom_app/my_custom_app/hooks.py
# Convert doc_events to extend_doctype_class if desired
# OLD (v14/v15):
# doc_events = {
# "Sales Invoice": {
# "validate": "my_custom_app.events.si.validate"
# }
# }
# NEW (v16):
extend_doctype_class = {
"Sales Invoice": "my_custom_app.overrides.sales_invoice.CustomSalesInvoice"
}# Step 6: Test on v15/v16 bench
bench --site mysite migrate
bench build --app my_custom_app
# Step 7: Run tests
bench --site mysite run-tests --app my_custom_appVersion Compatibility Matrix
| App Version | Frappe 14 | Frappe 15 | Frappe 16 |
|---|---|---|---|
| 1.x (setup.py) | ✅ | ✅ | ✅ |
| 2.x (pyproject) | ❌ | ✅ | ✅ |
| 2.x + extend_doctype | ❌ | ❌ | ✅ |