server-scripts

SKILL.md

Frappe Server Scripts Reference

Complete reference for server-side Python development in Frappe Framework.

When to Use This Skill

  • Writing document controllers
  • Creating whitelisted API endpoints
  • Handling document lifecycle events
  • Background job processing
  • Database operations and queries
  • Permission checks and validation
  • Email and notification handling

Controller Location

my_app/
└── my_module/
    └── doctype/
        └── my_doctype/
            └── my_doctype.py    # Python controller

Document Controller

Complete Controller Template

# my_doctype.py
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import nowdate, nowtime, flt, cint, getdate, add_days


class MyDocType(Document):
    # ===== NAMING =====

    def autoname(self):
        """Custom naming logic"""
        self.name = f"{self.prefix}-{frappe.generate_hash()[:8].upper()}"

    def before_naming(self):
        """Called before autoname"""
        pass

    # ===== VALIDATION =====

    def before_validate(self):
        """Called before validate"""
        self.set_defaults()

    def validate(self):
        """Main validation - called on insert and update"""
        self.validate_dates()
        self.validate_amounts()
        self.calculate_totals()
        self.set_status()

    def before_save(self):
        """Called after validate, before database write"""
        self.update_modified_info()

    # ===== INSERT =====

    def before_insert(self):
        """Called before new document is inserted"""
        self.set_initial_values()

    def after_insert(self):
        """Called after new document is inserted"""
        self.create_related_documents()
        self.send_notification()

    # ===== UPDATE =====

    def on_update(self):
        """Called after document is saved (insert or update)"""
        self.update_related_documents()
        self.clear_cache()

    def after_save(self):
        """Called after on_update, always runs"""
        pass

    def on_change(self):
        """Called when document changes in database"""
        pass

    # ===== SUBMISSION =====

    def before_submit(self):
        """Called before document is submitted"""
        self.validate_for_submit()

    def on_submit(self):
        """Called after document is submitted"""
        self.create_gl_entries()
        self.update_stock()

    def on_update_after_submit(self):
        """Called when submitted doc is updated (limited fields)"""
        pass

    # ===== CANCELLATION =====

    def before_cancel(self):
        """Called before document is cancelled"""
        self.validate_cancellation()

    def on_cancel(self):
        """Called after document is cancelled"""
        self.reverse_gl_entries()
        self.reverse_stock()

    # ===== DELETION =====

    def before_delete(self):
        """Called before document is deleted"""
        self.check_dependencies()

    def after_delete(self):
        """Called after document is deleted"""
        self.cleanup_related()

    def on_trash(self):
        """Called when document is trashed"""
        pass

    def after_restore(self):
        """Called after document is restored from trash"""
        pass

    # ===== CUSTOM METHODS =====

    def set_defaults(self):
        """Set default values"""
        if not self.posting_date:
            self.posting_date = nowdate()
        if not self.company:
            self.company = frappe.defaults.get_user_default("Company")

    def validate_dates(self):
        """Validate date fields"""
        if self.end_date and getdate(self.start_date) > getdate(self.end_date):
            frappe.throw(_("End Date cannot be before Start Date"))

        if getdate(self.posting_date) > getdate(nowdate()):
            frappe.throw(_("Posting Date cannot be in the future"))

    def validate_amounts(self):
        """Validate amount fields"""
        for item in self.items:
            if flt(item.qty) <= 0:
                frappe.throw(_("Row {0}: Quantity must be greater than 0").format(item.idx))
            if flt(item.rate) < 0:
                frappe.throw(_("Row {0}: Rate cannot be negative").format(item.idx))

    def calculate_totals(self):
        """Calculate document totals"""
        self.total = 0
        for item in self.items:
            item.amount = flt(item.qty) * flt(item.rate)
            self.total += item.amount

        self.tax_amount = flt(self.total) * flt(self.tax_rate) / 100
        self.grand_total = flt(self.total) + flt(self.tax_amount)

    def set_status(self):
        """Set document status based on state"""
        if self.docstatus == 0:
            self.status = "Draft"
        elif self.docstatus == 1:
            if self.is_completed():
                self.status = "Completed"
            else:
                self.status = "Submitted"
        elif self.docstatus == 2:
            self.status = "Cancelled"

    def is_completed(self):
        """Check if document is completed"""
        return all(item.delivered_qty >= item.qty for item in self.items)

Whitelisted APIs

Basic API

@frappe.whitelist()
def get_customer_details(customer):
    """Get customer details

    Args:
        customer (str): Customer ID

    Returns:
        dict: Customer details with outstanding amount
    """
    if not customer:
        frappe.throw(_("Customer is required"))

    doc = frappe.get_doc("Customer", customer)

    return {
        "customer_name": doc.customer_name,
        "customer_type": doc.customer_type,
        "territory": doc.territory,
        "credit_limit": flt(doc.credit_limit),
        "outstanding": get_customer_outstanding(customer)
    }


@frappe.whitelist()
def create_invoice(customer, items):
    """Create sales invoice from data

    Args:
        customer (str): Customer ID
        items (str): JSON string of items

    Returns:
        str: Invoice name
    """
    items = frappe.parse_json(items)

    doc = frappe.get_doc({
        "doctype": "Sales Invoice",
        "customer": customer,
        "items": [{
            "item_code": item.get("item_code"),
            "qty": flt(item.get("qty")),
            "rate": flt(item.get("rate"))
        } for item in items]
    })

    doc.insert()
    doc.submit()

    return doc.name

Guest API

@frappe.whitelist(allow_guest=True)
def get_public_data():
    """Public API - no login required"""
    return {
        "status": "ok",
        "message": "This is public data"
    }

Method-Restricted API

@frappe.whitelist(methods=["POST"])
def create_record(data):
    """Only accepts POST requests"""
    data = frappe.parse_json(data)
    doc = frappe.get_doc(data)
    doc.insert()
    return {"name": doc.name}


@frappe.whitelist(methods=["GET", "POST"])
def flexible_endpoint(**kwargs):
    """Accepts GET and POST"""
    return kwargs

Permission-Checked API

@frappe.whitelist()
def sensitive_operation(doctype, name):
    """API with permission check"""
    # Check permission
    if not frappe.has_permission(doctype, "write", name):
        frappe.throw(_("Not permitted"), frappe.PermissionError)

    # Proceed with operation
    doc = frappe.get_doc(doctype, name)
    # ... do something

    return {"status": "success"}

Database Operations

Reading Data

# Get single document
doc = frappe.get_doc("Customer", "CUST-001")

# Get with filters
doc = frappe.get_doc("Customer", {"customer_name": "John Corp"})

# Get single value
name = frappe.db.get_value("Customer", "CUST-001", "customer_name")

# Get multiple values
values = frappe.db.get_value("Customer", "CUST-001",
    ["customer_name", "territory"], as_dict=True)

# Get list
customers = frappe.db.get_all("Customer",
    filters={"status": "Active"},
    fields=["name", "customer_name", "territory"],
    order_by="customer_name asc",
    limit=10
)

# Complex filters
invoices = frappe.db.get_all("Sales Invoice",
    filters={
        "status": ["in", ["Paid", "Unpaid"]],
        "grand_total": [">", 1000],
        "posting_date": [">=", "2024-01-01"],
        "customer": ["like", "%Corp%"]
    },
    fields=["name", "customer", "grand_total"]
)

# Pluck single field
names = frappe.db.get_all("Customer",
    filters={"status": "Active"},
    pluck="name"
)

# Count
count = frappe.db.count("Customer", {"status": "Active"})

# Exists check
exists = frappe.db.exists("Customer", "CUST-001")

Raw SQL

# Simple query
result = frappe.db.sql("""
    SELECT name, customer_name, grand_total
    FROM `tabSales Invoice`
    WHERE status = %s AND grand_total > %s
    ORDER BY creation DESC
    LIMIT 10
""", ("Paid", 1000), as_dict=True)

# Named parameters
result = frappe.db.sql("""
    SELECT * FROM `tabCustomer`
    WHERE territory = %(territory)s
    AND status = %(status)s
""", {"territory": "West", "status": "Active"}, as_dict=True)

# Aggregation
total = frappe.db.sql("""
    SELECT SUM(grand_total) as total
    FROM `tabSales Invoice`
    WHERE status = 'Paid'
""")[0][0] or 0

Writing Data

# Create document
doc = frappe.get_doc({
    "doctype": "Customer",
    "customer_name": "New Customer",
    "customer_type": "Company"
})
doc.insert()

# Update document
doc = frappe.get_doc("Customer", "CUST-001")
doc.customer_name = "Updated Name"
doc.save()

# Quick update (bypasses controller)
frappe.db.set_value("Customer", "CUST-001", "status", "Inactive")

# Update multiple fields
frappe.db.set_value("Customer", "CUST-001", {
    "status": "Inactive",
    "disabled": 1
})

# Delete
frappe.delete_doc("Customer", "CUST-001")

# Commit transaction
frappe.db.commit()

# Rollback
frappe.db.rollback()

Background Jobs

Enqueue Jobs

# Basic enqueue
frappe.enqueue(
    "my_app.tasks.process_data",
    queue="default",
    customer="CUST-001"
)

# With options
frappe.enqueue(
    method="my_app.tasks.heavy_task",
    queue="long",        # short, default, long
    timeout=1800,        # 30 minutes
    is_async=True,
    job_name="Heavy Task",
    now=False,           # True to run immediately
    enqueue_after_commit=True,
    # Task arguments
    document_name="DOC-001",
    data={"key": "value"}
)

Task Function

# my_app/tasks.py
import frappe

def process_data(customer):
    """Background task"""
    frappe.init(site=frappe.local.site)
    frappe.connect()

    try:
        # Process logic
        doc = frappe.get_doc("Customer", customer)
        doc.last_processed = frappe.utils.now()
        doc.save()
        frappe.db.commit()
    except Exception:
        frappe.log_error(title="Process Data Failed")
        raise
    finally:
        frappe.destroy()

Scheduled Jobs (hooks.py)

scheduler_events = {
    "all": [
        "my_app.tasks.every_minute"
    ],
    "daily": [
        "my_app.tasks.daily_report"
    ],
    "hourly": [
        "my_app.tasks.hourly_sync"
    ],
    "cron": {
        "0 9 * * 1": [
            "my_app.tasks.monday_morning"
        ],
        "*/15 * * * *": [
            "my_app.tasks.every_15_min"
        ]
    }
}

Error Handling

from frappe import _
from frappe.exceptions import ValidationError, PermissionError

def my_function():
    # Throw with message
    frappe.throw(_("Invalid data"))

    # Throw with title
    frappe.throw(_("Cannot proceed"), title=_("Error"))

    # Throw with exception type
    frappe.throw(_("Permission denied"), exc=PermissionError)

    # Message without stopping
    frappe.msgprint(_("Warning: Check your data"))

    # Log error
    frappe.log_error(
        title="My Error",
        message=frappe.get_traceback()
    )

    # Try-except
    try:
        risky_operation()
    except Exception:
        frappe.log_error("Operation failed")
        frappe.throw(_("Something went wrong"))

Utilities

from frappe.utils import (
    nowdate, nowtime, now_datetime, today,
    getdate, get_datetime,
    add_days, add_months, add_years,
    date_diff, time_diff_in_seconds,
    flt, cint, cstr,
    fmt_money, rounded,
    strip_html, escape_html
)

# Date operations
today = nowdate()  # "2024-01-15"
week_later = add_days(nowdate(), 7)
month_end = frappe.utils.get_last_day(nowdate())

# Number operations
amount = flt(value, 2)  # Float with precision
count = cint(value)     # Integer

# Formatting
money = fmt_money(1234.56, currency="USD")

# Current user
user = frappe.session.user
roles = frappe.get_roles()

Email & Notifications

# Send email
frappe.sendmail(
    recipients=["user@example.com"],
    subject="Subject",
    message="Email body",
    reference_doctype="Sales Invoice",
    reference_name="SINV-00001"
)

# With template
frappe.sendmail(
    recipients=["user@example.com"],
    subject="Order Confirmation",
    template="order_confirmation",
    args={
        "customer_name": "John",
        "order_id": "ORD-001"
    }
)

# Real-time notification
frappe.publish_realtime(
    "msgprint",
    {"message": "Task completed"},
    user="user@example.com"
)

Document Events via Hooks

# hooks.py
doc_events = {
    "Sales Invoice": {
        "validate": "my_app.overrides.validate_invoice",
        "on_submit": "my_app.overrides.on_submit_invoice",
        "on_cancel": "my_app.overrides.on_cancel_invoice"
    },
    "*": {
        "on_update": "my_app.overrides.log_all_changes"
    }
}
# my_app/overrides.py
import frappe

def validate_invoice(doc, method):
    """Called during Sales Invoice validation"""
    if doc.grand_total > 100000:
        if not doc.manager_approval:
            frappe.throw(_("Manager approval required for orders above 100,000"))

def on_submit_invoice(doc, method):
    """Called when Sales Invoice is submitted"""
    create_delivery_note(doc)
    notify_warehouse(doc)
Weekly Installs
6
GitHub Stars
9
First Seen
Feb 5, 2026
Installed on
opencode6
gemini-cli6
claude-code6
github-copilot6
codex6
amp6