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
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
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-18 01:36:01 +05:00
commit db69de1c11
905 changed files with 102713 additions and 0 deletions

0
crm/www/__init__.py Normal file
View File

117
crm/www/crm.py Normal file
View File

@@ -0,0 +1,117 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# GNU GPLv3 License. See license.txt
import frappe
from frappe import _, get_installed_apps
from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site
from frappe.translate import get_messages_for_boot, get_translated_doctypes
from frappe.utils import cint, get_system_timezone
from frappe.utils.telemetry import capture
no_cache = 1
def get_context():
from crm.api import check_app_permission
if not check_app_permission():
frappe.throw(_("You do not have permission to access Frappe CRM"), frappe.PermissionError)
redirect_to_set_password()
frappe.db.commit()
context = frappe._dict()
context.boot = get_boot()
if frappe.session.user != "Guest":
capture("active_site", "crm")
return context
def redirect_to_set_password():
"""Send users who have no password of their own to the set password page.
The Frappe Cloud site owner arrives on a session handed over by the
dashboard and never sets a password, so they cannot get back in once it
expires. Invited users are already routed here by `accept_invitation`; this
covers everyone else, on the same page.
"""
from crm.api.user import needs_password_setup
if not needs_password_setup():
return
user = frappe.get_doc("User", frappe.session.user)
link = user._reset_password()
# this is a GET request, which is rolled back unless a commit is requested
frappe.local.flags.commit = True
frappe.local.flags.redirect_location = link
# 302, not the 301 default: v16+ lets the browser cache a permanent redirect,
# which would keep sending the user to a spent key long after they set a
# password. Set on the instance — v15's `Redirect` takes no constructor arg.
redirect = frappe.Redirect()
redirect.http_status_code = 302
raise redirect
@frappe.whitelist(methods=["POST"], allow_guest=True)
def get_context_for_dev():
if not frappe.conf.developer_mode:
frappe.throw(_("This method is only meant for developer mode"))
return get_boot()
def get_boot():
return frappe._dict(
{
"frappe_version": frappe.__version__,
"default_route": get_default_route(),
"site_name": frappe.local.site,
"socketio_port": frappe.conf.socketio_port,
"read_only_mode": frappe.flags.read_only,
"csrf_token": frappe.sessions.get_csrf_token(),
"setup_complete": cint(frappe.get_system_settings("setup_complete")),
"sysdefaults": frappe.defaults.get_defaults(),
"is_demo_site": frappe.conf.get("is_demo_site"),
"demo_data_created": frappe.db.get_default("crm_demo_data_created") == "1",
"is_fc_site": is_fc_site(),
"translated_doctypes": get_translated_doctypes(),
"translated_messages": get_messages_for_boot(),
"timezone": {
"system": get_system_timezone(),
"user": frappe.db.get_value("User", frappe.session.user, "time_zone")
or get_system_timezone(),
},
"state_options": get_state_options(),
}
)
def get_state_options() -> dict[str, list[str]]:
"""Country -> list of states, so the frontend can render a state dropdown.
Sourced from India Compliance's own constant when that app is installed, so the
options stay in sync with the desk. Returns an empty map (state field stays free
text) on any failure.
This runs inside ``get_boot``, so it must never raise — a failure here would break
the whole CRM page load. ``get_installed_apps`` can return `[]` or raise in
unauthenticated/boot contexts (notably on v15), so the lookup is wrapped defensively.
"""
try:
if "india_compliance" not in get_installed_apps():
return {}
from india_compliance.gst_india.constants import INDIAN_STATES
return {"India": list(INDIAN_STATES)}
except Exception:
# Degrade silently to free-text: this runs in boot, so the except branch
# must not do anything that can itself raise (e.g. logging to a missing dir).
return {}
def get_default_route():
return "/crm"

623
crm/www/crm_form.html Normal file
View File

@@ -0,0 +1,623 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ form_title }}</title>
<meta name="robots" content="noindex" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
:root {
/* espresso / frappe-ui light-theme tokens (pure-neutral grays) */
--ink-strong: oklch(0.168 0 0); /* ink-gray-9 — headings */
--ink: oklch(0.205 0 0); /* ink-gray-8 — field text */
--muted: oklch(0.586 0 0); /* ink-gray-5 — labels */
--faint: oklch(0.683 0 0); /* ink-gray-4 — placeholders */
--border: oklch(0.913 0 0); /* outline-gray-2 */
--border-focus: oklch(0.83 0 0); /* outline-gray-3 */
--bg: oklch(0.964 0 0); /* surface-gray-2 — page */
--card: oklch(1 0 0); /* white surface */
--field: oklch(0.964 0 0); /* surface-gray-2 — inputs */
--accent: oklch(0.205 0 0); /* surface-gray-10 — button */
--accent-hover: oklch(0.341 0 0); /* surface-gray-9 */
--danger: oklch(0.507 0.165 25.286); /* ink-red-5 */
--danger-bg: oklch(0.978 0.011 17.34); /* surface-red-1 */
--success-bg: oklch(0.965 0.03 155.987); /* surface-green-2 */
--success-ink: oklch(0.506 0.118 155.358); /* ink-green-5 */
--ring: oklch(0.586 0 0 / 0.15);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--ink);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 48px 16px;
-webkit-font-smoothing: antialiased;
}
.card {
width: 100%;
max-width: 560px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px 28px 26px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 32px rgba(0, 0, 0, 0.05);
}
/* embed mode (?embed=1): drop the page chrome so the form sits flush inside
the host page — no gray backdrop, no card border/shadow, full width */
body.embed { background: transparent; padding: 0; min-height: 0; }
body.embed .card { max-width: none; border: none; border-radius: 0; box-shadow: none; padding: 0; }
body.embed .draft-banner { max-width: none; }
.title { font-size: 20px; font-weight: 600; color: var(--ink-strong); margin: 0 0 14px; letter-spacing: -0.01em; line-height: 1.3; }
/* with no description, the title supplies the gap the subtitle normally would */
.title.title-only { margin-bottom: 22px; }
.subtitle { font-size: 14px; color: var(--muted); margin: 0 0 22px; line-height: 1.5; white-space: pre-wrap; }
.section { margin-bottom: 4px; }
.section-title { font-size: 15px; font-weight: 600; color: var(--ink-strong); margin: 20px 0 12px; }
.section:first-child .section-title { margin-top: 0; }
.cols { display: grid; gap: 0 20px; }
@media (max-width: 520px) { .cols { grid-template-columns: 1fr !important; } }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; font-weight: 400; margin-bottom: 6px; color: var(--muted); }
.field .req { color: var(--danger); margin-left: 2px; }
.field .desc { font-size: 12px; color: var(--muted); margin-top: 5px; line-height: 1.4; }
input[type="text"], input[type="email"], input[type="tel"], input[type="number"],
input[type="date"], input[type="datetime-local"], textarea, select {
width: 100%;
font: inherit;
font-size: 14px;
line-height: 1.4;
color: var(--ink);
background: var(--field);
border: 1px solid transparent;
border-radius: 8px;
padding: 6px 10px;
outline: none;
transition: border-color 0.12s, box-shadow 0.12s, background 0.12s;
}
::placeholder { color: var(--faint); }
textarea { min-height: 68px; resize: vertical; }
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 32px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%237c7c7c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
cursor: pointer;
}
input:focus, textarea:focus, select:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 2px var(--ring);
background: var(--card);
}
.has-error, .has-error:focus {
border-color: var(--danger);
box-shadow: 0 0 0 2px var(--danger-bg);
}
.check { display: flex; align-items: center; gap: 9px; }
.check input { width: 16px; height: 16px; accent-color: var(--accent); }
.check label { margin: 0; font-weight: 400; font-size: 14px; }
.actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
button.submit, button.discard {
font: inherit;
font-size: 14px;
font-weight: 500;
border: none;
border-radius: 8px;
padding: 8px 18px;
cursor: pointer;
transition: background 0.12s, transform 0.02s;
}
button.submit { color: oklch(1 0 0); background: var(--accent); }
button.submit:hover { background: var(--accent-hover); }
button.submit:active { transform: translateY(1px); }
button.submit:disabled { opacity: 0.5; cursor: default; }
button.discard { color: var(--ink); background: var(--field); }
button.discard:hover { background: var(--border); }
.form-error {
display: none;
background: var(--danger-bg);
color: var(--danger);
font-size: 13px;
border-radius: 9px;
padding: 10px 12px;
margin-bottom: 16px;
}
/* inline confirmation after an embedded submit (form stays, just clears) */
.form-ok {
display: none;
background: var(--success-bg);
color: var(--success-ink);
font-size: 13px;
border-radius: 9px;
padding: 10px 12px;
margin-bottom: 16px;
}
.success { text-align: center; padding: 20px 4px 8px; }
.success .check-ring {
width: 48px; height: 48px; border-radius: 999px; margin: 0 auto 16px;
background: var(--success-bg); color: var(--success-ink); display: flex; align-items: center;
justify-content: center; font-size: 24px;
}
.success h2 { font-size: 19px; color: var(--ink-strong); margin: 0 0 6px; }
.success p { color: var(--muted); font-size: 14px; margin: 0; line-height: 1.5; }
.redirect-note { margin-top: 12px !important; font-size: 13px; }
.redirect-note a { color: var(--ink-strong); text-decoration: underline; margin-left: 4px; }
/* frappe-ui Alert — yellow, subtle: surface-amber-2 bg, amber triangle-alert
icon, ink-gray-9 text, rounded-md, px-4 py-3.5, gap-3, text-base */
.draft-banner {
display: flex; align-items: flex-start; gap: 12px;
width: 100%; max-width: 560px; margin: 0 auto 12px;
padding: 14px 16px; border-radius: 6px;
background: oklch(0.967 0.045 91.733); /* surface-amber-2 */
color: var(--ink-strong); /* ink-gray-9 */
font: 400 14px/1.5 "Inter", sans-serif; text-align: left;
}
.draft-banner svg { flex-shrink: 0; width: 16px; height: 16px; margin-top: 1px; color: oklch(0.599 0.141 56.803); }
</style>
</head>
<body class="{% if embed %}embed{% endif %}">
{% if draft_preview %}
<div class="draft-banner">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
<span>{{ _("Draft preview — only you can see this. Publish the form to make it public.") }}</span>
</div>
{% endif %}
<div class="card">
<div id="form-view">
<h1 class="title{% if not form_description %} title-only{% endif %}">{{ form_title }}</h1>
{% if form_description %}<p class="subtitle">{{ form_description }}</p>{% endif %}
<div class="form-error" id="form-error"></div>
<div class="form-ok" id="form-ok"></div>
<form id="web-form" novalidate>
{% for section in layout %}
<div class="section">
{% if section.label %}<div class="section-title">{{ section.label }}</div>{% endif %}
<div class="cols" style="grid-template-columns: repeat({{ section.columns|length }}, minmax(0, 1fr))">
{% for col in section.columns %}
<div class="col">
{% for f in col %}
<div class="field">
{% if f.fieldtype == "Check" %}
<div class="check">
<input type="checkbox" id="{{ f.fieldname }}" name="{{ f.fieldname }}" />
<label for="{{ f.fieldname }}">{{ f.label }}{% if f.reqd or f.mandatory_depends_on %}<span class="req"{% if not f.reqd %} style="display: none"{% endif %}>*</span>{% endif %}</label>
</div>
{% else %}
<label for="{{ f.fieldname }}">{{ f.label }}{% if f.reqd or f.mandatory_depends_on %}<span class="req"{% if not f.reqd %} style="display: none"{% endif %}>*</span>{% endif %}</label>
{% if f.fieldtype in ("Small Text", "Text", "Long Text", "Text Editor", "HTML Editor", "Markdown Editor") %}
<textarea id="{{ f.fieldname }}" name="{{ f.fieldname }}" placeholder="{{ f.placeholder }}" {% if f.reqd %}required{% endif %}></textarea>
{% elif f.fieldtype == "Select" %}
<select id="{{ f.fieldname }}" name="{{ f.fieldname }}" {% if f.reqd %}required{% endif %}>
<option value="">Select…</option>
{% for opt in f.options.split("\n") %}{% if opt %}<option value="{{ opt }}">{{ opt }}</option>{% endif %}{% endfor %}
</select>
{% elif f.fieldtype == "Link" %}
<select id="{{ f.fieldname }}" name="{{ f.fieldname }}" {% if f.reqd %}required{% endif %}>
<option value="">{{ f.placeholder or "Select…" }}</option>
{% for opt in link_options.get(f.fieldname, []) %}<option value="{{ opt.value }}">{{ opt.label }}</option>{% endfor %}
</select>
{% elif f.fieldtype in ("Int", "Float", "Currency", "Percent") %}
<input type="number" id="{{ f.fieldname }}" name="{{ f.fieldname }}" placeholder="{{ f.placeholder }}" {% if f.reqd %}required{% endif %} />
{% elif f.fieldtype == "Date" %}
<input type="date" id="{{ f.fieldname }}" name="{{ f.fieldname }}" {% if f.reqd %}required{% endif %} />
{% elif f.fieldtype == "Datetime" %}
<input type="datetime-local" id="{{ f.fieldname }}" name="{{ f.fieldname }}" {% if f.reqd %}required{% endif %} />
{% elif f.fieldtype == "Time" %}
<input type="time" id="{{ f.fieldname }}" name="{{ f.fieldname }}" {% if f.reqd %}required{% endif %} />
{% elif f.fieldtype == "Color" %}
<input type="color" id="{{ f.fieldname }}" name="{{ f.fieldname }}" {% if f.reqd %}required{% endif %} />
{% else %}
<input
type="{% if f.options == 'Email' %}email{% elif f.options == 'Phone' or f.fieldtype == 'Phone' %}tel{% else %}text{% endif %}"
id="{{ f.fieldname }}" name="{{ f.fieldname }}" placeholder="{{ f.placeholder }}" {% if f.reqd %}required{% endif %} />
{% endif %}
{% endif %}
{% if f.description %}<div class="desc">{{ f.description }}</div>{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
<div class="actions">
<button type="reset" class="discard" id="discard-btn">{{ _("Discard") }}</button>
<button type="submit" class="submit" id="submit-btn">{{ submit_label }}</button>
</div>
</form>
</div>
<div class="success" id="success-view" style="display: none">
<div class="check-ring">&#10003;</div>
<h2 id="success-title">{{ success_message }}</h2>
<p id="success-note">Your response has been recorded.</p>
<p class="redirect-note" id="redirect-note" style="display: none"></p>
</div>
</div>
<script>
(function () {
var ROUTE = {{ form_route | tojson }};
var WEB_FORM = {{ web_form_name | tojson }};
var FIELDS = {{ fields | tojson }};
var CSRF = {{ csrf_token | tojson }};
var DRAFT = {{ draft_preview | tojson }};
var SUCCESS_URL = {{ success_url | tojson }};
var EMBED = {{ embed | tojson }};
var form = document.getElementById("web-form");
var errBox = document.getElementById("form-error");
var okBox = document.getElementById("form-ok");
var btn = document.getElementById("submit-btn");
var BREAKS = { "Section Break": 1, "Column Break": 1 };
// Field interdependencies: apply the DocType's depends_on / mandatory_depends_on
// / read_only_depends_on client-side, same rules as the Desk form.
function collectDoc() {
var doc = {};
FIELDS.forEach(function (f) {
if (BREAKS[f.fieldtype]) return;
var el = document.getElementById(f.fieldname);
if (!el) return;
doc[f.fieldname] = f.fieldtype === "Check" ? (el.checked ? 1 : 0) : el.value;
});
return doc;
}
// security boundary; tested by frontend/tests/unit/webFormConditionalEval.test.js
// [conditional-eval:start]
function coerce(v) {
return Array.isArray(v) ? v.length > 0 : !!v;
}
// Parse (never execute) an "eval:" expression, so stored metadata can't run JS on
// this public page. Supports doc/parent access, literals, comparisons, && || !,
// and parentheses; anything else throws → caller's fallback.
function safeEval(src, doc, parent) {
var i = 0;
function ws() {
while (i < src.length && /\s/.test(src[i])) i++;
}
function starts(tok) {
ws();
return src.substr(i, tok.length) === tok;
}
function primary() {
ws();
if (starts("(")) {
i++;
var e = orExpr();
ws();
if (!starts(")")) throw new Error("expected )");
i++;
return e;
}
if (starts("!")) {
i++;
return !coerce(primary());
}
var ch = src[i];
if (ch === '"' || ch === "'") {
i++;
var str = "";
while (i < src.length && src[i] !== ch) {
if (src[i] === "\\") i++;
str += src[i++];
}
i++;
return str;
}
var num = /^-?\d+(?:\.\d+)?/.exec(src.slice(i));
if (num) {
i += num[0].length;
return parseFloat(num[0]);
}
var id = /^[A-Za-z_$][\w$]*/.exec(src.slice(i));
if (!id) throw new Error("unexpected token");
i += id[0].length;
if (id[0] === "true") return true;
if (id[0] === "false") return false;
if (id[0] === "null") return null;
var base;
if (id[0] === "doc") base = doc;
else if (id[0] === "parent") base = parent;
else throw new Error("unknown identifier: " + id[0]); // no globals reachable
for (;;) {
ws();
if (src[i] === ".") {
i++;
var m = /^[A-Za-z_$][\w$]*/.exec(src.slice(i));
if (!m) throw new Error("bad member");
i += m[0].length;
base = base == null ? undefined : base[m[0]];
} else if (src[i] === "[") {
i++;
var key = orExpr();
ws();
if (!starts("]")) throw new Error("expected ]");
i++;
base = base == null ? undefined : base[key];
} else break;
}
return base;
}
function cmp() {
var l = primary();
ws();
var ops = ["===", "!==", "==", "!=", "<=", ">=", "<", ">"];
for (var k = 0; k < ops.length; k++) {
if (src.substr(i, ops[k].length) === ops[k]) {
i += ops[k].length;
var r = primary();
switch (ops[k]) {
case "===":
case "==":
return l == r; // eslint-disable-line eqeqeq
case "!==":
case "!=":
return l != r; // eslint-disable-line eqeqeq
case "<":
return l < r;
case "<=":
return l <= r;
case ">":
return l > r;
case ">=":
return l >= r;
}
}
}
return l;
}
function andExpr() {
var l = cmp();
while (starts("&&")) {
i += 2;
var r = cmp();
l = coerce(l) && coerce(r);
}
return l;
}
function orExpr() {
var l = andExpr();
while (starts("||")) {
i += 2;
var r = andExpr();
l = coerce(l) || coerce(r);
}
return l;
}
var out = orExpr();
ws();
if (i < src.length) throw new Error("trailing input");
return out;
}
// `fallback` covers an empty, unsupported, or invalid expression
function truthy(expr, doc, fallback) {
var s = expr ? String(expr).trim() : "";
if (!s) return fallback;
try {
if (s.slice(0, 5) === "eval:") return coerce(safeEval(s.slice(5), doc, doc));
if (s.slice(0, 3) === "fn:") return fallback;
return coerce(doc[s]);
} catch (e) {
return fallback;
}
}
// [conditional-eval:end]
function isVisible(f, doc) {
return truthy(f.depends_on, doc, true);
}
function isRequired(f, doc) {
return f.reqd ? true : truthy(f.mandatory_depends_on, doc, false);
}
function isReadOnly(f, doc) {
return truthy(f.read_only_depends_on, doc, false);
}
// apply the rules to the DOM: toggle visibility, read-only, and the required *
function refreshDeps() {
var doc = collectDoc();
FIELDS.forEach(function (f) {
if (BREAKS[f.fieldtype]) return;
var el = document.getElementById(f.fieldname);
if (!el) return;
var wrap = el.closest(".field");
var visible = isVisible(f, doc);
if (wrap) wrap.style.display = visible ? "" : "none";
var ro = isReadOnly(f, doc);
// selects/checkboxes have no readOnly; disable them (JS still reads .value)
if (f.fieldtype === "Check" || f.fieldtype === "Select" || f.fieldtype === "Link") {
el.disabled = ro;
} else {
el.readOnly = ro;
}
// toggle the * for a conditionally-mandatory field (static ones keep theirs)
if (wrap && !f.reqd) {
var star = wrap.querySelector(".req");
if (star) star.style.display = visible && isRequired(f, doc) ? "" : "none";
}
});
}
function showError(msg) {
errBox.textContent = msg || "Something went wrong. Please try again.";
errBox.style.display = "block";
errBox.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// transient success acknowledgement used in embed mode (auto-hides)
function showOk(msg) {
okBox.textContent = msg || "Your response has been recorded.";
okBox.style.display = "block";
okBox.scrollIntoView({ behavior: "smooth", block: "nearest" });
clearTimeout(showOk._t);
showOk._t = setTimeout(function () {
okBox.style.display = "none";
}, 5000);
}
function clearFieldErrors() {
[].forEach.call(form.querySelectorAll(".has-error"), function (el) {
el.classList.remove("has-error");
});
}
function redirectWithCountdown(url) {
var note = document.getElementById("redirect-note");
var link = document.createElement("a");
link.href = url;
link.textContent = "Go now";
var secs = 5;
function render() {
note.textContent =
"Redirecting in " + secs + (secs === 1 ? " second" : " seconds") + "…";
note.appendChild(link);
}
note.style.display = "block";
render();
var iv = setInterval(function () {
secs -= 1;
if (secs <= 0) {
clearInterval(iv);
window.location.assign(url);
return;
}
render();
}, 1000);
}
// keep the rules live as the user types/selects; also dismiss the success note
// as soon as they start the next entry
function onEdit() {
if (okBox.style.display !== "none") {
okBox.style.display = "none";
clearTimeout(showOk._t);
}
refreshDeps();
}
form.addEventListener("input", onEdit);
form.addEventListener("change", onEdit);
refreshDeps();
// Discard (type="reset") clears inputs; also drop errors and re-apply the rules
form.addEventListener("reset", function () {
errBox.style.display = "none";
clearFieldErrors();
setTimeout(refreshDeps, 0);
});
form.addEventListener("submit", function (e) {
e.preventDefault();
errBox.style.display = "none";
clearFieldErrors();
var doc = collectDoc();
var values = {};
var missing = [];
FIELDS.forEach(function (f) {
if (BREAKS[f.fieldtype]) return;
var el = document.getElementById(f.fieldname);
if (!el) return;
if (!isVisible(f, doc)) return; // hidden by depends_on → not submitted/validated
var val = f.fieldtype === "Check" ? (el.checked ? 1 : 0) : el.value;
values[f.fieldname] = val;
// a required checkbox must be checked; unchecked is 0, not "empty"
var empty =
f.fieldtype === "Check"
? val === 0
: val === "" || val === null || val === undefined;
// don't require a field the visitor can't edit (read-only) — otherwise a
// field that's both required and read-only would deadlock submission
if (isRequired(f, doc) && !isReadOnly(f, doc) && empty) {
missing.push(f);
el.classList.add("has-error");
}
});
if (missing.length) {
showError(
missing.length === 1
? "Please fill in the required field: " + (missing[0].label || "")
: "Please fill in all required fields."
);
var first = document.getElementById(missing[0].fieldname);
if (first && first.focus) first.focus();
return;
}
btn.disabled = true;
var original = btn.textContent;
btn.textContent = "Submitting…";
// Draft preview → dry-run (validate only, no record). Published → submit
// through Frappe's built-in Web Form engine (validation + insert).
var endpoint = DRAFT
? "/api/method/crm.api.form.test_submit_form"
: "/api/method/frappe.website.doctype.web_form.web_form.accept";
var payload = DRAFT
? { name: WEB_FORM, values: JSON.stringify(values) }
: { web_form: WEB_FORM, data: JSON.stringify(values) };
fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Frappe-CSRF-Token": (CSRF || "") },
body: JSON.stringify(payload),
})
.then(function (r) {
return r.json().then(function (data) { return { ok: r.ok, data: data }; });
})
.then(function (res) {
if (!res.ok) {
var m = res.data && res.data._server_messages;
var msg = "";
try { msg = m ? JSON.parse(JSON.parse(m)[0]).message : ""; } catch (e) {}
showError(msg || "Sorry, we couldn't submit your response. Please try again.");
btn.disabled = false;
btn.textContent = original;
return;
}
// embedded (and not redirecting): clear in place for the next submission
// instead of swapping the form out for the success page
if (EMBED && !DRAFT && !SUCCESS_URL) {
var okMsg = document.getElementById("success-title").textContent;
form.reset();
showOk(okMsg);
btn.disabled = false;
btn.textContent = original;
return;
}
document.getElementById("form-view").style.display = "none";
document.getElementById("success-view").style.display = "block";
if (DRAFT) {
document.getElementById("success-note").textContent =
"Test submission — no record was created. Publish the form to start collecting responses.";
} else if (SUCCESS_URL) {
// like the framework: show the success message, then redirect
// after a short countdown (with a manual fallback link)
redirectWithCountdown(SUCCESS_URL);
}
})
.catch(function () {
showError();
btn.disabled = false;
btn.textContent = original;
});
});
})();
</script>
</body>
</html>

170
crm/www/crm_form.py Normal file
View File

@@ -0,0 +1,170 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import re
import frappe
from crm.api.form import ALLOWED_DOCTYPES, guest_can_select
no_cache = 1
# Cap on options a Link renders into the page — a page-weight guard against a huge
# target table, not a permission gate.
MAX_LINK_OPTIONS = 500
# bare host, optional scheme/port, optional leading "*." wildcard — rejects any
# token containing CSP metacharacters like ";" so admin-entered domains can't
# inject extra directives into the Content-Security-Policy header
ALLOWED_EMBEDDING_DOMAIN_RE = re.compile(
r"^(https?://)?(\*\.)?[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$"
)
def get_context(context):
route = resolve_route()
filters = {"route": route, "crm_published": 1, "doc_type": ["in", ALLOWED_DOCTYPES]}
name = frappe.db.get_value("Web Form", filters)
# let CRM managers preview an unpublished (draft) form; guests only see published
is_author = frappe.session.user != "Guest" and bool(
set(frappe.get_roles()) & {"System Manager", "Sales Manager"}
)
if not name and is_author:
name = frappe.db.get_value("Web Form", {"route": route, "doc_type": ["in", ALLOWED_DOCTYPES]})
if not name:
raise frappe.DoesNotExistError
doc = frappe.get_doc("Web Form", name)
set_embedding_headers(doc)
context.no_cache = 1
try:
context.csrf_token = frappe.sessions.get_csrf_token()
except Exception:
context.csrf_token = ""
context.web_form_name = doc.name
# ?embed=1 (set by the iframe snippet) strips the page chrome so the form sits
# flush inside the host page instead of showing our own card-on-gray-background
context.embed = frappe.form_dict.get("embed") in ("1", "true", "yes")
context.draft_preview = not doc.crm_published
context.form_title = doc.title
context.form_description = doc.introduction_text or ""
context.form_route = doc.route
context.submit_label = doc.button_label or "Submit"
context.success_message = doc.success_message or "Thank you!"
context.success_url = doc.success_url or ""
context.fields = [
{
"fieldname": f.fieldname,
# breaks (Section/Column) keep an empty label when blank so unlabeled
# sections render no heading; only real fields fall back to fieldname
"label": f.label or ("" if f.fieldtype in ("Section Break", "Column Break") else f.fieldname),
"fieldtype": f.fieldtype,
"options": f.options or "",
"reqd": int(f.reqd or 0),
"placeholder": f.placeholder or "",
"description": f.description or "",
# conditional-logic expressions, evaluated client-side (see crm_form.html)
"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
]
context.layout = build_layout(context.fields)
# Link fields render as a server-populated dropdown of existing records. Resolve
# resolve Link options here (keyed by fieldname) so the template stays presentation-only.
context.link_options = {
f["fieldname"]: _link_field_options(f["options"])
for f in context.fields
if f["fieldtype"] == "Link" and f["options"]
}
return context
def _link_field_options(doctype: str) -> list[dict]:
"""Existing records of `doctype` as {value, label} dropdown options, scoped to what an
anonymous visitor may see — honouring the target's permissions at every level (doctype
`select`, row rules, if_owner).
An actual guest is already in that context, so we query directly. Only a logged-in author
*previewing* the page needs the session temporarily switched to Guest (rather than
`get_list(user="Guest")`, which still derives its permission *tier* from the active
session and would check the manager for Guest `read`, failing on a select-only target).
That switch is destructive — `frappe.set_user()` overwrites `session.sid` with the
username and wipes `session.data` — and restoring only the user leaves a corrupted
session that gets persisted under the real sid, logging the author out on their next
request. So snapshot and restore sid + data as well."""
if not doctype or not frappe.db.exists("DocType", doctype):
return []
if not guest_can_select(doctype):
return []
meta = frappe.get_meta(doctype)
title_field = meta.title_field or "name"
fields = ["name"] if title_field == "name" else ["name", title_field]
def _query():
return frappe.get_list(
doctype,
fields=fields,
order_by=f"{title_field} asc",
limit=MAX_LINK_OPTIONS,
)
current_user = frappe.session.user
if current_user == "Guest":
rows = _query()
else:
saved_sid = frappe.session.sid
saved_data = frappe.session.data
try:
frappe.set_user("Guest") # nosemgrep — session fully restored in finally
rows = _query()
finally:
frappe.set_user(current_user)
# set_user() clobbers sid (→ username) and wipes data; put the real ones
# back so the request doesn't persist a corrupted session (see #logout).
frappe.session.sid = saved_sid
frappe.session.data = saved_data
return [{"value": r["name"], "label": r.get(title_field) or r["name"]} for r in rows]
def set_embedding_headers(doc):
"""Allow this page to be embedded as an iframe on the form's allow-listed origins.
Emits a Content-Security-Policy `frame-ancestors` header. Modern browsers ignore
the default `X-Frame-Options: SAMEORIGIN` (set by nginx) whenever `frame-ancestors`
is present, so this is what makes cross-origin embedding work — without it a form
can only be embedded on its own site.
"""
raw_domains = (doc.allowed_embedding_domains or "").split()
domains = [d for d in raw_domains if ALLOWED_EMBEDDING_DOMAIN_RE.match(d)]
if not domains:
return
frappe.local.response_headers["Content-Security-Policy"] = "frame-ancestors 'self' " + " ".join(domains)
def build_layout(fields):
"""Group fields into sections -> columns using Section/Column Break rows."""
sections = []
current = {"label": None, "columns": [[]]}
for f in fields:
ft = f["fieldtype"]
if ft == "Section Break":
sections.append(current)
current = {"label": f.get("label") or None, "columns": [[]]}
elif ft == "Column Break":
current["columns"].append([])
else:
current["columns"][-1].append(f)
sections.append(current)
return [s for s in sections if s["label"] or any(col for col in s["columns"])]
def resolve_route() -> str:
"""The public slug, from the /crm-form/<route> path rule (with a path fallback)."""
route = (frappe.form_dict.get("route") or "").strip("/")
if route and route != "crm-form":
return route
path = (getattr(frappe.request, "path", "") or "").strip("/")
prefix = "crm-form/"
return path[len(prefix) :] if path.startswith(prefix) else path