Merge branch 'develop' into chrome-pdf

This commit is contained in:
Ejaaz Khan 2025-10-08 15:43:38 +05:30
commit f74671267d
78 changed files with 45849 additions and 10361 deletions

View file

@ -93,7 +93,7 @@ jobs:
- frappe/hrms
steps:
- name: Dispatch Downstream CI (if supported)
uses: peter-evans/repository-dispatch@v3
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.CI_PAT }}
repository: ${{ matrix.repo }}

View file

@ -1,5 +1,6 @@
exclude: 'node_modules|.git'
default_stages: [pre-commit]
default_install_hook_types: [pre-commit, commit-msg]
fail_fast: false
@ -69,6 +70,13 @@ repos:
frappe/public/js/lib/.*
)$
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.22.0
hooks:
- id: commitlint
stages: [commit-msg]
additional_dependencies: ['conventional-changelog-conventionalcommits']
ci:
autoupdate_schedule: weekly
skip: []

View file

@ -1,6 +1,7 @@
**/hooks.py,frappe.gettext.extractors.navbar.extract
**/doctype/*/*.json,frappe.gettext.extractors.doctype.extract
**/workspace/*/*.json,frappe.gettext.extractors.workspace.extract
**/web_form/*/*.json,frappe.gettext.extractors.web_form.extract
**/onboarding_step/*/*.json,frappe.gettext.extractors.onboarding_step.extract
**/module_onboarding/*/*.json,frappe.gettext.extractors.module_onboarding.extract
**/report/*/*.json,frappe.gettext.extractors.report.extract

1 **/hooks.py frappe.gettext.extractors.navbar.extract
2 **/doctype/*/*.json frappe.gettext.extractors.doctype.extract
3 **/workspace/*/*.json frappe.gettext.extractors.workspace.extract
4 **/web_form/*/*.json frappe.gettext.extractors.web_form.extract
5 **/onboarding_step/*/*.json frappe.gettext.extractors.onboarding_step.extract
6 **/module_onboarding/*/*.json frappe.gettext.extractors.module_onboarding.extract
7 **/report/*/*.json frappe.gettext.extractors.report.extract

View file

@ -75,14 +75,15 @@ context("Form", () => {
cy.get('.frappe-control[data-fieldname="email_ids"]').as("table");
cy.get("@table").find("button.grid-add-row").click();
cy.get("@table").find("button.grid-add-row").click();
cy.get("@table").find('[data-idx="1"]').as("row1");
cy.get("@table").find('[data-idx="2"]').as("row2");
cy.get("@row1").click();
cy.get("@row1").find("input.input-with-feedback.form-control").as("email_input1");
cy.get("@email_input1").type(website_input, { waitForAnimations: false });
cy.get("@table").find("button.grid-add-row").click();
cy.get("@table").find('[data-idx="2"]').as("row2");
cy.get("@row2").click();
cy.get("@row2").find("input.input-with-feedback.form-control").as("email_input2");
cy.get("@email_input2").type(valid_email, { waitForAnimations: false });

View file

@ -24,6 +24,7 @@ frappe.listview_settings["DocType"] = {
fieldtype: "Data",
reqd: 1,
default: doctype_name,
length: 61,
},
{ fieldtype: "Column Break" },
{

View file

@ -0,0 +1,7 @@
frappe.listview_settings["File"] = {
formatters: {
file_name: function (value) {
return frappe.utils.escape_html(value || "");
},
},
};

View file

@ -288,6 +288,8 @@ class Engine:
doctype: str | None = None,
) -> "Criterion | None":
"""Builds a pypika Criterion object for a simple filter condition."""
import operator as builtin_operator
_field = self._validate_and_prepare_filter_field(field, doctype)
_value = convert_to_value(value)
_operator = operator
@ -323,7 +325,7 @@ class Engine:
operator_fn = OPERATOR_MAP[_operator.casefold()]
if _value is None and isinstance(_field, Field):
return _field.isnull()
return _field.isnotnull() if operator_fn == builtin_operator.ne else _field.isnull()
else:
return operator_fn(_field, _value)

View file

@ -443,6 +443,9 @@ def get_definition(fieldtype, precision=None, length=None, *, options=None):
if length:
if coltype == "varchar":
# Reference: https://mariadb.com/docs/server/server-usage/storage-engines/innodb/innodb-row-formats/troubleshooting-row-size-too-large-errors-with-innodb
if length < 64:
length = 64
size = length
elif coltype == "int" and length < 11:
# allow setting custom length for int if length provided is less than 11

View file

@ -107,6 +107,7 @@ class SQLiteDatabase(SQLiteExceptionUtil, Database):
conn = self.create_connection(read_only)
conn.isolation_level = None
conn.create_function("regexp", 2, regexp)
conn.create_function("regexp_replace", 3, regexp_replace)
pragmas = {
"journal_mode": "WAL",
"synchronous": "NORMAL",
@ -583,3 +584,10 @@ def regexp(expr: str, item: str) -> bool:
Although it works in the CLI - doesn't work through python
"""
return re.search(expr, item) is not None
def regexp_replace(item: str, pattern: str, repl: str) -> str:
"""
Define regexp_replace implementation for SQLite
"""
return re.sub(pattern, repl, item)

View file

@ -125,7 +125,7 @@ class SQLiteTable(DBTable):
if self.meta.sort_field == "modified" and not frappe.db.get_column_index(
self.table_name, "modified", unique=False
):
index_queries.append(f"CREATE INDEX `modified` ON `{self.table_name}` (`modified`)")
index_queries.append(f"CREATE INDEX IF NOT EXISTS `modified` ON `{self.table_name}` (`modified`)")
for query in index_queries:
frappe.db.sql_ddl(query)

View file

@ -0,0 +1,73 @@
import json
def extract(fileobj, *args, **kwargs):
"""
Extract messages from Web Form JSON files. To be used to babel extractor
:param fileobj: the file-like object the messages should be extracted from
:rtype: `iterator`
"""
data = json.load(fileobj)
if isinstance(data, list):
return
if data.get("doctype") != "Web Form":
return
web_form_name = data.get("name")
# Extract main web form fields
if title := data.get("title"):
yield None, "_", title, [f"Title of the {web_form_name} Web Form"]
if introduction_text := data.get("introduction_text"):
yield None, "_", introduction_text, [f"Introduction text of the {web_form_name} Web Form"]
if success_message := data.get("success_message"):
yield None, "_", success_message, [f"Success message of the {web_form_name} Web Form"]
if success_title := data.get("success_title"):
yield None, "_", success_title, [f"Success title of the {web_form_name} Web Form"]
if list_title := data.get("list_title"):
yield None, "_", list_title, [f"List title of the {web_form_name} Web Form"]
if button_label := data.get("button_label"):
yield None, "_", button_label, [f"Button label of the {web_form_name} Web Form"]
if meta_title := data.get("meta_title"):
yield None, "_", meta_title, [f"Meta title of the {web_form_name} Web Form"]
if meta_description := data.get("meta_description"):
yield None, "_", meta_description, [f"Meta description of the {web_form_name} Web Form"]
# Extract web form fields
for field in data.get("web_form_fields", []):
if label := field.get("label"):
yield None, "_", label, [f"Label of a field in the {web_form_name} Web Form"]
if description := field.get("description"):
yield None, "_", description, [f"Description of a field in the {web_form_name} Web Form"]
# Extract options for Select fields
if field.get("fieldtype") == "Select" and (options := field.get("options")):
skip_options = (
web_form_name == "edit-profile" and field.get("fieldname") == "time_zone"
) # Dumb workaround for avoiding a flood of strings from this field
if isinstance(options, str) and not skip_options:
# Handle both single values and newline-separated values
option_list = options.split("\n") if "\n" in options else [options]
for option in option_list:
if option.strip():
yield (
None,
"_",
option.strip(),
[f"Option in a Select field in the {web_form_name} Web Form"],
)
# Extract list columns
for column in data.get("list_columns", []):
if isinstance(column, dict) and (label := column.get("label")):
yield None, "_", label, [f"Label of a list column in the {web_form_name} Web Form"]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

31819
frappe/locale/ta.po Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1127,7 +1127,12 @@ from {tables}
r"select\b.*\bfrom",
}
if any(re.search(r"\b" + pattern + r"\b", _lower) for pattern in subquery_indicators):
# Replace doctype names with a hardcoded string "doc"
# This is to avoid false positives based on doctype name
sanitized = re.sub(r"`tab[^`]*`", " doc ", _lower)
# Run the subquery checks against the sanitized string
if any(re.search(r"\b" + pattern + r"\b", sanitized) for pattern in subquery_indicators):
frappe.throw(_("Cannot use sub-query here."))
blacklisted_sql_functions = {

View file

@ -217,7 +217,7 @@ def rename_doc(
new_doc.add_comment("Edit", _("renamed from {0} to {1}").format(frappe.bold(old), frappe.bold(new)))
if merge:
frappe.delete_doc(doctype, old)
frappe.delete_doc(doctype, old, ignore_permissions=ignore_permissions)
new_doc.clear_cache()
frappe.clear_cache()

View file

@ -7,17 +7,13 @@ frappe.ui.form.ControlDynamicLink = class ControlDynamicLink extends frappe.ui.f
//for dialog box
options = cur_dialog.get_value(this.df.options);
} else if (!cur_frm) {
const selector = `input[data-fieldname="${this.df.options}"]`;
let input = null;
if (cur_list) {
// for list page
input = cur_list.filter_area.standard_filters_wrapper.find(selector);
}
if (cur_page) {
input = $(cur_page.page).find(selector);
}
if (input) {
options = input.val();
options = cur_list.page.fields_dict[this.df.options].get_input_value();
} else if (cur_page) {
const selector = `input[data-fieldname="${this.df.options}"]`;
let input = $(cur_page.page).find(selector);
options = input.length ? input.val() : null;
}
} else {
options = frappe.model.get_value(this.df.parent, this.docname, this.df.options);

View file

@ -16,25 +16,6 @@ frappe.ui.form.ControlTable = class ControlTable extends frappe.ui.form.Control
this.frm.grids[this.frm.grids.length] = this;
}
const me = this;
this.$wrapper.on("keydown", (e) => {
if (e.which == 9) {
if (e.shiftKey) {
let row_idx = me.set_current_row(e.target);
if (row_idx) {
this.grid.grid_rows[row_idx - 1].toggle_editable_row(true);
}
} else {
if (this.grid.grid_rows.length > 0) {
this.grid.grid_rows[this.grid.grid_rows.length - 1].toggle_editable_row(
true
);
} else {
this.grid.add_new_row(null, null, true, null, true);
this.grid.grid_rows[0].toggle_editable_row(true);
}
}
}
});
this.$wrapper.on("paste", ":text", (e) => {
const table_field = this.df.fieldname;
const grid = this.grid;
@ -173,13 +154,4 @@ frappe.ui.form.ControlTable = class ControlTable extends frappe.ui.form.Control
check_all_rows() {
this.$wrapper.find(".grid-row-check")[0].click();
}
set_current_row(target) {
let current_row = null;
for (let i = 0; i < this.grid.grid_rows.length; i++) {
if (this.grid.grid_rows[i].wrapper.get(0).contains(target)) {
current_row = i + 1;
}
}
return current_row;
}
};

View file

@ -99,7 +99,7 @@ frappe.form.formatters = {
docfield.precision ||
cint(frappe.boot.sysdefaults && frappe.boot.sysdefaults.float_precision) ||
2;
return frappe.form.formatters._right(flt(value, precision) + "%", options);
return frappe.form.formatters._right(format_number(value, null, precision) + "%", options);
},
Rating: function (value, docfield) {
let rating_html = "";

View file

@ -70,7 +70,7 @@ export default class Grid {
<p class="text-muted small grid-description"></p>
<div class="grid-custom-buttons"></div>
<div class="form-grid-container">
<div class="form-grid" tabIndex="0">
<div class="form-grid">
<div class="grid-heading-row"></div>
<div class="grid-body">
<div class="rows"></div>
@ -939,6 +939,7 @@ export default class Grid {
}
setTimeout(() => {
this.grid_rows[idx].toggle_editable_row(true);
this.grid_rows[idx].row
.find('input[type="Text"],textarea,select')
.filter(":visible:first")
@ -1276,4 +1277,14 @@ export default class Grid {
this.debounced_refresh();
}
get_current_row(target) {
let current_row = null;
for (let i = 0; i < this.grid_rows.length; i++) {
if (this.grid_rows[i].wrapper.get(0).contains(target)) {
current_row = i;
}
}
return current_row;
}
}

View file

@ -1102,7 +1102,17 @@ export default class GridRow {
this.columns[df.fieldname] = $col;
this.columns_list.push($col);
if (ci == 0 && !this.header_row) {
$col.attr("tabIndex", 0);
$col.on("focus", function () {
if (me.grid.grid_rows.length == 0) {
me.grid.add_new_row();
}
me.grid.grid_rows[me.grid.grid_rows.length - 1].toggle_editable_row(true);
me.grid.set_focus_on_row();
$col.attr("tabIndex", "");
});
}
return $col;
}
@ -1200,6 +1210,8 @@ export default class GridRow {
// flag list input
if (this.columns_list && this.columns_list.slice(-1)[0] === column) {
field.$input.attr("data-last-input", 1);
} else if (this.columns_list && this.columns_list.slice(0)[0] === column) {
field.$input.attr("data-first-input", 1);
}
}
@ -1289,6 +1301,18 @@ export default class GridRow {
return false;
}
}
} else if (e.which === TAB && e.shiftKey) {
var first_column = me.wrapper
.find("input:enabled:not([type='checkbox'])")
.first()
.get(0);
var is_first_column =
$(this).attr("data-first-input") || first_column === this;
if (is_first_column) {
let ri = me.grid.get_current_row(e.target);
if (ri == 0) return;
me.grid.grid_rows[ri - 1].toggle_editable_row(true);
}
}
});
}
@ -1433,7 +1457,10 @@ export default class GridRow {
this.grid_form.wrapper.css("display", "none");
}
this.wrapper.removeClass("grid-row-open");
this.open_form_button.parent().focus();
if (this.grid.meta.editable_grid) {
this.open_form_button.parent().focus();
}
}
has_prev() {
return this.doc.idx > 1;

View file

@ -14,7 +14,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
this.is_dialog = true;
this.last_focus = null;
$.extend(this, { animate: true, size: null, auto_make: true }, opts);
$.extend(this, { animate: true, size: null, auto_make: true, centered: false }, opts);
if (this.auto_make) {
this.make();
}
@ -34,6 +34,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
if (!this.size) this.set_modal_size();
this.wrapper = this.$wrapper.find(".modal-dialog").get(0);
if (this.centered) $(this.wrapper).addClass("modal-dialog-centered");
if (this.size == "small") $(this.wrapper).addClass("modal-sm");
else if (this.size == "large") $(this.wrapper).addClass("modal-lg");
else if (this.size == "extra-large") $(this.wrapper).addClass("modal-xl");
@ -248,7 +249,10 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
show() {
// show it
this.handle_focus();
if (window.location.pathname.startsWith("/app")) {
this.handle_focus();
}
if (this.animate) {
this.$wrapper.addClass("fade");
} else {
@ -278,7 +282,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
handle_focus() {
const me = this;
if (frappe.get_route) {
if (frappe.get_route()) {
if (frappe.get_route()[0] == "Form") {
if (!me.last_focus) me.last_focus = document.activeElement;
}

View file

@ -117,6 +117,10 @@ frappe.ui.FieldSelect = class FieldSelect {
// main table
var main_table_fields = std_filters.concat(frappe.meta.docfield_list[me.doctype]);
$.each(frappe.utils.sort(main_table_fields, "label", "string"), function (i, df) {
if (df.is_virtual) {
return;
}
let doctype =
frappe.get_meta(me.doctype).istable && me.parent_doctype
? me.parent_doctype
@ -128,7 +132,7 @@ frappe.ui.FieldSelect = class FieldSelect {
// child tables
$.each(me.table_fields, function (i, table_df) {
if (table_df.options) {
if (table_df.options && !table_df.is_virtual) {
let child_table_fields = [].concat(frappe.meta.docfield_list[table_df.options]);
if (table_df.fieldtype === "Table MultiSelect") {

View file

@ -66,6 +66,7 @@ frappe.search.AwesomeBar = class AwesomeBar {
"input",
frappe.utils.debounce(function (e) {
var value = e.target.value;
value = frappe.utils.xss_sanitise(value);
var txt = value.trim().replace(/\s\s+/g, " ");
var last_space = txt.lastIndexOf(" ");
me.global_results = [];

View file

@ -213,6 +213,7 @@ frappe.views.FileView = class FileView extends frappe.views.ListView {
title = d.file_name || d.file_url;
}
title = frappe.utils.escape_html(title);
title = title.slice(0, 60);
d._title = title;
d.icon_class = icon_class;

View file

@ -355,7 +355,7 @@ frappe.views.TreeView = class TreeView {
var node = me.tree.get_selected_node();
if (!(node && node.expandable)) {
frappe.msgprint(__("Select a group node first."));
frappe.msgprint(__("Select a group {0} first.", [__(me.doctype)]));
return;
}
@ -415,8 +415,10 @@ frappe.views.TreeView = class TreeView {
{
fieldtype: "Check",
fieldname: "is_group",
label: __("Group Node"),
description: __("Further nodes can be only created under 'Group' type nodes"),
label: __("Is Group"),
description: __(
"Further sub-groups can only be created under records marked as 'Group'"
),
},
];
@ -483,7 +485,7 @@ frappe.views.TreeView = class TreeView {
{
label: __("View List"),
action: function () {
frappe.set_route("List", me.doctype);
frappe.set_route(["List", me.doctype, "List"]);
},
},
{

View file

@ -103,6 +103,7 @@ export default class WebFormList {
label: df.label,
fieldname: df.fieldname,
fieldtype: df.fieldtype,
options: df.options,
};
});
}

View file

@ -8,9 +8,6 @@
color: var(--text-color);
min-height: 150px;
background-color: var(--subtle-accent);
&:focus-visible {
@include grid-focus();
}
}
.form-grid.error {
@ -165,7 +162,9 @@
display: flex;
vertical-align: middle;
}
.grid-static-col:focus-visible {
@include grid-focus();
}
.grid-static-col,
.row-index {
// height: 38px;

View file

@ -345,3 +345,13 @@ body.modal-open[style^="padding-right"] {
}
}
}
.modal-dialog-centered {
display: flex;
align-items: center;
min-height: calc(100% - 1rem);
}
@media (min-width: 576px) {
.modal-dialog-centered {
min-height: calc(100% - 3.5rem);
}
}

View file

@ -49,10 +49,6 @@
align-items: unset;
}
.input-area {
margin-top: 0.2rem;
}
.label-area {
white-space: unset;
}

View file

@ -28,6 +28,7 @@ table.user-perm {
margin-bottom: var(--margin-sm);
label {
position: relative;
align-items: center;
}
input[type="checkbox"] {
margin-left: 0;

View file

@ -176,6 +176,18 @@ class TestDBUpdate(IntegrationTestCase):
self.assertEqual(frappe.db.get_column_type(referring_doctype.name, link), "uuid")
@run_only_if(db_type_is.MARIADB)
def test_varchar_length(self):
from frappe.database.schema import add_column
test_doc = new_doctype().insert()
col_name = f"col_{frappe.generate_hash(length=4)}"
add_column(test_doc.name, fieldtype="Data", column_name=col_name, length=50)
length = frappe.db.sql(
f"SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'tab{test_doc.name}' AND COLUMN_NAME = '{col_name}' ",
)[0][0]
self.assertEqual(length, 64)
class TestDBUpdateSanityChecks(IntegrationTestCase):
@run_only_if(db_type_is.MARIADB)

View file

@ -1610,6 +1610,19 @@ class TestQuery(IntegrationTestCase):
frappe.qb.get_query("User", fields=[{"DROP": "TABLE users"}]).get_sql()
self.assertIn("Unsupported function or invalid field name: DROP", str(cm.exception))
def test_not_equal_condition_on_none(self):
self.assertEqual(
frappe.qb.get_query(
"DocType",
["*"],
[
["DocField", "name", "=", None],
["DocType", "parent", "!=", None],
],
).get_sql(),
"SELECT `tabDocType`.* FROM `tabDocType` LEFT JOIN `tabDocField` ON `tabDocField`.`parent`=`tabDocType`.`name` AND `tabDocField`.`parenttype`='DocType' AND `tabDocField`.`parentfield`='fields' WHERE `tabDocField`.`name` IS NULL AND `tabDocType`.`parent` IS NOT NULL",
)
# This function is used as a permission query condition hook
def test_permission_hook_condition(user):

View file

@ -180,6 +180,10 @@ class TestWebsite(IntegrationTestCase):
"route_redirects",
{"source": "/testdoc307", "target": "/testtarget", "redirect_http_status": 307},
)
website_settings.append(
"route_redirects",
{"source": "/test-query", "target": "/test-query-new", "forward_query_parameters": 1},
)
website_settings.save()
set_request(method="GET", path="/testfrom")
@ -226,6 +230,11 @@ class TestWebsite(IntegrationTestCase):
self.assertEqual(response.status_code, 307)
self.assertEqual(response.headers.get("Location"), "/test")
set_request(method="GET", path="/test-query?param=123")
response = get_response()
self.assertEqual(response.status_code, 301)
self.assertEqual(response.headers.get("Location"), "/test-query-new?param=123")
delattr(frappe.hooks, "website_redirects")
frappe.client_cache.delete_value("app_hooks")

124
frappe/utils/inplacevar.py Normal file
View file

@ -0,0 +1,124 @@
#############################################################################
#
# Copyright (c) 2002 Zope Foundation and Contributors.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
# FOR A PARTICULAR PURPOSE
#
##############################################################################
# Extracted from AccessControl.ZopeGuards
# https://github.com/zopefoundation/AccessControl
valid_inplace_types = (list, set)
inplace_slots = {
"+=": "__iadd__",
"-=": "__isub__",
"*=": "__imul__",
"/=": ((1 / 2 == 0) and "__idiv__") or "__itruediv__",
"//=": "__ifloordiv__",
"%=": "__imod__",
"**=": "__ipow__",
"<<=": "__ilshift__",
">>=": "__irshift__",
"&=": "__iand__",
"^=": "__ixor__",
"|=": "__ior__",
}
def __iadd__(x, y):
x += y
return x
def __isub__(x, y):
x -= y
return x
def __imul__(x, y):
x *= y
return x
def __idiv__(x, y):
x /= y
return x
def __ifloordiv__(x, y):
x //= y
return x
def __imod__(x, y):
x %= y
return x
def __ipow__(x, y):
x **= y
return x
def __ilshift__(x, y):
x <<= y
return x
def __irshift__(x, y):
x >>= y
return x
def __iand__(x, y):
x &= y
return x
def __ixor__(x, y):
x ^= y
return x
def __ior__(x, y):
x |= y
return x
inplace_ops = {
"+=": __iadd__,
"-=": __isub__,
"*=": __imul__,
"/=": __idiv__,
"//=": __ifloordiv__,
"%=": __imod__,
"**=": __ipow__,
"<<=": __ilshift__,
">>=": __irshift__,
"&=": __iand__,
"^=": __ixor__,
"|=": __ior__,
}
def protected_inplacevar(op, var, expr):
"""Do an inplace operation
If the var has an inplace slot, then disallow the operation
unless the var an instance of ``valid_inplace_types``.
"""
if hasattr(var, inplace_slots[op]) and not isinstance(var, valid_inplace_types):
try:
cls = var.__class__
except AttributeError:
cls = type(var)
raise TypeError("Augmented assignment to %s objects is not allowed in untrusted code" % cls.__name__)
return inplace_ops[op](var, expr)

View file

@ -89,7 +89,6 @@ def install_basic_docs():
"thread_notify": 0,
"send_me_a_copy": 0,
},
{"doctype": "Role", "role_name": "Translator"},
{
"doctype": "Workflow State",
"workflow_state_name": "Pending",

View file

@ -13,7 +13,6 @@ from typing import TYPE_CHECKING, Any
import orjson
import RestrictedPython.Guards
from AccessControl.ZopeGuards import protected_inplacevar
from RestrictedPython import PrintCollector, compile_restricted, safe_globals
from RestrictedPython.transformer import RestrictingNodeTransformer
@ -33,6 +32,7 @@ from frappe.model.rename_doc import rename_doc
from frappe.modules import scrub
from frappe.utils.background_jobs import enqueue, get_jobs
from frappe.utils.caching import site_cache
from frappe.utils.inplacevar import protected_inplacevar
from frappe.utils.number_format import NumberFormat
from frappe.utils.response import json_handler
from frappe.website.utils import get_next_link, get_toc

View file

@ -6,6 +6,7 @@
"document_type": "Other",
"engine": "InnoDB",
"field_order": [
"is_disabled",
"page_title",
"company_introduction",
"sb0",
@ -75,6 +76,12 @@
"fieldname": "team_members_subtitle",
"fieldtype": "Small Text",
"label": "Team Members Subtitle"
},
{
"default": "0",
"fieldname": "is_disabled",
"fieldtype": "Check",
"label": "Disabled"
}
],
"icon": "fa fa-group",
@ -82,7 +89,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2024-03-23 16:01:26.370657",
"modified": "2025-08-22 15:56:33.957707",
"modified_by": "Administrator",
"module": "Website",
"name": "About Us Settings",
@ -98,6 +105,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],

View file

@ -22,6 +22,7 @@ class AboutUsSettings(Document):
company_history_heading: DF.Data | None
company_introduction: DF.TextEditor | None
footer: DF.TextEditor | None
is_disabled: DF.Check
page_title: DF.Data | None
team_members: DF.Table[AboutUsTeamMember]
team_members_heading: DF.Data | None

View file

@ -5,8 +5,8 @@
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"disable_contact_us",
"introduction_section",
"is_disabled",
"forward_to_email",
"heading",
"introduction",
@ -56,7 +56,6 @@
"label": "Query Options"
},
{
"collapsible": 1,
"fieldname": "address",
"fieldtype": "Section Break",
"label": "Address"
@ -120,16 +119,16 @@
},
{
"default": "0",
"fieldname": "disable_contact_us",
"fieldname": "is_disabled",
"fieldtype": "Check",
"label": "Disable Contact Us Page"
"label": "Disabled"
}
],
"icon": "fa fa-cog",
"idx": 1,
"issingle": 1,
"links": [],
"modified": "2025-05-15 07:19:31.401053",
"modified": "2025-08-22 12:59:39.463182",
"modified_by": "Administrator",
"module": "Website",
"name": "Contact Us Settings",
@ -145,6 +144,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],

View file

@ -26,6 +26,7 @@ class ContactUsSettings(Document):
forward_to_email: DF.Data | None
heading: DF.Data | None
introduction: DF.TextEditor | None
is_disabled: DF.Check
phone: DF.Data | None
pincode: DF.Data | None
query_options: DF.SmallText | None

View file

@ -301,6 +301,7 @@ frappe.ui.form.on("Web Form List Column", {
if (!df) return;
doc.fieldtype = df.fieldtype;
doc.label = df.label;
doc.options = df.options;
frm.refresh_field("list_columns");
},
});

View file

@ -274,53 +274,69 @@ def get_context(context):
def load_translations(self, context):
messages = [
"Sr",
"Attach",
"Next",
"Previous",
"Discard?",
"Cancel",
"Discard:Button in web form",
"Edit:Button in web form",
"See previous responses::Button in web form",
"Edit your response::Button in web form",
"{0} if you are not redirected within {1} seconds",
"← Back to upload files",
"Are you sure you want to delete this record?",
"Are you sure you want to discard the changes?",
"Mandatory fields required::Error message in web form",
"Invalid values for fields::Error message in web form",
"Error:Title of error message in web form",
"Page {0} of {1}",
"Couldn't save, please check the data you have entered",
"Validation Error",
"No {0} found",
"Create a new {0}",
"Attach a web link",
"Attach",
"Attachments",
"Camera",
"Delete",
"Cancel",
"Capture",
"Click here",
"Comments",
("Confirm", "Title of confirmation dialog"),
"Couldn't save, please check the data you have entered",
"Create a new {0}",
("Delete", "Button in web form"),
"Deleted!",
("Discard", "Button in web form"),
"Discard?",
"Drag and drop files here or upload from",
"Following fields have missing values::Error message in web form",
"Drop files here",
("Edit your response", "Button in web form"),
("Edit", "Button in web form"),
("Error", "Title of error message in web form"),
"Following fields have missing values:",
("Invalid values for fields", "Error message in web form"),
"Link",
"Link",
"Load More",
"Message",
"Missing Values Required:Error message in web form",
"Missing Values Required",
"My Device",
"New",
"Next",
"No {0} found",
"No comments yet.",
"No Images",
"No more items to display",
("No", "Dismiss confirmation dialog"),
"Not Saved",
"Optimize",
"Page {0} of {1}",
"Preview",
"Previous",
"Private",
"Public",
("See previous responses", "Button in web form"),
"Set all private",
"Set all public",
"Sr",
"Start a new discussion",
"Upload",
"Link",
"Public",
"Private",
"Optimize",
"Drop files here",
("Submit another response", "Button in web form"),
("Submit", "Button in web form"),
"Submitted",
"Take Photo",
"No Images",
"Thank you for spending your valuable time to fill this form",
"Total Images",
"Preview",
"Submit",
"Capture",
"Attach a web link",
"← Back to upload files",
"Updated",
"Upload",
"Validation Error",
("View your response", "Button in web form"),
("Yes", "Approve confirmation dialog"),
"Your form has been successfully updated",
self.title,
self.introduction_text,
self.success_title,
@ -338,53 +354,64 @@ def get_context(context):
# When at least one field in self.web_form_fields has fieldtype "Table" then add "No data" to messages
if any(field.fieldtype == "Table" for field in self.web_form_fields):
messages.append("Move")
messages.append("Insert Above")
messages.append("Insert Below")
messages.append("Duplicate")
messages.append("Shortcuts")
messages.append("Ctrl + Up")
messages.append("Ctrl + Down")
messages.append("ESC")
messages.append("Editing Row")
messages.append("Add / Remove Columns")
messages.append("Fieldname")
messages.append("Column Width")
messages.append("Configure Columns")
messages.append("Select Fields")
messages.append("Select All")
messages.append("Update")
messages.append("Reset to default")
messages.append("No Data")
messages.append("Delete")
messages.append("Delete All")
messages.append("Add Row")
messages.append("Add Multiple")
messages.append("Download")
messages.append("of")
messages.append("Upload")
messages.append("Last")
messages.append("First")
messages.append("No.")
messages.extend(
(
"Move",
"Insert Above",
"Insert Below",
"Duplicate",
"Shortcuts",
"Ctrl + Up",
"Ctrl + Down",
"ESC",
"Editing Row",
"Add / Remove Columns",
"Fieldname",
"Column Width",
"Configure Columns",
"Select Fields",
"Select All",
"Update",
"Reset to default",
"No Data",
"Delete",
"Delete All",
"Add Row",
"Add Multiple",
"Download",
"of",
"Upload",
"Last",
"First",
"No.",
)
)
# Phone Picker
if any(field.fieldtype == "Phone" for field in self.web_form_fields):
messages.append("Search for countries...")
# Dates
if any(field.fieldtype == "Date" for field in self.web_form_fields):
messages.append("Now")
messages.append("Today")
messages.append("Date {0} must be in format: {1}")
messages.append("{0} to {1}")
messages.extend(("Now", "Today", "Date {0} must be in format: {1}", "{0} to {1}"))
# Time
if any(field.fieldtype == "Time" for field in self.web_form_fields):
messages.append("Now")
messages.extend(col.get("label") if col else "" for col in self.list_columns)
context.translated_messages = frappe.as_json({message: _(message) for message in messages if message})
translation_dict = {}
for key in messages:
if not key:
continue
if isinstance(key, tuple):
msg, ctx = key
# Use the original tuple as the key for backward compatibility
translation_dict[f"{msg}:{ctx}"] = _(msg, context=ctx)
else:
translation_dict[key] = _(key)
context.translated_messages = frappe.as_json(translation_dict)
def load_list_data(self, context):
if not self.list_columns:

View file

@ -8,7 +8,8 @@
"field_order": [
"fieldname",
"fieldtype",
"label"
"label",
"options"
],
"fields": [
{
@ -30,19 +31,26 @@
"in_list_view": 1,
"label": "Fieldtype",
"read_only": 1
},
{
"fieldname": "options",
"fieldtype": "Text",
"in_list_view": 1,
"label": "Options"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-23 16:04:02.310851",
"modified": "2025-09-24 22:28:54.931089",
"modified_by": "Administrator",
"module": "Website",
"name": "Web Form List Column",
"naming_rule": "Autoincrement",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View file

@ -18,6 +18,7 @@ class WebFormListColumn(Document):
fieldtype: DF.Data | None
label: DF.Data | None
name: DF.Int | None
options: DF.Text | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View file

@ -4,8 +4,10 @@
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"column_break_mzuh",
"source",
"target",
"forward_query_parameters",
"redirect_http_status"
],
"fields": [
@ -29,18 +31,30 @@
"fieldtype": "Select",
"label": "Redirect HTTP Status",
"options": "301\n302\n307\n308"
},
{
"fieldname": "column_break_mzuh",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "forward_query_parameters",
"fieldtype": "Check",
"label": "Forward Query Parameters"
}
],
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2024-03-23 16:04:03.818023",
"modified": "2025-10-06 11:45:35.866316",
"modified_by": "Administrator",
"module": "Website",
"name": "Website Route Redirect",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}
}

View file

@ -14,6 +14,7 @@ class WebsiteRouteRedirect(Document):
if TYPE_CHECKING:
from frappe.types import DF
forward_query_parameters: DF.Check
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View file

@ -115,11 +115,21 @@ def resolve_redirect(path, query_string=None):
]
"""
def raise_redirect(redirect_location, status_code=301, forward_query_params=False):
if forward_query_params and query_string:
separator = "&" if "?" in redirect_location else "?"
redirect_location += separator + frappe.safe_decode(query_string)
frappe.flags.redirect_location = redirect_location
raise frappe.Redirect(status_code)
redirect_to = frappe.cache.hget("website_redirects", path or "/")
if redirect_to:
if isinstance(redirect_to, dict):
frappe.flags.redirect_location = redirect_to["path"]
raise frappe.Redirect(redirect_to["status_code"])
raise_redirect(
redirect_to["path"],
redirect_to.get("status_code", 301),
redirect_to.get("forward_query_parameters", False),
)
frappe.flags.redirect_location = redirect_to
raise frappe.Redirect
@ -128,7 +138,12 @@ def resolve_redirect(path, query_string=None):
redirects = frappe.get_hooks("website_redirects")
redirects += [
{"source": r.source, "target": r.target, "redirect_http_status": r.redirect_http_status}
{
"source": r.source,
"target": r.target,
"redirect_http_status": r.redirect_http_status,
"forward_query_parameters": r.get("forward_query_parameters"),
}
for r in (frappe.get_website_settings("route_redirects") or [])
]
@ -148,12 +163,19 @@ def resolve_redirect(path, query_string=None):
if match:
redirect_to = re.sub(pattern, rule["target"], path_to_match)
frappe.flags.redirect_location = redirect_to
status_code = rule.get("redirect_http_status") or 301
frappe.cache.hset(
"website_redirects", path_to_match or "/", {"path": redirect_to, "status_code": status_code}
"website_redirects",
path_to_match or "/",
{
"path": redirect_to,
"status_code": status_code,
"forward_query_parameters": rule.get("forward_query_parameters"),
},
)
raise frappe.Redirect(status_code)
raise_redirect(redirect_to, status_code, rule.get("forward_query_parameters"))
frappe.cache.hset("website_redirects", path_to_match or "/", False)

View file

@ -16,7 +16,7 @@ from frappe.model.workflow import (
send_email_alert,
)
from frappe.query_builder import DocType
from frappe.utils import get_datetime, get_url
from frappe.utils import get_datetime, get_url, now_datetime
from frappe.utils.background_jobs import enqueue
from frappe.utils.data import get_link_to_form
from frappe.utils.user import get_users_with_role
@ -264,6 +264,8 @@ def update_completed_workflow_actions_using_role(user=None, workflow_action=None
.set(WorkflowAction.status, "Completed")
.set(WorkflowAction.completed_by, user)
.set(WorkflowAction.completed_by_role, workflow_action[0].role)
.set(WorkflowAction.modified, now_datetime())
.set(WorkflowAction.modified_by, user)
.where(WorkflowAction.name == workflow_action[0].name)
).run()

View file

@ -8,5 +8,7 @@ sitemap = 1
def get_context(context):
context.doc = frappe.get_cached_doc("About Us Settings")
if context.doc.is_disabled:
frappe.local.flags.redirect_location = "/404"
raise frappe.Redirect
return context

View file

@ -13,6 +13,9 @@ sitemap = 1
def get_context(context):
doc = frappe.get_doc("Contact Us Settings", "Contact Us Settings")
if doc.is_disabled:
frappe.local.flags.redirect_location = "/404"
raise frappe.Redirect
if doc.query_options:
query_options = [opt.strip() for opt in doc.query_options.replace(",", "\n").split("\n") if opt]
@ -28,6 +31,10 @@ def get_context(context):
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=1000, seconds=60 * 60)
def send_message(sender, message, subject="Website Query"):
doc = frappe.get_doc("Contact Us Settings", "Contact Us Settings")
if doc.is_disabled:
return
sender = validate_email_address(sender, throw=True)
message = escape_html(message)

View file

@ -26,7 +26,6 @@ dependencies = [
"PyQRCode~=1.2.1",
"PyYAML~=6.0.2",
"RestrictedPython~=8.0",
"AccessControl~=7.2",
"WeasyPrint==66.0",
"pydyf==0.11.0",
"Werkzeug==3.1.3",

View file

@ -16,8 +16,12 @@ function authenticate_with_frappe(socket, next) {
return;
}
if (!socket.request.headers.cookie) {
next(new Error("No cookie transmitted."));
if (!socket.request.headers.cookie && !socket.request.headers.authorization) {
next(
new Error(
"Missing cookie and authorization header. Either one needed for authentication."
)
);
return;
}