Merge branch 'develop' into event
This commit is contained in:
commit
5d925384dd
853 changed files with 402032 additions and 217871 deletions
23
.coveragerc
23
.coveragerc
|
|
@ -1,23 +0,0 @@
|
|||
[run]
|
||||
omit =
|
||||
tests/*
|
||||
.github/*
|
||||
commands/*
|
||||
**/test_*.py
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
|
||||
exclude_also =
|
||||
def __repr__
|
||||
if self.debug:
|
||||
if settings.DEBUG
|
||||
raise AssertionError
|
||||
raise NotImplementedError
|
||||
if 0:
|
||||
if __name__ == .__main__.:
|
||||
if TYPE_CHECKING:
|
||||
class .*\bProtocol\):
|
||||
@(abc\.)?abstractmethod
|
||||
|
|
@ -58,3 +58,6 @@ e9bbe03354079cfcef65a77b0c33f57b047a7c93
|
|||
|
||||
# ruff update
|
||||
84ef6ec677c8657c3243ac456a1ef794bfb34a50
|
||||
|
||||
# replace `frappe.flags.in_test` with `frappe.in_test`
|
||||
653c80b8483cc41aef25cd7d66b9b6bb188bf5f8
|
||||
|
|
|
|||
8
.github/actions/setup/action.yml
vendored
8
.github/actions/setup/action.yml
vendored
|
|
@ -8,7 +8,7 @@ inputs:
|
|||
node-version:
|
||||
description: 'Node.js version to use'
|
||||
required: false
|
||||
default: '20'
|
||||
default: '22'
|
||||
build-assets:
|
||||
required: false
|
||||
description: 'Wether to build assets'
|
||||
|
|
@ -100,7 +100,7 @@ runs:
|
|||
run: |
|
||||
# Install System Dependencies
|
||||
start_time=$(date +%s)
|
||||
|
||||
curl -LsS https://r.mariadb.com/downloads/mariadb_repo_setup | sudo bash
|
||||
sudo apt -qq update
|
||||
sudo apt -qq remove mysql-server mysql-client
|
||||
sudo apt -qq install libcups2-dev redis-server mariadb-client libmariadb-dev
|
||||
|
|
@ -121,6 +121,7 @@ runs:
|
|||
python -m venv ${GITHUB_WORKSPACE}/env
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
pip install --quiet --upgrade pip
|
||||
pip cache remove mysqlclient
|
||||
|
||||
pip install --quiet frappe-bench
|
||||
|
||||
|
|
@ -166,9 +167,6 @@ runs:
|
|||
mariadb --host 127.0.0.1 --port 3306 -u root -p${{ inputs.db-root-password }} -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'";
|
||||
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -p${{ inputs.db-root-password }} -e "FLUSH PRIVILEGES";
|
||||
elif [ "$DB" == "postgres" ]; then
|
||||
echo "${{ inputs.db-root-password }}" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
|
||||
echo "${{ inputs.db-root-password }}" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
|
||||
fi
|
||||
|
||||
- shell: bash -e {0}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
{
|
||||
"db_host": "127.0.0.1",
|
||||
"db_port": 5432,
|
||||
"db_name": "test_frappe",
|
||||
"db_password": "test_frappe",
|
||||
"db_type": "postgres",
|
||||
"db_type": "sqlite",
|
||||
"allow_tests": true,
|
||||
"auto_email_id": "test@example.com",
|
||||
"mail_server": "localhost",
|
||||
|
|
@ -11,8 +8,6 @@
|
|||
"mail_login": "test@example.com",
|
||||
"mail_password": "test",
|
||||
"admin_password": "admin",
|
||||
"root_login": "postgres",
|
||||
"root_password": "db_root",
|
||||
"host_name": "http://test_site:8000",
|
||||
"server_script_enabled": true
|
||||
}
|
||||
}
|
||||
1
.github/helper/documentation.py
vendored
1
.github/helper/documentation.py
vendored
|
|
@ -11,6 +11,7 @@ WEBSITE_REPOS = [
|
|||
DOCUMENTATION_DOMAINS = [
|
||||
"docs.erpnext.com",
|
||||
"frappeframework.com",
|
||||
"docs.frappe.io",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
6
.github/workflows/_base-migration.yml
vendored
6
.github/workflows/_base-migration.yml
vendored
|
|
@ -16,7 +16,7 @@ on:
|
|||
node-version:
|
||||
required: false
|
||||
type: number
|
||||
default: 20
|
||||
default: 22
|
||||
db-artifact-url:
|
||||
required: false
|
||||
type: string
|
||||
|
|
@ -33,14 +33,14 @@ jobs:
|
|||
DB_ROOT_PASSWORD: db_root
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.3
|
||||
image: mariadb:11.8
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/setup
|
||||
name: Environment Setup
|
||||
with:
|
||||
|
|
|
|||
24
.github/workflows/_base-server-tests.yml
vendored
24
.github/workflows/_base-server-tests.yml
vendored
|
|
@ -13,12 +13,12 @@ on:
|
|||
node-version:
|
||||
required: false
|
||||
type: number
|
||||
default: 20
|
||||
default: 22
|
||||
parallel-runs:
|
||||
required: false
|
||||
type: number
|
||||
default: 2
|
||||
enable-postgres:
|
||||
enable-sqlite:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
|
@ -55,7 +55,6 @@ jobs:
|
|||
timeout-minutes: 30
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
PYTHONOPTIMIZE: 2
|
||||
# noisy 3rd party library warnings
|
||||
PYTHONWARNINGS: "module,ignore:::babel.messages.extract"
|
||||
DB_ROOT_PASSWORD: db_root
|
||||
|
|
@ -63,34 +62,23 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
db: ${{ fromJson(inputs.enable-postgres && '["mariadb", "postgres"]' || '["mariadb"]') }}
|
||||
db: ${{ fromJson(inputs.enable-sqlite && '["mariadb", "sqlite"]' || '["mariadb"]') }}
|
||||
index: ${{ fromJson(needs.gen-idx-integration.outputs.indices) }}
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.3
|
||||
image: mariadb:11.8
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
|
||||
postgres:
|
||||
image: postgres:12.4
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
|
||||
smtp_server:
|
||||
image: rnwood/smtp4dev
|
||||
image: rnwood/smtp4dev:3.7.1
|
||||
ports:
|
||||
- 2525:25
|
||||
- 3000:80
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/setup
|
||||
name: Environment Setup
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/_base-type-check.yml
vendored
2
.github/workflows/_base-type-check.yml
vendored
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.check-changes.outputs.result == 'true'
|
||||
|
||||
- name: Cache pip
|
||||
|
|
|
|||
12
.github/workflows/_base-ui-tests.yml
vendored
12
.github/workflows/_base-ui-tests.yml
vendored
|
|
@ -13,7 +13,7 @@ on:
|
|||
node-version:
|
||||
required: false
|
||||
type: number
|
||||
default: 20
|
||||
default: 22
|
||||
parallel-runs:
|
||||
required: false
|
||||
type: number
|
||||
|
|
@ -44,7 +44,6 @@ jobs:
|
|||
timeout-minutes: 30
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
PYTHONOPTIMIZE: 2
|
||||
# noisy 3rd party library warnings
|
||||
PYTHONWARNINGS: "ignore"
|
||||
DB_ROOT_PASSWORD: db_root
|
||||
|
|
@ -56,14 +55,14 @@ jobs:
|
|||
index: ${{ fromJson(needs.gen-idx-integration.outputs.indices) }}
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.3
|
||||
image: mariadb:11.8
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/setup
|
||||
name: Environment Setup
|
||||
with:
|
||||
|
|
@ -99,6 +98,10 @@ jobs:
|
|||
bench --site test_site execute frappe.utils.install.complete_setup_wizard
|
||||
bench --site test_site execute frappe.tests.ui_test_helpers.create_test_user
|
||||
|
||||
- uses: browser-actions/setup-chrome@latest
|
||||
- run: |
|
||||
echo "BROWSER_PATH=$(which chrome)" >> $GITHUB_ENV
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
|
|
@ -107,6 +110,7 @@ jobs:
|
|||
--with-coverage \
|
||||
--headless \
|
||||
--parallel \
|
||||
--browser ${{ env.BROWSER_PATH }} \
|
||||
--ci-build-id $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT
|
||||
env:
|
||||
CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb
|
||||
|
|
|
|||
2
.github/workflows/backport.yml
vendored
2
.github/workflows/backport.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout Actions
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: "frappe/backport"
|
||||
path: ./actions
|
||||
|
|
|
|||
4
.github/workflows/create-release.yml
vendored
4
.github/workflows/create-release.yml
vendored
|
|
@ -12,14 +12,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Entire Repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Setup dependencies
|
||||
run: |
|
||||
npm install @semantic-release/git @semantic-release/exec --no-save
|
||||
|
|
|
|||
2
.github/workflows/generate-pot-file.yml
vendored
2
.github/workflows/generate-pot-file.yml
vendored
|
|
@ -18,7 +18,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ matrix.branch }}
|
||||
|
||||
|
|
|
|||
2
.github/workflows/labeller.yml
vendored
2
.github/workflows/labeller.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
|
|
|
|||
14
.github/workflows/linters.yml
vendored
14
.github/workflows/linters.yml
vendored
|
|
@ -19,12 +19,12 @@ jobs:
|
|||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 200
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
check-latest: true
|
||||
|
||||
- name: Check commit titles
|
||||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Validate Docs
|
||||
env:
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
|
@ -80,7 +80,7 @@ jobs:
|
|||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
|
|
@ -95,7 +95,7 @@ jobs:
|
|||
run: |
|
||||
pip install pip-audit
|
||||
cd ${GITHUB_WORKSPACE}
|
||||
pip-audit --desc on .
|
||||
pip-audit --desc on --ignore-vuln PYSEC-2023-312 .
|
||||
|
||||
precommit:
|
||||
name: 'Pre-Commit'
|
||||
|
|
@ -103,7 +103,7 @@ jobs:
|
|||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
|
|
|||
4
.github/workflows/on_release.yml
vendored
4
.github/workflows/on_release.yml
vendored
|
|
@ -16,13 +16,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: 'frappe'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
|
|
|
|||
4
.github/workflows/publish-assets-develop.yml
vendored
4
.github/workflows/publish-assets-develop.yml
vendored
|
|
@ -11,12 +11,12 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: 'frappe'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
|
|
|||
8
.github/workflows/run-indinvidual-tests.yml
vendored
8
.github/workflows/run-indinvidual-tests.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- id: set-matrix
|
||||
run: |
|
||||
# Use grep and find to get the list of test files
|
||||
|
|
@ -60,7 +60,7 @@ jobs:
|
|||
|
||||
services:
|
||||
mysql:
|
||||
image: mariadb:11.3
|
||||
image: mariadb:11.8
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: db_root
|
||||
ports:
|
||||
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
|
|
@ -79,7 +79,7 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
|
|
|
|||
10
.github/workflows/server-tests.yml
vendored
10
.github/workflows/server-tests.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
build: ${{ steps.check-build.outputs.build }}
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Check if unit tests should be run
|
||||
id: check-build
|
||||
run: |
|
||||
|
|
@ -44,7 +44,7 @@ jobs:
|
|||
name: Tests
|
||||
uses: ./.github/workflows/_base-server-tests.yml
|
||||
with:
|
||||
enable-postgres: true # This will test against both MariaDB and PostgreSQL
|
||||
enable-sqlite: false # This will test against both MariaDB and SQLite if enabled
|
||||
parallel-runs: 2
|
||||
enable-coverage: ${{ github.event_name != 'pull_request' }}
|
||||
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
|
||||
|
|
@ -58,7 +58,7 @@ jobs:
|
|||
with:
|
||||
db-artifact-url: https://frappeframework.com/files/v13-frappe.sql.gz
|
||||
python-version: '3.10'
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
|
||||
|
||||
coverage:
|
||||
|
|
@ -68,9 +68,9 @@ jobs:
|
|||
if: ${{ github.event_name != 'pull_request' }}
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4.2.1
|
||||
uses: actions/download-artifact@v5.0.0
|
||||
- name: Upload coverage data
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
|
|
|||
6
.github/workflows/ui-tests.yml
vendored
6
.github/workflows/ui-tests.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Check if build should be run
|
||||
id: check-build
|
||||
|
|
@ -55,9 +55,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4.2.1
|
||||
uses: actions/download-artifact@v5.0.0
|
||||
- name: Upload python coverage data
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -6,7 +6,7 @@
|
|||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a target="_blank" href="#LICENSE" title="License: MIT"><img src="https://img.shields.io/badge/License-MIT-success.svg"></a>
|
||||
<a target="_blank" href="LICENSE" title="License: MIT"><img src="https://img.shields.io/badge/License-MIT-success.svg"></a>
|
||||
<a href="https://codecov.io/gh/frappe/frappe"><img src="https://codecov.io/gh/frappe/frappe/branch/develop/graph/badge.svg?token=XoTa679hIj"/></a>
|
||||
</div>
|
||||
<div align="center">
|
||||
|
|
@ -21,10 +21,15 @@
|
|||
## Frappe Framework
|
||||
Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for ERPNext.
|
||||
|
||||
### Motivation
|
||||
## Philosophy
|
||||
|
||||
> The best code is the one that is not written
|
||||
|
||||
Started in 2005, Frappe Framework was inspired by the Semantic Web. The "big idea" behind semantic web was of a framework that not only described how information is shown (like headings, body etc), but also what it means, like name, address etc.
|
||||
|
||||
By creating a web framework that allowed for easy definition of metadata, it made building complex applications easy. Applications usually designed around how users interact with a system, but not based on semantics of the underlying system. Applications built on semantics end up being much more consistent and extensible. The first application built on Framework was ERPNext, a beast with more than 700 object types. Framework is not for the light hearted - it is not the first thing you might want to learn if you are beginning to learn web programming, but if you are ready to do real work, then Framework is the right tool for the job.
|
||||
By creating a web framework that allowed for easy definition of metadata, it made building complex applications easy. Applications usually designed around how users interact with a system, but not based on semantics of the underlying system. Applications built on semantics end up being much more consistent and extensible.
|
||||
|
||||
The first application built on Framework was ERPNext, a beast with more than 700 object types. Framework is not for the light hearted - it is not the first thing you might want to learn if you are beginning to learn web programming, but if you are ready to do real work, then Framework is the right tool for the job.
|
||||
|
||||
### Key Features
|
||||
|
||||
|
|
@ -103,26 +108,24 @@ To setup the repository locally follow the steps mentioned below:
|
|||
2. In a separate terminal window, run the following commands:
|
||||
```
|
||||
# Create a new site
|
||||
bench new-site frappe.dev
|
||||
|
||||
# Map your site to localhost
|
||||
bench --site frappe.dev add-to-hosts
|
||||
bench new-site frappe.localhost
|
||||
```
|
||||
|
||||
3. Open the URL `http://frappe.dev:8000/app` in your browser, you should see the app running
|
||||
3. Open the URL `http://frappe.localhost:8000/app` in your browser, you should see the app running
|
||||
|
||||
## Learning and community
|
||||
|
||||
1. [Frappe School](https://frappe.school) - Learn Frappe Framework and ERPNext from the various courses by the maintainers or from the community.
|
||||
2. [Official documentation](https://docs.frappe.io/framework) - Extensive documentation for Frappe Framework.
|
||||
3. [Discussion Forum](https://discuss.frappe.io/) - Engage with community of Frappe Framework users and service providers.
|
||||
4. [buildwithhussain.dev](https://buildwithhussain.dev) - Watch Frappe Framework being used in the wild to build world-class web apps.
|
||||
4. [buildwithhussain.com](https://buildwithhussain.com) - Watch Frappe Framework being used in the wild to build world-class web apps.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. [Issue Guidelines](https://github.com/frappe/erpnext/wiki/Issue-Guidelines)
|
||||
1. [Report Security Vulnerabilities](https://frappe.io/security)
|
||||
1. [Pull Request Requirements](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines)
|
||||
2. [Translations](https://crowdin.com/project/frappe)
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
|
@ -133,4 +136,4 @@ To setup the repository locally follow the steps mentioned below:
|
|||
<img src="https://frappe.io/files/Frappe-black.png" alt="Frappe Technologies" height="28"/>
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ context("Awesome Bar", () => {
|
|||
cy.login();
|
||||
cy.visit("/app/todo"); // Make sure ToDo filters are cleared.
|
||||
cy.clear_filters();
|
||||
cy.visit("/app/blog-post"); // Make sure Blog Post filters are cleared.
|
||||
cy.visit("/app/web-page"); // Make sure Blog Post filters are cleared.
|
||||
cy.clear_filters();
|
||||
cy.visit("/app/website"); // Go to some other page.
|
||||
cy.visit("/app/build"); // Go to some other page.
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -53,19 +53,19 @@ context("Awesome Bar", () => {
|
|||
});
|
||||
|
||||
it("navigates to another doctype, filter not bleeding", () => {
|
||||
cy.get("@awesome_bar").type("blog post");
|
||||
cy.get("@awesome_bar").type("web page");
|
||||
cy.wait(150); // Wait a bit before hitting enter.
|
||||
cy.get("@awesome_bar").type("{enter}");
|
||||
cy.get(".title-text").should("contain", "Blog Post");
|
||||
cy.get(".title-text").should("contain", "Web Page");
|
||||
cy.wait(200); // Wait a bit longer before checking the filter.
|
||||
cy.location("search").should("be.empty");
|
||||
});
|
||||
|
||||
it("navigates to new form", () => {
|
||||
cy.get("@awesome_bar").type("new blog post");
|
||||
cy.get("@awesome_bar").type("new web page");
|
||||
cy.wait(150); // Wait a bit before hitting enter
|
||||
cy.get("@awesome_bar").type("{enter}");
|
||||
cy.get(".title-text:visible").should("have.text", "New Blog Post");
|
||||
cy.get(".title-text:visible").should("have.text", "New Web Page");
|
||||
});
|
||||
|
||||
it("calculates math expressions", () => {
|
||||
|
|
|
|||
|
|
@ -51,4 +51,32 @@ context("List View", () => {
|
|||
cy.get(".list-row-container:visible").should("contain", "Approved");
|
||||
});
|
||||
});
|
||||
|
||||
it("Adds a button to each list view row", () => {
|
||||
// Get a ToDo with a reference name
|
||||
cy.call("frappe.client.get_value", {
|
||||
doctype: "ToDo",
|
||||
filters: {
|
||||
reference_name: ["is", "set"],
|
||||
},
|
||||
fieldname: "name",
|
||||
}).then((r) => {
|
||||
const todo_name = r.message.name;
|
||||
cy.go_to_list("ToDo");
|
||||
|
||||
// Check if the 'Open' button is present in the ToDo list view
|
||||
cy.get(`.btn-default[data-name="${todo_name}"]`)
|
||||
.scrollIntoView({ inline: "center", block: "nearest" })
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
cy.window()
|
||||
.its("cur_frm")
|
||||
.then((frm) => {
|
||||
// Routes to the reference document
|
||||
expect(frm.doc.doctype).to.equal("ToDo");
|
||||
expect(frm.doc.name).to.not.equal(todo_name);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ context("List View Settings", () => {
|
|||
cy.clear_filters();
|
||||
cy.wait(300);
|
||||
cy.get(".list-count").should("contain", "20 of");
|
||||
cy.get("[href='#es-line-chat-alt']").should("be.visible");
|
||||
cy.get(".frappe-list svg.es-icon.es-line").should("be.visible");
|
||||
cy.get(".menu-btn-group button").click();
|
||||
cy.get(".dropdown-menu li").filter(":visible").contains("List Settings").click();
|
||||
cy.get(".modal-dialog").should("contain", "DocType Settings");
|
||||
cy.get(".modal-dialog").should("contain", "DocType List View Settings");
|
||||
|
||||
cy.findByLabelText("Disable Count").check({ force: true });
|
||||
cy.findByLabelText("Disable Comment Count").check({ force: true });
|
||||
|
|
@ -33,7 +33,7 @@ context("List View Settings", () => {
|
|||
|
||||
cy.get(".menu-btn-group button").click({ force: true });
|
||||
cy.get(".dropdown-menu li").filter(":visible").contains("List Settings").click();
|
||||
cy.get(".modal-dialog").should("contain", "DocType Settings");
|
||||
cy.get(".modal-dialog").should("contain", "DocType List View Settings");
|
||||
cy.findByLabelText("Disable Count").uncheck({ force: true });
|
||||
cy.findByLabelText("Disable Comment Count").uncheck({ force: true });
|
||||
cy.findByLabelText("Disable Sidebar Stats").uncheck({ force: true });
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ context("Sidebar", () => {
|
|||
.window()
|
||||
.its("frappe")
|
||||
.then((frappe) => {
|
||||
return frappe.call("frappe.tests.ui_test_helpers.create_blog_post");
|
||||
return frappe.call("frappe.tests.ui_test_helpers.create_doctype_for_attachment");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ context("Sidebar", () => {
|
|||
}).then((todo) => {
|
||||
verify_attachment_visibility(`todo/${todo.message.name}`, true);
|
||||
});
|
||||
verify_attachment_visibility("blog-post/test-blog-attachment-post", false);
|
||||
verify_attachment_visibility("test-blog-category/_Test Blog Category 2", false);
|
||||
});
|
||||
|
||||
it("Verify attachment accessibility UX", () => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ context("Table MultiSelect", () => {
|
|||
it("select value from multiselect dropdown", () => {
|
||||
cy.new_form("Assignment Rule");
|
||||
cy.fill_field("__newname", name);
|
||||
cy.fill_field("document_type", "Blog Post");
|
||||
cy.fill_field("document_type", "Web Page");
|
||||
cy.get(".section-head").contains("Assignment Rules").scrollIntoView();
|
||||
cy.fill_field("assign_condition", 'status=="Open"', "Code");
|
||||
cy.get('input[data-fieldname="users"]').focus().as("input");
|
||||
|
|
|
|||
99
cypress/integration/utils.js
Normal file
99
cypress/integration/utils.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
context("Utils", () => {
|
||||
before(() => {
|
||||
cy.login();
|
||||
cy.visit("/app");
|
||||
});
|
||||
|
||||
function run_util(name, ...args) {
|
||||
return cy
|
||||
.window()
|
||||
.its("frappe")
|
||||
.then((frappe) => {
|
||||
return frappe.utils[name](...args);
|
||||
});
|
||||
}
|
||||
|
||||
it("should round hidden seconds to minutes", () => {
|
||||
run_util("seconds_to_duration", 89, { hide_seconds: 1 }).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 1,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
run_util("seconds_to_duration", -89, { hide_seconds: 1 }).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: -0,
|
||||
hours: -0,
|
||||
minutes: -1,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
run_util("seconds_to_duration", 91, { hide_seconds: 1 }).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 2,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
run_util("seconds_to_duration", -91, { hide_seconds: 1 }).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: -0,
|
||||
hours: -0,
|
||||
minutes: -2,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse days, hours, minutes and seconds", () => {
|
||||
run_util("seconds_to_duration", 60 * 60 * 24 + 60 * 60 + 60 + 1).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: 1,
|
||||
hours: 1,
|
||||
minutes: 1,
|
||||
seconds: 1,
|
||||
});
|
||||
});
|
||||
|
||||
run_util("seconds_to_duration", (60 * 60 * 24 + 60 * 60 + 60 + 1) * -1).then(
|
||||
(duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: -1,
|
||||
hours: -1,
|
||||
minutes: -1,
|
||||
seconds: -1,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
run_util("seconds_to_duration", 60 * 60 * 24 + 60 * 60 + 60 + 1, {
|
||||
hide_days: 1,
|
||||
hide_seconds: 1,
|
||||
}).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: 0,
|
||||
hours: 25,
|
||||
minutes: 1,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
run_util("seconds_to_duration", (60 * 60 * 24 + 60 * 60 + 60 + 1) * -1, {
|
||||
hide_days: 1,
|
||||
hide_seconds: 1,
|
||||
}).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: 0,
|
||||
hours: -25,
|
||||
minutes: -1,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -472,6 +472,11 @@ async function write_assets_json(metafile) {
|
|||
}
|
||||
|
||||
async function update_assets_json_in_cache() {
|
||||
// Redis won't be present during docker image build
|
||||
if (process.env.FRAPPE_DOCKER_BUILD) {
|
||||
return;
|
||||
}
|
||||
|
||||
// update assets_json cache in redis, so that it can be read directly by python
|
||||
let client = get_redis_subscriber("redis_cache");
|
||||
// handle error event to avoid printing stack traces
|
||||
|
|
@ -523,7 +528,7 @@ function run_build_command_for_apps(apps) {
|
|||
log(
|
||||
`\nInstalling dependencies for ${chalk.bold(app)} (because node_modules not found)`
|
||||
);
|
||||
execSync("yarn install", { encoding: "utf8", stdio: "inherit" });
|
||||
execSync("yarn install --frozen-lockfile", { encoding: "utf8", stdio: "inherit" });
|
||||
}
|
||||
|
||||
log("\nRunning build command for", chalk.bold(app));
|
||||
|
|
|
|||
|
|
@ -24,16 +24,12 @@ from collections.abc import Callable, Iterable
|
|||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Generic,
|
||||
Literal,
|
||||
Optional,
|
||||
TypeAlias,
|
||||
TypeVar,
|
||||
Union,
|
||||
overload,
|
||||
)
|
||||
|
||||
import click
|
||||
import orjson
|
||||
from werkzeug.datastructures import Headers
|
||||
|
||||
import frappe
|
||||
|
|
@ -45,10 +41,11 @@ from frappe.utils.caching import deprecated_local_cache as local_cache
|
|||
from frappe.utils.caching import request_cache, site_cache
|
||||
from frappe.utils.data import as_unicode, bold, cint, cstr, safe_decode, safe_encode, sbool
|
||||
from frappe.utils.local import Local, LocalProxy, release_local
|
||||
from frappe.utils.translations import _, _lt, set_user_lang
|
||||
|
||||
# Local application imports
|
||||
from .exceptions import *
|
||||
from .types import Filters, FilterSignature, FilterTuple, _dict
|
||||
from .types import _dict
|
||||
from .utils.jinja import (
|
||||
get_email_from_template,
|
||||
get_jenv,
|
||||
|
|
@ -62,25 +59,27 @@ __title__ = "Frappe Framework"
|
|||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from logging import Logger
|
||||
from types import ModuleType
|
||||
|
||||
from werkzeug.wrappers import Request
|
||||
|
||||
from frappe.database.mariadb.database import MariaDBDatabase as PyMariaDBDatabase
|
||||
from frappe.database.mariadb.mysqlclient import MariaDBDatabase
|
||||
from frappe.database.postgres.database import PostgresDatabase
|
||||
from frappe.email.doctype.email_queue.email_queue import EmailQueue
|
||||
from frappe.database.sqlite.database import SQLiteDatabase
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.builder import MariaDB, Postgres
|
||||
from frappe.types.lazytranslatedstring import _LazyTranslate
|
||||
from frappe.query_builder.builder import MariaDB, Postgres, SQLite
|
||||
from frappe.utils.redis_wrapper import ClientCache, RedisWrapper
|
||||
|
||||
controllers: dict[str, "Document"] = {}
|
||||
controllers: dict[str, type] = {}
|
||||
lazy_controllers: dict[str, type] = {}
|
||||
local = Local()
|
||||
cache: Optional["RedisWrapper"] = None
|
||||
client_cache: Optional["ClientCache"] = None
|
||||
STANDARD_USERS = ("Guest", "Administrator")
|
||||
|
||||
# this global may be subsequently changed by frappe.tests.utils.toggle_test_mode()
|
||||
in_test = False
|
||||
|
||||
_dev_server = int(sbool(os.environ.get("DEV_SERVER", False)))
|
||||
|
||||
if _dev_server:
|
||||
|
|
@ -88,66 +87,6 @@ if _dev_server:
|
|||
warnings.simplefilter("always", PendingDeprecationWarning)
|
||||
|
||||
|
||||
def _(msg: str, lang: str | None = None, context: str | None = None) -> str:
|
||||
"""Return translated string in current lang, if exists.
|
||||
Usage:
|
||||
_('Change')
|
||||
_('Change', context='Coins')
|
||||
"""
|
||||
from frappe.translate import get_all_translations
|
||||
from frappe.utils import is_html, strip_html_tags
|
||||
|
||||
if not hasattr(local, "lang"):
|
||||
local.lang = lang or "en"
|
||||
|
||||
if not lang:
|
||||
lang = local.lang
|
||||
|
||||
non_translated_string = msg
|
||||
|
||||
if is_html(msg):
|
||||
msg = strip_html_tags(msg)
|
||||
|
||||
# msg should always be unicode
|
||||
msg = as_unicode(msg).strip()
|
||||
|
||||
translated_string = ""
|
||||
|
||||
all_translations = get_all_translations(lang)
|
||||
if context:
|
||||
string_key = f"{msg}:{context}"
|
||||
translated_string = all_translations.get(string_key)
|
||||
|
||||
if not translated_string:
|
||||
translated_string = all_translations.get(msg)
|
||||
|
||||
return translated_string or non_translated_string
|
||||
|
||||
|
||||
def _lt(msg: str, lang: str | None = None, context: str | None = None) -> "_LazyTranslate":
|
||||
"""Lazily translate a string.
|
||||
|
||||
|
||||
This function returns a "lazy string" which when casted to string via some operation applies
|
||||
translation first before casting.
|
||||
|
||||
This is only useful for translating strings in global scope or anything that potentially runs
|
||||
before `frappe.init()`
|
||||
|
||||
Note: Result is not guaranteed to equivalent to pure strings for all operations.
|
||||
"""
|
||||
from .types.lazytranslatedstring import _LazyTranslate
|
||||
|
||||
return _LazyTranslate(msg, lang, context)
|
||||
|
||||
|
||||
def set_user_lang(user: str, user_language: str | None = None) -> None:
|
||||
"""Guess and set user language for the session. `frappe.local.lang`"""
|
||||
from frappe.translate import get_user_lang
|
||||
|
||||
local.lang = get_user_lang(user) or user_language
|
||||
|
||||
|
||||
# local-globals
|
||||
ConfType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
|
||||
# TODO: make session a dataclass instead of undtyped _dict
|
||||
|
|
@ -161,8 +100,10 @@ ResponseDict: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
|
|||
FlagsDict: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
|
||||
FormDict: TypeAlias = _dict[str, str]
|
||||
|
||||
db: LocalProxy[Union["PyMariaDBDatabase", "MariaDBDatabase", "PostgresDatabase"]] = local("db")
|
||||
qb: LocalProxy[Union["MariaDB", "Postgres"]] = local("qb")
|
||||
db: LocalProxy[Union["PyMariaDBDatabase", "MariaDBDatabase", "PostgresDatabase", "SQLiteDatabase"]] = local(
|
||||
"db"
|
||||
)
|
||||
qb: LocalProxy[Union["MariaDB", "Postgres", "SQLite"]] = local("qb")
|
||||
conf: LocalProxy[ConfType] = local("conf")
|
||||
form_dict: LocalProxy[FormDict] = local("form_dict")
|
||||
form = form_dict
|
||||
|
|
@ -182,7 +123,7 @@ lang: LocalProxy[str] = local("lang")
|
|||
if TYPE_CHECKING: # pragma: no cover
|
||||
# trick because some type checkers fail to follow "RedisWrapper", etc (written as string literal)
|
||||
# trough a generic wrapper; seems to be a bug
|
||||
db: PyMariaDBDatabase | MariaDBDatabase | PostgresDatabase
|
||||
db: PyMariaDBDatabase | MariaDBDatabase | PostgresDatabase | SQLiteDatabase
|
||||
qb: MariaDB | Postgres
|
||||
conf: ConfType
|
||||
form_dict: FormDict
|
||||
|
|
@ -215,7 +156,7 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force: bool =
|
|||
"in_install_db": False,
|
||||
"in_install_app": False,
|
||||
"in_import": False,
|
||||
"in_test": False,
|
||||
"in_test": in_test,
|
||||
"mute_messages": False,
|
||||
"ignore_links": False,
|
||||
"mute_emails": False,
|
||||
|
|
@ -259,7 +200,7 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force: bool =
|
|||
local.form_dict = _dict()
|
||||
local.preload_assets = {"style": [], "script": [], "icons": []}
|
||||
local.session = _dict()
|
||||
local.dev_server = _dev_server
|
||||
local.dev_server = _dev_server # only for backwards compatibility
|
||||
local.qb = get_query_builder(local.conf.db_type)
|
||||
if not cache or not client_cache:
|
||||
setup_redis_cache_connection()
|
||||
|
|
@ -304,9 +245,11 @@ def connect(site: str | None = None, db_name: str | None = None, set_admin_as_us
|
|||
db_name_ = conf.db_name or db_name
|
||||
db_password = conf.db_password
|
||||
|
||||
assert db_user, "site must be fully initialized, db_user missing"
|
||||
assert db_name_, "site must be fully initialized, db_name missing"
|
||||
assert db_password, "site must be fully initialized, db_password missing"
|
||||
|
||||
if frappe.conf.db_type in ("mariadb", "postgres"):
|
||||
assert db_user, "site must be fully initialized, db_user missing"
|
||||
assert db_password, "site must be fully initialized, db_password missing"
|
||||
|
||||
local.db = get_db(
|
||||
socket=conf.db_socket,
|
||||
|
|
@ -348,6 +291,9 @@ def connect_replica() -> bool:
|
|||
local.primary_db = local.db
|
||||
local.db = local.replica_db
|
||||
|
||||
if hasattr(frappe.local, "_recorder"):
|
||||
frappe.local._recorder._patch_sql(local.db)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -389,13 +335,6 @@ def setup_redis_cache_connection():
|
|||
client_cache = ClientCache()
|
||||
|
||||
|
||||
def get_traceback(with_context: bool = False) -> str:
|
||||
"""Return error traceback."""
|
||||
from frappe.utils import get_traceback
|
||||
|
||||
return get_traceback(with_context=with_context)
|
||||
|
||||
|
||||
def errprint(msg: str) -> None:
|
||||
"""Log error. This is sent back as `exc` in response.
|
||||
|
||||
|
|
@ -422,20 +361,6 @@ def log(msg: str) -> None:
|
|||
debug_log.append(as_unicode(msg))
|
||||
|
||||
|
||||
def create_folder(path, with_init=False):
|
||||
"""Create a folder in the given path and add an `__init__.py` file (optional).
|
||||
|
||||
:param path: Folder path.
|
||||
:param with_init: Create `__init__.py` in the new folder."""
|
||||
from frappe.utils import touch_file
|
||||
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path)
|
||||
|
||||
if with_init:
|
||||
touch_file(os.path.join(path, "__init__.py"))
|
||||
|
||||
|
||||
def set_user(username: str):
|
||||
"""Set current user.
|
||||
|
||||
|
|
@ -476,138 +401,22 @@ def get_request_header(key, default=None):
|
|||
return request.headers.get(key, default)
|
||||
|
||||
|
||||
def sendmail(
|
||||
recipients=None,
|
||||
sender="",
|
||||
subject="No Subject",
|
||||
message="No Message",
|
||||
as_markdown=False,
|
||||
delayed=True,
|
||||
reference_doctype=None,
|
||||
reference_name=None,
|
||||
unsubscribe_method=None,
|
||||
unsubscribe_params=None,
|
||||
unsubscribe_message=None,
|
||||
add_unsubscribe_link=1,
|
||||
attachments=None,
|
||||
content=None,
|
||||
doctype=None,
|
||||
name=None,
|
||||
reply_to=None,
|
||||
queue_separately=False,
|
||||
cc=None,
|
||||
bcc=None,
|
||||
message_id=None,
|
||||
in_reply_to=None,
|
||||
send_after=None,
|
||||
expose_recipients=None,
|
||||
send_priority=1,
|
||||
communication=None,
|
||||
retry=1,
|
||||
now=None,
|
||||
read_receipt=None,
|
||||
is_notification=False,
|
||||
inline_images=None,
|
||||
template=None,
|
||||
args=None,
|
||||
header=None,
|
||||
print_letterhead=False,
|
||||
with_container=False,
|
||||
email_read_tracker_url=None,
|
||||
) -> Optional["EmailQueue"]:
|
||||
"""Send email using user's default **Email Account** or global default **Email Account**.
|
||||
|
||||
|
||||
:param recipients: List of recipients.
|
||||
:param sender: Email sender. Default is current user or default outgoing account.
|
||||
:param subject: Email Subject.
|
||||
:param message: (or `content`) Email Content.
|
||||
:param as_markdown: Convert content markdown to HTML.
|
||||
:param delayed: Send via scheduled email sender **Email Queue**. Don't send immediately. Default is true
|
||||
:param send_priority: Priority for Email Queue, default 1.
|
||||
:param reference_doctype: (or `doctype`) Append as communication to this DocType.
|
||||
:param reference_name: (or `name`) Append as communication to this document name.
|
||||
:param unsubscribe_method: Unsubscribe url with options email, doctype, name. e.g. `/api/method/unsubscribe`
|
||||
:param unsubscribe_params: Unsubscribe paramaters to be loaded on the unsubscribe_method [optional] (dict).
|
||||
:param attachments: List of attachments.
|
||||
:param reply_to: Reply-To Email Address.
|
||||
:param message_id: Used for threading. If a reply is received to this email, Message-Id is sent back as In-Reply-To in received email.
|
||||
:param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To.
|
||||
:param send_after: Send after the given datetime.
|
||||
:param expose_recipients: Display all recipients in the footer message - "This email was sent to"
|
||||
:param communication: Communication link to be set in Email Queue record
|
||||
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id
|
||||
:param template: Name of html template from templates/emails folder
|
||||
:param args: Arguments for rendering the template
|
||||
:param header: Append header in email
|
||||
:param with_container: Wraps email inside a styled container
|
||||
"""
|
||||
|
||||
if recipients is None:
|
||||
recipients = []
|
||||
if cc is None:
|
||||
cc = []
|
||||
if bcc is None:
|
||||
bcc = []
|
||||
|
||||
text_content = None
|
||||
if template:
|
||||
message, text_content = get_email_from_template(template, args)
|
||||
|
||||
message = content or message
|
||||
|
||||
if as_markdown:
|
||||
from frappe.utils import md_to_html
|
||||
|
||||
message = md_to_html(message)
|
||||
|
||||
if not delayed:
|
||||
now = True
|
||||
|
||||
from frappe.email.doctype.email_queue.email_queue import QueueBuilder
|
||||
|
||||
builder = QueueBuilder(
|
||||
recipients=recipients,
|
||||
sender=sender,
|
||||
subject=subject,
|
||||
message=message,
|
||||
text_content=text_content,
|
||||
reference_doctype=doctype or reference_doctype,
|
||||
reference_name=name or reference_name,
|
||||
add_unsubscribe_link=add_unsubscribe_link,
|
||||
unsubscribe_method=unsubscribe_method,
|
||||
unsubscribe_params=unsubscribe_params,
|
||||
unsubscribe_message=unsubscribe_message,
|
||||
attachments=attachments,
|
||||
reply_to=reply_to,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
message_id=message_id,
|
||||
in_reply_to=in_reply_to,
|
||||
send_after=send_after,
|
||||
expose_recipients=expose_recipients,
|
||||
send_priority=send_priority,
|
||||
queue_separately=queue_separately,
|
||||
communication=communication,
|
||||
read_receipt=read_receipt,
|
||||
is_notification=is_notification,
|
||||
inline_images=inline_images,
|
||||
header=header,
|
||||
print_letterhead=print_letterhead,
|
||||
with_container=with_container,
|
||||
email_read_tracker_url=email_read_tracker_url,
|
||||
)
|
||||
|
||||
# build email queue and send the email if send_now is True.
|
||||
return builder.process(send_now=now)
|
||||
|
||||
|
||||
whitelisted: set[Callable] = set()
|
||||
guest_methods: set[Callable] = set()
|
||||
xss_safe_methods: set[Callable] = set()
|
||||
allowed_http_methods_for_whitelisted_func: dict[Callable, list[str]] = {}
|
||||
|
||||
|
||||
def _in_request_or_test():
|
||||
"""
|
||||
Internal
|
||||
|
||||
Used by whitelist to determine whether type hints should be validated or not
|
||||
"""
|
||||
|
||||
return getattr(local, "request", None) or in_test
|
||||
|
||||
|
||||
def whitelist(allow_guest=False, xss_safe=False, methods=None):
|
||||
"""
|
||||
Decorator for whitelisting a function and making it accessible via HTTP.
|
||||
|
|
@ -631,17 +440,8 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
|
|||
|
||||
global whitelisted, guest_methods, xss_safe_methods, allowed_http_methods_for_whitelisted_func
|
||||
|
||||
# validate argument types only if request is present
|
||||
in_request_or_test = lambda: getattr(local, "request", None) or local.flags.in_test # noqa: E731
|
||||
|
||||
# get function from the unbound / bound method
|
||||
# this is needed because functions can be compared, but not methods
|
||||
method = None
|
||||
if hasattr(fn, "__func__"):
|
||||
method = validate_argument_types(fn, apply_condition=in_request_or_test)
|
||||
fn = method.__func__
|
||||
else:
|
||||
fn = validate_argument_types(fn, apply_condition=in_request_or_test)
|
||||
# validate argument types if request is present or in test context
|
||||
fn = validate_argument_types(fn, apply_condition=_in_request_or_test)
|
||||
|
||||
whitelisted.add(fn)
|
||||
allowed_http_methods_for_whitelisted_func[fn] = methods
|
||||
|
|
@ -652,7 +452,7 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
|
|||
if xss_safe:
|
||||
xss_safe_methods.add(fn)
|
||||
|
||||
return method or fn
|
||||
return fn
|
||||
|
||||
return innerfn
|
||||
|
||||
|
|
@ -662,7 +462,7 @@ def is_whitelisted(method):
|
|||
|
||||
is_guest = session["user"] == "Guest"
|
||||
if method not in whitelisted or (is_guest and method not in guest_methods):
|
||||
summary = _("You are not permitted to access this resource.")
|
||||
summary = _("You are not permitted to access this resource. Login to access")
|
||||
detail = _("Function {0} is not whitelisted.").format(bold(f"{method.__module__}.{method.__name__}"))
|
||||
msg = f"<details><summary>{summary}</summary>{detail}</details>"
|
||||
throw(msg, PermissionError, title=_("Method Not Allowed"))
|
||||
|
|
@ -732,7 +532,7 @@ def only_for(roles: list[str] | tuple[str] | str, message=False):
|
|||
:param roles: Permitted role(s)
|
||||
"""
|
||||
|
||||
if local.flags.in_test or local.session.user == "Administrator":
|
||||
if local.session.user == "Administrator":
|
||||
return
|
||||
|
||||
if isinstance(roles, str):
|
||||
|
|
@ -759,7 +559,7 @@ def get_domain_data(module):
|
|||
else:
|
||||
return _dict()
|
||||
except ImportError:
|
||||
if local.flags.in_test:
|
||||
if in_test:
|
||||
return _dict()
|
||||
else:
|
||||
raise
|
||||
|
|
@ -839,7 +639,7 @@ def has_website_permission(doc=None, ptype="read", user=None, verbose=False, doc
|
|||
|
||||
if doc:
|
||||
if isinstance(doc, str):
|
||||
doc = get_doc(doctype, doc)
|
||||
doc = get_lazy_doc(doctype, doc)
|
||||
|
||||
doctype = doc.doctype
|
||||
|
||||
|
|
@ -899,30 +699,6 @@ def generate_hash(txt: str | None = None, length: int = 56) -> str:
|
|||
return secrets.token_hex(math.ceil(length / 2))[:length]
|
||||
|
||||
|
||||
def new_doc(
|
||||
doctype: str,
|
||||
*,
|
||||
parent_doc: Optional["Document"] = None,
|
||||
parentfield: str | None = None,
|
||||
as_dict: bool = False,
|
||||
**kwargs,
|
||||
) -> "Document":
|
||||
"""Return a new document of the given DocType with defaults set.
|
||||
|
||||
:param doctype: DocType of the new document.
|
||||
:param parent_doc: [optional] add to parent document.
|
||||
:param parentfield: [optional] add against this `parentfield`.
|
||||
:param as_dict: [optional] return as dictionary instead of Document.
|
||||
:param kwargs: [optional] You can specify fields as field=value pairs in function call.
|
||||
"""
|
||||
|
||||
from frappe.model.create_new import get_new_doc
|
||||
|
||||
new_doc = get_new_doc(doctype, parent_doc, parentfield, as_dict=as_dict)
|
||||
|
||||
return new_doc.update(kwargs)
|
||||
|
||||
|
||||
def set_value(doctype, docname, fieldname, value=None):
|
||||
"""Set document value. Calls `frappe.client.set_value`"""
|
||||
import frappe.client
|
||||
|
|
@ -930,170 +706,6 @@ def set_value(doctype, docname, fieldname, value=None):
|
|||
return frappe.client.set_value(doctype, docname, fieldname, value)
|
||||
|
||||
|
||||
def get_cached_doc(*args: Any, **kwargs: Any) -> "Document":
|
||||
"""Identical to `frappe.get_doc`, but return from cache if available."""
|
||||
if (key := can_cache_doc(args)) and (doc := cache.get_value(key)):
|
||||
return doc
|
||||
|
||||
# Not found in cache, fetch from DB
|
||||
doc = get_doc(*args, **kwargs)
|
||||
|
||||
# Store in cache
|
||||
if not key:
|
||||
key = get_document_cache_key(doc.doctype, doc.name)
|
||||
|
||||
_set_document_in_cache(key, doc)
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
def _set_document_in_cache(key: str, doc: "Document") -> None:
|
||||
cache.set_value(key, doc, expires_in_sec=3600)
|
||||
|
||||
|
||||
def can_cache_doc(args) -> str | None:
|
||||
"""
|
||||
Determine if document should be cached based on get_doc params.
|
||||
Return cache key if doc can be cached, None otherwise.
|
||||
"""
|
||||
|
||||
if not args:
|
||||
return
|
||||
|
||||
doctype = args[0]
|
||||
name = doctype if len(args) == 1 or args[1] is None else args[1]
|
||||
|
||||
# Only cache if both doctype and name are strings
|
||||
if isinstance(doctype, str) and isinstance(name, str):
|
||||
return get_document_cache_key(doctype, name)
|
||||
|
||||
|
||||
def get_document_cache_key(doctype: str, name: str):
|
||||
return f"document_cache::{doctype}::{name}"
|
||||
|
||||
|
||||
def clear_document_cache(doctype: str, name: str | None = None) -> None:
|
||||
def clear_in_redis():
|
||||
if name is not None:
|
||||
cache.delete_value(get_document_cache_key(doctype, name))
|
||||
else:
|
||||
cache.delete_keys(get_document_cache_key(doctype, ""))
|
||||
|
||||
clear_in_redis()
|
||||
if hasattr(db, "after_commit"):
|
||||
db.after_commit.add(clear_in_redis)
|
||||
db.after_rollback.add(clear_in_redis)
|
||||
|
||||
if doctype == "System Settings" and hasattr(local, "system_settings"):
|
||||
delattr(local, "system_settings")
|
||||
|
||||
if doctype == "Website Settings" and hasattr(local, "website_settings"):
|
||||
delattr(local, "website_settings")
|
||||
|
||||
|
||||
def get_cached_value(
|
||||
doctype: str, name: str | dict, fieldname: str | Iterable[str] = "name", as_dict: bool = False
|
||||
) -> Any:
|
||||
try:
|
||||
doc = get_cached_doc(doctype, name)
|
||||
except DoesNotExistError:
|
||||
clear_last_message()
|
||||
return
|
||||
|
||||
if isinstance(fieldname, str):
|
||||
if as_dict:
|
||||
throw("Cannot make dict for single fieldname")
|
||||
return doc.get(fieldname)
|
||||
|
||||
values = [doc.get(f) for f in fieldname]
|
||||
if as_dict:
|
||||
return _dict(zip(fieldname, values, strict=False))
|
||||
return values
|
||||
|
||||
|
||||
_SingleDocument: TypeAlias = "Document"
|
||||
_NewDocument: TypeAlias = "Document"
|
||||
|
||||
|
||||
@overload
|
||||
def get_doc(document: "Document", /) -> "Document":
|
||||
pass
|
||||
|
||||
|
||||
@overload
|
||||
def get_doc(doctype: str, /) -> _SingleDocument:
|
||||
"""Retrieve Single DocType from DB, doctype must be positional argument."""
|
||||
pass
|
||||
|
||||
|
||||
@overload
|
||||
def get_doc(doctype: str, name: str, /, *, for_update: bool | None = None) -> "Document":
|
||||
"""Retrieve DocType from DB, doctype and name must be positional argument."""
|
||||
pass
|
||||
|
||||
|
||||
@overload
|
||||
def get_doc(**kwargs: dict) -> "_NewDocument":
|
||||
"""Initialize document from kwargs.
|
||||
Not recommended. Use `frappe.new_doc` instead."""
|
||||
pass
|
||||
|
||||
|
||||
@overload
|
||||
def get_doc(documentdict: dict) -> "_NewDocument":
|
||||
"""Create document from dict.
|
||||
Not recommended. Use `frappe.new_doc` instead."""
|
||||
pass
|
||||
|
||||
|
||||
def get_doc(*args: Any, **kwargs: Any) -> "Document":
|
||||
"""Return a `frappe.model.document.Document` object of the given type and name.
|
||||
|
||||
:param arg1: DocType name as string **or** document JSON.
|
||||
:param arg2: [optional] Document name as string.
|
||||
|
||||
Examples:
|
||||
|
||||
# insert a new document
|
||||
todo = frappe.get_doc({"doctype":"ToDo", "description": "test"})
|
||||
todo.insert()
|
||||
|
||||
# open an existing document
|
||||
todo = frappe.get_doc("ToDo", "TD0001")
|
||||
|
||||
"""
|
||||
import frappe.model.document
|
||||
|
||||
return frappe.model.document.get_doc(*args, **kwargs)
|
||||
|
||||
|
||||
def get_last_doc(
|
||||
doctype,
|
||||
filters: FilterSignature | None = None,
|
||||
order_by="creation desc",
|
||||
*,
|
||||
for_update=False,
|
||||
):
|
||||
"""Get last created document of this type."""
|
||||
d = get_all(doctype, filters=filters, limit_page_length=1, order_by=order_by, pluck="name")
|
||||
if d:
|
||||
return get_doc(doctype, d[0], for_update=for_update)
|
||||
else:
|
||||
raise DoesNotExistError(doctype=doctype)
|
||||
|
||||
|
||||
def get_single(doctype):
|
||||
"""Return a `frappe.model.document.Document` object of the given Single doctype."""
|
||||
return get_doc(doctype, doctype)
|
||||
|
||||
|
||||
def get_meta(doctype, cached=True):
|
||||
"""Get `frappe.model.meta.Meta` instance of given doctype name."""
|
||||
import frappe.model.meta
|
||||
|
||||
return frappe.model.meta.get_meta(doctype, cached=cached)
|
||||
|
||||
|
||||
def get_meta_module(doctype):
|
||||
import frappe.modules
|
||||
|
||||
|
|
@ -1137,11 +749,6 @@ def delete_doc(
|
|||
)
|
||||
|
||||
|
||||
def delete_doc_if_exists(doctype, name, force=0):
|
||||
"""Delete document if exists."""
|
||||
delete_doc(doctype, name, force=force, ignore_missing=True)
|
||||
|
||||
|
||||
def reload_doctype(doctype, force=False, reset_permissions=False):
|
||||
"""Reload DocType from model (`[module]/[doctype]/[name]/[name].json`) files."""
|
||||
reload_doc(
|
||||
|
|
@ -1205,7 +812,7 @@ def rename_doc(
|
|||
)
|
||||
|
||||
|
||||
def get_module(modulename: str) -> "ModuleType":
|
||||
def get_module(modulename: str):
|
||||
"""Return a module object for given Python module name using `importlib.import_module`."""
|
||||
return importlib.import_module(modulename)
|
||||
|
||||
|
|
@ -1306,7 +913,7 @@ def get_installed_apps(*, _ensure_on_bench: bool = False) -> list[str]:
|
|||
if not db:
|
||||
connect()
|
||||
|
||||
installed = json.loads(db.get_global("installed_apps") or "[]")
|
||||
installed = orjson.loads(db.get_global("installed_apps") or "[]")
|
||||
|
||||
if _ensure_on_bench:
|
||||
all_apps = cache.get_value("all_apps", get_all_apps)
|
||||
|
|
@ -1564,7 +1171,7 @@ def get_newargs(fn: Callable, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
|
||||
def make_property_setter(
|
||||
args, ignore_validate=False, validate_fields_for_doctype=True, is_system_generated=True
|
||||
args, ignore_validate=False, validate_fields_for_doctype=True, is_system_generated=True, *, module=None
|
||||
):
|
||||
"""Create a new **Property Setter** (for overriding DocType and DocField properties).
|
||||
|
||||
|
|
@ -1603,6 +1210,7 @@ def make_property_setter(
|
|||
"doctype": "Property Setter",
|
||||
"doctype_or_field": args.doctype_or_field,
|
||||
"doc_type": doctype,
|
||||
"module": module,
|
||||
"field_name": args.fieldname,
|
||||
"row_name": args.row_name,
|
||||
"property": args.property,
|
||||
|
|
@ -1625,50 +1233,6 @@ def import_doc(path):
|
|||
import_doc(path)
|
||||
|
||||
|
||||
def copy_doc(doc: "Document", ignore_no_copy: bool = True) -> "Document":
|
||||
"""No_copy fields also get copied."""
|
||||
import copy
|
||||
from types import MappingProxyType
|
||||
|
||||
from frappe.model.base_document import BaseDocument
|
||||
|
||||
def remove_no_copy_fields(d):
|
||||
for df in d.meta.get("fields", {"no_copy": 1}):
|
||||
if hasattr(d, df.fieldname):
|
||||
d.set(df.fieldname, None)
|
||||
|
||||
fields_to_clear = ["name", "owner", "creation", "modified", "modified_by"]
|
||||
|
||||
if not local.flags.in_test:
|
||||
fields_to_clear.append("docstatus")
|
||||
|
||||
if isinstance(doc, BaseDocument) or hasattr(doc, "as_dict"):
|
||||
d = doc.as_dict()
|
||||
elif isinstance(doc, MappingProxyType): # global test record
|
||||
d = dict(doc)
|
||||
else:
|
||||
d = doc
|
||||
|
||||
newdoc = get_doc(copy.deepcopy(d))
|
||||
newdoc.set("__islocal", 1)
|
||||
for fieldname in [*fields_to_clear, "amended_from", "amendment_date"]:
|
||||
newdoc.set(fieldname, None)
|
||||
|
||||
if not ignore_no_copy:
|
||||
remove_no_copy_fields(newdoc)
|
||||
|
||||
for d in newdoc.get_all_children():
|
||||
d.set("__islocal", 1)
|
||||
|
||||
for fieldname in fields_to_clear:
|
||||
d.set(fieldname, None)
|
||||
|
||||
if not ignore_no_copy:
|
||||
remove_no_copy_fields(d)
|
||||
|
||||
return newdoc
|
||||
|
||||
|
||||
def respond_as_web_page(
|
||||
title,
|
||||
html,
|
||||
|
|
@ -1833,7 +1397,7 @@ def get_value(*args, **kwargs):
|
|||
:param as_dict: Return values as dict.
|
||||
:param debug: Print query in error log.
|
||||
"""
|
||||
return db.get_value(*args, **kwargs)
|
||||
return local.db.get_value(*args, **kwargs)
|
||||
|
||||
|
||||
def as_json(obj: dict | list, indent=1, separators=None, ensure_ascii=True) -> str:
|
||||
|
|
@ -1872,96 +1436,6 @@ def are_emails_muted():
|
|||
from frappe.deprecation_dumpster import frappe_get_test_records as get_test_records
|
||||
|
||||
|
||||
def format_value(*args, **kwargs):
|
||||
"""Format value with given field properties.
|
||||
|
||||
:param value: Value to be formatted.
|
||||
:param df: (Optional) DocField object with properties `fieldtype`, `options` etc."""
|
||||
import frappe.utils.formatters
|
||||
|
||||
return frappe.utils.formatters.format_value(*args, **kwargs)
|
||||
|
||||
|
||||
def format(*args, **kwargs):
|
||||
"""Format value with given field properties.
|
||||
|
||||
:param value: Value to be formatted.
|
||||
:param df: (Optional) DocField object with properties `fieldtype`, `options` etc."""
|
||||
import frappe.utils.formatters
|
||||
|
||||
return frappe.utils.formatters.format_value(*args, **kwargs)
|
||||
|
||||
|
||||
def attach_print(
|
||||
doctype,
|
||||
name,
|
||||
file_name=None,
|
||||
print_format=None,
|
||||
style=None,
|
||||
html=None,
|
||||
doc=None,
|
||||
lang=None,
|
||||
print_letterhead=True,
|
||||
password=None,
|
||||
letterhead=None,
|
||||
):
|
||||
from frappe.translate import print_language
|
||||
from frappe.utils import scrub_urls
|
||||
from frappe.utils.pdf import get_pdf
|
||||
|
||||
print_settings = db.get_singles_dict("Print Settings")
|
||||
|
||||
kwargs = dict(
|
||||
print_format=print_format,
|
||||
style=style,
|
||||
doc=doc,
|
||||
no_letterhead=not print_letterhead,
|
||||
letterhead=letterhead,
|
||||
password=password,
|
||||
)
|
||||
|
||||
local.flags.ignore_print_permissions = True
|
||||
|
||||
with print_language(lang or local.lang):
|
||||
content = ""
|
||||
if cint(print_settings.send_print_as_pdf):
|
||||
ext = ".pdf"
|
||||
kwargs["as_pdf"] = True
|
||||
content = (
|
||||
get_pdf(html, options={"password": password} if password else None)
|
||||
if html
|
||||
else get_print(doctype, name, **kwargs)
|
||||
)
|
||||
else:
|
||||
ext = ".html"
|
||||
content = html or scrub_urls(get_print(doctype, name, **kwargs)).encode("utf-8")
|
||||
|
||||
local.flags.ignore_print_permissions = False
|
||||
|
||||
if not file_name:
|
||||
file_name = name
|
||||
file_name = cstr(file_name).replace(" ", "").replace("/", "-") + ext
|
||||
|
||||
return {"fname": file_name, "fcontent": content}
|
||||
|
||||
|
||||
def enqueue(*args, **kwargs):
|
||||
"""
|
||||
Enqueue method to be executed using a background worker
|
||||
|
||||
:param method: method string or method object
|
||||
:param queue: (optional) should be either long, default or short
|
||||
:param timeout: (optional) should be set according to the functions
|
||||
:param event: this is passed to enable clearing of jobs from queues
|
||||
:param is_async: (optional) if is_async=False, the method is executed immediately, else via a worker
|
||||
:param job_name: (optional) can be used to name an enqueue call, which can be used to prevent duplicate calls
|
||||
:param kwargs: keyword arguments to be passed to the method
|
||||
"""
|
||||
import frappe.utils.background_jobs
|
||||
|
||||
return frappe.utils.background_jobs.enqueue(*args, **kwargs)
|
||||
|
||||
|
||||
def task(**task_kwargs):
|
||||
def decorator_task(f):
|
||||
f.enqueue = lambda **fun_kwargs: enqueue(f, **task_kwargs, **fun_kwargs)
|
||||
|
|
@ -1970,22 +1444,6 @@ def task(**task_kwargs):
|
|||
return decorator_task
|
||||
|
||||
|
||||
def enqueue_doc(*args, **kwargs):
|
||||
"""
|
||||
Enqueue method to be executed using a background worker
|
||||
|
||||
:param doctype: DocType of the document on which you want to run the event
|
||||
:param name: Name of the document on which you want to run the event
|
||||
:param method: method string or method object
|
||||
:param queue: (optional) should be either long, default or short
|
||||
:param timeout: (optional) should be set according to the functions
|
||||
:param kwargs: keyword arguments to be passed to the method
|
||||
"""
|
||||
import frappe.utils.background_jobs
|
||||
|
||||
return frappe.utils.background_jobs.enqueue_doc(*args, **kwargs)
|
||||
|
||||
|
||||
def get_doctype_app(doctype):
|
||||
def _get_doctype_app():
|
||||
doctype_module = local.db.get_value("DocType", doctype, "module")
|
||||
|
|
@ -2014,12 +1472,30 @@ def logger(
|
|||
)
|
||||
|
||||
|
||||
def get_desk_link(doctype, name):
|
||||
def get_desk_link(doctype, name, show_title_with_name=False, open_in_new_tab=False):
|
||||
from urllib.parse import quote
|
||||
|
||||
meta = get_meta(doctype)
|
||||
title = get_value(doctype, name, meta.get_title_field())
|
||||
|
||||
html = '<a href="/app/Form/{doctype}/{name}" style="font-weight: bold;">{doctype_local} {title_local}</a>'
|
||||
return html.format(doctype=doctype, name=name, doctype_local=_(doctype), title_local=_(title))
|
||||
target_attr = ' target="_blank"' if open_in_new_tab else ""
|
||||
|
||||
# encode for href
|
||||
encoded_name = quote(name)
|
||||
|
||||
if show_title_with_name and name != title:
|
||||
html = '<a href="/app/Form/{doctype}/{encoded_name}"{target} style="font-weight: bold;">{doctype_local} {name}: {title_local}</a>'
|
||||
else:
|
||||
html = '<a href="/app/Form/{doctype}/{encoded_name}"{target} style="font-weight: bold;">{doctype_local} {title_local}</a>'
|
||||
|
||||
return html.format(
|
||||
doctype=doctype,
|
||||
name=name,
|
||||
encoded_name=encoded_name,
|
||||
doctype_local=_(doctype),
|
||||
title_local=_(title),
|
||||
target=target_attr,
|
||||
)
|
||||
|
||||
|
||||
def get_website_settings(key):
|
||||
|
|
@ -2039,6 +1515,24 @@ def get_active_domains():
|
|||
return get_active_domains()
|
||||
|
||||
|
||||
@request_cache
|
||||
def is_setup_complete():
|
||||
setup_complete = False
|
||||
if not frappe.db.table_exists("Installed Application"):
|
||||
return setup_complete
|
||||
|
||||
if all(
|
||||
frappe.get_all(
|
||||
"Installed Application",
|
||||
{"app_name": ("in", ["frappe", "erpnext"])},
|
||||
pluck="is_setup_complete",
|
||||
)
|
||||
):
|
||||
setup_complete = True
|
||||
|
||||
return setup_complete
|
||||
|
||||
|
||||
@whitelist(allow_guest=True)
|
||||
def ping():
|
||||
return "pong"
|
||||
|
|
@ -2075,10 +1569,33 @@ import frappe._optimizations
|
|||
from frappe.cache_manager import clear_cache, reset_metadata_version
|
||||
from frappe.config import get_common_site_config, get_conf, get_site_config
|
||||
from frappe.core.doctype.system_settings.system_settings import get_system_settings
|
||||
from frappe.model.document import (
|
||||
get_doc,
|
||||
get_lazy_doc,
|
||||
copy_doc,
|
||||
new_doc,
|
||||
get_cached_doc,
|
||||
can_cache_doc,
|
||||
get_document_cache_key,
|
||||
clear_document_cache,
|
||||
get_cached_value,
|
||||
get_single_value,
|
||||
get_last_doc,
|
||||
get_single,
|
||||
_set_document_in_cache,
|
||||
)
|
||||
from frappe.model.meta import get_meta
|
||||
from frappe.realtime import publish_progress, publish_realtime
|
||||
from frappe.utils import mock, parse_json, safe_eval
|
||||
from frappe.utils import get_traceback, mock, parse_json, safe_eval, create_folder
|
||||
from frappe.utils.background_jobs import enqueue, enqueue_doc
|
||||
from frappe.utils.error import log_error
|
||||
from frappe.utils.print_utils import get_print
|
||||
from frappe.utils.formatters import format_value
|
||||
from frappe.utils.print_utils import get_print, attach_print
|
||||
from frappe.email import sendmail
|
||||
|
||||
# for backwards compatibility
|
||||
format = format_value
|
||||
delete_doc_if_exists = delete_doc
|
||||
|
||||
frappe._optimizations.optimize_all()
|
||||
frappe._optimizations.register_fault_handler()
|
||||
|
|
|
|||
|
|
@ -93,9 +93,7 @@ def freeze_gc():
|
|||
|
||||
|
||||
def optimize_for_gil_contention():
|
||||
from frappe.utils import sbool
|
||||
|
||||
if not bool(sbool(os.environ.get("FRAPPE_PERF_PIN_WORKERS", True))):
|
||||
if not os.environ.get("FRAPPE_PERF_PIN_WORKERS"):
|
||||
return
|
||||
|
||||
if "gunicorn" not in str(sys.argv[0]):
|
||||
|
|
|
|||
|
|
@ -41,6 +41,17 @@ def handle(request: Request):
|
|||
`DELETE` will delete
|
||||
"""
|
||||
|
||||
if frappe.get_system_settings("log_api_requests"):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "API Request Log",
|
||||
"path": request.path,
|
||||
"user": frappe.session.user,
|
||||
"method": request.method,
|
||||
}
|
||||
)
|
||||
doc.deferred_insert()
|
||||
|
||||
try:
|
||||
endpoint, arguments = API_URL_MAP.bind_to_environ(request.environ).match()
|
||||
except NotFound: # Wrap 404 - backward compatiblity
|
||||
|
|
|
|||
115
frappe/api/v2.py
115
frappe/api/v2.py
|
|
@ -15,7 +15,7 @@ from werkzeug.routing import Rule
|
|||
|
||||
import frappe
|
||||
import frappe.client
|
||||
from frappe import _, get_newargs, is_whitelisted
|
||||
from frappe import _, cint, cstr, get_newargs, is_whitelisted
|
||||
from frappe.core.doctype.server_script.server_script_utils import get_server_script_map
|
||||
from frappe.handler import is_valid_http_method, run_server_script, upload_file
|
||||
|
||||
|
|
@ -65,17 +65,99 @@ def read_doc(doctype: str, name: str):
|
|||
doc = frappe.get_doc(doctype, name)
|
||||
doc.check_permission("read")
|
||||
doc.apply_fieldlevel_read_permissions()
|
||||
return doc
|
||||
_doc = doc.as_dict()
|
||||
|
||||
for key in _doc:
|
||||
df = doc.meta.get_field(key)
|
||||
if df and df.fieldtype == "Link" and isinstance(_doc.get(key), int):
|
||||
_doc[key] = cstr(_doc.get(key))
|
||||
|
||||
return _doc
|
||||
|
||||
|
||||
def document_list(doctype: str):
|
||||
if frappe.form_dict.get("fields"):
|
||||
frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"])
|
||||
def document_list(doctype: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
GET /api/v2/document/<doctype>?fields=[...],filters={...},...
|
||||
|
||||
# set limit of records for frappe.get_list
|
||||
frappe.form_dict.limit_page_length = frappe.form_dict.limit or 20
|
||||
# evaluate frappe.get_list
|
||||
return frappe.call(frappe.client.get_list, doctype, **frappe.form_dict)
|
||||
REST API endpoint for fetching doctype records
|
||||
|
||||
Args:
|
||||
doctype: DocType name
|
||||
|
||||
Query Parameters (accessible via frappe.form_dict):
|
||||
fields: JSON string of field names to fetch
|
||||
filters: JSON string of filters to apply
|
||||
order_by: Order by field
|
||||
start: Starting offset for pagination (default: 0)
|
||||
limit: Maximum number of records to fetch (default: 20)
|
||||
group_by: Group by field
|
||||
as_dict: Return results as dictionary (default: True)
|
||||
|
||||
Response:
|
||||
frappe.response["data"]: List of document records as dicts
|
||||
frappe.response["has_next_page"]: Indicates if more pages are available
|
||||
|
||||
Controller Customization:
|
||||
Doctype controllers can customize queries by implementing a static get_list(query) method
|
||||
that receives a QueryBuilder object and returns a modified QueryBuilder.
|
||||
|
||||
Example:
|
||||
class Project(Document):
|
||||
@staticmethod
|
||||
def get_list(query):
|
||||
Project = frappe.qb.DocType("Project")
|
||||
if user_has_role("Project Owner"):
|
||||
query = query.where(Project.owner == frappe.session.user)
|
||||
else:
|
||||
query = query.where(Project.is_private == 0)
|
||||
return query
|
||||
"""
|
||||
from frappe.model.base_document import get_controller
|
||||
|
||||
args = frappe.form_dict
|
||||
fields: list | None = frappe.parse_json(args.get("fields", None))
|
||||
filters: dict | None = frappe.parse_json(args.get("filters", None))
|
||||
order_by: str | None = args.get("order_by", None)
|
||||
start: int = cint(args.get("start", 0))
|
||||
limit: int = cint(args.get("limit", 20))
|
||||
group_by: str | None = args.get("group_by", None)
|
||||
debug: bool = args.get("debug", False)
|
||||
as_dict: bool = args.get("as_dict", True)
|
||||
|
||||
query = frappe.qb.get_query(
|
||||
table=doctype,
|
||||
fields=fields,
|
||||
filters=filters,
|
||||
order_by=order_by,
|
||||
offset=start,
|
||||
limit=limit + 1, # Fetch one extra to check if there's a next page
|
||||
group_by=group_by,
|
||||
ignore_permissions=False,
|
||||
)
|
||||
|
||||
# Check if the doctype controller has a static get_list method
|
||||
controller = get_controller(doctype)
|
||||
if hasattr(controller, "get_list"):
|
||||
try:
|
||||
return_value = controller.get_list(query)
|
||||
|
||||
if return_value is not None:
|
||||
# Validate that the returned value has a run method (is a QueryBuilder-like object)
|
||||
if not hasattr(return_value, "run"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Custom get_list method for {0} must return a QueryBuilder object or None, got {1}"
|
||||
).format(doctype, type(return_value).__name__)
|
||||
)
|
||||
|
||||
query = return_value
|
||||
|
||||
except Exception as e:
|
||||
frappe.throw(_("Error in {0}.get_list: {1}").format(doctype, str(e)))
|
||||
|
||||
data = query.run(as_dict=as_dict, debug=debug)
|
||||
frappe.response["has_next_page"] = len(data) > limit
|
||||
return data[:limit]
|
||||
|
||||
|
||||
def count(doctype: str) -> int:
|
||||
|
|
@ -89,7 +171,13 @@ def count(doctype: str) -> int:
|
|||
def create_doc(doctype: str):
|
||||
data = frappe.form_dict
|
||||
data.pop("doctype", None)
|
||||
return frappe.new_doc(doctype, **data).insert()
|
||||
|
||||
doc = frappe.new_doc(doctype, **data)
|
||||
|
||||
if (name := data.get("name")) and isinstance(name, str | int):
|
||||
doc.flags.name_set = True
|
||||
|
||||
return doc.insert().as_dict()
|
||||
|
||||
|
||||
def copy_doc(doctype: str, name: str, ignore_no_copy: bool = True):
|
||||
|
|
@ -110,12 +198,13 @@ def update_doc(doctype: str, name: str):
|
|||
data.pop("flags", None)
|
||||
doc.update(data)
|
||||
doc.save()
|
||||
doc.apply_fieldlevel_read_permissions()
|
||||
|
||||
# check for child table doctype
|
||||
if doc.get("parenttype"):
|
||||
frappe.get_doc(doc.parenttype, doc.parent).save()
|
||||
|
||||
return doc
|
||||
return doc.as_dict()
|
||||
|
||||
|
||||
def delete_doc(doctype: str, name: str):
|
||||
|
|
@ -141,7 +230,9 @@ def execute_doc_method(doctype: str, name: str, method: str | None = None):
|
|||
doc.is_whitelisted(method)
|
||||
|
||||
doc.check_permission(PERMISSION_MAP[frappe.request.method])
|
||||
return doc.run_method(method, **frappe.form_dict)
|
||||
result = doc.run_method(method, **frappe.form_dict)
|
||||
frappe.response.docs.append(doc.as_dict())
|
||||
return result
|
||||
|
||||
|
||||
def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None):
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import functools
|
|||
import logging
|
||||
import os
|
||||
|
||||
import orjson
|
||||
from werkzeug.exceptions import HTTPException, NotFound
|
||||
from werkzeug.middleware.profiler import ProfilerMiddleware
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
|
@ -21,6 +22,7 @@ import frappe.recorder
|
|||
import frappe.utils.response
|
||||
from frappe import _
|
||||
from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest, check_request_ip, validate_auth
|
||||
from frappe.integrations.oauth2 import get_resource_url, handle_wellknown, is_oauth_metadata_enabled
|
||||
from frappe.middlewares import StaticDataMiddleware
|
||||
from frappe.permissions import handle_does_not_exist_error
|
||||
from frappe.utils import CallbackManager, cint, get_site_name
|
||||
|
|
@ -65,6 +67,11 @@ import frappe.website.website_generator # web page doctypes
|
|||
|
||||
# end: module pre-loading
|
||||
|
||||
# better werkzeug default
|
||||
# this is necessary because frappe desk sends most requests as form data
|
||||
# and some of them can exceed werkzeug's default limit of 500kb
|
||||
Request.max_form_memory_size = None
|
||||
|
||||
|
||||
def after_response_wrapper(app):
|
||||
"""Wrap a WSGI application to call after_response hooks after we have responded.
|
||||
|
|
@ -92,8 +99,6 @@ def application(request: Request):
|
|||
response = None
|
||||
|
||||
try:
|
||||
rollback = True
|
||||
|
||||
init_request(request)
|
||||
|
||||
validate_auth()
|
||||
|
|
@ -121,29 +126,28 @@ def application(request: Request):
|
|||
elif request.path.startswith("/private/files/"):
|
||||
response = frappe.utils.response.download_private_file(request.path)
|
||||
|
||||
elif request.path.startswith("/.well-known/") and request.method == "GET":
|
||||
response = handle_wellknown(request.path)
|
||||
|
||||
elif request.method in ("GET", "HEAD", "POST"):
|
||||
response = get_response()
|
||||
|
||||
else:
|
||||
raise NotFound
|
||||
|
||||
except HTTPException as e:
|
||||
return e
|
||||
|
||||
except Exception as e:
|
||||
response = handle_exception(e)
|
||||
response = e.get_response(request.environ) if isinstance(e, HTTPException) else handle_exception(e)
|
||||
if db := getattr(frappe.local, "db", None):
|
||||
db.rollback(chain=True)
|
||||
|
||||
else:
|
||||
rollback = sync_database(rollback)
|
||||
sync_database()
|
||||
|
||||
finally:
|
||||
# Important note:
|
||||
# this function *must* always return a response, hence any exception thrown outside of
|
||||
# try..catch block like this finally block needs to be handled appropriately.
|
||||
|
||||
if rollback and request.method in UNSAFE_HTTP_METHODS and frappe.db:
|
||||
frappe.db.rollback()
|
||||
|
||||
try:
|
||||
run_after_request_hooks(request, response)
|
||||
except Exception:
|
||||
|
|
@ -177,14 +181,13 @@ def init_request(request):
|
|||
# site does not exist
|
||||
raise NotFound
|
||||
|
||||
frappe.connect(set_admin_as_user=False)
|
||||
if frappe.local.conf.maintenance_mode:
|
||||
frappe.connect()
|
||||
if frappe.local.conf.allow_reads_during_maintenance:
|
||||
setup_read_only_mode()
|
||||
else:
|
||||
raise frappe.SessionStopped("Session Stopped")
|
||||
else:
|
||||
frappe.connect(set_admin_as_user=False)
|
||||
|
||||
if request.path.startswith("/api/method/upload_file"):
|
||||
from frappe.core.api.file import get_max_file_size
|
||||
|
||||
|
|
@ -256,6 +259,9 @@ def process_response(response: Response):
|
|||
if hasattr(frappe.local, "conf"):
|
||||
set_cors_headers(response)
|
||||
|
||||
if response.status_code in (401, 403) and is_oauth_metadata_enabled("resource"):
|
||||
set_authenticate_headers(response)
|
||||
|
||||
# Update custom headers added during request processing
|
||||
response.headers.update(frappe.local.response_headers)
|
||||
|
||||
|
|
@ -269,10 +275,12 @@ def process_response(response: Response):
|
|||
|
||||
|
||||
def set_cors_headers(response):
|
||||
allowed_origins = frappe.conf.allow_cors
|
||||
if hasattr(frappe.local, "allow_cors"):
|
||||
allowed_origins = frappe.local.allow_cors
|
||||
|
||||
if not (
|
||||
(allowed_origins := frappe.conf.allow_cors)
|
||||
and (request := frappe.local.request)
|
||||
and (origin := request.headers.get("Origin"))
|
||||
allowed_origins and (request := frappe.local.request) and (origin := request.headers.get("Origin"))
|
||||
):
|
||||
return
|
||||
|
||||
|
|
@ -303,12 +311,17 @@ def set_cors_headers(response):
|
|||
response.headers.update(cors_headers)
|
||||
|
||||
|
||||
def make_form_dict(request: Request):
|
||||
import json
|
||||
def set_authenticate_headers(response: Response):
|
||||
headers = {
|
||||
"WWW-Authenticate": f'Bearer resource_metadata="{get_resource_url()}/.well-known/oauth-protected-resource"'
|
||||
}
|
||||
response.headers.update(headers)
|
||||
|
||||
|
||||
def make_form_dict(request: Request):
|
||||
request_data = request.get_data(as_text=True)
|
||||
if request_data and request.is_json:
|
||||
args = json.loads(request_data)
|
||||
args = orjson.loads(request_data)
|
||||
else:
|
||||
args = {}
|
||||
args.update(request.args or {})
|
||||
|
|
@ -397,21 +410,21 @@ def handle_exception(e):
|
|||
return response
|
||||
|
||||
|
||||
def sync_database(rollback: bool) -> bool:
|
||||
def sync_database():
|
||||
db = getattr(frappe.local, "db", None)
|
||||
if not db:
|
||||
# db isn't initialized, can't commit or rollback
|
||||
return
|
||||
|
||||
# if HTTP method would change server state, commit if necessary
|
||||
if frappe.db and (frappe.local.flags.commit or frappe.local.request.method in UNSAFE_HTTP_METHODS):
|
||||
frappe.db.commit()
|
||||
rollback = False
|
||||
elif frappe.db:
|
||||
frappe.db.rollback()
|
||||
rollback = False
|
||||
if frappe.local.request.method in UNSAFE_HTTP_METHODS or frappe.local.flags.commit:
|
||||
db.commit(chain=True)
|
||||
else:
|
||||
db.rollback(chain=True)
|
||||
|
||||
# update session
|
||||
if session := getattr(frappe.local, "session_obj", None):
|
||||
if session.update():
|
||||
rollback = False
|
||||
|
||||
return rollback
|
||||
frappe.request.after_response.add(session.update)
|
||||
|
||||
|
||||
# Always initialize sentry SDK if the DSN is sent
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ import re
|
|||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.installed_applications.installed_applications import (
|
||||
get_apps_with_incomplete_dependencies,
|
||||
get_setup_wizard_completed_apps,
|
||||
get_setup_wizard_not_required_apps,
|
||||
)
|
||||
|
||||
# check if route is /app or /app/* and not /app1 or /app1/*
|
||||
DESK_APP_PATTERN = re.compile(r"^/app(/.*)?$")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -12,23 +20,33 @@ def get_apps():
|
|||
apps = frappe.get_installed_apps()
|
||||
app_list = []
|
||||
for app in apps:
|
||||
if (
|
||||
app not in get_setup_wizard_completed_apps()
|
||||
and app not in get_setup_wizard_not_required_apps()
|
||||
and "System Manager" not in frappe.get_roles()
|
||||
):
|
||||
continue
|
||||
|
||||
if app == "frappe":
|
||||
continue
|
||||
app_details = frappe.get_hooks("add_to_apps_screen", app_name=app)
|
||||
if not len(app_details):
|
||||
continue
|
||||
for app_detail in app_details:
|
||||
has_permission_path = app_detail.get("has_permission")
|
||||
if has_permission_path and not frappe.get_attr(has_permission_path)():
|
||||
continue
|
||||
app_list.append(
|
||||
{
|
||||
"name": app,
|
||||
"logo": app_detail.get("logo"),
|
||||
"title": _(app_detail.get("title")),
|
||||
"route": app_detail.get("route"),
|
||||
}
|
||||
)
|
||||
try:
|
||||
has_permission_path = app_detail.get("has_permission")
|
||||
if has_permission_path and not frappe.get_attr(has_permission_path)():
|
||||
continue
|
||||
app_list.append(
|
||||
{
|
||||
"name": app,
|
||||
"logo": app_detail.get("logo"),
|
||||
"title": _(app_detail.get("title")),
|
||||
"route": app_detail.get("route"),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
frappe.log_error(f"Failed to call has_permission hook ({has_permission_path}) for {app}")
|
||||
return app_list
|
||||
|
||||
|
||||
|
|
@ -40,10 +58,8 @@ def get_route(app_name):
|
|||
|
||||
def is_desk_apps(apps):
|
||||
for app in apps:
|
||||
# check if route is /app or /app/* and not /app1 or /app1/*
|
||||
pattern = r"^/app(/.*)?$"
|
||||
route = app.get("route")
|
||||
if route and not re.match(pattern, route):
|
||||
if route and not re.match(DESK_APP_PATTERN, route):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
|
@ -56,7 +72,7 @@ def get_default_path():
|
|||
return None
|
||||
|
||||
system_default_app = frappe.get_system_settings("default_app")
|
||||
user_default_app = frappe.db.get_value("User", frappe.session.user, "default_app")
|
||||
user_default_app = frappe.get_cached_value("User", frappe.session.user, "default_app")
|
||||
if system_default_app and not user_default_app:
|
||||
return get_route(system_default_app)
|
||||
elif user_default_app:
|
||||
|
|
@ -75,3 +91,24 @@ def set_app_as_default(app_name):
|
|||
frappe.db.set_value("User", frappe.session.user, "default_app", "")
|
||||
else:
|
||||
frappe.db.set_value("User", frappe.session.user, "default_app", app_name)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_incomplete_setup_route(current_app, app_route):
|
||||
pending_apps = get_apps_with_incomplete_dependencies(current_app)
|
||||
|
||||
if not pending_apps:
|
||||
return app_route
|
||||
|
||||
for app in pending_apps:
|
||||
if app == "frappe":
|
||||
return "app"
|
||||
|
||||
app_details = frappe.get_hooks("add_to_apps_screen", app_name=app)
|
||||
if not app_details:
|
||||
continue
|
||||
|
||||
if route := app_details[0].get("route"):
|
||||
return route
|
||||
|
||||
return app_route
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ class LoginManager:
|
|||
self.make_session(resume=True)
|
||||
self.get_user_info()
|
||||
self.set_user_info(resume=True)
|
||||
except AttributeError:
|
||||
except (AttributeError, frappe.DoesNotExistError):
|
||||
self.user = "Guest"
|
||||
self.get_user_info()
|
||||
self.make_session()
|
||||
|
|
@ -487,7 +487,10 @@ def validate_ip_address(user):
|
|||
if bypass_restrict_ip_check:
|
||||
return
|
||||
|
||||
frappe.throw(_("Access not allowed from this IP Address"), frappe.AuthenticationError)
|
||||
frappe.throw(
|
||||
_("Access not allowed from this IP Address") + f": {frappe.local.request_ip}",
|
||||
frappe.AuthenticationError,
|
||||
)
|
||||
|
||||
|
||||
def get_login_attempt_tracker(key: str, raise_locked_exception: bool = True):
|
||||
|
|
@ -701,17 +704,22 @@ def validate_auth_via_api_keys(authorization_header):
|
|||
|
||||
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
|
||||
"""frappe_authorization_source to provide api key and secret for a doctype apart from User"""
|
||||
if not api_key or not api_secret:
|
||||
raise frappe.AuthenticationError
|
||||
|
||||
doctype = frappe_authorization_source or "User"
|
||||
doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"])
|
||||
if not doc:
|
||||
docname = frappe.db.get_value(
|
||||
doctype=doctype, filters={"api_key": api_key, "enabled": True}, fieldname=["name"]
|
||||
)
|
||||
if not docname:
|
||||
raise frappe.AuthenticationError
|
||||
form_dict = frappe.local.form_dict
|
||||
doc_secret = get_decrypted_password(doctype, doc, fieldname="api_secret")
|
||||
if api_secret == doc_secret:
|
||||
doc_secret = get_decrypted_password(doctype, docname, fieldname="api_secret", raise_exception=False)
|
||||
if doc_secret and api_secret == doc_secret:
|
||||
if doctype == "User":
|
||||
user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"])
|
||||
else:
|
||||
user = frappe.db.get_value(doctype, doc, "user")
|
||||
user = frappe.db.get_value(doctype, docname, "user")
|
||||
if frappe.local.login_manager.user in ("", "Guest"):
|
||||
frappe.set_user(user)
|
||||
frappe.local.form_dict = form_dict
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ def get_assignments(doc) -> list[dict]:
|
|||
"ToDo",
|
||||
fields=["name", "assignment_rule"],
|
||||
filters=dict(
|
||||
reference_type=doc.get("doctype"), reference_name=doc.get("name"), status=("!=", "Cancelled")
|
||||
reference_type=doc.get("doctype"), reference_name=str(doc.get("name")), status=("!=", "Cancelled")
|
||||
),
|
||||
limit=5,
|
||||
)
|
||||
|
|
@ -220,7 +220,7 @@ def reopen_closed_assignment(doc):
|
|||
"ToDo",
|
||||
filters={
|
||||
"reference_type": doc.doctype,
|
||||
"reference_name": doc.name,
|
||||
"reference_name": str(doc.name),
|
||||
"status": "Closed",
|
||||
},
|
||||
pluck="name",
|
||||
|
|
@ -312,7 +312,7 @@ def apply(doc=None, method=None, doctype=None, name=None):
|
|||
"ToDo",
|
||||
filters={
|
||||
"reference_type": doc.doctype,
|
||||
"reference_name": doc.name,
|
||||
"reference_name": str(doc.name),
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
|
@ -367,7 +367,7 @@ def update_due_date(doc, state=None):
|
|||
filters={
|
||||
"assignment_rule": rule.get("name"),
|
||||
"reference_type": doc.doctype,
|
||||
"reference_name": doc.name,
|
||||
"reference_name": str(doc.name),
|
||||
"status": "Open",
|
||||
},
|
||||
pluck="name",
|
||||
|
|
|
|||
|
|
@ -2,21 +2,12 @@
|
|||
# License: MIT. See LICENSE
|
||||
|
||||
import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.tests.utils import make_test_records
|
||||
|
||||
TEST_DOCTYPE = "Assignment Test"
|
||||
|
||||
|
||||
class UnitTestAssignmentRule(UnitTestCase):
|
||||
"""
|
||||
Unit tests for AssignmentRule.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestAutoAssign(IntegrationTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ frappe.ui.form.on("Auto Repeat", {
|
|||
frappe.model.with_doc("Email Template", frm.doc.template, () => {
|
||||
let email_template = frappe.get_doc("Email Template", frm.doc.template);
|
||||
frm.set_value("subject", email_template.subject);
|
||||
frm.set_value("message", email_template.response);
|
||||
let message_value = email_template.response;
|
||||
if (email_template.use_html) message_value = email_template.response_html;
|
||||
frm.set_value("message", message_value);
|
||||
frm.refresh_field("subject");
|
||||
frm.refresh_field("message");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
"repeat_on_last_day",
|
||||
"column_break_12",
|
||||
"next_schedule_date",
|
||||
"section_break_looa",
|
||||
"generate_separate_documents_for_each_assignee",
|
||||
"assignee",
|
||||
"section_break_16",
|
||||
"repeat_on_days",
|
||||
"notification",
|
||||
|
|
@ -86,7 +89,7 @@
|
|||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Frequency",
|
||||
"options": "\nDaily\nWeekly\nMonthly\nQuarterly\nHalf-yearly\nYearly",
|
||||
"options": "\nDaily\nWeekly\nFortnightly\nMonthly\nQuarterly\nHalf-yearly\nYearly",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
|
|
@ -198,10 +201,26 @@
|
|||
"depends_on": "eval:doc.frequency==='Weekly';",
|
||||
"fieldname": "section_break_16",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "generate_separate_documents_for_each_assignee",
|
||||
"fieldtype": "Check",
|
||||
"label": "Generate Separate Documents For Each Assignee"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_looa",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "assignee",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"label": "Assignee",
|
||||
"options": "Auto Repeat User"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2025-01-20 14:15:55.287788",
|
||||
"modified": "2025-06-09 18:20:23.775881",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Automation",
|
||||
"name": "Auto Repeat",
|
||||
|
|
@ -245,10 +264,11 @@
|
|||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "reference_document",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "reference_document",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from frappe.contacts.doctype.contact.contact import (
|
|||
get_contacts_linking_to,
|
||||
)
|
||||
from frappe.core.doctype.communication.email import make
|
||||
from frappe.desk.form import assign_to
|
||||
from frappe.desk.form.assign_to import add as assign_to
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
|
|
@ -49,11 +49,16 @@ class AutoRepeat(Document):
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.automation.doctype.auto_repeat_day.auto_repeat_day import AutoRepeatDay
|
||||
from frappe.automation.doctype.auto_repeat_user.auto_repeat_user import AutoRepeatUser
|
||||
from frappe.types import DF
|
||||
|
||||
assignee: DF.TableMultiSelect[AutoRepeatUser]
|
||||
disabled: DF.Check
|
||||
end_date: DF.Date | None
|
||||
frequency: DF.Literal["", "Daily", "Weekly", "Monthly", "Quarterly", "Half-yearly", "Yearly"]
|
||||
frequency: DF.Literal[
|
||||
"", "Daily", "Weekly", "Fortnightly", "Monthly", "Quarterly", "Half-yearly", "Yearly"
|
||||
]
|
||||
generate_separate_documents_for_each_assignee: DF.Check
|
||||
message: DF.Text | None
|
||||
next_schedule_date: DF.Date | None
|
||||
notify_by_email: DF.Check
|
||||
|
|
@ -86,10 +91,9 @@ class AutoRepeat(Document):
|
|||
validate_template(self.message or "")
|
||||
|
||||
def before_insert(self):
|
||||
if not frappe.flags.in_test:
|
||||
start_date = getdate(self.start_date)
|
||||
today_date = getdate(today())
|
||||
if start_date <= today_date:
|
||||
if not frappe.in_test:
|
||||
today_date = getdate()
|
||||
if getdate(self.start_date) < today_date:
|
||||
self.start_date = today_date
|
||||
|
||||
def on_update(self):
|
||||
|
|
@ -112,7 +116,7 @@ class AutoRepeat(Document):
|
|||
frappe.db.set_value(self.reference_doctype, self.reference_document, "auto_repeat", "")
|
||||
|
||||
def validate_reference_doctype(self):
|
||||
if frappe.flags.in_test or frappe.flags.in_patch:
|
||||
if frappe.in_test or frappe.flags.in_patch:
|
||||
return
|
||||
if not frappe.get_meta(self.reference_doctype).allow_auto_repeat:
|
||||
frappe.throw(
|
||||
|
|
@ -134,14 +138,18 @@ class AutoRepeat(Document):
|
|||
return
|
||||
|
||||
if self.end_date:
|
||||
end_date = getdate(self.end_date)
|
||||
|
||||
self.validate_from_to_dates("start_date", "end_date")
|
||||
|
||||
if self.end_date == self.start_date:
|
||||
frappe.throw(
|
||||
_("{0} should not be same as {1}").format(
|
||||
frappe.bold(_("End Date")), frappe.bold(_("Start Date"))
|
||||
if end_date == getdate():
|
||||
frappe.throw(_("End Date cannot be today."))
|
||||
if end_date == getdate(self.start_date):
|
||||
frappe.throw(
|
||||
_("{0} should not be same as {1}").format(
|
||||
frappe.bold(_("End Date")), frappe.bold(_("Start Date"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def validate_email_id(self):
|
||||
if self.notify_by_email:
|
||||
|
|
@ -219,9 +227,16 @@ class AutoRepeat(Document):
|
|||
|
||||
def create_documents(self):
|
||||
try:
|
||||
new_doc = self.make_new_document()
|
||||
if self.generate_separate_documents_for_each_assignee and self.assignee:
|
||||
new_docs = self.make_new_documents()
|
||||
else:
|
||||
new_docs = self.make_new_document([assignee.user for assignee in self.assignee])
|
||||
if self.notify_by_email and self.recipients:
|
||||
self.send_notification(new_doc)
|
||||
if isinstance(new_docs, list):
|
||||
for new_doc in new_docs:
|
||||
self.send_notification(new_doc)
|
||||
else:
|
||||
self.send_notification(new_docs)
|
||||
except Exception:
|
||||
error_log = self.log_error(
|
||||
_("Auto repeat failed. Please enable auto repeat after fixing the issues.")
|
||||
|
|
@ -229,15 +244,34 @@ class AutoRepeat(Document):
|
|||
|
||||
self.disable_auto_repeat()
|
||||
|
||||
if self.reference_document and not frappe.flags.in_test:
|
||||
if self.reference_document and not frappe.in_test:
|
||||
self.notify_error_to_user(error_log)
|
||||
|
||||
def make_new_document(self):
|
||||
def make_new_documents(self):
|
||||
docs = []
|
||||
for assignee in self.assignee:
|
||||
new_doc = self.make_new_document(assignee=[assignee.user])
|
||||
docs.append(new_doc)
|
||||
return docs
|
||||
|
||||
def make_new_document(self, assignee=None):
|
||||
reference_doc = frappe.get_doc(self.reference_doctype, self.reference_document)
|
||||
new_doc = frappe.copy_doc(reference_doc, ignore_no_copy=False)
|
||||
self.update_doc(new_doc, reference_doc)
|
||||
new_doc.flags.updater_reference = {
|
||||
"doctype": self.doctype,
|
||||
"docname": self.name,
|
||||
"label": _("via Auto Repeat"),
|
||||
}
|
||||
new_doc.insert(ignore_permissions=True)
|
||||
|
||||
if assignee:
|
||||
args = {
|
||||
"assign_to": assignee,
|
||||
"doctype": self.reference_doctype,
|
||||
"name": new_doc.name,
|
||||
"description": new_doc.get_title(),
|
||||
}
|
||||
assign_to(args=args)
|
||||
if self.submit_on_creation:
|
||||
new_doc.submit()
|
||||
|
||||
|
|
@ -343,6 +377,8 @@ class AutoRepeat(Document):
|
|||
def get_days(self, schedule_date):
|
||||
if self.frequency == "Weekly":
|
||||
days = self.get_offset_for_weekly_frequency(schedule_date)
|
||||
elif self.frequency == "Fortnightly":
|
||||
days = 14
|
||||
else:
|
||||
# daily frequency
|
||||
days = 1
|
||||
|
|
@ -483,9 +519,7 @@ def make_auto_repeat_entry():
|
|||
if not jobs or enqueued_method not in jobs[frappe.local.site]:
|
||||
date = getdate(today())
|
||||
data = get_auto_repeat_entries(date)
|
||||
frappe.enqueue(enqueued_method, data=data)
|
||||
# Set auto-repeat to complete when all auto-repeats are added to the queue
|
||||
set_auto_repeat_as_completed(data)
|
||||
frappe.enqueue(enqueued_method, data=data, queue="long")
|
||||
|
||||
|
||||
def create_repeated_entries(data):
|
||||
|
|
@ -501,6 +535,10 @@ def create_repeated_entries(data):
|
|||
if schedule_date and not doc.disabled:
|
||||
frappe.db.set_value("Auto Repeat", doc.name, "next_schedule_date", schedule_date)
|
||||
|
||||
if doc.is_completed():
|
||||
doc.status = "Completed"
|
||||
doc.save()
|
||||
|
||||
|
||||
def get_auto_repeat_entries(date=None):
|
||||
if not date:
|
||||
|
|
@ -517,14 +555,6 @@ def get_auto_repeat_entries(date=None):
|
|||
return query.run(as_dict=1)
|
||||
|
||||
|
||||
def set_auto_repeat_as_completed(auto_repeat):
|
||||
for entry in auto_repeat:
|
||||
doc = frappe.get_doc("Auto Repeat", entry.name)
|
||||
if doc.is_completed():
|
||||
doc.status = "Completed"
|
||||
doc.save()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_auto_repeat(doctype, docname, frequency="Daily", start_date=None, end_date=None):
|
||||
if not start_date:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from frappe.automation.doctype.auto_repeat.auto_repeat import (
|
|||
week_map,
|
||||
)
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.utils import add_days, add_months, getdate, today
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -32,15 +32,6 @@ def add_custom_fields() -> "CustomField":
|
|||
)
|
||||
|
||||
|
||||
class UnitTestAutoRepeat(UnitTestCase):
|
||||
"""
|
||||
Unit tests for AutoRepeat.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestAutoRepeat(IntegrationTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
|
|
@ -94,6 +85,32 @@ class TestAutoRepeat(IntegrationTestCase):
|
|||
|
||||
self.assertEqual(todo.get("description"), new_todo.get("description"))
|
||||
|
||||
def test_fortnightly_auto_repeat(self):
|
||||
todo = frappe.get_doc(
|
||||
doctype="ToDo", description="test fortnightly todo", assigned_by="Administrator"
|
||||
).insert()
|
||||
|
||||
doc = make_auto_repeat(
|
||||
reference_doctype="ToDo",
|
||||
frequency="Fortnightly",
|
||||
reference_document=todo.name,
|
||||
start_date=add_days(today(), -14),
|
||||
)
|
||||
|
||||
self.assertEqual(doc.next_schedule_date, today())
|
||||
data = get_auto_repeat_entries(getdate(today()))
|
||||
create_repeated_entries(data)
|
||||
frappe.db.commit()
|
||||
|
||||
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
|
||||
self.assertEqual(todo.auto_repeat, doc.name)
|
||||
|
||||
new_todo = frappe.db.get_value("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
|
||||
|
||||
new_todo = frappe.get_doc("ToDo", new_todo)
|
||||
|
||||
self.assertEqual(todo.get("description"), new_todo.get("description"))
|
||||
|
||||
def test_weekly_auto_repeat_with_weekdays(self):
|
||||
todo = frappe.get_doc(
|
||||
doctype="ToDo", description="test auto repeat with weekdays", assigned_by="Administrator"
|
||||
|
|
@ -230,6 +247,68 @@ class TestAutoRepeat(IntegrationTestCase):
|
|||
)
|
||||
self.assertEqual(docnames[0].docstatus, 1)
|
||||
|
||||
def test_auto_repeat_assignee(self):
|
||||
todo = frappe.get_doc(
|
||||
doctype="ToDo", description="test assignee todo", assigned_by="Administrator"
|
||||
).insert()
|
||||
|
||||
doc = make_auto_repeat(reference_document=todo.name)
|
||||
doc.update(
|
||||
{
|
||||
"assignee": [
|
||||
{"user": "Administrator"},
|
||||
{"user": "Guest"},
|
||||
]
|
||||
}
|
||||
)
|
||||
doc.save()
|
||||
self.assertEqual(doc.next_schedule_date, today())
|
||||
data = get_auto_repeat_entries(getdate(today()))
|
||||
create_repeated_entries(data)
|
||||
frappe.db.commit()
|
||||
|
||||
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
|
||||
self.assertEqual(todo.auto_repeat, doc.name)
|
||||
|
||||
new_todo = frappe.db.get_value("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
|
||||
|
||||
new_todo = frappe.get_doc("ToDo", new_todo)
|
||||
self.assertEqual(todo.get("description"), new_todo.get("description"))
|
||||
self.assertListEqual(
|
||||
sorted(list(new_todo.get_assigned_users())),
|
||||
sorted(["Administrator", "Guest"]),
|
||||
)
|
||||
|
||||
def test_auto_repeat_assignee_with_separate_documents(self):
|
||||
todo = frappe.get_doc(
|
||||
doctype="ToDo",
|
||||
description="test assignee todo with multiple doc",
|
||||
assigned_by="Administrator",
|
||||
).insert()
|
||||
|
||||
doc = make_auto_repeat(reference_document=todo.name)
|
||||
doc.update(
|
||||
{
|
||||
"assignee": [
|
||||
{"user": "Administrator"},
|
||||
{"user": "Guest"},
|
||||
],
|
||||
"generate_separate_documents_for_each_assignee": 1,
|
||||
}
|
||||
)
|
||||
doc.save()
|
||||
self.assertEqual(doc.next_schedule_date, today())
|
||||
data = get_auto_repeat_entries(getdate(today()))
|
||||
create_repeated_entries(data)
|
||||
frappe.db.commit()
|
||||
|
||||
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
|
||||
self.assertEqual(todo.auto_repeat, doc.name)
|
||||
|
||||
new_todo_count = frappe.db.count("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
|
||||
|
||||
self.assertEqual(new_todo_count, 2)
|
||||
|
||||
|
||||
def make_auto_repeat(**args):
|
||||
args = frappe._dict(args)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-06-09 18:19:22.034128",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"user"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "User",
|
||||
"options": "User",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-09 18:19:41.543336",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Automation",
|
||||
"name": "Auto Repeat User",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
# Copyright (c) 2021, Frappe Technologies and contributors
|
||||
# Copyright (c) 2025, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class NewsletterAttachment(Document):
|
||||
class AutoRepeatUser(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
|
@ -14,10 +14,10 @@ class NewsletterAttachment(Document):
|
|||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
attachment: DF.Attach
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
user: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
|
@ -1,16 +1,7 @@
|
|||
# Copyright (c) 2019, Frappe Technologies and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestMilestone(UnitTestCase):
|
||||
"""
|
||||
Unit tests for Milestone.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestMilestone(IntegrationTestCase):
|
||||
|
|
|
|||
|
|
@ -2,16 +2,7 @@
|
|||
# License: MIT. See LICENSE
|
||||
import frappe
|
||||
import frappe.cache_manager
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestMilestoneTracker(UnitTestCase):
|
||||
"""
|
||||
Unit tests for MilestoneTracker.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestMilestoneTracker(IntegrationTestCase):
|
||||
|
|
|
|||
|
|
@ -4,19 +4,10 @@
|
|||
import frappe
|
||||
from frappe.automation.doctype.reminder.reminder import create_new_reminder, send_reminders
|
||||
from frappe.desk.doctype.notification_log.notification_log import get_notification_logs
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.utils import add_to_date, now_datetime
|
||||
|
||||
|
||||
class UnitTestReminder(UnitTestCase):
|
||||
"""
|
||||
Unit tests for Reminder.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestReminder(IntegrationTestCase):
|
||||
def test_reminder(self):
|
||||
description = "TEST_REMINDER"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"charts": [],
|
||||
"content": "[{\"id\":\"sR-UFcO7II\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Import Data\",\"col\":3}},{\"id\":\"IkcVmgWb3z\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"ToDo\",\"col\":3}},{\"id\":\"6wir-jZFRE\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File\",\"col\":3}},{\"id\":\"45a1jzQkTm\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Assignment Rule\",\"col\":3}},{\"id\":\"EgtURZsoiF\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"0yceBIfhHM\",\"type\":\"card\",\"data\":{\"card_name\":\"Data\",\"col\":4}},{\"id\":\"42WbBA9rpj\",\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"id\":\"wE9n7TIrAc\",\"type\":\"card\",\"data\":{\"card_name\":\"Alerts and Notifications\",\"col\":4}},{\"id\":\"7_U7_xCOos\",\"type\":\"card\",\"data\":{\"card_name\":\"Email\",\"col\":4}},{\"id\":\"3imoh2oqsJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Printing\",\"col\":4}},{\"id\":\"SlYKJZj5r3\",\"type\":\"card\",\"data\":{\"card_name\":\"Automation\",\"col\":4}},{\"id\":\"O7jrc2YQTN\",\"type\":\"card\",\"data\":{\"card_name\":\"Newsletter\",\"col\":4}}]",
|
||||
"content": "[{\"id\":\"sR-UFcO7II\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Import Data\",\"col\":3}},{\"id\":\"IkcVmgWb3z\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"ToDo\",\"col\":3}},{\"id\":\"6wir-jZFRE\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File\",\"col\":3}},{\"id\":\"45a1jzQkTm\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Assignment Rule\",\"col\":3}},{\"id\":\"EgtURZsoiF\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"0yceBIfhHM\",\"type\":\"card\",\"data\":{\"card_name\":\"Data\",\"col\":4}},{\"id\":\"42WbBA9rpj\",\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"id\":\"wE9n7TIrAc\",\"type\":\"card\",\"data\":{\"card_name\":\"Alerts and Notifications\",\"col\":4}},{\"id\":\"7_U7_xCOos\",\"type\":\"card\",\"data\":{\"card_name\":\"Email\",\"col\":4}},{\"id\":\"3imoh2oqsJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Printing\",\"col\":4}},{\"id\":\"SlYKJZj5r3\",\"type\":\"card\",\"data\":{\"card_name\":\"Automation\",\"col\":4}}]",
|
||||
"creation": "2020-03-02 14:53:24.980279",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
|
|
@ -105,74 +105,6 @@
|
|||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email",
|
||||
"link_count": 3,
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Card Break"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Account",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Account",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Domain",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Domain",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Template",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Template",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Newsletter",
|
||||
"link_count": 2,
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Card Break"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Newsletter",
|
||||
"link_count": 0,
|
||||
"link_to": "Newsletter",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Group",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Group",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
|
|
@ -320,9 +252,58 @@
|
|||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email",
|
||||
"link_count": 4,
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Card Break"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Account",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Account",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Domain",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Domain",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Template",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Template",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Group",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Group",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2024-09-03 21:54:05.403066",
|
||||
"modified": "2025-06-27 11:39:44.392114",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Automation",
|
||||
"name": "Tools",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import os
|
|||
import frappe
|
||||
import frappe.defaults
|
||||
import frappe.desk.desk_page
|
||||
from frappe.core.doctype.installed_applications.installed_applications import (
|
||||
get_setup_wizard_completed_apps,
|
||||
)
|
||||
from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo, get_navbar_settings
|
||||
from frappe.desk.doctype.changelog_feed.changelog_feed import get_changelog_feed_items
|
||||
from frappe.desk.doctype.form_tour.form_tour import get_onboarding_ui_tours
|
||||
|
|
@ -21,10 +24,6 @@ from frappe.permissions import has_permission
|
|||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Count
|
||||
from frappe.query_builder.terms import ParameterizedValueWrapper, SubQuery
|
||||
from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_points
|
||||
from frappe.social.doctype.energy_point_settings.energy_point_settings import (
|
||||
is_energy_point_enabled,
|
||||
)
|
||||
from frappe.utils import add_user_info, cstr, get_system_timezone
|
||||
from frappe.utils.change_log import get_versions
|
||||
from frappe.utils.frappecloud import on_frappecloud
|
||||
|
|
@ -46,6 +45,8 @@ def get_bootinfo():
|
|||
# system info
|
||||
bootinfo.sitename = frappe.local.site
|
||||
bootinfo.sysdefaults = frappe.defaults.get_defaults()
|
||||
bootinfo.sysdefaults["setup_complete"] = frappe.is_setup_complete()
|
||||
|
||||
bootinfo.server_date = frappe.utils.nowdate()
|
||||
|
||||
if frappe.session["user"] != "Guest":
|
||||
|
|
@ -99,10 +100,7 @@ def get_bootinfo():
|
|||
bootinfo.lang_dict = get_lang_dict()
|
||||
bootinfo.success_action = get_success_action()
|
||||
bootinfo.update(get_email_accounts(user=frappe.session.user))
|
||||
bootinfo.energy_points_enabled = is_energy_point_enabled()
|
||||
bootinfo.website_tracking_enabled = is_tracking_enabled()
|
||||
bootinfo.sms_gateway_enabled = bool(frappe.db.get_single_value("SMS Settings", "sms_gateway_url"))
|
||||
bootinfo.points = get_energy_points(frappe.session.user)
|
||||
bootinfo.frequently_visited_links = frequently_visited_links()
|
||||
bootinfo.link_preview_doctypes = get_link_preview_doctypes()
|
||||
bootinfo.additional_filters_config = get_additional_filters_from_hooks()
|
||||
|
|
@ -121,6 +119,7 @@ def get_bootinfo():
|
|||
if sentry_dsn := get_sentry_dsn():
|
||||
bootinfo.sentry_dsn = sentry_dsn
|
||||
|
||||
bootinfo.setup_wizard_completed_apps = get_setup_wizard_completed_apps() or []
|
||||
return bootinfo
|
||||
|
||||
|
||||
|
|
@ -352,7 +351,7 @@ def add_home_page(bootinfo, docs):
|
|||
return
|
||||
home_page = frappe.db.get_default("desktop:home_page")
|
||||
|
||||
if home_page == "setup-wizard":
|
||||
if not frappe.is_setup_complete():
|
||||
bootinfo.setup_wizard_requires = frappe.get_hooks("setup_wizard_requires")
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ def build_missing_files():
|
|||
folder = os.path.join(sites_path, "assets", "frappe", "dist", type)
|
||||
current_asset_files.extend(os.listdir(folder))
|
||||
|
||||
development = frappe.local.conf.developer_mode or frappe.local.dev_server
|
||||
development = frappe.local.conf.developer_mode or frappe._dev_server
|
||||
build_mode = "development" if development else "production"
|
||||
|
||||
assets_json = frappe.read_file("assets/assets.json")
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import frappe
|
|||
common_default_keys = ["__default", "__global"]
|
||||
|
||||
doctypes_for_mapping = {
|
||||
"Energy Point Rule",
|
||||
"Assignment Rule",
|
||||
"Milestone Tracker",
|
||||
"Document Naming Rule",
|
||||
|
|
@ -120,6 +119,7 @@ def clear_defaults_cache(user=None):
|
|||
|
||||
def clear_doctype_cache(doctype=None):
|
||||
clear_controller_cache(doctype)
|
||||
frappe.client_cache.erase_persistent_caches(doctype=doctype)
|
||||
|
||||
_clear_doctype_cache_from_redis(doctype)
|
||||
if hasattr(frappe.db, "after_commit"):
|
||||
|
|
@ -173,14 +173,18 @@ def _clear_doctype_cache_from_redis(doctype: str | None = None):
|
|||
frappe.cache.delete_value(to_del)
|
||||
|
||||
|
||||
def clear_controller_cache(doctype=None):
|
||||
def clear_controller_cache(doctype=None, *, site=None):
|
||||
if not doctype:
|
||||
frappe.controllers.pop(frappe.local.site, None)
|
||||
frappe.controllers.pop(site or frappe.local.site, None)
|
||||
frappe.lazy_controllers.pop(site or frappe.local.site, None)
|
||||
return
|
||||
|
||||
if site_controllers := frappe.controllers.get(frappe.local.site):
|
||||
if site_controllers := frappe.controllers.get(site or frappe.local.site):
|
||||
site_controllers.pop(doctype, None)
|
||||
|
||||
if lazy_site_controllers := frappe.lazy_controllers.get(site or frappe.local.site):
|
||||
lazy_site_controllers.pop(doctype, None)
|
||||
|
||||
|
||||
def get_doctype_map(doctype, name, filters=None, order_by=None):
|
||||
return frappe.client_cache.get_value(
|
||||
|
|
@ -203,13 +207,24 @@ def build_table_count_cache():
|
|||
):
|
||||
return
|
||||
|
||||
table_name = frappe.qb.Field("table_name").as_("name")
|
||||
table_rows = frappe.qb.Field("table_rows").as_("count")
|
||||
information_schema = frappe.qb.Schema("information_schema")
|
||||
if frappe.db.db_type != "sqlite":
|
||||
table_name = frappe.qb.Field("table_name").as_("name")
|
||||
table_rows = frappe.qb.Field("table_rows").as_("count")
|
||||
information_schema = frappe.qb.Schema("information_schema")
|
||||
|
||||
data = (frappe.qb.from_(information_schema.tables).select(table_name, table_rows)).run(as_dict=True)
|
||||
counts = {d.get("name").replace("tab", "", 1): d.get("count", None) for d in data}
|
||||
frappe.cache.set_value("information_schema:counts", counts)
|
||||
data = (frappe.qb.from_(information_schema.tables).select(table_name, table_rows)).run(as_dict=True)
|
||||
counts = {d.get("name").replace("tab", "", 1): d.get("count", None) for d in data}
|
||||
frappe.cache.set_value("information_schema:counts", counts)
|
||||
else:
|
||||
counts = {}
|
||||
name = frappe.qb.Field("name")
|
||||
type = frappe.qb.Field("type")
|
||||
sqlite_master = frappe.qb.Schema("sqlite_master")
|
||||
data = frappe.qb.from_(sqlite_master).select(name).where(type == "table").run(as_dict=True)
|
||||
for table in data:
|
||||
count = frappe.db.sql(f"SELECT COUNT(*) FROM `{table.name}`")[0][0]
|
||||
counts[table.name.replace("tab", "", 1)] = count
|
||||
frappe.cache.set_value("information_schema:counts", counts)
|
||||
|
||||
return counts
|
||||
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ def bulk_update(docs):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def has_permission(doctype, docname, perm_type="read"):
|
||||
def has_permission(doctype: str, docname: str, perm_type: str = "read"):
|
||||
"""Return a JSON with data whether the document has the requested permission.
|
||||
|
||||
:param doctype: DocType of the document to be checked
|
||||
|
|
@ -306,18 +306,18 @@ def has_permission(doctype, docname, perm_type="read"):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_doc_permissions(doctype, docname):
|
||||
def get_doc_permissions(doctype: str, docname: str):
|
||||
"""Return an evaluated document permissions dict like `{"read":1, "write":1}`.
|
||||
|
||||
:param doctype: DocType of the document to be evaluated
|
||||
:param docname: `name` of the document to be evaluated
|
||||
"""
|
||||
doc = frappe.get_doc(doctype, docname)
|
||||
doc = frappe.get_lazy_doc(doctype, docname)
|
||||
return {"permissions": frappe.permissions.get_doc_permissions(doc)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_password(doctype, name, fieldname):
|
||||
def get_password(doctype: str, name: str, fieldname: str):
|
||||
"""Return a password type property. Only applicable for System Managers
|
||||
|
||||
:param doctype: DocType of the document that holds the password
|
||||
|
|
@ -325,7 +325,7 @@ def get_password(doctype, name, fieldname):
|
|||
:param fieldname: `fieldname` of the password property
|
||||
"""
|
||||
frappe.only_for("System Manager")
|
||||
return frappe.get_doc(doctype, name).get_password(fieldname)
|
||||
return frappe.get_lazy_doc(doctype, name).get_password(fieldname)
|
||||
|
||||
|
||||
from frappe.deprecation_dumpster import get_js as _get_js
|
||||
|
|
@ -361,7 +361,7 @@ def attach_file(
|
|||
:param is_private: Attach file as private file (1 or 0)
|
||||
:param docfield: file to attach to (optional)"""
|
||||
|
||||
doc = frappe.get_doc(doctype, docname)
|
||||
doc = frappe.get_lazy_doc(doctype, docname)
|
||||
doc.check_permission()
|
||||
|
||||
file = frappe.get_doc(
|
||||
|
|
@ -387,7 +387,7 @@ def attach_file(
|
|||
|
||||
@frappe.whitelist()
|
||||
@http_cache(max_age=10 * 60)
|
||||
def is_document_amended(doctype, docname):
|
||||
def is_document_amended(doctype: str, docname: str):
|
||||
if frappe.permissions.has_permission(doctype):
|
||||
try:
|
||||
return frappe.db.exists(doctype, {"amended_from": docname})
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ from frappe.utils.bench_helper import CliCtxObj
|
|||
@click.option(
|
||||
"--db-type",
|
||||
default="mariadb",
|
||||
type=click.Choice(["mariadb", "postgres"]),
|
||||
help='Optional "postgres" or "mariadb". Default is "mariadb"',
|
||||
type=click.Choice(["mariadb", "postgres", "sqlite"]),
|
||||
help='Optional "sqlite", "postgres" or "mariadb". Default is "mariadb"',
|
||||
)
|
||||
@click.option("--db-host", help="Database Host")
|
||||
@click.option("--db-port", type=int, help="Database Port")
|
||||
|
|
@ -225,7 +225,7 @@ def _restore(
|
|||
click.secho("Failed to detect type of backup file", fg="red")
|
||||
sys.exit(1)
|
||||
|
||||
if "cipher" in out.decode().split(":")[-1].strip():
|
||||
if "AES" in out.decode().split(":")[-1].strip():
|
||||
if encryption_key:
|
||||
click.secho("Encrypted backup file detected. Decrypting using provided key.", fg="yellow")
|
||||
|
||||
|
|
@ -379,6 +379,11 @@ def partial_restore(context: CliCtxObj, sql_file_path, verbose, encryption_key=N
|
|||
verbose = context.verbose or verbose
|
||||
frappe.init(site)
|
||||
frappe.connect()
|
||||
|
||||
if frappe.conf.db_type == "sqlite":
|
||||
click.secho("Partial restore is not supported for SQLite databases", fg="red")
|
||||
sys.exit(1)
|
||||
|
||||
err, out = frappe.utils.execute_in_shell(f"file {sql_file_path}", check_exit_code=True)
|
||||
if err:
|
||||
click.secho("Failed to detect type of backup file", fg="red")
|
||||
|
|
@ -686,8 +691,9 @@ def disable_user(context: CliCtxObj, email):
|
|||
@click.command("migrate")
|
||||
@click.option("--skip-failing", is_flag=True, help="Skip patches that fail to run")
|
||||
@click.option("--skip-search-index", is_flag=True, help="Skip search indexing for web documents")
|
||||
@click.option("--skip-fixtures", is_flag=True, help="Skip loading fixtures")
|
||||
@pass_context
|
||||
def migrate(context: CliCtxObj, skip_failing=False, skip_search_index=False):
|
||||
def migrate(context: CliCtxObj, skip_failing=False, skip_search_index=False, skip_fixtures=False):
|
||||
"Run patches, sync schema and rebuild files/translations"
|
||||
|
||||
from frappe.migrate import SiteMigration
|
||||
|
|
@ -696,8 +702,7 @@ def migrate(context: CliCtxObj, skip_failing=False, skip_search_index=False):
|
|||
click.secho(f"Migrating {site}", fg="green")
|
||||
try:
|
||||
SiteMigration(
|
||||
skip_failing=skip_failing,
|
||||
skip_search_index=skip_search_index,
|
||||
skip_failing=skip_failing, skip_search_index=skip_search_index, skip_fixtures=skip_fixtures
|
||||
).run(site=site)
|
||||
finally:
|
||||
print()
|
||||
|
|
|
|||
|
|
@ -304,6 +304,10 @@ class TestCommands(BaseTestCommands):
|
|||
self.execute("bench --site {test_site} restore {database}", site_data)
|
||||
self.assertEqual(self.returncode, 1)
|
||||
|
||||
@skipIf(
|
||||
frappe.conf.db_type == "sqlite",
|
||||
"Not for SQLite for now",
|
||||
)
|
||||
def test_partial_restore(self):
|
||||
_now = now()
|
||||
for num in range(10):
|
||||
|
|
@ -330,6 +334,10 @@ class TestCommands(BaseTestCommands):
|
|||
self.assertEqual(self.returncode, 0)
|
||||
self.assertEqual(frappe.db.count("ToDo"), todo_count)
|
||||
|
||||
@skipIf(
|
||||
frappe.conf.db_type == "sqlite",
|
||||
"Not for SQLite for now",
|
||||
)
|
||||
def test_recorder(self):
|
||||
frappe.recorder.stop()
|
||||
|
||||
|
|
@ -528,6 +536,10 @@ class TestCommands(BaseTestCommands):
|
|||
|
||||
self.assertEqual(conf[key], value)
|
||||
|
||||
@skipIf(
|
||||
frappe.conf.db_type == "sqlite",
|
||||
"Not for SQLite for now",
|
||||
)
|
||||
def test_different_db_username(self):
|
||||
site = frappe.generate_hash()
|
||||
user = "".join(secrets.choice(string.ascii_letters) for _ in range(8))
|
||||
|
|
@ -565,6 +577,10 @@ class TestCommands(BaseTestCommands):
|
|||
)
|
||||
self.assertEqual(self.returncode, 0)
|
||||
|
||||
@skipIf(
|
||||
frappe.conf.db_type == "sqlite",
|
||||
"Not for SQLite for now",
|
||||
)
|
||||
def test_existing_db_username(self):
|
||||
site = frappe.generate_hash()
|
||||
user = "".join(secrets.choice(string.ascii_letters) for _ in range(8))
|
||||
|
|
@ -687,6 +703,10 @@ class TestBackups(BaseTestCommands):
|
|||
)
|
||||
self.assertEqual(self.returncode, 0)
|
||||
|
||||
@skipIf(
|
||||
frappe.conf.db_type == "sqlite",
|
||||
"Not for SQLite for now",
|
||||
)
|
||||
def test_backup_fails_with_exit_code(self):
|
||||
"""Provide incorrect options to check if exit code is 1"""
|
||||
odb = BackupGenerator(
|
||||
|
|
@ -778,6 +798,10 @@ class TestBackups(BaseTestCommands):
|
|||
self.execute("bench --site {site} backup --verbose")
|
||||
self.assertEqual(self.returncode, 0)
|
||||
|
||||
@skipIf(
|
||||
frappe.conf.db_type == "sqlite",
|
||||
"Not for SQLite for now",
|
||||
)
|
||||
def test_backup_only_specific_doctypes(self):
|
||||
"""Take a backup with (include) backup options set in the site config `frappe.conf.backup.includes`"""
|
||||
self.execute(
|
||||
|
|
@ -789,6 +813,10 @@ class TestBackups(BaseTestCommands):
|
|||
database = fetch_latest_backups(partial=True)["database"]
|
||||
self.assertEqual([], missing_in_backup(self.backup_map["includes"]["includes"], database))
|
||||
|
||||
@skipIf(
|
||||
frappe.conf.db_type == "sqlite",
|
||||
"Not for SQLite for now",
|
||||
)
|
||||
def test_backup_excluding_specific_doctypes(self):
|
||||
"""Take a backup with (exclude) backup options set (`frappe.conf.backup.excludes`, `--exclude`)"""
|
||||
# test 1: take a backup with frappe.conf.backup.excludes
|
||||
|
|
@ -811,6 +839,10 @@ class TestBackups(BaseTestCommands):
|
|||
database = fetch_latest_backups(partial=True)["database"]
|
||||
self.assertFalse(exists_in_backup(self.backup_map["excludes"]["excludes"], database))
|
||||
|
||||
@skipIf(
|
||||
frappe.conf.db_type == "sqlite",
|
||||
"Not for SQLite for now",
|
||||
)
|
||||
def test_selective_backup_priority_resolution(self):
|
||||
"""Take a backup with conflicting backup options set (`frappe.conf.excludes`, `--include`)"""
|
||||
self.execute(
|
||||
|
|
@ -821,6 +853,10 @@ class TestBackups(BaseTestCommands):
|
|||
database = fetch_latest_backups(partial=True)["database"]
|
||||
self.assertEqual([], missing_in_backup(self.backup_map["includes"]["includes"], database))
|
||||
|
||||
@skipIf(
|
||||
frappe.conf.db_type == "sqlite",
|
||||
"Not for SQLite for now",
|
||||
)
|
||||
def test_dont_backup_conf(self):
|
||||
"""Take a backup ignoring frappe.conf.backup settings (with --ignore-backup-conf option)"""
|
||||
self.execute("bench --site {site} backup --ignore-backup-conf")
|
||||
|
|
@ -901,7 +937,7 @@ class TestAddNewUser(BaseTestCommands):
|
|||
|
||||
class TestBenchBuild(IntegrationTestCase):
|
||||
def test_build_assets_size_check(self):
|
||||
CURRENT_SIZE = 3.3 # MB
|
||||
CURRENT_SIZE = 3.4 # MB
|
||||
JS_ASSET_THRESHOLD = 0.01
|
||||
|
||||
hooks = frappe.get_hooks()
|
||||
|
|
@ -960,7 +996,11 @@ class TestCommandUtils(IntegrationTestCase):
|
|||
class TestDBCli(BaseTestCommands):
|
||||
@timeout(10)
|
||||
def test_db_cli(self):
|
||||
self.execute("bench --site {site} db-console", kwargs={"cmd_input": rb"\q"})
|
||||
if frappe.conf.db_type == "sqlite":
|
||||
cmd_input = b".quit"
|
||||
else:
|
||||
cmd_input = rb"\q"
|
||||
self.execute("bench --site {site} db-console", kwargs={"cmd_input": cmd_input})
|
||||
self.assertEqual(self.returncode, 0)
|
||||
|
||||
@run_only_if(db_type_is.MARIADB)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import os
|
|||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
|
@ -11,8 +12,6 @@ from frappe.commands import get_site, pass_context
|
|||
from frappe.utils.bench_helper import CliCtxObj
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import unittest
|
||||
|
||||
from frappe.testing import TestRunner
|
||||
|
||||
|
||||
|
|
@ -34,8 +33,30 @@ def main(
|
|||
debug: bool = False,
|
||||
debug_exceptions: tuple[Exception] | None = None,
|
||||
selected_categories: list[str] | None = None,
|
||||
lightmode: bool = False,
|
||||
) -> None:
|
||||
"""Main function to run tests"""
|
||||
if lightmode:
|
||||
from frappe.testing.config import TestParameters
|
||||
|
||||
test_params = TestParameters(
|
||||
site=site,
|
||||
app=app,
|
||||
module=module,
|
||||
doctype=doctype,
|
||||
module_def=module_def,
|
||||
verbose=verbose,
|
||||
tests=tests,
|
||||
force=force,
|
||||
profile=profile,
|
||||
junit_xml_output=junit_xml_output,
|
||||
doctype_list_path=doctype_list_path,
|
||||
failfast=failfast,
|
||||
case=case,
|
||||
)
|
||||
run_tests_in_light_mode(test_params)
|
||||
return
|
||||
|
||||
import logging
|
||||
|
||||
from frappe.testing import (
|
||||
|
|
@ -46,6 +67,9 @@ def main(
|
|||
discover_module_tests,
|
||||
)
|
||||
from frappe.testing.environment import _cleanup_after_tests, _initialize_test_environment
|
||||
from frappe.tests.utils.generators import _clear_test_log
|
||||
|
||||
_clear_test_log()
|
||||
|
||||
if debug and not debug_exceptions:
|
||||
debug_exceptions = (Exception,)
|
||||
|
|
@ -156,6 +180,30 @@ def main(
|
|||
testing_module_logger.debug(f"Total test run time: {end_time - start_time:.3f} seconds")
|
||||
|
||||
|
||||
def run_tests_in_light_mode(test_params):
|
||||
from frappe.testing.loader import FrappeTestLoader
|
||||
from frappe.testing.result import FrappeTestResult
|
||||
from frappe.tests.utils import toggle_test_mode
|
||||
|
||||
# init environment
|
||||
frappe.init(test_params.site)
|
||||
if not frappe.db:
|
||||
frappe.connect()
|
||||
|
||||
# disable scheduler
|
||||
global scheduler_disabled_by_user
|
||||
scheduler_disabled_by_user = frappe.utils.scheduler.is_scheduler_disabled(verbose=False)
|
||||
if not scheduler_disabled_by_user:
|
||||
frappe.utils.scheduler.disable_scheduler()
|
||||
frappe.clear_cache()
|
||||
|
||||
toggle_test_mode(True)
|
||||
suite = FrappeTestLoader().discover_tests(test_params)
|
||||
result = unittest.TextTestRunner(failfast=test_params.failfast, resultclass=FrappeTestResult).run(suite)
|
||||
if not result.wasSuccessful():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _setup_xml_output(junit_xml_output):
|
||||
"""Setup XML output for test results if specified"""
|
||||
global unittest_runner
|
||||
|
|
@ -245,6 +293,7 @@ def _get_doctypes_for_module_def(app, module_def):
|
|||
default="all",
|
||||
help="Select test category to run",
|
||||
)
|
||||
@click.option("--lightmode", is_flag=True, default=False)
|
||||
@pass_context
|
||||
def run_tests(
|
||||
context: CliCtxObj,
|
||||
|
|
@ -262,6 +311,7 @@ def run_tests(
|
|||
failfast=False,
|
||||
case=None,
|
||||
test_category="all",
|
||||
lightmode=False,
|
||||
debug=False,
|
||||
):
|
||||
"""Run python unit-tests"""
|
||||
|
|
@ -275,7 +325,7 @@ def run_tests(
|
|||
site = get_site(context)
|
||||
|
||||
frappe.init(site)
|
||||
allow_tests = frappe.get_conf().allow_tests
|
||||
allow_tests = frappe.conf.allow_tests
|
||||
|
||||
if not (allow_tests or os.environ.get("CI")):
|
||||
click.secho("Testing is disabled for the site!", bold=True)
|
||||
|
|
@ -306,6 +356,7 @@ def run_tests(
|
|||
skip_before_tests=skip_before_tests,
|
||||
debug=debug,
|
||||
selected_categories=[] if test_category == "all" else test_category,
|
||||
lightmode=lightmode,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -321,6 +372,7 @@ def run_tests(
|
|||
)
|
||||
@click.option("--use-orchestrator", is_flag=True, help="Use orchestrator to run parallel tests")
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Dont actually run tests")
|
||||
@click.option("--lightmode", is_flag=True, default=False, help="Skips all before test setup")
|
||||
@pass_context
|
||||
def run_parallel_tests(
|
||||
context: CliCtxObj,
|
||||
|
|
@ -330,6 +382,7 @@ def run_parallel_tests(
|
|||
with_coverage=False,
|
||||
use_orchestrator=False,
|
||||
dry_run=False,
|
||||
lightmode=False,
|
||||
):
|
||||
from traceback_with_variables import activate_by_import
|
||||
|
||||
|
|
@ -350,6 +403,7 @@ def run_parallel_tests(
|
|||
build_number=build_number,
|
||||
total_builds=total_builds,
|
||||
dry_run=dry_run,
|
||||
lightmode=lightmode,
|
||||
)
|
||||
mode = "Orchestrator" if use_orchestrator else "Parallel"
|
||||
banner = f"""
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ def build(
|
|||
skip_frappe = False
|
||||
|
||||
# don't minify in developer_mode for faster builds
|
||||
development = frappe.local.conf.developer_mode or frappe.local.dev_server
|
||||
development = frappe.local.conf.developer_mode or frappe._dev_server
|
||||
mode = "development" if development else "production"
|
||||
if production:
|
||||
mode = "production"
|
||||
|
|
@ -175,7 +175,7 @@ def destroy_all_sessions(context: CliCtxObj, reason=None):
|
|||
@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text")
|
||||
@pass_context
|
||||
def show_config(context: CliCtxObj, format):
|
||||
"Print configuration file to STDOUT in speified format"
|
||||
"Print configuration file to STDOUT in specified format"
|
||||
|
||||
if not context.sites:
|
||||
raise SiteNotSpecifiedError
|
||||
|
|
@ -524,12 +524,27 @@ def postgres(context: CliCtxObj, extra_args):
|
|||
_enter_console(extra_args=extra_args)
|
||||
|
||||
|
||||
@click.command("sqlite", context_settings=EXTRA_ARGS_CTX)
|
||||
@click.argument("extra_args", nargs=-1)
|
||||
@pass_context
|
||||
def sqlite(context: CliCtxObj, extra_args):
|
||||
"""
|
||||
Enter into sqlite console for a given site.
|
||||
"""
|
||||
site = get_site(context)
|
||||
frappe.init(site)
|
||||
frappe.conf.db_type = "sqlite"
|
||||
_enter_console(extra_args=extra_args)
|
||||
|
||||
|
||||
def _enter_console(extra_args=None):
|
||||
from frappe.database import get_command
|
||||
from frappe.utils import get_site_path
|
||||
|
||||
if frappe.conf.db_type == "mariadb":
|
||||
os.environ["MYSQL_HISTFILE"] = os.path.abspath(get_site_path("logs", "mariadb_console.log"))
|
||||
elif frappe.conf.db_type == "sqlite":
|
||||
os.environ["SQLITE_HISTORY"] = os.path.abspath(get_site_path("logs", "sqlite_console.log"))
|
||||
else:
|
||||
os.environ["PSQL_HISTORY"] = os.path.abspath(get_site_path("logs", "postgresql_console.log"))
|
||||
|
||||
|
|
@ -899,7 +914,7 @@ def set_config(context: CliCtxObj, key, value, global_=False, parse=False):
|
|||
"output",
|
||||
type=click.Choice(["plain", "table", "json", "legacy"]),
|
||||
help="Output format",
|
||||
default="legacy",
|
||||
default="plain",
|
||||
)
|
||||
def get_version(output):
|
||||
"""Show the versions of all the installed apps."""
|
||||
|
|
@ -1033,6 +1048,7 @@ commands = [
|
|||
make_app,
|
||||
create_patch,
|
||||
mariadb,
|
||||
sqlite,
|
||||
postgres,
|
||||
request,
|
||||
reset_perms,
|
||||
|
|
|
|||
|
|
@ -78,21 +78,23 @@ def _get_site_config(sites_path: str, site_path: str) -> _dict[str, Any]:
|
|||
os.environ.get("FRAPPE_REDIS_CACHE") or config.get("redis_cache") or "redis://127.0.0.1:13311"
|
||||
)
|
||||
config["db_type"] = os.environ.get("FRAPPE_DB_TYPE") or config.get("db_type") or "mariadb"
|
||||
config["db_socket"] = os.environ.get("FRAPPE_DB_SOCKET") or config.get("db_socket")
|
||||
config["db_host"] = os.environ.get("FRAPPE_DB_HOST") or config.get("db_host") or "127.0.0.1"
|
||||
config["db_port"] = int(
|
||||
os.environ.get("FRAPPE_DB_PORT") or config.get("db_port") or db_default_ports(config["db_type"])
|
||||
)
|
||||
|
||||
# Set the user as database name if not set in config
|
||||
config["db_user"] = os.environ.get("FRAPPE_DB_USER") or config.get("db_user") or config.get("db_name")
|
||||
if config["db_type"] in ("mariadb", "postgres"):
|
||||
config["db_socket"] = os.environ.get("FRAPPE_DB_SOCKET") or config.get("db_socket")
|
||||
config["db_host"] = os.environ.get("FRAPPE_DB_HOST") or config.get("db_host") or "127.0.0.1"
|
||||
config["db_port"] = int(
|
||||
os.environ.get("FRAPPE_DB_PORT") or config.get("db_port") or db_default_ports(config["db_type"])
|
||||
)
|
||||
|
||||
# Set the user as database name if not set in config
|
||||
config["db_user"] = os.environ.get("FRAPPE_DB_USER") or config.get("db_user") or config.get("db_name")
|
||||
|
||||
# read password
|
||||
config["db_password"] = os.environ.get("FRAPPE_DB_PASSWORD") or config.get("db_password")
|
||||
|
||||
# vice versa for dbname if not defined
|
||||
config["db_name"] = os.environ.get("FRAPPE_DB_NAME") or config.get("db_name") or config["db_user"]
|
||||
|
||||
# read password
|
||||
config["db_password"] = os.environ.get("FRAPPE_DB_PASSWORD") or config.get("db_password")
|
||||
|
||||
# Allow externally extending the config with hooks
|
||||
if extra_config := config.get("extra_config"):
|
||||
if isinstance(extra_config, str):
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@
|
|||
{
|
||||
"fieldname": "phone",
|
||||
"fieldtype": "Data",
|
||||
"label": "Phone"
|
||||
"label": "Phone",
|
||||
"options": "Phone"
|
||||
},
|
||||
{
|
||||
"fieldname": "fax",
|
||||
|
|
|
|||
|
|
@ -4,16 +4,7 @@ from functools import partial
|
|||
|
||||
import frappe
|
||||
from frappe.contacts.doctype.address.address import address_query, get_address_display
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestAddress(UnitTestCase):
|
||||
"""
|
||||
Unit tests for Address.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestAddress(IntegrationTestCase):
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class AddressTemplate(Document):
|
|||
|
||||
if not self.is_default and not self._get_previous_default():
|
||||
self.is_default = 1
|
||||
if frappe.get_system_settings("setup_complete"):
|
||||
if frappe.is_setup_complete():
|
||||
frappe.msgprint(_("Setting this Address Template as default as there is no other default"))
|
||||
|
||||
def on_update(self):
|
||||
|
|
|
|||
|
|
@ -2,19 +2,10 @@
|
|||
# License: MIT. See LICENSE
|
||||
import frappe
|
||||
from frappe.contacts.doctype.address_template.address_template import get_default_address_template
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.utils.jinja import validate_template
|
||||
|
||||
|
||||
class UnitTestAddressTemplate(UnitTestCase):
|
||||
"""
|
||||
Unit tests for AddressTemplate.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestAddressTemplate(IntegrationTestCase):
|
||||
def setUp(self) -> None:
|
||||
frappe.db.delete("Address Template", {"country": "India"})
|
||||
|
|
|
|||
|
|
@ -3,20 +3,11 @@
|
|||
import frappe
|
||||
from frappe.contacts.doctype.contact.contact import get_full_name
|
||||
from frappe.email import get_contact_list
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = ["Contact", "Salutation"]
|
||||
|
||||
|
||||
class UnitTestContact(UnitTestCase):
|
||||
"""
|
||||
Unit tests for Contact.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestContact(IntegrationTestCase):
|
||||
def test_check_default_email(self):
|
||||
emails = [
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
# Copyright (c) 2017, Frappe Technologies and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestGender(UnitTestCase):
|
||||
"""
|
||||
Unit tests for Gender.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestGender(IntegrationTestCase):
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
# Copyright (c) 2017, Frappe Technologies and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestSalutation(UnitTestCase):
|
||||
"""
|
||||
Unit tests for Salutation.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestSalutation(IntegrationTestCase):
|
||||
|
|
|
|||
139
frappe/core/api/user_invitation.py
Normal file
139
frappe/core/api/user_invitation.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import frappe
|
||||
import frappe.utils
|
||||
from frappe import _
|
||||
from frappe.core.doctype.user_invitation.user_invitation import UserInvitation
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def invite_by_email(
|
||||
emails: str, roles: list[str], redirect_to_path: str, app_name: str = "frappe"
|
||||
) -> dict[str, list[str]]:
|
||||
UserInvitation.validate_role(app_name)
|
||||
|
||||
# validate emails
|
||||
frappe.utils.validate_email_address(emails, throw=True)
|
||||
email_list = frappe.utils.split_emails(emails)
|
||||
if not email_list:
|
||||
frappe.throw(title=_("Invalid input"), msg=_("No email addresses to invite"))
|
||||
|
||||
# get relevant data from the database
|
||||
disabled_user_emails = frappe.db.get_all(
|
||||
"User",
|
||||
filters={"email": ["in", email_list], "enabled": 0},
|
||||
pluck="email",
|
||||
)
|
||||
accepted_invite_emails = frappe.db.get_all(
|
||||
"User Invitation",
|
||||
filters={
|
||||
"email": ["in", email_list],
|
||||
"status": "Accepted",
|
||||
"app_name": app_name,
|
||||
"user": ["is", "set"],
|
||||
},
|
||||
pluck="email",
|
||||
)
|
||||
pending_invite_emails = frappe.db.get_all(
|
||||
"User Invitation",
|
||||
filters={"email": ["in", email_list], "status": "Pending", "app_name": app_name},
|
||||
pluck="email",
|
||||
)
|
||||
|
||||
# create invitation documents
|
||||
to_invite = list(
|
||||
set(email_list) - set(disabled_user_emails) - set(accepted_invite_emails) - set(pending_invite_emails)
|
||||
)
|
||||
for email in to_invite:
|
||||
frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=email,
|
||||
roles=[dict(role=role) for role in roles],
|
||||
app_name=app_name,
|
||||
redirect_to_path=redirect_to_path,
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
return {
|
||||
"disabled_user_emails": disabled_user_emails,
|
||||
"accepted_invite_emails": accepted_invite_emails,
|
||||
"pending_invite_emails": pending_invite_emails,
|
||||
"invited_emails": to_invite,
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True, methods=["GET"])
|
||||
def accept_invitation(key: str) -> None:
|
||||
_accept_invitation(key, False)
|
||||
|
||||
|
||||
# `app_name` is required for security
|
||||
@frappe.whitelist(methods=["PATCH", "POST"])
|
||||
def cancel_invitation(name: str, app_name: str):
|
||||
UserInvitation.validate_role(app_name)
|
||||
|
||||
if not frappe.db.exists("User Invitation", name):
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation not found"))
|
||||
|
||||
invitation = frappe.get_doc("User Invitation", name)
|
||||
if invitation.app_name != app_name:
|
||||
# message is not specific enough for security
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation not found"))
|
||||
|
||||
if invitation.status == "Cancelled":
|
||||
return {"cancelled_now": False}
|
||||
|
||||
if invitation.status != "Pending":
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation cannot be cancelled"))
|
||||
|
||||
invitation.flags.ignore_permissions = True
|
||||
return {"cancelled_now": invitation.cancel_invite()}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["GET"])
|
||||
def get_pending_invitations(app_name: str):
|
||||
UserInvitation.validate_role(app_name)
|
||||
|
||||
pending_invitations = frappe.db.get_all(
|
||||
"User Invitation", fields=["name", "email"], filters={"status": "Pending", "app_name": app_name}
|
||||
)
|
||||
res = []
|
||||
for pending_invitation in pending_invitations:
|
||||
roles = frappe.db.get_all("User Role", fields=["role"], filters={"parent": pending_invitation.name})
|
||||
res.append(
|
||||
{
|
||||
"name": pending_invitation.name,
|
||||
"email": pending_invitation.email,
|
||||
"roles": [r.role for r in roles],
|
||||
}
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
def _accept_invitation(key: str, in_test: bool) -> None:
|
||||
# get invitation
|
||||
hashed_key = frappe.utils.sha256_hash(key)
|
||||
invitation_name = frappe.db.get_value("User Invitation", filters={"key": hashed_key})
|
||||
if not invitation_name:
|
||||
frappe.throw(title=_("Error"), msg=_("Invalid key"))
|
||||
invitation = frappe.get_doc("User Invitation", invitation_name)
|
||||
|
||||
# accept invitation
|
||||
invitation.accept(ignore_permissions=True)
|
||||
|
||||
user = frappe.get_doc("User", invitation.email)
|
||||
should_update_password = not user.last_password_reset_date and not bool(
|
||||
frappe.get_system_settings("disable_user_pass_login")
|
||||
)
|
||||
|
||||
# set redirect_to
|
||||
redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path())
|
||||
if should_update_password:
|
||||
redirect_to = f"{user.reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
|
||||
|
||||
# GET requests do not cause an implicit commit
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
if not in_test and not should_update_password:
|
||||
frappe.local.login_manager.login_as(invitation.email)
|
||||
|
||||
# set response
|
||||
frappe.local.response["type"] = "redirect"
|
||||
frappe.local.response["location"] = redirect_to
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "User ",
|
||||
"label": "User",
|
||||
"options": "User",
|
||||
"read_only": 1
|
||||
},
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ class AccessLog(Document):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.write_only()
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
retry=retry_if_exception_type(frappe.DuplicateEntryError),
|
||||
reraise=True,
|
||||
)
|
||||
def make_access_log(
|
||||
doctype=None,
|
||||
document=None,
|
||||
|
|
@ -48,41 +54,10 @@ def make_access_log(
|
|||
page=None,
|
||||
columns=None,
|
||||
):
|
||||
_make_access_log(
|
||||
doctype,
|
||||
document,
|
||||
method,
|
||||
file_type,
|
||||
report_name,
|
||||
filters,
|
||||
page,
|
||||
columns,
|
||||
)
|
||||
|
||||
|
||||
@frappe.write_only()
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
retry=retry_if_exception_type(frappe.DuplicateEntryError),
|
||||
reraise=True,
|
||||
)
|
||||
def _make_access_log(
|
||||
doctype=None,
|
||||
document=None,
|
||||
method=None,
|
||||
file_type=None,
|
||||
report_name=None,
|
||||
filters=None,
|
||||
page=None,
|
||||
columns=None,
|
||||
):
|
||||
user = frappe.session.user
|
||||
in_request = frappe.request and frappe.request.method == "GET"
|
||||
|
||||
access_log = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Access Log",
|
||||
"user": user,
|
||||
"user": frappe.session.user,
|
||||
"export_from": doctype,
|
||||
"reference_document": document,
|
||||
"file_type": file_type,
|
||||
|
|
@ -94,14 +69,11 @@ def _make_access_log(
|
|||
}
|
||||
)
|
||||
|
||||
if frappe.flags.read_only:
|
||||
if not frappe.in_test:
|
||||
access_log.deferred_insert()
|
||||
return
|
||||
else:
|
||||
access_log.db_insert()
|
||||
|
||||
# `frappe.db.commit` added because insert doesnt `commit` when called in GET requests like `printview`
|
||||
# dont commit in test mode. It must be tempting to put this block along with the in_request in the
|
||||
# whitelisted method...yeah, don't do it. That part would be executed possibly on a read only DB conn
|
||||
if not frappe.flags.in_test or in_request:
|
||||
frappe.db.commit()
|
||||
|
||||
# only for backward compatibility
|
||||
_make_access_log = make_access_log
|
||||
|
|
|
|||
|
|
@ -14,19 +14,10 @@ from frappe.core.doctype.data_import.data_import import export_csv
|
|||
from frappe.core.doctype.user.user import generate_keys
|
||||
|
||||
# imports - standard imports
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.utils import cstr, get_site_url
|
||||
|
||||
|
||||
class UnitTestAccessLog(UnitTestCase):
|
||||
"""
|
||||
Unit tests for AccessLog.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestAccessLog(IntegrationTestCase):
|
||||
def setUp(self):
|
||||
# generate keys for current user to send requests for the following tests
|
||||
|
|
|
|||
|
|
@ -4,16 +4,7 @@ import time
|
|||
|
||||
import frappe
|
||||
from frappe.auth import CookieManager, LoginManager
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestActivityLog(UnitTestCase):
|
||||
"""
|
||||
Unit tests for ActivityLog.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestActivityLog(IntegrationTestCase):
|
||||
|
|
|
|||
8
frappe/core/doctype/api_request_log/api_request_log.js
Normal file
8
frappe/core/doctype/api_request_log/api_request_log.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Copyright (c) 2025, Frappe Technologies and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("API Request Log", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
62
frappe/core/doctype/api_request_log/api_request_log.json
Normal file
62
frappe/core/doctype/api_request_log/api_request_log.json
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-05-21 16:51:56.070193",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"path",
|
||||
"method",
|
||||
"user"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "path",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Path"
|
||||
},
|
||||
{
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "User",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"fieldname": "method",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Method"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-21 17:09:55.054044",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "API Request Log",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
28
frappe/core/doctype/api_request_log/api_request_log.py
Normal file
28
frappe/core/doctype/api_request_log/api_request_log.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Copyright (c) 2025, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class APIRequestLog(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
|
||||
|
||||
method: DF.Data | None
|
||||
path: DF.Data | None
|
||||
user: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
@staticmethod
|
||||
def clear_old_logs(days: int = 90):
|
||||
from frappe.query_builder import Interval
|
||||
from frappe.query_builder.functions import Now
|
||||
|
||||
table = frappe.qb.DocType("API Request Log")
|
||||
frappe.db.delete(table, filters=(table.creation < (Now() - Interval(days=days))))
|
||||
20
frappe/core/doctype/api_request_log/test_api_request_log.py
Normal file
20
frappe/core/doctype/api_request_log/test_api_request_log.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Copyright (c) 2025, Frappe Technologies and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class IntegrationTestAPIRequestLog(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for APIRequestLog.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
|
@ -2,19 +2,10 @@
|
|||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.utils import today
|
||||
|
||||
|
||||
class UnitTestAuditTrail(UnitTestCase):
|
||||
"""
|
||||
Unit tests for AuditTrail.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestAuditTrail(IntegrationTestCase):
|
||||
def setUp(self):
|
||||
self.child_doctype = create_custom_child_doctype()
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class Comment(Document):
|
|||
|
||||
def on_update(self):
|
||||
update_comment_in_doc(self)
|
||||
if self.is_new():
|
||||
if not self.is_new():
|
||||
self.notify_change("update")
|
||||
|
||||
def on_trash(self):
|
||||
|
|
|
|||
|
|
@ -4,21 +4,17 @@ import json
|
|||
|
||||
import frappe
|
||||
from frappe.templates.includes.comments.comments import add_comment
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.tests.test_helpers import setup_for_tests
|
||||
from frappe.tests.test_model_utils import set_user
|
||||
from frappe.website.doctype.blog_post.test_blog_post import make_test_blog
|
||||
|
||||
|
||||
class UnitTestComment(UnitTestCase):
|
||||
"""
|
||||
Unit tests for Comment.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = ["Web Page"]
|
||||
|
||||
|
||||
class TestComment(IntegrationTestCase):
|
||||
def setUp(self):
|
||||
setup_for_tests()
|
||||
|
||||
def test_comment_creation(self):
|
||||
test_doc = frappe.get_doc(doctype="ToDo", description="test")
|
||||
test_doc.insert()
|
||||
|
|
@ -51,16 +47,16 @@ class TestComment(IntegrationTestCase):
|
|||
|
||||
# test via blog
|
||||
def test_public_comment(self):
|
||||
test_blog = make_test_blog()
|
||||
test_blog = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
|
||||
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
|
||||
add_comment_args = {
|
||||
"comment": "Good comment with 10 chars",
|
||||
"comment_email": "test@test.com",
|
||||
"comment_by": "Good Tester",
|
||||
"reference_doctype": test_blog.doctype,
|
||||
"reference_name": test_blog.name,
|
||||
"route": test_blog.route,
|
||||
"route": f"blog/{test_blog.doctype}/{test_blog.name}",
|
||||
}
|
||||
add_comment(**add_comment_args)
|
||||
|
||||
|
|
@ -73,7 +69,7 @@ class TestComment(IntegrationTestCase):
|
|||
1,
|
||||
)
|
||||
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
|
||||
|
||||
add_comment_args.update(comment="pleez vizits my site http://mysite.com", comment_by="bad commentor")
|
||||
add_comment(**add_comment_args)
|
||||
|
|
@ -90,7 +86,7 @@ class TestComment(IntegrationTestCase):
|
|||
)
|
||||
|
||||
# test for filtering html and css injection elements
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
|
||||
|
||||
add_comment_args.update(comment="<script>alert(1)</script>Comment", comment_by="hacker")
|
||||
add_comment(**add_comment_args)
|
||||
|
|
@ -105,26 +101,10 @@ class TestComment(IntegrationTestCase):
|
|||
|
||||
test_blog.delete()
|
||||
|
||||
@IntegrationTestCase.change_settings("Blog Settings", {"allow_guest_to_comment": 0})
|
||||
def test_guest_cannot_comment(self):
|
||||
test_blog = make_test_blog()
|
||||
with set_user("Guest"):
|
||||
self.assertEqual(
|
||||
add_comment(
|
||||
comment="Good comment with 10 chars",
|
||||
comment_email="mail@example.org",
|
||||
comment_by="Good Tester",
|
||||
reference_doctype="Blog Post",
|
||||
reference_name=test_blog.name,
|
||||
route=test_blog.route,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def test_user_not_logged_in(self):
|
||||
some_system_user = frappe.db.get_value("User", {"name": ("not in", frappe.STANDARD_USERS)})
|
||||
|
||||
test_blog = make_test_blog()
|
||||
test_blog = frappe.get_doc("Web Page", "test-web-page-1")
|
||||
with set_user("Guest"):
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
|
|
@ -132,7 +112,7 @@ class TestComment(IntegrationTestCase):
|
|||
comment="Good comment with 10 chars",
|
||||
comment_email=some_system_user,
|
||||
comment_by="Good Tester",
|
||||
reference_doctype="Blog Post",
|
||||
reference_doctype="Web Page",
|
||||
reference_name=test_blog.name,
|
||||
route=test_blog.route,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -401,7 +401,11 @@ class Communication(Document, CommunicationEmailMixin):
|
|||
return
|
||||
|
||||
for doctype, docname in parse_email([self.recipients, self.cc, self.bcc]):
|
||||
if not frappe.db.get_value(doctype, docname, ignore=True):
|
||||
# Both document and doctype names should be case insensitive in email addresses.
|
||||
doctype = frappe.db.get_value("DocType", doctype)
|
||||
if doctype:
|
||||
docname = frappe.db.get_value(doctype, docname, ignore=True)
|
||||
if not (doctype and docname):
|
||||
continue
|
||||
|
||||
self.add_link(doctype, docname)
|
||||
|
|
@ -579,7 +583,7 @@ def parse_email(email_strings):
|
|||
if not document_parts or len(document_parts) != 2:
|
||||
continue
|
||||
|
||||
doctype = unquote_plus(document_parts[0])
|
||||
doctype = frappe.unscrub(unquote_plus(document_parts[0]))
|
||||
docname = unquote_plus(document_parts[1])
|
||||
yield doctype, docname
|
||||
|
||||
|
|
@ -644,7 +648,10 @@ def update_first_response_time(parent, communication):
|
|||
is_system_user(communication.sender)
|
||||
or frappe.get_cached_value("User", frappe.session.user, "user_type") == "System User"
|
||||
):
|
||||
if communication.sent_or_received == "Sent":
|
||||
if (
|
||||
communication.sent_or_received == "Sent"
|
||||
and communication.communication_type == "Communication"
|
||||
):
|
||||
first_responded_on = communication.creation
|
||||
if parent.meta.has_field("first_responded_on"):
|
||||
parent.db_set("first_responded_on", first_responded_on)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
|
|||
import frappe
|
||||
import frappe.email.smtp
|
||||
from frappe import _
|
||||
from frappe.database.utils import commit_after_response
|
||||
from frappe.email.email_body import get_message_id
|
||||
from frappe.utils import (
|
||||
cint,
|
||||
|
|
@ -272,7 +273,7 @@ def add_attachments(name: str, attachments: Iterable[str | dict]) -> None:
|
|||
|
||||
@frappe.whitelist(allow_guest=True, methods=("GET",))
|
||||
def mark_email_as_seen(name: str | None = None):
|
||||
frappe.request.after_response.add(lambda: _mark_email_as_seen(name))
|
||||
commit_after_response(lambda: _mark_email_as_seen(name))
|
||||
frappe.response.update(frappe.utils.get_imaginary_pixel_response())
|
||||
|
||||
|
||||
|
|
@ -282,8 +283,6 @@ def _mark_email_as_seen(name):
|
|||
except Exception:
|
||||
frappe.log_error("Unable to mark as seen", None, "Communication", name)
|
||||
|
||||
frappe.db.commit() # nosemgrep: after_response requires explicit commit
|
||||
|
||||
|
||||
def update_communication_as_read(name):
|
||||
if not name or not isinstance(name, str):
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ class CommunicationEmailMixin:
|
|||
if doc_owner := self.get_owner():
|
||||
cc.append(doc_owner)
|
||||
cc = set(cc) - {self.sender_mailid}
|
||||
assignees = set(self.get_assignees())
|
||||
assignees = set(self.get_assignees()) - {self.sender_mailid}
|
||||
# Check and remove If user disabled notifications for incoming emails on assigned document.
|
||||
for assignee in assignees.copy():
|
||||
if not is_email_notifications_enabled_for_type(assignee, "threads_on_assigned_document"):
|
||||
|
|
|
|||
|
|
@ -6,22 +6,13 @@ import frappe
|
|||
from frappe.core.doctype.communication.communication import Communication, get_emails, parse_email
|
||||
from frappe.core.doctype.communication.email import add_attachments, make
|
||||
from frappe.email.doctype.email_queue.email_queue import EmailQueue
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.contacts.doctype.contact.contact import Contact
|
||||
from frappe.email.doctype.email_account.email_account import EmailAccount
|
||||
|
||||
|
||||
class UnitTestCommunication(UnitTestCase):
|
||||
"""
|
||||
Unit tests for Communication.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestCommunication(IntegrationTestCase):
|
||||
def test_email(self):
|
||||
valid_email_list = [
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
# Copyright (c) 2015, Frappe Technologies and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestCustomDocperm(UnitTestCase):
|
||||
"""
|
||||
Unit tests for CustomDocperm.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestCustomDocPerm(IntegrationTestCase):
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
# Copyright (c) 2015, Frappe Technologies and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestCustomRole(UnitTestCase):
|
||||
"""
|
||||
Unit tests for CustomRole.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestCustomRole(IntegrationTestCase):
|
||||
|
|
|
|||
|
|
@ -446,7 +446,8 @@ class DataExporter:
|
|||
value = format_datetime(value)
|
||||
elif fieldtype == "Duration":
|
||||
value = format_duration(value, df.hide_days)
|
||||
|
||||
elif fieldtype == "Text Editor" and value:
|
||||
value = frappe.core.utils.html2text(value)
|
||||
row[_column_start_end.start + i + 1] = value
|
||||
|
||||
def build_response_as_excel(self):
|
||||
|
|
|
|||
|
|
@ -2,16 +2,7 @@
|
|||
# License: MIT. See LICENSE
|
||||
import frappe
|
||||
from frappe.core.doctype.data_export.exporter import DataExporter
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestDataExport(UnitTestCase):
|
||||
"""
|
||||
Unit tests for DataExport.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestDataExporter(IntegrationTestCase):
|
||||
|
|
|
|||
|
|
@ -77,10 +77,11 @@ class DataImport(Document):
|
|||
return
|
||||
validate_google_sheets_url(self.google_sheets_url)
|
||||
|
||||
def set_payload_count(self):
|
||||
def set_payload_count(self, importer: Importer | None = None):
|
||||
if self.import_file:
|
||||
i = self.get_importer()
|
||||
payloads = i.import_file.get_payloads_for_import()
|
||||
if importer is None:
|
||||
importer = self.get_importer()
|
||||
payloads = importer.import_file.get_payloads_for_import()
|
||||
self.payload_count = len(payloads)
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -101,7 +102,7 @@ class DataImport(Document):
|
|||
def start_import(self):
|
||||
from frappe.utils.scheduler import is_scheduler_inactive
|
||||
|
||||
run_now = frappe.flags.in_test or frappe.conf.developer_mode
|
||||
run_now = frappe.in_test or frappe.conf.developer_mode
|
||||
if is_scheduler_inactive() and not run_now:
|
||||
frappe.throw(_("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive"))
|
||||
|
||||
|
|
@ -135,15 +136,19 @@ class DataImport(Document):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_preview_from_template(data_import, import_file=None, google_sheets_url=None):
|
||||
return frappe.get_doc("Data Import", data_import).get_preview_from_template(
|
||||
import_file, google_sheets_url
|
||||
)
|
||||
def get_preview_from_template(
|
||||
data_import: str, import_file: str | None = None, google_sheets_url: str | None = None
|
||||
):
|
||||
di: DataImport = frappe.get_doc("Data Import", data_import)
|
||||
di.check_permission("read")
|
||||
return di.get_preview_from_template(import_file, google_sheets_url)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def form_start_import(data_import: str):
|
||||
return frappe.get_doc("Data Import", data_import).start_import()
|
||||
di: DataImport = frappe.get_doc("Data Import", data_import)
|
||||
di.check_permission("write")
|
||||
return di.start_import()
|
||||
|
||||
|
||||
def start_import(data_import):
|
||||
|
|
@ -175,6 +180,7 @@ def download_template(doctype, export_fields=None, export_records=None, export_f
|
|||
:param export_filters: Filter dict
|
||||
:param file_type: File type to export into
|
||||
"""
|
||||
frappe.has_permission(doctype, "read", throw=True)
|
||||
|
||||
export_fields = frappe.parse_json(export_fields)
|
||||
export_filters = frappe.parse_json(export_filters)
|
||||
|
|
@ -192,24 +198,25 @@ def download_template(doctype, export_fields=None, export_records=None, export_f
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_errored_template(data_import_name):
|
||||
data_import = frappe.get_doc("Data Import", data_import_name)
|
||||
def download_errored_template(data_import_name: str):
|
||||
data_import: DataImport = frappe.get_doc("Data Import", data_import_name)
|
||||
data_import.check_permission("read")
|
||||
data_import.export_errored_rows()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_import_log(data_import_name):
|
||||
data_import = frappe.get_doc("Data Import", data_import_name)
|
||||
def download_import_log(data_import_name: str):
|
||||
data_import: DataImport = frappe.get_doc("Data Import", data_import_name)
|
||||
data_import.check_permission("read")
|
||||
data_import.download_import_log()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_import_status(data_import_name):
|
||||
import_status = {}
|
||||
|
||||
data_import = frappe.get_doc("Data Import", data_import_name)
|
||||
import_status["status"] = data_import.status
|
||||
def get_import_status(data_import_name: str):
|
||||
data_import: DataImport = frappe.get_doc("Data Import", data_import_name)
|
||||
data_import.check_permission("read")
|
||||
|
||||
import_status = {"status": data_import.status}
|
||||
logs = frappe.get_all(
|
||||
"Data Import Log",
|
||||
fields=["count(*) as count", "success"],
|
||||
|
|
@ -217,7 +224,7 @@ def get_import_status(data_import_name):
|
|||
group_by="success",
|
||||
)
|
||||
|
||||
total_payload_count = frappe.db.get_value("Data Import", data_import_name, "payload_count")
|
||||
total_payload_count = data_import.payload_count
|
||||
|
||||
for log in logs:
|
||||
if log.get("success"):
|
||||
|
|
@ -256,12 +263,15 @@ def import_file(doctype, file_path, import_type, submit_after_import=False, cons
|
|||
"""
|
||||
|
||||
data_import = frappe.new_doc("Data Import")
|
||||
data_import.reference_doctype = doctype
|
||||
data_import.import_file = file_path
|
||||
data_import.submit_after_import = submit_after_import
|
||||
data_import.import_type = (
|
||||
"Insert New Records" if import_type.lower() == "insert" else "Update Existing Records"
|
||||
)
|
||||
|
||||
i = Importer(doctype=doctype, file_path=file_path, data_import=data_import, console=console)
|
||||
data_import.set_payload_count(i)
|
||||
i.import_data()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -733,7 +733,7 @@ class Row:
|
|||
}
|
||||
)
|
||||
return
|
||||
elif df.fieldtype in ["Date", "Datetime"]:
|
||||
elif df.fieldtype == "Date":
|
||||
value = self.get_date(value, col)
|
||||
if isinstance(value, str):
|
||||
# value was not parsed as datetime object
|
||||
|
|
@ -748,6 +748,21 @@ class Row:
|
|||
}
|
||||
)
|
||||
return
|
||||
elif df.fieldtype == "Datetime":
|
||||
value = self.get_datetime(value, col)
|
||||
if isinstance(value, str):
|
||||
# value was not parsed as datetime object
|
||||
self.warnings.append(
|
||||
{
|
||||
"row": self.row_number,
|
||||
"col": col.column_number,
|
||||
"field": df_as_json(df),
|
||||
"message": _("Value {0} must in {1} format").format(
|
||||
frappe.bold(value), frappe.bold(get_user_format(col.date_format))
|
||||
),
|
||||
}
|
||||
)
|
||||
return
|
||||
elif df.fieldtype == "Duration":
|
||||
if not DURATION_PATTERN.match(value):
|
||||
self.warnings.append(
|
||||
|
|
@ -783,15 +798,31 @@ class Row:
|
|||
value = cint(value)
|
||||
elif df.fieldtype in ["Float", "Percent", "Currency"]:
|
||||
value = flt(value)
|
||||
elif df.fieldtype in ["Date", "Datetime"]:
|
||||
elif df.fieldtype == "Date":
|
||||
value = self.get_date(value, col)
|
||||
elif df.fieldtype == "Datetime":
|
||||
value = self.get_datetime(value, col)
|
||||
elif df.fieldtype == "Duration":
|
||||
value = duration_to_seconds(value)
|
||||
|
||||
return value
|
||||
|
||||
def get_date(self, value, column):
|
||||
if isinstance(value, datetime | date):
|
||||
def get_date(self, value, column) -> date:
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
|
||||
date_format = column.date_format
|
||||
if date_format:
|
||||
try:
|
||||
return datetime.strptime(value, date_format).date()
|
||||
except ValueError:
|
||||
# ignore date values that dont match the format
|
||||
# import will break for these values later
|
||||
pass
|
||||
return value
|
||||
|
||||
def get_datetime(self, value, column) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
|
||||
date_format = column.date_format
|
||||
|
|
@ -1004,8 +1035,11 @@ class Column:
|
|||
|
||||
if self.df.fieldtype == "Link":
|
||||
# find all values that dont exist
|
||||
values = list({cstr(v) for v in self.column_values if v})
|
||||
exists = [cstr(d.name) for d in frappe.get_all(self.df.options, filters={"name": ("in", values)})]
|
||||
transform = (lambda v: cstr(v).lower()) if frappe.db.db_type == "mariadb" else cstr
|
||||
values = list({transform(v) for v in self.column_values if v})
|
||||
exists = [
|
||||
transform(d.name) for d in frappe.get_all(self.df.options, filters={"name": ("in", values)})
|
||||
]
|
||||
not_exists = list(set(values) - set(exists))
|
||||
if not_exists:
|
||||
missing_values = ", ".join(not_exists)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,7 @@
|
|||
# Copyright (c) 2020, Frappe Technologies and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestDataImport(UnitTestCase):
|
||||
"""
|
||||
Unit tests for DataImport.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestDataImport(IntegrationTestCase):
|
||||
|
|
|
|||
|
|
@ -3,20 +3,11 @@
|
|||
import frappe
|
||||
from frappe.core.doctype.data_import.exporter import Exporter
|
||||
from frappe.core.doctype.data_import.test_importer import create_doctype_if_not_exists
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
doctype_name = "DocType for Export"
|
||||
|
||||
|
||||
class UnitTestDataImport(UnitTestCase):
|
||||
"""
|
||||
Unit tests for DataImport.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestExporter(IntegrationTestCase):
|
||||
def setUp(self):
|
||||
create_doctype_if_not_exists(doctype_name)
|
||||
|
|
|
|||
|
|
@ -2,22 +2,13 @@
|
|||
# License: MIT. See LICENSE
|
||||
import frappe
|
||||
from frappe.core.doctype.data_import.importer import Importer
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.tests.test_query_builder import db_type_is, run_only_if
|
||||
from frappe.utils import format_duration, getdate
|
||||
|
||||
doctype_name = "DocType for Import"
|
||||
|
||||
|
||||
class UnitTestDataImport(UnitTestCase):
|
||||
"""
|
||||
Unit tests for DataImport.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestImporter(IntegrationTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
|
|
|
|||
|
|
@ -2,16 +2,7 @@
|
|||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestDataImportLog(UnitTestCase):
|
||||
"""
|
||||
Unit tests for DataImportLog.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestDataImportLog(IntegrationTestCase):
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
# Copyright (c) 2015, Frappe Technologies and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestDeletedDocument(UnitTestCase):
|
||||
"""
|
||||
Unit tests for DeletedDocument.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestDeletedDocument(IntegrationTestCase):
|
||||
|
|
|
|||
|
|
@ -499,7 +499,7 @@
|
|||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)",
|
||||
"depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\", \"Percent\"], doc.fieldtype)",
|
||||
"fieldname": "non_negative",
|
||||
"fieldtype": "Check",
|
||||
"label": "Non Negative"
|
||||
|
|
@ -609,18 +609,20 @@
|
|||
"label": "Sticky"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-30 14:58:19.746600",
|
||||
"modified": "2025-08-26 22:08:20.940308",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocField",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class DocShare(Document):
|
|||
|
||||
def get_doc(self):
|
||||
if not getattr(self, "_doc", None):
|
||||
self._doc = frappe.get_doc(self.share_doctype, self.share_name)
|
||||
self._doc = frappe.get_lazy_doc(self.share_doctype, self.share_name)
|
||||
return self._doc
|
||||
|
||||
def validate_user(self):
|
||||
|
|
|
|||
|
|
@ -4,20 +4,11 @@
|
|||
import frappe
|
||||
import frappe.share
|
||||
from frappe.automation.doctype.auto_repeat.test_auto_repeat import create_submittable_doctype
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = ["User"]
|
||||
|
||||
|
||||
class UnitTestDocshare(UnitTestCase):
|
||||
"""
|
||||
Unit tests for Docshare.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestDocShare(IntegrationTestCase):
|
||||
def setUp(self):
|
||||
self.user = "test@example.com"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
|
|
@ -12,14 +12,6 @@ EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
|||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class UnitTest{classname}(UnitTestCase):
|
||||
"""
|
||||
Unit tests for {classname}.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationTest{classname}(IntegrationTestCase):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"restrict_to_domain",
|
||||
"read_only",
|
||||
"in_create",
|
||||
"protect_attached_files",
|
||||
"sb1",
|
||||
"naming_rule",
|
||||
"autoname",
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
"editable_grid",
|
||||
"quick_entry",
|
||||
"grid_page_length",
|
||||
"rows_threshold_for_grid_search",
|
||||
"cb01",
|
||||
"track_changes",
|
||||
"track_seen",
|
||||
|
|
@ -75,6 +77,7 @@
|
|||
"email_append_to",
|
||||
"sender_field",
|
||||
"sender_name_field",
|
||||
"recipient_account_field",
|
||||
"subject_field",
|
||||
"fields_tab",
|
||||
"fields_section",
|
||||
|
|
@ -286,6 +289,7 @@
|
|||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:!doc.issingle",
|
||||
"fieldname": "allow_import",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Import (via Data Import Tool)"
|
||||
|
|
@ -683,14 +687,36 @@
|
|||
"options": "Dynamic\nCompressed"
|
||||
},
|
||||
{
|
||||
"default": "50",
|
||||
"depends_on": "istable",
|
||||
"fieldname": "grid_page_length",
|
||||
"fieldtype": "Int",
|
||||
"label": "Grid Page Length",
|
||||
"non_negative": 1
|
||||
"default": "50",
|
||||
"depends_on": "istable",
|
||||
"fieldname": "grid_page_length",
|
||||
"fieldtype": "Int",
|
||||
"label": "Grid Page Length",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Users are only able to delete attached files if the document is either in draft or if the document is canceled and they are also able to delete the document.",
|
||||
"fieldname": "protect_attached_files",
|
||||
"fieldtype": "Check",
|
||||
"label": "Protect Attached Files"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "istable",
|
||||
"fieldname": "rows_threshold_for_grid_search",
|
||||
"fieldtype": "Int",
|
||||
"label": "Rows Threshold for Grid Search",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "email_append_to",
|
||||
"fieldname": "recipient_account_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Recipient Account Field"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "fa fa-bolt",
|
||||
"idx": 6,
|
||||
"index_web_pages_for_search": 1,
|
||||
|
|
@ -764,14 +790,9 @@
|
|||
"group": "Rules",
|
||||
"link_doctype": "Assignment Rule",
|
||||
"link_fieldname": "document_type"
|
||||
},
|
||||
{
|
||||
"group": "Rules",
|
||||
"link_doctype": "Energy Point Rule",
|
||||
"link_fieldname": "reference_doctype"
|
||||
}
|
||||
],
|
||||
"modified": "2025-02-20 19:05:52.119679",
|
||||
"modified": "2025-07-19 12:23:16.296416",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocType",
|
||||
|
|
@ -801,6 +822,7 @@
|
|||
}
|
||||
],
|
||||
"route": "doctype",
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "module",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "creation",
|
||||
|
|
|
|||
|
|
@ -154,12 +154,15 @@ class DocType(Document):
|
|||
]
|
||||
nsm_parent_field: DF.Data | None
|
||||
permissions: DF.Table[DocPerm]
|
||||
protect_attached_files: DF.Check
|
||||
queue_in_background: DF.Check
|
||||
quick_entry: DF.Check
|
||||
read_only: DF.Check
|
||||
recipient_account_field: DF.Data | None
|
||||
restrict_to_domain: DF.Link | None
|
||||
route: DF.Data | None
|
||||
row_format: DF.Literal["Dynamic", "Compressed"]
|
||||
rows_threshold_for_grid_search: DF.Int
|
||||
search_fields: DF.Data | None
|
||||
sender_field: DF.Data | None
|
||||
sender_name_field: DF.Data | None
|
||||
|
|
@ -320,7 +323,7 @@ class DocType(Document):
|
|||
|
||||
def check_developer_mode(self):
|
||||
"""Throw exception if not developer mode or via patch"""
|
||||
if frappe.flags.in_patch or frappe.flags.in_test:
|
||||
if frappe.flags.in_patch or frappe.in_test:
|
||||
return
|
||||
|
||||
if not frappe.conf.get("developer_mode") and not self.custom:
|
||||
|
|
@ -332,7 +335,7 @@ class DocType(Document):
|
|||
if self.is_virtual and self.custom:
|
||||
frappe.throw(_("Not allowed to create custom Virtual DocType."), CannotCreateStandardDoctypeError)
|
||||
|
||||
if frappe.conf.get("developer_mode"):
|
||||
if frappe.conf.developer_mode and not self.owner:
|
||||
self.owner = "Administrator"
|
||||
self.modified_by = "Administrator"
|
||||
|
||||
|
|
@ -517,7 +520,7 @@ class DocType(Document):
|
|||
self.setup_autoincrement_and_sequence()
|
||||
|
||||
try:
|
||||
frappe.db.updatedb(self.name, Meta(None, bootstrap=self))
|
||||
frappe.db.updatedb(self.name, Meta(self))
|
||||
except Exception as e:
|
||||
print(f"\n\nThere was an issue while migrating the DocType: {self.name}\n")
|
||||
raise e
|
||||
|
|
@ -592,7 +595,7 @@ class DocType(Document):
|
|||
global_search_fields_after_update.append("name")
|
||||
|
||||
if set(global_search_fields_before_update) != set(global_search_fields_after_update):
|
||||
now = (not frappe.request) or frappe.flags.in_test or frappe.flags.in_install
|
||||
now = (not frappe.request) or frappe.in_test or frappe.flags.in_install
|
||||
frappe.enqueue("frappe.utils.global_search.rebuild_for_doctype", now=now, doctype=self.name)
|
||||
|
||||
def set_base_class_for_controller(self):
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import os
|
|||
import random
|
||||
import string
|
||||
import unittest
|
||||
from unittest.case import skipIf
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
|
|
@ -24,19 +25,10 @@ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
|||
from frappe.desk.form.load import getdoc
|
||||
from frappe.model.delete_doc import delete_controllers
|
||||
from frappe.model.sync import remove_orphan_doctypes
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.utils import get_table_name
|
||||
|
||||
|
||||
class UnitTestDoctype(UnitTestCase):
|
||||
"""
|
||||
Unit tests for Doctype.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestDocType(IntegrationTestCase):
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
|
@ -56,6 +48,10 @@ class TestDocType(IntegrationTestCase):
|
|||
doc = new_doctype(name).insert()
|
||||
doc.delete()
|
||||
|
||||
@skipIf(
|
||||
frappe.conf.db_type == "sqlite",
|
||||
"Not for SQLite for now",
|
||||
)
|
||||
def test_making_sequence_on_change(self):
|
||||
frappe.delete_doc_if_exists("DocType", self._testMethodName)
|
||||
dt = new_doctype(self._testMethodName).insert(ignore_permissions=True)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,7 @@
|
|||
# Copyright (c) 2020, Frappe Technologies and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestDocumentNamingRule(UnitTestCase):
|
||||
"""
|
||||
Unit tests for DocumentNamingRule.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestDocumentNamingRule(IntegrationTestCase):
|
||||
|
|
|
|||
|
|
@ -1,16 +1,7 @@
|
|||
# Copyright (c) 2020, Frappe Technologies and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
class UnitTestDocumentNamingRuleCondition(UnitTestCase):
|
||||
"""
|
||||
Unit tests for DocumentNamingRuleCondition.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestDocumentNamingRuleCondition(IntegrationTestCase):
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue