Merge branch 'develop' into add-custom-hook
This commit is contained in:
commit
a74725f826
499 changed files with 77628 additions and 62636 deletions
|
|
@ -64,3 +64,6 @@ e9bbe03354079cfcef65a77b0c33f57b047a7c93
|
|||
|
||||
# another ruff update
|
||||
6ca4d4d167a1a009d99062747711de7a994aa633
|
||||
|
||||
# some more ruff
|
||||
8723a2b6ee9dbec800077f18202ba53b0ef553e7
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
|
@ -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.
|
||||
|
|
|
|||
12
.github/actions/setup/action.yml
vendored
12
.github/actions/setup/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
15
.github/helper/roulette.py
vendored
15
.github/helper/roulette.py
vendored
|
|
@ -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
4
.github/stale.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
31
.github/workflows/_base-migration.yml
vendored
31
.github/workflows/_base-migration.yml
vendored
|
|
@ -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
|
||||
|
|
@ -50,6 +50,15 @@ jobs:
|
|||
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
|
||||
run: |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/_base-server-tests.yml
vendored
2
.github/workflows/_base-server-tests.yml
vendored
|
|
@ -13,7 +13,7 @@ on:
|
|||
node-version:
|
||||
required: false
|
||||
type: number
|
||||
default: 22
|
||||
default: 24
|
||||
parallel-runs:
|
||||
required: false
|
||||
type: number
|
||||
|
|
|
|||
2
.github/workflows/_base-ui-tests.yml
vendored
2
.github/workflows/_base-ui-tests.yml
vendored
|
|
@ -13,7 +13,7 @@ on:
|
|||
node-version:
|
||||
required: false
|
||||
type: number
|
||||
default: 22
|
||||
default: 24
|
||||
parallel-runs:
|
||||
required: false
|
||||
type: number
|
||||
|
|
|
|||
2
.github/workflows/create-release.yml
vendored
2
.github/workflows/create-release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
7
.github/workflows/generate-pot-file.yml
vendored
7
.github/workflows/generate-pot-file.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/initiate_release.yml
vendored
2
.github/workflows/initiate_release.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version: ["14", "15"]
|
||||
version: ["14", "15", "16"]
|
||||
|
||||
steps:
|
||||
- uses: octokit/request-action@v2.x
|
||||
|
|
|
|||
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/on_release.yml
vendored
2
.github/workflows/on_release.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/publish-assets-develop.yml
vendored
2
.github/workflows/publish-assets-develop.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
2
.github/workflows/run-indinvidual-tests.yml
vendored
2
.github/workflows/run-indinvidual-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
9
.github/workflows/server-tests.yml
vendored
9
.github/workflows/server-tests.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
49
.mergify.yml
49
.mergify.yml
|
|
@ -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 }}"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
11
.releaserc
11
.releaserc
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ context("Customize Form", () => {
|
|||
"Set by user": "prompt",
|
||||
"By fieldname": "field:",
|
||||
Expression: "",
|
||||
"Expression (old style)": "format:",
|
||||
Random: "hash",
|
||||
"By script": "",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
323
frappe/api/v2.py
323
frappe/api/v2.py
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
123
frappe/client.py
123
frappe/client.py
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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("%", ""),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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})",
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (c) {year}, {app_publisher} and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.treeview_settings["{doctype}"] = {{
|
||||
// }};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{{% extends "templates/web.html" %}}
|
||||
|
||||
{{% block page_content %}}
|
||||
{{% include "templates/includes/list/list.html" %}}
|
||||
{{% endblock %}}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
},
|
||||
{
|
||||
"fieldname": "error_message",
|
||||
"fieldtype": "Text",
|
||||
"fieldtype": "Code",
|
||||
"label": "Error Message",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +94,7 @@
|
|||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
|
|
|||
|
|
@ -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("<script>", result)
|
||||
self.assertIn("<div>", 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)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
27
frappe/core/number_card/error_logs/error_logs.json
Normal file
27
frappe/core/number_card/error_logs/error_logs.json
Normal 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
Loading…
Add table
Reference in a new issue