chore: merge develop

This commit is contained in:
Saqib Ansari 2026-02-25 12:43:02 +05:30
commit 9d3dd2a12a
16 changed files with 152 additions and 59 deletions

View file

@ -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

View file

@ -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",

View file

@ -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:

View file

@ -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():

View file

@ -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

View file

@ -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
}
}

View file

@ -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) {

View file

@ -291,8 +291,9 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
set_primary_action() {
if (this.can_create && !frappe.boot.read_only) {
const doctype_name = __(frappe.router.doctype_layout) || __(this.doctype);
const add_button_label = __("Add {0}", [doctype_name], "Primary action in list view");
const create_button = this.page.set_primary_action(
__("Add {0}", [doctype_name], "Primary action in list view"),
add_button_label,
() => {
if (this.settings.primary_action) {
this.settings.primary_action();
@ -304,12 +305,26 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
);
if (frappe.is_mobile()) {
create_button.append(__("Add"));
} else {
this._trim_primary_action_if_overflow(create_button, add_button_label);
}
} else {
this.page.clear_primary_action();
}
}
_trim_primary_action_if_overflow(btn, add_button_label) {
const container = this.page.wrapper.find(".page-head-content")[0];
if (!container || !btn[0]) return;
const containerRect = container.getBoundingClientRect();
const btnRect = btn[0].getBoundingClientRect();
if (btnRect.right > containerRect.right) {
const short_label = __("Add");
btn.attr("title", add_button_label).tooltip();
btn.find("span").text(short_label);
}
}
make_new_doc() {
const doctype = this.doctype;
const options = {};

View file

@ -43,7 +43,7 @@
<p>
<a class="onboarding-sidebar">
{%= frappe.utils.icon("user-check" , "sm", "", "", "text-ink-gray-7 current-color", true)%}
<span> {%= __("Getting started") %} </span>
<span> {%= __("Getting Started") %} </span>
</a>
</p>
<a class="collapse-sidebar-link">

View file

@ -170,6 +170,7 @@ function updateSettings(step) {
};
frappe.set_route("Form", step.reference_document);
markComplete(step);
}
async function createEntry(step) {
@ -263,10 +264,10 @@ function markReset(step) {
</div>
<div v-if="skippAll">
<span class="onb-skip" @click="resetAll(steps)"> {{ __("Reset all") }}</span>
<span class="onb-skip" @click="resetAll(steps)"> {{ __("Reset All") }}</span>
</div>
<div v-else>
<span class="onb-skip" @click="skipAll(steps)">Skip all</span>
<span class="onb-skip" @click="skipAll(steps)">{{ __("Skip All") }}</span>
</div>
</div>

View file

@ -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);

View file

@ -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) {

View file

@ -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)
)

View file

@ -18,7 +18,7 @@
{{ _("Print") }}
</a>
<a class="p-2"
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}}">
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') }}
</a>
</div>

View file

@ -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),
}

View file

@ -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"