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. # 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. # 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 # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
exemptLabels: exemptLabels:

View file

@ -29,17 +29,10 @@ on:
enable-coverage: enable-coverage:
required: false required: false
type: boolean type: boolean
default: false default: true
jobs: 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: gen-idx-integration:
name: Gen Integration Test Matrix name: Gen Integration Test Matrix
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -100,7 +93,6 @@ jobs:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
disable-socketio: true disable-socketio: true
enable-coverage: ${{ inputs.enable-coverage }}
db-root-password: ${{ env.DB_ROOT_PASSWORD }} db-root-password: ${{ env.DB_ROOT_PASSWORD }}
db: ${{ matrix.db }} db: ${{ matrix.db }}
env: env:
@ -108,43 +100,22 @@ jobs:
- name: Run Tests - name: Run Tests
run: | run: |
bench --site test_site \ cd sites && ../env/bin/python3 ../apps/frappe/.github/helper/ci.py
run-parallel-tests \
--app "${{ github.event.repository.name }}" \
--total-builds ${{ inputs.parallel-runs }} \
--build-number ${{ matrix.index }} 2> >(tee -a stderr.log >&2)
# 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: env:
DB: ${{ matrix.db }} DB: ${{ matrix.db }}
# consumed by bench run-parallel-tests # consumed by bench run-parallel-tests
CAPTURE_COVERAGE: ${{ inputs.enable-coverage }} CAPTURE_COVERAGE: ${{ inputs.enable-coverage }}
BUILD_NUMBER: ${{ matrix.index }}
TOTAL_BUILDS: ${{ inputs.parallel-runs }}
FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN || '' }} FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN || '' }}
- name: Upload coverage data - name: Upload coverage data
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
if: inputs.enable-coverage if: ${{ inputs.fake-success == false && inputs.enable-coverage }}
with: with:
name: coverage-${{ matrix.db }}-${{ matrix.index }} name: coverage-${{ matrix.db }}-${{ matrix.index }}
path: ./sites/*-coverage*.xml path: ./sites/*coverage*.xml
- name: Setup tmate session - name: Setup tmate session
uses: mxschmitt/action-tmate@v3 uses: mxschmitt/action-tmate@v3
@ -164,15 +135,14 @@ jobs:
# TIP: Use these for checks, e.g. Server / Tests / Success # TIP: Use these for checks, e.g. Server / Tests / Success
success: success:
name: Success name: Success
needs: [unit-test, integration-test] needs: [integration-test]
if: always() if: always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Unit '${{ needs.unit-test.result }}' / Integration '${{ needs.integration-test.result }}' - name: Integration '${{ needs.integration-test.result }}'
shell: python shell: python
run: | run: |
stati = [ stati = [
'${{ needs.unit-test.result }}',
'${{ needs.integration-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: | run: |
pip install pip-audit pip install pip-audit
cd ${GITHUB_WORKSPACE} 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: precommit:
name: 'Pre-Commit' name: 'Pre-Commit'

View file

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

View file

@ -1,8 +1,6 @@
name: Server name: Server
on: on:
repository_dispatch:
types: [frappe-framework-change]
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
@ -18,14 +16,9 @@ permissions:
contents: read contents: read
jobs: jobs:
typecheck:
name: Types
uses: ./.github/workflows/_base-type-check.yml
checkrun: checkrun:
name: Plan Tests name: Plan Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: typecheck
outputs: outputs:
build: ${{ steps.check-build.outputs.build }} build: ${{ steps.check-build.outputs.build }}
run_postgres: ${{ steps.check-build.outputs.run_postgres }} 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-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 enable-sqlite: false # This will test against both MariaDB and SQLite if enabled
parallel-runs: 2 parallel-runs: 2
enable-coverage: ${{ github.event_name != 'pull_request' }}
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }} fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
needs: checkrun needs: checkrun
secrets: inherit secrets: inherit
@ -67,37 +59,16 @@ jobs:
name: Coverage Wrap Up name: Coverage Wrap Up
needs: [test, checkrun] needs: [test, checkrun]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event_name != 'pull_request' }}
steps: steps:
- name: Clone - name: Clone
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@v8.0.1 uses: actions/download-artifact@v8.0.1
- name: Upload coverage data - name: Upload coverage data
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v6
with: with:
name: Server name: Server
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true fail_ci_if_error: true
verbose: true verbose: true
flags: server 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: on:
pull_request: pull_request:
repository_dispatch:
types: [frappe-framework-change]
workflow_dispatch: workflow_dispatch:
schedule: schedule:
# Run everday at midnight UTC / 5:30 IST # Run everday at midnight UTC / 5:30 IST
@ -44,35 +42,6 @@ jobs:
uses: ./.github/workflows/_base-ui-tests.yml uses: ./.github/workflows/_base-ui-tests.yml
with: with:
parallel-runs: 3 parallel-runs: 3
enable-coverage: ${{ github.event_name != 'pull_request' }} enable-coverage: false
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }} fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
needs: checkrun 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: paths:
- "**/*.py" - "**/*.py"
carryforward: true carryforward: true
ui-tests:
paths:
- "**/*.js"
carryforward: true
server-ui: server-ui:
paths: paths:
- "**/*.py" - "**/*.py"

View file

@ -111,4 +111,76 @@ context("Grid", () => {
cy.get("@table-form").find(".grid-footer-toolbar").click(); 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.core.doctype.system_settings.system_settings import get_system_settings
from frappe.model.document import ( from frappe.model.document import (
get_doc, get_doc,
get_docs,
get_lazy_doc, get_lazy_doc,
copy_doc, copy_doc,
new_doc, new_doc,
@ -1594,6 +1595,7 @@ from frappe.utils.error import log_error
from frappe.utils.formatters import format_value from frappe.utils.formatters import format_value
from frappe.utils.print_utils import get_print, attach_print from frappe.utils.print_utils import get_print, attach_print
from frappe.email import sendmail from frappe.email import sendmail
from frappe.concurrency_limiter import concurrent_limit
# for backwards compatibility # for backwards compatibility
format = format_value 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) 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}) doc_dict.update({field.fieldname: link_doc})

View file

@ -126,6 +126,12 @@ def application(request: Request):
elif request.path.startswith("/private/files/"): elif request.path.startswith("/private/files/"):
response = frappe.utils.response.download_private_file(request.path) 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": elif request.path.startswith("/.well-known/") and request.method == "GET":
response = handle_wellknown(request.path) response = handle_wellknown(request.path)

View file

@ -53,6 +53,8 @@ def get_apps():
def get_route(app_name): 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) 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) 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" return app.get("route") if app and app.get("route") else "/apps"
@ -89,6 +91,9 @@ def get_default_path():
@frappe.whitelist() @frappe.whitelist()
def set_app_as_default(app_name: str): 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: if frappe.db.get_value("User", frappe.session.user, "default_app") == app_name:
frappe.db.set_value("User", frappe.session.user, "default_app", "") frappe.db.set_value("User", frappe.session.user, "default_app", "")
else: else:

View file

@ -155,7 +155,9 @@ class LoginManager:
self.authenticate(user=user, pwd=pwd) self.authenticate(user=user, pwd=pwd)
if self.force_user_to_reset_password(): if self.force_user_to_reset_password():
doc = frappe.get_doc("User", self.user) 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" frappe.local.response["message"] = "Password Reset"
return False return False
@ -724,9 +726,13 @@ def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=Non
raise frappe.AuthenticationError raise frappe.AuthenticationError
doctype = frappe_authorization_source or "User" doctype = frappe_authorization_source or "User"
docname = frappe.db.get_value( try:
doctype=doctype, filters={"api_key": api_key, "enabled": True}, fieldname=["name"] docname = frappe.db.get_value(
) doctype=doctype, filters={"api_key": api_key, "enabled": True}, fieldname=["name"]
)
except Exception:
raise frappe.AuthenticationError
if not docname: if not docname:
raise frappe.AuthenticationError raise frappe.AuthenticationError
form_dict = frappe.local.form_dict 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.home_folder = frappe.db.get_value("File", {"is_home_folder": 1})
bootinfo.navbar_settings = get_navbar_settings() bootinfo.navbar_settings = get_navbar_settings()
bootinfo.notification_settings = get_notification_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() bootinfo.onboarding_tours = get_onboarding_ui_tours()
set_time_zone(bootinfo) set_time_zone(bootinfo)
@ -342,10 +345,10 @@ def get_user_pages_or_reports(parent, cache=False):
def load_translations(bootinfo): def load_translations(bootinfo):
from frappe.translate import get_messages_for_boot from frappe.translate import get_translation_version
bootinfo["lang"] = frappe.lang bootinfo["lang"] = frappe.lang
bootinfo["__messages"] = get_messages_for_boot() bootinfo["translations_version"] = get_translation_version()
def get_user_info(): def get_user_info():
@ -562,8 +565,9 @@ def get_sidebar_items(allowed_workspaces):
sidebar_doc = sidebar sidebar_doc = sidebar
if ( if (
frappe.session.user == "Administrator" frappe.session.user == "Administrator"
or sidebar_doc.module in sidebar_doc.user.allow_modules
or sidebar_title == "My Workspaces" 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()] = { sidebar_items[sidebar_title.lower()] = {
"label": sidebar_title, "label": sidebar_title,
@ -590,6 +594,7 @@ def get_sidebar_items(allowed_workspaces):
"filters": item.filters, "filters": item.filters,
"route_options": item.route_options, "route_options": item.route_options,
"tab": item.navigate_to_tab, "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): 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( report_type, ref_doctype = frappe.db.get_value(

View file

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

View file

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

View file

@ -108,6 +108,19 @@ def build(
print("Compiling translations for", app) print("Compiling translations for", app)
compile_translations(app, force=force) 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.command("watch")
@click.option("--apps", help="Watch assets for specific apps") @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) hashed_key = frappe.utils.sha256_hash(key)
invitation_name = frappe.db.get_value("User Invitation", filters={"key": hashed_key}) invitation_name = frappe.db.get_value("User Invitation", filters={"key": hashed_key})
if not invitation_name: 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) invitation = frappe.get_doc("User Invitation", invitation_name)
# accept invitation # accept invitation
@ -143,7 +143,7 @@ def _accept_invitation(key: str, in_test: bool) -> None:
# set redirect_to # set redirect_to
redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path()) redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path())
if should_update_password: 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 # GET requests do not cause an implicit commit
frappe.db.commit() # nosemgrep frappe.db.commit() # nosemgrep

View file

@ -20,6 +20,22 @@ class CommunicationEmailMixin:
parent_doc = get_parent_doc(self) parent_doc = get_parent_doc(self)
return parent_doc.owner if parent_doc else None 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): def get_all_email_addresses(self, exclude_displayname=False):
"""Get all Email addresses mentioned in the doc along with display name.""" """Get all Email addresses mentioned in the doc along with display name."""
return ( return (
@ -60,7 +76,7 @@ class CommunicationEmailMixin:
"""Build cc list to send an email. """Build cc list to send an email.
* if email copy is requested by sender, then add sender to CC. * 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 all the thread_notify disabled users.
* Remove standard users from email list * Remove standard users from email list
""" """
@ -77,9 +93,9 @@ class CommunicationEmailMixin:
cc.append(sender) cc.append(sender)
if is_inbound_mail_communcation: if is_inbound_mail_communcation:
# inform parent document owner incase communication is created through inbound mail # inform the configured notification recipient in case communication is created inbound
if doc_owner := self.get_owner(): if notification_recipient := self.get_notification_recipient():
cc.append(doc_owner) cc.append(notification_recipient)
cc = set(cc) - {self.sender_mailid} cc = set(cc) - {self.sender_mailid}
assignees = set(self.get_assignees()) - {self.sender_mailid} assignees = set(self.get_assignees()) - {self.sender_mailid}
# Check and remove If user disabled notifications for incoming emails on assigned document. # Check and remove If user disabled notifications for incoming emails on assigned document.

View file

@ -228,7 +228,7 @@
], ],
"grid_page_length": 50, "grid_page_length": 50,
"links": [], "links": [],
"modified": "2026-03-31 20:37:16.503023", "modified": "2026-04-09 11:13:35.484376",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "Custom DocPerm", "name": "Custom DocPerm",
@ -240,6 +240,7 @@
"delete": 1, "delete": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"import": 1,
"print": 1, "print": 1,
"read": 1, "read": 1,
"report": 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.model import table_fields as table_fieldtypes
from frappe.utils import flt, format_duration, groupby_metric from frappe.utils import flt, format_duration, groupby_metric
from frappe.utils.csvutils import build_csv_response 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: class Exporter:
@ -253,7 +253,17 @@ class Exporter:
if self.file_type == "CSV": if self.file_type == "CSV":
build_csv_response(self.get_csv_array_for_export(), _(self.doctype)) build_csv_response(self.get_csv_array_for_export(), _(self.doctype))
elif self.file_type == "Excel": 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]): def group_children_data_by_parent(self, children_data: dict[str, list]):
return groupby_metric(children_data, key="parent") 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.model import no_value_fields
from frappe.utils import cint, cstr, duration_to_seconds, flt, update_progress_bar 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.csvutils import get_csv_content_from_google_sheets, read_csv_content
from frappe.utils.data import escape_html
from frappe.utils.xlsxutils import ( from frappe.utils.xlsxutils import (
read_xls_file_from_attached_file, read_xls_file_from_attached_file,
read_xlsx_file_from_attached_file, read_xlsx_file_from_attached_file,
@ -727,7 +728,9 @@ class Row:
elif df.fieldtype == "Link": elif df.fieldtype == "Link":
exists = self.link_exists(value, df) exists = self.link_exists(value, df)
if not exists: 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( self.warnings.append(
{ {
"row": self.row_number, "row": self.row_number,
@ -746,7 +749,8 @@ class Row:
"col": col.column_number, "col": col.column_number,
"field": df_as_json(df), "field": df_as_json(df),
"message": _("Value {0} must in {1} format").format( "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, "col": col.column_number,
"field": df_as_json(df), "field": df_as_json(df),
"message": _("Value {0} must in {1} format").format( "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, "col": col.column_number,
"field": df_as_json(df), "field": df_as_json(df),
"message": _("Value {0} must be in the valid duration format: d h m s").format( "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)) not_exists = list(set(values) - set(exists))
if not_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}") message = _("The following values do not exist for {0}: {1}")
self.warnings.append( self.warnings.append(
{ {
@ -1088,7 +1093,7 @@ class Column:
invalid = values - set(options) invalid = values - set(options)
if invalid: if invalid:
valid_values = ", ".join(frappe.bold(o) for o in options) 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}") message = _("The following values are invalid: {0}. Values must be one of {1}")
self.warnings.append( self.warnings.append(
{ {

View file

@ -41,6 +41,7 @@
"print_hide", "print_hide",
"print_hide_if_no_value", "print_hide_if_no_value",
"report_hide", "report_hide",
"in_import_template",
"column_break_28", "column_break_28",
"depends_on", "depends_on",
"collapsible", "collapsible",
@ -640,6 +641,13 @@
"fieldname": "show_description_on_click", "fieldname": "show_description_on_click",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Show Description on Click" "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, "grid_page_length": 50,
@ -647,7 +655,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-03-10 21:39:58.400441", "modified": "2026-04-24 13:21:02.590853",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "DocField", "name": "DocField",

View file

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

View file

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

View file

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

View file

@ -101,6 +101,7 @@ class DocType(Document):
actions: DF.Table[DocTypeAction] actions: DF.Table[DocTypeAction]
allow_auto_repeat: DF.Check allow_auto_repeat: DF.Check
allow_bulk_edit: DF.Check
allow_copy: DF.Check allow_copy: DF.Check
allow_events_in_timeline: DF.Check allow_events_in_timeline: DF.Check
allow_guest_to_view: DF.Check allow_guest_to_view: DF.Check
@ -550,10 +551,19 @@ class DocType(Document):
and (frappe.conf.developer_mode or frappe.flags.allow_doctype_export) and (frappe.conf.developer_mode or frappe.flags.allow_doctype_export)
) )
if allow_doctype_export: if allow_doctype_export:
self.export_doc()
self.make_controller_template() def export_doctype_files():
self.set_base_class_for_controller() self.export_doc()
self.export_types_to_controller() 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 # update index
if not self.custom: if not self.custom:

View file

@ -48,7 +48,13 @@ frappe.ui.form.on("File", {
const field = frm.get_field("attached_to_name"); const field = frm.get_field("attached_to_name");
field.$input_wrapper field.$input_wrapper
.find(".control-value") .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", "fieldname": "is_home_folder",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 1, "hidden": 1,
"label": "Is Home Folder" "label": "Is Home Folder",
"search_index": 1
}, },
{ {
"default": "0", "default": "0",
@ -190,7 +191,7 @@
"icon": "fa fa-file", "icon": "fa fa-file",
"idx": 1, "idx": 1,
"links": [], "links": [],
"modified": "2025-01-15 11:46:42.917146", "modified": "2026-04-15 19:56:45.317786",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "File", "name": "File",
@ -222,4 +223,4 @@
"states": [], "states": [],
"title_field": "file_name", "title_field": "file_name",
"track_changes": 1 "track_changes": 1
} }

View file

@ -111,10 +111,21 @@ class File(Document):
self.validate_attachment_limit() self.validate_attachment_limit()
self.set_file_type() self.set_file_type()
self.validate_file_extension() self.validate_file_extension()
self.validate_private_file_access()
if self.is_folder: if self.is_folder:
return 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: if self.is_remote_file:
self.validate_remote_file() self.validate_remote_file()
else: else:
@ -128,6 +139,29 @@ class File(Document):
if not self.is_folder: if not self.is_folder:
self.create_attachment_record() 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): def validate(self):
if self.is_folder: if self.is_folder:
return return
@ -167,6 +201,36 @@ class File(Document):
except PermissionError: except PermissionError:
frappe.throw(_("Only System Managers can make this file public.")) 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): def after_rename(self, *args, **kwargs):
for successor in self.get_successors(): for successor in self.get_successors():
setup_folder_path(successor, self.name) setup_folder_path(successor, self.name)

View file

@ -254,6 +254,66 @@ class TestSameContent(IntegrationTestCase):
limit_property.delete() limit_property.delete()
frappe.clear_cache(doctype="ToDo") 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): def test_utf8_bom_content_decoding(self):
utf8_bom_content = test_content1.encode("utf-8-sig") utf8_bom_content = test_content1.encode("utf-8-sig")
_file: frappe.Document = frappe.get_doc( _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: def get_safe_file_name(file_name: str) -> str:
return re.sub(r"[/\\%?#]", "_", file_name) 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,8 +93,9 @@ class PackageRelease(Document):
def export_package_files(self, package): def export_package_files(self, package):
# write readme # write readme
with open(frappe.get_site_path("packages", package.package_name, "README.md"), "w") as readme: if package.readme:
readme.write(package.readme) with open(frappe.get_site_path("packages", package.package_name, "README.md"), "w") as readme:
readme.write(package.readme)
# write license # write license
if package.license: if package.license:

View file

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

View file

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

View file

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

View file

@ -5,16 +5,17 @@ import json
import threading import threading
import frappe import frappe
import frappe.desk.query_report
from frappe import _, scrub from frappe import _, scrub
from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles 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.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.desk.reportview import append_totals_row
from frappe.model.document import Document from frappe.model.document import Document
from frappe.modules import make_boilerplate from frappe.modules import make_boilerplate
from frappe.modules.export_file import export_to_files from frappe.modules.export_file import export_to_files
from frappe.utils import cint, cstr from frappe.utils import cint, cstr
from frappe.utils.safe_exec import check_safe_sql_query, safe_exec from frappe.utils.safe_exec import check_safe_sql_query, safe_exec
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder
class Report(Document): class Report(Document):
@ -32,6 +33,7 @@ class Report(Document):
add_total_row: DF.Check add_total_row: DF.Check
add_translate_data: DF.Check add_translate_data: DF.Check
columns: DF.Table[ReportColumn] columns: DF.Table[ReportColumn]
default_print_format: DF.Link | None
disabled: DF.Check disabled: DF.Check
filters: DF.Table[ReportFilter] filters: DF.Table[ReportFilter]
is_standard: DF.Literal["No", "Yes"] 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")) frappe.throw(_("Cannot edit a standard report. Please duplicate and create a new report"))
if self.is_standard == "Yes": if self.is_standard == "Yes":
if frappe.session.user != "Administrator": self.validate_standard_report()
frappe.throw(_("Only Administrator can save a standard report. Please rename and save."))
# Letter Head is visible only for non-standard reports.
# It should not remain set when it's invisible.
self.letter_head = None
if self.report_type == "Report Builder": if self.report_type == "Report Builder":
self.update_report_json() 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): def before_insert(self):
self.set_doctype_roles() self.set_doctype_roles()
@ -89,7 +92,6 @@ class Report(Document):
self.export_doc() self.export_doc()
def before_export(self, doc): def before_export(self, doc):
doc.letter_head = None
doc.prepared_report = 0 doc.prepared_report = 0
def on_trash(self): def on_trash(self):
@ -106,6 +108,13 @@ class Report(Document):
delete_custom_role("report", self.name) 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): def delete_report_folder(self):
from frappe.modules.export_file import delete_folder from frappe.modules.export_file import delete_folder
@ -202,11 +211,14 @@ class Report(Document):
return res 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): def execute_module(self, filters):
# report in python module # report in python module
module = self.module or frappe.db.get_value("DocType", self.ref_doctype, "module") return self.get_module_method("execute")(frappe._dict(filters))
method_name = get_report_module_dotted_path(module, self.name) + ".execute"
return frappe.get_attr(method_name)(frappe._dict(filters))
def execute_script(self, filters): def execute_script(self, filters):
# server script # server script
@ -242,7 +254,7 @@ class Report(Document):
self, filters=None, user=None, ignore_prepared_report=False, are_default_filters=True self, filters=None, user=None, ignore_prepared_report=False, are_default_filters=True
): ):
columns, result = [], [] columns, result = [], []
data = frappe.desk.query_report.run( data = run(
self.name, self.name,
filters=filters, filters=filters,
user=user, user=user,
@ -314,8 +326,6 @@ class Report(Document):
columns = params.get("fields") columns = params.get("fields")
elif params.get("columns"): elif params.get("columns"):
columns = params.get("columns") columns = params.get("columns")
elif params.get("fields"):
columns = params.get("fields")
else: else:
columns = [["name", self.ref_doctype]] columns = [["name", self.ref_doctype]]
columns.extend( columns.extend(
@ -401,6 +411,53 @@ class Report(Document):
return data 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() @frappe.whitelist()
def toggle_disable(self, disable: bool): def toggle_disable(self, disable: bool):
if not self.has_permission("write"): if not self.has_permission("write"):
@ -408,6 +465,18 @@ class Report(Document):
self.db_set("disabled", cint(disable)) 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): def is_prepared_report_enabled(report):
return cint(frappe.db.get_value("Report", report, "prepared_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][0], "Total")
self.assertEqual(result[-1][1], 200) self.assertEqual(result[-1][1], 200)
self.assertEqual(result[-1][2], 150.50) 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"} "Submission Queue", {"ref_doctype": doc.doctype, "ref_docname": doc.name, "status": "Queued"}
): ):
frappe.msgprint( frappe.msgprint(
_( _("This document has already been queued for {0}. You can track the progress over {1}.").format(
"This document has already been queued for submission. You can track the progress over {0}." action, f"<a href='/desk/submission-queue/{existing_queue}'><b>here</b></a>"
).format(f"<a href='/desk/submission-queue/{existing_queue}'><b>here</b></a>"), ),
indicator="orange", indicator="orange",
alert=True, alert=True,
) )
@ -183,8 +183,8 @@ def queue_submission(doc: Document, action: str, alert: bool = True):
if alert: if alert:
frappe.msgprint( frappe.msgprint(
_("Queued for Submission. You can track the progress over {0}.").format( _("Queued for {0}. You can track the progress over {1}.").format(
f"<a href='/desk/submission-queue/{queue.name}'><b>here</b></a>" action, f"<a href='/desk/submission-queue/{queue.name}'><b>here</b></a>"
), ),
indicator="green", indicator="green",
alert=True, alert=True,

View file

@ -51,3 +51,71 @@ class TestSubmissionQueue(IntegrationTestCase):
job = self.queue.fetch_job(submission_queue.job_id) job = self.queue.fetch_job(submission_queue.job_id)
# Test completion # Test completion
self.check_status(job, status="finished") 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

@ -19,7 +19,7 @@ frappe.ui.form.on("System Settings", {
frappe.xcall("frappe.apps.get_apps").then((r) => { frappe.xcall("frappe.apps.get_apps").then((r) => {
let apps = r?.map((r) => r.name) || []; let apps = r?.map((r) => r.name) || [];
frm.set_df_property("default_app", "options", [" ", ...apps]); frm.set_df_property("default_app", "options", ["", ...apps]);
}); });
frm.trigger("set_rounding_method_options"); frm.trigger("set_rounding_method_options");

View file

@ -114,6 +114,8 @@
"enable_telemetry", "enable_telemetry",
"search_section", "search_section",
"link_field_results_limit", "link_field_results_limit",
"column_break_nebx",
"allow_clearing_link_fields",
"api_logging_section", "api_logging_section",
"log_api_requests" "log_api_requests"
], ],
@ -783,13 +785,23 @@
"fieldname": "only_allow_system_managers_to_upload_public_files", "fieldname": "only_allow_system_managers_to_upload_public_files",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Only allow System Managers to upload public files" "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", "icon": "fa fa-cog",
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-02-24 14:27:04.763075", "modified": "2026-04-14 16:26:19.634212",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "System Settings", "name": "System Settings",

View file

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

View file

@ -16,16 +16,20 @@ class TestTranslation(IntegrationTestCase):
clear_cache() clear_cache()
def test_doctype(self): def test_doctype(self):
translation_data = get_translation_data() doctype = "Translation"
for lang, (source_string, new_translation) in translation_data.items(): 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 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) docname = create_translation(lang, source_string, new_translation, context=doctype)
self.assertEqual(_(source_string), new_translation) self.assertEqual(_(source_string, context=doctype), new_translation)
frappe.delete_doc("Translation", docname) frappe.delete_doc(doctype, docname)
self.assertEqual(_(source_string), original_translation) self.assertEqual(_(source_string, context=doctype), original_translation)
def test_parent_language(self): def test_parent_language(self):
data = { data = {
@ -60,37 +64,54 @@ class TestTranslation(IntegrationTestCase):
source = "User" source = "User"
self.assertNotEqual(_(source, lang="de"), _(source, lang="es")) self.assertNotEqual(_(source, lang="de"), _(source, lang="es"))
def test_html_content_data_translation(self): def test_html_content_translation(self):
# ruff: noqa: RUF001
source = """ source = """
<span style="color: rgb(51, 51, 51); font-family: &quot;Amazon Ember&quot;, Arial, sans-serif; font-size: To add dynamic subject, use jinja tags like
small;">MacBook Air lasts up to an incredible 12 hours between charges. So from your morning coffee to <div><pre><code>{{ doc.name }} Billed</code></pre></div>
your evening commute, you can work unplugged. When its time to kick back and relax, """.strip()
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>
"""
target = """ target = """
MacBook Air dura hasta 12 horas increíbles entre cargas. Por lo tanto, Um einen dynamischen Betreff hinzuzufügen, verwenden Sie Jinja-Tags wie
desde el café de la mañana hasta el viaje nocturno, puede trabajar desconectado. <div><pre><code>{{ doc.name }} Abgerechnet</code></pre></div>
Cuando es hora de descansar y relajarse, puede obtener hasta 12 horas de reproducción de películas de iTunes. """.strip()
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.
"""
create_translation("es", source, target) frappe.local.lang = "de"
source = """ self.assertEqual(_(source), 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.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): def test_html_message_translations(self):
"""Test fallback for messages w/ HTML Tags""" """Test fallback for messages w/ HTML Tags"""
@ -100,27 +121,12 @@ class TestTranslation(IntegrationTestCase):
self.assertEqual(_(message, lang="zh"), translated_message) self.assertEqual(_(message, lang="zh"), translated_message)
def get_translation_data(): def create_translation(lang, source_string, new_translation, context=None) -> str:
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:
doc = frappe.new_doc("Translation") doc = frappe.new_doc("Translation")
doc.language = lang doc.language = lang
doc.source_text = source_string doc.source_text = source_string
doc.translated_text = new_translation doc.translated_text = new_translation
doc.context = context
doc.save() doc.save()
return doc.name return doc.name

View file

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

View file

@ -42,7 +42,7 @@ class TestUser(IntegrationTestCase):
@staticmethod @staticmethod
def reset_password(user) -> str: def reset_password(user) -> str:
link = user.reset_password() link = user._reset_password()
return parse_qs(urlparse(link).query)["key"][0] return parse_qs(urlparse(link).query)["key"][0]
def test_user_type(self): def test_user_type(self):
@ -292,7 +292,7 @@ class TestUser(IntegrationTestCase):
c = FrappeClient(url) c = FrappeClient(url)
res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers) 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) 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) self.assertEqual(res2.status_code, 429)
def test_user_rename(self): def test_user_rename(self):
@ -415,6 +415,12 @@ class TestUser(IntegrationTestCase):
# test API endpoint # test API endpoint
with patch.object(user_module.frappe, "sendmail") as sendmail: 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() frappe.clear_messages()
test_user = frappe.get_doc("User", "test2@example.com") test_user = frappe.get_doc("User", "test2@example.com")
self.assertEqual(reset_password(user="test2@example.com"), None) self.assertEqual(reset_password(user="test2@example.com"), None)
@ -425,15 +431,28 @@ class TestUser(IntegrationTestCase):
update_password(old_password, old_password=new_password) update_password(old_password, old_password=new_password)
self.assertEqual( self.assertEqual(
frappe.message_log[0].get("message"), 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() sendmail.assert_called_once()
self.assertEqual(sendmail.call_args[1]["recipients"], "test2@example.com") self.assertEqual(sendmail.call_args[1]["recipients"], "test2@example.com")
self.assertEqual(reset_password(user="test2@example.com"), None) # Constant-response guarantee: every path — existing user, Administrator,
self.assertEqual(reset_password(user="Administrator"), "not allowed") # and non-existent user — must return None AND enqueue the same generic
self.assertEqual(reset_password(user="random"), "not found") # 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): def test_user_onload_modules(self):
from frappe.desk.form.load import getdoc 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()), 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) @IntegrationTestCase.change_settings("System Settings", reset_password_link_expiry_duration=1)
def test_reset_password_link_expiry(self): def test_reset_password_link_expiry(self):
new_password = "new_password" new_password = "new_password"

View file

@ -3,7 +3,7 @@ frappe.ui.form.on("User", {
frm.set_query("default_workspace", () => { frm.set_query("default_workspace", () => {
return { return {
filters: { filters: {
for_user: ["in", [null, frappe.session.user]], for_user: ["in", ["", frappe.session.user]],
title: ["!=", "Welcome Workspace"], title: ["!=", "Welcome Workspace"],
}, },
}; };
@ -69,6 +69,8 @@ frappe.ui.form.on("User", {
frm.roles_editor.reset(); frm.roles_editor.reset();
} }
frm.fields_dict.new_password?.$input?.attr("autocomplete", "new-password");
if ( if (
frm.can_edit_roles && frm.can_edit_roles &&
!frm.is_new() && !frm.is_new() &&
@ -108,7 +110,7 @@ frappe.ui.form.on("User", {
frappe.xcall("frappe.apps.get_apps").then((r) => { frappe.xcall("frappe.apps.get_apps").then((r) => {
let apps = r?.map((r) => r.name) || []; let apps = r?.map((r) => r.name) || [];
frm.set_df_property("default_app", "options", [" ", ...apps]); frm.set_df_property("default_app", "options", ["", ...apps]);
}); });
if (frm.is_new()) { if (frm.is_new()) {

View file

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

View file

@ -1,9 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE # License: MIT. See LICENSE
import re
from collections.abc import Iterable from collections.abc import Iterable
from datetime import timedelta from datetime import timedelta
from functools import cached_property from functools import cached_property, lru_cache
from typing import Any from typing import Any
import frappe import frappe
@ -233,6 +234,7 @@ class User(Document):
self.check_enable_disable() self.check_enable_disable()
self.ensure_unique_roles() self.ensure_unique_roles()
self.ensure_unique_role_profiles() self.ensure_unique_role_profiles()
self.sync_role_profile_name()
self.remove_all_roles_for_guest() self.remove_all_roles_for_guest()
self.validate_username() self.validate_username()
self.remove_disabled_roles() self.remove_disabled_roles()
@ -248,6 +250,9 @@ class User(Document):
if self.language == "Loading...": if self.language == "Loading...":
self.language = None 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")): 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)) 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): def move_role_profile_name_to_role_profiles(self):
"""This handles old role_profile_name field if programatically set. """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: if not self.role_profile_name:
return 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: if self.role_profile_name in current_role_profiles:
self.role_profile_name = None self.role_profile_name = None
return return
@ -297,6 +302,10 @@ class User(Document):
self.append("role_profiles", {"role_profile": self.role_profile_name}) self.append("role_profiles", {"role_profile": self.role_profile_name})
self.role_profile_name = None 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): def validate_allowed_modules(self):
if self.module_profile: if self.module_profile:
module_profile = frappe.get_doc("Module Profile", self.module_profile) module_profile = frappe.get_doc("Module Profile", self.module_profile)
@ -360,7 +369,7 @@ class User(Document):
def clean_name(self): def clean_name(self):
for field in ("first_name", "middle_name", "last_name"): for field in ("first_name", "middle_name", "last_name"):
if field_value := self.get(field): 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): def set_full_name(self):
self.full_name = " ".join(p for p in [self.first_name, self.middle_name, self.last_name] if p) 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) toggle_notifications(self.name, enable=cint(self.enabled), ignore_permissions=True)
self.disable_email_fields_if_user_disabled() 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: if new_password and not self.flags.in_insert:
_update_password(user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions) _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): def set_system_user(self):
"""For the standard users like admin and guest, the user type is fixed.""" """For the standard users like admin and guest, the user type is fixed."""
@ -435,25 +458,26 @@ class User(Document):
def send_password_notification(self, new_password): def send_password_notification(self, new_password):
try: try:
if self.flags.in_insert: if self.flags.in_insert:
if self.name not in STANDARD_USERS: if self.name in STANDARD_USERS:
if new_password: return
# new password given, no email required if new_password:
_update_password( # new password given, no email required
user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions _update_password(
) user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions
)
if ( if (
not self.flags.no_welcome_mail not self.flags.no_welcome_mail
and cint(self.send_welcome_email) and cint(self.send_welcome_email)
and not self.flags.email_sent and not self.flags.email_sent
): ):
self.send_welcome_mail_to_user() self.send_welcome_mail_to_user()
self.flags.email_sent = 1 self.flags.email_sent = 1
if frappe.session.user != "Guest": if frappe.session.user != "Guest":
msgprint(_("Welcome email sent")) msgprint(_("Welcome email sent"))
return return
else: else:
self.email_new_password(new_password) self.set_new_password(new_password)
except frappe.OutgoingEmailError: except frappe.OutgoingEmailError:
frappe.clear_last_message() frappe.clear_last_message()
@ -467,7 +491,7 @@ class User(Document):
def validate_reset_password(self): def validate_reset_password(self):
pass 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 from frappe.utils import get_url
key = frappe.generate_hash() key = frappe.generate_hash()
@ -492,18 +516,24 @@ class User(Document):
def password_reset_mail(self, link): def password_reset_mail(self, link):
reset_password_template = frappe.db.get_system_setting("reset_password_template") reset_password_template = frappe.db.get_system_setting("reset_password_template")
self.send_login_mail( q = self.send_login_mail(
_("Password Reset"), _("Password Reset"),
"password_reset", "password_reset",
{"link": link}, {"link": link},
now=True, now=True,
custom_template=reset_password_template, 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): def send_welcome_mail_to_user(self):
from frappe.utils import get_url from frappe.utils import get_url
link = self.reset_password() link = self._reset_password()
subject = None subject = None
method = frappe.get_hooks("welcome_email") method = frappe.get_hooks("welcome_email")
if method: if method:
@ -517,7 +547,7 @@ class User(Document):
welcome_email_template = frappe.db.get_system_setting("welcome_email_template") welcome_email_template = frappe.db.get_system_setting("welcome_email_template")
self.send_login_mail( q = self.send_login_mail(
subject, subject,
"new_user", "new_user",
dict( dict(
@ -526,6 +556,12 @@ class User(Document):
), ),
custom_template=welcome_email_template, 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): def send_login_mail(self, subject, template, add_args, now=None, custom_template=None):
"""send mail with login details""" """send mail with login details"""
@ -557,7 +593,7 @@ class User(Document):
subject = email_template.get("subject") subject = email_template.get("subject")
content = email_template.get("message") content = email_template.get("message")
frappe.sendmail( return frappe.sendmail(
recipients=self.email, recipients=self.email,
sender=sender, sender=sender,
subject=subject, subject=subject,
@ -619,18 +655,16 @@ class User(Document):
frappe.db.delete("List Filter", {"for_user": self.name}) frappe.db.delete("List Filter", {"for_user": self.name})
# Remove user from Note's Seen By table # Remove user from Note's Seen By table
seen_notes = frappe.get_all("Note", filters=[["Note Seen By", "user", "=", self.name]], pluck="name") seen_notes = frappe.get_docs("Note", filters=[["Note Seen By", "user", "=", self.name]])
for note_id in seen_notes: for note in seen_notes:
note = frappe.get_doc("Note", note_id)
for row in note.seen_by: for row in note.seen_by:
if row.user == self.name: if row.user == self.name:
note.remove(row) note.remove(row)
note.save(ignore_permissions=True) note.save(ignore_permissions=True)
# Unlink user from all of its invitation docs # Unlink user from all of its invitation docs
invites = frappe.db.get_all("User Invitation", filters={"email": self.name}, pluck="name") invites = frappe.get_docs("User Invitation", filters={"email": self.name})
for invite in invites: for invite_doc in invites:
invite_doc = frappe.get_doc("User Invitation", invite)
invite_doc.user = None invite_doc.user = None
invite_doc.save(ignore_permissions=True) invite_doc.save(ignore_permissions=True)
@ -865,9 +899,14 @@ class User(Document):
@frappe.whitelist() @frappe.whitelist()
def get_timezones(): 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() @frappe.whitelist()
@ -1009,6 +1048,9 @@ def has_email_account(email: str):
@frappe.whitelist(allow_guest=False) @frappe.whitelist(allow_guest=False)
def get_email_awaiting(user: str): def get_email_awaiting(user: str):
if user != frappe.session.user:
frappe.has_permission("User", "read", doc=user, throw=True)
return frappe.get_all( return frappe.get_all(
"User Email", "User Email",
fields=["email_account", "email_id"], 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"]) @frappe.whitelist(allow_guest=True, methods=["POST"])
@rate_limit(limit=get_password_reset_limit, seconds=60 * 60) @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: try:
user: User = frappe.get_doc("User", user) user_doc: User = frappe.get_doc("User", user)
if user.name == "Administrator": if user_doc.name != "Administrator" and user_doc.enabled:
return "not allowed" user_doc.validate_reset_password()
if not user.enabled: user_doc._reset_password(send_email=True)
return "disabled" # For Administrator or disabled users: silently skip — same response below
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"),
)
except frappe.DoesNotExistError: except frappe.DoesNotExistError:
frappe.local.response["http_status_code"] = 404
frappe.clear_messages() 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() @frappe.whitelist()

View file

@ -4,6 +4,9 @@
frappe.listview_settings["User"] = { frappe.listview_settings["User"] = {
add_fields: ["enabled", "user_type", "user_image"], add_fields: ["enabled", "user_type", "user_image"],
filters: [["enabled", "=", 1]], filters: [["enabled", "=", 1]],
onload(listview) {
this.set_default_app_options(listview);
},
prepare_data: function (data) { prepare_data: function (data) {
data["user_for_avatar"] = data["name"]; data["user_for_avatar"] = data["name"];
}, },
@ -14,6 +17,15 @@ frappe.listview_settings["User"] = {
return [__("Disabled"), "grey", "enabled,=,0"]; 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"; frappe.help.youtube_id["User"] = "8Slw1hsTmUI";

View file

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

View file

@ -51,9 +51,6 @@ def create_user_type(user_type):
if frappe.db.exists("User Type", user_type): if frappe.db.exists("User Type", user_type):
frappe.delete_doc("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( doc = frappe.get_doc(
{ {
"doctype": "User Type", "doctype": "User Type",

View file

@ -48,7 +48,6 @@ class UserType(Document):
if self.is_standard: if self.is_standard:
return return
self.validate_document_type_limit()
self.validate_role() self.validate_role()
self.add_role_permissions_for_user_doctypes() self.add_role_permissions_for_user_doctypes()
self.add_role_permissions_for_select_doctypes() self.add_role_permissions_for_select_doctypes()
@ -75,37 +74,6 @@ class UserType(Document):
for module in modules: for module in modules:
self.append("user_type_modules", {"module": module}) 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): def validate_role(self):
if not self.role: if not self.role:
frappe.throw(_("The field {0} is mandatory").format(frappe.bold(_("Role")))) frappe.throw(_("The field {0} is mandatory").format(frappe.bold(_("Role"))))

View file

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

View file

@ -443,3 +443,53 @@ def _update_fieldname_references(field: CustomField, old_fieldname: str, new_fie
"insert_after", "insert_after",
new_fieldname, 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 ( from frappe.custom.doctype.custom_field.custom_field import (
create_custom_field, create_custom_field,
create_custom_fields, create_custom_fields,
delete_custom_fields,
rename_fieldname, rename_fieldname,
) )
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.tests import IntegrationTestCase from frappe.tests import IntegrationTestCase
@ -183,3 +185,50 @@ class TestCustomField(IntegrationTestCase):
self.assertFalse(doc.get(old)) self.assertFalse(doc.get(old))
field.delete() 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", "track_views",
"allow_auto_repeat", "allow_auto_repeat",
"allow_import", "allow_import",
"allow_bulk_edit",
"queue_in_background", "queue_in_background",
"naming_section", "naming_section",
"naming_rule", "naming_rule",
@ -222,6 +223,14 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow Import (via Data Import Tool)" "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", "depends_on": "email_append_to",
"fieldname": "subject_field", "fieldname": "subject_field",

View file

@ -13,13 +13,14 @@ import frappe.translate
from frappe import _ from frappe import _
from frappe.core.doctype.doctype.doctype import ( from frappe.core.doctype.doctype.doctype import (
check_email_append_to, check_email_append_to,
get_fields_not_allowed_in_list_view,
validate_autoincrement_autoname, validate_autoincrement_autoname,
validate_fields_for_doctype, validate_fields_for_doctype,
validate_series, validate_series,
) )
from frappe.custom.doctype.custom_field.custom_field import create_custom_field 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.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.docfield import supports_translation
from frappe.model.document import Document from frappe.model.document import Document
from frappe.model.meta import trim_table from frappe.model.meta import trim_table
@ -41,6 +42,7 @@ class CustomizeForm(Document):
actions: DF.Table[DocTypeAction] actions: DF.Table[DocTypeAction]
allow_auto_repeat: DF.Check allow_auto_repeat: DF.Check
allow_bulk_edit: DF.Check
allow_copy: DF.Check allow_copy: DF.Check
allow_import: DF.Check allow_import: DF.Check
autoname: DF.Data | None autoname: DF.Data | None
@ -319,12 +321,12 @@ class CustomizeForm(Document):
def set_property_setters_for_docfield(self, meta, df, meta_df): def set_property_setters_for_docfield(self, meta, df, meta_df):
for prop, prop_type in docfield_properties.items(): for prop, prop_type in docfield_properties.items():
if prop != "idx" and (df.get(prop) or "") != (meta_df[0].get(prop) or ""): 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 continue
self.make_property_setter(prop, df.get(prop), prop_type, fieldname=df.fieldname) 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": if prop == "fieldtype":
self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop)) self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop))
@ -360,8 +362,7 @@ class CustomizeForm(Document):
elif ( elif (
prop == "in_list_view" prop == "in_list_view"
and df.get(prop) and df.get(prop)
and df.fieldtype != "Attach Image" and df.fieldtype in get_fields_not_allowed_in_list_view(meta)
and df.fieldtype in no_value_fields
): ):
frappe.msgprint( frappe.msgprint(
_("'In List View' not allowed for type {0} in row {1}").format(df.fieldtype, df.idx) _("'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"): 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 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 return True
def set_property_setters_for_actions_and_links(self, meta): def set_property_setters_for_actions_and_links(self, meta):
@ -740,6 +745,7 @@ doctype_properties = {
"track_views": "Check", "track_views": "Check",
"allow_auto_repeat": "Check", "allow_auto_repeat": "Check",
"allow_import": "Check", "allow_import": "Check",
"allow_bulk_edit": "Check",
"show_name_in_global_search": "Check", "show_name_in_global_search": "Check",
"show_preview_popup": "Check", "show_preview_popup": "Check",
"default_email_template": "Data", "default_email_template": "Data",
@ -787,6 +793,7 @@ docfield_properties = {
"print_hide": "Check", "print_hide": "Check",
"print_hide_if_no_value": "Check", "print_hide_if_no_value": "Check",
"report_hide": "Check", "report_hide": "Check",
"in_import_template": "Check",
"allow_on_submit": "Check", "allow_on_submit": "Check",
"translatable": "Check", "translatable": "Check",
"mandatory_depends_on": "Data", "mandatory_depends_on": "Data",

View file

@ -48,6 +48,7 @@
"ignore_user_permissions", "ignore_user_permissions",
"allow_on_submit", "allow_on_submit",
"report_hide", "report_hide",
"in_import_template",
"remember_last_selected_value", "remember_last_selected_value",
"hide_border", "hide_border",
"ignore_xss_filter", "ignore_xss_filter",
@ -293,6 +294,13 @@
"oldfieldname": "report_hide", "oldfieldname": "report_hide",
"oldfieldtype": "Check" "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", "default": "0",
"depends_on": "eval:(doc.fieldtype == 'Link')", "depends_on": "eval:(doc.fieldtype == 'Link')",
@ -523,7 +531,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-03-22 10:36:12.968197", "modified": "2026-04-27 12:00:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom", "module": "Custom",
"name": "Customize Form Field", "name": "Customize Form Field",

View file

@ -81,6 +81,7 @@ class CustomizeFormField(Document):
ignore_xss_filter: DF.Check ignore_xss_filter: DF.Check
in_filter: DF.Check in_filter: DF.Check
in_global_search: DF.Check in_global_search: DF.Check
in_import_template: DF.Check
in_list_view: DF.Check in_list_view: DF.Check
in_preview: DF.Check in_preview: DF.Check
in_standard_filter: 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: if row_name:
filters["row_name"] = 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: for ps in property_setters:
frappe.get_doc("Property Setter", ps).delete(ignore_permissions=True, force=True) 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 # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE # License: MIT. See LICENSE
import frappe
from frappe.custom.doctype.property_setter.property_setter import (
bulk_delete_property_setters,
)
from frappe.tests import IntegrationTestCase from frappe.tests import IntegrationTestCase
class TestPropertySetter(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: if query_type in WRITE_QUERY_TYPES:
self.transaction_writes += 1 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.transaction_writes > self.MAX_WRITES_PER_TRANSACTION:
if self.auto_commit_on_many_writes: if self.auto_commit_on_many_writes:
self.commit() self.commit()

View file

@ -8,7 +8,6 @@ import frappe
from frappe.database.utils import NestedSetHierarchy from frappe.database.utils import NestedSetHierarchy
from frappe.model.db_query import get_timespan_date_range from frappe.model.db_query import get_timespan_date_range
from frappe.query_builder import Field from frappe.query_builder import Field
from frappe.query_builder.functions import Coalesce
from frappe.utils import cstr from frappe.utils import cstr
@ -48,6 +47,10 @@ def func_in(key: Field, value: list | tuple) -> frappe.qb:
""" """
if isinstance(value, str): if isinstance(value, str):
value = value.split(",") 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) 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): elif isinstance(value, datetime.datetime):
return value.date() return value.date()
except AttributeError, TypeError, KeyError: except (AttributeError, TypeError, KeyError):
pass pass
return value return value
@ -136,11 +136,7 @@ WORDS_PATTERN = re.compile(r"\w+")
COMMA_PATTERN = re.compile(r",\s*(?![^()]*\))") COMMA_PATTERN = re.compile(r",\s*(?![^()]*\))")
# Pattern for validating simple field names (alphanumeric + underscore) # Pattern for validating simple field names (alphanumeric + underscore)
SIMPLE_FIELD_PATTERN = re.compile(r"^\w+$", flags=re.ASCII) SIMPLE_FIELD_PATTERN = re.compile(r"^\w+$")
# 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)
# Pattern for detecting SQL function calls: identifier followed by opening parenthesis # 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) 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' # - ... as 'Child:field'
ALLOWED_FIELD_PATTERN = re.compile( ALLOWED_FIELD_PATTERN = re.compile(
r"^(?:(`[\w\s-]+`|\w+)\.)?(`\w+`|\w+)(?:\s+as\s+(?:`[\w\s-]+`|'[\w\s:-]+'|\w+))?$", 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: # Regex to parse field names:
@ -176,6 +172,9 @@ BACKTICK_FIELD_PARSE_REGEX = re.compile(r"^`tab([\w\s-]+)`\.(`?)(\w+)\2$")
# Group 3: Fieldname # Group 3: Fieldname
CHILD_TABLE_FIELD_PATTERN = re.compile(r'^[`"]?tab([\w\s]+)[`"]?\.([`"]?)(\w+)\2$') 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 # Direct mapping from uppercase function names to pypika function classes
FUNCTION_MAPPING = { FUNCTION_MAPPING = {
"COUNT": functions.Count, "COUNT": functions.Count,
@ -303,6 +302,11 @@ class Engine:
if offset: if offset:
if not isinstance(offset, int) or offset < 0: if not isinstance(offset, int) or offset < 0:
frappe.throw(_("Offset must be a non-negative integer"), TypeError) 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) self.query = self.query.offset(offset)
if distinct: if distinct:
@ -676,7 +680,7 @@ class Engine:
else: else:
try: try:
fallback_value = int(fallback_sql) fallback_value = int(fallback_sql)
except ValueError, TypeError: except (ValueError, TypeError):
fallback_value = fallback_sql fallback_value = fallback_sql
return operator_fn(_field, ValueWrapper(fallback_value)) return operator_fn(_field, ValueWrapper(fallback_value))
@ -705,7 +709,7 @@ class Engine:
else: else:
try: try:
fallback_value = int(fallback_sql) fallback_value = int(fallback_sql)
except ValueError, TypeError: except (ValueError, TypeError):
fallback_value = fallback_sql fallback_value = fallback_sql
if fallback_value == _value: if fallback_value == _value:
@ -2424,14 +2428,15 @@ class SQLFunctionParser:
).format(arg), ).format(arg),
frappe.ValidationError, 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.) # Check if it's a numeric string like "1" (for COUNT(1), etc.)
elif arg.isdigit(): elif arg.isdigit():
return int(arg) return int(arg)
elif self._is_valid_field_name(arg):
self._check_function_field_permission(arg)
return self.engine.table[arg]
else: else:
frappe.throw( frappe.throw(
_( _(
@ -2443,7 +2448,7 @@ class SQLFunctionParser:
def _is_valid_field_name(self, name: str) -> bool: def _is_valid_field_name(self, name: str) -> bool:
"""Check if a string is a valid field name.""" """Check if a string is a valid field name."""
# Field names should only contain alphanumeric characters and underscores # 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): def _validate_alias(self, alias: str):
"""Validate alias name for SQL injection.""" """Validate alias name for SQL injection."""
@ -2456,7 +2461,7 @@ class SQLFunctionParser:
# Alias should be a simple identifier # Alias should be a simple identifier
# Note: pypika wraps aliases in backticks, so anything without backticks is safe # 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( frappe.throw(
_("Invalid alias format: {0}. Alias must be a simple identifier.").format(alias), _("Invalid alias format: {0}. Alias must be a simple identifier.").format(alias),
frappe.ValidationError, frappe.ValidationError,

View file

@ -5,7 +5,7 @@ from frappe import _
from frappe.utils import cint, cstr, flt from frappe.utils import cint, cstr, flt
from frappe.utils.defaults import get_not_null_defaults 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) SPECIAL_CHAR_PATTERN = re.compile(r"[\W]", flags=re.UNICODE)
VARCHAR_CAST_PATTERN = re.compile(r"varchar\(([\d]+)\)") 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 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) frappe.db.set_value("Onboarding Step", name, field, value)
capture(frappe.scrub(name), app="frappe_onboarding", properties={field: value}) capture(frappe.scrub(name), app="frappe_onboarding", properties={field: value})
@ -682,6 +685,9 @@ def get_onboarding_data(module: str):
Return: Return:
dict: onboarding data dict: onboarding data
""" """
if not frappe.get_system_settings("enable_onboarding"):
return []
onboardings = [] onboardings = []
onboarding_doc = frappe.get_doc("Module Onboarding", module) onboarding_doc = frappe.get_doc("Module Onboarding", module)
if onboarding_doc.is_complete: if onboarding_doc.is_complete:

View file

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

View file

@ -31,17 +31,18 @@ class BulkUpdate(Document):
def bulk_update(self): def bulk_update(self):
self.check_permission("write") self.check_permission("write")
limit = self.limit if self.limit and cint(self.limit) < 500 else 500 limit = self.limit if self.limit and cint(self.limit) < 500 else 500
query_args = {"doctype": self.document_type, "limit": limit, "pluck": "name"}
condition = ""
if self.condition: if self.condition:
if ";" in self.condition: try:
frappe.throw(_("; not allowed in condition")) 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.get_all(**query_args)
docnames = frappe.db.sql_list(
f"""select name from `tab{self.document_type}`{condition} limit {limit} offset 0"""
)
return submit_cancel_or_update_docs( return submit_cancel_or_update_docs(
self.document_type, docnames, "update", {self.field: self.update_value} 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") 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) 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")) 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 import frappe
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder.utils import DocType from frappe.query_builder.utils import DocType
from frappe.utils import has_common
class CustomHTMLBlock(Document): class CustomHTMLBlock(Document):
@ -23,7 +24,12 @@ class CustomHTMLBlock(Document):
style: DF.Code | None style: DF.Code | None
# end: auto-generated types # 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() @frappe.whitelist()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,8 +11,8 @@ frappe.ui.form.on("Workspace", {
frm.trigger("add_to_desktop"); frm.trigger("add_to_desktop");
let url = `/desk/${ let url = `/desk/${
frm.doc.public frm.doc.public
? frappe.router.slug(frm.doc.title) ? frappe.router.slug(frm.doc.name)
: "private/" + frappe.router.slug(frm.doc.title) : "private/" + frappe.router.slug(frm.doc.name)
}`; }`;
frm.sidebar frm.sidebar
.add_user_action(__("Go to Workspace")) .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(): 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")) 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"): if self.has_value_changed("title"):
validate_route_conflict(self.doctype, self.title) validate_route_conflict(self.doctype, self.title)
else: else:

View file

@ -16,6 +16,7 @@
"child", "child",
"navigate_to_tab", "navigate_to_tab",
"url", "url",
"open_in_new_tab",
"display_section", "display_section",
"collapsible_column", "collapsible_column",
"collapsible", "collapsible",
@ -168,13 +169,20 @@
"fieldname": "filter_area", "fieldname": "filter_area",
"fieldtype": "HTML", "fieldtype": "HTML",
"label": "Filter Area" "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, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-01-12 15:35:56.930873", "modified": "2026-03-15 02:26:37.285903",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Desk", "module": "Desk",
"name": "Workspace Sidebar Item", "name": "Workspace Sidebar Item",

View file

@ -24,6 +24,7 @@ class WorkspaceSidebarItem(Document):
link_to: DF.DynamicLink | None link_to: DF.DynamicLink | None
link_type: DF.Literal["DocType", "Page", "Report", "Workspace", "Dashboard", "URL"] link_type: DF.Literal["DocType", "Page", "Report", "Workspace", "Dashboard", "URL"]
navigate_to_tab: DF.Autocomplete | None navigate_to_tab: DF.Autocomplete | None
open_in_new_tab: DF.Check
parent: DF.Data parent: DF.Data
parentfield: DF.Data parentfield: DF.Data
parenttype: 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")) frappe.toast(_("Administrator can't follow"))
return False 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): 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.")) frappe.toast(_("Document follow is not enabled for this user."))
return False return False
@ -74,6 +77,9 @@ def follow_document(doctype: str, doc_name: str, user: str) -> Document | bool:
@frappe.whitelist() @frappe.whitelist()
def unfollow_document(doctype: str, doc_name: str, user: str) -> bool: 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( doc = frappe.get_all(
"Document Follow", "Document Follow",
filters={"ref_doctype": doctype, "ref_docname": doc_name, "user": user}, 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: if workflow_state_fieldname and workflow_state:
doc.set(workflow_state_fieldname, 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() doc.cancel()
send_updated_docs(doc) send_updated_docs(doc)
frappe.msgprint(frappe._("Cancelled"), indicator="red", alert=True) frappe.msgprint(frappe._("Cancelled"), indicator="red", alert=True)

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