Extending and Customizing

When in-app and ERPNext-native configuration (covered in Configuration and Customization) is not enough, developers extend Warka in code. This document shows the established patterns: adding a whitelisted endpoint with a role guard and calling it from the frontend, adding a frontend module that follows the shared kit conventions, adding custom fields and document hooks through the self-heal mechanism, and the ERPNext-native customization that needs no code at all. It closes with the after_install / after_migrate self-heal pattern and why it matters for reproducibility. For the existing endpoints and doctypes you are extending, see the API Reference and Data Model.

Add a whitelisted endpoint

A backend endpoint is a Python function in the cyber_zeb_erp app decorated with @frappe.whitelist(). The convention is: guard first, validate input, do the work, commit, return a plain dict (which Frappe wraps in {"message": ...}).

# cyber_zeb_erp/cyber_zeb_erp/api.py
@frappe.whitelist(methods=["POST"])
def close_project(project=None, note=None):
    """Mark a project completed. Projects Manager or admin only."""
    _require_projects()                       # 1. role guard, first line

    project = (project or "").strip()         # 2. validate input
    if not project or not frappe.db.exists("Project", project):
        frappe.throw("Project not found")

    doc = frappe.get_doc("Project", project)  # 3. do the work
    doc.status = "Completed"
    if note:
        doc.notes = note
    doc.save(ignore_permissions=True)

    frappe.db.commit()                        # 4. commit
    _bust_dashboard_cache()                   # invalidate cached overviews if relevant
    return {"project": project, "status": "Completed"}  # 5. return a dict

Rules that keep an endpoint consistent with the rest of the codebase:

  • Pick the right guard. Reuse an existing _require_* helper. Choose the manager tier (_require_hr_manager, _require_accounts_manager) for anything that approves, posts to the ledger, or runs payroll, and the user tier for routine entry. To add a new domain, define a role set and a guard alongside the others near the top of api.py.
  • Declare methods=["POST"] for anything that writes, so it cannot be triggered by a bare GET.
  • Use allow_guest=True only when truly public, and then add a @rate_limit(...), allowlist the fields you read, escape or sanitize all stored text, and never leak unpublished data. The careers portal in careers.py is the reference for a safe guest endpoint.
  • Validate list and date parameters with the shared helpers (_parse_json_list, _parse_date) so bad client input becomes a friendly error, not a 500.
  • Commit explicitly and bust the dashboard cache when your write changes a number an overview shows.

Call it from the frontend

Add nothing to a config file: the cyber helper in frontend/lib/frappe.ts already resolves any method under the cyber_zeb_erp.api. namespace.

import { cyber, getFrappeErrorMessage, notify } from "@/lib/frappe";

try {
  const res = await cyber.call("close_project", { project, note });
  notify("Project closed", `Set ${res.project} to ${res.status}.`);
} catch (err) {
  notify("Could not close project", getFrappeErrorMessage(err, "Try again."), "error");
}

cyber.call issues a GET when there is no body and a POST otherwise, unwraps the message envelope, and throws a FrappeError carrying the HTTP status so you can detect a 403 with isPermissionError(err) and render an access-denied state.

Add a frontend module or manager

New modules follow the manager pattern used across frontend/components/* and build on the shared toolkit in frontend/components/erp/kit.tsx, so every module looks and behaves the same.

A manager typically:

  1. Loads data on mount with cyber.call("get_<module>_overview") for the dashboard and frappeList(doctype, opts) for record lists.
  2. Renders a SectionNav tab bar and a set of StatCards from the kit.
  3. Lists records with ListCard and EntityRow, filtered client-side via useFilter.
  4. Opens a Drawer or useFullDoc detail panel for a single record.
  5. Creates records with CreateDrawer (a field-list-driven form) or a custom form that posts through cyber.call, using LineItemsEditor for documents with line items.
  6. Catches isPermissionError and renders <AccessDenied module=... role=... /> so a user without the role sees a friendly notice instead of an error.
// Sketch of a module manager
const data = await cyber.call<Overview>("get_widgets_overview");
// ...
<ListCard rows={rows} loading={loading} query={q} onQuery={setQ}
  renderRow={(r) => <EntityRow title={r.name} subtitle={r.status} onClick={...} />} />

Register the module in the app shell and sidebar so it appears in navigation, and gate its visibility with the role helpers exported from lib/frappe.ts (isHR, isAdmin, hasOperationalRole). Keep the client-side role check for UX only: the server guard is the real enforcement.

Add custom fields

Never edit a standard doctype's JSON. Add fields through Frappe's custom-field system in an idempotent function, following ensure_employee_custom_fields, ensure_careers_fields, and ensure_boarding_field:

def ensure_widget_fields():
    from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
    create_custom_fields({
        "Project": [
            {"fieldname": "cz_client_code", "label": "Client Code",
             "fieldtype": "Data", "insert_after": "project_name"},
        ]
    }, ignore_validate=True)
    frappe.db.commit()

Then wire it into both hook lists in hooks.py (see the self-heal section below). create_custom_fields is idempotent, so re-running it on every migrate is safe and keeps the schema reproducible on any site.

Add a document event hook

To make a doctype self-complete or to enforce a rule on every write path, register a doc_events hook. Warka already does this for Expense Claim:

# hooks.py
doc_events = {
    "Expense Claim": {
        "validate": "cyber_zeb_erp.doc_hooks.expense_claim_defaults",
    },
}
# doc_hooks.py
def widget_defaults(doc, method=None):
    if not doc.get("cz_client_code") and doc.get("customer"):
        doc.cz_client_code = frappe.db.get_value("Customer", doc.customer, "customer_code")

Because validate runs on the frontend REST API, the whitelisted API, and bulk imports alike, the rule holds no matter how the record was created. Common events are validate, before_save, on_submit, on_cancel, and on_trash.

ERPNext-native customization (no code)

A large amount of tailoring needs no code at all, because Warka inherits the full ERPNext customization surface. Prefer these before writing code:

NeedTool
Extra fields on a formCustom Field
Reorder, hide, relabel, or require fieldsCustomize Form
Approval chains with states and transitionsWorkflow
Branded invoices, payslips, purchase ordersPrint Format
Document numbering (for example INV-2026-#####)Naming Series
Email or system alerts on document eventsNotification
New roles and fine-grained per-doctype permissionsRole and Role Permissions Manager

These are documented for administrators in Configuration and Customization. A custom role you create here can also be added to a role set in api.py if you want it to unlock custom endpoints.

The self-heal pattern and why it matters

The custom app declares the same list of setup functions on two hooks in hooks.py:

after_install = [
    "cyber_zeb_erp.api.ensure_employee_custom_fields",
    "cyber_zeb_erp.api.ensure_boarding_field",
    "cyber_zeb_erp.api.ensure_lifecycle_templates",
    "cyber_zeb_erp.careers.ensure_careers_fields",
    "cyber_zeb_erp.setup_defaults.ensure_operational_defaults",
]
after_migrate = [ ... same five ... ]

after_install runs once when the app is installed; after_migrate runs on every bench migrate, which is part of every update. Every function in the list is idempotent: it checks whether each field, template, or master record exists and creates only what is missing. ensure_operational_defaults isolates each step so one failure is logged and never blocks the rest of the migrate.

This matters for three reasons:

  1. Reproducibility. A brand-new site converges to a fully working state with no manual steps, so dev, staging, and production are identical.
  2. Resilience. If an administrator deletes a required master record (a holiday list, a price list, a tax account), the next migrate restores it, so a form never dead-ends on a missing link.
  3. Safe updates. Adding a new custom field or default is just adding an idempotent function to the two hook lists. Ship it, and every existing site picks it up on its next update with no data migration script.

When you extend Warka, add your own idempotent ensure_* functions to these two lists rather than writing one-off migration patches. See Operations for how updates and migrations run in production.