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
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:
0
crm/www/__init__.py
Normal file
0
crm/www/__init__.py
Normal file
117
crm/www/crm.py
Normal file
117
crm/www/crm.py
Normal 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
623
crm/www/crm_form.html
Normal 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">✓</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
170
crm/www/crm_form.py
Normal 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
|
||||
Reference in New Issue
Block a user