Merge branch 'frappe:develop' into chore/add-brazilian-portuguese-language

This commit is contained in:
Flavia de Castro 2026-03-10 15:41:59 -03:00 committed by GitHub
commit 997656339e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
119 changed files with 35429 additions and 28909 deletions

View file

@ -43,7 +43,7 @@ def contains_documentation_link(body: str) -> bool:
def check_pull_request(number: str) -> "tuple[int, str]":
response = requests.get(f"https://api.github.com/repos/frappe/frappe/pulls/{number}")
if not response.ok:
return 1, "Pull Request Not Found! ⚠️"
return 0, "Pull Request Not Found! ⚠️"
payload = response.json()
title = (payload.get("title") or "").lower().strip()

View file

@ -141,7 +141,7 @@ jobs:
FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN || '' }}
- name: Upload coverage data
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: inputs.enable-coverage
with:
name: coverage-${{ matrix.db }}-${{ matrix.index }}

View file

@ -124,7 +124,7 @@ jobs:
( tail -f ${GITHUB_WORKSPACE}/sites/*-coverage*.xml & ) | grep -q "\/coverage"
- name: Upload JS coverage data
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: inputs.enable-coverage
with:
name: coverage-js-${{ matrix.index }}
@ -137,7 +137,7 @@ jobs:
fi
- name: Upload Cypress Videos
if: always() && steps.ui-tests.outcome == 'failure'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: Cypress CI Video Recordings
path: ./cypress_recordings.zip
@ -145,7 +145,7 @@ jobs:
- name: Upload coverage data
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: inputs.enable-coverage
with:
name: coverage-py-${{ matrix.index }}

View file

@ -72,7 +72,7 @@ jobs:
- name: Clone
uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v7.0.0
uses: actions/download-artifact@v8.0.0
- name: Upload coverage data
uses: codecov/codecov-action@v5
with:

View file

@ -57,7 +57,7 @@ jobs:
- name: Clone
uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v7.0.0
uses: actions/download-artifact@v8.0.0
- name: Upload python coverage data
uses: codecov/codecov-action@v5
with:

View file

@ -58,7 +58,7 @@ def handle_rpc_call(method: str, doctype: str | None = None):
try:
method = frappe.get_attr(method)
except Exception as e:
frappe.throw(_("Failed to get method {0} with {1}").format(method, e))
frappe.throw(_("Failed to get method {0} with {1}").format(method, str(e)))
is_whitelisted(method)
is_valid_http_method(method)

View file

@ -26,7 +26,10 @@
"rule",
"field",
"users",
"last_user"
"weighted_users",
"column_break_mkgo",
"last_user",
"current_index"
],
"fields": [
{
@ -96,15 +99,15 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Rule",
"options": "Round Robin\nLoad Balancing\nBased on Field",
"options": "Round Robin\nLoad Balancing\nBased on Field\nWeighted Distribution",
"reqd": 1
},
{
"depends_on": "eval: doc.rule !== 'Based on Field'",
"depends_on": "eval:in_list(['Round Robin', \"Load Balancing\"], doc.rule)",
"fieldname": "users",
"fieldtype": "Table MultiSelect",
"label": "Users",
"mandatory_depends_on": "eval: doc.rule !== 'Based on Field'",
"mandatory_depends_on": "eval:in_list(['Round Robin', \"Load Balancing\"], doc.rule)",
"options": "Assignment Rule User"
},
{
@ -150,12 +153,31 @@
"fieldtype": "Select",
"label": "Field",
"mandatory_depends_on": "eval: doc.rule == 'Based on Field'"
},
{
"depends_on": "eval:doc.rule=='Weighted Distribution'",
"fieldname": "current_index",
"fieldtype": "Int",
"label": "Current Index",
"read_only": 1
},
{
"fieldname": "column_break_mkgo",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.rule=='Weighted Distribution'",
"fieldname": "weighted_users",
"fieldtype": "Table",
"label": "Users",
"mandatory_depends_on": "eval:doc.rule=='Weighted Distribution'",
"options": "Assignment Rule User"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-08-25 17:09:11.644603",
"modified": "2026-03-03 13:08:12.561504",
"modified_by": "Administrator",
"module": "Automation",
"name": "Assignment Rule",

View file

@ -20,14 +20,13 @@ class AssignmentRule(Document):
if TYPE_CHECKING:
from frappe.automation.doctype.assignment_rule_day.assignment_rule_day import AssignmentRuleDay
from frappe.automation.doctype.assignment_rule_user.assignment_rule_user import (
AssignmentRuleUser,
)
from frappe.automation.doctype.assignment_rule_user.assignment_rule_user import AssignmentRuleUser
from frappe.types import DF
assign_condition: DF.Code
assignment_days: DF.Table[AssignmentRuleDay]
close_condition: DF.Code | None
current_index: DF.Int
description: DF.SmallText
disabled: DF.Check
document_type: DF.Link
@ -35,9 +34,10 @@ class AssignmentRule(Document):
field: DF.Literal[None]
last_user: DF.Link | None
priority: DF.Int
rule: DF.Literal["Round Robin", "Load Balancing", "Based on Field"]
rule: DF.Literal["Round Robin", "Load Balancing", "Based on Field", "Weighted Distribution"]
unassign_condition: DF.Code | None
users: DF.TableMultiSelect[AssignmentRuleUser]
weighted_users: DF.Table[AssignmentRuleUser]
# end: auto-generated types
def validate(self):
@ -80,25 +80,25 @@ class AssignmentRule(Document):
user = self.get_user(doc)
if user:
assign_to.add(
dict(
assign_to=[user],
doctype=doc.get("doctype"),
name=doc.get("name"),
description=frappe.render_template(self.description, doc),
assignment_rule=self.name,
notify=True,
date=doc.get(self.due_date_based_on) if self.due_date_based_on else None,
),
ignore_permissions=True,
)
if not user or not frappe.db.exists("User", user):
return False
# set for reference in round robin
self.db_set("last_user", user)
return True
assign_to.add(
dict(
assign_to=[user],
doctype=doc.get("doctype"),
name=doc.get("name"),
description=frappe.render_template(self.description, doc),
assignment_rule=self.name,
notify=True,
date=doc.get(self.due_date_based_on) if self.due_date_based_on else None,
),
ignore_permissions=True,
)
return False
# set for reference in round robin
self.db_set("last_user", user)
return True
def clear_assignment(self, doc):
"""Clear assignments"""
@ -122,6 +122,8 @@ class AssignmentRule(Document):
return self.get_user_load_balancing()
elif self.rule == "Based on Field":
return self.get_user_based_on_field(doc)
elif self.rule == "Weighted Distribution":
return self.get_weighted_user()
def get_user_round_robin(self):
"""
@ -167,6 +169,33 @@ class AssignmentRule(Document):
if frappe.db.exists("User", val):
return val
def get_weighted_user(self):
"""
Assign to the user based on weights assigned to users
Each rule maintains its own counter.
"""
users = [(d.user, d.weight or 1) for d in self.weighted_users if d.user]
if not users:
return None
total_weight = sum(weight for _, weight in users)
if total_weight <= 0:
return None
current_index = (
frappe.db.get_value("Assignment Rule", self.name, "current_index", for_update=True) or 0
)
slot = current_index % total_weight
cumulative_weight = 0
for user, weight in users:
cumulative_weight += weight
if slot < cumulative_weight:
frappe.db.set_value(
"Assignment Rule", self.name, "current_index", current_index + 1, update_modified=False
)
return user
def safe_eval(self, fieldname, doc):
try:
if self.get(fieldname):

View file

@ -113,6 +113,26 @@ class TestAutoAssign(IntegrationTestCase):
len(frappe.get_all("ToDo", dict(allocated_to=user, reference_type=TEST_DOCTYPE))), 10
)
def test_weighted_distribution(self):
self.assignment_rule.rule = "Weighted Distribution"
self.assignment_rule.weighted_users.clear()
self.assignment_rule.append("weighted_users", dict(user="test@example.com", weight=1))
self.assignment_rule.append("weighted_users", dict(user="test1@example.com", weight=2))
self.assignment_rule.save()
for _ in range(5):
_make_test_record(public=1)
# check if users are assigned based on weights (out of 5,
# test@example.com should have 2 assignments and test1@example.com should have 3 assignments )
self.assertEqual(
len(frappe.get_all("ToDo", dict(allocated_to="test@example.com", reference_type=TEST_DOCTYPE))), 2
)
self.assertEqual(
len(frappe.get_all("ToDo", dict(allocated_to="test1@example.com", reference_type=TEST_DOCTYPE))),
3,
)
def test_assingment_on_guest_submissions(self):
"""Sometimes documents are inserted as guest, check if assignment rules run on them. Use case: Web Forms"""
with self.set_user("Guest"):

View file

@ -5,7 +5,8 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"user"
"user",
"weight"
],
"fields": [
{
@ -15,20 +16,29 @@
"label": "User",
"options": "User",
"reqd": 1
},
{
"default": "1",
"fieldname": "weight",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Weight"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-23 16:01:27.847608",
"modified": "2026-03-03 12:30:01.394107",
"modified_by": "Administrator",
"module": "Automation",
"name": "Assignment Rule User",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View file

@ -18,6 +18,7 @@ class AssignmentRuleUser(Document):
parentfield: DF.Data
parenttype: DF.Data
user: DF.Link
weight: DF.Int
# end: auto-generated types
pass

View file

@ -151,9 +151,10 @@ def get_letter_heads():
def load_conf_settings(bootinfo):
from frappe.core.api.file import get_max_file_size
from frappe.core.api.file import get_file_chunk_size, get_max_file_size
bootinfo.max_file_size = get_max_file_size()
bootinfo.file_chunk_size = get_file_chunk_size()
for key in ("developer_mode", "socketio_port", "file_watcher_port"):
if key in frappe.conf:
bootinfo[key] = frappe.conf.get(key)

View file

@ -1122,6 +1122,7 @@ class TestGunicornWorker(IntegrationTestCase):
path = f"http://{self.TEST_SITE}:{self.port}/api/method/ping"
self.assertEqual(requests.get(path).status_code, 200)
@unittest.skip("Flaky test")
def test_gunicorn_ping_gthread(self):
self.spawn_gunicorn(["--threads=2"])
path = f"http://{self.TEST_SITE}:{self.port}/api/method/ping"
@ -1174,9 +1175,9 @@ class TestRQWorker(IntegrationTestCase):
def test_rq_pool_idle_cpu_usage(self):
self.spawn_rq(pool=True)
self.assertLessEqual(self.get_total_usage(), 2)
self.assertLessEqual(self.get_total_usage(), 10)
for _ in range(3):
frappe.enqueue("frappe.ping")
time.sleep(1)
self.assertLessEqual(self.get_total_usage(), 2)
self.assertLessEqual(self.get_total_usage(), 10)

View file

@ -90,6 +90,10 @@ def get_max_file_size() -> int:
)
def get_file_chunk_size() -> int:
return cint(frappe.conf.get("file_chunk_size")) or 25 * 1024 * 1024
@frappe.whitelist()
def create_new_folder(file_name: str, folder: str) -> File:
"""create new folder under current parent folder"""

View file

@ -6,7 +6,7 @@ from frappe.core.doctype.user_invitation.user_invitation import UserInvitation
@frappe.whitelist(methods=["POST"])
def invite_by_email(
emails: str, roles: list[str], redirect_to_path: str, app_name: str = "frappe"
emails: str, roles: list[str], redirect_to_path: str, app_name: str = "frappe", **kwargs
) -> dict[str, list[str]]:
UserInvitation.validate_role(app_name)
@ -42,6 +42,9 @@ def invite_by_email(
to_invite = list(
set(email_list) - set(disabled_user_emails) - set(accepted_invite_emails) - set(pending_invite_emails)
)
extra_args = get_allowed_invite_params(app_name, kwargs)
for email in to_invite:
frappe.get_doc(
doctype="User Invitation",
@ -49,6 +52,7 @@ def invite_by_email(
roles=[dict(role=role) for role in roles],
app_name=app_name,
redirect_to_path=redirect_to_path,
**extra_args,
).insert(ignore_permissions=True)
return {
@ -59,6 +63,19 @@ def invite_by_email(
}
def get_allowed_invite_params(app_name: str, kwargs: dict) -> dict:
# get extra args based on app_name
allowed_params = frappe._dict()
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=app_name)
if not isinstance(user_invitation_hook, dict):
return {}
extra_invite_params = user_invitation_hook.get("extra_invite_params", [])
for param in extra_invite_params:
if param in kwargs:
allowed_params[param] = kwargs[param]
return allowed_params
@frappe.whitelist(allow_guest=True, methods=["GET"])
def accept_invitation(key: str) -> None:
_accept_invitation(key, False)

View file

@ -18,7 +18,10 @@ from frappe.utils import (
get_imaginary_pixel_response,
get_string_between,
list_to_str,
now_datetime,
parse_addr,
split_emails,
time_diff_in_seconds,
validate_email_address,
)
@ -328,3 +331,63 @@ def update_communication_as_read(name):
name,
{"read_by_recipient": 1, "delivery_status": "Read", "read_by_recipient_on": get_datetime()},
)
@frappe.whitelist()
def undo_email_send(communication_name: str):
communication = frappe.get_doc("Communication", communication_name)
if communication.owner != frappe.session.user:
frappe.throw(_("You are not authorized to undo this email"))
if communication.sent_or_received != "Sent" or communication.communication_medium != "Email":
frappe.throw(_("Failed to delete communication"))
time_elapsed_in_seconds = time_diff_in_seconds(now_datetime(), communication.creation)
if time_elapsed_in_seconds > 10:
frappe.msgprint(
_("Email undo window is over. Cannot undo email."), alert=True, indicator="red", raise_exception=1
)
email_queue_records = frappe.get_all(
"Email Queue", filters={"communication": communication_name}, fields=["name", "status"]
)
for queue in email_queue_records:
if queue.status != "Not Sent":
frappe.msgprint(
_("It is too late to undo this email. It is already being sent."),
alert=True,
indicator="red",
raise_exception=1,
)
for queue in email_queue_records:
frappe.delete_doc("Email Queue", queue.name, ignore_permissions=True)
communication_data = {
"subject": communication.subject,
"content": communication.content,
"recipients": communication.recipients,
"cc": communication.cc,
"bcc": communication.bcc,
"doc": {"doctype": communication.reference_doctype, "name": communication.reference_name},
"sender": communication.sender,
"send_read_receipt": communication.read_receipt,
}
linked_files = frappe.get_all(
"File",
filters={"attached_to_doctype": "Communication", "attached_to_name": communication_name},
pluck="name",
)
if linked_files:
for file_name in linked_files:
frappe.db.set_value("File", file_name, {"attached_to_doctype": None, "attached_to_name": None})
communication_data["attachments"] = linked_files
communication.delete(ignore_permissions=True)
return communication_data

View file

@ -1,12 +1,14 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from datetime import timedelta
from typing import TYPE_CHECKING
import frappe
from frappe.core.doctype.communication.communication import Communication, get_emails, parse_email
from frappe.core.doctype.communication.email import add_attachments, make
from frappe.core.doctype.communication.email import add_attachments, make, undo_email_send
from frappe.email.doctype.email_queue.email_queue import EmailQueue
from frappe.tests import IntegrationTestCase
from frappe.utils import add_to_date, now_datetime
if TYPE_CHECKING:
from frappe.contacts.doctype.contact.contact import Contact
@ -438,6 +440,79 @@ class TestCommunicationEmailMixin(IntegrationTestCase):
self.assertEqual(attached_file.file_name, file_name)
self.assertEqual(attached_file.get_content(), file_content)
def test_undo_email_send(self):
"""Undo should delete Communication and Email Queue, and return original data."""
comm = self.new_communication(recipients=["to@test.com"])
comm.sent_or_received = "Sent"
comm.save(ignore_permissions=True)
eq = frappe.get_doc(
{
"doctype": "Email Queue",
"sender": "Test <test@example.com>",
"message": "Test message",
"status": "Not Sent",
"priority": 1,
"communication": comm.name,
"recipients": [{"recipient": "to@test.com", "status": "Not Sent"}],
}
).insert(ignore_permissions=True)
result = undo_email_send(comm.name)
self.assertFalse(frappe.db.exists("Communication", comm.name))
self.assertFalse(frappe.db.exists("Email Queue", eq.name))
self.assertFalse(frappe.db.exists("Email Queue Recipient", {"parent": eq.name}))
self.assertEqual(result["subject"], comm.subject)
self.assertEqual(result["recipients"], comm.recipients)
def test_undo_email_send_fails_for_different_user(self):
"""Undo should fail if the current user is not the owner."""
comm = self.new_communication(recipients=["to@test.com"])
comm.sent_or_received = "Sent"
comm.save(ignore_permissions=True)
frappe.db.set_value("Communication", comm.name, "owner", "other@test.com")
with self.assertRaises(frappe.exceptions.ValidationError):
undo_email_send(comm.name)
self.assertTrue(frappe.db.exists("Communication", comm.name))
def test_undo_email_send_fails_after_time_window(self):
"""Undo should fail if the 10-second window has passed."""
comm = self.new_communication(recipients=["to@test.com"])
comm.sent_or_received = "Sent"
comm.save(ignore_permissions=True)
with self.freeze_time(add_to_date(now_datetime(), seconds=12)):
with self.assertRaises(frappe.exceptions.ValidationError):
undo_email_send(comm.name)
self.assertTrue(frappe.db.exists("Communication", comm.name))
def test_undo_email_send_fails_if_already_sent(self):
"""Undo should fail if Email Queue status is not 'Not Sent'."""
comm = self.new_communication(recipients=["to@test.com"])
comm.sent_or_received = "Sent"
comm.save(ignore_permissions=True)
frappe.get_doc(
{
"doctype": "Email Queue",
"sender": "Test <test@example.com>",
"message": "Test message",
"status": "Sent",
"priority": 1,
"communication": comm.name,
"recipients": [{"recipient": "to@test.com", "status": "Sent"}],
}
).insert(ignore_permissions=True)
with self.assertRaises(frappe.exceptions.ValidationError):
undo_email_send(comm.name)
self.assertTrue(frappe.db.exists("Communication", comm.name))
def create_email_account() -> "EmailAccount":
frappe.delete_doc_if_exists("Email Account", "_Test Comm Account 1")

View file

@ -217,6 +217,7 @@ class DocType(Document):
self.validate_virtual_doctype_methods()
self.ensure_minimum_max_attachment_limit()
self.patch_old_naming_expressions()
self.deduplicate_document_links()
validate_links_table_fieldnames(self)
if not self.is_new():
@ -1086,6 +1087,30 @@ class DocType(Document):
)
return True
def deduplicate_document_links(self):
"""Remove duplicate document links from the links child table."""
seen_links = set()
unique_links = []
for link in self.links or []:
if link.is_child_table:
link_tuple = (
link.link_doctype,
link.link_fieldname,
link.parent_doctype or "",
link.table_fieldname or "",
)
else:
link_tuple = (link.link_doctype, link.link_fieldname)
if link_tuple not in seen_links:
seen_links.add(link_tuple)
unique_links.append(link)
if len(unique_links) < len(self.links or []):
self.links = unique_links
def validate_series(dt, autoname=None, name=None):
"""Validate if `autoname` property is correctly set."""

View file

@ -535,6 +535,25 @@ class TestDocType(IntegrationTestCase):
self.assertRaises(InvalidFieldNameError, validate_links_table_fieldnames, doc)
def test_deduplicate_document_links(self):
"""Test that duplicate document links are automatically removed during validation."""
doc = new_doctype("Test Deduplicate Links")
doc.append("links", {"link_doctype": "User", "link_fieldname": "email"})
doc.append("links", {"link_doctype": "User", "link_fieldname": "email"})
doc.append("links", {"link_doctype": "User", "link_fieldname": "email"})
doc.append("links", {"link_doctype": "User", "link_fieldname": "first_name"})
doc.append("links", {"link_doctype": "Role", "link_fieldname": "name"})
self.assertEqual(len(doc.links), 5)
doc.deduplicate_document_links()
self.assertEqual(len(doc.links), 3)
link_tuples = [(link.link_doctype, link.link_fieldname) for link in doc.links]
self.assertIn(("User", "email"), link_tuples)
self.assertIn(("User", "first_name"), link_tuples)
self.assertIn(("Role", "name"), link_tuples)
def test_create_virtual_doctype(self):
"""Test virtual DocType."""
virtual_doc = new_doctype("Test Virtual Doctype")

View file

@ -751,7 +751,7 @@ class File(Document):
return self.save_file_on_filesystem()
def save_file_on_filesystem(self):
safe_file_name = re.sub(r"[/\\%?#]", "_", self.file_name)
safe_file_name = get_safe_file_name(self.file_name)
if self.is_private:
self.file_url = f"/private/files/{safe_file_name}"
else:

View file

@ -476,3 +476,7 @@ def find_file_by_url(path: str, name: str | None = None) -> "File" | None:
file: File = frappe.get_doc(doctype="File", **file_data)
if file.is_downloadable():
return file
def get_safe_file_name(file_name: str) -> str:
return re.sub(r"[/\\%?#]", "_", file_name)

View file

@ -118,7 +118,7 @@
"fieldtype": "Select",
"hidden": 1,
"label": "Status",
"options": "Updated\nRemoved\nAdded"
"options": "Updated\nRemoved\nAdded\nReset"
}
],
"index_web_pages_for_search": 1,
@ -150,6 +150,10 @@
{
"color": "Green",
"title": "Added"
},
{
"color": "Blue",
"title": "Reset"
}
],
"title_field": "changed_by"

View file

@ -23,7 +23,7 @@ class PermissionLog(Document):
for_document: DF.DynamicLink
reference: DF.DynamicLink | None
reference_type: DF.Link | None
status: DF.Literal["Updated", "Removed", "Added"]
status: DF.Literal["Updated", "Removed", "Added", "Reset"]
# end: auto-generated types
@property
@ -34,6 +34,13 @@ class PermissionLog(Document):
def make_perm_log(doc, method=None):
if not hasattr(doc, "get_permission_log_options"):
return
# During reset we insert a single "Reset" log; skip per-Custom-DocPerm "Removed" logs
if (
method == "after_delete"
and doc.doctype == "Custom DocPerm"
and getattr(frappe.flags, "skip_perm_log_for_doctype", None) == doc.parent
):
return
params = doc.get_permission_log_options(method) or {}
if not getattr(doc, "_no_perm_log", False):
@ -46,16 +53,34 @@ def insert_perm_log(
for_doctype: str | None = None,
for_document: str | None = None,
fields: list | tuple | None = None,
custom_changes: dict | None = None,
):
"""Log a permission change. When custom_changes is provided (e.g. for reset-to-standard),
it must be {"from": {...}, "to": {...}} and optionally "status"; doc is used for
reference/owner only."""
if frappe.flags.in_install or frappe.flags.in_migrate:
# no need to log changes when migrating or installing app/site
return
current, previous = get_changes(doc, doc_before_save, fields)
if not previous and not current:
return
status = "Updated" if doc_before_save else ("Added" if doc.flags.in_insert else "Removed")
if custom_changes is not None:
previous = custom_changes.get("from", {})
current = custom_changes.get("to", {})
status = custom_changes.get("status", "Updated")
else:
current, previous = get_changes(doc, doc_before_save, fields)
if not previous and not current:
return
status = "Updated" if doc_before_save else ("Added" if doc.flags.in_insert else "Removed")
# Ensure role (and parent) are always in changes for Custom DocPerm so the UI can show them
if doc.doctype == "Custom DocPerm":
previous["role"] = previous.get("role") or (
doc_before_save and getattr(doc_before_save, "role", None)
)
current["role"] = current.get("role") or getattr(doc, "role", None)
previous["parent"] = previous.get("parent") or (
doc_before_save and getattr(doc_before_save, "parent", None)
)
current["parent"] = current.get("parent") or getattr(doc, "parent", None)
frappe.get_doc(
{

View file

@ -4,6 +4,7 @@
import frappe
from frappe.core.page.permission_manager.permission_manager import get_permissions
from frappe.model.document import Document
from frappe.permissions import setup_custom_perms
class RoleReplication(Document):
@ -28,9 +29,21 @@ class RoleReplication(Document):
new_role = frappe.get_doc({"doctype": "Role", "role_name": self.new_role}).insert().name
perms = get_permissions(role=self.existing_role)
doctypes_with_custom_perms_setup = set()
for perm in perms:
perm.update(
doctype = perm.get("parent")
if doctype and doctype not in doctypes_with_custom_perms_setup:
# if no Custom DocPerm exists for the doctype, move standard permissions to Custom DocPerm
# before creating first Custom DocPerm for the new role
setup_custom_perms(doctype)
doctypes_with_custom_perms_setup.add(doctype)
# Create Custom DocPerm for the new role
frappe.get_doc(
{
"doctype": "Custom DocPerm",
**perm,
"name": None,
"creation": None,
"modified": None,
@ -39,5 +52,4 @@ class RoleReplication(Document):
"linked_doctypes": None,
"role": new_role,
}
)
frappe.get_doc({"doctype": "Custom DocPerm", **perm}).insert()
).insert()

View file

@ -1,9 +1,98 @@
# Copyright (c) 2024, Frappe Technologies and Contributors
# See license.txt
# import frappe
import frappe
from frappe.permissions import get_all_perms
from frappe.tests import IntegrationTestCase
class TestRoleReplication(IntegrationTestCase):
pass
def setUp(self):
# Create a test role with permissions
self.test_role_name = "_Test Role For Replication"
self.new_role_name = "_Test Replicated Role"
# Clean up any existing test roles and permissions
self._cleanup_test_data()
# Create the test role
self.test_role = frappe.get_doc({"doctype": "Role", "role_name": self.test_role_name}).insert()
# Add a DocPerm permission (simulating standard permission)
# We use a doctype that doesn't have Custom DocPerm to simulate the bug scenario
self.test_doctype = "User"
# First ensure no Custom DocPerm exists for this doctype
frappe.db.delete("Custom DocPerm", {"parent": self.test_doctype})
# Add DocPerm for the test role
self.test_perm = frappe.get_doc(
{
"doctype": "DocPerm",
"parent": self.test_doctype,
"parenttype": "DocType",
"parentfield": "permissions",
"role": self.test_role_name,
"permlevel": 0,
"read": 1,
"write": 1,
"create": 0,
}
).insert()
def _cleanup_test_data(self):
"""Clean up test roles and permissions."""
for role_name in [self.test_role_name, self.new_role_name]:
frappe.db.delete("Custom DocPerm", {"role": role_name})
frappe.db.delete("DocPerm", {"role": role_name})
if frappe.db.exists("Role", role_name):
frappe.delete_doc("Role", role_name, force=True)
def tearDown(self):
self._cleanup_test_data()
def test_replicate_role_preserves_original_permissions(self):
"""
Test that replicating a role does not erase the original role's permissions.
This is a regression test for https://github.com/frappe/frappe/issues/34605
"""
# Get original permissions count before replication using get_all_perms
# (this is what the Role Permissions Manager UI uses)
original_perms_before = get_all_perms(self.test_role_name)
self.assertTrue(
len(original_perms_before) > 0, "Test role should have permissions before replication"
)
# Perform role replication
role_replication = frappe.get_doc(
{
"doctype": "Role Replication",
"existing_role": self.test_role_name,
"new_role": self.new_role_name,
}
)
role_replication.replicate_role()
# Verify new role was created
self.assertTrue(frappe.db.exists("Role", self.new_role_name), "New role should be created")
# Verify new role has permissions
new_role_perms = get_all_perms(self.new_role_name)
self.assertTrue(len(new_role_perms) > 0, "New role should have permissions after replication")
# Verify original role still has its permissions visible via get_all_perms
original_perms_after = get_all_perms(self.test_role_name)
self.assertEqual(
len(original_perms_before),
len(original_perms_after),
"Original role should retain all its permissions after replication",
)
# Verify the original role now has Custom DocPerm entries
original_custom_perms = frappe.get_all(
"Custom DocPerm", filters={"role": self.test_role_name}, fields=["parent", "read", "write"]
)
self.assertTrue(
len(original_custom_perms) > 0,
"Original role should have Custom DocPerm entries after replication to preserve visibility",
)

View file

@ -28,6 +28,10 @@ Define user invitation hooks in your app's `hooks.py` file. An example is shown
A map of `only_for` roles to a list of roles that are allowed to be invited to your app.
- `extra_invite_params`
A list of additional parameters that can be passed when creating a user invitation. Optional parameter.
- `after_accept`
Dot path of the function to execute after the user accepts the invitation.

View file

@ -72,6 +72,11 @@ frappe.PermissionEngine = class PermissionEngine {
this.page.add_inner_button(__("Set User Permissions"), () => {
return frappe.set_route("List", "User Permission");
});
this.page.add_inner_button(__("View Activity Log"), () => {
this.show_activity_log();
});
this.set_from_route();
}
@ -577,4 +582,159 @@ frappe.PermissionEngine = class PermissionEngine {
options: ["not in", ["User", "[Select]"]],
});
}
show_activity_log() {
const PERM_FIELDS = [
"select",
"read",
"write",
"create",
"delete",
"submit",
"cancel",
"amend",
"print",
"email",
"report",
"import",
"export",
"share",
"mask",
];
const STATUS_COLOR = { Added: "green", Removed: "red", Updated: "orange", Reset: "blue" };
let doctype = this.get_doctype();
let show_doctype_column = !doctype;
let title = doctype
? __("Activity Log for {0}", [__(doctype)])
: __("Role Permissions Activity Log");
let d = new frappe.ui.Dialog({ title, size: "large" });
let $body = $(d.body);
$body.html(`<div class="text-muted text-center p-4">${__("Loading\u2026")}</div>`);
frappe
.call({
module: "frappe.core",
page: "permission_manager",
method: "get_permission_logs",
args: { doctype: doctype || null, limit: 50 },
})
.then((r) => {
let logs = r.message || [];
$body.empty();
if (!logs.length) {
$body.html(
`<div class="text-muted text-center p-4">${__(
"No activity recorded yet."
)}</div>`
);
return;
}
let rows = logs
.map((log) => {
let ch = log.changes || {};
let from = ch.from || {};
let to = ch.to || {};
// Role: prefer the side that has data
let role =
(log.status === "Removed" ? from.role : to.role) || from.role || "—";
// Active permissions: for Added/Removed show the full set;
// for Updated show only what flipped; for Reset show summary
let changes_text = "";
if (log.status === "Reset") {
changes_text = __("Restored to standard permissions");
} else if (log.status === "Updated") {
let parts = [];
PERM_FIELDS.forEach((f) => {
if (f in to && to[f] !== from[f]) {
let label = toTitle(frappe.unscrub(f));
parts.push(
to[f]
? `<span class="diff-add">${__(label)}</span>`
: `<span class="diff-remove">${__(label)}</span>`
);
}
});
changes_text = parts.join(", ") || "—";
} else {
// Added or Removed — list the active permission types
let source = log.status === "Removed" ? from : to;
let active = PERM_FIELDS.filter(
(f) => source[f] == 1 || source[f] === true
);
changes_text =
active.map((f) => __(toTitle(frappe.unscrub(f)))).join(", ") ||
"—";
}
let badge_color = STATUS_COLOR[log.status] || "grey";
let ts = frappe.datetime.comment_when(log.changed_at);
let user_display = log.changed_by || "—";
let doctype_cell =
show_doctype_column && log.for_document
? `<td>${frappe.utils.get_form_link(
"DocType",
log.for_document,
true
)}</td>`
: "";
return `<tr>
<td>${user_display}</td>
<td><span class="indicator-pill ${badge_color}">${__(log.status)}</span></td>
<td>${__(role)}</td>
${doctype_cell}
<td class="small">${changes_text}</td>
<td class="frappe-timestamp-cell">${ts}</td>
</tr>`;
})
.join("");
let header_doctype = show_doctype_column
? `<th style="min-width:120px">${__("DocType")}</th>`
: "";
$body.html(`
<div style="overflow-x: auto;">
<table class="table table-bordered table-sm" style="font-size:13px">
<thead style="background:var(--fg-color)">
<tr>
<th style="min-width:110px">${__("Modified By")}</th>
<th style="min-width:90px">${__("Action")}</th>
<th style="min-width:110px">${__("Role")}</th>
${header_doctype}
<th>${__("Changes")}</th>
<th style="min-width:100px">${__("Timestamp")}</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
<div class="text-right mt-2">
<button class="btn btn-sm btn-default btn-view-full-log">
${frappe.utils.icon("external-link", "sm", "mr-1")}
${__("View full log")}
</button>
</div>
`);
$body.find(".btn-view-full-log").on("click", () => {
d.hide();
frappe.route_options = { for_doctype: "DocType" };
if (doctype) {
frappe.route_options.for_document = doctype;
}
frappe.set_route("List", "Permission Log");
});
});
d.show();
}
};

View file

@ -178,8 +178,35 @@ def remove(doctype: str, role: str, permlevel: int, if_owner: str | int = 0):
@frappe.whitelist()
def reset(doctype: str):
frappe.only_for("System Manager")
reset_perms(doctype)
clear_permissions_cache(doctype)
from frappe.core.doctype.permission_log.permission_log import insert_perm_log
frappe.flags.skip_perm_log_for_doctype = doctype
try:
reset_perms(doctype)
clear_permissions_cache(doctype)
doc = frappe.new_doc("DocType")
doc.name = doctype
standard_perms = frappe.get_all("DocPerm", filters={"parent": doctype}, fields="*")
insert_perm_log(
doc,
for_doctype="DocType",
for_document=doctype,
custom_changes={
"from": {"permissions": "custom"},
"to": {
"permissions": "standard",
"standard_rules": [
{"role": p.role, "permlevel": p.permlevel, "read": p.read, "write": p.write}
for p in standard_perms
],
},
"status": "Reset",
},
)
finally:
frappe.flags.pop("skip_perm_log_for_doctype", None)
@frappe.whitelist()
@ -199,3 +226,35 @@ def get_standard_permissions(doctype: str):
# also used to setup permissions via patch
path = get_file_path(meta.module, "DocType", doctype)
return read_doc_from_file(path).get("permissions")
@frappe.whitelist()
def get_permission_logs(doctype: str | None = None, limit: int = 20) -> list:
"""Return recent Permission Log entries for the given DocType (or all if not specified).
Args:
doctype: Filter logs to a specific DocType. If omitted, returns logs for all DocTypes.
limit: Maximum number of log entries to return (default 20).
"""
frappe.only_for("System Manager")
filters = {"for_doctype": "DocType"}
if doctype:
filters["for_document"] = doctype
logs = frappe.get_all(
"Permission Log",
filters=filters,
fields=["name", "changed_by", "creation", "status", "for_document", "changes"],
order_by="creation desc",
limit=limit,
)
for log in logs:
log["changed_at"] = log.pop("creation")
try:
log["changes"] = frappe.parse_json(log["changes"])
except Exception:
pass
return logs

View file

@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from markdownify import markdownify as md
import frappe
@ -88,3 +89,42 @@ def html2text(html: str, strip_links=False, wrap=True) -> str:
"""Return the given `html` as markdown text."""
strip = ["a"] if strip_links else None
return md(html, heading_style="ATX", strip=strip, wrap=wrap)
def html_to_plain_text(html: str) -> str:
"""Return the given `html` as plain text."""
if not html:
return ""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
for element in soup(["script", "style"]):
element.decompose()
# Introduce explicit newlines for block-level elements while keeping inline content on the same line.
for br in soup.find_all("br"):
br.replace_with("\n")
for block in soup.find_all(["p", "div", "tr", "li", "h1", "h2", "h3", "h4", "h5", "h6"]):
block.append("\n")
# Use a space separator between text nodes so inline tags don't break lines
text = soup.get_text(separator=" ")
lines = [line.strip() for line in text.splitlines()]
cleaned = []
previous_blank = False
for line in lines:
if line:
cleaned.append(line)
previous_blank = False
else:
if not previous_blank:
cleaned.append("")
previous_blank = True
return "\n".join(cleaned).strip()

View file

@ -61,13 +61,18 @@ frappe.ui.form.on("Customize Form", {
if (r) {
if (r._server_messages && r._server_messages.length) {
frm.set_value("doc_type", "");
localStorage.removeItem("customize_doctype");
} else {
localStorage["customize_doctype"] = frm.doc.doc_type;
frm.refresh();
frm.trigger("add_customize_child_table_button");
frm.trigger("setup_default_views");
}
}
localStorage["customize_doctype"] = frm.doc.doc_type;
},
error: function () {
frm.set_value("doc_type", "");
localStorage.removeItem("customize_doctype");
},
});
} else {
@ -96,69 +101,75 @@ frappe.ui.form.on("Customize Form", {
frm.page.clear_icons();
if (frm.doc.doc_type) {
frappe.model.with_doctype(frm.doc.doc_type).then(() => {
frm.page.set_title(__("Customize Form - {0}", [__(frm.doc.doc_type)]));
frappe.customize_form.set_primary_action(frm);
frappe.model
.with_doctype(frm.doc.doc_type)
.then(() => {
frm.page.set_title(__("Customize Form - {0}", [__(frm.doc.doc_type)]));
frappe.customize_form.set_primary_action(frm);
frm.add_custom_button(
__("Go to {0} List", [__(frm.doc.doc_type)]),
function () {
frappe.set_route("List", frm.doc.doc_type);
},
__("Actions")
);
frm.add_custom_button(
__("Go to {0} List", [__(frm.doc.doc_type)]),
function () {
frappe.set_route("List", frm.doc.doc_type);
},
__("Actions")
);
frm.add_custom_button(
__("Set Permissions"),
function () {
frappe.set_route("permission-manager", frm.doc.doc_type);
},
__("Actions")
);
frm.add_custom_button(
__("Set Permissions"),
function () {
frappe.set_route("permission-manager", frm.doc.doc_type);
},
__("Actions")
);
frm.add_custom_button(
__("Reload"),
function () {
frm.script_manager.trigger("doc_type");
},
__("Actions")
);
frm.add_custom_button(
__("Reload"),
function () {
frm.script_manager.trigger("doc_type");
},
__("Actions")
);
frm.add_custom_button(
__("Reset Layout"),
() => {
frm.trigger("reset_layout");
},
__("Actions")
);
frm.add_custom_button(
__("Reset Layout"),
() => {
frm.trigger("reset_layout");
},
__("Actions")
);
frm.add_custom_button(
__("Reset All Customizations"),
function () {
frappe.customize_form.confirm(__("Remove all customizations?"), frm);
},
__("Actions")
);
frm.add_custom_button(
__("Reset All Customizations"),
function () {
frappe.customize_form.confirm(__("Remove all customizations?"), frm);
},
__("Actions")
);
frm.add_custom_button(
__("Trim Table"),
function () {
frm.trigger("trim_table");
},
__("Actions")
);
frm.add_custom_button(
__("Trim Table"),
function () {
frm.trigger("trim_table");
},
__("Actions")
);
const is_autoname_autoincrement = frm.doc.autoname === "autoincrement";
frm.set_df_property("naming_rule", "hidden", is_autoname_autoincrement);
frm.set_df_property("autoname", "read_only", is_autoname_autoincrement);
frm.toggle_display(
["queue_in_background"],
frappe.get_meta(frm.doc.doc_type).is_submittable || 0
);
const is_autoname_autoincrement = frm.doc.autoname === "autoincrement";
frm.set_df_property("naming_rule", "hidden", is_autoname_autoincrement);
frm.set_df_property("autoname", "read_only", is_autoname_autoincrement);
frm.toggle_display(
["queue_in_background"],
frappe.get_meta(frm.doc.doc_type).is_submittable || 0
);
render_form_builder(frm);
frm.get_field("form_builder").tab.set_active();
});
render_form_builder(frm);
frm.get_field("form_builder").tab.set_active();
})
.catch(() => {
frm.set_value("doc_type", "");
localStorage.removeItem("customize_doctype");
});
}
frm.events.setup_export(frm);

View file

@ -32,6 +32,7 @@
"image_field",
"max_attachments",
"column_break_21",
"hide_toolbar",
"allow_copy",
"make_attachments_public",
"protect_attached_files",
@ -105,6 +106,12 @@
"fieldtype": "Int",
"label": "Max Attachments"
},
{
"default": "0",
"fieldname": "hide_toolbar",
"fieldtype": "Check",
"label": "Hide Sidebar, Menu, and Comments"
},
{
"default": "0",
"fieldname": "allow_copy",

View file

@ -727,6 +727,7 @@ doctype_properties = {
"sort_field": "Data",
"sort_order": "Data",
"default_print_format": "Data",
"hide_toolbar": "Check",
"allow_copy": "Check",
"istable": "Check",
"quick_entry": "Check",

View file

@ -453,7 +453,7 @@ class Engine:
self.query = self.query.where(combined_criterion)
except Exception as e:
# Log the original filters list for better debugging context
frappe.throw(_("Error parsing nested filters: {0}. {1}").format(filters, e), exc=e)
frappe.throw(_("Error parsing nested filters: {0}. {1}").format(filters, str(e)), exc=e)
else: # Not a nested structure, assume it's a list of simple filters (implicitly ANDed)
for filter_item in filters:

View file

@ -3,7 +3,7 @@
"chart_name": "Login Activity",
"chart_type": "Count",
"creation": "2025-08-28 16:48:49.946848",
"currency": "INR",
"currency": "",
"docstatus": 0,
"doctype": "Dashboard Chart",
"document_type": "Activity Log",
@ -13,8 +13,8 @@
"idx": 1,
"is_public": 0,
"is_standard": 1,
"last_synced_on": "2026-01-11 23:34:36.361407",
"modified": "2026-01-11 23:37:58.619758",
"last_synced_on": "2026-03-06 12:38:48.424157",
"modified": "2026-03-06 12:39:21.536001",
"modified_by": "Administrator",
"module": "Desk",
"name": "Login Activity",

View file

@ -64,7 +64,7 @@ class Dashboard(Document):
try:
json.loads(self.chart_options)
except ValueError as error:
frappe.throw(_("Invalid json added in the custom options: {0}").format(error))
frappe.throw(_("Invalid json added in the custom options: {0}").format(str(error)))
def get_permission_query_conditions(user):

View file

@ -424,7 +424,7 @@ class DashboardChart(Document):
try:
json.loads(self.custom_options)
except ValueError as error:
frappe.throw(_("Invalid json added in the custom options: {0}").format(error))
frappe.throw(_("Invalid json added in the custom options: {0}").format(str(error)))
@frappe.whitelist()

View file

@ -1,7 +1,6 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:label",
"creation": "2020-04-15 18:06:39.444683",
"doctype": "DocType",
"editable_grid": 1,
@ -73,8 +72,7 @@
"fieldtype": "Data",
"in_list_view": 1,
"label": "Label",
"reqd": 1,
"unique": 1
"reqd": 1
},
{
"fieldname": "color",
@ -231,11 +229,10 @@
}
],
"links": [],
"modified": "2026-02-25 16:33:09.032056",
"modified": "2026-03-06 14:23:01.586707",
"modified_by": "Administrator",
"module": "Desk",
"name": "Number Card",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{

View file

@ -286,7 +286,6 @@ def auto_generate_sidebar_from_module():
sidebar.items = sidebar_items
sidebar.module = module
sidebar.header_icon = "hammer"
sidebar.app = frappe.local.module_app.get(frappe.scrub(module), None)
sidebars.append(sidebar)
return sidebars

View file

@ -312,7 +312,6 @@ class DesktopPage {
return !me.edit_mode;
},
onClick: function () {
me.$desktop_edit_button.hide();
frappe.new_desktop_icons = JSON.parse(JSON.stringify(frappe.desktop_icons));
me.start_editing_layout();
},

View file

@ -197,7 +197,8 @@ class EmailAccount(Document):
try:
self.validate_imap_folders_exist(server)
finally:
server.logout()
if hasattr(server, "imap") and server.imap is not None:
server.logout()
self.no_failed = 0
@ -224,7 +225,10 @@ class EmailAccount(Document):
frappe_mail_client.validate()
def validate_imap_folders_exist(self, server: EmailServer) -> None:
"""Validate that the configured IMAP folders exist on the server."""
"""Validate that each configured IMAP folder exists on the server by attempting to SELECT it directly."""
if not hasattr(server, "imap") or server.imap is None:
server.connect()
status, mailboxes = server.imap.list()
if status != "OK":
@ -232,22 +236,29 @@ class EmailAccount(Document):
_(
"Failed to retrieve the list of IMAP folders from the server. Please ensure the mailbox is accessible and the account has permission to list folders."
),
title=_("IMAP Folder Not Found"),
title=_("IMAP Folder Validation Failed"),
)
folders = []
for mailbox in mailboxes:
decoded = mailbox.decode()
parts = decoded.split(' "/" ')
if len(parts) == 2:
folder = parts[1].strip('"')
folders.append(folder)
if not mailboxes:
frappe.throw(
_(
"No IMAP folders were found on the server. Please verify the email account settings and ensure the mailbox contains folders."
),
title=_("IMAP Folder Validation Failed"),
)
if not folders:
frappe.throw(_("The server did not return any IMAP folders for this account."))
missing_folders = []
for row in self.imap_folder:
folder = row.folder_name.strip()
configured_folders = [f.folder_name for f in self.imap_folder]
missing_folders = [folder for folder in configured_folders if folder not in folders]
if not folder:
frappe.throw(_("IMAP Folder name cannot be empty."))
status, _response = server.imap.select(f'"{folder}"', readonly=True)
if status != "OK":
missing_folders.append(folder)
continue
if missing_folders:
missing_list = "".join(
@ -255,10 +266,11 @@ class EmailAccount(Document):
)
frappe.throw(
_(
"The following configured IMAP folder(s) were not found on the server:<br>"
"The following configured IMAP folder(s) were not found or "
"are not accessible on the server:<br>"
"<ul>{0}</ul>"
"Please verify the folder names exactly as they appear on the server "
"(folder names are case-sensitive)."
"and ensure the account has access to them."
).format(missing_list),
title=_("IMAP Folder Not Found"),
)
@ -380,7 +392,7 @@ class EmailAccount(Document):
frappe.throw(_("{0} is required").format("Email Server"))
if self.flags.validate_imap_pop_connection:
args.timeout = 15
args.timeout = 30
email_server = EmailServer(frappe._dict(args))
self.check_email_server_connection(email_server, in_receive)

View file

@ -13,7 +13,7 @@ from typing import TYPE_CHECKING
import frappe
from frappe import _, are_emails_muted, safe_encode, task
from frappe.core.utils import html2text
from frappe.core.utils import html_to_plain_text
from frappe.database.database import savepoint
from frappe.email.doctype.email_account.email_account import EmailAccount
from frappe.email.email_body import add_attachment, get_email, get_formatted_html
@ -691,7 +691,7 @@ class QueueBuilder:
return self._text_content + unsubscribe_text_message
try:
text_content = html2text(self._message)
text_content = html_to_plain_text(self._message)
except Exception:
text_content = "See html attachment"
return text_content + unsubscribe_text_message

View file

@ -103,7 +103,7 @@ class Notification(Document):
return _("Yes") if evaluate_filters(doc, json.loads(self.filters)) else _("No")
except Exception as e:
frappe.local.message_log = []
return _("Failed to evaluate conditions: {}").format(e)
return _("Failed to evaluate conditions: {}").format(str(e))
@frappe.whitelist()
def preview_message(self, preview_document: str):
@ -120,7 +120,7 @@ class Notification(Document):
return frappe.utils.strip_html_tags(msg)
return msg
except Exception as e:
return _("Failed to render message: {}").format(e)
return _("Failed to render message: {}").format(str(e))
@frappe.whitelist()
def preview_subject(self, preview_document: str):
@ -138,7 +138,7 @@ class Notification(Document):
return frappe.render_template(self.subject, context)
return self.subject
except Exception as e:
return _("Failed to render subject: {}").format(e)
return _("Failed to render subject: {}").format(str(e))
# END: PreviewRenderer API

View file

@ -5,7 +5,7 @@ from datetime import timedelta
import frappe
from frappe import _, msgprint
from frappe.utils import cint, cstr, get_url, now_datetime
from frappe.utils.data import getdate
from frappe.utils.data import add_to_date, getdate
from frappe.utils.verified_command import get_signed_params, verify_request
# After this percent of failures in every batch, entire batch is aborted.
@ -163,19 +163,21 @@ def flush():
def get_queue():
batch_size = cint(frappe.conf.email_queue_batch_size) or 500
undo_window = add_to_date(now_datetime(), seconds=-10)
return frappe.db.sql(
f"""select
"""select
name, sender
from
`tabEmail Queue`
where
(status='Not Sent' or status='Partially Sent') and
(send_after is null or send_after < %(now)s)
(send_after is null or send_after < %(now)s) and
(creation < %(undo_window)s)
order
by priority desc, retry asc, creation asc
limit {batch_size}""",
{"now": now_datetime()},
limit %(batch_size)s""",
{"now": now_datetime(), "undo_window": undo_window, "batch_size": batch_size},
as_dict=True,
)

View file

@ -212,6 +212,8 @@
"Bahamas": {
"code": "bs",
"currency": "BSD",
"currency_fraction": "Cent",
"currency_fraction_units": 100,
"currency_name": "Bahamian Dollar",
"number_format": "#,###.##",
"timezones": [
@ -338,6 +340,8 @@
"Bolivia, Plurinational State of": {
"code": "bo",
"currency": "BOB",
"currency_fraction": "Centavo",
"currency_fraction_units": 100,
"currency_name": "Boliviano",
"number_format": "#,###.##",
"isd": "+591"
@ -418,6 +422,8 @@
"Brunei Darussalam": {
"code": "bn",
"currency": "BND",
"currency_fraction": "Sen",
"currency_fraction_units": 100,
"currency_name": "Brunei Dollar",
"number_format": "#,###.##",
"timezones": [
@ -600,6 +606,7 @@
"China": {
"code": "cn",
"currency": "CNY",
"currency_fraction": "Fen",
"currency_name": "Yuan Renminbi",
"currency_fraction_units": 100,
"smallest_currency_fraction_value": 0.01,
@ -909,6 +916,8 @@
"Falkland Islands (Malvinas)": {
"code": "fk",
"currency": "FKP",
"currency_fraction": "Penny",
"currency_fraction_units": 100,
"currency_name": "Falkland Islands Pound",
"number_format": "#,###.##",
"isd": "+500"
@ -1006,6 +1015,8 @@
"Gambia": {
"code": "gm",
"currency": "GMD",
"currency_fraction": "Butut",
"currency_fraction_units": 100,
"currency_name": "Dalasi",
"number_format": "#,###.##",
"timezones": [
@ -1289,6 +1300,8 @@
"Iran": {
"code": "ir",
"currency": "IRR",
"currency_fraction": "Dinar",
"currency_fraction_units": 100,
"currency_name": "Iranian Rial",
"currency_symbol": "\ufdfc",
"number_format": "#,###.##",
@ -1470,6 +1483,8 @@
"Korea, Democratic Peoples Republic of": {
"code": "kp",
"currency": "KPW",
"currency_fraction": "Chon",
"currency_fraction_units": 100,
"currency_name": "North Korean Won",
"number_format": "#,###.##",
"isd": "+850"
@ -1477,6 +1492,8 @@
"Korea, Republic of": {
"code": "kr",
"currency": "KRW",
"currency_fraction": "Jeon",
"currency_fraction_units": 100,
"currency_name": "Won",
"number_format": "#,###",
"isd": "+82"
@ -1510,6 +1527,8 @@
"Lao Peoples Democratic Republic": {
"code": "la",
"currency": "LAK",
"currency_fraction": "Att",
"currency_fraction_units": 100,
"currency_name": "Kip",
"number_format": "#,###.##",
"timezones": [
@ -1623,6 +1642,8 @@
"Macao": {
"code": "mo",
"currency": "MOP",
"currency_fraction": "Avo",
"currency_fraction_units": 100,
"currency_name": "Pataca",
"number_format": "#,###.##",
"isd": "+853"
@ -1801,6 +1822,8 @@
"Moldova, Republic of": {
"code": "md",
"currency": "MDL",
"currency_fraction": "Ban",
"currency_fraction_units": 100,
"currency_name": "Moldovan Leu",
"number_format": "#,###.##",
"isd": "+373"
@ -1888,6 +1911,8 @@
"Myanmar": {
"code": "mm",
"currency": "MMK",
"currency_fraction": "Pya",
"currency_fraction_units": 100,
"currency_name": "Kyat",
"number_format": "#,###.##",
"timezones": [
@ -2237,6 +2262,8 @@
"Russian Federation": {
"code": "ru",
"currency": "RUB",
"currency_fraction": "Kopek",
"currency_fraction_units": 100,
"currency_name": "Russian Ruble",
"number_format": "#.###,##",
"isd": "+7"
@ -2267,6 +2294,8 @@
"Saint Helena, Ascension and Tristan da Cunha": {
"code": "sh",
"currency": "SHP",
"currency_fraction": "Penny",
"currency_fraction_units": 100,
"currency_name": "Saint Helena Pound",
"number_format": "#,###.##",
"isd": "+290"
@ -2349,6 +2378,8 @@
"Sao Tome and Principe": {
"code": "st",
"currency": "STD",
"currency_fraction": "Cêntimo",
"currency_fraction_units": 100,
"currency_name": "Dobra",
"number_format": "#,###.##",
"isd": "+239"
@ -2623,6 +2654,8 @@
"Syria": {
"code": "sy",
"currency": "SYP",
"currency_fraction": "Piastre",
"currency_fraction_units": 100,
"currency_name": "Syrian Pound",
"number_format": "#,###.##",
"isd": "+963"
@ -2630,6 +2663,8 @@
"Taiwan": {
"code": "tw",
"currency": "TWD",
"currency_fraction": "Fen",
"currency_fraction_units": 100,
"date_format": "yyyy-mm-dd",
"number_format": "#,###.##",
"isd": "+886"
@ -2648,6 +2683,8 @@
"Tanzania": {
"code": "tz",
"currency": "TZS",
"currency_fraction": "Senti",
"currency_fraction_units": 100,
"currency_name": "Tanzanian Shilling",
"number_format": "#,###.##",
"timezones": [
@ -2932,6 +2969,8 @@
"Vietnam": {
"code": "vn",
"currency": "VND",
"currency_fraction": "Hào",
"currency_fraction_units": 10,
"currency_name": "Dong",
"number_format": "#.###",
"isd": "+84"

View file

@ -3,6 +3,7 @@
import os
from mimetypes import guess_type
from pathlib import Path
from typing import TYPE_CHECKING
from werkzeug.wrappers import Response
@ -11,11 +12,11 @@ import frappe
import frappe.sessions
import frappe.utils
from frappe import _, is_whitelisted, ping
from frappe.core.doctype.file.utils import find_file_by_url
from frappe.core.doctype.file.utils import find_file_by_url, get_safe_file_name
from frappe.core.doctype.server_script.server_script_utils import get_server_script_map
from frappe.monitor import add_data_to_monitor
from frappe.permissions import check_doctype_permission
from frappe.utils import cint
from frappe.utils import cint, get_files_path
from frappe.utils.csvutils import build_csv_response
from frappe.utils.deprecations import deprecated
from frappe.utils.image import optimize_image
@ -74,7 +75,7 @@ def execute_cmd(cmd, from_async=False):
try:
method = get_attr(cmd)
except Exception as e:
frappe.throw(_("Failed to get method for command {0} with {1}").format(cmd, e))
frappe.throw(_("Failed to get method for command {0} with {1}").format(cmd, str(e)))
if from_async:
method = method.queue
@ -162,9 +163,27 @@ def upload_file():
if "file" in files:
file = files["file"]
content = file.stream.read()
filename = file.filename
total_file_size = frappe.form_dict.total_file_size
if frappe.form_dict.chunk_index:
current_chunk = int(frappe.form_dict.chunk_index)
total_chunks = int(frappe.form_dict.total_chunk_count)
offset = int(frappe.form_dict.chunk_byte_offset)
else:
offset = 0
current_chunk = 0
total_chunks = 1
temp_path = Path(get_files_path(".temp-" + get_safe_file_name(filename), is_private=is_private))
with temp_path.open("ab") as f:
f.seek(offset)
f.write(file.stream.read())
if not f.tell() >= int(total_file_size) or current_chunk != total_chunks - 1:
return
content = temp_path.read_bytes()
temp_path.unlink()
content_type = guess_type(filename)[0]
if optimize and content_type and content_type.startswith("image/"):
args = {"content": content, "content_type": content_type}

View file

@ -210,6 +210,7 @@ scheduler_events = {
# 5 minutes
"0/5 * * * *": [
"frappe.email.doctype.notification.notification.trigger_offset_alerts",
"frappe.search.sqlite_search.index_docs_in_queue",
],
# 15 minutes
"0/15 * * * *": [
@ -471,21 +472,6 @@ get_changelog_feed = "frappe.desk.doctype.changelog_feed.changelog_feed.get_feed
export_python_type_annotations = True
standard_navbar_items = [
{
"item_label": "User Settings",
"item_type": "Action",
"action": "frappe.ui.toolbar.route_to_user()",
"is_standard": 1,
},
{
"item_label": "Log out",
"item_type": "Action",
"action": "frappe.app.logout()",
"is_standard": 1,
},
]
standard_help_items = [
{
"item_label": "About",

View file

@ -99,7 +99,7 @@ class LDAPSettings(Document):
except LDAPAttributeError as ex:
frappe.throw(
_("LDAP settings incorrect. validation response was: {0}").format(ex),
_("LDAP settings incorrect. validation response was: {0}").format(str(ex)),
title=_("Misconfigured"),
)

View file

@ -88,7 +88,7 @@ class Webhook(Document):
try:
frappe.safe_eval(self.condition, eval_locals=get_context(temp_doc))
except Exception as e:
frappe.throw(_("Invalid Condition: {}").format(e))
frappe.throw(_("Invalid Condition: {}").format(str(e)))
def validate_request_url(self):
try:
@ -128,7 +128,7 @@ class Webhook(Document):
met_condition = frappe.safe_eval(self.condition, eval_locals=get_context(doc))
except Exception as e:
frappe.local.message_log = []
return _("Failed to evaluate conditions: {}").format(e)
return _("Failed to evaluate conditions: {}").format(str(e))
return _("Yes") if met_condition else _("No")
@frappe.whitelist()
@ -138,7 +138,7 @@ class Webhook(Document):
return frappe.as_json(get_webhook_data(doc, self))
except Exception as e:
frappe.local.message_log = []
return _("Failed to compute request body: {}").format(e)
return _("Failed to compute request body: {}").format(str(e))
def get_context(doc):

View file

@ -44,21 +44,19 @@ def get_headers():
def current_site_info():
from frappe.utils import cint
res = {}
request = requests.post(f"{get_base_url()}/api/method/press.saas.api.site.info", headers=get_headers())
if request.status_code == 200:
res = request.json().get("message")
if not res:
if not res or not isinstance(res, dict):
return None
return {
**res,
"site_name": get_site_name(),
"base_url": get_base_url(),
"setup_complete": cint(frappe.get_system_settings("setup_complete")),
}
else:
frappe.throw(_("Failed to get site info"))
return {
**res,
"site_name": get_site_name(),
"base_url": get_base_url(),
"setup_complete": cint(frappe.get_system_settings("setup_complete")),
}
@frappe.whitelist()

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

@ -109,7 +109,7 @@ def evaluate_workflow_value(value, evaluate_as_expression, doc):
return frappe.safe_eval(value, get_workflow_safe_globals(), dict(doc=doc.as_dict()))
except Exception as e:
frappe.throw(
_("Invalid expression in Workflow Update Value: {0}").format(e),
_("Invalid expression in Workflow Update Value: {0}").format(str(e)),
title=_("Workflow Evaluation Error"),
)
else:

View file

@ -208,13 +208,7 @@ frappe.ui.form.PrintView = class {
// print designer link
if (!cint(frappe.boot.sysdefaults.disable_product_suggestion)) {
if (Object.keys(frappe.boot.versions).includes("print_designer")) {
this.page.add_inner_message(`
<a style="line-height: 2.4" href="/desk/print-designer?doctype=${this.frm.doctype}">
${__("Try the new Print Designer")}
</a>
`);
} else {
if (!Object.keys(frappe.boot.versions).includes("print_designer")) {
this.page.add_inner_message(`
<a style="line-height: 2.4" href="https://frappecloud.com/marketplace/apps/print_designer?utm_source=framework-desk&utm_medium=print-view&utm_campaign=try-link">
${__("Try the new Print Designer")}
@ -741,7 +735,9 @@ frappe.ui.form.PrintView = class {
encodeURIComponent(this.get_letterhead()) +
"&settings=" +
encodeURIComponent(JSON.stringify(this.additional_settings)) +
(this.lang_code ? "&_lang=" + this.lang_code : "")
(this.lang_code ? "&_lang=" + this.lang_code : "") +
"&pdf_generator=" +
encodeURIComponent(pdf_generator)
)
);
if (!w) {

View file

@ -104,7 +104,7 @@ function addChatBubble() {
let chat_banner = document.createElement("script");
chat_banner.setAttribute("id", "chat_widget_trigger");
chat_banner.innerHTML =
'(function(d,t){var BASE_URL="https://chat.frappe.cloud";var g=d.createElement(t),s=d.getElementsByTagName(t)[0];g.src=BASE_URL+"/packs/js/sdk.js";g.async=true;s.parentNode.insertBefore(g,s);g.onload=function(){window.chatwootSDK.run({websiteToken:"LdmfJzftdJGEcFjoTqk8CrSq",baseUrl:BASE_URL})}})(document,"script");';
'window.chatwootSettings = {"position":"right","type":"expanded_bubble","launcherTitle":"Chat with us"}; (function(d,t){var BASE_URL="https://chat.frappe.cloud";var g=d.createElement(t),s=d.getElementsByTagName(t)[0];g.src=BASE_URL+"/packs/js/sdk.js";g.async=true;s.parentNode.insertBefore(g,s);g.onload=function(){window.chatwootSDK.run({websiteToken:"LdmfJzftdJGEcFjoTqk8CrSq",baseUrl:BASE_URL})}})(document,"script");';
document.body.append(chat_banner);
const root = document.documentElement;
root.style.setProperty("--s-700", "var(--gray-500)");

View file

@ -208,7 +208,6 @@ frappe.data_import.DataExporter = class DataExporter {
}
update_record_count_message() {
let export_records = this.dialog.get_value("export_records");
let count_method = {
all: () => frappe.db.count(this.doctype),
by_filter: () =>
@ -219,6 +218,10 @@ frappe.data_import.DataExporter = class DataExporter {
"5_records": () => Promise.resolve(5),
};
let export_records = this.dialog.get_value("export_records");
if (!export_records || !count_method[export_records]) return;
count_method[export_records]().then((value) => {
let message = "";
value = parseInt(value, 10);

View file

@ -568,57 +568,66 @@ function return_as_dataurl() {
close_dialog.value = true;
return Promise.all(promises);
}
function upload_file(file, i) {
async function upload_file(file, i) {
currently_uploading.value = i;
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.upload.addEventListener("loadstart", (e) => {
file.uploading = true;
});
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
file.progress = e.loaded;
file.total = e.total;
}
});
xhr.upload.addEventListener("load", (e) => {
file.uploading = false;
});
xhr.addEventListener("error", (e) => {
file.failed = true;
reject();
});
xhr.onreadystatechange = () => {
if (xhr.readyState == XMLHttpRequest.DONE) {
const CHUNK_SIZE = frappe.boot.file_chunk_size;
const use_chunks = file.file_obj && file.file_obj.size > CHUNK_SIZE;
const total_chunks = use_chunks ? Math.ceil(file.file_obj.size / CHUNK_SIZE) : 1;
const send_chunk = (chunk_blob, chunk_index, chunk_byte_offset) => {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.upload.addEventListener("loadstart", () => {
file.uploading = true;
});
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
file.progress = chunk_byte_offset + e.loaded;
file.total = file.file_obj?.size || e.total;
}
});
xhr.upload.addEventListener("load", () => {
if (chunk_index === total_chunks - 1) {
file.uploading = false;
}
});
xhr.addEventListener("error", () => {
file.failed = true;
reject();
});
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) return;
if (xhr.status === 200) {
resolve();
file.request_succeeded = true;
let r = null;
let file_doc = null;
try {
r = JSON.parse(xhr.responseText);
if (r.message.doctype === "File") {
file_doc = r.message;
// Only the last chunk returns a meaningful response
if (chunk_index === total_chunks - 1) {
file.request_succeeded = true;
let r = null;
let file_doc = null;
try {
r = JSON.parse(xhr.responseText);
if (r.message?.doctype === "File") {
file_doc = r.message;
}
} catch (e) {
r = xhr.responseText;
}
} catch (e) {
r = xhr.responseText;
}
file.doc = file_doc;
if (props.on_success) {
props.on_success(file_doc, r);
}
if (
i == files.value.length - 1 &&
files.value.every((file) => file.request_succeeded)
) {
close_dialog.value = true;
}
if (show_web_link.value && file.file_url) {
close_dialog.value = true;
file.doc = file_doc;
if (props.on_success) {
props.on_success(file_doc, r);
}
if (
(i == files.value.length - 1 &&
files.value.every((f) => f.request_succeeded)) ||
(show_web_link.value && file.file_url)
) {
close_dialog.value = true;
}
}
} else if (xhr.status === 403) {
reject();
@ -669,60 +678,58 @@ function upload_file(file, i) {
}
frappe.request.cleanup({}, error);
}
};
xhr.open("POST", "/api/method/upload_file", true);
xhr.setRequestHeader("Accept", "application/json");
xhr.setRequestHeader("X-Frappe-CSRF-Token", frappe.csrf_token);
let form_data = new FormData();
if (chunk_blob) {
form_data.append("file", chunk_blob, file.name);
}
};
xhr.open("POST", "/api/method/upload_file", true);
xhr.setRequestHeader("Accept", "application/json");
xhr.setRequestHeader("X-Frappe-CSRF-Token", frappe.csrf_token);
let form_data = new FormData();
if (file.file_obj) {
form_data.append("file", file.file_obj, file.name);
}
form_data.append("is_private", +file.private);
form_data.append("folder", props.folder);
form_data.append("is_private", +file.private);
form_data.append("folder", props.folder);
form_data.append("total_file_size", file.file_obj?.size ?? 0);
if (file.file_url) {
form_data.append("file_url", file.file_url);
}
if (file.file_size) {
form_data.append("file_size", file.file_size);
}
if (file.file_name) {
form_data.append("file_name", file.file_name);
}
if (file.library_file_name) {
form_data.append("library_file_name", file.library_file_name);
}
if (use_chunks) {
form_data.append("chunk_index", chunk_index);
form_data.append("total_chunk_count", total_chunks);
form_data.append("chunk_byte_offset", chunk_byte_offset);
}
if (props.doctype) {
form_data.append("doctype", props.doctype);
}
if (file.file_url) form_data.append("file_url", file.file_url);
if (file.file_size) form_data.append("file_size", file.file_size);
if (file.file_name) form_data.append("file_name", file.file_name);
if (file.library_file_name)
form_data.append("library_file_name", file.library_file_name);
if (props.doctype) form_data.append("doctype", props.doctype);
if (props.docname) form_data.append("docname", props.docname);
if (props.fieldname) form_data.append("fieldname", props.fieldname);
if (props.method) form_data.append("method", props.method);
if (file.optimize) form_data.append("optimize", true);
if (props.attach_doc_image) {
form_data.append("max_width", 200);
form_data.append("max_height", 200);
}
if (props.docname) {
form_data.append("docname", props.docname);
}
xhr.send(form_data);
});
};
if (props.fieldname) {
form_data.append("fieldname", props.fieldname);
}
if (props.method) {
form_data.append("method", props.method);
}
if (file.optimize) {
form_data.append("optimize", true);
}
if (props.attach_doc_image) {
form_data.append("max_width", 200);
form_data.append("max_height", 200);
}
xhr.send(form_data);
});
// Slice and send chunks sequentially
let chunk_byte_offset = 0;
for (let chunk_index = 0; chunk_index < total_chunks; chunk_index++) {
const chunk_blob = file.file_obj
? file.file_obj.slice(chunk_byte_offset, chunk_byte_offset + CHUNK_SIZE)
: null;
await send_chunk(chunk_blob, chunk_index, chunk_byte_offset);
chunk_byte_offset += CHUNK_SIZE;
}
}
function parse_error_response(response_text) {
let error_message = "";
let server_messages = [];

View file

@ -37,8 +37,8 @@ export default class Column {
}
resize_all_columns() {
// distribute visible columns equally
let all_columns = this.section.wrapper.find(".form-column");
// distribute visible columns equally, scoped to this section's direct children only
let all_columns = this.section.body.children(".form-column");
let visible_columns = all_columns.filter(":not(.hide-control)");
let columns = visible_columns.length || all_columns.length;
let colspan = cint(12 / columns);

View file

@ -10,11 +10,16 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co
} else if (value.toLowerCase() === "now") {
value = frappe.datetime.now_datetime();
}
const raw_value = value;
let should_refresh = this.last_value && this.last_value !== value;
value = this.format_for_input(value);
this.$input && this.$input.val(value);
if (should_refresh) {
this.datepicker.selectDate(frappe.datetime.user_to_obj(value));
} else if (value && !this.datepicker.selectedDates.length) {
const date_obj = frappe.datetime.str_to_obj(raw_value);
this.datepicker.selectedDates = [date_obj];
this.datepicker.viewDate = date_obj;
}
}

View file

@ -12,13 +12,14 @@ class FormTimeline extends BaseTimeline {
super.make();
this.setup_timeline_actions();
this.render_timeline_items();
this.setup_activity_toggle();
}
refresh() {
super.refresh();
this.frm.trigger("timeline_refresh");
this.setup_activity_toggle();
this.setup_document_email_link();
this.toggle_show_all_activity();
}
setup_timeline_actions() {
@ -48,11 +49,17 @@ class FormTimeline extends BaseTimeline {
}
}
setup_activity_toggle() {
const doc_info = this.doc_info || this.frm.get_docinfo();
const has_communications = () =>
doc_info.communications?.length || doc_info.comments?.length;
toggle_show_all_activity() {
const btn = this.timeline_wrapper.find(".timeline-item .show-all-activity");
btn.toggle(!!this.has_communications());
}
has_communications() {
const doc_info = this.doc_info || this.frm.get_docinfo();
return doc_info.communications?.length || doc_info.comments?.length;
}
setup_activity_toggle() {
this.timeline_wrapper.remove(this.timeline_actions_wrapper);
this.timeline_wrapper.find(".timeline-item.activity-title").remove();
this.timeline_wrapper.prepend(`
@ -62,28 +69,26 @@ class FormTimeline extends BaseTimeline {
`);
const me = this;
if (has_communications()) {
this.timeline_wrapper
.find(".timeline-item.activity-title")
.append(
`
<div class="d-flex align-items-center show-all-activity">
<span style="color: var(--text-light); margin:0px 6px;">${__("Show all activity")}</span>
<label class="switch">
<input type="checkbox">
<span class="slider round"></span>
</label>
</div>
this.timeline_wrapper
.find(".timeline-item.activity-title")
.append(
`
)
.find("input[type=checkbox]")
.prop("checked", !me.only_communication)
.on("click", function (e) {
me.only_communication = !this.checked;
me.render_timeline_items();
$(this).tab("show");
});
}
<div class="flex align-items-center show-all-activity">
<span style="color: var(--text-light); margin:0px 6px;">${__("Show all activity")}</span>
<label class="switch">
<input type="checkbox">
<span class="slider round"></span>
</label>
</div>
`
)
.find("input[type=checkbox]")
.prop("checked", !me.only_communication)
.on("click", function (e) {
me.only_communication = !this.checked;
me.render_timeline_items();
$(this).tab("show");
});
this.timeline_wrapper
.find(".timeline-item.activity-title")
.append(this.timeline_actions_wrapper);

View file

@ -94,7 +94,9 @@ frappe.form.formatters = {
if (value === null) {
return "";
}
const valuePrecision = value.toString().split(".")[1]?.length || 0;
const valuePrecision = value?.toString().split(".")[1]?.length || 0;
const precision =
docfield.precision ||
cint(frappe.boot.sysdefaults && frappe.boot.sysdefaults.float_precision) ||
@ -419,7 +421,8 @@ function get_link_display_value(doctype, link_title, value) {
return link_title || value;
}
function format_attachment_url(url) {
return url ? `<a href="${url}" target="_blank">${url}</a>` : "";
let escaped = frappe.utils.escape_html(url);
return url ? `<a href="${escaped}" target="_blank">${escaped}</a>` : "";
}
frappe.form.get_formatter = function (fieldtype) {

View file

@ -1033,7 +1033,7 @@ export default class GridRow {
let is_focused = false;
var $col = $(
`<div class="col grid-static-col flex col-xs-${colsize} ${add_class}" style="${add_style}"></div>`
`<div class="col grid-static-col col-xs-${colsize} ${add_class}" style="${add_style}"></div>`
)
.attr("data-fieldname", df.fieldname)
.attr("data-fieldtype", df.fieldtype)
@ -1095,9 +1095,7 @@ export default class GridRow {
return out;
});
$col.field_area = $('<div class="field-area flex flex-grow-1"></div>')
.appendTo($col)
.toggle(false);
$col.field_area = $('<div class="field-area"></div>').appendTo($col).toggle(false);
$col.static_area = $('<div class="static-area ellipsis"></div>').appendTo($col).html(txt);
// set title attribute to see full label for columns in the heading row

View file

@ -198,36 +198,37 @@ frappe.ui.form.check_mandatory = function (frm) {
return !has_errors;
function is_docfield_mandatory(doc, df) {
if (df.reqd) return true;
if (!df.mandatory_depends_on || !doc) return;
if (df.mandatory_depends_on && doc) {
let out = null;
let expression = df.mandatory_depends_on;
let parent = frappe.get_meta(df.parent);
let out = null;
let expression = df.mandatory_depends_on;
let parent = frappe.get_meta(df.parent);
if (typeof expression === "boolean") {
out = expression;
} else if (typeof expression === "function") {
out = expression(doc);
} else if (expression.substr(0, 5) == "eval:") {
try {
out = frappe.utils.eval(expression.substr(5), { doc, parent });
if (parent && parent.istable && expression.includes("is_submittable")) {
out = true;
if (typeof expression === "boolean") {
out = expression;
} else if (typeof expression === "function") {
out = expression(doc);
} else if (expression.substr(0, 5) == "eval:") {
try {
out = frappe.utils.eval(expression.substr(5), { doc, parent });
if (parent && parent.istable && expression.includes("is_submittable")) {
out = true;
}
} catch (e) {
frappe.throw(__('Invalid "mandatory_depends_on" expression'));
}
} catch (e) {
frappe.throw(__('Invalid "mandatory_depends_on" expression'));
}
} else {
var value = doc[expression];
if ($.isArray(value)) {
out = !!value.length;
} else {
out = !!value;
var value = doc[expression];
if ($.isArray(value)) {
out = !!value.length;
} else {
out = !!value;
}
}
return out;
}
return out;
return !!df.reqd;
}
function scroll_to(fieldname) {

View file

@ -102,22 +102,16 @@ frappe.ui.form.States = class FormStates {
if (frappe.user_roles.includes(d.allowed) && has_approval_access(d)) {
added = true;
me.frm.page.add_action_item(__(d.action), function () {
frappe.db
.get_value(
"Workflow",
{ document_type: me.frm.doctype },
"enable_action_confirmation"
)
.then((r) => {
if (r.message.enable_action_confirmation) {
frappe.confirm(
__("Are you sure you want to {0}?", [d.action]),
() => me.handle_workflow_action(d)
);
} else {
me.handle_workflow_action(d);
}
});
if (
frappe.workflow?.workflows?.[me.frm.doctype]
?.enable_action_confirmation
) {
frappe.confirm(__("Are you sure you want to {0}?", [d.action]), () =>
me.handle_workflow_action(d)
);
} else {
me.handle_workflow_action(d);
}
});
}
});

View file

@ -391,12 +391,14 @@ export default class BulkOperations {
show_help_text();
function set_value_field(dialogObj) {
const new_df = Object.assign({}, field_mappings[dialogObj.get_value("field")]);
const field_value = dialogObj.get_value("field");
if (!field_value || !field_mappings[field_value]) return;
const new_df = Object.assign({}, field_mappings[field_value]);
/* if the field label has status in it and
if it has select fieldtype with no default value then
set a default value from the available option. */
if (
new_df.label.match(status_regex) &&
new_df.label?.match(status_regex) &&
new_df.fieldtype === "Select" &&
!new_df.default
) {

View file

@ -837,9 +837,13 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
let col = this.columns[i];
if (frappe.is_mobile() && col.type == "Field" && [3, 4].includes(i)) {
left_html += `<div class="mobile-layout ${
i == 3 ? "mobile-layout-seperator" : ""
}">${this.get_column_html(col, doc, true)}</div>`;
const no_seperator_class = !doc[col?.df?.fieldname] ? "no-seperator" : "";
left_html += `<div
class="mobile-layout ${no_seperator_class} ${i == 3 ? "mobile-layout-seperator" : ""}"
${no_seperator_class ? "style='padding-left: var(--margin-sm);'" : ""}
>
${this.get_column_html(col, doc, true)}
</div>`;
} else {
left_html += this.get_column_html(col, doc, false);
}
@ -964,7 +968,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
if (df.fieldtype === "Image") {
html = df.options
? `<img src="${doc[df.options]}"
? `<img src="${frappe.utils.escape_html(doc[df.options])}"
style="max-height: 30px; max-width: 100%;">`
: `<div class="missing-image small">
${frappe.utils.icon("restriction")}

View file

@ -19,7 +19,7 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout {
}
resolve_date_default_keywords(def_value, fieldtype) {
if (!def_value || typeof def_value !== "string") return def_value;
if (!def_value) return def_value;
def_value = def_value.toLowerCase();
@ -39,6 +39,28 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout {
return def_value;
}
get_field_default_value(field) {
let def_value = field.df["default"];
// loose equality check matches undefined also
if (
def_value == null ||
(!def_value && !frappe.model.is_numeric_field(field.df.fieldtype))
)
return;
if (typeof def_value !== "string") return def_value;
if (["Date", "Datetime", "Time"].includes(field.df.fieldtype)) {
def_value = this.resolve_date_default_keywords(def_value, field.df.fieldtype);
} else if (def_value == "__user" || def_value.toLowerCase() == "user") {
def_value = frappe.session.user;
} else if (def_value == "user_fullname") {
def_value = frappe.session.user_fullname;
}
return def_value;
}
make() {
let me = this;
if (this.fields) {
@ -47,19 +69,9 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout {
let defaults = {};
$.each(this.fields_list, function (i, field) {
let def_value = field.df["default"];
// loose equality check matches undefined also
if (
def_value == null ||
(!def_value && !frappe.model.is_numeric_field(field.df.fieldtype))
)
return;
if (["Date", "Datetime", "Time"].includes(field.df.fieldtype)) {
def_value = me.resolve_date_default_keywords(def_value, field.df.fieldtype);
}
$.each(this.fields_list, (i, field) => {
let def_value = this.get_field_default_value(field);
if (def_value === undefined) return;
defaults[field.df.fieldname] = def_value;
});

View file

@ -46,15 +46,22 @@ frappe.ui.menu = class ContextMenu {
make() {
this.template.empty();
this.menu_items_to_show = [];
this.menu_items.forEach((f) => {
f.condition =
f.condition ||
this.menu_items.forEach((item) => {
item.condition =
item.condition ||
function () {
return true;
};
if (f.condition()) {
this.add_menu_item(f);
this.menu_items_to_show.push(f);
console.log(typeof item.condition);
let render = false;
if (typeof item.condition == "function") {
render = item.condition();
} else {
render = frappe.utils.eval_expression(item.condition);
}
if (render) {
this.add_menu_item(item);
this.menu_items_to_show.push(item);
}
});
@ -133,7 +140,9 @@ frappe.ui.menu = class ContextMenu {
me.current_menu = null;
} else {
// this ensures the other nested item would close before opening the next one
me.current_menu.nested_menus.forEach((m) => m.hide());
me.current_menu.hide();
me.current_menu = null;
me.nested_menus.forEach((menu) => {
if (menu.parent.get(0) == this) {
me.current_menu = menu;

View file

@ -183,6 +183,7 @@ frappe.msgprint = function (msg, title, is_minimizable, re_route) {
onhide: function () {
if (frappe.msg_dialog.custom_onhide) {
frappe.msg_dialog.custom_onhide();
delete frappe.msg_dialog.custom_onhide;
}
frappe.msg_dialog.msg_area.empty();
},

View file

@ -52,10 +52,10 @@
<span> {%= __("Collapse") %} </span>
</a>
<div class="nav-item dropdown dropdown-navbar-user dropdown-mobile mt-3">
<button
<a
class="align-center btn-reset flex nav-link"
style="width: 100%; height: 40px;"
data-toggle="dropdown"
onclick="return frappe.ui.toolbar.route_to_user()"
aria-label="{{ __("User Menu") }}"
>
<div> {{ avatar }} </div>
@ -68,25 +68,7 @@
</span>
</div>
</button>
<div class="dropdown-menu dropdown-menu-left" id="toolbar-user" role="menu">
{% for item in navbar_settings.settings_dropdown %}
{% var condition = item.condition ? eval(item.condition) : true %}
{% if (condition && !item.hidden) { %}
{% if (item.route) { %}
<a class="dropdown-item" href="{{ item.route }}">
{%= __(item.item_label) %}
</a>
{% } else if (item.action) { %}
<button class="btn-reset dropdown-item" onclick="return {{ item.action }}">
{%= __(item.item_label) %}
</button>
{% } else { %}
<div class="dropdown-divider"></div>
{% } %}
{% } %}
{% endfor %}
</div>
</a>
</div>
</div>
</div>

View file

@ -65,6 +65,16 @@ frappe.ui.Sidebar = class Sidebar {
frappe.current_app = app;
this.app_logo_url = app.app_logo_url;
return;
} else {
let app_name = frappe.boot.module_app[this.workspace_title];
if (app_name) {
let app_title = frappe.boot.app_data.find((f) => {
return f.app_name == app_name;
}).app_title;
this.header_subtitle = app_title;
} else {
this.header_subtitle = frappe.session.user;
}
}
}
@ -616,7 +626,7 @@ frappe.ui.Sidebar = class Sidebar {
switch (route.length) {
case 1:
view = "Page";
entity_name = route[1];
entity_name = route[0];
break;
case 2:
view = route[0];

View file

@ -76,15 +76,29 @@ frappe.ui.SidebarHeader = class SidebarHeader {
label: "Help",
icon: "info",
items: this.get_help_siblings(),
},
{
name: "logout",
label: "Logout",
icon: "logout",
onClick: function () {
return frappe.app.logout();
},
}
);
}
this.add_navbar_items();
this.make();
this.setup_app_switcher();
this.populate_dropdown_menu();
this.setup_select_options();
}
add_navbar_items() {
frappe.boot.navbar_settings.settings_dropdown.forEach((item) => {
item.label = item.item_label;
this.dropdown_items.push(item);
});
}
fetch_related_icons() {
let sibling_workspaces = [];
let workspaces_not_to_show = ["My Workspaces"];

View file

@ -18,6 +18,7 @@ frappe.ui.Tree = class {
get_label,
on_render,
on_click,
on_node_render,
}) {
$.extend(this, arguments[0]);
if (root_value == null) {
@ -164,11 +165,13 @@ frappe.ui.Tree = class {
() => this.get_all_nodes(value, is_root, node.label),
(data_list) => this.render_children_of_all_nodes(data_list),
() => this.set_selected_node(node),
() => this.on_node_render && this.on_node_render(node, deep),
])
: frappe.run_serially([
() => this.get_nodes(value, is_root),
(data_set) => this.render_node_children(node, data_set),
() => this.set_selected_node(node),
() => this.on_node_render && this.on_node_render(node, deep),
]);
}

View file

@ -896,6 +896,8 @@ frappe.views.CommunicationComposer = class {
if (!r.exc) {
frappe.utils.play_sound("email");
const communication_name = r.message["name"];
if (r.message["emails_not_sent_to"]) {
frappe.msgprint(
__("Email not sent to {0} (unsubscribed / disabled)", [
@ -910,6 +912,54 @@ frappe.views.CommunicationComposer = class {
me.frm.reload_doc();
}
let undo_alert = frappe.show_alert(
{
message: `<span>${__(
"Email Sent"
)}</span><span class="cursor-pointer ml-4" data-action="undo" style="font-weight: 500; text-decoration: underline;">${__(
"Undo"
)}</span>`,
indicator: "green",
},
10,
{
undo: () => {
if (undo_alert) {
undo_alert.find(".close").click();
}
frappe
.xcall(
"frappe.core.doctype.communication.email.undo_email_send",
{ communication_name: communication_name }
)
.then((d) => {
if (me.frm) {
me.frm.reload_doc();
}
// Reopen the composer with the recovered data
new frappe.views.CommunicationComposer({
doc: d.doc,
subject: d.subject,
recipients: d.recipients,
cc: d.cc,
bcc: d.bcc,
message: d.content,
sender: d.sender,
read_receipt: d.send_read_receipt,
attachments: d.attachments,
frm: me.frm,
});
frappe.show_alert({
message: __("Email sending undone"),
indicator: "blue",
});
});
},
}
);
// try the success callback if it exists
if (me.success) {
try {

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