Skip to content

Goal

Build production-ready Frappe custom apps that are modular, testable, and maintainable. Every app follows the same battle-tested architecture: separate business logic engines from Frappe ORM, expose clean APIs, schedule tasks, test pure-logic without Frappe, and use hooks/fixtures for portability.

📐 Layer 1: DocType Design

Controller Pattern (Python)

python
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import now


class WarehouseViolation(Document):
    def validate(self):
        """Auto-fill derived fields BEFORE save."""
        self.fetch_employee_details()
        self.set_period()
        self.fetch_penalty_from_type()

    def fetch_employee_details(self):
        """Pull company, branch, name from Employee — avoid manual entry."""
        if self.employee:
            emp = frappe.db.get_value(
                "Employee", self.employee,
                ["company", "branch", "employee_name"], as_dict=True,
            )
            if emp:
                self.company = emp.company
                self.branch = emp.branch
                self.employee_name = emp.employee_name

    def set_period(self):
        """Auto-derive period (YYYY-MM) from date field."""
        if self.date:
            self.period = str(self.date)[:7]

    def fetch_penalty_from_type(self):
        """Auto-fill penalty points from master data if not manually set."""
        if self.violation_type and not self.penalty_points:
            pts = frappe.db.get_value(
                "Warehouse Violation Type", self.violation_type, "penalty_points"
            )
            if pts:
                self.penalty_points = pts

    def on_submit(self):
        """Record who confirmed and when."""
        self.confirmed_by = frappe.session.user
        self.confirmed_at = now()
        self.db_update()
        frappe.msgprint(
            _("Confirmed. Penalty: {0} pts for {1}.").format(
                self.penalty_points, self.employee_name
            ),
            indicator="orange", alert=True,
        )

    def on_cancel(self):
        frappe.msgprint(
            _("Cancelled for {0}.").format(self.employee_name), alert=True
        )

Key patterns:

  • validate() = auto-fill derived fields (period from date, employee details from Link)
  • on_submit() = record audit trail (who, when), show user feedback
  • on_cancel() = cleanup or notify
  • Use self.db_update() after modifying fields in on_submit (doc already saved)
  • Use frappe.db.get_value() for single-field lookups (fast, no full doc load)

Controller Pattern (JavaScript)

javascript
frappe.ui.form.on("Warehouse Violation", {
    refresh(frm) {
        // Color-coded workflow state indicator
        if (frm.doc.workflow_state) {
            const colors = {
                "Pending": "orange", "Confirmed": "green",
                "Appealed": "blue", "Waived": "red"
            };
            frm.page.set_indicator(
                frm.doc.workflow_state,
                colors[frm.doc.workflow_state] || "gray"
            );
        }

        // Dashboard indicators for key metrics
        if (frm.doc.penalty_points) {
            frm.dashboard.add_indicator(
                __("Penalty: {0} pts", [frm.doc.penalty_points]),
                frm.doc.penalty_points >= 8 ? "red" :
                frm.doc.penalty_points >= 5 ? "orange" : "blue"
            );
        }

        // Custom action buttons (non-submitted docs only)
        if (!frm.is_new() && frm.doc.docstatus === 0) {
            frm.add_custom_button(__("View Dashboard"), function () {
                frappe.set_route("query-report", "Employee Summary", {
                    period: frm.doc.period
                });
            }, __("Actions"));
        }
    },

    date(frm) {
        // Auto-fill period from date
        boxme.autoSetPeriod(frm, "date", "period");
    },

    violation_type(frm) {
        // Auto-fill penalty from master data
        if (frm.doc.violation_type) {
            frappe.db.get_value(
                "Warehouse Violation Type",
                frm.doc.violation_type,
                "penalty_points",
                (r) => {
                    if (r && r.penalty_points) {
                        frm.set_value("penalty_points", r.penalty_points);
                    }
                }
            );
        }
    }
});

DocType Design Rules

✅ DO:
- Use Link fields to reference other DocTypes (Employee, Company, Branch)
- Add "period" field (Data, YYYY-MM) auto-derived from date
- Use Submittable DocTypes for records that need approval workflows
- Set "module" in DocType JSON to your app module name
- Add naming_rule or autoname for meaningful document names

❌ DON'T:
- Hardcode company/branch/employee names — always use Link fields
- Put business logic in DocType controllers — use engines/ instead
- Forget to add "module" property — makes fixtures export fail
- Create DocType without think about workflow states first

🌐 Layer 3: API Endpoints

External Webhook Pattern (WMS/ERP Integration)

python
@frappe.whitelist(allow_guest=False)
def receive_data(data):
    """
    POST /api/method/my_app.api.external.receive_data
    Body: {"data": [{...}, {...}]}
    Auth: token api_key:api_secret
    """
    if isinstance(data, str):
        data = json.loads(data)

    created = skipped = 0
    errors = []

    for rec in data:
        emp_code = rec.get("employee_code")
        try:
            employee = frappe.db.get_value(
                "Employee", {"employee_number": emp_code}, "name"
            )
            if not employee:
                errors.append({"code": emp_code, "error": f"Not found"})
                skipped += 1
                continue

            # Deduplicate: skip if already exists
            if frappe.db.exists("My DocType", {"employee": employee, "date": rec["date"]}):
                skipped += 1
                continue

            doc = frappe.get_doc({
                "doctype": "My DocType",
                "employee": employee,
                "date": rec.get("date"),
                "value": rec.get("value", 0),
                "source": "external_system",
            })
            doc.insert(ignore_permissions=True)
            created += 1

        except Exception as exc:
            errors.append({"code": emp_code, "error": str(exc)})
            skipped += 1
            frappe.log_error(str(exc), "receive_data")

    frappe.db.commit()
    return {"status": "ok" if not errors else "partial",
            "created": created, "skipped": skipped, "errors": errors}

Internal API Pattern (UI + Dashboard)

python
@frappe.whitelist()
def calculate_for_employee(employee, period_type, period_value, overwrite=False):
    """Manual trigger with idempotent upsert."""
    if not frappe.has_permission("My DocType", "write"):
        frappe.throw(_("Permission denied"), frappe.PermissionError)

    data = engine.calculate(employee, period_type, period_value)

    existing = frappe.db.get_value("My DocType", {
        "employee": employee, "period_type": period_type,
        "period_value": period_value,
    })

    if existing:
        if not overwrite:
            return {"status": "exists", "name": existing}
        doc = frappe.get_doc("My DocType", existing)
        if doc.docstatus == 1:
            frappe.throw(_("Cannot overwrite submitted record"))
        doc.update(data)
        doc.save(ignore_permissions=True)
        frappe.db.commit()
        return {"status": "updated", "name": doc.name}

    doc = frappe.get_doc({"doctype": "My DocType", **data})
    doc.insert(ignore_permissions=True)
    frappe.db.commit()
    return {"status": "created", "name": doc.name}

Permission Query Pattern

python
def get_permission_query(user: str) -> str:
    """Row-level security: user sees only their own records unless manager/admin."""
    roles = frappe.get_roles(user)
    admin_roles = {"System Manager", "HR Reviewer", "Department Manager"}
    if admin_roles & set(roles):
        return ""  # No filter = see all
    emp = frappe.db.get_value("Employee", {"user_id": user}, "name")
    if emp:
        return f"`tabMy DocType`.employee = '{emp}'"
    return "1=0"  # See nothing

Key patterns:

  • allow_guest=False for webhooks (requires API key auth)
  • Parse data as string or dict (Frappe may pass either)
  • Batch processing with created/skipped/errors counters
  • Idempotent upsert: check existing → update or insert
  • frappe.db.commit() after bulk operations
  • Permission queries for row-level security

🔗 hooks.py — The Nervous System

python
app_name = "my_app"
app_title = "My App"
app_publisher = "My Company"
app_description = "App description"
app_license = "MIT"

required_apps = ["frappe/hrms"]  # Declare dependencies

# ── Assets ────────────────────────────────────────────────────────────────
app_include_js = ["/assets/my_app/js/my_app.js"]

# ── DocType JS overrides (for core DocTypes like Employee) ────────────────
doctype_js = {
    "Employee": "public/js/employee.js"
}

# ── After install / migrate ───────────────────────────────────────────────
after_install = "my_app.setup.install.after_install"
after_migrate = "my_app.setup.install.after_migrate"

# ── Doc Events (server-side hooks) ────────────────────────────────────────
doc_events = {
    "My Submittable Doc": {
        "on_submit": "my_app.engines.my_engine.on_doc_submit",
        "on_cancel": "my_app.engines.my_engine.on_doc_cancel",
    },
    "My Auto Doc": {
        "after_insert": "my_app.engines.my_engine.on_doc_insert",
    },
}

# ── Fixtures (portable across sites) ─────────────────────────────────────
fixtures = [
    {"dt": "Role", "filters": [["name", "in", ["My Role 1", "My Role 2"]]]},
    {"dt": "Custom Field", "filters": [["module", "=", "My App"]]},
    {"dt": "Workflow", "filters": [["document_type", "in", ["My Doc"]]]},
    {"dt": "Workflow State", "filters": [["name", "in", [
        "Draft", "Pending", "Approved", "Rejected"
    ]]]},
]

# ── Permission query conditions ───────────────────────────────────────────
permission_query_conditions = {
    "My Score Doc": "my_app.api.internal.get_permission_query",
}

📊 Layer 6: Reports

Script Report Pattern

Python (employee_scoring_summary.py):

python
def execute(filters=None):
    filters = filters or {}
    columns = get_columns()
    data = get_data(filters)
    chart = get_chart(data)
    report_summary = get_report_summary(data)
    return columns, data, None, chart, report_summary

def get_columns():
    return [
        {"fieldname": "employee", "label": "Employee", "fieldtype": "Link",
         "options": "Employee", "width": 120},
        {"fieldname": "value", "label": "Value", "fieldtype": "Float",
         "precision": 1, "width": 90},
        # ... more columns
    ]

def get_data(filters):
    conditions = "WHERE d.docstatus != 2"
    values = {}
    if filters.get("company"):
        conditions += " AND d.company = %(company)s"
        values["company"] = filters["company"]
    # ... more filters
    return frappe.db.sql(f"""
        SELECT d.employee, d.value, ...
        FROM `tabMy Doc` d {conditions}
        ORDER BY d.value DESC
    """, values, as_dict=True)

def get_chart(data):
    if not data:
        return None
    top = data[:10]
    return {
        "data": {
            "labels": [r.employee_name for r in top],
            "datasets": [
                {"name": "Value", "values": [r.value for r in top]},
            ],
        },
        "type": "bar",
        "colors": ["#2980b9"],
    }

def get_report_summary(data):
    if not data:
        return []
    total = len(data)
    avg = sum(r.value for r in data) / total if total else 0
    return [
        {"value": total, "label": "Total", "datatype": "Int", "indicator": "Blue"},
        {"value": round(avg, 1), "label": "Average", "datatype": "Float",
         "indicator": "Green" if avg >= 50 else "Orange"},
    ]

JavaScript (employee_scoring_summary.js):

javascript
frappe.query_reports["Employee Scoring Summary"] = {
    filters: [
        {
            fieldname: "company",
            label: __("Company"),
            fieldtype: "Link",
            options: "Company",
            default: frappe.defaults.get_user_default("Company"),
        },
        {
            fieldname: "period",
            label: __("Period (YYYY-MM)"),
            fieldtype: "Data",
            default: frappe.datetime.get_today().substring(0, 7),
        },
    ],
};

🧪 Layer 6: Testing

Standalone Pure-Logic Tests (No Frappe!)

python
"""
Run: python -m pytest my_app/tests/test_engine.py -v
No Frappe instance needed — tests only pure-logic functions.
"""
import unittest

# Inline pure-logic (copy from engine, no frappe imports)
PPH_LEVEL_ORDER = ["needs_improvement", "average", "good", "excellent"]
_DEFAULT_THRESHOLDS = [
    {"level": "needs_improvement", "min_pph": 0,  "max_pph": 35},
    {"level": "average",           "min_pph": 35, "max_pph": 55},
]
# ... paste pure functions here

class TestClassifyLevel(unittest.TestCase):
    def test_boundary(self):
        self.assertEqual(classify_level(35, _DEFAULT_THRESHOLDS), "average")

    def test_empty_defaults(self):
        self.assertEqual(classify_level(60, []), "good")

class TestLevelOrdering(unittest.TestCase):
    def test_gte(self):
        self.assertTrue(level_gte("excellent", "good"))
        self.assertFalse(level_gte("average", "good"))

if __name__ == "__main__":
    unittest.main(verbosity=2)

Why inline pure functions in test files?

  • Tests run with pytest -v — no bench, no MariaDB, no Frappe site
  • CI/CD friendly — fast, isolated, reliable
  • Keep the test file self-contained

Examples

Example 1: Scaffold a New Frappe Custom App

WARNING: Always use bench new-app to create the initial app structure! Do not create it manually, as Frappe Cloud and other tools rely on standard boilerplate files (setup.py, MANIFEST.in, patches.txt, hooks.py, etc.) that are generated by bench. Failure to do so will result in "Not a valid Frappe App" errors during deployment.

bash
# 1. Create app (this generates setup.py, pyproject.toml, MANIFEST.in, requirements.txt, etc.)
bench new-app my_custom_app

# 2. Create module structure inside the generated app
mkdir -p apps/my_custom_app/my_custom_app/{engines,api,tasks,setup,tests,scripts,fixtures}
mkdir -p apps/my_custom_app/my_custom_app/public/js
touch apps/my_custom_app/my_custom_app/{engines,api,tasks,setup,tests}/__init__.py

# 3. Install on site
bench --site mysite.localhost install-app my_custom_app

# 4. Enable dev mode
bench --site mysite.localhost set-config developer_mode 1

Example 2: Add a New DocType with Full Stack

  1. Create DocType via Frappe UI or JSON
  2. Write controller (.py) with validate/on_submit/on_cancel
  3. Write client script (.js) with refresh/field triggers
  4. Register doc_events in hooks.py
  5. Add engine function for business logic
  6. Write API endpoint calling engine
  7. Add scheduler task for batch processing
  8. Write tests for pure logic
  9. bench --site mysite migrate && bench build --app my_custom_app

🚀 Layer 9: CI/CD & GitHub Actions

When building or fixing CI/CD pipelines (ci.yml, linter.yml) for Frappe apps, adhere to the following stability rules:

Versions & Environments

  • Python: Strictly unify the Python version across ALL jobs (e.g., python-version: '3.12'). Avoid alpha versions (e.g., 3.14) that break dependencies.
  • Node.js: Use Node 20+ (e.g., node-version: 20). Frappe v15 ecosystem deeply relies on packages like jsdom@29+ which explicitly drop Node 18 support.
  • GitHub Actions: Use stable tags (@v4, @v5). Never guess action versions (@v6) as it causes silent failures.

Frappe Installation in CI

  • Skip Assets: Always use --skip-assets with bench get-app to prevent memory exhaustion (OOM errors) and esbuild race conditions during concurrent CI setups.
  • Local App Registration: If you bench get-app /path/to/local/app --skip-assets, the app is NOT added to sites/apps.txt automatically.
  • apps.txt Fix: Manually append it safely before bench install-app:
    bash
    grep -q my_app sites/apps.txt 2>/dev/null || printf '\nmy_app\n' >> sites/apps.txt
    (Always use printf '\n...' instead of echo to prevent concatenating with the last line if it lacks a trailing newline.)