Merge branch 'frappe:develop' into chore/add-brazilian-portuguese-language
This commit is contained in:
commit
997656339e
119 changed files with 35429 additions and 28909 deletions
2
.github/helper/documentation.py
vendored
2
.github/helper/documentation.py
vendored
|
|
@ -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()
|
||||
|
|
|
|||
2
.github/workflows/_base-server-tests.yml
vendored
2
.github/workflows/_base-server-tests.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
6
.github/workflows/_base-ui-tests.yml
vendored
6
.github/workflows/_base-ui-tests.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
2
.github/workflows/server-tests.yml
vendored
2
.github/workflows/server-tests.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
2
.github/workflows/ui-tests.yml
vendored
2
.github/workflows/ui-tests.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class AssignmentRuleUser(Document):
|
|||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
user: DF.Link
|
||||
weight: DF.Int
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
2149
frappe/locale/ar.po
2149
frappe/locale/ar.po
File diff suppressed because it is too large
Load diff
2156
frappe/locale/bs.po
2156
frappe/locale/bs.po
File diff suppressed because it is too large
Load diff
2141
frappe/locale/cs.po
2141
frappe/locale/cs.po
File diff suppressed because it is too large
Load diff
2143
frappe/locale/da.po
2143
frappe/locale/da.po
File diff suppressed because it is too large
Load diff
2149
frappe/locale/de.po
2149
frappe/locale/de.po
File diff suppressed because it is too large
Load diff
2151
frappe/locale/eo.po
2151
frappe/locale/eo.po
File diff suppressed because it is too large
Load diff
2149
frappe/locale/es.po
2149
frappe/locale/es.po
File diff suppressed because it is too large
Load diff
2143
frappe/locale/fa.po
2143
frappe/locale/fa.po
File diff suppressed because it is too large
Load diff
2145
frappe/locale/fr.po
2145
frappe/locale/fr.po
File diff suppressed because it is too large
Load diff
2152
frappe/locale/hr.po
2152
frappe/locale/hr.po
File diff suppressed because it is too large
Load diff
2239
frappe/locale/hu.po
2239
frappe/locale/hu.po
File diff suppressed because it is too large
Load diff
2141
frappe/locale/id.po
2141
frappe/locale/id.po
File diff suppressed because it is too large
Load diff
2143
frappe/locale/it.po
2143
frappe/locale/it.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
2141
frappe/locale/my.po
2141
frappe/locale/my.po
File diff suppressed because it is too large
Load diff
2149
frappe/locale/nb.po
2149
frappe/locale/nb.po
File diff suppressed because it is too large
Load diff
2149
frappe/locale/nl.po
2149
frappe/locale/nl.po
File diff suppressed because it is too large
Load diff
2145
frappe/locale/pl.po
2145
frappe/locale/pl.po
File diff suppressed because it is too large
Load diff
2143
frappe/locale/pt.po
2143
frappe/locale/pt.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
3469
frappe/locale/ru.po
3469
frappe/locale/ru.po
File diff suppressed because it is too large
Load diff
2143
frappe/locale/sl.po
2143
frappe/locale/sl.po
File diff suppressed because it is too large
Load diff
2152
frappe/locale/sr.po
2152
frappe/locale/sr.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
2154
frappe/locale/sv.po
2154
frappe/locale/sv.po
File diff suppressed because it is too large
Load diff
2147
frappe/locale/th.po
2147
frappe/locale/th.po
File diff suppressed because it is too large
Load diff
2147
frappe/locale/tr.po
2147
frappe/locale/tr.po
File diff suppressed because it is too large
Load diff
2149
frappe/locale/vi.po
2149
frappe/locale/vi.po
File diff suppressed because it is too large
Load diff
2149
frappe/locale/zh.po
2149
frappe/locale/zh.po
File diff suppressed because it is too large
Load diff
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue