
Frappe Impl Serverscripts
- 58 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Provides workflows for implementing Frappe Server Scripts including document validation, auto-fill, API endpoints, scheduled tasks, and sandbox-safe coding.
About
An implementation skill for building Frappe Server Scripts via the low-code Setup interface. A developer uses it to add no-code validation, auto-fill, API endpoints, and scheduled tasks within the sandbox.
- Workflows for validation, auto-fill, API endpoints, and scheduled scripts
- Sandbox-safe coding, script type selection, and migration to controllers
Frappe Impl Serverscripts by the numbers
- 58 all-time installs (skills.sh)
- Ranked #3,178 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-serverscriptsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| 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 Server Scripts including document validation, auto-fill, API endpoints, scheduled tasks, and sandbox-safe coding.
Files
Server Scripts — Implementation Workflows
Step-by-step workflows for building server-side features without a custom app. For exact syntax, see frappe-syntax-serverscripts.
Version: v14/v15/v16 | v15+ Note: Server Scripts disabled by default — enable with bench set-config server_script_enabled true
CRITICAL: Sandbox Limitations
ALL IMPORTS BLOCKED — RestrictedPython sandbox
import json → ImportError: __import__ not found
from frappe.utils → ImportError
import requests → ImportError
SOLUTION: Use pre-loaded namespace:
frappe.utils.nowdate() frappe.utils.flt()
frappe.parse_json(data) json.loads() (json IS available)
frappe.as_json(obj) json.dumps()
frappe.make_get_request(url) (replaces requests.get)Rule: If you need import statements beyond json, ALWAYS use a Controller instead.
Workflow 1: Create a Server Script
1. Enable server scripts: bench set-config server_script_enabled true 2. Navigate to Setup > Server Script (or awesomebar: "New Server Script") 3. Select Script Type (see decision tree below) 4. Configure type-specific settings (DocType, event, API method, cron) 5. Write script in the editor 6. Save — script is active immediately 7. Test by triggering the configured event 8. Use "Compare Versions" button to diff changes
Workflow 2: Choose the Script Type
WHAT DO YOU NEED?
│
├── React to document save/submit/cancel?
│ └── Document Event
│ └── Select DocType + Event (Before Save, After Save, etc.)
│
├── Create a REST API endpoint?
│ └── API
│ └── Set method name + guest access setting
│ └── Endpoint: /api/method/{method_name}
│
├── Run task on schedule (daily/hourly/cron)?
│ └── Scheduler Event
│ └── Set cron pattern or frequency
│
└── Filter list views per user/role?
└── Permission Query
└── Select DocType — set `conditions` variableSee references/decision-tree.md for complete decision tree.
Workflow 3: Document Event: Validation
Goal: Validate Sales Order before save.
Step 1: Choose event — "Before Save" maps to validate hook.
Step 2: Write sandbox-safe script:
# Type: Document Event | Event: Before Save | DocType: Sales Order
errors = []
if not doc.customer:
errors.append("Customer is required")
if doc.delivery_date and doc.delivery_date < frappe.utils.today():
errors.append("Delivery date cannot be in the past")
for item in doc.items:
if item.qty <= 0:
errors.append(f"Row {item.idx}: Quantity must be positive")
if errors:
frappe.throw("<br>".join(errors), title="Validation Error")Rules:
- ALWAYS collect errors and throw once (better UX than multiple throws)
- NEVER call
doc.save()in Before Save — framework handles it - ALWAYS use
frappe.throw()—msgprintdoes NOT stop save
Workflow 4: Document Event: Auto-Calculate
Goal: Auto-calculate totals and set derived fields.
# Type: Document Event | Event: Before Save | DocType: Purchase Order
doc.total_qty = sum(item.qty or 0 for item in doc.items)
doc.total_amount = sum((item.qty or 0) * (item.rate or 0) for item in doc.items)
if doc.total_amount > 50000:
doc.requires_approval = 1
doc.approval_status = "Pending"
if doc.supplier and not doc.supplier_name:
doc.supplier_name = frappe.db.get_value("Supplier", doc.supplier, "supplier_name")Rule: ALWAYS modify doc fields directly in Before Save — they are automatically persisted.
Workflow 5: Document Event: Create Related Document
Goal: Create a ToDo when a new Lead is inserted.
# Type: Document Event | Event: After Insert | DocType: Lead
frappe.get_doc({
"doctype": "ToDo",
"allocated_to": doc.lead_owner or doc.owner,
"reference_type": "Lead",
"reference_name": doc.name,
"description": f"Follow up with new lead: {doc.lead_name}",
"date": frappe.utils.add_days(frappe.utils.today(), 1),
"priority": "High" if doc.status == "Hot" else "Medium"
}).insert(ignore_permissions=True)Rules:
- ALWAYS use After Insert or After Save for creating related docs
- NEVER create documents in Before Save —
doc.namemay not exist yet - ALWAYS use
ignore_permissions=Truefor system-generated documents
Workflow 6: API Endpoint
Goal: Create authenticated REST API returning customer data.
# Type: API | Method: get_customer_dashboard | Allow Guest: No
# Endpoint: /api/method/get_customer_dashboard
customer = frappe.form_dict.get("customer")
if not customer:
frappe.throw("Parameter 'customer' is required")
# ALWAYS check permissions
if not frappe.has_permission("Customer", "read", customer):
frappe.throw("Access denied", frappe.PermissionError)
orders = frappe.db.count("Sales Order", {"customer": customer, "docstatus": 1})
revenue = frappe.db.get_value("Sales Invoice",
filters={"customer": customer, "docstatus": 1},
fieldname="sum(grand_total)") or 0
frappe.response["message"] = {
"customer": customer,
"total_orders": orders,
"total_revenue": revenue
}Rules:
- ALWAYS validate input parameters
- ALWAYS check permissions (even with Allow Guest: No)
- ALWAYS cap query limits:
min(frappe.utils.cint(limit), 100) - NEVER expose full documents — return only needed fields
Workflow 7: Scheduler Event
Goal: Daily reminder for overdue invoices.
# Type: Scheduler Event | Cron: 0 9 * * * (daily at 9:00)
BATCH_SIZE = 50
today = frappe.utils.today()
overdue = frappe.get_all("Sales Invoice",
filters={
"status": "Unpaid",
"due_date": ["<", today],
"docstatus": 1
},
fields=["name", "customer", "owner", "due_date", "grand_total"],
limit=BATCH_SIZE
)
for inv in overdue:
days = frappe.utils.date_diff(today, inv.due_date)
if not frappe.db.exists("ToDo", {
"reference_type": "Sales Invoice",
"reference_name": inv.name,
"status": "Open"
}):
frappe.get_doc({
"doctype": "ToDo",
"allocated_to": inv.owner,
"reference_type": "Sales Invoice",
"reference_name": inv.name,
"description": f"Invoice {inv.name} is {days} days overdue"
}).insert(ignore_permissions=True)
frappe.db.commit() # REQUIRED in scheduler scriptsRules:
- ALWAYS add
frappe.db.commit()at end of scheduler scripts - ALWAYS add
limitto queries — prevent memory exhaustion - ALWAYS use
try/except+frappe.log_error()in loops - NEVER run scheduler scripts that process unlimited records
Workflow 8: Permission Query
Goal: Users see only their territory's customers.
# Type: Permission Query | DocType: Customer
user_territory = frappe.db.get_value("User", user, "territory")
user_roles = frappe.get_roles(user)
if "System Manager" in user_roles:
conditions = "" # Full access
elif user_territory:
conditions = f"`tabCustomer`.territory = {frappe.db.escape(user_territory)}"
else:
conditions = f"`tabCustomer`.owner = {frappe.db.escape(user)}"Rules:
- ALWAYS give System Manager full access (
conditions = "") - ALWAYS use
frappe.db.escape()for user input in SQL - ALWAYS set
conditionsvariable — it is the output - Permission Query only affects
frappe.db.get_list, NOTfrappe.db.get_all
Event Name Mapping
| UI Name | Internal Hook | Best For |
|---|---|---|
| Before Validate | before_validate | Pre-validation defaults |
| Before Save | `validate` | Validation + calculations (MOST COMMON) |
| After Save | on_update | Notifications, audit logs |
| After Insert | after_insert | Create related docs (new only) |
| Before Submit | before_submit | Submit-time validation |
| After Submit | on_submit | Post-submit automation |
| Before Cancel | before_cancel | Cancel prevention |
| After Cancel | on_cancel | Cleanup after cancel |
| Before Delete | on_trash | Delete prevention |
Sandbox-Safe API Quick Reference
| Need | Use (NOT import) |
|---|---|
| Parse JSON | frappe.parse_json() or json.loads() |
| Serialize JSON | frappe.as_json() or json.dumps() |
| Today's date | frappe.utils.today() |
| Now (datetime) | frappe.utils.now() |
| Add days | frappe.utils.add_days(date, n) |
| Date diff | frappe.utils.date_diff(d1, d2) |
| Float conversion | frappe.utils.flt(val) |
| Int conversion | frappe.utils.cint(val) |
| HTTP GET | frappe.make_get_request(url) |
| HTTP POST | frappe.make_post_request(url, data) |
| Render template | frappe.render_template(tmpl, ctx) |
| Log error | frappe.log_error(msg, title) |
| Send email | frappe.sendmail(recipients, subject, message) |
When to Migrate to Controller
ALWAYS migrate to a Document Controller when:
- You need
importstatements (beyondjson) - Script exceeds 100 lines
- You need try/except with rollback
- You need
frappe.enqueue()for background jobs - You need to extend an existing ERPNext DocType
- Multiple scripts on same DocType become hard to manage
Migration path: See frappe-impl-controllers for controller implementation.
Related Skills
frappe-syntax-serverscripts— Exact sandbox API referencefrappe-errors-serverscripts— Error handling and anti-patternsfrappe-core-database—frappe.db.*operationsfrappe-core-permissions— Permission system detailsfrappe-impl-controllers— When to migrate from Server Script
See references/decision-tree.md for complete decision trees.
See references/workflows.md for extended patterns.
See references/examples.md for 10+ complete examples.
Server Script Anti-Patterns
Sandbox Violations
AP-1: Using Import Statements
# WRONG — All imports blocked
import json
from datetime import datetime
from frappe.utils import nowdate# CORRECT — Use pre-loaded namespace
data = frappe.parse_json(doc.custom_json) # or json.loads()
today = frappe.utils.now()
date = frappe.utils.nowdate()AP-2: File System Access
# WRONG — open() is blocked
with open('/tmp/data.json', 'r') as f:
data = f.read()# CORRECT — Use Frappe's file handling
file_doc = frappe.get_doc("File", {"file_url": doc.attachment})
content = file_doc.get_content()AP-3: Dynamic Code Execution
# WRONG — eval/exec blocked
result = eval(doc.price_formula)# CORRECT — Explicit logic or safe_eval (v15+)
if doc.price_type == "markup":
result = doc.cost * (1 + doc.markup_percent / 100)Database Mistakes
AP-4: SQL Injection
# WRONG — String formatting
results = frappe.db.sql(f"SELECT * FROM `tabItem` WHERE name = '{name}'")# CORRECT — Parameterized queries
results = frappe.db.sql("""
SELECT * FROM `tabItem` WHERE name = %(name)s
""", {"name": name}, as_dict=True)AP-5: N+1 Query Problem
# WRONG — Query per item
for item in doc.items:
stock = frappe.db.get_value("Bin",
{"item_code": item.item_code}, "actual_qty")# CORRECT — Batch fetch
codes = [item.item_code for item in doc.items]
stock_data = frappe.db.get_all("Bin",
filters={"item_code": ["in", codes]},
fields=["item_code", "actual_qty"])
stock_map = {s["item_code"]: s["actual_qty"] for s in stock_data}
for item in doc.items:
item.available_qty = stock_map.get(item.item_code, 0)AP-6: Commit in Document Events
# WRONG — Framework handles commits in doc events
doc.total = calculate_total(doc)
frappe.db.commit()# CORRECT — No commit needed
doc.total = calculate_total(doc)AP-7: Missing Commit in Scheduler
# WRONG — Changes may not persist
for inv in overdue:
frappe.db.set_value("Sales Invoice", inv.name, "overdue_notified", 1)
# Missing commit!# CORRECT — ALWAYS commit in scheduler scripts
for inv in overdue:
frappe.db.set_value("Sales Invoice", inv.name, "overdue_notified", 1)
frappe.db.commit()Event Selection Mistakes
AP-8: Validation in After Save
# WRONG — Document already saved
# Event: After Save
if doc.grand_total < 0:
frappe.throw("Total cannot be negative") # Too late!# CORRECT — Validate in Before Save
# Event: Before Save
if doc.grand_total < 0:
frappe.throw("Total cannot be negative") # Prevents saveAP-9: Creating Documents in Before Save
# WRONG — doc.name may not exist yet
# Event: Before Save
frappe.get_doc({"doctype": "ToDo",
"reference_name": doc.name}).insert() # doc.name is None!# CORRECT — Use After Insert or After Save
# Event: After Insert
frappe.get_doc({"doctype": "ToDo",
"reference_name": doc.name}).insert() # doc.name guaranteedAP-10: doc.save() in Before Save
# WRONG — Infinite loop
# Event: Before Save
doc.total = recalculate(doc)
doc.save() # Triggers Before Save again!# CORRECT — Modify doc directly
# Event: Before Save
doc.total = recalculate(doc)
# Framework saves automaticallyAPI Script Mistakes
AP-11: Missing Permission Check
# WRONG — Anyone can access
data = frappe.get_doc("Customer", customer).as_dict()
frappe.response["message"] = data# CORRECT — Check permissions
if not frappe.has_permission("Customer", "read", customer):
frappe.throw("Permission denied", frappe.PermissionError)
data = frappe.db.get_value("Customer", customer,
["name", "customer_name"], as_dict=True) # Only needed fields
frappe.response["message"] = dataAP-12: Not Validating Input
# WRONG — User controls limit
limit = frappe.form_dict.get("limit")
items = frappe.db.get_all("Item", limit=limit) # Could be 1000000# CORRECT — Validate and cap
limit = min(frappe.utils.cint(frappe.form_dict.get("limit", 20)), 100)
items = frappe.db.get_all("Item", limit=limit)General Mistakes
AP-13: Silent Error Swallowing
# WRONG
try:
process_data()
except:
pass# CORRECT
try:
process_data()
except Exception:
frappe.log_error(f"Failed for {doc.name}", "Processing Error")AP-14: Using msgprint for Validation
# WRONG — Does NOT stop save
if doc.total < 0:
frappe.msgprint("Total cannot be negative")# CORRECT — Stops execution and prevents save
if doc.total < 0:
frappe.throw("Total cannot be negative")AP-15: Hardcoded Values
# WRONG
if doc.grand_total > 10000:
doc.requires_approval = 1# CORRECT — Use configurable settings
threshold = frappe.db.get_single_value("Selling Settings",
"approval_threshold") or 10000
if doc.grand_total > threshold:
doc.requires_approval = 1AP-16: get_all in User-Facing Code
# WRONG — get_all bypasses permission query
docs = frappe.db.get_all("Sales Invoice")# CORRECT — get_list applies permission query
docs = frappe.db.get_list("Sales Invoice")Quick Reference
| Do NOT | Do Instead |
|---|---|
import json | json.loads() (pre-loaded) or frappe.parse_json() |
import datetime | frappe.utils.getdate() |
open(file) | frappe.get_doc("File") |
eval(expr) | Explicit logic |
| SQL string formatting | Parameterized queries %(var)s |
| Query in loop | Batch fetch with ["in", list] |
frappe.db.commit() in events | Let framework handle |
| Validate in After Save | Validate in Before Save |
| Create docs in Before Save | Create in After Insert/Save |
get_all for user data | get_list with permissions |
except: pass | Log and handle errors |
msgprint for errors | throw for validation |
Server Script Decision Trees
Decision 1: Server Script vs Controller vs hooks.py?
WHAT DO YOU NEED?
│
├── Document automation (validation, auto-fill, notifications)?
│ ├── Need external libraries or complex transactions?
│ │ ├── YES → Controller in custom app
│ │ └── NO → Server Script: Document Event
│ └── Run on MULTIPLE doctypes?
│ ├── YES → hooks.py doc_events
│ └── NO → Server Script: Document Event
│
├── Custom REST API endpoint?
│ ├── Need file uploads, streaming, or complex auth?
│ │ ├── YES → @frappe.whitelist() in custom app
│ │ └── NO → Server Script: API
│ └── Need database transaction control?
│ ├── YES → Controller whitelisted method
│ └── NO → Server Script: API
│
├── Scheduled/background task?
│ ├── Simple task (< 100 records, no external calls)?
│ │ └── Server Script: Scheduler Event
│ ├── Complex task or external integrations?
│ │ └── hooks.py scheduler_events + custom method
│ └── Need frappe.enqueue() for background job?
│ └── Controller method (enqueue not in sandbox)
│
└── Dynamic permission filtering?
├── Filter list view per user/role?
│ └── Server Script: Permission Query
└── Custom has_permission logic?
└── hooks.py has_permissionDecision 2: Which Document Event?
WHAT TRIGGERS YOUR CODE?
│
├── BEFORE document operations
│ ├── Before any validation runs?
│ │ └── Before Validate (before_validate)
│ │ Use for: Pre-processing, setting defaults
│ │
│ ├── Validate data / auto-calculate before save?
│ │ └── Before Save (validate) ← MOST COMMON
│ │ Use for: Validation, calculations, auto-fill
│ │ Changes to doc: SAVED automatically
│ │
│ ├── Check conditions before submit?
│ │ └── Before Submit (before_submit)
│ │ Use for: Submit-time checks, approval validation
│ │
│ └── Prevent or validate cancel?
│ └── Before Cancel (before_cancel)
│ Use for: Check linked docs, block cancel
│
├── AFTER document operations
│ ├── React to new document (first save only)?
│ │ └── After Insert (after_insert)
│ │ Use for: Welcome emails, create related docs
│ │ doc.name: GUARANTEED to exist
│ │
│ ├── React to any save (new or update)?
│ │ └── After Save (on_update)
│ │ Use for: Audit logs, notifications, sync
│ │ Changes to doc: NOT saved — use db_set
│ │
│ ├── React to submission?
│ │ └── After Submit (on_submit)
│ │ Use for: Ledger entries, stock updates
│ │
│ └── React to cancellation?
│ └── After Cancel (on_cancel)
│ Use for: Reverse entries, cleanup
│
└── DELETE operations
├── Prevent or validate delete?
│ └── Before Delete (on_trash)
└── Cleanup after delete?
└── After Delete (after_delete)Decision 3: Validation Location
WHERE SHOULD VALIDATION HAPPEN?
│
├── UX feedback only (can be bypassed via API)?
│ └── Client Script validate event
│
├── MUST always run, even via API/import?
│ ├── Simple validation (single doctype, no imports)?
│ │ └── Server Script: Before Save
│ └── Complex validation or multiple doctypes?
│ └── Controller validate method
│
└── Data integrity (can NEVER be violated)?
└── BOTH client + server validation
(Server is authoritative)Decision 4: API Authentication
WHO CAN ACCESS YOUR API?
│
├── Anyone (public, no login)?
│ └── Server Script API + Allow Guest: Yes
│ ALWAYS validate/sanitize all inputs
│
├── Any logged-in user?
│ └── Server Script API + Allow Guest: No
│ Add: Permission check in script body
│
├── Specific roles only?
│ └── Server Script API + Allow Guest: No
│ Add: frappe.get_roles() check
│
└── External systems (API key auth)?
└── @frappe.whitelist() in custom app
Use: frappe.get_request_header("Authorization")Document Lifecycle Order
NEW DOCUMENT:
before_insert → before_validate → validate → after_insert → on_update
EXISTING DOCUMENT:
before_validate → validate → on_update
SUBMIT:
before_submit → on_submit
CANCEL:
before_cancel → on_cancel
DELETE:
on_trash → after_deleteScheduler Cron Patterns
| Pattern | Meaning | Use Case |
|---|---|---|
*/15 * * * * | Every 15 min | Status updates, queue processing |
0 * * * * | Every hour | Aggregations, cache refresh |
0 9 * * * | Daily 9:00 | Morning reports, reminders |
0 2 * * * | Daily 2:00 | Nightly cleanup, data sync |
0 9 * * 1 | Monday 9:00 | Weekly reports |
0 6 1 * * | 1st of month | Monthly reports, billing |
Server Script Complete Examples
Example 1: Comprehensive Invoice Validation
# Type: Document Event | Event: Before Save | DocType: Sales Invoice
errors = []
# Customer validation
if not doc.customer:
errors.append("Customer is required")
else:
cust = frappe.db.get_value("Customer", doc.customer,
["disabled", "is_frozen"], as_dict=True)
if cust and cust.disabled:
errors.append(f"Customer {doc.customer} is disabled")
if cust and cust.is_frozen:
errors.append(f"Customer {doc.customer} is frozen")
# Date validation
if doc.posting_date and doc.posting_date > frappe.utils.today():
errors.append("Posting date cannot be in the future")
# Items validation
if not doc.items:
errors.append("At least one item is required")
else:
for item in doc.items:
if item.qty <= 0:
errors.append(f"Row {item.idx}: Quantity must be positive")
if item.rate < 0:
errors.append(f"Row {item.idx}: Rate cannot be negative")
if doc.discount_percentage and doc.discount_percentage > 50:
errors.append("Discount cannot exceed 50%")
if errors:
frappe.throw("<br>".join(errors), title="Validation Errors")Example 2: Stock Availability Check (Before Submit)
# Type: Document Event | Event: Before Submit | DocType: Sales Order
errors = []
for item in doc.items:
is_stock = frappe.db.get_value("Item", item.item_code, "is_stock_item")
if not is_stock:
continue
available = frappe.db.get_value("Bin",
{"item_code": item.item_code, "warehouse": item.warehouse},
"actual_qty") or 0
reserved = frappe.db.sql("""
SELECT COALESCE(SUM(soi.qty - soi.delivered_qty), 0)
FROM `tabSales Order Item` soi
JOIN `tabSales Order` so ON soi.parent = so.name
WHERE soi.item_code = %(item)s AND soi.warehouse = %(wh)s
AND so.docstatus = 1 AND so.name != %(exclude)s
""", {"item": item.item_code, "wh": item.warehouse,
"exclude": doc.name})[0][0] or 0
net = available - reserved
if item.qty > net:
errors.append(f"Row {item.idx}: {item.item_code} — "
f"Need: {item.qty}, Available: {net}")
if errors:
frappe.throw("Insufficient stock:<br>" + "<br>".join(errors))Example 3: Approval Workflow Logic
# Type: Document Event | Event: Before Save | DocType: Purchase Order
MATRIX = [
{"min": 0, "max": 10000, "role": None},
{"min": 10000, "max": 50000, "role": "Purchase Manager"},
{"min": 50000, "max": 100000, "role": "Finance Manager"},
{"min": 100000, "max": 999999999, "role": "Director"}
]
required_role = None
for level in MATRIX:
if level["min"] <= doc.grand_total < level["max"]:
required_role = level["role"]
break
if required_role is None:
doc.approval_status = "Approved"
doc.approved_by = frappe.session.user
elif doc.approval_status == "Approved" and doc.approved_by:
if required_role not in frappe.get_roles(doc.approved_by):
frappe.throw(f"Requires approval from {required_role}")
else:
doc.approval_status = "Pending"
doc.required_approver_role = required_roleExample 4: CRUD REST API
# Type: API | Method: manage_bookmark | Allow Guest: No
action = frappe.form_dict.get("action")
user = frappe.session.user
if action == "list":
bookmarks = frappe.get_all("Bookmark",
filters={"owner": user},
fields=["name", "title", "url", "category", "creation"],
order_by="creation desc", limit=50)
frappe.response["message"] = {"bookmarks": bookmarks}
elif action == "create":
title = frappe.form_dict.get("title")
url = frappe.form_dict.get("url")
if not title or not url:
frappe.throw("Title and URL are required")
bm = frappe.get_doc({
"doctype": "Bookmark", "title": title, "url": url,
"category": frappe.form_dict.get("category", "General")
}).insert()
frappe.response["message"] = {"success": True, "id": bm.name}
elif action == "delete":
bm_id = frappe.form_dict.get("id")
if not bm_id:
frappe.throw("Bookmark ID required")
bm = frappe.get_doc("Bookmark", bm_id)
if bm.owner != user:
frappe.throw("Access denied", frappe.PermissionError)
bm.delete()
frappe.response["message"] = {"success": True}
else:
frappe.throw("Invalid action. Use: list, create, delete")Example 5: Customer Health Scoring (Scheduler)
# Type: Scheduler Event | Cron: 0 4 * * * (daily 4:00 AM)
BATCH_SIZE = 50
processed = 0
today = frappe.utils.today()
year_ago = frappe.utils.add_days(today, -365)
customers = frappe.get_all("Customer", filters={"disabled": 0}, pluck="name")
for i in range(0, len(customers), BATCH_SIZE):
batch = customers[i:i + BATCH_SIZE]
for customer in batch:
try:
orders = frappe.db.sql("""
SELECT COUNT(*) as cnt, COALESCE(SUM(grand_total), 0) as total
FROM `tabSales Order`
WHERE customer = %(c)s AND transaction_date >= %(y)s AND docstatus = 1
""", {"c": customer, "y": year_ago}, as_dict=True)[0]
outstanding = frappe.db.get_value("Sales Invoice",
{"customer": customer, "docstatus": 1, "status": "Unpaid"},
"sum(outstanding_amount)") or 0
score = 50
score += 20 if orders.cnt >= 12 else 10 if orders.cnt >= 6 else 0
score += 15 if orders.total >= 100000 else 10 if orders.total >= 50000 else 0
score -= 20 if outstanding > 50000 else 10 if outstanding > 10000 else 0
score = max(0, min(100, score))
frappe.db.set_value("Customer", customer, {
"health_score": score, "last_score_date": today
}, update_modified=False)
processed += 1
except Exception:
frappe.log_error(f"Failed: {customer}", "Health Scoring")
frappe.db.commit()
frappe.log_error(f"Scored {processed} customers", "Health Scoring Complete")Example 6: Audit Trail Logger
# Type: Document Event | Event: After Save | DocType: Employee
TRACKED = ["employee_name", "department", "designation", "status",
"employment_type", "branch", "reports_to"]
if not doc.is_new():
old = frappe.db.get_value("Employee", doc.name, TRACKED, as_dict=True)
changes = []
for field in TRACKED:
old_val = old.get(field) if old else None
new_val = doc.get(field)
if old_val != new_val:
changes.append({"field": field,
"old": str(old_val) if old_val else "",
"new": str(new_val) if new_val else ""})
if changes:
frappe.get_doc({
"doctype": "Audit Log",
"reference_doctype": "Employee",
"reference_name": doc.name,
"action": "Update",
"changed_by": frappe.session.user,
"changes": frappe.as_json(changes)
}).insert(ignore_permissions=True)Example 7: Linked Document Prevention (Before Delete)
# Type: Document Event | Event: Before Delete | DocType: Item
linked = []
for child_dt, label in [
("Sales Order Item", "Sales Orders"),
("Purchase Order Item", "Purchase Orders"),
("Sales Invoice Item", "Sales Invoices"),
("Stock Entry Detail", "Stock Entries")]:
count = frappe.db.count(child_dt, {"item_code": doc.name})
if count:
linked.append(f"{label}: {count} line(s)")
stock = frappe.db.get_value("Bin",
{"item_code": doc.name}, "sum(actual_qty)") or 0
if stock != 0:
linked.append(f"Current Stock: {stock}")
if linked:
frappe.throw(f"Cannot delete {doc.name}:<br>" + "<br>".join(linked),
title="Deletion Blocked")Example 8: Hierarchical Territory Permission
# Type: Permission Query | DocType: Customer
user_roles = frappe.get_roles(user)
if "System Manager" in user_roles:
conditions = ""
else:
user_territory = frappe.db.get_value("User", user, "territory")
if user_territory:
# Get all child territories using lft/rgt
parent = frappe.db.get_value("Territory", user_territory,
["lft", "rgt"], as_dict=True)
if parent:
territories = frappe.db.sql("""
SELECT name FROM `tabTerritory`
WHERE lft >= %(lft)s AND rgt <= %(rgt)s
""", parent, pluck="name")
t_list = ", ".join(frappe.db.escape(t) for t in territories)
conditions = f"`tabCustomer`.territory IN ({t_list})"
else:
conditions = f"`tabCustomer`.owner = {frappe.db.escape(user)}"
else:
conditions = f"`tabCustomer`.owner = {frappe.db.escape(user)}"Example 9: Client + Server Combined Pattern
Client Script:
frappe.call({
method: 'check_credit_limit',
args: { customer: frm.doc.customer, amount: frm.doc.grand_total },
callback: function(r) {
if (!r.message.allowed)
frappe.throw(__('Credit limit exceeded'));
}
});Server Script (API type, method: check_credit_limit):
customer = frappe.form_dict.get("customer")
amount = frappe.utils.flt(frappe.form_dict.get("amount"))
credit_limit = frappe.db.get_value("Customer", customer, "credit_limit") or 0
outstanding = frappe.db.get_value("Sales Invoice",
{"customer": customer, "docstatus": 1, "status": "Unpaid"},
"sum(outstanding_amount)") or 0
frappe.response["message"] = {
"allowed": (outstanding + amount) <= credit_limit or credit_limit == 0,
"available": max(0, credit_limit - outstanding)
}Server Script Implementation Workflows
Category 1: Validation Patterns
Multi-Field Validation (Collect All Errors)
# Type: Document Event | Event: Before Save | DocType: Sales Order
errors = []
if not doc.customer:
errors.append("Customer is required")
if doc.delivery_date and doc.delivery_date < frappe.utils.today():
errors.append("Delivery date cannot be in the past")
if not doc.items or len(doc.items) == 0:
errors.append("At least one item is required")
for item in doc.items:
if item.qty <= 0:
errors.append(f"Row {item.idx}: Quantity must be positive")
if errors:
frappe.throw("<br>".join(errors), title="Validation Error")Cross-Document Validation
# Type: Document Event | Event: Before Save | DocType: Delivery Note
if doc.sales_order:
so = frappe.db.get_value("Sales Order", doc.sales_order,
["docstatus", "status"], as_dict=True)
if not so:
frappe.throw(f"Sales Order {doc.sales_order} not found")
if so.docstatus != 1:
frappe.throw(f"Sales Order {doc.sales_order} is not submitted")
if so.status == "Closed":
frappe.throw(f"Sales Order {doc.sales_order} is closed")Conditional Required Fields
# Type: Document Event | Event: Before Save | DocType: Sales Invoice
if not doc.payment_terms_template and not doc.due_date:
frappe.throw("Due Date is required when Payment Terms is not set")
if doc.is_shipping_required and not doc.shipping_address:
frappe.throw("Shipping Address required when shipping is enabled")Category 2: Auto-Calculation Patterns
Child Table Aggregation
# Type: Document Event | Event: Before Save | DocType: Purchase Order
for item in doc.items:
item.amount = (item.qty or 0) * (item.rate or 0)
item.net_amount = item.amount - (item.discount_amount or 0)
doc.total_qty = sum(item.qty or 0 for item in doc.items)
doc.net_total = sum(item.net_amount or 0 for item in doc.items)
tax_rate = frappe.db.get_value("Tax Template", doc.tax_template, "rate") or 0
doc.tax_amount = doc.net_total * (tax_rate / 100)
doc.grand_total = doc.net_total + doc.tax_amountDate Calculations
# Type: Document Event | Event: Before Save | DocType: Project
if doc.expected_start_date and doc.estimated_days:
doc.expected_end_date = frappe.utils.add_days(
doc.expected_start_date, doc.estimated_days)
if doc.actual_start_date and doc.actual_end_date:
doc.actual_days = frappe.utils.date_diff(
doc.actual_end_date, doc.actual_start_date)Category 3: Notification Patterns
Conditional Notification on Submit
# Type: Document Event | Event: After Submit | DocType: Sales Order
if doc.grand_total > 100000:
managers = frappe.get_all("User",
filters={"role": "Sales Manager", "enabled": 1}, pluck="name")
for manager in managers:
frappe.get_doc({
"doctype": "Notification Log",
"subject": f"High-Value Order: {doc.name}",
"for_user": manager,
"type": "Alert",
"document_type": "Sales Order",
"document_name": doc.name
}).insert(ignore_permissions=True)Email Notification
# Type: Document Event | Event: After Submit | DocType: Purchase Order
email = frappe.db.get_value("Supplier", doc.supplier, "email_id")
if email:
frappe.sendmail(
recipients=[email],
subject=f"Purchase Order {doc.name}",
message=f"Dear {doc.supplier_name},\n\nPO {doc.name} for {doc.grand_total}.",
reference_doctype="Purchase Order",
reference_name=doc.name)Category 4: Status Management
Auto-Status Based on Child Items
# Type: Document Event | Event: Before Save | DocType: Sales Order
total_ordered = sum(item.qty or 0 for item in doc.items)
total_delivered = sum(item.delivered_qty or 0 for item in doc.items)
if total_delivered == 0:
doc.delivery_status = "Not Delivered"
elif total_delivered < total_ordered:
doc.delivery_status = "Partially Delivered"
else:
doc.delivery_status = "Fully Delivered"Cascading Status Update (After Save)
# Type: Document Event | Event: After Save | DocType: Task
if not doc.project:
return
tasks = frappe.get_all("Task",
filters={"project": doc.project}, fields=["status"])
total = len(tasks)
completed = len([t for t in tasks if t.status == "Completed"])
status = "Completed" if completed == total else "Open" if completed > 0 else "Pending"
percent = (completed / total * 100) if total > 0 else 0
frappe.db.set_value("Project", doc.project, {
"status": status, "percent_complete": percent
}, update_modified=False)Category 5: API Patterns
Paginated List API
# Type: API | Method: get_orders | Allow Guest: No
page = frappe.utils.cint(frappe.form_dict.get("page", 1))
page_size = min(frappe.utils.cint(frappe.form_dict.get("page_size", 20)), 100)
filters = {"docstatus": 1}
customer = frappe.form_dict.get("customer")
if customer:
filters["customer"] = customer
if "Sales Manager" not in frappe.get_roles():
filters["owner"] = frappe.session.user
orders = frappe.get_all("Sales Order",
filters=filters,
fields=["name", "customer", "grand_total", "status"],
order_by="transaction_date desc",
limit_start=(page - 1) * page_size,
limit_page_length=page_size)
total = frappe.db.count("Sales Order", filters)
frappe.response["message"] = {
"data": orders, "page": page, "page_size": page_size,
"total": total, "pages": -(-total // page_size)
}Action API with Validation
# Type: API | Method: approve_order | Allow Guest: No
order_name = frappe.form_dict.get("order")
if not order_name:
frappe.throw("Order parameter is required")
if not frappe.has_permission("Sales Order", "write", order_name):
frappe.throw("No permission", frappe.PermissionError)
if "Sales Manager" not in frappe.get_roles():
frappe.throw("Only Sales Managers can approve orders")
order = frappe.get_doc("Sales Order", order_name)
if order.docstatus != 0:
frappe.throw("Only draft orders can be approved")
order.approval_status = "Approved"
order.approved_by = frappe.session.user
order.save()
frappe.response["message"] = {"success": True, "order": order_name}Category 6: Scheduler Patterns
Batch Processing with Chunking
# Type: Scheduler Event | Cron: 0 3 * * *
BATCH_SIZE = 100
processed = 0
while True:
records = frappe.get_all("Sales Invoice",
filters={"status": "Unpaid",
"due_date": ["<", frappe.utils.add_days(frappe.utils.today(), -30)],
"overdue_notified": 0},
fields=["name"], limit=BATCH_SIZE)
if not records:
break
for rec in records:
try:
frappe.db.set_value("Sales Invoice", rec.name, "overdue_notified", 1)
processed += 1
except Exception:
frappe.log_error(f"Failed: {rec.name}", "Overdue Processing")
frappe.db.commit()
frappe.log_error(f"Processed {processed} invoices", "Overdue Complete")Weekly Report Generation
# Type: Scheduler Event | Cron: 0 7 * * 1 (Monday 7:00 AM)
week_start = frappe.utils.add_days(frappe.utils.today(), -7)
week_end = frappe.utils.add_days(frappe.utils.today(), -1)
summary = frappe.db.sql("""
SELECT COUNT(*) as orders, SUM(grand_total) as revenue,
COUNT(DISTINCT customer) as customers
FROM `tabSales Order`
WHERE transaction_date BETWEEN %(s)s AND %(e)s AND docstatus = 1
""", {"s": week_start, "e": week_end}, as_dict=True)[0]
frappe.get_doc({
"doctype": "Weekly Report",
"report_date": frappe.utils.today(),
"total_orders": summary.orders,
"total_revenue": summary.revenue
}).insert(ignore_permissions=True)
frappe.db.commit()Category 7: Permission Query Patterns
Multi-Company Access
# Type: Permission Query | DocType: Sales Invoice
allowed = frappe.get_all("User Permission",
filters={"user": user, "allow": "Company"}, pluck="for_value")
user_roles = frappe.get_roles(user)
if "System Manager" in user_roles:
conditions = ""
elif allowed:
company_list = ", ".join(frappe.db.escape(c) for c in allowed)
conditions = f"`tabSales Invoice`.company IN ({company_list})"
else:
default = frappe.db.get_single_value("Global Defaults", "default_company")
conditions = f"`tabSales Invoice`.company = {frappe.db.escape(default)}" if default else "1=0"Date-Restricted Access
# Type: Permission Query | DocType: Expense Claim
user_roles = frappe.get_roles(user)
if "System Manager" in user_roles or "HR Manager" in user_roles:
conditions = ""
elif "Expense Approver" in user_roles:
team = frappe.get_all("Employee",
filters={"reports_to": frappe.db.get_value("Employee", {"user_id": user}, "name")},
pluck="user_id")
team.append(user)
user_list = ", ".join(frappe.db.escape(u) for u in team if u)
conditions = f"`tabExpense Claim`.owner IN ({user_list})"
else:
month_start = frappe.utils.get_first_day(frappe.utils.today())
conditions = (f"`tabExpense Claim`.owner = {frappe.db.escape(user)} "
f"AND `tabExpense Claim`.posting_date >= {frappe.db.escape(month_start)}")