Merge branch 'develop' into 38159-allow-bulk-edit-in-child-tables

This commit is contained in:
Sumit Jain 2026-04-24 13:44:28 +05:30 committed by GitHub
commit aea2f722e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
181 changed files with 169592 additions and 170034 deletions

2
.github/stale.yml vendored
View file

@ -5,7 +5,7 @@ daysUntilStale: 60
# Number of days of inactivity before a stale Issue or Pull Request is closed.
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
daysUntilClose: 5
daysUntilClose: 3
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
exemptLabels:

59
.github/workflows/backport_reminder.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: Backport Reminder
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
jobs:
remind:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
steps:
- uses: actions/github-script@v8
with:
script: |
const labelName = 'defer backport';
const marker = '<!-- backport-reminder -->';
const waitDays = 14;
const maxDays = 30;
const now = new Date();
const query = `is:pr is:merged label:"${labelName}" repo:${context.repo.owner}/${context.repo.repo}`;
const searchResult = await github.rest.search.issuesAndPullRequests({ q: query });
for (const pr of searchResult.data.items) {
const { data: fullPr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number
});
if (!fullPr.merged_at) continue;
const mergedAt = new Date(fullPr.merged_at);
const diffInDays = (now - mergedAt) / (1000 * 60 * 60 * 24);
if (diffInDays >= waitDays && diffInDays <= maxDays) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
per_page: 100
});
const alreadyReminded = comments.some(c => c.body.includes(marker));
if (!alreadyReminded) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `${marker}\n**Backport Reminder**: This PR was merged ${Math.floor(diffInDays)} days ago. Time to backport!`
});
}
}
}

View file

@ -74,7 +74,7 @@ jobs:
- name: Download artifacts
uses: actions/download-artifact@v8.0.1
- name: Upload coverage data
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
name: Server
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -59,7 +59,7 @@ jobs:
- name: Download artifacts
uses: actions/download-artifact@v8.0.1
- name: Upload python coverage data
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
name: UIBackend
token: ${{ secrets.CODECOV_TOKEN }}
@ -68,7 +68,7 @@ jobs:
exclude: coverage-js*
flags: server-ui
- name: Upload JS coverage data
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
name: Cypress
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -1573,6 +1573,7 @@ from frappe.config import get_common_site_config, get_conf, get_site_config
from frappe.core.doctype.system_settings.system_settings import get_system_settings
from frappe.model.document import (
get_doc,
get_docs,
get_lazy_doc,
copy_doc,
new_doc,
@ -1594,6 +1595,7 @@ from frappe.utils.error import log_error
from frappe.utils.formatters import format_value
from frappe.utils.print_utils import get_print, attach_print
from frappe.email import sendmail
from frappe.concurrency_limiter import concurrent_limit
# for backwards compatibility
format = format_value

View file

@ -126,6 +126,12 @@ def application(request: Request):
elif request.path.startswith("/private/files/"):
response = frappe.utils.response.download_private_file(request.path)
elif request.path == "/.well-known/security.txt" and request.method == "GET":
if request.scheme != "https":
raise NotFound
security_settings = frappe.get_doc("Security Settings")
response = Response(security_settings.security_txt, content_type="text/plain")
elif request.path.startswith("/.well-known/") and request.method == "GET":
response = handle_wellknown(request.path)

View file

@ -78,6 +78,9 @@ def get_bootinfo():
bootinfo.home_folder = frappe.db.get_value("File", {"is_home_folder": 1})
bootinfo.navbar_settings = get_navbar_settings()
bootinfo.notification_settings = get_notification_settings()
bootinfo.notification_unread_count = frappe.db.count(
"Notification Log", {"read": 0, "for_user": frappe.session.user}
)
bootinfo.onboarding_tours = get_onboarding_ui_tours()
set_time_zone(bootinfo)
@ -562,8 +565,9 @@ def get_sidebar_items(allowed_workspaces):
sidebar_doc = sidebar
if (
frappe.session.user == "Administrator"
or sidebar_doc.module in sidebar_doc.user.allow_modules
or sidebar_title == "My Workspaces"
or not sidebar_doc.module
or sidebar_doc.module in sidebar_doc.user.allow_modules
):
sidebar_items[sidebar_title.lower()] = {
"label": sidebar_title,

View file

@ -189,18 +189,21 @@ def set_value(doctype: str, name: str | int, fieldname: str | dict[str, Any], va
:param fieldname: fieldname string or JSON / dict with key value pair
:param value: value if fieldname is JSON / dict"""
if fieldname in (frappe.model.default_fields + frappe.model.child_table_fields):
frappe.throw(_("Cannot edit standard fields"))
if not value:
values = fieldname
if isinstance(fieldname, str):
try:
values = json.loads(fieldname)
except ValueError:
values = {fieldname: ""}
else:
values = {}
if value is not None:
values = {fieldname: value}
elif isinstance(fieldname, dict):
values = fieldname
elif isinstance(fieldname, str):
try:
values = json.loads(fieldname)
except ValueError:
values = {fieldname: ""}
forbidden = set(frappe.model.default_fields + frappe.model.child_table_fields)
for field in values:
if field in forbidden:
frappe.throw(_("Cannot edit standard fields"))
# check for child table doctype
if not frappe.get_meta(doctype).istable:
@ -250,6 +253,11 @@ def save(doc: str | dict[str, Any]):
if isinstance(doc, str):
doc = json.loads(doc)
forbidden = {"docstatus", "idx"}
for field in doc:
if field in forbidden:
frappe.throw(_("Cannot edit standard fields"))
doc = frappe.get_doc(doc)
doc.save()

View file

@ -108,6 +108,19 @@ def build(
print("Compiling translations for", app)
compile_translations(app, force=force)
run_after_build_hook(apps)
def run_after_build_hook(apps):
from importlib import import_module
for app in apps:
for fn in frappe.get_hooks("after_build", app_name=app):
modulename = ".".join(fn.split(".")[:-1])
methodname = fn.split(".")[-1]
method = getattr(import_module(modulename), methodname)
method()
@click.command("watch")
@click.option("--apps", help="Watch assets for specific apps")

View file

@ -0,0 +1,125 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
"""
Concurrency limiter for expensive whitelisted methods.
Provides a @frappe.concurrent_limit() decorator that limits the number of
simultaneous in-flight executions of a function across all gunicorn workers
using a Redis-backed semaphore (LIST + BLPOP).
Usage::
@frappe.whitelist(allow_guest=True)
@frappe.concurrent_limit(limit=3)
def download_pdf(...):
...
"""
from collections.abc import Callable
from functools import wraps
import frappe
from frappe.exceptions import ServiceUnavailableError
from frappe.utils import cint
from frappe.utils.caching import redis_cache
from frappe.utils.redis_semaphore import RedisSemaphore
# Default wait timeout (seconds) before returning 503 to the caller.
_DEFAULT_WAIT_TIMEOUT = 10
@redis_cache(shared=True)
def _default_limit() -> int:
"""Derive a sensible default concurrency limit from gunicorn's max concurrency."""
return max(1, gunicorn_max_concurrency() // 2)
def gunicorn_max_concurrency() -> int:
"""Detect max concurrent requests from the running gunicorn master's cmdline."""
import os
fallback = 4
try:
ppid = os.getppid()
with open(f"/proc/{ppid}/cmdline", "rb") as f:
args = f.read().rstrip(b"\0").decode().split("\0")
if not any("gunicorn" in a for a in args):
return fallback
workers = _extract_cli_int(args, "-w", "--workers") or fallback
threads = _extract_cli_int(args, "--threads") or 1
return workers * threads
except OSError:
return fallback
def _extract_cli_int(args: list[str], *flags: str) -> int | None:
"""Return the integer value for a CLI flag from a split argument list.
Handles both ``--flag value`` and ``--flag=value`` forms.
"""
for i, arg in enumerate(args):
for flag in flags:
if arg == flag and i + 1 < len(args):
return int(args[i + 1])
if arg.startswith(f"{flag}="):
return int(arg.split("=", 1)[1])
return None
def concurrent_limit(limit: int | None = None, wait_timeout: int = _DEFAULT_WAIT_TIMEOUT):
"""Decorator that limits simultaneous in-flight executions of the wrapped function.
:param limit: Maximum number of concurrent executions. Defaults to half of ``workers x threads``
as detected from the gunicorn master process.
:param wait_timeout: Seconds to wait for a free slot before returning 503.
Defaults to 10 s.
The limiter is skipped entirely for background jobs, CLI commands, and
tests that call functions directly (i.e. outside of an HTTP request).
"""
def decorator(fn: Callable) -> Callable:
@wraps(fn)
def wrapper(*args, **kwargs):
# Skip concurrency limiting outside of HTTP requests (background jobs,
# CLI commands, tests that call functions directly, etc.).
if getattr(frappe.local, "request", None) is None:
return fn(*args, **kwargs)
_limit = cint(limit) if limit is not None else _default_limit()
key = f"concurrency:{fn.__module__}.{fn.__qualname__}"
sem = RedisSemaphore(key, _limit, wait_timeout, shared=True)
token = sem.acquire()
if not token:
retry_after = max(1, int(wait_timeout))
if (headers := getattr(frappe.local, "response_headers", None)) is not None:
headers.set("Retry-After", str(retry_after))
exc = ServiceUnavailableError(frappe._("Server is busy. Please try again in a few seconds."))
exc.retry_after = retry_after
raise exc
try:
return fn(*args, **kwargs)
finally:
sem.release(token)
return wrapper
return decorator
@frappe.whitelist()
def get_stats() -> dict:
frappe.only_for("System Manager")
cached_limit = _default_limit()
gunicorn_limit = gunicorn_max_concurrency()
return {
"cached_limit": cached_limit,
"gunicorn_limit": gunicorn_limit,
}

View file

@ -7,7 +7,7 @@ from frappe.model import display_fieldtypes, no_value_fields
from frappe.model import table_fields as table_fieldtypes
from frappe.utils import flt, format_duration, groupby_metric
from frappe.utils.csvutils import build_csv_response
from frappe.utils.xlsxutils import build_xlsx_response
from frappe.utils.xlsxutils import build_xlsx_response, get_default_xlsx_styles
class Exporter:
@ -253,7 +253,17 @@ class Exporter:
if self.file_type == "CSV":
build_csv_response(self.get_csv_array_for_export(), _(self.doctype))
elif self.file_type == "Excel":
build_xlsx_response(self.get_csv_array_for_export(), _(self.doctype))
data = self.get_csv_array_for_export()
styles = get_default_xlsx_styles(
columns=self.fields,
# exclude header row
data=data[1:],
# from the second child row onwards, parent values will be empty
# so currency value from parent doc may be absent, avoid inconsistency
currency_formatting=False,
)
build_xlsx_response(data, _(self.doctype), styles=styles)
def group_children_data_by_parent(self, children_data: dict[str, list]):
return groupby_metric(children_data, key="parent")

View file

@ -3,8 +3,11 @@
frappe.ui.form.on("DocType", {
onload: function (frm) {
if (frm.is_new() && !frm.doc?.fields) {
frappe.listview_settings["DocType"].new_doctype_dialog();
if (frm.is_new()) {
frm.set_value("allow_auto_repeat", 0);
if (!frm.doc?.fields) {
frappe.listview_settings["DocType"].new_doctype_dialog();
}
}
frm.call("check_pending_migration");
},

View file

@ -667,6 +667,7 @@
"label": "Sender Name Field"
},
{
"depends_on": "eval:!doc.istable",
"fieldname": "permissions_tab",
"fieldtype": "Tab Break",
"label": "Permissions"
@ -802,7 +803,7 @@
"link_fieldname": "document_type"
}
],
"modified": "2026-04-13 21:50:34.942938",
"modified": "2026-04-20 16:06:57.212832",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",
@ -833,6 +834,7 @@
],
"route": "doctype",
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"search_fields": "module",
"show_name_in_global_search": 1,
"sort_field": "creation",

View file

@ -551,10 +551,19 @@ class DocType(Document):
and (frappe.conf.developer_mode or frappe.flags.allow_doctype_export)
)
if allow_doctype_export:
self.export_doc()
self.make_controller_template()
self.set_base_class_for_controller()
self.export_types_to_controller()
def export_doctype_files():
self.export_doc()
self.make_controller_template()
self.set_base_class_for_controller()
self.export_types_to_controller()
request = getattr(frappe.local, "request", None)
# Defer file writes until after the response so the client can sync the saved doc first.
if request and hasattr(request, "after_response"):
request.after_response.add(export_doctype_files)
else:
export_doctype_files()
# update index
if not self.custom:

View file

@ -67,7 +67,8 @@
"fieldname": "is_home_folder",
"fieldtype": "Check",
"hidden": 1,
"label": "Is Home Folder"
"label": "Is Home Folder",
"search_index": 1
},
{
"default": "0",
@ -190,7 +191,7 @@
"icon": "fa fa-file",
"idx": 1,
"links": [],
"modified": "2025-01-15 11:46:42.917146",
"modified": "2026-04-15 19:56:45.317786",
"modified_by": "Administrator",
"module": "Core",
"name": "File",
@ -222,4 +223,4 @@
"states": [],
"title_field": "file_name",
"track_changes": 1
}
}

View file

@ -480,3 +480,16 @@ def find_file_by_url(path: str, name: str | None = None) -> "File" | None:
def get_safe_file_name(file_name: str) -> str:
return re.sub(r"[/\\%?#]", "_", file_name)
def check_path_safety(base_path: str, requested_path: str) -> bool:
"""Util to check path safety by ensuring sandboxing and logging unsuccessful attempts"""
base_path = os.path.realpath(base_path)
requested_path = os.path.realpath(requested_path)
if os.path.commonpath([base_path, requested_path]) != base_path:
frappe.log_error(
title="Attempted Unauthorized File Access",
message=f"Blocked access to: {requested_path}",
)
return False
return True

View file

@ -87,18 +87,21 @@ class PreparedReport(Document):
)
def get_prepared_data(self, with_file_name=False):
if attachments := get_attachments(self.doctype, self.name):
attachment = None
for f in attachments or []:
if f.file_url.endswith(".gz"):
attachment = f
break
attachments = get_attachments(self.doctype, self.name)
if not attachments:
frappe.throw(_("No attachment found for the prepared report"), title=_("Attachment Not Found"))
attached_file = frappe.get_doc("File", attachment.name)
attachment = None
for f in attachments or []:
if f.file_url.endswith(".gz"):
attachment = f
break
if with_file_name:
return (gzip.decompress(attached_file.get_content()), attachment.file_name)
return gzip.decompress(attached_file.get_content())
attached_file = frappe.get_doc("File", attachment.name)
if with_file_name:
return (gzip.decompress(attached_file.get_content()), attachment.file_name)
return gzip.decompress(attached_file.get_content())
def generate_report(prepared_report):
@ -141,7 +144,10 @@ def generate_report(prepared_report):
except Exception:
# we need to ensure that error gets stored
_save_error(instance, error=frappe.get_traceback(with_context=True))
return
instance.reload()
instance.status = "Completed"
instance.report_end_time = frappe.utils.now()
instance.peak_memory_usage = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
add_data_to_monitor(peak_memory_usage=instance.peak_memory_usage)

View file

@ -66,6 +66,19 @@ frappe.ui.form.on("Report", {
},
};
});
frm.set_query("letter_head", () => {
const filters = {
letter_head_for: "Report",
disabled: 0,
};
if (frm.doc.is_standard === "Yes") {
filters.standard = "Yes";
}
return { filters };
});
},
ref_doctype: function (frm) {

View file

@ -97,7 +97,6 @@
"label": "Disabled"
},
{
"depends_on": "eval: doc.is_standard == \"No\"",
"fieldname": "letter_head",
"fieldtype": "Link",
"label": "Default Letter Head",
@ -214,7 +213,7 @@
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-03-31 14:42:49.829920",
"modified": "2026-04-10 00:03:15.212213",
"modified_by": "Administrator",
"module": "Core",
"name": "Report",

View file

@ -5,16 +5,17 @@ import json
import threading
import frappe
import frappe.desk.query_report
from frappe import _, scrub
from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
from frappe.core.doctype.page.page import delete_custom_role
from frappe.desk.query_report import run
from frappe.desk.reportview import append_totals_row
from frappe.model.document import Document
from frappe.modules import make_boilerplate
from frappe.modules.export_file import export_to_files
from frappe.utils import cint, cstr
from frappe.utils.safe_exec import check_safe_sql_query, safe_exec
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder
class Report(Document):
@ -76,16 +77,15 @@ class Report(Document):
if frappe.session.user != "Administrator":
frappe.throw(_("Only Administrator can save a standard report. Please rename and save."))
# Letter Head is visible only for non-standard reports.
# It should not remain set when it's invisible.
self.letter_head = None
if self.report_type == "Report Builder":
self.update_report_json()
if self.default_print_format and self.has_value_changed("default_print_format"):
self.validate_default_print_format()
if self.letter_head and self.has_value_changed("letter_head"):
self.validate_letter_head()
def before_insert(self):
self.set_doctype_roles()
@ -93,7 +93,6 @@ class Report(Document):
self.export_doc()
def before_export(self, doc):
doc.letter_head = None
doc.prepared_report = 0
def on_trash(self):
@ -213,11 +212,14 @@ class Report(Document):
return res
def get_module_method(self, method):
module = self.module or frappe.db.get_value("DocType", self.ref_doctype, "module")
method_path = get_report_module_dotted_path(module, self.name) + "." + method
return frappe.get_attr(method_path)
def execute_module(self, filters):
# report in python module
module = self.module or frappe.db.get_value("DocType", self.ref_doctype, "module")
method_name = get_report_module_dotted_path(module, self.name) + ".execute"
return frappe.get_attr(method_name)(frappe._dict(filters))
return self.get_module_method("execute")(frappe._dict(filters))
def execute_script(self, filters):
# server script
@ -253,7 +255,7 @@ class Report(Document):
self, filters=None, user=None, ignore_prepared_report=False, are_default_filters=True
):
columns, result = [], []
data = frappe.desk.query_report.run(
data = run(
self.name,
filters=filters,
user=user,
@ -325,8 +327,6 @@ class Report(Document):
columns = params.get("fields")
elif params.get("columns"):
columns = params.get("columns")
elif params.get("fields"):
columns = params.get("fields")
else:
columns = [["name", self.ref_doctype]]
columns.extend(
@ -429,6 +429,29 @@ class Report(Document):
):
frappe.throw(_("Selected Print Format is invalid for this Report."))
def validate_letter_head(self):
if not self.letter_head:
return
letter_head = frappe.db.get_value(
"Letter Head",
self.letter_head,
["letter_head_for", "standard", "disabled"],
as_dict=True,
)
if (
not letter_head
or letter_head.letter_head_for != "Report"
or (self.is_standard == "Yes" and letter_head.standard != "Yes")
or letter_head.disabled
):
frappe.throw(
_("Selected Letter Head '{0}' is invalid for '{1}' Report.").format(
self.letter_head, self.name
)
)
@frappe.whitelist()
def toggle_disable(self, disable: bool):
if not self.has_permission("write"):
@ -436,6 +459,18 @@ class Report(Document):
self.db_set("disabled", cint(disable))
def get_xlsx_styles_from_module(self, metadata: XLSXMetadata) -> dict:
if self.is_standard != "Yes" or self.report_type not in ("Query Report", "Script Report"):
return
try:
method = self.get_module_method("get_xlsx_styles")
except AttributeError:
# Ignore if hook(method) is not defined
return
return method(metadata)
def is_prepared_report_enabled(report):
return cint(frappe.db.get_value("Report", report, "prepared_report"))

View file

@ -0,0 +1,20 @@
// Copyright (c) 2026, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("Security Settings", {
refresh(frm) {
const wrapper = frm.fields_dict.securitytxt_section.wrapper;
if ($(wrapper).find(".security-txt-banner").length) return;
$(wrapper)
.find(".section-body")
.prepend(
`<div class="alert alert-warning border d-flex justify-content-between align-items-center security-txt-banner" style="flex: 0 0 100%; max-width: 100%; border-color: var(--border-color);">
<span>${__("Security.txt will be served only under HTTPS.")}</span>
<a href="https://tools.ietf.org/html/rfc9116#section-6.7" target="_blank" class="btn btn-xs btn-secondary">${__(
"Learn more"
)}</a>
</div>`
);
},
});

View file

@ -0,0 +1,82 @@
{
"actions": [],
"creation": "2026-04-10 16:14:40.343135",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"securitytxt_section",
"public_expires",
"public_contacts",
"public_languages",
"public_policy",
"security_txt"
],
"fields": [
{
"fieldname": "securitytxt_section",
"fieldtype": "Section Break",
"label": "Security.txt"
},
{
"description": "Date after which this security.txt should be considered stale. Expires timestamp is converted to UTC.",
"fieldname": "public_expires",
"fieldtype": "Datetime",
"label": "Expires"
},
{
"description": "Website, email or phone where vulnerabilities can be reported. Defaults to `https://security.frappe.io`",
"fieldname": "public_contacts",
"fieldtype": "Table",
"label": "Contact",
"options": "Security Settings Contact"
},
{
"description": "Defaults to `en`",
"fieldname": "public_languages",
"fieldtype": "Table MultiSelect",
"label": "Preferred Language",
"options": "Security Settings Language"
},
{
"description": "Guidelines and policies on vulnerability reporting. Defaults to `https://frappe.io/security`",
"fieldname": "public_policy",
"fieldtype": "Data",
"label": "Policy",
"options": "URL"
},
{
"fieldname": "security_txt",
"fieldtype": "Small Text",
"is_virtual": 1,
"label": "Preview",
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-04-17 13:07:45.259146",
"modified_by": "Administrator",
"module": "Core",
"name": "Security Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -0,0 +1,122 @@
# Copyright (c) 2026, Frappe Technologies and contributors
# For license information, please see license.txt
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
import frappe
import frappe.utils
from frappe import _
from frappe.model.document import Document
from frappe.utils import (
get_system_timezone,
now_datetime,
validate_email_address,
validate_phone_number,
validate_url,
)
class SecuritySettings(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.core.doctype.security_settings_contact.security_settings_contact import (
SecuritySettingsContact,
)
from frappe.core.doctype.security_settings_language.security_settings_language import (
SecuritySettingsLanguage,
)
from frappe.types import DF
public_contacts: DF.Table[SecuritySettingsContact]
public_expires: DF.Datetime | None
public_languages: DF.TableMultiSelect[SecuritySettingsLanguage]
public_policy: DF.Data | None
# end: auto-generated types
@property
def security_txt(self):
return (
"\n\n".join(
[
self.public_policy_section,
self.public_contacts_section,
self.public_languages_section,
self.public_expires_section,
]
)
+ "\n"
)
@property
def public_policy_section(self):
value = self.public_policy or "https://frappe.io/security"
return f"# Read our security policy before reporting an issue\nPolicy: {value}"
@property
def public_contacts_section(self):
contacts = [self.with_protocol(c.contact, c.type) for c in self.public_contacts] or [
"https://security.frappe.io"
]
value = "\n".join(f"Contact: {c}" for c in contacts)
return f"# Our security address\n{value}"
@property
def public_languages_section(self):
langs = [l.language for l in self.public_languages] or ["en"]
value = ", ".join(langs)
return f"# We prefer talking in\nPreferred-Languages: {value}"
@property
def public_expires_section(self):
expires = self.public_expires or frappe.utils.add_years(frappe.utils.now_datetime(), 1)
if isinstance(expires, str):
expires = datetime.fromisoformat(expires)
expires = expires.replace(microsecond=0, tzinfo=ZoneInfo(get_system_timezone())).astimezone(UTC)
value = expires.strftime("%Y-%m-%dT%H:%M:%SZ")
return f"Expires: {value}"
def with_protocol(self, url: str, type_: str) -> str:
"""Prefix the URL with the appropriate protocol based on the contact type."""
match type_:
case "Email":
if not url.startswith("mailto:"):
return f"mailto:{url}"
case "Phone":
if not url.startswith("tel:"):
return f"tel:{url}"
return url
def validate(self):
self.validate_public_policy()
self.validate_public_contacts()
self.validate_expires()
def validate_public_policy(self):
if self.public_policy:
if not self.public_policy.startswith("https://"):
frappe.throw(_("Public Policy URL must start with https://"))
def validate_public_contacts(self):
for contact in self.public_contacts:
match contact.type:
case "Email":
validate_email_address(contact.contact, throw=True)
case "Phone":
validate_phone_number(contact.contact, throw=True)
case "Website":
validate_url(contact.contact, throw=True)
if not contact.contact.startswith("https://"):
frappe.throw(_("URL contact must start with https://"))
def validate_expires(self):
if self.public_expires:
expires = self.public_expires
if isinstance(expires, str):
expires = datetime.fromisoformat(expires)
if expires <= now_datetime():
frappe.throw(_("Expiration date must be in the future"))

View file

@ -0,0 +1,43 @@
# Copyright (c) 2026, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
from frappe.utils import get_datetime, now_datetime
from frappe.utils.user import get_users_with_role
def check_security_txt_expiry():
security_settings = frappe.get_doc("Security Settings")
if not security_settings.public_expires:
return
expires = security_settings.public_expires
if isinstance(expires, str):
expires = get_datetime(expires)
now = now_datetime()
days_until_expiry = (expires - now).days
alert_days = [30, 15, 7, 1]
if days_until_expiry in alert_days:
send_expiry_alert(frappe.local.site, expires, days_until_expiry)
def send_expiry_alert(site: str, expires, days_until_expiry: int):
recipients = get_users_with_role("System Manager")
if not recipients:
return
subject = get_email_subject(site, days_until_expiry)
frappe.sendmail(
recipients=recipients,
subject=subject,
template="security_txt_expiry_alert",
args={
"site": site,
"expires": expires,
"days_remaining": days_until_expiry,
},
)
def get_email_subject(site: str, days_until_expiry: int) -> str:
if days_until_expiry == 1:
return f"[URGENT] Security.txt expires in 1 day - {site}"
return f"Security.txt expires in {days_until_expiry} days - {site}"

View file

@ -0,0 +1,272 @@
# Copyright (c) 2026, Frappe Technologies and Contributors
# License: MIT. See LICENSE
from datetime import UTC, datetime, timedelta
import frappe
from frappe.tests import IntegrationTestCase
class TestSecuritySettings(IntegrationTestCase):
def test_public_policy_section_default(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_policy": None,
}
)
section = doc.public_policy_section
self.assertIn("Policy: https://frappe.io/security", section)
def test_public_policy_section_custom(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_policy": "https://example.com/security-policy",
}
)
section = doc.public_policy_section
self.assertIn("Policy: https://example.com/security-policy", section)
def test_public_languages_section_default(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
section = doc.public_languages_section
self.assertIn("Preferred-Languages: en", section)
def test_public_languages_section_custom(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_languages": [
{"language": "en"},
{"language": "fr"},
],
}
)
section = doc.public_languages_section
self.assertIn("Preferred-Languages: en, fr", section)
def test_public_contacts_section_default(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
section = doc.public_contacts_section
self.assertIn("https://security.frappe.io", section)
def test_public_contacts_section_email(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Email", "contact": "security@example.com"},
],
}
)
section = doc.public_contacts_section
self.assertIn("mailto:security@example.com", section)
def test_public_contacts_section_phone(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Phone", "contact": "+1234567890"},
],
}
)
section = doc.public_contacts_section
self.assertIn("tel:+1234567890", section)
def test_public_contacts_section_website(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Website", "contact": "https://security.example.com"},
],
}
)
section = doc.public_contacts_section
self.assertIn("https://security.example.com", section)
def test_with_protocol_email_without_protocol(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
result = doc.with_protocol("security@example.com", "Email")
self.assertEqual(result, "mailto:security@example.com")
def test_with_protocol_email_with_protocol(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
result = doc.with_protocol("mailto:security@example.com", "Email")
self.assertEqual(result, "mailto:security@example.com")
def test_with_protocol_phone_without_protocol(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
result = doc.with_protocol("+1234567890", "Phone")
self.assertEqual(result, "tel:+1234567890")
def test_with_protocol_phone_with_protocol(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
result = doc.with_protocol("tel:+1234567890", "Phone")
self.assertEqual(result, "tel:+1234567890")
def test_with_protocol_website(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
result = doc.with_protocol("https://example.com", "Website")
self.assertEqual(result, "https://example.com")
def test_security_txt_full(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_policy": "https://example.com/policy",
"public_contacts": [
{"type": "Email", "contact": "security@example.com"},
],
"public_languages": [
{"language": "en"},
],
"public_expires": datetime.now() + timedelta(days=365),
}
)
security_txt = doc.security_txt
self.assertIn("Policy: https://example.com/policy", security_txt)
self.assertIn("mailto:security@example.com", security_txt)
self.assertIn("Preferred-Languages: en", security_txt)
self.assertIn("Expires:", security_txt)
def test_validate_public_policy_with_http(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_policy": "http://example.com",
}
)
self.assertRaises(frappe.ValidationError, doc.validate_public_policy)
def test_validate_public_policy_with_https(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_policy": "https://example.com",
}
)
# Should not raise
doc.validate_public_policy()
def test_validate_public_contacts_invalid_email(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Email", "contact": "invalid-email"},
],
}
)
self.assertRaises(frappe.ValidationError, doc.validate_public_contacts)
def test_validate_public_contacts_valid_email(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Email", "contact": "security@example.com"},
],
}
)
# Should not raise
doc.validate_public_contacts()
def test_validate_public_contacts_invalid_phone(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Phone", "contact": "not-a-phone"},
],
}
)
self.assertRaises(frappe.ValidationError, doc.validate_public_contacts)
def test_validate_public_contacts_valid_phone(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Phone", "contact": "+1234567890"},
],
}
)
# Should not raise
doc.validate_public_contacts()
def test_validate_public_contacts_website_without_https(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Website", "contact": "http://example.com"},
],
}
)
self.assertRaises(frappe.ValidationError, doc.validate_public_contacts)
def test_validate_public_contacts_valid_website(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_contacts": [
{"type": "Website", "contact": "https://example.com"},
],
}
)
# Should not raise
doc.validate_public_contacts()
def test_validate_expires_past(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_expires": datetime.now() - timedelta(days=1),
}
)
self.assertRaises(frappe.ValidationError, doc.validate_expires)
def test_validate_expires_future(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_expires": datetime.now() + timedelta(days=365),
}
)
# Should not raise
doc.validate_expires()
@IntegrationTestCase.change_settings("System Settings", {"time_zone": "Etc/UTC"})
def test_public_expires_section_future_date(self):
from datetime import timezone
future_date = datetime(2027, 12, 31, 23, 59, 59)
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_expires": future_date,
}
)
section = doc.public_expires_section
self.assertIn("2027-12-31T23:59:59Z", section)
@IntegrationTestCase.change_settings("System Settings", {"time_zone": "Asia/Kolkata"})
def test_public_expires_section_string(self):
doc = frappe.get_doc(
{
"doctype": "Security Settings",
"public_expires": "2028-01-01T05:29:59",
}
)
section = doc.public_expires_section
self.assertIn("2027-12-31T23:59:59Z", section)
def test_public_expires_section_default(self):
doc = frappe.get_doc({"doctype": "Security Settings"})
section = doc.public_expires_section
# Default is 1 year from now
self.assertIn("Expires:", section)
self.assertIn("T", section) # ISO format

View file

@ -0,0 +1,43 @@
{
"actions": [],
"creation": "2026-04-11 13:06:29.308243",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"type",
"contact"
],
"fields": [
{
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Website\nEmail\nPhone",
"reqd": 1
},
{
"fieldname": "contact",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Contact",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-04-14 12:50:25.814560",
"modified_by": "Administrator",
"module": "Core",
"name": "Security Settings Contact",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,24 @@
# Copyright (c) 2026, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SecuritySettingsContact(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
contact: DF.Data
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
type: DF.Literal["Website", "Email", "Phone"]
# end: auto-generated types
pass

View file

@ -0,0 +1,35 @@
{
"actions": [],
"creation": "2026-04-11 12:53:09.006649",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"language"
],
"fields": [
{
"fieldname": "language",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Language",
"options": "Language",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-04-14 12:50:44.554462",
"modified_by": "Administrator",
"module": "Core",
"name": "Security Settings Language",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,23 @@
# Copyright (c) 2026, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SecuritySettingsLanguage(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
language: DF.Link
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
# end: auto-generated types
pass

View file

@ -114,6 +114,8 @@
"enable_telemetry",
"search_section",
"link_field_results_limit",
"column_break_nebx",
"allow_clearing_link_fields",
"api_logging_section",
"log_api_requests"
],
@ -783,12 +785,23 @@
"fieldname": "only_allow_system_managers_to_upload_public_files",
"fieldtype": "Check",
"label": "Only allow System Managers to upload public files"
},
{
"fieldname": "column_break_nebx",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "Adds a clear (\u00d7) button to Link fields, allowing users to quickly remove the selected value.",
"fieldname": "allow_clearing_link_fields",
"fieldtype": "Check",
"label": "Allow Clearing Link Fields"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2026-03-28 23:46:03.614749",
"modified": "2026-04-14 16:26:19.634212",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",

View file

@ -18,6 +18,7 @@ class SystemSettings(Document):
if TYPE_CHECKING:
from frappe.types import DF
allow_clearing_link_fields: DF.Check
allow_consecutive_login_attempts: DF.Int
allow_error_traceback: DF.Check
allow_guests_to_upload_files: DF.Check

View file

@ -292,7 +292,7 @@ class TestUser(IntegrationTestCase):
c = FrappeClient(url)
res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
res2 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
self.assertEqual(res1.status_code, 404)
self.assertEqual(res1.status_code, 200)
self.assertEqual(res2.status_code, 429)
def test_user_rename(self):
@ -431,15 +431,28 @@ class TestUser(IntegrationTestCase):
update_password(old_password, old_password=new_password)
self.assertEqual(
frappe.message_log[0].get("message"),
f"Password reset instructions have been sent to {test_user.full_name}'s email",
"If this email is registered with us, we have sent password reset instructions to it. Please check your inbox.",
)
sendmail.assert_called_once()
self.assertEqual(sendmail.call_args[1]["recipients"], "test2@example.com")
self.assertEqual(reset_password(user="test2@example.com"), None)
self.assertEqual(reset_password(user="Administrator"), "not allowed")
self.assertEqual(reset_password(user="random"), "not found")
# Constant-response guarantee: every path — existing user, Administrator,
# and non-existent user — must return None AND enqueue the same generic
# message, so callers cannot distinguish between them.
_GENERIC_MSG = "If this email is registered with us, we have sent password reset instructions to it. Please check your inbox."
frappe.clear_messages()
self.assertIsNone(reset_password(user="test2@example.com"))
self.assertEqual(frappe.message_log[0].get("message"), _GENERIC_MSG)
frappe.clear_messages()
self.assertIsNone(reset_password(user="Administrator"))
self.assertEqual(frappe.message_log[0].get("message"), _GENERIC_MSG)
frappe.clear_messages()
self.assertIsNone(reset_password(user="random"))
self.assertEqual(frappe.message_log[0].get("message"), _GENERIC_MSG)
def test_user_onload_modules(self):
from frappe.desk.form.load import getdoc

View file

@ -69,6 +69,8 @@ frappe.ui.form.on("User", {
frm.roles_editor.reset();
}
frm.fields_dict.new_password?.$input?.attr("autocomplete", "new-password");
if (
frm.can_edit_roles &&
!frm.is_new() &&

View file

@ -232,6 +232,7 @@ class User(Document):
self.check_enable_disable()
self.ensure_unique_roles()
self.ensure_unique_role_profiles()
self.sync_role_profile_name()
self.remove_all_roles_for_guest()
self.validate_username()
self.remove_disabled_roles()
@ -277,11 +278,11 @@ class User(Document):
def move_role_profile_name_to_role_profiles(self):
"""This handles old role_profile_name field if programatically set.
This behaviour will be remoed in future versions."""
This behaviour will be removed in future versions."""
if not self.role_profile_name:
return
current_role_profiles = [r.role_profile for r in self.role_profiles]
current_role_profiles = {r.role_profile for r in self.role_profiles}
if self.role_profile_name in current_role_profiles:
self.role_profile_name = None
return
@ -296,6 +297,10 @@ class User(Document):
self.append("role_profiles", {"role_profile": self.role_profile_name})
self.role_profile_name = None
def sync_role_profile_name(self):
"""Keep deprecated role_profile_name in sync for list view display."""
self.role_profile_name = self.role_profiles[0].role_profile if self.role_profiles else None
def validate_allowed_modules(self):
if self.module_profile:
module_profile = frappe.get_doc("Module Profile", self.module_profile)
@ -359,7 +364,7 @@ class User(Document):
def clean_name(self):
for field in ("first_name", "middle_name", "last_name"):
if field_value := self.get(field):
self.set(field, sanitize_html(field_value, always_sanitize=True))
self.set(field, sanitize_html(field_value, always_sanitize=True, disallowed_tags="*"))
def set_full_name(self):
self.full_name = " ".join(p for p in [self.first_name, self.middle_name, self.last_name] if p)
@ -645,18 +650,16 @@ class User(Document):
frappe.db.delete("List Filter", {"for_user": self.name})
# Remove user from Note's Seen By table
seen_notes = frappe.get_all("Note", filters=[["Note Seen By", "user", "=", self.name]], pluck="name")
for note_id in seen_notes:
note = frappe.get_doc("Note", note_id)
seen_notes = frappe.get_docs("Note", filters=[["Note Seen By", "user", "=", self.name]])
for note in seen_notes:
for row in note.seen_by:
if row.user == self.name:
note.remove(row)
note.save(ignore_permissions=True)
# Unlink user from all of its invitation docs
invites = frappe.db.get_all("User Invitation", filters={"email": self.name}, pluck="name")
for invite in invites:
invite_doc = frappe.get_doc("User Invitation", invite)
invites = frappe.get_docs("User Invitation", filters={"email": self.name})
for invite_doc in invites:
invite_doc.user = None
invite_doc.save(ignore_permissions=True)
@ -1154,25 +1157,32 @@ def sign_up(email: str, full_name: str, redirect_to: str) -> tuple[int, str]:
@frappe.whitelist(allow_guest=True, methods=["POST"])
@rate_limit(limit=get_password_reset_limit, seconds=60 * 60)
def reset_password(user: str) -> str:
def reset_password(user: str) -> None:
# Always return the same generic response regardless of whether the user
# exists, is disabled, or is restricted. This prevents username enumeration
# via different messages or HTTP status codes (CWE-204).
try:
user: User = frappe.get_doc("User", user)
if user.name == "Administrator":
return "not allowed"
if not user.enabled:
return "disabled"
user.validate_reset_password()
user._reset_password(send_email=True)
return frappe.msgprint(
msg=_("Password reset instructions have been sent to {}'s email").format(user.full_name),
title=_("Password Email Sent"),
)
user_doc: User = frappe.get_doc("User", user)
if user_doc.name != "Administrator" and user_doc.enabled:
user_doc.validate_reset_password()
user_doc._reset_password(send_email=True)
# For Administrator or disabled users: silently skip — same response below
except frappe.DoesNotExistError:
frappe.local.response["http_status_code"] = 404
frappe.clear_messages()
return "not found"
except frappe.OutgoingEmailError:
frappe.clear_messages()
frappe.log_error(title="Password reset email could not be sent", message=frappe.get_traceback())
except Exception:
frappe.clear_messages()
frappe.log_error(title="Password reset failed unexpectedly", message=frappe.get_traceback())
frappe.msgprint(
msg=_(
"If this email is registered with us, we have sent password reset instructions to it. Please check your inbox."
),
title=_("Password Reset"),
)
@frappe.whitelist()

View file

@ -4,6 +4,9 @@
frappe.listview_settings["User"] = {
add_fields: ["enabled", "user_type", "user_image"],
filters: [["enabled", "=", 1]],
onload(listview) {
this.set_default_app_options(listview);
},
prepare_data: function (data) {
data["user_for_avatar"] = data["name"];
},
@ -14,6 +17,15 @@ frappe.listview_settings["User"] = {
return [__("Disabled"), "grey", "enabled,=,0"];
}
},
set_default_app_options(listview) {
const default_app_field = frappe.meta.get_docfield("User", "default_app");
if (!default_app_field) return;
frappe.xcall("frappe.apps.get_apps").then((r) => {
let apps = r?.map((r) => r.name) || [];
default_app_field.options = [" ", ...apps].join("\n");
});
},
};
frappe.help.youtube_id["User"] = "8Slw1hsTmUI";

View file

@ -206,12 +206,11 @@ class UserInvitation(Document):
def mark_expired_invitations() -> None:
days = 3
invitations_to_expire = frappe.db.get_all(
invitations_to_expire = frappe.get_docs(
"User Invitation",
filters={"status": "Pending", "creation": ["<", frappe.utils.add_days(frappe.utils.now(), -days)]},
)
for invitation in invitations_to_expire:
invitation = frappe.get_doc("User Invitation", invitation.name)
invitation.expire()
# to avoid losing work in case the job times out without finishing
frappe.db.commit() # nosemgrep

View file

@ -443,3 +443,53 @@ def _update_fieldname_references(field: CustomField, old_fieldname: str, new_fie
"insert_after",
new_fieldname,
)
def delete_custom_fields(custom_fields: dict, bypass_hooks: bool = False):
"""
Delete custom fields from doctypes.
:param custom_fields: Dict mapping doctype to field names.
:param bypass_hooks: If `True`, fast raw delete (skips hooks (doc events like on_trash)).
Example:
```
delete_custom_fields({"Address": ["custom_a", "custom_b"]})
delete_custom_fields({"ToDo": [{"fieldname": "cf_1"}]}, bypass_hooks=True)
````
"""
for doctype, fields in custom_fields.items():
fieldnames = []
if isinstance(fields, (list, tuple, set)):
for field in fields:
if isinstance(field, str):
fieldnames.append(field)
elif isinstance(field, dict) and field.get("fieldname"):
fieldnames.append(field["fieldname"])
if not fieldnames:
continue
fieldnames = tuple(set(fieldnames))
if bypass_hooks:
frappe.db.delete(
"Custom Field",
{
"fieldname": ("in", fieldnames),
"dt": doctype,
},
)
frappe.clear_cache(doctype=doctype)
else:
custom_field_names = frappe.get_all(
"Custom Field",
filters={"fieldname": ("in", fieldnames), "dt": doctype},
pluck="name",
)
for custom_field_name in custom_field_names:
frappe.get_doc("Custom Field", custom_field_name).delete(ignore_permissions=True, force=True)

View file

@ -5,8 +5,10 @@ import frappe
from frappe.custom.doctype.custom_field.custom_field import (
create_custom_field,
create_custom_fields,
delete_custom_fields,
rename_fieldname,
)
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.tests import IntegrationTestCase
@ -183,3 +185,50 @@ class TestCustomField(IntegrationTestCase):
self.assertFalse(doc.get(old))
field.delete()
def test_delete_custom_fields(self):
doctype = "ToDo"
fields = [
{
"fieldname": f"test_delete_{frappe.generate_hash(length=5)}",
"fieldtype": "Data",
"insert_after": "status",
}
for _ in range(4)
]
fieldnames = [f["fieldname"] for f in fields]
create_custom_fields({doctype: fields})
# create property setters for fields deleted via safe path (hooks should clean these up)
for fieldname in fieldnames[:2]:
make_property_setter(doctype, fieldname, "hidden", "1", "Check")
def field_exists(fieldname):
return frappe.db.exists("Custom Field", {"fieldname": fieldname, "dt": doctype})
def property_setter_exists(fieldname):
return frappe.db.exists("Property Setter", {"doc_type": doctype, "field_name": fieldname})
for fieldname in fieldnames:
self.assertTrue(field_exists(fieldname))
for fieldname in fieldnames[:2]:
self.assertTrue(property_setter_exists(fieldname))
# 1
delete_custom_fields({doctype: [fieldnames[0], fieldnames[0]]})
self.assertFalse(field_exists(fieldnames[0]))
self.assertFalse(property_setter_exists(fieldnames[0]))
# 2
delete_custom_fields({doctype: [{"fieldname": fieldnames[1]}]})
self.assertFalse(field_exists(fieldnames[1]))
self.assertFalse(property_setter_exists(fieldnames[1]))
# 3
delete_custom_fields({doctype: [fieldnames[2], fieldnames[2]]}, bypass_hooks=True)
self.assertFalse(field_exists(fieldnames[2]))
# 4
delete_custom_fields({doctype: [{"fieldname": fieldnames[3]}]}, bypass_hooks=True)
self.assertFalse(field_exists(fieldnames[3]))

View file

@ -13,13 +13,14 @@ import frappe.translate
from frappe import _
from frappe.core.doctype.doctype.doctype import (
check_email_append_to,
get_fields_not_allowed_in_list_view,
validate_autoincrement_autoname,
validate_fields_for_doctype,
validate_series,
)
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter
from frappe.model import core_doctypes_list, no_value_fields
from frappe.model import core_doctypes_list
from frappe.model.docfield import supports_translation
from frappe.model.document import Document
from frappe.model.meta import trim_table
@ -319,12 +320,12 @@ class CustomizeForm(Document):
def set_property_setters_for_docfield(self, meta, df, meta_df):
for prop, prop_type in docfield_properties.items():
if prop != "idx" and (df.get(prop) or "") != (meta_df[0].get(prop) or ""):
if not self.allow_property_change(prop, meta_df, df):
if not self.allow_property_change(prop, meta_df, df, meta):
continue
self.make_property_setter(prop, df.get(prop), prop_type, fieldname=df.fieldname)
def allow_property_change(self, prop, meta_df, df):
def allow_property_change(self, prop, meta_df, df, meta):
if prop == "fieldtype":
self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop))
@ -360,8 +361,7 @@ class CustomizeForm(Document):
elif (
prop == "in_list_view"
and df.get(prop)
and df.fieldtype != "Attach Image"
and df.fieldtype in no_value_fields
and df.fieldtype in get_fields_not_allowed_in_list_view(meta)
):
frappe.msgprint(
_("'In List View' not allowed for type {0} in row {1}").format(df.fieldtype, df.idx)
@ -401,6 +401,10 @@ class CustomizeForm(Document):
elif prop == "in_global_search" and df.in_global_search != meta_df[0].get("in_global_search"):
self.flags.rebuild_doctype_for_global_search = True
elif prop == "is_virtual" and meta_df[0].get("is_virtual") == 0 and df.get("is_virtual") == 1:
frappe.msgprint(_("You can't set standard field {0} as virtual").format(frappe.bold(df.label)))
return False
return True
def set_property_setters_for_actions_and_links(self, meta):

View file

@ -475,6 +475,9 @@ class Database:
if query_type in WRITE_QUERY_TYPES:
self.transaction_writes += 1
if frappe.conf.get("max_writes_per_transaction"):
self.MAX_WRITES_PER_TRANSACTION = cint(frappe.conf.max_writes_per_transaction)
if self.transaction_writes > self.MAX_WRITES_PER_TRANSACTION:
if self.auto_commit_on_many_writes:
self.commit()

View file

@ -682,6 +682,9 @@ def get_onboarding_data(module: str):
Return:
dict: onboarding data
"""
if not frappe.get_system_settings("enable_onboarding"):
return []
onboardings = []
onboarding_doc = frappe.get_doc("Module Onboarding", module)
if onboarding_doc.is_complete:

View file

@ -4,6 +4,7 @@
import frappe
from frappe.model.document import Document
from frappe.query_builder.utils import DocType
from frappe.utils import has_common
class CustomHTMLBlock(Document):
@ -23,7 +24,12 @@ class CustomHTMLBlock(Document):
style: DF.Code | None
# end: auto-generated types
pass
def validate(self):
self.validate_private()
def validate_private(self):
if not has_common(frappe.get_roles(), ["Administrator", "System Manager", "Workspace Manager"]):
self.private = 1
@frappe.whitelist()

View file

@ -63,8 +63,9 @@ class DesktopIcon(Document):
clear_desktop_icons_cache(user=self.owner)
def after_rename(self, old, new, merge):
delete_desktop_icon_file(self.app, old)
self.export_desktop_icon()
if self.standard and self.app:
delete_desktop_icon_file(self.app, old)
self.export_desktop_icon()
def export_desktop_icon(self):
allow_export = (

View file

@ -48,6 +48,8 @@ def save_layout(user: str, layout: str, new_icons: str | None = None):
new_workspace = frappe.new_doc("Workspace")
new_workspace.update(workspace)
new_workspace.title = new_workspace.label
if not new_workspace.public:
new_workspace.for_user = frappe.session.user
new_workspace.save()
return add_workspace_to_desktop(new_workspace.name)
desktop_icon = frappe.new_doc("Desktop Icon")

View file

@ -137,7 +137,7 @@ class Event(Document):
return
for participant in self.event_participants:
if communications := frappe.get_all(
if communications := frappe.get_docs(
"Communication",
filters=[
["Communication", "reference_doctype", "=", self.doctype],
@ -145,11 +145,9 @@ class Event(Document):
["Communication Link", "link_doctype", "=", participant.reference_doctype],
["Communication Link", "link_name", "=", participant.reference_docname],
],
pluck="name",
distinct=True,
):
for comm in communications:
communication = frappe.get_doc("Communication", comm)
for communication in communications:
self.update_communication(participant, communication)
else:
meta = frappe.get_meta(participant.reference_doctype)
@ -238,8 +236,15 @@ class Event(Document):
@frappe.whitelist()
def update_attending_status(event_name: str, attendee: str, status: str):
event_doc = frappe.get_doc("Event", event_name)
caller = frappe.session.user
if event_doc.owner == attendee == frappe.session.user:
if attendee != caller:
if event_doc.owner != caller and not frappe.has_permission("Event", "write", event_name):
frappe.throw(
_("You are not allowed to update attendance for another user."), frappe.PermissionError
)
if event_doc.owner == caller:
frappe.db.set_value("Event", event_name, "attending", status)
return
@ -248,8 +253,7 @@ def update_attending_status(event_name: str, attendee: str, status: str):
frappe.db.set_value("Event Participants", participant.name, "attending", status)
return
if not has_permission(event_doc, user=attendee):
frappe.throw(_("You are not allowed to update the status of this event."))
frappe.throw(_("Attendee not found in this event."))
@frappe.whitelist()
@ -339,7 +343,12 @@ def get_events(
for_reminder: bool = False,
filters: str | list | dict[str, Any] | None = None,
) -> list[frappe._dict]:
user = user or frappe.session.user
caller = frappe.session.user
target_user = user or caller
if user and user != caller:
if not frappe.has_permission("Event", ptype="read"):
frappe.throw(_("You are not allowed to view events for another user."), frappe.PermissionError)
type EventLikeDict = Event | frappe._dict
resolved_events: list[EventLikeDict] = []
@ -411,7 +420,7 @@ def get_events(
{
"start": start,
"end": end,
"user": user,
"user": target_user,
},
as_dict=True,
)

View file

@ -36,6 +36,7 @@ class Note(Document):
if not self.content:
self.content = "<span></span>"
self.content = frappe.utils.sanitize_html(self.content, always_sanitize=True)
def before_print(self, settings=None):
self.print_heading = self.name

View file

@ -504,7 +504,7 @@ frappe.ui.form.on("Number Card", {
<td class="text-center">
<a class="remove-filter text-muted" style="cursor: pointer;">
<svg class="icon icon-sm">
<use href="#icon-close" class="close"></use>
<use href="#icon-x" class="close"></use>
</svg>
</a>
</td>

View file

@ -29,9 +29,7 @@ class SystemConsole(Document):
try:
frappe.local.debug_log = []
if self.type == "Python":
safe_exec(
self.console, script_filename="System Console", restrict_commit_rollback=not self.commit
)
safe_exec(self.console, script_filename="System Console")
self.output = "\n".join(frappe.debug_log)
elif self.type == "SQL":
frappe.db.begin(read_only=True)

View file

@ -26,7 +26,7 @@
<div class="flex" style="gap:16px; align-items: center;">
<div class="desktop-notifications">
<div class="dropdown dropdown-notifications">
<button class="btn-reset nav-link text-muted" data-toggle="dropdown" aria-label="{{ _("Notifications") }}" aria-haspopup="true">
<button class="btn-reset nav-link text-muted desktop-notification-icon" data-toggle="dropdown" aria-label="{{ _("Notifications") }}" aria-haspopup="true">
<svg
class="icon icon-md" aria-hidden="true"
>

View file

@ -522,25 +522,28 @@ class DesktopPage {
if (frappe.boot.desk_settings.search_bar) {
let awesome_bar = new frappe.search.AwesomeBar();
awesome_bar.setup(".desktop-search-wrapper #desktop-navbar-modal-search");
frappe.ui.keys.add_shortcut({
shortcut: "ctrl+g",
action: function (e) {
$(".desktop-search-wrapper #desktop-navbar-modal-search").click();
e.preventDefault();
return false;
},
description: __("Open Awesomebar"),
ignore_inputs: true,
});
frappe.ui.keys.add_shortcut({
shortcut: "ctrl+k",
action: function (e) {
$(".desktop-search-wrapper #desktop-navbar-modal-search").click();
e.preventDefault();
return false;
},
description: __("Toggle Awesomebar"),
ignore_inputs: true,
});
}
frappe.ui.keys.add_shortcut({
shortcut: "ctrl+g",
action: function (e) {
$(".desktop-search-wrapper #desktop-navbar-modal-search").click();
e.preventDefault();
return false;
},
description: __("Open Awesomebar"),
});
frappe.ui.keys.add_shortcut({
shortcut: "ctrl+k",
action: function (e) {
$(".desktop-search-wrapper #desktop-navbar-modal-search").click();
e.preventDefault();
return false;
},
description: __("Open Awesomebar"),
});
}
handle_route_change() {
const me = this;

View file

@ -512,6 +512,8 @@ frappe.setup.slides_settings = [
],
onload: function (slide) {
slide.form.fields_dict.password?.$input?.attr("autocomplete", "new-password");
if (frappe.session.user !== "Administrator") {
const { first_name, last_name, email } = frappe.boot.user;
if (first_name || last_name) {

View file

@ -18,6 +18,7 @@ from frappe.monitor import add_data_to_monitor
from frappe.permissions import get_role_permissions, get_roles, has_permission
from frappe.utils import cint, cstr, flt, format_datetime, format_duration, formatdate, get_html_format, sbool
from frappe.utils.caching import request_cache
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder, handle_html, make_xlsx
def get_report_doc(report_name):
@ -81,7 +82,6 @@ def generate_report_result(
custom_columns=None,
is_tree=False,
parent_field=None,
skip_total_calculation=False,
):
user = user or frappe.session.user
filters = filters or []
@ -118,7 +118,7 @@ def generate_report_result(
has_total_row = cint(report.add_total_row) and result and not skip_total_row
if has_total_row and not skip_total_calculation:
if has_total_row:
result = add_total_row(result, columns, is_tree=is_tree, parent_field=parent_field)
if isinstance(filters, dict) and filters.get("translate_data"):
@ -208,7 +208,6 @@ def run(
parent_field: str | None = None,
are_default_filters: bool = True,
js_filters: str | list | None = None,
skip_total_calculation: bool = False,
) -> dict:
if not user:
user = frappe.session.user
@ -226,7 +225,6 @@ def run(
filters = report.custom_filters
is_prepared_report = report.prepared_report and not sbool(ignore_prepared_report) and not custom_columns
skip_total_calculation = sbool(skip_total_calculation)
try:
if is_prepared_report:
@ -239,9 +237,7 @@ def run(
dn = ""
result = get_prepared_report_result(report, filters, dn, user)
else:
result = generate_report_result(
report, filters, user, custom_columns, is_tree, parent_field, skip_total_calculation
)
result = generate_report_result(report, filters, user, custom_columns, is_tree, parent_field)
add_data_to_monitor(report=report.reference_report or report.name)
except Exception:
frappe.log_error("Report execution failed for: {}".format(report_name))
@ -249,10 +245,6 @@ def run(
result["add_total_row"] = report.add_total_row and not result.get("skip_total_row", False)
if skip_total_calculation and is_prepared_report:
# remove total row from result
result["result"] = result["result"][:-1]
if sbool(are_default_filters) and report.get("custom_filters"):
result["custom_filters"] = report.custom_filters
@ -377,16 +369,14 @@ def run_export_query_job(user_email: str, form_params, csv_params):
def _export_query(form_params, csv_params, populate_response=True):
from frappe.desk.utils import get_csv_bytes, provide_binary_file
from frappe.utils.xlsxutils import handle_html, make_xlsx
report_name = form_params.report_name
file_format_type = form_params.file_format_type
custom_columns = frappe.parse_json(form_params.custom_columns or "[]")
include_indentation = form_params.include_indentation
include_filters = form_params.include_filters
visible_idx = form_params.visible_idx or []
visible_idx = form_params.visible_idx or [] # excluding total row idx
ignore_visible_idx = sbool(form_params.get("ignore_visible_idx"))
skip_all_rows_total = not ignore_visible_idx
include_hidden_columns = form_params.include_hidden_columns
if isinstance(visible_idx, str):
@ -397,11 +387,13 @@ def _export_query(form_params, csv_params, populate_response=True):
form_params.filters,
custom_columns=custom_columns,
are_default_filters=False,
skip_total_calculation=skip_all_rows_total,
)
data = frappe._dict(data)
data.filters = form_params.applied_filters
data.report_name = report_name
data.filters = form_params.filters
data.applied_filters = form_params.applied_filters
if not data.columns:
frappe.respond_as_web_page(
@ -410,36 +402,51 @@ def _export_query(form_params, csv_params, populate_response=True):
)
return
# calculate total row only for visible rows
if skip_all_rows_total and cint(data.get("add_total_row")):
data["result"] = add_total_row(data.result, data.columns, visible_idx=visible_idx)
has_total_row = cint(data.get("add_total_row"))
needs_visible_filtering = (
visible_idx
and not ignore_visible_idx
and len(visible_idx) < len(data.result) - (1 if has_total_row else 0)
)
if needs_visible_filtering:
visible_idx = set(visible_idx)
filtered_result = [row for idx, row in enumerate(data.result) if idx in visible_idx]
if has_total_row:
filtered_result = add_total_row(filtered_result, data.columns)
data["result"] = filtered_result
format_fields(data)
xlsx_data, column_widths, header_index = build_xlsx_data(
xlsx_data, column_widths, styles = build_xlsx_data(
data,
visible_idx,
include_indentation,
include_indentation=include_indentation,
include_filters=include_filters,
include_hidden_columns=include_hidden_columns,
ignore_visible_idx=ignore_visible_idx,
build_styles=file_format_type == "Excel",
)
if file_format_type == "CSV":
file_extension = "csv"
content = get_csv_bytes(
[[handle_html(v) if isinstance(v, str) else v for v in r] for r in xlsx_data],
csv_params,
)
file_extension = "csv"
elif file_format_type == "Excel":
file_extension = "xlsx"
content = make_xlsx(
xlsx_data,
"Query Report",
report_name,
column_widths=column_widths,
header_index=header_index,
has_filters=bool(include_filters),
styles=styles,
).getvalue()
else:
frappe.throw(
title=_("Unsupported file format: {0}").format(file_format_type),
msg=_("Only CSV and Excel formats are supported for export"),
)
if include_filters:
for value in (data.filters or {}).values():
@ -492,31 +499,40 @@ def format_fields(data: frappe._dict) -> None:
row[index] = format_datetime(val)
def format_filter_value(value):
return ", ".join([cstr(x) for x in value]) if isinstance(value, list) else cstr(value)
def build_xlsx_data(
data: frappe._dict,
visible_idx: list[int],
include_indentation: bool,
visible_idx: list[int] | None = None,
include_indentation: bool = False,
include_filters: bool = False,
ignore_visible_idx: bool = False,
include_hidden_columns: bool = False,
) -> tuple[list[list[Any]], list[int], int]:
*,
build_styles: bool = False,
) -> tuple[list[list[Any]], list[int], dict | None]:
"""
Build Excel data structure from report data with proper formatting.
Args:
data: Report data containing columns, result, and filters
visible_idx: List of row indices that are visible in the report
data: Report data containing columns, result, filters, applied_filters, report_name etc.
visible_idx: Deprecated (v17). Row indices to include.
include_indentation: Whether to include indentation for tree-like data
include_filters: Whether to include filter rows at the top of the Excel sheet
ignore_visible_idx: Whether to ignore the visible_idx parameter
ignore_visible_idx: Deprecated (v17). Skips visible_idx filtering.
include_hidden_columns: Whether to include columns marked as hidden
build_styles: Whether to build style metadata for Excel formatting
Returns:
tuple: A tuple containing:
- result: List of rows for the Excel sheet
- column_widths: List of column widths for the Excel sheet
- header_index: Index of the header row in the result
- styles: Dictionary of styles for Excel formatting (if applicable)
"""
metadata = None
EXCEL_TYPES = (
str,
bool,
@ -528,8 +544,18 @@ def build_xlsx_data(
datetime.time,
datetime.timedelta,
)
if visible_idx or ignore_visible_idx:
from frappe.deprecation_dumpster import deprecation_warning
if len(visible_idx) == len(data.result) or not visible_idx:
deprecation_warning(
"2026-04-19",
"v17",
"The 'visible_idx' and 'ignore_visible_idx' parameters of build_xlsx_data are deprecated. "
"Filter data.result before calling build_xlsx_data instead.",
)
# NOTE: for backwards compatibility. remove in v17.
if not visible_idx or len(visible_idx) == len(data.result):
# It's not possible to have same length and different content.
ignore_visible_idx = True
else:
@ -537,53 +563,83 @@ def build_xlsx_data(
visible_idx = set(visible_idx)
result = []
column_data = []
column_widths = []
header_index = 0
include_hidden_columns = cint(include_hidden_columns)
excel_row_idx = 0
include_filters = cint(include_filters)
include_indentation = cint(include_indentation)
include_hidden_columns = cint(include_hidden_columns)
has_total_row = sbool(data.get("add_total_row"))
if cint(include_filters) and data.filters:
if build_styles:
metadata = XLSXMetadata(
report_name=data.report_name,
filters=data.filters,
has_total_row=has_total_row,
has_indentation=include_indentation,
)
# adding applied filter rows
if include_filters and data.applied_filters:
filter_data = []
for filter_name, filter_value in data.filters.items():
for filter_name, filter_value in data.applied_filters.items():
if not filter_value:
continue
filter_value = (
", ".join([cstr(x) for x in filter_value])
if isinstance(filter_value, list)
else cstr(filter_value)
)
filter_data.append([cstr(filter_name), filter_value])
applied_filter = [cstr(filter_name), format_filter_value(filter_value)]
if build_styles:
metadata.applied_filters_map[excel_row_idx] = applied_filter
excel_row_idx += 1
filter_data.append(applied_filter)
# empty row after filters
filter_data.append([])
excel_row_idx += 1
result += filter_data
# header is after filters + 1 empty row
header_index = len(result)
column_data = []
# adding header row
column_idx = 0
for column in data.columns:
if column.get("hidden") and not include_hidden_columns:
continue
if build_styles:
metadata.column_map[column_idx] = column
column_idx += 1
column_data.append(_(column.get("label")))
column_width = cint(column.get("width", 0))
# to convert into scale accepted by openpyxl
# to convert into scale accepted by xlsxwriter
column_width /= 10
column_widths.append(column_width)
result.append(column_data)
last_row_index = len(data.result) - 1
result.append(column_data)
excel_row_idx += 1
# build table from result
handle_indentation = include_indentation and not build_styles
for row_idx, row in enumerate(data.result):
# only pick up rows that are visible in the report + total row if added
if not (
ignore_visible_idx or row_idx in visible_idx or (data.add_total_row and row_idx == last_row_index)
):
# NOTE: for backwards compatibility. remove in v17.
if not (ignore_visible_idx or row_idx in visible_idx):
continue
row_data = []
row_is_dict = isinstance(row, dict)
indent = 0
if row_is_dict and handle_indentation:
indent = row.get("indent") or 0
if indent:
indent = cint(indent)
if build_styles:
metadata.row_map[excel_row_idx] = row
excel_row_idx += 1
for col_idx, column in enumerate(data.columns):
if column.get("hidden") and not include_hidden_columns:
continue
@ -595,14 +651,31 @@ def build_xlsx_data(
if not isinstance(cell_value, EXCEL_TYPES):
cell_value = cstr(cell_value)
if row_is_dict and include_indentation and "indent" in row and col_idx == 0:
cell_value = (" " * cint(row["indent"])) + cstr(cell_value)
if handle_indentation and indent and col_idx == 0:
cell_value = (" " * indent) + cstr(cell_value)
row_data.append(cell_value)
result.append(row_data)
return result, column_widths, header_index
return result, column_widths, get_xlsx_styles(metadata, data.report_name) if build_styles else None
def get_xlsx_styles(metadata: XLSXMetadata, report_name: str | None = None) -> dict | None:
"""
Returns styles for XLSX export.
If report_name is provided, it tries to fetch styles defined in the report's module.
"""
styles = None
if report_name:
report = frappe.get_doc("Report", report_name)
styles = report.get_xlsx_styles_from_module(metadata)
if not styles:
styles = XLSXStyleBuilder(metadata).result
return styles
def add_total_row(
@ -611,21 +684,10 @@ def add_total_row(
meta=None,
is_tree=False,
parent_field=None,
visible_idx: list[int] | None = None,
ignore_visible_idx: bool = False,
) -> list[dict | list[Any]]:
total_row = [""] * len(columns)
has_percent = []
if not visible_idx or len(visible_idx) == len(result):
# It's not possible to have same length and different content.
ignore_visible_idx = True
visible_idx_set = set()
else:
# Note: converted for faster lookups
ignore_visible_idx = False
visible_idx_set = set(visible_idx)
# all rows are dict or list/tuple, we can check the first row to decide the type
is_row_dict = isinstance(result[0], dict) if result else False
@ -652,11 +714,7 @@ def add_total_row(
fieldname = col.get("fieldname")
options = col.get("options")
for row_idx, row in enumerate(result):
# Skip rows not in visible_idx when filtering is enabled
if not ignore_visible_idx and row_idx not in visible_idx_set:
continue
for row in result:
# Skip if column index is out of bounds for list/tuple rows
if not is_row_dict and col_idx >= len(row):
continue
@ -683,9 +741,7 @@ def add_total_row(
total_row[col_idx] = result[0].get(fieldname) if is_row_dict else result[0][col_idx]
for col_idx in has_percent:
total_row[col_idx] = flt(total_row[col_idx]) / (
len(result) if ignore_visible_idx else len(visible_idx)
)
total_row[col_idx] = flt(total_row[col_idx]) / len(result)
first_col_fieldtype = None
if isinstance(columns[0], str):

View file

@ -415,13 +415,14 @@ def run_report_view_export_job(user_email, form_params, csv_params):
def _export_query(form_params, csv_params, populate_response=True):
from frappe.desk.utils import get_csv_bytes, provide_binary_file
from frappe.utils.xlsxutils import handle_html, make_xlsx
from frappe.utils.xlsxutils import get_default_xlsx_styles, handle_html, make_xlsx
doctype = form_params.pop("doctype")
owner_field = f"`tab{doctype}`.`owner`"
if isinstance(form_params["fields"], list):
form_params["fields"].append("owner")
form_params["fields"].append(owner_field)
elif isinstance(form_params["fields"], tuple):
form_params["fields"] = form_params["fields"] + ("owner",)
form_params["fields"] = form_params["fields"] + (owner_field,)
file_format_type = form_params.pop("file_format_type")
title = form_params.pop("title", doctype)
add_totals_row = 1 if form_params.pop("add_totals_row", None) == "1" else None
@ -456,7 +457,8 @@ def _export_query(form_params, csv_params, populate_response=True):
fields_info = get_field_info(db_query.fields, doctype)
labels = [info["label"] for info in fields_info]
data = [[_("Sr"), *labels]]
sr_label = _("Sr")
data = [[sr_label, *labels]]
processed_data = []
if frappe.local.lang == "en" or not translate_values:
@ -481,7 +483,21 @@ def _export_query(form_params, csv_params, populate_response=True):
)
elif file_format_type == "Excel":
file_extension = "xlsx"
content = make_xlsx(data, doctype).getvalue()
styles = get_default_xlsx_styles(
columns=[
{
"fieldname": "sr",
"label": sr_label,
"fieldtype": "Int",
},
*fields_info,
],
data=data[1:], # exclude header row
has_total_row=bool(add_totals_row),
)
content = make_xlsx(data, doctype, styles=styles).getvalue()
if not populate_response:
return title, file_extension, content
@ -509,66 +525,91 @@ def append_totals_row(data):
return data
def get_field_info(fields, doctype):
"""Get column names, labels, field types, and translatable properties based on column names."""
def get_field_info(fields, parent_doctype):
"""
Get field's
- fieldname
- label
- fieldtype
- translatable
- options (if any)
:param fields: List of field names (can include child table fields and aggregate functions).
:param parent_doctype: The main doctype from which the report is generated.
"""
from frappe.model.meta import get_default_df
field_info = []
for key in fields:
for field in fields:
df = None
doctype = None
try:
parenttype, fieldname = parse_field(key)
doctype, fieldname = parse_field(field)
except ValueError:
# handles aggregate functions
parenttype = doctype
fieldname = key.split("(", 1)[0]
fieldname = fieldname[0].upper() + fieldname[1:]
if isinstance(field, dict):
# Eg: {"COUNT": "name", "as": "count_name"} -> "COUNT"
fieldname = next(f for f in field if f != "as")
else:
# Eg: "count(name)" -> "count"
fieldname = field.split("(", 1)[0]
fieldname = fieldname.capitalize()
parenttype = parenttype or doctype
doctype = doctype or parent_doctype
options = None
if parenttype == doctype and fieldname == "name":
name = fieldname
# Special-case the primary `name` column on the parent doctype
if doctype == parent_doctype and fieldname == "name":
label = _("ID", context="Label of name column in report")
fieldtype = "Data"
translatable = True
else:
df = frappe.get_meta(parenttype).get_field(fieldname)
if df and df.fieldtype in ("Data", "Select", "Small Text", "Text"):
name = df.name
label = _(df.label)
meta = frappe.get_meta(doctype)
meta_df = meta.get_field(fieldname)
df = meta_df or get_default_df(fieldname)
if df:
fieldname = df.fieldname
label = _(df.label or "") if meta_df else meta.get_label(fieldname)
fieldtype = df.fieldtype
translatable = getattr(df, "translatable", False)
elif df and df.fieldtype == "Link" and frappe.get_meta(df.options).translated_doctype:
name = df.name
label = _(df.label)
fieldtype = df.fieldtype
translatable = True
translatable = df.translatable or False
options = df.options
if df.fieldtype == "Link" and options and frappe.get_meta(options).translated_doctype:
translatable = True
else:
name = fieldname
label = _(df.label) if df else _(fieldname)
label = _(frappe.unscrub(fieldname))
fieldtype = "Data"
translatable = False
if parenttype != doctype:
if doctype != parent_doctype:
# If the column is from a child table, append the child doctype.
# For example, "Item Code (Sales Invoice Item)".
label += f" ({_(parenttype)})"
label += f" ({_(doctype)})"
field_info.append(
{"name": name, "label": label, "fieldtype": fieldtype, "translatable": translatable}
{
"fieldname": fieldname,
"label": label,
"fieldtype": fieldtype,
"translatable": translatable,
"options": options,
}
)
return field_info
def handle_duration_fieldtype_values(doctype, data, fields):
def handle_duration_fieldtype_values(parent_doctype, data, fields):
for field in fields:
try:
parenttype, fieldname = parse_field(field)
doctype, fieldname = parse_field(field)
except ValueError:
continue
parenttype = parenttype or doctype
df = frappe.get_meta(parenttype).get_field(fieldname)
doctype = doctype or parent_doctype
df = frappe.get_meta(doctype).get_field(fieldname)
if df and df.fieldtype == "Duration":
index = fields.index(field) + 1
@ -580,8 +621,18 @@ def handle_duration_fieldtype_values(doctype, data, fields):
return data
def parse_field(field: str) -> tuple[str | None, str]:
"""Parse a field into parenttype and fieldname."""
def parse_field(field: str | dict) -> tuple[str | None, str]:
"""
Parse a field into doctype and fieldname.
:param field: The field string to parse.
:returns: A tuple of (doctype, fieldname). Doctype is None if not specified.
:raises ValueError: If the field contains aggregate functions.
"""
if isinstance(field, dict): # for aggregates via qb
raise ValueError
key = field.split(" as ", 1)[0]
if key.startswith(("count(", "sum(", "avg(")):

View file

@ -168,7 +168,9 @@ def search_widget(
}
search_fields = ["name"]
if meta.title_field:
search_fields.append(meta.title_field)
is_virtual_field = getattr(meta.get_field(meta.title_field), "is_virtual", False)
if not is_virtual_field:
search_fields.append(meta.title_field)
if meta.search_fields:
search_fields.extend(meta.get_search_fields())
@ -348,7 +350,12 @@ def build_for_autosuggest(res: list[tuple], doctype: str) -> list[LinkSearchResu
for item in res:
item = list(item)
if len(item) == 1:
item = [item[0], item[0]]
title_field = meta.title_field
docfield = meta.get_field(title_field)
if docfield and docfield.is_virtual:
doc = frappe.get_doc(meta.name, item[0])
title_value = doc.get_virtual_field_value(docfield)
item = [item[0], title_value or item[0]]
label = _(item[1]) if meta.translated_doctype else item[1]
item[1] = item[0]

View file

@ -159,9 +159,9 @@ class AutoEmailReport(Document):
)
# add serial numbers
columns.insert(0, frappe._dict(fieldname="idx", label="", width="30px"))
columns.insert(0, frappe._dict(fieldname="sr", label=_("Sr"), fieldtype="Int", width="30px"))
for i in range(len(data)):
data[i]["idx"] = i + 1
data[i]["sr"] = i + 1
if len(data) == 0 and self.send_if_data:
return None
@ -172,21 +172,23 @@ class AutoEmailReport(Document):
return self.get_html_table(columns, data)
elif self.format in ("XLSX", "CSV"):
report_data = frappe._dict()
report_data["columns"] = columns
report_data["result"] = data
report_data = frappe._dict(
{
"report_name": self.report,
"filters": self.filters,
"columns": columns,
"result": data,
}
)
is_excel = self.format == "XLSX"
xlsx_data, column_widths, header_index = build_xlsx_data(
report_data, [], 1, ignore_visible_idx=True
xlsx_data, column_widths, styles = build_xlsx_data(
report_data, [], 1, ignore_visible_idx=True, build_styles=is_excel
)
if self.format == "XLSX":
if is_excel:
xlsx_file = make_xlsx(
xlsx_data,
"Auto Email Report",
column_widths=column_widths,
header_index=header_index,
has_filters=bool(self.filters),
xlsx_data, "Auto Email Report", column_widths=column_widths, styles=styles
)
return xlsx_file.getvalue()
@ -359,8 +361,8 @@ def process_auto_email_report(report):
def send_monthly():
"""Check reports to be sent monthly"""
for report in frappe.get_all("Auto Email Report", {"enabled": 1, "frequency": "Monthly"}):
frappe.get_doc("Auto Email Report", report.name).send()
for report in frappe.get_docs("Auto Email Report", filters={"enabled": 1, "frequency": "Monthly"}):
report.send()
def make_links(columns, data):

View file

@ -203,7 +203,7 @@
"depends_on": "eval:doc.condition_type===\"Python\"",
"fieldname": "html_7",
"fieldtype": "HTML",
"options": "<p><strong>Condition Examples:</strong></p>\n<pre><code class=\"language-python\">doc.status==\"Open\"<br>doc.due_date==nowdate()<br>doc.total &gt; 40000\n</code></pre>\n"
"options": "<p><strong>{{ __(\"Condition Examples\") }}:</strong></p>\n<pre><code class=\"language-python\">doc.status==\"Open\"<br>doc.due_date==nowdate()<br>doc.total &gt; 40000\n</code></pre>\n"
},
{
"collapsible": 1,
@ -363,7 +363,7 @@
"icon": "fa fa-envelope",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-02-19 18:07:15.888314",
"modified": "2026-04-22 15:37:19.355265",
"modified_by": "Administrator",
"module": "Email",
"name": "Notification",

View file

@ -183,6 +183,8 @@ def get_queue():
def retry_sending_emails():
from frappe.email.doctype.email_queue.email_queue import get_email_retry_limit
emails_in_sending = frappe.get_all(
"Email Queue", filters={"status": "Sending"}, fields=["name", "modified"]
)
@ -193,7 +195,7 @@ def retry_sending_emails():
sent_to_atleast_one_recipient = any(
rec.recipient for rec in email_queue.recipients if rec.is_mail_sent()
)
if email_queue.retry < cint(frappe.db.get_system_setting("email_retry_limit")) or 3:
if email_queue.retry < get_email_retry_limit():
update_fields.update(
{
"status": "Partially Sent" if sent_to_atleast_one_recipient else "Not Sent",

View file

@ -93,7 +93,8 @@ class SMTPServer:
frappe.msgprint(res[1], raise_exception=frappe.OutgoingEmailError)
# Re-issue EHLO after AUTH to refresh server capabilities
_session.ehlo()
if not frappe.conf.smtp_no_ehlo_after_auth:
_session.ehlo()
self._session = _session
self._enqueue_connection_closure()

View file

@ -86,6 +86,10 @@ class TooManyRequestsError(Exception):
http_status_code = 429
class ServiceUnavailableError(Exception):
http_status_code = 503
class ImproperDBConfigurationError(Exception):
"""
Used when frappe detects that database or tables are not properly

View file

@ -343,12 +343,16 @@ class FrappeClient:
)
return self.post_process(res)
def post_api(self, method, params=None):
if params is None:
params = {}
res = self.session.post(
f"{self.url}/api/method/{method}", params=params, verify=self.verify, headers=self.headers
)
def post_api(self, method, params=None, json=None):
url = f"{self.url}/api/method/{method}"
if json is not None:
headers = {**self.headers, "content-type": "application/json"}
res = self.session.post(url, json=json, verify=self.verify, headers=headers)
else:
res = self.session.post(
url, data=self.preprocess(params or {}), verify=self.verify, headers=self.headers
)
return self.post_process(res)
def get_request(self, params):

View file

@ -232,6 +232,10 @@ scheduler_events = {
"0 */3 * * *": [
"frappe.search.sqlite_search.build_index_if_not_exists",
],
# Daily at 6:00 AM.
"0 6 * * *": [
"frappe.core.doctype.security_settings.security_settings_alert.check_security_txt_expiry",
],
},
"all": [
"frappe.email.queue.flush",
@ -526,6 +530,7 @@ persistent_cache_keys = [
"monitor-transactions",
"rate-limit-counter-*",
"rl:*",
"concurrency:*",
]
user_invitation = {

View file

@ -77,7 +77,7 @@
{
"fieldname": "html_condition",
"fieldtype": "HTML",
"options": "<p><strong>Condition Examples:</strong></p>\n<pre>doc.status==\"Open\"<br>doc.due_date==nowdate()<br>doc.total &gt; 40000\n</pre>"
"options": "<p><strong>{{ __(\"Condition Examples\") }}:</strong></p>\n<pre>doc.status==\"Open\"<br>doc.due_date==nowdate()<br>doc.total &gt; 40000\n</pre>"
},
{
"fieldname": "sb_webhook",
@ -189,7 +189,7 @@
"link_fieldname": "webhook"
}
],
"modified": "2025-07-18 18:22:38.276809",
"modified": "2026-04-22 15:33:54.888415",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Webhook",
@ -209,8 +209,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -541,6 +541,15 @@ class BaseDocument:
eval_locals={"doc": self},
)
def get_virtual_field_value(self, df):
fieldname = df.fieldname
if (prop := getattr(type(self), fieldname, None)) and is_a_property(prop):
return getattr(self, fieldname)
elif options := getattr(df, "options", None):
return self._evaluate_virtual_field_options(options)
def get_valid_dict(
self, sanitize=True, convert_dates_to_str=False, ignore_nulls=False, ignore_virtual=False
) -> _dict:
@ -563,12 +572,7 @@ class BaseDocument:
if is_virtual_field:
if ignore_virtual or fieldname not in self.permitted_fieldnames:
continue
if (prop := getattr(type(self), fieldname, None)) and is_a_property(prop):
value = getattr(self, fieldname)
elif options := getattr(df, "options", None):
value = self._evaluate_virtual_field_options(options)
value = self.get_virtual_field_value(df)
fieldtype = df.fieldtype
if isinstance(value, list) and fieldtype not in table_fields:

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