Merge branch 'develop' into add-custom-hook

This commit is contained in:
Aarol D'Souza 2026-01-30 23:32:46 +05:30 committed by GitHub
commit a74725f826
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
499 changed files with 77628 additions and 62636 deletions

View file

@ -64,3 +64,6 @@ e9bbe03354079cfcef65a77b0c33f57b047a7c93
# another ruff update
6ca4d4d167a1a009d99062747711de7a994aa633
# some more ruff
8723a2b6ee9dbec800077f18202ba53b0ef553e7

View file

@ -9,7 +9,7 @@ Welcome to the Frappe Framework issue tracker! Before creating an issue, please
1. This tracker should only be used to report bugs and request features / enhancements to Frappe
- For questions and general support, use https://stackoverflow.com/questions/tagged/frappe
- For documentation issues, refer to https://frappeframework.com/docs/user/en or the developer cheetsheet https://github.com/frappe/frappe/wiki/Developer-Cheatsheet
- For documentation issues, refer to https://docs.frappe.io/framework/user/en/introduction or the developer cheatsheet https://github.com/frappe/frappe/wiki/Developer-Cheatsheet
2. Use the search function before creating a new issue. Duplicates will be closed and directed to
the original discussion.
3. When making a bug report, make sure you provide all required information. The easier it is for

View file

@ -9,7 +9,7 @@ Welcome to the Frappe Framework issue tracker! Before creating an issue, please
1. This tracker should only be used to report bugs and request features / enhancements to Frappe
- For questions and general support, refer to https://stackoverflow.com/questions/tagged/frappe
- For documentation issues, use https://frappeframework.com/docs/user/en or the developer cheetsheet https://frappeframework.com/docs/user/en/bench/resources/bench-commands-cheatsheet
- For documentation issues, use https://docs.frappe.io/framework/user/en/introduction or the developer cheatsheet https://frappeframework.com/docs/user/en/bench/resources/bench-commands-cheatsheet
2. Use the search function before creating a new issue. Duplicates will be closed and directed to
the original discussion.
3. When making a feature request, make sure to be as verbose as possible. The better you convey your message, the greater the drive to make it happen.

View file

@ -4,11 +4,11 @@ inputs:
python-version:
description: 'Python version to use'
required: false
default: '3.12.6'
default: '3.14'
node-version:
description: 'Node.js version to use'
required: false
default: '22'
default: '24'
build-assets:
required: false
description: 'Wether to build assets'
@ -45,12 +45,12 @@ runs:
git config --global advice.detachedHead false
- name: Clone
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
path: apps/${{ github.event.repository.name }}
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ inputs.python-version }}
@ -64,14 +64,14 @@ runs:
fi
- name: Checkout Frappe
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: ${{ env.FRAPPE_GH_ORG || github.repository_owner }}/frappe
ref: ${{ github.event.client_payload.frappe_sha || github.base_ref || github.ref_name }}
path: apps/frappe
if: github.event.repository.name != 'frappe'
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
check-latest: true

View file

@ -141,9 +141,17 @@ def is_ci(file):
def is_frontend_code(file):
"""Check if the file is frontend code."""
return file.lower().endswith((".css", ".scss", ".less", ".sass", ".styl", ".js", ".ts", ".vue", ".html"))
return file.lower().endswith((".css", ".scss", ".less", ".sass", ".styl", ".js", ".ts", ".vue", ".html", ".svg"))
def matches_postgres_filenames(files_list):
"""Check if any changed files suggest database involvement."""
db_keywords = ["database", "query", "schema", "postgres"]
return any(
any(word in f.lower() for word in db_keywords)
for f in files_list
)
def is_docs(file):
"""Check if the file is documentation or image."""
regex = re.compile(r"\.(md|png|jpg|jpeg|csv|svg)$|^.github|LICENSE")
@ -174,6 +182,10 @@ if __name__ == "__main__":
only_frontend_code_changed = len(list(filter(is_frontend_code, files_list))) == len(files_list)
updated_py_file_count = len(list(filter(is_server_side_code, files_list)))
only_py_changed = updated_py_file_count == len(files_list)
run_postgres = (
has_label(pr_number, "postgres", repo) or
matches_postgres_filenames(files_list)
)
# Check for Skip CI label and other conditions
if has_skip_ci_label(pr_number, repo):
@ -202,3 +214,4 @@ if __name__ == "__main__":
# If we reach here, run the build
os.system('echo "build=strawberry" >> $GITHUB_OUTPUT')
os.system(f'echo "run_postgres={"true" if run_postgres else "false"}" >> $GITHUB_OUTPUT')

4
.github/stale.yml vendored
View file

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

View file

@ -12,11 +12,11 @@ on:
python-version:
required: false
type: string
default: '3.10'
default: '3.14'
node-version:
required: false
type: number
default: 22
default: 24
db-artifact-url:
required: false
type: string
@ -49,6 +49,15 @@ jobs:
disable-socketio: true
disable-web: true
db-root-password: ${{ env.DB_ROOT_PASSWORD }}
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: |
3.11
3.13
${{ inputs.python-version }}
- name: Execute pre-migration tasks
if: inputs.pre
@ -108,7 +117,9 @@ jobs:
fi
echo "Setting up environment..."
if rm -rf ${GITHUB_WORKSPACE}/env && python -m venv ${GITHUB_WORKSPACE}/env; then
# Last python version in the array is the "default", so the 2nd parameter here is optional
if rm -rf ${GITHUB_WORKSPACE}/env && python"$2" -m venv ${GITHUB_WORKSPACE}/env; then
source ${GITHUB_WORKSPACE}/env/bin/activate
pip install --quiet --upgrade pip
pip install --quiet frappe-bench
@ -145,16 +156,16 @@ jobs:
# Save this script into a file for later use.
declare -f update_to_version > "$RUNNER_TEMP/migrate"
- name: Update to v14
run: |
source $RUNNER_TEMP/migrate
update_to_version 14
exit $?
- name: Update to v15
run: |
source $RUNNER_TEMP/migrate
update_to_version 15
update_to_version 15 3.13
exit $?
- name: Update to v16
run: |
source $RUNNER_TEMP/migrate
update_to_version 16
exit $?
- name: Update to last commit

View file

@ -13,7 +13,7 @@ on:
node-version:
required: false
type: number
default: 22
default: 24
parallel-runs:
required: false
type: number

View file

@ -13,7 +13,7 @@ on:
node-version:
required: false
type: number
default: 22
default: 24
parallel-runs:
required: false
type: number

View file

@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save

View file

@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
branch: ["develop"]
branch: ["develop", "version-16-hotfix"]
permissions:
contents: write
@ -27,6 +27,11 @@ jobs:
with:
python-version: "3.14"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
- name: Run script to update POT file
run: |
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh

View file

@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
version: ["14", "15"]
version: ["14", "15", "16"]
steps:
- uses: octokit/request-action@v2.x

View file

@ -24,7 +24,7 @@ jobs:
fetch-depth: 200
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
check-latest: true
- name: Check commit titles

View file

@ -22,7 +22,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: actions/setup-python@v6
with:

View file

@ -16,7 +16,7 @@ jobs:
path: 'frappe'
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: actions/setup-python@v6
with:
python-version: '3.14'

View file

@ -79,7 +79,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
check-latest: true
- name: Add to Hosts

View file

@ -28,6 +28,7 @@ jobs:
needs: typecheck
outputs:
build: ${{ steps.check-build.outputs.build }}
run_postgres: ${{ steps.check-build.outputs.run_postgres }}
steps:
- name: Clone
uses: actions/checkout@v6
@ -44,7 +45,7 @@ jobs:
name: Tests
uses: ./.github/workflows/_base-server-tests.yml
with:
enable-postgres: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }} # 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
parallel-runs: 2
enable-coverage: ${{ github.event_name != 'pull_request' }}
@ -57,9 +58,9 @@ jobs:
needs: checkrun
uses: ./.github/workflows/_base-migration.yml
with:
db-artifact-url: https://frappeframework.com/files/v13-frappe.sql.gz
python-version: '3.10'
node-version: 22
db-artifact-url: https://frappe.io/files/v14-frappe.sql.gz
python-version: '3.14'
node-version: 24
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
coverage:

View file

@ -2,24 +2,24 @@ pull_request_rules:
- name: Auto-close PRs on stable branch
conditions:
- and:
- and:
- author!=surajshetty3416
- author!=deepeshgarg007
- author!=ankush
- author!=frappe-pr-bot
- author!=mergify[bot]
- or:
- base=version-16
- base=version-15
- base=version-14
- base=version-13
- base=version-12
- and:
- author!=surajshetty3416
- author!=deepeshgarg007
- author!=ankush
- author!=frappe-pr-bot
- author!=mergify[bot]
- or:
- base=version-16
- base=version-15
- base=version-14
- base=version-13
- base=version-12
actions:
close:
comment:
message: |
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
message: |
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
- name: backport to develop
conditions:
@ -31,16 +31,6 @@ pull_request_rules:
assignees:
- "{{ author }}"
- name: backport to version-13-hotfix
conditions:
- label="backport version-13-hotfix"
actions:
backport:
branches:
- version-13-hotfix
assignees:
- "{{ author }}"
- name: backport to version-14-hotfix
conditions:
- label="backport version-14-hotfix"
@ -61,3 +51,12 @@ pull_request_rules:
assignees:
- "{{ author }}"
- name: backport to version-16-hotfix
conditions:
- label="backport version-16-hotfix"
actions:
backport:
branches:
- version-16-hotfix
assignees:
- "{{ author }}"

View file

@ -6,7 +6,7 @@ fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
files: "frappe.*"
@ -22,7 +22,7 @@ repos:
exclude: ^frappe/tests/classes/context_managers\.py$
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.2
rev: v0.14.10
hooks:
- id: ruff
name: "Run ruff import sorter"
@ -71,7 +71,7 @@ repos:
)$
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.22.0
rev: v9.23.0
hooks:
- id: commitlint
stages: [commit-msg]

View file

@ -1,19 +1,22 @@
{
"branches": ["develop", {"name": "version-14-beta", "channel": "beta", "prerelease": true}],
"branches": ["version-17"],
"plugins": [
"@semantic-release/commit-analyzer", {
"preset": "angular"
"preset": "angular",
"releaseRules": [
{"breaking": true, "release": false}
]
},
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec", {
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" frappe/__init__.py'
"prepareCmd": 'sed -ir -E "s/\"[0-9]+\.[0-9]+\.[0-9]+\"/\"${nextRelease.version}\"/" frappe/__init__.py'
}
],
[
"@semantic-release/git", {
"assets": ["frappe/__init__.py"],
"message": "chore(release): Bumped to Version ${nextRelease.version}"
"message": "chore(release): Bumped to Version ${nextRelease.version}\n\n${nextRelease.notes}"
}
],
"@semantic-release/github"

View file

@ -27,7 +27,7 @@ Full-stack web application framework that uses Python and MariaDB on the server
Started in 2005, Frappe Framework was inspired by the Semantic Web. The "big idea" behind semantic web was of a framework that not only described how information is shown (like headings, body etc), but also what it means, like name, address etc.
By creating a web framework that allowed for easy definition of metadata, it made building complex applications easy. Applications usually designed around how users interact with a system, but not based on semantics of the underlying system. Applications built on semantics end up being much more consistent and extensible.
By creating a web framework that allowed for easy definition of metadata, it made building complex applications easy. Applications are usually designed around how users interact with a system, but not based on semantics of the underlying system. Applications built on semantics end up being much more consistent and extensible.
The first application built on Framework was ERPNext, a beast with more than 700 object types. Framework is not for the light hearted - it is not the first thing you might want to learn if you are beginning to learn web programming, but if you are ready to do real work, then Framework is the right tool for the job.

View file

@ -118,7 +118,7 @@ context("Attach Control", () => {
//Navigating to the new form for the newly created doctype
let doctype = "Test Attach Control";
let dt_in_route = doctype.toLowerCase().replace(/ /g, "-");
cy.visit(`/app/${dt_in_route}/new`, {
cy.visit(`/desk/${dt_in_route}/new`, {
onBeforeLoad(win) {
// Mock "window.navigator.mediaDevices" property
// to return mock mediaDevices object
@ -144,7 +144,7 @@ context("Attach Control", () => {
//Navigating to the new form for the newly created doctype
let doctype = "Test Attach Control";
let dt_in_route = doctype.toLowerCase().replace(/ /g, "-");
cy.visit(`/app/${dt_in_route}/new`, {
cy.visit(`/desk/${dt_in_route}/new`, {
onBeforeLoad(win) {
// Delete "window.navigator.mediaDevices" property
delete win.navigator.mediaDevices;

View file

@ -65,7 +65,7 @@ context("Data Control", () => {
.should("have.class", "reqd");
//Checking if the status is "Not Saved" initially
cy.get(".indicator-pill").should("have.text", "Not Saved");
cy.get(".page-head-content .indicator-pill").should("have.text", "Not Saved");
//Inputting data in the field
cy.fill_field("name1", "@@###", "Data");

View file

@ -79,7 +79,7 @@ context("Control Link", () => {
it("should unset invalid value", () => {
get_dialog_with_link().as("dialog");
cy.intercept("/api/method/frappe.client.validate_link*").as("validate_link");
cy.intercept("/api/method/frappe.client.validate_link_and_fetch*").as("validate_link");
cy.get(".frappe-control[data-fieldname=link] input").focus().as("input");
// Wait for dropdown to appear (request might be cached)
cy.get("@input").parent().findByRole("listbox").should("be.visible");
@ -92,7 +92,7 @@ context("Control Link", () => {
it("should be possible set empty value explicitly", () => {
get_dialog_with_link().as("dialog");
cy.intercept("/api/method/frappe.client.validate_link*").as("validate_link");
cy.intercept("/api/method/frappe.client.validate_link_and_fetch*").as("validate_link");
cy.get(".frappe-control[data-fieldname=link] input").focus().as("input");
// Wait for dropdown to appear (request might be cached)
@ -179,7 +179,7 @@ context("Control Link", () => {
it("should update dependant fields (via fetch_from)", () => {
cy.get("@todos").then((todos) => {
cy.visit(`/desk/todo/${todos[0]}`);
cy.intercept("/api/method/frappe.client.validate_link*").as("validate_link");
cy.intercept("/api/method/frappe.client.validate_link_and_fetch*").as("validate_link");
cy.fill_field("assigned_by", cy.config("testUser"), "Link");
cy.call("frappe.client.get_value", {
@ -203,7 +203,7 @@ context("Control Link", () => {
""
);
cy.window().its("cur_frm.doc.assigned_by").should("eq", null);
cy.window().its("cur_frm.doc.assigned_by").should("eq", undefined);
// set valid value again
cy.get("@input").clear().focus();

View file

@ -11,7 +11,6 @@ context("Customize Form", () => {
"Set by user": "prompt",
"By fieldname": "field:",
Expression: "",
"Expression (old style)": "format:",
Random: "hash",
"By script": "",
};

View file

@ -42,7 +42,7 @@ describe("Dashboard view", { scrollBehavior: false }, () => {
true
);
cy.visit(`/app/dashboard-view/${dashboard}`);
cy.visit(`/desk/dashboard-view/${dashboard}`);
// expect chart to be loaded
cy.findByText(chart).should("be.visible");

View file

@ -12,6 +12,9 @@ context("Dashboard Chart", () => {
cy.fill_field("chart_name", "Test Chart", "Data");
cy.fill_field("document_type", "Workspace Link", "Link");
// wait for link field events to complete
cy.wait(1000);
cy.get('[data-fieldname="filters_json"]').click();
cy.get(".modal-dialog", { timeout: 500 }).should("be.visible");

View file

@ -27,7 +27,7 @@ context("Dashboard links", () => {
cy.visit("/desk/contact");
cy.clear_filters();
cy.visit(`/app/user/${cy.config("testUser")}`);
cy.visit(`/desk/user/${cy.config("testUser")}`);
//To check if initially the dashboard contains only the "Contact" link and there is no counter
cy.select_form_tab("Connections");
@ -40,7 +40,7 @@ context("Dashboard links", () => {
cy.findByRole("button", { name: "Add Contact" }).click();
cy.get('[data-doctype="Contact"][data-fieldname="first_name"]').type("Admin");
cy.findByRole("button", { name: "Save" }).click();
cy.visit(`/app/user/${cy.config("testUser")}`);
cy.visit(`/desk/user/${cy.config("testUser")}`);
//To check if the counter for contact doc is "2" after adding additional contact
cy.select_form_tab("Connections");
@ -62,7 +62,7 @@ context("Dashboard links", () => {
});
it("Report link in dashboard", () => {
cy.visit(`/app/user/${cy.config("testUser")}`);
cy.visit(`/desk/user/${cy.config("testUser")}`);
cy.select_form_tab("Connections");
cy.get('.document-link[data-doctype="Contact"]').contains("Contact");
cy.window()

View file

@ -111,7 +111,7 @@ context("Depends On", () => {
cy.fill_field("dependant_field", "Some Value");
//cy.fill_field('test_field', 'Some Other Value');
cy.get('.frappe-control[data-fieldname="child_test_depends_on_field"]').as("table");
cy.get("@table").findByRole("button", { name: "Add Row" }).click();
cy.get("@table").findByRole("button", { name: "Add row" }).click();
cy.get("@table").find('[data-idx="1"]').as("row1");
cy.get("@row1").find(".btn-open-row").click();
cy.get("@row1").find(".form-in-grid").as("row1-form_in_grid");

View file

@ -1,7 +1,11 @@
const jump_to_field = (field_label) => {
cy.get("body")
.type("{esc}") // lose focus if any
.type("{ctrl+j}") // jump to field
.type("{ctrl+j}"); // jump to field
cy.get(".modal input[type='text']").first().focus();
cy.get("body")
.type(field_label)
.wait(1000)
.type("{enter}")

View file

@ -20,7 +20,7 @@ context("Form Builder", () => {
});
it("Save without change, check form dirty", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.visit(`/desk/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
// Save without change
@ -37,7 +37,7 @@ context("Form Builder", () => {
it("Check if Filters are applied to the link field", () => {
// Visit the Form Builder
cy.visit(`/app/doctype/${doctype_name}`);
cy.visit(`/desk/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
cy.get("[data-fieldname='gender']").click();
@ -81,7 +81,7 @@ context("Form Builder", () => {
});
it("Add empty section and save", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.visit(`/desk/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_section = ".tab-content.active .form-section-container:first";
@ -97,7 +97,9 @@ context("Form Builder", () => {
});
it("Add Table field and check if columns are rendered", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link");
cy.visit(`/desk/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_column = ".tab-content.active .section-columns-container:first .column:first";
@ -157,7 +159,7 @@ context("Form Builder", () => {
});
// not important and was flaky on CI
it.skip("Drag Field/Column/Section & Tab", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.visit(`/desk/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_column = ".tab-content.active .section-columns-container:first .column:first";
@ -218,7 +220,7 @@ context("Form Builder", () => {
});
it("Add New Tab/Section/Column to Form", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.visit(`/desk/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_section = ".tab-content.active .form-section-container:first";
@ -261,7 +263,7 @@ context("Form Builder", () => {
});
it("Update Title field Label to New Title through Customize Form", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.visit(`/desk/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_field =
@ -279,7 +281,7 @@ context("Form Builder", () => {
});
it("Validate Duplicate Name & reqd + hidden without default logic", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.visit(`/desk/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_column = ".tab-content.active .section-columns-container:first .column:first";
@ -325,7 +327,7 @@ context("Form Builder", () => {
});
it.skip("Undo/Redo", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.visit(`/desk/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
// click on second tab

View file

@ -38,12 +38,12 @@ context("Grid Pagination", () => {
it("adds and deletes rows and changes page", () => {
cy.visit("/desk/contact/Test Contact");
cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table");
cy.get("@table").findByRole("button", { name: "Add Row" }).click();
cy.get("@table").findByRole("button", { name: "Add row" }).click();
cy.get("@table").find(".grid-body .row-index").should("contain", 1001);
cy.get("@table").find(".current-page-number").should("have.value", "21");
cy.get("@table").find(".total-page-number").should("contain", "21");
cy.get("@table").find(".grid-body .grid-row .grid-row-check").click({ force: true });
cy.get("@table").findByRole("button", { name: "Delete" }).click();
cy.get("@table").findByRole("button", { name: "Delete row" }).click();
cy.get("@table").find(".grid-body .row-index").last().should("contain", 1000);
cy.get("@table").find(".current-page-number").should("have.value", "20");
cy.get("@table").find(".total-page-number").should("contain", "20");
@ -70,7 +70,7 @@ context("Grid Pagination", () => {
cy.get("@table").find(".current-page-number").should("have.value", "1");
});
// it('deletes all rows', ()=> {
// cy.visit('/app/contact/Test Contact');
// cy.visit('/desk/contact/Test Contact');
// cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
// cy.get('@table').find('.grid-heading-row .grid-row-check').click({force: true});
// cy.get('@table').find('button.grid-remove-all-rows').click();

View file

@ -25,7 +25,7 @@ context("Grid Row Form Tabs", () => {
// Add a row to the child table
cy.get('.frappe-control[data-fieldname="items"]').as("table");
cy.get("@table").findByRole("button", { name: "Add Row" }).click();
cy.get("@table").findByRole("button", { name: "Add row" }).click();
// Open the grid row form
cy.get("@table").find('[data-idx="1"]').as("row1");
@ -48,7 +48,7 @@ context("Grid Row Form Tabs", () => {
// Add a row to the child table
cy.get('.frappe-control[data-fieldname="items"]').as("table");
cy.get("@table").findByRole("button", { name: "Add Row" }).click();
cy.get("@table").findByRole("button", { name: "Add row" }).click();
// Open the grid row form
cy.get("@table").find('[data-idx="1"]').as("row1");
@ -88,8 +88,8 @@ context("Grid Row Form Tabs", () => {
// Add two rows to the child table
cy.get('.frappe-control[data-fieldname="items"]').as("table");
cy.get("@table").findByRole("button", { name: "Add Row" }).click();
cy.get("@table").findByRole("button", { name: "Add Row" }).click();
cy.get("@table").findByRole("button", { name: "Add row" }).click();
cy.get("@table").findByRole("button", { name: "Add row" }).click();
// Open first row and switch to Details tab
cy.get("@table").find('[data-idx="1"]').as("row1");
@ -118,7 +118,7 @@ context("Grid Row Form Tabs", () => {
// Add a row to the child table
cy.get('.frappe-control[data-fieldname="items"]').as("table");
cy.get("@table").findByRole("button", { name: "Add Row" }).click();
cy.get("@table").findByRole("button", { name: "Add row" }).click();
// Open the grid row form
cy.get("@table").find('[data-idx="1"]').as("row1");

View file

@ -4,13 +4,13 @@
// context('Relative Timeframe', () => {
// before(() => {
// cy.login();
// cy.visit('/app/website');
// cy.visit('/desk/website');
// cy.window().its('frappe').then(frappe => {
// frappe.call("frappe.tests.ui_test_helpers.create_todo_records");
// });
// });
// it('sets relative timespan filter for last week and filters list', () => {
// cy.visit('/app/List/ToDo/List');
// cy.visit('/desk/List/ToDo/List');
// cy.clear_filters();
// cy.get('.list-row:contains("this is fourth todo")').should('exist');
// cy.add_filter();
@ -29,7 +29,7 @@
// cy.wait('@save_user_settings');
// });
// it('sets relative timespan filter for next week and filters list', () => {
// cy.visit('/app/List/ToDo/List');
// cy.visit('/desk/List/ToDo/List');
// cy.clear_filters();
// cy.get('.list-row:contains("this is fourth todo")').should('exist');
// cy.add_filter();

View file

@ -21,7 +21,7 @@ context("Report View", () => {
it("Field with enabled allow_on_submit should be editable.", () => {
cy.intercept("POST", "api/method/frappe.client.set_value").as("value-update");
cy.visit(`/app/List/${doctype_name}/Report`);
cy.visit(`/desk/List/${doctype_name}/Report`);
// check status column added from docstatus
cy.get(".dt-row-0 > .dt-cell--col-3").should("contain", "Submitted");

View file

@ -1,5 +1,5 @@
const verify_attachment_visibility = (document, is_private) => {
cy.visit(`/app/${document}`);
cy.visit(`/desk/${document}`);
const assertion = is_private ? "be.checked" : "not.be.checked";
cy.get(".add-attachment-btn").click();
@ -60,7 +60,7 @@ context("Sidebar", () => {
cy.call("frappe.tests.ui_test_helpers.create_todo_with_attachment_limit", {
description: "Sidebar Attachment Access Test ToDo",
}).then((todo) => {
cy.visit(`/app/todo/${todo.message.name}`);
cy.visit(`/desk/todo/${todo.message.name}`);
attach_file("cypress/fixtures/sample_image.jpg");
cy.get(".explore-link").should("be.visible");
@ -72,8 +72,9 @@ context("Sidebar", () => {
// attach 1 more image to reach attachment limit
attach_file("cypress/fixtures/sample_attachments/attachment-11.txt");
cy.get(".layout-side-section").scrollTo("top", { ensureScrollable: false });
cy.get(".add-attachment-btn").should("be.hidden");
cy.get(".explore-link").should("be.visible");
// cy.get(".explore-link").should("be.visible");
// test "Show All" button
cy.get(".attachment-row").should("have.length", 10);

View file

@ -13,7 +13,7 @@ context("Realtime updates", () => {
it("Shows version conflict warning", { scrollBehavior: false }, () => {
cy.insert_doc("ToDo", { description: "old" }).then((doc) => {
cy.visit(`/app/todo/${doc.name}`);
cy.visit(`/desk/todo/${doc.name}`);
// make form dirty
cy.fill_field("status", "Cancelled", "Select");

View file

@ -226,6 +226,6 @@ context("View", () => {
it("Route to Website Workspace", () => {
cy.visit("/desk/website");
cy.get(".workspace-title").should("contain", "Website");
cy.get(".navbar-breadcrumbs:visible").get("li > a").should("contain", "Website");
});
});

View file

@ -122,7 +122,7 @@ context("Web Form", () => {
cy.findByRole("tab", { name: "Settings" }).click();
cy.get('[data-fieldname="list_columns"] .grid-footer button')
.contains("Add Row")
.contains("Add row")
.as("add-row");
cy.get("@add-row").click();

View file

@ -143,7 +143,7 @@ context("Workspace Blocks", () => {
});
it("Number Card Block", () => {
cy.visit("/app/private/test-block-page");
cy.visit("/desk/private/test-block-page");
cy.create_records([
{
doctype: "Number Card",

View file

@ -263,7 +263,7 @@ Cypress.Commands.add("awesomebar", (text) => {
Cypress.Commands.add("new_form", (doctype) => {
let dt_in_route = doctype.toLowerCase().replace(/ /g, "-");
cy.visit(`/app/${dt_in_route}/new`);
cy.visit(`/desk/${dt_in_route}/new`);
cy.get("body").should(($body) => {
const dataRoute = $body.attr("data-route");
expect(dataRoute).to.match(new RegExp(`^Form/${doctype}/new-${dt_in_route}-`));
@ -277,7 +277,7 @@ Cypress.Commands.add("select_form_tab", (label) => {
Cypress.Commands.add("go_to_list", (doctype) => {
let dt_in_route = doctype.toLowerCase().replace(/ /g, "-");
cy.visit(`/app/${dt_in_route}`);
cy.visit(`/desk/${dt_in_route}`);
});
Cypress.Commands.add("clear_cache", () => {

View file

@ -54,7 +54,7 @@ from .utils.jinja import (
render_template,
)
__version__ = "16.0.0-dev"
__version__ = "17.0.0-dev"
__title__ = "Frappe Framework"
if TYPE_CHECKING: # pragma: no cover
@ -73,8 +73,8 @@ if TYPE_CHECKING: # pragma: no cover
controllers: dict[str, type] = {}
lazy_controllers: dict[str, type] = {}
local = Local()
cache: Optional["RedisWrapper"] = None
client_cache: Optional["ClientCache"] = None
cache: "RedisWrapper" | None = None
client_cache: "ClientCache" | None = None
STANDARD_USERS = ("Guest", "Administrator")
# this global may be subsequently changed by frappe.tests.utils.toggle_test_mode()
@ -88,22 +88,20 @@ if _dev_server:
# local-globals
ConfType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
type ConfType = _dict[str, Any] # type: ignore[no-any-explicit]
# TODO: make session a dataclass instead of undtyped _dict
SessionType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
type SessionType = _dict[str, Any] # type: ignore[no-any-explicit]
# TODO: implement dataclass
LogMessageType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
type LogMessageType = _dict[str, Any] # type: ignore[no-any-explicit]
# TODO: implement dataclass
# holds job metadata if the code is run in a background job context
JobMetaType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
ResponseDict: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
FlagsDict: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
FormDict: TypeAlias = _dict[str, str]
type JobMetaType = _dict[str, Any] # type: ignore[no-any-explicit]
type ResponseDict = _dict[str, Any] # type: ignore[no-any-explicit]
type FlagsDict = _dict[str, Any] # type: ignore[no-any-explicit]
type FormDict = _dict[str, str]
db: LocalProxy[Union["PyMariaDBDatabase", "MariaDBDatabase", "PostgresDatabase", "SQLiteDatabase"]] = local(
"db"
)
qb: LocalProxy[Union["MariaDB", "Postgres", "SQLite"]] = local("qb")
db: LocalProxy["PyMariaDBDatabase" | "MariaDBDatabase" | "PostgresDatabase" | "SQLiteDatabase"] = local("db")
qb: LocalProxy["MariaDB" | "Postgres" | "SQLite"] = local("qb")
conf: LocalProxy[ConfType] = local("conf")
form_dict: LocalProxy[FormDict] = local("form_dict")
form = form_dict
@ -198,7 +196,7 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force: bool =
local.cache = {}
local.form_dict = _dict()
local.preload_assets = {"style": [], "script": [], "icons": []}
local.session = _dict(user="Guest")
local.session = _dict(user="Guest", data=_dict())
local.dev_server = _dev_server # only for backwards compatibility
local.qb = get_query_builder(local.conf.db_type)
if not cache or not client_cache:
@ -675,7 +673,7 @@ def is_table(doctype: str) -> bool:
def get_precision(
doctype: str, fieldname: str, currency: str | None = None, doc: Optional["Document"] = None
doctype: str, fieldname: str, currency: str | None = None, doc: "Document" | None = None
) -> int:
"""Get precision for a given field"""
from frappe.model.meta import get_field_precision
@ -1483,9 +1481,9 @@ def get_desk_link(doctype, name, show_title_with_name=False, open_in_new_tab=Fal
encoded_name = quote(name)
if show_title_with_name and name != title:
html = '<a href="/app/Form/{doctype}/{encoded_name}"{target} style="font-weight: bold;">{doctype_local} {name}: {title_local}</a>'
html = '<a href="/desk/Form/{doctype}/{encoded_name}"{target} style="font-weight: bold;">{doctype_local} {name}: {title_local}</a>'
else:
html = '<a href="/app/Form/{doctype}/{encoded_name}"{target} style="font-weight: bold;">{doctype_local} {title_local}</a>'
html = '<a href="/desk/Form/{doctype}/{encoded_name}"{target} style="font-weight: bold;">{doctype_local} {title_local}</a>'
return html.format(
doctype=doctype,

View file

@ -11,8 +11,8 @@ import frappe
from frappe import _
from frappe.modules.utils import get_doctype_app_map
from frappe.monitor import add_data_to_monitor
from frappe.pulse.app_heartbeat_event import capture_app_heartbeat
from frappe.utils.response import build_response
from frappe.utils.telemetry.pulse.app_heartbeat_event import capture_app_heartbeat
class ApiVersion(str, Enum):

View file

@ -25,6 +25,21 @@ PERMISSION_MAP = {
}
def get_bulk_operation_async_threshold(doctype: str | None = None) -> int:
conf = frappe.conf.get("bulk_operation_async_threshold", 20)
if isinstance(conf, dict):
value = conf.get(doctype, 20) if doctype else conf.get("*", 20)
else:
value = conf
return cint(value)
class FrappeValueError(ValueError):
http_status_code = 417
def handle_rpc_call(method: str, doctype: str | None = None):
from frappe.modules.utils import load_doctype_module
@ -121,8 +136,17 @@ def document_list(doctype: str) -> list[dict[str, Any]]:
start: int = cint(args.get("start", 0))
limit: int = cint(args.get("limit", 20))
group_by: str | None = args.get("group_by", None)
debug: bool = args.get("debug", False)
as_dict: bool = args.get("as_dict", True)
debug: bool = bool(args.get("debug", False))
as_dict: bool = bool(args.get("as_dict", True))
if fields and not isinstance(fields, list):
raise FrappeValueError("'fields' must be a list")
if filters and not isinstance(filters, (list, dict)):
raise FrappeValueError("'filters' must be a list or dictionary")
if order_by and not isinstance(order_by, str):
raise FrappeValueError("'order_by' must be a string")
if group_by and not isinstance(group_by, str):
raise FrappeValueError("'group_by' must be a string")
query = frappe.qb.get_query(
table=doctype,
@ -235,6 +259,294 @@ def execute_doc_method(doctype: str, name: str, method: str | None = None):
return result
def bulk_delete_docs(doctype: str):
"""Bulk delete multiple documents of the same doctype.
Request body should contain:
names: List of document names to delete
Returns:
deleted: List of successfully deleted document names
failed: List of failed deletions with error messages
total: Total number of documents attempted
success_count: Number of successful deletions
failure_count: Number of failed deletions
"""
names = frappe.form_dict.get("names")
if not isinstance(names, list):
raise FrappeValueError("'names' must be a list")
if len(names) > get_bulk_operation_async_threshold(doctype):
job = frappe.enqueue(
"frappe.api.v2.execute_bulk_delete_docs",
doctype=doctype,
names=names,
)
frappe.response.http_status_code = 202
return {"job_id": job.id}
return execute_bulk_delete_docs(doctype, names)
def execute_bulk_delete_docs(doctype: str, names: list[str | int]):
deleted = []
failed = []
for name in names:
if not isinstance(name, str | int):
failed.append({"name": name, "error": "'name' must be a string or integer"})
continue
if isinstance(name, int):
name = str(name)
savepoint = "bulk_delete_docs"
frappe.db.savepoint(savepoint)
try:
frappe.delete_doc(doctype, name, ignore_missing=False)
deleted.append(name)
except Exception as e:
frappe.db.rollback(save_point=savepoint)
failed.append({"name": name, "error": str(e)})
return {
"deleted": deleted,
"failed": failed,
"total": len(names),
"success_count": len(deleted),
"failure_count": len(failed),
}
def bulk_delete():
"""Bulk delete documents across multiple doctypes.
Request body should contain:
docs: List of {"doctype": str, "name": str} objects
Returns:
deleted: List of successfully deleted documents
failed: List of failed deletions with error messages
total: Total number of documents attempted
success_count: Number of successful deletions
failure_count: Number of failed deletions
"""
docs = frappe.form_dict.get("docs", [])
if not isinstance(docs, list):
raise FrappeValueError("'docs' must be a list")
if len(docs) > get_bulk_operation_async_threshold():
job = frappe.enqueue(
"frappe.api.v2.execute_bulk_delete",
docs=docs,
)
frappe.response.http_status_code = 202
return {"job_id": job.id}
return execute_bulk_delete(docs)
def execute_bulk_delete(docs: list):
deleted = []
failed = []
for item in docs:
doctype = None
name = None
savepoint = "bulk_delete"
frappe.db.savepoint(savepoint)
try:
if not isinstance(item, dict):
raise FrappeValueError("Each document must be a dictionary with 'doctype' and 'name' keys")
doctype = item.get("doctype")
name = item.get("name")
if not isinstance(doctype, str):
raise FrappeValueError("'doctype' must be a string")
if not isinstance(name, str | int):
raise FrappeValueError("'name' must be a string or integer")
if isinstance(name, int):
name = str(name)
frappe.delete_doc(doctype, name, ignore_missing=False)
deleted.append({"doctype": doctype, "name": name})
except Exception as e:
frappe.db.rollback(save_point=savepoint)
failed.append({"doctype": doctype, "name": name, "error": str(e)})
return {
"deleted": deleted,
"failed": failed,
"total": len(docs),
"success_count": len(deleted),
"failure_count": len(failed),
}
def bulk_update_docs(doctype: str):
"""Bulk update multiple documents of the same doctype.
Request body should contain:
docs: List of {"name": str, ...fields} objects where each object contains
the document name and the fields to update
Returns:
updated: List of successfully updated document names
failed: List of failed updates with error messages
total: Total number of documents attempted
success_count: Number of successful updates
failure_count: Number of failed updates
"""
docs = frappe.form_dict.get("docs")
if not isinstance(docs, list):
raise FrappeValueError("'docs' must be a list")
if len(docs) > get_bulk_operation_async_threshold(doctype):
job = frappe.enqueue(
"frappe.api.v2.execute_bulk_update_docs",
doctype=doctype,
docs=docs,
)
frappe.response.http_status_code = 202
return {"job_id": job.id}
return execute_bulk_update_docs(doctype, docs)
def execute_bulk_update_docs(doctype: str, docs: list):
updated = []
failed = []
for item in docs:
name = None
savepoint = "bulk_update_docs"
frappe.db.savepoint(savepoint)
try:
if not isinstance(item, dict):
raise FrappeValueError("Each update must be a dictionary with 'name' and field values")
name = item.get("name")
if not isinstance(name, str | int):
raise FrappeValueError("'name' must be a string or integer")
if isinstance(name, int):
name = str(name)
doc = frappe.get_doc(doctype, name, for_update=True)
item_copy = item.copy()
item_copy.pop("name")
item_copy.pop("flags", None)
doc.update(item_copy)
doc.save()
doc.apply_fieldlevel_read_permissions()
updated.append(name)
frappe.response.docs.append(doc.as_dict())
except Exception as e:
frappe.db.rollback(save_point=savepoint)
failed.append({"name": name, "error": str(e)})
return {
"updated": updated,
"failed": failed,
"total": len(docs),
"success_count": len(updated),
"failure_count": len(failed),
}
def bulk_update():
"""Bulk update documents across multiple doctypes.
Request body should contain:
docs: List of {"doctype": str, "name": str, ...fields} objects
Returns:
updated: List of successfully updated documents
failed: List of failed updates with error messages
total: Total number of documents attempted
success_count: Number of successful updates
failure_count: Number of failed updates
"""
docs = frappe.form_dict.get("docs")
if not isinstance(docs, list):
raise FrappeValueError("'docs' must be a list")
if len(docs) > get_bulk_operation_async_threshold():
job = frappe.enqueue(
"frappe.api.v2.execute_bulk_update",
docs=docs,
)
frappe.response.http_status_code = 202
return {"job_id": job.id}
return execute_bulk_update(docs)
def execute_bulk_update(docs: list):
updated = []
failed = []
for item in docs:
doctype = None
name = None
savepoint = "bulk_update"
frappe.db.savepoint(savepoint)
try:
if not isinstance(item, dict):
raise FrappeValueError(
"Each document must be a dictionary with 'doctype', 'name', and field values"
)
doctype = item.get("doctype")
name = item.get("name")
if not isinstance(doctype, str):
raise FrappeValueError("'doctype' must be a string")
if not isinstance(name, str | int):
raise FrappeValueError("'name' must be a string or integer")
if isinstance(name, int):
name = str(name)
doc = frappe.get_doc(doctype, name, for_update=True)
item_copy = item.copy()
item_copy.pop("doctype")
item_copy.pop("name")
item_copy.pop("flags", None)
doc.update(item_copy)
doc.save()
doc.apply_fieldlevel_read_permissions()
updated.append({"doctype": doctype, "name": name})
frappe.response.docs.append(doc.as_dict())
except Exception as e:
frappe.db.rollback(save_point=savepoint)
failed.append({"doctype": doctype, "name": name, "error": str(e)})
return {
"updated": updated,
"failed": failed,
"total": len(docs),
"success_count": len(updated),
"failure_count": len(failed),
}
def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None):
"""run a whitelisted controller method on in-memory document.
@ -248,6 +560,9 @@ def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None):
if isinstance(document, str):
document = frappe.parse_json(document)
if not isinstance(document, dict):
raise FrappeValueError("'document' must be a dictionary")
if kwargs is None:
kwargs = {}
@ -272,6 +587,8 @@ url_rules = [
Rule("/method/logout", endpoint=logout, methods=["POST"]),
Rule("/method/ping", endpoint=frappe.ping),
Rule("/method/upload_file", endpoint=upload_file, methods=["POST"]),
Rule("/method/bulk_delete", endpoint=bulk_delete, methods=["POST"]),
Rule("/method/bulk_update", endpoint=bulk_update, methods=["POST"]),
Rule("/method/<method>", endpoint=handle_rpc_call),
Rule(
"/method/run_doc_method",
@ -282,6 +599,8 @@ url_rules = [
# Document level APIs
Rule("/document/<doctype>", methods=["GET"], endpoint=document_list),
Rule("/document/<doctype>", methods=["POST"], endpoint=create_doc),
Rule("/document/<doctype>/bulk_delete", methods=["POST"], endpoint=bulk_delete_docs),
Rule("/document/<doctype>/bulk_update", methods=["POST"], endpoint=bulk_update_docs),
Rule("/document/<doctype>/<path:name>/", methods=["GET"], endpoint=read_doc),
Rule("/document/<doctype>/<path:name>/copy", methods=["GET"], endpoint=copy_doc),
Rule("/document/<doctype>/<path:name>/", methods=["PATCH", "PUT"], endpoint=update_doc),

View file

@ -40,7 +40,7 @@ import gettext
import babel
import babel.messages
import bleach
import nh3
import num2words
import pydantic
@ -48,7 +48,7 @@ import frappe.boot
import frappe.client
import frappe.core.doctype.file.file
import frappe.core.doctype.user.user
import frappe.database.mariadb.database # Load database related utils
import frappe.database.mariadb.mysqlclient # Load database related utils
import frappe.database.query
import frappe.desk.desktop # workspace
import frappe.desk.form.save

View file

@ -10,12 +10,14 @@ from frappe.core.doctype.installed_applications.installed_applications import (
get_setup_wizard_completed_apps,
get_setup_wizard_not_required_apps,
)
from frappe.utils.caching import request_cache
# check if route is /app or /app/* and not /app1 or /app1/*
DESK_APP_PATTERN = re.compile(r"^/app(/.*)?$")
# check if route is /desk or /desk/* and not /app1 or /app1/*
DESK_APP_PATTERN = re.compile(r"^/desk(/.*)?$")
@frappe.whitelist()
@request_cache
def get_apps():
apps = frappe.get_installed_apps()
app_list = []

View file

@ -67,8 +67,11 @@ frappe.ui.form.on("Assignment Rule", {
[{ label: "Owner", value: "owner" }]
);
if (doctype) {
frm.set_fields_as_options("due_date_based_on", doctype, (df) =>
["Date", "Datetime"].includes(df.fieldtype)
frm.set_fields_as_options(
"due_date_based_on",
doctype,
(df) => ["Date", "Datetime"].includes(df.fieldtype),
[{ value: " ", label: " " }]
).then((options) =>
frm.set_df_property("due_date_based_on", "hidden", !options.length)
);

View file

@ -30,7 +30,7 @@ frappe.ui.form.on("Auto Repeat", {
refresh: function (frm) {
// auto repeat message
if (frm.is_new()) {
let customize_form_link = `<a href="/app/customize-form">${__("Customize Form")}</a>`;
let customize_form_link = `<a href="/desk/customize-form">${__("Customize Form")}</a>`;
frm.dashboard.set_headline(
__('To configure Auto Repeat, enable "Allow Auto Repeat" from {0}.', [
customize_form_link,

View file

@ -161,11 +161,8 @@ def load_desktop_data(bootinfo):
from frappe.desk.desktop import get_workspace_sidebar_items
bootinfo.workspaces = get_workspace_sidebar_items()
bootinfo.show_app_icons_as_folder = frappe.db.get_single_value(
"Desktop Settings", "show_app_icons_as_folder"
)
bootinfo.workspace_sidebar_item = get_sidebar_items()
allowed_pages = [d.name for d in bootinfo.workspaces.get("pages")]
bootinfo.workspace_sidebar_item = get_sidebar_items(allowed_pages)
bootinfo.module_wise_workspaces = get_controller("Workspace").get_module_wise_workspaces()
bootinfo.dashboards = frappe.get_all("Dashboard")
bootinfo.app_data = []
@ -536,7 +533,8 @@ def get_sentry_dsn():
return os.getenv("FRAPPE_SENTRY_DSN")
def get_sidebar_items():
def get_sidebar_items(allowed_workspaces):
from frappe import _
from frappe.desk.doctype.workspace_sidebar.workspace_sidebar import auto_generate_sidebar_from_module
sidebars = frappe.get_all("Workspace Sidebar", fields=["name", "header_icon"])
@ -560,7 +558,7 @@ def get_sidebar_items():
}
for si in w.items:
workspace_sidebar = {
"label": si.label,
"label": _(si.label),
"link_to": si.link_to,
"link_type": si.link_type,
"type": si.type,
@ -574,6 +572,7 @@ def get_sidebar_items():
"show_arrow": si.show_arrow,
"filters": si.filters,
"route_options": si.route_options,
"tab": si.navigate_to_tab,
}
if si.link_type == "Report" and si.link_to and frappe.db.exists("Report", si.link_to):
report_type, ref_doctype = frappe.db.get_value(
@ -586,7 +585,7 @@ def get_sidebar_items():
if (
"My Workspaces" in sidebar_title
or si.type == "Section Break"
or w.is_item_allowed(si.link_to, si.link_type)
or w.is_item_allowed(si.link_to, si.link_type, allowed_workspaces)
):
sidebar_items[sidebar_title.lower()]["items"].append(workspace_sidebar)
add_user_specific_sidebar(sidebar_items)
@ -626,6 +625,9 @@ def add_user_specific_sidebar(sidebar_items):
if f"-{frappe.session.user.lower()}" in sidebar:
sidebars_to_remove.append(sidebar)
for sidebar in sidebars_to_remove:
sidebar_name = sidebar.replace(f"-{frappe.session.user.lower()}", "")
sidebar_items[sidebar]["label"] = sidebar_items[sidebar_name]["label"]
sidebar_items[sidebar_name] = sidebar_items.pop(sidebar)
try:
sidebar_name = sidebar.replace(f"-{frappe.session.user.lower()}", "")
sidebar_items[sidebar]["label"] = sidebar_items[sidebar_name]["label"]
sidebar_items[sidebar_name] = sidebar_items.pop(sidebar)
except KeyError:
pass

View file

@ -105,7 +105,8 @@ def clear_global_cache():
clear_doctype_cache()
clear_website_cache()
frappe.cache.delete_value(global_cache_keys + bench_cache_keys)
frappe.cache.delete_value(global_cache_keys)
frappe.cache.delete_value(bench_cache_keys, shared=True)
frappe.setup_module_map()

View file

@ -9,7 +9,7 @@ import frappe.model
import frappe.utils
from frappe import _
from frappe.desk.reportview import validate_args
from frappe.model.utils import is_virtual_doctype
from frappe.desk.search import PAGE_LENGTH_FOR_LINK_VALIDATION, search_widget
from frappe.utils import attach_expanded_links, get_safe_filters
from frappe.utils.caching import http_cache
@ -77,7 +77,13 @@ def get_list(
@frappe.whitelist()
def get_count(doctype, filters=None, debug=False, cache=False):
return frappe.db.count(doctype, get_safe_filters(filters), debug, cache)
from frappe.desk.reportview import get_count
frappe.form_dict.doctype = doctype
frappe.form_dict.filters = get_safe_filters(filters)
frappe.form_dict.debug = debug
return get_count()
@frappe.whitelist()
@ -400,52 +406,95 @@ def is_document_amended(doctype: str, docname: str):
return False
@frappe.whitelist()
def validate_link(doctype: str, docname: str, fields=None):
if not isinstance(doctype, str):
frappe.throw(_("DocType must be a string"))
@frappe.whitelist(methods=["GET", "POST"])
def validate_link_and_fetch(
doctype: str,
docname: str,
fields_to_fetch: list[str] | str | None = None,
# search_widget parameters
query: str | None = None,
filters: dict | list | str | None = None,
**search_args,
):
if not docname:
frappe.throw(_("Document Name must not be empty"))
if not isinstance(docname, str):
frappe.throw(_("Document Name must be a string"))
meta = frappe.get_meta(doctype)
fields_to_fetch = frappe.parse_json(fields_to_fetch)
parent_doctype = None
if doctype != "DocType":
if frappe.get_meta(doctype).istable: # needed for links to child rows
parent_doctype = frappe.db.get_value(doctype, docname, "parenttype")
if not (
frappe.has_permission(doctype, "select", parent_doctype=parent_doctype)
or frappe.has_permission(doctype, "read", parent_doctype=parent_doctype)
):
frappe.throw(
_("You do not have Read or Select Permissions for {}").format(frappe.bold(doctype)),
frappe.PermissionError,
)
# only cache is no fields to fetch and request is GET
can_cache = not fields_to_fetch and frappe.request.method == "GET"
values = frappe._dict()
# Use search_widget to validate - ensures filters/custom queries are respected
# in addition to standard permission checks
# we match the exact docname for non-custom queries and rely on txt for custom queries
search_args.update(
as_dict=False,
# when relying on txt (custom queries), we want to match "A" with "A" only and not "A1", "BA" etc.
# so we set page_length to a conservative value within which exact match is expected to appear
page_length=PAGE_LENGTH_FOR_LINK_VALIDATION,
# translated doctypes are expected to be searchable with translated values, even for custom queries
# for non-custom queries, docname is always matched exactly so we don't translate it
txt=_(docname) if (query and meta.translated_doctype) else docname,
for_link_validation=True,
)
if is_virtual_doctype(doctype):
search_result = frappe.call(
search_widget,
doctype=doctype,
query=query,
filters=filters,
**search_args,
)
if not search_result:
return {} # does not exist or filtered out
values = None
is_virtual_dt = bool(meta.get("is_virtual"))
if is_virtual_dt:
try:
frappe.get_doc(doctype, docname)
values.name = docname
doc = frappe.get_doc(doctype, docname)
doc.check_permission("select" if frappe.only_has_select_perm(doctype) else "read")
values = {"name": doc.name}
except frappe.DoesNotExistError:
frappe.clear_last_message()
frappe.msgprint(
_("Document {0} {1} does not exist").format(frappe.bold(doctype), frappe.bold(docname)),
else:
# get value in the right case and type (str | int)
# for matching with search result
columns_to_fetch = ["name"]
if frappe.is_table(doctype):
columns_to_fetch.append("parenttype") # for child table permission check
values = frappe.db.get_value(doctype, docname, columns_to_fetch, as_dict=True)
if not values:
return {} # does not exist
name_to_compare = values["name"]
# this will be used to fetch fields later
parent_doctype = values.pop("parenttype", None)
# try to match name in search result
# if search_result is large, assume valid link (result may not appear in some custom queries)
if len(search_result) < PAGE_LENGTH_FOR_LINK_VALIDATION and not any(
item[0] == name_to_compare for item in search_result
):
return {} # no permission or filtered out
# don't cache or fetch for virtual doctypes
if is_virtual_dt:
return values
if not fields_to_fetch:
if can_cache:
frappe.local.response_headers.set(
"Cache-Control", "private,max-age=1800,stale-while-revalidate=7200"
)
return values
values.name = frappe.db.get_value(doctype, docname, cache=True)
fields = frappe.parse_json(fields)
if not values.name:
return values
if not fields:
frappe.local.response_headers.set("Cache-Control", "private,max-age=1800,stale-while-revalidate=7200")
return values
try:
values.update(get_value(doctype, fields, docname, parent=parent_doctype))
values.update(get_value(doctype, fields_to_fetch, docname, parent=parent_doctype))
except frappe.PermissionError:
frappe.clear_last_message()
frappe.msgprint(

View file

@ -1583,31 +1583,34 @@ def bypass_patch(context: CliCtxObj, patch_name: str, yes: bool):
frappe.destroy()
@click.command("create-desktop-icons-and-sidebar")
@click.command("sync-desktop-icons")
@pass_context
def create_icons_and_sidebar(context: CliCtxObj):
"""Create desktop icons and workspace sidebars."""
from frappe.desk.doctype.desktop_icon.desktop_icon import create_desktop_icons
from frappe.desk.doctype.workspace_sidebar.workspace_sidebar import (
create_workspace_sidebar_for_workspaces,
)
def sync_desktop_icons(context: CliCtxObj):
from frappe.model.sync import import_file_by_path
from frappe.modules.utils import get_app_level_directory_path
from frappe.utils import update_progress_bar
if not context.sites:
raise SiteNotSpecifiedError
files = []
app_level_folders = ["desktop_icon"]
for site in context.sites:
print("Sycning icons for " + site)
frappe.init(site)
frappe.connect()
try:
print("Creating Desktop Icons")
create_desktop_icons()
print("Creating Workspace Sidebars")
create_workspace_sidebar_for_workspaces()
# Saving it in a command need it
frappe.db.commit() # nosemgrep
except Exception as e:
print(f"Error creating icons {site}: {e}")
finally:
frappe.destroy()
for app_name in frappe.get_installed_apps():
for folder_name in app_level_folders:
directory_path = get_app_level_directory_path(folder_name, app_name)
if os.path.exists(directory_path):
icon_files = [
os.path.join(directory_path, filename) for filename in os.listdir(directory_path)
]
for doc_path in icon_files:
files.append(doc_path)
for i, doc_path in enumerate(files):
imported = import_file_by_path(doc_path, force=True, ignore_version=True)
if imported:
frappe.db.commit(chain=True)
update_progress_bar("Updating Desktop Icons", i, len(files))
commands = [
@ -1646,5 +1649,5 @@ commands = [
trim_database,
clear_log_table,
bypass_patch,
create_icons_and_sidebar,
sync_desktop_icons,
]

View file

@ -37,7 +37,7 @@ from frappe.installer import add_to_installed_apps, remove_app
from frappe.query_builder.utils import db_type_is
from frappe.tests import IntegrationTestCase, timeout
from frappe.tests.test_query_builder import run_only_if
from frappe.utils import add_to_date, get_bench_path, get_bench_relative_path, now
from frappe.utils import add_to_date, execute_in_shell, get_bench_path, get_bench_relative_path, now
from frappe.utils.backups import BackupGenerator, fetch_latest_backups
from frappe.utils.jinja_globals import bundled_asset
from frappe.utils.scheduler import enable_scheduler, is_scheduler_inactive
@ -1086,12 +1086,15 @@ class TestGunicornWorker(IntegrationTestCase):
self.addCleanup(self.kill_gunicorn)
def kill_gunicorn(self):
time.sleep(1)
time.sleep(2)
self.handle.send_signal(signal.SIGTERM)
try:
self.handle.communicate(timeout=1)
self.handle.communicate(timeout=2)
except subprocess.TimeoutExpired:
self.handle.kill()
pass
time.sleep(2)
execute_in_shell("pgrep gunicorn | xargs -L1 kill -9")
def test_gunicorn_ping_sync(self):
self.spawn_gunicorn()
@ -1108,13 +1111,15 @@ class TestGunicornWorker(IntegrationTestCase):
process = psutil.Process(self.handle.pid)
return sum(c.cpu_percent(1.0) for c in process.children(True)) + process.cpu_percent(1.0)
usage_threshold = 10
self.spawn_gunicorn(["--threads=2"])
self.assertLessEqual(get_total_usage(), 3)
self.assertLessEqual(get_total_usage(), usage_threshold)
# Wake up at least one thread, go idle and check again
path = f"http://{self.TEST_SITE}:{self.port}/api/method/ping"
self.assertEqual(requests.get(path).status_code, 200)
self.assertLessEqual(get_total_usage(), 3)
self.assertLessEqual(get_total_usage(), usage_threshold)
class TestRQWorker(IntegrationTestCase):

View file

@ -465,7 +465,13 @@ def run_ui_tests(
os.chdir(app_base_path)
node_bin = subprocess.getoutput("(cd ../frappe && yarn bin)")
node_bin = subprocess.run(
"(cd ../frappe && yarn bin)",
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
).stdout.strip()
cypress_path = f"{node_bin}/cypress"
drag_drop_plugin_path = f"{node_bin}/../@4tw/cypress-drag-drop"
real_events_plugin_path = f"{node_bin}/../cypress-real-events"

View file

@ -366,7 +366,7 @@ def contact_query(doctype, txt, searchfield, start, page_len, filters):
order by
if(locate(%(_txt)s, `tabContact`.full_name), locate(%(_txt)s, `tabContact`.company_name), 99999),
`tabContact`.idx desc, `tabContact`.full_name
limit %(start)s, %(page_len)s """,
limit %(page_len)s offset %(start)s """,
{
"txt": "%" + txt + "%",
"_txt": txt.replace("%", ""),

View file

@ -13,8 +13,8 @@
"idx": 0,
"is_public": 0,
"is_standard": 1,
"last_synced_on": "2025-10-30 21:36:33.646973",
"modified": "2025-10-30 21:37:11.340673",
"last_synced_on": "2026-01-12 00:01:03.263885",
"modified": "2026-01-12 00:03:10.123061",
"modified_by": "Administrator",
"module": "Core",
"name": "Background Job Activity",

View file

@ -0,0 +1,35 @@
{
"based_on": "",
"chart_name": "Notifications By Type",
"chart_type": "Group By",
"creation": "2025-09-08 12:07:04.576729",
"currency": "",
"docstatus": 0,
"doctype": "Dashboard Chart",
"document_type": "Notification Log",
"dynamic_filters_json": "[]",
"filters_json": "[]",
"group_by_based_on": "type",
"group_by_type": "Count",
"idx": 0,
"is_public": 0,
"is_standard": 1,
"last_synced_on": "2026-01-12 00:01:03.245282",
"modified": "2026-01-12 00:02:23.444272",
"modified_by": "Administrator",
"module": "Core",
"name": "Notifications By Type",
"number_of_groups": 0,
"owner": "Administrator",
"parent_document_type": "",
"roles": [],
"show_values_over_chart": 0,
"source": "",
"time_interval": "Yearly",
"timeseries": 0,
"timespan": "Last Year",
"type": "Pie",
"use_report_chart": 0,
"value_based_on": "",
"y_axis": []
}

View file

@ -62,7 +62,9 @@ class Comment(Document):
def validate(self):
if not self.comment_email:
self.comment_email = frappe.session.user
self.content = frappe.utils.sanitize_html(self.content, always_sanitize=True)
self.content = frappe.utils.sanitize_html(
self.content, always_sanitize=True, disallowed_tags=["form", "input", "button"]
)
def on_update(self):
update_comment_in_doc(self)

View file

@ -372,7 +372,7 @@
"idx": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2025-02-20 19:19:29.427081",
"modified": "2025-12-25 19:19:29.427081",
"modified_by": "Administrator",
"module": "Core",
"name": "Communication",
@ -437,4 +437,4 @@
"title_field": "subject",
"track_changes": 1,
"track_seen": 1
}
}

View file

@ -230,7 +230,7 @@ class Communication(Document, CommunicationEmailMixin):
html_signature = soup.find("div", {"class": "ql-editor read-mode"})
_signature = None
if html_signature:
_signature = html_signature.renderContents()
_signature = html_signature.encode_contents()
if (cstr(_signature) or signature) not in self.content:
self.content = f'{self.content}</p><br><p class="signature">{signature}'

View file

@ -50,6 +50,8 @@ def make(
send_after=None,
print_language=None,
now=False,
raw_html=False,
add_css=True,
**kwargs,
) -> dict[str, str]:
"""Make a new communication. Checks for email permissions for specified Document.
@ -69,10 +71,12 @@ def make(
:param send_me_a_copy: Send a copy to the sender (default **False**).
:param email_template: Template which is used to compose mail .
:param send_after: Send after the given datetime.
:param raw_html: Whether to use html version of email template
:param add_css: Add default CSS from hooks/email_css to the email template (default **True**)
"""
if kwargs:
from frappe.utils.commands import warn
from frappe.utils.commands import warn
if kwargs:
warn(
f"Options {kwargs} used in frappe.core.doctype.communication.email.make "
"are deprecated or unsupported",
@ -82,6 +86,20 @@ def make(
if doctype and name:
frappe.has_permission(doctype, doc=name, ptype="email", throw=True)
if (
raw_html
and email_template
and not frappe.get_cached_value("Email Template", email_template, "use_html")
):
warn(
_(
"Raw HTML can be used only with Email Templates having 'Use HTML' checked. "
"Proceeding with plain text email."
),
category=UserWarning,
)
raw_html = False
return _make(
doctype=doctype,
name=name,
@ -107,6 +125,8 @@ def make(
send_after=send_after,
print_language=print_language,
now=now,
raw_html=raw_html,
add_css=add_css,
)
@ -135,6 +155,8 @@ def _make(
send_after=None,
print_language=None,
now=False,
raw_html=False,
add_css=True,
) -> dict[str, str]:
"""Internal method to make a new communication that ignores Permission checks."""
@ -165,7 +187,9 @@ def _make(
"send_after": send_after,
}
)
comm.flags.skip_add_signature = not add_signature
comm.flags.skip_add_signature = not add_signature or (
raw_html and frappe.get_cached_value("Email Template", email_template, "use_html")
)
comm.insert(ignore_permissions=True)
# if not committed, delayed task doesn't find the communication
@ -190,6 +214,8 @@ def _make(
print_letterhead=print_letterhead,
print_language=print_language,
now=now,
raw_html=raw_html,
add_css=add_css,
)
emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy)

View file

@ -258,6 +258,8 @@ class CommunicationEmailMixin:
print_letterhead=None,
is_inbound_mail_communcation=None,
print_language=None,
raw_html=False,
add_css=True,
) -> dict:
outgoing_email_account = self.get_outgoing_email_account()
if not outgoing_email_account:
@ -307,6 +309,8 @@ class CommunicationEmailMixin:
"is_notification": (self.sent_or_received == "Received"),
"print_letterhead": print_letterhead,
"send_after": self.send_after,
"raw_html": raw_html,
"add_css": add_css,
}
def send_email(
@ -318,6 +322,8 @@ class CommunicationEmailMixin:
is_inbound_mail_communcation=None,
print_language=None,
now=False,
raw_html=False,
add_css=True,
):
if input_dict := self.sendmail_input_dict(
print_html=print_html,
@ -326,5 +332,7 @@ class CommunicationEmailMixin:
print_letterhead=print_letterhead,
is_inbound_mail_communcation=is_inbound_mail_communcation,
print_language=print_language,
raw_html=raw_html,
add_css=add_css,
):
frappe.sendmail(now=now, **input_dict)

View file

@ -6,18 +6,32 @@ def execute():
batch_size = 10_000
while True:
frappe.db.sql(
"""
update `tabCommunication Link` cl
inner join `tabCommunication` c on cl.parent = c.name
set cl.communication_date = c.communication_date
where cl.communication_date is null
and c.communication_date is not null
limit %s
""",
frappe.db.multisql(
{
"mariadb": """
update `tabCommunication Link` cl
inner join `tabCommunication` c on cl.parent = c.name
set cl.communication_date = c.communication_date
where cl.communication_date is null
and c.communication_date is not null
limit %s
""",
"*": """
UPDATE `tabCommunication Link`
SET communication_date = sub.communication_date
FROM (
SELECT cl.name, c.communication_date
FROM `tabCommunication Link` cl
JOIN `tabCommunication` c ON cl.parent = c.name
WHERE cl.communication_date IS NULL
AND c.communication_date IS NOT NULL
LIMIT %s
) AS sub
WHERE `tabCommunication Link`.name = sub.name
""",
},
(batch_size,),
)
frappe.db.commit()
if not frappe.db.sql(

View file

@ -1153,7 +1153,8 @@ def build_fields_dict_for_column_matching(parent_doctype):
doctypes = [(parent_doctype, None)] + [(df.options, df) for df in parent_meta.get_table_fields()]
for doctype, table_df in doctypes:
translated_table_label = _(table_df.label) if table_df else None
table_ref = (table_df.label or table_df.fieldname) if table_df else None
translated_table_label = _(table_ref) if table_ref else None
# name field
name_df = frappe._dict(
@ -1175,7 +1176,7 @@ def build_fields_dict_for_column_matching(parent_doctype):
else:
name_headers = (
f"{table_df.fieldname}.name", # fieldname
f"ID ({table_df.label})", # label
f"ID ({table_ref})", # label
"{} ({})".format(_("ID"), translated_table_label), # translated label
)
@ -1229,7 +1230,7 @@ def build_fields_dict_for_column_matching(parent_doctype):
# fieldname
f"{table_df.fieldname}.{df.fieldname}",
# label
f"{label} ({table_df.label})",
f"{label} ({table_ref})",
# translated label
f"{translated_label} ({translated_table_label})",
):

View file

@ -1,7 +1,7 @@
# Copyright (c) 2019, Frappe Technologies and Contributors
# License: MIT. See LICENSE
import frappe
from frappe.core.doctype.data_import.importer import Importer
from frappe.core.doctype.data_import.importer import Importer, build_fields_dict_for_column_matching
from frappe.tests import IntegrationTestCase
from frappe.tests.test_query_builder import db_type_is, unimplemented_for
from frappe.utils import format_duration, getdate
@ -146,6 +146,22 @@ class TestImporter(IntegrationTestCase):
self.assertEqual(updated_doc.table_field_1[0].child_description, "child description")
self.assertEqual(updated_doc.table_field_1_again[0].child_title, "child title again")
def test_data_import_without_label(self):
"""Test fallback to fieldname when label is not set for a table."""
meta = frappe.get_meta(doctype_name)
table_field = meta.get_field("table_field_1")
original_label = table_field.label
table_field.label = None
fields_dict = build_fields_dict_for_column_matching(doctype_name)
expected_key = "Child Title (table_field_1)"
self.assertIn(
expected_key, fields_dict, f"Fallback failed: '{expected_key}' not found in mapping dict"
)
expected_id_key = "ID (table_field_1)"
self.assertIn(expected_id_key, fields_dict, "ID fallback failed")
table_field.label = original_label # maintain sanity in test env
def get_importer(self, doctype, import_file, update=False, use_sniffer=False):
data_import = frappe.new_doc("Data Import")
data_import.import_type = "Insert New Records" if not update else "Update Existing Records"

View file

@ -45,7 +45,7 @@ def restore(name, alert=True):
frappe.throw(_("Document {0} Already Restored").format(name), exc=frappe.DocumentAlreadyRestored)
doc = frappe.get_doc(json.loads(deleted.data))
doc.flags.from_restore = True
try:
doc.insert()
except frappe.DocstatusTransitionError:

View file

@ -11,7 +11,7 @@ frappe.listview_settings["Deleted Document"] = {
if (r.message) {
let body = (docnames) => {
const html = docnames.map((docname) => {
return `<li><a href='/app/deleted-document/${docname}'>${docname}</a></li>`;
return `<li><a href='/desk/deleted-document/${docname}'>${docname}</a></li>`;
});
return "<br><ul>" + html.join("");
};

View file

@ -72,6 +72,7 @@
"mandatory_depends_on",
"read_only_depends_on",
"display",
"alignment",
"print_width",
"width",
"max_height",
@ -231,7 +232,6 @@
"fieldtype": "Column Break"
},
{
"description": "For Links, enter the DocType as range.\nFor Select, enter list of Options, each on a new line.",
"fieldname": "options",
"fieldtype": "Small Text",
"ignore_xss_filter": 1,
@ -476,6 +476,13 @@
"max_height": "3rem",
"options": "JS"
},
{
"depends_on": "eval:in_list([\"Data\", \"Int\", \"Float\", \"Currency\", \"Percent\"], doc.fieldtype)",
"fieldname": "alignment",
"fieldtype": "Select",
"label": "Alignment",
"options": "\nLeft\nCenter\nRight"
},
{
"fieldname": "column_break_38",
"fieldtype": "Column Break"
@ -633,7 +640,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-12-01 06:56:29.335491",
"modified": "2026-01-06 01:37:29.723265",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",

View file

@ -17,6 +17,7 @@ class DocField(Document):
allow_bulk_edit: DF.Check
allow_in_quick_entry: DF.Check
allow_on_submit: DF.Check
alignment: DF.Literal["", "Left", "Center", "Right"]
bold: DF.Check
button_color: DF.Literal["", "Default", "Primary", "Info", "Success", "Warning", "Danger"]
collapsible: DF.Check
@ -126,7 +127,6 @@ class DocField(Document):
def get_link_doctype(self):
"""Return the Link doctype for the `docfield` (if applicable).
* If fieldtype is Link: Return "options".
* If fieldtype is Table MultiSelect: Return "options" of the Link field in the Child Table.
"""

View file

@ -224,3 +224,26 @@ class TestDocShare(IntegrationTestCase):
add,
{"doctype": "Event", "name": self.event.name, "assign_to": ["test1@example.com"]},
)
def test_cannot_share_without_permission(self):
"""Test that users cannot share permissions they don't have."""
# Users don't have write permission on Communication
doc = frappe.new_doc("Communication", subject="Hello World").save()
try:
frappe.set_user(self.user)
# Attempting to share with write permission should fail
self.assertRaises(
frappe.PermissionError,
frappe.share.add,
"Communication",
doc.name,
"test1@example.com",
write=1,
)
# Can share read
frappe.share.add("Communication", doc.name, "test1@example.com")
finally:
doc.delete()

View file

@ -0,0 +1,5 @@
// Copyright (c) {year}, {app_publisher} and contributors
// For license information, please see license.txt
// frappe.treeview_settings["{doctype}"] = {{
// }};

View file

@ -0,0 +1,5 @@
{{% extends "templates/web.html" %}}
{{% block page_content %}}
{{% include "templates/includes/list/list.html" %}}
{{% endblock %}}

View file

@ -54,11 +54,11 @@ frappe.ui.form.on("DocType", {
? __("Go to {0}", [__(frm.doc.name)])
: __("Go to {0} List", [__(frm.doc.name)]);
frm.add_custom_button(button_text, () => {
window.open(`/app/${frappe.router.slug(frm.doc.name)}`);
window.open(`/desk/${frappe.router.slug(frm.doc.name)}`);
});
}
const customize_form_link = `<a href="/app/customize-form">${__("Customize Form")}</a>`;
const customize_form_link = `<a href="/desk/customize-form">${__("Customize Form")}</a>`;
if (!frappe.boot.developer_mode && !frm.doc.custom) {
// make the document read-only
frm.set_read_only();

View file

@ -362,19 +362,7 @@ class DocType(Document):
continue # Invalid expression
link_df = new_meta.get_field(link_fieldname)
if frappe.db.db_type == "postgres":
update_query = """
UPDATE `tab{doctype}`
SET `{fieldname}` = source.`{source_fieldname}`
FROM `tab{link_doctype}` as source
WHERE `{link_fieldname}` = source.name
"""
if df.not_nullable:
update_query += "AND `{fieldname}`=''"
else:
update_query += "AND ifnull(`{fieldname}`, '')=''"
else:
if frappe.db.db_type == "mariadb":
update_query = """
UPDATE `tab{doctype}` as target
INNER JOIN `tab{link_doctype}` as source
@ -386,6 +374,18 @@ class DocType(Document):
else:
update_query += "WHERE ifnull(`target`.`{fieldname}`, '')=''"
else:
update_query = """
UPDATE `tab{doctype}`
SET `{fieldname}` = source.`{source_fieldname}`
FROM `tab{link_doctype}` as source
WHERE `{link_fieldname}` = source.name
"""
if df.not_nullable:
update_query += "AND `{fieldname}`=''"
else:
update_query += "AND ifnull(`{fieldname}`, '')=''"
self.flags.update_fields_to_fetch_queries.append(
update_query.format(
link_doctype=link_df.options,
@ -877,6 +877,9 @@ class DocType(Document):
make_boilerplate("controller.js", self.as_dict())
# make_boilerplate("controller_list.js", self.as_dict())
if self.is_tree:
make_boilerplate("controller_tree.js", self.as_dict())
if self.has_web_view:
templates_path = frappe.get_module_path(
frappe.scrub(self.module), "doctype", frappe.scrub(self.name), "templates"
@ -884,6 +887,7 @@ class DocType(Document):
if not os.path.exists(templates_path):
os.makedirs(templates_path)
make_boilerplate("templates/controller.html", self.as_dict())
make_boilerplate("templates/controller_list.html", self.as_dict())
make_boilerplate("templates/controller_row.html", self.as_dict())
def export_types_to_controller(self):
@ -1139,10 +1143,10 @@ def validate_empty_name(dt, autoname):
frappe.toast(_("Warning: Naming is not set"), indicator="yellow")
def validate_autoincrement_autoname(dt: Union[DocType, "CustomizeForm"]) -> bool:
def validate_autoincrement_autoname(dt: DocType | "CustomizeForm") -> bool:
"""Checks if can doctype can change to/from autoincrement autoname"""
def get_autoname_before_save(dt: Union[DocType, "CustomizeForm"]) -> str:
def get_autoname_before_save(dt: DocType | "CustomizeForm") -> str:
if dt.doctype == "Customize Form":
property_value = frappe.db.get_value(
"Property Setter", {"doc_type": dt.doc_type, "property": "autoname"}, "value"
@ -1277,6 +1281,8 @@ def validate_fields(meta: Meta):
validate_column_name(fieldname)
def check_invalid_fieldnames(docname, fieldname):
RESERVED_DOCFIELD_NAMES = frozenset(("autoname",))
if fieldname in RESERVED_KEYWORDS:
frappe.throw(
_("{0}: fieldname cannot be set to reserved keyword {1}").format(
@ -1286,6 +1292,15 @@ def validate_fields(meta: Meta):
title=_("Invalid Fieldname"),
)
if fieldname in RESERVED_DOCFIELD_NAMES and docname != "DocType":
frappe.throw(
_("{0}: fieldname cannot be set to reserved field {1} in DocType").format(
frappe.bold(docname),
frappe.bold(fieldname),
),
title=_("Invalid Fieldname"),
)
def check_unique_fieldname(docname, fieldname):
duplicates = list(
filter(None, map(lambda df: (df.fieldname == fieldname and str(df.idx)) or None, fields))
@ -1826,34 +1841,84 @@ def validate_permissions(doctype, for_remove=False, alert=False):
def check_permission_dependency(d):
if d.cancel and not d.submit:
frappe.throw(_("{0}: Cannot set Cancel without Submit").format(get_txt(d)))
frappe.throw(
_("{0}: The 'Cancel' permission cannot be granted without the 'Submit' permission.").format(
get_txt(d)
)
)
if (d.submit or d.cancel or d.amend) and not d.write:
frappe.throw(_("{0}: Cannot set Submit, Cancel, Amend without Write").format(get_txt(d)))
if d.amend and not d.write:
frappe.throw(_("{0}: Cannot set Amend without Cancel").format(get_txt(d)))
frappe.throw(
_(
"{0}: The 'Submit', 'Cancel', and 'Amend' permissions cannot be granted without the 'Write' permission."
).format(get_txt(d))
)
if d.amend and not d.create:
frappe.throw(
_("{0}: The 'Amend' permission cannot be granted without the 'Create' permission.").format(
get_txt(d)
)
)
if d.get("import") and not d.create:
frappe.throw(_("{0}: Cannot set Import without Create").format(get_txt(d)))
frappe.throw(
_("{0}: The 'Import' permission cannot be granted without the 'Create' permission.").format(
get_txt(d)
)
)
def remove_rights_for_single(d):
if not issingle:
return
if d.report:
frappe.msgprint(_("Report cannot be set for Single types"))
d.report = 0
if d.get("report"):
d.set("report", 0)
frappe.msgprint(
_(
"{0}: The 'Report' permission was removed because it cannot be granted for a 'single' DocType."
).format(get_txt(d))
)
if d.get("import"):
d.set("import", 0)
frappe.msgprint(
_(
"{0}: The 'Import' permission was removed because it cannot be granted for a 'single' DocType."
).format(get_txt(d))
)
if d.get("export"):
d.set("export", 0)
frappe.msgprint(
_(
"{0}: The 'Export' permission was removed because it cannot be granted for a 'single' DocType."
).format(get_txt(d))
)
def check_if_submittable(d):
if d.submit and not issubmittable:
frappe.throw(_("{0}: Cannot set Assign Submit if not Submittable").format(get_txt(d)))
elif d.amend and not issubmittable:
frappe.throw(_("{0}: Cannot set Assign Amend if not Submittable").format(get_txt(d)))
if issubmittable:
return
if d.submit:
frappe.throw(
_("{0}: The 'Submit' permission cannot be granted for a non-submittable DocType.").format(
get_txt(d)
)
)
if d.amend:
frappe.throw(
_("{0}: The 'Amend' permission cannot be granted for a non-submittable DocType.").format(
get_txt(d)
)
)
def check_if_importable(d):
if d.get("import") and not isimportable:
frappe.throw(_("{0}: Cannot set import as {1} is not importable").format(get_txt(d), doctype))
frappe.throw(
_("{0}: The 'Import' permission cannot be granted for a non-importable DocType.").format(
get_txt(d)
)
)
def validate_permission_for_all_role(d):
if frappe.session.user == "Administrator":
@ -1863,7 +1928,7 @@ def validate_permissions(doctype, for_remove=False, alert=False):
if d.role in AUTOMATIC_ROLES:
frappe.throw(
_(
"Row # {0}: Non administrator user can not set the role {1} to the custom doctype"
"Row # {0}: Non-administrator users cannot add the role {1} to a custom DocType."
).format(d.idx, frappe.bold(_(d.role))),
title=_("Permissions Error"),
)
@ -1873,7 +1938,7 @@ def validate_permissions(doctype, for_remove=False, alert=False):
if d.role in roles:
frappe.throw(
_(
"Row # {0}: Non administrator user can not set the role {1} to the custom doctype"
"Row # {0}: Non-administrator users cannot add the role {1} to a custom DocType."
).format(d.idx, frappe.bold(_(d.role))),
title=_("Permissions Error"),
)

View file

@ -892,7 +892,7 @@ def has_permission(doc, ptype=None, user=None, debug=False):
try:
ref_doc = frappe.get_doc(attached_to_doctype, attached_to_name)
except ModuleNotFoundError:
except (ModuleNotFoundError, ImportError):
return False
except frappe.DoesNotExistError:
frappe.clear_last_message()

View file

@ -54,7 +54,7 @@ def get_extension(
filename,
extn: str | None = None,
content: bytes | None = None,
response: Optional["Response"] = None,
response: "Response" | None = None,
) -> str:
mimetype = None
@ -426,7 +426,7 @@ def decode_file_content(content: bytes) -> bytes:
return safe_b64decode(content)
def find_file_by_url(path: str, name: str | None = None) -> Optional["File"]:
def find_file_by_url(path: str, name: str | None = None) -> "File" | None:
filters = {"file_url": str(path)}
if name:
filters["name"] = str(name)

View file

@ -6,6 +6,7 @@ import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils.caching import redis_cache
class InvalidAppOrder(frappe.ValidationError):
@ -152,24 +153,16 @@ def get_installed_app_order() -> list[str]:
return frappe.get_installed_apps(_ensure_on_bench=True)
@frappe.request_cache
def get_setup_wizard_completed_apps():
"""Get list of apps that have completed setup wizard"""
return frappe.get_all(
"Installed Application",
filters={"has_setup_wizard": 1, "is_setup_complete": 1},
pluck="app_name",
)
apps: InstalledApplications = frappe.client_cache.get_doc("Installed Applications")
return [a.app_name for a in apps.installed_applications if a.has_setup_wizard and a.is_setup_complete]
@frappe.request_cache
def get_setup_wizard_not_required_apps():
"""Get list of apps that do not require setup wizard"""
return frappe.get_all(
"Installed Application",
filters={"has_setup_wizard": 0},
pluck="app_name",
)
apps: InstalledApplications = frappe.client_cache.get_doc("Installed Applications")
return [a.app_name for a in apps.installed_applications if not a.has_setup_wizard]
@frappe.request_cache
@ -190,17 +183,14 @@ def get_apps_with_incomplete_dependencies(current_app):
return pending_apps
@frappe.request_cache
def get_setup_wizard_pending_apps(apps=None):
"""Get list of apps that have completed setup wizard"""
filters = {"has_setup_wizard": 1, "is_setup_complete": 0}
apps: InstalledApplications = frappe.client_cache.get_doc("Installed Applications")
pending_apps = [
a.app_name for a in apps.installed_applications if a.has_setup_wizard and not a.is_setup_complete
]
if apps:
filters["app_name"] = ["in", apps]
pending_apps = [a for a in pending_apps if a in apps]
return frappe.get_all(
"Installed Application",
filters=filters,
order_by="idx",
pluck="app_name",
)
return pending_apps

View file

@ -123,7 +123,7 @@ def has_unseen_error_log():
return {
"show_alert": True,
"message": _("You have unseen {0}").format(
'<a href="/app/List/Error%20Log/List"> Error Logs </a>'
'<a href="/desk/List/Error%20Log/List"> Error Logs </a>'
),
}
@ -131,7 +131,7 @@ def has_unseen_error_log():
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_log_doctypes(doctype, txt, searchfield, start, page_len, filters):
filters = filters or {}
filters = filters or []
filters.extend(
[

View file

@ -43,9 +43,9 @@ def make_perm_log(doc, method=None):
def insert_perm_log(
doc: Document,
doc_before_save: Document = None,
for_doctype: Optional["str"] = None,
for_document: Optional["str"] = None,
fields: Optional["list | tuple"] = None,
for_doctype: str | None = None,
for_document: str | None = None,
fields: list | tuple | None = None,
):
if frappe.flags.in_install or frappe.flags.in_migrate:
# no need to log changes when migrating or installing app/site

View file

@ -58,7 +58,7 @@
},
{
"fieldname": "error_message",
"fieldtype": "Text",
"fieldtype": "Code",
"label": "Error Message",
"no_copy": 1,
"print_hide": 1,

View file

@ -65,7 +65,7 @@ class Report(Document):
if self.is_standard == "No":
# allow only script manager to edit scripts
if self.report_type != "Report Builder":
if self.report_type not in ("Report Builder", "Custom Report"):
frappe.only_for("Script Manager", True)
if frappe.db.get_value("Report", self.name, "is_standard") == "Yes":
@ -416,9 +416,10 @@ def get_report_module_dotted_path(module, report_name):
def get_group_by_field(args, doctype):
if args["aggregate_function"] == "count":
group_by_field = "count(*) as _aggregate_column"
group_by_field = {"COUNT": "*", "as": "_aggregate_column"}
else:
group_by_field = f"{args.aggregate_function}({args.aggregate_on}) as _aggregate_column"
func_name = args["aggregate_function"].upper()
group_by_field = {func_name: args["aggregate_on"], "as": "_aggregate_column"}
return group_by_field

View file

@ -53,7 +53,7 @@
"options": "Domain"
},
{
"description": "Route: Example \"/app\"",
"description": "Route: Example \"/desk\"",
"fieldname": "home_page",
"fieldtype": "Data",
"label": "Home Page"
@ -74,7 +74,7 @@
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-19 17:07:08.672124",
"modified": "2026-01-28 15:58:11.939407",
"modified_by": "Administrator",
"module": "Core",
"name": "Role",
@ -94,9 +94,10 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1,
"translated_doctype": 1
}
}

View file

@ -116,7 +116,8 @@ class TestRQJob(IntegrationTestCase):
frappe.enqueue(self.BG_JOB, sleep=1, queue=q)
_, stderr = execute_in_shell(
"bench worker-pool --queue short,default --burst --num-workers=4", check_exit_code=True
"bench worker-pool --queue short,default --burst --num-workers=4",
check_exit_code=True,
)
self.assertIn("quitting", cstr(stderr))
@ -178,10 +179,7 @@ class TestRQJob(IntegrationTestCase):
LAST_MEASURED_USAGE += 2
# Observed higher usage on 3.14. Temporarily raising the limit
from sys import version_info
if version_info >= (3, 14):
LAST_MEASURED_USAGE += 5
LAST_MEASURED_USAGE += 6
self.assertLessEqual(rss, LAST_MEASURED_USAGE * 1.05, msg)

View file

@ -109,7 +109,6 @@ def serialize_worker(worker: Worker) -> frappe._dict:
def compute_utilization(worker: Worker) -> float:
with suppress(Exception):
total_time = (
datetime.datetime.now(datetime.timezone.utc)
- worker.birth_date.replace(tzinfo=datetime.timezone.utc)
datetime.datetime.now(datetime.UTC) - worker.birth_date.replace(tzinfo=datetime.UTC)
).total_seconds()
return worker.total_working_time / total_time * 100

View file

@ -136,7 +136,7 @@ order by creation desc
<hr>
<h4>Workflow Task</h4>
<p>Execute when a particular <a href="/app/workflow-action-master">Workflow Action Master</a> is executed.</p>
<p>Execute when a particular <a href="/desk/workflow-action-master">Workflow Action Master</a> is executed.</p>
<p>Gets the document which the action is being applied on in the <code>doc</code> variable.</p>
<pre><code class="language-python">
# create a customer with the same name as the given document

View file

@ -123,8 +123,13 @@ class ServerScript(Document):
if scheduled_script := frappe.db.get_value("Scheduled Job Type", {"server_script": self.name}):
return frappe.get_doc("Scheduled Job Type", scheduled_script)
else:
should_create_log = self.event_frequency not in ("All", "Cron")
return frappe.get_doc(
{"doctype": "Scheduled Job Type", "server_script": self.name, "create_log": 1}
{
"doctype": "Scheduled Job Type",
"server_script": self.name,
"create_log": should_create_log,
}
)
previous_script_type = self.get_value_before_save("script_type")

View file

@ -132,7 +132,7 @@ class SubmissionQueue(Document):
{
"message": message.format(
*message_replacements,
f"<a href='/app/{quote(doctype.lower().replace(' ', '-'))}/{quote(docname)}'><b>here</b></a>",
f"<a href='/desk/{quote(doctype.lower().replace(' ', '-'))}/{quote(docname)}'><b>here</b></a>",
),
"alert": True,
"indicator": "red" if submission_status == "Failed" else "green",
@ -170,7 +170,7 @@ def queue_submission(doc: Document, action: str, alert: bool = True):
frappe.msgprint(
_(
"This document has already been queued for submission. You can track the progress over {0}."
).format(f"<a href='/app/submission-queue/{existing_queue}'><b>here</b></a>"),
).format(f"<a href='/desk/submission-queue/{existing_queue}'><b>here</b></a>"),
indicator="orange",
alert=True,
)
@ -184,7 +184,7 @@ def queue_submission(doc: Document, action: str, alert: bool = True):
if alert:
frappe.msgprint(
_("Queued for Submission. You can track the progress over {0}.").format(
f"<a href='/app/submission-queue/{queue.name}'><b>here</b></a>"
f"<a href='/desk/submission-queue/{queue.name}'><b>here</b></a>"
),
indicator="green",
alert=True,

View file

@ -41,7 +41,7 @@ frappe.ui.form.on("Success Action", {
frm.action_multicheck = frappe.ui.form.make_control({
parent: next_actions_wrapper,
df: {
label: "Next Actions",
label: __("Next Actions"),
fieldname: "next_actions_multicheck",
fieldtype: "MultiCheck",
options: action_multicheck_options,

View file

@ -786,10 +786,11 @@
"label": "Only allow System Managers to upload public files"
}
],
"hide_toolbar": 1,
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2025-12-17 15:01:24.823184",
"modified": "2026-01-02 18:13:45.430712",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",

View file

@ -27,6 +27,7 @@ from frappe.tests.classes.context_managers import change_settings
from frappe.tests.test_api import FrappeAPITestCase
from frappe.tests.utils import toggle_test_mode
from frappe.utils import get_url
from frappe.www.login import sanitize_redirect
user_module = frappe.core.doctype.user.user
@ -333,7 +334,9 @@ class TestUser(IntegrationTestCase):
sign_up(random_user, random_user_name, "/welcome"),
(1, "Please check your email for verification"),
)
self.assertEqual(frappe.cache.hget("redirect_after_login", random_user), "/welcome")
self.assertEqual(
frappe.cache.hget("redirect_after_login", random_user), sanitize_redirect("/welcome")
)
# re-register
self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (0, "Already Registered"))

View file

@ -59,6 +59,7 @@
"view_switcher",
"form_settings_section",
"form_sidebar",
"form_navigation_buttons",
"timeline",
"dashboard",
"show_absolute_datetime_in_timeline",
@ -636,7 +637,7 @@
"fieldname": "desk_theme",
"fieldtype": "Select",
"label": "Desk Theme",
"options": "Automatic\nLight\nDark"
"options": "Light\nDark\nAutomatic"
},
{
"fieldname": "module_profile",
@ -850,6 +851,12 @@
"is_virtual": 1,
"label": "Active Sessions",
"options": "User Session Display"
},
{
"default": "0",
"fieldname": "form_navigation_buttons",
"fieldtype": "Check",
"label": "Navigation Buttons"
}
],
"icon": "fa fa-user",
@ -903,7 +910,7 @@
}
],
"make_attachments_public": 1,
"modified": "2025-12-13 12:53:46.486021",
"modified": "2026-01-12 16:04:21.542524",
"modified_by": "Administrator",
"module": "Core",
"name": "User",

View file

@ -39,6 +39,7 @@ from frappe.utils.password import check_password, get_password_reset_limit
from frappe.utils.password import update_password as _update_password
from frappe.utils.user import get_system_managers
from frappe.website.utils import get_home_page, is_signup_disabled
from frappe.www.login import sanitize_redirect
desk_properties = (
"search_bar",
@ -47,6 +48,7 @@ desk_properties = (
"bulk_actions",
"view_switcher",
"form_sidebar",
"form_navigation_buttons",
"timeline",
"dashboard",
)
@ -83,7 +85,7 @@ class User(Document):
default_app: DF.Literal[None]
default_workspace: DF.Link | None
defaults: DF.Table[DefaultValue]
desk_theme: DF.Literal["Automatic", "Light", "Dark"]
desk_theme: DF.Literal["Light", "Dark", "Automatic"]
document_follow_frequency: DF.Literal["Hourly", "Daily", "Weekly"]
document_follow_notify: DF.Check
email: DF.Data
@ -95,6 +97,7 @@ class User(Document):
follow_created_documents: DF.Check
follow_liked_documents: DF.Check
follow_shared_documents: DF.Check
form_navigation_buttons: DF.Check
form_sidebar: DF.Check
full_name: DF.Data | None
gender: DF.Link | None
@ -554,7 +557,7 @@ class User(Document):
if custom_template:
from frappe.email.doctype.email_template.email_template import get_email_template
email_template = get_email_template(custom_template, args)
email_template = get_email_template(custom_template, args, sender=sender)
subject = email_template.get("subject")
content = email_template.get("message")
@ -1115,7 +1118,7 @@ def sign_up(email: str, full_name: str, redirect_to: str) -> tuple[int, str]:
user.add_roles(default_role)
if redirect_to:
frappe.cache.hset("redirect_after_login", user.name, redirect_to)
frappe.cache.hset("redirect_after_login", user.name, sanitize_redirect(redirect_to))
if user.flags.email_sent:
return 1, _("Please check your email for verification")
@ -1285,7 +1288,27 @@ def handle_password_test_fail(feedback: dict):
suggestions = feedback.get("suggestions", [])
warning = feedback.get("warning", "")
frappe.throw(msg=" ".join([warning, *suggestions]), title=_("Invalid Password"))
# Add fallback suggestion if nothing provided
if not (suggestions or warning):
suggestions = [_("Better add a few more letters or another word")]
message_parts = []
if warning:
message_parts.append(f'<div class="alert alert-warning" role="alert">{warning}</div>')
if suggestions:
suggestions_html = (
'<ul style="margin: 0; padding-left: 1em;">'
+ "".join(f"<li>{suggestion}</li>" for suggestion in suggestions)
+ "</ul>"
)
message_parts.append(suggestions_html)
frappe.throw(
msg="".join(message_parts),
title=_("Password requirements not met"),
)
def update_gravatar(name):

View file

@ -15,7 +15,9 @@ frappe.ui.form.on("User Permission", {
frm.set_query("applicable_for", () => {
return {
query: "frappe.core.doctype.user_permission.user_permission.get_applicable_for_doctype_list",
doctype: frm.doc.allow,
filters: {
doctype: frm.doc.allow,
},
};
});
},

View file

@ -161,7 +161,8 @@ def user_permission_exists(user, allow, for_value, applicable_for=None):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_applicable_for_doctype_list(doctype, txt, searchfield, start, page_len, filters):
linked_doctypes_map = get_linked_doctypes(doctype, True)
actual_doctype = filters.get("doctype")
linked_doctypes_map = get_linked_doctypes(actual_doctype, True)
linked_doctypes = []
for linked_doctype, linked_doctype_values in linked_doctypes_map.items():
@ -170,7 +171,7 @@ def get_applicable_for_doctype_list(doctype, txt, searchfield, start, page_len,
if child_doctype:
linked_doctypes.append(child_doctype)
linked_doctypes += [doctype]
linked_doctypes += [actual_doctype]
if txt:
linked_doctypes = [d for d in linked_doctypes if txt.lower() in d.lower()]

View file

@ -3,12 +3,137 @@
import copy
import frappe
from frappe.core.doctype.version.version import get_diff
from frappe.tests import IntegrationTestCase
from frappe.core.doctype.version.version import (
_as_string,
_generate_html_diff,
_should_generate_html_diff,
get_diff,
)
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests.utils import make_test_objects
class TestHTMLDiff(UnitTestCase):
def test_generate_html_diff_produces_table(self):
"""Test HTML diff generates a table with content."""
result = _generate_html_diff("line1\nline2", "line1\nmodified")
self.assertIsNotNone(result)
self.assertIn("<table", result)
self.assertIn("line1", result)
def test_generate_html_diff_escapes_html(self):
"""Test HTML output is properly escaped and safe."""
old_value = "<script>alert('xss')</script>\nline2"
new_value = "<div>injected</div>\nline2"
result = _generate_html_diff(old_value, new_value)
self.assertIsNotNone(result)
# Raw script/div tags should be escaped, not executable
self.assertNotIn("<script>alert", result)
self.assertNotIn("<div>injected", result)
# Escaped versions should be present
self.assertIn("&lt;script&gt;", result)
self.assertIn("&lt;div&gt;", result)
def test_should_generate_html_diff_multiline(self):
"""Test should_generate_html_diff returns True for multiline text."""
self.assertTrue(_should_generate_html_diff("line1\nline2", "line1\nmodified"))
self.assertTrue(_should_generate_html_diff("single", "multi\nline"))
self.assertTrue(_should_generate_html_diff("multi\nline", "single"))
def test_should_generate_html_diff_long_text(self):
"""Test should_generate_html_diff returns True for text > 80 characters."""
self.assertTrue(_should_generate_html_diff("a" * 81, "b"))
self.assertTrue(_should_generate_html_diff("a", "b" * 81))
self.assertTrue(_should_generate_html_diff("a" * 81, "b" * 81))
def test_should_generate_html_diff_short_text(self):
"""Test should_generate_html_diff returns False for short single-line text."""
self.assertFalse(_should_generate_html_diff("short", "text"))
self.assertFalse(_should_generate_html_diff("a" * 80, "b" * 80)) # Exactly 80 chars
def test_should_generate_html_diff_empty_values(self):
"""Test should_generate_html_diff returns False when either value is empty."""
self.assertFalse(_should_generate_html_diff("", "short"))
self.assertFalse(_should_generate_html_diff("short", ""))
self.assertFalse(_should_generate_html_diff("", ""))
# Even long/multiline text returns False if the other value is empty
self.assertFalse(_should_generate_html_diff("", "a" * 81))
self.assertFalse(_should_generate_html_diff("multi\nline", ""))
def test_as_string_converts_values(self):
"""Test _as_string converts values to strings correctly."""
self.assertEqual(_as_string("text"), "text")
self.assertEqual(_as_string(None), "")
self.assertEqual(_as_string(""), "")
self.assertEqual(_as_string(0), "0")
class TestVersion(IntegrationTestCase):
def test_onload_generates_html_diffs_for_multiline(self):
"""Test onload generates HTML diffs for multiline changes."""
version = frappe.get_doc(
doctype="Version",
ref_doctype="ToDo",
docname="test-doc",
data=frappe.as_json({"changed": [["description", "line1\nline2", "line1\nmodified"]]}),
)
version.onload()
html_diffs = version.get_onload().get("html_diffs")
self.assertIsNotNone(html_diffs)
self.assertIn("description", html_diffs)
self.assertIn("<table", html_diffs["description"])
def test_onload_generates_html_diffs_for_long_text(self):
"""Test onload generates HTML diffs for text > 80 characters."""
version = frappe.get_doc(
doctype="Version",
ref_doctype="ToDo",
docname="test-doc",
data=frappe.as_json({"changed": [["notes", "x" * 81, "y" * 81]]}),
)
version.onload()
html_diffs = version.get_onload().get("html_diffs")
self.assertIsNotNone(html_diffs)
self.assertIn("notes", html_diffs)
def test_onload_no_html_diffs_for_simple_changes(self):
"""Test onload doesn't generate HTML diffs for simple short changes."""
version = frappe.get_doc(
doctype="Version",
ref_doctype="ToDo",
docname="test-doc",
data=frappe.as_json({"changed": [["status", "Open", "Closed"]]}),
)
version.onload()
html_diffs = version.get_onload().get("html_diffs")
self.assertIsNone(html_diffs)
def test_onload_handles_empty_data(self):
"""Test onload handles empty or missing data gracefully."""
version = frappe.get_doc(
doctype="Version",
ref_doctype="ToDo",
docname="test-doc",
data=None,
)
# Should not raise an error
version.onload()
self.assertIsNone(version.get_onload().get("html_diffs"))
version.data = frappe.as_json({"changed": []})
version.onload()
self.assertIsNone(version.get_onload().get("html_diffs"))
def test_get_diff(self):
frappe.set_user("Administrator")
test_records = make_test_objects("Event", reset=True)

View file

@ -1,12 +1,23 @@
frappe.ui.form.on("Version", "refresh", function (frm) {
$(
frappe.render_template("version_view", { doc: frm.doc, data: JSON.parse(frm.doc.data) })
).appendTo(frm.fields_dict.table_html.$wrapper.empty());
frm.add_custom_button(__("Show all Versions"), function () {
frappe.set_route("List", "Version", {
ref_doctype: frm.doc.ref_doctype,
docname: frm.doc.docname,
frappe.ui.form.on("Version", {
refresh: function (frm) {
frm.add_custom_button(__("Show all Versions"), function () {
frappe.set_route("List", "Version", {
ref_doctype: frm.doc.ref_doctype,
docname: frm.doc.docname,
});
});
});
frm.trigger("render_version_view");
},
render_version_view: async function (frm) {
await frappe.model.with_doctype(frm.doc.ref_doctype);
$(
frappe.render_template("version_view", {
doc: frm.doc,
data: JSON.parse(frm.doc.data),
})
).appendTo(frm.fields_dict.table_html.$wrapper.empty());
},
});

View file

@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import difflib
import json
import frappe
@ -74,6 +75,29 @@ class Version(Document):
def get_data(self):
return json.loads(self.data)
def onload(self):
"""Generate HTML diffs for multiline changes on document load."""
if not self.data:
return
data = self.get_data()
changed = data.get("changed", [])
if not changed:
return
html_diffs = {}
for item in changed:
if len(item) >= 3:
fieldname, old_str, new_str = item[0], _as_string(item[1]), _as_string(item[2])
if not _should_generate_html_diff(old_str, new_str):
continue
html_diff = _generate_html_diff(old_str, new_str)
if html_diff:
html_diffs[fieldname] = html_diff
if html_diffs:
self.set_onload("html_diffs", html_diffs)
def get_diff(old, new, for_child=False, compare_cancelled=False):
"""Get diff between 2 document objects
@ -203,3 +227,32 @@ def get_diff(old, new, for_child=False, compare_cancelled=False):
def on_doctype_update():
frappe.db.add_index("Version", ["ref_doctype", "docname"])
def _generate_html_diff(old_str: str, new_str: str) -> str | None:
"""Generate HTML diff for the given old and new strings."""
old_lines = old_str.splitlines(keepends=True)
new_lines = new_str.splitlines(keepends=True)
differ = difflib.HtmlDiff(wrapcolumn=80)
html_diff = differ.make_table(
old_lines,
new_lines,
fromdesc=frappe._("Original"),
todesc=frappe._("New"),
context=True,
numlines=3,
)
return html_diff
def _should_generate_html_diff(old_str: str, new_str: str) -> bool:
"""Determine if HTML diff should be generated for the given values."""
return (
old_str and new_str and ("\n" in old_str or "\n" in new_str or len(old_str) > 80 or len(new_str) > 80)
)
def _as_string(value: str | None) -> str:
"""Convert the given value to a string."""
return cstr(value) if value is not None else ""

View file

@ -1,3 +1,52 @@
<style>
.version-diff-container {
margin-bottom: 1rem;
}
.version-diff-container h5 {
margin-bottom: 0.5rem;
}
.version-html-diff table.diff {
width: 100%;
border-collapse: collapse;
font-family: monospace;
font-size: 12px;
}
.version-html-diff table.diff td {
padding: 2px 6px;
border: 1px solid var(--border-color);
vertical-align: top;
}
.version-html-diff table.diff .diff_header {
background-color: var(--subtle-fg);
text-align: right;
padding: 2px 6px;
color: var(--text-muted);
font-weight: normal;
width: 40px;
}
.version-html-diff table.diff .diff_next {
background-color: var(--subtle-fg);
width: 10px;
}
.version-html-diff table.diff .diff_add {
background-color: var(--diff-added);
}
.version-html-diff table.diff .diff_chg {
background-color: var(--diff-changed);
}
.version-html-diff table.diff .diff_sub {
background-color: var(--diff-removed);
}
.version-html-diff table.diff th {
background-color: var(--subtle-fg);
padding: 6px;
border: 1px solid var(--border-color);
font-weight: 500;
}
.version-html-diff table.diff colgroup {
display: none;
}
</style>
<div class="version-info">
{% if data.comment %}
<h4>{{ __("Comment") + " (" + data.comment_type }})</h4>
@ -5,8 +54,19 @@
{% endif %}
{% const getEscapedValue = (v) => v === null ? "null" : frappe.utils.escape_html(v) %}
{% const htmlDiffs = (doc.__onload && doc.__onload.html_diffs) || {} %}
{% if data.changed && data.changed.length %}
<h4>{{ __("Values Changed") }}</h4>
{% for item in data.changed %}
{% if htmlDiffs[item[0]] %}
<div class="version-diff-container">
<h5>{{ frappe.meta.get_label(doc.ref_doctype, item[0]) }}</h5>
<div class="version-html-diff">{{ htmlDiffs[item[0]] }}</div>
</div>
{% endif %}
{% endfor %}
{% var hasSimpleChanges = data.changed.some(item => !htmlDiffs[item[0]]) %}
{% if hasSimpleChanges %}
<table class="table table-bordered">
<thead>
<tr>
@ -17,15 +77,18 @@
</thead>
<tbody>
{% for item in data.changed %}
{% if !htmlDiffs[item[0]] %}
<tr>
<td>{{ frappe.meta.get_label(doc.ref_doctype, item[0]) }}</td>
<td class="diff-remove">{{ getEscapedValue(item[1]) }}</td>
<td class="diff-add">{{ getEscapedValue(item[2]) }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% endif %}
{% endif %}
{% var _keys = ["added", "removed"]; %}
{% for key in _keys %}

View file

@ -0,0 +1,26 @@
{
"aggregate_function_based_on": "",
"creation": "2026-01-11 23:59:34.870238",
"currency": "INR",
"docstatus": 0,
"doctype": "Number Card",
"document_type": "RQ Worker",
"dynamic_filters_json": "[]",
"filters_json": "[]",
"function": "Count",
"idx": 0,
"is_public": 0,
"is_standard": 1,
"label": "Active RQ Worker",
"modified": "2026-01-11 23:59:34.870238",
"modified_by": "Administrator",
"module": "Core",
"name": "Active RQ Worker",
"owner": "Administrator",
"parent_document_type": "",
"report_function": "Sum",
"show_full_number": 0,
"show_percentage_stats": 1,
"stats_time_interval": "Daily",
"type": "Document Type"
}

View file

@ -0,0 +1,27 @@
{
"aggregate_function_based_on": "",
"color": "#CB2929",
"creation": "2026-01-11 23:49:55.987084",
"currency": "INR",
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Error Log",
"dynamic_filters_json": "[]",
"filters_json": "[]",
"function": "Count",
"idx": 0,
"is_public": 0,
"is_standard": 1,
"label": "Error Logs",
"modified": "2026-01-11 23:56:36.628717",
"modified_by": "Administrator",
"module": "Core",
"name": "Error Logs",
"owner": "Administrator",
"parent_document_type": "",
"report_function": "Sum",
"show_full_number": 0,
"show_percentage_stats": 1,
"stats_time_interval": "Daily",
"type": "Document Type"
}

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