Merge branch 'develop' into reset-password-fix
This commit is contained in:
commit
8764dada2a
103 changed files with 16268 additions and 15666 deletions
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 }}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,6 +70,7 @@
|
|||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
1050
frappe/locale/ar.po
1050
frappe/locale/ar.po
File diff suppressed because it is too large
Load diff
1064
frappe/locale/bs.po
1064
frappe/locale/bs.po
File diff suppressed because it is too large
Load diff
1040
frappe/locale/cs.po
1040
frappe/locale/cs.po
File diff suppressed because it is too large
Load diff
1040
frappe/locale/da.po
1040
frappe/locale/da.po
File diff suppressed because it is too large
Load diff
1529
frappe/locale/de.po
1529
frappe/locale/de.po
File diff suppressed because it is too large
Load diff
1054
frappe/locale/eo.po
1054
frappe/locale/eo.po
File diff suppressed because it is too large
Load diff
1048
frappe/locale/es.po
1048
frappe/locale/es.po
File diff suppressed because it is too large
Load diff
1048
frappe/locale/fa.po
1048
frappe/locale/fa.po
File diff suppressed because it is too large
Load diff
1040
frappe/locale/fr.po
1040
frappe/locale/fr.po
File diff suppressed because it is too large
Load diff
1218
frappe/locale/hr.po
1218
frappe/locale/hr.po
File diff suppressed because it is too large
Load diff
1052
frappe/locale/hu.po
1052
frappe/locale/hu.po
File diff suppressed because it is too large
Load diff
1042
frappe/locale/id.po
1042
frappe/locale/id.po
File diff suppressed because it is too large
Load diff
1042
frappe/locale/it.po
1042
frappe/locale/it.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1040
frappe/locale/my.po
1040
frappe/locale/my.po
File diff suppressed because it is too large
Load diff
1050
frappe/locale/nb.po
1050
frappe/locale/nb.po
File diff suppressed because it is too large
Load diff
1044
frappe/locale/nl.po
1044
frappe/locale/nl.po
File diff suppressed because it is too large
Load diff
1040
frappe/locale/pl.po
1040
frappe/locale/pl.po
File diff suppressed because it is too large
Load diff
1040
frappe/locale/pt.po
1040
frappe/locale/pt.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1050
frappe/locale/ru.po
1050
frappe/locale/ru.po
File diff suppressed because it is too large
Load diff
1040
frappe/locale/sl.po
1040
frappe/locale/sl.po
File diff suppressed because it is too large
Load diff
1050
frappe/locale/sr.po
1050
frappe/locale/sr.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1090
frappe/locale/sv.po
1090
frappe/locale/sv.po
File diff suppressed because it is too large
Load diff
1046
frappe/locale/th.po
1046
frappe/locale/th.po
File diff suppressed because it is too large
Load diff
1046
frappe/locale/tr.po
1046
frappe/locale/tr.po
File diff suppressed because it is too large
Load diff
1050
frappe/locale/vi.po
1050
frappe/locale/vi.po
File diff suppressed because it is too large
Load diff
1050
frappe/locale/zh.po
1050
frappe/locale/zh.po
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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">`)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ frappe.ui.form.LinkSelector = class LinkSelector {
|
|||
fieldtype: "Data",
|
||||
fieldname: "txt",
|
||||
label: __("Beginning with"),
|
||||
description: __("You can use wildcard %"),
|
||||
},
|
||||
{
|
||||
fieldtype: "HTML",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)}">
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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", [
|
||||
|
|
|
|||
|
|
@ -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 }}" />
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue