diff --git a/frappe/auth.py b/frappe/auth.py
index 5a447a99af..1658930317 100644
--- a/frappe/auth.py
+++ b/frappe/auth.py
@@ -253,7 +253,11 @@ class LoginManager:
):
return
- clear_sessions(frappe.session.user, keep_current=True)
+ clear_sessions(
+ frappe.session.user,
+ keep_current=True,
+ force=frappe.session.user != "Administrator",
+ )
def authenticate(self, user: str | None = None, pwd: str | None = None):
from frappe.core.doctype.user.user import User
diff --git a/frappe/commands/test_commands.py b/frappe/commands/test_commands.py
index bd9697d599..f3d9f3d0d9 100644
--- a/frappe/commands/test_commands.py
+++ b/frappe/commands/test_commands.py
@@ -1116,6 +1116,7 @@ class TestGunicornWorker(IntegrationTestCase):
time.sleep(2)
execute_in_shell("pgrep gunicorn | xargs -L1 kill -9")
+ @unittest.skip("Flaky test")
def test_gunicorn_ping_sync(self):
self.spawn_gunicorn()
path = f"http://{self.TEST_SITE}:{self.port}/api/method/ping"
@@ -1126,6 +1127,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_idle_cpu_usage(self):
def get_total_usage():
process = psutil.Process(self.handle.pid)
diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py
index 59aefc53bf..16dae174ec 100644
--- a/frappe/core/doctype/communication/communication.py
+++ b/frappe/core/doctype/communication/communication.py
@@ -419,7 +419,7 @@ class Communication(Document, CommunicationEmailMixin):
# Skip timeline links if a "Sent" communication already exists
# else will create duplicate timeline entries
if self.sent_or_received == "Received" and self.find_one_by_filters(
- message_id=self.message_id, sent_or_received="Sent"
+ message_id=self.message_id, email_account=self.email_account, sent_or_received="Sent"
):
return
diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js
index f3f1380855..2dd4bb48a2 100644
--- a/frappe/core/doctype/file/file.js
+++ b/frappe/core/doctype/file/file.js
@@ -43,25 +43,34 @@ frappe.ui.form.on("File", {
if (!frappe.utils.can_upload_public_files() && frm.doc.is_private) {
frm.set_df_property("is_private", "read_only", 1);
}
+
+ if (frm.doc.attached_to_name) {
+ const field = frm.get_field("attached_to_name");
+ field.$input_wrapper
+ .find(".control-value")
+ .html(`${frappe.utils.get_form_link(frm.doctype, frm.docname, true)}`);
+ }
},
preview_file: function (frm) {
let $preview = "";
let file_extension = frm.doc.file_type.toLowerCase();
+ const full_file_url = frm.doc.file_url + "?fid=" + frm.doc.name;
+ const src_url = frappe.utils.escape_html(full_file_url);
- if (frappe.utils.is_image_file(frm.doc.file_url)) {
+ if (frappe.utils.is_image_file(full_file_url)) {
$preview = $(`
`);
- } else if (frappe.utils.is_video_file(frm.doc.file_url)) {
+ } else if (frappe.utils.is_video_file(full_file_url)) {
$preview = $(`
-
+
${__("Your browser does not support the video element.")}
`);
@@ -72,14 +81,14 @@ frappe.ui.form.on("File", {
style="background:#323639;"
width="100%"
height="1190"
- src="${frappe.utils.escape_html(frm.doc.file_url)}" type="application/pdf"
+ src="${src_url}" type="application/pdf"
>
`);
} else if (file_extension === "mp3") {
$preview = $(`
-
+
${__("Your browser does not support the audio element.")}
`);
diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json
index c1aa3fb0d7..75d1bd978a 100644
--- a/frappe/core/doctype/system_settings/system_settings.json
+++ b/frappe/core/doctype/system_settings/system_settings.json
@@ -248,7 +248,6 @@
},
{
"default": "0",
- "description": "Note: Multiple sessions will be allowed in case of mobile device",
"fieldname": "deny_multiple_sessions",
"fieldtype": "Check",
"label": "Allow only one session per user"
@@ -790,7 +789,7 @@
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
- "modified": "2026-01-02 18:13:45.430712",
+ "modified": "2026-02-24 14:27:04.763075",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",
diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py
index 29d9cb6b0f..1424e2a3d8 100644
--- a/frappe/desk/doctype/workspace/workspace.py
+++ b/frappe/desk/doctype/workspace/workspace.py
@@ -76,6 +76,18 @@ class Workspace(Document):
if self.public and not is_workspace_manager() and not disable_saving_as_public():
frappe.throw(_("You need to be Workspace Manager to edit this document"))
+
+ if (
+ not self.public
+ and self.for_user
+ and self.for_user != frappe.session.user
+ and not is_workspace_manager()
+ ):
+ frappe.throw(
+ _("You are not allowed to edit this workspace"),
+ frappe.PermissionError,
+ )
+
if self.has_value_changed("title"):
validate_route_conflict(self.doctype, self.title)
else:
diff --git a/frappe/desk/page/desktop/desktop.css b/frappe/desk/page/desktop/desktop.css
index 27a128b60c..ef5e4e6062 100644
--- a/frappe/desk/page/desktop/desktop.css
+++ b/frappe/desk/page/desktop/desktop.css
@@ -2,7 +2,7 @@
--desktop-blur: blur(10.2px);
--desktop-modal-width: 590px;
--desktop-modal-height: 450px;
- --folder-thumbnail-icon-height: 12px;
+ --folder-thumbnail-icon-height: 16px;
--desktop-icon-dimension: 54px;
--folder-icon-background-color: var(--surface-gray-1);
--desktop-modal-radius: 30px;
@@ -91,7 +91,7 @@
padding:0px;
margin: 0px;
height: 100%;
- overflow: auto;
+ overflow: hidden;
}
.icons{
gap: 16px;
@@ -109,6 +109,10 @@
gap: 12px;
padding: 13px 16px 12px 16px;
position: relative;
+ border-radius: 20px;
+ border-width: 1px;
+ border-style: dashed;
+ border-color: transparent;
}
.desktop-icon.desktop-edit-mode .hide-button {
display: flex;
@@ -129,6 +133,7 @@
.icon-container{
padding: 10px;
border-radius: 16px;
+ overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
@@ -254,13 +259,13 @@
position: absolute;
}
-.folder-icon{
- border-radius: 10px;
- background-color: var(--folder-icon-background-color) !important;
+.folder-icon {
+ border-radius: 16px;
+ background-color: var(--folder-icon-background-color) !important;
box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.14);
padding: 7px;
align-items: normal;
- box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.14), 0 1px 3px 0 rgba(0, 0, 0, 0.14);
+ /* box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.14), 0 1px 3px 0 rgba(0, 0, 0, 0.14); */
& .icons{
gap: 2.1px;
margin-top: 0px;
@@ -343,8 +348,7 @@
}
.desktop-edit-mode{
- border: 1px dashed var(--outline-gray-2);
- border-radius: 20px;
+ border-color: var(--outline-gray-2);
}
.edit-mode-buttons{
display: none;
@@ -364,7 +368,7 @@
:root {
--desktop-icon-dimension: 50px;
--desktop-icon-container: 117px;
- --folder-thumbnail-icon-height:17px;
+ --folder-thumbnail-icon-height:15px;
}
.desktop-container {
@@ -443,6 +447,12 @@
}
}
+.icons-container {
+ > .icons-container {
+ padding: 0px;
+ }
+}
+
.desktop-edit{
width: 36px;
height: 36px;
diff --git a/frappe/desk/page/desktop/desktop.js b/frappe/desk/page/desktop/desktop.js
index 2690d5dd74..514af59374 100644
--- a/frappe/desk/page/desktop/desktop.js
+++ b/frappe/desk/page/desktop/desktop.js
@@ -286,7 +286,6 @@ class DesktopPage {
this.setup_navbar();
this.setup_awesomebar();
this.handle_route_change();
- this.setup_edit_button();
}
setup_edit_button() {
if (this.edit_mode || frappe.is_mobile()) return;
@@ -1087,11 +1086,6 @@ class DesktopIcon {
this.folder_grid = new DesktopIconGrid({
wrapper: this.folder_wrapper,
icons_data: this.child_icons,
- row_size: 3,
- page_size: {
- row: 3,
- col: 3,
- },
in_folder: true,
in_modal: false,
no_dragging: true,
diff --git a/frappe/email/doctype/email_account/test_email_account.py b/frappe/email/doctype/email_account/test_email_account.py
index b50dd69b34..810e87a424 100644
--- a/frappe/email/doctype/email_account/test_email_account.py
+++ b/frappe/email/doctype/email_account/test_email_account.py
@@ -521,12 +521,14 @@ class TestInboundMail(IntegrationTestCase):
def test_mail_exist_validation(self):
"""Do not create communication record if the mail is already downloaded into the system."""
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
mail_content = self.get_test_mail(fname="incoming-1.raw")
message_id = Email(mail_content).message_id
# Create new communication record in DB
- communication = self.new_communication(message_id=message_id, sent_or_received="Received")
+ communication = self.new_communication(
+ message_id=message_id, email_account=email_account.name, sent_or_received="Received"
+ )
- email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
inbound_mail = InboundMail(mail_content, email_account, 12345, 1)
new_communication = inbound_mail.process()
diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py
index b0576dfe38..ca05ec6278 100644
--- a/frappe/email/doctype/email_queue/email_queue.py
+++ b/frappe/email/doctype/email_queue/email_queue.py
@@ -514,7 +514,18 @@ def send_now(name: str | int, force_send: bool = False):
@frappe.whitelist()
def toggle_sending(enable: bool | int | str):
frappe.only_for("System Manager")
- frappe.db.set_default("suspend_email_queue", 0 if sbool(enable) else 1)
+ suspend_value = 0 if sbool(enable) else 1
+ frappe.db.set_default("suspend_email_queue", suspend_value)
+
+ action = "Resumed" if suspend_value == 0 else "Suspended"
+ frappe.get_doc(
+ {
+ "doctype": "Activity Log",
+ "user": frappe.session.user,
+ "status": "Success",
+ "subject": f"Email Queue sending {action.lower()}",
+ }
+ ).insert(ignore_permissions=True, ignore_links=True)
def on_doctype_update():
diff --git a/frappe/email/receive.py b/frappe/email/receive.py
index 81e539d706..45d587a69a 100644
--- a/frappe/email/receive.py
+++ b/frappe/email/receive.py
@@ -751,7 +751,10 @@ class InboundMail(Email):
return
return Communication.find_one_by_filters(
- message_id=self.message_id, sent_or_received="Received", order_by="creation DESC"
+ message_id=self.message_id,
+ email_account=self.email_account.name,
+ sent_or_received="Received",
+ order_by="creation DESC",
)
def is_sender_same_as_receiver(self):
diff --git a/frappe/locale/bs.po b/frappe/locale/bs.po
index c0467055e8..f05ddd2f43 100644
--- a/frappe/locale/bs.po
+++ b/frappe/locale/bs.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2026-02-22 09:42+0000\n"
-"PO-Revision-Date: 2026-02-23 22:07\n"
+"PO-Revision-Date: 2026-02-25 23:15\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Bosnian\n"
"MIME-Version: 1.0\n"
@@ -2870,7 +2870,7 @@ msgstr "Dodjela je Završena"
#. Label of the assignment_days (Table) field in DocType 'Assignment Rule'
#: frappe/automation/doctype/assignment_rule/assignment_rule.json
msgid "Assignment Days"
-msgstr "Dani Dodjeljivanja"
+msgstr "Dani Dodjele"
#. Name of a DocType
#. Label of the assignment_rule (Link) field in DocType 'ToDo'
@@ -2888,7 +2888,7 @@ msgstr "Dan Dodjele Pravila"
#. Name of a DocType
#: frappe/automation/doctype/assignment_rule_user/assignment_rule_user.json
msgid "Assignment Rule User"
-msgstr "Korisnik Dodjele Pravila"
+msgstr "Korisnik Pravila Dodjele"
#: frappe/automation/doctype/assignment_rule/assignment_rule.py:55
msgid "Assignment Rule is not allowed on document type {0}"
diff --git a/frappe/locale/fa.po b/frappe/locale/fa.po
index cd633e0b2d..8862e8e299 100644
--- a/frappe/locale/fa.po
+++ b/frappe/locale/fa.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2026-02-22 09:42+0000\n"
-"PO-Revision-Date: 2026-02-23 22:07\n"
+"PO-Revision-Date: 2026-02-25 23:14\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Persian\n"
"MIME-Version: 1.0\n"
@@ -6314,7 +6314,7 @@ msgstr "سفارشیسازی"
#: frappe/custom/doctype/customize_form/customize_form.js:89
msgid "Customize Child Table"
-msgstr "سفارشی کردن جدول فرزند"
+msgstr "سفارشیسازی جدول فرزند"
#: frappe/public/js/frappe/views/dashboard/dashboard_view.js:38
msgid "Customize Dashboard"
@@ -6339,7 +6339,7 @@ msgstr "سفارشیسازی فرم - {0}"
#. Name of a DocType
#: frappe/custom/doctype/customize_form_field/customize_form_field.json
msgid "Customize Form Field"
-msgstr "سفارشی کردن فیلد فرم"
+msgstr "سفارشیسازی فیلد فرم"
#: frappe/public/js/frappe/list/list_view.js:1994
msgctxt "Customize qucik filters of List View"
@@ -18808,7 +18808,7 @@ msgstr ""
#: frappe/core/doctype/doctype/doctype.py:1699
msgid "Options for Rating field can range from 3 to 10"
-msgstr "گزینههای فیلد رتبه بندی میتواند از 3 تا 10 باشد"
+msgstr "گزینههای فیلد رتبهبندی میتواند از 3 تا 10 باشد"
#: frappe/custom/doctype/custom_field/custom_field.js:96
msgid "Options for select. Each option on a new line."
diff --git a/frappe/locale/hr.po b/frappe/locale/hr.po
index 9f534aaf36..8851d800e9 100644
--- a/frappe/locale/hr.po
+++ b/frappe/locale/hr.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2026-02-22 09:42+0000\n"
-"PO-Revision-Date: 2026-02-23 22:07\n"
+"PO-Revision-Date: 2026-02-25 23:14\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Croatian\n"
"MIME-Version: 1.0\n"
@@ -2870,7 +2870,7 @@ msgstr "Dodjela je Završena"
#. Label of the assignment_days (Table) field in DocType 'Assignment Rule'
#: frappe/automation/doctype/assignment_rule/assignment_rule.json
msgid "Assignment Days"
-msgstr "Dani Dodjeljivanja"
+msgstr "Dani Dodjele"
#. Name of a DocType
#. Label of the assignment_rule (Link) field in DocType 'ToDo'
diff --git a/frappe/locale/sv.po b/frappe/locale/sv.po
index 0b77050332..7ad41225a0 100644
--- a/frappe/locale/sv.po
+++ b/frappe/locale/sv.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2026-02-22 09:42+0000\n"
-"PO-Revision-Date: 2026-02-23 22:07\n"
+"PO-Revision-Date: 2026-02-25 23:14\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Swedish\n"
"MIME-Version: 1.0\n"
@@ -2868,7 +2868,7 @@ msgstr "Tilldelning Klar"
#. Label of the assignment_days (Table) field in DocType 'Assignment Rule'
#: frappe/automation/doctype/assignment_rule/assignment_rule.json
msgid "Assignment Days"
-msgstr "Automation Dagar"
+msgstr "Tilldelning Dagar"
#. Name of a DocType
#. Label of the assignment_rule (Link) field in DocType 'ToDo'
@@ -2876,7 +2876,7 @@ msgstr "Automation Dagar"
#: frappe/automation/doctype/assignment_rule/assignment_rule.json
#: frappe/desk/doctype/todo/todo.json frappe/workspace_sidebar/automation.json
msgid "Assignment Rule"
-msgstr "Automation Regel"
+msgstr "Tilldelning Regel"
#. Name of a DocType
#: frappe/automation/doctype/assignment_rule_day/assignment_rule_day.json
@@ -2890,13 +2890,13 @@ msgstr "Automation Regel Användare"
#: frappe/automation/doctype/assignment_rule/assignment_rule.py:55
msgid "Assignment Rule is not allowed on document type {0}"
-msgstr "Automation Regel är ej tillåten på dokument typ {0}"
+msgstr "Tilldelning Regel är ej tillåten på dokument typ {0}"
#. Label of the assignment_rules_section (Section Break) field in DocType
#. 'Assignment Rule'
#: frappe/automation/doctype/assignment_rule/assignment_rule.json
msgid "Assignment Rules"
-msgstr "Automation Regler"
+msgstr "Tilldelning Regler"
#: frappe/desk/doctype/notification_log/notification_log.py:153
msgid "Assignment Update on {0}"
diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py
index 60df4c279a..858e5212d0 100644
--- a/frappe/model/delete_doc.py
+++ b/frappe/model/delete_doc.py
@@ -100,9 +100,6 @@ def delete_doc(
else:
return False
- # delete passwords
- delete_all_passwords_for(doctype, name)
-
doc = None
if doctype == "DocType":
if for_reload:
@@ -200,6 +197,9 @@ def delete_doc(
enqueue_after_commit=True,
)
+ # delete passwords
+ delete_all_passwords_for(doctype, name)
+
# clear cache for Document
doc.clear_cache()
# delete global search entry
diff --git a/frappe/model/meta.py b/frappe/model/meta.py
index 9e5624252d..add084d38e 100644
--- a/frappe/model/meta.py
+++ b/frappe/model/meta.py
@@ -793,11 +793,21 @@ class Meta(Document):
group.get("items").append(doctype)
link.added = True
+ # Add fieldname to transaction group for external links
+ if not link.is_child_table:
+ if "fieldnames" not in group:
+ group["fieldnames"] = {}
+ group["fieldnames"][link.link_doctype] = link.link_fieldname
+
if not link.added:
# group not found, make a new group
- data.transactions.append(
- dict(label=link.group, items=[link.parent_doctype or link.link_doctype])
- )
+ new_group = dict(label=link.group, items=[link.parent_doctype or link.link_doctype])
+
+ # Add fieldname to new transaction group for external links
+ if not link.is_child_table:
+ new_group["fieldnames"] = {link.link_doctype: link.link_fieldname}
+
+ data.transactions.append(new_group)
if not data.fieldname and link.link_fieldname:
data.fieldname = link.link_fieldname
diff --git a/frappe/oauth.py b/frappe/oauth.py
index 5e170db5d2..67595555f9 100644
--- a/frappe/oauth.py
+++ b/frappe/oauth.py
@@ -2,13 +2,14 @@ import base64
import datetime
import hashlib
import re
-from http import cookies
-from urllib.parse import unquote, urljoin, urlparse
+from urllib.parse import urljoin, urlparse
+from oauthlib.common import Request
from oauthlib.openid import RequestValidator
import frappe
from frappe.auth import LoginManager
+from frappe.integrations.doctype.oauth_client.oauth_client import OAuthClient
from frappe.utils.data import cstr, get_system_timezone, now_datetime
@@ -73,13 +74,11 @@ class OAuthWebRequestValidator(RequestValidator):
# Post-authorization
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
- cookie_dict = get_cookie_dict_from_headers(request)
-
oac = frappe.new_doc("OAuth Authorization Code")
oac.scopes = get_url_delimiter().join(request.scopes)
oac.redirect_uri_bound_to_authorization_code = request.redirect_uri
oac.client = client_id
- oac.user = unquote(cookie_dict["user_id"].value)
+ oac.user = frappe.session.user
oac.authorization_code = code["code"]
if request.nonce:
@@ -92,43 +91,32 @@ class OAuthWebRequestValidator(RequestValidator):
oac.save(ignore_permissions=True)
frappe.db.commit()
- def authenticate_client(self, request, *args, **kwargs):
+ def authenticate_client(self, request: Request, *args, **kwargs) -> bool | None:
+ """
+ Loads the client based on request parameters and sets in oauth request.
+ Returns True on success, None on error.
+ """
# Get ClientID in URL
if request.client_id:
- oc = frappe.get_doc("OAuth Client", request.client_id)
+ client_name = request.client_id
else:
# Extract token, instantiate OAuth Bearer Token and use clientid from there.
if "refresh_token" in frappe.form_dict:
- oc = frappe.get_doc(
- "OAuth Client",
- frappe.db.get_value(
- "OAuth Bearer Token",
- {"refresh_token": frappe.form_dict["refresh_token"]},
- "client",
- ),
- )
+ token_filters = {"refresh_token": frappe.form_dict["refresh_token"]}
elif "token" in frappe.form_dict:
- oc = frappe.get_doc(
- "OAuth Client",
- frappe.db.get_value("OAuth Bearer Token", frappe.form_dict["token"], "client"),
- )
+ token_filters = {"name": frappe.form_dict["token"]}
else:
- oc = frappe.get_doc(
- "OAuth Client",
- frappe.db.get_value(
- "OAuth Bearer Token",
- frappe.get_request_header("Authorization").split(" ")[1],
- "client",
- ),
- )
+ token_filters = {"name": frappe.get_request_header("Authorization").split(" ")[1]}
+
+ client_name = frappe.db.get_value("OAuth Bearer Token", filters=token_filters, fieldname="client")
+
+ oc: OAuthClient = frappe.get_doc("OAuth Client", client_name)
try:
request.client = request.client or oc.as_dict()
except Exception as e:
return generate_json_error_response(e)
- cookie_dict = get_cookie_dict_from_headers(request)
- user_id = unquote(cookie_dict.get("user_id").value) if "user_id" in cookie_dict else "Guest"
- return frappe.session.user == user_id
+ return True
def authenticate_client_id(self, client_id, request, *args, **kwargs):
cli_id = frappe.db.get_value("OAuth Client", client_id, "name")
@@ -506,13 +494,6 @@ class OAuthWebRequestValidator(RequestValidator):
return True
-def get_cookie_dict_from_headers(r):
- cookie = cookies.BaseCookie()
- if r.headers.get("Cookie"):
- cookie.load(r.headers.get("Cookie"))
- return cookie
-
-
def calculate_at_hash(access_token, hash_alg):
"""Helper method for calculating an access token
hash, as described in http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken
diff --git a/frappe/printing/doctype/letter_head/letter_head.json b/frappe/printing/doctype/letter_head/letter_head.json
index 9855c0ff32..4ddafc47db 100644
--- a/frappe/printing/doctype/letter_head/letter_head.json
+++ b/frappe/printing/doctype/letter_head/letter_head.json
@@ -98,6 +98,7 @@
"description": "Letter Head in HTML",
"fieldname": "content",
"fieldtype": "HTML Editor",
+ "ignore_xss_filter": 1,
"label": "Header HTML",
"oldfieldname": "content",
"oldfieldtype": "Text Editor"
@@ -113,6 +114,7 @@
"description": "Footer will display correctly only in PDF",
"fieldname": "footer",
"fieldtype": "HTML Editor",
+ "ignore_xss_filter": 1,
"label": "Footer HTML"
},
{
@@ -184,6 +186,7 @@
{
"collapsible": 1,
"collapsible_depends_on": "eval: doc.header_script || doc.footer_script",
+ "depends_on": "eval: !doc.__islocal",
"fieldname": "scripts_section",
"fieldtype": "Section Break",
"label": "Scripts"
@@ -200,7 +203,7 @@
"links": [],
"make_attachments_public": 1,
"max_attachments": 3,
- "modified": "2024-04-12 10:30:25.793932",
+ "modified": "2026-02-24 20:53:14.297567",
"modified_by": "Administrator",
"module": "Printing",
"name": "Letter Head",
@@ -223,8 +226,9 @@
"role": "Desk User"
}
],
+ "row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js
index f2f69e3aa3..0e5d68c165 100644
--- a/frappe/printing/page/print/print.js
+++ b/frappe/printing/page/print/print.js
@@ -706,7 +706,10 @@ frappe.ui.form.PrintView = class {
return;
}
} else {
- this.is_wkhtmltopdf_valid();
+ let pdf_generator = this.get_pdf_generator(print_format?.pdf_generator);
+ if (pdf_generator === "wkhtmltopdf") {
+ this.is_wkhtmltopdf_valid();
+ }
this.render_page(
"/api/method/frappe.utils.print_format.download_pdf?",
false,
@@ -738,9 +741,7 @@ frappe.ui.form.PrintView = class {
encodeURIComponent(this.get_letterhead()) +
"&settings=" +
encodeURIComponent(JSON.stringify(this.additional_settings)) +
- (this.lang_code ? "&_lang=" + this.lang_code : "") +
- "&pdf_generator=" +
- encodeURIComponent(pdf_generator)
+ (this.lang_code ? "&_lang=" + this.lang_code : "")
)
);
if (!w) {
diff --git a/frappe/public/js/billing.bundle.js b/frappe/public/js/billing.bundle.js
index 00fd226e1a..79e0609c58 100644
--- a/frappe/public/js/billing.bundle.js
+++ b/frappe/public/js/billing.bundle.js
@@ -27,6 +27,14 @@ $(document).ready(function () {
dismiss_key: `${frappe.boot.site_info.name}_trial_card_time`,
dismiss_it_for: "day",
};
+ let visiblity_condition =
+ frappe.boot.is_fc_site &&
+ !!frappe.boot.setup_complete &&
+ !frappe.is_mobile() &&
+ frappe.user.has_role("System Manager");
+ if (visiblity_condition && isFCUser) {
+ addChatBubble();
+ }
if (isFCUser) {
$.extend(card_args, {
primary_action_label: "Upgrade",
@@ -42,12 +50,7 @@ $(document).ready(function () {
});
}
$(document).on("desktop_screen", function (event, data) {
- if (
- frappe.boot.is_fc_site &&
- !!frappe.boot.setup_complete &&
- !frappe.is_mobile() &&
- frappe.user.has_role("System Manager")
- ) {
+ if (visiblity_condition) {
if (site_info.trial_end_date && trial_end_date > new Date()) {
card_args.parent = $(".icons-container").first();
let banner_card = new frappe.ui.SidebarCard(card_args);
@@ -84,3 +87,25 @@ function openFrappeCloudDashboard() {
"_blank"
);
}
+
+function addChatBubble() {
+ const all_apps = frappe.utils.get_installed_apps();
+ const desk_apps = ["erpnext", "hrms"];
+
+ const apps_allowed = frappe.utils.is_sub_array(all_apps, desk_apps);
+ if (checkBusinessHours && apps_allowed) {
+ let chat_banner = document.createElement("script");
+ 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");';
+ document.body.append(chat_banner);
+ const root = document.documentElement;
+ root.style.setProperty("--s-700", "var(--gray-500)");
+ }
+}
+
+function checkBusinessHours() {
+ let currentTime = new Date();
+ const istTime = new Date(currentTime.toLocaleString("en-US", { timeZone: "Asia/Kolkata" }));
+
+ return istTime.getHours() >= 11 && istTime.getHours() < 18;
+}
diff --git a/frappe/public/js/bootstrap-4-web.bundle.js b/frappe/public/js/bootstrap-4-web.bundle.js
index 3845a7b185..0d37b745a7 100644
--- a/frappe/public/js/bootstrap-4-web.bundle.js
+++ b/frappe/public/js/bootstrap-4-web.bundle.js
@@ -25,7 +25,7 @@ frappe.get_modal = function (title, content) {
diff --git a/frappe/public/js/frappe/dom.js b/frappe/public/js/frappe/dom.js
index 5d676259a6..0012759e2a 100644
--- a/frappe/public/js/frappe/dom.js
+++ b/frappe/public/js/frappe/dom.js
@@ -324,8 +324,8 @@ frappe.get_data_pill = (
`);
if (remove_action) {
let remove_btn = $(`
-
- ${frappe.utils.icon("close", "sm", "es-icon")}
+
+ ${frappe.utils.icon("x", "sm")}
`);
if (typeof remove_action === "function") {
diff --git a/frappe/public/js/frappe/form/controls/button.js b/frappe/public/js/frappe/form/controls/button.js
index 41fd49b5af..c4f70c5501 100644
--- a/frappe/public/js/frappe/form/controls/button.js
+++ b/frappe/public/js/frappe/form/controls/button.js
@@ -24,7 +24,7 @@ frappe.ui.form.ControlButton = class ControlButton extends frappe.ui.form.Contro
this.$input = $(
``
)
.prependTo(me.input_area)
diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js
index ddb8ba2e02..55015a5103 100644
--- a/frappe/public/js/frappe/form/dashboard.js
+++ b/frappe/public/js/frappe/form/dashboard.js
@@ -270,6 +270,13 @@ frappe.ui.form.Dashboard = class FormDashboard {
if (d.label == group.label) {
group_added.push(d.label);
group.items.push(...d.items);
+
+ if (d.fieldnames) {
+ if (!group.fieldnames) {
+ group.fieldnames = {};
+ }
+ Object.assign(group.fieldnames, d.fieldnames);
+ }
}
});
});
@@ -335,7 +342,9 @@ frappe.ui.form.Dashboard = class FormDashboard {
// bind new
transactions_area_body.find(".btn-new").on("click", function () {
- me.frm.make_new($(this).attr("data-doctype"));
+ const doctype = $(this).attr("data-doctype");
+ const fieldname = $(this).attr("data-fieldname");
+ me.frm.make_new(doctype, fieldname);
});
this.data_rendered = true;
@@ -369,6 +378,11 @@ frappe.ui.form.Dashboard = class FormDashboard {
let doctype = $link.attr("data-doctype"),
names = $link.attr("data-names") || [];
+ const fieldname =
+ $link.find(".document-link-badge").attr("data-fieldname") ||
+ (this.data.non_standard_fieldnames && this.data.non_standard_fieldnames[doctype]) ||
+ this.data.fieldname;
+
if (
this.internal_links_found &&
this.internal_links_found.find((d) => d.doctype === doctype)
@@ -378,8 +392,8 @@ frappe.ui.form.Dashboard = class FormDashboard {
} else {
return false;
}
- } else if (this.data.fieldname) {
- frappe.route_options = this.get_document_filter(doctype);
+ } else if (fieldname) {
+ frappe.route_options = this.get_document_filter(doctype, fieldname);
if (show_open && frappe.ui.notifications) {
frappe.ui.notifications.show_open_count_list(doctype);
}
@@ -388,13 +402,10 @@ frappe.ui.form.Dashboard = class FormDashboard {
frappe.set_route("List", doctype, "List");
}
- get_document_filter(doctype) {
+ get_document_filter(doctype, fieldname) {
// return the default filter for the given document
// like {"customer": frm.doc.name}
- let filter = {};
- let fieldname = this.data.non_standard_fieldnames
- ? this.data.non_standard_fieldnames[doctype] || this.data.fieldname
- : this.data.fieldname;
+ const filter = {};
if (this.data.dynamic_links && this.data.dynamic_links[fieldname]) {
let dynamic_fieldname = this.data.dynamic_links[fieldname][1];
diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js
index cefd7e1527..2570864ee3 100644
--- a/frappe/public/js/frappe/form/form.js
+++ b/frappe/public/js/frappe/form/form.js
@@ -783,6 +783,7 @@ frappe.ui.form.Form = class FrappeForm {
this.show_submit_message();
this.clear_custom_buttons();
this.show_web_link();
+ this.show_report_bug_link();
this.show_workflow_read_only_banner();
}
@@ -1280,6 +1281,17 @@ frappe.ui.form.Form = class FrappeForm {
}
}
+ show_report_bug_link() {
+ if (this.meta.beta) {
+ this.add_web_link(
+ "https://github.com/frappe/" +
+ frappe.boot.module_app[frappe.scrub(this.meta.module)] +
+ "/issues/new",
+ __("Report bug")
+ );
+ }
+ }
+
add_web_link(path, label) {
label = __(label) || __("See on Website");
this.web_link = this.sidebar
@@ -2007,7 +2019,7 @@ frappe.ui.form.Form = class FrappeForm {
}
}
- make_new(doctype) {
+ make_new(doctype, fieldname) {
// make new doctype from the current form
// will handover to `make_methods` if defined
// or will create and match link fields
@@ -2021,7 +2033,7 @@ frappe.ui.form.Form = class FrappeForm {
let new_doc = frappe.model.get_new_doc(doctype, null, null, true);
// set link fields (if found)
- me.set_link_field(doctype, new_doc);
+ me.set_link_field(doctype, new_doc, fieldname);
frappe.ui.form.make_quick_entry(doctype, null, null, new_doc);
// frappe.set_route('Form', doctype, new_doc.name);
@@ -2029,16 +2041,27 @@ frappe.ui.form.Form = class FrappeForm {
}
}
- set_link_field(doctype, new_doc) {
+ set_link_field(doctype, new_doc, fieldname) {
let me = this;
frappe.get_meta(doctype).fields.forEach(function (df) {
- if (df.fieldtype === "Link" && df.options === me.doctype) {
+ const isLinkToParent = df.fieldtype === "Link" && df.options === me.doctype;
+
+ if (fieldname) {
+ if (df.fieldname === fieldname && isLinkToParent) {
+ new_doc[df.fieldname] = me.doc.name;
+ }
+ if (df.fieldtype === "Table" && df.options && df.reqd) {
+ me.set_link_field(df.options, new_doc[df.fieldname][0]);
+ }
+ return;
+ }
+
+ if (isLinkToParent) {
new_doc[df.fieldname] = me.doc.name;
} else if (["Link", "Dynamic Link"].includes(df.fieldtype) && me.doc[df.fieldname]) {
new_doc[df.fieldname] = me.doc[df.fieldname];
} else if (df.fieldtype === "Table" && df.options && df.reqd) {
- let row = new_doc[df.fieldname][0];
- me.set_link_field(df.options, row);
+ me.set_link_field(df.options, new_doc[df.fieldname][0]);
}
});
}
diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js
index c0da70f26e..893d8abe3e 100644
--- a/frappe/public/js/frappe/form/layout.js
+++ b/frappe/public/js/frappe/form/layout.js
@@ -121,7 +121,7 @@ frappe.ui.form.Layout = class Layout {
}
// Add close button to block if not permanent
- const close_message = $(`${frappe.utils.icon("close")}
`);
+ const close_message = $(`${frappe.utils.icon("x")}
`);
if (!permanent) {
close_message.appendTo($html);
close_message.on("click", () => $html.remove());
diff --git a/frappe/public/js/frappe/form/sidebar/assign_to.js b/frappe/public/js/frappe/form/sidebar/assign_to.js
index ce4e0ca995..6b867fdf51 100644
--- a/frappe/public/js/frappe/form/sidebar/assign_to.js
+++ b/frappe/public/js/frappe/form/sidebar/assign_to.js
@@ -285,8 +285,8 @@ frappe.ui.form.AssignmentClass = class AssignmentClass {
const row = $(`
- ${frappe.avatar(assignment)}
- ${frappe.user.full_name(assignment)}
+ ${frappe.avatar(assignment, "avatar-smaller")}
+ ${frappe.user.full_name(assignment)}
@@ -297,8 +297,8 @@ frappe.ui.form.AssignmentClass = class AssignmentClass {
if (assignment === frappe.session.user) {
btn_group.append(`
-
- ${frappe.utils.icon("tick", "xs")}
+
+ ${frappe.utils.icon("check")}
`);
btn_group.find(".complete-btn").click(() => {
@@ -324,7 +324,7 @@ frappe.ui.form.AssignmentClass = class AssignmentClass {
if (assignment === frappe.session.user || this.frm.perm[0].write) {
btn_group.append(`
-
+
${frappe.utils.icon("x")}
`);
diff --git a/frappe/public/js/frappe/form/sidebar/form_sidebar.js b/frappe/public/js/frappe/form/sidebar/form_sidebar.js
index 29f26b76db..907c88b1ad 100644
--- a/frappe/public/js/frappe/form/sidebar/form_sidebar.js
+++ b/frappe/public/js/frappe/form/sidebar/form_sidebar.js
@@ -26,6 +26,7 @@ frappe.ui.form.Sidebar = class {
.appendTo(this.page.sidebar.empty());
this.user_actions = this.sidebar.find(".user-actions");
+ this.user_actions_list = this.sidebar.find(".user-actions-list");
this.image_section = this.sidebar.find(".sidebar-image-section");
this.image_wrapper = this.image_section.find(".sidebar-image-wrapper");
this.make_assignments();
@@ -115,7 +116,6 @@ frappe.ui.form.Sidebar = class {
make_like() {
this.like_wrapper = this.sidebar.find(".liked-by");
this.like_icon = this.sidebar.find(".liked-by .like-icon");
- this.like_count = this.sidebar.find(".liked-by .like-count");
frappe.ui.setup_like_popover(this.sidebar.find(".form-stats-likes"), ".like-icon");
this.like_icon.on("click", () => {
@@ -138,8 +138,6 @@ frappe.ui.form.Sidebar = class {
.toggleClass("liked", liked)
.attr("data-doctype", this.frm.doctype)
.attr("data-name", this.frm.doc.name);
-
- this.like_count && this.like_count.text(JSON.parse(this.frm.doc._liked_by || "[]").length);
}
refresh_web_view_count() {
@@ -245,19 +243,23 @@ frappe.ui.form.Sidebar = class {
}
add_user_action(label, click) {
- return $("")
- .html(label)
- .appendTo(
- $('
').appendTo(
- this.user_actions.removeClass("hidden")
- )
+ const parent = this.user_actions_list.length ? this.user_actions_list : this.user_actions;
+ this.user_actions.removeClass("hidden");
+ const row = $('
').appendTo(parent);
+
+ return $(' ')
+ .html(
+ `${label}
+ ${frappe.utils.icon("external-link", "sm")} `
)
+ .appendTo(row)
.on("click", click);
}
clear_user_actions() {
this.user_actions.addClass("hidden");
- this.user_actions.find(".user-action-row").remove();
+ const parent = this.user_actions_list.length ? this.user_actions_list : this.user_actions;
+ parent.find(".user-action-row").remove();
}
refresh_image() {}
diff --git a/frappe/public/js/frappe/form/templates/form_links.html b/frappe/public/js/frappe/form/templates/form_links.html
index ed6256eaf0..d5788522d4 100644
--- a/frappe/public/js/frappe/form/templates/form_links.html
+++ b/frappe/public/js/frappe/form/templates/form_links.html
@@ -9,8 +9,9 @@
{% for (let j=0; j < transactions[i].items.length; j++) { %}
{% let doctype = transactions[i].items[j]; %}
+ {% let fieldname = (transactions[i].fieldnames && transactions[i].fieldnames[doctype]) || transactions[i].fieldname; %}
-
+
@@ -19,7 +20,8 @@
{% if !internal_links[doctype] %}
+ data-doctype="{{ doctype }}"
+ data-fieldname="{{ fieldname }}">
{% endif %}
diff --git a/frappe/public/js/frappe/form/templates/form_sidebar.html b/frappe/public/js/frappe/form/templates/form_sidebar.html
index a43117a67d..ba71f85f74 100644
--- a/frappe/public/js/frappe/form/templates/form_sidebar.html
+++ b/frappe/public/js/frappe/form/templates/form_sidebar.html
@@ -1,4 +1,3 @@
-
`);
diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar.html b/frappe/public/js/frappe/ui/sidebar/sidebar.html
index ef5e6d630a..20458ff16d 100644
--- a/frappe/public/js/frappe/ui/sidebar/sidebar.html
+++ b/frappe/public/js/frappe/ui/sidebar/sidebar.html
@@ -40,10 +40,11 @@
Save
+
+ ${banner.title}
+
+ `);
+
+ banner_html.prepend(banner.icon);
+ me.$promotional_banners.append(banner_html);
+ });
+ }
+
remove_onboarding_wrapper() {
this.$onboarding.empty();
this.wrapper.find(".onboarding-sidebar").removeClass("hidden");
+
+ if (!this.sidebar_data?.module_onboarding) {
+ this.wrapper.find(".onboarding-sidebar").addClass("hidden");
+ }
}
setup_onboarding() {
@@ -170,6 +256,7 @@ frappe.ui.Sidebar = class Sidebar {
this.sidebar_header = new frappe.ui.SidebarHeader(this);
this.make_sidebar();
this.add_sidebar_cards();
+ this.setup_promotional_banners();
this.setup_onboarding();
this.wrapper.find(".onboarding-sidebar").click(() => {
diff --git a/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue b/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue
index 68150e9744..9ee883cf31 100644
--- a/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue
+++ b/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue
@@ -170,6 +170,7 @@ function updateSettings(step) {
};
frappe.set_route("Form", step.reference_document);
+ markComplete(step);
}
async function createEntry(step) {
@@ -234,7 +235,7 @@ function markReset(step) {
-
Getting started
+
{{ __("Getting Started") }}
@@ -263,10 +264,10 @@ function markReset(step) {
- {{ __("Reset all") }}
+ {{ __("Reset All") }}
- Skip all
+ {{ __("Skip All") }}
@@ -296,8 +297,11 @@ function markReset(step) {
-
- {{ step.action_label }}
+
+ {{ __(step.action_label) }}
@@ -305,7 +309,7 @@ function markReset(step) {
class="text-base onb-step-text"
style="text-decoration-line: line-through"
>
- {{ step.action_label }}
+ {{ __(step.action_label) }}
diff --git a/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js b/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js
index 86a994519e..3ed2215733 100644
--- a/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js
+++ b/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js
@@ -67,7 +67,7 @@ function addStyles() {
position: fixed;
right: 24px;
bottom: 24px;
- width: 380px;
+ width: 310px;
max-height: 80vh;
background: #fff;
border-radius: 16px;
@@ -215,6 +215,46 @@ function addStyles() {
color: #6b7280;
font-size: 14px;
}
+
+ [data-theme="dark"] .onb-panel {
+ background-color: #232323;
+ color: #e5e7eb;
+ box-shadow: 0 12px 40px rgba(0,0,0,0.6);
+ }
+
+ [data-theme="dark"] .text-base {
+ color: #e5e7eb;
+ }
+
+ [data-theme="dark"] .onb-skip {
+ color: #9ca3af;
+ }
+
+ [data-theme="dark"] .onb-skip:hover {
+ color: #f3f4f6;
+ }
+
+ [data-theme="dark"] .onb-title-steps,
+ [data-theme="dark"] .onb-progress-text {
+ color: #9ca3af;
+ }
+
+ [data-theme="dark"] .onb-group:hover {
+ background: #374151;
+ color: #f3f4f6;
+ }
+
+ [data-theme="dark"] .onb-progress-badge {
+ background: rgba(245,158,11,0.15);
+ color: #fbbf24;
+ }
+
+ [data-theme="dark"] .onb-progress-badge-complete {
+ background: rgba(16,185,129,0.15);
+ color: #34d399;
+ }
+
+
`;
document.head.appendChild(style);
diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js
index bfc1d0ad38..e61e0f3b78 100644
--- a/frappe/public/js/frappe/utils/utils.js
+++ b/frappe/public/js/frappe/utils/utils.js
@@ -1575,7 +1575,8 @@ Object.assign(frappe.utils, {
if (item.is_query_report) {
route = "query-report/" + item.name;
} else if (!item.is_query_report && item.report_ref_doctype) {
- route = frappe.router.slug(item.report_ref_doctype) + "/view/report/";
+ route =
+ frappe.router.slug(item.report_ref_doctype) + "/view/report/" + item.name;
} else {
route = "report/" + item.name;
}
@@ -1909,7 +1910,13 @@ Object.assign(frappe.utils, {
process_filter_expression(filter) {
let filters = [];
- filters = filter ? new Function(`return ${filter}`)() : [];
+ if (filter) {
+ try {
+ filters = JSON.parse(filter);
+ } catch {
+ console.warn("Invalid JSON in filter expression", filter);
+ }
+ }
return this.cleanup_filters(filters);
},
cleanup_filters(filters) {
@@ -2222,4 +2229,16 @@ Object.assign(frappe.utils, {
}
return value;
},
+ get_installed_apps() {
+ return frappe.boot.app_data.map((app) => {
+ return app.app_name;
+ });
+ },
+ is_sub_array(big, small) {
+ let i = 0;
+ for (let num of big) {
+ if (num === small[i]) i++;
+ }
+ return i === small.length;
+ },
});
diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js
index bfa1706f11..528b5f81a3 100755
--- a/frappe/public/js/frappe/views/communication.js
+++ b/frappe/public/js/frappe/views/communication.js
@@ -62,6 +62,7 @@ frappe.views.CommunicationComposer = class {
{
fieldtype: "Button",
label: frappe.utils.icon("down", "xs"),
+ title: __("More Options"),
fieldname: "option_toggle_button",
click: () => {
this.toggle_more_options();
@@ -496,7 +497,11 @@ frappe.views.CommunicationComposer = class {
},
];
- frappe.utils.add_select_group_button(clear_and_add_template, email_template_actions);
+ frappe.utils.add_select_group_button(
+ clear_and_add_template,
+ email_template_actions,
+ "btn-default"
+ );
$(fields.use_html.wrapper).addClass("mt-2 text-center").appendTo(clear_and_add_template);
}
diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js
index 76e6691336..8ecde380be 100644
--- a/frappe/public/js/frappe/views/workspace/workspace.js
+++ b/frappe/public/js/frappe/views/workspace/workspace.js
@@ -493,6 +493,7 @@ frappe.views.Workspace = class Workspace {
let blocks = [
{
type: "header",
+
data: { text: values.title },
},
];
@@ -666,7 +667,6 @@ frappe.views.Workspace = class Workspace {
spacer: this.blocks["spacer"],
HeaderSize: frappe.workspace_block.tunes["header_size"],
};
-
this.editor = new EditorJS({
data: {
blocks: blocks || [],
@@ -676,6 +676,26 @@ frappe.views.Workspace = class Workspace {
readOnly: true,
logLevel: "ERROR",
});
+ if (blocks.length == 0) {
+ let message = __("Welcome to the {0} workspace", [this.page.title]);
+ let default_block = [
+ {
+ type: "header",
+ data: { text: message },
+ },
+ ];
+ if (this.has_access) {
+ default_block.push({
+ type: "paragraph",
+ data: {
+ text: __("Click on {0} to edit", [frappe.utils.icon("ellipsis")]),
+ },
+ });
+ }
+ this.editor.isReady.then(() => {
+ this.editor.render({ blocks: default_block });
+ });
+ }
}
save_page(page) {
diff --git a/frappe/public/scss/common/modal.scss b/frappe/public/scss/common/modal.scss
index 3cb388a3fd..9cd60935c2 100644
--- a/frappe/public/scss/common/modal.scss
+++ b/frappe/public/scss/common/modal.scss
@@ -243,10 +243,6 @@ body.modal-open[style^="padding-right"] {
}
.frappe-control:last-child {
margin-left: 10px;
- button {
- // same as form-control input
- height: calc(1.5em + 0.7rem);
- }
}
}
}
@@ -268,7 +264,19 @@ body.modal-open[style^="padding-right"] {
}
.frappe-control:last-child {
- margin-top: -14px;
+ margin-top: 10px;
+ }
+ }
+}
+
+.modal .frappe-control[data-fieldname="option_toggle_button"] {
+ margin-top: 10px;
+ .form-group {
+ margin-bottom: 0;
+
+ button {
+ width: 28px;
+ height: 28px;
}
}
}
@@ -299,6 +307,9 @@ body.modal-open[style^="padding-right"] {
}
.assignee {
flex: 1;
+ display: flex;
+ gap: 8px;
+ align-items: center;
}
&:hover {
.btn-group {
@@ -306,9 +317,6 @@ body.modal-open[style^="padding-right"] {
transition: opacity 0.1s ease-in-out;
}
}
- .avatar {
- margin-right: var(--margin-md);
- }
}
// Stack minimized modals
diff --git a/frappe/public/scss/desk/avatar.scss b/frappe/public/scss/desk/avatar.scss
index 7fd2a321e2..059518c414 100644
--- a/frappe/public/scss/desk/avatar.scss
+++ b/frappe/public/scss/desk/avatar.scss
@@ -98,6 +98,16 @@
}
}
+.avatar-smaller {
+ width: 22px;
+ height: 22px;
+ text-align: center;
+
+ .standard-image {
+ @include get_textstyle("xs", "regular");
+ }
+}
+
.avatar-medium {
width: 28px;
height: 28px;
diff --git a/frappe/public/scss/desk/form_sidebar.scss b/frappe/public/scss/desk/form_sidebar.scss
index fceffe52d7..02c7411027 100644
--- a/frappe/public/scss/desk/form_sidebar.scss
+++ b/frappe/public/scss/desk/form_sidebar.scss
@@ -20,6 +20,10 @@
flex-wrap: wrap;
color: var(--text-light);
+ .icon {
+ stroke: var(--text-light);
+ }
+
.icon-btn {
height: unset;
}
@@ -38,6 +42,82 @@
}
}
+ .user-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: var(--padding-md);
+
+ .user-actions-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .user-action-row {
+ margin: 0;
+ }
+
+ .user-action-link {
+ display: flex;
+ align-items: center;
+ text-decoration: underline;
+ justify-content: space-between;
+ gap: var(--margin-sm);
+ width: 100%;
+ padding: 4px 8px;
+ margin-left: -6px;
+ margin-right: -8px;
+ border-radius: var(--border-radius-md);
+ transition: background-color 120ms ease;
+
+ &:hover,
+ &:focus-visible {
+ background: var(--subtle-fg);
+ }
+
+ &:focus-visible {
+ outline: none;
+ }
+
+ .user-action-external-icon {
+ display: none;
+ line-height: 0;
+
+ .icon {
+ margin: 0;
+ --icon-stroke: var(--text-muted);
+ }
+ }
+
+ &[target="_blank"] .user-action-external-icon {
+ display: inline-flex;
+ align-items: center;
+ opacity: 0;
+ transform: translateX(-2px);
+ transition: opacity 120ms ease, transform 120ms ease;
+ }
+
+ &[target="_blank"]:hover .user-action-external-icon,
+ &[target="_blank"]:focus-visible .user-action-external-icon {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ }
+
+ .user-action-label {
+ display: block;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+
+ .sidebar-section.user-actions.border-bottom {
+ padding-bottom: 15px;
+ }
+
.form-tags {
.tag-area {
margin-top: -3px;
@@ -141,8 +221,13 @@
}
}
+ .form-title-text {
+ // to match the actions button height for center alignment
+ line-height: 28px;
+ }
+
.form-stats-likes {
- gap: 8px;
+ gap: 2px;
.form-print {
button:hover {
background: var(--btn-default-hover-bg);
@@ -318,30 +403,56 @@ body[data-route^="Form"] {
.attachment-row,
.form-tag-row {
- margin: var(--margin-xs) 0;
- max-width: 100%;
+ margin: 4px 0;
+
.data-pill {
@include get_textstyle("sm", "regular");
justify-content: space-between;
box-shadow: none;
+ display: flex;
+ align-items: center;
+ height: 24px;
+ padding: 0px 6px !important;
+
+ .pill-label {
+ color: inherit !important;
+ }
+
+ .icon {
+ stroke: currentColor;
+ }
}
}
.attachment-row {
+ margin-left: -6px;
+ margin-right: 0px;
+
.data-pill {
+ display: flex;
+ align-items: center;
+ height: 28px;
+ border-radius: var(--border-radius-md);
+ padding: 0px 6px !important;
background-color: unset;
box-shadow: none;
- padding-left: 0px !important;
width: 100%;
+ &:hover,
+ &:focus-within {
+ background-color: var(--subtle-fg);
+ }
+
&:active {
background-color: transparent !important;
box-shadow: none !important;
}
+ > div {
+ gap: 8px;
+ }
+
.attachment-file-label {
display: block;
- margin-left: var(--margin-xs);
- padding-right: var(--padding-xs);
text-align: left;
}
.attachment-icon {
@@ -377,13 +488,58 @@ body[data-route^="Form"] {
.form-attachments,
.form-tags,
.form-shared {
- padding: 8px;
+ padding: var(--padding-sm) var(--padding-md);
}
+
+.form-attachments {
+ // to add gap between attachment section label and attachments
+ // without affecting empty state
+ .attachments-actions + .attachment-row {
+ margin-top: 8px;
+ }
+}
+
+.form-tags {
+ // to add gap between tag section label and tags
+ // without affecting empty state
+ :not(.form-tag-row) + .form-tag-row {
+ margin-top: 8px;
+ }
+}
+
.form-assignments,
.form-shared {
.assignments,
.shares {
- margin: var(--margin-xs) 0px;
+ margin-top: 8px;
+
+ .dialog-assignment-row {
+ display: flex;
+ align-items: center;
+ height: 28px;
+ border-radius: var(--border-radius-md);
+ padding: 0px 6px;
+ margin-left: -8px;
+ margin-right: 0px;
+
+ &:hover,
+ &:focus-within {
+ background-color: var(--subtle-fg);
+ }
+
+ &:not(:last-child) {
+ margin-bottom: 4px;
+ }
+
+ .btn-group {
+ margin-right: -4px;
+ }
+ }
+
+ .view-all-assignment {
+ display: block;
+ margin-top: var(--padding-xs);
+ }
}
}
.add-assignment-btn,
@@ -415,17 +571,43 @@ body[data-route^="Form"] {
}
}
+.liked-by {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+}
+
.liked-by-popover {
+ max-width: 240px;
+ border-radius: var(--border-radius-md);
+ box-shadow: var(--shadow-md);
+ overflow: hidden;
+
.popover-body {
- min-height: 30px;
padding: 0px;
+ .liked-by-popover-summary {
+ padding: 4px 10px;
+ margin: 0;
+ color: var(--text-muted);
+ border-bottom: 1px solid var(--subtle-accent);
+ @include get_textstyle("sm", "regular");
+ }
+
ul.list-unstyled {
margin-bottom: 0px;
+ padding: 4px;
li {
- padding: var(--padding-xs) var(--padding-sm);
- margin: 2px;
+ display: flex;
+ align-items: center;
+ gap: var(--padding-xs);
+ padding: var(--padding-xs);
+ margin: 0;
+ border-radius: var(--border-radius-sm);
+ cursor: pointer;
&:hover {
background-color: var(--fg-hover-color);
diff --git a/frappe/public/scss/desk/sidebar.scss b/frappe/public/scss/desk/sidebar.scss
index 739e2c3b16..5f8c479268 100644
--- a/frappe/public/scss/desk/sidebar.scss
+++ b/frappe/public/scss/desk/sidebar.scss
@@ -127,7 +127,15 @@
}
}
- .onboarding-sidebar {
+ .promotional-banners {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin: var(--margin-sm) 0;
+ }
+
+ .onboarding-sidebar,
+ .promotional-banner {
text-decoration: none;
font-size: var(--text-sm);
display: flex;
@@ -287,9 +295,8 @@
width: auto;
}
}
- .collapse-sidebar-link {
- display: none;
- }
+ .promotional-banners,
+ .collapse-sidebar-link,
.dropdown-navbar-user {
display: none;
}
diff --git a/frappe/public/scss/desk/timeline.scss b/frappe/public/scss/desk/timeline.scss
index e5de39caf7..20d6f98e13 100644
--- a/frappe/public/scss/desk/timeline.scss
+++ b/frappe/public/scss/desk/timeline.scss
@@ -96,6 +96,11 @@ $threshold: 34;
max-width: var(--timeline-content-max-width);
padding: var(--padding-sm);
margin-left: var(--margin-md);
+
+ > .ql-editor {
+ display: inline-flex;
+ }
+
&.frappe-card {
color: var(--text-neutral);
background-color: var(--bg-color);
diff --git a/frappe/website/js/bootstrap-4.js b/frappe/website/js/bootstrap-4.js
index e525d423b6..b95d42ac6a 100644
--- a/frappe/website/js/bootstrap-4.js
+++ b/frappe/website/js/bootstrap-4.js
@@ -27,7 +27,7 @@ frappe.get_modal = function (title, content) {
diff --git a/frappe/website/page_renderers/not_found_page.py b/frappe/website/page_renderers/not_found_page.py
index 704dca77d1..9b980f5d51 100644
--- a/frappe/website/page_renderers/not_found_page.py
+++ b/frappe/website/page_renderers/not_found_page.py
@@ -2,6 +2,7 @@ import os
from urllib.parse import urlparse
import frappe
+from frappe.website.page_renderers.document_page import _find_matching_document_webview
from frappe.website.page_renderers.template_page import TemplatePage
from frappe.website.utils import can_cache
@@ -26,10 +27,26 @@ class NotFoundPage(TemplatePage):
def can_cache_404(self):
# do not cache 404 for custom homepages
- return can_cache() and self.request_url and not self.is_custom_home_page()
+ # also skip caching docs with website permission checks (access is dynamic)
+ return (
+ can_cache()
+ and self.request_url
+ and not self.is_custom_home_page()
+ and not self.has_website_permission_check()
+ )
def is_custom_home_page(self):
url_parts = urlparse(self.request_url)
request_url = os.path.splitext(url_parts.path)[0]
request_path = os.path.splitext(self.request_path)[0]
return request_url in HOMEPAGE_PATHS and request_path not in HOMEPAGE_PATHS
+
+ def has_website_permission_check(self):
+ request_path = os.path.splitext(self.request_path)[0]
+ if not (document := _find_matching_document_webview(request_path)):
+ return False
+ doctype, docname = document
+ doc = frappe.get_cached_doc(doctype, docname)
+ return hasattr(doc, "has_website_permission") or bool(
+ frappe.get_hooks("has_website_permission", {}).get(doctype)
+ )
diff --git a/frappe/workflow/doctype/workflow_action/workflow_action.py b/frappe/workflow/doctype/workflow_action/workflow_action.py
index 6735fac903..1f972beece 100644
--- a/frappe/workflow/doctype/workflow_action/workflow_action.py
+++ b/frappe/workflow/doctype/workflow_action/workflow_action.py
@@ -207,17 +207,61 @@ def return_action_confirmation_page(doc, action, action_link, alert_doc_change=F
def return_link_expired_page(doc, doc_workflow_state):
+ user_full_name = get_user_who_set_workflow_state(doc, doc_workflow_state) or frappe.get_value(
+ "User", doc.get("modified_by"), "full_name"
+ )
frappe.respond_as_web_page(
_("Link Expired"),
_("Document {0} has been set to state {1} by {2}").format(
frappe.bold(doc.get("name")),
frappe.bold(doc_workflow_state),
- frappe.bold(frappe.get_value("User", doc.get("modified_by"), "full_name")),
+ frappe.bold(
+ user_full_name
+ if user_full_name
+ else frappe.get_value("User", doc.get("modified_by"), "full_name")
+ ),
),
indicator_color="blue",
)
+def get_user_who_set_workflow_state(doc, doc_workflow_state):
+ """Get the full name of the user who triggered the workflow action that set the document to the given state.
+ Falls back to None if no completed Workflow Action is found (e.g. state was set without workflow).
+ """
+ workflow_name = get_workflow_name(doc.get("doctype"))
+ if not workflow_name:
+ return None
+
+ # Get states that have a transition to the current workflow state
+ from_states = frappe.get_all(
+ "Workflow Transition",
+ filters={"parent": workflow_name, "next_state": doc_workflow_state},
+ pluck="state",
+ )
+ if not from_states:
+ return None
+
+ # Find the most recently completed Workflow Action that led to this state
+ WorkflowAction = DocType("Workflow Action")
+ completed_by = (
+ frappe.qb.from_(WorkflowAction)
+ .select(WorkflowAction.completed_by)
+ .where(
+ (WorkflowAction.reference_doctype == doc.get("doctype"))
+ & (WorkflowAction.reference_name == doc.get("name"))
+ & (WorkflowAction.status == "Completed")
+ & (WorkflowAction.workflow_state.isin(from_states))
+ )
+ .orderby(WorkflowAction.modified, order=frappe.qb.desc)
+ .limit(1)
+ ).run()
+
+ if completed_by and completed_by[0][0]:
+ return frappe.get_value("User", completed_by[0][0], "full_name")
+ return None
+
+
def update_completed_workflow_actions(doc, user=None, workflow=None, workflow_state=None):
allowed_roles = get_allowed_roles(user, workflow, workflow_state)
# There is no transaction leading upto this state
diff --git a/frappe/www/printview.html b/frappe/www/printview.html
index c3c557c8e4..17e6380baa 100644
--- a/frappe/www/printview.html
+++ b/frappe/www/printview.html
@@ -18,7 +18,7 @@
{{ _("Print") }}
+ href="/api/method/frappe.utils.print_format.download_pdf?doctype={{doctype|e}}&name={{name|e}}&format={{print_format|e}}&letterhead={{letterhead|e}}&no_letterhead={{no_letterhead|e}}&_lang={{lang|e}}&key={{key|e}}&pdf_generator={{pdf_generator|e}}">
{{ _('Get PDF') }}
diff --git a/frappe/www/printview.py b/frappe/www/printview.py
index b6b7d9aa8d..773a8611ae 100644
--- a/frappe/www/printview.py
+++ b/frappe/www/printview.py
@@ -92,6 +92,7 @@ def get_context(context) -> PrintContext:
# Include selected print format name in access log
print_format_name = getattr(print_format, "name", "Standard")
+ pdf_generator = getattr(print_format, "pdf_generator", "wkhtmltopdf")
make_access_log(
doctype=frappe.form_dict.doctype,
@@ -114,7 +115,7 @@ def get_context(context) -> PrintContext:
"print_format": print_format_name,
"letterhead": letterhead,
"no_letterhead": frappe.form_dict.no_letterhead,
- "pdf_generator": frappe.form_dict.get("pdf_generator", "wkhtmltopdf"),
+ "pdf_generator": frappe.form_dict.get("pdf_generator", pdf_generator),
}
@@ -342,7 +343,8 @@ def get_html_and_style(
if isinstance(name, str):
document = frappe.get_lazy_doc(doc, name, check_permission=True)
else:
- document = frappe.get_doc(json.loads(doc), check_permission=True)
+ details = json.loads(doc)
+ document = frappe.get_cached_doc(details["doctype"], details["name"], check_permission=True)
print_format = get_print_format_doc(print_format, meta=document.meta)
set_link_titles(document)
diff --git a/pyproject.toml b/pyproject.toml
index d0bf7066b3..306aab4d3a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,7 @@ dependencies = [
# We depend on internal attributes,
# do NOT add loose requirements on PyMySQL versions.
"PyMySQL==1.1.2",
- "pypdf==6.7.1",
+ "pypdf==6.7.2",
"PyPika @ git+https://github.com/frappe/pypika@2c50e6142b2d61d2d243e466fdd5dc03b3d918f2",
"mysqlclient==2.2.7",
"PyQRCode~=1.2.1",
diff --git a/yarn.lock b/yarn.lock
index 9dd12ad0e0..689fa38fba 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2055,9 +2055,9 @@ mime@^1.4.1:
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
minimatch@^3.1.1:
- version "3.1.2"
- resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
- integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.3.tgz#6a5cba9b31f503887018f579c89f81f61162e624"
+ integrity sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==
dependencies:
brace-expansion "^1.1.7"