Merge remote-tracking branch 'upstream/develop' into seitime
This commit is contained in:
commit
6fbb6547bc
317 changed files with 190129 additions and 188119 deletions
119
.github/helper/ci.py
vendored
Normal file
119
.github/helper/ci.py
vendored
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""
|
||||||
|
Script to run Python tests while capturing accurte coverage.
|
||||||
|
|
||||||
|
Enabling coverage after `frappe` is imported leaves out a lot of lines that are imported by
|
||||||
|
default.
|
||||||
|
|
||||||
|
This is essentially a copy of `frappe/coverage.py` BUT also triggers test runner with desired
|
||||||
|
configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from coverage import Coverage
|
||||||
|
|
||||||
|
STANDARD_INCLUSIONS = ["*.py"]
|
||||||
|
|
||||||
|
STANDARD_EXCLUSIONS = [
|
||||||
|
"*.js",
|
||||||
|
"*.xml",
|
||||||
|
"*.pyc",
|
||||||
|
"*.css",
|
||||||
|
"*.less",
|
||||||
|
"*.scss",
|
||||||
|
"*.vue",
|
||||||
|
"*.html",
|
||||||
|
"*/test_*/*",
|
||||||
|
"*/node_modules/*",
|
||||||
|
"*/doctype/*/*_dashboard.py",
|
||||||
|
"*/patches/*",
|
||||||
|
".github/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
# tested via commands' test suite
|
||||||
|
TESTED_VIA_CLI = [
|
||||||
|
"*/frappe/installer.py",
|
||||||
|
"*/frappe/utils/install.py",
|
||||||
|
"*/frappe/utils/scheduler.py",
|
||||||
|
"*/frappe/utils/doctor.py",
|
||||||
|
"*/frappe/build.py",
|
||||||
|
"*/frappe/database/__init__.py",
|
||||||
|
"*/frappe/database/db_manager.py",
|
||||||
|
"*/frappe/database/**/setup_db.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
FRAPPE_EXCLUSIONS = [
|
||||||
|
"*/tests/*",
|
||||||
|
"*/commands/*",
|
||||||
|
"*/frappe/change_log/*",
|
||||||
|
"*/frappe/exceptions*",
|
||||||
|
"*/frappe/desk/page/setup_wizard/setup_wizard.py",
|
||||||
|
"*/frappe/coverage.py",
|
||||||
|
"*frappe/setup.py",
|
||||||
|
"*/doctype/*/*_dashboard.py",
|
||||||
|
"*/patches/*",
|
||||||
|
"*/frappe/database/postgres/*",
|
||||||
|
"*/.github/helper/ci.py",
|
||||||
|
"*/frappe/database/sqlite/*",
|
||||||
|
*TESTED_VIA_CLI,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_bench_path():
|
||||||
|
"""Get the path to the bench directory."""
|
||||||
|
return Path(__file__).resolve().parents[4]
|
||||||
|
|
||||||
|
|
||||||
|
class CodeCoverage:
|
||||||
|
"""
|
||||||
|
Context manager for handling code coverage.
|
||||||
|
|
||||||
|
This class sets up code coverage measurement for a specific app,
|
||||||
|
applying the appropriate inclusion and exclusion patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, with_coverage, app, outfile="coverage.xml"):
|
||||||
|
self.with_coverage = with_coverage
|
||||||
|
self.app = app or "frappe"
|
||||||
|
self.outfile = outfile
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if self.with_coverage:
|
||||||
|
# Generate coverage report only for app that is being tested
|
||||||
|
source_path = os.path.join(get_bench_path(), "apps", self.app)
|
||||||
|
omit = STANDARD_EXCLUSIONS[:]
|
||||||
|
|
||||||
|
if self.app == "frappe":
|
||||||
|
omit.extend(FRAPPE_EXCLUSIONS)
|
||||||
|
|
||||||
|
self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS)
|
||||||
|
|
||||||
|
assert "frappe" not in sys.modules, "frappe already imported, coverage will be inaccurate"
|
||||||
|
self.coverage.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
if self.with_coverage:
|
||||||
|
self.coverage.stop()
|
||||||
|
self.coverage.save()
|
||||||
|
self.coverage.xml_report(outfile=self.outfile)
|
||||||
|
print("Saved Coverage")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = "frappe"
|
||||||
|
site = os.environ.get("SITE") or "test_site"
|
||||||
|
with_coverage = json.loads(os.environ.get("CAPTURE_COVERAGE", "true").lower())
|
||||||
|
|
||||||
|
# Parse build information from environment variables
|
||||||
|
build_number = int(os.environ.get("BUILD_NUMBER"))
|
||||||
|
total_builds = int(os.environ.get("TOTAL_BUILDS"))
|
||||||
|
|
||||||
|
# Run tests with code coverage
|
||||||
|
with CodeCoverage(with_coverage=with_coverage, app=app):
|
||||||
|
from frappe.parallel_test_runner import ParallelTestRunner
|
||||||
|
|
||||||
|
runner = ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds)
|
||||||
|
runner.setup_and_run()
|
||||||
2
.github/stale.yml
vendored
2
.github/stale.yml
vendored
|
|
@ -5,7 +5,7 @@ daysUntilStale: 60
|
||||||
|
|
||||||
# Number of days of inactivity before a stale Issue or Pull Request is closed.
|
# Number of days of inactivity before a stale Issue or Pull Request is closed.
|
||||||
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||||
daysUntilClose: 5
|
daysUntilClose: 3
|
||||||
|
|
||||||
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
|
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
|
||||||
exemptLabels:
|
exemptLabels:
|
||||||
|
|
|
||||||
46
.github/workflows/_base-server-tests.yml
vendored
46
.github/workflows/_base-server-tests.yml
vendored
|
|
@ -29,17 +29,10 @@ on:
|
||||||
enable-coverage:
|
enable-coverage:
|
||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: true
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit-test:
|
|
||||||
name: Unit
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- id: placeholder
|
|
||||||
run: |
|
|
||||||
echo "Evolution towards a set of (fast) unit tests which run without a DB connection is being planned"
|
|
||||||
gen-idx-integration:
|
gen-idx-integration:
|
||||||
name: Gen Integration Test Matrix
|
name: Gen Integration Test Matrix
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -100,7 +93,6 @@ jobs:
|
||||||
python-version: ${{ inputs.python-version }}
|
python-version: ${{ inputs.python-version }}
|
||||||
node-version: ${{ inputs.node-version }}
|
node-version: ${{ inputs.node-version }}
|
||||||
disable-socketio: true
|
disable-socketio: true
|
||||||
enable-coverage: ${{ inputs.enable-coverage }}
|
|
||||||
db-root-password: ${{ env.DB_ROOT_PASSWORD }}
|
db-root-password: ${{ env.DB_ROOT_PASSWORD }}
|
||||||
db: ${{ matrix.db }}
|
db: ${{ matrix.db }}
|
||||||
env:
|
env:
|
||||||
|
|
@ -108,43 +100,22 @@ jobs:
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: |
|
run: |
|
||||||
bench --site test_site \
|
cd sites && ../env/bin/python3 ../apps/frappe/.github/helper/ci.py
|
||||||
run-parallel-tests \
|
|
||||||
--app "${{ github.event.repository.name }}" \
|
|
||||||
--total-builds ${{ inputs.parallel-runs }} \
|
|
||||||
--build-number ${{ matrix.index }} 2> >(tee -a stderr.log >&2)
|
|
||||||
|
|
||||||
# Process warnings and create annotations
|
|
||||||
if [ -s stderr.log ] && [ "$DB" == "mariadb" ]; then
|
|
||||||
echo "Processing deprecation warnings..."
|
|
||||||
grep -E "DeprecationWarning" stderr.log | sort -u | while read -r warning; do
|
|
||||||
# Extract file path, line number, and warning type
|
|
||||||
file_info=$(echo "$warning" | grep -oP '^.*?:\d+:')
|
|
||||||
file_path=$(echo "$file_info" | cut -d':' -f1)
|
|
||||||
line_number=$(echo "$file_info" | cut -d':' -f2)
|
|
||||||
warning_type=$(echo "$warning" | grep -oP '\w+Warning')
|
|
||||||
|
|
||||||
# Extract the actual warning message
|
|
||||||
message=$(echo "$warning" | sed -E "s/^.*$warning_type: //")
|
|
||||||
|
|
||||||
# Create the annotation
|
|
||||||
echo "::warning file=${file_path},line=${line_number}::${warning_type}: ${message}"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "No deprecation warnings found."
|
|
||||||
fi
|
|
||||||
env:
|
env:
|
||||||
DB: ${{ matrix.db }}
|
DB: ${{ matrix.db }}
|
||||||
# consumed by bench run-parallel-tests
|
# consumed by bench run-parallel-tests
|
||||||
CAPTURE_COVERAGE: ${{ inputs.enable-coverage }}
|
CAPTURE_COVERAGE: ${{ inputs.enable-coverage }}
|
||||||
|
BUILD_NUMBER: ${{ matrix.index }}
|
||||||
|
TOTAL_BUILDS: ${{ inputs.parallel-runs }}
|
||||||
FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN || '' }}
|
FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN || '' }}
|
||||||
|
|
||||||
- name: Upload coverage data
|
- name: Upload coverage data
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
if: inputs.enable-coverage
|
if: ${{ inputs.fake-success == false && inputs.enable-coverage }}
|
||||||
with:
|
with:
|
||||||
name: coverage-${{ matrix.db }}-${{ matrix.index }}
|
name: coverage-${{ matrix.db }}-${{ matrix.index }}
|
||||||
path: ./sites/*-coverage*.xml
|
path: ./sites/*coverage*.xml
|
||||||
|
|
||||||
- name: Setup tmate session
|
- name: Setup tmate session
|
||||||
uses: mxschmitt/action-tmate@v3
|
uses: mxschmitt/action-tmate@v3
|
||||||
|
|
@ -164,15 +135,14 @@ jobs:
|
||||||
# TIP: Use these for checks, e.g. Server / Tests / Success
|
# TIP: Use these for checks, e.g. Server / Tests / Success
|
||||||
success:
|
success:
|
||||||
name: Success
|
name: Success
|
||||||
needs: [unit-test, integration-test]
|
needs: [integration-test]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Unit '${{ needs.unit-test.result }}' / Integration '${{ needs.integration-test.result }}'
|
- name: Integration '${{ needs.integration-test.result }}'
|
||||||
shell: python
|
shell: python
|
||||||
run: |
|
run: |
|
||||||
stati = [
|
stati = [
|
||||||
'${{ needs.unit-test.result }}',
|
|
||||||
'${{ needs.integration-test.result }}',
|
'${{ needs.integration-test.result }}',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
59
.github/workflows/backport_reminder.yml
vendored
Normal file
59
.github/workflows/backport_reminder.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
name: Backport Reminder
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '30 1 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
remind:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v8
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const labelName = 'defer backport';
|
||||||
|
const marker = '<!-- backport-reminder -->';
|
||||||
|
const waitDays = 14;
|
||||||
|
const maxDays = 30;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const query = `is:pr is:merged label:"${labelName}" repo:${context.repo.owner}/${context.repo.repo}`;
|
||||||
|
const searchResult = await github.rest.search.issuesAndPullRequests({ q: query });
|
||||||
|
|
||||||
|
for (const pr of searchResult.data.items) {
|
||||||
|
const { data: fullPr } = await github.rest.pulls.get({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: pr.number
|
||||||
|
});
|
||||||
|
if (!fullPr.merged_at) continue;
|
||||||
|
const mergedAt = new Date(fullPr.merged_at);
|
||||||
|
|
||||||
|
const diffInDays = (now - mergedAt) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
if (diffInDays >= waitDays && diffInDays <= maxDays) {
|
||||||
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
per_page: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
const alreadyReminded = comments.some(c => c.body.includes(marker));
|
||||||
|
|
||||||
|
if (!alreadyReminded) {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
body: `${marker}\n**Backport Reminder**: This PR was merged ${Math.floor(diffInDays)} days ago. Time to backport!`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
|
|
@ -95,7 +95,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
pip install pip-audit
|
pip install pip-audit
|
||||||
cd ${GITHUB_WORKSPACE}
|
cd ${GITHUB_WORKSPACE}
|
||||||
pip-audit --desc on --ignore-vuln PYSEC-2023-312 .
|
pip-audit --desc on --ignore-vuln PYSEC-2023-312 --ignore-vuln CVE-2026-4539 .
|
||||||
|
|
||||||
precommit:
|
precommit:
|
||||||
name: 'Pre-Commit'
|
name: 'Pre-Commit'
|
||||||
|
|
|
||||||
3
.github/workflows/review-po-prs.yml
vendored
3
.github/workflows/review-po-prs.yml
vendored
|
|
@ -5,7 +5,6 @@ on:
|
||||||
types: [opened, reopened, synchronize, ready_for_review]
|
types: [opened, reopened, synchronize, ready_for_review]
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
- "version-[0-9][0-9]-hotfix"
|
|
||||||
paths:
|
paths:
|
||||||
- "**/*.po"
|
- "**/*.po"
|
||||||
|
|
||||||
|
|
@ -21,7 +20,7 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: read
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|
|
||||||
31
.github/workflows/server-tests.yml
vendored
31
.github/workflows/server-tests.yml
vendored
|
|
@ -1,8 +1,6 @@
|
||||||
name: Server
|
name: Server
|
||||||
|
|
||||||
on:
|
on:
|
||||||
repository_dispatch:
|
|
||||||
types: [frappe-framework-change]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
|
|
@ -18,14 +16,9 @@ permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
typecheck:
|
|
||||||
name: Types
|
|
||||||
uses: ./.github/workflows/_base-type-check.yml
|
|
||||||
|
|
||||||
checkrun:
|
checkrun:
|
||||||
name: Plan Tests
|
name: Plan Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: typecheck
|
|
||||||
outputs:
|
outputs:
|
||||||
build: ${{ steps.check-build.outputs.build }}
|
build: ${{ steps.check-build.outputs.build }}
|
||||||
run_postgres: ${{ steps.check-build.outputs.run_postgres }}
|
run_postgres: ${{ steps.check-build.outputs.run_postgres }}
|
||||||
|
|
@ -48,7 +41,6 @@ jobs:
|
||||||
enable-postgres: ${{ needs.checkrun.outputs.run_postgres == 'true' }} # This enables PostgreSQL to run tests
|
enable-postgres: ${{ needs.checkrun.outputs.run_postgres == 'true' }} # This enables PostgreSQL to run tests
|
||||||
enable-sqlite: false # This will test against both MariaDB and SQLite if enabled
|
enable-sqlite: false # This will test against both MariaDB and SQLite if enabled
|
||||||
parallel-runs: 2
|
parallel-runs: 2
|
||||||
enable-coverage: ${{ github.event_name != 'pull_request' }}
|
|
||||||
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
|
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
|
||||||
needs: checkrun
|
needs: checkrun
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
@ -67,37 +59,16 @@ jobs:
|
||||||
name: Coverage Wrap Up
|
name: Coverage Wrap Up
|
||||||
needs: [test, checkrun]
|
needs: [test, checkrun]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Clone
|
- name: Clone
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@v8.0.1
|
uses: actions/download-artifact@v8.0.1
|
||||||
- name: Upload coverage data
|
- name: Upload coverage data
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v6
|
||||||
with:
|
with:
|
||||||
name: Server
|
name: Server
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
verbose: true
|
verbose: true
|
||||||
flags: server
|
flags: server
|
||||||
|
|
||||||
dispatch:
|
|
||||||
name: Downstream
|
|
||||||
runs-on: "ubuntu-latest"
|
|
||||||
needs: [test, migrate]
|
|
||||||
if: ${{ contains( github.event.pull_request.labels.*.name, 'trigger-downstream-ci') }}
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
repo:
|
|
||||||
- frappe/erpnext
|
|
||||||
- frappe/lending
|
|
||||||
- frappe/hrms
|
|
||||||
steps:
|
|
||||||
- name: Dispatch Downstream CI (if supported)
|
|
||||||
uses: peter-evans/repository-dispatch@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.CI_PAT }}
|
|
||||||
repository: ${{ matrix.repo }}
|
|
||||||
event-type: frappe-framework-change
|
|
||||||
client-payload: '{"frappe_sha": "${{ github.sha }}"}'
|
|
||||||
|
|
|
||||||
33
.github/workflows/ui-tests.yml
vendored
33
.github/workflows/ui-tests.yml
vendored
|
|
@ -2,8 +2,6 @@ name: UI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
repository_dispatch:
|
|
||||||
types: [frappe-framework-change]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
# Run everday at midnight UTC / 5:30 IST
|
# Run everday at midnight UTC / 5:30 IST
|
||||||
|
|
@ -44,35 +42,6 @@ jobs:
|
||||||
uses: ./.github/workflows/_base-ui-tests.yml
|
uses: ./.github/workflows/_base-ui-tests.yml
|
||||||
with:
|
with:
|
||||||
parallel-runs: 3
|
parallel-runs: 3
|
||||||
enable-coverage: ${{ github.event_name != 'pull_request' }}
|
enable-coverage: false
|
||||||
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
|
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
|
||||||
needs: checkrun
|
needs: checkrun
|
||||||
|
|
||||||
coverage:
|
|
||||||
name: Coverage Wrap Up
|
|
||||||
needs: [test, checkrun]
|
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Clone
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
- name: Download artifacts
|
|
||||||
uses: actions/download-artifact@v8.0.1
|
|
||||||
- name: Upload python coverage data
|
|
||||||
uses: codecov/codecov-action@v5
|
|
||||||
with:
|
|
||||||
name: UIBackend
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
fail_ci_if_error: true
|
|
||||||
verbose: true
|
|
||||||
exclude: coverage-js*
|
|
||||||
flags: server-ui
|
|
||||||
- name: Upload JS coverage data
|
|
||||||
uses: codecov/codecov-action@v5
|
|
||||||
with:
|
|
||||||
name: Cypress
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
fail_ci_if_error: true
|
|
||||||
exclude: coverage-py*
|
|
||||||
verbose: true
|
|
||||||
flags: ui-tests
|
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,6 @@ flags:
|
||||||
paths:
|
paths:
|
||||||
- "**/*.py"
|
- "**/*.py"
|
||||||
carryforward: true
|
carryforward: true
|
||||||
ui-tests:
|
|
||||||
paths:
|
|
||||||
- "**/*.js"
|
|
||||||
carryforward: true
|
|
||||||
server-ui:
|
server-ui:
|
||||||
paths:
|
paths:
|
||||||
- "**/*.py"
|
- "**/*.py"
|
||||||
|
|
|
||||||
|
|
@ -111,4 +111,76 @@ context("Grid", () => {
|
||||||
cy.get("@table-form").find(".grid-footer-toolbar").click();
|
cy.get("@table-form").find(".grid-footer-toolbar").click();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows edit button only when child table allow_bulk_edit is enabled", () => {
|
||||||
|
cy.visit("/desk/contact/Test Contact");
|
||||||
|
cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table");
|
||||||
|
|
||||||
|
cy.window()
|
||||||
|
.its("cur_frm")
|
||||||
|
.then((frm) => {
|
||||||
|
const grid = frm.get_field("phone_nos").grid;
|
||||||
|
grid.meta.allow_bulk_edit = false;
|
||||||
|
grid.refresh_edit_rows_button();
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get("@table").find('.grid-row[data-idx="1"] .grid-row-check').click({ force: true });
|
||||||
|
cy.get("@table").find(".grid-edit-rows").should("have.class", "hidden");
|
||||||
|
|
||||||
|
cy.window()
|
||||||
|
.its("cur_frm")
|
||||||
|
.then((frm) => {
|
||||||
|
const grid = frm.get_field("phone_nos").grid;
|
||||||
|
grid.meta.allow_bulk_edit = true;
|
||||||
|
grid.refresh_edit_rows_button();
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get("@table").find(".grid-edit-rows").should("not.have.class", "hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bulk edit updates only selected child rows", () => {
|
||||||
|
const updated_phone = `99999${Date.now().toString().slice(-5)}`;
|
||||||
|
|
||||||
|
cy.visit("/desk/contact/Test Contact");
|
||||||
|
cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table");
|
||||||
|
|
||||||
|
cy.window()
|
||||||
|
.its("cur_frm")
|
||||||
|
.then((frm) => {
|
||||||
|
const grid = frm.get_field("phone_nos").grid;
|
||||||
|
grid.meta.allow_bulk_edit = true;
|
||||||
|
grid.refresh_edit_rows_button();
|
||||||
|
|
||||||
|
expect(frm.doc.phone_nos.length).to.be.greaterThan(1);
|
||||||
|
const phone_df = grid.docfields.find((df) => df.fieldname === "phone");
|
||||||
|
expect(phone_df).to.exist;
|
||||||
|
cy.wrap(phone_df.label).as("phoneFieldLabel");
|
||||||
|
cy.wrap(frm.doc.phone_nos[1].phone || "").as("secondRowPhoneBefore");
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get("@table").find('.grid-row[data-idx="1"] .grid-row-check').click({ force: true });
|
||||||
|
cy.get("@table").find(".grid-edit-rows").click({ force: true });
|
||||||
|
|
||||||
|
cy.window()
|
||||||
|
.its("cur_dialog")
|
||||||
|
.then((dialog) => {
|
||||||
|
cy.get("@phoneFieldLabel").then((phoneFieldLabel) => {
|
||||||
|
return dialog
|
||||||
|
.set_value("field", phoneFieldLabel)
|
||||||
|
.then(() => dialog.set_value("value", updated_phone))
|
||||||
|
.then(() => {
|
||||||
|
dialog.get_primary_btn().click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.window().its("cur_frm.doc.phone_nos.0.phone").should("eq", updated_phone);
|
||||||
|
cy.window()
|
||||||
|
.its("cur_frm")
|
||||||
|
.then((frm) => {
|
||||||
|
cy.get("@secondRowPhoneBefore").then((secondRowPhoneBefore) => {
|
||||||
|
expect(frm.doc.phone_nos[1].phone || "").to.equal(secondRowPhoneBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1573,6 +1573,7 @@ from frappe.config import get_common_site_config, get_conf, get_site_config
|
||||||
from frappe.core.doctype.system_settings.system_settings import get_system_settings
|
from frappe.core.doctype.system_settings.system_settings import get_system_settings
|
||||||
from frappe.model.document import (
|
from frappe.model.document import (
|
||||||
get_doc,
|
get_doc,
|
||||||
|
get_docs,
|
||||||
get_lazy_doc,
|
get_lazy_doc,
|
||||||
copy_doc,
|
copy_doc,
|
||||||
new_doc,
|
new_doc,
|
||||||
|
|
@ -1594,6 +1595,7 @@ from frappe.utils.error import log_error
|
||||||
from frappe.utils.formatters import format_value
|
from frappe.utils.formatters import format_value
|
||||||
from frappe.utils.print_utils import get_print, attach_print
|
from frappe.utils.print_utils import get_print, attach_print
|
||||||
from frappe.email import sendmail
|
from frappe.email import sendmail
|
||||||
|
from frappe.concurrency_limiter import concurrent_limit
|
||||||
|
|
||||||
# for backwards compatibility
|
# for backwards compatibility
|
||||||
format = format_value
|
format = format_value
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,8 @@ def get_values_for_link_and_dynamic_link_fields(doc_dict):
|
||||||
|
|
||||||
doctype = field.options if field.fieldtype == "Link" else doc_dict.get(field.options)
|
doctype = field.options if field.fieldtype == "Link" else doc_dict.get(field.options)
|
||||||
|
|
||||||
link_doc = frappe.get_doc(doctype, doc_fieldvalue)
|
link_doc = frappe.get_doc(doctype, doc_fieldvalue, check_permission="read")
|
||||||
|
link_doc.apply_fieldlevel_read_permissions()
|
||||||
doc_dict.update({field.fieldname: link_doc})
|
doc_dict.update({field.fieldname: link_doc})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,12 @@ def application(request: Request):
|
||||||
elif request.path.startswith("/private/files/"):
|
elif request.path.startswith("/private/files/"):
|
||||||
response = frappe.utils.response.download_private_file(request.path)
|
response = frappe.utils.response.download_private_file(request.path)
|
||||||
|
|
||||||
|
elif request.path == "/.well-known/security.txt" and request.method == "GET":
|
||||||
|
if request.scheme != "https":
|
||||||
|
raise NotFound
|
||||||
|
security_settings = frappe.get_doc("Security Settings")
|
||||||
|
response = Response(security_settings.security_txt, content_type="text/plain")
|
||||||
|
|
||||||
elif request.path.startswith("/.well-known/") and request.method == "GET":
|
elif request.path.startswith("/.well-known/") and request.method == "GET":
|
||||||
response = handle_wellknown(request.path)
|
response = handle_wellknown(request.path)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ def get_apps():
|
||||||
|
|
||||||
|
|
||||||
def get_route(app_name):
|
def get_route(app_name):
|
||||||
|
if app_name not in frappe.get_installed_apps():
|
||||||
|
return "/apps" # Invalid defaults
|
||||||
apps = frappe.get_hooks("add_to_apps_screen", app_name=app_name)
|
apps = frappe.get_hooks("add_to_apps_screen", app_name=app_name)
|
||||||
app = next((app for app in apps if app.get("name") == app_name), None)
|
app = next((app for app in apps if app.get("name") == app_name), None)
|
||||||
return app.get("route") if app and app.get("route") else "/apps"
|
return app.get("route") if app and app.get("route") else "/apps"
|
||||||
|
|
@ -89,6 +91,9 @@ def get_default_path():
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def set_app_as_default(app_name: str):
|
def set_app_as_default(app_name: str):
|
||||||
|
if app_name not in frappe.get_installed_apps():
|
||||||
|
frappe.throw(_("App {} is not installed").format(frappe.bold(app_name)))
|
||||||
|
|
||||||
if frappe.db.get_value("User", frappe.session.user, "default_app") == app_name:
|
if frappe.db.get_value("User", frappe.session.user, "default_app") == app_name:
|
||||||
frappe.db.set_value("User", frappe.session.user, "default_app", "")
|
frappe.db.set_value("User", frappe.session.user, "default_app", "")
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,9 @@ class LoginManager:
|
||||||
self.authenticate(user=user, pwd=pwd)
|
self.authenticate(user=user, pwd=pwd)
|
||||||
if self.force_user_to_reset_password():
|
if self.force_user_to_reset_password():
|
||||||
doc = frappe.get_doc("User", self.user)
|
doc = frappe.get_doc("User", self.user)
|
||||||
frappe.local.response["redirect_to"] = doc.reset_password(send_email=False, password_expired=True)
|
frappe.local.response["redirect_to"] = doc._reset_password(
|
||||||
|
send_email=False, password_expired=True
|
||||||
|
)
|
||||||
frappe.local.response["message"] = "Password Reset"
|
frappe.local.response["message"] = "Password Reset"
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -724,9 +726,13 @@ def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=Non
|
||||||
raise frappe.AuthenticationError
|
raise frappe.AuthenticationError
|
||||||
|
|
||||||
doctype = frappe_authorization_source or "User"
|
doctype = frappe_authorization_source or "User"
|
||||||
docname = frappe.db.get_value(
|
try:
|
||||||
doctype=doctype, filters={"api_key": api_key, "enabled": True}, fieldname=["name"]
|
docname = frappe.db.get_value(
|
||||||
)
|
doctype=doctype, filters={"api_key": api_key, "enabled": True}, fieldname=["name"]
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
raise frappe.AuthenticationError
|
||||||
|
|
||||||
if not docname:
|
if not docname:
|
||||||
raise frappe.AuthenticationError
|
raise frappe.AuthenticationError
|
||||||
form_dict = frappe.local.form_dict
|
form_dict = frappe.local.form_dict
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ def get_bootinfo():
|
||||||
bootinfo.home_folder = frappe.db.get_value("File", {"is_home_folder": 1})
|
bootinfo.home_folder = frappe.db.get_value("File", {"is_home_folder": 1})
|
||||||
bootinfo.navbar_settings = get_navbar_settings()
|
bootinfo.navbar_settings = get_navbar_settings()
|
||||||
bootinfo.notification_settings = get_notification_settings()
|
bootinfo.notification_settings = get_notification_settings()
|
||||||
|
bootinfo.notification_unread_count = frappe.db.count(
|
||||||
|
"Notification Log", {"read": 0, "for_user": frappe.session.user}
|
||||||
|
)
|
||||||
bootinfo.onboarding_tours = get_onboarding_ui_tours()
|
bootinfo.onboarding_tours = get_onboarding_ui_tours()
|
||||||
set_time_zone(bootinfo)
|
set_time_zone(bootinfo)
|
||||||
|
|
||||||
|
|
@ -342,10 +345,10 @@ def get_user_pages_or_reports(parent, cache=False):
|
||||||
|
|
||||||
|
|
||||||
def load_translations(bootinfo):
|
def load_translations(bootinfo):
|
||||||
from frappe.translate import get_messages_for_boot
|
from frappe.translate import get_translation_version
|
||||||
|
|
||||||
bootinfo["lang"] = frappe.lang
|
bootinfo["lang"] = frappe.lang
|
||||||
bootinfo["__messages"] = get_messages_for_boot()
|
bootinfo["translations_version"] = get_translation_version()
|
||||||
|
|
||||||
|
|
||||||
def get_user_info():
|
def get_user_info():
|
||||||
|
|
@ -562,8 +565,9 @@ def get_sidebar_items(allowed_workspaces):
|
||||||
sidebar_doc = sidebar
|
sidebar_doc = sidebar
|
||||||
if (
|
if (
|
||||||
frappe.session.user == "Administrator"
|
frappe.session.user == "Administrator"
|
||||||
or sidebar_doc.module in sidebar_doc.user.allow_modules
|
|
||||||
or sidebar_title == "My Workspaces"
|
or sidebar_title == "My Workspaces"
|
||||||
|
or not sidebar_doc.module
|
||||||
|
or sidebar_doc.module in sidebar_doc.user.allow_modules
|
||||||
):
|
):
|
||||||
sidebar_items[sidebar_title.lower()] = {
|
sidebar_items[sidebar_title.lower()] = {
|
||||||
"label": sidebar_title,
|
"label": sidebar_title,
|
||||||
|
|
@ -590,6 +594,7 @@ def get_sidebar_items(allowed_workspaces):
|
||||||
"filters": item.filters,
|
"filters": item.filters,
|
||||||
"route_options": item.route_options,
|
"route_options": item.route_options,
|
||||||
"tab": item.navigate_to_tab,
|
"tab": item.navigate_to_tab,
|
||||||
|
"open_in_new_tab": item.open_in_new_tab,
|
||||||
}
|
}
|
||||||
if item.link_type == "Report" and item.link_to and frappe.db.exists("Report", item.link_to):
|
if item.link_type == "Report" and item.link_to and frappe.db.exists("Report", item.link_to):
|
||||||
report_type, ref_doctype = frappe.db.get_value(
|
report_type, ref_doctype = frappe.db.get_value(
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ def get(
|
||||||
doc.check_permission()
|
doc.check_permission()
|
||||||
doc.apply_fieldlevel_read_permissions()
|
doc.apply_fieldlevel_read_permissions()
|
||||||
|
|
||||||
return doc.as_dict()
|
return doc.as_dict(no_nulls=True)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,6 @@ def main(
|
||||||
verbosity=2 if testing_module_logger.getEffectiveLevel() < logging.INFO else 1,
|
verbosity=2 if testing_module_logger.getEffectiveLevel() < logging.INFO else 1,
|
||||||
tb_locals=testing_module_logger.getEffectiveLevel() <= logging.INFO,
|
tb_locals=testing_module_logger.getEffectiveLevel() <= logging.INFO,
|
||||||
cfg=test_config,
|
cfg=test_config,
|
||||||
buffer=not debug, # unfortunate as it messes up stdout/stderr output order
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if doctype or doctype_list_path:
|
if doctype or doctype_list_path:
|
||||||
|
|
@ -159,11 +158,14 @@ def main(
|
||||||
discover_all_tests(apps, runner)
|
discover_all_tests(apps, runner)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
global unittest_runner
|
||||||
for app, category, suite in runner.iterRun():
|
for app, category, suite in runner.iterRun():
|
||||||
click.secho(
|
click.secho(
|
||||||
f"\nRunning {suite.countTestCases()} {category} tests for {app}", fg="cyan", bold=True
|
f"\nRunning {suite.countTestCases()} {category} tests for {app}", fg="cyan", bold=True
|
||||||
)
|
)
|
||||||
results.append([app, category, runner.run(suite)])
|
main_runner = unittest_runner if junit_xml_output and unittest_runner else runner
|
||||||
|
res = main_runner.run(suite)
|
||||||
|
results.append([app, category, res])
|
||||||
|
|
||||||
success = all(r.wasSuccessful() for _, _, r in results)
|
success = all(r.wasSuccessful() for _, _, r in results)
|
||||||
if not success:
|
if not success:
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,19 @@ def build(
|
||||||
print("Compiling translations for", app)
|
print("Compiling translations for", app)
|
||||||
compile_translations(app, force=force)
|
compile_translations(app, force=force)
|
||||||
|
|
||||||
|
run_after_build_hook(apps)
|
||||||
|
|
||||||
|
|
||||||
|
def run_after_build_hook(apps):
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
for app in apps:
|
||||||
|
for fn in frappe.get_hooks("after_build", app_name=app):
|
||||||
|
modulename = ".".join(fn.split(".")[:-1])
|
||||||
|
methodname = fn.split(".")[-1]
|
||||||
|
method = getattr(import_module(modulename), methodname)
|
||||||
|
method()
|
||||||
|
|
||||||
|
|
||||||
@click.command("watch")
|
@click.command("watch")
|
||||||
@click.option("--apps", help="Watch assets for specific apps")
|
@click.option("--apps", help="Watch assets for specific apps")
|
||||||
|
|
|
||||||
125
frappe/concurrency_limiter.py
Normal file
125
frappe/concurrency_limiter.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# License: MIT. See LICENSE
|
||||||
|
|
||||||
|
"""
|
||||||
|
Concurrency limiter for expensive whitelisted methods.
|
||||||
|
|
||||||
|
Provides a @frappe.concurrent_limit() decorator that limits the number of
|
||||||
|
simultaneous in-flight executions of a function across all gunicorn workers
|
||||||
|
using a Redis-backed semaphore (LIST + BLPOP).
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
@frappe.whitelist(allow_guest=True)
|
||||||
|
@frappe.concurrent_limit(limit=3)
|
||||||
|
def download_pdf(...):
|
||||||
|
...
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.exceptions import ServiceUnavailableError
|
||||||
|
from frappe.utils import cint
|
||||||
|
from frappe.utils.caching import redis_cache
|
||||||
|
from frappe.utils.redis_semaphore import RedisSemaphore
|
||||||
|
|
||||||
|
# Default wait timeout (seconds) before returning 503 to the caller.
|
||||||
|
_DEFAULT_WAIT_TIMEOUT = 10
|
||||||
|
|
||||||
|
|
||||||
|
@redis_cache(shared=True)
|
||||||
|
def _default_limit() -> int:
|
||||||
|
"""Derive a sensible default concurrency limit from gunicorn's max concurrency."""
|
||||||
|
return max(1, gunicorn_max_concurrency() // 2)
|
||||||
|
|
||||||
|
|
||||||
|
def gunicorn_max_concurrency() -> int:
|
||||||
|
"""Detect max concurrent requests from the running gunicorn master's cmdline."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
fallback = 4
|
||||||
|
|
||||||
|
try:
|
||||||
|
ppid = os.getppid()
|
||||||
|
with open(f"/proc/{ppid}/cmdline", "rb") as f:
|
||||||
|
args = f.read().rstrip(b"\0").decode().split("\0")
|
||||||
|
|
||||||
|
if not any("gunicorn" in a for a in args):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
workers = _extract_cli_int(args, "-w", "--workers") or fallback
|
||||||
|
threads = _extract_cli_int(args, "--threads") or 1
|
||||||
|
return workers * threads
|
||||||
|
except OSError:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_cli_int(args: list[str], *flags: str) -> int | None:
|
||||||
|
"""Return the integer value for a CLI flag from a split argument list.
|
||||||
|
|
||||||
|
Handles both ``--flag value`` and ``--flag=value`` forms.
|
||||||
|
"""
|
||||||
|
for i, arg in enumerate(args):
|
||||||
|
for flag in flags:
|
||||||
|
if arg == flag and i + 1 < len(args):
|
||||||
|
return int(args[i + 1])
|
||||||
|
if arg.startswith(f"{flag}="):
|
||||||
|
return int(arg.split("=", 1)[1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def concurrent_limit(limit: int | None = None, wait_timeout: int = _DEFAULT_WAIT_TIMEOUT):
|
||||||
|
"""Decorator that limits simultaneous in-flight executions of the wrapped function.
|
||||||
|
|
||||||
|
:param limit: Maximum number of concurrent executions. Defaults to half of ``workers x threads``
|
||||||
|
as detected from the gunicorn master process.
|
||||||
|
:param wait_timeout: Seconds to wait for a free slot before returning 503.
|
||||||
|
Defaults to 10 s.
|
||||||
|
|
||||||
|
The limiter is skipped entirely for background jobs, CLI commands, and
|
||||||
|
tests that call functions directly (i.e. outside of an HTTP request).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(fn: Callable) -> Callable:
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
# Skip concurrency limiting outside of HTTP requests (background jobs,
|
||||||
|
# CLI commands, tests that call functions directly, etc.).
|
||||||
|
if getattr(frappe.local, "request", None) is None:
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
_limit = cint(limit) if limit is not None else _default_limit()
|
||||||
|
key = f"concurrency:{fn.__module__}.{fn.__qualname__}"
|
||||||
|
|
||||||
|
sem = RedisSemaphore(key, _limit, wait_timeout, shared=True)
|
||||||
|
token = sem.acquire()
|
||||||
|
if not token:
|
||||||
|
retry_after = max(1, int(wait_timeout))
|
||||||
|
if (headers := getattr(frappe.local, "response_headers", None)) is not None:
|
||||||
|
headers.set("Retry-After", str(retry_after))
|
||||||
|
exc = ServiceUnavailableError(frappe._("Server is busy. Please try again in a few seconds."))
|
||||||
|
exc.retry_after = retry_after
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
sem.release(token)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_stats() -> dict:
|
||||||
|
frappe.only_for("System Manager")
|
||||||
|
cached_limit = _default_limit()
|
||||||
|
gunicorn_limit = gunicorn_max_concurrency()
|
||||||
|
return {
|
||||||
|
"cached_limit": cached_limit,
|
||||||
|
"gunicorn_limit": gunicorn_limit,
|
||||||
|
}
|
||||||
|
|
@ -129,7 +129,7 @@ def _accept_invitation(key: str, in_test: bool) -> None:
|
||||||
hashed_key = frappe.utils.sha256_hash(key)
|
hashed_key = frappe.utils.sha256_hash(key)
|
||||||
invitation_name = frappe.db.get_value("User Invitation", filters={"key": hashed_key})
|
invitation_name = frappe.db.get_value("User Invitation", filters={"key": hashed_key})
|
||||||
if not invitation_name:
|
if not invitation_name:
|
||||||
frappe.throw(title=_("Error"), msg=_("Invalid key"))
|
frappe.throw(title=_("Error"), msg=_("Invalid or expired key"))
|
||||||
invitation = frappe.get_doc("User Invitation", invitation_name)
|
invitation = frappe.get_doc("User Invitation", invitation_name)
|
||||||
|
|
||||||
# accept invitation
|
# accept invitation
|
||||||
|
|
@ -143,7 +143,7 @@ def _accept_invitation(key: str, in_test: bool) -> None:
|
||||||
# set redirect_to
|
# set redirect_to
|
||||||
redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path())
|
redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path())
|
||||||
if should_update_password:
|
if should_update_password:
|
||||||
redirect_to = f"{user.reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
|
redirect_to = f"{user._reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
|
||||||
|
|
||||||
# GET requests do not cause an implicit commit
|
# GET requests do not cause an implicit commit
|
||||||
frappe.db.commit() # nosemgrep
|
frappe.db.commit() # nosemgrep
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,22 @@ class CommunicationEmailMixin:
|
||||||
parent_doc = get_parent_doc(self)
|
parent_doc = get_parent_doc(self)
|
||||||
return parent_doc.owner if parent_doc else None
|
return parent_doc.owner if parent_doc else None
|
||||||
|
|
||||||
|
def get_notification_recipient(self):
|
||||||
|
"""Get notification recipient of the communication docs parent.
|
||||||
|
|
||||||
|
Calls `get_notification_email` on the parent if available; otherwise returns the owner.
|
||||||
|
This uses `run_method` so hooks can customize recipients per app/site.
|
||||||
|
"""
|
||||||
|
parent_doc = get_parent_doc(self)
|
||||||
|
if not parent_doc:
|
||||||
|
return None
|
||||||
|
|
||||||
|
notification_email = parent_doc.run_method("get_notification_email")
|
||||||
|
if notification_email:
|
||||||
|
return notification_email
|
||||||
|
|
||||||
|
return parent_doc.owner
|
||||||
|
|
||||||
def get_all_email_addresses(self, exclude_displayname=False):
|
def get_all_email_addresses(self, exclude_displayname=False):
|
||||||
"""Get all Email addresses mentioned in the doc along with display name."""
|
"""Get all Email addresses mentioned in the doc along with display name."""
|
||||||
return (
|
return (
|
||||||
|
|
@ -60,7 +76,7 @@ class CommunicationEmailMixin:
|
||||||
"""Build cc list to send an email.
|
"""Build cc list to send an email.
|
||||||
|
|
||||||
* if email copy is requested by sender, then add sender to CC.
|
* if email copy is requested by sender, then add sender to CC.
|
||||||
* If this doc is created through inbound mail, then add doc owner to cc list
|
* If this doc is created through inbound mail, then add the notification recipient to CC
|
||||||
* remove all the thread_notify disabled users.
|
* remove all the thread_notify disabled users.
|
||||||
* Remove standard users from email list
|
* Remove standard users from email list
|
||||||
"""
|
"""
|
||||||
|
|
@ -77,9 +93,9 @@ class CommunicationEmailMixin:
|
||||||
cc.append(sender)
|
cc.append(sender)
|
||||||
|
|
||||||
if is_inbound_mail_communcation:
|
if is_inbound_mail_communcation:
|
||||||
# inform parent document owner incase communication is created through inbound mail
|
# inform the configured notification recipient in case communication is created inbound
|
||||||
if doc_owner := self.get_owner():
|
if notification_recipient := self.get_notification_recipient():
|
||||||
cc.append(doc_owner)
|
cc.append(notification_recipient)
|
||||||
cc = set(cc) - {self.sender_mailid}
|
cc = set(cc) - {self.sender_mailid}
|
||||||
assignees = set(self.get_assignees()) - {self.sender_mailid}
|
assignees = set(self.get_assignees()) - {self.sender_mailid}
|
||||||
# Check and remove If user disabled notifications for incoming emails on assigned document.
|
# Check and remove If user disabled notifications for incoming emails on assigned document.
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-03-31 20:37:16.503023",
|
"modified": "2026-04-09 11:13:35.484376",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Core",
|
"module": "Core",
|
||||||
"name": "Custom DocPerm",
|
"name": "Custom DocPerm",
|
||||||
|
|
@ -240,6 +240,7 @@
|
||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from frappe.model import display_fieldtypes, no_value_fields
|
||||||
from frappe.model import table_fields as table_fieldtypes
|
from frappe.model import table_fields as table_fieldtypes
|
||||||
from frappe.utils import flt, format_duration, groupby_metric
|
from frappe.utils import flt, format_duration, groupby_metric
|
||||||
from frappe.utils.csvutils import build_csv_response
|
from frappe.utils.csvutils import build_csv_response
|
||||||
from frappe.utils.xlsxutils import build_xlsx_response
|
from frappe.utils.xlsxutils import build_xlsx_response, get_default_xlsx_styles
|
||||||
|
|
||||||
|
|
||||||
class Exporter:
|
class Exporter:
|
||||||
|
|
@ -253,7 +253,17 @@ class Exporter:
|
||||||
if self.file_type == "CSV":
|
if self.file_type == "CSV":
|
||||||
build_csv_response(self.get_csv_array_for_export(), _(self.doctype))
|
build_csv_response(self.get_csv_array_for_export(), _(self.doctype))
|
||||||
elif self.file_type == "Excel":
|
elif self.file_type == "Excel":
|
||||||
build_xlsx_response(self.get_csv_array_for_export(), _(self.doctype))
|
data = self.get_csv_array_for_export()
|
||||||
|
styles = get_default_xlsx_styles(
|
||||||
|
columns=self.fields,
|
||||||
|
# exclude header row
|
||||||
|
data=data[1:],
|
||||||
|
# from the second child row onwards, parent values will be empty
|
||||||
|
# so currency value from parent doc may be absent, avoid inconsistency
|
||||||
|
currency_formatting=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
build_xlsx_response(data, _(self.doctype), styles=styles)
|
||||||
|
|
||||||
def group_children_data_by_parent(self, children_data: dict[str, list]):
|
def group_children_data_by_parent(self, children_data: dict[str, list]):
|
||||||
return groupby_metric(children_data, key="parent")
|
return groupby_metric(children_data, key="parent")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from frappe.core.doctype.version.version import get_diff
|
||||||
from frappe.model import no_value_fields
|
from frappe.model import no_value_fields
|
||||||
from frappe.utils import cint, cstr, duration_to_seconds, flt, update_progress_bar
|
from frappe.utils import cint, cstr, duration_to_seconds, flt, update_progress_bar
|
||||||
from frappe.utils.csvutils import get_csv_content_from_google_sheets, read_csv_content
|
from frappe.utils.csvutils import get_csv_content_from_google_sheets, read_csv_content
|
||||||
|
from frappe.utils.data import escape_html
|
||||||
from frappe.utils.xlsxutils import (
|
from frappe.utils.xlsxutils import (
|
||||||
read_xls_file_from_attached_file,
|
read_xls_file_from_attached_file,
|
||||||
read_xlsx_file_from_attached_file,
|
read_xlsx_file_from_attached_file,
|
||||||
|
|
@ -727,7 +728,9 @@ class Row:
|
||||||
elif df.fieldtype == "Link":
|
elif df.fieldtype == "Link":
|
||||||
exists = self.link_exists(value, df)
|
exists = self.link_exists(value, df)
|
||||||
if not exists:
|
if not exists:
|
||||||
msg = _("Value {0} missing for {1}").format(frappe.bold(value), frappe.bold(df.options))
|
msg = _("Value {0} missing for {1}").format(
|
||||||
|
frappe.bold(escape_html(cstr(value))), frappe.bold(df.options)
|
||||||
|
)
|
||||||
self.warnings.append(
|
self.warnings.append(
|
||||||
{
|
{
|
||||||
"row": self.row_number,
|
"row": self.row_number,
|
||||||
|
|
@ -746,7 +749,8 @@ class Row:
|
||||||
"col": col.column_number,
|
"col": col.column_number,
|
||||||
"field": df_as_json(df),
|
"field": df_as_json(df),
|
||||||
"message": _("Value {0} must in {1} format").format(
|
"message": _("Value {0} must in {1} format").format(
|
||||||
frappe.bold(value), frappe.bold(get_user_format(col.date_format))
|
frappe.bold(escape_html(cstr(value))),
|
||||||
|
frappe.bold(get_user_format(col.date_format)),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -761,7 +765,8 @@ class Row:
|
||||||
"col": col.column_number,
|
"col": col.column_number,
|
||||||
"field": df_as_json(df),
|
"field": df_as_json(df),
|
||||||
"message": _("Value {0} must in {1} format").format(
|
"message": _("Value {0} must in {1} format").format(
|
||||||
frappe.bold(value), frappe.bold(get_user_format(col.date_format))
|
frappe.bold(escape_html(cstr(value))),
|
||||||
|
frappe.bold(get_user_format(col.date_format)),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -774,7 +779,7 @@ class Row:
|
||||||
"col": col.column_number,
|
"col": col.column_number,
|
||||||
"field": df_as_json(df),
|
"field": df_as_json(df),
|
||||||
"message": _("Value {0} must be in the valid duration format: d h m s").format(
|
"message": _("Value {0} must be in the valid duration format: d h m s").format(
|
||||||
frappe.bold(value)
|
frappe.bold(escape_html(cstr(value)))
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -1045,7 +1050,7 @@ class Column:
|
||||||
]
|
]
|
||||||
not_exists = list(set(values) - set(exists))
|
not_exists = list(set(values) - set(exists))
|
||||||
if not_exists:
|
if not_exists:
|
||||||
missing_values = ", ".join(not_exists)
|
missing_values = ", ".join(escape_html(v) for v in not_exists)
|
||||||
message = _("The following values do not exist for {0}: {1}")
|
message = _("The following values do not exist for {0}: {1}")
|
||||||
self.warnings.append(
|
self.warnings.append(
|
||||||
{
|
{
|
||||||
|
|
@ -1088,7 +1093,7 @@ class Column:
|
||||||
invalid = values - set(options)
|
invalid = values - set(options)
|
||||||
if invalid:
|
if invalid:
|
||||||
valid_values = ", ".join(frappe.bold(o) for o in options)
|
valid_values = ", ".join(frappe.bold(o) for o in options)
|
||||||
invalid_values = ", ".join(frappe.bold(i) for i in invalid)
|
invalid_values = ", ".join(frappe.bold(escape_html(i)) for i in invalid)
|
||||||
message = _("The following values are invalid: {0}. Values must be one of {1}")
|
message = _("The following values are invalid: {0}. Values must be one of {1}")
|
||||||
self.warnings.append(
|
self.warnings.append(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"print_hide",
|
"print_hide",
|
||||||
"print_hide_if_no_value",
|
"print_hide_if_no_value",
|
||||||
"report_hide",
|
"report_hide",
|
||||||
|
"in_import_template",
|
||||||
"column_break_28",
|
"column_break_28",
|
||||||
"depends_on",
|
"depends_on",
|
||||||
"collapsible",
|
"collapsible",
|
||||||
|
|
@ -640,6 +641,13 @@
|
||||||
"fieldname": "show_description_on_click",
|
"fieldname": "show_description_on_click",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Show Description on Click"
|
"label": "Show Description on Click"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"description": "Enable this option to include the field in the data import template",
|
||||||
|
"fieldname": "in_import_template",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Include in Import Template"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
|
|
@ -647,7 +655,7 @@
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-03-10 21:39:58.400441",
|
"modified": "2026-04-24 13:21:02.590853",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Core",
|
"module": "Core",
|
||||||
"name": "DocField",
|
"name": "DocField",
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ class DocField(Document):
|
||||||
ignore_xss_filter: DF.Check
|
ignore_xss_filter: DF.Check
|
||||||
in_filter: DF.Check
|
in_filter: DF.Check
|
||||||
in_global_search: DF.Check
|
in_global_search: DF.Check
|
||||||
|
in_import_template: DF.Check
|
||||||
in_list_view: DF.Check
|
in_list_view: DF.Check
|
||||||
in_preview: DF.Check
|
in_preview: DF.Check
|
||||||
in_standard_filter: DF.Check
|
in_standard_filter: DF.Check
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@
|
||||||
|
|
||||||
frappe.ui.form.on("DocType", {
|
frappe.ui.form.on("DocType", {
|
||||||
onload: function (frm) {
|
onload: function (frm) {
|
||||||
if (frm.is_new() && !frm.doc?.fields) {
|
if (frm.is_new()) {
|
||||||
frappe.listview_settings["DocType"].new_doctype_dialog();
|
frm.set_value("allow_auto_repeat", 0);
|
||||||
|
if (!frm.doc?.fields) {
|
||||||
|
frappe.listview_settings["DocType"].new_doctype_dialog();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
frm.call("check_pending_migration");
|
frm.call("check_pending_migration");
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"autoname": "Prompt",
|
"autoname": "Prompt",
|
||||||
"creation": "2013-02-18 13:36:19",
|
"creation": "2013-02-18 13:36:19",
|
||||||
|
|
@ -34,6 +35,7 @@
|
||||||
"quick_entry",
|
"quick_entry",
|
||||||
"grid_page_length",
|
"grid_page_length",
|
||||||
"rows_threshold_for_grid_search",
|
"rows_threshold_for_grid_search",
|
||||||
|
"allow_bulk_edit",
|
||||||
"cb01",
|
"cb01",
|
||||||
"track_changes",
|
"track_changes",
|
||||||
"track_seen",
|
"track_seen",
|
||||||
|
|
@ -665,6 +667,7 @@
|
||||||
"label": "Sender Name Field"
|
"label": "Sender Name Field"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"depends_on": "eval:!doc.istable",
|
||||||
"fieldname": "permissions_tab",
|
"fieldname": "permissions_tab",
|
||||||
"fieldtype": "Tab Break",
|
"fieldtype": "Tab Break",
|
||||||
"label": "Permissions"
|
"label": "Permissions"
|
||||||
|
|
@ -714,6 +717,14 @@
|
||||||
"fieldname": "recipient_account_field",
|
"fieldname": "recipient_account_field",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Recipient Account Field"
|
"label": "Recipient Account Field"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "1",
|
||||||
|
"depends_on": "istable",
|
||||||
|
"description": "Enable bulk update of this field across child table rows.",
|
||||||
|
"fieldname": "allow_bulk_edit",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Allow Bulk Edit"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
|
|
@ -792,7 +803,7 @@
|
||||||
"link_fieldname": "document_type"
|
"link_fieldname": "document_type"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2025-09-23 06:48:13.555017",
|
"modified": "2026-04-20 16:06:57.212832",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Core",
|
"module": "Core",
|
||||||
"name": "DocType",
|
"name": "DocType",
|
||||||
|
|
@ -823,6 +834,7 @@
|
||||||
],
|
],
|
||||||
"route": "doctype",
|
"route": "doctype",
|
||||||
"row_format": "Dynamic",
|
"row_format": "Dynamic",
|
||||||
|
"rows_threshold_for_grid_search": 20,
|
||||||
"search_fields": "module",
|
"search_fields": "module",
|
||||||
"show_name_in_global_search": 1,
|
"show_name_in_global_search": 1,
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ class DocType(Document):
|
||||||
|
|
||||||
actions: DF.Table[DocTypeAction]
|
actions: DF.Table[DocTypeAction]
|
||||||
allow_auto_repeat: DF.Check
|
allow_auto_repeat: DF.Check
|
||||||
|
allow_bulk_edit: DF.Check
|
||||||
allow_copy: DF.Check
|
allow_copy: DF.Check
|
||||||
allow_events_in_timeline: DF.Check
|
allow_events_in_timeline: DF.Check
|
||||||
allow_guest_to_view: DF.Check
|
allow_guest_to_view: DF.Check
|
||||||
|
|
@ -550,10 +551,19 @@ class DocType(Document):
|
||||||
and (frappe.conf.developer_mode or frappe.flags.allow_doctype_export)
|
and (frappe.conf.developer_mode or frappe.flags.allow_doctype_export)
|
||||||
)
|
)
|
||||||
if allow_doctype_export:
|
if allow_doctype_export:
|
||||||
self.export_doc()
|
|
||||||
self.make_controller_template()
|
def export_doctype_files():
|
||||||
self.set_base_class_for_controller()
|
self.export_doc()
|
||||||
self.export_types_to_controller()
|
self.make_controller_template()
|
||||||
|
self.set_base_class_for_controller()
|
||||||
|
self.export_types_to_controller()
|
||||||
|
|
||||||
|
request = getattr(frappe.local, "request", None)
|
||||||
|
# Defer file writes until after the response so the client can sync the saved doc first.
|
||||||
|
if request and hasattr(request, "after_response"):
|
||||||
|
request.after_response.add(export_doctype_files)
|
||||||
|
else:
|
||||||
|
export_doctype_files()
|
||||||
|
|
||||||
# update index
|
# update index
|
||||||
if not self.custom:
|
if not self.custom:
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,13 @@ frappe.ui.form.on("File", {
|
||||||
const field = frm.get_field("attached_to_name");
|
const field = frm.get_field("attached_to_name");
|
||||||
field.$input_wrapper
|
field.$input_wrapper
|
||||||
.find(".control-value")
|
.find(".control-value")
|
||||||
.html(`${frappe.utils.get_form_link(frm.doctype, frm.docname, true)}`);
|
.html(
|
||||||
|
`${frappe.utils.get_form_link(
|
||||||
|
frm.doc.attached_to_doctype,
|
||||||
|
frm.doc.attached_to_name,
|
||||||
|
true
|
||||||
|
)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,8 @@
|
||||||
"fieldname": "is_home_folder",
|
"fieldname": "is_home_folder",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"hidden": 1,
|
"hidden": 1,
|
||||||
"label": "Is Home Folder"
|
"label": "Is Home Folder",
|
||||||
|
"search_index": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
|
|
@ -190,7 +191,7 @@
|
||||||
"icon": "fa fa-file",
|
"icon": "fa fa-file",
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-01-15 11:46:42.917146",
|
"modified": "2026-04-15 19:56:45.317786",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Core",
|
"module": "Core",
|
||||||
"name": "File",
|
"name": "File",
|
||||||
|
|
@ -222,4 +223,4 @@
|
||||||
"states": [],
|
"states": [],
|
||||||
"title_field": "file_name",
|
"title_field": "file_name",
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,10 +111,21 @@ class File(Document):
|
||||||
self.validate_attachment_limit()
|
self.validate_attachment_limit()
|
||||||
self.set_file_type()
|
self.set_file_type()
|
||||||
self.validate_file_extension()
|
self.validate_file_extension()
|
||||||
|
self.validate_private_file_access()
|
||||||
|
|
||||||
if self.is_folder:
|
if self.is_folder:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self.flags.copy_from_existing_file:
|
||||||
|
# Preserve the normal insert lifecycle for hooks and validations, but skip
|
||||||
|
# reprocessing an existing blob that is already referenced by `file_url`.
|
||||||
|
if not self.file_url:
|
||||||
|
frappe.throw(
|
||||||
|
_("File URL is required when copying an existing attachment."),
|
||||||
|
exc=frappe.MandatoryError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if self.is_remote_file:
|
if self.is_remote_file:
|
||||||
self.validate_remote_file()
|
self.validate_remote_file()
|
||||||
else:
|
else:
|
||||||
|
|
@ -128,6 +139,29 @@ class File(Document):
|
||||||
if not self.is_folder:
|
if not self.is_folder:
|
||||||
self.create_attachment_record()
|
self.create_attachment_record()
|
||||||
|
|
||||||
|
def create_attachment_copy(
|
||||||
|
self,
|
||||||
|
attached_to_doctype: str,
|
||||||
|
attached_to_name: str,
|
||||||
|
attached_to_field: str | None = None,
|
||||||
|
ignore_permissions: bool = False,
|
||||||
|
):
|
||||||
|
"""Efficiently copy an attachment from one document to another by reusing `file_url`."""
|
||||||
|
if self.is_folder:
|
||||||
|
frappe.throw(_("Cannot attach a folder to a document"))
|
||||||
|
|
||||||
|
attachment = frappe.copy_doc(self)
|
||||||
|
attachment.update(
|
||||||
|
{
|
||||||
|
"attached_to_doctype": attached_to_doctype,
|
||||||
|
"attached_to_name": attached_to_name,
|
||||||
|
"attached_to_field": attached_to_field,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
attachment.folder = None
|
||||||
|
attachment.flags.copy_from_existing_file = True
|
||||||
|
return attachment.insert(ignore_permissions=ignore_permissions)
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
if self.is_folder:
|
if self.is_folder:
|
||||||
return
|
return
|
||||||
|
|
@ -167,6 +201,36 @@ class File(Document):
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
frappe.throw(_("Only System Managers can make this file public."))
|
frappe.throw(_("Only System Managers can make this file public."))
|
||||||
|
|
||||||
|
def validate_private_file_access(self):
|
||||||
|
"""Validate that the user has permission to access an existing private file."""
|
||||||
|
if not self.file_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_files = frappe.get_all(
|
||||||
|
"File",
|
||||||
|
filters={"file_url": self.file_url},
|
||||||
|
fields=["name", "owner", "is_private"],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existing_files:
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_file = existing_files[0]
|
||||||
|
|
||||||
|
if existing_file.is_private:
|
||||||
|
user = frappe.session.user
|
||||||
|
|
||||||
|
if user == existing_file.owner or user == "Administrator":
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_doc = frappe.get_doc("File", existing_file.name)
|
||||||
|
if not has_permission(existing_doc, "read", user=user):
|
||||||
|
frappe.throw(
|
||||||
|
_("You do not have permission to access this file"),
|
||||||
|
frappe.PermissionError,
|
||||||
|
)
|
||||||
|
|
||||||
def after_rename(self, *args, **kwargs):
|
def after_rename(self, *args, **kwargs):
|
||||||
for successor in self.get_successors():
|
for successor in self.get_successors():
|
||||||
setup_folder_path(successor, self.name)
|
setup_folder_path(successor, self.name)
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,66 @@ class TestSameContent(IntegrationTestCase):
|
||||||
limit_property.delete()
|
limit_property.delete()
|
||||||
frappe.clear_cache(doctype="ToDo")
|
frappe.clear_cache(doctype="ToDo")
|
||||||
|
|
||||||
|
def test_create_attachment_copy(self):
|
||||||
|
doctype, docname = make_test_doc()
|
||||||
|
source_file = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "File",
|
||||||
|
"file_name": f"existing-file-{frappe.generate_hash(length=8)}.txt",
|
||||||
|
"content": "Existing attachment content",
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
comment_count_before = frappe.db.count(
|
||||||
|
"Comment", {"reference_doctype": doctype, "reference_name": docname}
|
||||||
|
)
|
||||||
|
|
||||||
|
copied_file = source_file.create_attachment_copy(doctype, docname)
|
||||||
|
comment_count_after = frappe.db.count(
|
||||||
|
"Comment", {"reference_doctype": doctype, "reference_name": docname}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(copied_file.name, source_file.name)
|
||||||
|
self.assertEqual(copied_file.file_url, source_file.file_url)
|
||||||
|
self.assertEqual(copied_file.attached_to_doctype, doctype)
|
||||||
|
self.assertEqual(copied_file.attached_to_name, docname)
|
||||||
|
self.assertEqual(
|
||||||
|
copied_file.folder,
|
||||||
|
frappe.db.get_value("File", {"is_attachments_folder": 1}),
|
||||||
|
)
|
||||||
|
self.assertEqual(comment_count_after, comment_count_before + 1)
|
||||||
|
|
||||||
|
def test_create_attachment_copy_respects_attachment_limit(self):
|
||||||
|
doctype, docname = make_test_doc()
|
||||||
|
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
|
||||||
|
|
||||||
|
limit_property = make_property_setter("ToDo", None, "max_attachments", 1, "int", for_doctype=True)
|
||||||
|
source_file_1 = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "File",
|
||||||
|
"file_name": f"existing-limit-file-{frappe.generate_hash(length=8)}.txt",
|
||||||
|
"content": "Existing attachment content 1",
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
source_file_2 = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "File",
|
||||||
|
"file_name": f"existing-limit-file-{frappe.generate_hash(length=8)}.txt",
|
||||||
|
"content": "Existing attachment content 2",
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
try:
|
||||||
|
source_file_1.create_attachment_copy(doctype, docname)
|
||||||
|
self.assertRaises(
|
||||||
|
frappe.exceptions.AttachmentLimitReached,
|
||||||
|
source_file_2.create_attachment_copy,
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
limit_property.delete()
|
||||||
|
frappe.clear_cache(doctype="ToDo")
|
||||||
|
|
||||||
def test_utf8_bom_content_decoding(self):
|
def test_utf8_bom_content_decoding(self):
|
||||||
utf8_bom_content = test_content1.encode("utf-8-sig")
|
utf8_bom_content = test_content1.encode("utf-8-sig")
|
||||||
_file: frappe.Document = frappe.get_doc(
|
_file: frappe.Document = frappe.get_doc(
|
||||||
|
|
|
||||||
|
|
@ -480,3 +480,16 @@ def find_file_by_url(path: str, name: str | None = None) -> "File" | None:
|
||||||
|
|
||||||
def get_safe_file_name(file_name: str) -> str:
|
def get_safe_file_name(file_name: str) -> str:
|
||||||
return re.sub(r"[/\\%?#]", "_", file_name)
|
return re.sub(r"[/\\%?#]", "_", file_name)
|
||||||
|
|
||||||
|
|
||||||
|
def check_path_safety(base_path: str, requested_path: str) -> bool:
|
||||||
|
"""Util to check path safety by ensuring sandboxing and logging unsuccessful attempts"""
|
||||||
|
base_path = os.path.realpath(base_path)
|
||||||
|
requested_path = os.path.realpath(requested_path)
|
||||||
|
if os.path.commonpath([base_path, requested_path]) != base_path:
|
||||||
|
frappe.log_error(
|
||||||
|
title="Attempted Unauthorized File Access",
|
||||||
|
message=f"Blocked access to: {requested_path}",
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,9 @@ class PackageRelease(Document):
|
||||||
|
|
||||||
def export_package_files(self, package):
|
def export_package_files(self, package):
|
||||||
# write readme
|
# write readme
|
||||||
with open(frappe.get_site_path("packages", package.package_name, "README.md"), "w") as readme:
|
if package.readme:
|
||||||
readme.write(package.readme)
|
with open(frappe.get_site_path("packages", package.package_name, "README.md"), "w") as readme:
|
||||||
|
readme.write(package.readme)
|
||||||
|
|
||||||
# write license
|
# write license
|
||||||
if package.license:
|
if package.license:
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,9 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"in_create": 1,
|
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-11-13 16:17:58.536849",
|
"modified": "2026-04-27 13:30:28.567106",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Core",
|
"module": "Core",
|
||||||
"name": "Permission Type",
|
"name": "Permission Type",
|
||||||
|
|
|
||||||
|
|
@ -87,18 +87,21 @@ class PreparedReport(Document):
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_prepared_data(self, with_file_name=False):
|
def get_prepared_data(self, with_file_name=False):
|
||||||
if attachments := get_attachments(self.doctype, self.name):
|
attachments = get_attachments(self.doctype, self.name)
|
||||||
attachment = None
|
if not attachments:
|
||||||
for f in attachments or []:
|
frappe.throw(_("No attachment found for the prepared report"), title=_("Attachment Not Found"))
|
||||||
if f.file_url.endswith(".gz"):
|
|
||||||
attachment = f
|
|
||||||
break
|
|
||||||
|
|
||||||
attached_file = frappe.get_doc("File", attachment.name)
|
attachment = None
|
||||||
|
for f in attachments or []:
|
||||||
|
if f.file_url.endswith(".gz"):
|
||||||
|
attachment = f
|
||||||
|
break
|
||||||
|
|
||||||
if with_file_name:
|
attached_file = frappe.get_doc("File", attachment.name)
|
||||||
return (gzip.decompress(attached_file.get_content()), attachment.file_name)
|
|
||||||
return gzip.decompress(attached_file.get_content())
|
if with_file_name:
|
||||||
|
return (gzip.decompress(attached_file.get_content()), attachment.file_name)
|
||||||
|
return gzip.decompress(attached_file.get_content())
|
||||||
|
|
||||||
|
|
||||||
def generate_report(prepared_report):
|
def generate_report(prepared_report):
|
||||||
|
|
@ -141,7 +144,10 @@ def generate_report(prepared_report):
|
||||||
except Exception:
|
except Exception:
|
||||||
# we need to ensure that error gets stored
|
# we need to ensure that error gets stored
|
||||||
_save_error(instance, error=frappe.get_traceback(with_context=True))
|
_save_error(instance, error=frappe.get_traceback(with_context=True))
|
||||||
|
return
|
||||||
|
|
||||||
|
instance.reload()
|
||||||
|
instance.status = "Completed"
|
||||||
instance.report_end_time = frappe.utils.now()
|
instance.report_end_time = frappe.utils.now()
|
||||||
instance.peak_memory_usage = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
instance.peak_memory_usage = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||||
add_data_to_monitor(peak_memory_usage=instance.peak_memory_usage)
|
add_data_to_monitor(peak_memory_usage=instance.peak_memory_usage)
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,30 @@ frappe.ui.form.on("Report", {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
frm.set_query("default_print_format", () => {
|
||||||
|
return {
|
||||||
|
filters: {
|
||||||
|
print_format_for: "Report",
|
||||||
|
report: frm.doc.name,
|
||||||
|
print_format_type: "JS",
|
||||||
|
disabled: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
frm.set_query("letter_head", () => {
|
||||||
|
const filters = {
|
||||||
|
letter_head_for: "Report",
|
||||||
|
disabled: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (frm.doc.is_standard === "Yes") {
|
||||||
|
filters.standard = "Yes";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filters };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
ref_doctype: function (frm) {
|
ref_doctype: function (frm) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"column_break_4",
|
"column_break_4",
|
||||||
"report_type",
|
"report_type",
|
||||||
"letter_head",
|
"letter_head",
|
||||||
|
"default_print_format",
|
||||||
"add_total_row",
|
"add_total_row",
|
||||||
"disabled",
|
"disabled",
|
||||||
"prepared_report",
|
"prepared_report",
|
||||||
|
|
@ -96,10 +97,9 @@
|
||||||
"label": "Disabled"
|
"label": "Disabled"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "eval: doc.is_standard == \"No\"",
|
|
||||||
"fieldname": "letter_head",
|
"fieldname": "letter_head",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Letter Head",
|
"label": "Default Letter Head",
|
||||||
"options": "Letter Head"
|
"options": "Letter Head"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -202,12 +202,18 @@
|
||||||
"fieldname": "add_translate_data",
|
"fieldname": "add_translate_data",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Add Translate Data"
|
"label": "Add Translate Data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "default_print_format",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Default Print Format",
|
||||||
|
"options": "Print Format"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-08-28 18:28:32.510719",
|
"modified": "2026-04-10 00:03:15.212213",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Core",
|
"module": "Core",
|
||||||
"name": "Report",
|
"name": "Report",
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,17 @@ import json
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
import frappe.desk.query_report
|
|
||||||
from frappe import _, scrub
|
from frappe import _, scrub
|
||||||
from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
|
from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
|
||||||
from frappe.core.doctype.page.page import delete_custom_role
|
from frappe.core.doctype.page.page import delete_custom_role
|
||||||
|
from frappe.desk.query_report import run
|
||||||
from frappe.desk.reportview import append_totals_row
|
from frappe.desk.reportview import append_totals_row
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.modules import make_boilerplate
|
from frappe.modules import make_boilerplate
|
||||||
from frappe.modules.export_file import export_to_files
|
from frappe.modules.export_file import export_to_files
|
||||||
from frappe.utils import cint, cstr
|
from frappe.utils import cint, cstr
|
||||||
from frappe.utils.safe_exec import check_safe_sql_query, safe_exec
|
from frappe.utils.safe_exec import check_safe_sql_query, safe_exec
|
||||||
|
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder
|
||||||
|
|
||||||
|
|
||||||
class Report(Document):
|
class Report(Document):
|
||||||
|
|
@ -32,6 +33,7 @@ class Report(Document):
|
||||||
add_total_row: DF.Check
|
add_total_row: DF.Check
|
||||||
add_translate_data: DF.Check
|
add_translate_data: DF.Check
|
||||||
columns: DF.Table[ReportColumn]
|
columns: DF.Table[ReportColumn]
|
||||||
|
default_print_format: DF.Link | None
|
||||||
disabled: DF.Check
|
disabled: DF.Check
|
||||||
filters: DF.Table[ReportFilter]
|
filters: DF.Table[ReportFilter]
|
||||||
is_standard: DF.Literal["No", "Yes"]
|
is_standard: DF.Literal["No", "Yes"]
|
||||||
|
|
@ -72,16 +74,17 @@ class Report(Document):
|
||||||
frappe.throw(_("Cannot edit a standard report. Please duplicate and create a new report"))
|
frappe.throw(_("Cannot edit a standard report. Please duplicate and create a new report"))
|
||||||
|
|
||||||
if self.is_standard == "Yes":
|
if self.is_standard == "Yes":
|
||||||
if frappe.session.user != "Administrator":
|
self.validate_standard_report()
|
||||||
frappe.throw(_("Only Administrator can save a standard report. Please rename and save."))
|
|
||||||
|
|
||||||
# Letter Head is visible only for non-standard reports.
|
|
||||||
# It should not remain set when it's invisible.
|
|
||||||
self.letter_head = None
|
|
||||||
|
|
||||||
if self.report_type == "Report Builder":
|
if self.report_type == "Report Builder":
|
||||||
self.update_report_json()
|
self.update_report_json()
|
||||||
|
|
||||||
|
if self.default_print_format and self.has_value_changed("default_print_format"):
|
||||||
|
self.validate_default_print_format()
|
||||||
|
|
||||||
|
if self.letter_head and self.has_value_changed("letter_head"):
|
||||||
|
self.validate_letter_head()
|
||||||
|
|
||||||
def before_insert(self):
|
def before_insert(self):
|
||||||
self.set_doctype_roles()
|
self.set_doctype_roles()
|
||||||
|
|
||||||
|
|
@ -89,7 +92,6 @@ class Report(Document):
|
||||||
self.export_doc()
|
self.export_doc()
|
||||||
|
|
||||||
def before_export(self, doc):
|
def before_export(self, doc):
|
||||||
doc.letter_head = None
|
|
||||||
doc.prepared_report = 0
|
doc.prepared_report = 0
|
||||||
|
|
||||||
def on_trash(self):
|
def on_trash(self):
|
||||||
|
|
@ -106,6 +108,13 @@ class Report(Document):
|
||||||
|
|
||||||
delete_custom_role("report", self.name)
|
delete_custom_role("report", self.name)
|
||||||
|
|
||||||
|
def clear_cache(self):
|
||||||
|
self.update_report_cache()
|
||||||
|
return super().clear_cache()
|
||||||
|
|
||||||
|
def update_report_cache(self):
|
||||||
|
frappe.cache.delete_key("bootinfo")
|
||||||
|
|
||||||
def delete_report_folder(self):
|
def delete_report_folder(self):
|
||||||
from frappe.modules.export_file import delete_folder
|
from frappe.modules.export_file import delete_folder
|
||||||
|
|
||||||
|
|
@ -202,11 +211,14 @@ class Report(Document):
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
def get_module_method(self, method):
|
||||||
|
module = self.module or frappe.db.get_value("DocType", self.ref_doctype, "module")
|
||||||
|
method_path = get_report_module_dotted_path(module, self.name) + "." + method
|
||||||
|
return frappe.get_attr(method_path)
|
||||||
|
|
||||||
def execute_module(self, filters):
|
def execute_module(self, filters):
|
||||||
# report in python module
|
# report in python module
|
||||||
module = self.module or frappe.db.get_value("DocType", self.ref_doctype, "module")
|
return self.get_module_method("execute")(frappe._dict(filters))
|
||||||
method_name = get_report_module_dotted_path(module, self.name) + ".execute"
|
|
||||||
return frappe.get_attr(method_name)(frappe._dict(filters))
|
|
||||||
|
|
||||||
def execute_script(self, filters):
|
def execute_script(self, filters):
|
||||||
# server script
|
# server script
|
||||||
|
|
@ -242,7 +254,7 @@ class Report(Document):
|
||||||
self, filters=None, user=None, ignore_prepared_report=False, are_default_filters=True
|
self, filters=None, user=None, ignore_prepared_report=False, are_default_filters=True
|
||||||
):
|
):
|
||||||
columns, result = [], []
|
columns, result = [], []
|
||||||
data = frappe.desk.query_report.run(
|
data = run(
|
||||||
self.name,
|
self.name,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
user=user,
|
user=user,
|
||||||
|
|
@ -314,8 +326,6 @@ class Report(Document):
|
||||||
columns = params.get("fields")
|
columns = params.get("fields")
|
||||||
elif params.get("columns"):
|
elif params.get("columns"):
|
||||||
columns = params.get("columns")
|
columns = params.get("columns")
|
||||||
elif params.get("fields"):
|
|
||||||
columns = params.get("fields")
|
|
||||||
else:
|
else:
|
||||||
columns = [["name", self.ref_doctype]]
|
columns = [["name", self.ref_doctype]]
|
||||||
columns.extend(
|
columns.extend(
|
||||||
|
|
@ -401,6 +411,53 @@ class Report(Document):
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def validate_standard_report(self):
|
||||||
|
if frappe.session.user != "Administrator":
|
||||||
|
frappe.throw(_("Only Administrator can save a standard report. Please rename and save."))
|
||||||
|
|
||||||
|
if not cint(frappe.conf.developer_mode):
|
||||||
|
frappe.throw(_("Standard reports can only be created in developer mode."))
|
||||||
|
|
||||||
|
def validate_default_print_format(self):
|
||||||
|
pf = frappe.db.get_value(
|
||||||
|
"Print Format",
|
||||||
|
self.default_print_format,
|
||||||
|
["report", "print_format_for", "print_format_type", "disabled"],
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not pf
|
||||||
|
or pf.report != self.name
|
||||||
|
or pf.print_format_for != "Report"
|
||||||
|
or pf.print_format_type != "JS"
|
||||||
|
or pf.disabled
|
||||||
|
):
|
||||||
|
frappe.throw(_("Selected Print Format is invalid for this Report."))
|
||||||
|
|
||||||
|
def validate_letter_head(self):
|
||||||
|
if not self.letter_head:
|
||||||
|
return
|
||||||
|
|
||||||
|
letter_head = frappe.db.get_value(
|
||||||
|
"Letter Head",
|
||||||
|
self.letter_head,
|
||||||
|
["letter_head_for", "standard", "disabled"],
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not letter_head
|
||||||
|
or letter_head.letter_head_for != "Report"
|
||||||
|
or (self.is_standard == "Yes" and letter_head.standard != "Yes")
|
||||||
|
or letter_head.disabled
|
||||||
|
):
|
||||||
|
frappe.throw(
|
||||||
|
_("Selected Letter Head '{0}' is invalid for '{1}' Report.").format(
|
||||||
|
self.letter_head, self.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def toggle_disable(self, disable: bool):
|
def toggle_disable(self, disable: bool):
|
||||||
if not self.has_permission("write"):
|
if not self.has_permission("write"):
|
||||||
|
|
@ -408,6 +465,18 @@ class Report(Document):
|
||||||
|
|
||||||
self.db_set("disabled", cint(disable))
|
self.db_set("disabled", cint(disable))
|
||||||
|
|
||||||
|
def get_xlsx_styles_from_module(self, metadata: XLSXMetadata) -> dict:
|
||||||
|
if self.is_standard != "Yes" or self.report_type not in ("Query Report", "Script Report"):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
method = self.get_module_method("get_xlsx_styles")
|
||||||
|
except AttributeError:
|
||||||
|
# Ignore if hook(method) is not defined
|
||||||
|
return
|
||||||
|
|
||||||
|
return method(metadata)
|
||||||
|
|
||||||
|
|
||||||
def is_prepared_report_enabled(report):
|
def is_prepared_report_enabled(report):
|
||||||
return cint(frappe.db.get_value("Report", report, "prepared_report"))
|
return cint(frappe.db.get_value("Report", report, "prepared_report"))
|
||||||
|
|
|
||||||
|
|
@ -406,3 +406,32 @@ result = [
|
||||||
self.assertEqual(result[-1][0], "Total")
|
self.assertEqual(result[-1][0], "Total")
|
||||||
self.assertEqual(result[-1][1], 200)
|
self.assertEqual(result[-1][1], 200)
|
||||||
self.assertEqual(result[-1][2], 150.50)
|
self.assertEqual(result[-1][2], 150.50)
|
||||||
|
|
||||||
|
def test_report_cache_invalidation(self):
|
||||||
|
import frappe.sessions
|
||||||
|
from frappe.utils import set_request
|
||||||
|
|
||||||
|
frappe.set_user("test@example.com")
|
||||||
|
set_request(method="GET", path="/app")
|
||||||
|
|
||||||
|
try:
|
||||||
|
frappe.sessions.get()
|
||||||
|
|
||||||
|
report_name = _save_report(
|
||||||
|
"Test Cache Invalidation Report",
|
||||||
|
"User",
|
||||||
|
json.dumps([{"fieldname": "email", "fieldtype": "Data", "label": "Email"}]),
|
||||||
|
)
|
||||||
|
|
||||||
|
cached_bootinfo = frappe.sessions.get()
|
||||||
|
self.assertIn(report_name, cached_bootinfo["user"]["all_reports"])
|
||||||
|
|
||||||
|
doc = frappe.get_doc("Report", report_name)
|
||||||
|
delete_report(doc.name)
|
||||||
|
|
||||||
|
cached_bootinfo = frappe.sessions.get()
|
||||||
|
self.assertNotIn(report_name, cached_bootinfo["user"]["all_reports"])
|
||||||
|
|
||||||
|
finally:
|
||||||
|
frappe.local.request = None
|
||||||
|
frappe.set_user("Administrator")
|
||||||
|
|
|
||||||
0
frappe/core/doctype/security_settings/__init__.py
Normal file
0
frappe/core/doctype/security_settings/__init__.py
Normal file
20
frappe/core/doctype/security_settings/security_settings.js
Normal file
20
frappe/core/doctype/security_settings/security_settings.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Copyright (c) 2026, Frappe Technologies and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
frappe.ui.form.on("Security Settings", {
|
||||||
|
refresh(frm) {
|
||||||
|
const wrapper = frm.fields_dict.securitytxt_section.wrapper;
|
||||||
|
if ($(wrapper).find(".security-txt-banner").length) return;
|
||||||
|
|
||||||
|
$(wrapper)
|
||||||
|
.find(".section-body")
|
||||||
|
.prepend(
|
||||||
|
`<div class="alert alert-warning border d-flex justify-content-between align-items-center security-txt-banner" style="flex: 0 0 100%; max-width: 100%; border-color: var(--border-color);">
|
||||||
|
<span>${__("Security.txt will be served only under HTTPS.")}</span>
|
||||||
|
<a href="https://tools.ietf.org/html/rfc9116#section-6.7" target="_blank" class="btn btn-xs btn-secondary">${__(
|
||||||
|
"Learn more"
|
||||||
|
)}</a>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
82
frappe/core/doctype/security_settings/security_settings.json
Normal file
82
frappe/core/doctype/security_settings/security_settings.json
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"creation": "2026-04-10 16:14:40.343135",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"securitytxt_section",
|
||||||
|
"public_expires",
|
||||||
|
"public_contacts",
|
||||||
|
"public_languages",
|
||||||
|
"public_policy",
|
||||||
|
"security_txt"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "securitytxt_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Security.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Date after which this security.txt should be considered stale. Expires timestamp is converted to UTC.",
|
||||||
|
"fieldname": "public_expires",
|
||||||
|
"fieldtype": "Datetime",
|
||||||
|
"label": "Expires"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Website, email or phone where vulnerabilities can be reported. Defaults to `https://security.frappe.io`",
|
||||||
|
"fieldname": "public_contacts",
|
||||||
|
"fieldtype": "Table",
|
||||||
|
"label": "Contact",
|
||||||
|
"options": "Security Settings Contact"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Defaults to `en`",
|
||||||
|
"fieldname": "public_languages",
|
||||||
|
"fieldtype": "Table MultiSelect",
|
||||||
|
"label": "Preferred Language",
|
||||||
|
"options": "Security Settings Language"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Guidelines and policies on vulnerability reporting. Defaults to `https://frappe.io/security`",
|
||||||
|
"fieldname": "public_policy",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Policy",
|
||||||
|
"options": "URL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "security_txt",
|
||||||
|
"fieldtype": "Small Text",
|
||||||
|
"is_virtual": 1,
|
||||||
|
"label": "Preview",
|
||||||
|
"read_only": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"issingle": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2026-04-17 13:07:45.259146",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "Core",
|
||||||
|
"name": "Security Settings",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"rows_threshold_for_grid_search": 20,
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": [],
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
||||||
122
frappe/core/doctype/security_settings/security_settings.py
Normal file
122
frappe/core/doctype/security_settings/security_settings.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# Copyright (c) 2026, Frappe Technologies and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
import frappe.utils
|
||||||
|
from frappe import _
|
||||||
|
from frappe.model.document import Document
|
||||||
|
from frappe.utils import (
|
||||||
|
get_system_timezone,
|
||||||
|
now_datetime,
|
||||||
|
validate_email_address,
|
||||||
|
validate_phone_number,
|
||||||
|
validate_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SecuritySettings(Document):
|
||||||
|
# begin: auto-generated types
|
||||||
|
# This code is auto-generated. Do not modify anything in this block.
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frappe.core.doctype.security_settings_contact.security_settings_contact import (
|
||||||
|
SecuritySettingsContact,
|
||||||
|
)
|
||||||
|
from frappe.core.doctype.security_settings_language.security_settings_language import (
|
||||||
|
SecuritySettingsLanguage,
|
||||||
|
)
|
||||||
|
from frappe.types import DF
|
||||||
|
|
||||||
|
public_contacts: DF.Table[SecuritySettingsContact]
|
||||||
|
public_expires: DF.Datetime | None
|
||||||
|
public_languages: DF.TableMultiSelect[SecuritySettingsLanguage]
|
||||||
|
public_policy: DF.Data | None
|
||||||
|
# end: auto-generated types
|
||||||
|
|
||||||
|
@property
|
||||||
|
def security_txt(self):
|
||||||
|
return (
|
||||||
|
"\n\n".join(
|
||||||
|
[
|
||||||
|
self.public_policy_section,
|
||||||
|
self.public_contacts_section,
|
||||||
|
self.public_languages_section,
|
||||||
|
self.public_expires_section,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_policy_section(self):
|
||||||
|
value = self.public_policy or "https://frappe.io/security"
|
||||||
|
return f"# Read our security policy before reporting an issue\nPolicy: {value}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_contacts_section(self):
|
||||||
|
contacts = [self.with_protocol(c.contact, c.type) for c in self.public_contacts] or [
|
||||||
|
"https://security.frappe.io"
|
||||||
|
]
|
||||||
|
value = "\n".join(f"Contact: {c}" for c in contacts)
|
||||||
|
return f"# Our security address\n{value}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_languages_section(self):
|
||||||
|
langs = [l.language for l in self.public_languages] or ["en"]
|
||||||
|
value = ", ".join(langs)
|
||||||
|
return f"# We prefer talking in\nPreferred-Languages: {value}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_expires_section(self):
|
||||||
|
expires = self.public_expires or frappe.utils.add_years(frappe.utils.now_datetime(), 1)
|
||||||
|
if isinstance(expires, str):
|
||||||
|
expires = datetime.fromisoformat(expires)
|
||||||
|
expires = expires.replace(microsecond=0, tzinfo=ZoneInfo(get_system_timezone())).astimezone(UTC)
|
||||||
|
value = expires.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
return f"Expires: {value}"
|
||||||
|
|
||||||
|
def with_protocol(self, url: str, type_: str) -> str:
|
||||||
|
"""Prefix the URL with the appropriate protocol based on the contact type."""
|
||||||
|
match type_:
|
||||||
|
case "Email":
|
||||||
|
if not url.startswith("mailto:"):
|
||||||
|
return f"mailto:{url}"
|
||||||
|
case "Phone":
|
||||||
|
if not url.startswith("tel:"):
|
||||||
|
return f"tel:{url}"
|
||||||
|
return url
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
self.validate_public_policy()
|
||||||
|
self.validate_public_contacts()
|
||||||
|
self.validate_expires()
|
||||||
|
|
||||||
|
def validate_public_policy(self):
|
||||||
|
if self.public_policy:
|
||||||
|
if not self.public_policy.startswith("https://"):
|
||||||
|
frappe.throw(_("Public Policy URL must start with https://"))
|
||||||
|
|
||||||
|
def validate_public_contacts(self):
|
||||||
|
for contact in self.public_contacts:
|
||||||
|
match contact.type:
|
||||||
|
case "Email":
|
||||||
|
validate_email_address(contact.contact, throw=True)
|
||||||
|
case "Phone":
|
||||||
|
validate_phone_number(contact.contact, throw=True)
|
||||||
|
case "Website":
|
||||||
|
validate_url(contact.contact, throw=True)
|
||||||
|
if not contact.contact.startswith("https://"):
|
||||||
|
frappe.throw(_("URL contact must start with https://"))
|
||||||
|
|
||||||
|
def validate_expires(self):
|
||||||
|
if self.public_expires:
|
||||||
|
expires = self.public_expires
|
||||||
|
if isinstance(expires, str):
|
||||||
|
expires = datetime.fromisoformat(expires)
|
||||||
|
if expires <= now_datetime():
|
||||||
|
frappe.throw(_("Expiration date must be in the future"))
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Copyright (c) 2026, Frappe Technologies and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.utils import get_datetime, now_datetime
|
||||||
|
from frappe.utils.user import get_users_with_role
|
||||||
|
|
||||||
|
|
||||||
|
def check_security_txt_expiry():
|
||||||
|
security_settings = frappe.get_doc("Security Settings")
|
||||||
|
if not security_settings.public_expires:
|
||||||
|
return
|
||||||
|
expires = security_settings.public_expires
|
||||||
|
if isinstance(expires, str):
|
||||||
|
expires = get_datetime(expires)
|
||||||
|
now = now_datetime()
|
||||||
|
days_until_expiry = (expires - now).days
|
||||||
|
alert_days = [30, 15, 7, 1]
|
||||||
|
if days_until_expiry in alert_days:
|
||||||
|
send_expiry_alert(frappe.local.site, expires, days_until_expiry)
|
||||||
|
|
||||||
|
|
||||||
|
def send_expiry_alert(site: str, expires, days_until_expiry: int):
|
||||||
|
recipients = get_users_with_role("System Manager")
|
||||||
|
if not recipients:
|
||||||
|
return
|
||||||
|
subject = get_email_subject(site, days_until_expiry)
|
||||||
|
frappe.sendmail(
|
||||||
|
recipients=recipients,
|
||||||
|
subject=subject,
|
||||||
|
template="security_txt_expiry_alert",
|
||||||
|
args={
|
||||||
|
"site": site,
|
||||||
|
"expires": expires,
|
||||||
|
"days_remaining": days_until_expiry,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_email_subject(site: str, days_until_expiry: int) -> str:
|
||||||
|
if days_until_expiry == 1:
|
||||||
|
return f"[URGENT] Security.txt expires in 1 day - {site}"
|
||||||
|
return f"Security.txt expires in {days_until_expiry} days - {site}"
|
||||||
272
frappe/core/doctype/security_settings/test_security_settings.py
Normal file
272
frappe/core/doctype/security_settings/test_security_settings.py
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
# Copyright (c) 2026, Frappe Technologies and Contributors
|
||||||
|
# License: MIT. See LICENSE
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecuritySettings(IntegrationTestCase):
|
||||||
|
def test_public_policy_section_default(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_policy": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
section = doc.public_policy_section
|
||||||
|
self.assertIn("Policy: https://frappe.io/security", section)
|
||||||
|
|
||||||
|
def test_public_policy_section_custom(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_policy": "https://example.com/security-policy",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
section = doc.public_policy_section
|
||||||
|
self.assertIn("Policy: https://example.com/security-policy", section)
|
||||||
|
|
||||||
|
def test_public_languages_section_default(self):
|
||||||
|
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||||
|
section = doc.public_languages_section
|
||||||
|
self.assertIn("Preferred-Languages: en", section)
|
||||||
|
|
||||||
|
def test_public_languages_section_custom(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_languages": [
|
||||||
|
{"language": "en"},
|
||||||
|
{"language": "fr"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
section = doc.public_languages_section
|
||||||
|
self.assertIn("Preferred-Languages: en, fr", section)
|
||||||
|
|
||||||
|
def test_public_contacts_section_default(self):
|
||||||
|
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||||
|
section = doc.public_contacts_section
|
||||||
|
self.assertIn("https://security.frappe.io", section)
|
||||||
|
|
||||||
|
def test_public_contacts_section_email(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_contacts": [
|
||||||
|
{"type": "Email", "contact": "security@example.com"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
section = doc.public_contacts_section
|
||||||
|
self.assertIn("mailto:security@example.com", section)
|
||||||
|
|
||||||
|
def test_public_contacts_section_phone(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_contacts": [
|
||||||
|
{"type": "Phone", "contact": "+1234567890"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
section = doc.public_contacts_section
|
||||||
|
self.assertIn("tel:+1234567890", section)
|
||||||
|
|
||||||
|
def test_public_contacts_section_website(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_contacts": [
|
||||||
|
{"type": "Website", "contact": "https://security.example.com"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
section = doc.public_contacts_section
|
||||||
|
self.assertIn("https://security.example.com", section)
|
||||||
|
|
||||||
|
def test_with_protocol_email_without_protocol(self):
|
||||||
|
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||||
|
result = doc.with_protocol("security@example.com", "Email")
|
||||||
|
self.assertEqual(result, "mailto:security@example.com")
|
||||||
|
|
||||||
|
def test_with_protocol_email_with_protocol(self):
|
||||||
|
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||||
|
result = doc.with_protocol("mailto:security@example.com", "Email")
|
||||||
|
self.assertEqual(result, "mailto:security@example.com")
|
||||||
|
|
||||||
|
def test_with_protocol_phone_without_protocol(self):
|
||||||
|
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||||
|
result = doc.with_protocol("+1234567890", "Phone")
|
||||||
|
self.assertEqual(result, "tel:+1234567890")
|
||||||
|
|
||||||
|
def test_with_protocol_phone_with_protocol(self):
|
||||||
|
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||||
|
result = doc.with_protocol("tel:+1234567890", "Phone")
|
||||||
|
self.assertEqual(result, "tel:+1234567890")
|
||||||
|
|
||||||
|
def test_with_protocol_website(self):
|
||||||
|
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||||
|
result = doc.with_protocol("https://example.com", "Website")
|
||||||
|
self.assertEqual(result, "https://example.com")
|
||||||
|
|
||||||
|
def test_security_txt_full(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_policy": "https://example.com/policy",
|
||||||
|
"public_contacts": [
|
||||||
|
{"type": "Email", "contact": "security@example.com"},
|
||||||
|
],
|
||||||
|
"public_languages": [
|
||||||
|
{"language": "en"},
|
||||||
|
],
|
||||||
|
"public_expires": datetime.now() + timedelta(days=365),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
security_txt = doc.security_txt
|
||||||
|
self.assertIn("Policy: https://example.com/policy", security_txt)
|
||||||
|
self.assertIn("mailto:security@example.com", security_txt)
|
||||||
|
self.assertIn("Preferred-Languages: en", security_txt)
|
||||||
|
self.assertIn("Expires:", security_txt)
|
||||||
|
|
||||||
|
def test_validate_public_policy_with_http(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_policy": "http://example.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertRaises(frappe.ValidationError, doc.validate_public_policy)
|
||||||
|
|
||||||
|
def test_validate_public_policy_with_https(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_policy": "https://example.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Should not raise
|
||||||
|
doc.validate_public_policy()
|
||||||
|
|
||||||
|
def test_validate_public_contacts_invalid_email(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_contacts": [
|
||||||
|
{"type": "Email", "contact": "invalid-email"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertRaises(frappe.ValidationError, doc.validate_public_contacts)
|
||||||
|
|
||||||
|
def test_validate_public_contacts_valid_email(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_contacts": [
|
||||||
|
{"type": "Email", "contact": "security@example.com"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Should not raise
|
||||||
|
doc.validate_public_contacts()
|
||||||
|
|
||||||
|
def test_validate_public_contacts_invalid_phone(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_contacts": [
|
||||||
|
{"type": "Phone", "contact": "not-a-phone"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertRaises(frappe.ValidationError, doc.validate_public_contacts)
|
||||||
|
|
||||||
|
def test_validate_public_contacts_valid_phone(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_contacts": [
|
||||||
|
{"type": "Phone", "contact": "+1234567890"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Should not raise
|
||||||
|
doc.validate_public_contacts()
|
||||||
|
|
||||||
|
def test_validate_public_contacts_website_without_https(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_contacts": [
|
||||||
|
{"type": "Website", "contact": "http://example.com"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertRaises(frappe.ValidationError, doc.validate_public_contacts)
|
||||||
|
|
||||||
|
def test_validate_public_contacts_valid_website(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_contacts": [
|
||||||
|
{"type": "Website", "contact": "https://example.com"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Should not raise
|
||||||
|
doc.validate_public_contacts()
|
||||||
|
|
||||||
|
def test_validate_expires_past(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_expires": datetime.now() - timedelta(days=1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertRaises(frappe.ValidationError, doc.validate_expires)
|
||||||
|
|
||||||
|
def test_validate_expires_future(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_expires": datetime.now() + timedelta(days=365),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Should not raise
|
||||||
|
doc.validate_expires()
|
||||||
|
|
||||||
|
@IntegrationTestCase.change_settings("System Settings", {"time_zone": "Etc/UTC"})
|
||||||
|
def test_public_expires_section_future_date(self):
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
|
future_date = datetime(2027, 12, 31, 23, 59, 59)
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_expires": future_date,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
section = doc.public_expires_section
|
||||||
|
self.assertIn("2027-12-31T23:59:59Z", section)
|
||||||
|
|
||||||
|
@IntegrationTestCase.change_settings("System Settings", {"time_zone": "Asia/Kolkata"})
|
||||||
|
def test_public_expires_section_string(self):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Security Settings",
|
||||||
|
"public_expires": "2028-01-01T05:29:59",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
section = doc.public_expires_section
|
||||||
|
self.assertIn("2027-12-31T23:59:59Z", section)
|
||||||
|
|
||||||
|
def test_public_expires_section_default(self):
|
||||||
|
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||||
|
section = doc.public_expires_section
|
||||||
|
# Default is 1 year from now
|
||||||
|
self.assertIn("Expires:", section)
|
||||||
|
self.assertIn("T", section) # ISO format
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"creation": "2026-04-11 13:06:29.308243",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"type",
|
||||||
|
"contact"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "type",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Type",
|
||||||
|
"options": "Website\nEmail\nPhone",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "contact",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Contact",
|
||||||
|
"reqd": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"istable": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2026-04-14 12:50:25.814560",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "Core",
|
||||||
|
"name": "Security Settings Contact",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"rows_threshold_for_grid_search": 20,
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Copyright (c) 2026, Frappe Technologies and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class SecuritySettingsContact(Document):
|
||||||
|
# begin: auto-generated types
|
||||||
|
# This code is auto-generated. Do not modify anything in this block.
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frappe.types import DF
|
||||||
|
|
||||||
|
contact: DF.Data
|
||||||
|
parent: DF.Data
|
||||||
|
parentfield: DF.Data
|
||||||
|
parenttype: DF.Data
|
||||||
|
type: DF.Literal["Website", "Email", "Phone"]
|
||||||
|
# end: auto-generated types
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"creation": "2026-04-11 12:53:09.006649",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"language"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "language",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Language",
|
||||||
|
"options": "Language",
|
||||||
|
"reqd": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"istable": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2026-04-14 12:50:44.554462",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "Core",
|
||||||
|
"name": "Security Settings Language",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"rows_threshold_for_grid_search": 20,
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Copyright (c) 2026, Frappe Technologies and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class SecuritySettingsLanguage(Document):
|
||||||
|
# begin: auto-generated types
|
||||||
|
# This code is auto-generated. Do not modify anything in this block.
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frappe.types import DF
|
||||||
|
|
||||||
|
language: DF.Link
|
||||||
|
parent: DF.Data
|
||||||
|
parentfield: DF.Data
|
||||||
|
parenttype: DF.Data
|
||||||
|
# end: auto-generated types
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
@ -168,9 +168,9 @@ def queue_submission(doc: Document, action: str, alert: bool = True):
|
||||||
"Submission Queue", {"ref_doctype": doc.doctype, "ref_docname": doc.name, "status": "Queued"}
|
"Submission Queue", {"ref_doctype": doc.doctype, "ref_docname": doc.name, "status": "Queued"}
|
||||||
):
|
):
|
||||||
frappe.msgprint(
|
frappe.msgprint(
|
||||||
_(
|
_("This document has already been queued for {0}. You can track the progress over {1}.").format(
|
||||||
"This document has already been queued for submission. You can track the progress over {0}."
|
action, f"<a href='/desk/submission-queue/{existing_queue}'><b>here</b></a>"
|
||||||
).format(f"<a href='/desk/submission-queue/{existing_queue}'><b>here</b></a>"),
|
),
|
||||||
indicator="orange",
|
indicator="orange",
|
||||||
alert=True,
|
alert=True,
|
||||||
)
|
)
|
||||||
|
|
@ -183,8 +183,8 @@ def queue_submission(doc: Document, action: str, alert: bool = True):
|
||||||
|
|
||||||
if alert:
|
if alert:
|
||||||
frappe.msgprint(
|
frappe.msgprint(
|
||||||
_("Queued for Submission. You can track the progress over {0}.").format(
|
_("Queued for {0}. You can track the progress over {1}.").format(
|
||||||
f"<a href='/desk/submission-queue/{queue.name}'><b>here</b></a>"
|
action, f"<a href='/desk/submission-queue/{queue.name}'><b>here</b></a>"
|
||||||
),
|
),
|
||||||
indicator="green",
|
indicator="green",
|
||||||
alert=True,
|
alert=True,
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,71 @@ class TestSubmissionQueue(IntegrationTestCase):
|
||||||
job = self.queue.fetch_job(submission_queue.job_id)
|
job = self.queue.fetch_job(submission_queue.job_id)
|
||||||
# Test completion
|
# Test completion
|
||||||
self.check_status(job, status="finished")
|
self.check_status(job, status="finished")
|
||||||
|
|
||||||
|
def test_cancel_operation(self):
|
||||||
|
from frappe.core.doctype.doctype.test_doctype import new_doctype
|
||||||
|
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
|
||||||
|
|
||||||
|
if not frappe.db.table_exists("Test Submission Queue", cached=False):
|
||||||
|
doc = new_doctype("Test Submission Queue", is_submittable=True, queue_in_background=True)
|
||||||
|
doc.insert()
|
||||||
|
|
||||||
|
d = frappe.new_doc("Test Submission Queue")
|
||||||
|
d.update({"some_fieldname": "Random"})
|
||||||
|
d.insert()
|
||||||
|
d.submit()
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
self.assertEqual(d.docstatus, 1)
|
||||||
|
|
||||||
|
queue_submission(d, "Cancel")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
time.sleep(4)
|
||||||
|
submission_queue = frappe.get_last_doc("Submission Queue")
|
||||||
|
|
||||||
|
job = self.queue.fetch_job(submission_queue.job_id)
|
||||||
|
self.check_status(job, status="finished")
|
||||||
|
|
||||||
|
d.reload()
|
||||||
|
self.assertEqual(d.docstatus, 2)
|
||||||
|
|
||||||
|
def test_cancel_on_cancelled_doc(self):
|
||||||
|
from frappe.core.doctype.doctype.test_doctype import new_doctype
|
||||||
|
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
|
||||||
|
|
||||||
|
if not frappe.db.table_exists("Test Submission Queue", cached=False):
|
||||||
|
doc = new_doctype("Test Submission Queue", is_submittable=True, queue_in_background=True)
|
||||||
|
doc.insert()
|
||||||
|
|
||||||
|
d = frappe.new_doc("Test Submission Queue")
|
||||||
|
d.update({"some_fieldname": "Random"})
|
||||||
|
d.insert()
|
||||||
|
d.submit()
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
existing = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Submission Queue",
|
||||||
|
"ref_doctype": d.doctype,
|
||||||
|
"ref_docname": d.name,
|
||||||
|
"status": "Queued",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
existing.insert(d, "Cancel")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
initial_count = frappe.db.count(
|
||||||
|
"Submission Queue", {"ref_doctype": d.doctype, "ref_docname": d.name, "status": "Queued"}
|
||||||
|
)
|
||||||
|
|
||||||
|
queue_submission(d, "Cancel")
|
||||||
|
|
||||||
|
final_count = frappe.db.count(
|
||||||
|
"Submission Queue", {"ref_doctype": d.doctype, "ref_docname": d.name, "status": "Queued"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(initial_count, final_count)
|
||||||
|
|
||||||
|
existing.delete(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ frappe.ui.form.on("System Settings", {
|
||||||
|
|
||||||
frappe.xcall("frappe.apps.get_apps").then((r) => {
|
frappe.xcall("frappe.apps.get_apps").then((r) => {
|
||||||
let apps = r?.map((r) => r.name) || [];
|
let apps = r?.map((r) => r.name) || [];
|
||||||
frm.set_df_property("default_app", "options", [" ", ...apps]);
|
frm.set_df_property("default_app", "options", ["", ...apps]);
|
||||||
});
|
});
|
||||||
|
|
||||||
frm.trigger("set_rounding_method_options");
|
frm.trigger("set_rounding_method_options");
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,8 @@
|
||||||
"enable_telemetry",
|
"enable_telemetry",
|
||||||
"search_section",
|
"search_section",
|
||||||
"link_field_results_limit",
|
"link_field_results_limit",
|
||||||
|
"column_break_nebx",
|
||||||
|
"allow_clearing_link_fields",
|
||||||
"api_logging_section",
|
"api_logging_section",
|
||||||
"log_api_requests"
|
"log_api_requests"
|
||||||
],
|
],
|
||||||
|
|
@ -783,13 +785,23 @@
|
||||||
"fieldname": "only_allow_system_managers_to_upload_public_files",
|
"fieldname": "only_allow_system_managers_to_upload_public_files",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Only allow System Managers to upload public files"
|
"label": "Only allow System Managers to upload public files"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_nebx",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"description": "Adds a clear (\u00d7) button to Link fields, allowing users to quickly remove the selected value.",
|
||||||
|
"fieldname": "allow_clearing_link_fields",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Allow Clearing Link Fields"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"hide_toolbar": 1,
|
|
||||||
"icon": "fa fa-cog",
|
"icon": "fa fa-cog",
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-02-24 14:27:04.763075",
|
"modified": "2026-04-14 16:26:19.634212",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Core",
|
"module": "Core",
|
||||||
"name": "System Settings",
|
"name": "System Settings",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class SystemSettings(Document):
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from frappe.types import DF
|
from frappe.types import DF
|
||||||
|
|
||||||
|
allow_clearing_link_fields: DF.Check
|
||||||
allow_consecutive_login_attempts: DF.Int
|
allow_consecutive_login_attempts: DF.Int
|
||||||
allow_error_traceback: DF.Check
|
allow_error_traceback: DF.Check
|
||||||
allow_guests_to_upload_files: DF.Check
|
allow_guests_to_upload_files: DF.Check
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,20 @@ class TestTranslation(IntegrationTestCase):
|
||||||
clear_cache()
|
clear_cache()
|
||||||
|
|
||||||
def test_doctype(self):
|
def test_doctype(self):
|
||||||
translation_data = get_translation_data()
|
doctype = "Translation"
|
||||||
for lang, (source_string, new_translation) in translation_data.items():
|
meta = frappe.get_meta(doctype)
|
||||||
|
source_string = meta.get_label("translated_text")
|
||||||
|
|
||||||
|
for lang in ["de", "bs", "zh", "hr", "en", "sv"]:
|
||||||
frappe.local.lang = lang
|
frappe.local.lang = lang
|
||||||
original_translation = _(source_string)
|
original_translation = _(source_string, context=doctype)
|
||||||
|
new_translation = f"{original_translation} Customized"
|
||||||
|
|
||||||
docname = create_translation(lang, source_string, new_translation)
|
docname = create_translation(lang, source_string, new_translation, context=doctype)
|
||||||
self.assertEqual(_(source_string), new_translation)
|
self.assertEqual(_(source_string, context=doctype), new_translation)
|
||||||
|
|
||||||
frappe.delete_doc("Translation", docname)
|
frappe.delete_doc(doctype, docname)
|
||||||
self.assertEqual(_(source_string), original_translation)
|
self.assertEqual(_(source_string, context=doctype), original_translation)
|
||||||
|
|
||||||
def test_parent_language(self):
|
def test_parent_language(self):
|
||||||
data = {
|
data = {
|
||||||
|
|
@ -60,37 +64,54 @@ class TestTranslation(IntegrationTestCase):
|
||||||
source = "User"
|
source = "User"
|
||||||
self.assertNotEqual(_(source, lang="de"), _(source, lang="es"))
|
self.assertNotEqual(_(source, lang="de"), _(source, lang="es"))
|
||||||
|
|
||||||
def test_html_content_data_translation(self):
|
def test_html_content_translation(self):
|
||||||
# ruff: noqa: RUF001
|
|
||||||
source = """
|
source = """
|
||||||
<span style="color: rgb(51, 51, 51); font-family: "Amazon Ember", Arial, sans-serif; font-size:
|
To add dynamic subject, use jinja tags like
|
||||||
small;">MacBook Air lasts up to an incredible 12 hours between charges. So from your morning coffee to
|
<div><pre><code>{{ doc.name }} Billed</code></pre></div>
|
||||||
your evening commute, you can work unplugged. When it’s time to kick back and relax,
|
""".strip()
|
||||||
you can get up to 12 hours of iTunes movie playback. And with up to 30 days of standby time,
|
|
||||||
you can go away for weeks and pick up where you left off.Whatever the task,
|
|
||||||
fifth-generation Intel Core i5 and i7 processors with Intel HD Graphics 6000 are up to it.</span><br>
|
|
||||||
"""
|
|
||||||
|
|
||||||
target = """
|
target = """
|
||||||
MacBook Air dura hasta 12 horas increíbles entre cargas. Por lo tanto,
|
Um einen dynamischen Betreff hinzuzufügen, verwenden Sie Jinja-Tags wie
|
||||||
desde el café de la mañana hasta el viaje nocturno, puede trabajar desconectado.
|
<div><pre><code>{{ doc.name }} Abgerechnet</code></pre></div>
|
||||||
Cuando es hora de descansar y relajarse, puede obtener hasta 12 horas de reproducción de películas de iTunes.
|
""".strip()
|
||||||
Y con hasta 30 días de tiempo de espera, puede irse por semanas y continuar donde lo dejó. Sea cual sea la tarea,
|
|
||||||
los procesadores Intel Core i5 e i7 de quinta generación con Intel HD Graphics 6000 son capaces de hacerlo.
|
|
||||||
"""
|
|
||||||
|
|
||||||
create_translation("es", source, target)
|
frappe.local.lang = "de"
|
||||||
|
|
||||||
source = """
|
self.assertEqual(_(source), source)
|
||||||
<span style="font-family: "Amazon Ember", Arial, sans-serif; font-size:
|
|
||||||
small; color: rgb(51, 51, 51);">MacBook Air lasts up to an incredible 12 hours between charges. So from your morning coffee to
|
|
||||||
your evening commute, you can work unplugged. When it’s time to kick back and relax,
|
|
||||||
you can get up to 12 hours of iTunes movie playback. And with up to 30 days of standby time,
|
|
||||||
you can go away for weeks and pick up where you left off.Whatever the task,
|
|
||||||
fifth-generation Intel Core i5 and i7 processors with Intel HD Graphics 6000 are up to it.</span><br>
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.assertTrue(_(source), target)
|
create_translation("de", source, target)
|
||||||
|
|
||||||
|
self.assertEqual(_(source), target)
|
||||||
|
|
||||||
|
def test_translated_html_is_sanitized(self):
|
||||||
|
source = "Translation with HTML"
|
||||||
|
target = """
|
||||||
|
<span style="color:red" onclick="alert('xss')">Hallo</span>
|
||||||
|
<script>alert("xss")</script>
|
||||||
|
<iframe src="https://example.com"></iframe>
|
||||||
|
<div>Ok</div>
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
docname = create_translation("de", source, target)
|
||||||
|
translated_text = frappe.db.get_value("Translation", docname, "translated_text")
|
||||||
|
|
||||||
|
self.assertIn('<span style="color:red">Hallo</span>', translated_text)
|
||||||
|
self.assertIn("<div>Ok</div>", translated_text)
|
||||||
|
self.assertNotIn("onclick", translated_text)
|
||||||
|
self.assertNotIn("<script", translated_text)
|
||||||
|
self.assertNotIn('alert("xss")', translated_text)
|
||||||
|
self.assertNotIn("<iframe", translated_text)
|
||||||
|
self.assertNotIn("example.com", translated_text)
|
||||||
|
|
||||||
|
frappe.local.lang = "de"
|
||||||
|
self.assertEqual(_(source), translated_text)
|
||||||
|
|
||||||
|
def test_plain_text_translation_with_angle_brackets_is_unchanged(self):
|
||||||
|
source = "Comparison"
|
||||||
|
target = "1 < 2 and 3 > 2"
|
||||||
|
|
||||||
|
docname = create_translation("de", source, target)
|
||||||
|
|
||||||
|
self.assertEqual(frappe.db.get_value("Translation", docname, "translated_text"), target)
|
||||||
|
|
||||||
def test_html_message_translations(self):
|
def test_html_message_translations(self):
|
||||||
"""Test fallback for messages w/ HTML Tags"""
|
"""Test fallback for messages w/ HTML Tags"""
|
||||||
|
|
@ -100,27 +121,12 @@ class TestTranslation(IntegrationTestCase):
|
||||||
self.assertEqual(_(message, lang="zh"), translated_message)
|
self.assertEqual(_(message, lang="zh"), translated_message)
|
||||||
|
|
||||||
|
|
||||||
def get_translation_data():
|
def create_translation(lang, source_string, new_translation, context=None) -> str:
|
||||||
html_source_data = """<font color="#848484" face="arial, tahoma, verdana, sans-serif">
|
|
||||||
<span style="font-size: 11px; line-height: 16.9px;">Test Data</span></font>"""
|
|
||||||
html_translated_data = """<font color="#848484" face="arial, tahoma, verdana, sans-serif">
|
|
||||||
<span style="font-size: 11px; line-height: 16.9px;"> testituloksia </span></font>"""
|
|
||||||
|
|
||||||
return {
|
|
||||||
"hr": ["Test data", "Testdaten"],
|
|
||||||
"ms": ["Test Data", "ujian Data"],
|
|
||||||
"et": ["Test Data", "testandmed"],
|
|
||||||
"es": ["Test Data", "datos de prueba"],
|
|
||||||
"en": ["Quotation", "Tax Invoice"],
|
|
||||||
"fi": [html_source_data, html_translated_data],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def create_translation(lang, source_string, new_translation) -> str:
|
|
||||||
doc = frappe.new_doc("Translation")
|
doc = frappe.new_doc("Translation")
|
||||||
doc.language = lang
|
doc.language = lang
|
||||||
doc.source_text = source_string
|
doc.source_text = source_string
|
||||||
doc.translated_text = new_translation
|
doc.translated_text = new_translation
|
||||||
|
doc.context = context
|
||||||
doc.save()
|
doc.save()
|
||||||
|
|
||||||
return doc.name
|
return doc.name
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
# Copyright (c) 2015, Frappe Technologies and contributors
|
# Copyright (c) 2015, Frappe Technologies and contributors
|
||||||
# License: MIT. See LICENSE
|
# License: MIT. See LICENSE
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.translate import MERGED_TRANSLATION_KEY, USER_TRANSLATION_KEY
|
from frappe.translate import MERGED_TRANSLATION_KEY, USER_TRANSLATION_KEY, change_translation_version
|
||||||
from frappe.utils import is_html, strip_html_tags
|
from frappe.utils import sanitize_html
|
||||||
|
|
||||||
|
|
||||||
class Translation(Document):
|
class Translation(Document):
|
||||||
|
|
@ -28,11 +26,7 @@ class Translation(Document):
|
||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
if is_html(self.source_text):
|
self.translated_text = sanitize_html(self.translated_text)
|
||||||
self.remove_html_from_source()
|
|
||||||
|
|
||||||
def remove_html_from_source(self):
|
|
||||||
self.source_text = strip_html_tags(self.source_text).strip()
|
|
||||||
|
|
||||||
def on_update(self):
|
def on_update(self):
|
||||||
clear_user_translation_cache(self.language)
|
clear_user_translation_cache(self.language)
|
||||||
|
|
@ -46,3 +40,4 @@ class Translation(Document):
|
||||||
def clear_user_translation_cache(lang):
|
def clear_user_translation_cache(lang):
|
||||||
frappe.cache.hdel(USER_TRANSLATION_KEY, lang)
|
frappe.cache.hdel(USER_TRANSLATION_KEY, lang)
|
||||||
frappe.cache.hdel(MERGED_TRANSLATION_KEY, lang)
|
frappe.cache.hdel(MERGED_TRANSLATION_KEY, lang)
|
||||||
|
change_translation_version()
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class TestUser(IntegrationTestCase):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reset_password(user) -> str:
|
def reset_password(user) -> str:
|
||||||
link = user.reset_password()
|
link = user._reset_password()
|
||||||
return parse_qs(urlparse(link).query)["key"][0]
|
return parse_qs(urlparse(link).query)["key"][0]
|
||||||
|
|
||||||
def test_user_type(self):
|
def test_user_type(self):
|
||||||
|
|
@ -292,7 +292,7 @@ class TestUser(IntegrationTestCase):
|
||||||
c = FrappeClient(url)
|
c = FrappeClient(url)
|
||||||
res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
|
res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
|
||||||
res2 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
|
res2 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
|
||||||
self.assertEqual(res1.status_code, 404)
|
self.assertEqual(res1.status_code, 200)
|
||||||
self.assertEqual(res2.status_code, 429)
|
self.assertEqual(res2.status_code, 429)
|
||||||
|
|
||||||
def test_user_rename(self):
|
def test_user_rename(self):
|
||||||
|
|
@ -415,6 +415,12 @@ class TestUser(IntegrationTestCase):
|
||||||
|
|
||||||
# test API endpoint
|
# test API endpoint
|
||||||
with patch.object(user_module.frappe, "sendmail") as sendmail:
|
with patch.object(user_module.frappe, "sendmail") as sendmail:
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
mock_q = MagicMock()
|
||||||
|
mock_q.name = "test-email-queue-name"
|
||||||
|
mock_q.message = "Subject: Test\n\nDear User, here is your link"
|
||||||
|
sendmail.return_value = mock_q
|
||||||
frappe.clear_messages()
|
frappe.clear_messages()
|
||||||
test_user = frappe.get_doc("User", "test2@example.com")
|
test_user = frappe.get_doc("User", "test2@example.com")
|
||||||
self.assertEqual(reset_password(user="test2@example.com"), None)
|
self.assertEqual(reset_password(user="test2@example.com"), None)
|
||||||
|
|
@ -425,15 +431,28 @@ class TestUser(IntegrationTestCase):
|
||||||
update_password(old_password, old_password=new_password)
|
update_password(old_password, old_password=new_password)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
frappe.message_log[0].get("message"),
|
frappe.message_log[0].get("message"),
|
||||||
f"Password reset instructions have been sent to {test_user.full_name}'s email",
|
"If this email is registered with us, we have sent password reset instructions to it. Please check your inbox.",
|
||||||
)
|
)
|
||||||
|
|
||||||
sendmail.assert_called_once()
|
sendmail.assert_called_once()
|
||||||
self.assertEqual(sendmail.call_args[1]["recipients"], "test2@example.com")
|
self.assertEqual(sendmail.call_args[1]["recipients"], "test2@example.com")
|
||||||
|
|
||||||
self.assertEqual(reset_password(user="test2@example.com"), None)
|
# Constant-response guarantee: every path — existing user, Administrator,
|
||||||
self.assertEqual(reset_password(user="Administrator"), "not allowed")
|
# and non-existent user — must return None AND enqueue the same generic
|
||||||
self.assertEqual(reset_password(user="random"), "not found")
|
# message, so callers cannot distinguish between them.
|
||||||
|
_GENERIC_MSG = "If this email is registered with us, we have sent password reset instructions to it. Please check your inbox."
|
||||||
|
|
||||||
|
frappe.clear_messages()
|
||||||
|
self.assertIsNone(reset_password(user="test2@example.com"))
|
||||||
|
self.assertEqual(frappe.message_log[0].get("message"), _GENERIC_MSG)
|
||||||
|
|
||||||
|
frappe.clear_messages()
|
||||||
|
self.assertIsNone(reset_password(user="Administrator"))
|
||||||
|
self.assertEqual(frappe.message_log[0].get("message"), _GENERIC_MSG)
|
||||||
|
|
||||||
|
frappe.clear_messages()
|
||||||
|
self.assertIsNone(reset_password(user="random"))
|
||||||
|
self.assertEqual(frappe.message_log[0].get("message"), _GENERIC_MSG)
|
||||||
|
|
||||||
def test_user_onload_modules(self):
|
def test_user_onload_modules(self):
|
||||||
from frappe.desk.form.load import getdoc
|
from frappe.desk.form.load import getdoc
|
||||||
|
|
@ -447,6 +466,21 @@ class TestUser(IntegrationTestCase):
|
||||||
sorted(m.get("module_name") for m in get_modules_from_all_apps()),
|
sorted(m.get("module_name") for m in get_modules_from_all_apps()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_default_app(self):
|
||||||
|
from frappe.apps import get_default_path
|
||||||
|
|
||||||
|
with test_user(roles=["System Manager"]) as user:
|
||||||
|
user.default_app = "next_erp"
|
||||||
|
user.save()
|
||||||
|
self.assertFalse(user.default_app)
|
||||||
|
|
||||||
|
frappe.set_user(user.name)
|
||||||
|
user.db_set("default_app", "next_erp")
|
||||||
|
user.reload()
|
||||||
|
self.assertTrue(user.default_app)
|
||||||
|
|
||||||
|
get_default_path() # defaults will also trigger hooks logic
|
||||||
|
|
||||||
@IntegrationTestCase.change_settings("System Settings", reset_password_link_expiry_duration=1)
|
@IntegrationTestCase.change_settings("System Settings", reset_password_link_expiry_duration=1)
|
||||||
def test_reset_password_link_expiry(self):
|
def test_reset_password_link_expiry(self):
|
||||||
new_password = "new_password"
|
new_password = "new_password"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ frappe.ui.form.on("User", {
|
||||||
frm.set_query("default_workspace", () => {
|
frm.set_query("default_workspace", () => {
|
||||||
return {
|
return {
|
||||||
filters: {
|
filters: {
|
||||||
for_user: ["in", [null, frappe.session.user]],
|
for_user: ["in", ["", frappe.session.user]],
|
||||||
title: ["!=", "Welcome Workspace"],
|
title: ["!=", "Welcome Workspace"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -69,6 +69,8 @@ frappe.ui.form.on("User", {
|
||||||
frm.roles_editor.reset();
|
frm.roles_editor.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
frm.fields_dict.new_password?.$input?.attr("autocomplete", "new-password");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
frm.can_edit_roles &&
|
frm.can_edit_roles &&
|
||||||
!frm.is_new() &&
|
!frm.is_new() &&
|
||||||
|
|
@ -108,7 +110,7 @@ frappe.ui.form.on("User", {
|
||||||
|
|
||||||
frappe.xcall("frappe.apps.get_apps").then((r) => {
|
frappe.xcall("frappe.apps.get_apps").then((r) => {
|
||||||
let apps = r?.map((r) => r.name) || [];
|
let apps = r?.map((r) => r.name) || [];
|
||||||
frm.set_df_property("default_app", "options", [" ", ...apps]);
|
frm.set_df_property("default_app", "options", ["", ...apps]);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (frm.is_new()) {
|
if (frm.is_new()) {
|
||||||
|
|
|
||||||
|
|
@ -855,7 +855,7 @@
|
||||||
"options": "User Session Display"
|
"options": "User Session Display"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "1",
|
||||||
"fieldname": "form_navigation_buttons",
|
"fieldname": "form_navigation_buttons",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Show navigation buttons"
|
"label": "Show navigation buttons"
|
||||||
|
|
@ -924,8 +924,8 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2026-03-24 21:30:57.199337",
|
"modified": "2026-04-28 21:59:59.160099",
|
||||||
"modified_by": "admin@seitimegames.com",
|
"modified_by": "Administrator",
|
||||||
"module": "Core",
|
"module": "Core",
|
||||||
"name": "User",
|
"name": "User",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
# License: MIT. See LICENSE
|
# License: MIT. See LICENSE
|
||||||
|
|
||||||
|
import re
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from functools import cached_property
|
from functools import cached_property, lru_cache
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
|
@ -233,6 +234,7 @@ class User(Document):
|
||||||
self.check_enable_disable()
|
self.check_enable_disable()
|
||||||
self.ensure_unique_roles()
|
self.ensure_unique_roles()
|
||||||
self.ensure_unique_role_profiles()
|
self.ensure_unique_role_profiles()
|
||||||
|
self.sync_role_profile_name()
|
||||||
self.remove_all_roles_for_guest()
|
self.remove_all_roles_for_guest()
|
||||||
self.validate_username()
|
self.validate_username()
|
||||||
self.remove_disabled_roles()
|
self.remove_disabled_roles()
|
||||||
|
|
@ -248,6 +250,9 @@ class User(Document):
|
||||||
if self.language == "Loading...":
|
if self.language == "Loading...":
|
||||||
self.language = None
|
self.language = None
|
||||||
|
|
||||||
|
if self.default_app and self.default_app not in frappe.get_installed_apps():
|
||||||
|
self.default_app = ""
|
||||||
|
|
||||||
if (self.name not in ["Administrator", "Guest"]) and (not self.get_social_login_userid("frappe")):
|
if (self.name not in ["Administrator", "Guest"]) and (not self.get_social_login_userid("frappe")):
|
||||||
self.set_social_login_userid("frappe", frappe.generate_hash(length=39))
|
self.set_social_login_userid("frappe", frappe.generate_hash(length=39))
|
||||||
|
|
||||||
|
|
@ -278,11 +283,11 @@ class User(Document):
|
||||||
def move_role_profile_name_to_role_profiles(self):
|
def move_role_profile_name_to_role_profiles(self):
|
||||||
"""This handles old role_profile_name field if programatically set.
|
"""This handles old role_profile_name field if programatically set.
|
||||||
|
|
||||||
This behaviour will be remoed in future versions."""
|
This behaviour will be removed in future versions."""
|
||||||
if not self.role_profile_name:
|
if not self.role_profile_name:
|
||||||
return
|
return
|
||||||
|
|
||||||
current_role_profiles = [r.role_profile for r in self.role_profiles]
|
current_role_profiles = {r.role_profile for r in self.role_profiles}
|
||||||
if self.role_profile_name in current_role_profiles:
|
if self.role_profile_name in current_role_profiles:
|
||||||
self.role_profile_name = None
|
self.role_profile_name = None
|
||||||
return
|
return
|
||||||
|
|
@ -297,6 +302,10 @@ class User(Document):
|
||||||
self.append("role_profiles", {"role_profile": self.role_profile_name})
|
self.append("role_profiles", {"role_profile": self.role_profile_name})
|
||||||
self.role_profile_name = None
|
self.role_profile_name = None
|
||||||
|
|
||||||
|
def sync_role_profile_name(self):
|
||||||
|
"""Keep deprecated role_profile_name in sync for list view display."""
|
||||||
|
self.role_profile_name = self.role_profiles[0].role_profile if self.role_profiles else None
|
||||||
|
|
||||||
def validate_allowed_modules(self):
|
def validate_allowed_modules(self):
|
||||||
if self.module_profile:
|
if self.module_profile:
|
||||||
module_profile = frappe.get_doc("Module Profile", self.module_profile)
|
module_profile = frappe.get_doc("Module Profile", self.module_profile)
|
||||||
|
|
@ -360,7 +369,7 @@ class User(Document):
|
||||||
def clean_name(self):
|
def clean_name(self):
|
||||||
for field in ("first_name", "middle_name", "last_name"):
|
for field in ("first_name", "middle_name", "last_name"):
|
||||||
if field_value := self.get(field):
|
if field_value := self.get(field):
|
||||||
self.set(field, sanitize_html(field_value, always_sanitize=True))
|
self.set(field, sanitize_html(field_value, always_sanitize=True, disallowed_tags="*"))
|
||||||
|
|
||||||
def set_full_name(self):
|
def set_full_name(self):
|
||||||
self.full_name = " ".join(p for p in [self.first_name, self.middle_name, self.last_name] if p)
|
self.full_name = " ".join(p for p in [self.first_name, self.middle_name, self.last_name] if p)
|
||||||
|
|
@ -378,9 +387,23 @@ class User(Document):
|
||||||
toggle_notifications(self.name, enable=cint(self.enabled), ignore_permissions=True)
|
toggle_notifications(self.name, enable=cint(self.enabled), ignore_permissions=True)
|
||||||
self.disable_email_fields_if_user_disabled()
|
self.disable_email_fields_if_user_disabled()
|
||||||
|
|
||||||
def email_new_password(self, new_password=None):
|
def set_new_password(self, new_password=None):
|
||||||
|
"""Set New Password for user"""
|
||||||
if new_password and not self.flags.in_insert:
|
if new_password and not self.flags.in_insert:
|
||||||
_update_password(user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions)
|
_update_password(user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions)
|
||||||
|
outgoing_email_exists = frappe.db.exists(
|
||||||
|
"Email Account", {"default_outgoing": 1, "awaiting_password": 0}
|
||||||
|
)
|
||||||
|
if outgoing_email_exists:
|
||||||
|
email_message = _(
|
||||||
|
"Your password has been changed and you might have been logged out of all systems.<br>Please contact the Administrator for further assistance."
|
||||||
|
)
|
||||||
|
user_email = frappe.db.get_value("User", self.name, "email")
|
||||||
|
frappe.sendmail(
|
||||||
|
recipients=[user_email],
|
||||||
|
subject=_("Security Alert: Your password has been changed."),
|
||||||
|
content=email_message,
|
||||||
|
)
|
||||||
|
|
||||||
def set_system_user(self):
|
def set_system_user(self):
|
||||||
"""For the standard users like admin and guest, the user type is fixed."""
|
"""For the standard users like admin and guest, the user type is fixed."""
|
||||||
|
|
@ -435,25 +458,26 @@ class User(Document):
|
||||||
def send_password_notification(self, new_password):
|
def send_password_notification(self, new_password):
|
||||||
try:
|
try:
|
||||||
if self.flags.in_insert:
|
if self.flags.in_insert:
|
||||||
if self.name not in STANDARD_USERS:
|
if self.name in STANDARD_USERS:
|
||||||
if new_password:
|
return
|
||||||
# new password given, no email required
|
if new_password:
|
||||||
_update_password(
|
# new password given, no email required
|
||||||
user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions
|
_update_password(
|
||||||
)
|
user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not self.flags.no_welcome_mail
|
not self.flags.no_welcome_mail
|
||||||
and cint(self.send_welcome_email)
|
and cint(self.send_welcome_email)
|
||||||
and not self.flags.email_sent
|
and not self.flags.email_sent
|
||||||
):
|
):
|
||||||
self.send_welcome_mail_to_user()
|
self.send_welcome_mail_to_user()
|
||||||
self.flags.email_sent = 1
|
self.flags.email_sent = 1
|
||||||
if frappe.session.user != "Guest":
|
if frappe.session.user != "Guest":
|
||||||
msgprint(_("Welcome email sent"))
|
msgprint(_("Welcome email sent"))
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
self.email_new_password(new_password)
|
self.set_new_password(new_password)
|
||||||
|
|
||||||
except frappe.OutgoingEmailError:
|
except frappe.OutgoingEmailError:
|
||||||
frappe.clear_last_message()
|
frappe.clear_last_message()
|
||||||
|
|
@ -467,7 +491,7 @@ class User(Document):
|
||||||
def validate_reset_password(self):
|
def validate_reset_password(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def reset_password(self, send_email=False, password_expired=False):
|
def _reset_password(self, send_email=False, password_expired=False):
|
||||||
from frappe.utils import get_url
|
from frappe.utils import get_url
|
||||||
|
|
||||||
key = frappe.generate_hash()
|
key = frappe.generate_hash()
|
||||||
|
|
@ -492,18 +516,24 @@ class User(Document):
|
||||||
def password_reset_mail(self, link):
|
def password_reset_mail(self, link):
|
||||||
reset_password_template = frappe.db.get_system_setting("reset_password_template")
|
reset_password_template = frappe.db.get_system_setting("reset_password_template")
|
||||||
|
|
||||||
self.send_login_mail(
|
q = self.send_login_mail(
|
||||||
_("Password Reset"),
|
_("Password Reset"),
|
||||||
"password_reset",
|
"password_reset",
|
||||||
{"link": link},
|
{"link": link},
|
||||||
now=True,
|
now=True,
|
||||||
custom_template=reset_password_template,
|
custom_template=reset_password_template,
|
||||||
)
|
)
|
||||||
|
if q:
|
||||||
|
raw_message = q.message
|
||||||
|
parts = re.split(r"(?i)Dear", raw_message, maxsplit=1)
|
||||||
|
if len(parts) > 1:
|
||||||
|
redacted_message = parts[0] + "[THE FOLLOWING CONTENT HAS BEEN REDACTED FOR SECURITY REASONS]"
|
||||||
|
frappe.db.set_value("Email Queue", q.name, "message", redacted_message, update_modified=False)
|
||||||
|
|
||||||
def send_welcome_mail_to_user(self):
|
def send_welcome_mail_to_user(self):
|
||||||
from frappe.utils import get_url
|
from frappe.utils import get_url
|
||||||
|
|
||||||
link = self.reset_password()
|
link = self._reset_password()
|
||||||
subject = None
|
subject = None
|
||||||
method = frappe.get_hooks("welcome_email")
|
method = frappe.get_hooks("welcome_email")
|
||||||
if method:
|
if method:
|
||||||
|
|
@ -517,7 +547,7 @@ class User(Document):
|
||||||
|
|
||||||
welcome_email_template = frappe.db.get_system_setting("welcome_email_template")
|
welcome_email_template = frappe.db.get_system_setting("welcome_email_template")
|
||||||
|
|
||||||
self.send_login_mail(
|
q = self.send_login_mail(
|
||||||
subject,
|
subject,
|
||||||
"new_user",
|
"new_user",
|
||||||
dict(
|
dict(
|
||||||
|
|
@ -526,6 +556,12 @@ class User(Document):
|
||||||
),
|
),
|
||||||
custom_template=welcome_email_template,
|
custom_template=welcome_email_template,
|
||||||
)
|
)
|
||||||
|
if q:
|
||||||
|
raw_message = q.message
|
||||||
|
parts = re.split(r"(?i)Hello", raw_message, maxsplit=1)
|
||||||
|
if len(parts) > 1:
|
||||||
|
redacted_message = parts[0] + "[THE FOLLOWING CONTENT HAS BEEN REDACTED FOR SECURITY REASONS]"
|
||||||
|
frappe.db.set_value("Email Queue", q.name, "message", redacted_message, update_modified=False)
|
||||||
|
|
||||||
def send_login_mail(self, subject, template, add_args, now=None, custom_template=None):
|
def send_login_mail(self, subject, template, add_args, now=None, custom_template=None):
|
||||||
"""send mail with login details"""
|
"""send mail with login details"""
|
||||||
|
|
@ -557,7 +593,7 @@ class User(Document):
|
||||||
subject = email_template.get("subject")
|
subject = email_template.get("subject")
|
||||||
content = email_template.get("message")
|
content = email_template.get("message")
|
||||||
|
|
||||||
frappe.sendmail(
|
return frappe.sendmail(
|
||||||
recipients=self.email,
|
recipients=self.email,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
|
|
@ -619,18 +655,16 @@ class User(Document):
|
||||||
frappe.db.delete("List Filter", {"for_user": self.name})
|
frappe.db.delete("List Filter", {"for_user": self.name})
|
||||||
|
|
||||||
# Remove user from Note's Seen By table
|
# Remove user from Note's Seen By table
|
||||||
seen_notes = frappe.get_all("Note", filters=[["Note Seen By", "user", "=", self.name]], pluck="name")
|
seen_notes = frappe.get_docs("Note", filters=[["Note Seen By", "user", "=", self.name]])
|
||||||
for note_id in seen_notes:
|
for note in seen_notes:
|
||||||
note = frappe.get_doc("Note", note_id)
|
|
||||||
for row in note.seen_by:
|
for row in note.seen_by:
|
||||||
if row.user == self.name:
|
if row.user == self.name:
|
||||||
note.remove(row)
|
note.remove(row)
|
||||||
note.save(ignore_permissions=True)
|
note.save(ignore_permissions=True)
|
||||||
|
|
||||||
# Unlink user from all of its invitation docs
|
# Unlink user from all of its invitation docs
|
||||||
invites = frappe.db.get_all("User Invitation", filters={"email": self.name}, pluck="name")
|
invites = frappe.get_docs("User Invitation", filters={"email": self.name})
|
||||||
for invite in invites:
|
for invite_doc in invites:
|
||||||
invite_doc = frappe.get_doc("User Invitation", invite)
|
|
||||||
invite_doc.user = None
|
invite_doc.user = None
|
||||||
invite_doc.save(ignore_permissions=True)
|
invite_doc.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
@ -865,9 +899,14 @@ class User(Document):
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_timezones():
|
def get_timezones():
|
||||||
import zoneinfo
|
return {"timezones": _get_timezones()}
|
||||||
|
|
||||||
return {"timezones": zoneinfo.available_timezones()}
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _get_timezones():
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
return sorted(pytz.common_timezones)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|
@ -1009,6 +1048,9 @@ def has_email_account(email: str):
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=False)
|
@frappe.whitelist(allow_guest=False)
|
||||||
def get_email_awaiting(user: str):
|
def get_email_awaiting(user: str):
|
||||||
|
if user != frappe.session.user:
|
||||||
|
frappe.has_permission("User", "read", doc=user, throw=True)
|
||||||
|
|
||||||
return frappe.get_all(
|
return frappe.get_all(
|
||||||
"User Email",
|
"User Email",
|
||||||
fields=["email_account", "email_id"],
|
fields=["email_account", "email_id"],
|
||||||
|
|
@ -1128,25 +1170,32 @@ def sign_up(email: str, full_name: str, redirect_to: str) -> tuple[int, str]:
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True, methods=["POST"])
|
@frappe.whitelist(allow_guest=True, methods=["POST"])
|
||||||
@rate_limit(limit=get_password_reset_limit, seconds=60 * 60)
|
@rate_limit(limit=get_password_reset_limit, seconds=60 * 60)
|
||||||
def reset_password(user: str) -> str:
|
def reset_password(user: str) -> None:
|
||||||
|
# Always return the same generic response regardless of whether the user
|
||||||
|
# exists, is disabled, or is restricted. This prevents username enumeration
|
||||||
|
# via different messages or HTTP status codes (CWE-204).
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user: User = frappe.get_doc("User", user)
|
user_doc: User = frappe.get_doc("User", user)
|
||||||
if user.name == "Administrator":
|
if user_doc.name != "Administrator" and user_doc.enabled:
|
||||||
return "not allowed"
|
user_doc.validate_reset_password()
|
||||||
if not user.enabled:
|
user_doc._reset_password(send_email=True)
|
||||||
return "disabled"
|
# For Administrator or disabled users: silently skip — same response below
|
||||||
|
|
||||||
user.validate_reset_password()
|
|
||||||
user.reset_password(send_email=True)
|
|
||||||
|
|
||||||
return frappe.msgprint(
|
|
||||||
msg=_("Password reset instructions have been sent to {}'s email").format(user.full_name),
|
|
||||||
title=_("Password Email Sent"),
|
|
||||||
)
|
|
||||||
except frappe.DoesNotExistError:
|
except frappe.DoesNotExistError:
|
||||||
frappe.local.response["http_status_code"] = 404
|
|
||||||
frappe.clear_messages()
|
frappe.clear_messages()
|
||||||
return "not found"
|
except frappe.OutgoingEmailError:
|
||||||
|
frappe.clear_messages()
|
||||||
|
frappe.log_error(title="Password reset email could not be sent", message=frappe.get_traceback())
|
||||||
|
except Exception:
|
||||||
|
frappe.clear_messages()
|
||||||
|
frappe.log_error(title="Password reset failed unexpectedly", message=frappe.get_traceback())
|
||||||
|
|
||||||
|
frappe.msgprint(
|
||||||
|
msg=_(
|
||||||
|
"If this email is registered with us, we have sent password reset instructions to it. Please check your inbox."
|
||||||
|
),
|
||||||
|
title=_("Password Reset"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
frappe.listview_settings["User"] = {
|
frappe.listview_settings["User"] = {
|
||||||
add_fields: ["enabled", "user_type", "user_image"],
|
add_fields: ["enabled", "user_type", "user_image"],
|
||||||
filters: [["enabled", "=", 1]],
|
filters: [["enabled", "=", 1]],
|
||||||
|
onload(listview) {
|
||||||
|
this.set_default_app_options(listview);
|
||||||
|
},
|
||||||
prepare_data: function (data) {
|
prepare_data: function (data) {
|
||||||
data["user_for_avatar"] = data["name"];
|
data["user_for_avatar"] = data["name"];
|
||||||
},
|
},
|
||||||
|
|
@ -14,6 +17,15 @@ frappe.listview_settings["User"] = {
|
||||||
return [__("Disabled"), "grey", "enabled,=,0"];
|
return [__("Disabled"), "grey", "enabled,=,0"];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
set_default_app_options(listview) {
|
||||||
|
const default_app_field = frappe.meta.get_docfield("User", "default_app");
|
||||||
|
if (!default_app_field) return;
|
||||||
|
|
||||||
|
frappe.xcall("frappe.apps.get_apps").then((r) => {
|
||||||
|
let apps = r?.map((r) => r.name) || [];
|
||||||
|
default_app_field.options = ["", ...apps].join("\n");
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
frappe.help.youtube_id["User"] = "8Slw1hsTmUI";
|
frappe.help.youtube_id["User"] = "8Slw1hsTmUI";
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,7 @@ class UserInvitation(Document):
|
||||||
self._after_insert()
|
self._after_insert()
|
||||||
|
|
||||||
def accept(self, ignore_permissions: bool = False):
|
def accept(self, ignore_permissions: bool = False):
|
||||||
accepted_now = self._accept()
|
self._accept()
|
||||||
if not accepted_now:
|
|
||||||
return
|
|
||||||
user, user_inserted = self._upsert_user(ignore_permissions)
|
user, user_inserted = self._upsert_user(ignore_permissions)
|
||||||
self.save(ignore_permissions)
|
self.save(ignore_permissions)
|
||||||
user.save(ignore_permissions)
|
user.save(ignore_permissions)
|
||||||
|
|
@ -120,7 +118,7 @@ class UserInvitation(Document):
|
||||||
|
|
||||||
def _accept(self):
|
def _accept(self):
|
||||||
if self.status == "Accepted":
|
if self.status == "Accepted":
|
||||||
return False
|
frappe.throw(title=_("Error"), msg=_("Invitation already accepted"))
|
||||||
if self.status == "Expired":
|
if self.status == "Expired":
|
||||||
frappe.throw(title=_("Error"), msg=_("Invitation is expired"))
|
frappe.throw(title=_("Error"), msg=_("Invitation is expired"))
|
||||||
if self.status == "Cancelled":
|
if self.status == "Cancelled":
|
||||||
|
|
@ -128,6 +126,7 @@ class UserInvitation(Document):
|
||||||
self.status = "Accepted"
|
self.status = "Accepted"
|
||||||
self.accepted_at = frappe.utils.now()
|
self.accepted_at = frappe.utils.now()
|
||||||
self.user = self.email
|
self.user = self.email
|
||||||
|
self.key = None
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _upsert_user(self, ignore_permissions: bool = False):
|
def _upsert_user(self, ignore_permissions: bool = False):
|
||||||
|
|
@ -206,12 +205,11 @@ class UserInvitation(Document):
|
||||||
|
|
||||||
def mark_expired_invitations() -> None:
|
def mark_expired_invitations() -> None:
|
||||||
days = 3
|
days = 3
|
||||||
invitations_to_expire = frappe.db.get_all(
|
invitations_to_expire = frappe.get_docs(
|
||||||
"User Invitation",
|
"User Invitation",
|
||||||
filters={"status": "Pending", "creation": ["<", frappe.utils.add_days(frappe.utils.now(), -days)]},
|
filters={"status": "Pending", "creation": ["<", frappe.utils.add_days(frappe.utils.now(), -days)]},
|
||||||
)
|
)
|
||||||
for invitation in invitations_to_expire:
|
for invitation in invitations_to_expire:
|
||||||
invitation = frappe.get_doc("User Invitation", invitation.name)
|
|
||||||
invitation.expire()
|
invitation.expire()
|
||||||
# to avoid losing work in case the job times out without finishing
|
# to avoid losing work in case the job times out without finishing
|
||||||
frappe.db.commit() # nosemgrep
|
frappe.db.commit() # nosemgrep
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,6 @@ def create_user_type(user_type):
|
||||||
if frappe.db.exists("User Type", user_type):
|
if frappe.db.exists("User Type", user_type):
|
||||||
frappe.delete_doc("User Type", user_type)
|
frappe.delete_doc("User Type", user_type)
|
||||||
|
|
||||||
user_type_limit = {frappe.scrub(user_type): 1}
|
|
||||||
update_site_config("user_type_doctype_limit", user_type_limit)
|
|
||||||
|
|
||||||
doc = frappe.get_doc(
|
doc = frappe.get_doc(
|
||||||
{
|
{
|
||||||
"doctype": "User Type",
|
"doctype": "User Type",
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ class UserType(Document):
|
||||||
if self.is_standard:
|
if self.is_standard:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.validate_document_type_limit()
|
|
||||||
self.validate_role()
|
self.validate_role()
|
||||||
self.add_role_permissions_for_user_doctypes()
|
self.add_role_permissions_for_user_doctypes()
|
||||||
self.add_role_permissions_for_select_doctypes()
|
self.add_role_permissions_for_select_doctypes()
|
||||||
|
|
@ -75,37 +74,6 @@ class UserType(Document):
|
||||||
for module in modules:
|
for module in modules:
|
||||||
self.append("user_type_modules", {"module": module})
|
self.append("user_type_modules", {"module": module})
|
||||||
|
|
||||||
def validate_document_type_limit(self):
|
|
||||||
limit = frappe.conf.get("user_type_doctype_limit", {}).get(frappe.scrub(self.name))
|
|
||||||
|
|
||||||
if not limit and frappe.session.user != "Administrator":
|
|
||||||
frappe.throw(
|
|
||||||
_("User does not have permission to create the new {0}").format(frappe.bold(_("User Type"))),
|
|
||||||
title=_("Permission Error"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if limit is None:
|
|
||||||
frappe.msgprint(
|
|
||||||
_("The limit has not set for the user type {0} in the site config file.").format(
|
|
||||||
frappe.bold(self.name)
|
|
||||||
),
|
|
||||||
title=_("Set Limit"),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.user_doctypes and len(self.user_doctypes) > limit:
|
|
||||||
frappe.throw(
|
|
||||||
_("The total number of user document types limit has been crossed."),
|
|
||||||
title=_("User Document Types Limit Exceeded"),
|
|
||||||
)
|
|
||||||
|
|
||||||
custom_doctypes = [row.document_type for row in self.user_doctypes if row.is_custom]
|
|
||||||
if custom_doctypes and len(custom_doctypes) > 3:
|
|
||||||
frappe.throw(
|
|
||||||
_("You can only set the 3 custom doctypes in the Document Types table."),
|
|
||||||
title=_("Custom Document Types Limit Exceeded"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate_role(self):
|
def validate_role(self):
|
||||||
if not self.role:
|
if not self.role:
|
||||||
frappe.throw(_("The field {0} is mandatory").format(frappe.bold(_("Role"))))
|
frappe.throw(_("The field {0} is mandatory").format(frappe.bold(_("Role"))))
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ STANDARD_EXCLUSIONS = [
|
||||||
"*/node_modules/*",
|
"*/node_modules/*",
|
||||||
"*/doctype/*/*_dashboard.py",
|
"*/doctype/*/*_dashboard.py",
|
||||||
"*/patches/*",
|
"*/patches/*",
|
||||||
|
"*/.github/*",
|
||||||
]
|
]
|
||||||
|
|
||||||
# tested via commands' test suite
|
# tested via commands' test suite
|
||||||
|
|
@ -46,6 +47,9 @@ FRAPPE_EXCLUSIONS = [
|
||||||
"*frappe/setup.py",
|
"*frappe/setup.py",
|
||||||
"*/doctype/*/*_dashboard.py",
|
"*/doctype/*/*_dashboard.py",
|
||||||
"*/patches/*",
|
"*/patches/*",
|
||||||
|
"*/frappe/database/postgres/*",
|
||||||
|
"*/.github/helper/ci.py",
|
||||||
|
"*/frappe/database/sqlite/*",
|
||||||
*TESTED_VIA_CLI,
|
*TESTED_VIA_CLI,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -78,7 +82,12 @@ class CodeCoverage:
|
||||||
if self.app == "frappe":
|
if self.app == "frappe":
|
||||||
omit.extend(FRAPPE_EXCLUSIONS)
|
omit.extend(FRAPPE_EXCLUSIONS)
|
||||||
|
|
||||||
self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS)
|
self.coverage = Coverage(
|
||||||
|
source=[source_path],
|
||||||
|
omit=omit,
|
||||||
|
include=STANDARD_INCLUSIONS,
|
||||||
|
data_suffix=True,
|
||||||
|
)
|
||||||
self.coverage.start()
|
self.coverage.start()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -443,3 +443,53 @@ def _update_fieldname_references(field: CustomField, old_fieldname: str, new_fie
|
||||||
"insert_after",
|
"insert_after",
|
||||||
new_fieldname,
|
new_fieldname,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_custom_fields(custom_fields: dict, bypass_hooks: bool = False):
|
||||||
|
"""
|
||||||
|
Delete custom fields from doctypes.
|
||||||
|
|
||||||
|
:param custom_fields: Dict mapping doctype to field names.
|
||||||
|
:param bypass_hooks: If `True`, fast raw delete (skips hooks (doc events like on_trash)).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
delete_custom_fields({"Address": ["custom_a", "custom_b"]})
|
||||||
|
|
||||||
|
delete_custom_fields({"ToDo": [{"fieldname": "cf_1"}]}, bypass_hooks=True)
|
||||||
|
````
|
||||||
|
"""
|
||||||
|
for doctype, fields in custom_fields.items():
|
||||||
|
fieldnames = []
|
||||||
|
|
||||||
|
if isinstance(fields, (list, tuple, set)):
|
||||||
|
for field in fields:
|
||||||
|
if isinstance(field, str):
|
||||||
|
fieldnames.append(field)
|
||||||
|
elif isinstance(field, dict) and field.get("fieldname"):
|
||||||
|
fieldnames.append(field["fieldname"])
|
||||||
|
|
||||||
|
if not fieldnames:
|
||||||
|
continue
|
||||||
|
|
||||||
|
fieldnames = tuple(set(fieldnames))
|
||||||
|
|
||||||
|
if bypass_hooks:
|
||||||
|
frappe.db.delete(
|
||||||
|
"Custom Field",
|
||||||
|
{
|
||||||
|
"fieldname": ("in", fieldnames),
|
||||||
|
"dt": doctype,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
frappe.clear_cache(doctype=doctype)
|
||||||
|
else:
|
||||||
|
custom_field_names = frappe.get_all(
|
||||||
|
"Custom Field",
|
||||||
|
filters={"fieldname": ("in", fieldnames), "dt": doctype},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
|
||||||
|
for custom_field_name in custom_field_names:
|
||||||
|
frappe.get_doc("Custom Field", custom_field_name).delete(ignore_permissions=True, force=True)
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import frappe
|
||||||
from frappe.custom.doctype.custom_field.custom_field import (
|
from frappe.custom.doctype.custom_field.custom_field import (
|
||||||
create_custom_field,
|
create_custom_field,
|
||||||
create_custom_fields,
|
create_custom_fields,
|
||||||
|
delete_custom_fields,
|
||||||
rename_fieldname,
|
rename_fieldname,
|
||||||
)
|
)
|
||||||
|
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
|
||||||
from frappe.tests import IntegrationTestCase
|
from frappe.tests import IntegrationTestCase
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -183,3 +185,50 @@ class TestCustomField(IntegrationTestCase):
|
||||||
self.assertFalse(doc.get(old))
|
self.assertFalse(doc.get(old))
|
||||||
|
|
||||||
field.delete()
|
field.delete()
|
||||||
|
|
||||||
|
def test_delete_custom_fields(self):
|
||||||
|
doctype = "ToDo"
|
||||||
|
fields = [
|
||||||
|
{
|
||||||
|
"fieldname": f"test_delete_{frappe.generate_hash(length=5)}",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"insert_after": "status",
|
||||||
|
}
|
||||||
|
for _ in range(4)
|
||||||
|
]
|
||||||
|
fieldnames = [f["fieldname"] for f in fields]
|
||||||
|
|
||||||
|
create_custom_fields({doctype: fields})
|
||||||
|
|
||||||
|
# create property setters for fields deleted via safe path (hooks should clean these up)
|
||||||
|
for fieldname in fieldnames[:2]:
|
||||||
|
make_property_setter(doctype, fieldname, "hidden", "1", "Check")
|
||||||
|
|
||||||
|
def field_exists(fieldname):
|
||||||
|
return frappe.db.exists("Custom Field", {"fieldname": fieldname, "dt": doctype})
|
||||||
|
|
||||||
|
def property_setter_exists(fieldname):
|
||||||
|
return frappe.db.exists("Property Setter", {"doc_type": doctype, "field_name": fieldname})
|
||||||
|
|
||||||
|
for fieldname in fieldnames:
|
||||||
|
self.assertTrue(field_exists(fieldname))
|
||||||
|
for fieldname in fieldnames[:2]:
|
||||||
|
self.assertTrue(property_setter_exists(fieldname))
|
||||||
|
|
||||||
|
# 1
|
||||||
|
delete_custom_fields({doctype: [fieldnames[0], fieldnames[0]]})
|
||||||
|
self.assertFalse(field_exists(fieldnames[0]))
|
||||||
|
self.assertFalse(property_setter_exists(fieldnames[0]))
|
||||||
|
|
||||||
|
# 2
|
||||||
|
delete_custom_fields({doctype: [{"fieldname": fieldnames[1]}]})
|
||||||
|
self.assertFalse(field_exists(fieldnames[1]))
|
||||||
|
self.assertFalse(property_setter_exists(fieldnames[1]))
|
||||||
|
|
||||||
|
# 3
|
||||||
|
delete_custom_fields({doctype: [fieldnames[2], fieldnames[2]]}, bypass_hooks=True)
|
||||||
|
self.assertFalse(field_exists(fieldnames[2]))
|
||||||
|
|
||||||
|
# 4
|
||||||
|
delete_custom_fields({doctype: [{"fieldname": fieldnames[3]}]}, bypass_hooks=True)
|
||||||
|
self.assertFalse(field_exists(fieldnames[3]))
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"track_views",
|
"track_views",
|
||||||
"allow_auto_repeat",
|
"allow_auto_repeat",
|
||||||
"allow_import",
|
"allow_import",
|
||||||
|
"allow_bulk_edit",
|
||||||
"queue_in_background",
|
"queue_in_background",
|
||||||
"naming_section",
|
"naming_section",
|
||||||
"naming_rule",
|
"naming_rule",
|
||||||
|
|
@ -222,6 +223,14 @@
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Allow Import (via Data Import Tool)"
|
"label": "Allow Import (via Data Import Tool)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"default": "1",
|
||||||
|
"depends_on": "istable",
|
||||||
|
"description": "Enable bulk edit for child table fields in Form view.",
|
||||||
|
"fieldname": "allow_bulk_edit",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Allow Bulk Edit"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "email_append_to",
|
"depends_on": "email_append_to",
|
||||||
"fieldname": "subject_field",
|
"fieldname": "subject_field",
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,14 @@ import frappe.translate
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.core.doctype.doctype.doctype import (
|
from frappe.core.doctype.doctype.doctype import (
|
||||||
check_email_append_to,
|
check_email_append_to,
|
||||||
|
get_fields_not_allowed_in_list_view,
|
||||||
validate_autoincrement_autoname,
|
validate_autoincrement_autoname,
|
||||||
validate_fields_for_doctype,
|
validate_fields_for_doctype,
|
||||||
validate_series,
|
validate_series,
|
||||||
)
|
)
|
||||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
|
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
|
||||||
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter
|
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter
|
||||||
from frappe.model import core_doctypes_list, no_value_fields
|
from frappe.model import core_doctypes_list
|
||||||
from frappe.model.docfield import supports_translation
|
from frappe.model.docfield import supports_translation
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.model.meta import trim_table
|
from frappe.model.meta import trim_table
|
||||||
|
|
@ -41,6 +42,7 @@ class CustomizeForm(Document):
|
||||||
|
|
||||||
actions: DF.Table[DocTypeAction]
|
actions: DF.Table[DocTypeAction]
|
||||||
allow_auto_repeat: DF.Check
|
allow_auto_repeat: DF.Check
|
||||||
|
allow_bulk_edit: DF.Check
|
||||||
allow_copy: DF.Check
|
allow_copy: DF.Check
|
||||||
allow_import: DF.Check
|
allow_import: DF.Check
|
||||||
autoname: DF.Data | None
|
autoname: DF.Data | None
|
||||||
|
|
@ -319,12 +321,12 @@ class CustomizeForm(Document):
|
||||||
def set_property_setters_for_docfield(self, meta, df, meta_df):
|
def set_property_setters_for_docfield(self, meta, df, meta_df):
|
||||||
for prop, prop_type in docfield_properties.items():
|
for prop, prop_type in docfield_properties.items():
|
||||||
if prop != "idx" and (df.get(prop) or "") != (meta_df[0].get(prop) or ""):
|
if prop != "idx" and (df.get(prop) or "") != (meta_df[0].get(prop) or ""):
|
||||||
if not self.allow_property_change(prop, meta_df, df):
|
if not self.allow_property_change(prop, meta_df, df, meta):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.make_property_setter(prop, df.get(prop), prop_type, fieldname=df.fieldname)
|
self.make_property_setter(prop, df.get(prop), prop_type, fieldname=df.fieldname)
|
||||||
|
|
||||||
def allow_property_change(self, prop, meta_df, df):
|
def allow_property_change(self, prop, meta_df, df, meta):
|
||||||
if prop == "fieldtype":
|
if prop == "fieldtype":
|
||||||
self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop))
|
self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop))
|
||||||
|
|
||||||
|
|
@ -360,8 +362,7 @@ class CustomizeForm(Document):
|
||||||
elif (
|
elif (
|
||||||
prop == "in_list_view"
|
prop == "in_list_view"
|
||||||
and df.get(prop)
|
and df.get(prop)
|
||||||
and df.fieldtype != "Attach Image"
|
and df.fieldtype in get_fields_not_allowed_in_list_view(meta)
|
||||||
and df.fieldtype in no_value_fields
|
|
||||||
):
|
):
|
||||||
frappe.msgprint(
|
frappe.msgprint(
|
||||||
_("'In List View' not allowed for type {0} in row {1}").format(df.fieldtype, df.idx)
|
_("'In List View' not allowed for type {0} in row {1}").format(df.fieldtype, df.idx)
|
||||||
|
|
@ -401,6 +402,10 @@ class CustomizeForm(Document):
|
||||||
elif prop == "in_global_search" and df.in_global_search != meta_df[0].get("in_global_search"):
|
elif prop == "in_global_search" and df.in_global_search != meta_df[0].get("in_global_search"):
|
||||||
self.flags.rebuild_doctype_for_global_search = True
|
self.flags.rebuild_doctype_for_global_search = True
|
||||||
|
|
||||||
|
elif prop == "is_virtual" and meta_df[0].get("is_virtual") == 0 and df.get("is_virtual") == 1:
|
||||||
|
frappe.msgprint(_("You can't set standard field {0} as virtual").format(frappe.bold(df.label)))
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_property_setters_for_actions_and_links(self, meta):
|
def set_property_setters_for_actions_and_links(self, meta):
|
||||||
|
|
@ -740,6 +745,7 @@ doctype_properties = {
|
||||||
"track_views": "Check",
|
"track_views": "Check",
|
||||||
"allow_auto_repeat": "Check",
|
"allow_auto_repeat": "Check",
|
||||||
"allow_import": "Check",
|
"allow_import": "Check",
|
||||||
|
"allow_bulk_edit": "Check",
|
||||||
"show_name_in_global_search": "Check",
|
"show_name_in_global_search": "Check",
|
||||||
"show_preview_popup": "Check",
|
"show_preview_popup": "Check",
|
||||||
"default_email_template": "Data",
|
"default_email_template": "Data",
|
||||||
|
|
@ -787,6 +793,7 @@ docfield_properties = {
|
||||||
"print_hide": "Check",
|
"print_hide": "Check",
|
||||||
"print_hide_if_no_value": "Check",
|
"print_hide_if_no_value": "Check",
|
||||||
"report_hide": "Check",
|
"report_hide": "Check",
|
||||||
|
"in_import_template": "Check",
|
||||||
"allow_on_submit": "Check",
|
"allow_on_submit": "Check",
|
||||||
"translatable": "Check",
|
"translatable": "Check",
|
||||||
"mandatory_depends_on": "Data",
|
"mandatory_depends_on": "Data",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
"ignore_user_permissions",
|
"ignore_user_permissions",
|
||||||
"allow_on_submit",
|
"allow_on_submit",
|
||||||
"report_hide",
|
"report_hide",
|
||||||
|
"in_import_template",
|
||||||
"remember_last_selected_value",
|
"remember_last_selected_value",
|
||||||
"hide_border",
|
"hide_border",
|
||||||
"ignore_xss_filter",
|
"ignore_xss_filter",
|
||||||
|
|
@ -293,6 +294,13 @@
|
||||||
"oldfieldname": "report_hide",
|
"oldfieldname": "report_hide",
|
||||||
"oldfieldtype": "Check"
|
"oldfieldtype": "Check"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"description": "Enable this option to include the field in the data import template",
|
||||||
|
"fieldname": "in_import_template",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Include in Import Template"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"depends_on": "eval:(doc.fieldtype == 'Link')",
|
"depends_on": "eval:(doc.fieldtype == 'Link')",
|
||||||
|
|
@ -523,7 +531,7 @@
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-03-22 10:36:12.968197",
|
"modified": "2026-04-27 12:00:00.000000",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Custom",
|
"module": "Custom",
|
||||||
"name": "Customize Form Field",
|
"name": "Customize Form Field",
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ class CustomizeFormField(Document):
|
||||||
ignore_xss_filter: DF.Check
|
ignore_xss_filter: DF.Check
|
||||||
in_filter: DF.Check
|
in_filter: DF.Check
|
||||||
in_global_search: DF.Check
|
in_global_search: DF.Check
|
||||||
|
in_import_template: DF.Check
|
||||||
in_list_view: DF.Check
|
in_list_view: DF.Check
|
||||||
in_preview: DF.Check
|
in_preview: DF.Check
|
||||||
in_standard_filter: DF.Check
|
in_standard_filter: DF.Check
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,60 @@ def delete_property_setter(doc_type, property=None, field_name=None, row_name=No
|
||||||
if row_name:
|
if row_name:
|
||||||
filters["row_name"] = row_name
|
filters["row_name"] = row_name
|
||||||
|
|
||||||
property_setters = frappe.db.get_values("Property Setter", filters)
|
_delete_property_setters(filters)
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_delete_property_setters(property_setters: list[dict], bypass_hooks: bool = False):
|
||||||
|
"""
|
||||||
|
Delete property setters.
|
||||||
|
|
||||||
|
:param property_setters: List of filters for Property Setter rows.
|
||||||
|
:param bypass_hooks: If `True`, raw delete without doc hooks.
|
||||||
|
|
||||||
|
Example of `property_setters`:
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{"doctype": "ToDo", "fieldname": "status", "property": "hidden"},
|
||||||
|
{"doctype": "ToDo", "fieldname": "status", "property": "read_only"},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Note: `doctype` and `fieldname` are mandatory.
|
||||||
|
"""
|
||||||
|
field_map = {
|
||||||
|
"doctype": "doc_type",
|
||||||
|
"fieldname": "field_name",
|
||||||
|
}
|
||||||
|
|
||||||
|
doctypes_to_clear = set()
|
||||||
|
|
||||||
|
for property_setter in property_setters:
|
||||||
|
filters = property_setter.copy()
|
||||||
|
|
||||||
|
for key, fieldname in field_map.items():
|
||||||
|
if key in filters:
|
||||||
|
filters[fieldname] = filters.pop(key)
|
||||||
|
|
||||||
|
if not filters:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not filters.get("doc_type") or not filters.get("field_name"):
|
||||||
|
frappe.throw(_("`doctype` and `fieldname` are required for deleting property setters."))
|
||||||
|
|
||||||
|
if bypass_hooks:
|
||||||
|
frappe.db.delete("Property Setter", filters)
|
||||||
|
doctypes_to_clear.add(filters["doc_type"])
|
||||||
|
else:
|
||||||
|
_delete_property_setters(filters)
|
||||||
|
|
||||||
|
for doctype in doctypes_to_clear:
|
||||||
|
frappe.clear_cache(doctype=doctype)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_property_setters(filters: dict):
|
||||||
|
property_setters = frappe.get_all("Property Setter", filters=filters, pluck="name")
|
||||||
|
|
||||||
for ps in property_setters:
|
for ps in property_setters:
|
||||||
frappe.get_doc("Property Setter", ps).delete(ignore_permissions=True, force=True)
|
frappe.get_doc("Property Setter", ps).delete(ignore_permissions=True, force=True)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,48 @@
|
||||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
# License: MIT. See LICENSE
|
# License: MIT. See LICENSE
|
||||||
|
import frappe
|
||||||
|
from frappe.custom.doctype.property_setter.property_setter import (
|
||||||
|
bulk_delete_property_setters,
|
||||||
|
)
|
||||||
from frappe.tests import IntegrationTestCase
|
from frappe.tests import IntegrationTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestPropertySetter(IntegrationTestCase):
|
class TestPropertySetter(IntegrationTestCase):
|
||||||
pass
|
def test_bulk_delete_property_setters(self):
|
||||||
|
doctype = "ToDo"
|
||||||
|
fieldname = "status"
|
||||||
|
|
||||||
|
property_1 = "hidden"
|
||||||
|
property_2 = "no_copy"
|
||||||
|
properties = [property_1, property_2]
|
||||||
|
|
||||||
|
for property_name in properties:
|
||||||
|
frappe.make_property_setter(
|
||||||
|
{
|
||||||
|
"doctype": doctype,
|
||||||
|
"fieldname": fieldname,
|
||||||
|
"property": property_name,
|
||||||
|
"value": 1,
|
||||||
|
"property_type": "Check",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def property_setter_exists(property_name):
|
||||||
|
return frappe.db.exists(
|
||||||
|
"Property Setter",
|
||||||
|
{"doc_type": doctype, "field_name": fieldname, "property": property_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
for property_name in properties:
|
||||||
|
self.assertTrue(property_setter_exists(property_name))
|
||||||
|
|
||||||
|
# 1
|
||||||
|
bulk_delete_property_setters(
|
||||||
|
[{"doctype": doctype, "fieldname": fieldname, "property": property_1}],
|
||||||
|
bypass_hooks=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(property_setter_exists(property_1))
|
||||||
|
|
||||||
|
# 2
|
||||||
|
bulk_delete_property_setters([{"doc_type": doctype, "field_name": fieldname, "property": property_2}])
|
||||||
|
self.assertFalse(property_setter_exists(property_2))
|
||||||
|
|
|
||||||
|
|
@ -475,6 +475,9 @@ class Database:
|
||||||
|
|
||||||
if query_type in WRITE_QUERY_TYPES:
|
if query_type in WRITE_QUERY_TYPES:
|
||||||
self.transaction_writes += 1
|
self.transaction_writes += 1
|
||||||
|
if frappe.conf.get("max_writes_per_transaction"):
|
||||||
|
self.MAX_WRITES_PER_TRANSACTION = cint(frappe.conf.max_writes_per_transaction)
|
||||||
|
|
||||||
if self.transaction_writes > self.MAX_WRITES_PER_TRANSACTION:
|
if self.transaction_writes > self.MAX_WRITES_PER_TRANSACTION:
|
||||||
if self.auto_commit_on_many_writes:
|
if self.auto_commit_on_many_writes:
|
||||||
self.commit()
|
self.commit()
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import frappe
|
||||||
from frappe.database.utils import NestedSetHierarchy
|
from frappe.database.utils import NestedSetHierarchy
|
||||||
from frappe.model.db_query import get_timespan_date_range
|
from frappe.model.db_query import get_timespan_date_range
|
||||||
from frappe.query_builder import Field
|
from frappe.query_builder import Field
|
||||||
from frappe.query_builder.functions import Coalesce
|
|
||||||
from frappe.utils import cstr
|
from frappe.utils import cstr
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -48,6 +47,10 @@ def func_in(key: Field, value: list | tuple) -> frappe.qb:
|
||||||
"""
|
"""
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
value = value.split(",")
|
value = value.split(",")
|
||||||
|
|
||||||
|
value = ["" if v is None else v for v in value]
|
||||||
|
if "" in value:
|
||||||
|
return key.isin(value) | key.isnull()
|
||||||
return key.isin(value)
|
return key.isin(value)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ def _apply_date_field_filter_conversion(value, operator: str, doctype: str, fiel
|
||||||
elif isinstance(value, datetime.datetime):
|
elif isinstance(value, datetime.datetime):
|
||||||
return value.date()
|
return value.date()
|
||||||
|
|
||||||
except AttributeError, TypeError, KeyError:
|
except (AttributeError, TypeError, KeyError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
@ -136,11 +136,7 @@ WORDS_PATTERN = re.compile(r"\w+")
|
||||||
COMMA_PATTERN = re.compile(r",\s*(?![^()]*\))")
|
COMMA_PATTERN = re.compile(r",\s*(?![^()]*\))")
|
||||||
|
|
||||||
# Pattern for validating simple field names (alphanumeric + underscore)
|
# Pattern for validating simple field names (alphanumeric + underscore)
|
||||||
SIMPLE_FIELD_PATTERN = re.compile(r"^\w+$", flags=re.ASCII)
|
SIMPLE_FIELD_PATTERN = re.compile(r"^\w+$")
|
||||||
|
|
||||||
# Pattern for validating SQL identifiers (aliases, field names in functions)
|
|
||||||
# More restrictive: must start with letter or underscore
|
|
||||||
IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$", flags=re.ASCII)
|
|
||||||
|
|
||||||
# Pattern for detecting SQL function calls: identifier followed by opening parenthesis
|
# Pattern for detecting SQL function calls: identifier followed by opening parenthesis
|
||||||
FUNCTION_CALL_PATTERN = re.compile(r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*\(", flags=re.ASCII)
|
FUNCTION_CALL_PATTERN = re.compile(r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*\(", flags=re.ASCII)
|
||||||
|
|
@ -157,7 +153,7 @@ FUNCTION_CALL_PATTERN = re.compile(r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*\(", flags=re.
|
||||||
# - ... as 'Child:field'
|
# - ... as 'Child:field'
|
||||||
ALLOWED_FIELD_PATTERN = re.compile(
|
ALLOWED_FIELD_PATTERN = re.compile(
|
||||||
r"^(?:(`[\w\s-]+`|\w+)\.)?(`\w+`|\w+)(?:\s+as\s+(?:`[\w\s-]+`|'[\w\s:-]+'|\w+))?$",
|
r"^(?:(`[\w\s-]+`|\w+)\.)?(`\w+`|\w+)(?:\s+as\s+(?:`[\w\s-]+`|'[\w\s:-]+'|\w+))?$",
|
||||||
flags=re.ASCII | re.IGNORECASE,
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Regex to parse field names:
|
# Regex to parse field names:
|
||||||
|
|
@ -176,6 +172,9 @@ BACKTICK_FIELD_PARSE_REGEX = re.compile(r"^`tab([\w\s-]+)`\.(`?)(\w+)\2$")
|
||||||
# Group 3: Fieldname
|
# Group 3: Fieldname
|
||||||
CHILD_TABLE_FIELD_PATTERN = re.compile(r'^[`"]?tab([\w\s]+)[`"]?\.([`"]?)(\w+)\2$')
|
CHILD_TABLE_FIELD_PATTERN = re.compile(r'^[`"]?tab([\w\s]+)[`"]?\.([`"]?)(\w+)\2$')
|
||||||
|
|
||||||
|
# Maximum value of an unsigned 64-bit integer
|
||||||
|
MAX_LIMIT = 18446744073709551615
|
||||||
|
|
||||||
# Direct mapping from uppercase function names to pypika function classes
|
# Direct mapping from uppercase function names to pypika function classes
|
||||||
FUNCTION_MAPPING = {
|
FUNCTION_MAPPING = {
|
||||||
"COUNT": functions.Count,
|
"COUNT": functions.Count,
|
||||||
|
|
@ -303,6 +302,11 @@ class Engine:
|
||||||
if offset:
|
if offset:
|
||||||
if not isinstance(offset, int) or offset < 0:
|
if not isinstance(offset, int) or offset < 0:
|
||||||
frappe.throw(_("Offset must be a non-negative integer"), TypeError)
|
frappe.throw(_("Offset must be a non-negative integer"), TypeError)
|
||||||
|
|
||||||
|
# In MariaDB and SQLite, offset requires limit
|
||||||
|
if not self.is_postgres and not limit:
|
||||||
|
self.query = self.query.limit(MAX_LIMIT)
|
||||||
|
|
||||||
self.query = self.query.offset(offset)
|
self.query = self.query.offset(offset)
|
||||||
|
|
||||||
if distinct:
|
if distinct:
|
||||||
|
|
@ -676,7 +680,7 @@ class Engine:
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
fallback_value = int(fallback_sql)
|
fallback_value = int(fallback_sql)
|
||||||
except ValueError, TypeError:
|
except (ValueError, TypeError):
|
||||||
fallback_value = fallback_sql
|
fallback_value = fallback_sql
|
||||||
|
|
||||||
return operator_fn(_field, ValueWrapper(fallback_value))
|
return operator_fn(_field, ValueWrapper(fallback_value))
|
||||||
|
|
@ -705,7 +709,7 @@ class Engine:
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
fallback_value = int(fallback_sql)
|
fallback_value = int(fallback_sql)
|
||||||
except ValueError, TypeError:
|
except (ValueError, TypeError):
|
||||||
fallback_value = fallback_sql
|
fallback_value = fallback_sql
|
||||||
|
|
||||||
if fallback_value == _value:
|
if fallback_value == _value:
|
||||||
|
|
@ -2424,14 +2428,15 @@ class SQLFunctionParser:
|
||||||
).format(arg),
|
).format(arg),
|
||||||
frappe.ValidationError,
|
frappe.ValidationError,
|
||||||
)
|
)
|
||||||
elif self._is_valid_field_name(arg):
|
|
||||||
self._check_function_field_permission(arg)
|
|
||||||
return self.engine.table[arg]
|
|
||||||
|
|
||||||
# Check if it's a numeric string like "1" (for COUNT(1), etc.)
|
# Check if it's a numeric string like "1" (for COUNT(1), etc.)
|
||||||
elif arg.isdigit():
|
elif arg.isdigit():
|
||||||
return int(arg)
|
return int(arg)
|
||||||
|
|
||||||
|
elif self._is_valid_field_name(arg):
|
||||||
|
self._check_function_field_permission(arg)
|
||||||
|
return self.engine.table[arg]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_(
|
_(
|
||||||
|
|
@ -2443,7 +2448,7 @@ class SQLFunctionParser:
|
||||||
def _is_valid_field_name(self, name: str) -> bool:
|
def _is_valid_field_name(self, name: str) -> bool:
|
||||||
"""Check if a string is a valid field name."""
|
"""Check if a string is a valid field name."""
|
||||||
# Field names should only contain alphanumeric characters and underscores
|
# Field names should only contain alphanumeric characters and underscores
|
||||||
return IDENTIFIER_PATTERN.match(name) is not None
|
return SIMPLE_FIELD_PATTERN.match(name) is not None
|
||||||
|
|
||||||
def _validate_alias(self, alias: str):
|
def _validate_alias(self, alias: str):
|
||||||
"""Validate alias name for SQL injection."""
|
"""Validate alias name for SQL injection."""
|
||||||
|
|
@ -2456,7 +2461,7 @@ class SQLFunctionParser:
|
||||||
|
|
||||||
# Alias should be a simple identifier
|
# Alias should be a simple identifier
|
||||||
# Note: pypika wraps aliases in backticks, so anything without backticks is safe
|
# Note: pypika wraps aliases in backticks, so anything without backticks is safe
|
||||||
if not IDENTIFIER_PATTERN.match(alias):
|
if not SIMPLE_FIELD_PATTERN.match(alias):
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_("Invalid alias format: {0}. Alias must be a simple identifier.").format(alias),
|
_("Invalid alias format: {0}. Alias must be a simple identifier.").format(alias),
|
||||||
frappe.ValidationError,
|
frappe.ValidationError,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from frappe import _
|
||||||
from frappe.utils import cint, cstr, flt
|
from frappe.utils import cint, cstr, flt
|
||||||
from frappe.utils.defaults import get_not_null_defaults
|
from frappe.utils.defaults import get_not_null_defaults
|
||||||
|
|
||||||
# This matches anything that isn't [a-zA-Z0-9_]
|
# This matches anything that isn't Unicode Word Characters, Numbers and Underscore.
|
||||||
SPECIAL_CHAR_PATTERN = re.compile(r"[\W]", flags=re.UNICODE)
|
SPECIAL_CHAR_PATTERN = re.compile(r"[\W]", flags=re.UNICODE)
|
||||||
|
|
||||||
VARCHAR_CAST_PATTERN = re.compile(r"varchar\(([\d]+)\)")
|
VARCHAR_CAST_PATTERN = re.compile(r"varchar\(([\d]+)\)")
|
||||||
|
|
|
||||||
|
|
@ -661,6 +661,9 @@ def update_onboarding_step(name: str | int, field: str, value: int | str):
|
||||||
"""
|
"""
|
||||||
from frappe.utils.telemetry import capture
|
from frappe.utils.telemetry import capture
|
||||||
|
|
||||||
|
allowed_fields = ["is_skipped", "is_complete"]
|
||||||
|
if field not in allowed_fields:
|
||||||
|
return
|
||||||
frappe.db.set_value("Onboarding Step", name, field, value)
|
frappe.db.set_value("Onboarding Step", name, field, value)
|
||||||
|
|
||||||
capture(frappe.scrub(name), app="frappe_onboarding", properties={field: value})
|
capture(frappe.scrub(name), app="frappe_onboarding", properties={field: value})
|
||||||
|
|
@ -682,6 +685,9 @@ def get_onboarding_data(module: str):
|
||||||
Return:
|
Return:
|
||||||
dict: onboarding data
|
dict: onboarding data
|
||||||
"""
|
"""
|
||||||
|
if not frappe.get_system_settings("enable_onboarding"):
|
||||||
|
return []
|
||||||
|
|
||||||
onboardings = []
|
onboardings = []
|
||||||
onboarding_doc = frappe.get_doc("Module Onboarding", module)
|
onboarding_doc = frappe.get_doc("Module Onboarding", module)
|
||||||
if onboarding_doc.is_complete:
|
if onboarding_doc.is_complete:
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"bold": 1,
|
"bold": 1,
|
||||||
"description": "SQL Conditions. Example: status=\"Open\"",
|
"description": "SQL Conditions. Example: {\"status\" : \"open\", \"priority\" : \"medium\"}",
|
||||||
"fieldname": "condition",
|
"fieldname": "condition",
|
||||||
"fieldtype": "Small Text",
|
"fieldtype": "Small Text",
|
||||||
"label": "Condition"
|
"label": "Condition"
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
],
|
],
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-03-23 16:01:29.575802",
|
"modified": "2026-04-01 12:18:08.821282",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Desk",
|
"module": "Desk",
|
||||||
"name": "Bulk Update",
|
"name": "Bulk Update",
|
||||||
|
|
@ -70,8 +70,9 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"quick_entry": 1,
|
"quick_entry": 1,
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,17 +31,18 @@ class BulkUpdate(Document):
|
||||||
def bulk_update(self):
|
def bulk_update(self):
|
||||||
self.check_permission("write")
|
self.check_permission("write")
|
||||||
limit = self.limit if self.limit and cint(self.limit) < 500 else 500
|
limit = self.limit if self.limit and cint(self.limit) < 500 else 500
|
||||||
|
query_args = {"doctype": self.document_type, "limit": limit, "pluck": "name"}
|
||||||
condition = ""
|
|
||||||
if self.condition:
|
if self.condition:
|
||||||
if ";" in self.condition:
|
try:
|
||||||
frappe.throw(_("; not allowed in condition"))
|
filters = frappe.parse_json(self.condition)
|
||||||
|
if isinstance(filters, dict):
|
||||||
|
if "or_filters" in filters:
|
||||||
|
query_args["or_filters"] = filters.pop("or_filters")
|
||||||
|
query_args["filters"] = filters
|
||||||
|
except Exception as e:
|
||||||
|
frappe.throw(_("The Bulk Update could not happen due to <b>{0}</b>").format(str(e)))
|
||||||
|
|
||||||
condition = f" where {self.condition}"
|
docnames = frappe.get_all(**query_args)
|
||||||
|
|
||||||
docnames = frappe.db.sql_list(
|
|
||||||
f"""select name from `tab{self.document_type}`{condition} limit {limit} offset 0"""
|
|
||||||
)
|
|
||||||
return submit_cancel_or_update_docs(
|
return submit_cancel_or_update_docs(
|
||||||
self.document_type, docnames, "update", {self.field: self.update_value}
|
self.document_type, docnames, "update", {self.field: self.update_value}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -103,3 +103,45 @@ class TestBulkUpdate(IntegrationTestCase):
|
||||||
docnames_bg = frappe.get_all(self.doctype, {"docstatus": 0}, limit=20, pluck="name")
|
docnames_bg = frappe.get_all(self.doctype, {"docstatus": 0}, limit=20, pluck="name")
|
||||||
submit_cancel_or_update_docs(self.doctype, docnames_bg, action="update", data=update_data)
|
submit_cancel_or_update_docs(self.doctype, docnames_bg, action="update", data=update_data)
|
||||||
self.wait_for_assertion(lambda: check_child_field(docnames_bg, "_Test Child Updated"))
|
self.wait_for_assertion(lambda: check_child_field(docnames_bg, "_Test Child Updated"))
|
||||||
|
|
||||||
|
def test_bulk_update_conditions(self):
|
||||||
|
"""Test the whitelisted bulk update method"""
|
||||||
|
todo_names = []
|
||||||
|
for i in range(5):
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "ToDo",
|
||||||
|
"description": f"Bulk Update Status Test {i}",
|
||||||
|
"status": "Open" if i < 3 else "Closed",
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
todo_names.append(doc.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
condition_json = frappe.as_json({"status": "Open", "name": ["in", todo_names]})
|
||||||
|
|
||||||
|
bulk_upd = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Bulk Update",
|
||||||
|
"document_type": "ToDo",
|
||||||
|
"field": "status",
|
||||||
|
"update_value": "Closed",
|
||||||
|
"condition": condition_json,
|
||||||
|
"limit": 5,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
bulk_upd.bulk_update()
|
||||||
|
|
||||||
|
updated_docs = frappe.get_all("ToDo", filters={"name": ["in", todo_names]}, fields=["status"])
|
||||||
|
|
||||||
|
for doc in updated_docs:
|
||||||
|
self.assertEqual(doc.status, "Closed")
|
||||||
|
|
||||||
|
remaining_open_count = frappe.db.count("ToDo", {"name": ["in", todo_names], "status": "Open"})
|
||||||
|
self.assertEqual(remaining_open_count, 0)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
for name in todo_names:
|
||||||
|
frappe.delete_doc("ToDo", name)
|
||||||
|
frappe.db.commit()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.query_builder.utils import DocType
|
from frappe.query_builder.utils import DocType
|
||||||
|
from frappe.utils import has_common
|
||||||
|
|
||||||
|
|
||||||
class CustomHTMLBlock(Document):
|
class CustomHTMLBlock(Document):
|
||||||
|
|
@ -23,7 +24,12 @@ class CustomHTMLBlock(Document):
|
||||||
style: DF.Code | None
|
style: DF.Code | None
|
||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
|
|
||||||
pass
|
def validate(self):
|
||||||
|
self.validate_private()
|
||||||
|
|
||||||
|
def validate_private(self):
|
||||||
|
if not has_common(frappe.get_roles(), ["Administrator", "System Manager", "Workspace Manager"]):
|
||||||
|
self.private = 1
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,9 @@ class DesktopIcon(Document):
|
||||||
clear_desktop_icons_cache(user=self.owner)
|
clear_desktop_icons_cache(user=self.owner)
|
||||||
|
|
||||||
def after_rename(self, old, new, merge):
|
def after_rename(self, old, new, merge):
|
||||||
delete_desktop_icon_file(self.app, old)
|
if self.standard and self.app:
|
||||||
self.export_desktop_icon()
|
delete_desktop_icon_file(self.app, old)
|
||||||
|
self.export_desktop_icon()
|
||||||
|
|
||||||
def export_desktop_icon(self):
|
def export_desktop_icon(self):
|
||||||
allow_export = (
|
allow_export = (
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ def save_layout(user: str, layout: str, new_icons: str | None = None):
|
||||||
new_workspace = frappe.new_doc("Workspace")
|
new_workspace = frappe.new_doc("Workspace")
|
||||||
new_workspace.update(workspace)
|
new_workspace.update(workspace)
|
||||||
new_workspace.title = new_workspace.label
|
new_workspace.title = new_workspace.label
|
||||||
|
if not new_workspace.public:
|
||||||
|
new_workspace.for_user = frappe.session.user
|
||||||
new_workspace.save()
|
new_workspace.save()
|
||||||
return add_workspace_to_desktop(new_workspace.name)
|
return add_workspace_to_desktop(new_workspace.name)
|
||||||
desktop_icon = frappe.new_doc("Desktop Icon")
|
desktop_icon = frappe.new_doc("Desktop Icon")
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ class Event(Document):
|
||||||
return
|
return
|
||||||
|
|
||||||
for participant in self.event_participants:
|
for participant in self.event_participants:
|
||||||
if communications := frappe.get_all(
|
if communications := frappe.get_docs(
|
||||||
"Communication",
|
"Communication",
|
||||||
filters=[
|
filters=[
|
||||||
["Communication", "reference_doctype", "=", self.doctype],
|
["Communication", "reference_doctype", "=", self.doctype],
|
||||||
|
|
@ -145,11 +145,9 @@ class Event(Document):
|
||||||
["Communication Link", "link_doctype", "=", participant.reference_doctype],
|
["Communication Link", "link_doctype", "=", participant.reference_doctype],
|
||||||
["Communication Link", "link_name", "=", participant.reference_docname],
|
["Communication Link", "link_name", "=", participant.reference_docname],
|
||||||
],
|
],
|
||||||
pluck="name",
|
|
||||||
distinct=True,
|
distinct=True,
|
||||||
):
|
):
|
||||||
for comm in communications:
|
for communication in communications:
|
||||||
communication = frappe.get_doc("Communication", comm)
|
|
||||||
self.update_communication(participant, communication)
|
self.update_communication(participant, communication)
|
||||||
else:
|
else:
|
||||||
meta = frappe.get_meta(participant.reference_doctype)
|
meta = frappe.get_meta(participant.reference_doctype)
|
||||||
|
|
@ -238,8 +236,15 @@ class Event(Document):
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def update_attending_status(event_name: str, attendee: str, status: str):
|
def update_attending_status(event_name: str, attendee: str, status: str):
|
||||||
event_doc = frappe.get_doc("Event", event_name)
|
event_doc = frappe.get_doc("Event", event_name)
|
||||||
|
caller = frappe.session.user
|
||||||
|
|
||||||
if event_doc.owner == attendee == frappe.session.user:
|
if attendee != caller:
|
||||||
|
if event_doc.owner != caller and not frappe.has_permission("Event", "write", event_name):
|
||||||
|
frappe.throw(
|
||||||
|
_("You are not allowed to update attendance for another user."), frappe.PermissionError
|
||||||
|
)
|
||||||
|
|
||||||
|
if event_doc.owner == caller:
|
||||||
frappe.db.set_value("Event", event_name, "attending", status)
|
frappe.db.set_value("Event", event_name, "attending", status)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -248,8 +253,7 @@ def update_attending_status(event_name: str, attendee: str, status: str):
|
||||||
frappe.db.set_value("Event Participants", participant.name, "attending", status)
|
frappe.db.set_value("Event Participants", participant.name, "attending", status)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not has_permission(event_doc, user=attendee):
|
frappe.throw(_("Attendee not found in this event."))
|
||||||
frappe.throw(_("You are not allowed to update the status of this event."))
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|
@ -339,7 +343,12 @@ def get_events(
|
||||||
for_reminder: bool = False,
|
for_reminder: bool = False,
|
||||||
filters: str | list | dict[str, Any] | None = None,
|
filters: str | list | dict[str, Any] | None = None,
|
||||||
) -> list[frappe._dict]:
|
) -> list[frappe._dict]:
|
||||||
user = user or frappe.session.user
|
caller = frappe.session.user
|
||||||
|
target_user = user or caller
|
||||||
|
|
||||||
|
if user and user != caller:
|
||||||
|
if not frappe.has_permission("Event", ptype="read"):
|
||||||
|
frappe.throw(_("You are not allowed to view events for another user."), frappe.PermissionError)
|
||||||
type EventLikeDict = Event | frappe._dict
|
type EventLikeDict = Event | frappe._dict
|
||||||
resolved_events: list[EventLikeDict] = []
|
resolved_events: list[EventLikeDict] = []
|
||||||
|
|
||||||
|
|
@ -411,7 +420,7 @@ def get_events(
|
||||||
{
|
{
|
||||||
"start": start,
|
"start": start,
|
||||||
"end": end,
|
"end": end,
|
||||||
"user": user,
|
"user": target_user,
|
||||||
},
|
},
|
||||||
as_dict=True,
|
as_dict=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ class FormTour(Document):
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def reset_tour(tour_name: str):
|
def reset_tour(tour_name: str):
|
||||||
|
frappe.only_for("System Manager")
|
||||||
for user in frappe.get_all("User", pluck="name"):
|
for user in frappe.get_all("User", pluck="name"):
|
||||||
onboarding_status = frappe.parse_json(frappe.db.get_value("User", user, "onboarding_status"))
|
onboarding_status = frappe.parse_json(frappe.db.get_value("User", user, "onboarding_status"))
|
||||||
onboarding_status.pop(tour_name, None)
|
onboarding_status.pop(tour_name, None)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ class Note(Document):
|
||||||
|
|
||||||
if not self.content:
|
if not self.content:
|
||||||
self.content = "<span></span>"
|
self.content = "<span></span>"
|
||||||
|
self.content = frappe.utils.sanitize_html(self.content, always_sanitize=True)
|
||||||
|
|
||||||
def before_print(self, settings=None):
|
def before_print(self, settings=None):
|
||||||
self.print_heading = self.name
|
self.print_heading = self.name
|
||||||
|
|
|
||||||
|
|
@ -504,7 +504,7 @@ frappe.ui.form.on("Number Card", {
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<a class="remove-filter text-muted" style="cursor: pointer;">
|
<a class="remove-filter text-muted" style="cursor: pointer;">
|
||||||
<svg class="icon icon-sm">
|
<svg class="icon icon-sm">
|
||||||
<use href="#icon-close" class="close"></use>
|
<use href="#icon-x" class="close"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,7 @@ class SystemConsole(Document):
|
||||||
try:
|
try:
|
||||||
frappe.local.debug_log = []
|
frappe.local.debug_log = []
|
||||||
if self.type == "Python":
|
if self.type == "Python":
|
||||||
safe_exec(
|
safe_exec(self.console, script_filename="System Console")
|
||||||
self.console, script_filename="System Console", restrict_commit_rollback=not self.commit
|
|
||||||
)
|
|
||||||
self.output = "\n".join(frappe.debug_log)
|
self.output = "\n".join(frappe.debug_log)
|
||||||
elif self.type == "SQL":
|
elif self.type == "SQL":
|
||||||
frappe.db.begin(read_only=True)
|
frappe.db.begin(read_only=True)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ frappe.ui.form.on("Workspace", {
|
||||||
frm.trigger("add_to_desktop");
|
frm.trigger("add_to_desktop");
|
||||||
let url = `/desk/${
|
let url = `/desk/${
|
||||||
frm.doc.public
|
frm.doc.public
|
||||||
? frappe.router.slug(frm.doc.title)
|
? frappe.router.slug(frm.doc.name)
|
||||||
: "private/" + frappe.router.slug(frm.doc.title)
|
: "private/" + frappe.router.slug(frm.doc.name)
|
||||||
}`;
|
}`;
|
||||||
frm.sidebar
|
frm.sidebar
|
||||||
.add_user_action(__("Go to Workspace"))
|
.add_user_action(__("Go to Workspace"))
|
||||||
|
|
|
||||||
|
|
@ -76,18 +76,6 @@ class Workspace(Document):
|
||||||
|
|
||||||
if self.public and not is_workspace_manager() and not disable_saving_as_public():
|
if self.public and not is_workspace_manager() and not disable_saving_as_public():
|
||||||
frappe.throw(_("You need to be Workspace Manager to edit this document"))
|
frappe.throw(_("You need to be Workspace Manager to edit this document"))
|
||||||
|
|
||||||
if (
|
|
||||||
not self.public
|
|
||||||
and self.for_user
|
|
||||||
and self.for_user != frappe.session.user
|
|
||||||
and not is_workspace_manager()
|
|
||||||
):
|
|
||||||
frappe.throw(
|
|
||||||
_("You are not allowed to edit this workspace"),
|
|
||||||
frappe.PermissionError,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.has_value_changed("title"):
|
if self.has_value_changed("title"):
|
||||||
validate_route_conflict(self.doctype, self.title)
|
validate_route_conflict(self.doctype, self.title)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"child",
|
"child",
|
||||||
"navigate_to_tab",
|
"navigate_to_tab",
|
||||||
"url",
|
"url",
|
||||||
|
"open_in_new_tab",
|
||||||
"display_section",
|
"display_section",
|
||||||
"collapsible_column",
|
"collapsible_column",
|
||||||
"collapsible",
|
"collapsible",
|
||||||
|
|
@ -168,13 +169,20 @@
|
||||||
"fieldname": "filter_area",
|
"fieldname": "filter_area",
|
||||||
"fieldtype": "HTML",
|
"fieldtype": "HTML",
|
||||||
"label": "Filter Area"
|
"label": "Filter Area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "1",
|
||||||
|
"depends_on": "eval:doc.link_type === \"URL\";",
|
||||||
|
"fieldname": "open_in_new_tab",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Open in New Tab"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-01-12 15:35:56.930873",
|
"modified": "2026-03-15 02:26:37.285903",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Desk",
|
"module": "Desk",
|
||||||
"name": "Workspace Sidebar Item",
|
"name": "Workspace Sidebar Item",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class WorkspaceSidebarItem(Document):
|
||||||
link_to: DF.DynamicLink | None
|
link_to: DF.DynamicLink | None
|
||||||
link_type: DF.Literal["DocType", "Page", "Report", "Workspace", "Dashboard", "URL"]
|
link_type: DF.Literal["DocType", "Page", "Report", "Workspace", "Dashboard", "URL"]
|
||||||
navigate_to_tab: DF.Autocomplete | None
|
navigate_to_tab: DF.Autocomplete | None
|
||||||
|
open_in_new_tab: DF.Check
|
||||||
parent: DF.Data
|
parent: DF.Data
|
||||||
parentfield: DF.Data
|
parentfield: DF.Data
|
||||||
parenttype: DF.Data
|
parenttype: DF.Data
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ def follow_document(doctype: str, doc_name: str, user: str) -> Document | bool:
|
||||||
frappe.toast(_("Administrator can't follow"))
|
frappe.toast(_("Administrator can't follow"))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if user != frappe.session.user and not frappe.has_permission("Document Follow", "write"):
|
||||||
|
frappe.throw(_("You can only follow documents for yourself."), frappe.PermissionError)
|
||||||
|
|
||||||
if not frappe.db.get_value("User", user, "document_follow_notify", ignore=True, cache=True):
|
if not frappe.db.get_value("User", user, "document_follow_notify", ignore=True, cache=True):
|
||||||
frappe.toast(_("Document follow is not enabled for this user."))
|
frappe.toast(_("Document follow is not enabled for this user."))
|
||||||
return False
|
return False
|
||||||
|
|
@ -74,6 +77,9 @@ def follow_document(doctype: str, doc_name: str, user: str) -> Document | bool:
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def unfollow_document(doctype: str, doc_name: str, user: str) -> bool:
|
def unfollow_document(doctype: str, doc_name: str, user: str) -> bool:
|
||||||
|
if user != frappe.session.user and not frappe.has_permission("Document Follow", "write"):
|
||||||
|
frappe.throw(_("You can only unfollow documents for yourself."), frappe.PermissionError)
|
||||||
|
|
||||||
doc = frappe.get_all(
|
doc = frappe.get_all(
|
||||||
"Document Follow",
|
"Document Follow",
|
||||||
filters={"ref_doctype": doctype, "ref_docname": doc_name, "user": user},
|
filters={"ref_doctype": doctype, "ref_docname": doc_name, "user": user},
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,11 @@ def cancel(
|
||||||
|
|
||||||
if workflow_state_fieldname and workflow_state:
|
if workflow_state_fieldname and workflow_state:
|
||||||
doc.set(workflow_state_fieldname, workflow_state)
|
doc.set(workflow_state_fieldname, workflow_state)
|
||||||
|
|
||||||
|
if doc.meta.queue_in_background and not is_scheduler_inactive():
|
||||||
|
queue_submission(doc, "Cancel")
|
||||||
|
return
|
||||||
|
|
||||||
doc.cancel()
|
doc.cancel()
|
||||||
send_updated_docs(doc)
|
send_updated_docs(doc)
|
||||||
frappe.msgprint(frappe._("Cancelled"), indicator="red", alert=True)
|
frappe.msgprint(frappe._("Cancelled"), indicator="red", alert=True)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue