
Frappe Impl Hooks
- 1 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Provides workflows for implementing Frappe hooks.py including doc_events, scheduler_events, override/extend doctype class, permission hooks, and fixtures.
About
An implementation skill with step-by-step workflows for configuring hooks.py in a Frappe custom app. A developer uses it to wire up doc events, scheduler jobs, overrides, and permission hooks correctly.
- Workflows for doc_events, scheduler_events, and override/extend_doctype_class
- Permission hooks, extend_bootinfo, fixtures, asset injection, and website hooks
Frappe Impl Hooks 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-hooksAdd 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 implementing Frappe hooks.py including doc_events, scheduler_events, override/extend doctype class, permission hooks, and fixtures.
Files
Frappe Hooks Implementation Workflow
Step-by-step workflows for implementing hooks.py configurations. For API syntax reference, see frappe-syntax-hooks.
Version: v14/v15/v16 (V16-specific features noted)
---
Master Decision: What Are You Implementing?
WHAT DO YOU WANT TO ACHIEVE?
│
├─► React to document lifecycle events?
│ ├─► On OTHER app's DocTypes → doc_events in hooks.py
│ ├─► On YOUR OWN DocTypes → controller methods (preferred)
│ └─► On ALL DocTypes → doc_events with "*" wildcard
│
├─► Run code on a schedule?
│ └─► scheduler_events (daily, hourly, cron, etc.)
│
├─► Modify an existing DocType's behavior?
│ ├─► V16+: extend_doctype_class (RECOMMENDED)
│ └─► V14/V15: override_doctype_class (last app wins!)
│
├─► Override an existing API endpoint?
│ └─► override_whitelisted_methods
│
├─► Add custom permission logic?
│ ├─► List filtering → permission_query_conditions
│ └─► Document-level → has_permission
│
├─► Send config data to client on page load?
│ └─► extend_bootinfo
│
├─► Export/import configuration?
│ └─► fixtures
│
├─► Add JS/CSS to desk or portal?
│ ├─► Desk-wide → app_include_js / app_include_css
│ ├─► Portal-wide → web_include_js / web_include_css
│ └─► Specific form → doctype_js
│
├─► Customize website/portal behavior?
│ └─► website_context, portal_menu_items, website_route_rules
│
└─► Hook into session/auth lifecycle?
└─► on_login, on_session_creation, on_logout---
Workflow 1: Implementing doc_events
When to Use
Use doc_events when you need to react to document lifecycle events on DocTypes owned by OTHER apps (ERPNext, Frappe core). For YOUR OWN DocTypes, ALWAYS prefer controller methods.
Step-by-Step
Step 1: Choose the right event (see references/decision-tree.md)
BEFORE save: validate (every save), before_insert (new only)
AFTER save: after_insert (new only), on_update (every save), on_change (any change)
SUBMIT flow: before_submit → on_submit → on_change
CANCEL flow: before_cancel → on_cancel → on_change
DELETE: on_trash (before), after_delete (after)
RENAME: before_rename, after_renameStep 2: Add to hooks.py
# myapp/hooks.py
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.sales_invoice.validate",
"on_submit": "myapp.events.sales_invoice.on_submit"
}
}Step 3: Create handler module
# myapp/events/sales_invoice.py
import frappe
def validate(doc, method=None):
"""Changes to doc ARE saved (before-save event)."""
if doc.grand_total < 0:
frappe.throw("Total cannot be negative")
def on_submit(doc, method=None):
"""Document already saved. Use db_set_value for changes."""
frappe.db.set_value("Sales Invoice", doc.name,
"custom_external_id", create_external(doc))Step 4: Deploy
bench --site sitename migrateStep 5: Test
bench --site sitename execute myapp.events.sales_invoice.validate --kwargs '{"doc_name": "INV-001"}'
# Or in bench console:
# doc = frappe.get_doc("Sales Invoice", "INV-001"); doc.save()Critical Rules for doc_events
- NEVER call
frappe.db.commit()inside a doc_event handler — Frappe manages the transaction - NEVER modify
docfields inon_update— changes are lost; usefrappe.db.set_value()instead - ALWAYS accept
method=Noneas second parameter in handler signature - ALWAYS use rename signature:
def handler(doc, method, old, new, merge) - ALWAYS run
bench --site sitename migrateafter changing hooks.py
---
Workflow 2: Implementing scheduler_events
Step-by-Step
Step 1: Choose frequency
| Frequency | Short (< 5 min) | Long (5-25 min) |
|---|---|---|
| Every tick | all | — |
| Hourly | hourly | hourly_long |
| Daily | daily | daily_long |
| Weekly | weekly | weekly_long |
| Monthly | monthly | monthly_long |
| Custom | cron | cron (use long queue manually) |
Step 2: Add to hooks.py
scheduler_events = {
"daily": ["myapp.tasks.daily_cleanup"],
"daily_long": ["myapp.tasks.heavy_sync"],
"cron": {
"0 9 * * 1-5": ["myapp.tasks.weekday_report"]
}
}Step 3: Implement task (NO arguments)
# myapp/tasks.py
import frappe
def daily_cleanup():
"""Scheduler calls with NO arguments."""
frappe.db.delete("Error Log", {
"creation": ["<", frappe.utils.add_days(None, -30)]
})
frappe.db.commit()
def heavy_sync():
"""Long task — commit periodically."""
records = get_records_to_sync()
for i, record in enumerate(records):
process(record)
if i % 100 == 0:
frappe.db.commit()
frappe.db.commit()Step 4: Deploy and verify
bench --site sitename migrate
bench --site sitename scheduler enable
bench --site sitename scheduler status
# Test manually:
bench --site sitename execute myapp.tasks.daily_cleanupCritical Rules for Scheduler
- NEVER add parameters to scheduler task functions — the scheduler passes none
- ALWAYS use
_longvariants for tasks exceeding 5 minutes (default queue timeout is 5 min) - ALWAYS commit periodically in long tasks to save progress
- Tasks > 25 minutes: split into chunks or use
frappe.enqueue()
---
Workflow 3: Implementing extend_doctype_class (V16+)
Step-by-Step
Step 1: Add to hooks.py
extend_doctype_class = {
"Sales Invoice": ["myapp.extensions.sales_invoice.SalesInvoiceMixin"]
}Step 2: Create mixin class
# myapp/extensions/sales_invoice.py
import frappe
from frappe.model.document import Document
class SalesInvoiceMixin(Document):
def validate(self):
super().validate() # ALWAYS call super() FIRST
self.custom_validation()
def custom_validation(self):
if self.grand_total > 1000000:
frappe.msgprint("High-value invoice", indicator="orange")Step 3: Deploy — bench --site sitename migrate
When to Use extend vs override
- ALWAYS prefer
extend_doctype_classon V16+ — multiple apps can extend safely - ONLY use
override_doctype_classwhen you must completely replace controller logic - On V14/V15,
override_doctype_classis the only option — last installed app wins
---
Workflow 4: Implementing Permission Hooks
Step-by-Step
Step 1: Add to hooks.py
permission_query_conditions = {
"Sales Invoice": "myapp.permissions.si_query"
}
has_permission = {
"Sales Invoice": "myapp.permissions.si_permission"
}Step 2: Implement handlers
# myapp/permissions.py
import frappe
def si_query(user):
"""Returns SQL WHERE clause for list filtering."""
if not user:
user = frappe.session.user
if "Sales Manager" in frappe.get_roles(user):
return "" # See all
return f"`tabSales Invoice`.owner = {frappe.db.escape(user)}"
def si_permission(doc, user=None, permission_type=None):
"""Returns True (allow), False (deny), or None (use default)."""
if not user:
user = frappe.session.user
if permission_type == "write" and doc.status == "Closed":
return False
return NoneCritical Rules for Permission Hooks
permission_query_conditionsONLY works withget_list, NEVER withget_allhas_permissioncan ONLY deny access — returning True does NOT grant additional permissions- ALWAYS handle
user=Noneby defaulting tofrappe.session.user
---
Workflow 5: Asset Injection and doctype_js
Adding Global JS/CSS
# hooks.py
app_include_js = "/assets/myapp/js/myapp.min.js" # Desk
app_include_css = "/assets/myapp/css/myapp.min.css" # Desk
web_include_js = "/assets/myapp/js/portal.min.js" # Portal
web_include_css = "/assets/myapp/css/portal.min.css" # PortalExtending a Specific Form
# hooks.py
doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js"
}// myapp/public/js/sales_invoice.js
frappe.ui.form.on("Sales Invoice", {
refresh(frm) {
if (frm.doc.docstatus === 1) {
frm.add_custom_button(__("Custom Action"), () => {
frappe.call({
method: "myapp.api.custom_action",
args: { invoice: frm.doc.name },
freeze: true
});
}, __("Actions"));
}
}
});ALWAYS run bench build --app myapp after changing JS/CSS files.
---
Workflow 6: Fixtures, Boot Info, and Website Hooks
Fixtures
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "My App"]]},
{"dt": "Property Setter", "filters": [["module", "=", "My App"]]}
]NEVER export fixtures without filters — it captures ALL apps' customizations.
extend_bootinfo
extend_bootinfo = "myapp.boot.extend_with_config"def extend_with_config(bootinfo):
bootinfo.my_app = {"feature_enabled": True}
# NEVER send secrets — bootinfo is visible in browser DevToolsWebsite Hooks
website_route_rules = [
{"from_route": "/shop/<category>", "to_route": "shop"}
]
portal_menu_items = [
{"title": "My Orders", "route": "/my-orders", "role": "Customer"}
]
on_login = "myapp.handlers.on_login"
on_logout = "myapp.handlers.on_logout"---
Migration: Moving Logic Between Hooks, Controllers, and Server Scripts
| From | To | Steps |
|---|---|---|
| Server Script → hooks.py | 1. Create Python handler, 2. Add doc_events, 3. Disable Server Script, 4. Migrate | |
| hooks.py → Controller | 1. Move logic to doctype .py, 2. Remove doc_events entry, 3. Migrate | |
| Controller → hooks.py | 1. Create events module, 2. Add doc_events, 3. Remove from controller, 4. Migrate |
ALWAYS migrate after ANY hooks.py change: bench --site sitename migrate
---
Handler Signatures Quick Reference
| Hook | Signature |
|---|---|
| doc_events | def handler(doc, method=None): |
| rename events | def handler(doc, method, old, new, merge): |
| scheduler_events | def handler(): (no args) |
| extend_bootinfo | def handler(bootinfo): |
| permission_query | def handler(user): returns SQL string |
| has_permission | def handler(doc, user=None, permission_type=None): returns True/False/None |
| on_login | def handler(login_manager): |
| on_logout | def handler(): |
---
Version Differences
| Feature | V14 | V15 | V16 |
|---|---|---|---|
| doc_events | Yes | Yes | Yes |
| scheduler_events | Yes | Yes | Yes |
| override_doctype_class | Yes | Yes | Yes |
| extend_doctype_class | No | No | Yes |
| permission hooks | Yes | Yes | Yes |
| Scheduler tick interval | ~4 min | ~4 min | ~60 sec |
| auth_hooks | No | Yes | Yes |
---
Reference Files
| File | Contents |
|---|---|
| decision-tree.md | Complete hook selection flowcharts |
| workflows.md | Step-by-step implementation patterns |
| examples.md | Working code examples for all hook types |
Hook Anti-Patterns
Common mistakes and their solutions when implementing hooks.
---
Anti-Pattern 1: Committing in doc_events
❌ Wrong
def on_update(doc, method=None):
create_related_record(doc)
frappe.db.commit() # BREAKS TRANSACTION!Why It's Wrong
- Frappe wraps document operations in transactions
- Manual commit breaks the transaction boundary
- If later code fails, partial changes are saved
- Can cause data inconsistency
✅ Correct
def on_update(doc, method=None):
create_related_record(doc)
# Frappe commits automatically after all handlers complete---
Anti-Pattern 2: Modifying doc After on_update
❌ Wrong
def on_update(doc, method=None):
doc.status = "Processed" # Change is LOST!Why It's Wrong
- on_update runs AFTER the document is saved
- Changes to
docobject are not persisted - Document is already in database
✅ Correct
def on_update(doc, method=None):
frappe.db.set_value(
doc.doctype,
doc.name,
"status",
"Processed"
)Or use flags to do it in validate:
def validate(doc, method=None):
if doc.flags.mark_processed:
doc.status = "Processed" # This WILL be saved---
Anti-Pattern 3: Scheduler Task with Arguments
❌ Wrong
# hooks.py
scheduler_events = {
"daily": ["myapp.tasks.process_records"]
}
# tasks.py
def process_records(doctype, filters): # WRONG - args not passed!
passWhy It's Wrong
- Scheduler calls tasks with NO arguments
- Function signature must be empty
- Arguments are silently dropped
✅ Correct
def process_records():
# Fetch data INSIDE the function
doctype = "Sales Invoice"
filters = {"status": "Draft"}
records = frappe.get_all(doctype, filters=filters)
for record in records:
process(record)---
Anti-Pattern 4: Heavy Task in Default Queue
❌ Wrong
scheduler_events = {
"daily": ["myapp.tasks.sync_all_data"] # May take 20 minutes
}Why It's Wrong
- Default queue timeout is 5 minutes
- Task gets killed mid-execution
- Data may be left in inconsistent state
✅ Correct
scheduler_events = {
"daily_long": ["myapp.tasks.sync_all_data"] # 25 min timeout
}Or split into smaller tasks:
scheduler_events = {
"hourly": ["myapp.tasks.sync_batch"] # Process in chunks
}
def sync_batch():
# Process only 100 records per run
records = get_unsynced_records(limit=100)
for record in records:
sync_record(record)---
Anti-Pattern 5: Forgetting super() in Override
❌ Wrong
class CustomSalesInvoice(SalesInvoice):
def validate(self):
self.my_validation() # Core validation SKIPPED!Why It's Wrong
- Parent class validation is completely skipped
- Core business logic doesn't run
- GL entries, stock updates may fail
- Breaks ERPNext functionality
✅ Correct
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate() # ALWAYS call parent first
self.my_validation()---
Anti-Pattern 6: Using get_all with permission_query
❌ Wrong
# hooks.py
permission_query_conditions = {
"Sales Invoice": "myapp.permissions.si_query"
}
# Somewhere in code
invoices = frappe.db.get_all("Sales Invoice") # NOT FILTERED!Why It's Wrong
permission_query_conditionsonly works withget_listget_allbypasses permission filters entirely- Users may see data they shouldn't
✅ Correct
# Use get_list for permission-filtered results
invoices = frappe.db.get_list("Sales Invoice")
# get_all is for system/admin operations only
# (when you intentionally want to bypass permissions)---
Anti-Pattern 7: Sensitive Data in bootinfo
❌ Wrong
def extend_boot(bootinfo):
bootinfo.api_secret = frappe.conf.api_secret # EXPOSED!
bootinfo.db_password = frappe.conf.db_password # DISASTER!Why It's Wrong
- bootinfo is sent to browser
- Anyone can see it in Developer Tools
- Credentials are exposed to all users
✅ Correct
def extend_boot(bootinfo):
# Only PUBLIC configuration
bootinfo.my_app = {
"feature_enabled": True,
"public_api_url": "https://api.example.com"
}
# Never include: passwords, secrets, tokens, internal URLs---
Anti-Pattern 8: Fixtures Without Filters
❌ Wrong
fixtures = [
"Custom Field", # ALL custom fields from ALL apps!
"Role" # ALL roles!
]Why It's Wrong
- Exports customizations from OTHER apps
- Creates conflicts on import
- May overwrite other apps' settings
- Huge fixture files
✅ Correct
fixtures = [
{
"dt": "Custom Field",
"filters": [["module", "=", "My App"]]
},
{
"dt": "Role",
"filters": [["name", "like", "MyApp%"]]
}
]---
Anti-Pattern 9: No Migrate After Hooks Change
❌ Wrong
# Edit hooks.py
# Expect changes to work immediatelyWhy It's Wrong
- Scheduler events are registered at migrate
- Permission hooks are cached
- Changes don't take effect
✅ Correct
# After ANY hooks.py change:
bench --site sitename migrate
# For scheduler specifically:
bench --site sitename scheduler enable---
Anti-Pattern 10: Infinite Loop with on_change
❌ Wrong
def on_change(doc, method=None):
# This triggers on_change again → infinite loop!
frappe.db.set_value(doc.doctype, doc.name, "modified", now())Why It's Wrong
on_changefires on ANY change, includingdb_set_value- Setting a value triggers on_change again
- Infinite recursion until stack overflow
✅ Correct
def on_change(doc, method=None):
# Check flag to prevent recursion
if doc.flags.in_change_handler:
return
doc.flags.in_change_handler = True
frappe.db.set_value(
doc.doctype, doc.name, "modified", now(),
update_modified=False # Prevents triggering on_change
)Or use on_update instead (doesn't fire on db_set_value).
---
Anti-Pattern 11: has_permission Trying to Grant Access
❌ Wrong
def has_permission(doc, user=None, permission_type=None):
if is_special_user(user):
return True # Trying to GRANT access - doesn't work!Why It's Wrong
has_permissioncan only DENY access- Returning True doesn't grant additional permissions
- User must already have base permission
✅ Correct
def has_permission(doc, user=None, permission_type=None):
# Can only DENY
if should_deny_access(doc, user):
return False
# Return None to use default permission system
return None
# To grant additional access, use Role Permissions Manager
# or create proper permission rules---
Anti-Pattern 12: Wrong Handler Signature for Rename
❌ Wrong
def before_rename(doc, method=None): # Missing required args!
passWhy It's Wrong
- Rename handlers receive additional arguments
- Missing args cause errors or unexpected behavior
✅ Correct
def before_rename(doc, method, old, new, merge):
"""
Args:
doc: Document object
method: "before_rename"
old: Old name
new: New name
merge: Whether this is a merge operation
"""
if new.startswith("_"):
frappe.throw("Names cannot start with underscore")---
Anti-Pattern 13: Multiple Apps Overriding Same DocType
❌ Problem
# app1/hooks.py
override_doctype_class = {
"Sales Invoice": "app1.CustomSI"
}
# app2/hooks.py (installed later)
override_doctype_class = {
"Sales Invoice": "app2.CustomSI" # WINS, app1 ignored!
}Why It's Wrong (V14/V15)
- Only the last installed app's override works
- app1's customizations are completely ignored
- No warning or error
✅ Correct (V16+)
# Use extend_doctype_class instead
extend_doctype_class = {
"Sales Invoice": ["app1.SIMixin"] # Both work!
}
extend_doctype_class = {
"Sales Invoice": ["app2.SIMixin"] # Both active!
}✅ Workaround (V14/V15)
# app2 must inherit from app1's override
from app1.overrides import CustomSI as App1SI
class CustomSI(App1SI): # Chain the overrides
def validate(self):
super().validate()
self.app2_validation()---
Anti-Pattern 14: Not Handling None User
❌ Wrong
def si_query_conditions(user):
roles = frappe.get_roles(user) # Error if user is None!Why It's Wrong
userparameter can be None- Causes errors in background jobs
- Breaks permission checks
✅ Correct
def si_query_conditions(user):
if not user:
user = frappe.session.user
roles = frappe.get_roles(user)---
Anti-Pattern 15: Blocking UI with Heavy Validation
❌ Wrong
def validate(doc, method=None):
# Heavy operation blocks form save
for item in doc.items:
response = call_external_api(item) # Slow!
validate_response(response)Why It's Wrong
- User waits while external API is called
- Network issues cause save failures
- Poor user experience
✅ Correct
def validate(doc, method=None):
# Quick validation only
if not all(item.item_code for item in doc.items):
frappe.throw("All items must have item code")
def on_submit(doc, method=None):
# Queue heavy operations
frappe.enqueue(
"myapp.tasks.validate_with_external",
queue="long",
doc_name=doc.name
)---
Summary Table
| Anti-Pattern | Risk | Solution |
|---|---|---|
| Commit in doc_events | Data inconsistency | Let Frappe commit |
| Modify doc in on_update | Lost changes | Use db_set_value |
| Scheduler args | Silent failure | No args, fetch inside |
| Heavy task in default | Timeout kill | Use _long queue |
| Missing super() | Broken core | Always call super() first |
| get_all with perms | Data leak | Use get_list |
| Secrets in bootinfo | Security breach | Only public config |
| Fixtures no filter | App conflicts | Always filter by module |
| No migrate | Changes ignored | Always bench migrate |
| on_change loop | Stack overflow | Use flags or on_update |
| Grant via has_permission | Doesn't work | Can only deny |
| Wrong rename signature | Errors | Include all 5 args |
| Multiple overrides | One ignored | Use extend (V16) |
| None user | Errors | Default to session.user |
| Heavy validation | Poor UX | Queue heavy tasks |
Frappe Hook Selection Decision Trees
Complete flowcharts for selecting the right hook type.
---
Master Decision Tree
┌─────────────────────────────────────────────────────────────────────────────┐
│ WHAT ARE YOU TRYING TO DO? │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌───────────────────────────┼───────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ DOCUMENT │ │ SCHEDULED │ │ MODIFY │
│ LIFECYCLE │ │ TASKS │ │ EXISTING │
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
▼ ▼ ▼
doc_events scheduler_events Override hooks
(Section 1) (Section 2) (Section 3)
┌───────────────────────────┼───────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ PERMISSIONS │ │ CLIENT DATA │ │ ASSETS & │
│ │ │ │ │ CONFIG │
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
▼ ▼ ▼
Permission hooks extend_bootinfo fixtures,
(Section 4) (Section 5) asset includes
(Section 6)---
Section 1: Document Lifecycle (doc_events)
When to Use doc_events vs Controller
IS THE DOCTYPE YOURS OR EXTERNAL?
│
├─► YOUR app's DocType
│ │
│ │ Do you need...
│ ├─► Full control, imports, complex logic?
│ │ └─► Controller methods in doctype/xxx/xxx.py
│ │
│ └─► Quick hook alongside controller?
│ └─► Can use doc_events (runs after controller)
│
├─► EXTERNAL app's DocType (ERPNext, Frappe)
│ └─► doc_events in hooks.py (ONLY option)
│
└─► ALL DocTypes (logging, audit trail)
└─► doc_events with wildcard "*"Which doc_event to Use
WHEN DOES YOUR CODE NEED TO RUN?
│
├─► BEFORE the document is saved
│ │
│ ├─► Validate or calculate on EVERY save?
│ │ └─► validate
│ │ - Called on insert AND update
│ │ - Changes to doc ARE saved
│ │ - Use frappe.throw() to block save
│ │
│ ├─► Only on NEW documents?
│ │ └─► before_insert
│ │ - Only first save
│ │ - Good for: auto-naming, defaults
│ │
│ └─► Before validation starts?
│ └─► before_validate
│ - Rarely needed
│ - Runs before validate
│
├─► AFTER the document is saved
│ │
│ ├─► Only after FIRST save (new doc)?
│ │ └─► after_insert
│ │ - Document has name now
│ │ - Good for: notifications, linked docs
│ │
│ ├─► After EVERY save?
│ │ └─► on_update
│ │ - Most common "after save" hook
│ │ - Changes need db_set_value
│ │
│ └─► After ANY change (including db_set)?
│ └─► on_change
│ - Also fires on db_set_value
│ - Use carefully (can loop)
│
├─► SUBMITTABLE document workflow
│ │
│ ├─► Before submit button?
│ │ └─► before_submit
│ │ - Last chance to validate
│ │ - Can block with frappe.throw()
│ │
│ ├─► After submit?
│ │ └─► on_submit
│ │ - Create GL entries here
│ │ - Create linked docs
│ │
│ ├─► Before cancel?
│ │ └─► before_cancel
│ │ - Validate cancel allowed
│ │
│ ├─► After cancel?
│ │ └─► on_cancel
│ │ - Reverse GL entries here
│ │ - Update linked docs
│ │
│ ├─► Before amend?
│ │ └─► before_update_after_submit
│ │
│ └─► After amend?
│ └─► on_update_after_submit
│
├─► DELETION
│ │
│ ├─► Before delete (can prevent)?
│ │ └─► on_trash
│ │ - frappe.throw() blocks delete
│ │ - Cleanup linked data
│ │
│ └─► After delete (cleanup)?
│ └─► after_delete
│ - Document already gone
│ - External cleanup only
│
└─► RENAME
│
├─► Before rename?
│ └─► before_rename(doc, method, old, new, merge)
│
└─► After rename?
└─► after_rename(doc, method, old, new, merge)Execution Order
DOCUMENT SAVE FLOW:
1. before_validate
2. validate
3. before_insert (new docs only)
4. [Database INSERT/UPDATE]
5. after_insert (new docs only)
6. on_update
7. on_change
SUBMIT FLOW:
1. before_submit
2. [Status → Submitted]
3. on_submit
4. on_change
CANCEL FLOW:
1. before_cancel
2. [Status → Cancelled]
3. on_cancel
4. on_change---
Section 2: Scheduler Events
Frequency Selection
HOW OFTEN SHOULD THE TASK RUN?
│
├─► Every ~60 seconds (V16) / ~4 min (V14/V15)
│ └─► all
│ ⚠️ Very frequent - use sparingly
│
├─► Every hour
│ ├─► Task < 5 min → hourly
│ └─► Task 5-25 min → hourly_long
│
├─► Every day
│ ├─► Task < 5 min → daily
│ └─► Task 5-25 min → daily_long
│
├─► Every week
│ ├─► Task < 5 min → weekly
│ └─► Task 5-25 min → weekly_long
│
├─► Every month
│ ├─► Task < 5 min → monthly
│ └─► Task 5-25 min → monthly_long
│
└─► Specific time (cron syntax)
└─► cron: {"0 9 * * 1-5": [...]}
Examples:
- "*/15 * * * *" → Every 15 minutes
- "0 9 * * *" → Daily at 9 AM
- "0 9 * * 1-5" → Weekdays at 9 AM
- "0 0 1 * *" → First of month midnight
- "30 17 * * 5" → Friday 5:30 PMQueue Selection
HOW LONG DOES YOUR TASK TAKE?
│
├─► Under 5 minutes
│ └─► Standard events (hourly, daily, cron, etc.)
│ Queue: default
│ Timeout: 5 minutes
│
├─► 5-25 minutes
│ └─► Long events (hourly_long, daily_long, etc.)
│ Queue: long
│ Timeout: 25 minutes
│
└─► Over 25 minutes
└─► Split into smaller tasks OR
Use frappe.enqueue() with custom timeout---
Section 3: Override Hooks
Controller Override Selection (Critical for V16)
FRAPPE VERSION?
│
├─► V16 or later
│ │
│ │ WHAT DO YOU NEED?
│ │
│ ├─► ADD functionality (properties, methods)?
│ │ └─► extend_doctype_class ✅ RECOMMENDED
│ │ - Multiple apps can extend same DocType
│ │ - All extensions active simultaneously
│ │ - Safer upgrades
│ │
│ ├─► REPLACE functionality completely?
│ │ └─► override_doctype_class
│ │ - Last app wins (others ignored)
│ │ - Risky on updates
│ │ - Use only when necessary
│ │
│ └─► Not sure?
│ └─► Start with extend_doctype_class
│ Fall back to override if needed
│
└─► V14 or V15
└─► override_doctype_class (only option)
⚠️ Last installed app wins
⚠️ Multiple apps = conflictsAPI Override Selection
WHAT ARE YOU MODIFYING?
│
├─► Existing whitelisted API method?
│ └─► override_whitelisted_methods
│ - Must match EXACT signature
│ - Last app wins
│
├─► Form UI behavior?
│ └─► doctype_js
│ - Add JS to specific forms
│ - Extends, doesn't replace
│
└─► Need completely new API?
└─► Create new whitelisted method
(Not an override)---
Section 4: Permission Hooks
WHAT PERMISSION LOGIC DO YOU NEED?
│
├─► Filter LIST views (who sees what records)?
│ └─► permission_query_conditions
│ - Returns SQL WHERE clause
│ - Only affects get_list, NOT get_all
│ - Good for: territory-based, role-based filtering
│
├─► Control individual DOCUMENT access?
│ └─► has_permission
│ - Called for each document access
│ - Return True/False/None
│ - Can only DENY, not grant extra permissions
│ - Good for: status-based, dynamic conditions
│
└─► Both?
└─► Use both hooks
- permission_query for lists
- has_permission for documents---
Section 5: Client Data (extend_bootinfo)
DO YOU NEED TO SEND DATA TO CLIENT ON PAGE LOAD?
│
├─► Yes - Configuration/settings
│ └─► extend_bootinfo
│ - Adds to frappe.boot object
│ - Available in all JS
│ ⚠️ Never send sensitive data
│
└─► Yes - But only for specific forms
└─► Fetch via frappe.call instead
- More secure
- On-demand loading---
Section 6: Assets & Configuration
Asset Includes
WHERE DO YOU NEED JS/CSS?
│
├─► Desk (backend/admin interface)
│ ├─► Global JS → app_include_js
│ ├─► Global CSS → app_include_css
│ └─► Specific form → doctype_js
│
└─► Portal (website/frontend)
├─► Global JS → web_include_js
└─► Global CSS → web_include_cssFixtures
WHAT DO YOU NEED TO EXPORT/IMPORT?
│
├─► Custom Fields you created?
│ └─► fixtures: [{"dt": "Custom Field", "filters": [...]}]
│
├─► Property Setters (field modifications)?
│ └─► fixtures: [{"dt": "Property Setter", "filters": [...]}]
│
├─► Custom Roles?
│ └─► fixtures: [{"dt": "Role", "filters": [...]}]
│
├─► Custom DocTypes?
│ └─► fixtures: [{"dt": "DocType", "filters": [...]}]
│
└─► Other configuration data?
└─► fixtures: [{"dt": "Your DocType", "filters": [...]}]
⚠️ Always use filters to scope to your app---
Quick Selection Matrix
| Need | Hook |
|---|---|
| Validate before save | doc_events.validate |
| After save notification | doc_events.on_update |
| Daily cleanup | scheduler_events.daily |
| Heavy daily task | scheduler_events.daily_long |
| 9 AM weekday report | scheduler_events.cron |
| Extend Sales Invoice (V16) | extend_doctype_class |
| Override Sales Invoice (V14/15) | override_doctype_class |
| Custom API behavior | override_whitelisted_methods |
| Filter list by user | permission_query_conditions |
| Block edit on status | has_permission |
| Client-side config | extend_bootinfo |
| Export Custom Fields | fixtures |
| Add global JS | app_include_js |
| Extend form JS | doctype_js |
Frappe Hook Examples
Complete working code examples for common hook scenarios.
---
Example 1: Credit Limit Check (doc_events.validate)
Scenario: Block Sales Invoice if customer exceeds credit limit.
# myapp/hooks.py
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.si_credit_check"
}
}# myapp/events.py
import frappe
def si_credit_check(doc, method=None):
"""Block invoice if exceeds credit limit"""
if doc.is_return:
return # Skip credit returns
customer = frappe.get_doc("Customer", doc.customer)
if not customer.credit_limit:
return # No limit set
# Get outstanding amount
outstanding = frappe.db.sql("""
SELECT SUM(outstanding_amount)
FROM `tabSales Invoice`
WHERE customer = %s
AND docstatus = 1
AND name != %s
""", (doc.customer, doc.name))[0][0] or 0
# Check if new invoice exceeds limit
total_exposure = outstanding + doc.grand_total
if total_exposure > customer.credit_limit:
frappe.throw(
f"Credit limit exceeded. "
f"Limit: {customer.credit_limit}, "
f"Outstanding: {outstanding}, "
f"This invoice: {doc.grand_total}"
)---
Example 2: Auto-Create Follow-up Task (doc_events.on_submit)
Scenario: Create ToDo when Sales Order is submitted.
# myapp/hooks.py
doc_events = {
"Sales Order": {
"on_submit": "myapp.events.create_followup_task"
}
}# myapp/events.py
import frappe
from frappe.utils import add_days, today
def create_followup_task(doc, method=None):
"""Create follow-up task for sales team"""
# Skip if no delivery date set
if not doc.delivery_date:
return
# Create follow-up 2 days before delivery
followup_date = add_days(doc.delivery_date, -2)
# Don't create if already passed
if followup_date < today():
return
todo = frappe.get_doc({
"doctype": "ToDo",
"description": f"Follow up on delivery for {doc.name}",
"reference_type": "Sales Order",
"reference_name": doc.name,
"allocated_to": doc.owner,
"date": followup_date,
"priority": "High" if doc.grand_total > 100000 else "Medium"
})
todo.insert(ignore_permissions=True)
frappe.msgprint(f"Follow-up task created for {followup_date}")---
Example 3: Audit Trail (doc_events wildcard)
Scenario: Log all document changes for compliance.
# myapp/hooks.py
doc_events = {
"*": {
"after_insert": "myapp.audit.log_insert",
"on_update": "myapp.audit.log_update",
"on_trash": "myapp.audit.log_delete",
"on_submit": "myapp.audit.log_submit",
"on_cancel": "myapp.audit.log_cancel"
}
}# myapp/audit.py
import frappe
import json
# DocTypes to skip
SKIP_DOCTYPES = {
"Error Log", "Activity Log", "Audit Log",
"Communication", "Email Queue", "Version"
}
def log_insert(doc, method=None):
if doc.doctype not in SKIP_DOCTYPES:
create_log(doc, "Created")
def log_update(doc, method=None):
if doc.doctype not in SKIP_DOCTYPES:
create_log(doc, "Updated")
def log_delete(doc, method=None):
if doc.doctype not in SKIP_DOCTYPES:
create_log(doc, "Deleted")
def log_submit(doc, method=None):
if doc.doctype not in SKIP_DOCTYPES:
create_log(doc, "Submitted")
def log_cancel(doc, method=None):
if doc.doctype not in SKIP_DOCTYPES:
create_log(doc, "Cancelled")
def create_log(doc, action):
"""Create audit log entry"""
frappe.get_doc({
"doctype": "Audit Log",
"reference_doctype": doc.doctype,
"reference_name": doc.name,
"action": action,
"user": frappe.session.user,
"ip_address": frappe.local.request_ip if hasattr(frappe.local, 'request_ip') else None,
"data": json.dumps(doc.as_dict(), default=str)[:65535]
}).insert(ignore_permissions=True)---
Example 4: Daily Cleanup Task (scheduler_events.daily)
Scenario: Clean up old error logs and temporary files.
# myapp/hooks.py
scheduler_events = {
"daily": [
"myapp.tasks.daily_cleanup"
]
}# myapp/tasks.py
import frappe
from frappe.utils import add_days, today
def daily_cleanup():
"""
Daily cleanup task - runs in default queue.
NO arguments - scheduler calls without args.
"""
cleanup_error_logs()
cleanup_temp_files()
cleanup_old_versions()
frappe.db.commit()
def cleanup_error_logs():
"""Delete error logs older than 30 days"""
cutoff = add_days(today(), -30)
frappe.db.delete("Error Log", {
"creation": ["<", cutoff]
})
frappe.logger().info(f"Cleaned error logs older than {cutoff}")
def cleanup_temp_files():
"""Delete temporary uploaded files"""
cutoff = add_days(today(), -7)
temp_files = frappe.get_all(
"File",
filters={
"attached_to_doctype": "",
"creation": ["<", cutoff]
},
pluck="name"
)
for name in temp_files:
try:
frappe.delete_doc("File", name, ignore_permissions=True)
except Exception:
pass
def cleanup_old_versions():
"""Keep only last 10 versions per document"""
# Get documents with many versions
docs_with_versions = frappe.db.sql("""
SELECT ref_doctype, docname, COUNT(*) as cnt
FROM tabVersion
GROUP BY ref_doctype, docname
HAVING cnt > 10
""", as_dict=True)
for row in docs_with_versions:
# Get versions to delete (keep newest 10)
to_delete = frappe.get_all(
"Version",
filters={
"ref_doctype": row.ref_doctype,
"docname": row.docname
},
order_by="creation desc",
pluck="name",
start=10
)
for name in to_delete:
frappe.delete_doc("Version", name, ignore_permissions=True)---
Example 5: Weekly Report (scheduler_events.cron)
Scenario: Send sales summary every Monday at 9 AM.
# myapp/hooks.py
scheduler_events = {
"cron": {
"0 9 * * 1": [ # Monday 9:00 AM
"myapp.tasks.send_weekly_sales_report"
]
}
}# myapp/tasks.py
import frappe
from frappe.utils import add_days, today, fmt_money
def send_weekly_sales_report():
"""Send weekly sales summary to managers"""
# Get last week's date range
end_date = add_days(today(), -1) # Yesterday
start_date = add_days(end_date, -6) # 7 days ago
# Compile statistics
stats = get_sales_stats(start_date, end_date)
# Get recipients
recipients = frappe.get_all(
"User",
filters={
"enabled": 1,
"user_type": "System User"
},
or_filters=[
["role", "like", "%Sales Manager%"],
["role", "like", "%System Manager%"]
],
pluck="email"
)
if not recipients:
return
# Send email
frappe.sendmail(
recipients=recipients,
subject=f"Weekly Sales Report: {start_date} to {end_date}",
message=render_report(stats, start_date, end_date),
delayed=False
)
def get_sales_stats(start_date, end_date):
"""Get sales statistics for date range"""
return frappe.db.sql("""
SELECT
COUNT(*) as invoice_count,
SUM(grand_total) as total_sales,
SUM(outstanding_amount) as outstanding,
COUNT(DISTINCT customer) as unique_customers
FROM `tabSales Invoice`
WHERE docstatus = 1
AND posting_date BETWEEN %s AND %s
""", (start_date, end_date), as_dict=True)[0]
def render_report(stats, start_date, end_date):
"""Render email content"""
return f"""
<h2>Weekly Sales Summary</h2>
<p>Period: {start_date} to {end_date}</p>
<table border="1" cellpadding="10">
<tr><td><b>Total Invoices</b></td><td>{stats.invoice_count}</td></tr>
<tr><td><b>Total Sales</b></td><td>{fmt_money(stats.total_sales)}</td></tr>
<tr><td><b>Outstanding</b></td><td>{fmt_money(stats.outstanding)}</td></tr>
<tr><td><b>Unique Customers</b></td><td>{stats.unique_customers}</td></tr>
</table>
"""---
Example 6: Heavy Data Sync (scheduler_events.daily_long)
Scenario: Sync large dataset with external system (takes 15-20 minutes).
# myapp/hooks.py
scheduler_events = {
"daily_long": [ # Long queue - 25 min timeout
"myapp.tasks.sync_external_data"
]
}# myapp/tasks.py
import frappe
def sync_external_data():
"""
Heavy sync task - runs in LONG queue.
Commits periodically to avoid losing progress.
"""
records = get_records_to_sync()
total = len(records)
synced = 0
failed = 0
for i, record in enumerate(records):
try:
sync_single_record(record)
synced += 1
except Exception as e:
frappe.log_error(
f"Sync failed for {record.name}: {e}",
"External Sync Error"
)
failed += 1
# Commit every 50 records
if (i + 1) % 50 == 0:
frappe.db.commit()
frappe.logger().info(f"Sync progress: {i+1}/{total}")
# Final commit
frappe.db.commit()
# Log summary
frappe.logger().info(
f"Sync complete: {synced} synced, {failed} failed, {total} total"
)
def get_records_to_sync():
"""Get records needing sync"""
return frappe.get_all(
"Customer",
filters={
"custom_needs_sync": 1,
"custom_last_sync": ["<", frappe.utils.add_days(None, -1)]
},
fields=["name", "customer_name", "custom_external_id"]
)
def sync_single_record(record):
"""Sync single record to external system"""
# Your sync logic here
pass---
Example 7: extend_doctype_class (V16+)
Scenario: Add profit margin tracking to Sales Invoice.
# myapp/hooks.py
extend_doctype_class = {
"Sales Invoice": ["myapp.extensions.SalesInvoiceProfitMixin"]
}# myapp/extensions.py
import frappe
from frappe.model.document import Document
class SalesInvoiceProfitMixin(Document):
"""
Mixin to add profit tracking to Sales Invoice.
V16+ only - multiple apps can extend same DocType.
"""
@property
def total_cost(self):
"""Calculate total cost from items"""
return sum(
(item.qty * (item.incoming_rate or 0))
for item in self.items
)
@property
def profit_amount(self):
"""Calculate profit"""
return self.grand_total - self.total_cost
@property
def profit_margin_percent(self):
"""Calculate profit margin percentage"""
if self.grand_total:
return (self.profit_amount / self.grand_total) * 100
return 0
def validate(self):
"""Extend validation"""
super().validate()
self.validate_minimum_margin()
self.set_profit_fields()
def validate_minimum_margin(self):
"""Warn if margin too low"""
min_margin = frappe.db.get_single_value(
"Selling Settings", "custom_min_margin_percent"
) or 5
if self.profit_margin_percent < min_margin:
frappe.msgprint(
f"Warning: Profit margin ({self.profit_margin_percent:.1f}%) "
f"is below minimum ({min_margin}%)",
indicator="orange",
alert=True
)
def set_profit_fields(self):
"""Set custom profit fields"""
self.custom_profit_amount = self.profit_amount
self.custom_profit_margin = self.profit_margin_percent
def get_profit_breakdown(self):
"""Custom method - get detailed profit breakdown"""
breakdown = []
for item in self.items:
cost = item.qty * (item.incoming_rate or 0)
profit = item.amount - cost
margin = (profit / item.amount * 100) if item.amount else 0
breakdown.append({
"item_code": item.item_code,
"amount": item.amount,
"cost": cost,
"profit": profit,
"margin_percent": margin
})
return breakdownUsage:
doc = frappe.get_doc("Sales Invoice", "INV-001")
print(doc.profit_margin_percent) # Property
print(doc.get_profit_breakdown()) # Method---
Example 8: Permission Query (permission_query_conditions)
Scenario: Territory-based access control.
# myapp/hooks.py
permission_query_conditions = {
"Customer": "myapp.permissions.customer_query",
"Sales Invoice": "myapp.permissions.si_query"
}# myapp/permissions.py
import frappe
def customer_query(user):
"""
Filter Customer list by user's territory.
Returns SQL WHERE fragment.
"""
if not user:
user = frappe.session.user
# System managers see all
if "System Manager" in frappe.get_roles(user):
return ""
# Get user's territories
territories = get_user_territories(user)
if not territories:
return "1=0" # No access
# Build territory filter
territory_list = ", ".join(frappe.db.escape(t) for t in territories)
return f"`tabCustomer`.territory IN ({territory_list})"
def si_query(user):
"""Filter Sales Invoice by customer territory"""
if not user:
user = frappe.session.user
if "System Manager" in frappe.get_roles(user):
return ""
territories = get_user_territories(user)
if not territories:
return "1=0"
# Join with Customer to filter by territory
territory_list = ", ".join(frappe.db.escape(t) for t in territories)
return f"""
`tabSales Invoice`.customer IN (
SELECT name FROM `tabCustomer`
WHERE territory IN ({territory_list})
)
"""
def get_user_territories(user):
"""Get territories assigned to user"""
return frappe.get_all(
"User Territory", # Custom DocType
filters={"user": user},
pluck="territory"
)---
Example 9: Has Permission (Document-Level)
Scenario: Complex permission rules based on document state.
# myapp/hooks.py
has_permission = {
"Sales Invoice": "myapp.permissions.si_has_permission"
}# myapp/permissions.py
import frappe
def si_has_permission(doc, user=None, permission_type=None):
"""
Document-level permission check.
Returns:
True: Allow
False: Deny
None: Use default
NOTE: Can only DENY, not grant new permissions!
"""
if not user:
user = frappe.session.user
# System managers always pass
if "System Manager" in frappe.get_roles(user):
return None
# Rule 1: No editing closed invoices
if permission_type == "write":
if doc.status == "Closed":
frappe.throw("Cannot edit closed invoices")
return False
# Rule 2: No cancellation after 30 days
if permission_type == "cancel":
days_old = frappe.utils.date_diff(
frappe.utils.today(),
doc.posting_date
)
if days_old > 30:
frappe.throw("Cannot cancel invoices older than 30 days")
return False
# Rule 3: Only finance can see draft invoices > 100k
if permission_type == "read" and doc.docstatus == 0:
if doc.grand_total > 100000:
if "Accounts Manager" not in frappe.get_roles(user):
return False
# Rule 4: Only owner can delete drafts
if permission_type == "delete":
if doc.owner != user:
frappe.throw("Only the creator can delete this invoice")
return False
# Use default permission system
return None---
Example 10: Complete hooks.py Template
Scenario: Full-featured custom app hooks.
# myapp/hooks.py
app_name = "myapp"
app_title = "My App"
app_publisher = "My Company"
app_description = "Custom ERPNext Extensions"
app_version = "1.0.0"
# Document Events
doc_events = {
"*": {
"after_insert": "myapp.audit.log_create",
"on_trash": "myapp.audit.log_delete"
},
"Sales Invoice": {
"validate": "myapp.events.si.validate",
"on_submit": "myapp.events.si.on_submit"
},
"Sales Order": {
"on_submit": "myapp.events.so.create_followup"
}
}
# Scheduler Events
scheduler_events = {
"daily": [
"myapp.tasks.daily_cleanup"
],
"daily_long": [
"myapp.tasks.sync_external"
],
"cron": {
"0 9 * * 1": ["myapp.tasks.monday_report"],
"0 17 * * 5": ["myapp.tasks.friday_summary"]
}
}
# DocType Extensions (V16+)
extend_doctype_class = {
"Sales Invoice": ["myapp.extensions.SalesInvoiceMixin"],
"Customer": ["myapp.extensions.CustomerMixin"]
}
# Permission Hooks
permission_query_conditions = {
"Customer": "myapp.permissions.customer_query",
"Sales Invoice": "myapp.permissions.si_query"
}
has_permission = {
"Sales Invoice": "myapp.permissions.si_permission"
}
# Boot Info
extend_bootinfo = "myapp.boot.extend"
# Fixtures
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "My App"]]},
{"dt": "Property Setter", "filters": [["module", "=", "My App"]]},
{"dt": "Role", "filters": [["name", "like", "MyApp%"]]}
]
# Assets
app_include_js = "/assets/myapp/js/myapp.min.js"
app_include_css = "/assets/myapp/css/myapp.min.css"
doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js",
"Customer": "public/js/customer.js"
}
# Install/Migrate Hooks
after_install = "myapp.setup.after_install"
after_migrate = "myapp.setup.after_migrate"Frappe Hook Implementation Workflows
Step-by-step implementation patterns for each hook type.
---
Workflow 1: Basic doc_events Setup
Use Case
React to document events on an external DocType (ERPNext/Frappe).
Steps
Step 1: Plan your handlers
DocType: Sales Invoice
Events needed:
- validate: Check credit limit
- on_submit: Create external recordStep 2: Add to hooks.py
# myapp/hooks.py
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.sales_invoice.check_credit",
"on_submit": "myapp.events.sales_invoice.create_external"
}
}Step 3: Create events module
# Create directory structure
mkdir -p myapp/events
touch myapp/events/__init__.py
touch myapp/events/sales_invoice.pyStep 4: Implement handlers
# myapp/events/sales_invoice.py
import frappe
def check_credit(doc, method=None):
"""
Runs before save.
Changes to doc ARE automatically saved.
"""
customer_credit = get_customer_credit(doc.customer)
if doc.grand_total > customer_credit:
frappe.throw(f"Exceeds credit limit of {customer_credit}")
def create_external(doc, method=None):
"""
Runs after submit.
Document already saved - use db_set_value for changes.
"""
external_id = send_to_external_system(doc)
frappe.db.set_value("Sales Invoice", doc.name,
"custom_external_id", external_id)Step 5: Deploy
bench --site sitename migrateStep 6: Test
# In console
doc = frappe.get_doc("Sales Invoice", "INV-001")
doc.save() # Should trigger validate
doc.submit() # Should trigger on_submit---
Workflow 2: Wildcard Event Handler
Use Case
Audit trail for all document changes.
Steps
Step 1: Add wildcard handler
# myapp/hooks.py
doc_events = {
"*": {
"after_insert": "myapp.audit.log_creation",
"on_update": "myapp.audit.log_update",
"on_trash": "myapp.audit.log_deletion"
}
}Step 2: Implement audit module
# myapp/audit.py
import frappe
def log_creation(doc, method=None):
"""Log all document creations"""
if should_audit(doc.doctype):
create_audit_log(doc, "Created")
def log_update(doc, method=None):
"""Log all document updates"""
if should_audit(doc.doctype):
create_audit_log(doc, "Updated")
def log_deletion(doc, method=None):
"""Log all document deletions"""
if should_audit(doc.doctype):
create_audit_log(doc, "Deleted")
def should_audit(doctype):
"""Skip system doctypes"""
skip = ["Error Log", "Activity Log", "Custom Audit Log"]
return doctype not in skip
def create_audit_log(doc, action):
"""Create audit record"""
frappe.get_doc({
"doctype": "Custom Audit Log",
"reference_doctype": doc.doctype,
"reference_name": doc.name,
"action": action,
"user": frappe.session.user,
"timestamp": frappe.utils.now()
}).insert(ignore_permissions=True)---
Workflow 3: Scheduler Task Setup
Use Case
Daily cleanup of old records + weekly report.
Steps
Step 1: Add scheduler events
# myapp/hooks.py
scheduler_events = {
"daily": [
"myapp.tasks.cleanup_old_logs"
],
"weekly": [
"myapp.tasks.send_weekly_summary"
],
"cron": {
"0 9 * * 1-5": [
"myapp.tasks.weekday_morning_check"
]
}
}Step 2: Implement tasks
# myapp/tasks.py
import frappe
from frappe.utils import add_days, today
def cleanup_old_logs():
"""
Daily task - NO arguments!
Runs in default queue (5 min timeout)
"""
cutoff = add_days(today(), -30)
old_logs = frappe.get_all(
"Error Log",
filters={"creation": ["<", cutoff]},
pluck="name",
limit=1000 # Process in batches
)
for name in old_logs:
frappe.delete_doc("Error Log", name, ignore_permissions=True)
frappe.db.commit()
def send_weekly_summary():
"""Weekly summary email"""
data = compile_weekly_stats()
frappe.sendmail(
recipients=get_managers(),
subject="Weekly Summary",
message=render_summary(data)
)
def weekday_morning_check():
"""Runs at 9 AM on weekdays"""
check_pending_approvals()
notify_overdue_tasks()Step 3: Deploy and enable
bench --site sitename migrate
bench --site sitename scheduler enableStep 4: Verify
# Check scheduler status
bench --site sitename scheduler status
# Run task manually for testing
bench --site sitename execute myapp.tasks.cleanup_old_logs---
Workflow 4: Long Running Task
Use Case
Heavy data processing that takes 10-20 minutes.
Steps
Step 1: Use _long variant
# myapp/hooks.py
scheduler_events = {
"daily_long": [
"myapp.tasks.heavy_data_sync"
]
}Step 2: Implement with progress commits
# myapp/tasks.py
import frappe
def heavy_data_sync():
"""
Long task - up to 25 minutes
Commit periodically to save progress
"""
records = get_all_records_to_process()
total = len(records)
for i, record in enumerate(records):
try:
process_record(record)
# Commit every 100 records
if i % 100 == 0:
frappe.db.commit()
frappe.publish_progress(
percent=int((i / total) * 100),
title="Data Sync"
)
except Exception as e:
frappe.log_error(f"Failed {record}: {e}")
continue
frappe.db.commit()---
Workflow 5: extend_doctype_class (V16+)
Use Case
Add custom methods and properties to Sales Invoice without replacing it.
Steps
Step 1: Add to hooks.py
# myapp/hooks.py
extend_doctype_class = {
"Sales Invoice": ["myapp.extensions.sales_invoice.SalesInvoiceExtension"]
}Step 2: Create extension class
# myapp/extensions/sales_invoice.py
import frappe
from frappe.model.document import Document
class SalesInvoiceExtension(Document):
"""
Mixin class - extends Sales Invoice
All methods/properties added to Sales Invoice
"""
@property
def profit_margin_percent(self):
"""Computed property - access as doc.profit_margin_percent"""
if not self.grand_total:
return 0
cost = sum(item.amount for item in self.items)
return ((self.grand_total - cost) / self.grand_total) * 100
def validate(self):
"""Extend validation"""
super().validate() # ALWAYS call super() first!
self.validate_profit_margin()
self.set_custom_fields()
def validate_profit_margin(self):
"""Custom validation"""
if self.profit_margin_percent < 5:
frappe.msgprint(
f"Warning: Low margin ({self.profit_margin_percent:.1f}%)",
indicator="orange"
)
def set_custom_fields(self):
"""Auto-set custom fields"""
self.custom_margin = self.profit_margin_percent
def send_to_external_erp(self):
"""Custom method - callable as doc.send_to_external_erp()"""
# Implementation
passStep 3: Deploy
bench --site sitename migrateStep 4: Use in code
doc = frappe.get_doc("Sales Invoice", "INV-001")
# Access computed property
print(doc.profit_margin_percent)
# Call custom method
doc.send_to_external_erp()---
Workflow 6: override_doctype_class (V14/V15)
Use Case
Modify Sales Invoice behavior when extend is not available.
Steps
Step 1: Add to hooks.py
# myapp/hooks.py
override_doctype_class = {
"Sales Invoice": "myapp.overrides.sales_invoice.CustomSalesInvoice"
}Step 2: Create override class
# myapp/overrides/sales_invoice.py
import frappe
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
class CustomSalesInvoice(SalesInvoice):
"""
Complete override - REPLACES SalesInvoice class
⚠️ Last installed app wins if multiple apps override!
"""
def validate(self):
# CRITICAL: Always call super() first!
super().validate()
self.custom_validation()
def on_submit(self):
super().on_submit()
self.post_submit_actions()
def custom_validation(self):
if self.grand_total > 1000000:
frappe.throw("Amount exceeds limit. Use Purchase Order workflow.")
def post_submit_actions(self):
self.notify_finance_team()---
Workflow 7: Permission Hooks
Use Case
Sales users only see their own invoices, managers see all.
Steps
Step 1: Add permission hooks
# myapp/hooks.py
permission_query_conditions = {
"Sales Invoice": "myapp.permissions.sales_invoice.get_query_conditions"
}
has_permission = {
"Sales Invoice": "myapp.permissions.sales_invoice.has_permission"
}Step 2: Implement permission handlers
# myapp/permissions/sales_invoice.py
import frappe
def get_query_conditions(user):
"""
Filter for LIST views only.
Returns SQL WHERE clause fragment.
⚠️ Only works with get_list, NOT get_all!
"""
if not user:
user = frappe.session.user
# Admins and managers see all
if "System Manager" in frappe.get_roles(user):
return ""
if "Sales Manager" in frappe.get_roles(user):
return ""
# Sales users see only their own
if "Sales User" in frappe.get_roles(user):
return f"`tabSales Invoice`.owner = {frappe.db.escape(user)}"
# Others see nothing
return "1=0"
def has_permission(doc, user=None, permission_type=None):
"""
Document-level permission check.
Returns:
True: Allow access
False: Deny access
None: Use default permission system
⚠️ Can only DENY permissions, not grant new ones!
"""
if not user:
user = frappe.session.user
# Block editing of closed invoices
if permission_type == "write" and doc.status == "Closed":
frappe.throw("Cannot edit closed invoices")
return False
# Block cancellation after 30 days
if permission_type == "cancel":
days_old = frappe.utils.date_diff(frappe.utils.today(), doc.posting_date)
if days_old > 30:
frappe.throw("Cannot cancel invoices older than 30 days")
return False
# Use default for everything else
return None---
Workflow 8: extend_bootinfo
Use Case
Send configuration to client JavaScript.
Steps
Step 1: Add to hooks.py
# myapp/hooks.py
extend_bootinfo = "myapp.boot.extend_with_config"Step 2: Implement boot handler
# myapp/boot.py
import frappe
def extend_with_config(bootinfo):
"""
Add data to frappe.boot object.
Available in client JS as frappe.boot.xxx
⚠️ Never send sensitive data (passwords, secrets)!
"""
# Add app settings
settings = frappe.get_single("My App Settings")
bootinfo.my_app = {
"feature_enabled": settings.feature_enabled,
"max_items": settings.max_items,
"api_endpoint": settings.public_api_endpoint
}
# Add user-specific data
bootinfo.my_app["user_preferences"] = frappe.db.get_value(
"User Preference",
{"user": frappe.session.user},
["theme", "notifications"],
as_dict=True
) or {}Step 3: Access in JavaScript
// Available immediately in any JS file
console.log(frappe.boot.my_app.feature_enabled);
if (frappe.boot.my_app.max_items > 100) {
// Handle large item lists differently
}---
Workflow 9: Fixtures Setup
Use Case
Export Custom Fields and Roles for deployment.
Steps
Step 1: Add fixtures configuration
# myapp/hooks.py
fixtures = [
# Custom Fields created by this app
{
"dt": "Custom Field",
"filters": [["module", "=", "My App"]]
},
# Property Setters (field modifications)
{
"dt": "Property Setter",
"filters": [["module", "=", "My App"]]
},
# Custom Roles
{
"dt": "Role",
"filters": [["name", "like", "MyApp%"]]
},
# Custom DocType data
{
"dt": "My App Config",
"filters": [["is_default", "=", 1]]
}
]Step 2: Export fixtures
# Creates JSON files in myapp/fixtures/
bench --site sitename export-fixturesStep 3: Import on other site
# Imports from fixtures folder
bench --site newsite migrate---
Workflow 10: doctype_js Form Extension
Use Case
Add custom buttons and behavior to Sales Invoice form.
Steps
Step 1: Add to hooks.py
# myapp/hooks.py
doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js"
}Step 2: Create JS file
// myapp/public/js/sales_invoice.js
frappe.ui.form.on("Sales Invoice", {
refresh: function(frm) {
// Add custom button on submitted invoices
if (frm.doc.docstatus === 1) {
frm.add_custom_button(__("Send to External ERP"), function() {
frm.trigger("send_to_erp");
}, __("Actions"));
}
// Add indicator
if (frm.doc.custom_external_id) {
frm.dashboard.add_indicator(
__("Synced: {0}", [frm.doc.custom_external_id]),
"green"
);
}
},
customer: function(frm) {
// React to customer change
if (frm.doc.customer) {
frappe.call({
method: "myapp.api.get_customer_discount",
args: { customer: frm.doc.customer },
callback: function(r) {
if (r.message) {
frm.set_value("discount_percentage", r.message);
}
}
});
}
},
send_to_erp: function(frm) {
frappe.call({
method: "myapp.api.send_to_external",
args: { invoice: frm.doc.name },
freeze: true,
freeze_message: __("Sending..."),
callback: function(r) {
if (r.message) {
frm.reload_doc();
frappe.show_alert({
message: __("Sent successfully"),
indicator: "green"
});
}
}
});
}
});Step 3: Build assets
bench build --app myapp---
Workflow 11: Website Hooks
Use Case
Customize portal behavior, add menu items, set up route rules.
Steps
Step 1: Add website hooks
# myapp/hooks.py
# Custom portal menu
portal_menu_items = [
{"title": "My Orders", "route": "/my-orders", "role": "Customer"},
{"title": "Support", "route": "/support", "role": "Customer"}
]
# URL routing rules
website_route_rules = [
{"from_route": "/shop/<category>", "to_route": "shop"},
{"from_route": "/invoice/<name>", "to_route": "invoice-view"}
]
# Inject context into all web pages
update_website_context = "myapp.website.update_context"
# Custom home page per role
role_home_page = {
"Customer": "my-orders",
"Supplier": "my-purchase-orders"
}Step 2: Implement context handler
# myapp/website.py
import frappe
def update_context(context):
"""Add data to all web pages."""
context.company_name = frappe.db.get_single_value(
"Website Settings", "company"
) or "My Company"Step 3: Deploy
bench --site sitename migrate---
Workflow 12: Session & Auth Hooks
Use Case
Execute code on login, logout, or session creation.
Steps
Step 1: Add session hooks
# myapp/hooks.py
on_login = "myapp.auth.on_login"
on_session_creation = "myapp.auth.on_session_creation"
on_logout = "myapp.auth.on_logout"Step 2: Implement handlers
# myapp/auth.py
import frappe
def on_login(login_manager):
"""Runs after successful login."""
user = login_manager.user
frappe.logger().info(f"User logged in: {user}")
# Example: enforce IP whitelist for admin
if "System Manager" in frappe.get_roles(user):
allowed_ips = ["10.0.0.0/8", "192.168.0.0/16"]
# Validate IP...
def on_session_creation(login_manager):
"""Runs when session is created."""
pass
def on_logout():
"""Runs on logout — no arguments."""
frappe.logger().info(f"User logged out: {frappe.session.user}")---
Workflow 13: Debugging Hooks That Don't Fire
Checklist
1. Did you migrate? bench --site sitename migrate — ALWAYS required 2. Is the path correct? The dotted path in hooks.py must match the actual module path 3. Is the module importable? Run bench --site sitename execute myapp.events.handler 4. Is __init__.py present? Every directory in the path needs __init__.py 5. Is the app installed? Check bench --site sitename list-apps 6. For scheduler: Is scheduler enabled? bench --site sitename scheduler status 7. For scheduler: Is the worker running? Check bench --site sitename doctor 8. Cache issue? bench --site sitename clear-cache
Quick Debug Pattern
# Add at the top of your handler to verify it fires
import frappe
def my_handler(doc, method=None):
frappe.logger("myapp").info(f"Hook fired: {doc.doctype} {doc.name}")
# ... rest of handler---
Anti-Patterns to Avoid (Quick Reference)
| Anti-Pattern | Risk | Correct Approach |
|---|---|---|
frappe.db.commit() in doc_events | Breaks transaction | Let Frappe commit |
Modify doc in on_update | Changes lost | Use frappe.db.set_value() |
| Scheduler task with args | Silent failure | No args, fetch data inside |
| Heavy task in default queue | Timeout (5 min) | Use _long variant |
Missing super() in override | Breaks core logic | ALWAYS call super() first |
get_all with permission_query | Not filtered | Use get_list instead |
| Secrets in bootinfo | Exposed in browser | Only public config |
| Fixtures without filters | Captures all apps | ALWAYS filter by module |
on_change + db_set_value | Infinite loop | Use flags or on_update |
| Multiple apps override same DocType | Last wins (V14/V15) | Use extend (V16) |