snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
This commit is contained in:
638
crm/api/form.py
Normal file
638
crm/api/form.py
Normal file
@@ -0,0 +1,638 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
"""Hybrid forms API.
|
||||
|
||||
Storage/submission/validation is delegated to Frappe's built-in `Web Form`
|
||||
doctype; this module only:
|
||||
1. curates which target fields a CRM form may collect,
|
||||
2. gives the CRM builder a small, scoped CRUD surface over Web Form records,
|
||||
3. re-applies CRM-specific enrichment (source, organization, primary contact)
|
||||
on submission, which the framework's generic insert does not do.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils.telemetry import capture
|
||||
|
||||
ALLOWED_DOCTYPES = ("CRM Lead", "CRM Deal")
|
||||
FORM_SOURCE = "Web Form"
|
||||
FORM_MODULE = "FCRM"
|
||||
|
||||
|
||||
def ensure_form_source() -> str:
|
||||
"""Return the 'Web Form' CRM Lead Source, creating it once if needed."""
|
||||
if not frappe.db.exists("CRM Lead Source", FORM_SOURCE):
|
||||
frappe.get_doc({"doctype": "CRM Lead Source", "source_name": FORM_SOURCE}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
return FORM_SOURCE
|
||||
|
||||
|
||||
# Fieldtypes a form can render/collect. A `Link` renders as a server-populated,
|
||||
# pick-only dropdown (see crm_form.py); excludes types needing a live widget or file
|
||||
# upload (Dynamic Link, Table, Attach, …).
|
||||
SUPPORTED_FIELDTYPES = (
|
||||
"Data",
|
||||
"Small Text",
|
||||
"Text",
|
||||
"Long Text",
|
||||
"Text Editor",
|
||||
"HTML Editor",
|
||||
"Markdown Editor",
|
||||
"Select",
|
||||
"Link",
|
||||
"Int",
|
||||
"Float",
|
||||
"Currency",
|
||||
"Percent",
|
||||
"Check",
|
||||
"Date",
|
||||
"Datetime",
|
||||
"Time",
|
||||
"Phone",
|
||||
"Color",
|
||||
)
|
||||
|
||||
# Never expose these as mappable fields even if their type is supported.
|
||||
DENIED_FIELDNAMES = (
|
||||
"naming_series",
|
||||
# a mandatory Link the system sets — seeded as a hidden field with a default, not
|
||||
# offered in the picker.
|
||||
"status",
|
||||
"lead_name",
|
||||
"converted",
|
||||
"sla_status",
|
||||
"response_by",
|
||||
"first_response_time",
|
||||
"first_responded_on",
|
||||
"facebook_form_id",
|
||||
"facebook_lead_id",
|
||||
)
|
||||
|
||||
LAYOUT_BREAKS = ("Section Break", "Column Break")
|
||||
|
||||
# Starting layout of a brand-new form, per target doctype: labelled sections,
|
||||
# each a list of columns, each column a list of fieldnames. A sensible
|
||||
# contact-capture starting point the author can then edit.
|
||||
SEED_LAYOUT = {
|
||||
"CRM Lead": [
|
||||
{
|
||||
"label": "Personal Details",
|
||||
"columns": [["first_name", "email"], ["last_name", "phone"]],
|
||||
},
|
||||
],
|
||||
"CRM Deal": [
|
||||
{
|
||||
"label": "Personal Details",
|
||||
"columns": [["first_name", "email"], ["last_name", "phone"]],
|
||||
},
|
||||
{
|
||||
"label": "Organization Details",
|
||||
"columns": [["organization_name"]],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _seeded_visible_fieldnames(document_type: str) -> set:
|
||||
names = set()
|
||||
for section in SEED_LAYOUT.get(document_type, []):
|
||||
for col in section["columns"]:
|
||||
names.update(col)
|
||||
return names
|
||||
|
||||
|
||||
def guest_can_select(doctype: str) -> bool:
|
||||
"""Whether Guest may reference `doctype` in a Link field. Gates on `select` (never
|
||||
`read`) — Frappe's minimal tier that lists record names without exposing full
|
||||
documents — so a doctype is linkable only when Guest select is deliberately granted."""
|
||||
return bool(doctype) and frappe.has_permission(doctype, ptype="select", user="Guest")
|
||||
|
||||
|
||||
def _link_target_doctypes() -> set:
|
||||
"""Doctypes reachable via a Link field on any form-mappable target — the only
|
||||
doctypes `grant_guest_link_access` is allowed to touch, so the endpoint can't be
|
||||
used to open Guest select on an arbitrary doctype."""
|
||||
targets = set()
|
||||
for document_type in ALLOWED_DOCTYPES:
|
||||
for df in frappe.get_meta(document_type).fields:
|
||||
if df.fieldtype == "Link" and df.options:
|
||||
targets.add(df.options)
|
||||
return targets
|
||||
|
||||
|
||||
def _mappable_fields(document_type: str) -> list[dict]:
|
||||
"""Fields of a target DocType a form may collect (shared by the picker and by
|
||||
the brand-new-form seeding)."""
|
||||
meta = frappe.get_meta(document_type)
|
||||
fields = []
|
||||
for df in meta.fields:
|
||||
if df.fieldtype not in SUPPORTED_FIELDTYPES:
|
||||
continue
|
||||
if not df.fieldname or df.fieldname in DENIED_FIELDNAMES:
|
||||
continue
|
||||
if df.hidden or df.read_only:
|
||||
continue
|
||||
# a Link is offered even when guests can't select the target yet; the builder
|
||||
# warns and offers a one-click grant (see grant_guest_link_access).
|
||||
fields.append(
|
||||
{
|
||||
"fieldname": df.fieldname,
|
||||
"label": df.label or df.fieldname,
|
||||
"fieldtype": df.fieldtype,
|
||||
"options": df.options,
|
||||
"reqd": df.reqd,
|
||||
"default": df.default,
|
||||
}
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
def _default_status(document_type: str) -> str | None:
|
||||
"""The status a new Lead/Deal defaults to — mirrors the doctype controllers so
|
||||
the hidden 'Status' field is pre-filled with the value the CRM would use."""
|
||||
status_dt = "CRM Lead Status" if document_type == "CRM Lead" else "CRM Deal Status"
|
||||
preferred = "New" if document_type == "CRM Lead" else "Qualification"
|
||||
if frappe.db.exists(status_dt, preferred):
|
||||
return preferred
|
||||
rows = frappe.get_all(status_dt, {"type": "Open"}, pluck="name")
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def _seed_visible_fields(document_type: str) -> list[dict]:
|
||||
"""Web-form rows for a new form's visible layout: the curated contact set,
|
||||
arranged into labelled sections and columns (with Section/Column breaks)."""
|
||||
catalog = {f["fieldname"]: f for f in _mappable_fields(document_type)}
|
||||
|
||||
def _break(fieldtype, i, label=""):
|
||||
prefix = "section_break" if fieldtype == "Section Break" else "column_break"
|
||||
return {
|
||||
"fieldname": f"{prefix}_seed{i}",
|
||||
"label": label,
|
||||
"fieldtype": fieldtype,
|
||||
"options": "",
|
||||
"reqd": 0,
|
||||
"placeholder": "",
|
||||
"field_description": "",
|
||||
}
|
||||
|
||||
rows = []
|
||||
n = 0
|
||||
for section in SEED_LAYOUT.get(document_type, []):
|
||||
n += 1
|
||||
rows.append(_break("Section Break", n, section.get("label") or ""))
|
||||
for ci, col in enumerate(section["columns"]):
|
||||
if ci > 0:
|
||||
n += 1
|
||||
rows.append(_break("Column Break", n))
|
||||
for fn in col:
|
||||
f = catalog.get(fn)
|
||||
if not f:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"fieldname": f["fieldname"],
|
||||
"label": f["label"],
|
||||
"fieldtype": f["fieldtype"],
|
||||
"options": f["options"],
|
||||
"reqd": 1 if f["reqd"] else 0,
|
||||
"placeholder": "",
|
||||
"field_description": "",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _seed_hidden_fields(document_type: str) -> list[dict]:
|
||||
"""Mandatory fields a public visitor should not fill (e.g. Status, a Link the
|
||||
system sets). Seeded into the hidden section with a sensible default so the
|
||||
author sees them and can override the value applied on submission."""
|
||||
meta = frappe.get_meta(document_type)
|
||||
visible = _seeded_visible_fieldnames(document_type)
|
||||
hidden = []
|
||||
for df in meta.fields:
|
||||
if not df.reqd or df.fieldname in visible:
|
||||
continue
|
||||
if df.fieldtype in SUPPORTED_FIELDTYPES and df.fieldname not in DENIED_FIELDNAMES:
|
||||
continue # a fillable mandatory field belongs in the visible layout
|
||||
default = _default_status(document_type) if df.fieldname == "status" else (df.default or "")
|
||||
hidden.append(
|
||||
{
|
||||
"fieldname": df.fieldname,
|
||||
"label": df.label or df.fieldname,
|
||||
"fieldtype": df.fieldtype,
|
||||
"options": df.options or "",
|
||||
"default": default,
|
||||
}
|
||||
)
|
||||
return hidden
|
||||
|
||||
|
||||
def _check_manager():
|
||||
"""CRM forms are managed by CRM managers, not Website Managers. Gate here and
|
||||
then write the Web Form with ignore_permissions (the role mismatch, in code)."""
|
||||
roles = set(frappe.get_roles())
|
||||
if not roles & {"System Manager", "Sales Manager"}:
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_form_fields(document_type: str) -> list[dict]:
|
||||
"""Mappable fields of a target DocType, for the builder's field picker."""
|
||||
if document_type not in ALLOWED_DOCTYPES:
|
||||
frappe.throw(_("Forms can only map to: {0}").format(", ".join(ALLOWED_DOCTYPES)))
|
||||
return _mappable_fields(document_type)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_hidden_seed(document_type: str) -> list[dict]:
|
||||
"""The system-managed hidden required fields for a doctype (e.g. Status) with
|
||||
their default value. Used by the builder to reconcile on a doctype switch."""
|
||||
if document_type not in ALLOWED_DOCTYPES:
|
||||
frappe.throw(_("Forms can only map to: {0}").format(", ".join(ALLOWED_DOCTYPES)))
|
||||
return _seed_hidden_fields(document_type)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def link_field_guest_access(doctype: str) -> dict:
|
||||
"""Whether guests can already select `doctype`. Drives the builder's Link-field
|
||||
notice: if False, the author is warned that the public dropdown will be empty and
|
||||
is offered a one-click grant (grant_guest_link_access)."""
|
||||
_check_manager()
|
||||
return {"doctype": doctype, "guest_can_select": guest_can_select(doctype)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def grant_guest_link_access(doctype: str) -> dict:
|
||||
"""Grant Guest `select` on `doctype` so a public Link dropdown can list its records — a
|
||||
deliberate choice by a form manager (the builder warns that anyone with the form link
|
||||
will then see them). Limited to doctypes that are Link targets on a CRM form; stored as
|
||||
a site-level Custom DocPerm."""
|
||||
_check_manager()
|
||||
if doctype not in _link_target_doctypes():
|
||||
frappe.throw(_("{0} isn't a linkable field on a CRM form.").format(doctype))
|
||||
|
||||
if not guest_can_select(doctype):
|
||||
from frappe.permissions import add_permission
|
||||
|
||||
# a Sales Manager may run the builder but can't normally write Custom DocPerm; do
|
||||
# this narrow, doctype-scoped grant with permission checks off (as this module
|
||||
# already does to write the Web Form).
|
||||
had_flag = frappe.flags.ignore_permissions
|
||||
frappe.flags.ignore_permissions = True
|
||||
try:
|
||||
perm_name = frappe.db.get_value(
|
||||
"Custom DocPerm",
|
||||
{"parent": doctype, "role": "Guest", "permlevel": 0, "if_owner": 0},
|
||||
)
|
||||
if not perm_name:
|
||||
add_permission(doctype, "Guest", 0, ptype="select")
|
||||
perm_name = frappe.db.get_value(
|
||||
"Custom DocPerm",
|
||||
{"parent": doctype, "role": "Guest", "permlevel": 0, "if_owner": 0},
|
||||
)
|
||||
# add_permission defaults the row to read/export; a public Link only lists
|
||||
# names, so trim the Guest row to `select` alone.
|
||||
perm = frappe.get_doc("Custom DocPerm", perm_name)
|
||||
perm.update(
|
||||
{
|
||||
"select": 1,
|
||||
"read": 0,
|
||||
"write": 0,
|
||||
"create": 0,
|
||||
"delete": 0,
|
||||
"submit": 0,
|
||||
"cancel": 0,
|
||||
"amend": 0,
|
||||
"report": 0,
|
||||
"export": 0,
|
||||
"import": 0,
|
||||
"print": 0,
|
||||
"email": 0,
|
||||
"share": 0,
|
||||
}
|
||||
)
|
||||
perm.save(ignore_permissions=True)
|
||||
finally:
|
||||
frappe.flags.ignore_permissions = had_flag
|
||||
frappe.clear_cache(doctype=doctype)
|
||||
|
||||
return {"doctype": doctype, "guest_can_select": guest_can_select(doctype)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def list_forms() -> list[dict]:
|
||||
"""CRM forms only (native Web Form records mapped to Lead/Deal)."""
|
||||
_check_manager()
|
||||
return frappe.get_all(
|
||||
"Web Form",
|
||||
filters={"module": FORM_MODULE, "doc_type": ["in", ALLOWED_DOCTYPES]},
|
||||
fields=[
|
||||
"name",
|
||||
"title",
|
||||
"route",
|
||||
"doc_type as document_type",
|
||||
"crm_published as published",
|
||||
"modified",
|
||||
],
|
||||
order_by="modified desc",
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_form_config(name: str) -> dict:
|
||||
"""Full config for the builder, read from the native Web Form record."""
|
||||
_check_manager()
|
||||
doc = _get_crm_form(name)
|
||||
return {
|
||||
"name": doc.name,
|
||||
"title": doc.title,
|
||||
"route": doc.route,
|
||||
"document_type": doc.doc_type,
|
||||
"published": doc.crm_published,
|
||||
"submit_button_label": doc.button_label or _("Submit"),
|
||||
"description": doc.introduction_text or "",
|
||||
"success_message": doc.success_message or "",
|
||||
"redirect_url": doc.success_url or "",
|
||||
"allowed_embedding_domains": doc.allowed_embedding_domains or "",
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": f.fieldname,
|
||||
"label": f.label,
|
||||
"fieldtype": f.fieldtype,
|
||||
"options": f.options,
|
||||
"reqd": f.reqd,
|
||||
"placeholder": f.placeholder,
|
||||
"field_description": f.description,
|
||||
"depends_on": f.depends_on or "",
|
||||
"mandatory_depends_on": f.mandatory_depends_on or "",
|
||||
"read_only_depends_on": f.read_only_depends_on or "",
|
||||
}
|
||||
for f in doc.web_form_fields
|
||||
],
|
||||
"hidden_fields": _load_hidden_fields(doc),
|
||||
}
|
||||
|
||||
|
||||
def _load_hidden_fields(doc) -> list[dict]:
|
||||
try:
|
||||
return json.loads(doc.get("crm_hidden_defaults") or "[]")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _assert_hidden_defaults_set(hidden: list[dict]):
|
||||
"""Publishing requires every hidden required field to carry a default — a blank
|
||||
one would break record creation on submission. Drafts may save without them."""
|
||||
missing = [
|
||||
h.get("label") or h.get("fieldname") for h in hidden if not str(h.get("default") or "").strip()
|
||||
]
|
||||
if missing:
|
||||
frappe.throw(_("Set a default value before publishing for: {0}").format(", ".join(missing)))
|
||||
|
||||
|
||||
def _validated_visible_fields(document_type: str, fields: list[dict]) -> list[dict]:
|
||||
"""Re-apply the catalog the picker filters by: `accept()` lets a public visitor
|
||||
write anything listed in `web_form_fields`."""
|
||||
meta = frappe.get_meta(document_type)
|
||||
catalog = {f["fieldname"]: f for f in _mappable_fields(document_type)}
|
||||
rows = []
|
||||
for f in fields:
|
||||
fieldname = f.get("fieldname")
|
||||
if f.get("fieldtype") in LAYOUT_BREAKS:
|
||||
# a break carries no data, but `accept()` still writes any row naming a field
|
||||
if meta.has_field(fieldname):
|
||||
frappe.throw(_("{0} can't be used as a layout break").format(fieldname))
|
||||
rows.append(f)
|
||||
continue
|
||||
allowed = catalog.get(fieldname)
|
||||
if not allowed:
|
||||
frappe.throw(_("{0} can't be collected by a form").format(fieldname or _("Unnamed field")))
|
||||
rows.append({**f, "fieldtype": allowed["fieldtype"], "options": allowed["options"]})
|
||||
return rows
|
||||
|
||||
|
||||
def _validated_hidden_fields(document_type: str, hidden: list[dict]) -> list[dict]:
|
||||
"""Same gate for the hidden section: `_apply_hidden_defaults` writes these onto
|
||||
every submission."""
|
||||
catalog = {h["fieldname"]: h for h in _seed_hidden_fields(document_type)}
|
||||
rows = []
|
||||
for h in hidden:
|
||||
fieldname = h.get("fieldname")
|
||||
allowed = catalog.get(fieldname)
|
||||
if not allowed:
|
||||
frappe.throw(_("{0} can't be set as a hidden field").format(fieldname or _("Unnamed field")))
|
||||
rows.append({**h, "fieldtype": allowed["fieldtype"], "options": allowed["options"]})
|
||||
return rows
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_form(name: str | None, form: dict | str) -> dict:
|
||||
"""Create/update a native Web Form scoped to CRM doctypes."""
|
||||
_check_manager()
|
||||
if isinstance(form, str):
|
||||
form = json.loads(form or "{}")
|
||||
|
||||
if form.get("document_type") not in ALLOWED_DOCTYPES:
|
||||
frappe.throw(_("Forms can only map to: {0}").format(", ".join(ALLOWED_DOCTYPES)))
|
||||
|
||||
doc = _get_crm_form(name) if name else frappe.new_doc("Web Form")
|
||||
|
||||
doc.title = form.get("title")
|
||||
doc.route = form.get("route")
|
||||
doc.doc_type = form.get("document_type")
|
||||
doc.introduction_text = form.get("description")
|
||||
doc.button_label = form.get("submit_button_label") or "Submit"
|
||||
doc.success_message = form.get("success_message")
|
||||
# redirect visitors here after a successful submission (native Web Form field);
|
||||
# blank falls back to showing the success message
|
||||
doc.success_url = form.get("redirect_url") or ""
|
||||
doc.allowed_embedding_domains = form.get("allowed_embedding_domains")
|
||||
# `crm_published` drives the CRM's own branded page at /crm-form/<route>; mirror
|
||||
# it onto the native `published` so the Desk Web Form view shows the right state.
|
||||
# (This also makes Frappe serve its generic form at /<route> — an additional,
|
||||
# unbranded public URL alongside /crm-form/<route>.)
|
||||
doc.crm_published = 1 if form.get("published") else 0
|
||||
doc.published = doc.crm_published
|
||||
# CRM forms are public, single-purpose lead/deal capture
|
||||
doc.login_required = 0
|
||||
doc.allow_multiple = 1
|
||||
doc.is_standard = 0
|
||||
doc.module = FORM_MODULE
|
||||
|
||||
# brand-new form → seed a starting contact-capture layout (only on create)
|
||||
fields = form.get("fields")
|
||||
if not name and not fields:
|
||||
fields = _seed_visible_fields(form["document_type"])
|
||||
|
||||
# only rewrite the layout when fields were actually sent — an update that omits
|
||||
# `fields` (e.g. a settings-only save) must not wipe the existing layout
|
||||
if fields is not None:
|
||||
fields = _validated_visible_fields(form["document_type"], fields)
|
||||
doc.set("web_form_fields", [])
|
||||
for i, f in enumerate(fields):
|
||||
doc.append(
|
||||
"web_form_fields",
|
||||
{
|
||||
"fieldname": f.get("fieldname"),
|
||||
"label": f.get("label"),
|
||||
"fieldtype": f.get("fieldtype"),
|
||||
"options": f.get("options"),
|
||||
"reqd": 1 if f.get("reqd") else 0,
|
||||
"placeholder": f.get("placeholder"),
|
||||
"description": f.get("field_description"),
|
||||
# conditional-logic rules authored per field in the builder (same as
|
||||
# the framework's Web Form fields; evaluated client-side)
|
||||
"depends_on": f.get("depends_on") or "",
|
||||
"mandatory_depends_on": f.get("mandatory_depends_on") or "",
|
||||
"read_only_depends_on": f.get("read_only_depends_on") or "",
|
||||
"idx": i + 1,
|
||||
},
|
||||
)
|
||||
|
||||
# hidden required fields: mandatory fields kept out of the visible form, with
|
||||
# the default value applied on submission (see enrich_form_submission). Seeded
|
||||
# once on create (e.g. Status); thereafter whatever the builder sends wins.
|
||||
hidden = form.get("hidden_fields")
|
||||
if hidden is None and not name:
|
||||
hidden = _seed_hidden_fields(form["document_type"])
|
||||
if isinstance(hidden, str):
|
||||
hidden = json.loads(hidden or "[]")
|
||||
hidden = _validated_hidden_fields(form["document_type"], hidden or [])
|
||||
if doc.crm_published:
|
||||
_assert_hidden_defaults_set(hidden)
|
||||
doc.crm_hidden_defaults = json.dumps(hidden) if hidden else ""
|
||||
|
||||
doc.save(ignore_permissions=True)
|
||||
return {"name": doc.name, "route": doc.route}
|
||||
|
||||
|
||||
def _get_crm_form(name: str):
|
||||
doc = frappe.get_doc("Web Form", name)
|
||||
# scope to CRM's own forms — a Web Form from another app that happens to target
|
||||
# CRM Lead/Deal must not be readable/mutable/deletable through this API
|
||||
if doc.module != FORM_MODULE or doc.doc_type not in ALLOWED_DOCTYPES:
|
||||
frappe.throw(_("Not a CRM form"))
|
||||
return doc
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_published(name: str, published: int) -> None:
|
||||
"""Publish/unpublish from the list, bypassing native Web Form role perms."""
|
||||
_check_manager()
|
||||
doc = _get_crm_form(name)
|
||||
if int(published):
|
||||
_assert_hidden_defaults_set(_load_hidden_fields(doc))
|
||||
doc.crm_published = 1 if int(published) else 0
|
||||
doc.published = doc.crm_published # mirror onto native flag (see save_form)
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_form(name: str) -> None:
|
||||
_check_manager()
|
||||
_get_crm_form(name)
|
||||
frappe.delete_doc("Web Form", name, ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def test_submit_form(name: str, values: dict | str) -> dict:
|
||||
"""Dry-run a submission for an author previewing a draft: validate required
|
||||
fields the same way the live form does, but create no record."""
|
||||
_check_manager()
|
||||
if isinstance(values, str):
|
||||
values = json.loads(values or "{}")
|
||||
|
||||
doc = _get_crm_form(name)
|
||||
for f in doc.web_form_fields:
|
||||
if f.fieldtype in ("Section Break", "Column Break"):
|
||||
continue
|
||||
value = values.get(f.fieldname)
|
||||
if f.reqd and (value is None or value == ""):
|
||||
frappe.throw(_("{0} is required").format(f.label or f.fieldname))
|
||||
return {"test": True}
|
||||
|
||||
|
||||
# Public form serving + submission run through the framework's own Web Form engine:
|
||||
# the CRM page (`www/crm_form.py`) renders the published form and posts to the
|
||||
# built-in `accept()`, which triggers `enrich_form_submission` below via the
|
||||
# `in_web_form` flag. No CRM-owned guest endpoint is needed for that path.
|
||||
|
||||
|
||||
def enrich_form_submission(doc):
|
||||
"""Called from the CRM Lead/Deal `before_insert`: when the record is created via a
|
||||
web form, apply the same enrichment the CRM applies on manual creation.
|
||||
|
||||
The framework's Web Form `accept()` just inserts the target doc, so without this
|
||||
web submissions would miss Source / Organization / primary Contact. `accept()`
|
||||
sets `frappe.flags.in_web_form`, which is how we scope this to web submissions.
|
||||
"""
|
||||
if not frappe.flags.get("in_web_form"):
|
||||
return
|
||||
if doc.doctype not in ALLOWED_DOCTYPES:
|
||||
return
|
||||
|
||||
_apply_hidden_defaults(doc)
|
||||
|
||||
# stamp the source so form records are identifiable/filterable
|
||||
if doc.meta.has_field("source") and not doc.get("source"):
|
||||
doc.source = ensure_form_source()
|
||||
|
||||
capture(
|
||||
"web_form_submitted",
|
||||
"crm",
|
||||
properties={"doctype": doc.doctype, "form": frappe.form_dict.get("web_form")},
|
||||
)
|
||||
|
||||
if doc.doctype != "CRM Deal":
|
||||
return
|
||||
|
||||
from crm.fcrm.doctype.crm_deal.crm_deal import create_contact, create_organization
|
||||
|
||||
# auto-create the linked Organization from the org name (as the CRM does for deals)
|
||||
if doc.get("organization_name") and not doc.get("organization"):
|
||||
created = create_organization(doc)
|
||||
if created:
|
||||
doc.organization = created
|
||||
|
||||
# auto-create & link a primary Contact from the person's details
|
||||
if not doc.get("contacts") and (
|
||||
doc.get("first_name") or doc.get("last_name") or doc.get("email") or doc.get("mobile_no")
|
||||
):
|
||||
contact = create_contact(doc)
|
||||
if contact:
|
||||
doc.append("contacts", {"contact": contact, "is_primary": 1})
|
||||
|
||||
|
||||
def _apply_hidden_defaults(doc):
|
||||
"""Apply the submitting form's hidden-field defaults (e.g. Status) to fields the
|
||||
visitor didn't fill, so mandatory values are present before the record is saved."""
|
||||
web_form = frappe.form_dict.get("web_form")
|
||||
if not web_form:
|
||||
return
|
||||
# `web_form` comes from the (client-controllable) POST body — only trust a CRM
|
||||
# form that targets this exact doctype, else a submission could pull in another
|
||||
# form's defaults (or a different doctype's).
|
||||
form = frappe.db.get_value("Web Form", web_form, ["doc_type", "crm_hidden_defaults"], as_dict=True)
|
||||
if not form or form.doc_type not in ALLOWED_DOCTYPES or form.doc_type != doc.doctype:
|
||||
return
|
||||
raw = form.crm_hidden_defaults
|
||||
if not raw:
|
||||
return
|
||||
try:
|
||||
hidden = json.loads(raw)
|
||||
except Exception:
|
||||
return
|
||||
for h in hidden:
|
||||
fieldname = h.get("fieldname")
|
||||
default = h.get("default")
|
||||
if (
|
||||
fieldname
|
||||
and default not in (None, "")
|
||||
and doc.meta.has_field(fieldname)
|
||||
and not doc.get(fieldname)
|
||||
):
|
||||
doc.set(fieldname, default)
|
||||
Reference in New Issue
Block a user