Merge branch 'develop' into 38159-allow-bulk-edit-in-child-tables
This commit is contained in:
commit
aea2f722e7
181 changed files with 169592 additions and 170034 deletions
2
.github/stale.yml
vendored
2
.github/stale.yml
vendored
|
|
@ -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
59
.github/workflows/backport_reminder.yml
vendored
Normal 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!`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
2
.github/workflows/server-tests.yml
vendored
2
.github/workflows/server-tests.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
4
.github/workflows/ui-tests.yml
vendored
4
.github/workflows/ui-tests.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
125
frappe/concurrency_limiter.py
Normal file
125
frappe/concurrency_limiter.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
0
frappe/core/doctype/security_settings/__init__.py
Normal file
0
frappe/core/doctype/security_settings/__init__.py
Normal file
20
frappe/core/doctype/security_settings/security_settings.js
Normal file
20
frappe/core/doctype/security_settings/security_settings.js
Normal 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>`
|
||||
);
|
||||
},
|
||||
});
|
||||
82
frappe/core/doctype/security_settings/security_settings.json
Normal file
82
frappe/core/doctype/security_settings/security_settings.json
Normal 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
|
||||
}
|
||||
122
frappe/core/doctype/security_settings/security_settings.py
Normal file
122
frappe/core/doctype/security_settings/security_settings.py
Normal 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"))
|
||||
|
|
@ -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}"
|
||||
272
frappe/core/doctype/security_settings/test_security_settings.py
Normal file
272
frappe/core/doctype/security_settings/test_security_settings.py
Normal 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
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() &&
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(")):
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 > 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 > 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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 > 40000\n</pre>"
|
||||
"options": "<p><strong>{{ __(\"Condition Examples\") }}:</strong></p>\n<pre>doc.status==\"Open\"<br>doc.due_date==nowdate()<br>doc.total > 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
44806
frappe/locale/af.po
44806
frappe/locale/af.po
File diff suppressed because it is too large
Load diff
2253
frappe/locale/ar.po
2253
frappe/locale/ar.po
File diff suppressed because it is too large
Load diff
1535
frappe/locale/bs.po
1535
frappe/locale/bs.po
File diff suppressed because it is too large
Load diff
13274
frappe/locale/cs.po
13274
frappe/locale/cs.po
File diff suppressed because it is too large
Load diff
12661
frappe/locale/da.po
12661
frappe/locale/da.po
File diff suppressed because it is too large
Load diff
1535
frappe/locale/de.po
1535
frappe/locale/de.po
File diff suppressed because it is too large
Load diff
1537
frappe/locale/eo.po
1537
frappe/locale/eo.po
File diff suppressed because it is too large
Load diff
2281
frappe/locale/es.po
2281
frappe/locale/es.po
File diff suppressed because it is too large
Load diff
5142
frappe/locale/fa.po
5142
frappe/locale/fa.po
File diff suppressed because it is too large
Load diff
44750
frappe/locale/fi.po
44750
frappe/locale/fi.po
File diff suppressed because it is too large
Load diff
9596
frappe/locale/fr.po
9596
frappe/locale/fr.po
File diff suppressed because it is too large
Load diff
1535
frappe/locale/hr.po
1535
frappe/locale/hr.po
File diff suppressed because it is too large
Load diff
1665
frappe/locale/hu.po
1665
frappe/locale/hu.po
File diff suppressed because it is too large
Load diff
9540
frappe/locale/id.po
9540
frappe/locale/id.po
File diff suppressed because it is too large
Load diff
11200
frappe/locale/it.po
11200
frappe/locale/it.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
11351
frappe/locale/mn.po
11351
frappe/locale/mn.po
File diff suppressed because it is too large
Load diff
13228
frappe/locale/my.po
13228
frappe/locale/my.po
File diff suppressed because it is too large
Load diff
2222
frappe/locale/nb.po
2222
frappe/locale/nb.po
File diff suppressed because it is too large
Load diff
2876
frappe/locale/nl.po
2876
frappe/locale/nl.po
File diff suppressed because it is too large
Load diff
12291
frappe/locale/pl.po
12291
frappe/locale/pl.po
File diff suppressed because it is too large
Load diff
12939
frappe/locale/pt.po
12939
frappe/locale/pt.po
File diff suppressed because it is too large
Load diff
13133
frappe/locale/pt_BR.po
13133
frappe/locale/pt_BR.po
File diff suppressed because it is too large
Load diff
1543
frappe/locale/ru.po
1543
frappe/locale/ru.po
File diff suppressed because it is too large
Load diff
11939
frappe/locale/sl.po
11939
frappe/locale/sl.po
File diff suppressed because it is too large
Load diff
1535
frappe/locale/sr.po
1535
frappe/locale/sr.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1535
frappe/locale/sv.po
1535
frappe/locale/sv.po
File diff suppressed because it is too large
Load diff
18873
frappe/locale/ta.po
18873
frappe/locale/ta.po
File diff suppressed because it is too large
Load diff
6969
frappe/locale/th.po
6969
frappe/locale/th.po
File diff suppressed because it is too large
Load diff
5617
frappe/locale/tr.po
5617
frappe/locale/tr.po
File diff suppressed because it is too large
Load diff
4018
frappe/locale/vi.po
4018
frappe/locale/vi.po
File diff suppressed because it is too large
Load diff
2180
frappe/locale/zh.po
2180
frappe/locale/zh.po
File diff suppressed because it is too large
Load diff
46698
frappe/locale/zh_TW.po
46698
frappe/locale/zh_TW.po
File diff suppressed because it is too large
Load diff
|
|
@ -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
Loading…
Add table
Reference in a new issue