v2.0 to v2.0

269 added, 250 removed. Audit A to A.

---
name: frappe-syntax-controllers
description: >
Use when writing Python Document Controllers for ERPNext/Frappe DocTypes.
Covers lifecycle hooks (validate, on_update, on_submit), controller
override, submittable documents, autoname patterns, UUID naming (v16),
and the flags system. Keywords: document controller, lifecycle hook,
validate, on_update, on_submit, autoname, naming series, flags, v14-v16.
license: MIT
compatibility: "Claude Code, Claude.ai Projects, Claude API. Frappe v14-v16."
metadata:
author: OpenAEC-Foundation
version: "2.0"
---
- # ERPNext Syntax: Document Controllers
+ # Frappe Syntax: Document Controllers
- Document Controllers are Python classes that implement the server-side logic of a DocType.
+ Document Controllers are Python classes that define all server-side logic for a DocType.
+ EVERY DocType has exactly one controller file. The controller class extends `frappe.model.document.Document`.
## Quick Reference
- ### Controller Basic Structure
-
```python
import frappe
+ from frappe import _
from frappe.model.document import Document
class SalesOrder(Document):
+ def autoname(self):
+ """Custom naming logic. Sets self.name."""
+ self.name = f"SO-{self.customer_code}-{frappe.utils.now_datetime().year}"
+
def validate(self):
- """Main validation - runs on every save."""
+ """MAIN validation — runs on EVERY save (insert and update).
+ Changes to self ARE saved to database."""
if not self.items:
frappe.throw(_("Items are required"))
self.total = sum(item.amount for item in self.items)
-
+
def on_update(self):
- """After save - changes to self are NOT saved."""
- self.update_linked_docs()
+ """After save — changes to self are NOT saved.
+ Use frappe.db.set_value() for post-save field changes."""
+ self.notify_linked_docs()
+
+ def on_submit(self):
+ """After submit (docstatus 0 -> 1). Create ledger entries here."""
+ self.create_gl_entries()
+
+ def on_cancel(self):
+ """After cancel (docstatus 1 -> 2). Reverse ledger entries here."""
+ self.reverse_gl_entries()
+
+ @frappe.whitelist()
+ def recalculate(self):
+ """Exposed to client JS via frm.call('recalculate')."""
+ self.total = sum(item.amount for item in self.items)
+ return {"total": self.total}
```
- ### Location and Naming
+ ### File Location and Naming
- | DocType | Class | File |
- |---------|-------|------|
+ | DocType Name | Class Name | File Path |
+ |---|---|---|
| Sales Order | `SalesOrder` | `selling/doctype/sales_order/sales_order.py` |
- | Custom Doc | `CustomDoc` | `module/doctype/custom_doc/custom_doc.py` |
+ | My Custom Doc | `MyCustomDoc` | `module/doctype/my_custom_doc/my_custom_doc.py` |
- **Rule**: DocType name → PascalCase (remove spaces) → snake_case filename
+ **Rule**: DocType name -> PascalCase class -> snake_case filename. ALWAYS match exactly.
---
- ## Most Used Hooks
+ ## Lifecycle Hook Execution Order
- | Hook | When | Typical Use |
- |------|------|-------------|
- | `validate` | Before every save | Validation, calculations |
- | `on_update` | After every save | Notifications, linked docs |
- | `after_insert` | After new doc | Creation-only actions |
- | `on_submit` | After submit | Ledger entries, stock |
- | `on_cancel` | After cancel | Reverse ledger entries |
- | `on_trash` | Before delete | Cleanup related data |
- | `autoname` | On naming | Custom document name |
+ ### INSERT (new document)
- **Complete list and execution order**: See [lifecycle-methods.md](references/lifecycle-methods.md)
+ ```
+ before_insert -> before_naming -> autoname -> before_validate -> validate
+ -> before_save -> [db_insert] -> after_insert -> on_update -> on_change
+ ```
+ ### SAVE (existing document)
+
+ ```
+ before_validate -> validate -> before_save -> [db_update]
+ -> on_update -> on_change
+ ```
+
+ ### SUBMIT (docstatus 0 -> 1)
+
+ ```
+ before_validate -> validate -> before_submit -> [db_update]
+ -> on_submit -> on_update -> on_change
+ ```
+
+ ### CANCEL (docstatus 1 -> 2)
+
+ ```
+ before_cancel -> [db_update] -> on_cancel -> on_change
+ ```
+
+ ### UPDATE AFTER SUBMIT
+
+ ```
+ before_update_after_submit -> [db_update]
+ -> on_update_after_submit -> on_change
+ ```
+
+ ### DELETE
+
+ ```
+ on_trash -> [db_delete] -> after_delete
+ ```
+
+ ### DISCARD [v15+]
+
+ ```
+ before_discard -> [db_set docstatus=2] -> on_discard
+ ```
+
+ **Complete hook reference with parameters**: See [lifecycle-methods.md](references/lifecycle-methods.md)
+
---
## Hook Selection Decision Tree
```
- What do you want to do?
- │
- ├─► Validate or calculate fields?
- │ └─► validate
- │
- ├─► Action after save (emails, linked docs)?
- │ └─► on_update
- │
- ├─► Only for NEW docs?
- │ └─► after_insert
- │
- ├─► On SUBMIT?
- │ ├─► Check beforehand? → before_submit
- │ └─► Action afterwards? → on_submit
- │
- ├─► On CANCEL?
- │ ├─► Check beforehand? → before_cancel
- │ └─► Cleanup? → on_cancel
- │
- ├─► Custom document name?
- │ └─► autoname
- │
- └─► Cleanup before delete?
- └─► on_trash
+ What do you need to do?
+ |
+ +-- Validate data or calculate fields?
+ | +-- validate (changes to self ARE saved)
+ |
+ +-- Action AFTER save (emails, sync, linked docs)?
+ | +-- on_update (changes to self are NOT saved)
+ |
+ +-- Only for NEW documents?
+ | +-- after_insert (runs once on first save only)
+ |
+ +-- Custom document name?
+ | +-- autoname (set self.name)
+ |
+ +-- Before/after SUBMIT?
+ | +-- Validate before submit? -> before_submit
+ | +-- Create entries after submit? -> on_submit
+ |
+ +-- Before/after CANCEL?
+ | +-- Check linked docs? -> before_cancel
+ | +-- Reverse entries? -> on_cancel
+ |
+ +-- Cleanup before delete?
+ | +-- on_trash
+ |
+ +-- React to ANY value change (including db_set)?
+ | +-- on_change (MUST be idempotent)
```
---
## Critical Rules
### 1. Changes after on_update are NOT saved
```python
- # ❌ WRONG - change is lost
+ # WRONG - change is lost after on_update
def on_update(self):
- self.status = "Completed" # NOT saved
+ self.status = "Completed" # NOT saved to database
- # ✅ CORRECT - use db_set
+ # CORRECT - use db_set or frappe.db.set_value
def on_update(self):
- frappe.db.set_value(self.doctype, self.name, "status", "Completed")
+ self.db_set("status", "Completed")
```
- ### 2. No commits in controllers
+ ### 2. NEVER call frappe.db.commit() in controllers
```python
- # ❌ WRONG - Frappe handles commits
- def on_update(self):
- frappe.db.commit() # DON'T DO THIS
+ # WRONG - breaks Frappe transaction management
+ def validate(self):
+ frappe.db.commit() # Can cause partial updates on error
- # ✅ CORRECT - no commit needed
- def on_update(self):
- self.update_related() # Frappe commits automatically
+ # CORRECT - Frappe commits automatically at end of request
+ def validate(self):
+ self.update_related() # No commit needed
```
- ### 3. Always call super() when overriding
+ ### 3. ALWAYS call super() when overriding
```python
- # ❌ WRONG - parent logic is skipped
+ # WRONG - parent validation is skipped entirely
def validate(self):
self.custom_check()
- # ✅ CORRECT - parent logic is preserved
+ # CORRECT - parent logic preserved
def validate(self):
super().validate()
self.custom_check()
```
### 4. Use flags for recursion prevention
```python
def on_update(self):
- if self.flags.get('from_linked_doc'):
+ if self.flags.get("from_linked_doc"):
return
-
linked = frappe.get_doc("Linked Doc", self.linked_doc)
linked.flags.from_linked_doc = True
linked.save()
```
+ ### 5. NEVER put validation logic in on_update
+
+ ```python
+ # WRONG - document is already saved when this throws
+ def on_update(self):
+ if self.total < 0:
+ frappe.throw("Invalid total") # Too late!
+
+ # CORRECT - validate BEFORE save
+ def validate(self):
+ if self.total < 0:
+ frappe.throw("Invalid total") # Blocks save
+ ```
+
---
## Document Naming (autoname)
- ### Available Naming Options
-
- | Option | Example | Result | Version |
- |--------|---------|--------|---------|
+ | Method | Example | Result | Version |
+ |---|---|---|---|
| `field:fieldname` | `field:customer_name` | `ABC Company` | All |
| `naming_series:` | `naming_series:` | `SO-2024-00001` | All |
- | `format:PREFIX-{##}` | `format:INV-{YYYY}-{####}` | `INV-2024-0001` | All |
- | `hash` | `hash` | `a1b2c3d4e5` | All |
+ | Expression | `PRE-.#####` | `PRE-00001` | All |
+ | Old-style format | `INV-{YYYY}-{####}` | `INV-2024-0001` | Deprecated v16 |
+ | `hash` / `random` | `hash` | `a1b2c3d4e5` | All |
| `Prompt` | `Prompt` | User enters name | All |
- | **`UUID`** | `UUID` | `01948d5f-...` | **v16+** |
- | Custom method | Controller autoname() | Any pattern | All |
-
- ### UUID Naming (v16+)
-
- New in v16: UUID-based naming for globally unique identifiers.
-
- ```json
- {
- "doctype": "DocType",
- "autoname": "UUID"
- }
- ```
-
- **Benefits:**
- - Globally unique across systems
- - Better data integrity and traceability
- - Reduced database storage
- - Faster bulk record creation
- - Link fields store UUID in native format
-
- **Implementation:**
- ```python
- # Frappe automatically generates UUID7
- # In naming.py:
- if meta.autoname == "UUID":
- doc.name = str(uuid_utils.uuid7())
- ```
-
- **Validation:**
- ```python
- # UUID names are validated on import
- from uuid import UUID
- try:
- UUID(doc.name)
- except ValueError:
- frappe.throw(_("Invalid UUID: {}").format(doc.name))
- ```
+ | `autoincrement` | `autoincrement` | `1`, `2`, `3` | All |
+ | **`UUID`** | `UUID` | `550e8400-e29b-...` | **v16+** |
+ | Custom method | `autoname()` in controller | Any pattern | All |
### Custom autoname Method
```python
from frappe.model.naming import getseries
class Project(Document):
def autoname(self):
- # Custom naming based on customer
- prefix = f"P-{self.customer}-"
+ prefix = f"P-{self.customer[:3].upper()}-"
self.name = getseries(prefix, 3)
- # Result: P-ACME-001, P-ACME-002, etc.
+ # Result: P-ACM-001, P-ACM-002, etc.
```
- ### Format Patterns
+ ### UUID Naming [v16+]
- | Pattern | Description | Example |
- |---------|-------------|---------|
- | `{#}` | Counter | 1, 2, 3 |
- | `{##}` | Zero-padded counter | 01, 02, 03 |
- | `{####}` | 4-digit counter | 0001, 0002 |
- | `{YYYY}` | Full year | 2024 |
- | `{YY}` | 2-digit year | 24 |
- | `{MM}` | Month | 01-12 |
- | `{DD}` | Day | 01-31 |
- | `{fieldname}` | Field value | (value) |
+ Set `autoname = "UUID"` in DocType definition. Frappe generates UUID v4.
+ ```
+ When to use UUID: When to use traditional naming:
+ - Cross-system sync - User-facing references (SO-00001)
+ - Bulk record creation - Sequential numbering required
+ - Global uniqueness needed - Auditing requires readable names
+ ```
+
---
- ## Controller Override
+ ## Controller Extension Mechanisms
- ### Via hooks.py (override_doctype_class)
+ ### 1. override_doctype_class (full replacement) [All versions]
```python
# hooks.py
override_doctype_class = {
"Sales Order": "custom_app.overrides.CustomSalesOrder"
}
# custom_app/overrides.py
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
class CustomSalesOrder(SalesOrder):
def validate(self):
- super().validate()
+ super().validate() # ALWAYS call super()
self.custom_validation()
```
- ### Via doc_events (hooks.py)
+ **WARNING**: Only ONE app can override a DocType class. Multiple overrides conflict.
+ ### 2. extend_doctype_class (mixin, non-destructive) [v16+]
+
```python
# hooks.py
+ extend_doctype_class = {
+ "Address": ["custom_app.extensions.address.GeocodingMixin"],
+ "Contact": [
+ "custom_app.extensions.common.ValidationMixin",
+ "custom_app.extensions.contact.PhoneMixin"
+ ]
+ }
+
+ # custom_app/extensions/address.py
+ from frappe.model.document import Document
+
+ class GeocodingMixin(Document):
+ @property
+ def full_address(self):
+ return f"{self.address_line1}, {self.city}, {self.country}"
+
+ def validate(self):
+ super().validate()
+ self.geocode_address()
+ ```
+
+ **ALWAYS prefer `extend_doctype_class` over `override_doctype_class` in v16+.**
+ Multiple apps can safely extend the same DocType.
+
+ ### 3. doc_events (hook individual events) [All versions]
+
+ ```python
+ # hooks.py
doc_events = {
"Sales Order": {
"validate": "custom_app.events.validate_sales_order",
"on_submit": "custom_app.events.on_submit_sales_order"
+ },
+ "*": { # ALL DocTypes
+ "after_insert": "custom_app.events.log_creation"
}
}
# custom_app/events.py
- def validate_sales_order(doc, method):
+ def validate_sales_order(doc, method=None):
if doc.total > 100000:
doc.requires_approval = 1
```
- **Choice**: `override_doctype_class` for full control, `doc_events` for individual hooks.
+ ### When to Use Which
- ---
+ ```
+ Need full class replacement? -> override_doctype_class [all versions]
+ Need to add methods/properties? -> extend_doctype_class [v16+]
+ Need to hook one or two events? -> doc_events [all versions]
+ Need to extend in v14/v15? -> override_doctype_class or doc_events
+ ```
- ## Submittable Documents
+ ---
- Documents with `is_submittable = 1` have a docstatus lifecycle:
+ ## Whitelisted Methods
- | docstatus | Status | Editable | Can go to |
- |-----------|--------|----------|-----------|
- | 0 | Draft | ✅ Yes | 1 (Submit) |
- | 1 | Submitted | ❌ No | 2 (Cancel) |
- | 2 | Cancelled | ❌ No | - |
+ Expose controller methods to client-side JavaScript with `@frappe.whitelist()`:
```python
- class StockEntry(Document):
- def on_submit(self):
- """After submit - create stock ledger entries."""
- self.update_stock_ledger()
-
- def on_cancel(self):
- """After cancel - reverse the entries."""
- self.reverse_stock_ledger()
+ class SalesOrder(Document):
+ @frappe.whitelist()
+ def send_email(self, recipient):
+ """Callable from JS: frm.call('send_email', {recipient: '...'})"""
+ frappe.sendmail(recipients=[recipient], message="Order confirmed")
+ return {"status": "sent"}
```
+ ```javascript
+ // Client-side call
+ frm.call('send_email', { recipient: 'customer@example.com' })
+ .then(r => frappe.msgprint(r.message.status));
+ ```
+
+ **Rules**:
+ - ALWAYS add `@frappe.whitelist()` decorator — without it, the method is NOT callable from client
+ - The method MUST be defined on the controller class (not standalone)
+ - Permission checks happen automatically (user must have read access to the document)
+
---
- ## Virtual DocTypes
+ ## Submittable Documents
- For external data sources (no database table):
+ Documents with `is_submittable = 1` follow the docstatus lifecycle:
- ```python
- class ExternalCustomer(Document):
- @staticmethod
- def get_list(args):
- return external_api.get_customers(args.get("filters"))
-
- @staticmethod
- def get_count(args):
- return external_api.count_customers(args.get("filters"))
-
- @staticmethod
- def get_stats(args):
- return {}
- ```
+ | docstatus | State | Editable | Transitions |
+ |---|---|---|---|
+ | 0 | Draft | Yes | -> 1 (Submit) |
+ | 1 | Submitted | Only "Allow on Submit" fields | -> 2 (Cancel) |
+ | 2 | Cancelled | No | None (amend creates new Draft) |
+ ALWAYS implement both `on_submit` and `on_cancel` as a pair.
+ ALWAYS reverse in `on_cancel` what `on_submit` created.
+
---
## Inheritance Patterns
- ### Standard Controller
```python
+ # Standard controller
from frappe.model.document import Document
-
- class MyDocType(Document):
- pass
- ```
+ class MyDoc(Document): pass
- ### Tree DocType
- ```python
+ # Tree DocType (hierarchical)
from frappe.utils.nestedset import NestedSet
-
class Department(NestedSet):
- pass
- ```
-
- ### Extend Existing Controller
- ```python
- from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
+ nsm_parent_field = "parent_department"
- class CustomSalesOrder(SalesOrder):
- def validate(self):
- super().validate()
- self.custom_validation()
+ # Virtual DocType (no database table)
+ class ExternalData(Document):
+ def load_from_db(self): ...
+ def db_insert(self, *args, **kwargs): ...
+ def db_update(self, *args, **kwargs): ...
+ @staticmethod
+ def get_list(args): ...
+ @staticmethod
+ def get_count(args): ...
```
---
- ## Type Annotations (v15+)
+ ## Type Annotations [v15+]
```python
class Person(Document):
if TYPE_CHECKING:
from frappe.types import DF
first_name: DF.Data
last_name: DF.Data
birth_date: DF.Date
- ```
-
- Enable in `hooks.py`:
- ```python
- export_python_type_annotations = True
+ company: DF.Link
```
- ---
-
- ## Reference Files
-
- | File | Contents |
- |------|----------|
- | [lifecycle-methods.md](references/lifecycle-methods.md) | All hooks, execution order, examples |
- | [methods.md](references/methods.md) | All doc.* methods with signatures |
- | [flags.md](references/flags.md) | Flags system documentation |
- | [examples.md](references/examples.md) | Complete working controller examples |
- | [anti-patterns.md](references/anti-patterns.md) | Common mistakes and corrections |
+ Enable auto-generation in `hooks.py`: `export_python_type_annotations = True`
---
## Version Differences
| Feature | v14 | v15 | v16 |
- |---------|-----|-----|-----|
- | Type annotations | ❌ | ✅ Auto-generated | ✅ |
- | `before_discard` hook | ❌ | ✅ | ✅ |
- | `on_discard` hook | ❌ | ✅ | ✅ |
- | `flags.notify_update` | ❌ | ✅ | ✅ |
- | **UUID autoname** | ❌ | ❌ | ✅ |
- | **UUID in Link fields (native)** | ❌ | ❌ | ✅ |
-
- ### v16-Specific Notes
-
- **UUID Naming:**
- - Set `autoname = "UUID"` in DocType definition
- - Uses `uuid7()` for time-ordered UUIDs
- - Link fields store UUIDs in native format (not text)
- - Improves performance for bulk operations
-
- **Choosing UUID vs Traditional Naming:**
- ```
- When to use UUID:
- ├── Cross-system data synchronization
- ├── Bulk record creation
- ├── Global uniqueness required
- └── No human-readable name needed
-
- When to use traditional naming:
- ├── User-facing document references (SO-00001)
- ├── Sequential numbering required
- ├── Auditing requires readable names
- └── Integration with legacy systems
- ```
+ |---|---|---|---|
+ | Type annotations | No | Auto-generated | Yes |
+ | `before_discard` / `on_discard` | No | Yes | Yes |
+ | `flags.notify_update` | No | Yes | Yes |
+ | `extend_doctype_class` | No | No | **Yes** |
+ | UUID autoname | No | No | **Yes** |
+ | Old-style format naming | Yes | Yes | Deprecated |
---
- ## Anti-Patterns
-
- ### ❌ Direct field change after on_update
- ```python
- def on_update(self):
- self.status = "Done" # Will be lost!
- ```
-
- ### ❌ frappe.db.commit() in controller
- ```python
- def validate(self):
- frappe.db.commit() # Breaks transaction!
- ```
-
- ### ❌ Forgetting to call super()
- ```python
- def validate(self):
- self.my_check() # Parent validate is skipped
- ```
-
- → See [anti-patterns.md](references/anti-patterns.md) for complete list.
+ ## Reference Files
- ---
+ | File | Contents |
+ |---|---|
+ | [lifecycle-methods.md](references/lifecycle-methods.md) | All hooks with execution order diagrams |
+ | [methods.md](references/methods.md) | Document class method signatures |
+ | [events.md](references/events.md) | All document events in order |
+ | [examples.md](references/examples.md) | Complete working controller examples |
+ | [anti-patterns.md](references/anti-patterns.md) | Common mistakes and corrections |
+ | [flags.md](references/flags.md) | Flags system (doc.flags, frappe.flags) |
+ | [hooks.md](references/hooks.md) | Controller interaction with hooks.py |
+ | [patterns.md](references/patterns.md) | Common controller patterns |
+ | [syntax.md](references/syntax.md) | Controller class syntax reference |
## Related Skills
- - `frappe-syntax-serverscripts` – Server Scripts (sandbox alternative)
- - `frappe-syntax-hooks` – hooks.py configuration
- - `frappe-impl-controllers` – Implementation workflows
+ - `frappe-syntax-serverscripts` -- Server Scripts (sandbox alternative)
+ - `frappe-syntax-hooks` -- hooks.py configuration
+ - `frappe-impl-controllers` -- Implementation workflows
+ - `frappe-core-permissions` -- Permission system