Merge branch 'develop' into reset-password-fix

This commit is contained in:
Shariq Ansari 2026-04-14 15:28:01 +05:30 committed by GitHub
commit 8764dada2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 16268 additions and 15666 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -42,7 +42,7 @@ class TestUser(IntegrationTestCase):
@staticmethod
def reset_password(user) -> str:
link = user.reset_password()
link = user._reset_password()
return parse_qs(urlparse(link).query)["key"][0]
def test_user_type(self):
@ -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)

View file

@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import re
from collections.abc import Iterable
from datetime import timedelta
from functools import cached_property
@ -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,
@ -1135,7 +1163,7 @@ def reset_password(user: str) -> str:
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)
user_doc._reset_password(send_email=True)
# For Administrator or disabled users: silently skip — same response below
except frappe.DoesNotExistError:
frappe.clear_messages()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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" data-toggle="dropdown" aria-label="{{ _("Notifications") }}" aria-haspopup="true">
<svg
class="icon icon-md"
class="icon icon-md" aria-hidden="true"
>
<use href="#icon-bell"></use>
</svg>
@ -50,8 +50,8 @@
</div>
</div>
</div>
<div class="desktop-avatar">
</div>
<button class="desktop-avatar btn-reset" aria-label="{{ _('User Menu') }}">
</button>
</div>
</header>

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1766,7 +1766,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 +1780,9 @@ class Document(BaseDocument):
# PERF: flt internally has to resolve this if we don't specify it.
rounding_method = frappe.get_system_settings("rounding_method")
for fieldname in fieldnames:
if do_not_round_fields and fieldname in do_not_round_fields:
continue
doc.set(
fieldname,
flt(

View file

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

View file

@ -380,15 +380,17 @@ frappe.Application = class Application {
logout() {
var me = this;
me.logged_out = true;
return frappe.call({
method: "logout",
callback: function (r) {
if (r.exc) {
return;
}
frappe.confirm(__("Are you sure you want to log out?"), function () {
return frappe.call({
method: "logout",
callback: function (r) {
if (r.exc) {
return;
}
me.redirect_to_login();
},
me.redirect_to_login();
},
});
});
}
handle_session_expired() {

View file

@ -1,4 +1,5 @@
frappe.ui.form.ControlFloat = class ControlFloat extends frappe.ui.form.ControlInt {
static input_mode = "decimal";
parse(value) {
value = this.eval_expression(value);
return isNaN(parseFloat(value)) ? null : flt(value, this.get_precision());

View file

@ -242,7 +242,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
) {
html +=
'<br><span class="small">' +
__(frappe.utils.escape_html(frappe.utils.html2text(d.description))) +
__(frappe.utils.html2text(frappe.utils.escape_html(d.description))) +
"</span>";
}
return $(`<div role="option">`)

View file

@ -105,6 +105,7 @@ frappe.ui.form.ControlMultiSelectList = class ControlMultiSelectList extends (
.concat(this._options)
.uniqBy((opt) => opt.value);
this.set_selectable_items(this._options);
this.$filter_input.trigger("focus");
});
});

View file

@ -27,7 +27,6 @@ frappe.ui.form.LinkSelector = class LinkSelector {
fieldtype: "Data",
fieldname: "txt",
label: __("Beginning with"),
description: __("You can use wildcard %"),
},
{
fieldtype: "HTML",

View file

@ -4,7 +4,8 @@ frappe.ui.get_print_settings = function (
letter_head,
pick_columns,
has_filters = false,
title = null
title = null,
default_print_format = null
) {
var print_settings = locals[":Print Settings"]["Print Settings"];
@ -31,6 +32,10 @@ frappe.ui.get_print_settings = function (
fieldname: "print_format",
label: __("Print Format"),
options: "Print Format",
default: default_print_format,
description: __(
"If no Print Format is selected, the default template for this report will be used."
),
get_query: () => ({
filters: {
print_format_for: "Report",
@ -43,7 +48,7 @@ frappe.ui.get_print_settings = function (
{
fieldtype: "Check",
fieldname: "with_letter_head",
label: __("With Letter head"),
label: __("With Letter Head"),
},
{
fieldtype: "Link",
@ -60,6 +65,7 @@ frappe.ui.get_print_settings = function (
label: __("Include filters"),
fieldtype: "Check",
fieldname: "include_filters",
depends_on: "eval: !doc.print_format",
});
}

View file

@ -341,7 +341,7 @@ frappe.ui.form.Toolbar = class Toolbar {
// Navigate
if (!this.frm.is_new() && !this.frm.meta.issingle) {
this.page.add_action_icon(
"es-line-left-chevron",
frappe.utils.is_rtl() ? "es-line-right-chevron" : "es-line-left-chevron",
() => {
this.frm.navigate_records(1);
},
@ -349,7 +349,7 @@ frappe.ui.form.Toolbar = class Toolbar {
__("Previous Document")
);
this.page.add_action_icon(
"es-line-right-chevron",
frappe.utils.is_rtl() ? "es-line-left-chevron" : "es-line-right-chevron",
() => {
this.frm.navigate_records(0);
},
@ -735,6 +735,12 @@ frappe.ui.form.Toolbar = class Toolbar {
.then((is_amended) => {
if (is_amended) {
this.page.clear_actions();
let btn = this.page.set_secondary_action(__("Amend"), () => {});
btn.prop("disabled", true)
.wrap('<span style="display:inline-block"></span>')
.parent()
.attr("title", __("Already amended as {0}", [is_amended]))
.tooltip({ delay: { show: 400, hide: 100 }, trigger: "hover" });
return;
}
this.set_page_actions(status);

View file

@ -984,6 +984,11 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
html = `<span class="ellipsis">
${_value}
</span>`;
} else if (df.fieldtype === "Percent") {
return `<div style="width: 100%;"
title="${__(label)}: ${frappe.utils.escape_html(_value)}">
${format()}
</div>`;
} else {
html = `<a class="${filterable} ellipsis"
data-filter="${fieldname},=,${frappe.utils.escape_html(value)}">

View file

@ -1,13 +1,38 @@
frappe.RoleEditor = class {
constructor(wrapper, frm, disable) {
/**
* Create a role editor for a form child table.
*
* @param {HTMLElement|JQuery} wrapper Container for the MultiCheck control.
* @param {frappe.ui.form.Form} frm Form whose role rows are edited.
* @param {boolean|number|Object} [disable=false] Disable role selection inputs, or pass role row configuration as the third argument.
* @param {Object} [options] Role row configuration overrides.
* @param {string} [options.table_fieldname="roles"] Child table field containing role rows.
* @param {string} [options.role_fieldname="role"] Field in each child row that stores the role value.
* @param {string} [options.child_doctype] Child DocType used when adding role rows. Defaults to the table field's configured child DocType or "Has Role".
*/
constructor(wrapper, frm, disable = false, options = {}) {
if (disable && typeof disable === "object") {
options = disable;
disable = false;
}
const { table_fieldname = "roles", role_fieldname = "role", child_doctype } = options;
const configured_child_doctype = frappe.meta.get_docfield(
frm.doctype,
table_fieldname
)?.options;
this.frm = frm;
this.wrapper = wrapper;
this.disable = disable;
let user_roles = this.frm.doc.roles ? this.frm.doc.roles.map((a) => a.role) : [];
this.disable = Boolean(disable);
this.table_fieldname = table_fieldname;
this.role_fieldname = role_fieldname;
this.child_doctype = child_doctype || configured_child_doctype || "Has Role";
let user_roles = this.get_selected_roles();
this.multicheck = frappe.ui.form.make_control({
parent: wrapper,
df: {
fieldname: "roles",
fieldname: this.table_fieldname,
fieldtype: "MultiCheck",
select_all: true,
columns: "15rem",
@ -130,25 +155,41 @@ frappe.RoleEditor = class {
}
reset() {
let user_roles = (this.frm.doc.roles || []).map((a) => a.role);
let user_roles = this.get_selected_roles();
this.multicheck.selected_options = user_roles;
this.multicheck.refresh_input();
}
set_roles_in_table() {
let roles = this.frm.doc.roles || [];
let roles = this.get_role_rows();
let checked_options = this.multicheck.get_checked_options();
roles.map((role_doc) => {
if (!checked_options.includes(role_doc.role)) {
roles.forEach((role_doc) => {
if (!checked_options.includes(this.get_role_value(role_doc))) {
frappe.model.clear_doc(role_doc.doctype, role_doc.name);
}
});
checked_options.map((role) => {
if (!roles.find((d) => d.role === role)) {
let role_doc = frappe.model.add_child(this.frm.doc, "Has Role", "roles");
role_doc.role = role;
checked_options.forEach((role) => {
if (!roles.find((d) => this.get_role_value(d) === role)) {
let role_doc = frappe.model.add_child(
this.frm.doc,
this.child_doctype,
this.table_fieldname
);
this.set_role_value(role_doc, role);
}
});
}
get_role_rows() {
return this.frm.doc[this.table_fieldname] || [];
}
get_selected_roles() {
return this.get_role_rows().map((row) => this.get_role_value(row));
}
get_role_value(row) {
return row[this.role_fieldname];
}
set_role_value(row, role) {
row[this.role_fieldname] = role;
}
get_roles() {
return {
checked_roles: this.multicheck.get_checked_options(),

View file

@ -173,8 +173,7 @@ frappe.router = {
route = ["Workspaces", frappe.workspaces[route[0]].name];
} else if (route[0] == "private") {
// private workspace
let private_workspace =
route[1] && frappe.router.slug(`${route[1]}-${frappe.user.name.toLowerCase()}`);
let private_workspace = route[1] && frappe.router.slug(`${route[1]}`);
if (!frappe.workspaces[private_workspace]) {
frappe.msgprint(
__("Workspace <b>{0}</b> does not exist", [

View file

@ -1,4 +1,4 @@
<a class="desktop-icon" data-id="{{ icon.label}}" data-logo="{{ icon.logo_url }}" data-icon="{{ icon.icon }}" data-type="{{ icon.type }}" style="text-decoration:none">
<a href="#" class="desktop-icon" data-id="{{ icon.label}}" data-logo="{{ icon.logo_url }}" data-icon="{{ icon.icon }}" data-type="{{ icon.type }}" style="text-decoration:none">
{% if(frappe.utils.get_desktop_icon(icon.label, frappe.boot.desktop_icon_style ) && icon.icon_type != "Folder") %}
<div class="icon-container">
<img class="app-icon" src="{{ frappe.utils.get_desktop_icon(icon.label, frappe.boot.desktop_icon_style) }}" alt="{{ icon.label }}" />

View file

@ -491,7 +491,10 @@ frappe.ui.filter_utils = {
const parsed = JSON.parse(val);
val = Array.isArray(parsed) ? parsed : [String(parsed)];
} catch {
val = val.split(",").map((v) => strip(v));
val = val
.split(",")
.map((v) => strip(v))
.filter((v) => v != null && v !== "");
}
}
} else if (frappe.boot.additional_filters_config[condition]) {

View file

@ -26,15 +26,21 @@ frappe.throw = function (msg) {
throw new Error(msg.message);
};
frappe.confirm = function (message, confirm_action, reject_action) {
frappe.confirm = function (
message,
confirm_action,
reject_action,
primary_label,
secondary_label
) {
var d = new frappe.ui.Dialog({
title: __("Confirm", null, "Title of confirmation dialog"),
primary_action_label: __("Yes", null, "Approve confirmation dialog"),
primary_action_label: __(primary_label || "Yes", null, "Approve confirmation dialog"),
primary_action: () => {
confirm_action && confirm_action();
d.hide();
},
secondary_action_label: __("No", null, "Dismiss confirmation dialog"),
secondary_action_label: __(secondary_label || "No", null, "Dismiss confirmation dialog"),
secondary_action: () => d.hide(),
});

View file

@ -34,7 +34,6 @@ frappe.ui.Notifications = class Notifications {
</span>`)
.on("click", (e) => {
e.stopImmediatePropagation();
console.log("what");
frappe.set_route("Form", "Notification Settings", frappe.session.user);
})
.appendTo(this.header_actions)
@ -224,15 +223,13 @@ class NotificationsView extends BaseNotificationsView {
.tooltip({ delay: { show: 600, hide: 100 }, trigger: "hover" });
this.setup_notification_listeners();
this.get_notifications_list(this.max_length).then((r) => {
if (!r.message) return;
this.dropdown_items = r.message.notification_logs;
frappe.update_user_info(r.message.user_info);
this.render_notifications_dropdown();
if (this.settings.seen == 0 && this.dropdown_items.length > 0) {
this.toggle_notification_icon(false);
}
});
this.dropdown_items = [];
this.notifications_fetched = false;
if (this.settings && this.settings.seen == 0) {
this.toggle_notification_icon(false);
}
}
update_dropdown() {
@ -347,7 +344,7 @@ class NotificationsView extends BaseNotificationsView {
<div class="full-log-btn">${__("See all Activity")}</div>
</a>`);
} else {
this.container.append(
this.container.html(
$(`<div class="notification-null-state">
<div class="text-center">
<img src="/assets/frappe/images/ui-states/notification-empty-state.svg" alt="Generic Empty State" class="null-state">
@ -408,6 +405,24 @@ class NotificationsView extends BaseNotificationsView {
});
this.parent.on("show.bs.dropdown", () => {
if (!this.notifications_fetched) {
this.container.html(`<div class="notification-null-state">
<div class="text-center">
<div class="spinner-border spinner-border-sm text-muted"></div>
</div>
</div>`);
this.get_notifications_list(this.max_length).then((r) => {
if (r.message && r.message.notification_logs) {
this.dropdown_items = r.message.notification_logs;
frappe.update_user_info(r.message.user_info);
} else {
this.dropdown_items = [];
}
this.render_notifications_dropdown();
this.notifications_fetched = true;
});
}
this.toggle_seen(true);
if (this.notifications_icon.find(".notifications-unseen").is(":visible")) {
this.toggle_notification_icon(true);
@ -421,20 +436,33 @@ class NotificationsView extends BaseNotificationsView {
class EventsView extends BaseNotificationsView {
make() {
let today = frappe.datetime.get_today();
frappe
.xcall(
"frappe.desk.doctype.event.event.get_events",
{
start: today,
end: today,
},
"GET",
{ cache: true }
)
.then((event_list) => {
this.render_events_html(event_list);
});
this.events_fetched = false;
this.parent.on("show.bs.dropdown", () => {
if (this.events_fetched) return;
this.container.html(`<div class="notification-null-state">
<div class="text-center">
<div class="spinner-border spinner-border-sm text-muted"></div>
</div>
</div>`);
let today = frappe.datetime.get_today();
frappe
.xcall(
"frappe.desk.doctype.event.event.get_events",
{
start: today,
end: today,
},
"GET",
{ cache: true }
)
.then((event_list) => {
this.render_events_html(event_list);
this.events_fetched = true;
});
});
}
render_events_html(event_list) {

View file

@ -195,7 +195,7 @@ frappe.ui.Sidebar = class Sidebar {
}
this.remove_onboarding_wrapper();
if (module_name) {
if (module_name && !frappe.is_mobile()) {
if (
this?.onboarding_widget[module_name] &&
this.onboarding_widget[module_name].hide_panel
@ -484,7 +484,11 @@ frappe.ui.Sidebar = class Sidebar {
type: "Button",
class: "sidebar-notification hidden",
onClick: () => {
this.wrapper.find(".dropdown-notifications").toggleClass("hidden");
const $dropdown = this.wrapper.find(".dropdown-notifications");
$dropdown.toggleClass("hidden");
if (!$dropdown.hasClass("hidden")) {
$dropdown.trigger("show.bs.dropdown");
}
if (frappe.is_mobile()) {
this.wrapper.removeClass("expanded");
}
@ -657,7 +661,7 @@ frappe.ui.Sidebar = class Sidebar {
if (module) {
sidebars = this.filter_sidebars_from_app(
sidebars,
frappe.boot.module_app[module.toLowerCase()]
frappe.boot.module_app[module.toLowerCase().replace(/[ -]/g, "_")]
);
}
if (sidebars.length == 1) {

View file

@ -10,7 +10,7 @@ frappe.ui.SidebarHeader = class SidebarHeader {
{
name: "desktop",
label: __("Desktop"),
icon: "layout-grid",
icon: "home",
onClick: function (el) {
frappe.set_route("/desk");
},

View file

@ -239,107 +239,114 @@ function markReset(step) {
</div>
</div>
<div v-if="!collapsed" class="body">
<div class="onb-title">
<div class="onb-title-icon" v-html="headerIcon"></div>
<div
class="onb-collapsible"
:class="collapsed ? 'onb-collapsible--collapsed' : 'onb-collapsible--expanded'"
>
<div class="body">
<div class="onb-title">
<div class="onb-title-icon" v-html="headerIcon"></div>
<div class="text-base font-medium">{{ title }}</div>
<div class="text-base font-medium">{{ title }}</div>
<div class="onb-title-steps">
{{ completedCount }}/{{ steps.length }} {{ __("steps completed") }}
</div>
</div>
<div class="onb-progress-row">
<div v-if="progress !== 100">
<div class="onb-progress-badge">{{ progress }}% {{ __("completed") }}</div>
</div>
<div v-else>
<div class="onb-progress-badge-complete">
{{ progress }}% {{ __("completed") }}
<div class="onb-title-steps">
{{ completedCount }}/{{ steps.length }} {{ __("steps completed") }}
</div>
</div>
<div v-if="skippAll">
<span class="onb-skip" @click="resetAll(steps)"> {{ __("Reset All") }}</span>
</div>
<div v-else>
<span class="onb-skip" @click="skipAll(steps)">{{ __("Skip All") }}</span>
</div>
</div>
<!-- Steps -->
<div class="onb-steps flex flex-col gap-2.5 overflow-hidden">
<div
style="width: 100%"
v-for="(step, i) in steps"
:key="i"
:class="{ is_complete: step.is_complete }"
>
<div
class="onb-group w-full step-title flex items-center"
style="align-items: center"
:class="
step.is_complete
? 'text-extra-muted onb-select-cursor'
: 'text-ink-gray-8 onb-select-cursor'
"
>
<div class="onb-step-left" @click="handleAction(step)">
<div class="onb-step-icon" v-if="step.is_complete">
<div v-html="completeChecklistIcon"></div>
</div>
<div class="onb-step-icon" v-else>
<div v-html="checklistIcon"></div>
</div>
<div v-if="!step.is_skipped">
<span
class="text-base onb-step-text"
:class="step.is_complete ? 'text-extra-muted' : ''"
>
{{ __(step.action_label) }}
</span>
</div>
<div v-else>
<span
class="text-base onb-step-text text-extra-muted"
style="text-decoration-line: line-through"
>
{{ __(step.action_label) }}
</span>
</div>
<div class="onb-progress-row">
<div v-if="progress !== 100">
<div class="onb-progress-badge">{{ progress }}% {{ __("completed") }}</div>
</div>
<div v-else>
<div class="onb-progress-badge-complete">
{{ progress }}% {{ __("completed") }}
</div>
</div>
<div v-if="!step.is_complete">
<div v-if="!step.is_skipped">
<div class="ml-auto onb-show-on-hover text-sm w-12 text-right">
<div v-if="skippAll">
<span class="onb-skip" @click="resetAll(steps)">
{{ __("Reset All") }}</span
>
</div>
<div v-else>
<span class="onb-skip" @click="skipAll(steps)">{{ __("Skip All") }}</span>
</div>
</div>
<!-- Steps -->
<div class="onb-steps flex flex-col gap-2.5 overflow-hidden">
<div
style="width: 100%"
v-for="(step, i) in steps"
:key="i"
:class="{ is_complete: step.is_complete }"
>
<div
class="onb-group w-full step-title flex items-center"
style="align-items: center"
:class="
step.is_complete
? 'text-extra-muted onb-select-cursor'
: 'text-ink-gray-8 onb-select-cursor'
"
>
<div class="onb-step-left" @click="handleAction(step)">
<div class="onb-step-icon" v-if="step.is_complete">
<div v-html="completeChecklistIcon"></div>
</div>
<div class="onb-step-icon" v-else>
<div v-html="checklistIcon"></div>
</div>
<div v-if="!step.is_skipped">
<span
style="
font-size: 12px;
vertical-align: text-top;
margin-right: 0px;
"
class="text-ink-gray-7"
@click="markSkip(step)"
class="text-base onb-step-text"
:class="step.is_complete ? 'text-extra-muted' : ''"
>
{{ __("Skip") }}
{{ __(step.action_label) }}
</span>
</div>
<div v-else>
<span
class="text-base onb-step-text text-extra-muted"
style="text-decoration-line: line-through"
>
{{ __(step.action_label) }}
</span>
</div>
</div>
<div v-if="step.is_skipped">
<div class="ml-auto onb-show-on-hover text-sm w-12 text-right">
<span
style="
font-size: 12px;
vertical-align: text-top;
margin-right: 0px;
"
class="text-ink-gray-7"
@click="markReset(step)"
>
{{ __("Reset") }}
</span>
<div v-if="!step.is_complete">
<div v-if="!step.is_skipped">
<div class="ml-auto onb-show-on-hover text-sm w-12 text-right">
<span
style="
font-size: 12px;
vertical-align: text-top;
margin-right: 0px;
"
class="text-ink-gray-7"
@click="markSkip(step)"
>
{{ __("Skip") }}
</span>
</div>
</div>
<div v-if="step.is_skipped">
<div class="ml-auto onb-show-on-hover text-sm w-12 text-right">
<span
style="
font-size: 12px;
vertical-align: text-top;
margin-right: 0px;
"
class="text-ink-gray-7"
@click="markReset(step)"
>
{{ __("Reset") }}
</span>
</div>
</div>
</div>
</div>

View file

@ -72,7 +72,7 @@ function addStyles() {
.onb-panel {
position: fixed;
left: 236px;
left: 66px;
bottom: 24px;
width: 310px;
max-height: 80vh;
@ -83,7 +83,30 @@ function addStyles() {
z-index: 1000;
display: flex;
flex-direction: column;
}
overflow-y: auto;
transition-property: all;
transition-duration: 0.3s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.expanded .onb-panel {
left: 236px;
}
.onb-collapsible {
overflow: hidden;
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.onb-collapsible--expanded {
max-height: 3000px;
pointer-events: auto;
}
.onb-collapsible--collapsed {
max-height: 0;
pointer-events: none;
}
.onb-header-main {
display: flex;

View file

@ -11,10 +11,12 @@ function prettyDate(date, mini) {
);
}
let diff =
(new Date(frappe.datetime.now_datetime().replace(/-/g, "/")).getTime() - date.getTime()) /
1000;
let day_diff = Math.floor(diff / 86400);
let now = new Date(frappe.datetime.now_datetime().replace(/-/g, "/"));
let diff = (now.getTime() - date.getTime()) / 1000;
let today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
let event_day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
let day_diff = Math.floor((today - event_day) / 86400000);
if (isNaN(day_diff) || day_diff < 0) return "";

View file

@ -275,7 +275,7 @@ frappe.breadcrumbs = {
clear() {
this.$breadcrumbs = $(".navbar-breadcrumbs").empty();
this.append_breadcrumb_element("/desk", frappe.utils.icon("monitor"));
this.append_breadcrumb_element("/desk", frappe.utils.icon("home"));
},
toggle(show) {

View file

@ -417,6 +417,10 @@ frappe.views.Calendar = class Calendar {
d.end = frappe.datetime.add_days(d.start, 1);
}
if (d.allDay && d.end) {
d.end = frappe.datetime.add_days(d.end, 1);
}
me.prepare_colors(d);
d.title = frappe.utils.html2text(d.title);

View file

@ -1941,7 +1941,9 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
(print_settings) => this.print_report(print_settings),
this.report_doc.letter_head,
this.get_visible_columns(),
true
true,
null,
this.report_doc.default_print_format
);
this.add_portrait_warning(dialog);
},

View file

@ -147,16 +147,36 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
}
set_link_title_field_value() {
let rows = this.datatable?.datamanager?.rows;
let link_col_indices = this.datatable?.datamanager?.columns
?.filter((c) => c.docfield?.fieldtype === "Link")
.map((c) => c.colIndex);
Object.keys(this.link_title_doctype_fields).forEach(async (key) => {
let link_title = await this.get_link_title_field_value(
this.link_title_doctype_fields[key],
key
);
if (link_title !== undefined) {
document.querySelectorAll(`a[data-name="${key}"]`).forEach((el) => {
el.innerHTML = link_title;
});
if (link_title === undefined) return;
// update visible DOM elements and cell tooltip
document.querySelectorAll(`a[data-name="${key}"]`).forEach((el) => {
if (el.innerHTML === link_title) return;
el.innerHTML = link_title;
$(el).closest(".dt-cell__content").attr("title", link_title);
});
if (rows?.length && link_col_indices?.length) {
for (let row of rows) {
for (let ci of link_col_indices) {
let cell = row[ci];
if (cell?.content === key && cell.html) {
cell.html = null;
}
}
}
}
});
}

View file

@ -4,6 +4,7 @@ export default class Widget {
constructor(opts) {
Object.assign(this, opts);
this.make();
this.apply_hidden_state();
}
refresh() {
@ -197,4 +198,10 @@ export default class Widget {
set_footer() {
//
}
apply_hidden_state() {
const is_hidden = Boolean(this.hidden);
const show_for_customize = is_hidden && this.in_customize_mode;
this.widget.toggleClass("hidden", is_hidden && !show_for_customize);
}
}

View file

@ -217,7 +217,7 @@ let fields = computed(() => {
return fields;
});
let print_templates = computed(() => {
let templates = print_format.value.__onload.print_templates || {};
let templates = print_format.value.__onload?.print_templates || [];
let out = [];
for (let template of templates) {
let df;

View file

@ -19,14 +19,19 @@ a {
a,
a:hover,
a:active,
a:focus,
.btn,
.btn:hover,
.btn:active,
.btn:focus {
.btn:active {
outline: 0;
}
a:focus-visible,
.btn:focus-visible,
.btn-reset:focus-visible {
box-shadow: var(--focus-default);
outline: none;
}
a.grey,
.sidebar-section a,
.control-value a,

View file

@ -43,9 +43,6 @@ def add_comment(
if not guest_allowed:
frappe.throw(_("Please login to post a comment."))
if frappe.db.exists("User", comment_email):
frappe.throw(_("Please login to post a comment."))
if not comment.strip():
frappe.msgprint(_("The comment cannot be empty"))
return False

View file

@ -525,15 +525,17 @@ class TestOperatorIn(IntegrationTestCase):
query = func_in(note.name, [None, "user1"])
sql_str = str(query).lower()
self.assertIn("coalesce", sql_str)
self.assertNotIn("coalesce", sql_str)
self.assertIn("is null", sql_str)
self.assertIn("''", sql_str)
def test_func_in_with_empty_string_uses_coalesce(self):
def test_func_in_with_empty_string_uses_or_is_null(self):
note = frappe.qb.DocType("Note")
query = func_in(note.name, ["", "user1"])
sql_str = str(query).lower()
self.assertIn("coalesce", sql_str)
self.assertNotIn("coalesce", sql_str)
self.assertIn("is null", sql_str)
self.assertIn("''", sql_str)
def test_func_in_with_mixed_none_and_values(self):
@ -541,7 +543,8 @@ class TestOperatorIn(IntegrationTestCase):
query = func_in(note.name, ["val1", None, "val2"])
sql_str = str(query).lower()
self.assertIn("coalesce", sql_str)
self.assertNotIn("coalesce", sql_str)
self.assertIn("is null", sql_str)
def test_in_filter_matches_null_and_empty_columns(self):
test_doctype = new_doctype(

View file

@ -515,6 +515,12 @@ app_license = "{app_license}"
# before_app_uninstall = "{app_name}.utils.before_app_uninstall"
# after_app_uninstall = "{app_name}.utils.after_app_uninstall"
# Build
# ------------------
# To hook into the build process
# after_build = "{app_name}.build.after_build"
# Desk Notifications
# ------------------
# See frappe.core.notifications.get_notification_config

View file

@ -174,7 +174,7 @@ def sanitize_html(html, linkify=False, always_sanitize=False, disallowed_tags=No
attributes = {"*": acceptable_attributes, "svg": svg_attributes}
# returns html with escaped tags, escaped orphan >, <, etc.
# returns sanitized HTML with unsafe tags and attributes removed
escaped_html = nh3.clean(
html,
tags=tags,

View file

@ -3,6 +3,7 @@ from typing import ClassVar
from bs4 import BeautifulSoup
import frappe
from frappe.utils.data import cint
from frappe.utils.pdf import get_host_url
from frappe.utils.print_utils import convert_uom, parse_float_and_unit
@ -10,6 +11,7 @@ from frappe.utils.print_utils import convert_uom, parse_float_and_unit
class Browser:
def __init__(self, generator, print_format, html, options):
self.is_print_designer = frappe.get_cached_value("Print Format", print_format, "print_designer")
self.debug_mode = frappe.conf.developer_mode and bool(frappe.form_dict.get("pdf_debug"))
self.browserID = frappe.utils.random_string(10)
generator.add_browser(self.browserID)
# sets soup from html
@ -31,7 +33,8 @@ class Browser:
# now wait for page to load as we need DOM to generate pdf
self.body_page.wait_for_set_content()
self.body_pdf = self.body_page.generate_pdf(raw=not self.header_page and not self.footer_page)
self.body_page.close()
if not self.debug_mode:
self.body_page.close()
self.update_header_footer_page()
if self.header_page:
@ -39,18 +42,23 @@ class Browser:
self.header_pdf = self.header_page.get_pdf_from_stream(self.header_page.get_pdf_stream_id())
else:
self.header_pdf = self.header_page.generate_pdf()
self.header_page.close()
if not self.debug_mode:
self.header_page.close()
if self.footer_page:
if not self.is_footer_dynamic:
self.footer_pdf = self.footer_page.get_pdf_from_stream(self.footer_page.get_pdf_stream_id())
else:
self.footer_pdf = self.footer_page.generate_pdf()
self.footer_page.close()
if not self.debug_mode:
self.footer_page.close()
self.close()
if not self.debug_mode:
self.close()
generator.remove_browser(self.browserID)
if self.debug_mode:
generator.detach_debug_browser()
def open(self, generator):
from frappe.utils.pdf_generator.cdp_connection import CDPSocketClient

View file

@ -9,6 +9,7 @@ import requests
import frappe
from frappe import _
from frappe.utils.data import cint
from frappe.utils.print_utils import find_or_download_chromium_executable
# TODO: close browser when worker is killed.
@ -69,11 +70,13 @@ class ChromePDFGenerator:
self.USE_PERSISTENT_CHROMIUM = site_config.get("use_persistent_chromium", False)
# time to wait for chromium to start and provide dev tools url used in _set_devtools_url.
self.START_TIMEOUT = site_config.get("chromium_start_timeout", 3)
# Allow a single PDF request to opt into interactive Chromium debugging in developer mode only.
self.debug_mode = frappe.conf.developer_mode and bool(frappe.form_dict.get("pdf_debug"))
self._chromium_path = find_or_download_chromium_executable()
if self._verify_chromium_installation():
if not self._devtools_url:
self.start_chromium_process()
self.start_chromium_process(debug=self.debug_mode)
def _verify_chromium_installation(self):
"""Ensures Chromium is available and executable, raising clearer errors if not."""
@ -231,6 +234,19 @@ class ChromePDFGenerator:
self._devtools_url = None
frappe.log("Headless Chromium closed successfully.")
def detach_debug_browser(self):
"""
Detach the generator from an interactive debug Chromium process.
This keeps the debug browser window available for inspection, while ensuring
the next PDF request starts with a fresh generator/process instead of reusing
the old debug session.
"""
ChromePDFGenerator._instance = None
self._initialized = False
self._chromium_process = None
self._devtools_url = None
# not used anywhere in the code. read _set_devtools_url for more info. useful in case we want to take different approch to fetch devtools url.
def fetch_devtools_url(self, port):
if not port:

View file

@ -95,6 +95,15 @@ def get_print(
)
# if hook returns a value, assume it was the correct pdf_generator and return it
if pdf:
if output and isinstance(pdf, bytes):
from io import BytesIO
from pypdf import PdfReader
reader = PdfReader(BytesIO(pdf))
for page in reader.pages:
output.add_page(page)
return output
return pdf
for hook in frappe.get_hooks("on_print_pdf"):

View file

@ -8,7 +8,6 @@ def _(msg: str, lang: str | None = None, context: str | None = None) -> str:
_('Change', context='Coins')
"""
from frappe.translate import get_all_translations
from frappe.utils import is_html, strip_html_tags
if not hasattr(frappe.local, "lang"):
frappe.local.lang = lang or "en"
@ -20,9 +19,6 @@ def _(msg: str, lang: str | None = None, context: str | None = None) -> str:
non_translated_string = msg
if is_html(msg):
msg = strip_html_tags(msg)
# msg should always be unicode
msg = frappe.as_unicode(msg).strip()

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