Merge branch 'develop' into report-sticky-ui

This commit is contained in:
Ejaaz Khan 2026-04-16 10:31:29 +05:30 committed by GitHub
commit a7484851fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
163 changed files with 86795 additions and 114217 deletions

View file

@ -95,7 +95,7 @@ jobs:
run: |
pip install pip-audit
cd ${GITHUB_WORKSPACE}
pip-audit --desc on --ignore-vuln PYSEC-2023-312 .
pip-audit --desc on --ignore-vuln PYSEC-2023-312 --ignore-vuln CVE-2026-4539 .
precommit:
name: 'Pre-Commit'

View file

@ -74,7 +74,7 @@ jobs:
- name: Download artifacts
uses: actions/download-artifact@v8.0.1
- name: Upload coverage data
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
name: Server
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -59,7 +59,7 @@ jobs:
- name: Download artifacts
uses: actions/download-artifact@v8.0.1
- name: Upload python coverage data
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
name: UIBackend
token: ${{ secrets.CODECOV_TOKEN }}
@ -68,7 +68,7 @@ jobs:
exclude: coverage-js*
flags: server-ui
- name: Upload JS coverage data
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
name: Cypress
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -1573,6 +1573,7 @@ from frappe.config import get_common_site_config, get_conf, get_site_config
from frappe.core.doctype.system_settings.system_settings import get_system_settings
from frappe.model.document import (
get_doc,
get_docs,
get_lazy_doc,
copy_doc,
new_doc,

View file

@ -97,7 +97,8 @@ def get_values_for_link_and_dynamic_link_fields(doc_dict):
doctype = field.options if field.fieldtype == "Link" else doc_dict.get(field.options)
link_doc = frappe.get_doc(doctype, doc_fieldvalue)
link_doc = frappe.get_doc(doctype, doc_fieldvalue, check_permission="read")
link_doc.apply_fieldlevel_read_permissions()
doc_dict.update({field.fieldname: link_doc})

View file

@ -126,6 +126,12 @@ def application(request: Request):
elif request.path.startswith("/private/files/"):
response = frappe.utils.response.download_private_file(request.path)
elif request.path == "/.well-known/security.txt" and request.method == "GET":
if request.scheme != "https":
raise NotFound
security_settings = frappe.get_doc("Security Settings")
response = Response(security_settings.security_txt, content_type="text/plain")
elif request.path.startswith("/.well-known/") and request.method == "GET":
response = handle_wellknown(request.path)

View file

@ -155,7 +155,9 @@ class LoginManager:
self.authenticate(user=user, pwd=pwd)
if self.force_user_to_reset_password():
doc = frappe.get_doc("User", self.user)
frappe.local.response["redirect_to"] = doc.reset_password(send_email=False, password_expired=True)
frappe.local.response["redirect_to"] = doc._reset_password(
send_email=False, password_expired=True
)
frappe.local.response["message"] = "Password Reset"
return False

View file

@ -114,7 +114,7 @@ def get(
doc.check_permission()
doc.apply_fieldlevel_read_permissions()
return doc.as_dict()
return doc.as_dict(no_nulls=True)
@frappe.whitelist()

View file

@ -159,11 +159,14 @@ def main(
discover_all_tests(apps, runner)
results = []
global unittest_runner
for app, category, suite in runner.iterRun():
click.secho(
f"\nRunning {suite.countTestCases()} {category} tests for {app}", fg="cyan", bold=True
)
results.append([app, category, runner.run(suite)])
main_runner = unittest_runner if junit_xml_output and unittest_runner else runner
res = main_runner.run(suite)
results.append([app, category, res])
success = all(r.wasSuccessful() for _, _, r in results)
if not success:

View file

@ -108,6 +108,19 @@ def build(
print("Compiling translations for", app)
compile_translations(app, force=force)
run_after_build_hook(apps)
def run_after_build_hook(apps):
from importlib import import_module
for app in apps:
for fn in frappe.get_hooks("after_build", app_name=app):
modulename = ".".join(fn.split(".")[:-1])
methodname = fn.split(".")[-1]
method = getattr(import_module(modulename), methodname)
method()
@click.command("watch")
@click.option("--apps", help="Watch assets for specific apps")

View file

@ -143,7 +143,7 @@ def _accept_invitation(key: str, in_test: bool) -> None:
# set redirect_to
redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path())
if should_update_password:
redirect_to = f"{user.reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
redirect_to = f"{user._reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
# GET requests do not cause an implicit commit
frappe.db.commit() # nosemgrep

View file

@ -20,6 +20,22 @@ class CommunicationEmailMixin:
parent_doc = get_parent_doc(self)
return parent_doc.owner if parent_doc else None
def get_notification_recipient(self):
"""Get notification recipient of the communication docs parent.
Calls `get_notification_email` on the parent if available; otherwise returns the owner.
This uses `run_method` so hooks can customize recipients per app/site.
"""
parent_doc = get_parent_doc(self)
if not parent_doc:
return None
notification_email = parent_doc.run_method("get_notification_email")
if notification_email:
return notification_email
return parent_doc.owner
def get_all_email_addresses(self, exclude_displayname=False):
"""Get all Email addresses mentioned in the doc along with display name."""
return (
@ -60,7 +76,7 @@ class CommunicationEmailMixin:
"""Build cc list to send an email.
* if email copy is requested by sender, then add sender to CC.
* If this doc is created through inbound mail, then add doc owner to cc list
* If this doc is created through inbound mail, then add the notification recipient to CC
* remove all the thread_notify disabled users.
* Remove standard users from email list
"""
@ -77,9 +93,9 @@ class CommunicationEmailMixin:
cc.append(sender)
if is_inbound_mail_communcation:
# inform parent document owner incase communication is created through inbound mail
if doc_owner := self.get_owner():
cc.append(doc_owner)
# inform the configured notification recipient in case communication is created inbound
if notification_recipient := self.get_notification_recipient():
cc.append(notification_recipient)
cc = set(cc) - {self.sender_mailid}
assignees = set(self.get_assignees()) - {self.sender_mailid}
# Check and remove If user disabled notifications for incoming emails on assigned document.

View file

@ -227,7 +227,7 @@
],
"grid_page_length": 50,
"links": [],
"modified": "2025-05-22 16:59:35.484376",
"modified": "2026-04-09 11:13:35.484376",
"modified_by": "Administrator",
"module": "Core",
"name": "Custom DocPerm",
@ -239,6 +239,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,

View file

@ -13,6 +13,7 @@ from frappe.core.doctype.version.version import get_diff
from frappe.model import no_value_fields
from frappe.utils import cint, cstr, duration_to_seconds, flt, update_progress_bar
from frappe.utils.csvutils import get_csv_content_from_google_sheets, read_csv_content
from frappe.utils.data import escape_html
from frappe.utils.xlsxutils import (
read_xls_file_from_attached_file,
read_xlsx_file_from_attached_file,
@ -727,7 +728,9 @@ class Row:
elif df.fieldtype == "Link":
exists = self.link_exists(value, df)
if not exists:
msg = _("Value {0} missing for {1}").format(frappe.bold(value), frappe.bold(df.options))
msg = _("Value {0} missing for {1}").format(
frappe.bold(escape_html(cstr(value))), frappe.bold(df.options)
)
self.warnings.append(
{
"row": self.row_number,
@ -746,7 +749,8 @@ class Row:
"col": col.column_number,
"field": df_as_json(df),
"message": _("Value {0} must in {1} format").format(
frappe.bold(value), frappe.bold(get_user_format(col.date_format))
frappe.bold(escape_html(cstr(value))),
frappe.bold(get_user_format(col.date_format)),
),
}
)
@ -761,7 +765,8 @@ class Row:
"col": col.column_number,
"field": df_as_json(df),
"message": _("Value {0} must in {1} format").format(
frappe.bold(value), frappe.bold(get_user_format(col.date_format))
frappe.bold(escape_html(cstr(value))),
frappe.bold(get_user_format(col.date_format)),
),
}
)
@ -774,7 +779,7 @@ class Row:
"col": col.column_number,
"field": df_as_json(df),
"message": _("Value {0} must be in the valid duration format: d h m s").format(
frappe.bold(value)
frappe.bold(escape_html(cstr(value)))
),
}
)
@ -1045,7 +1050,7 @@ class Column:
]
not_exists = list(set(values) - set(exists))
if not_exists:
missing_values = ", ".join(not_exists)
missing_values = ", ".join(escape_html(v) for v in not_exists)
message = _("The following values do not exist for {0}: {1}")
self.warnings.append(
{
@ -1088,7 +1093,7 @@ class Column:
invalid = values - set(options)
if invalid:
valid_values = ", ".join(frappe.bold(o) for o in options)
invalid_values = ", ".join(frappe.bold(i) for i in invalid)
invalid_values = ", ".join(frappe.bold(escape_html(i)) for i in invalid)
message = _("The following values are invalid: {0}. Values must be one of {1}")
self.warnings.append(
{

View file

@ -48,7 +48,13 @@ frappe.ui.form.on("File", {
const field = frm.get_field("attached_to_name");
field.$input_wrapper
.find(".control-value")
.html(`${frappe.utils.get_form_link(frm.doctype, frm.docname, true)}`);
.html(
`${frappe.utils.get_form_link(
frm.doc.attached_to_doctype,
frm.doc.attached_to_name,
true
)}`
);
}
},

View file

@ -115,6 +115,16 @@ class File(Document):
if self.is_folder:
return
if self.flags.copy_from_existing_file:
# Preserve the normal insert lifecycle for hooks and validations, but skip
# reprocessing an existing blob that is already referenced by `file_url`.
if not self.file_url:
frappe.throw(
_("File URL is required when copying an existing attachment."),
exc=frappe.MandatoryError,
)
return
if self.is_remote_file:
self.validate_remote_file()
else:
@ -128,6 +138,29 @@ class File(Document):
if not self.is_folder:
self.create_attachment_record()
def create_attachment_copy(
self,
attached_to_doctype: str,
attached_to_name: str,
attached_to_field: str | None = None,
ignore_permissions: bool = False,
):
"""Efficiently copy an attachment from one document to another by reusing `file_url`."""
if self.is_folder:
frappe.throw(_("Cannot attach a folder to a document"))
attachment = frappe.copy_doc(self)
attachment.update(
{
"attached_to_doctype": attached_to_doctype,
"attached_to_name": attached_to_name,
"attached_to_field": attached_to_field,
}
)
attachment.folder = None
attachment.flags.copy_from_existing_file = True
return attachment.insert(ignore_permissions=ignore_permissions)
def validate(self):
if self.is_folder:
return

View file

@ -254,6 +254,66 @@ class TestSameContent(IntegrationTestCase):
limit_property.delete()
frappe.clear_cache(doctype="ToDo")
def test_create_attachment_copy(self):
doctype, docname = make_test_doc()
source_file = frappe.get_doc(
{
"doctype": "File",
"file_name": f"existing-file-{frappe.generate_hash(length=8)}.txt",
"content": "Existing attachment content",
}
).insert()
comment_count_before = frappe.db.count(
"Comment", {"reference_doctype": doctype, "reference_name": docname}
)
copied_file = source_file.create_attachment_copy(doctype, docname)
comment_count_after = frappe.db.count(
"Comment", {"reference_doctype": doctype, "reference_name": docname}
)
self.assertNotEqual(copied_file.name, source_file.name)
self.assertEqual(copied_file.file_url, source_file.file_url)
self.assertEqual(copied_file.attached_to_doctype, doctype)
self.assertEqual(copied_file.attached_to_name, docname)
self.assertEqual(
copied_file.folder,
frappe.db.get_value("File", {"is_attachments_folder": 1}),
)
self.assertEqual(comment_count_after, comment_count_before + 1)
def test_create_attachment_copy_respects_attachment_limit(self):
doctype, docname = make_test_doc()
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
limit_property = make_property_setter("ToDo", None, "max_attachments", 1, "int", for_doctype=True)
source_file_1 = frappe.get_doc(
{
"doctype": "File",
"file_name": f"existing-limit-file-{frappe.generate_hash(length=8)}.txt",
"content": "Existing attachment content 1",
}
).insert()
source_file_2 = frappe.get_doc(
{
"doctype": "File",
"file_name": f"existing-limit-file-{frappe.generate_hash(length=8)}.txt",
"content": "Existing attachment content 2",
}
).insert()
try:
source_file_1.create_attachment_copy(doctype, docname)
self.assertRaises(
frappe.exceptions.AttachmentLimitReached,
source_file_2.create_attachment_copy,
doctype,
docname,
)
finally:
limit_property.delete()
frappe.clear_cache(doctype="ToDo")
def test_utf8_bom_content_decoding(self):
utf8_bom_content = test_content1.encode("utf-8-sig")
_file: frappe.Document = frappe.get_doc(

View file

@ -93,8 +93,9 @@ class PackageRelease(Document):
def export_package_files(self, package):
# write readme
with open(frappe.get_site_path("packages", package.package_name, "README.md"), "w") as readme:
readme.write(package.readme)
if package.readme:
with open(frappe.get_site_path("packages", package.package_name, "README.md"), "w") as readme:
readme.write(package.readme)
# write license
if package.license:

View file

@ -55,6 +55,17 @@ frappe.ui.form.on("Report", {
},
};
});
frm.set_query("default_print_format", () => {
return {
filters: {
print_format_for: "Report",
report: frm.doc.name,
print_format_type: "JS",
disabled: 0,
},
};
});
},
ref_doctype: function (frm) {

View file

@ -14,6 +14,7 @@
"column_break_4",
"report_type",
"letter_head",
"default_print_format",
"add_total_row",
"disabled",
"prepared_report",
@ -99,7 +100,7 @@
"depends_on": "eval: doc.is_standard == \"No\"",
"fieldname": "letter_head",
"fieldtype": "Link",
"label": "Letter Head",
"label": "Default Letter Head",
"options": "Letter Head"
},
{
@ -202,12 +203,18 @@
"fieldname": "add_translate_data",
"fieldtype": "Check",
"label": "Add Translate Data"
},
{
"fieldname": "default_print_format",
"fieldtype": "Link",
"label": "Default Print Format",
"options": "Print Format"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-08-28 18:28:32.510719",
"modified": "2026-03-31 14:42:49.829920",
"modified_by": "Administrator",
"module": "Core",
"name": "Report",

View file

@ -32,6 +32,7 @@ class Report(Document):
add_total_row: DF.Check
add_translate_data: DF.Check
columns: DF.Table[ReportColumn]
default_print_format: DF.Link | None
disabled: DF.Check
filters: DF.Table[ReportFilter]
is_standard: DF.Literal["No", "Yes"]
@ -82,6 +83,9 @@ class Report(Document):
if self.report_type == "Report Builder":
self.update_report_json()
if self.default_print_format and self.has_value_changed("default_print_format"):
self.validate_default_print_format()
def before_insert(self):
self.set_doctype_roles()
@ -408,6 +412,23 @@ class Report(Document):
return data
def validate_default_print_format(self):
pf = frappe.db.get_value(
"Print Format",
self.default_print_format,
["report", "print_format_for", "print_format_type", "disabled"],
as_dict=True,
)
if (
not pf
or pf.report != self.name
or pf.print_format_for != "Report"
or pf.print_format_type != "JS"
or pf.disabled
):
frappe.throw(_("Selected Print Format is invalid for this Report."))
@frappe.whitelist()
def toggle_disable(self, disable: bool):
if not self.has_permission("write"):

View file

@ -0,0 +1,20 @@
// Copyright (c) 2026, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("Security Settings", {
refresh(frm) {
const wrapper = frm.fields_dict.securitytxt_section.wrapper;
if ($(wrapper).find(".security-txt-banner").length) return;
$(wrapper)
.find(".section-body")
.prepend(
`<div class="alert alert-warning border d-flex justify-content-between align-items-center security-txt-banner" style="flex: 0 0 100%; max-width: 100%; border-color: var(--border-color);">
<span>${__("Security.txt will be served only under HTTPS.")}</span>
<a href="https://tools.ietf.org/html/rfc9116#section-6.7" target="_blank" class="btn btn-xs btn-secondary">${__(
"Learn more"
)}</a>
</div>`
);
},
});

View file

@ -0,0 +1,81 @@
{
"actions": [],
"creation": "2026-04-10 16:14:40.343135",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"securitytxt_section",
"public_expires",
"public_contacts",
"public_languages",
"public_policy",
"security_txt"
],
"fields": [
{
"fieldname": "securitytxt_section",
"fieldtype": "Section Break",
"label": "Security.txt"
},
{
"description": "Date after which this security.txt should be considered stale.",
"fieldname": "public_expires",
"fieldtype": "Datetime",
"label": "Expires"
},
{
"description": "Website, email or phone where vulnerabilities can be reported. Defaults to `https://security.frappe.io`",
"fieldname": "public_contacts",
"fieldtype": "Table",
"label": "Contact",
"options": "Security Settings Contact"
},
{
"description": "Defaults to `en`",
"fieldname": "public_languages",
"fieldtype": "Table MultiSelect",
"label": "Preferred Language",
"options": "Security Settings Language"
},
{
"description": "Guidelines and policies on vulnerability reporting. Defaults to `https://frappe.io/security`",
"fieldname": "public_policy",
"fieldtype": "Data",
"label": "Policy",
"options": "URL"
},
{
"fieldname": "security_txt",
"fieldtype": "Small Text",
"is_virtual": 1,
"label": "Preview",
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-04-14 12:50:57.138749",
"modified_by": "Administrator",
"module": "Core",
"name": "Security Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,116 @@
# Copyright (c) 2026, Frappe Technologies and contributors
# For license information, please see license.txt
from datetime import UTC, datetime
import frappe
import frappe.utils
from frappe import _
from frappe.model.document import Document
from frappe.utils import validate_email_address, validate_phone_number, validate_url
class SecuritySettings(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.core.doctype.security_settings_contact.security_settings_contact import (
SecuritySettingsContact,
)
from frappe.core.doctype.security_settings_language.security_settings_language import (
SecuritySettingsLanguage,
)
from frappe.types import DF
public_contacts: DF.Table[SecuritySettingsContact]
public_expires: DF.Datetime | None
public_languages: DF.TableMultiSelect[SecuritySettingsLanguage]
public_policy: DF.Data | None
# end: auto-generated types
@property
def security_txt(self):
return (
"\n\n".join(
[
self.public_policy_section,
self.public_contacts_section,
self.public_languages_section,
self.public_expires_section,
]
)
+ "\n"
)
@property
def public_policy_section(self):
value = self.public_policy or "https://frappe.io/security"
return f"# Read our security policy before reporting an issue\nPolicy: {value}"
@property
def public_contacts_section(self):
contacts = [self.with_protocol(c.contact, c.type) for c in self.public_contacts] or [
"https://security.frappe.io"
]
value = "\n".join(f"Contact: {c}" for c in contacts)
return f"# Our security address\n{value}"
@property
def public_languages_section(self):
langs = [l.language for l in self.public_languages] or ["en"]
value = ", ".join(langs)
return f"# We prefer talking in\nPreferred-Languages: {value}"
@property
def public_expires_section(self):
expires = self.public_expires or frappe.utils.add_years(frappe.utils.now_datetime(), 1)
if isinstance(expires, str):
expires = datetime.fromisoformat(expires)
expires = expires.replace(microsecond=0)
expires = expires.astimezone(UTC)
value = expires.strftime("%Y-%m-%dT%H:%M:%SZ")
return f"Expires: {value}"
def with_protocol(self, url: str, type_: str) -> str:
"""Prefix the URL with the appropriate protocol based on the contact type."""
match type_:
case "Email":
if not url.startswith("mailto:"):
return f"mailto:{url}"
case "Phone":
if not url.startswith("tel:"):
return f"tel:{url}"
return url
def validate(self):
self.validate_public_policy()
self.validate_public_contacts()
self.validate_expires()
def validate_public_policy(self):
if self.public_policy:
if not self.public_policy.startswith("https://"):
frappe.throw(_("Public Policy URL must start with https://"))
def validate_public_contacts(self):
for contact in self.public_contacts:
match contact.type:
case "Email":
validate_email_address(contact.contact, throw=True)
case "Phone":
validate_phone_number(contact.contact, throw=True)
case "Website":
validate_url(contact.contact, throw=True)
if not contact.contact.startswith("https://"):
frappe.throw(_("URL contact must start with https://"))
def validate_expires(self):
if self.public_expires:
expires = self.public_expires
if isinstance(expires, str):
expires = datetime.fromisoformat(expires)
if expires <= datetime.now():
frappe.throw(_("Expiration date must be in the future"))

View file

@ -0,0 +1,43 @@
# Copyright (c) 2026, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
from frappe.utils import get_datetime, now_datetime
from frappe.utils.user import get_users_with_role
def check_security_txt_expiry():
security_settings = frappe.get_doc("Security Settings")
if not security_settings.public_expires:
return
expires = security_settings.public_expires
if isinstance(expires, str):
expires = get_datetime(expires)
now = now_datetime()
days_until_expiry = (expires - now).days
alert_days = [30, 15, 7, 1]
if days_until_expiry in alert_days:
send_expiry_alert(frappe.local.site, expires, days_until_expiry)
def send_expiry_alert(site: str, expires, days_until_expiry: int):
recipients = get_users_with_role("System Manager")
if not recipients:
return
subject = get_email_subject(site, days_until_expiry)
frappe.sendmail(
recipients=recipients,
subject=subject,
template="security_txt_expiry_alert",
args={
"site": site,
"expires": expires,
"days_remaining": days_until_expiry,
},
)
def get_email_subject(site: str, days_until_expiry: int) -> str:
if days_until_expiry == 1:
return f"[URGENT] Security.txt expires in 1 day - {site}"
return f"Security.txt expires in {days_until_expiry} days - {site}"

View file

@ -0,0 +1,270 @@
# Copyright (c) 2026, Frappe Technologies and Contributors
# License: MIT. See LICENSE
from datetime import UTC, datetime, timedelta
import frappe
from frappe.tests import UnitTestCase
class TestSecuritySettings(UnitTestCase):
def test_public_policy_section_default(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_policy": None,
}
)
section = doc.public_policy_section
self.assertIn("Policy: https://frappe.io/security", section)
def test_public_policy_section_custom(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_policy": "https://example.com/security-policy",
}
)
section = doc.public_policy_section
self.assertIn("Policy: https://example.com/security-policy", section)
def test_public_languages_section_default(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
section = doc.public_languages_section
self.assertIn("Preferred-Languages: en", section)
def test_public_languages_section_custom(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_languages": [
{"language": "en"},
{"language": "fr"},
],
}
)
section = doc.public_languages_section
self.assertIn("Preferred-Languages: en, fr", section)
def test_public_contacts_section_default(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
section = doc.public_contacts_section
self.assertIn("https://security.frappe.io", section)
def test_public_contacts_section_email(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Email", "contact": "security@example.com"},
],
}
)
section = doc.public_contacts_section
self.assertIn("mailto:security@example.com", section)
def test_public_contacts_section_phone(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Phone", "contact": "+1234567890"},
],
}
)
section = doc.public_contacts_section
self.assertIn("tel:+1234567890", section)
def test_public_contacts_section_website(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Website", "contact": "https://security.example.com"},
],
}
)
section = doc.public_contacts_section
self.assertIn("https://security.example.com", section)
def test_with_protocol_email_without_protocol(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
result = doc.with_protocol("security@example.com", "Email")
self.assertEqual(result, "mailto:security@example.com")
def test_with_protocol_email_with_protocol(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
result = doc.with_protocol("mailto:security@example.com", "Email")
self.assertEqual(result, "mailto:security@example.com")
def test_with_protocol_phone_without_protocol(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
result = doc.with_protocol("+1234567890", "Phone")
self.assertEqual(result, "tel:+1234567890")
def test_with_protocol_phone_with_protocol(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
result = doc.with_protocol("tel:+1234567890", "Phone")
self.assertEqual(result, "tel:+1234567890")
def test_with_protocol_website(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
result = doc.with_protocol("https://example.com", "Website")
self.assertEqual(result, "https://example.com")
def test_security_txt_full(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_policy": "https://example.com/policy",
"public_contacts": [
{"type": "Email", "contact": "security@example.com"},
],
"public_languages": [
{"language": "en"},
],
"public_expires": datetime.now() + timedelta(days=365),
}
)
security_txt = doc.security_txt
self.assertIn("Policy: https://example.com/policy", security_txt)
self.assertIn("mailto:security@example.com", security_txt)
self.assertIn("Preferred-Languages: en", security_txt)
self.assertIn("Expires:", security_txt)
def test_validate_public_policy_with_http(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_policy": "http://example.com",
}
)
self.assertRaises(frappe.ValidationError, doc.validate_public_policy)
def test_validate_public_policy_with_https(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_policy": "https://example.com",
}
)
# Should not raise
doc.validate_public_policy()
def test_validate_public_contacts_invalid_email(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Email", "contact": "invalid-email"},
],
}
)
self.assertRaises(frappe.ValidationError, doc.validate_public_contacts)
def test_validate_public_contacts_valid_email(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Email", "contact": "security@example.com"},
],
}
)
# Should not raise
doc.validate_public_contacts()
def test_validate_public_contacts_invalid_phone(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Phone", "contact": "not-a-phone"},
],
}
)
self.assertRaises(frappe.ValidationError, doc.validate_public_contacts)
def test_validate_public_contacts_valid_phone(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Phone", "contact": "+1234567890"},
],
}
)
# Should not raise
doc.validate_public_contacts()
def test_validate_public_contacts_website_without_https(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Website", "contact": "http://example.com"},
],
}
)
self.assertRaises(frappe.ValidationError, doc.validate_public_contacts)
def test_validate_public_contacts_valid_website(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Website", "contact": "https://example.com"},
],
}
)
# Should not raise
doc.validate_public_contacts()
def test_validate_expires_past(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_expires": datetime.now() - timedelta(days=1),
}
)
self.assertRaises(frappe.ValidationError, doc.validate_expires)
def test_validate_expires_future(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_expires": datetime.now() + timedelta(days=365),
}
)
# Should not raise
doc.validate_expires()
def test_public_expires_section_future_date(self):
from datetime import timezone
future_date = datetime(2027, 12, 31, 23, 59, 59, tzinfo=UTC)
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_expires": future_date,
}
)
section = doc.public_expires_section
self.assertIn("2027-12-31T23:59:59Z", section)
def test_public_expires_section_string(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_expires": "2027-12-31T23:59:59+00:00",
}
)
section = doc.public_expires_section
self.assertIn("2027-12-31T23:59:59Z", section)
def test_public_expires_section_default(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
section = doc.public_expires_section
# Default is 1 year from now
self.assertIn("Expires:", section)
self.assertIn("T", section) # ISO format

View file

@ -0,0 +1,43 @@
{
"actions": [],
"creation": "2026-04-11 13:06:29.308243",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"type",
"contact"
],
"fields": [
{
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Website\nEmail\nPhone",
"reqd": 1
},
{
"fieldname": "contact",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Contact",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-04-14 12:50:25.814560",
"modified_by": "Administrator",
"module": "Core",
"name": "Security Settings Contact",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,24 @@
# Copyright (c) 2026, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SecuritySettingsContact(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
contact: DF.Data
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
type: DF.Literal["Website", "Email", "Phone"]
# end: auto-generated types
pass

View file

@ -0,0 +1,35 @@
{
"actions": [],
"creation": "2026-04-11 12:53:09.006649",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"language"
],
"fields": [
{
"fieldname": "language",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Language",
"options": "Language",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-04-14 12:50:44.554462",
"modified_by": "Administrator",
"module": "Core",
"name": "Security Settings Language",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,23 @@
# Copyright (c) 2026, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SecuritySettingsLanguage(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
language: DF.Link
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
# end: auto-generated types
pass

View file

@ -114,6 +114,8 @@
"enable_telemetry",
"search_section",
"link_field_results_limit",
"column_break_nebx",
"allow_clearing_link_fields",
"api_logging_section",
"log_api_requests"
],
@ -783,13 +785,23 @@
"fieldname": "only_allow_system_managers_to_upload_public_files",
"fieldtype": "Check",
"label": "Only allow System Managers to upload public files"
},
{
"fieldname": "column_break_nebx",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "Adds a clear (\u00d7) button to Link fields, allowing users to quickly remove the selected value.",
"fieldname": "allow_clearing_link_fields",
"fieldtype": "Check",
"label": "Allow Clearing Link Fields"
}
],
"hide_toolbar": 1,
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2026-02-24 14:27:04.763075",
"modified": "2026-04-14 16:26:19.634212",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",

View file

@ -18,6 +18,7 @@ class SystemSettings(Document):
if TYPE_CHECKING:
from frappe.types import DF
allow_clearing_link_fields: DF.Check
allow_consecutive_login_attempts: DF.Int
allow_error_traceback: DF.Check
allow_guests_to_upload_files: DF.Check

View file

@ -16,16 +16,20 @@ class TestTranslation(IntegrationTestCase):
clear_cache()
def test_doctype(self):
translation_data = get_translation_data()
for lang, (source_string, new_translation) in translation_data.items():
doctype = "Translation"
meta = frappe.get_meta(doctype)
source_string = meta.get_label("translated_text")
for lang in ["de", "bs", "zh", "hr", "en", "sv"]:
frappe.local.lang = lang
original_translation = _(source_string)
original_translation = _(source_string, context=doctype)
new_translation = f"{original_translation} Customized"
docname = create_translation(lang, source_string, new_translation)
self.assertEqual(_(source_string), new_translation)
docname = create_translation(lang, source_string, new_translation, context=doctype)
self.assertEqual(_(source_string, context=doctype), new_translation)
frappe.delete_doc("Translation", docname)
self.assertEqual(_(source_string), original_translation)
frappe.delete_doc(doctype, docname)
self.assertEqual(_(source_string, context=doctype), original_translation)
def test_parent_language(self):
data = {
@ -60,37 +64,54 @@ class TestTranslation(IntegrationTestCase):
source = "User"
self.assertNotEqual(_(source, lang="de"), _(source, lang="es"))
def test_html_content_data_translation(self):
# ruff: noqa: RUF001
def test_html_content_translation(self):
source = """
<span style="color: rgb(51, 51, 51); font-family: &quot;Amazon Ember&quot;, Arial, sans-serif; font-size:
small;">MacBook Air lasts up to an incredible 12 hours between charges. So from your morning coffee to
your evening commute, you can work unplugged. When its time to kick back and relax,
you can get up to 12 hours of iTunes movie playback. And with up to 30 days of standby time,
you can go away for weeks and pick up where you left off.Whatever the task,
fifth-generation Intel Core i5 and i7 processors with Intel HD Graphics 6000 are up to it.</span><br>
"""
To add dynamic subject, use jinja tags like
<div><pre><code>{{ doc.name }} Billed</code></pre></div>
""".strip()
target = """
MacBook Air dura hasta 12 horas increíbles entre cargas. Por lo tanto,
desde el café de la mañana hasta el viaje nocturno, puede trabajar desconectado.
Cuando es hora de descansar y relajarse, puede obtener hasta 12 horas de reproducción de películas de iTunes.
Y con hasta 30 días de tiempo de espera, puede irse por semanas y continuar donde lo dejó. Sea cual sea la tarea,
los procesadores Intel Core i5 e i7 de quinta generación con Intel HD Graphics 6000 son capaces de hacerlo.
"""
Um einen dynamischen Betreff hinzuzufügen, verwenden Sie Jinja-Tags wie
<div><pre><code>{{ doc.name }} Abgerechnet</code></pre></div>
""".strip()
create_translation("es", source, target)
frappe.local.lang = "de"
source = """
<span style="font-family: &quot;Amazon Ember&quot;, Arial, sans-serif; font-size:
small; color: rgb(51, 51, 51);">MacBook Air lasts up to an incredible 12 hours between charges. So from your morning coffee to
your evening commute, you can work unplugged. When its time to kick back and relax,
you can get up to 12 hours of iTunes movie playback. And with up to 30 days of standby time,
you can go away for weeks and pick up where you left off.Whatever the task,
fifth-generation Intel Core i5 and i7 processors with Intel HD Graphics 6000 are up to it.</span><br>
"""
self.assertEqual(_(source), source)
self.assertTrue(_(source), target)
create_translation("de", source, target)
self.assertEqual(_(source), target)
def test_translated_html_is_sanitized(self):
source = "Translation with HTML"
target = """
<span style="color:red" onclick="alert('xss')">Hallo</span>
<script>alert("xss")</script>
<iframe src="https://example.com"></iframe>
<div>Ok</div>
""".strip()
docname = create_translation("de", source, target)
translated_text = frappe.db.get_value("Translation", docname, "translated_text")
self.assertIn('<span style="color:red">Hallo</span>', translated_text)
self.assertIn("<div>Ok</div>", translated_text)
self.assertNotIn("onclick", translated_text)
self.assertNotIn("<script", translated_text)
self.assertNotIn('alert("xss")', translated_text)
self.assertNotIn("<iframe", translated_text)
self.assertNotIn("example.com", translated_text)
frappe.local.lang = "de"
self.assertEqual(_(source), translated_text)
def test_plain_text_translation_with_angle_brackets_is_unchanged(self):
source = "Comparison"
target = "1 < 2 and 3 > 2"
docname = create_translation("de", source, target)
self.assertEqual(frappe.db.get_value("Translation", docname, "translated_text"), target)
def test_html_message_translations(self):
"""Test fallback for messages w/ HTML Tags"""
@ -100,27 +121,12 @@ class TestTranslation(IntegrationTestCase):
self.assertEqual(_(message, lang="zh"), translated_message)
def get_translation_data():
html_source_data = """<font color="#848484" face="arial, tahoma, verdana, sans-serif">
<span style="font-size: 11px; line-height: 16.9px;">Test Data</span></font>"""
html_translated_data = """<font color="#848484" face="arial, tahoma, verdana, sans-serif">
<span style="font-size: 11px; line-height: 16.9px;"> testituloksia </span></font>"""
return {
"hr": ["Test data", "Testdaten"],
"ms": ["Test Data", "ujian Data"],
"et": ["Test Data", "testandmed"],
"es": ["Test Data", "datos de prueba"],
"en": ["Quotation", "Tax Invoice"],
"fi": [html_source_data, html_translated_data],
}
def create_translation(lang, source_string, new_translation) -> str:
def create_translation(lang, source_string, new_translation, context=None) -> str:
doc = frappe.new_doc("Translation")
doc.language = lang
doc.source_text = source_string
doc.translated_text = new_translation
doc.context = context
doc.save()
return doc.name

View file

@ -1,12 +1,10 @@
# Copyright (c) 2015, Frappe Technologies and contributors
# License: MIT. See LICENSE
import json
import frappe
from frappe.model.document import Document
from frappe.translate import MERGED_TRANSLATION_KEY, USER_TRANSLATION_KEY, change_translation_version
from frappe.utils import is_html, strip_html_tags
from frappe.utils import sanitize_html
class Translation(Document):
@ -28,11 +26,7 @@ class Translation(Document):
# end: auto-generated types
def validate(self):
if is_html(self.source_text):
self.remove_html_from_source()
def remove_html_from_source(self):
self.source_text = strip_html_tags(self.source_text).strip()
self.translated_text = sanitize_html(self.translated_text)
def on_update(self):
clear_user_translation_cache(self.language)

View file

@ -42,7 +42,7 @@ class TestUser(IntegrationTestCase):
@staticmethod
def reset_password(user) -> str:
link = user.reset_password()
link = user._reset_password()
return parse_qs(urlparse(link).query)["key"][0]
def test_user_type(self):
@ -292,7 +292,7 @@ class TestUser(IntegrationTestCase):
c = FrappeClient(url)
res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
res2 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
self.assertEqual(res1.status_code, 404)
self.assertEqual(res1.status_code, 200)
self.assertEqual(res2.status_code, 429)
def test_user_rename(self):
@ -415,6 +415,12 @@ class TestUser(IntegrationTestCase):
# test API endpoint
with patch.object(user_module.frappe, "sendmail") as sendmail:
from unittest.mock import MagicMock
mock_q = MagicMock()
mock_q.name = "test-email-queue-name"
mock_q.message = "Subject: Test\n\nDear User, here is your link"
sendmail.return_value = mock_q
frappe.clear_messages()
test_user = frappe.get_doc("User", "test2@example.com")
self.assertEqual(reset_password(user="test2@example.com"), None)
@ -425,15 +431,28 @@ class TestUser(IntegrationTestCase):
update_password(old_password, old_password=new_password)
self.assertEqual(
frappe.message_log[0].get("message"),
f"Password reset instructions have been sent to {test_user.full_name}'s email",
"If this email is registered with us, we have sent password reset instructions to it. Please check your inbox.",
)
sendmail.assert_called_once()
self.assertEqual(sendmail.call_args[1]["recipients"], "test2@example.com")
self.assertEqual(reset_password(user="test2@example.com"), None)
self.assertEqual(reset_password(user="Administrator"), "not allowed")
self.assertEqual(reset_password(user="random"), "not found")
# Constant-response guarantee: every path — existing user, Administrator,
# and non-existent user — must return None AND enqueue the same generic
# message, so callers cannot distinguish between them.
_GENERIC_MSG = "If this email is registered with us, we have sent password reset instructions to it. Please check your inbox."
frappe.clear_messages()
self.assertIsNone(reset_password(user="test2@example.com"))
self.assertEqual(frappe.message_log[0].get("message"), _GENERIC_MSG)
frappe.clear_messages()
self.assertIsNone(reset_password(user="Administrator"))
self.assertEqual(frappe.message_log[0].get("message"), _GENERIC_MSG)
frappe.clear_messages()
self.assertIsNone(reset_password(user="random"))
self.assertEqual(frappe.message_log[0].get("message"), _GENERIC_MSG)
def test_user_onload_modules(self):
from frappe.desk.form.load import getdoc

View file

@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import re
from collections.abc import Iterable
from datetime import timedelta
from functools import cached_property
@ -358,7 +359,7 @@ class User(Document):
def clean_name(self):
for field in ("first_name", "middle_name", "last_name"):
if field_value := self.get(field):
self.set(field, sanitize_html(field_value, always_sanitize=True))
self.set(field, sanitize_html(field_value, always_sanitize=True, disallowed_tags="*"))
def set_full_name(self):
self.full_name = " ".join(p for p in [self.first_name, self.middle_name, self.last_name] if p)
@ -376,9 +377,23 @@ class User(Document):
toggle_notifications(self.name, enable=cint(self.enabled), ignore_permissions=True)
self.disable_email_fields_if_user_disabled()
def email_new_password(self, new_password=None):
def set_new_password(self, new_password=None):
"""Set New Password for user"""
if new_password and not self.flags.in_insert:
_update_password(user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions)
outgoing_email_exists = frappe.db.exists(
"Email Account", {"default_outgoing": 1, "awaiting_password": 0}
)
if outgoing_email_exists:
email_message = _(
"Your password has been changed and you might have been logged out of all systems.<br>Please contact the Administrator for further assistance."
)
user_email = frappe.db.get_value("User", self.name, "email")
frappe.sendmail(
recipients=[user_email],
subject=_("Security Alert: Your password has been changed."),
content=email_message,
)
def set_system_user(self):
"""For the standard users like admin and guest, the user type is fixed."""
@ -433,25 +448,26 @@ class User(Document):
def send_password_notification(self, new_password):
try:
if self.flags.in_insert:
if self.name not in STANDARD_USERS:
if new_password:
# new password given, no email required
_update_password(
user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions
)
if self.name in STANDARD_USERS:
return
if new_password:
# new password given, no email required
_update_password(
user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions
)
if (
not self.flags.no_welcome_mail
and cint(self.send_welcome_email)
and not self.flags.email_sent
):
self.send_welcome_mail_to_user()
self.flags.email_sent = 1
if frappe.session.user != "Guest":
msgprint(_("Welcome email sent"))
return
if (
not self.flags.no_welcome_mail
and cint(self.send_welcome_email)
and not self.flags.email_sent
):
self.send_welcome_mail_to_user()
self.flags.email_sent = 1
if frappe.session.user != "Guest":
msgprint(_("Welcome email sent"))
return
else:
self.email_new_password(new_password)
self.set_new_password(new_password)
except frappe.OutgoingEmailError:
frappe.clear_last_message()
@ -465,7 +481,7 @@ class User(Document):
def validate_reset_password(self):
pass
def reset_password(self, send_email=False, password_expired=False):
def _reset_password(self, send_email=False, password_expired=False):
from frappe.utils import get_url
key = frappe.generate_hash()
@ -490,18 +506,24 @@ class User(Document):
def password_reset_mail(self, link):
reset_password_template = frappe.db.get_system_setting("reset_password_template")
self.send_login_mail(
q = self.send_login_mail(
_("Password Reset"),
"password_reset",
{"link": link},
now=True,
custom_template=reset_password_template,
)
if q:
raw_message = q.message
parts = re.split(r"(?i)Dear", raw_message, maxsplit=1)
if len(parts) > 1:
redacted_message = parts[0] + "[THE FOLLOWING CONTENT HAS BEEN REDACTED FOR SECURITY REASONS]"
frappe.db.set_value("Email Queue", q.name, "message", redacted_message, update_modified=False)
def send_welcome_mail_to_user(self):
from frappe.utils import get_url
link = self.reset_password()
link = self._reset_password()
subject = None
method = frappe.get_hooks("welcome_email")
if method:
@ -515,7 +537,7 @@ class User(Document):
welcome_email_template = frappe.db.get_system_setting("welcome_email_template")
self.send_login_mail(
q = self.send_login_mail(
subject,
"new_user",
dict(
@ -524,6 +546,12 @@ class User(Document):
),
custom_template=welcome_email_template,
)
if q:
raw_message = q.message
parts = re.split(r"(?i)Hello", raw_message, maxsplit=1)
if len(parts) > 1:
redacted_message = parts[0] + "[THE FOLLOWING CONTENT HAS BEEN REDACTED FOR SECURITY REASONS]"
frappe.db.set_value("Email Queue", q.name, "message", redacted_message, update_modified=False)
def send_login_mail(self, subject, template, add_args, now=None, custom_template=None):
"""send mail with login details"""
@ -555,7 +583,7 @@ class User(Document):
subject = email_template.get("subject")
content = email_template.get("message")
frappe.sendmail(
return frappe.sendmail(
recipients=self.email,
sender=sender,
subject=subject,
@ -617,18 +645,16 @@ class User(Document):
frappe.db.delete("List Filter", {"for_user": self.name})
# Remove user from Note's Seen By table
seen_notes = frappe.get_all("Note", filters=[["Note Seen By", "user", "=", self.name]], pluck="name")
for note_id in seen_notes:
note = frappe.get_doc("Note", note_id)
seen_notes = frappe.get_docs("Note", filters=[["Note Seen By", "user", "=", self.name]])
for note in seen_notes:
for row in note.seen_by:
if row.user == self.name:
note.remove(row)
note.save(ignore_permissions=True)
# Unlink user from all of its invitation docs
invites = frappe.db.get_all("User Invitation", filters={"email": self.name}, pluck="name")
for invite in invites:
invite_doc = frappe.get_doc("User Invitation", invite)
invites = frappe.get_docs("User Invitation", filters={"email": self.name})
for invite_doc in invites:
invite_doc.user = None
invite_doc.save(ignore_permissions=True)
@ -1126,25 +1152,32 @@ def sign_up(email: str, full_name: str, redirect_to: str) -> tuple[int, str]:
@frappe.whitelist(allow_guest=True, methods=["POST"])
@rate_limit(limit=get_password_reset_limit, seconds=60 * 60)
def reset_password(user: str) -> str:
def reset_password(user: str) -> None:
# Always return the same generic response regardless of whether the user
# exists, is disabled, or is restricted. This prevents username enumeration
# via different messages or HTTP status codes (CWE-204).
try:
user: User = frappe.get_doc("User", user)
if user.name == "Administrator":
return "not allowed"
if not user.enabled:
return "disabled"
user.validate_reset_password()
user.reset_password(send_email=True)
return frappe.msgprint(
msg=_("Password reset instructions have been sent to {}'s email").format(user.full_name),
title=_("Password Email Sent"),
)
user_doc: User = frappe.get_doc("User", user)
if user_doc.name != "Administrator" and user_doc.enabled:
user_doc.validate_reset_password()
user_doc._reset_password(send_email=True)
# For Administrator or disabled users: silently skip — same response below
except frappe.DoesNotExistError:
frappe.local.response["http_status_code"] = 404
frappe.clear_messages()
return "not found"
except frappe.OutgoingEmailError:
frappe.clear_messages()
frappe.log_error(title="Password reset email could not be sent", message=frappe.get_traceback())
except Exception:
frappe.clear_messages()
frappe.log_error(title="Password reset failed unexpectedly", message=frappe.get_traceback())
frappe.msgprint(
msg=_(
"If this email is registered with us, we have sent password reset instructions to it. Please check your inbox."
),
title=_("Password Reset"),
)
@frappe.whitelist()

View file

@ -206,12 +206,11 @@ class UserInvitation(Document):
def mark_expired_invitations() -> None:
days = 3
invitations_to_expire = frappe.db.get_all(
invitations_to_expire = frappe.get_docs(
"User Invitation",
filters={"status": "Pending", "creation": ["<", frappe.utils.add_days(frappe.utils.now(), -days)]},
)
for invitation in invitations_to_expire:
invitation = frappe.get_doc("User Invitation", invitation.name)
invitation.expire()
# to avoid losing work in case the job times out without finishing
frappe.db.commit() # nosemgrep

View file

@ -13,13 +13,14 @@ import frappe.translate
from frappe import _
from frappe.core.doctype.doctype.doctype import (
check_email_append_to,
get_fields_not_allowed_in_list_view,
validate_autoincrement_autoname,
validate_fields_for_doctype,
validate_series,
)
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter
from frappe.model import core_doctypes_list, no_value_fields
from frappe.model import core_doctypes_list
from frappe.model.docfield import supports_translation
from frappe.model.document import Document
from frappe.model.meta import trim_table
@ -319,12 +320,12 @@ class CustomizeForm(Document):
def set_property_setters_for_docfield(self, meta, df, meta_df):
for prop, prop_type in docfield_properties.items():
if prop != "idx" and (df.get(prop) or "") != (meta_df[0].get(prop) or ""):
if not self.allow_property_change(prop, meta_df, df):
if not self.allow_property_change(prop, meta_df, df, meta):
continue
self.make_property_setter(prop, df.get(prop), prop_type, fieldname=df.fieldname)
def allow_property_change(self, prop, meta_df, df):
def allow_property_change(self, prop, meta_df, df, meta):
if prop == "fieldtype":
self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop))
@ -360,8 +361,7 @@ class CustomizeForm(Document):
elif (
prop == "in_list_view"
and df.get(prop)
and df.fieldtype != "Attach Image"
and df.fieldtype in no_value_fields
and df.fieldtype in get_fields_not_allowed_in_list_view(meta)
):
frappe.msgprint(
_("'In List View' not allowed for type {0} in row {1}").format(df.fieldtype, df.idx)
@ -401,6 +401,10 @@ class CustomizeForm(Document):
elif prop == "in_global_search" and df.in_global_search != meta_df[0].get("in_global_search"):
self.flags.rebuild_doctype_for_global_search = True
elif prop == "is_virtual" and meta_df[0].get("is_virtual") == 0 and df.get("is_virtual") == 1:
frappe.msgprint(_("You can't set standard field {0} as virtual").format(frappe.bold(df.label)))
return False
return True
def set_property_setters_for_actions_and_links(self, meta):

View file

@ -475,6 +475,9 @@ class Database:
if query_type in WRITE_QUERY_TYPES:
self.transaction_writes += 1
if frappe.conf.get("max_writes_per_transaction"):
self.MAX_WRITES_PER_TRANSACTION = cint(frappe.conf.max_writes_per_transaction)
if self.transaction_writes > self.MAX_WRITES_PER_TRANSACTION:
if self.auto_commit_on_many_writes:
self.commit()

View file

@ -8,7 +8,6 @@ import frappe
from frappe.database.utils import NestedSetHierarchy
from frappe.model.db_query import get_timespan_date_range
from frappe.query_builder import Field
from frappe.query_builder.functions import Coalesce
from frappe.utils import cstr
@ -51,7 +50,7 @@ def func_in(key: Field, value: list | tuple) -> frappe.qb:
value = ["" if v is None else v for v in value]
if "" in value:
return Coalesce(key, "").isin(value)
return key.isin(value) | key.isnull()
return key.isin(value)

View file

@ -5,7 +5,7 @@ from frappe import _
from frappe.utils import cint, cstr, flt
from frappe.utils.defaults import get_not_null_defaults
# This matches anything that isn't [a-zA-Z0-9_]
# This matches anything that isn't Unicode Word Characters, Numbers and Underscore.
SPECIAL_CHAR_PATTERN = re.compile(r"[\W]", flags=re.UNICODE)
VARCHAR_CAST_PATTERN = re.compile(r"varchar\(([\d]+)\)")

View file

@ -682,6 +682,9 @@ def get_onboarding_data(module: str):
Return:
dict: onboarding data
"""
if not frappe.get_system_settings("enable_onboarding"):
return []
onboardings = []
onboarding_doc = frappe.get_doc("Module Onboarding", module)
if onboarding_doc.is_complete:

View file

@ -36,7 +36,7 @@
},
{
"bold": 1,
"description": "SQL Conditions. Example: status=\"Open\"",
"description": "SQL Conditions. Example: {\"status\" : \"open\", \"priority\" : \"medium\"}",
"fieldname": "condition",
"fieldtype": "Small Text",
"label": "Condition"
@ -52,7 +52,7 @@
],
"issingle": 1,
"links": [],
"modified": "2024-03-23 16:01:29.575802",
"modified": "2026-04-01 12:18:08.821282",
"modified_by": "Administrator",
"module": "Desk",
"name": "Bulk Update",
@ -70,8 +70,9 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View file

@ -31,17 +31,18 @@ class BulkUpdate(Document):
def bulk_update(self):
self.check_permission("write")
limit = self.limit if self.limit and cint(self.limit) < 500 else 500
condition = ""
query_args = {"doctype": self.document_type, "limit": limit, "pluck": "name"}
if self.condition:
if ";" in self.condition:
frappe.throw(_("; not allowed in condition"))
try:
filters = frappe.parse_json(self.condition)
if isinstance(filters, dict):
if "or_filters" in filters:
query_args["or_filters"] = filters.pop("or_filters")
query_args["filters"] = filters
except Exception as e:
frappe.throw(_("The Bulk Update could not happen due to <b>{0}</b>").format(str(e)))
condition = f" where {self.condition}"
docnames = frappe.db.sql_list(
f"""select name from `tab{self.document_type}`{condition} limit {limit} offset 0"""
)
docnames = frappe.get_all(**query_args)
return submit_cancel_or_update_docs(
self.document_type, docnames, "update", {self.field: self.update_value}
)

View file

@ -103,3 +103,45 @@ class TestBulkUpdate(IntegrationTestCase):
docnames_bg = frappe.get_all(self.doctype, {"docstatus": 0}, limit=20, pluck="name")
submit_cancel_or_update_docs(self.doctype, docnames_bg, action="update", data=update_data)
self.wait_for_assertion(lambda: check_child_field(docnames_bg, "_Test Child Updated"))
def test_bulk_update_conditions(self):
"""Test the whitelisted bulk update method"""
todo_names = []
for i in range(5):
doc = frappe.get_doc(
{
"doctype": "ToDo",
"description": f"Bulk Update Status Test {i}",
"status": "Open" if i < 3 else "Closed",
}
).insert()
todo_names.append(doc.name)
try:
condition_json = frappe.as_json({"status": "Open", "name": ["in", todo_names]})
bulk_upd = frappe.get_doc(
{
"doctype": "Bulk Update",
"document_type": "ToDo",
"field": "status",
"update_value": "Closed",
"condition": condition_json,
"limit": 5,
}
)
bulk_upd.bulk_update()
updated_docs = frappe.get_all("ToDo", filters={"name": ["in", todo_names]}, fields=["status"])
for doc in updated_docs:
self.assertEqual(doc.status, "Closed")
remaining_open_count = frappe.db.count("ToDo", {"name": ["in", todo_names], "status": "Open"})
self.assertEqual(remaining_open_count, 0)
finally:
for name in todo_names:
frappe.delete_doc("ToDo", name)
frappe.db.commit()

View file

@ -63,8 +63,9 @@ class DesktopIcon(Document):
clear_desktop_icons_cache(user=self.owner)
def after_rename(self, old, new, merge):
delete_desktop_icon_file(self.app, old)
self.export_desktop_icon()
if self.standard and self.app:
delete_desktop_icon_file(self.app, old)
self.export_desktop_icon()
def export_desktop_icon(self):
allow_export = (

View file

@ -48,6 +48,8 @@ def save_layout(user: str, layout: str, new_icons: str | None = None):
new_workspace = frappe.new_doc("Workspace")
new_workspace.update(workspace)
new_workspace.title = new_workspace.label
if not new_workspace.public:
new_workspace.for_user = frappe.session.user
new_workspace.save()
return add_workspace_to_desktop(new_workspace.name)
desktop_icon = frappe.new_doc("Desktop Icon")

View file

@ -137,7 +137,7 @@ class Event(Document):
return
for participant in self.event_participants:
if communications := frappe.get_all(
if communications := frappe.get_docs(
"Communication",
filters=[
["Communication", "reference_doctype", "=", self.doctype],
@ -145,11 +145,9 @@ class Event(Document):
["Communication Link", "link_doctype", "=", participant.reference_doctype],
["Communication Link", "link_name", "=", participant.reference_docname],
],
pluck="name",
distinct=True,
):
for comm in communications:
communication = frappe.get_doc("Communication", comm)
for communication in communications:
self.update_communication(participant, communication)
else:
meta = frappe.get_meta(participant.reference_doctype)

View file

@ -26,9 +26,9 @@
<div class="flex" style="gap:16px; align-items: center;">
<div class="desktop-notifications">
<div class="dropdown dropdown-notifications">
<button class="btn-reset nav-link text-muted" data-toggle="dropdown" >
<button class="btn-reset nav-link text-muted desktop-notification-icon" data-toggle="dropdown" aria-label="{{ _("Notifications") }}" aria-haspopup="true">
<svg
class="icon icon-md"
class="icon icon-md" aria-hidden="true"
>
<use href="#icon-bell"></use>
</svg>
@ -50,8 +50,8 @@
</div>
</div>
</div>
<div class="desktop-avatar">
</div>
<button class="desktop-avatar btn-reset" aria-label="{{ _('User Menu') }}">
</button>
</div>
</header>

View file

@ -520,8 +520,11 @@ def get_field_info(fields, doctype):
except ValueError:
# handles aggregate functions
parenttype = doctype
fieldname = key.split("(", 1)[0]
fieldname = fieldname[0].upper() + fieldname[1:]
if isinstance(key, dict):
fieldname = next(k for k in key if k != "as")
else:
fieldname = key.split("(", 1)[0]
fieldname = fieldname.capitalize()
parenttype = parenttype or doctype
@ -580,8 +583,11 @@ def handle_duration_fieldtype_values(doctype, data, fields):
return data
def parse_field(field: str) -> tuple[str | None, str]:
def parse_field(field: str | dict) -> tuple[str | None, str]:
"""Parse a field into parenttype and fieldname."""
if isinstance(field, dict): # for aggregates via qb
raise ValueError
key = field.split(" as ", 1)[0]
if key.startswith(("count(", "sum(", "avg(")):

View file

@ -168,7 +168,9 @@ def search_widget(
}
search_fields = ["name"]
if meta.title_field:
search_fields.append(meta.title_field)
is_virtual_field = getattr(meta.get_field(meta.title_field), "is_virtual", False)
if not is_virtual_field:
search_fields.append(meta.title_field)
if meta.search_fields:
search_fields.extend(meta.get_search_fields())
@ -348,7 +350,12 @@ def build_for_autosuggest(res: list[tuple], doctype: str) -> list[LinkSearchResu
for item in res:
item = list(item)
if len(item) == 1:
item = [item[0], item[0]]
title_field = meta.title_field
docfield = meta.get_field(title_field)
if docfield and docfield.is_virtual:
doc = frappe.get_doc(meta.name, item[0])
title_value = doc.get_virtual_field_value(docfield)
item = [item[0], title_value or item[0]]
label = _(item[1]) if meta.translated_doctype else item[1]
item[1] = item[0]

View file

@ -359,8 +359,8 @@ def process_auto_email_report(report):
def send_monthly():
"""Check reports to be sent monthly"""
for report in frappe.get_all("Auto Email Report", {"enabled": 1, "frequency": "Monthly"}):
frappe.get_doc("Auto Email Report", report.name).send()
for report in frappe.get_docs("Auto Email Report", filters={"enabled": 1, "frequency": "Monthly"}):
report.send()
def make_links(columns, data):

View file

@ -492,7 +492,7 @@ class EmailAccount(Document):
@classmethod
def create_dummy(cls):
return cls.from_record({"sender": "notifications@example.com"})
return cls.from_record({"name": "Notifications", "email_id": "notifications@example.com"})
@classmethod
@cache_email_account("outgoing_email_account")

View file

@ -188,16 +188,18 @@ class EmailQueue(Document):
if ctx.smtp_server.session.has_extn("SIZE"):
if max_size := ctx.smtp_server.session.esmtp_features.get("size"):
max_size = int(max_size)
msg_size = len(msg)
if msg_size > max_size:
msg_size_mb = msg_size / (1024 * 1024)
max_size_mb = max_size / (1024 * 1024)
frappe.throw(
_(
"Email size {0:.2f} MB exceeds the maximum allowed size of {1:.2f} MB"
).format(msg_size_mb, max_size_mb)
)
if max_size > 0:
msg_size = len(msg)
if msg_size > max_size:
msg_size_mb = msg_size / (1024 * 1024)
max_size_mb = max_size / (1024 * 1024)
frappe.throw(
_(
"Email size {0:.2f} MB exceeds the maximum allowed size of {1:.2f} MB"
).format(msg_size_mb, max_size_mb)
)
return msg

View file

@ -210,7 +210,18 @@ class EMail:
if has_inline_images:
# process inline images
message, _inline_images = replace_filename_with_cid(message)
provided_images = {}
if inline_images:
for img in inline_images:
if img.get("filename") and img.get("filecontent"):
# index by full path and basename for flexible matching
provided_images[img["filename"]] = img["filecontent"]
basename = img["filename"].rsplit("/", 1)[-1]
if basename not in provided_images:
provided_images[basename] = img["filecontent"]
# process inline images while preferring provided_images over disk reads
message, _inline_images = replace_filename_with_cid(message, provided_images)
# prepare parts
msg_related = MIMEMultipart("related", policy=policy.SMTP)
@ -571,11 +582,22 @@ def get_footer(email_account, footer=None):
return footer
def replace_filename_with_cid(message):
def replace_filename_with_cid(message, provided_images=None):
"""Replaces <img embed="assets/frappe/images/filename.jpg" ...> with
<img src="cid:content_id" ...> and return the modified message and
a list of inline_images with {filename, filecontent, content_id}
Args:
message: The HTML message to process
provided_images: A dictionary of images to use instead of reading from disk
Example:
{
"assets/frappe/images/filename.jpg": filecontent,
"filename.jpg": filecontent,
}
"""
if provided_images is None:
provided_images = {}
inline_images = []
@ -590,7 +612,11 @@ def replace_filename_with_cid(message):
img_path_escaped = frappe.utils.html_utils.unescape_html(img_path)
filename = img_path_escaped.rsplit("/")[-1]
filecontent = get_filecontent_from_path(img_path_escaped)
# check if the image is provided in the provided_images(by checking full path and basename)
filecontent = provided_images.get(img_path_escaped) or provided_images.get(filename)
if not filecontent:
filecontent = get_filecontent_from_path(img_path_escaped)
if not filecontent:
message = re.sub(f"""embed=['"]{re.escape(img_path)}['"]""", "", message)
continue

View file

@ -137,6 +137,43 @@ w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
""".format(inline_images[0].get("content_id"))
self.assertEqual(message, processed_message)
def test_sendmail_inline_images_parameter_respected(self):
"""
Test that inline_images parameter works through sendmail.
Earlier this was ignored and the image was read from disk instead of using the provided content.
The way to check this is essentially checking if the image is embedded with cid:
<img src="cid:content_id" ...> -> Correct behavior
If the image is not embedded with cid: -> Incorrect behavior
"""
test_image_content = b"FAKE_PNG_BINARY_CONTENT_FOR_TESTING"
html_content = '<div><img embed="files/nonexistent_test_image.png" alt="Logo"></div>'
inline_images = [
{
"filename": "files/nonexistent_test_image.png",
"filecontent": test_image_content,
}
]
# use QueueBuilder to send the email (sendmail uses this internally)
from frappe.email.doctype.email_queue.email_queue import QueueBuilder
builder = QueueBuilder(
recipients=["test@example.com"],
sender="me@example.com",
subject="Test Inline Images",
message=html_content,
inline_images=inline_images,
)
mail = builder.prepare_email_content()
email_string = mail.as_string()
self.assertIn("cid:", email_string)
self.assertNotIn('embed="files/nonexistent_test_image.png"', email_string)
def test_inline_styling(self):
html = """
<h3>Hi John</h3>

View file

@ -343,12 +343,16 @@ class FrappeClient:
)
return self.post_process(res)
def post_api(self, method, params=None):
if params is None:
params = {}
res = self.session.post(
f"{self.url}/api/method/{method}", params=params, verify=self.verify, headers=self.headers
)
def post_api(self, method, params=None, json=None):
url = f"{self.url}/api/method/{method}"
if json is not None:
headers = {**self.headers, "content-type": "application/json"}
res = self.session.post(url, json=json, verify=self.verify, headers=headers)
else:
res = self.session.post(
url, data=self.preprocess(params or {}), verify=self.verify, headers=self.headers
)
return self.post_process(res)
def get_request(self, params):

View file

@ -165,7 +165,7 @@ def upload_file():
file = files["file"]
filename = file.filename
if frappe.form_dict.chunk_index:
if frappe.form_dict.get("chunk_index") is not None:
current_chunk = int(frappe.form_dict.chunk_index)
total_chunks = int(frappe.form_dict.total_chunk_count)
offset = int(frappe.form_dict.chunk_byte_offset)

View file

@ -232,6 +232,10 @@ scheduler_events = {
"0 */3 * * *": [
"frappe.search.sqlite_search.build_index_if_not_exists",
],
# Daily at 6:00 AM.
"0 6 * * *": [
"frappe.core.doctype.security_settings.security_settings_alert.check_security_txt_expiry",
],
},
"all": [
"frappe.email.queue.flush",

File diff suppressed because one or more lines are too long

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 one or more lines are too long

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 one or more lines are too long

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 one or more lines are too long

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 one or more lines are too long

View file

@ -541,6 +541,15 @@ class BaseDocument:
eval_locals={"doc": self},
)
def get_virtual_field_value(self, df):
fieldname = df.fieldname
if (prop := getattr(type(self), fieldname, None)) and is_a_property(prop):
return getattr(self, fieldname)
elif options := getattr(df, "options", None):
return self._evaluate_virtual_field_options(options)
def get_valid_dict(
self, sanitize=True, convert_dates_to_str=False, ignore_nulls=False, ignore_virtual=False
) -> _dict:
@ -563,12 +572,7 @@ class BaseDocument:
if is_virtual_field:
if ignore_virtual or fieldname not in self.permitted_fieldnames:
continue
if (prop := getattr(type(self), fieldname, None)) and is_a_property(prop):
value = getattr(self, fieldname)
elif options := getattr(df, "options", None):
value = self._evaluate_virtual_field_options(options)
value = self.get_virtual_field_value(df)
fieldtype = df.fieldtype
if isinstance(value, list) and fieldtype not in table_fields:

View file

@ -156,6 +156,164 @@ def get_lazy_doc(
raise ImportError(doctype)
def get_docs(
doctype: str,
filters: dict | None = None,
*,
chunk_size: int = 1000,
limit: int | None = None,
limit_start: int = 0,
order_by: str = "creation asc",
as_iterator: bool = False,
for_update: bool = False,
distinct: bool = False,
) -> list["Document"] | Generator["Document"]:
"""Fetch fully instantiated Document objects from the database.
Returns a list of Documents by default. Pass `as_iterator=True` to get
a chunked generator that yields a list of Documents per chunk to reduce memory usage.
:param doctype: DocType of the records to fetch.
:param filters: Dict or list of filters to apply.
:param chunk_size: Number of records to fetch in each chunk if using `as_iterator`.
:param limit: Maximum total number of records to fetch.
:param limit_start: Start results at record #. Default 0.
:param order_by: Order By string, e.g. `creation desc`.
:param as_iterator: If True, returns a iterator yielding Documents.
:param for_update: If True, locks the fetched rows for update.
:param distinct: If True, return distinct rows.
Note: Chunk size controls memory usage vs # of queries tradeoff. Using chunk size larger than
10,000 is not advisable.
"""
if is_virtual_doctype(doctype):
frappe.throw(_("Virtual DocType {0} cannot be fetched in bulk.").format(doctype))
meta = frappe.get_meta(doctype)
if meta.issingle:
frappe.throw(_("Single DocType {0} cannot be fetched in bulk.").format(doctype))
if limit_start and limit is None:
frappe.throw(_("limit cannot be None when limit_start is used"))
if not order_by:
# Sort order is mandatory for iterator logic
order_by = "name asc"
child_tables = [
(df.fieldname, df.options) for df in meta.get_table_fields() if not is_virtual_doctype(df.options)
]
controller = get_controller(doctype)
for_update = for_update and frappe.db.db_type != "sqlite"
iterator = _get_docs_generator(
doctype,
controller,
child_tables,
filters=filters,
chunk_size=chunk_size,
limit_start=limit_start,
order_by=order_by,
for_update=for_update,
distinct=distinct,
)
iterator = itertools.islice(iterator, limit)
if as_iterator:
return iterator
return list(iterator)
def _get_docs_generator(
doctype,
controller,
child_tables,
*,
filters,
chunk_size,
limit_start,
order_by,
for_update,
distinct,
) -> Generator["Document"]:
offset = limit_start
while True:
chunk_data = _fetch_rows(
doctype,
filters=filters,
order_by=order_by,
limit=chunk_size,
offset=offset,
for_update=for_update,
child_tables=child_tables,
distinct=distinct,
)
if not chunk_data:
break
yield from _build_document_objects(controller, chunk_data, for_update)
offset += chunk_size
def _fetch_rows(doctype, *, filters, order_by, limit, offset, for_update, child_tables, distinct=False):
kwargs = {}
if limit is not None:
kwargs["limit"] = limit
if offset:
kwargs["offset"] = offset
data = frappe.qb.get_query(
table=doctype,
filters=filters or {},
fields=["*"],
order_by=order_by,
for_update=for_update,
distinct=distinct,
**kwargs,
).run(as_dict=True)
if not data:
return []
for row in data:
row["doctype"] = doctype
fetched_docs_by_name = {row.name: row for row in data}
parent_names = list(fetched_docs_by_name.keys())
for fieldname, child_doctype in child_tables:
child_table_data = frappe.qb.get_query(
table=child_doctype,
filters={"parent": ("in", parent_names), "parenttype": doctype, "parentfield": fieldname},
fields=["*"],
order_by="idx asc",
for_update=for_update,
).run(as_dict=True)
for child in child_table_data:
child["doctype"] = child_doctype
for parent_doc in fetched_docs_by_name.values():
parent_doc[fieldname] = []
for child in child_table_data:
if child.parent in fetched_docs_by_name:
fetched_docs_by_name[child.parent][fieldname].append(child)
return list(fetched_docs_by_name.values())
def _build_document_objects(controller, data: list, for_update: bool):
for row in data:
doc = controller(row)
if for_update:
doc.flags.for_update = True
yield doc
def get_doc_permission_check(doc: "Document", check_permission: str | bool | None = None) -> "Document":
"""
Checks permissions for the given document, if specified.
@ -1766,7 +1924,7 @@ class Document(BaseDocument):
_("Table {0} cannot be empty").format(label), raise_exception or frappe.EmptyTableError
)
def round_floats_in(self, doc, fieldnames=None):
def round_floats_in(self, doc, fieldnames=None, do_not_round_fields=None):
"""Round floats for all `Currency`, `Float`, `Percent` fields for the given doc.
:param doc: Document whose numeric properties are to be rounded.
@ -1780,6 +1938,9 @@ class Document(BaseDocument):
# PERF: flt internally has to resolve this if we don't specify it.
rounding_method = frappe.get_system_settings("rounding_method")
for fieldname in fieldnames:
if do_not_round_fields and fieldname in do_not_round_fields:
continue
doc.set(
fieldname,
flt(

View file

@ -57,6 +57,7 @@ class InvalidIncludePath(frappe.ValidationError):
def render_include(content):
"""render {% raw %}{% include "app/path/filename" %}{% endraw %} in js file"""
import os
content = cstr(content)
@ -69,7 +70,13 @@ def render_include(content):
for path in paths:
app, app_path = path.split("/", 1)
with open(frappe.get_app_path(app, app_path), encoding="utf-8") as f:
resolved_path = os.path.realpath(frappe.get_app_path(app, app_path))
app_root = os.path.realpath(frappe.get_app_path(app))
if not resolved_path.startswith(app_root + os.sep):
frappe.throw(frappe._("Security Error: The Path provided is not safe."))
with open(resolved_path, encoding="utf-8") as f:
include = f.read()
if path.endswith(".html"):
include = html_to_js_template(path, include)

View file

@ -483,7 +483,14 @@ frappe.ui.form.PrintView = class {
this.$print_format_body
.find("body")
.html(`<div class="print-format print-format-preview">${out.html}</div>`);
const iframeDoc = this.$print_format_body[0];
iframeDoc.querySelectorAll("svg[data-barcode-value]").forEach((el) => {
const get_options = frappe.ui.form.ControlBarcode.prototype.get_options.bind({
df: { options: el.dataset.options },
});
JsBarcode(el, el.dataset.barcodeValue, get_options(el.dataset.barcodeValue));
el.setAttribute("width", "100%");
});
this.show_footer();
this.$print_format_body.find(".print-format").css({

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