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 ofapi.py. - Declare
methods=["POST"]for anything that writes, so it cannot be triggered by a bare GET. - Use
allow_guest=Trueonly 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 incareers.pyis 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:
- Loads data on mount with
cyber.call("get_<module>_overview")for the dashboard andfrappeList(doctype, opts)for record lists. - Renders a
SectionNavtab bar and a set ofStatCards from the kit. - Lists records with
ListCardandEntityRow, filtered client-side viauseFilter. - Opens a
DraweroruseFullDocdetail panel for a single record. - Creates records with
CreateDrawer(a field-list-driven form) or a custom form that posts throughcyber.call, usingLineItemsEditorfor documents with line items. - Catches
isPermissionErrorand 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:
| Need | Tool |
|---|---|
| Extra fields on a form | Custom Field |
| Reorder, hide, relabel, or require fields | Customize Form |
| Approval chains with states and transitions | Workflow |
| Branded invoices, payslips, purchase orders | Print Format |
Document numbering (for example INV-2026-#####) | Naming Series |
| Email or system alerts on document events | Notification |
| New roles and fine-grained per-doctype permissions | Role 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:
- Reproducibility. A brand-new site converges to a fully working state with no manual steps, so dev, staging, and production are identical.
- 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.
- 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.