Merge branch 'develop' into setup-postgres-ci

This commit is contained in:
mergify[bot] 2025-11-14 05:36:48 +00:00 committed by GitHub
commit 4e50fc2dcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 10109 additions and 7233 deletions

View file

@ -78,12 +78,13 @@ def read_doc(doctype: str, name: str):
doc = frappe.get_doc(doctype, name)
doc.check_permission("read")
doc.apply_fieldlevel_read_permissions()
doc_dict = doc.as_dict()
if sbool(frappe.form_dict.get("expand_links")):
doc_dict = doc.as_dict()
get_values_for_link_and_dynamic_link_fields(doc_dict)
get_values_for_table_and_multiselect_fields(doc_dict)
return doc_dict
return doc_dict
return doc
def get_values_for_link_and_dynamic_link_fields(doc_dict):

View file

@ -85,14 +85,17 @@ frappe.ui.form.on("Auto Repeat", {
},
preview_message: function (frm) {
if (frm.is_dirty()) {
frappe.msgprint(__("Please save the form before previewing the message"));
return;
}
if (frm.doc.message) {
frappe.call({
method: "frappe.automation.doctype.auto_repeat.auto_repeat.generate_message_preview",
type: "POST",
args: {
reference_dt: frm.doc.reference_doctype,
reference_doc: frm.doc.reference_document,
subject: frm.doc.subject,
message: frm.doc.message,
name: frm.doc.name,
},
callback: function (r) {
if (r.message) {

View file

@ -605,14 +605,15 @@ def update_reference(docname: str, reference: str):
return "success" # backward compatbility
@frappe.whitelist()
def generate_message_preview(reference_dt, reference_doc, message=None, subject=None):
@frappe.whitelist(methods=["POST"])
def generate_message_preview(name: str):
frappe.has_permission("Auto Repeat", "write", throw=True)
doc = frappe.get_doc(reference_dt, reference_doc)
auto_repeat = frappe.get_doc("Auto Repeat", str(name))
doc = frappe.get_doc(auto_repeat.reference_doctype, auto_repeat.reference_document)
doc.check_permission()
subject_preview = _("Please add a subject to your email")
msg_preview = frappe.render_template(message, {"doc": doc})
if subject:
subject_preview = frappe.render_template(subject, {"doc": doc})
msg_preview = frappe.render_template(auto_repeat.message, {"doc": doc})
if auto_repeat.subject:
subject_preview = frappe.render_template(auto_repeat.subject, {"doc": doc})
return {"message": msg_preview, "subject": subject_preview}

View file

@ -34,9 +34,10 @@
}
],
"grid_page_length": 50,
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-05-21 17:09:55.054044",
"modified": "2025-10-29 11:26:28.177653",
"modified_by": "Administrator",
"module": "Core",
"name": "API Request Log",

View file

@ -63,9 +63,12 @@ class TestPreparedReport(IntegrationTestCase):
self.assertEqual(len(prepared_data["result"]), len(generated_data["result"]))
self.assertEqual(len(prepared_data), len(generated_data))
@run_only_if(db_type_is.MARIADB)
def test_start_status_and_kill_jobs(self):
with test_report(report_type="Query Report", query="select sleep(10)") as report:
if frappe.db.db_type == "postgres":
query = "select pg_sleep(5)"
elif frappe.db.db_type == "mariadb":
query = "select sleep(5)"
with test_report(report_type="Query Report", query=query) as report:
doc = self.create_prepared_report(report.name)
self.wait_for_status(doc, "Started")
job_id = doc.job_id

View file

@ -51,12 +51,15 @@
"column_break_34",
"allow_login_after_fail",
"two_factor_authentication",
"column_break_odhl",
"enable_two_factor_auth",
"bypass_2fa_for_retricted_ip_users",
"bypass_restrict_ip_check_if_2fa_enabled",
"column_break_bzfr",
"two_factor_method",
"lifespan_qrcode_image",
"otp_issuer_name",
"otp_sms_template",
"password_tab",
"password_settings",
"logout_on_password_reset",
@ -752,12 +755,27 @@
"fieldtype": "Select",
"label": "Show External Link Warning",
"options": "Never\nAsk\nAlways"
},
{
"fieldname": "column_break_odhl",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.enable_two_factor_auth==1 && doc.two_factor_method==\"SMS\"",
"description": "OTP placeholder should be defined as <code>{{ otp }}</code> ",
"fieldname": "otp_sms_template",
"fieldtype": "Small Text",
"label": "OTP SMS Template"
},
{
"fieldname": "column_break_bzfr",
"fieldtype": "Column Break"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2025-09-24 16:04:02.016562",
"modified": "2025-11-04 16:47:54.230874",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",

View file

@ -89,6 +89,7 @@ class SystemSettings(Document):
"#,###",
]
otp_issuer_name: DF.Data | None
otp_sms_template: DF.SmallText | None
password_reset_limit: DF.Int
rate_limit_email_link_login: DF.Int
reset_password_link_expiry_duration: DF.Duration | None
@ -145,6 +146,7 @@ class SystemSettings(Document):
self.validate_user_pass_login()
self.validate_backup_limit()
self.validate_file_extensions()
self.validate_otp_sms_template()
if not self.link_field_results_limit:
self.link_field_results_limit = 10
@ -156,6 +158,17 @@ class SystemSettings(Document):
_("{0} can not be more than {1}").format(label, 50), alert=True, indicator="yellow"
)
def validate_otp_sms_template(self):
if not self.enable_two_factor_auth or self.two_factor_method != "SMS" or not self.otp_sms_template:
return
if "{{otp}}" not in self.otp_sms_template.replace(" ", ""):
frappe.throw(
_("OTP SMS Template must contain <code>{0}</code> placeholder to insert the OTP.").format(
"{{otp}}"
)
)
def validate_user_pass_login(self):
if not self.disable_user_pass_login:
return

View file

@ -171,9 +171,13 @@ frappe.ui.form.on("Auto Email Report", {
.appendTo(row);
});
// remove mandatory but hidden filters from dialog
const dialog_filter_fields = report_filters.filter(
(f) => !(f.hidden == 1 && f.reqd == 1)
);
table.on("click", function () {
dialog = new frappe.ui.Dialog({
fields: report_filters,
fields: dialog_filter_fields,
primary_action: function () {
var values = this.get_values();
if (values) {

View file

@ -124,7 +124,9 @@ class AutoEmailReport(Document):
filters = frappe.parse_json(self.filters) if self.filters else {}
filter_meta = frappe.parse_json(self.filter_meta) if self.filter_meta else {}
throw_list = [
meta["label"] for meta in filter_meta if meta.get("reqd") and not filters.get(meta["fieldname"])
meta["label"]
for meta in filter_meta
if (meta.get("reqd") and (not meta.get("hidden"))) and not filters.get(meta["fieldname"])
]
if throw_list:
frappe.throw(

View file

@ -105,6 +105,14 @@ frappe.notification = {
"options",
[""].concat(["owner"]).concat(receiver_fields)
);
// set options for "From Attach Field"
let attach_fields = fields.filter((d) => d.fieldtype === "Attach");
let attach_options = $.map(attach_fields, function (d) {
return get_select_options(d);
});
frm.set_df_property("from_attach_field", "options", [""].concat(attach_options));
});
},
setup_example_message: function (frm) {

View file

@ -48,7 +48,10 @@
"view_properties",
"column_break_25",
"attach_print",
"print_format"
"print_format",
"column_break_llcs",
"attach_files",
"from_attach_field"
],
"fields": [
{
@ -260,7 +263,7 @@
"collapsible_depends_on": "attach_print",
"fieldname": "column_break_25",
"fieldtype": "Section Break",
"label": "Print Settings"
"label": "Attachment Settings"
},
{
"default": "0",
@ -337,13 +340,30 @@
"fieldname": "filters_section",
"fieldtype": "Section Break",
"label": "Filters"
},
{
"fieldname": "column_break_llcs",
"fieldtype": "Column Break"
},
{
"fieldname": "attach_files",
"fieldtype": "Select",
"label": "Attach Files",
"options": "\nFrom Field\nAll"
},
{
"depends_on": "eval:doc.attach_files === \"From Field\"",
"fieldname": "from_attach_field",
"fieldtype": "Select",
"label": "From Attach Field",
"mandatory_depends_on": "eval:doc.attach_files === \"From Field\""
}
],
"grid_page_length": 50,
"icon": "fa fa-envelope",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-10-21 18:54:38.454748",
"modified": "2025-10-21 23:14:52.345857",
"modified_by": "Administrator",
"module": "Email",
"name": "Notification",

View file

@ -34,6 +34,7 @@ class Notification(Document):
from frappe.email.doctype.notification_recipient.notification_recipient import NotificationRecipient
from frappe.types import DF
attach_files: DF.Literal["", "From Field", "All"]
attach_print: DF.Check
channel: DF.Literal["Email", "Slack", "System Notification", "SMS"]
condition: DF.Code | None
@ -59,6 +60,7 @@ class Notification(Document):
"Custom",
]
filters: DF.Code | None
from_attach_field: DF.Literal[None]
is_standard: DF.Check
message: DF.Code | None
message_type: DF.Literal["Markdown", "HTML", "Plain Text"]
@ -166,6 +168,9 @@ class Notification(Document):
if self.event == "Value Change" and not self.value_changed:
frappe.throw(_("Please specify which value field must be checked"))
if self.attach_files == "From Field" and not self.from_attach_field:
frappe.throw(_("Please specify the field from which to attach files"))
self.validate_forbidden_document_types()
self.validate_condition()
self.validate_filters()
@ -445,7 +450,7 @@ def get_context(context):
"subject": subject,
"from_user": doc.modified_by or doc.owner,
"email_content": frappe.render_template(self.message, context),
"attached_file": attachments and json.dumps(attachments[0]),
"attached_file": json.dumps(attachments) if attachments else None,
}
enqueue_create_notification(users, notification_doc)
@ -490,6 +495,13 @@ def get_context(context):
comm = frappe.get_lazy_doc("Communication", communication)
comm.get_outgoing_email_account()
# We expect at most one print format attachment, but we don't know where it is.
print_letterhead = any(
attachment.get("print_letterhead")
for attachment in attachments
if attachment.get("print_format_attachment") == 1
)
frappe.sendmail(
recipients=recipients,
subject=subject,
@ -501,7 +513,7 @@ def get_context(context):
reference_name=get_reference_name(doc),
attachments=attachments,
expose_recipients="header",
print_letterhead=((attachments and attachments[0].get("print_letterhead")) or False),
print_letterhead=print_letterhead,
communication=communication,
)
@ -624,10 +636,28 @@ def get_context(context):
return list(set(receiver_list))
def get_attachment(self, doc):
"""check print settings are attach the pdf"""
if not self.attach_print:
return None
def get_attachment(self, doc) -> list[dict]:
"""Check Attachment Settings and return attachments accordingly"""
attachments = []
if self.attach_print:
attachments.append(self.get_print(doc))
if self.attach_files == "From Field" and self.from_attach_field:
attachments.append({"file_url": doc.get(self.from_attach_field)})
elif self.attach_files == "All":
attachments.extend(
frappe.get_all(
"File",
fields=["file_url"],
filters={"attached_to_doctype": self.document_type, "attached_to_name": doc.name},
)
)
return attachments
def get_print(self, doc):
"""check print settings and return dict with print info"""
print_settings = frappe.get_doc("Print Settings", "Print Settings")
if (doc.docstatus == 0 and not print_settings.allow_print_for_draft) or (
@ -642,18 +672,17 @@ def get_context(context):
title=_("Error in Notification"),
)
else:
return [
{
"print_format_attachment": 1,
"doctype": doc.doctype,
"name": doc.name,
"print_format": self.print_format,
"print_letterhead": print_settings.with_letterhead,
"lang": frappe.db.get_value("Print Format", self.print_format, "default_print_language")
if self.print_format
else "en",
}
]
return {
"print_format_attachment": 1,
"doctype": doc.doctype,
"name": doc.name,
"print_format": self.print_format,
"print_letterhead": print_settings.with_letterhead,
"lang": doc.get("language")
or frappe.db.get_value("Print Format", self.print_format, "default_print_language")
if self.print_format
else "en",
}
def get_template(self, md_as_html=False):
module = get_doc_module(self.module, self.doctype, self.name)

View file

@ -25,6 +25,51 @@ def get_test_notification(config):
frappe.db.commit()
@contextmanager
def get_test_doctype_with_attach_field(doctype_name="Test Attach Doctype"):
"""Create a temporary doctype with an attach field for testing."""
try:
# Create custom doctype with attach field
if not frappe.db.exists("DocType", doctype_name):
frappe.get_doc(
{
"doctype": "DocType",
"name": doctype_name,
"module": "Core",
"custom": 1,
"fields": [
{"label": "Title", "fieldname": "title", "fieldtype": "Data", "reqd": 1},
{"label": "Attachment", "fieldname": "attachment", "fieldtype": "Attach"},
],
"permissions": [
{"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}
],
}
).insert()
yield doctype_name
finally:
frappe.db.delete(doctype_name)
frappe.delete_doc_if_exists("DocType", doctype_name, force=1)
frappe.db.commit()
@contextmanager
def create_test_file(file_name="test_attachment.txt", content="Test file content"):
"""Create a test file and return its File document."""
try:
file_doc = frappe.get_doc(
{"doctype": "File", "file_name": file_name, "content": content, "is_private": 0}
).insert()
yield file_doc
finally:
frappe.delete_doc_if_exists("File", file_doc.name, force=1)
frappe.db.commit()
class TestNotification(IntegrationTestCase):
def setUp(self):
frappe.db.delete("Email Queue")
@ -465,6 +510,159 @@ class TestNotification(IntegrationTestCase):
user.save()
self.assertEqual(1, frappe.db.count("Notification Log", {"subject": n.subject}))
def test_attach_files_from_field(self):
"""Test notification with 'From Field' attachment option."""
frappe.db.delete("Email Queue")
with get_test_doctype_with_attach_field("Test From Field Doctype") as test_doctype:
with create_test_file("from_field_test.txt", "Content from specific field") as test_file:
# Create notification with "From Field" attachment
notification_config = {
"name": "Test From Field Attachment",
"subject": "Test From Field Attachment",
"document_type": test_doctype,
"event": "Save",
"message": "Document saved with attachment",
"channel": "Email",
"attach_files": "From Field",
"from_attach_field": "attachment",
"recipients": [{"receiver_by_document_field": "owner"}],
}
with get_test_notification(notification_config):
# Create document with attachment
test_doc = frappe.get_doc(
{
"doctype": test_doctype,
"title": "Test Document with Attachment",
"attachment": test_file.file_url,
}
).insert()
# Trigger save event to send notification
test_doc.save()
# Verify Email Queue created
email_queue = frappe.get_doc(
"Email Queue", {"reference_doctype": test_doctype, "reference_name": test_doc.name}
)
self.assertTrue(email_queue, "Email Queue not created")
# Verify attachment metadata in Email Queue
attachments = json.loads(email_queue.attachments) if email_queue.attachments else []
self.assertEqual(len(attachments), 1, "Expected exactly one attachment")
self.assertEqual(
attachments[0].get("file_url"), test_file.file_url, "Attachment URL doesn't match"
)
# Clean up test document
test_doc.delete()
def test_attach_files_all(self):
"""Test notification with 'All' attachment option."""
frappe.db.delete("Email Queue")
with get_test_doctype_with_attach_field("Test All Attachments Doctype") as test_doctype:
with create_test_file("all_test_1.txt", "First file content") as test_file1:
# Create notification with "All" attachment option
notification_config = {
"name": "Test All Attachments",
"subject": "Test All Attachments",
"document_type": test_doctype,
"event": "Save",
"message": "Document saved with all attachments",
"channel": "Email",
"attach_files": "All",
"recipients": [{"receiver_by_document_field": "owner"}],
}
with get_test_notification(notification_config):
# Create document with one attachment
test_doc = frappe.get_doc(
{
"doctype": test_doctype,
"title": "Test Document with Multiple Attachments",
"attachment": test_file1.file_url,
}
).insert()
# Upload additional file that is not attached to a specific field
with create_test_file(
"additional_file.txt", "Additional file content"
) as additional_file:
additional_file.attached_to_doctype = test_doctype
additional_file.attached_to_name = test_doc.name
additional_file.save()
# Trigger save event to send notification
test_doc.save()
# Verify Email Queue created
email_queue = frappe.get_doc(
"Email Queue",
{"reference_doctype": test_doctype, "reference_name": test_doc.name},
)
self.assertTrue(email_queue, "Email Queue not created")
# Verify all attachments in Email Queue
attachments = json.loads(email_queue.attachments) if email_queue.attachments else []
# Should have the additional file that's directly attached to the document
self.assertEqual(len(attachments), 2, "Expected exactly two attachments")
# Verify the additional file is included
attachment_urls = [att.get("file_url") for att in attachments]
self.assertIn(
test_file1.file_url, attachment_urls, "First file not found in attachments"
)
self.assertIn(
additional_file.file_url,
attachment_urls,
"Additional file not found in attachments",
)
test_doc.delete()
def test_attach_files_empty_option(self):
"""Test notification with empty attachment option (no attachments)."""
frappe.db.delete("Email Queue")
# Create notification with empty attach_files option
notification_config = {
"name": "Test No Attachments",
"subject": "Test No Attachments",
"document_type": "ToDo",
"event": "Save",
"message": "Todo saved without attachments",
"channel": "Email",
"attach_print": 0,
"attach_files": "", # Empty option
"recipients": [{"receiver_by_document_field": "owner"}],
}
with get_test_notification(notification_config):
# Create a ToDo
todo = frappe.new_doc("ToDo")
todo.description = "Test ToDo"
todo.insert()
todo.save()
# Verify Email Queue created
email_queue = frappe.get_doc(
"Email Queue", {"reference_doctype": "ToDo", "reference_name": todo.name}
)
self.assertTrue(email_queue, "Email Queue not created")
# Verify no attachments in Email Queue (or empty list)
attachments = json.loads(email_queue.attachments) if email_queue.attachments else []
self.assertEqual(len(attachments), 0, "Expected no attachments")
# Clean up
todo.delete(ignore_permissions=True)
@classmethod
def tearDownClass(cls):
frappe.delete_doc_if_exists("Notification", "ToDo Status Update")

View file

@ -10,6 +10,7 @@ from babel.messages.catalog import Catalog
from babel.messages.extract import DEFAULT_KEYWORDS, extract_from_dir
from babel.messages.mofile import read_mo, write_mo
from babel.messages.pofile import read_po, write_po
from click import secho
import frappe
from frappe.utils import get_bench_path
@ -136,6 +137,7 @@ def generate_pot(target_app: str | None = None):
for app in apps:
app_path = frappe.get_pymodule_path(app, "..")
catalog = new_catalog(app)
ignored_strings = _get_ignored_strings(app)
# Each file will only be processed by the first method that matches,
# so more specific methods should come first.
@ -148,12 +150,63 @@ def generate_pot(target_app: str | None = None):
if not message:
continue
if (message, context) in ignored_strings:
continue
catalog.add(message, locations=[(filename, lineno)], auto_comments=comments, context=context)
pot_path = write_catalog(app, catalog)
print(f"POT file created at {pot_path}")
def _get_ignored_strings(app: str) -> set[tuple[str, str | None]]:
"""Return a set of tuples (message, context) that should be excluded from the given app's POT file.
Example:
If [app]/hooks.py contains:
ignore_translatable_strings_from = ["frappe"]
Then this will return a set of tuples (message, context) with all
entries from frappe's POT file.
"""
ignored_strings = set()
for ignore_app in frappe.get_hooks("ignore_translatable_strings_from", [], app_name=app):
if ignore_app == app:
raise ValueError(
f"Invalid configuration: App '{app}' cannot ignore its own translatable strings. "
f"Remove '{app}' from the 'ignore_translatable_strings_from' hook in {app}/hooks.py to fix this."
)
try:
catalog = get_catalog(ignore_app)
except ModuleNotFoundError:
secho(
f"App '{ignore_app}' specified in '{app}/hooks.py' 'ignore_translatable_strings_from' hook is not installed. Skipping",
err=True,
fg="yellow",
)
continue
except ImportError:
secho(
f"App '{ignore_app}' specified in '{app}/hooks.py' 'ignore_translatable_strings_from' hook could not be imported. Skipping",
err=True,
fg="yellow",
)
continue
except AttributeError:
secho(
f"Site not initialized. Cannot load app '{ignore_app}' specified in '{app}/hooks.py' 'ignore_translatable_strings_from' hook. Skipping",
err=True,
fg="yellow",
)
continue
for message in catalog:
ignored_strings.add((message.id, message.context))
return ignored_strings
def get_is_gitignored_function_for_app(app: str | None):
"""
Used to check if a directory is gitignored or not.

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

@ -1,7 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import os
from pathlib import Path
from werkzeug.exceptions import NotFound
from werkzeug.middleware.shared_data import SharedDataMiddleware
@ -18,11 +18,12 @@ class StaticDataMiddleware(SharedDataMiddleware):
def get_directory_loader(self, directory):
def loader(path):
site = get_site_name(frappe.app._site or self.environ.get("HTTP_HOST"))
path = os.path.join(directory, site, "public", "files", cstr(path))
if os.path.isfile(path):
return os.path.basename(path), self._opener(path)
else:
files_path = Path(directory) / site / "public" / "files"
requested_path = Path(cstr(path))
path = (files_path / requested_path).resolve()
if not path.is_relative_to(files_path) or not path.is_file():
raise NotFound
# return None, None
return path.name, self._opener(path)
return loader

View file

@ -1703,10 +1703,14 @@ class Document(BaseDocument):
return view_log
def log_error(self, title=None, message=None):
def log_error(self, title=None, message=None, *, defer_insert=False):
"""Helper function to create an Error Log"""
return frappe.log_error(
message=message, title=title, reference_doctype=self.doctype, reference_name=self.name
message=message,
title=title,
reference_doctype=self.doctype,
reference_name=self.name,
defer_insert=defer_insert,
)
def get_signature(self):

View file

@ -6,6 +6,7 @@ Utilities for using modules
import json
import os
from pathlib import Path
from textwrap import dedent, indent
from typing import TYPE_CHECKING, Union
@ -200,7 +201,11 @@ def scrub_dt_dn(dt: str, dn: str) -> tuple[str, str]:
def get_doc_path(module: str, doctype: str, name: str) -> str:
"""Return path of a doc in a module."""
return os.path.join(get_module_path(module), *scrub_dt_dn(doctype, name))
module_path = Path(get_module_path(module))
path = module_path / Path(*scrub_dt_dn(doctype, name))
if not path.resolve().is_relative_to(module_path.resolve()):
raise ValueError(_("Path {0} is not within module {1}").format(path, module))
return path.resolve()
def reload_doc(

View file

@ -30,7 +30,7 @@ let display_checked = computed(() => {
type="checkbox"
:checked="display_checked"
:disabled="read_only"
@change="(event) => $emit('update:modelValue', event.target.checked)"
@change="(event) => $emit('update:modelValue', event.target.checked ? 1 : 0)"
/>
<span class="label-area" :class="{ reqd: df.reqd }">{{ __(df.label) }}</span>
</label>

View file

@ -155,7 +155,11 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control
} else {
value = this.value || value;
}
if (["Data", "Long Text", "Small Text", "Text", "Password"].includes(this.df.fieldtype)) {
if (
["Data", "Long Text", "Small Text", "Text", "Password", "MultiSelect"].includes(
this.df.fieldtype
)
) {
value = frappe.utils.escape_html(value);
}
let doc = this.doc || (this.frm && this.frm.doc);

View file

@ -103,6 +103,7 @@ frappe.ui.form.ControlDuration = class ControlDuration extends frappe.ui.form.Co
});
this.$input.on("focus", () => {
if (this.df.read_only) return;
this.$picker.show();
let is_picker_set = this.is_duration_picker_set(this.inputs);
if (!is_picker_set) {

View file

@ -1121,14 +1121,14 @@ export default class GridRow {
this.columns[df.fieldname] = $col;
this.columns_list.push($col);
if (ci == 0 && !this.header_row) {
if (ci == 0 && this.header_row) {
$col.attr("tabIndex", 0);
$col.on("focus", function () {
if (me.grid.grid_rows.length == 0) {
me.grid.add_new_row();
}
me.grid.grid_rows[me.grid.grid_rows.length - 1].toggle_editable_row(true);
me.grid.set_focus_on_row();
me.grid.set_focus_on_row(0);
$col.attr("tabIndex", "");
});
}
@ -1296,9 +1296,13 @@ export default class GridRow {
if (is_last_column) {
// last row
if (me.doc.idx === values.length) {
me.grid.add_new_row(null, null, true);
me.grid.grid_rows[me.grid.grid_rows.length - 1].toggle_editable_row();
me.grid.set_focus_on_row();
setTimeout(function () {
me.grid.add_new_row(null, null, true);
me.grid.grid_rows[
me.grid.grid_rows.length - 1
].toggle_editable_row();
me.grid.set_focus_on_row();
}, 100);
} else {
// last column before last row
me.grid.grid_rows[me.doc.idx].toggle_editable_row();

View file

@ -129,7 +129,9 @@ frappe.ui.form.Layout = class Layout {
// Add block color and append to parent container `form-message-container`
const block_color =
color && ["yellow", "blue", "red", "green", "orange"].includes(color) ? color : "blue";
color && ["yellow", "blue", "red", "green", "orange", "white"].includes(color)
? color
: "blue";
$html.addClass(block_color).appendTo(this.message);
// Show parent container if hidden

View file

@ -26,7 +26,11 @@ frappe.ui.form.Toolbar = class Toolbar {
this.page.hide_menu();
this.print_icon && this.print_icon.addClass("hide");
} else {
this.page.show_menu();
if (this.page.menu.children().length > 0) {
this.page.show_menu();
} else {
this.page.hide_menu();
}
this.print_icon && this.print_icon.removeClass("hide");
}
}
@ -300,11 +304,33 @@ frappe.ui.form.Toolbar = class Toolbar {
this.page.clear_menu();
if (frappe.boot.desk_settings.form_sidebar) {
// this.make_navigation();
this.make_navigation();
this.make_menu_items();
}
}
make_navigation() {
// Navigate
if (!this.frm.is_new() && !this.frm.meta.issingle) {
this.page.add_action_icon(
"es-line-left-chevron",
() => {
this.frm.navigate_records(1);
},
"prev-doc",
__("Previous Document")
);
this.page.add_action_icon(
"es-line-right-chevron",
() => {
this.frm.navigate_records(0);
},
"next-doc",
__("Next Document")
);
}
}
make_menu_items() {
// Print
this.add_discard();

View file

@ -72,10 +72,8 @@
</div>
</ul>
</div>
<div class="sidebar-label">
<div class="sidebar-action show-tags">
<a class="list-tag-preview">{{ __("Show Tags") }}</a>
</div>
<div class="sidebar-action show-tags">
<a class="list-tag-preview">{{ __("Show Tags") }}</a>
</div>
</div>
</div>

View file

@ -997,7 +997,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
* If the length of the text is not available, it defaults to a length of 22.5.
*/
let textLength = $(column_html).text()?.trim()?.length || 22.5;
let calculatedWidth = (textLength * 10) / 1.3;
let calculatedWidth = (textLength * 10) / 1.3 + (col.type == "Subject" ? 30 : 0);
/**
* Updates the `column_max_widths` object by setting the maximum width for a specific column (fieldname).

View file

@ -60,8 +60,12 @@ $.extend(frappe.perm, {
perm[0].read = 1;
}
if (!meta) return perm;
if (!meta) {
if (frappe.boot.user.can_read.includes(doctype)) {
perm[0].read = 1;
}
return perm;
}
perm = frappe.perm.get_role_permissions(meta);
const base_perm = perm[0];

View file

@ -52,7 +52,7 @@
</div>
<button class="btn btn-secondary btn-default btn-sm hide"></button>
<div class="actions-btn-group hide">
<button type="button" class="btn btn-primary btn-sm" data-toggle="dropdown" aria-expanded="false">
<button type="button" class="btn btn-primary btn-sm justify-center" data-toggle="dropdown" aria-expanded="false">
<span>
<span class="hidden-xs actions-btn-group-label">{%= __("Actions") %}</span>
<svg class="icon icon-xs">

View file

@ -56,9 +56,6 @@ frappe.views.CommunicationComposer = class {
fieldname: "recipients",
default: this.get_default_recipients("recipients"),
ignore_validation: true,
onchange: function () {
me.sanitize_emails(this);
},
},
{
fieldtype: "Button",
@ -79,9 +76,6 @@ frappe.views.CommunicationComposer = class {
fieldname: "cc",
default: this.get_default_recipients("cc"),
ignore_validation: true,
onchange: function () {
me.sanitize_emails(this);
},
},
{
label: __("BCC", null, "Email Recipients"),
@ -89,9 +83,6 @@ frappe.views.CommunicationComposer = class {
fieldname: "bcc",
default: this.get_default_recipients("bcc"),
ignore_validation: true,
onchange: function () {
me.sanitize_emails(this);
},
},
{
label: __("Schedule Send At"),
@ -986,16 +977,4 @@ frappe.views.CommunicationComposer = class {
const text = frappe.utils.html2text(html);
return text.replace(/\n{3,}/g, "\n\n");
}
sanitize_emails(control) {
let emails = control.get_value();
if (!emails) return;
let sanitized = emails
.split(",")
.map((email) => frappe.utils.xss_sanitise(email.trim()))
.join(",");
if (sanitized != emails) {
control.set_value(sanitized);
}
}
};

View file

@ -726,8 +726,6 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
)
.map(({ fieldname, fieldtype, options }) => ({ fieldname, fieldtype, options }));
console.log(js_filters, "js_filters");
this.last_ajax = frappe.call({
method: "frappe.desk.query_report.run",
type: "GET",

View file

@ -424,7 +424,7 @@ textarea.form-control {
/* duration control */
.duration-picker {
position: absolute;
position: fixed;
z-index: 999;
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
@ -470,7 +470,7 @@ textarea.form-control {
width: 55px;
border: none;
color: var(--text-color);
background-color: var(--control-bg);
background-color: var(--bg-gray);
border-radius: var(--border-radius);
padding: 4px 8px;
}
@ -486,6 +486,22 @@ textarea.form-control {
.picker-row {
display: flex;
margin: var(--margin-sm);
.col {
&:last-child {
.grid-row & {
// remove flex and justify content added by grid context
display: block;
justify-content: initial;
}
}
.grid-row & {
// remove border and padding added by grid context
border-right: none;
padding: unset;
}
}
}
}

View file

@ -15,6 +15,7 @@
.for-description {
@include get_textstyle("sm", "regular");
}
min-height: var(--input-height);
border-radius: var(--border-radius-sm);
padding: 4px 8px;

View file

@ -12,7 +12,7 @@ body {
.checkbox {
label {
display: inline-flex;
align-items: start;
align-items: center;
margin-bottom: 0;
}
--checkbox-right-margin: 8px;

View file

@ -58,7 +58,6 @@
.grid-static-col,
.row-check,
.row-index {
height: 32px;
padding: 4px 8px !important;
background-color: var(--subtle-fg);
}
@ -188,6 +187,7 @@
input {
margin-right: 0 !important;
margin-bottom: -3px;
margin-top: 5px;
}
&.search {
@ -314,7 +314,6 @@
.form-control:focus {
border-color: $text-muted;
z-index: 2;
}
.has-error .form-control {
@ -687,9 +686,7 @@
&:focus-visible {
@include grid-focus();
}
.grid-static-col {
height: 38px;
}
.grid-static-col.col-xs-1 {
flex: 1 0 60px;
width: 60px;

View file

@ -98,7 +98,7 @@
}
.frappe-list {
margin: var(--margin-xs) var(--margin-md);
margin: 0 var(--margin-md);
.result.has-assign-to {
.list-row .level-right {
flex: 0 0 180px;
@ -620,7 +620,7 @@ input.list-header-checkbox {
@media (max-width: map-get($grid-breakpoints, "sm")) {
.layout-main-section .frappe-list .result-container {
.result {
overflow-x: hidden;
overflow: hidden;
input.list-row-checkbox,
input.list-header-checkbox {
width: 15px !important;

View file

@ -127,7 +127,7 @@
.form-group {
padding: 0 10px 0 0;
margin: 5px 0;
margin: 8px 0;
}
.checkbox {
margin-top: 4px;

View file

@ -87,7 +87,8 @@ body {
.field-icon {
left: 9px;
top: 5px;
top: 50%;
transform: translateY(-50%);
position: absolute;
z-index: 2;
fill: var(--text-light);

View file

@ -31,6 +31,7 @@ from frappe.utils import (
get_bench_path,
get_file_timestamp,
get_gravatar,
get_link_to_report,
get_site_info,
get_sites,
get_url,
@ -343,6 +344,13 @@ class TestFilters(IntegrationTestCase):
self.assertTrue(compare(None, "is", "Not Set"))
self.assertTrue(compare(None, "is", "not set"))
def test_get_link_to_report_with_between_filter(self):
filters = {
"creation": [["between", ["2024-01-01", "2024-12-31"]]],
}
link = get_link_to_report(name="ToDo", filters=filters)
self.assertIn('creation=["between",["2024-01-01","2024-12-31"]]', link)
class TestMoney(IntegrationTestCase):
def test_money_in_words(self):

View file

@ -210,6 +210,8 @@ def get_translation_dict_from_file(path, lang, app, throw=False) -> dict[str, st
csv_content = read_csv_file(path)
for item in csv_content:
item[0] = item[0].replace("\\n", "\n")
item[1] = item[1].replace("\\n", "\n")
if len(item) == 3 and item[2]:
key = item[0] + ":" + item[2]
translation_map[key] = strip(item[1])

View file

@ -320,7 +320,8 @@ def send_token_via_sms(otpsecret, token=None, phone_no=None):
return False
hotp = pyotp.HOTP(otpsecret)
args = {ss.message_parameter: f"Your verification code is {hotp.at(int(token))}"}
otp = hotp.at(int(token))
args = {ss.message_parameter: get_rendered_otp_message(otp)}
for d in ss.get("parameters"):
args[d.parameter] = d.value
@ -341,6 +342,14 @@ def send_token_via_sms(otpsecret, token=None, phone_no=None):
return True
def get_rendered_otp_message(otp: str) -> str:
default_template = "Your verification code is {{otp}}"
custom_template = frappe.get_system_settings("otp_sms_template")
template = custom_template or default_template
return frappe.render_template(template, {"otp": otp})
def send_token_via_email(user, token, otp_secret, otp_issuer, subject=None, message=None):
"""Send token to user as email."""
user_email = frappe.db.get_value("User", user, "email")

View file

@ -643,6 +643,11 @@ app_license = "{app_license}"
# "Logging DocType Name": 30 # days to retain logs
# }}
# Translation
# ------------
# List of apps whose translatable strings should be excluded from this app's translations.
# ignore_translatable_strings_from = []
"""
gitignore_template = """# Byte-compiled / optimized / DLL files

View file

@ -1935,10 +1935,23 @@ def get_link_to_report(
conditions = []
for k, v in filters.items():
if isinstance(v, list):
conditions.extend(
str(k) + "=" + '["' + str(value[0] + '"' + "," + '"' + str(value[1]) + '"]')
for value in v
)
for value in v:
if value[0] == "between":
conditions.append(
str(k)
+ "="
+ '["'
+ str(value[0])
+ '",["'
+ str(value[1][0])
+ '","'
+ str(value[1][1])
+ '"]]'
)
else:
conditions.append(
str(k) + "=" + '["' + str(value[0] + '"' + "," + '"' + str(value[1]) + '"]')
)
else:
conditions.append(str(k) + "=" + quote(str(v)))

View file

@ -219,9 +219,15 @@ def insert_values_for_multiple_docs(all_contents):
# ignoring duplicate keys for doctype_name
frappe.db.multisql(
{
"mariadb": """INSERT IGNORE INTO `__global_search`
"mariadb": """INSERT INTO `__global_search`
(doctype, name, content, published, title, route)
VALUES {} """.format(", ".join(batch_values)),
VALUES {}
ON DUPLICATE KEY UPDATE
content=VALUE(content),
published=VALUE(published),
title=VALUE(title),
route=VALUE(route)
""".format(", ".join(batch_values)),
"postgres": """INSERT INTO `__global_search`
(doctype, name, content, published, title, route)
VALUES {}
@ -422,6 +428,7 @@ def sync_value_in_queue(value):
frappe.cache.lpush("global_search_queue", json.dumps(value))
except redis.exceptions.ConnectionError:
# not connected, sync directly
assert not frappe.flags.in_test, "Should not fail silently in tests"
sync_value(value)

View file

@ -182,14 +182,9 @@
<div class="email-field">
<input type="email" id="forgot_email" class="form-control"
placeholder="{{ _('Email Address') }}" required autofocus autocomplete="username">
<svg class="field-icon email-icon" width="20" height="20" viewBox="0 0 20 20" fill="none"
<svg class="field-icon email-icon" width="16" height="16" viewBox="0 0 16 16" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M2.5 7.65149V15.0757C2.5 15.4374 2.64367 15.7842 2.8994 16.04C3.15513 16.2957 3.50198 16.4394 3.86364 16.4394H16.1364C16.498 16.4394 16.8449 16.2957 17.1006 16.04C17.3563 15.7842 17.5 15.4374 17.5 15.0757V7.65149"
stroke="#74808B" stroke-miterlimit="10" stroke-linecap="square" />
<path
d="M17.5 7.57572V5.53026C17.5 5.1686 17.3563 4.82176 17.1006 4.56603C16.8449 4.31029 16.498 4.16663 16.1364 4.16663H3.86364C3.50198 4.16663 3.15513 4.31029 2.8994 4.56603C2.64367 4.82176 2.5 5.1686 2.5 5.53026V7.57572L10 10.8333L17.5 7.57572Z"
stroke="#74808B" stroke-miterlimit="10" stroke-linecap="square" />
<use class="es-lock" href="#es-line-email"></use>
</svg>
</div>
@ -214,14 +209,9 @@
<div class="email-field">
<input type="email" id="login_with_email_link_email" class="form-control"
placeholder="{{ _('Email Address') }}" required autofocus autocomplete="username">
<svg class="field-icon email-icon" width="20" height="20" viewBox="0 0 20 20" fill="none"
<svg class="field-icon email-icon" width="16" height="16" viewBox="0 0 16 16" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M2.5 7.65149V15.0757C2.5 15.4374 2.64367 15.7842 2.8994 16.04C3.15513 16.2957 3.50198 16.4394 3.86364 16.4394H16.1364C16.498 16.4394 16.8449 16.2957 17.1006 16.04C17.3563 15.7842 17.5 15.4374 17.5 15.0757V7.65149"
stroke="#74808B" stroke-miterlimit="10" stroke-linecap="square" />
<path
d="M17.5 7.57572V5.53026C17.5 5.1686 17.3563 4.82176 17.1006 4.56603C16.8449 4.31029 16.498 4.16663 16.1364 4.16663H3.86364C3.50198 4.16663 3.15513 4.31029 2.8994 4.56603C2.64367 4.82176 2.5 5.1686 2.5 5.53026V7.57572L10 10.8333L17.5 7.57572Z"
stroke="#74808B" stroke-miterlimit="10" stroke-linecap="square" />
<use class="es-lock" href="#es-line-email"></use>
</svg>
</div>
</div>