Merge remote-tracking branch 'upstream/develop' into seitime

This commit is contained in:
Vassili Minaev 2026-05-05 18:51:04 -06:00
commit 6fbb6547bc
317 changed files with 190129 additions and 188119 deletions

119
.github/helper/ci.py vendored Normal file
View file

@ -0,0 +1,119 @@
"""
Script to run Python tests while capturing accurte coverage.
Enabling coverage after `frappe` is imported leaves out a lot of lines that are imported by
default.
This is essentially a copy of `frappe/coverage.py` BUT also triggers test runner with desired
configuration.
"""
import json
import sys
import os
from pathlib import Path
from coverage import Coverage
STANDARD_INCLUSIONS = ["*.py"]
STANDARD_EXCLUSIONS = [
"*.js",
"*.xml",
"*.pyc",
"*.css",
"*.less",
"*.scss",
"*.vue",
"*.html",
"*/test_*/*",
"*/node_modules/*",
"*/doctype/*/*_dashboard.py",
"*/patches/*",
".github/*",
]
# tested via commands' test suite
TESTED_VIA_CLI = [
"*/frappe/installer.py",
"*/frappe/utils/install.py",
"*/frappe/utils/scheduler.py",
"*/frappe/utils/doctor.py",
"*/frappe/build.py",
"*/frappe/database/__init__.py",
"*/frappe/database/db_manager.py",
"*/frappe/database/**/setup_db.py",
]
FRAPPE_EXCLUSIONS = [
"*/tests/*",
"*/commands/*",
"*/frappe/change_log/*",
"*/frappe/exceptions*",
"*/frappe/desk/page/setup_wizard/setup_wizard.py",
"*/frappe/coverage.py",
"*frappe/setup.py",
"*/doctype/*/*_dashboard.py",
"*/patches/*",
"*/frappe/database/postgres/*",
"*/.github/helper/ci.py",
"*/frappe/database/sqlite/*",
*TESTED_VIA_CLI,
]
def get_bench_path():
"""Get the path to the bench directory."""
return Path(__file__).resolve().parents[4]
class CodeCoverage:
"""
Context manager for handling code coverage.
This class sets up code coverage measurement for a specific app,
applying the appropriate inclusion and exclusion patterns.
"""
def __init__(self, with_coverage, app, outfile="coverage.xml"):
self.with_coverage = with_coverage
self.app = app or "frappe"
self.outfile = outfile
def __enter__(self):
if self.with_coverage:
# Generate coverage report only for app that is being tested
source_path = os.path.join(get_bench_path(), "apps", self.app)
omit = STANDARD_EXCLUSIONS[:]
if self.app == "frappe":
omit.extend(FRAPPE_EXCLUSIONS)
self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS)
assert "frappe" not in sys.modules, "frappe already imported, coverage will be inaccurate"
self.coverage.start()
return self
def __exit__(self, exc_type, exc_value, traceback):
if self.with_coverage:
self.coverage.stop()
self.coverage.save()
self.coverage.xml_report(outfile=self.outfile)
print("Saved Coverage")
if __name__ == "__main__":
app = "frappe"
site = os.environ.get("SITE") or "test_site"
with_coverage = json.loads(os.environ.get("CAPTURE_COVERAGE", "true").lower())
# Parse build information from environment variables
build_number = int(os.environ.get("BUILD_NUMBER"))
total_builds = int(os.environ.get("TOTAL_BUILDS"))
# Run tests with code coverage
with CodeCoverage(with_coverage=with_coverage, app=app):
from frappe.parallel_test_runner import ParallelTestRunner
runner = ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds)
runner.setup_and_run()

2
.github/stale.yml vendored
View file

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

View file

@ -29,17 +29,10 @@ on:
enable-coverage:
required: false
type: boolean
default: false
default: true
jobs:
unit-test:
name: Unit
runs-on: ubuntu-latest
steps:
- id: placeholder
run: |
echo "Evolution towards a set of (fast) unit tests which run without a DB connection is being planned"
gen-idx-integration:
name: Gen Integration Test Matrix
runs-on: ubuntu-latest
@ -100,7 +93,6 @@ jobs:
python-version: ${{ inputs.python-version }}
node-version: ${{ inputs.node-version }}
disable-socketio: true
enable-coverage: ${{ inputs.enable-coverage }}
db-root-password: ${{ env.DB_ROOT_PASSWORD }}
db: ${{ matrix.db }}
env:
@ -108,43 +100,22 @@ jobs:
- name: Run Tests
run: |
bench --site test_site \
run-parallel-tests \
--app "${{ github.event.repository.name }}" \
--total-builds ${{ inputs.parallel-runs }} \
--build-number ${{ matrix.index }} 2> >(tee -a stderr.log >&2)
cd sites && ../env/bin/python3 ../apps/frappe/.github/helper/ci.py
# Process warnings and create annotations
if [ -s stderr.log ] && [ "$DB" == "mariadb" ]; then
echo "Processing deprecation warnings..."
grep -E "DeprecationWarning" stderr.log | sort -u | while read -r warning; do
# Extract file path, line number, and warning type
file_info=$(echo "$warning" | grep -oP '^.*?:\d+:')
file_path=$(echo "$file_info" | cut -d':' -f1)
line_number=$(echo "$file_info" | cut -d':' -f2)
warning_type=$(echo "$warning" | grep -oP '\w+Warning')
# Extract the actual warning message
message=$(echo "$warning" | sed -E "s/^.*$warning_type: //")
# Create the annotation
echo "::warning file=${file_path},line=${line_number}::${warning_type}: ${message}"
done
else
echo "No deprecation warnings found."
fi
env:
DB: ${{ matrix.db }}
# consumed by bench run-parallel-tests
CAPTURE_COVERAGE: ${{ inputs.enable-coverage }}
BUILD_NUMBER: ${{ matrix.index }}
TOTAL_BUILDS: ${{ inputs.parallel-runs }}
FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN || '' }}
- name: Upload coverage data
uses: actions/upload-artifact@v7
if: inputs.enable-coverage
if: ${{ inputs.fake-success == false && inputs.enable-coverage }}
with:
name: coverage-${{ matrix.db }}-${{ matrix.index }}
path: ./sites/*-coverage*.xml
path: ./sites/*coverage*.xml
- name: Setup tmate session
uses: mxschmitt/action-tmate@v3
@ -164,15 +135,14 @@ jobs:
# TIP: Use these for checks, e.g. Server / Tests / Success
success:
name: Success
needs: [unit-test, integration-test]
needs: [integration-test]
if: always()
runs-on: ubuntu-latest
steps:
- name: Unit '${{ needs.unit-test.result }}' / Integration '${{ needs.integration-test.result }}'
- name: Integration '${{ needs.integration-test.result }}'
shell: python
run: |
stati = [
'${{ needs.unit-test.result }}',
'${{ needs.integration-test.result }}',
]

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

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

View file

@ -95,7 +95,7 @@ jobs:
run: |
pip install pip-audit
cd ${GITHUB_WORKSPACE}
pip-audit --desc on --ignore-vuln PYSEC-2023-312 .
pip-audit --desc on --ignore-vuln PYSEC-2023-312 --ignore-vuln CVE-2026-4539 .
precommit:
name: 'Pre-Commit'

View file

@ -5,7 +5,6 @@ on:
types: [opened, reopened, synchronize, ready_for_review]
branches:
- develop
- "version-[0-9][0-9]-hotfix"
paths:
- "**/*.po"
@ -21,7 +20,7 @@ jobs:
permissions:
contents: read
issues: write
pull-requests: read
pull-requests: write
steps:
- name: Checkout

View file

@ -1,8 +1,6 @@
name: Server
on:
repository_dispatch:
types: [frappe-framework-change]
pull_request:
workflow_dispatch:
schedule:
@ -18,14 +16,9 @@ permissions:
contents: read
jobs:
typecheck:
name: Types
uses: ./.github/workflows/_base-type-check.yml
checkrun:
name: Plan Tests
runs-on: ubuntu-latest
needs: typecheck
outputs:
build: ${{ steps.check-build.outputs.build }}
run_postgres: ${{ steps.check-build.outputs.run_postgres }}
@ -48,7 +41,6 @@ jobs:
enable-postgres: ${{ needs.checkrun.outputs.run_postgres == 'true' }} # This enables PostgreSQL to run tests
enable-sqlite: false # This will test against both MariaDB and SQLite if enabled
parallel-runs: 2
enable-coverage: ${{ github.event_name != 'pull_request' }}
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
needs: checkrun
secrets: inherit
@ -67,37 +59,16 @@ jobs:
name: Coverage Wrap Up
needs: [test, checkrun]
runs-on: ubuntu-latest
if: ${{ github.event_name != 'pull_request' }}
steps:
- name: Clone
uses: actions/checkout@v6
- 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 }}
fail_ci_if_error: true
verbose: true
flags: server
dispatch:
name: Downstream
runs-on: "ubuntu-latest"
needs: [test, migrate]
if: ${{ contains( github.event.pull_request.labels.*.name, 'trigger-downstream-ci') }}
strategy:
matrix:
repo:
- frappe/erpnext
- frappe/lending
- frappe/hrms
steps:
- name: Dispatch Downstream CI (if supported)
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.CI_PAT }}
repository: ${{ matrix.repo }}
event-type: frappe-framework-change
client-payload: '{"frappe_sha": "${{ github.sha }}"}'

View file

@ -2,8 +2,6 @@ name: UI
on:
pull_request:
repository_dispatch:
types: [frappe-framework-change]
workflow_dispatch:
schedule:
# Run everday at midnight UTC / 5:30 IST
@ -44,35 +42,6 @@ jobs:
uses: ./.github/workflows/_base-ui-tests.yml
with:
parallel-runs: 3
enable-coverage: ${{ github.event_name != 'pull_request' }}
enable-coverage: false
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
needs: checkrun
coverage:
name: Coverage Wrap Up
needs: [test, checkrun]
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v8.0.1
- name: Upload python coverage data
uses: codecov/codecov-action@v5
with:
name: UIBackend
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
verbose: true
exclude: coverage-js*
flags: server-ui
- name: Upload JS coverage data
uses: codecov/codecov-action@v5
with:
name: Cypress
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
exclude: coverage-py*
verbose: true
flags: ui-tests

View file

@ -29,10 +29,6 @@ flags:
paths:
- "**/*.py"
carryforward: true
ui-tests:
paths:
- "**/*.js"
carryforward: true
server-ui:
paths:
- "**/*.py"

View file

@ -111,4 +111,76 @@ context("Grid", () => {
cy.get("@table-form").find(".grid-footer-toolbar").click();
});
});
it("shows edit button only when child table allow_bulk_edit is enabled", () => {
cy.visit("/desk/contact/Test Contact");
cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table");
cy.window()
.its("cur_frm")
.then((frm) => {
const grid = frm.get_field("phone_nos").grid;
grid.meta.allow_bulk_edit = false;
grid.refresh_edit_rows_button();
});
cy.get("@table").find('.grid-row[data-idx="1"] .grid-row-check').click({ force: true });
cy.get("@table").find(".grid-edit-rows").should("have.class", "hidden");
cy.window()
.its("cur_frm")
.then((frm) => {
const grid = frm.get_field("phone_nos").grid;
grid.meta.allow_bulk_edit = true;
grid.refresh_edit_rows_button();
});
cy.get("@table").find(".grid-edit-rows").should("not.have.class", "hidden");
});
it("bulk edit updates only selected child rows", () => {
const updated_phone = `99999${Date.now().toString().slice(-5)}`;
cy.visit("/desk/contact/Test Contact");
cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table");
cy.window()
.its("cur_frm")
.then((frm) => {
const grid = frm.get_field("phone_nos").grid;
grid.meta.allow_bulk_edit = true;
grid.refresh_edit_rows_button();
expect(frm.doc.phone_nos.length).to.be.greaterThan(1);
const phone_df = grid.docfields.find((df) => df.fieldname === "phone");
expect(phone_df).to.exist;
cy.wrap(phone_df.label).as("phoneFieldLabel");
cy.wrap(frm.doc.phone_nos[1].phone || "").as("secondRowPhoneBefore");
});
cy.get("@table").find('.grid-row[data-idx="1"] .grid-row-check').click({ force: true });
cy.get("@table").find(".grid-edit-rows").click({ force: true });
cy.window()
.its("cur_dialog")
.then((dialog) => {
cy.get("@phoneFieldLabel").then((phoneFieldLabel) => {
return dialog
.set_value("field", phoneFieldLabel)
.then(() => dialog.set_value("value", updated_phone))
.then(() => {
dialog.get_primary_btn().click();
});
});
});
cy.window().its("cur_frm.doc.phone_nos.0.phone").should("eq", updated_phone);
cy.window()
.its("cur_frm")
.then((frm) => {
cy.get("@secondRowPhoneBefore").then((secondRowPhoneBefore) => {
expect(frm.doc.phone_nos[1].phone || "").to.equal(secondRowPhoneBefore);
});
});
});
});

View file

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

View file

@ -97,7 +97,8 @@ def get_values_for_link_and_dynamic_link_fields(doc_dict):
doctype = field.options if field.fieldtype == "Link" else doc_dict.get(field.options)
link_doc = frappe.get_doc(doctype, doc_fieldvalue)
link_doc = frappe.get_doc(doctype, doc_fieldvalue, check_permission="read")
link_doc.apply_fieldlevel_read_permissions()
doc_dict.update({field.fieldname: link_doc})

View file

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

View file

@ -53,6 +53,8 @@ def get_apps():
def get_route(app_name):
if app_name not in frappe.get_installed_apps():
return "/apps" # Invalid defaults
apps = frappe.get_hooks("add_to_apps_screen", app_name=app_name)
app = next((app for app in apps if app.get("name") == app_name), None)
return app.get("route") if app and app.get("route") else "/apps"
@ -89,6 +91,9 @@ def get_default_path():
@frappe.whitelist()
def set_app_as_default(app_name: str):
if app_name not in frappe.get_installed_apps():
frappe.throw(_("App {} is not installed").format(frappe.bold(app_name)))
if frappe.db.get_value("User", frappe.session.user, "default_app") == app_name:
frappe.db.set_value("User", frappe.session.user, "default_app", "")
else:

View file

@ -155,7 +155,9 @@ class LoginManager:
self.authenticate(user=user, pwd=pwd)
if self.force_user_to_reset_password():
doc = frappe.get_doc("User", self.user)
frappe.local.response["redirect_to"] = doc.reset_password(send_email=False, password_expired=True)
frappe.local.response["redirect_to"] = doc._reset_password(
send_email=False, password_expired=True
)
frappe.local.response["message"] = "Password Reset"
return False
@ -724,9 +726,13 @@ def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=Non
raise frappe.AuthenticationError
doctype = frappe_authorization_source or "User"
try:
docname = frappe.db.get_value(
doctype=doctype, filters={"api_key": api_key, "enabled": True}, fieldname=["name"]
)
except Exception:
raise frappe.AuthenticationError
if not docname:
raise frappe.AuthenticationError
form_dict = frappe.local.form_dict

View file

@ -78,6 +78,9 @@ def get_bootinfo():
bootinfo.home_folder = frappe.db.get_value("File", {"is_home_folder": 1})
bootinfo.navbar_settings = get_navbar_settings()
bootinfo.notification_settings = get_notification_settings()
bootinfo.notification_unread_count = frappe.db.count(
"Notification Log", {"read": 0, "for_user": frappe.session.user}
)
bootinfo.onboarding_tours = get_onboarding_ui_tours()
set_time_zone(bootinfo)
@ -342,10 +345,10 @@ def get_user_pages_or_reports(parent, cache=False):
def load_translations(bootinfo):
from frappe.translate import get_messages_for_boot
from frappe.translate import get_translation_version
bootinfo["lang"] = frappe.lang
bootinfo["__messages"] = get_messages_for_boot()
bootinfo["translations_version"] = get_translation_version()
def get_user_info():
@ -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,
@ -590,6 +594,7 @@ def get_sidebar_items(allowed_workspaces):
"filters": item.filters,
"route_options": item.route_options,
"tab": item.navigate_to_tab,
"open_in_new_tab": item.open_in_new_tab,
}
if item.link_type == "Report" and item.link_to and frappe.db.exists("Report", item.link_to):
report_type, ref_doctype = frappe.db.get_value(

View file

@ -114,7 +114,7 @@ def get(
doc.check_permission()
doc.apply_fieldlevel_read_permissions()
return doc.as_dict()
return doc.as_dict(no_nulls=True)
@frappe.whitelist()

View file

@ -144,7 +144,6 @@ def main(
verbosity=2 if testing_module_logger.getEffectiveLevel() < logging.INFO else 1,
tb_locals=testing_module_logger.getEffectiveLevel() <= logging.INFO,
cfg=test_config,
buffer=not debug, # unfortunate as it messes up stdout/stderr output order
)
if doctype or doctype_list_path:
@ -159,11 +158,14 @@ def main(
discover_all_tests(apps, runner)
results = []
global unittest_runner
for app, category, suite in runner.iterRun():
click.secho(
f"\nRunning {suite.countTestCases()} {category} tests for {app}", fg="cyan", bold=True
)
results.append([app, category, runner.run(suite)])
main_runner = unittest_runner if junit_xml_output and unittest_runner else runner
res = main_runner.run(suite)
results.append([app, category, res])
success = all(r.wasSuccessful() for _, _, r in results)
if not success:

View file

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

View file

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

View file

@ -129,7 +129,7 @@ def _accept_invitation(key: str, in_test: bool) -> None:
hashed_key = frappe.utils.sha256_hash(key)
invitation_name = frappe.db.get_value("User Invitation", filters={"key": hashed_key})
if not invitation_name:
frappe.throw(title=_("Error"), msg=_("Invalid key"))
frappe.throw(title=_("Error"), msg=_("Invalid or expired key"))
invitation = frappe.get_doc("User Invitation", invitation_name)
# accept invitation
@ -143,7 +143,7 @@ def _accept_invitation(key: str, in_test: bool) -> None:
# set redirect_to
redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path())
if should_update_password:
redirect_to = f"{user.reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
redirect_to = f"{user._reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
# GET requests do not cause an implicit commit
frappe.db.commit() # nosemgrep

View file

@ -20,6 +20,22 @@ class CommunicationEmailMixin:
parent_doc = get_parent_doc(self)
return parent_doc.owner if parent_doc else None
def get_notification_recipient(self):
"""Get notification recipient of the communication docs parent.
Calls `get_notification_email` on the parent if available; otherwise returns the owner.
This uses `run_method` so hooks can customize recipients per app/site.
"""
parent_doc = get_parent_doc(self)
if not parent_doc:
return None
notification_email = parent_doc.run_method("get_notification_email")
if notification_email:
return notification_email
return parent_doc.owner
def get_all_email_addresses(self, exclude_displayname=False):
"""Get all Email addresses mentioned in the doc along with display name."""
return (
@ -60,7 +76,7 @@ class CommunicationEmailMixin:
"""Build cc list to send an email.
* if email copy is requested by sender, then add sender to CC.
* If this doc is created through inbound mail, then add doc owner to cc list
* If this doc is created through inbound mail, then add the notification recipient to CC
* remove all the thread_notify disabled users.
* Remove standard users from email list
"""
@ -77,9 +93,9 @@ class CommunicationEmailMixin:
cc.append(sender)
if is_inbound_mail_communcation:
# inform parent document owner incase communication is created through inbound mail
if doc_owner := self.get_owner():
cc.append(doc_owner)
# inform the configured notification recipient in case communication is created inbound
if notification_recipient := self.get_notification_recipient():
cc.append(notification_recipient)
cc = set(cc) - {self.sender_mailid}
assignees = set(self.get_assignees()) - {self.sender_mailid}
# Check and remove If user disabled notifications for incoming emails on assigned document.

View file

@ -228,7 +228,7 @@
],
"grid_page_length": 50,
"links": [],
"modified": "2026-03-31 20:37:16.503023",
"modified": "2026-04-09 11:13:35.484376",
"modified_by": "Administrator",
"module": "Core",
"name": "Custom DocPerm",
@ -240,6 +240,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,

View file

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

View file

@ -13,6 +13,7 @@ from frappe.core.doctype.version.version import get_diff
from frappe.model import no_value_fields
from frappe.utils import cint, cstr, duration_to_seconds, flt, update_progress_bar
from frappe.utils.csvutils import get_csv_content_from_google_sheets, read_csv_content
from frappe.utils.data import escape_html
from frappe.utils.xlsxutils import (
read_xls_file_from_attached_file,
read_xlsx_file_from_attached_file,
@ -727,7 +728,9 @@ class Row:
elif df.fieldtype == "Link":
exists = self.link_exists(value, df)
if not exists:
msg = _("Value {0} missing for {1}").format(frappe.bold(value), frappe.bold(df.options))
msg = _("Value {0} missing for {1}").format(
frappe.bold(escape_html(cstr(value))), frappe.bold(df.options)
)
self.warnings.append(
{
"row": self.row_number,
@ -746,7 +749,8 @@ class Row:
"col": col.column_number,
"field": df_as_json(df),
"message": _("Value {0} must in {1} format").format(
frappe.bold(value), frappe.bold(get_user_format(col.date_format))
frappe.bold(escape_html(cstr(value))),
frappe.bold(get_user_format(col.date_format)),
),
}
)
@ -761,7 +765,8 @@ class Row:
"col": col.column_number,
"field": df_as_json(df),
"message": _("Value {0} must in {1} format").format(
frappe.bold(value), frappe.bold(get_user_format(col.date_format))
frappe.bold(escape_html(cstr(value))),
frappe.bold(get_user_format(col.date_format)),
),
}
)
@ -774,7 +779,7 @@ class Row:
"col": col.column_number,
"field": df_as_json(df),
"message": _("Value {0} must be in the valid duration format: d h m s").format(
frappe.bold(value)
frappe.bold(escape_html(cstr(value)))
),
}
)
@ -1045,7 +1050,7 @@ class Column:
]
not_exists = list(set(values) - set(exists))
if not_exists:
missing_values = ", ".join(not_exists)
missing_values = ", ".join(escape_html(v) for v in not_exists)
message = _("The following values do not exist for {0}: {1}")
self.warnings.append(
{
@ -1088,7 +1093,7 @@ class Column:
invalid = values - set(options)
if invalid:
valid_values = ", ".join(frappe.bold(o) for o in options)
invalid_values = ", ".join(frappe.bold(i) for i in invalid)
invalid_values = ", ".join(frappe.bold(escape_html(i)) for i in invalid)
message = _("The following values are invalid: {0}. Values must be one of {1}")
self.warnings.append(
{

View file

@ -41,6 +41,7 @@
"print_hide",
"print_hide_if_no_value",
"report_hide",
"in_import_template",
"column_break_28",
"depends_on",
"collapsible",
@ -640,6 +641,13 @@
"fieldname": "show_description_on_click",
"fieldtype": "Check",
"label": "Show Description on Click"
},
{
"default": "0",
"description": "Enable this option to include the field in the data import template",
"fieldname": "in_import_template",
"fieldtype": "Check",
"label": "Include in Import Template"
}
],
"grid_page_length": 50,
@ -647,7 +655,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-03-10 21:39:58.400441",
"modified": "2026-04-24 13:21:02.590853",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",

View file

@ -83,6 +83,7 @@ class DocField(Document):
ignore_xss_filter: DF.Check
in_filter: DF.Check
in_global_search: DF.Check
in_import_template: DF.Check
in_list_view: DF.Check
in_preview: DF.Check
in_standard_filter: DF.Check

View file

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

View file

@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"autoname": "Prompt",
"creation": "2013-02-18 13:36:19",
@ -34,6 +35,7 @@
"quick_entry",
"grid_page_length",
"rows_threshold_for_grid_search",
"allow_bulk_edit",
"cb01",
"track_changes",
"track_seen",
@ -665,6 +667,7 @@
"label": "Sender Name Field"
},
{
"depends_on": "eval:!doc.istable",
"fieldname": "permissions_tab",
"fieldtype": "Tab Break",
"label": "Permissions"
@ -714,6 +717,14 @@
"fieldname": "recipient_account_field",
"fieldtype": "Data",
"label": "Recipient Account Field"
},
{
"default": "1",
"depends_on": "istable",
"description": "Enable bulk update of this field across child table rows.",
"fieldname": "allow_bulk_edit",
"fieldtype": "Check",
"label": "Allow Bulk Edit"
}
],
"grid_page_length": 50,
@ -792,7 +803,7 @@
"link_fieldname": "document_type"
}
],
"modified": "2025-09-23 06:48:13.555017",
"modified": "2026-04-20 16:06:57.212832",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",
@ -823,6 +834,7 @@
],
"route": "doctype",
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"search_fields": "module",
"show_name_in_global_search": 1,
"sort_field": "creation",

View file

@ -101,6 +101,7 @@ class DocType(Document):
actions: DF.Table[DocTypeAction]
allow_auto_repeat: DF.Check
allow_bulk_edit: DF.Check
allow_copy: DF.Check
allow_events_in_timeline: DF.Check
allow_guest_to_view: DF.Check
@ -550,11 +551,20 @@ class DocType(Document):
and (frappe.conf.developer_mode or frappe.flags.allow_doctype_export)
)
if allow_doctype_export:
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:
self.run_module_method("on_doctype_update")

View file

@ -48,7 +48,13 @@ frappe.ui.form.on("File", {
const field = frm.get_field("attached_to_name");
field.$input_wrapper
.find(".control-value")
.html(`${frappe.utils.get_form_link(frm.doctype, frm.docname, true)}`);
.html(
`${frappe.utils.get_form_link(
frm.doc.attached_to_doctype,
frm.doc.attached_to_name,
true
)}`
);
}
},

View file

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

View file

@ -111,10 +111,21 @@ class File(Document):
self.validate_attachment_limit()
self.set_file_type()
self.validate_file_extension()
self.validate_private_file_access()
if self.is_folder:
return
if self.flags.copy_from_existing_file:
# Preserve the normal insert lifecycle for hooks and validations, but skip
# reprocessing an existing blob that is already referenced by `file_url`.
if not self.file_url:
frappe.throw(
_("File URL is required when copying an existing attachment."),
exc=frappe.MandatoryError,
)
return
if self.is_remote_file:
self.validate_remote_file()
else:
@ -128,6 +139,29 @@ class File(Document):
if not self.is_folder:
self.create_attachment_record()
def create_attachment_copy(
self,
attached_to_doctype: str,
attached_to_name: str,
attached_to_field: str | None = None,
ignore_permissions: bool = False,
):
"""Efficiently copy an attachment from one document to another by reusing `file_url`."""
if self.is_folder:
frappe.throw(_("Cannot attach a folder to a document"))
attachment = frappe.copy_doc(self)
attachment.update(
{
"attached_to_doctype": attached_to_doctype,
"attached_to_name": attached_to_name,
"attached_to_field": attached_to_field,
}
)
attachment.folder = None
attachment.flags.copy_from_existing_file = True
return attachment.insert(ignore_permissions=ignore_permissions)
def validate(self):
if self.is_folder:
return
@ -167,6 +201,36 @@ class File(Document):
except PermissionError:
frappe.throw(_("Only System Managers can make this file public."))
def validate_private_file_access(self):
"""Validate that the user has permission to access an existing private file."""
if not self.file_url:
return
existing_files = frappe.get_all(
"File",
filters={"file_url": self.file_url},
fields=["name", "owner", "is_private"],
limit=1,
)
if not existing_files:
return
existing_file = existing_files[0]
if existing_file.is_private:
user = frappe.session.user
if user == existing_file.owner or user == "Administrator":
return
existing_doc = frappe.get_doc("File", existing_file.name)
if not has_permission(existing_doc, "read", user=user):
frappe.throw(
_("You do not have permission to access this file"),
frappe.PermissionError,
)
def after_rename(self, *args, **kwargs):
for successor in self.get_successors():
setup_folder_path(successor, self.name)

View file

@ -254,6 +254,66 @@ class TestSameContent(IntegrationTestCase):
limit_property.delete()
frappe.clear_cache(doctype="ToDo")
def test_create_attachment_copy(self):
doctype, docname = make_test_doc()
source_file = frappe.get_doc(
{
"doctype": "File",
"file_name": f"existing-file-{frappe.generate_hash(length=8)}.txt",
"content": "Existing attachment content",
}
).insert()
comment_count_before = frappe.db.count(
"Comment", {"reference_doctype": doctype, "reference_name": docname}
)
copied_file = source_file.create_attachment_copy(doctype, docname)
comment_count_after = frappe.db.count(
"Comment", {"reference_doctype": doctype, "reference_name": docname}
)
self.assertNotEqual(copied_file.name, source_file.name)
self.assertEqual(copied_file.file_url, source_file.file_url)
self.assertEqual(copied_file.attached_to_doctype, doctype)
self.assertEqual(copied_file.attached_to_name, docname)
self.assertEqual(
copied_file.folder,
frappe.db.get_value("File", {"is_attachments_folder": 1}),
)
self.assertEqual(comment_count_after, comment_count_before + 1)
def test_create_attachment_copy_respects_attachment_limit(self):
doctype, docname = make_test_doc()
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
limit_property = make_property_setter("ToDo", None, "max_attachments", 1, "int", for_doctype=True)
source_file_1 = frappe.get_doc(
{
"doctype": "File",
"file_name": f"existing-limit-file-{frappe.generate_hash(length=8)}.txt",
"content": "Existing attachment content 1",
}
).insert()
source_file_2 = frappe.get_doc(
{
"doctype": "File",
"file_name": f"existing-limit-file-{frappe.generate_hash(length=8)}.txt",
"content": "Existing attachment content 2",
}
).insert()
try:
source_file_1.create_attachment_copy(doctype, docname)
self.assertRaises(
frappe.exceptions.AttachmentLimitReached,
source_file_2.create_attachment_copy,
doctype,
docname,
)
finally:
limit_property.delete()
frappe.clear_cache(doctype="ToDo")
def test_utf8_bom_content_decoding(self):
utf8_bom_content = test_content1.encode("utf-8-sig")
_file: frappe.Document = frappe.get_doc(

View file

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

View file

@ -93,6 +93,7 @@ class PackageRelease(Document):
def export_package_files(self, package):
# write readme
if package.readme:
with open(frappe.get_site_path("packages", package.package_name, "README.md"), "w") as readme:
readme.write(package.readme)

View file

@ -25,10 +25,9 @@
}
],
"grid_page_length": 50,
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-11-13 16:17:58.536849",
"modified": "2026-04-27 13:30:28.567106",
"modified_by": "Administrator",
"module": "Core",
"name": "Permission Type",

View file

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

View file

@ -55,6 +55,30 @@ frappe.ui.form.on("Report", {
},
};
});
frm.set_query("default_print_format", () => {
return {
filters: {
print_format_for: "Report",
report: frm.doc.name,
print_format_type: "JS",
disabled: 0,
},
};
});
frm.set_query("letter_head", () => {
const filters = {
letter_head_for: "Report",
disabled: 0,
};
if (frm.doc.is_standard === "Yes") {
filters.standard = "Yes";
}
return { filters };
});
},
ref_doctype: function (frm) {

View file

@ -14,6 +14,7 @@
"column_break_4",
"report_type",
"letter_head",
"default_print_format",
"add_total_row",
"disabled",
"prepared_report",
@ -96,10 +97,9 @@
"label": "Disabled"
},
{
"depends_on": "eval: doc.is_standard == \"No\"",
"fieldname": "letter_head",
"fieldtype": "Link",
"label": "Letter Head",
"label": "Default Letter Head",
"options": "Letter Head"
},
{
@ -202,12 +202,18 @@
"fieldname": "add_translate_data",
"fieldtype": "Check",
"label": "Add Translate Data"
},
{
"fieldname": "default_print_format",
"fieldtype": "Link",
"label": "Default Print Format",
"options": "Print Format"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-08-28 18:28:32.510719",
"modified": "2026-04-10 00:03:15.212213",
"modified_by": "Administrator",
"module": "Core",
"name": "Report",

View file

@ -5,16 +5,17 @@ import json
import threading
import frappe
import frappe.desk.query_report
from frappe import _, scrub
from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
from frappe.core.doctype.page.page import delete_custom_role
from frappe.desk.query_report import run
from frappe.desk.reportview import append_totals_row
from frappe.model.document import Document
from frappe.modules import make_boilerplate
from frappe.modules.export_file import export_to_files
from frappe.utils import cint, cstr
from frappe.utils.safe_exec import check_safe_sql_query, safe_exec
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder
class Report(Document):
@ -32,6 +33,7 @@ class Report(Document):
add_total_row: DF.Check
add_translate_data: DF.Check
columns: DF.Table[ReportColumn]
default_print_format: DF.Link | None
disabled: DF.Check
filters: DF.Table[ReportFilter]
is_standard: DF.Literal["No", "Yes"]
@ -72,16 +74,17 @@ class Report(Document):
frappe.throw(_("Cannot edit a standard report. Please duplicate and create a new report"))
if self.is_standard == "Yes":
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
self.validate_standard_report()
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()
@ -89,7 +92,6 @@ class Report(Document):
self.export_doc()
def before_export(self, doc):
doc.letter_head = None
doc.prepared_report = 0
def on_trash(self):
@ -106,6 +108,13 @@ class Report(Document):
delete_custom_role("report", self.name)
def clear_cache(self):
self.update_report_cache()
return super().clear_cache()
def update_report_cache(self):
frappe.cache.delete_key("bootinfo")
def delete_report_folder(self):
from frappe.modules.export_file import delete_folder
@ -202,11 +211,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
@ -242,7 +254,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,
@ -314,8 +326,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(
@ -401,6 +411,53 @@ class Report(Document):
return data
def validate_standard_report(self):
if frappe.session.user != "Administrator":
frappe.throw(_("Only Administrator can save a standard report. Please rename and save."))
if not cint(frappe.conf.developer_mode):
frappe.throw(_("Standard reports can only be created in developer mode."))
def validate_default_print_format(self):
pf = frappe.db.get_value(
"Print Format",
self.default_print_format,
["report", "print_format_for", "print_format_type", "disabled"],
as_dict=True,
)
if (
not pf
or pf.report != self.name
or pf.print_format_for != "Report"
or pf.print_format_type != "JS"
or pf.disabled
):
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"):
@ -408,6 +465,18 @@ class Report(Document):
self.db_set("disabled", cint(disable))
def get_xlsx_styles_from_module(self, metadata: XLSXMetadata) -> dict:
if self.is_standard != "Yes" or self.report_type not in ("Query Report", "Script Report"):
return
try:
method = self.get_module_method("get_xlsx_styles")
except AttributeError:
# Ignore if hook(method) is not defined
return
return method(metadata)
def is_prepared_report_enabled(report):
return cint(frappe.db.get_value("Report", report, "prepared_report"))

View file

@ -406,3 +406,32 @@ result = [
self.assertEqual(result[-1][0], "Total")
self.assertEqual(result[-1][1], 200)
self.assertEqual(result[-1][2], 150.50)
def test_report_cache_invalidation(self):
import frappe.sessions
from frappe.utils import set_request
frappe.set_user("test@example.com")
set_request(method="GET", path="/app")
try:
frappe.sessions.get()
report_name = _save_report(
"Test Cache Invalidation Report",
"User",
json.dumps([{"fieldname": "email", "fieldtype": "Data", "label": "Email"}]),
)
cached_bootinfo = frappe.sessions.get()
self.assertIn(report_name, cached_bootinfo["user"]["all_reports"])
doc = frappe.get_doc("Report", report_name)
delete_report(doc.name)
cached_bootinfo = frappe.sessions.get()
self.assertNotIn(report_name, cached_bootinfo["user"]["all_reports"])
finally:
frappe.local.request = None
frappe.set_user("Administrator")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -168,9 +168,9 @@ def queue_submission(doc: Document, action: str, alert: bool = True):
"Submission Queue", {"ref_doctype": doc.doctype, "ref_docname": doc.name, "status": "Queued"}
):
frappe.msgprint(
_(
"This document has already been queued for submission. You can track the progress over {0}."
).format(f"<a href='/desk/submission-queue/{existing_queue}'><b>here</b></a>"),
_("This document has already been queued for {0}. You can track the progress over {1}.").format(
action, f"<a href='/desk/submission-queue/{existing_queue}'><b>here</b></a>"
),
indicator="orange",
alert=True,
)
@ -183,8 +183,8 @@ def queue_submission(doc: Document, action: str, alert: bool = True):
if alert:
frappe.msgprint(
_("Queued for Submission. You can track the progress over {0}.").format(
f"<a href='/desk/submission-queue/{queue.name}'><b>here</b></a>"
_("Queued for {0}. You can track the progress over {1}.").format(
action, f"<a href='/desk/submission-queue/{queue.name}'><b>here</b></a>"
),
indicator="green",
alert=True,

View file

@ -51,3 +51,71 @@ class TestSubmissionQueue(IntegrationTestCase):
job = self.queue.fetch_job(submission_queue.job_id)
# Test completion
self.check_status(job, status="finished")
def test_cancel_operation(self):
from frappe.core.doctype.doctype.test_doctype import new_doctype
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
if not frappe.db.table_exists("Test Submission Queue", cached=False):
doc = new_doctype("Test Submission Queue", is_submittable=True, queue_in_background=True)
doc.insert()
d = frappe.new_doc("Test Submission Queue")
d.update({"some_fieldname": "Random"})
d.insert()
d.submit()
frappe.db.commit()
self.assertEqual(d.docstatus, 1)
queue_submission(d, "Cancel")
frappe.db.commit()
time.sleep(4)
submission_queue = frappe.get_last_doc("Submission Queue")
job = self.queue.fetch_job(submission_queue.job_id)
self.check_status(job, status="finished")
d.reload()
self.assertEqual(d.docstatus, 2)
def test_cancel_on_cancelled_doc(self):
from frappe.core.doctype.doctype.test_doctype import new_doctype
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
if not frappe.db.table_exists("Test Submission Queue", cached=False):
doc = new_doctype("Test Submission Queue", is_submittable=True, queue_in_background=True)
doc.insert()
d = frappe.new_doc("Test Submission Queue")
d.update({"some_fieldname": "Random"})
d.insert()
d.submit()
frappe.db.commit()
existing = frappe.get_doc(
{
"doctype": "Submission Queue",
"ref_doctype": d.doctype,
"ref_docname": d.name,
"status": "Queued",
}
)
existing.insert(d, "Cancel")
frappe.db.commit()
initial_count = frappe.db.count(
"Submission Queue", {"ref_doctype": d.doctype, "ref_docname": d.name, "status": "Queued"}
)
queue_submission(d, "Cancel")
final_count = frappe.db.count(
"Submission Queue", {"ref_doctype": d.doctype, "ref_docname": d.name, "status": "Queued"}
)
self.assertEqual(initial_count, final_count)
existing.delete(ignore_permissions=True)
frappe.db.commit()

View file

@ -114,6 +114,8 @@
"enable_telemetry",
"search_section",
"link_field_results_limit",
"column_break_nebx",
"allow_clearing_link_fields",
"api_logging_section",
"log_api_requests"
],
@ -783,13 +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"
}
],
"hide_toolbar": 1,
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2026-02-24 14:27:04.763075",
"modified": "2026-04-14 16:26:19.634212",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",

View file

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

View file

@ -16,16 +16,20 @@ class TestTranslation(IntegrationTestCase):
clear_cache()
def test_doctype(self):
translation_data = get_translation_data()
for lang, (source_string, new_translation) in translation_data.items():
doctype = "Translation"
meta = frappe.get_meta(doctype)
source_string = meta.get_label("translated_text")
for lang in ["de", "bs", "zh", "hr", "en", "sv"]:
frappe.local.lang = lang
original_translation = _(source_string)
original_translation = _(source_string, context=doctype)
new_translation = f"{original_translation} Customized"
docname = create_translation(lang, source_string, new_translation)
self.assertEqual(_(source_string), new_translation)
docname = create_translation(lang, source_string, new_translation, context=doctype)
self.assertEqual(_(source_string, context=doctype), new_translation)
frappe.delete_doc("Translation", docname)
self.assertEqual(_(source_string), original_translation)
frappe.delete_doc(doctype, docname)
self.assertEqual(_(source_string, context=doctype), original_translation)
def test_parent_language(self):
data = {
@ -60,37 +64,54 @@ class TestTranslation(IntegrationTestCase):
source = "User"
self.assertNotEqual(_(source, lang="de"), _(source, lang="es"))
def test_html_content_data_translation(self):
# ruff: noqa: RUF001
def test_html_content_translation(self):
source = """
<span style="color: rgb(51, 51, 51); font-family: &quot;Amazon Ember&quot;, Arial, sans-serif; font-size:
small;">MacBook Air lasts up to an incredible 12 hours between charges. So from your morning coffee to
your evening commute, you can work unplugged. When its time to kick back and relax,
you can get up to 12 hours of iTunes movie playback. And with up to 30 days of standby time,
you can go away for weeks and pick up where you left off.Whatever the task,
fifth-generation Intel Core i5 and i7 processors with Intel HD Graphics 6000 are up to it.</span><br>
"""
To add dynamic subject, use jinja tags like
<div><pre><code>{{ doc.name }} Billed</code></pre></div>
""".strip()
target = """
MacBook Air dura hasta 12 horas increíbles entre cargas. Por lo tanto,
desde el café de la mañana hasta el viaje nocturno, puede trabajar desconectado.
Cuando es hora de descansar y relajarse, puede obtener hasta 12 horas de reproducción de películas de iTunes.
Y con hasta 30 días de tiempo de espera, puede irse por semanas y continuar donde lo dejó. Sea cual sea la tarea,
los procesadores Intel Core i5 e i7 de quinta generación con Intel HD Graphics 6000 son capaces de hacerlo.
"""
Um einen dynamischen Betreff hinzuzufügen, verwenden Sie Jinja-Tags wie
<div><pre><code>{{ doc.name }} Abgerechnet</code></pre></div>
""".strip()
create_translation("es", source, target)
frappe.local.lang = "de"
source = """
<span style="font-family: &quot;Amazon Ember&quot;, Arial, sans-serif; font-size:
small; color: rgb(51, 51, 51);">MacBook Air lasts up to an incredible 12 hours between charges. So from your morning coffee to
your evening commute, you can work unplugged. When its time to kick back and relax,
you can get up to 12 hours of iTunes movie playback. And with up to 30 days of standby time,
you can go away for weeks and pick up where you left off.Whatever the task,
fifth-generation Intel Core i5 and i7 processors with Intel HD Graphics 6000 are up to it.</span><br>
"""
self.assertEqual(_(source), source)
self.assertTrue(_(source), target)
create_translation("de", source, target)
self.assertEqual(_(source), target)
def test_translated_html_is_sanitized(self):
source = "Translation with HTML"
target = """
<span style="color:red" onclick="alert('xss')">Hallo</span>
<script>alert("xss")</script>
<iframe src="https://example.com"></iframe>
<div>Ok</div>
""".strip()
docname = create_translation("de", source, target)
translated_text = frappe.db.get_value("Translation", docname, "translated_text")
self.assertIn('<span style="color:red">Hallo</span>', translated_text)
self.assertIn("<div>Ok</div>", translated_text)
self.assertNotIn("onclick", translated_text)
self.assertNotIn("<script", translated_text)
self.assertNotIn('alert("xss")', translated_text)
self.assertNotIn("<iframe", translated_text)
self.assertNotIn("example.com", translated_text)
frappe.local.lang = "de"
self.assertEqual(_(source), translated_text)
def test_plain_text_translation_with_angle_brackets_is_unchanged(self):
source = "Comparison"
target = "1 < 2 and 3 > 2"
docname = create_translation("de", source, target)
self.assertEqual(frappe.db.get_value("Translation", docname, "translated_text"), target)
def test_html_message_translations(self):
"""Test fallback for messages w/ HTML Tags"""
@ -100,27 +121,12 @@ class TestTranslation(IntegrationTestCase):
self.assertEqual(_(message, lang="zh"), translated_message)
def get_translation_data():
html_source_data = """<font color="#848484" face="arial, tahoma, verdana, sans-serif">
<span style="font-size: 11px; line-height: 16.9px;">Test Data</span></font>"""
html_translated_data = """<font color="#848484" face="arial, tahoma, verdana, sans-serif">
<span style="font-size: 11px; line-height: 16.9px;"> testituloksia </span></font>"""
return {
"hr": ["Test data", "Testdaten"],
"ms": ["Test Data", "ujian Data"],
"et": ["Test Data", "testandmed"],
"es": ["Test Data", "datos de prueba"],
"en": ["Quotation", "Tax Invoice"],
"fi": [html_source_data, html_translated_data],
}
def create_translation(lang, source_string, new_translation) -> str:
def create_translation(lang, source_string, new_translation, context=None) -> str:
doc = frappe.new_doc("Translation")
doc.language = lang
doc.source_text = source_string
doc.translated_text = new_translation
doc.context = context
doc.save()
return doc.name

View file

@ -1,12 +1,10 @@
# Copyright (c) 2015, Frappe Technologies and contributors
# License: MIT. See LICENSE
import json
import frappe
from frappe.model.document import Document
from frappe.translate import MERGED_TRANSLATION_KEY, USER_TRANSLATION_KEY
from frappe.utils import is_html, strip_html_tags
from frappe.translate import MERGED_TRANSLATION_KEY, USER_TRANSLATION_KEY, change_translation_version
from frappe.utils import sanitize_html
class Translation(Document):
@ -28,11 +26,7 @@ class Translation(Document):
# end: auto-generated types
def validate(self):
if is_html(self.source_text):
self.remove_html_from_source()
def remove_html_from_source(self):
self.source_text = strip_html_tags(self.source_text).strip()
self.translated_text = sanitize_html(self.translated_text)
def on_update(self):
clear_user_translation_cache(self.language)
@ -46,3 +40,4 @@ class Translation(Document):
def clear_user_translation_cache(lang):
frappe.cache.hdel(USER_TRANSLATION_KEY, lang)
frappe.cache.hdel(MERGED_TRANSLATION_KEY, lang)
change_translation_version()

View file

@ -42,7 +42,7 @@ class TestUser(IntegrationTestCase):
@staticmethod
def reset_password(user) -> str:
link = user.reset_password()
link = user._reset_password()
return parse_qs(urlparse(link).query)["key"][0]
def test_user_type(self):
@ -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):
@ -415,6 +415,12 @@ class TestUser(IntegrationTestCase):
# test API endpoint
with patch.object(user_module.frappe, "sendmail") as sendmail:
from unittest.mock import MagicMock
mock_q = MagicMock()
mock_q.name = "test-email-queue-name"
mock_q.message = "Subject: Test\n\nDear User, here is your link"
sendmail.return_value = mock_q
frappe.clear_messages()
test_user = frappe.get_doc("User", "test2@example.com")
self.assertEqual(reset_password(user="test2@example.com"), None)
@ -425,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
@ -447,6 +466,21 @@ class TestUser(IntegrationTestCase):
sorted(m.get("module_name") for m in get_modules_from_all_apps()),
)
def test_default_app(self):
from frappe.apps import get_default_path
with test_user(roles=["System Manager"]) as user:
user.default_app = "next_erp"
user.save()
self.assertFalse(user.default_app)
frappe.set_user(user.name)
user.db_set("default_app", "next_erp")
user.reload()
self.assertTrue(user.default_app)
get_default_path() # defaults will also trigger hooks logic
@IntegrationTestCase.change_settings("System Settings", reset_password_link_expiry_duration=1)
def test_reset_password_link_expiry(self):
new_password = "new_password"

View file

@ -3,7 +3,7 @@ frappe.ui.form.on("User", {
frm.set_query("default_workspace", () => {
return {
filters: {
for_user: ["in", [null, frappe.session.user]],
for_user: ["in", ["", frappe.session.user]],
title: ["!=", "Welcome Workspace"],
},
};
@ -69,6 +69,8 @@ frappe.ui.form.on("User", {
frm.roles_editor.reset();
}
frm.fields_dict.new_password?.$input?.attr("autocomplete", "new-password");
if (
frm.can_edit_roles &&
!frm.is_new() &&

View file

@ -855,7 +855,7 @@
"options": "User Session Display"
},
{
"default": "0",
"default": "1",
"fieldname": "form_navigation_buttons",
"fieldtype": "Check",
"label": "Show navigation buttons"
@ -924,8 +924,8 @@
}
],
"make_attachments_public": 1,
"modified": "2026-03-24 21:30:57.199337",
"modified_by": "admin@seitimegames.com",
"modified": "2026-04-28 21:59:59.160099",
"modified_by": "Administrator",
"module": "Core",
"name": "User",
"owner": "Administrator",

View file

@ -1,9 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import re
from collections.abc import Iterable
from datetime import timedelta
from functools import cached_property
from functools import cached_property, lru_cache
from typing import Any
import frappe
@ -233,6 +234,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()
@ -248,6 +250,9 @@ class User(Document):
if self.language == "Loading...":
self.language = None
if self.default_app and self.default_app not in frappe.get_installed_apps():
self.default_app = ""
if (self.name not in ["Administrator", "Guest"]) and (not self.get_social_login_userid("frappe")):
self.set_social_login_userid("frappe", frappe.generate_hash(length=39))
@ -278,11 +283,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
@ -297,6 +302,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)
@ -360,7 +369,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)
@ -378,9 +387,23 @@ class User(Document):
toggle_notifications(self.name, enable=cint(self.enabled), ignore_permissions=True)
self.disable_email_fields_if_user_disabled()
def email_new_password(self, new_password=None):
def set_new_password(self, new_password=None):
"""Set New Password for user"""
if new_password and not self.flags.in_insert:
_update_password(user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions)
outgoing_email_exists = frappe.db.exists(
"Email Account", {"default_outgoing": 1, "awaiting_password": 0}
)
if outgoing_email_exists:
email_message = _(
"Your password has been changed and you might have been logged out of all systems.<br>Please contact the Administrator for further assistance."
)
user_email = frappe.db.get_value("User", self.name, "email")
frappe.sendmail(
recipients=[user_email],
subject=_("Security Alert: Your password has been changed."),
content=email_message,
)
def set_system_user(self):
"""For the standard users like admin and guest, the user type is fixed."""
@ -435,7 +458,8 @@ class User(Document):
def send_password_notification(self, new_password):
try:
if self.flags.in_insert:
if self.name not in STANDARD_USERS:
if self.name in STANDARD_USERS:
return
if new_password:
# new password given, no email required
_update_password(
@ -453,7 +477,7 @@ class User(Document):
msgprint(_("Welcome email sent"))
return
else:
self.email_new_password(new_password)
self.set_new_password(new_password)
except frappe.OutgoingEmailError:
frappe.clear_last_message()
@ -467,7 +491,7 @@ class User(Document):
def validate_reset_password(self):
pass
def reset_password(self, send_email=False, password_expired=False):
def _reset_password(self, send_email=False, password_expired=False):
from frappe.utils import get_url
key = frappe.generate_hash()
@ -492,18 +516,24 @@ class User(Document):
def password_reset_mail(self, link):
reset_password_template = frappe.db.get_system_setting("reset_password_template")
self.send_login_mail(
q = self.send_login_mail(
_("Password Reset"),
"password_reset",
{"link": link},
now=True,
custom_template=reset_password_template,
)
if q:
raw_message = q.message
parts = re.split(r"(?i)Dear", raw_message, maxsplit=1)
if len(parts) > 1:
redacted_message = parts[0] + "[THE FOLLOWING CONTENT HAS BEEN REDACTED FOR SECURITY REASONS]"
frappe.db.set_value("Email Queue", q.name, "message", redacted_message, update_modified=False)
def send_welcome_mail_to_user(self):
from frappe.utils import get_url
link = self.reset_password()
link = self._reset_password()
subject = None
method = frappe.get_hooks("welcome_email")
if method:
@ -517,7 +547,7 @@ class User(Document):
welcome_email_template = frappe.db.get_system_setting("welcome_email_template")
self.send_login_mail(
q = self.send_login_mail(
subject,
"new_user",
dict(
@ -526,6 +556,12 @@ class User(Document):
),
custom_template=welcome_email_template,
)
if q:
raw_message = q.message
parts = re.split(r"(?i)Hello", raw_message, maxsplit=1)
if len(parts) > 1:
redacted_message = parts[0] + "[THE FOLLOWING CONTENT HAS BEEN REDACTED FOR SECURITY REASONS]"
frappe.db.set_value("Email Queue", q.name, "message", redacted_message, update_modified=False)
def send_login_mail(self, subject, template, add_args, now=None, custom_template=None):
"""send mail with login details"""
@ -557,7 +593,7 @@ class User(Document):
subject = email_template.get("subject")
content = email_template.get("message")
frappe.sendmail(
return frappe.sendmail(
recipients=self.email,
sender=sender,
subject=subject,
@ -619,18 +655,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)
@ -865,9 +899,14 @@ class User(Document):
@frappe.whitelist()
def get_timezones():
import zoneinfo
return {"timezones": _get_timezones()}
return {"timezones": zoneinfo.available_timezones()}
@lru_cache(maxsize=1)
def _get_timezones():
import pytz
return sorted(pytz.common_timezones)
@frappe.whitelist()
@ -1009,6 +1048,9 @@ def has_email_account(email: str):
@frappe.whitelist(allow_guest=False)
def get_email_awaiting(user: str):
if user != frappe.session.user:
frappe.has_permission("User", "read", doc=user, throw=True)
return frappe.get_all(
"User Email",
fields=["email_account", "email_id"],
@ -1128,25 +1170,32 @@ def sign_up(email: str, full_name: str, redirect_to: str) -> tuple[int, str]:
@frappe.whitelist(allow_guest=True, methods=["POST"])
@rate_limit(limit=get_password_reset_limit, seconds=60 * 60)
def reset_password(user: str) -> str:
def reset_password(user: str) -> None:
# Always return the same generic response regardless of whether the user
# exists, is disabled, or is restricted. This prevents username enumeration
# via different messages or HTTP status codes (CWE-204).
try:
user: User = frappe.get_doc("User", user)
if user.name == "Administrator":
return "not allowed"
if not user.enabled:
return "disabled"
user.validate_reset_password()
user.reset_password(send_email=True)
return frappe.msgprint(
msg=_("Password reset instructions have been sent to {}'s email").format(user.full_name),
title=_("Password Email Sent"),
)
user_doc: User = frappe.get_doc("User", user)
if user_doc.name != "Administrator" and user_doc.enabled:
user_doc.validate_reset_password()
user_doc._reset_password(send_email=True)
# For Administrator or disabled users: silently skip — same response below
except frappe.DoesNotExistError:
frappe.local.response["http_status_code"] = 404
frappe.clear_messages()
return "not found"
except frappe.OutgoingEmailError:
frappe.clear_messages()
frappe.log_error(title="Password reset email could not be sent", message=frappe.get_traceback())
except Exception:
frappe.clear_messages()
frappe.log_error(title="Password reset failed unexpectedly", message=frappe.get_traceback())
frappe.msgprint(
msg=_(
"If this email is registered with us, we have sent password reset instructions to it. Please check your inbox."
),
title=_("Password Reset"),
)
@frappe.whitelist()

View file

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

View file

@ -39,9 +39,7 @@ class UserInvitation(Document):
self._after_insert()
def accept(self, ignore_permissions: bool = False):
accepted_now = self._accept()
if not accepted_now:
return
self._accept()
user, user_inserted = self._upsert_user(ignore_permissions)
self.save(ignore_permissions)
user.save(ignore_permissions)
@ -120,7 +118,7 @@ class UserInvitation(Document):
def _accept(self):
if self.status == "Accepted":
return False
frappe.throw(title=_("Error"), msg=_("Invitation already accepted"))
if self.status == "Expired":
frappe.throw(title=_("Error"), msg=_("Invitation is expired"))
if self.status == "Cancelled":
@ -128,6 +126,7 @@ class UserInvitation(Document):
self.status = "Accepted"
self.accepted_at = frappe.utils.now()
self.user = self.email
self.key = None
return True
def _upsert_user(self, ignore_permissions: bool = False):
@ -206,12 +205,11 @@ class UserInvitation(Document):
def mark_expired_invitations() -> None:
days = 3
invitations_to_expire = frappe.db.get_all(
invitations_to_expire = frappe.get_docs(
"User Invitation",
filters={"status": "Pending", "creation": ["<", frappe.utils.add_days(frappe.utils.now(), -days)]},
)
for invitation in invitations_to_expire:
invitation = frappe.get_doc("User Invitation", invitation.name)
invitation.expire()
# to avoid losing work in case the job times out without finishing
frappe.db.commit() # nosemgrep

View file

@ -51,9 +51,6 @@ def create_user_type(user_type):
if frappe.db.exists("User Type", user_type):
frappe.delete_doc("User Type", user_type)
user_type_limit = {frappe.scrub(user_type): 1}
update_site_config("user_type_doctype_limit", user_type_limit)
doc = frappe.get_doc(
{
"doctype": "User Type",

View file

@ -48,7 +48,6 @@ class UserType(Document):
if self.is_standard:
return
self.validate_document_type_limit()
self.validate_role()
self.add_role_permissions_for_user_doctypes()
self.add_role_permissions_for_select_doctypes()
@ -75,37 +74,6 @@ class UserType(Document):
for module in modules:
self.append("user_type_modules", {"module": module})
def validate_document_type_limit(self):
limit = frappe.conf.get("user_type_doctype_limit", {}).get(frappe.scrub(self.name))
if not limit and frappe.session.user != "Administrator":
frappe.throw(
_("User does not have permission to create the new {0}").format(frappe.bold(_("User Type"))),
title=_("Permission Error"),
)
if limit is None:
frappe.msgprint(
_("The limit has not set for the user type {0} in the site config file.").format(
frappe.bold(self.name)
),
title=_("Set Limit"),
)
return
if self.user_doctypes and len(self.user_doctypes) > limit:
frappe.throw(
_("The total number of user document types limit has been crossed."),
title=_("User Document Types Limit Exceeded"),
)
custom_doctypes = [row.document_type for row in self.user_doctypes if row.is_custom]
if custom_doctypes and len(custom_doctypes) > 3:
frappe.throw(
_("You can only set the 3 custom doctypes in the Document Types table."),
title=_("Custom Document Types Limit Exceeded"),
)
def validate_role(self):
if not self.role:
frappe.throw(_("The field {0} is mandatory").format(frappe.bold(_("Role"))))

View file

@ -22,6 +22,7 @@ STANDARD_EXCLUSIONS = [
"*/node_modules/*",
"*/doctype/*/*_dashboard.py",
"*/patches/*",
"*/.github/*",
]
# tested via commands' test suite
@ -46,6 +47,9 @@ FRAPPE_EXCLUSIONS = [
"*frappe/setup.py",
"*/doctype/*/*_dashboard.py",
"*/patches/*",
"*/frappe/database/postgres/*",
"*/.github/helper/ci.py",
"*/frappe/database/sqlite/*",
*TESTED_VIA_CLI,
]
@ -78,7 +82,12 @@ class CodeCoverage:
if self.app == "frappe":
omit.extend(FRAPPE_EXCLUSIONS)
self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS)
self.coverage = Coverage(
source=[source_path],
omit=omit,
include=STANDARD_INCLUSIONS,
data_suffix=True,
)
self.coverage.start()
return self

View file

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

View file

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

View file

@ -24,6 +24,7 @@
"track_views",
"allow_auto_repeat",
"allow_import",
"allow_bulk_edit",
"queue_in_background",
"naming_section",
"naming_rule",
@ -222,6 +223,14 @@
"fieldtype": "Check",
"label": "Allow Import (via Data Import Tool)"
},
{
"default": "1",
"depends_on": "istable",
"description": "Enable bulk edit for child table fields in Form view.",
"fieldname": "allow_bulk_edit",
"fieldtype": "Check",
"label": "Allow Bulk Edit"
},
{
"depends_on": "email_append_to",
"fieldname": "subject_field",

View file

@ -13,13 +13,14 @@ import frappe.translate
from frappe import _
from frappe.core.doctype.doctype.doctype import (
check_email_append_to,
get_fields_not_allowed_in_list_view,
validate_autoincrement_autoname,
validate_fields_for_doctype,
validate_series,
)
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter
from frappe.model import core_doctypes_list, no_value_fields
from frappe.model import core_doctypes_list
from frappe.model.docfield import supports_translation
from frappe.model.document import Document
from frappe.model.meta import trim_table
@ -41,6 +42,7 @@ class CustomizeForm(Document):
actions: DF.Table[DocTypeAction]
allow_auto_repeat: DF.Check
allow_bulk_edit: DF.Check
allow_copy: DF.Check
allow_import: DF.Check
autoname: DF.Data | None
@ -319,12 +321,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 +362,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 +402,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):
@ -740,6 +745,7 @@ doctype_properties = {
"track_views": "Check",
"allow_auto_repeat": "Check",
"allow_import": "Check",
"allow_bulk_edit": "Check",
"show_name_in_global_search": "Check",
"show_preview_popup": "Check",
"default_email_template": "Data",
@ -787,6 +793,7 @@ docfield_properties = {
"print_hide": "Check",
"print_hide_if_no_value": "Check",
"report_hide": "Check",
"in_import_template": "Check",
"allow_on_submit": "Check",
"translatable": "Check",
"mandatory_depends_on": "Data",

View file

@ -48,6 +48,7 @@
"ignore_user_permissions",
"allow_on_submit",
"report_hide",
"in_import_template",
"remember_last_selected_value",
"hide_border",
"ignore_xss_filter",
@ -293,6 +294,13 @@
"oldfieldname": "report_hide",
"oldfieldtype": "Check"
},
{
"default": "0",
"description": "Enable this option to include the field in the data import template",
"fieldname": "in_import_template",
"fieldtype": "Check",
"label": "Include in Import Template"
},
{
"default": "0",
"depends_on": "eval:(doc.fieldtype == 'Link')",
@ -523,7 +531,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-03-22 10:36:12.968197",
"modified": "2026-04-27 12:00:00.000000",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form Field",

View file

@ -81,6 +81,7 @@ class CustomizeFormField(Document):
ignore_xss_filter: DF.Check
in_filter: DF.Check
in_global_search: DF.Check
in_import_template: DF.Check
in_list_view: DF.Check
in_preview: DF.Check
in_standard_filter: DF.Check

View file

@ -111,6 +111,60 @@ def delete_property_setter(doc_type, property=None, field_name=None, row_name=No
if row_name:
filters["row_name"] = row_name
property_setters = frappe.db.get_values("Property Setter", filters)
_delete_property_setters(filters)
def bulk_delete_property_setters(property_setters: list[dict], bypass_hooks: bool = False):
"""
Delete property setters.
:param property_setters: List of filters for Property Setter rows.
:param bypass_hooks: If `True`, raw delete without doc hooks.
Example of `property_setters`:
```
[
{"doctype": "ToDo", "fieldname": "status", "property": "hidden"},
{"doctype": "ToDo", "fieldname": "status", "property": "read_only"},
]
```
---
Note: `doctype` and `fieldname` are mandatory.
"""
field_map = {
"doctype": "doc_type",
"fieldname": "field_name",
}
doctypes_to_clear = set()
for property_setter in property_setters:
filters = property_setter.copy()
for key, fieldname in field_map.items():
if key in filters:
filters[fieldname] = filters.pop(key)
if not filters:
continue
if not filters.get("doc_type") or not filters.get("field_name"):
frappe.throw(_("`doctype` and `fieldname` are required for deleting property setters."))
if bypass_hooks:
frappe.db.delete("Property Setter", filters)
doctypes_to_clear.add(filters["doc_type"])
else:
_delete_property_setters(filters)
for doctype in doctypes_to_clear:
frappe.clear_cache(doctype=doctype)
def _delete_property_setters(filters: dict):
property_setters = frappe.get_all("Property Setter", filters=filters, pluck="name")
for ps in property_setters:
frappe.get_doc("Property Setter", ps).delete(ignore_permissions=True, force=True)

View file

@ -1,7 +1,48 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import frappe
from frappe.custom.doctype.property_setter.property_setter import (
bulk_delete_property_setters,
)
from frappe.tests import IntegrationTestCase
class TestPropertySetter(IntegrationTestCase):
pass
def test_bulk_delete_property_setters(self):
doctype = "ToDo"
fieldname = "status"
property_1 = "hidden"
property_2 = "no_copy"
properties = [property_1, property_2]
for property_name in properties:
frappe.make_property_setter(
{
"doctype": doctype,
"fieldname": fieldname,
"property": property_name,
"value": 1,
"property_type": "Check",
}
)
def property_setter_exists(property_name):
return frappe.db.exists(
"Property Setter",
{"doc_type": doctype, "field_name": fieldname, "property": property_name},
)
for property_name in properties:
self.assertTrue(property_setter_exists(property_name))
# 1
bulk_delete_property_setters(
[{"doctype": doctype, "fieldname": fieldname, "property": property_1}],
bypass_hooks=True,
)
self.assertFalse(property_setter_exists(property_1))
# 2
bulk_delete_property_setters([{"doc_type": doctype, "field_name": fieldname, "property": property_2}])
self.assertFalse(property_setter_exists(property_2))

View file

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

View file

@ -8,7 +8,6 @@ import frappe
from frappe.database.utils import NestedSetHierarchy
from frappe.model.db_query import get_timespan_date_range
from frappe.query_builder import Field
from frappe.query_builder.functions import Coalesce
from frappe.utils import cstr
@ -48,6 +47,10 @@ def func_in(key: Field, value: list | tuple) -> frappe.qb:
"""
if isinstance(value, str):
value = value.split(",")
value = ["" if v is None else v for v in value]
if "" in value:
return key.isin(value) | key.isnull()
return key.isin(value)

View file

@ -84,7 +84,7 @@ def _apply_date_field_filter_conversion(value, operator: str, doctype: str, fiel
elif isinstance(value, datetime.datetime):
return value.date()
except AttributeError, TypeError, KeyError:
except (AttributeError, TypeError, KeyError):
pass
return value
@ -136,11 +136,7 @@ WORDS_PATTERN = re.compile(r"\w+")
COMMA_PATTERN = re.compile(r",\s*(?![^()]*\))")
# Pattern for validating simple field names (alphanumeric + underscore)
SIMPLE_FIELD_PATTERN = re.compile(r"^\w+$", flags=re.ASCII)
# Pattern for validating SQL identifiers (aliases, field names in functions)
# More restrictive: must start with letter or underscore
IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$", flags=re.ASCII)
SIMPLE_FIELD_PATTERN = re.compile(r"^\w+$")
# Pattern for detecting SQL function calls: identifier followed by opening parenthesis
FUNCTION_CALL_PATTERN = re.compile(r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*\(", flags=re.ASCII)
@ -157,7 +153,7 @@ FUNCTION_CALL_PATTERN = re.compile(r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*\(", flags=re.
# - ... as 'Child:field'
ALLOWED_FIELD_PATTERN = re.compile(
r"^(?:(`[\w\s-]+`|\w+)\.)?(`\w+`|\w+)(?:\s+as\s+(?:`[\w\s-]+`|'[\w\s:-]+'|\w+))?$",
flags=re.ASCII | re.IGNORECASE,
flags=re.IGNORECASE,
)
# Regex to parse field names:
@ -176,6 +172,9 @@ BACKTICK_FIELD_PARSE_REGEX = re.compile(r"^`tab([\w\s-]+)`\.(`?)(\w+)\2$")
# Group 3: Fieldname
CHILD_TABLE_FIELD_PATTERN = re.compile(r'^[`"]?tab([\w\s]+)[`"]?\.([`"]?)(\w+)\2$')
# Maximum value of an unsigned 64-bit integer
MAX_LIMIT = 18446744073709551615
# Direct mapping from uppercase function names to pypika function classes
FUNCTION_MAPPING = {
"COUNT": functions.Count,
@ -303,6 +302,11 @@ class Engine:
if offset:
if not isinstance(offset, int) or offset < 0:
frappe.throw(_("Offset must be a non-negative integer"), TypeError)
# In MariaDB and SQLite, offset requires limit
if not self.is_postgres and not limit:
self.query = self.query.limit(MAX_LIMIT)
self.query = self.query.offset(offset)
if distinct:
@ -676,7 +680,7 @@ class Engine:
else:
try:
fallback_value = int(fallback_sql)
except ValueError, TypeError:
except (ValueError, TypeError):
fallback_value = fallback_sql
return operator_fn(_field, ValueWrapper(fallback_value))
@ -705,7 +709,7 @@ class Engine:
else:
try:
fallback_value = int(fallback_sql)
except ValueError, TypeError:
except (ValueError, TypeError):
fallback_value = fallback_sql
if fallback_value == _value:
@ -2424,14 +2428,15 @@ class SQLFunctionParser:
).format(arg),
frappe.ValidationError,
)
elif self._is_valid_field_name(arg):
self._check_function_field_permission(arg)
return self.engine.table[arg]
# Check if it's a numeric string like "1" (for COUNT(1), etc.)
elif arg.isdigit():
return int(arg)
elif self._is_valid_field_name(arg):
self._check_function_field_permission(arg)
return self.engine.table[arg]
else:
frappe.throw(
_(
@ -2443,7 +2448,7 @@ class SQLFunctionParser:
def _is_valid_field_name(self, name: str) -> bool:
"""Check if a string is a valid field name."""
# Field names should only contain alphanumeric characters and underscores
return IDENTIFIER_PATTERN.match(name) is not None
return SIMPLE_FIELD_PATTERN.match(name) is not None
def _validate_alias(self, alias: str):
"""Validate alias name for SQL injection."""
@ -2456,7 +2461,7 @@ class SQLFunctionParser:
# Alias should be a simple identifier
# Note: pypika wraps aliases in backticks, so anything without backticks is safe
if not IDENTIFIER_PATTERN.match(alias):
if not SIMPLE_FIELD_PATTERN.match(alias):
frappe.throw(
_("Invalid alias format: {0}. Alias must be a simple identifier.").format(alias),
frappe.ValidationError,

View file

@ -5,7 +5,7 @@ from frappe import _
from frappe.utils import cint, cstr, flt
from frappe.utils.defaults import get_not_null_defaults
# This matches anything that isn't [a-zA-Z0-9_]
# This matches anything that isn't Unicode Word Characters, Numbers and Underscore.
SPECIAL_CHAR_PATTERN = re.compile(r"[\W]", flags=re.UNICODE)
VARCHAR_CAST_PATTERN = re.compile(r"varchar\(([\d]+)\)")

View file

@ -661,6 +661,9 @@ def update_onboarding_step(name: str | int, field: str, value: int | str):
"""
from frappe.utils.telemetry import capture
allowed_fields = ["is_skipped", "is_complete"]
if field not in allowed_fields:
return
frappe.db.set_value("Onboarding Step", name, field, value)
capture(frappe.scrub(name), app="frappe_onboarding", properties={field: value})
@ -682,6 +685,9 @@ def get_onboarding_data(module: str):
Return:
dict: onboarding data
"""
if not frappe.get_system_settings("enable_onboarding"):
return []
onboardings = []
onboarding_doc = frappe.get_doc("Module Onboarding", module)
if onboarding_doc.is_complete:

View file

@ -36,7 +36,7 @@
},
{
"bold": 1,
"description": "SQL Conditions. Example: status=\"Open\"",
"description": "SQL Conditions. Example: {\"status\" : \"open\", \"priority\" : \"medium\"}",
"fieldname": "condition",
"fieldtype": "Small Text",
"label": "Condition"
@ -52,7 +52,7 @@
],
"issingle": 1,
"links": [],
"modified": "2024-03-23 16:01:29.575802",
"modified": "2026-04-01 12:18:08.821282",
"modified_by": "Administrator",
"module": "Desk",
"name": "Bulk Update",
@ -70,6 +70,7 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],

View file

@ -31,17 +31,18 @@ class BulkUpdate(Document):
def bulk_update(self):
self.check_permission("write")
limit = self.limit if self.limit and cint(self.limit) < 500 else 500
condition = ""
query_args = {"doctype": self.document_type, "limit": limit, "pluck": "name"}
if self.condition:
if ";" in self.condition:
frappe.throw(_("; not allowed in condition"))
try:
filters = frappe.parse_json(self.condition)
if isinstance(filters, dict):
if "or_filters" in filters:
query_args["or_filters"] = filters.pop("or_filters")
query_args["filters"] = filters
except Exception as e:
frappe.throw(_("The Bulk Update could not happen due to <b>{0}</b>").format(str(e)))
condition = f" where {self.condition}"
docnames = frappe.db.sql_list(
f"""select name from `tab{self.document_type}`{condition} limit {limit} offset 0"""
)
docnames = frappe.get_all(**query_args)
return submit_cancel_or_update_docs(
self.document_type, docnames, "update", {self.field: self.update_value}
)

View file

@ -103,3 +103,45 @@ class TestBulkUpdate(IntegrationTestCase):
docnames_bg = frappe.get_all(self.doctype, {"docstatus": 0}, limit=20, pluck="name")
submit_cancel_or_update_docs(self.doctype, docnames_bg, action="update", data=update_data)
self.wait_for_assertion(lambda: check_child_field(docnames_bg, "_Test Child Updated"))
def test_bulk_update_conditions(self):
"""Test the whitelisted bulk update method"""
todo_names = []
for i in range(5):
doc = frappe.get_doc(
{
"doctype": "ToDo",
"description": f"Bulk Update Status Test {i}",
"status": "Open" if i < 3 else "Closed",
}
).insert()
todo_names.append(doc.name)
try:
condition_json = frappe.as_json({"status": "Open", "name": ["in", todo_names]})
bulk_upd = frappe.get_doc(
{
"doctype": "Bulk Update",
"document_type": "ToDo",
"field": "status",
"update_value": "Closed",
"condition": condition_json,
"limit": 5,
}
)
bulk_upd.bulk_update()
updated_docs = frappe.get_all("ToDo", filters={"name": ["in", todo_names]}, fields=["status"])
for doc in updated_docs:
self.assertEqual(doc.status, "Closed")
remaining_open_count = frappe.db.count("ToDo", {"name": ["in", todo_names], "status": "Open"})
self.assertEqual(remaining_open_count, 0)
finally:
for name in todo_names:
frappe.delete_doc("ToDo", name)
frappe.db.commit()

View file

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

View file

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

View file

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

View file

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

View file

@ -76,6 +76,7 @@ class FormTour(Document):
@frappe.whitelist()
def reset_tour(tour_name: str):
frappe.only_for("System Manager")
for user in frappe.get_all("User", pluck="name"):
onboarding_status = frappe.parse_json(frappe.db.get_value("User", user, "onboarding_status"))
onboarding_status.pop(tour_name, None)

View file

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

View file

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

View file

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

View file

@ -11,8 +11,8 @@ frappe.ui.form.on("Workspace", {
frm.trigger("add_to_desktop");
let url = `/desk/${
frm.doc.public
? frappe.router.slug(frm.doc.title)
: "private/" + frappe.router.slug(frm.doc.title)
? frappe.router.slug(frm.doc.name)
: "private/" + frappe.router.slug(frm.doc.name)
}`;
frm.sidebar
.add_user_action(__("Go to Workspace"))

View file

@ -76,18 +76,6 @@ class Workspace(Document):
if self.public and not is_workspace_manager() and not disable_saving_as_public():
frappe.throw(_("You need to be Workspace Manager to edit this document"))
if (
not self.public
and self.for_user
and self.for_user != frappe.session.user
and not is_workspace_manager()
):
frappe.throw(
_("You are not allowed to edit this workspace"),
frappe.PermissionError,
)
if self.has_value_changed("title"):
validate_route_conflict(self.doctype, self.title)
else:

View file

@ -16,6 +16,7 @@
"child",
"navigate_to_tab",
"url",
"open_in_new_tab",
"display_section",
"collapsible_column",
"collapsible",
@ -168,13 +169,20 @@
"fieldname": "filter_area",
"fieldtype": "HTML",
"label": "Filter Area"
},
{
"default": "1",
"depends_on": "eval:doc.link_type === \"URL\";",
"fieldname": "open_in_new_tab",
"fieldtype": "Check",
"label": "Open in New Tab"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-01-12 15:35:56.930873",
"modified": "2026-03-15 02:26:37.285903",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Sidebar Item",

View file

@ -24,6 +24,7 @@ class WorkspaceSidebarItem(Document):
link_to: DF.DynamicLink | None
link_type: DF.Literal["DocType", "Page", "Report", "Workspace", "Dashboard", "URL"]
navigate_to_tab: DF.Autocomplete | None
open_in_new_tab: DF.Check
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View file

@ -58,6 +58,9 @@ def follow_document(doctype: str, doc_name: str, user: str) -> Document | bool:
frappe.toast(_("Administrator can't follow"))
return False
if user != frappe.session.user and not frappe.has_permission("Document Follow", "write"):
frappe.throw(_("You can only follow documents for yourself."), frappe.PermissionError)
if not frappe.db.get_value("User", user, "document_follow_notify", ignore=True, cache=True):
frappe.toast(_("Document follow is not enabled for this user."))
return False
@ -74,6 +77,9 @@ def follow_document(doctype: str, doc_name: str, user: str) -> Document | bool:
@frappe.whitelist()
def unfollow_document(doctype: str, doc_name: str, user: str) -> bool:
if user != frappe.session.user and not frappe.has_permission("Document Follow", "write"):
frappe.throw(_("You can only unfollow documents for yourself."), frappe.PermissionError)
doc = frappe.get_all(
"Document Follow",
filters={"ref_doctype": doctype, "ref_docname": doc_name, "user": user},

View file

@ -64,6 +64,11 @@ def cancel(
if workflow_state_fieldname and workflow_state:
doc.set(workflow_state_fieldname, workflow_state)
if doc.meta.queue_in_background and not is_scheduler_inactive():
queue_submission(doc, "Cancel")
return
doc.cancel()
send_updated_docs(doc)
frappe.msgprint(frappe._("Cancelled"), indicator="red", alert=True)

View file

@ -26,9 +26,9 @@
<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" >
<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"
class="icon icon-md" aria-hidden="true"
>
<use href="#icon-bell"></use>
</svg>
@ -50,8 +50,8 @@
</div>
</div>
</div>
<div class="desktop-avatar">
</div>
<button class="desktop-avatar btn-reset" aria-label="{{ _('User Menu') }}">
</button>
</div>
</header>

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