Merge branch 'frappe:develop' into feat/report-view-bulk-column-select

This commit is contained in:
UmakanthKaspa 2025-10-12 10:49:08 +05:30 committed by GitHub
commit 0b57eaec71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
168 changed files with 79473 additions and 11425 deletions

View file

@ -61,3 +61,6 @@ e9bbe03354079cfcef65a77b0c33f57b047a7c93
# replace `frappe.flags.in_test` with `frappe.in_test`
653c80b8483cc41aef25cd7d66b9b6bb188bf5f8
# another ruff update
6ca4d4d167a1a009d99062747711de7a994aa633

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
@ -21,7 +22,7 @@ repos:
exclude: ^frappe/tests/classes/context_managers\.py$
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.1
rev: v0.13.2
hooks:
- id: ruff
name: "Run ruff import sorter"
@ -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

@ -321,7 +321,10 @@ def set_authenticate_headers(response: Response):
def make_form_dict(request: Request):
request_data = request.get_data(as_text=True)
if request_data and request.is_json:
args = orjson.loads(request_data)
try:
args = orjson.loads(request_data)
except orjson.JSONDecodeError:
frappe.throw(_("Invalid request body"), frappe.DataError)
else:
args = {}
args.update(request.args or {})

View file

@ -66,6 +66,9 @@ class HTTPRequest:
elif frappe.get_request_header("REMOTE_ADDR"):
frappe.local.request_ip = frappe.get_request_header("REMOTE_ADDR")
elif frappe.request and getattr(frappe.request, "remote_addr", None):
frappe.local.request_ip = frappe.request.remote_addr
else:
frappe.local.request_ip = "127.0.0.1"
@ -666,7 +669,7 @@ def validate_oauth(authorization_header):
required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split(
get_url_delimiter()
)
valid, oauthlib_request = get_oauth_server().verify_request(
valid, _oauthlib_request = get_oauth_server().verify_request(
uri, http_method, body, headers, required_scopes
)
if valid:

View file

@ -147,17 +147,26 @@ def _clear_doctype_cache_from_redis(doctype: str | None = None):
clear_single(doctype)
# clear all parent doctypes
for dt in frappe.get_all(
"DocField", "parent", dict(fieldtype=["in", frappe.model.table_fields], options=doctype)
):
clear_single(dt.parent)
# clear all parent doctypes
if not frappe.flags.in_install:
try:
for dt in frappe.get_all(
"Custom Field", "dt", dict(fieldtype=["in", frappe.model.table_fields], options=doctype)
"DocField",
"parent",
dict(fieldtype=["in", frappe.model.table_fields], options=doctype),
ignore_ddl=True,
):
clear_single(dt.dt)
clear_single(dt.parent)
# clear all parent doctypes
if not frappe.flags.in_install:
for dt in frappe.get_all(
"Custom Field",
"dt",
dict(fieldtype=["in", frappe.model.table_fields], options=doctype),
ignore_ddl=True,
):
clear_single(dt.dt)
except frappe.DoesNotExistError:
pass # core doctypes getting migrated.
# clear all notifications
delete_notification_count_for(doctype)

View file

@ -61,7 +61,7 @@ def create_rq_users(set_admin_password=False, use_rq_auth=False):
)
click.secho(f"`export {env_key}={user_credentials['default'][1]}`")
click.secho(
"NOTE: Please save the admin password as you " "can not access redis server without the password",
"NOTE: Please save the admin password as you can not access redis server without the password",
fg="yellow",
)

View file

@ -336,7 +336,7 @@ def restore_backup(
# Check if the backup is of an older version of frappe and the user hasn't specified force
if is_downgrade(sql_file_path, verbose=True) and not force:
warn_message = (
"This is not recommended and may lead to unexpected behaviour. " "Do you want to continue anyway?"
"This is not recommended and may lead to unexpected behaviour. Do you want to continue anyway?"
)
click.confirm(warn_message, abort=True)

View file

@ -297,7 +297,7 @@ class TestCommands(BaseTestCommands):
self.execute("bench --site {test_site} backup --exclude 'ToDo'", site_data)
site_data.update({"kw": "\"{'partial':True}\""})
self.execute(
"bench --site {test_site} execute" " frappe.utils.backups.fetch_latest_backups --kwargs {kw}",
"bench --site {test_site} execute frappe.utils.backups.fetch_latest_backups --kwargs {kw}",
site_data,
)
site_data.update({"database": json.loads(self.stdout)["database"]})

View file

@ -435,8 +435,7 @@ def import_doc(context: CliCtxObj, path, force=False):
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
required=True,
help=(
"Path to import file (.csv, .xlsx)."
"Consider that relative paths will resolve from 'sites' directory"
"Path to import file (.csv, .xlsx). Consider that relative paths will resolve from 'sites' directory"
),
)
@click.option("--doctype", type=str, required=True)

View file

@ -25,7 +25,7 @@ class TestAuditTrail(IntegrationTestCase):
re_amended_doc = amend_document(amended_doc, changed_fields, {}, 1)
comparator = create_comparator_doc("Test Custom Doctype for Doc Comparator", re_amended_doc.name)
documents, results = comparator.compare_document()
_documents, results = comparator.compare_document()
test_field_values = results["changed"]["Field"]
self.check_expected_values(test_field_values, ["first value", "second value", "third value"])
@ -41,7 +41,7 @@ class TestAuditTrail(IntegrationTestCase):
amended_doc = amend_document(doc, {}, rows_updated, 1)
comparator = create_comparator_doc("Test Custom Doctype for Doc Comparator", amended_doc.name)
documents, results = comparator.compare_document()
_documents, results = comparator.compare_document()
results = frappe._dict(results)
self.check_rows_updated(results.row_changed)

View file

@ -565,11 +565,11 @@ def parse_email(email_strings):
for email in email_string.split(","):
local_part = email.split("@", 1)[0].strip('"')
user, detail = None, None
_user, detail = None, None
if "+" in local_part:
user, detail = local_part.split("+", 1)
_user, detail = local_part.split("+", 1)
elif "--" in local_part:
detail, user = local_part.rsplit("--", 1)
detail, _user = local_part.rsplit("--", 1)
if not detail:
continue

View file

@ -23,6 +23,7 @@
"submit",
"cancel",
"amend",
"mask",
"additional_permissions",
"report",
"export",
@ -153,6 +154,16 @@
"print_width": "32px",
"width": "32px"
},
{
"default": "0",
"fieldname": "mask",
"fieldtype": "Check",
"label": "Mask",
"oldfieldname": "mask",
"oldfieldtype": "Check",
"print_width": "32px",
"width": "32px"
},
{
"fieldname": "additional_permissions",
"fieldtype": "Section Break",
@ -214,11 +225,13 @@
"label": "Select"
}
],
"grid_page_length": 50,
"links": [],
"modified": "2024-03-23 16:02:14.726078",
"modified": "2025-05-22 16:59:35.484376",
"modified_by": "Administrator",
"module": "Core",
"name": "Custom DocPerm",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
@ -235,8 +248,9 @@
}
],
"read_only": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": [],
"title_field": "parent"
}
}

View file

@ -21,6 +21,7 @@ class CustomDocPerm(Document):
email: DF.Check
export: DF.Check
if_owner: DF.Check
mask: DF.Check
parent: DF.Data | None
permlevel: DF.Int
print: DF.Check

View file

@ -20,6 +20,7 @@
"is_virtual",
"search_index",
"not_nullable",
"mask",
"column_break_18",
"options",
"sort_options",
@ -607,6 +608,13 @@
"fieldname": "sticky",
"fieldtype": "Check",
"label": "Sticky"
},
{
"default": "0",
"depends_on": "eval:[\"Select\", \"Read Only\", \"Phone\", \"Percent\", \"Password\", \"Link\", \"Int\", \"Float\", \"Dynamic Link\", \"Duration\", \"Datetime\", \"Currency\", \"Data\", \"Date\"].includes(doc.fieldtype)",
"fieldname": "mask",
"fieldtype": "Check",
"label": "Mask"
}
],
"grid_page_length": 50,

View file

@ -90,6 +90,7 @@ class DocField(Document):
link_filters: DF.JSON | None
make_attachment_public: DF.Check
mandatory_depends_on: DF.Code | None
mask: DF.Check
max_height: DF.Data | None
no_copy: DF.Check
non_negative: DF.Check

View file

@ -22,6 +22,7 @@
"submit",
"cancel",
"amend",
"mask",
"additional_permissions",
"report",
"export",
@ -205,18 +206,27 @@
"fieldtype": "Check",
"in_list_view": 1,
"label": "Select"
},
{
"default": "0",
"fieldname": "mask",
"fieldtype": "Check",
"label": "Mask"
}
],
"grid_page_length": 50,
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-23 16:02:18.443496",
"modified": "2025-05-20 16:50:32.679113",
"modified_by": "Administrator",
"module": "Core",
"name": "DocPerm",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}
}

View file

@ -20,6 +20,7 @@ class DocPerm(Document):
email: DF.Check
export: DF.Check
if_owner: DF.Check
mask: DF.Check
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View file

@ -702,7 +702,7 @@
"label": "Protect Attached Files"
},
{
"default": "0",
"default": "20",
"depends_on": "istable",
"fieldname": "rows_threshold_for_grid_search",
"fieldtype": "Int",
@ -792,7 +792,7 @@
"link_fieldname": "document_type"
}
],
"modified": "2025-07-19 12:23:16.296416",
"modified": "2025-09-23 06:48:13.555017",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",

View file

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

View file

@ -856,6 +856,16 @@ class TestDocType(IntegrationTestCase):
)
self.assertRaises(frappe.ValidationError, doctype.insert)
def test_delete_doc_clears_cache(self):
dt = new_doctype(
fields=[{"fieldname": "test_fdname", "fieldtype": "Data", "label": "Test Field"}],
).insert()
frappe.get_meta(dt.name)
frappe.delete_doc("DocType", dt.name, force=1, delete_permanently=False)
frappe.db.commit()
with self.assertRaises(frappe.DoesNotExistError):
frappe.get_meta(dt.name)
def new_doctype(
name: str | None = None,

View file

@ -25,6 +25,7 @@ from frappe.utils import (
get_url,
)
from frappe.utils.file_manager import is_safe_path
from frappe.utils.html_utils import escape_html
from frappe.utils.image import optimize_image, strip_exif_data
from frappe.utils.pdf import pdf_contains_js
@ -141,7 +142,6 @@ class File(Document):
self.validate_file_url()
self.validate_file_on_disk()
self.file_size = frappe.form_dict.file_size or self.file_size
self.check_content()
def validate_attachment_references(self):
if not self.attached_to_doctype:
@ -785,7 +785,7 @@ class File(Document):
def create_attachment_record(self):
icon = ' <i class="fa fa-lock text-warning"></i>' if self.is_private else ""
file_url = quote(frappe.safe_encode(self.file_url), safe="/:") if self.file_url else self.file_name
file_name = self.file_name or self.file_url
file_name = escape_html(self.file_name or self.file_url)
self.add_comment_in_reference_doc(
"Attachment",

View file

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

View file

@ -164,6 +164,18 @@ class SubmissionQueue(Document):
def queue_submission(doc: Document, action: str, alert: bool = True):
if existing_queue := frappe.db.get_value(
"Submission Queue", {"ref_doctype": doc.doctype, "ref_docname": doc.name, "status": "Queued"}
):
frappe.msgprint(
_(
"This document has already been queued for submission. You can track the progress over {0}."
).format(f"<a href='/app/submission-queue/{existing_queue}'><b>here</b></a>"),
indicator="orange",
alert=True,
)
return
queue = frappe.new_doc("Submission Queue")
queue.ref_doctype = doc.doctype
queue.ref_docname = doc.name

View file

@ -30,6 +30,7 @@
"apply_strict_user_permissions",
"column_break_21",
"allow_older_web_view_links",
"show_external_link_warning",
"security_tab",
"security",
"session_expiry",
@ -744,12 +745,19 @@
"fieldtype": "Int",
"label": "Max signups allowed per hour",
"non_negative": 1
},
{
"default": "Never",
"fieldname": "show_external_link_warning",
"fieldtype": "Select",
"label": "Show External Link Warning",
"options": "Never\nAsk\nAlways"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2025-09-03 10:52:38.096662",
"modified": "2025-09-24 16:04:02.016562",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",

View file

@ -97,6 +97,7 @@ class SystemSettings(Document):
session_expiry: DF.Data | None
setup_complete: DF.Check
show_absolute_datetime_in_timeline: DF.Check
show_external_link_warning: DF.Literal["Never", "Ask", "Always"]
store_attached_pdf_document: DF.Check
strip_exif_metadata_from_uploaded_images: DF.Check
time_format: DF.Literal["HH:mm:ss", "HH:mm"]

View file

@ -280,7 +280,7 @@ frappe.PermissionEngine = class PermissionEngine {
add_check(cell, d, fieldname, label, description = "") {
if (!label) label = toTitle(fieldname.replace(/_/g, " "));
if (d.permlevel > 0 && ["read", "write"].indexOf(fieldname) == -1) {
if (d.permlevel > 0 && ["read", "write", "mask"].indexOf(fieldname) == -1) {
return;
}
@ -331,6 +331,7 @@ frappe.PermissionEngine = class PermissionEngine {
"import",
"export",
"share",
"mask",
];
}

View file

@ -13,6 +13,7 @@
"label",
"search_fields",
"grid_page_length",
"rows_threshold_for_grid_search",
"link_filters",
"column_break_5",
"istable",
@ -43,6 +44,7 @@
"force_re_route_to_default_view",
"column_break_29",
"show_preview_popup",
"show_name_in_global_search",
"email_settings_section",
"default_email_template",
"column_break_26",
@ -422,6 +424,19 @@
"fieldname": "recipient_account_field",
"fieldtype": "Data",
"label": "Recipient Account Field"
},
{
"depends_on": "istable",
"fieldname": "rows_threshold_for_grid_search",
"fieldtype": "Int",
"label": "Rows Threshold for Grid Search",
"non_negative": 1
},
{
"default": "0",
"fieldname": "show_name_in_global_search",
"fieldtype": "Check",
"label": "Make \"name\" searchable in Global Search"
}
],
"hide_toolbar": 1,
@ -430,7 +445,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-07-19 12:23:41.564203",
"modified": "2025-09-23 07:13:52.631903",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",

View file

@ -75,9 +75,11 @@ class CustomizeForm(Document):
queue_in_background: DF.Check
quick_entry: DF.Check
recipient_account_field: DF.Data | None
rows_threshold_for_grid_search: DF.Int
search_fields: DF.Data | None
sender_field: DF.Data | None
sender_name_field: DF.Data | None
show_name_in_global_search: DF.Check
show_preview_popup: DF.Check
show_title_field_in_link: DF.Check
sort_field: DF.Literal[None]
@ -306,6 +308,8 @@ class CustomizeForm(Document):
)
def set_property_setters_for_doctype(self, meta):
if self.get("show_name_in_global_search") != meta.get("show_name_in_global_search"):
self.flags.rebuild_doctype_for_global_search = True
for prop, prop_type in doctype_properties.items():
if self.get(prop) != meta.get(prop):
self.make_property_setter(prop, self.get(prop), prop_type)
@ -735,6 +739,7 @@ doctype_properties = {
"track_views": "Check",
"allow_auto_repeat": "Check",
"allow_import": "Check",
"show_name_in_global_search": "Check",
"show_preview_popup": "Check",
"default_email_template": "Data",
"email_append_to": "Check",
@ -748,6 +753,7 @@ doctype_properties = {
"force_re_route_to_default_view": "Check",
"translated_doctype": "Check",
"grid_page_length": "Int",
"rows_threshold_for_grid_search": "Int",
}
docfield_properties = {

View file

@ -1192,6 +1192,7 @@ class Database:
self.sql("commit")
self.begin()
self.value_cache.clear()
self.after_commit.run()
def rollback(self, *, save_point=None, chain=False):
@ -1206,10 +1207,12 @@ class Database:
if chain:
self.sql("rollback and chain")
self.value_cache.clear()
else:
self.sql("rollback")
self.begin()
self.value_cache.clear()
self.after_rollback.run()
else:
warnings.warn(message=TRANSACTION_DISABLED_MSG, stacklevel=2)

View file

@ -147,7 +147,7 @@ class PostgresTable(DBTable):
if isinstance(default, str):
default = frappe.db.escape(default)
change_nullability.append(
f"ALTER COLUMN \"{col.fieldname}\" {'SET' if col.not_nullable else 'DROP'} NOT NULL"
f'ALTER COLUMN "{col.fieldname}" {"SET" if col.not_nullable else "DROP"} NOT NULL'
)
change_nullability.append(f'ALTER COLUMN "{col.fieldname}" SET DEFAULT {default}')

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 cint(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

@ -899,7 +899,7 @@ def tests_utils_get_dependencies(doctype):
import frappe
from frappe.tests.utils.generators import get_modules
module, test_module = get_modules(doctype)
_module, test_module = get_modules(doctype)
meta = frappe.get_meta(doctype)
link_fields = meta.get_link_fields()

View file

@ -622,8 +622,7 @@ def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False):
"DocField", fields=["parent", "options"], filters=child_filters, as_list=1
):
ret[parent] = {"child_doctype": options, "fieldname": links_dict[options]}
if options in ret:
del ret[options]
ret.pop(options, None)
virtual_doctypes = frappe.get_all("DocType", {"is_virtual": 1}, pluck="name")
for dt in virtual_doctypes:

View file

@ -76,6 +76,9 @@ class FormMeta(Meta):
for k in ASSET_KEYS:
d[k] = __dict.get(k)
# add masked fields (per-user, per-meta)
d["masked_fields"] = [df.fieldname for df in self.get_masked_fields()]
return d
def add_code(self):

View file

@ -83,7 +83,7 @@ def get_group_by_count(doctype: str, current_filters: str, field: str) -> list[d
break
if owner_idx:
data = [data.pop(owner_idx)] + data[0:49]
data = [data.pop(owner_idx), *data[0:49]]
else:
data = data[0:50]
else:

View file

@ -1,7 +1,7 @@
<!-- jinja -->
<div class="row download-backups">
<div class="row download-backups py-4 px-4 m-0">
{% for f in files %}
<div class="col-lg-3 col-md-4 col-12">
<div class="col-lg-3 col-md-4 col-12 pr-4 pl-0">
<a href="{{ f[0] }}" target="_blank" rel="noopener noreferrer" class="frappe-card download-backup-card">
<div>
{{ f[1] }}

View file

@ -10,7 +10,7 @@ from frappe.utils.data import convert_utc_to_system_timezone
def get_time(path: Path):
return convert_utc_to_system_timezone(
datetime.datetime.fromtimestamp(path.stat().st_mtime, tz=datetime.UTC)
datetime.datetime.fromtimestamp(path.stat().st_mtime, tz=datetime.timezone.utc)
).strftime("%a %b %d %H:%M %Y")

View file

@ -674,9 +674,20 @@ def get_filtered_data(ref_doctype, columns, data, user):
shared = frappe.share.get_shared(ref_doctype, user)
columns_dict = get_columns_dict(columns)
role_permissions = get_role_permissions(frappe.get_meta(ref_doctype), user)
ref_doctype_meta = frappe.get_meta(ref_doctype)
role_permissions = get_role_permissions(ref_doctype_meta, user)
if_owner = role_permissions.get("if_owner", {}).get("report")
if ref_doctype_meta.get_masked_fields():
from frappe.model.db_query import mask_field_value
# Apply masking to the fields
for field in ref_doctype_meta.get_masked_fields():
for row in data:
val = row.get(field.fieldname)
row[field.fieldname] = mask_field_value(field, val)
if match_filters_per_doctype:
for row in data:
# Why linked_doctypes.get(ref_doctype)? because if column is empty, linked_doctypes[ref_doctype] is removed

View file

@ -376,6 +376,7 @@ def export_query():
form_params = get_form_params()
form_params["limit_page_length"] = None
form_params["as_list"] = True
csv_params = pop_csv_params(form_params)
export_in_background = int(form_params.pop("export_in_background", 0))
@ -547,7 +548,7 @@ def get_field_info(fields, doctype):
if parenttype != doctype:
# If the column is from a child table, append the child doctype.
# For example, "Item Code (Sales Invoice Item)".
label += f" ({ _(parenttype) })"
label += f" ({_(parenttype)})"
field_info.append(
{"name": name, "label": label, "fieldtype": fieldtype, "translatable": translatable}

View file

@ -9,8 +9,7 @@ from frappe import _
def get_all_nodes(doctype, label, parent, tree_method, **filters):
"""Recursively gets all data from tree nodes"""
if "cmd" in filters:
del filters["cmd"]
filters.pop("cmd", None)
filters.pop("data", None)
tree_method = frappe.get_attr(tree_method)
@ -20,8 +19,7 @@ def get_all_nodes(doctype, label, parent, tree_method, **filters):
data = tree_method(doctype, parent, **filters)
out = [dict(parent=label, data=data)]
if "is_root" in filters:
del filters["is_root"]
filters.pop("is_root", None)
to_check = [d.get("value") for d in data if d.get("expandable")]
while to_check:

View file

@ -54,7 +54,7 @@ class TestEmailQueue(IntegrationTestCase):
Subject: {subject}
From: Test <test@example.com>
To: <!--recipient-->
Date: {frappe.utils.now_datetime().strftime('%a, %d %b %Y %H:%M:%S %z')}
Date: {frappe.utils.now_datetime().strftime("%a, %d %b %Y %H:%M:%S %z")}
Reply-To: test@example.com
X-Frappe-Site: {frappe.local.site}
"""

View file

@ -352,7 +352,9 @@ def get_context(context):
To queue a notification from a server script:
```python
notification = frappe.get_doc("Notification", "My Notification", ignore_permissions=True)
notification = frappe.get_doc(
"Notification", "My Notification", ignore_permissions=True
)
notification.queue_send(customer)
```

View file

@ -237,7 +237,7 @@ class EMail:
"""Append the message with MIME content to the root node (as attachment)"""
from email.mime.text import MIMEText
maintype, subtype = mime_type.split("/")
_maintype, subtype = mime_type.split("/")
part = MIMEText(message, _subtype=subtype, policy=policy.SMTP)
if as_attachment:
@ -445,7 +445,7 @@ def add_attachment(fname, fcontent, content_type=None, parent=None, content_id=N
from email.mime.text import MIMEText
if not content_type:
content_type, encoding = mimetypes.guess_type(fname)
content_type, _encoding = mimetypes.guess_type(fname)
if not parent:
return
@ -597,7 +597,7 @@ def get_header(header=None):
if not title:
title = frappe.get_hooks("app_title")[-1]
email_header, text = get_email_from_template(
email_header, _text = get_email_from_template(
"email_header", {"header_title": title, "indicator": indicator}
)

View file

@ -205,7 +205,7 @@ class EmailServer:
readonly = self.settings.email_sync_rule != "UNSEEN"
self.imap.select(folder, readonly=readonly)
response, message = self.imap.uid("search", None, self.settings.email_sync_rule)
_response, message = self.imap.uid("search", None, self.settings.email_sync_rule)
if message[0]:
email_list = message[0].split()
else:
@ -217,7 +217,7 @@ class EmailServer:
# compare the UIDVALIDITY of email account and imap server
uid_validity = self.settings.uid_validity
response, message = self.imap.status(folder, "(UIDVALIDITY UIDNEXT)")
_response, message = self.imap.status(folder, "(UIDVALIDITY UIDNEXT)")
current_uid_validity = self.parse_imap_response("UIDVALIDITY", message[0]) or 0
uidnext = int(self.parse_imap_response("UIDNEXT", message[0]) or "1")
@ -270,7 +270,7 @@ class EmailServer:
def retrieve_message(self, uid, msg_num, folder):
try:
if cint(self.settings.use_imap):
status, message = self.imap.uid("fetch", uid, "(BODY.PEEK[] BODY.PEEK[HEADER] FLAGS)")
_status, message = self.imap.uid("fetch", uid, "(BODY.PEEK[] BODY.PEEK[HEADER] FLAGS)")
raw = message[0]
self.get_email_seen_status(uid, raw[0])

View file

@ -52,7 +52,7 @@ ml,മലയാളം,0
mn,Монгол,0
mr,मराठी,0
ms,Melayu,0
my,မြန်မာ,0
my,မြန်မာ1
nb,Norsk Bokmål,1
nl,Nederlands,0
no,Norsk,0

Can't render this file because it has a wrong number of fields in line 55.

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"]

View file

@ -34,7 +34,7 @@ class Here:
"label": address["label"],
"value": json.dumps(
{
"address_line1": f'{address.get("street", "")} {address.get("houseNumber", "")}'.strip(),
"address_line1": f"{address.get('street', '')} {address.get('houseNumber', '')}".strip(),
"city": address.get("city", ""),
"state": address.get("state", ""),
"pincode": address.get("postalCode", ""),

View file

@ -37,7 +37,7 @@ class Nomatim:
"label": result["display_name"],
"value": json.dumps(
{
"address_line1": f'{address.get("road")} {address.get("house_number", "")}'.strip(),
"address_line1": f"{address.get('road')} {address.get('house_number', '')}".strip(),
"city": address.get("city") or address.get("town") or address.get("village"),
"state": address.get("state"),
"pincode": address.get("postcode"),

View file

@ -278,13 +278,14 @@ class LDAPSettings(Document):
elif self.ldap_directory_server.lower() == "openldap":
ldap_object_class = "posixgroup"
ldap_group_members_attribute = "memberuid"
user_search_str = getattr(user, self.ldap_username_field).value
user_search_str = escape_filter_chars(getattr(user, self.ldap_username_field).value)
elif self.ldap_directory_server.lower() == "custom":
ldap_object_class = self.ldap_group_objectclass
ldap_group_members_attribute = self.ldap_group_member_attribute
ldap_custom_group_search = self.ldap_custom_group_search or "{0}"
user_search_str = ldap_custom_group_search.format(getattr(user, self.ldap_username_field).value)
user_value = escape_filter_chars(getattr(user, self.ldap_username_field).value)
user_search_str = ldap_custom_group_search.format(user_value)
else:
# NOTE: depreciate this else path
@ -308,6 +309,7 @@ class LDAPSettings(Document):
if not self.enabled:
frappe.throw(_("LDAP is not enabled."))
username = escape_filter_chars(username)
user_filter = self.ldap_search_string.format(username)
ldap_attributes = self.get_ldap_attributes()
conn = self.connect_to_ldap(self.base_dn, self.get_password(raise_exception=False))
@ -335,7 +337,8 @@ class LDAPSettings(Document):
except LDAPInvalidCredentialsResult:
frappe.throw(_("Invalid username or password"))
def reset_password(self, user, password, logout_sessions=False):
def reset_password(self, user: str, password: str, logout_sessions: int = 0):
user = escape_filter_chars(user)
search_filter = f"({self.ldap_email_field}={user})"
conn = self.connect_to_ldap(self.base_dn, self.get_password(raise_exception=False), read_only=False)
@ -420,7 +423,7 @@ def login():
@frappe.whitelist()
def reset_password(user, password, logout):
def reset_password(user: str, password: str, logout: int):
ldap: LDAPSettings = frappe.get_doc("LDAP Settings")
if not ldap.enabled:
frappe.throw(_("LDAP is not enabled."))

View file

@ -240,7 +240,7 @@ class LDAP_TestCase:
function_return = self.test_class.connect_to_ldap(
base_dn=self.base_dn, password=self.base_password
)
args, kwargs = ldap3_connection_method.call_args
_args, kwargs = ldap3_connection_method.call_args
for connection_arg in kwargs:
if (
@ -305,7 +305,7 @@ class LDAP_TestCase:
base_dn=self.base_dn, password=self.base_password, read_only=False
)
args, kwargs = ldap3_connection_method.call_args
_args, kwargs = ldap3_connection_method.call_args
self.assertFalse(
kwargs["read_only"],

View file

@ -72,7 +72,7 @@ def approve(*args, **kwargs):
frappe.flags.oauth_credentials,
) = get_oauth_server().validate_authorization_request(r.url, r.method, r.get_data(), r.headers)
headers, body, status = get_oauth_server().create_authorization_response(
headers, _body, _status = get_oauth_server().create_authorization_response(
uri=frappe.flags.oauth_credentials["redirect_uri"],
body=r.get_data(),
headers=r.headers,
@ -144,7 +144,7 @@ def authorize(**kwargs):
def get_token(*args, **kwargs):
try:
r = frappe.request
headers, body, status = get_oauth_server().create_token_response(
_headers, body, _status = get_oauth_server().create_token_response(
r.url, r.method, r.form, r.headers, frappe.flags.oauth_credentials
)
body = frappe._dict(json.loads(body))
@ -165,7 +165,7 @@ def get_token(*args, **kwargs):
def revoke_token(*args, **kwargs):
try:
r = frappe.request
headers, body, status = get_oauth_server().create_revocation_response(
_headers, _body, status = get_oauth_server().create_revocation_response(
r.url,
headers=r.headers,
body=r.form,
@ -184,7 +184,7 @@ def revoke_token(*args, **kwargs):
def openid_profile(*args, **kwargs):
try:
r = frappe.request
headers, body, status = get_oauth_server().create_userinfo_response(
_headers, body, _status = get_oauth_server().create_userinfo_response(
r.url,
headers=r.headers,
body=r.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

31819
frappe/locale/my.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

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

@ -1399,7 +1399,7 @@ class BaseDocument:
else:
return True
def reset_values_if_no_permlevel_access(self, has_access_to, high_permlevel_fields):
def reset_values_if_no_permlevel_access(self, has_access_to, high_permlevel_fields, mask_fields=None):
"""If the user does not have permissions at permlevel > 0, then reset the values to original / default"""
to_reset = [
df
@ -1411,22 +1411,38 @@ class BaseDocument:
)
]
if to_reset:
if self.is_new():
# if new, set default value
ref_doc = frappe.new_doc(self.doctype)
else:
# get values from old doc
if self.parent_doc:
parent_doc = self.parent_doc.get_latest()
child_docs = [d for d in parent_doc.get(self.parentfield) if d.name == self.name]
if not child_docs:
return
ref_doc = child_docs[0]
else:
ref_doc = self.get_latest()
if not mask_fields:
mask_fields = []
for df in to_reset:
to_reset = to_reset + mask_fields
if not to_reset:
return
if self.is_new():
# if new, set default value
ref_doc = frappe.new_doc(self.doctype)
else:
# get values from old doc
if self.parent_doc:
parent_doc = self.parent_doc.get_latest()
child_docs = [d for d in parent_doc.get(self.parentfield) if d.name == self.name]
if not child_docs:
return
ref_doc = child_docs[0]
else:
ref_doc = self.get_latest()
masked_fieldnames = [df.fieldname for df in to_reset if df.get("mask_readonly")]
ref_values = {}
if not self.is_new() and masked_fieldnames:
ref_values = frappe.db.get_value(self.doctype, self.name, masked_fieldnames, as_dict=True) or {}
for df in to_reset:
if df.get("mask_readonly") and not self.is_new():
if df.fieldname in ref_values:
self.set(df.fieldname, ref_values[df.fieldname])
else:
self.set(df.fieldname, ref_doc.get(df.fieldname))
def get_value(self, fieldname):

View file

@ -219,8 +219,57 @@ class DatabaseQuery:
if pluck:
return [d[pluck] for d in result]
if self.doctype and result:
result = self.mask_fields(result)
return result
def mask_fields(self, result):
"""Mask fields in the result based on the doctype's masked fields"""
masked_fields = self.get_masked_fields()
if not masked_fields:
return result
if self.as_list:
masked_result = []
field_index_map = {}
for idx, field in enumerate(self.fields):
# handle aliases (e.g. `tabSI`.`posting_date` as posting_date)
if " as " in field.lower():
alias = field.split(" as ")[1].strip(" '")
field_index_map[alias] = idx
else:
# extract last part after `.`
col = field.split(".")[-1].strip("`")
field_index_map[col] = idx
# if as_list then we don't have field names in the result so we need to mask by position
for row in result:
row = list(row) # convert tuple to list mutable
for field in masked_fields:
if field.fieldname in field_index_map:
idx = field_index_map[field.fieldname]
val = row[idx]
row[idx] = mask_field_value(field, val)
masked_result.append(tuple(row)) # convert back to tuple
result = masked_result
else:
for row in result:
for field in masked_fields:
if field.fieldname in row:
val = row[field.fieldname]
row[field.fieldname] = mask_field_value(field, val)
return result
def get_masked_fields(self):
"""Get masked fields for the doctype"""
meta = self.get_meta(self.doctype)
return meta.get_masked_fields()
def build_and_run(self):
args = self.prepare_args()
args.limit = self.add_limit()
@ -394,8 +443,6 @@ from {tables}
"concat",
"concat_ws",
"if",
"ifnull",
"nullif",
"coalesce",
"connection_id",
"current_user",
@ -425,16 +472,19 @@ from {tables}
if SUB_QUERY_PATTERN.match(field):
# Check for subquery anywhere in the field, not just at the beginning
if "(" in lower_field:
location = lower_field.index("(")
subquery_token = lower_field[location + 1 :].lstrip().split(" ", 1)[0]
if any(keyword in subquery_token for keyword in blacklisted_keywords):
_raise_exception()
function = lower_field.split("(", 1)[0].rstrip()
if function in blacklisted_functions:
frappe.throw(
_("Use of function {0} in field is restricted").format(function), exc=frappe.DataError
)
# Check all parentheses pairs, not just the first one
paren_start = 0
while True:
location = lower_field.find("(", paren_start)
if location == -1:
break
token = lower_field[location + 1 :].lstrip().split(" ", 1)[0]
if any(
re.search(r"\b" + re.escape(keyword) + r"\b", token)
for keyword in blacklisted_keywords + blacklisted_functions
):
_raise_exception()
paren_start = location + 1
if "@" in lower_field:
# prevent access to global variables
@ -622,6 +672,7 @@ from {tables}
ignore_virtual=True,
)
)
permitted_child_table_fields = {}
# Create a copy of the fields list and reverse it to avoid index issues when removing fields
@ -1126,7 +1177,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 = {
@ -1194,6 +1250,26 @@ from {tables}
update_user_settings(self.doctype, user_settings)
def mask_field_value(field, val):
if not val:
return val
if field.fieldtype == "Data" and field.options == "Phone":
if len(val) > 3:
return val[:3] + "XXXXXX"
else:
return "X" * len(val)
elif field.fieldtype == "Data" and field.options == "Email":
email = val.split("@")
return "XXXXXX@" + email[1] if len(email) > 1 else "XXXXXX"
elif field.fieldtype == "Date":
return "XX-XX-XXXX"
elif field.fieldtype == "Time":
return "XX:XX"
else:
return "XXXXXXXX"
def cast_name(column: str) -> str:
"""Casts name field to varchar for postgres

View file

@ -3,6 +3,7 @@
import os
import shutil
from typing import Any
import frappe
import frappe.defaults
@ -20,19 +21,58 @@ from frappe.utils.password import delete_all_passwords_for
def delete_doc(
doctype=None,
name=None,
force=0,
ignore_doctypes=None,
for_reload=False,
ignore_permissions=False,
flags=None,
ignore_on_trash=False,
ignore_missing=True,
delete_permanently=False,
):
doctype: str | None = None,
name: str | int | list[str | int] | None = None,
force: int | bool = 0,
ignore_doctypes: list[str] | None = None,
for_reload: bool = False,
ignore_permissions: bool = False,
flags: dict[str, Any] | None = None,
ignore_on_trash: bool = False,
ignore_missing: bool = True,
delete_permanently: bool = False,
) -> bool | None:
"""
Deletes a doc(dt, dn) and validates if it is not submitted and not linked in a live record
Deletes a document and validates if it is not submitted and not linked in a live record.
Args:
doctype (str, optional): The document type to delete. If not provided,
retrieved from frappe.form_dict.get("dt"). Defaults to None.
name (str | int | list, optional): The name/ID of the document(s) to delete.
Can be a single name or a list of names. If not provided,
retrieved from frappe.form_dict.get("dn"). Defaults to None.
force (bool, optional): When True, bypasses link existence checks and allows
deletion of documents that are linked to other records. Also allows
deletion of standard DocTypes. Defaults to 0 (False).
ignore_doctypes (list, optional): A list of child doctypes to ignore when
deleting child table records associated with the document. Defaults to None.
for_reload (bool, optional): When True, indicates the deletion is for reloading
purposes (like during doctype updates). Skips certain validations like
permissions and on_trash methods, and automatically sets delete_permanently=True.
Defaults to False.
ignore_permissions (bool, optional): When True, bypasses permission checks
during deletion. Useful for system operations. Defaults to False.
flags (dict, optional): Additional flags to set on the document during the
deletion process. These flags affect document behavior during deletion.
Defaults to None.
ignore_on_trash (bool, optional): When True, skips calling the document's
on_trash method, which typically contains cleanup logic. Defaults to False.
ignore_missing (bool, optional): When True, doesn't raise an error if the
document doesn't exist and returns False. When False, raises
frappe.DoesNotExistError if document is missing. Defaults to True.
delete_permanently (bool, optional): When True, permanently deletes the document
without adding it to the "Deleted Document" table for recovery purposes.
When False, the document is soft-deleted and can be recovered. Defaults to False.
Raises:
frappe.DoesNotExistError: When document doesn't exist and ignore_missing=False.
frappe.LinkExistsError: When document is linked to other records and force=False.
frappe.PermissionError: When user doesn't have delete permissions and ignore_permissions=False.
frappe.ValidationError: When trying to delete a submitted document.
frappe.QueryTimeoutError: When document is locked by another user.
Returns:
bool: False if document doesn't exist and ignore_missing=True, otherwise None.
"""
if not ignore_doctypes:
ignore_doctypes = []
@ -104,6 +144,8 @@ def delete_doc(
# in case a doctype doesnt have any controller code nor any app and module
pass
frappe.clear_cache(doctype=name)
else:
# Lock the doc without waiting
try:

View file

@ -268,8 +268,20 @@ class Document(BaseDocument):
if hasattr(self, "__setup__"):
self.__setup__()
if not is_doctype:
self.mask_fields()
return self
def mask_fields(self):
from frappe.model.db_query import mask_field_value
mask_fields = frappe.get_meta(self.doctype).get_masked_fields()
for field in mask_fields:
val = self.get(field.fieldname)
self.set(field.fieldname, mask_field_value(field, val))
def load_children_from_db(self):
is_doctype = self.doctype == "DocType"
@ -909,8 +921,10 @@ class Document(BaseDocument):
has_access_to = self.get_permlevel_access()
high_permlevel_fields = self.meta.get_high_permlevel_fields()
if high_permlevel_fields:
self.reset_values_if_no_permlevel_access(has_access_to, high_permlevel_fields)
mask_fields = self.meta.get_masked_fields()
if high_permlevel_fields or mask_fields:
self.reset_values_if_no_permlevel_access(has_access_to, high_permlevel_fields, mask_fields)
# If new record then don't reset the values for child table
if self.is_new():

View file

@ -13,7 +13,7 @@ dynamic_link_queries = [
`tabDocField`.fieldname, `tabDocField`.options
from `tabDocField`, `tabDocType`
where `tabDocField`.fieldtype='Dynamic Link' and
`tabDocType`.`name`=`tabDocField`.parent and `tabDocType`.is_virtual = 0
`tabDocType`.`name`=`tabDocField`.parent and `tabDocType`.is_virtual = 0 and `tabDocField`.is_virtual = 0
order by `tabDocType`.read_only, `tabDocType`.in_create""",
"""select `tabCustom Field`.dt as parent,
`tabDocType`.read_only, `tabDocType`.in_create,

View file

@ -87,6 +87,7 @@ def get_meta(doctype: "str | DocType", cached: bool = True) -> "_Meta":
return meta
meta = Meta(doctype)
key = f"doctype_meta::{meta.name}"
frappe.client_cache.set_value(key, meta)
return meta
@ -193,6 +194,28 @@ class Meta(Document):
def get_dynamic_link_fields(self):
return self._dynamic_link_fields
def get_masked_fields(self):
import copy
if frappe.session.user == "Administrator":
return []
cache_key = f"masked_fields::{self.name}::{frappe.session.user}"
masked_fields = frappe.cache.get_value(cache_key)
if masked_fields is None:
masked_fields = []
for df in self.fields:
if df.get("mask") and not self.has_permlevel_access_to(
fieldname=df.fieldname, df=df, permission_type="mask"
):
# work on a copy instead of original df
df_copy = copy.deepcopy(df)
df_copy.mask_readonly = 1
masked_fields.append(df_copy)
frappe.cache.set_value(cache_key, masked_fields)
return masked_fields
@cached_property
def _dynamic_link_fields(self):
return self.get("fields", {"fieldtype": "Dynamic Link"})

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

@ -144,7 +144,7 @@ onMounted(() => store.fetch());
}
.editable {
input,
input:not([type="checkbox"]),
textarea,
select,
.ace_editor,
@ -258,7 +258,7 @@ onMounted(() => store.fetch());
border-color: transparent;
}
input,
input:not([type="checkbox"]),
textarea,
select,
.ace_editor,
@ -269,10 +269,6 @@ onMounted(() => store.fetch());
.ql-editor {
background-color: var(--control-bg) !important;
}
input[type="checkbox"] {
background-color: var(--fg-bg) !important;
}
}
.form-main > :deep(div:first-child:not(.tab-header)) {

View file

@ -1,8 +1,18 @@
<script setup>
import { useSlots } from "vue";
import { useSlots, computed } from "vue";
const props = defineProps(["df", "value", "read_only"]);
let slots = useSlots();
// Get the display value considering both current value and default
let display_checked = computed(() => {
// Use current value if explicitly set, otherwise fall back to default
const value =
props.value !== undefined && props.value !== null ? props.value : props.df.default;
// Frappe checkboxes use "1"/"0" strings or 1/0 numbers
return value === "1" || value === 1;
});
</script>
<template>
@ -10,7 +20,7 @@ let slots = useSlots();
<!-- checkbox -->
<label v-if="slots.label" class="field-controls">
<div class="checkbox">
<input type="checkbox" disabled />
<input type="checkbox" :checked="display_checked" disabled />
<slot name="label" />
</div>
<slot name="actions" />
@ -18,7 +28,7 @@ let slots = useSlots();
<label v-else>
<input
type="checkbox"
:checked="value"
:checked="display_checked"
:disabled="read_only"
@change="(event) => $emit('update:modelValue', event.target.checked)"
/>
@ -42,7 +52,6 @@ label .checkbox {
align-items: center;
input {
background-color: var(--fg-color);
box-shadow: none;
border: 1px solid var(--gray-400);
pointer-events: none;

View file

@ -67,10 +67,20 @@ let select_control = computed(() => {
});
let content = computed({
get: () => props.modelValue,
get: () => props.modelValue ?? props.df.default,
set: (value) => emit("update:modelValue", value),
});
// Get the display label for the current selected value
let display_value = computed(() => {
const current_value = content.value;
if (!current_value) return "";
const options = get_options();
const selected_option = options?.find((opt) => opt.value === current_value);
return selected_option ? selected_option.label : current_value;
});
onMounted(() => {
if (select.value) select_control.value;
});
@ -101,7 +111,7 @@ watch(
<!-- select input -->
<div class="select-input">
<input class="form-control" readonly />
<input class="form-control" readonly :value="display_value" />
<div class="select-icon" v-html="frappe.utils.icon('select', 'sm')"></div>
</div>

View file

@ -377,6 +377,7 @@ function on_file_input(e) {
}
function remove_file(file) {
files.value = files.value.filter((f) => f !== file);
if (file_input.value) file_input.value.value = "";
}
function toggle_image_cropper(index) {
crop_image_with_index.value = show_image_cropper.value ? -1 : index;

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

@ -503,10 +503,16 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
filter[3].push("...");
}
let value =
filter[3] == null || filter[3] === "" ? __("empty") : String(__(filter[3]));
let value;
if (filter[3] && Array.isArray(filter[3])) {
value = filter[3].map((v) => String(__(v)).bold()).join(", ");
} else if (filter[3] == null || filter[3] === "") {
value = __("empty").bold();
} else {
value = String(__(filter[3])).bold();
}
return [__(label).bold(), __(filter[2]), value.bold()].join(" ");
return [__(label).bold(), __(filter[2]), value].join(" ");
}
let filter_string = filter_array.map(get_filter_description).join(", ");

View file

@ -21,6 +21,7 @@ frappe.ui.form.ControlMultiCheck = class ControlMultiCheck extends frappe.ui.for
const columns = this.df.columns;
this.$checkbox_area = $('<div class="checkbox-options"></div>').appendTo(this.wrapper);
this.$checkbox_area.get(0).style.setProperty("--checkbox-options-columns", columns);
this.$checkbox_area.get(0).style.setProperty("padding", "1em");
}
refresh() {
@ -154,8 +155,8 @@ frappe.ui.form.ControlMultiCheck = class ControlMultiCheck extends frappe.ui.for
get_checkbox_element(option) {
return $(`
<div class="checkbox unit-checkbox">
<label title="${option.description || ""}">
<input type="checkbox" data-unit="${option.value}"></input>
<label title="${option.description || ""}" style="display: flex; align-items: center;">
<input type="checkbox" data-unit="${option.value}" style="flex-shrink: 0;">
<span class="label-area" data-unit="${option.value}">${option.label}</span>
</label>
</div>

Some files were not shown because too many files have changed in this diff Show more