Merge branch 'develop' into report-sticky-ui
This commit is contained in:
commit
a7484851fc
163 changed files with 86795 additions and 114217 deletions
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
2
.github/workflows/server-tests.yml
vendored
2
.github/workflows/server-tests.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
4
.github/workflows/ui-tests.yml
vendored
4
.github/workflows/ui-tests.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
0
frappe/core/doctype/security_settings/__init__.py
Normal file
0
frappe/core/doctype/security_settings/__init__.py
Normal file
20
frappe/core/doctype/security_settings/security_settings.js
Normal file
20
frappe/core/doctype/security_settings/security_settings.js
Normal 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>`
|
||||
);
|
||||
},
|
||||
});
|
||||
81
frappe/core/doctype/security_settings/security_settings.json
Normal file
81
frappe/core/doctype/security_settings/security_settings.json
Normal 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": []
|
||||
}
|
||||
116
frappe/core/doctype/security_settings/security_settings.py
Normal file
116
frappe/core/doctype/security_settings/security_settings.py
Normal 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"))
|
||||
|
|
@ -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}"
|
||||
270
frappe/core/doctype/security_settings/test_security_settings.py
Normal file
270
frappe/core/doctype/security_settings/test_security_settings.py
Normal 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
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: "Amazon Ember", 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 it’s 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: "Amazon Ember", 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 it’s 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]+)\)")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(")):
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
40038
frappe/locale/af.po
40038
frappe/locale/af.po
File diff suppressed because one or more lines are too long
1468
frappe/locale/ar.po
1468
frappe/locale/ar.po
File diff suppressed because it is too large
Load diff
1480
frappe/locale/bs.po
1480
frappe/locale/bs.po
File diff suppressed because it is too large
Load diff
1456
frappe/locale/cs.po
1456
frappe/locale/cs.po
File diff suppressed because it is too large
Load diff
1456
frappe/locale/da.po
1456
frappe/locale/da.po
File diff suppressed because it is too large
Load diff
1947
frappe/locale/de.po
1947
frappe/locale/de.po
File diff suppressed because it is too large
Load diff
1474
frappe/locale/eo.po
1474
frappe/locale/eo.po
File diff suppressed because it is too large
Load diff
1466
frappe/locale/es.po
1466
frappe/locale/es.po
File diff suppressed because it is too large
Load diff
1466
frappe/locale/fa.po
1466
frappe/locale/fa.po
File diff suppressed because it is too large
Load diff
40010
frappe/locale/fi.po
40010
frappe/locale/fi.po
File diff suppressed because one or more lines are too long
1458
frappe/locale/fr.po
1458
frappe/locale/fr.po
File diff suppressed because it is too large
Load diff
1634
frappe/locale/hr.po
1634
frappe/locale/hr.po
File diff suppressed because it is too large
Load diff
1472
frappe/locale/hu.po
1472
frappe/locale/hu.po
File diff suppressed because it is too large
Load diff
1460
frappe/locale/id.po
1460
frappe/locale/id.po
File diff suppressed because it is too large
Load diff
1460
frappe/locale/it.po
1460
frappe/locale/it.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
14766
frappe/locale/mn.po
14766
frappe/locale/mn.po
File diff suppressed because one or more lines are too long
1456
frappe/locale/my.po
1456
frappe/locale/my.po
File diff suppressed because it is too large
Load diff
1468
frappe/locale/nb.po
1468
frappe/locale/nb.po
File diff suppressed because it is too large
Load diff
1462
frappe/locale/nl.po
1462
frappe/locale/nl.po
File diff suppressed because it is too large
Load diff
1456
frappe/locale/pl.po
1456
frappe/locale/pl.po
File diff suppressed because it is too large
Load diff
1456
frappe/locale/pt.po
1456
frappe/locale/pt.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1470
frappe/locale/ru.po
1470
frappe/locale/ru.po
File diff suppressed because it is too large
Load diff
1456
frappe/locale/sl.po
1456
frappe/locale/sl.po
File diff suppressed because it is too large
Load diff
1468
frappe/locale/sr.po
1468
frappe/locale/sr.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1506
frappe/locale/sv.po
1506
frappe/locale/sv.po
File diff suppressed because it is too large
Load diff
15325
frappe/locale/ta.po
15325
frappe/locale/ta.po
File diff suppressed because one or more lines are too long
6605
frappe/locale/th.po
6605
frappe/locale/th.po
File diff suppressed because it is too large
Load diff
1464
frappe/locale/tr.po
1464
frappe/locale/tr.po
File diff suppressed because it is too large
Load diff
1468
frappe/locale/vi.po
1468
frappe/locale/vi.po
File diff suppressed because it is too large
Load diff
1468
frappe/locale/zh.po
1468
frappe/locale/zh.po
File diff suppressed because it is too large
Load diff
39962
frappe/locale/zh_TW.po
39962
frappe/locale/zh_TW.po
File diff suppressed because one or more lines are too long
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue