diff --git a/.editorconfig b/.editorconfig
index 24f122a8d4..d76f67cd7f 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -9,6 +9,6 @@ trim_trailing_whitespace = true
charset = utf-8
# python, js indentation settings
-[{*.py,*.js}]
+[{*.py,*.js,*.vue}]
indent_style = tab
indent_size = 4
diff --git a/.eslintrc b/.eslintrc
index d123023a68..937f11586c 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -80,6 +80,7 @@
"validate_email": true,
"validate_name": true,
"validate_phone": true,
+ "validate_url": true,
"get_number_format": true,
"format_number": true,
"format_currency": true,
@@ -135,7 +136,6 @@
"PhotoSwipeUI_Default": true,
"fluxify": true,
"io": true,
- "QUnit": true,
"JsBarcode": true,
"L": true,
"Chart": true,
@@ -143,11 +143,14 @@
"Cypress": true,
"cy": true,
"it": true,
+ "describe": true,
"expect": true,
"context": true,
"before": true,
"beforeEach": true,
+ "after": true,
"qz": true,
- "localforage": true
+ "localforage": true,
+ "extend_cscript": true
}
}
diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000000..56c9b9a369
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,33 @@
+[flake8]
+ignore =
+ E121,
+ E126,
+ E127,
+ E128,
+ E203,
+ E225,
+ E226,
+ E231,
+ E241,
+ E251,
+ E261,
+ E265,
+ E302,
+ E303,
+ E305,
+ E402,
+ E501,
+ E741,
+ W291,
+ W292,
+ W293,
+ W391,
+ W503,
+ W504,
+ F403,
+ B007,
+ B950,
+ W191,
+
+max-line-length = 200
+exclude=.github/helper/semgrep_rules
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 0000000000..96e9be8b3c
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,21 @@
+# Since version 2.23 (released in August 2019), git-blame has a feature
+# to ignore or bypass certain commits.
+#
+# This file contains a list of commits that are not likely what you
+# are looking for in a blame, such as mass reformatting or renaming.
+# You can set this file as a default ignore file for blame by running
+# the following command.
+#
+# $ git config blame.ignoreRevsFile .git-blame-ignore-revs
+
+# Replace use of Class.extend with native JS class
+fe20515c23a3ac41f1092bf0eaf0a0a452ec2e85
+
+# Updating license headers
+34460265554242a8d05fb09f049033b1117e1a2b
+
+# Refactor "not a in b" -> "a not in b"
+745297a49d516e5e3c4bb3e1b0c4235e7d31165d
+
+# Clean up whitespace
+b2fc959307c7c79f5584625569d5aed04133ba13
diff --git a/.travis/consumer_db/mariadb.json b/.github/helper/consumer_db/mariadb.json
similarity index 89%
rename from .travis/consumer_db/mariadb.json
rename to .github/helper/consumer_db/mariadb.json
index fb5b3bc976..2e32157e1a 100644
--- a/.travis/consumer_db/mariadb.json
+++ b/.github/helper/consumer_db/mariadb.json
@@ -1,5 +1,6 @@
{
- "db_host": "localhost",
+ "db_host": "127.0.0.1",
+ "db_port": 3306,
"db_name": "test_frappe_consumer",
"db_password": "test_frappe",
"allow_tests": true,
diff --git a/.travis/consumer_db/postgres.json b/.github/helper/consumer_db/postgres.json
similarity index 90%
rename from .travis/consumer_db/postgres.json
rename to .github/helper/consumer_db/postgres.json
index fed9fdfde2..9532670029 100644
--- a/.travis/consumer_db/postgres.json
+++ b/.github/helper/consumer_db/postgres.json
@@ -1,5 +1,6 @@
{
- "db_host": "localhost",
+ "db_host": "127.0.0.1",
+ "db_port": 5432,
"db_name": "test_frappe_consumer",
"db_password": "test_frappe",
"db_type": "postgres",
diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py
index 08d1d1aa9c..aece5f543b 100644
--- a/.github/helper/documentation.py
+++ b/.github/helper/documentation.py
@@ -24,6 +24,8 @@ def docs_link_exists(body):
parts = parsed_url.path.split('/')
if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos:
return True
+ if parsed_url.netloc in ["docs.erpnext.com", "frappeframework.com"]:
+ return True
if __name__ == "__main__":
@@ -32,9 +34,9 @@ if __name__ == "__main__":
if response.ok:
payload = response.json()
- title = payload.get("title", "").lower()
- head_sha = payload.get("head", {}).get("sha")
- body = payload.get("body", "").lower()
+ title = (payload.get("title") or "").lower()
+ head_sha = (payload.get("head") or {}).get("sha")
+ body = (payload.get("body") or "").lower()
if title.startswith("feat") and head_sha and "no-docs" not in body:
if docs_link_exists(body):
diff --git a/.github/helper/install.sh b/.github/helper/install.sh
new file mode 100644
index 0000000000..246bdbe096
--- /dev/null
+++ b/.github/helper/install.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+
+set -e
+
+cd ~ || exit
+
+pip install frappe-bench
+
+bench init frappe-bench --skip-assets --python "$(which python)" --frappe-path "${GITHUB_WORKSPACE}"
+
+mkdir ~/frappe-bench/sites/test_site
+cp "${GITHUB_WORKSPACE}/.github/helper/consumer_db/$DB.json" ~/frappe-bench/sites/test_site/site_config.json
+
+if [ "$TYPE" == "server" ]; then
+ mkdir ~/frappe-bench/sites/test_site_producer;
+ cp "${GITHUB_WORKSPACE}/.github/helper/producer_db/$DB.json" ~/frappe-bench/sites/test_site_producer/site_config.json;
+fi
+
+if [ "$DB" == "mariadb" ];then
+ sudo apt update && sudo apt install mariadb-client-10.3
+ mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL character_set_server = 'utf8mb4'";
+ mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'";
+
+ mysql --host 127.0.0.1 --port 3306 -u root -e "CREATE DATABASE test_frappe_consumer";
+ mysql --host 127.0.0.1 --port 3306 -u root -e "CREATE USER 'test_frappe_consumer'@'localhost' IDENTIFIED BY 'test_frappe_consumer'";
+ mysql --host 127.0.0.1 --port 3306 -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe_consumer\`.* TO 'test_frappe_consumer'@'localhost'";
+
+ mysql --host 127.0.0.1 --port 3306 -u root -e "CREATE DATABASE test_frappe_producer";
+ mysql --host 127.0.0.1 --port 3306 -u root -e "CREATE USER 'test_frappe_producer'@'localhost' IDENTIFIED BY 'test_frappe_producer'";
+ mysql --host 127.0.0.1 --port 3306 -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe_producer\`.* TO 'test_frappe_producer'@'localhost'";
+
+ mysql --host 127.0.0.1 --port 3306 -u root -e "UPDATE mysql.user SET Password=PASSWORD('travis') WHERE User='root'";
+ mysql --host 127.0.0.1 --port 3306 -u root -e "FLUSH PRIVILEGES";
+ fi
+
+if [ "$DB" == "postgres" ];then
+ echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe_consumer" -U postgres;
+ echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe_consumer WITH PASSWORD 'test_frappe'" -U postgres;
+
+ echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe_producer" -U postgres;
+ echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe_producer WITH PASSWORD 'test_frappe'" -U postgres;
+fi
+
+cd ./frappe-bench || exit
+
+sed -i 's/^watch:/# watch:/g' Procfile
+sed -i 's/^schedule:/# schedule:/g' Procfile
+
+if [ "$TYPE" == "server" ]; then sed -i 's/^socketio:/# socketio:/g' Procfile; fi
+if [ "$TYPE" == "server" ]; then sed -i 's/^redis_socketio:/# redis_socketio:/g' Procfile; fi
+
+if [ "$TYPE" == "ui" ]; then bench setup requirements --node; fi
+bench setup requirements --dev
+
+if [ "$TYPE" == "ui" ]; then sed -i 's/^web: bench serve/web: bench serve --with-coverage/g' Procfile; fi
+
+# install node-sass which is required for website theme test
+cd ./apps/frappe || exit
+yarn add node-sass@4.13.1
+cd ../..
+
+bench start &
+bench --site test_site reinstall --yes
+if [ "$TYPE" == "server" ]; then bench --site test_site_producer reinstall --yes; fi
+if [ "$TYPE" == "server" ]; then CI=Yes bench build --app frappe; fi
diff --git a/.github/helper/install_dependencies.sh b/.github/helper/install_dependencies.sh
new file mode 100644
index 0000000000..d16f5b62ad
--- /dev/null
+++ b/.github/helper/install_dependencies.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+
+set -e
+
+ # install wkhtmltopdf
+wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz
+tar -xf /tmp/wkhtmltox.tar.xz -C /tmp
+sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf
+sudo chmod o+x /usr/local/bin/wkhtmltopdf
+
+# install cups
+sudo apt-get install libcups2-dev
+
+# install redis
+sudo apt-get install redis-server
+
diff --git a/.travis/producer_db/mariadb.json b/.github/helper/producer_db/mariadb.json
similarity index 89%
rename from .travis/producer_db/mariadb.json
rename to .github/helper/producer_db/mariadb.json
index 988282a554..c1db0d765f 100644
--- a/.travis/producer_db/mariadb.json
+++ b/.github/helper/producer_db/mariadb.json
@@ -1,5 +1,6 @@
{
- "db_host": "localhost",
+ "db_host": "127.0.0.1",
+ "db_port": 3306,
"db_name": "test_frappe_producer",
"db_password": "test_frappe",
"allow_tests": true,
diff --git a/.travis/producer_db/postgres.json b/.github/helper/producer_db/postgres.json
similarity index 89%
rename from .travis/producer_db/postgres.json
rename to .github/helper/producer_db/postgres.json
index 6426e99058..8b9d2a20fd 100644
--- a/.travis/producer_db/postgres.json
+++ b/.github/helper/producer_db/postgres.json
@@ -1,5 +1,6 @@
{
- "db_host": "localhost",
+ "db_host": "127.0.0.1",
+ "db_port": 5432,
"db_name": "test_frappe_producer",
"db_password": "test_frappe",
"db_type": "postgres",
diff --git a/.github/helper/roulette.py b/.github/helper/roulette.py
new file mode 100644
index 0000000000..90f4608a22
--- /dev/null
+++ b/.github/helper/roulette.py
@@ -0,0 +1,78 @@
+import json
+import os
+import re
+import shlex
+import subprocess
+import sys
+import urllib.request
+
+
+def get_files_list(pr_number, repo="frappe/frappe"):
+ req = urllib.request.Request(f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files")
+ res = urllib.request.urlopen(req)
+ dump = json.loads(res.read().decode('utf8'))
+ return [change["filename"] for change in dump]
+
+def get_output(command, shell=True):
+ print(command)
+ command = shlex.split(command)
+ return subprocess.check_output(command, shell=shell, encoding="utf8").strip()
+
+def is_py(file):
+ return file.endswith("py")
+
+def is_ci(file):
+ return ".github" in file
+
+def is_frontend_code(file):
+ return file.lower().endswith((".css", ".scss", ".less", ".sass", ".styl", ".js", ".ts", ".vue"))
+
+def is_docs(file):
+ regex = re.compile(r'\.(md|png|jpg|jpeg|csv)$|^.github|LICENSE')
+ return bool(regex.search(file))
+
+
+if __name__ == "__main__":
+ files_list = sys.argv[1:]
+ build_type = os.environ.get("TYPE")
+ pr_number = os.environ.get("PR_NUMBER")
+ repo = os.environ.get("REPO_NAME")
+
+ # this is a push build, run all builds
+ if not pr_number:
+ os.system('echo "::set-output name=build::strawberry"')
+ os.system('echo "::set-output name=build-server::strawberry"')
+ sys.exit(0)
+
+ files_list = files_list or get_files_list(pr_number=pr_number, repo=repo)
+
+ if not files_list:
+ print("No files' changes detected. Build is shutting")
+ sys.exit(0)
+
+ ci_files_changed = any(f for f in files_list if is_ci(f))
+ only_docs_changed = len(list(filter(is_docs, files_list))) == len(files_list)
+ only_frontend_code_changed = len(list(filter(is_frontend_code, files_list))) == len(files_list)
+ updated_py_file_count = len(list(filter(is_py, files_list)))
+ only_py_changed = updated_py_file_count == len(files_list)
+
+ if ci_files_changed:
+ print("CI related files were updated, running all build processes.")
+
+ elif only_docs_changed:
+ print("Only docs were updated, stopping build process.")
+ sys.exit(0)
+
+ elif only_frontend_code_changed and build_type == "server":
+ print("Only Frontend code was updated; Stopping Python build process.")
+ sys.exit(0)
+
+ elif build_type == "ui":
+ if only_py_changed:
+ print("Only Python code was updated, stopping Cypress build process.")
+ sys.exit(0)
+ elif updated_py_file_count > 0:
+ # both frontend and backend code were updated
+ os.system('echo "::set-output name=build-server::strawberry"')
+
+ os.system('echo "::set-output name=build::strawberry"')
diff --git a/.github/semantic.yml b/.github/semantic.yml
index e1e53bc1a4..fa15046b4a 100644
--- a/.github/semantic.yml
+++ b/.github/semantic.yml
@@ -11,3 +11,20 @@ allowRevertCommits: true
# For allowed PR types: https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json
# Tool Reference: https://github.com/zeke/semantic-pull-requests
+
+# By default types specified in commitizen/conventional-commit-types is used.
+# See: https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json
+# You can override the valid types
+types:
+ - BREAKING CHANGE
+ - feat
+ - fix
+ - docs
+ - style
+ - refactor
+ - perf
+ - test
+ - build
+ - ci
+ - chore
+ - revert
diff --git a/.github/stale.yml b/.github/stale.yml
index dd1ab9e9e7..2d776759e4 100644
--- a/.github/stale.yml
+++ b/.github/stale.yml
@@ -1,7 +1,7 @@
# Configuration for probot-stale - https://github.com/probot/stale
# Number of days of inactivity before an Issue or Pull Request becomes stale
-daysUntilStale: 10
+daysUntilStale: 7
# Number of days of inactivity before a stale Issue or Pull Request is closed.
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
@@ -28,7 +28,7 @@ markComment: >
you can always reopen the PR when you're ready. Thank you for contributing.
# Limit the number of actions per hour, from 1-30. Default is 30
-limitPerRun: 30
+limitPerRun: 10
# Limit to only `issues` or `pulls`
only: pulls
diff --git a/.github/try-on-f-cloud-button.svg b/.github/try-on-f-cloud-button.svg
new file mode 100644
index 0000000000..fe0bb2c52d
--- /dev/null
+++ b/.github/try-on-f-cloud-button.svg
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index 510e7c7678..dba13f9358 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -12,4 +12,4 @@ jobs:
- name: curl
run: |
apk add curl bash
- curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "Travis-API-Version: 3" -H "Authorization: token ${{ secrets.TRAVIS_CI_TOKEN }}" -d '{"request":{"branch":"master"}}' https://api.travis-ci.com/repo/frappe%2Ffrappe_docker/requests
+ curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.CI_PAT }}" https://api.github.com/repos/frappe/frappe_docker/actions/workflows/build_stable.yml/dispatches -d '{"ref":"main"}'
diff --git a/.github/workflows/docs-checker.yml b/.github/workflows/docs-checker.yml
index 90453cd1b4..5e91063698 100644
--- a/.github/workflows/docs-checker.yml
+++ b/.github/workflows/docs-checker.yml
@@ -12,7 +12,7 @@ jobs:
- name: 'Setup Environment'
uses: actions/setup-python@v2
with:
- python-version: 3.6
+ python-version: 3.8
- name: 'Clone repo'
uses: actions/checkout@v2
diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml
new file mode 100644
index 0000000000..443ee45bf7
--- /dev/null
+++ b/.github/workflows/linters.yml
@@ -0,0 +1,31 @@
+name: Linters
+
+on:
+ pull_request: { }
+
+jobs:
+
+ linters:
+ name: Frappe Linter
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Set up Python 3.8
+ uses: actions/setup-python@v2
+ with:
+ python-version: 3.8
+
+ - name: Install and Run Pre-commit
+ uses: pre-commit/action@v2.0.3
+
+ - name: Download Semgrep rules
+ run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules
+
+ - uses: returntocorp/semgrep-action@v1
+ env:
+ SEMGREP_TIMEOUT: 120
+ with:
+ config: >-
+ r/python.lang.correctness
+ ./frappe-semgrep-rules/rules
diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml
new file mode 100644
index 0000000000..c8294886a0
--- /dev/null
+++ b/.github/workflows/patch-mariadb-tests.yml
@@ -0,0 +1,131 @@
+name: Patch
+
+on: [pull_request, workflow_dispatch]
+
+
+concurrency:
+ group: patch-mariadb-develop-${{ github.event.number }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+
+ name: Patch Test
+
+ services:
+ mysql:
+ image: mariadb:10.3
+ env:
+ MYSQL_ALLOW_EMPTY_PASSWORD: YES
+ ports:
+ - 3306:3306
+ options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
+
+ steps:
+ - name: Clone
+ uses: actions/checkout@v2
+
+ - name: Setup Python
+ uses: actions/setup-python@v2
+ with:
+ python-version: '3.9'
+
+ - name: Setup Node
+ uses: actions/setup-node@v2
+ with:
+ node-version: 14
+ check-latest: true
+
+ - name: Check if build should be run
+ id: check-build
+ run: |
+ python "${GITHUB_WORKSPACE}/.github/helper/roulette.py"
+ env:
+ TYPE: "server"
+ PR_NUMBER: ${{ github.event.number }}
+ REPO_NAME: ${{ github.repository }}
+
+ - name: Add to Hosts
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
+
+ - name: Cache pip
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ uses: actions/cache@v2
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+ restore-keys: |
+ ${{ runner.os }}-pip-
+ ${{ runner.os }}-
+
+ - name: Cache node modules
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ uses: actions/cache@v2
+ env:
+ cache-name: cache-node-modules
+ with:
+ path: ~/.npm
+ key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-build-${{ env.cache-name }}-
+ ${{ runner.os }}-build-
+ ${{ runner.os }}-
+
+ - name: Get yarn cache directory path
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ id: yarn-cache-dir-path
+ run: echo "::set-output name=dir::$(yarn cache dir)"
+
+ - uses: actions/cache@v2
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ id: yarn-cache
+ with:
+ path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-yarn-
+
+ - name: Install Dependencies
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh
+ env:
+ BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
+ AFTER: ${{ env.GITHUB_EVENT_PATH.after }}
+ TYPE: server
+
+ - name: Install
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
+ env:
+ DB: mariadb
+ TYPE: server
+
+ - name: Run Patch Tests
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: |
+ cd ~/frappe-bench/
+ wget https://frappeframework.com/files/v10-frappe.sql.gz
+ bench --site test_site --force restore ~/frappe-bench/v10-frappe.sql.gz
+
+ source env/bin/activate
+ cd apps/frappe/
+ git remote set-url upstream https://github.com/frappe/frappe.git
+
+ for version in $(seq 12 13)
+ do
+ echo "Updating to v$version"
+ branch_name="version-$version-hotfix"
+ git fetch --depth 1 upstream $branch_name:$branch_name
+
+ git checkout -q -f $branch_name
+ pip install -q -r requirements.txt
+ bench --site test_site migrate
+ done
+
+ echo "Updating to last commit"
+ git checkout -q -f "$GITHUB_SHA"
+ bench setup requirements --python
+ bench --site test_site migrate
diff --git a/.github/workflows/publish-assets-develop.yml b/.github/workflows/publish-assets-develop.yml
index 2a934a6795..f56d1460b5 100644
--- a/.github/workflows/publish-assets-develop.yml
+++ b/.github/workflows/publish-assets-develop.yml
@@ -15,11 +15,11 @@ jobs:
path: 'frappe'
- uses: actions/setup-node@v1
with:
- python-version: '12.x'
+ node-version: 14
- uses: actions/setup-python@v2
with:
- python-version: '3.6'
- - name: Set up bench for current push
+ python-version: '3.9'
+ - name: Set up bench and build assets
run: |
npm install -g yarn
pip3 install -U frappe-bench
@@ -29,7 +29,7 @@ jobs:
- name: Package assets
run: |
mkdir -p $GITHUB_WORKSPACE/build
- tar -cvpzf $GITHUB_WORKSPACE/build/$GITHUB_SHA.tar.gz ./frappe-bench/sites/assets/js ./frappe-bench/sites/assets/css
+ tar -cvpzf $GITHUB_WORKSPACE/build/$GITHUB_SHA.tar.gz ./frappe-bench/sites/assets/frappe/dist
- name: Publish assets to S3
uses: jakejarvis/s3-sync-action@master
diff --git a/.github/workflows/publish-assets-releases.yml b/.github/workflows/publish-assets-releases.yml
index e86f884f35..2582632fa0 100644
--- a/.github/workflows/publish-assets-releases.yml
+++ b/.github/workflows/publish-assets-releases.yml
@@ -21,8 +21,8 @@ jobs:
python-version: '12.x'
- uses: actions/setup-python@v2
with:
- python-version: '3.6'
- - name: Set up bench for current push
+ python-version: '3.9'
+ - name: Set up bench and build assets
run: |
npm install -g yarn
pip3 install -U frappe-bench
@@ -32,7 +32,7 @@ jobs:
- name: Package assets
run: |
mkdir -p $GITHUB_WORKSPACE/build
- tar -cvpzf $GITHUB_WORKSPACE/build/assets.tar.gz ./frappe-bench/sites/assets/js ./frappe-bench/sites/assets/css
+ tar -cvpzf $GITHUB_WORKSPACE/build/assets.tar.gz ./frappe-bench/sites/assets/frappe/dist
- name: Get release
id: get_release
diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml
new file mode 100644
index 0000000000..4edf74ba71
--- /dev/null
+++ b/.github/workflows/server-mariadb-tests.yml
@@ -0,0 +1,132 @@
+name: Server
+
+on:
+ pull_request:
+ workflow_dispatch:
+ push:
+ branches: [ develop ]
+
+concurrency:
+ group: server-mariadb-develop-${{ github.event.number }}
+ cancel-in-progress: true
+
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+
+ strategy:
+ fail-fast: false
+ matrix:
+ container: [1, 2]
+
+ name: Python Unit Tests (MariaDB)
+
+ services:
+ mysql:
+ image: mariadb:10.3
+ env:
+ MYSQL_ALLOW_EMPTY_PASSWORD: YES
+ ports:
+ - 3306:3306
+ options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
+
+ steps:
+ - name: Clone
+ uses: actions/checkout@v2
+
+ - name: Setup Python
+ uses: actions/setup-python@v2
+ with:
+ python-version: '3.9'
+
+ - name: Check if build should be run
+ id: check-build
+ run: |
+ python "${GITHUB_WORKSPACE}/.github/helper/roulette.py"
+ env:
+ TYPE: "server"
+ PR_NUMBER: ${{ github.event.number }}
+ REPO_NAME: ${{ github.repository }}
+
+ - uses: actions/setup-node@v2
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ with:
+ node-version: 14
+ check-latest: true
+
+ - name: Add to Hosts
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: |
+ echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
+ echo "127.0.0.1 test_site_producer" | sudo tee -a /etc/hosts
+
+ - name: Cache pip
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ uses: actions/cache@v2
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+ restore-keys: |
+ ${{ runner.os }}-pip-
+ ${{ runner.os }}-
+
+ - name: Cache node modules
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ uses: actions/cache@v2
+ env:
+ cache-name: cache-node-modules
+ with:
+ path: ~/.npm
+ key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-build-${{ env.cache-name }}-
+ ${{ runner.os }}-build-
+ ${{ runner.os }}-
+
+ - name: Get yarn cache directory path
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ id: yarn-cache-dir-path
+ run: echo "::set-output name=dir::$(yarn cache dir)"
+
+ - uses: actions/cache@v2
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ id: yarn-cache
+ with:
+ path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-yarn-
+
+ - name: Install Dependencies
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh
+ env:
+ BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
+ AFTER: ${{ env.GITHUB_EVENT_PATH.after }}
+ TYPE: server
+
+ - name: Install
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
+ env:
+ DB: mariadb
+ TYPE: server
+
+ - name: Run Tests
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage
+ env:
+ CI_BUILD_ID: ${{ github.run_id }}
+ ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
+
+ - name: Upload coverage data
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ uses: codecov/codecov-action@v2
+ with:
+ name: MariaDB
+ fail_ci_if_error: true
+ files: /home/runner/frappe-bench/sites/coverage.xml
+ verbose: true
+ flags: server
diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml
new file mode 100644
index 0000000000..895af5184e
--- /dev/null
+++ b/.github/workflows/server-postgres-tests.yml
@@ -0,0 +1,135 @@
+name: Server
+
+on:
+ pull_request:
+ workflow_dispatch:
+ push:
+ branches: [ develop ]
+
+concurrency:
+ group: server-postgres-develop-${{ github.event.number }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+
+ strategy:
+ fail-fast: false
+ matrix:
+ container: [1, 2]
+
+ name: Python Unit Tests (Postgres)
+
+ services:
+ postgres:
+ image: postgres:12.4
+ env:
+ POSTGRES_PASSWORD: travis
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ steps:
+ - name: Clone
+ uses: actions/checkout@v2
+
+ - name: Setup Python
+ uses: actions/setup-python@v2
+ with:
+ python-version: '3.9'
+
+ - name: Check if build should be run
+ id: check-build
+ run: |
+ python "${GITHUB_WORKSPACE}/.github/helper/roulette.py"
+ env:
+ TYPE: "server"
+ PR_NUMBER: ${{ github.event.number }}
+ REPO_NAME: ${{ github.repository }}
+
+ - uses: actions/setup-node@v2
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ with:
+ node-version: '14'
+ check-latest: true
+
+ - name: Add to Hosts
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: |
+ echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
+ echo "127.0.0.1 test_site_producer" | sudo tee -a /etc/hosts
+
+ - name: Cache pip
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ uses: actions/cache@v2
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+ restore-keys: |
+ ${{ runner.os }}-pip-
+ ${{ runner.os }}-
+
+ - name: Cache node modules
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ uses: actions/cache@v2
+ env:
+ cache-name: cache-node-modules
+ with:
+ path: ~/.npm
+ key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-build-${{ env.cache-name }}-
+ ${{ runner.os }}-build-
+ ${{ runner.os }}-
+
+ - name: Get yarn cache directory path
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ id: yarn-cache-dir-path
+ run: echo "::set-output name=dir::$(yarn cache dir)"
+
+ - uses: actions/cache@v2
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ id: yarn-cache
+ with:
+ path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-yarn-
+
+ - name: Install Dependencies
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh
+ env:
+ BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
+ AFTER: ${{ env.GITHUB_EVENT_PATH.after }}
+ TYPE: server
+
+ - name: Install
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
+ env:
+ DB: postgres
+ TYPE: server
+
+ - name: Run Tests
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage
+ env:
+ CI_BUILD_ID: ${{ github.run_id }}
+ ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
+
+ - name: Upload coverage data
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ uses: codecov/codecov-action@v2
+ with:
+ name: Postgres
+ fail_ci_if_error: true
+ files: /home/runner/frappe-bench/sites/coverage.xml
+ verbose: true
+ flags: server
diff --git a/.github/workflows/translation_linter.yml b/.github/workflows/translation_linter.yml
deleted file mode 100644
index 4becaebd6b..0000000000
--- a/.github/workflows/translation_linter.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-name: Frappe Linter
-on:
- pull_request:
- branches:
- - develop
- - version-12-hotfix
- - version-11-hotfix
-jobs:
- check_translation:
- name: Translation Syntax Check
- runs-on: ubuntu-18.04
- steps:
- - uses: actions/checkout@v2
- - name: Setup python3
- uses: actions/setup-python@v1
- with:
- python-version: 3.6
- - name: Validating Translation Syntax
- run: |
- git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q
- files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF)
- python $GITHUB_WORKSPACE/.github/helper/translation.py $files
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
new file mode 100644
index 0000000000..fc8093444e
--- /dev/null
+++ b/.github/workflows/ui-tests.yml
@@ -0,0 +1,174 @@
+name: UI
+
+on:
+ pull_request:
+ workflow_dispatch:
+ push:
+ branches: [ develop ]
+
+concurrency:
+ group: ui-develop-${{ github.event.number }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+
+ strategy:
+ fail-fast: false
+ matrix:
+ containers: [1, 2]
+
+ name: UI Tests (Cypress)
+
+ services:
+ mysql:
+ image: mariadb:10.3
+ env:
+ MYSQL_ALLOW_EMPTY_PASSWORD: YES
+ ports:
+ - 3306:3306
+ options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
+
+ steps:
+ - name: Clone
+ uses: actions/checkout@v2
+
+ - name: Setup Python
+ uses: actions/setup-python@v2
+ with:
+ python-version: '3.9'
+
+ - name: Check if build should be run
+ id: check-build
+ run: |
+ python "${GITHUB_WORKSPACE}/.github/helper/roulette.py"
+ env:
+ TYPE: "ui"
+ PR_NUMBER: ${{ github.event.number }}
+ REPO_NAME: ${{ github.repository }}
+
+ - uses: actions/setup-node@v2
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ with:
+ node-version: 14
+ check-latest: true
+
+ - name: Add to Hosts
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: |
+ echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
+ echo "127.0.0.1 test_site_producer" | sudo tee -a /etc/hosts
+
+ - name: Cache pip
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ uses: actions/cache@v2
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+ restore-keys: |
+ ${{ runner.os }}-pip-
+ ${{ runner.os }}-
+
+ - name: Cache node modules
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ uses: actions/cache@v2
+ env:
+ cache-name: cache-node-modules
+ with:
+ path: ~/.npm
+ key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-build-${{ env.cache-name }}-
+ ${{ runner.os }}-build-
+ ${{ runner.os }}-
+
+ - name: Get yarn cache directory path
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ id: yarn-cache-dir-path
+ run: echo "::set-output name=dir::$(yarn cache dir)"
+
+ - uses: actions/cache@v2
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ id: yarn-cache
+ with:
+ path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-yarn-
+
+ - name: Cache cypress binary
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ uses: actions/cache@v2
+ with:
+ path: ~/.cache
+ key: ${{ runner.os }}-cypress-
+ restore-keys: |
+ ${{ runner.os }}-cypress-
+ ${{ runner.os }}-
+
+ - name: Install Dependencies
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh
+ env:
+ BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
+ AFTER: ${{ env.GITHUB_EVENT_PATH.after }}
+ TYPE: ui
+
+ - name: Install
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
+ env:
+ DB: mariadb
+ TYPE: ui
+
+ - name: Instrument Source Code
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: cd ~/frappe-bench/apps/frappe/ && npx nyc instrument -x 'frappe/public/dist/**' -x 'frappe/public/js/lib/**' -x '**/*.bundle.js' --compact=false --in-place frappe
+
+ - name: Build
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: cd ~/frappe-bench/ && bench build --apps frappe
+
+ - name: Site Setup
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: cd ~/frappe-bench/ && bench --site test_site execute frappe.utils.install.complete_setup_wizard
+
+ - name: UI Tests
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --with-coverage --headless --parallel --ci-build-id $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT
+ env:
+ CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb
+
+ - name: Stop server
+ if: ${{ steps.check-build.outputs.build-server == 'strawberry' }}
+ run: |
+ ps -ef | grep "frappe serve" | awk '{print $2}' | xargs kill -s SIGINT 2> /dev/null || true
+ sleep 5
+
+ - name: Check If Coverage Report Exists
+ id: check_coverage
+ uses: andstor/file-existence-action@v1
+ with:
+ files: "/home/runner/frappe-bench/apps/frappe/.cypress-coverage/clover.xml"
+
+ - name: Upload Coverage Data
+ if: ${{ steps.check-build.outputs.build == 'strawberry' && steps.check_coverage.outputs.files_exists == 'true' }}
+ uses: codecov/codecov-action@v2
+ with:
+ name: Cypress
+ fail_ci_if_error: true
+ directory: /home/runner/frappe-bench/apps/frappe/.cypress-coverage/
+ verbose: true
+ flags: ui-tests
+
+ - name: Upload Server Coverage Data
+ if: ${{ steps.check-build.outputs.build-server == 'strawberry' }}
+ uses: codecov/codecov-action@v2
+ with:
+ name: MariaDB
+ fail_ci_if_error: true
+ files: /home/runner/frappe-bench/sites/coverage.xml
+ verbose: true
+ flags: server
diff --git a/.gitignore b/.gitignore
index 766288fe2e..7e3d178630 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,7 +9,9 @@ locale
dist/
# build/
frappe/docs/current
+frappe/public/dist
.vscode
+.vs
node_modules
.kdev4/
*.kdev4
@@ -66,6 +68,7 @@ coverage.xml
*.cover
.hypothesis/
.pytest_cache/
+.cypress-coverage
# Translations
*.mo
diff --git a/.mergify.yml b/.mergify.yml
index eae959b8a0..63fe1a0086 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -1,9 +1,30 @@
pull_request_rules:
+ - name: Auto-close PRs on stable branch
+ conditions:
+ - and:
+ - and:
+ - author!=surajshetty3416
+ - author!=gavindsouza
+ - or:
+ - base=version-13
+ - base=version-12
+ actions:
+ close:
+ comment:
+ message: |
+ @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
+ https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
+
- name: Automatic merge on CI success and review
conditions:
- status-success=Sider
- status-success=Semantic Pull Request
- - status-success=Travis CI - Pull Request
+ - status-success=Python Unit Tests (MariaDB) (1)
+ - status-success=Python Unit Tests (MariaDB) (2)
+ - status-success=Python Unit Tests (Postgres) (1)
+ - status-success=Python Unit Tests (Postgres) (2)
+ - status-success=UI Tests (Cypress) (1)
+ - status-success=UI Tests (Cypress) (2)
- status-success=security/snyk (frappe)
- label!=dont-merge
- label!=squash
@@ -14,7 +35,12 @@ pull_request_rules:
- name: Automatic squash on CI success and review
conditions:
- status-success=Sider
- - status-success=Travis CI - Pull Request
+ - status-success=Python Unit Tests (MariaDB) (1)
+ - status-success=Python Unit Tests (MariaDB) (2)
+ - status-success=Python Unit Tests (Postgres) (1)
+ - status-success=Python Unit Tests (Postgres) (2)
+ - status-success=UI Tests (Cypress) (1)
+ - status-success=UI Tests (Cypress) (2)
- status-success=security/snyk (frappe)
- label!=dont-merge
- label=squash
@@ -22,3 +48,7 @@ pull_request_rules:
actions:
merge:
method: squash
+ commit_message_template: |
+ {{ title }} (#{{ number }})
+
+ {{ body }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000000..f3c3447cb3
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,23 @@
+exclude: 'node_modules|.git'
+default_stages: [commit]
+fail_fast: false
+
+
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.0.1
+ hooks:
+ - id: trailing-whitespace
+ files: "frappe.*"
+ exclude: ".*json$|.*txt$|.*csv|.*md|.*svg"
+ - id: check-yaml
+ - id: no-commit-to-branch
+ args: ['--branch', 'develop']
+ - id: check-merge-conflict
+ - id: check-ast
+
+
+ci:
+ autoupdate_schedule: weekly
+ skip: []
+ submodules: false
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 53ad56a948..0000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,129 +0,0 @@
-language: python
-dist: bionic
-
-addons:
- hosts:
- - test_site
- - test_site_producer
- mariadb: 10.3
- postgresql: 9.5
- chrome: stable
-
-services:
- - xvfb
- - mysql
-
-git:
- depth: 1
-
-cache:
- pip: true
- npm: true
- yarn: true
- directories:
- # we also need to cache folder with Cypress binary
- # https://docs.cypress.io/guides/guides/continuous-integration.html#Caching
- - ~/.cache
-
-
-matrix:
- include:
- - name: "Python 3.7 MariaDB"
- python: 3.7
- env: DB=mariadb TYPE=server
- script: bench --verbose --site test_site run-tests --coverage
-
- - name: "Python 3.7 PostgreSQL"
- python: 3.7
- env: DB=postgres TYPE=server
- script: bench --verbose --site test_site run-tests --coverage
-
- - name: "Cypress"
- python: 3.7
- env: DB=mariadb TYPE=ui
- before_script:
- - bench --site test_site execute frappe.utils.install.complete_setup_wizard
- script: bench --site test_site run-ui-tests frappe --headless
-
-before_install:
- # do we really want to run travis?
- - |
- python ./.travis/roulette.py
- if [[ $? != 2 ]];then
- exit;
- fi
-
- # install wkhtmltopdf
- - wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz
- - tar -xf /tmp/wkhtmltox.tar.xz -C /tmp
- - sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf
- - sudo chmod o+x /usr/local/bin/wkhtmltopdf
-
- # install cups
- - sudo apt-get install libcups2-dev
-
-install:
- - cd ~
- - source ./.nvm/nvm.sh
- - nvm install 12
-
- - pip install frappe-bench
-
- - bench init frappe-bench --skip-assets --python $(which python) --frappe-path $TRAVIS_BUILD_DIR
-
- - mkdir ~/frappe-bench/sites/test_site
- - cp $TRAVIS_BUILD_DIR/.travis/consumer_db/$DB.json ~/frappe-bench/sites/test_site/site_config.json
-
- - if [ $TYPE == "server" ]; then
- mkdir ~/frappe-bench/sites/test_site_producer;
- cp $TRAVIS_BUILD_DIR/.travis/producer_db/$DB.json ~/frappe-bench/sites/test_site_producer/site_config.json;
- fi
-
- - if [ $DB == "mariadb" ];then
- mysql -u root -e "SET GLOBAL character_set_server = 'utf8mb4'";
- mysql -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'";
-
- mysql -u root -e "CREATE DATABASE test_frappe_consumer";
- mysql -u root -e "CREATE USER 'test_frappe_consumer'@'localhost' IDENTIFIED BY 'test_frappe_consumer'";
- mysql -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe_consumer\`.* TO 'test_frappe_consumer'@'localhost'";
-
- mysql -u root -e "CREATE DATABASE test_frappe_producer";
- mysql -u root -e "CREATE USER 'test_frappe_producer'@'localhost' IDENTIFIED BY 'test_frappe_producer'";
- mysql -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe_producer\`.* TO 'test_frappe_producer'@'localhost'";
-
- mysql -u root -e "UPDATE mysql.user SET Password=PASSWORD('travis') WHERE User='root'";
- mysql -u root -e "FLUSH PRIVILEGES";
- fi
-
- - if [ $DB == "postgres" ];then
- psql -c "CREATE DATABASE test_frappe_consumer" -U postgres;
- psql -c "CREATE USER test_frappe_consumer WITH PASSWORD 'test_frappe'" -U postgres;
-
- psql -c "CREATE DATABASE test_frappe_producer" -U postgres;
- psql -c "CREATE USER test_frappe_producer WITH PASSWORD 'test_frappe'" -U postgres;
- fi
-
- - cd ./frappe-bench
-
- - sed -i 's/^watch:/# watch:/g' Procfile
- - sed -i 's/^schedule:/# schedule:/g' Procfile
-
- - if [ $TYPE == "server" ]; then sed -i 's/^socketio:/# socketio:/g' Procfile; fi
- - if [ $TYPE == "server" ]; then sed -i 's/^redis_socketio:/# redis_socketio:/g' Procfile; fi
-
- - if [ $TYPE == "ui" ]; then bench setup requirements --node; fi
-
- # install node-sass which is required for website theme test
- - cd ./apps/frappe
- - yarn add node-sass@4.13.1
- - cd ../..
-
- - bench start &
- - bench --site test_site reinstall --yes
- - if [ $TYPE == "server" ]; then bench --site test_site_producer reinstall --yes; fi
- - bench build --app frappe
-
-after_script:
- - pip install coverage==4.5.4
- - pip install python-coveralls
- - coveralls -b apps/frappe -d ../../sites/.coverage
diff --git a/.travis/roulette.py b/.travis/roulette.py
deleted file mode 100644
index 4d83137199..0000000000
--- a/.travis/roulette.py
+++ /dev/null
@@ -1,54 +0,0 @@
-# if the script ends with exit code 0, then no tests are run further, else all tests are run
-import os
-import re
-import shlex
-import subprocess
-import sys
-
-
-def get_output(command, shell=True):
- print(command)
- command = shlex.split(command)
- return subprocess.check_output(command, shell=shell, encoding="utf8").strip()
-
-def is_py(file):
- return file.endswith("py")
-
-def is_js(file):
- return file.endswith("js")
-
-def is_docs(file):
- regex = re.compile('\.(md|png|jpg|jpeg)$|^.github|LICENSE')
- return bool(regex.search(file))
-
-
-if __name__ == "__main__":
- build_type = os.environ.get("TYPE")
- commit_range = os.environ.get("TRAVIS_COMMIT_RANGE")
- print("Build Type: {}".format(build_type))
- print("Commit Range: {}".format(commit_range))
-
- try:
- files_changed = get_output("git diff --name-only {}".format(commit_range), shell=False)
- except Exception:
- sys.exit(2)
-
- if "fatal" not in files_changed:
- files_list = files_changed.split()
- only_docs_changed = len(list(filter(is_docs, files_list))) == len(files_list)
- only_js_changed = len(list(filter(is_js, files_list))) == len(files_list)
- only_py_changed = len(list(filter(is_py, files_list))) == len(files_list)
-
- if only_docs_changed:
- print("Only docs were updated, stopping build process.")
- sys.exit(0)
-
- if only_js_changed and build_type == "server":
- print("Only JavaScript code was updated; Stopping Python build process.")
- sys.exit(0)
-
- if only_py_changed and build_type == "ui":
- print("Only Python code was updated, stopping Cypress build process.")
- sys.exit(0)
-
- sys.exit(2)
diff --git a/CODEOWNERS b/CODEOWNERS
index 1afa3f72e3..f7d759c123 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -3,17 +3,18 @@
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence,
-* @frappe/frappe-review-team
-website/ @prssanna
-web_form/ @prssanna
-templates/ @surajshetty3416
-www/ @surajshetty3416
-integrations/ @nextchamp-saqib
-patches/ @surajshetty3416
-dashboard/ @prssanna
-email/ @saurabh6790
-event_streaming/ @ruchamahabal
-data_import* @netchampfaris
-core/ @surajshetty3416
-requirements.txt @gavindsouza
-commands/ @gavindsouza
+* @frappe/frappe-review-team
+templates/ @surajshetty3416
+www/ @surajshetty3416
+integrations/ @leela
+patches/ @surajshetty3416 @gavindsouza
+email/ @leela
+event_streaming/ @ruchamahabal
+data_import* @netchampfaris
+core/ @surajshetty3416
+database @gavindsouza
+model @gavindsouza
+requirements.txt @gavindsouza
+query_builder/ @gavindsouza
+commands/ @gavindsouza
+workspace @shariquerik
diff --git a/LICENSE b/LICENSE
index 5dfc0fd5bd..6919960f8b 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
The MIT License
-Copyright (c) 2016-2018 Frappe Technologies Pvt. Ltd.
+Copyright (c) 2016-2021 Frappe Technologies Pvt. Ltd.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index f99988ae79..8c8317c8bd 100644
--- a/README.md
+++ b/README.md
@@ -14,42 +14,57 @@
Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com)
-### Table of Contents
-* [Installation](https://frappeframework.com/docs/user/en/installation)
-* [Documentation](https://frappeframework.com/docs)
+
+
+## Table of Contents
+* [Installation](#installation)
+* [Contributing](#contributing)
+* [Resources](#resources)
* [License](#license)
-### Installation
+## Installation
-[Install via Frappe Bench](https://github.com/frappe/bench)
+* [Install via Docker](https://github.com/frappe/frappe_docker)
+* [Install via Frappe Bench](https://github.com/frappe/bench)
+* [Offical Documentation](https://frappeframework.com/docs/user/en/installation)
+* [Managed Hosting on Frappe Cloud](https://frappecloud.com/deploy?apps=frappe&source=frappe_readme)
## Contributing
+1. [Code of Conduct](CODE_OF_CONDUCT.md)
1. [Contribution Guidelines](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines)
+1. [Security Policy](SECURITY.md)
1. [Translations](https://translate.erpnext.com)
-### Website
+## Resources
-For details and documentation, see the website
-[https://frappeframework.com](https://frappeframework.com)
+1. [frappeframework.com](https://frappeframework.com) - Official documentation of the Frappe Framework.
+1. [frappe.school](https://frappe.school) - Pick from the various courses by the maintainers or from the community.
-### License
+## License
This repository has been released under the [MIT License](LICENSE).
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000000..1326403cfe
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,35 @@
+codecov:
+ require_ci_to_pass: yes
+
+coverage:
+ status:
+ project:
+ default: false
+ server:
+ target: auto
+ threshold: 0.5%
+ flags:
+ - server
+ patch:
+ default: false
+ server:
+ target: 85%
+ threshold: 0%
+ only_pulls: true
+ if_ci_failed: ignore
+ flags:
+ - server
+
+comment:
+ layout: "diff, flags"
+ require_changes: true
+
+flags:
+ server:
+ paths:
+ - ".*\\.py"
+ carryforward: true
+ ui-tests:
+ paths:
+ - ".*\\.js"
+ carryforward: true
diff --git a/cypress.json b/cypress.json
index f2508ca66e..15f8f230fa 100644
--- a/cypress.json
+++ b/cypress.json
@@ -4,8 +4,12 @@
"adminPassword": "admin",
"defaultCommandTimeout": 20000,
"pageLoadTimeout": 15000,
+ "video": true,
+ "videoUploadOnPasses": false,
"retries": {
"runMode": 2,
"openMode": 2
- }
+ },
+ "integrationFolder": ".",
+ "testFiles": ["cypress/integration/*.js", "**/ui_test_*.js"]
}
diff --git a/cypress/fixtures/child_table_doctype.js b/cypress/fixtures/child_table_doctype.js
new file mode 100644
index 0000000000..f65e5d1765
--- /dev/null
+++ b/cypress/fixtures/child_table_doctype.js
@@ -0,0 +1,30 @@
+export default {
+ name: "Child Table Doctype",
+ actions: [],
+ custom: 1,
+ autoname: "field:title",
+ creation: "2022-02-09 20:15:21.242213",
+ doctype: "DocType",
+ editable_grid: 1,
+ engine: "InnoDB",
+ fields: [
+ {
+ fieldname: "title",
+ fieldtype: "Data",
+ in_list_view: 1,
+ label: "Title",
+ unique: 1
+ }
+ ],
+ links: [],
+ istable: 1,
+ modified: "2022-02-10 12:03:12.603763",
+ modified_by: "Administrator",
+ module: "Custom",
+ naming_rule: "By fieldname",
+ owner: "Administrator",
+ permissions: [],
+ sort_field: 'modified',
+ sort_order: 'ASC',
+ track_changes: 1
+};
\ No newline at end of file
diff --git a/cypress/fixtures/child_table_doctype_1.js b/cypress/fixtures/child_table_doctype_1.js
new file mode 100644
index 0000000000..4657d63e2e
--- /dev/null
+++ b/cypress/fixtures/child_table_doctype_1.js
@@ -0,0 +1,59 @@
+export default {
+ name: "Child Table Doctype 1",
+ actions: [],
+ custom: 1,
+ autoname: "format: Test-{####}",
+ creation: "2022-02-09 20:15:21.242213",
+ doctype: "DocType",
+ editable_grid: 1,
+ engine: "InnoDB",
+ fields: [
+ {
+ fieldname: "data",
+ fieldtype: "Data",
+ in_list_view: 1,
+ label: "Data"
+ },
+ {
+ fieldname: "barcode",
+ fieldtype: "Barcode",
+ in_list_view: 1,
+ label: "Barcode"
+ },
+ {
+ fieldname: "check",
+ fieldtype: "Check",
+ in_list_view: 1,
+ label: "Check"
+ },
+ {
+ fieldname: "rating",
+ fieldtype: "Rating",
+ in_list_view: 1,
+ label: "Rating"
+ },
+ {
+ fieldname: "duration",
+ fieldtype: "Duration",
+ in_list_view: 1,
+ label: "Duration"
+ },
+ {
+ fieldname: "date",
+ fieldtype: "Date",
+ in_list_view: 1,
+ label: "Date"
+ }
+ ],
+ links: [],
+ istable: 1,
+ modified: "2022-02-10 12:03:12.603763",
+ modified_by: "Administrator",
+ module: "Custom",
+ naming_rule: "By fieldname",
+ owner: "Administrator",
+ permissions: [],
+ sort_field: 'modified',
+ sort_order: 'ASC',
+ track_changes: 1
+};
\ No newline at end of file
diff --git a/cypress/fixtures/data_field_validation_doctype.js b/cypress/fixtures/data_field_validation_doctype.js
new file mode 100644
index 0000000000..da091af7e5
--- /dev/null
+++ b/cypress/fixtures/data_field_validation_doctype.js
@@ -0,0 +1,65 @@
+export default {
+ name: 'Validation Test',
+ custom: 1,
+ actions: [],
+ creation: '2019-03-15 06:29:07.215072',
+ doctype: 'DocType',
+ editable_grid: 1,
+ engine: 'InnoDB',
+ fields: [
+ {
+ fieldname: 'email',
+ fieldtype: 'Data',
+ label: 'Email',
+ options: 'Email'
+ },
+ {
+ fieldname: 'URL',
+ fieldtype: 'Data',
+ label: 'URL',
+ options: 'URL'
+ },
+ {
+ fieldname: 'Phone',
+ fieldtype: 'Data',
+ label: 'Phone',
+ options: 'Phone'
+ },
+ {
+ fieldname: 'person_name',
+ fieldtype: 'Data',
+ label: 'Person Name',
+ options: 'Name'
+ },
+ {
+ fieldname: 'read_only_url',
+ fieldtype: 'Data',
+ label: 'Read Only URL',
+ options: 'URL',
+ read_only: '1',
+ default: 'https://frappe.io'
+ }
+ ],
+ issingle: 1,
+ links: [],
+ modified: '2021-04-19 14:40:53.127615',
+ modified_by: 'Administrator',
+ module: 'Custom',
+ owner: 'Administrator',
+ permissions: [
+ {
+ create: 1,
+ delete: 1,
+ email: 1,
+ print: 1,
+ read: 1,
+ role: 'System Manager',
+ share: 1,
+ write: 1
+ }
+ ],
+ quick_entry: 1,
+ sort_field: 'modified',
+ sort_order: 'ASC',
+ track_changes: 1
+};
diff --git a/cypress/fixtures/doctype_to_link.js b/cypress/fixtures/doctype_to_link.js
new file mode 100644
index 0000000000..f5335b1755
--- /dev/null
+++ b/cypress/fixtures/doctype_to_link.js
@@ -0,0 +1,45 @@
+export default {
+ name: "Doctype to Link",
+ actions: [],
+ custom: 1,
+ naming_rule: "By fieldname",
+ autoname: "field:title",
+ creation: "2022-02-09 20:15:21.242213",
+ doctype: "DocType",
+ editable_grid: 1,
+ engine: "InnoDB",
+ fields: [
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "label": "Title",
+ "unique": 1
+ }
+ ],
+ links: [
+ {
+ "group": "Child Doctype",
+ "link_doctype": "Doctype With Child Table",
+ "link_fieldname": "title"
+ }
+ ],
+ modified: "2022-02-10 12:03:12.603763",
+ modified_by: "Administrator",
+ module: "Custom",
+ owner: "Administrator",
+ permissions: [
+ {
+ create: 1,
+ delete: 1,
+ email: 1,
+ print: 1,
+ read: 1,
+ role: 'System Manager',
+ share: 1,
+ write: 1
+ }
+ ],
+ sort_field: 'modified',
+ sort_order: 'ASC',
+ track_changes: 1
+};
\ No newline at end of file
diff --git a/cypress/fixtures/doctype_with_child_table.js b/cypress/fixtures/doctype_with_child_table.js
new file mode 100644
index 0000000000..014074b0b5
--- /dev/null
+++ b/cypress/fixtures/doctype_with_child_table.js
@@ -0,0 +1,52 @@
+export default {
+ name: "Doctype With Child Table",
+ actions: [],
+ custom: 1,
+ autoname: "field:title",
+ creation: "2022-02-09 20:15:21.242213",
+ doctype: "DocType",
+ editable_grid: 1,
+ engine: "InnoDB",
+ fields: [
+ {
+ fieldname: "title",
+ fieldtype: "Data",
+ label: "Title",
+ unique: 1
+ },
+ {
+ fieldname: "child_table",
+ fieldtype: "Table",
+ label: "Child Table",
+ options: "Child Table Doctype",
+ reqd: 1
+ },
+ {
+ fieldname: "child_table_1",
+ fieldtype: "Table",
+ label: "Child Table 1",
+ options: "Child Table Doctype 1"
+ }
+ ],
+ links: [],
+ modified: "2022-02-10 12:03:12.603763",
+ modified_by: "Administrator",
+ module: "Custom",
+ naming_rule: "By fieldname",
+ owner: "Administrator",
+ permissions: [
+ {
+ create: 1,
+ delete: 1,
+ email: 1,
+ print: 1,
+ read: 1,
+ role: 'System Manager',
+ share: 1,
+ write: 1
+ }
+ ],
+ sort_field: 'modified',
+ sort_order: 'ASC',
+ track_changes: 1
+};
diff --git a/cypress/fixtures/doctype_with_tab_break.js b/cypress/fixtures/doctype_with_tab_break.js
new file mode 100644
index 0000000000..74e5e6abba
--- /dev/null
+++ b/cypress/fixtures/doctype_with_tab_break.js
@@ -0,0 +1,54 @@
+export default {
+ name: 'Form With Tab Break',
+ custom: 1,
+ actions: [],
+ doctype: 'DocType',
+ engine: 'InnoDB',
+ fields: [
+ {
+ fieldname: 'username',
+ fieldtype: 'Data',
+ label: 'Name',
+ options: 'Name'
+ },
+ {
+ fieldname: 'tab',
+ fieldtype: 'Tab Break',
+ label: 'Tab 2',
+ },
+ {
+ fieldname: 'Phone',
+ fieldtype: 'Data',
+ label: 'Phone',
+ options: 'Phone',
+ reqd: 1
+ },
+ ],
+ links: [
+ {
+ "group": "Profile",
+ "link_doctype": "Contact",
+ "link_fieldname": "user"
+ },
+ ],
+ modified_by: 'Administrator',
+ module: 'Custom',
+ owner: 'Administrator',
+ permissions: [
+ {
+ create: 1,
+ delete: 1,
+ email: 1,
+ print: 1,
+ read: 1,
+ role: 'System Manager',
+ share: 1,
+ write: 1
+ }
+ ],
+ quick_entry: 1,
+ autoname: "format: Test-{####}",
+ sort_field: 'modified',
+ sort_order: 'ASC',
+ track_changes: 1
+};
diff --git a/cypress/fixtures/sample_image.jpg b/cypress/fixtures/sample_image.jpg
new file mode 100644
index 0000000000..6322b65e33
Binary files /dev/null and b/cypress/fixtures/sample_image.jpg differ
diff --git a/cypress/integration/api.js b/cypress/integration/api.js
index 7a5b1611b0..e8c39e6e25 100644
--- a/cypress/integration/api.js
+++ b/cypress/integration/api.js
@@ -31,8 +31,13 @@ context('API Resources', () => {
});
it('Removes the Comments', () => {
- cy.get_list('Comment').then(body => body.data.forEach(comment => {
- cy.remove_doc('Comment', comment.name);
- }));
+ cy.get_list('Comment').then(body => {
+ let comment_names = [];
+ body.data.map(comment => comment_names.push(comment.name));
+ comment_names = [...new Set(comment_names)]; // remove duplicates
+ comment_names.forEach((comment_name) => {
+ cy.remove_doc('Comment', comment_name);
+ });
+ });
});
});
diff --git a/cypress/integration/awesome_bar.js b/cypress/integration/awesome_bar.js
index 3e12101532..8e503cce46 100644
--- a/cypress/integration/awesome_bar.js
+++ b/cypress/integration/awesome_bar.js
@@ -7,12 +7,13 @@ context('Awesome Bar', () => {
beforeEach(() => {
cy.get('.navbar .navbar-home').click();
+ cy.findByPlaceholderText('Search or type a command (Ctrl + G)').clear();
});
it('navigates to doctype list', () => {
- cy.get('#navbar-search').type('todo', { delay: 200 });
- cy.get('#navbar-search + ul').should('be.visible');
- cy.get('#navbar-search').type('{downarrow}{enter}', { delay: 100 });
+ cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('todo', { delay: 700 });
+ cy.get('.awesomplete').findByRole('listbox').should('be.visible');
+ cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('{downarrow}{enter}', { delay: 700 });
cy.get('.title-text').should('contain', 'To Do');
@@ -20,25 +21,25 @@ context('Awesome Bar', () => {
});
it('find text in doctype list', () => {
- cy.get('#navbar-search')
- .type('test in todo{downarrow}{enter}', { delay: 200 });
+ cy.findByPlaceholderText('Search or type a command (Ctrl + G)')
+ .type('test in todo{downarrow}{enter}', { delay: 700 });
cy.get('.title-text').should('contain', 'To Do');
- cy.get('[data-original-title="Name"] > .input-with-feedback')
+ cy.findByPlaceholderText('Name')
.should('have.value', '%test%');
});
it('navigates to new form', () => {
- cy.get('#navbar-search')
- .type('new blog post{downarrow}{enter}', { delay: 200 });
+ cy.findByPlaceholderText('Search or type a command (Ctrl + G)')
+ .type('new blog post{downarrow}{enter}', { delay: 700 });
cy.get('.title-text:visible').should('have.text', 'New Blog Post');
});
it('calculates math expressions', () => {
- cy.get('#navbar-search')
- .type('55 + 32{downarrow}{enter}', { delay: 200 });
+ cy.findByPlaceholderText('Search or type a command (Ctrl + G)')
+ .type('55 + 32{downarrow}{enter}', { delay: 700 });
cy.get('.modal-title').should('contain', 'Result');
cy.get('.msgprint').should('contain', '55 + 32 = 87');
diff --git a/cypress/integration/control_autocomplete.js b/cypress/integration/control_autocomplete.js
new file mode 100644
index 0000000000..3bf3e829f9
--- /dev/null
+++ b/cypress/integration/control_autocomplete.js
@@ -0,0 +1,57 @@
+context('Control Autocomplete', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/website');
+ });
+
+ function get_dialog_with_autocomplete(options) {
+ cy.visit('/app/website');
+ return cy.dialog({
+ title: 'Autocomplete',
+ fields: [
+ {
+ 'label': 'Select an option',
+ 'fieldname': 'autocomplete',
+ 'fieldtype': 'Autocomplete',
+ 'options': options || ['Option 1', 'Option 2', 'Option 3'],
+ }
+ ]
+ });
+ }
+
+ it('should set the valid value', () => {
+ get_dialog_with_autocomplete().as('dialog');
+
+ cy.get('.frappe-control[data-fieldname=autocomplete] input').focus().as('input');
+ cy.wait(1000);
+ cy.get('@input').type('2', { delay: 300 });
+ cy.get('.frappe-control[data-fieldname=autocomplete]').findByRole('listbox').should('be.visible');
+ cy.get('.frappe-control[data-fieldname=autocomplete] input').type('{enter}', { delay: 300 });
+ cy.get('.frappe-control[data-fieldname=autocomplete] input').blur();
+ cy.get('@dialog').then(dialog => {
+ let value = dialog.get_value('autocomplete');
+ expect(value).to.eq('Option 2');
+ dialog.clear();
+ });
+ });
+
+ it('should set the valid value with different label', () => {
+ const options_with_label = [
+ { label: "Option 1", value: "option_1" },
+ { label: "Option 2", value: "option_2" }
+ ];
+ get_dialog_with_autocomplete(options_with_label).as('dialog');
+
+ cy.get('.frappe-control[data-fieldname=autocomplete] input').focus().as('input');
+ cy.get('.frappe-control[data-fieldname=autocomplete]').findByRole('listbox').should('be.visible');
+ cy.get('@input').type('2', { delay: 300 });
+ cy.get('.frappe-control[data-fieldname=autocomplete] input').type('{enter}', { delay: 300 });
+ cy.get('.frappe-control[data-fieldname=autocomplete] input').blur();
+ cy.get('@dialog').then(dialog => {
+ let value = dialog.get_value('autocomplete');
+ expect(value).to.eq('option_2');
+ dialog.clear();
+ });
+ });
+
+});
diff --git a/cypress/integration/control_barcode.js b/cypress/integration/control_barcode.js
index 1df5e64f0e..03ab61fac4 100644
--- a/cypress/integration/control_barcode.js
+++ b/cypress/integration/control_barcode.js
@@ -20,8 +20,7 @@ context('Control Barcode', () => {
it('should generate barcode on setting a value', () => {
get_dialog_with_barcode().as('dialog');
- cy.get('.frappe-control[data-fieldname=barcode] input')
- .focus()
+ cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox')
.type('123456789')
.blur();
cy.get('.frappe-control[data-fieldname=barcode] svg[data-barcode-value="123456789"]')
@@ -37,11 +36,10 @@ context('Control Barcode', () => {
it('should reset when input is cleared', () => {
get_dialog_with_barcode().as('dialog');
- cy.get('.frappe-control[data-fieldname=barcode] input')
- .focus()
+ cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox')
.type('123456789')
.blur();
- cy.get('.frappe-control[data-fieldname=barcode] input')
+ cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox')
.clear()
.blur();
cy.get('.frappe-control[data-fieldname=barcode] svg[data-barcode-value="123456789"]')
diff --git a/cypress/integration/control_duration.js b/cypress/integration/control_duration.js
index 266d421e70..09629a344f 100644
--- a/cypress/integration/control_duration.js
+++ b/cypress/integration/control_duration.js
@@ -33,12 +33,13 @@ context('Control Duration', () => {
cy.get('@dialog').then(dialog => {
let value = dialog.get_value('duration');
expect(value).to.equal(3889800);
+ cy.hide_dialog();
});
});
it('should hide days or seconds according to duration options', () => {
get_dialog_with_duration(1, 1).as('dialog');
- cy.get('.frappe-control[data-fieldname=duration] input').first().click();
+ cy.get('.frappe-control[data-fieldname=duration] input').first();
cy.get('.duration-input[data-duration=days]').should('not.be.visible');
cy.get('.duration-input[data-duration=seconds]').should('not.be.visible');
});
diff --git a/cypress/integration/control_float.js b/cypress/integration/control_float.js
new file mode 100644
index 0000000000..670d1fe73e
--- /dev/null
+++ b/cypress/integration/control_float.js
@@ -0,0 +1,93 @@
+context("Control Float", () => {
+ before(() => {
+ cy.login();
+ cy.visit("/app/website");
+ });
+
+ function get_dialog_with_float() {
+ return cy.dialog({
+ title: "Float Check",
+ fields: [
+ {
+ fieldname: "float_number",
+ fieldtype: "Float",
+ Label: "Float"
+ }
+ ]
+ });
+ }
+
+ it("check value changes", () => {
+ get_dialog_with_float().as("dialog");
+
+ let data = get_data();
+ data.forEach(x => {
+ cy.window()
+ .its("frappe")
+ .then(frappe => {
+ frappe.boot.sysdefaults.number_format = x.number_format;
+ });
+ x.values.forEach(d => {
+ cy.get_field("float_number", "Float").clear();
+ cy.fill_field("float_number", d.input, "Float").blur();
+ cy.get_field("float_number", "Float").should(
+ "have.value",
+ d.blur_expected
+ );
+
+ cy.get_field("float_number", "Float").focus();
+ cy.get_field("float_number", "Float").blur();
+ cy.get_field("float_number", "Float").focus();
+ cy.get_field("float_number", "Float").should(
+ "have.value",
+ d.focus_expected
+ );
+ });
+ });
+ });
+
+ function get_data() {
+ return [
+ {
+ number_format: "#.###,##",
+ values: [
+ {
+ input: "364.87,334",
+ blur_expected: "36.487,334",
+ focus_expected: "36487.334"
+ },
+ {
+ input: "36487,334",
+ blur_expected: "36.487,334",
+ focus_expected: "36487.334"
+ },
+ {
+ input: "100",
+ blur_expected: "100,000",
+ focus_expected: "100"
+ }
+ ]
+ },
+ {
+ number_format: "#,###.##",
+ values: [
+ {
+ input: "364,87.334",
+ blur_expected: "36,487.334",
+ focus_expected: "36487.334"
+ },
+ {
+ input: "36487.334",
+ blur_expected: "36,487.334",
+ focus_expected: "36487.334"
+ },
+ {
+ input: "100",
+ blur_expected: "100.000",
+ focus_expected: "100"
+ }
+ ]
+ }
+ ];
+ }
+});
diff --git a/cypress/integration/control_icon.js b/cypress/integration/control_icon.js
new file mode 100644
index 0000000000..d89eba8840
--- /dev/null
+++ b/cypress/integration/control_icon.js
@@ -0,0 +1,50 @@
+context('Control Icon', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/website');
+ });
+
+ function get_dialog_with_icon() {
+ return cy.dialog({
+ title: 'Icon',
+ fields: [{
+ label: 'Icon',
+ fieldname: 'icon',
+ fieldtype: 'Icon'
+ }]
+ });
+ }
+
+ it('should set icon', () => {
+ get_dialog_with_icon().as('dialog');
+ cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').click();
+
+ cy.get('.icon-picker .icon-wrapper[id=heart-active]').first().click();
+ cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'heart-active');
+ cy.get('@dialog').then(dialog => {
+ let value = dialog.get_value('icon');
+ expect(value).to.equal('heart-active');
+ });
+
+ cy.get('.icon-picker .icon-wrapper[id=heart]').first().click();
+ cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'heart');
+ cy.get('@dialog').then(dialog => {
+ let value = dialog.get_value('icon');
+ expect(value).to.equal('heart');
+ });
+ });
+
+ it('search for icon and clear search input', () => {
+ let search_text = 'ed';
+ cy.get('.icon-picker').findByRole('searchbox').click().type(search_text);
+ cy.get('.icon-section .icon-wrapper:not(.hidden)').then(i => {
+ cy.get(`.icon-section .icon-wrapper[id*='${search_text}']`).then(icons => {
+ expect(i.length).to.equal(icons.length);
+ });
+ });
+
+ cy.get('.icon-picker').findByRole('searchbox').clear().blur();
+ cy.get('.icon-section .icon-wrapper').should('not.have.class', 'hidden');
+ });
+
+});
\ No newline at end of file
diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js
index 8f9257e9c4..7a7e94d2f5 100644
--- a/cypress/integration/control_link.js
+++ b/cypress/integration/control_link.js
@@ -35,7 +35,7 @@ context('Control Link', () => {
cy.wait('@search_link');
cy.get('@input').type('todo for link', { delay: 200 });
cy.wait('@search_link');
- cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible');
+ cy.get('.frappe-control[data-fieldname=link]').findByRole('listbox').should('be.visible');
cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 });
cy.get('.frappe-control[data-fieldname=link] input').blur();
cy.get('@dialog').then(dialog => {
@@ -49,7 +49,7 @@ context('Control Link', () => {
it('should unset invalid value', () => {
get_dialog_with_link().as('dialog');
- cy.intercept('GET', '/api/method/frappe.desk.form.utils.validate_link*').as('validate_link');
+ cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link');
cy.get('.frappe-control[data-fieldname=link] input')
.type('invalid value', { delay: 100 })
@@ -58,10 +58,27 @@ context('Control Link', () => {
cy.get('.frappe-control[data-fieldname=link] input').should('have.value', '');
});
+ it("should be possible set empty value explicitly", () => {
+ get_dialog_with_link().as("dialog");
+
+ cy.intercept("POST", "/api/method/frappe.client.validate_link").as("validate_link");
+
+ cy.get(".frappe-control[data-fieldname=link] input")
+ .type(" ", { delay: 100 })
+ .blur();
+ cy.wait("@validate_link");
+ cy.get(".frappe-control[data-fieldname=link] input").should("have.value", "");
+ cy.window()
+ .its("cur_dialog")
+ .then((dialog) => {
+ expect(dialog.get_value("link")).to.equal('');
+ });
+ });
+
it('should route to form on arrow click', () => {
get_dialog_with_link().as('dialog');
- cy.intercept('GET', '/api/method/frappe.desk.form.utils.validate_link*').as('validate_link');
+ cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link');
cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link');
cy.get('@todos').then(todos => {
@@ -71,10 +88,130 @@ context('Control Link', () => {
cy.get('@input').type(todos[0]).blur();
cy.wait('@validate_link');
cy.get('@input').focus();
- cy.get('.frappe-control[data-fieldname=link] .link-btn')
+ cy.findByTitle('Open Link')
.should('be.visible')
.click();
cy.location('pathname').should('eq', `/app/todo/${todos[0]}`);
});
});
+
+ it('show title field in link', () => {
+ get_dialog_with_link().as('dialog');
+
+ cy.insert_doc("Property Setter", {
+ "doctype": "Property Setter",
+ "doc_type": "ToDo",
+ "property": "show_title_field_in_link",
+ "property_type": "Check",
+ "doctype_or_field": "DocType",
+ "value": "1"
+ }, true);
+
+ cy.window().its('frappe').then(frappe => {
+ if (!frappe.boot) {
+ frappe.boot = {
+ link_title_doctypes: ['ToDo']
+ };
+ } else {
+ frappe.boot.link_title_doctypes = ['ToDo'];
+ }
+ });
+
+ cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link');
+
+ cy.get('.frappe-control[data-fieldname=link] input').focus().as('input');
+ cy.wait('@search_link');
+ cy.get('@input').type('todo for link');
+ cy.wait('@search_link');
+ cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible');
+ cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 });
+ cy.get('.frappe-control[data-fieldname=link] input').blur();
+ cy.get('@dialog').then(dialog => {
+ cy.get('@todos').then(todos => {
+ let field = dialog.get_field('link');
+ let value = field.get_value();
+ let label = field.get_label_value();
+
+ expect(value).to.eq(todos[0]);
+ expect(label).to.eq('this is a test todo for link');
+
+ cy.remove_doc("Property Setter", "ToDo-main-show_title_field_in_link");
+ });
+ });
+ });
+
+ it('should update dependant fields (via fetch_from)', () => {
+ cy.get('@todos').then(todos => {
+ cy.visit(`/app/todo/${todos[0]}`);
+ cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link');
+
+ cy.get('.frappe-control[data-fieldname=assigned_by] input').focus().as('input');
+ cy.get('@input').type('Administrator', {delay: 100}).blur();
+ cy.wait('@validate_link');
+ cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should(
+ 'contain', 'Administrator'
+ );
+
+ cy.window()
+ .its("cur_frm.doc.assigned_by")
+ .should("eq", "Administrator");
+
+ // invalid input
+ cy.get('@input').clear().type('invalid input', {delay: 100}).blur();
+ cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should(
+ 'contain', ''
+ );
+
+ cy.window()
+ .its("cur_frm.doc.assigned_by")
+ .should("eq", null);
+
+ // set valid value again
+ cy.get('@input').clear().type('Administrator', {delay: 100}).blur();
+ cy.wait('@validate_link');
+
+ cy.window()
+ .its("cur_frm.doc.assigned_by")
+ .should("eq", "Administrator");
+
+ // clear input
+ cy.get('@input').clear().blur();
+ cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should(
+ 'contain', ''
+ );
+
+ cy.window()
+ .its("cur_frm.doc.assigned_by")
+ .should("eq", "");
+ });
+ });
+
+ it("should set default values", () => {
+ cy.insert_doc("Property Setter", {
+ "doctype_or_field": "DocField",
+ "doc_type": "ToDo",
+ "field_name": "assigned_by",
+ "property": "default",
+ "property_type": "Text",
+ "value": "Administrator"
+ }, true);
+ cy.reload();
+ cy.new_form("ToDo");
+ cy.fill_field("description", "new", "Text Editor");
+ cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form");
+ cy.findByRole("button", {name: "Save"}).click();
+ cy.wait("@save_form");
+ cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should(
+ "contain", "Administrator"
+ );
+ // if user clears default value explicitly, system should not reset default again
+ cy.get_field("assigned_by").clear().blur();
+ cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form");
+ cy.findByRole("button", {name: "Save"}).click();
+ cy.wait("@save_form");
+ cy.get_field("assigned_by").should("have.value", "");
+ cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should(
+ "contain", ""
+ );
+ });
});
diff --git a/cypress/integration/control_rating.js b/cypress/integration/control_rating.js
index 592ed87004..15c11b352b 100644
--- a/cypress/integration/control_rating.js
+++ b/cypress/integration/control_rating.js
@@ -10,6 +10,7 @@ context('Control Rating', () => {
fields: [{
'fieldname': 'rate',
'fieldtype': 'Rating',
+ 'options': 7
}]
});
}
@@ -19,12 +20,13 @@ context('Control Rating', () => {
cy.get('div.rating')
.children('svg')
+ .find('.right-half')
.first()
.click()
.should('have.class', 'star-click');
cy.get('@dialog').then(dialog => {
var value = dialog.get_value('rate');
- expect(value).to.equal(1);
+ expect(value).to.equal(1/7);
dialog.hide();
});
});
@@ -34,10 +36,21 @@ context('Control Rating', () => {
cy.get('div.rating')
.children('svg')
+ .find('.right-half')
.first()
.invoke('trigger', 'mouseenter')
.should('have.class', 'star-hover')
.invoke('trigger', 'mouseleave')
.should('not.have.class', 'star-hover');
});
+
+ it('check number of stars in rating', () => {
+ get_dialog_with_rating();
+
+ cy.get('div.rating')
+ .first()
+ .children('svg')
+ .should('have.length', 7);
+ });
+
});
diff --git a/cypress/integration/control_select.js b/cypress/integration/control_select.js
new file mode 100644
index 0000000000..8e18d21260
--- /dev/null
+++ b/cypress/integration/control_select.js
@@ -0,0 +1,38 @@
+context('Control Select', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/website');
+ });
+
+ function get_dialog_with_select() {
+ return cy.dialog({
+ title: 'Select',
+ fields: [{
+ 'fieldname': 'select_control',
+ 'fieldtype': 'Select',
+ 'placeholder': 'Select an Option',
+ 'options': ['', 'Option 1', 'Option 2', 'Option 2'],
+ }]
+ });
+ }
+
+ it('toggles placholder on clicking an option', () => {
+ get_dialog_with_select().as('dialog');
+
+ cy.get('.frappe-control[data-fieldname=select_control] .control-input').as('control');
+ cy.get('.frappe-control[data-fieldname=select_control] .control-input select').as('select');
+ cy.get('@control').get('.select-icon').should('exist');
+ cy.get('@control').get('.placeholder').should('have.css', 'display', 'block');
+ cy.get('@select').select('Option 1');
+ cy.findByDisplayValue('Option 1').should('exist');
+ cy.get('@control').get('.placeholder').should('have.css', 'display', 'none');
+ cy.get('@select').invoke('val', '');
+ cy.findByDisplayValue('Option 1').should('not.exist');
+ cy.get('@control').get('.placeholder').should('have.css', 'display', 'block');
+
+
+ cy.get('@dialog').then(dialog => {
+ dialog.hide();
+ });
+ });
+});
diff --git a/cypress/integration/dashboard_chart.js b/cypress/integration/dashboard_chart.js
new file mode 100644
index 0000000000..ae71fcda3a
--- /dev/null
+++ b/cypress/integration/dashboard_chart.js
@@ -0,0 +1,22 @@
+context('Dashboard Chart', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/website');
+ });
+
+ it('Check filter populate for child table doctype', () => {
+ cy.visit('/app/dashboard-chart/new-dashboard-chart-1');
+ cy.get('[data-fieldname="parent_document_type"]').should('have.css', 'display', 'none');
+
+ cy.get_field('document_type', 'Link');
+ cy.fill_field('document_type', 'Workspace Link', 'Link').focus().blur();
+ cy.get_field('document_type', 'Link').should('have.value', 'Workspace Link');
+
+ cy.fill_field('chart_name', 'Test Chart', 'Data');
+
+ cy.get('[data-fieldname="filters_json"]').click().wait(200);
+ cy.get('.modal-body .filter-action-buttons .add-filter').click();
+ cy.get('.modal-body .fieldname-select-area').click();
+ cy.get('.modal-actions .btn-modal-close').click();
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/dashboard_links.js b/cypress/integration/dashboard_links.js
new file mode 100644
index 0000000000..019de1991d
--- /dev/null
+++ b/cypress/integration/dashboard_links.js
@@ -0,0 +1,91 @@
+import doctype_with_child_table from '../fixtures/doctype_with_child_table';
+import child_table_doctype from '../fixtures/child_table_doctype';
+import child_table_doctype_1 from '../fixtures/child_table_doctype_1';
+import doctype_to_link from '../fixtures/doctype_to_link';
+const doctype_to_link_name = doctype_to_link.name;
+const child_table_doctype_name = child_table_doctype.name;
+
+context('Dashboard links', () => {
+ before(() => {
+ cy.visit('/login');
+ cy.login();
+ cy.insert_doc('DocType', child_table_doctype, true);
+ cy.insert_doc('DocType', child_table_doctype_1, true);
+ cy.insert_doc('DocType', doctype_with_child_table, true);
+ cy.insert_doc('DocType', doctype_to_link, true);
+ return cy.window().its('frappe').then(frappe => {
+ return frappe.xcall("frappe.tests.ui_test_helpers.update_child_table", {
+ name: child_table_doctype_name
+ });
+ });
+ });
+
+ it('Adding a new contact, checking for the counter on the dashboard and deleting the created contact', () => {
+ cy.visit('/app/contact');
+ cy.clear_filters();
+
+ cy.visit('/app/user');
+ cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click({ force: true });
+
+ //To check if initially the dashboard contains only the "Contact" link and there is no counter
+ cy.get('[data-doctype="Contact"]').should('contain', 'Contact');
+
+ //Adding a new contact
+ cy.get('.document-link-badge[data-doctype="Contact"]').click();
+ cy.wait(300);
+ cy.findByRole('button', {name: 'Add Contact'}).should('be.visible');
+ cy.findByRole('button', {name: 'Add Contact'}).click();
+ cy.get('[data-doctype="Contact"][data-fieldname="first_name"]').type('Admin');
+ cy.findByRole('button', {name: 'Save'}).click();
+ cy.visit('/app/user');
+ cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click({ force: true });
+
+ //To check if the counter for contact doc is "1" after adding the contact
+ cy.get('[data-doctype="Contact"] > .count').should('contain', '1');
+ cy.get('[data-doctype="Contact"]').contains('Contact').click();
+
+ //Deleting the newly created contact
+ cy.visit('/app/contact');
+ cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click({ force: true });
+ cy.findByRole('button', {name: 'Actions'}).click();
+ cy.get('.actions-btn-group [data-label="Delete"]').click();
+ cy.findByRole('button', {name: 'Yes'}).click({delay: 700});
+
+
+ //To check if the counter from the "Contact" doc link is removed
+ cy.wait(700);
+ cy.visit('/app/user');
+ cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click({ force: true });
+ cy.get('[data-doctype="Contact"]').should('contain', 'Contact');
+ });
+
+ it('Report link in dashboard', () => {
+ cy.visit('/app/user');
+ cy.visit('/app/user/Administrator');
+ cy.get('[data-doctype="Contact"]').should('contain', 'Contact');
+ cy.findByText('Connections');
+ cy.window()
+ .its('cur_frm')
+ .then(cur_frm => {
+ cur_frm.dashboard.data.reports = [
+ {
+ 'label': 'Reports',
+ 'items': ['Website Analytics']
+ }
+ ];
+ cur_frm.dashboard.render_report_links();
+ cy.get('[data-report="Website Analytics"]').contains('Website Analytics').click();
+ cy.findByText('Website Analytics');
+ });
+ });
+
+ it('check if child table is populated with linked field on creation from dashboard link', () => {
+ cy.new_form(doctype_to_link_name);
+ cy.fill_field("title", "Test Linking");
+ cy.findByRole("button", {name: "Save"}).click();
+
+ cy.get('.document-link .btn-new').click();
+ cy.get('.frappe-control[data-fieldname="child_table"] .rows .data-row .col[data-fieldname="doctype_to_link"]')
+ .should('contain.text', 'Test Linking');
+ });
+});
diff --git a/cypress/integration/data_field_form_validation.js b/cypress/integration/data_field_form_validation.js
new file mode 100644
index 0000000000..c6feea5550
--- /dev/null
+++ b/cypress/integration/data_field_form_validation.js
@@ -0,0 +1,43 @@
+import data_field_validation_doctype from '../fixtures/data_field_validation_doctype';
+const doctype_name = data_field_validation_doctype.name;
+
+
+context('Data Field Input Validation in New Form', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/website');
+ return cy.insert_doc('DocType', data_field_validation_doctype, true);
+ });
+
+ function validateField(fieldname, invalid_value, valid_value) {
+ // Invalid, should have has-error class
+ cy.get_field(fieldname).clear().type(invalid_value).blur();
+ cy.get(`.frappe-control[data-fieldname="${fieldname}"]`).should('have.class', 'has-error');
+ // Valid value, should not have has-error class
+ cy.get_field(fieldname).clear().type(valid_value);
+ cy.get(`.frappe-control[data-fieldname="${fieldname}"]`).should('not.have.class', 'has-error');
+ }
+
+ describe('Data Field Options', () => {
+ it('should validate email address', () => {
+ cy.new_form(doctype_name);
+ validateField('email', 'captian', 'hello@test.com');
+ });
+
+ it('should validate URL', () => {
+ validateField('url', 'jkl', 'https://frappe.io');
+ validateField('url', 'abcd.com', 'http://google.com/home');
+ validateField('url', '&&http://google.uae', 'gopher://frappe.io');
+ validateField('url', 'ftt2:://google.in?q=news', 'ftps2://frappe.io/__/#home');
+ validateField('url', 'ftt2://', 'ntps://localhost'); // For intranet URLs
+ });
+
+ it('should validate phone number', () => {
+ validateField('phone', 'america', '89787878');
+ });
+
+ it('should validate name', () => {
+ validateField('person_name', ' 777Hello', 'James Bond');
+ });
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/datetime.js b/cypress/integration/datetime.js
index b310526c7c..4a24faf40b 100644
--- a/cypress/integration/datetime.js
+++ b/cypress/integration/datetime.js
@@ -92,17 +92,18 @@ context('Control Date, Time and DateTime', () => {
date_format: 'dd.mm.yyyy',
time_format: 'HH:mm:ss',
value: ' 02.12.2019 11:00:12',
- doc_value: '2019-12-02 11:00:12',
- input_value: '02.12.2019 11:00:12'
+ doc_value: '2019-12-02 00:30:12', // system timezone (America/New_York)
+ input_value: '02.12.2019 11:00:12' // admin timezone (Asia/Kolkata)
},
{
date_format: 'mm-dd-yyyy',
time_format: 'HH:mm',
value: ' 12-02-2019 11:00:00',
- doc_value: '2019-12-02 11:00:00',
- input_value: '12-02-2019 11:00'
+ doc_value: '2019-12-02 00:30:00', // system timezone (America/New_York)
+ input_value: '12-02-2019 11:00' // admin timezone (Asia/Kolkata)
}
];
+
datetime_formats.forEach(d => {
it(`test datetime format ${d.date_format} ${d.time_format}`, () => {
cy.set_value('System Settings', 'System Settings', {
diff --git a/cypress/integration/datetime_field_form_validation.js b/cypress/integration/datetime_field_form_validation.js
new file mode 100644
index 0000000000..ef47a0fbf7
--- /dev/null
+++ b/cypress/integration/datetime_field_form_validation.js
@@ -0,0 +1,19 @@
+// TODO: Enable this again
+// currently this is flaky possibly because of different timezone in CI
+
+// context('Datetime Field Validation', () => {
+// before(() => {
+// cy.login();
+// cy.visit('/app/communication');
+// });
+
+// it('datetime field form validation', () => {
+// // validating datetime field value when value is set from backend and get validated on form load.
+// cy.window().its('frappe').then(frappe => {
+// return frappe.xcall("frappe.tests.ui_test_helpers.create_communication_record");
+// }).then(doc => {
+// cy.visit(`/app/communication/${doc.name}`);
+// cy.get('.indicator-pill').should('contain', 'Open').should('have.class', 'red');
+// });
+// });
+// });
\ No newline at end of file
diff --git a/cypress/integration/depends_on.js b/cypress/integration/depends_on.js
index d33babb134..12f54f2b6e 100644
--- a/cypress/integration/depends_on.js
+++ b/cypress/integration/depends_on.js
@@ -55,18 +55,39 @@ context('Depends On', () => {
'read_only_depends_on': "eval:doc.test_field=='Some Other Value'",
'options': "Child Test Depends On"
},
+ {
+ "label": "Dependent Tab",
+ "fieldname": "dependent_tab",
+ "fieldtype": "Tab Break",
+ "depends_on": "eval:doc.test_field=='Show Tab'"
+ },
+ {
+ "fieldname": "tab_section",
+ "fieldtype": "Section Break",
+ },
+ {
+ "label": "Field in Tab",
+ "fieldname": "field_in_tab",
+ "fieldtype": "Data",
+ }
]
});
});
});
+ it('should show the tab on other setting field value', () => {
+ cy.new_form('Test Depends On');
+ cy.fill_field('test_field', 'Show Tab');
+ cy.get('body').click();
+ cy.findByRole("tab", {name: "Dependent Tab"}).should('be.visible');
+ });
it('should set the field as mandatory depending on other fields value', () => {
cy.new_form('Test Depends On');
cy.fill_field('test_field', 'Some Value');
- cy.get('button.primary-action').contains('Save').click();
+ cy.findByRole('button', {name: 'Save'}).click();
cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('be.visible');
cy.hide_dialog();
cy.fill_field('test_field', 'Random value');
- cy.get('button.primary-action').contains('Save').click();
+ cy.findByRole('button', {name: 'Save'}).click();
cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('not.be.visible');
});
it('should set the field as read only depending on other fields value', () => {
@@ -84,7 +105,7 @@ context('Depends On', () => {
cy.fill_field('dependant_field', 'Some Value');
//cy.fill_field('test_field', 'Some Other Value');
cy.get('.frappe-control[data-fieldname="child_test_depends_on_field"]').as('table');
- cy.get('@table').find('button.grid-add-row').click();
+ cy.get('@table').findByRole('button', {name: 'Add Row'}).click();
cy.get('@table').find('[data-idx="1"]').as('row1');
cy.get('@row1').find('.btn-open-row').click();
cy.get('@row1').find('.form-in-grid').as('row1-form_in_grid');
diff --git a/cypress/integration/discussions.js b/cypress/integration/discussions.js
new file mode 100644
index 0000000000..a6e0ff9b56
--- /dev/null
+++ b/cypress/integration/discussions.js
@@ -0,0 +1,79 @@
+context('Discussions', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app');
+ return cy.window().its('frappe').then(frappe => {
+ return frappe.call('frappe.tests.ui_test_helpers.create_data_for_discussions');
+ });
+ });
+
+ const reply_through_modal = () => {
+ cy.visit('/test-page-discussions');
+
+ // Open the modal
+ cy.get('.reply').click();
+ cy.wait(500);
+ cy.get('.discussion-modal').should('be.visible');
+
+ // Enter title
+ cy.get('.modal .topic-title').type('Discussion from tests')
+ .should('have.value', 'Discussion from tests');
+
+ // Enter comment
+ cy.get('.modal .comment-field')
+ .type('This is a discussion from the cypress ui tests.')
+ .should('have.value', 'This is a discussion from the cypress ui tests.');
+
+ // Submit
+ cy.get('.modal .submit-discussion').click();
+ cy.wait(2000);
+
+ // Check if discussion is added to page and content is visible
+ cy.get('.sidebar-parent:first .discussion-topic-title').should('have.text', 'Discussion from tests');
+ cy.get('.discussion-on-page:visible').should('have.class', 'show');
+ cy.get('.discussion-on-page:visible .reply-card .reply-text')
+ .should('have.text', 'This is a discussion from the cypress ui tests.\n');
+
+ };
+
+ const reply_through_comment_box = () => {
+ cy.get('.discussion-on-page:visible .comment-field')
+ .type('This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page.')
+ .should('have.value', 'This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page.');
+
+ cy.get('.discussion-on-page:visible .submit-discussion').click();
+ cy.wait(3000);
+ cy.get('.discussion-on-page:visible').should('have.class', 'show');
+ cy.get('.discussion-on-page:visible').children(".reply-card").eq(1).children(".reply-text")
+ .should('have.text', 'This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page.\n');
+ };
+
+ const cancel_and_clear_comment_box = () => {
+ cy.get('.discussion-on-page:visible .comment-field')
+ .type('This is a discussion from the cypress ui tests.')
+ .should('have.value', 'This is a discussion from the cypress ui tests.');
+
+ cy.get('.discussion-on-page:visible .cancel-comment').click();
+ cy.get('.discussion-on-page:visible .comment-field').should('have.value', '');
+ };
+
+ const single_thread_discussion = () => {
+ cy.visit('/test-single-thread');
+ cy.get('.discussions-sidebar').should('have.length', 0);
+ cy.get('.reply').should('have.length', 0);
+
+ cy.get('.discussion-on-page .comment-field')
+ .type('This comment is being made on a single thread discussion.')
+ .should('have.value', 'This comment is being made on a single thread discussion.');
+
+ cy.get('.discussion-on-page .submit-discussion').click();
+ cy.wait(3000);
+ cy.get('.discussion-on-page').children(".reply-card").eq(-1).children(".reply-text")
+ .should('have.text', 'This comment is being made on a single thread discussion.\n');
+ };
+
+ it('reply through modal', reply_through_modal);
+ it('reply through comment box', reply_through_comment_box);
+ it('cancel and clear comment box', cancel_and_clear_comment_box);
+ it('single thread discussion', single_thread_discussion);
+});
diff --git a/cypress/integration/file_uploader.js b/cypress/integration/file_uploader.js
index 2f457983de..3d4f92df3c 100644
--- a/cypress/integration/file_uploader.js
+++ b/cypress/integration/file_uploader.js
@@ -25,7 +25,7 @@ context('FileUploader', () => {
cy.get_open_dialog().find('.file-name').should('contain', 'example.json');
cy.intercept('POST', '/api/method/upload_file').as('upload_file');
- cy.get_open_dialog().find('.btn-modal-primary').click();
+ cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click();
cy.wait('@upload_file').its('response.statusCode').should('eq', 200);
cy.get('.modal:visible').should('not.exist');
});
@@ -33,11 +33,11 @@ context('FileUploader', () => {
it('should accept uploaded files', () => {
open_upload_dialog();
- cy.get_open_dialog().find('.btn-file-upload div:contains("Library")').click();
- cy.get('.file-filter').type('example.json');
- cy.get_open_dialog().find('.tree-label:contains("example.json")').first().click();
+ cy.get_open_dialog().findByRole('button', {name: 'Library'}).click();
+ cy.findByPlaceholderText('Search by filename or extension').type('example.json');
+ cy.get_open_dialog().findAllByText('example.json').first().click();
cy.intercept('POST', '/api/method/upload_file').as('upload_file');
- cy.get_open_dialog().find('.btn-primary').click();
+ cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click();
cy.wait('@upload_file').its('response.body.message')
.should('have.property', 'file_name', 'example.json');
cy.get('.modal:visible').should('not.exist');
@@ -46,12 +46,33 @@ context('FileUploader', () => {
it('should accept web links', () => {
open_upload_dialog();
- cy.get_open_dialog().find('.btn-file-upload div:contains("Link")').click();
- cy.get_open_dialog().find('.file-web-link input').type('https://github.com', { delay: 100, force: true });
+ cy.get_open_dialog().findByRole('button', {name: 'Link'}).click();
+ cy.get_open_dialog()
+ .findByPlaceholderText('Attach a web link')
+ .type('https://github.com', { delay: 100, force: true });
cy.intercept('POST', '/api/method/upload_file').as('upload_file');
- cy.get_open_dialog().find('.btn-primary').click();
+ cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click();
cy.wait('@upload_file').its('response.body.message')
.should('have.property', 'file_url', 'https://github.com');
cy.get('.modal:visible').should('not.exist');
});
+
+ it('should allow cropping and optimization for valid images', () => {
+ open_upload_dialog();
+
+ cy.get_open_dialog().find('.file-upload-area').attachFile('sample_image.jpg', {
+ subjectType: 'drag-n-drop',
+ });
+
+ cy.get_open_dialog().findAllByText('sample_image.jpg').should('exist');
+ cy.get_open_dialog().find('.btn-crop').first().click();
+ cy.get_open_dialog().findByRole('button', {name: 'Crop'}).click();
+ cy.get_open_dialog().findAllByRole('checkbox', {name: 'Optimize'}).should('exist');
+ cy.get_open_dialog().findAllByLabelText('Optimize').first().click();
+
+ cy.intercept('POST', '/api/method/upload_file').as('upload_file');
+ cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click();
+ cy.wait('@upload_file').its('response.statusCode').should('eq', 200);
+ cy.get('.modal:visible').should('not.exist');
+ });
});
diff --git a/cypress/integration/first_day_of_the_week.js b/cypress/integration/first_day_of_the_week.js
new file mode 100644
index 0000000000..1e65b78990
--- /dev/null
+++ b/cypress/integration/first_day_of_the_week.js
@@ -0,0 +1,45 @@
+context("First Day of the Week", () => {
+ before(() => {
+ cy.login();
+ });
+
+ beforeEach(() => {
+ cy.visit('/app/system-settings');
+ cy.findByText('Date and Number Format').click();
+ });
+
+ it("Date control starts with same day as selected in System Settings", () => {
+ cy.intercept('POST', '/api/method/frappe.core.doctype.system_settings.system_settings.load').as("load_settings");
+ cy.fill_field('first_day_of_the_week', 'Tuesday', 'Select');
+ cy.findByRole('button', {name: 'Save'}).click();
+ cy.wait("@load_settings");
+ cy.dialog({
+ title: 'Date',
+ fields: [
+ {
+ label: 'Date',
+ fieldname: 'date',
+ fieldtype: 'Date'
+ }
+ ]
+ });
+ cy.get_field('date').click();
+ cy.get('.datepicker--day-name').eq(0).should('have.text', 'Tu');
+ });
+
+ it("Calendar view starts with same day as selected in System Settings", () => {
+ cy.intercept('POST', '/api/method/frappe.core.doctype.system_settings.system_settings.load').as("load_settings");
+ cy.fill_field('first_day_of_the_week', 'Monday', 'Select');
+ cy.findByRole('button', {name: 'Save'}).click();
+ cy.wait("@load_settings");
+ cy.visit("app/todo/view/calendar/default");
+ cy.get('.fc-day-header > span').eq(0).should('have.text', 'Mon');
+ });
+
+ after(() => {
+ cy.visit('/app/system-settings');
+ cy.findByText('Date and Number Format').click();
+ cy.fill_field('first_day_of_the_week', 'Sunday', 'Select');
+ cy.findByRole('button', {name: 'Save'}).click();
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js
new file mode 100644
index 0000000000..cec7edb59f
--- /dev/null
+++ b/cypress/integration/folder_navigation.js
@@ -0,0 +1,79 @@
+context('Folder Navigation', () => {
+ before(() => {
+ cy.visit('/login');
+ cy.login();
+ cy.visit('/app/file');
+ });
+
+ it('Adding Folders', () => {
+ //Adding filter to go into the home folder
+ cy.get('.filter-selector > .btn').findByText('1 filter').click();
+ cy.findByRole('button', {name: 'Clear Filters'}).click();
+ cy.get('.filter-action-buttons > .text-muted').findByText('+ Add a Filter').click();
+ cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}');
+ cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}');
+ cy.get('.filter-action-buttons > div > .btn-primary').findByText('Apply Filters').click();
+
+ //Adding folder (Test Folder)
+ cy.get('.menu-btn-group > .btn').click();
+ cy.get('.menu-btn-group [data-label="New Folder"]').click();
+ cy.get('form > [data-fieldname="value"]').type('Test Folder');
+ cy.findByRole('button', {name: 'Create'}).click();
+ });
+
+ it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => {
+ //Navigating inside the Attachments folder
+ cy.get('[title="Attachments"] > span').click();
+
+ //To check if the URL formed after visiting the attachments folder is correct
+ cy.location('pathname').should('eq', '/app/file/view/home/Attachments');
+ cy.visit('/app/file/view/home/Attachments');
+
+ //Adding folder inside the attachments folder
+ cy.get('.menu-btn-group > .btn').click();
+ cy.get('.menu-btn-group [data-label="New Folder"]').click();
+ cy.get('form > [data-fieldname="value"]').type('Test Folder');
+ cy.findByRole('button', {name: 'Create'}).click();
+
+ //Navigating inside the added folder in the Attachments folder
+ cy.get('[title="Test Folder"] > span').click();
+
+ //To check if the URL is correct after visiting the Test Folder
+ cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder');
+ cy.visit('/app/file/view/home/Attachments/Test%20Folder');
+
+ //Adding a file inside the Test Folder
+ cy.findByRole('button', {name: 'Add File'}).eq(0).click({force: true});
+ cy.get('.file-uploader').findByText('Link').click();
+ cy.get('.input-group > .form-control').type('https://wallpaperplay.com/walls/full/8/2/b/72402.jpg');
+ cy.findByRole('button', {name: 'Upload'}).click();
+
+ //To check if the added file is present in the Test Folder
+ cy.get('span.level-item > span').should('contain', 'Test Folder');
+ cy.get('.list-row-container').eq(0).should('contain.text', '72402.jpg');
+ cy.get('.list-row-checkbox').eq(0).click();
+
+ //Deleting the added file from the Test folder
+ cy.findByRole('button', {name: 'Actions'}).click();
+ cy.get('.actions-btn-group [data-label="Delete"]').click();
+ cy.wait(700);
+ cy.findByRole('button', {name: 'Yes'}).click();
+ cy.wait(700);
+
+ //Deleting the Test Folder
+ cy.visit('/app/file/view/home/Attachments');
+ cy.get('.list-row-checkbox').eq(0).click();
+ cy.findByRole('button', {name: 'Actions'}).click();
+ cy.get('.actions-btn-group [data-label="Delete"]').click();
+ cy.findByRole('button', {name: 'Yes'}).click();
+ });
+
+ it('Deleting Test Folder from the home', () => {
+ //Deleting the Test Folder added in the home directory
+ cy.visit('/app/file/view/home');
+ cy.get('.level-left > .list-subject > .file-select >.list-row-checkbox').eq(0).click({force: true, delay: 500});
+ cy.findByRole('button', {name: 'Actions'}).click();
+ cy.get('.actions-btn-group [data-label="Delete"]').click();
+ cy.findByRole('button', {name: 'Yes'}).click();
+ });
+});
diff --git a/cypress/integration/form.js b/cypress/integration/form.js
index 9c63fe4e8b..71cc6f4f0d 100644
--- a/cypress/integration/form.js
+++ b/cypress/integration/form.js
@@ -8,8 +8,7 @@ context('Form', () => {
});
it('create a new form', () => {
cy.visit('/app/todo/new');
- cy.fill_field('description', 'this is a test todo', 'Text Editor').blur();
- cy.wait(300);
+ cy.get_field('description', 'Text Editor').type('this is a test todo', {force: true}).wait(200);
cy.get('.page-title').should('contain', 'Not Saved');
cy.intercept({
method: 'POST',
@@ -17,43 +16,64 @@ context('Form', () => {
}).as('form_save');
cy.get('.primary-action').click();
cy.wait('@form_save').its('response.statusCode').should('eq', 200);
+
cy.visit('/app/todo');
- cy.get('.title-text').should('be.visible').and('contain', 'To Do');
+ cy.get('.page-head').findByTitle('To Do').should('exist');
cy.get('.list-row').should('contain', 'this is a test todo');
});
+
it('navigates between documents with child table list filters applied', () => {
cy.visit('/app/contact');
- cy.add_filter();
- cy.get('.filter-field .input-with-feedback.form-control').type('123', { force: true });
- cy.get('.filter-popover .apply-filters').click({ force: true });
- cy.visit('/app/contact/Test Form Contact 3');
+
+ cy.clear_filters();
+ cy.get('.standard-filter-section [data-fieldname="name"] input').type('Test Form Contact 3').blur();
+ cy.click_listview_row_item(0);
+
cy.get('.prev-doc').should('be.visible').click();
cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible');
cy.hide_dialog();
- cy.get('.next-doc').click();
- cy.wait(200);
+
+ cy.get('.next-doc').should('be.visible').click();
+ cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible');
cy.hide_dialog();
- cy.contains('Test Form Contact 2').should('not.exist');
- cy.get('.title-text').should('contain', 'Test Form Contact 3');
+
+ cy.get('#page-Contact .page-head').findByTitle('Test Form Contact 3').should('exist');
+
// clear filters
cy.visit('/app/contact');
cy.clear_filters();
});
+
it('validates behaviour of Data options validations in child table', () => {
// test email validations for set_invalid controller
let website_input = 'website.in';
+ let valid_email = 'user@email.com';
let expectBackgroundColor = 'rgb(255, 245, 245)';
cy.visit('/app/contact/new');
cy.get('.frappe-control[data-fieldname="email_ids"]').as('table');
cy.get('@table').find('button.grid-add-row').click();
- cy.get('.grid-body .rows [data-fieldname="email_id"]').click();
- cy.get('@table').find('input.input-with-feedback.form-control').as('email_input');
- cy.get('@email_input').type(website_input, { waitForAnimations: false });
+ cy.get('@table').find('button.grid-add-row').click();
+ cy.get('@table').find('[data-idx="1"]').as('row1');
+ cy.get('@table').find('[data-idx="2"]').as('row2');
+ cy.get('@row1').click();
+ cy.get('@row1').find('input.input-with-feedback.form-control').as('email_input1');
+
+ cy.get('@email_input1').type(website_input, { waitForAnimations: false });
cy.fill_field('company_name', 'Test Company');
- cy.get('@email_input').should($div => {
+
+ cy.get('@row2').click();
+ cy.get('@row2').find('input.input-with-feedback.form-control').as('email_input2');
+ cy.get('@email_input2').type(valid_email, { waitForAnimations: false });
+
+ cy.get('@row1').click();
+ cy.get('@email_input1').should($div => {
const style = window.getComputedStyle($div[0]);
expect(style.backgroundColor).to.equal(expectBackgroundColor);
});
+ cy.get('@email_input1').should('have.class', 'invalid');
+
+ cy.get('@row2').click();
+ cy.get('@email_input2').should('not.have.class', 'invalid');
});
});
diff --git a/cypress/integration/form_tab_break.js b/cypress/integration/form_tab_break.js
new file mode 100644
index 0000000000..45c3c92084
--- /dev/null
+++ b/cypress/integration/form_tab_break.js
@@ -0,0 +1,31 @@
+import doctype_with_tab_break from '../fixtures/doctype_with_tab_break';
+const doctype_name = doctype_with_tab_break.name;
+context("Form Tab Break", () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/website');
+ return cy.insert_doc('DocType', doctype_with_tab_break, true);
+ });
+ it("Should switch tab and open correct tabs on validation error", () => {
+ cy.new_form(doctype_name);
+ // test tab switch
+ cy.findByRole("tab", {name: "Tab 2"}).click();
+ cy.findByText("Phone");
+ cy.findByRole("tab", {name: "Details"}).click();
+ cy.findByText("Name");
+
+ // form should switch to the tab with un-filled mandatory field
+ cy.fill_field("username", "Test");
+ cy.findByRole("button", {name: "Save"}).click();
+ cy.findByText("Missing Fields");
+ cy.hide_dialog();
+ cy.findByText("Phone");
+ cy.fill_field("phone", "12345678");
+ cy.findByRole("button", {name: "Save"}).click();
+
+ // After save, first tab should have dashboard
+ cy.get(".form-tabs > .nav-item").eq(0).click();
+ cy.findByText("Connections");
+
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/form_tour.js b/cypress/integration/form_tour.js
new file mode 100644
index 0000000000..ab7ada9034
--- /dev/null
+++ b/cypress/integration/form_tour.js
@@ -0,0 +1,88 @@
+context('Form Tour', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/form-tour');
+ return cy.window().its('frappe').then(frappe => {
+ return frappe.call("frappe.tests.ui_test_helpers.create_form_tour");
+ });
+ });
+
+ const open_test_form_tour = () => {
+ cy.visit('/app/form-tour/Test Form Tour');
+ cy.findByRole('button', {name: 'Show Tour'}).should('be.visible').as('show_tour');
+ cy.get('@show_tour').click();
+ cy.wait(500);
+ cy.url().should('include', '/app/contact');
+ };
+
+ it('jump to a form tour', open_test_form_tour);
+
+ it('navigates a form tour', () => {
+ open_test_form_tour();
+
+ cy.get('.frappe-driver').should('be.visible');
+ cy.get('.frappe-control[data-fieldname="first_name"]').as('first_name');
+ cy.get('@first_name').should('have.class', 'driver-highlighted-element');
+ cy.get('.frappe-driver').findByRole('button', {name: 'Next'}).as('next_btn');
+
+ // next btn shouldn't move to next step, if first name is not entered
+ cy.get('@next_btn').click();
+ cy.wait(500);
+ cy.get('@first_name').should('have.class', 'driver-highlighted-element');
+
+ // after filling the field, next step should be highlighted
+ cy.fill_field('first_name', 'Test Name', 'Data');
+ cy.wait(500);
+ cy.get('@next_btn').click();
+ cy.wait(500);
+
+ // assert field is highlighted
+ cy.get('.frappe-control[data-fieldname="last_name"]').as('last_name');
+ cy.get('@last_name').should('have.class', 'driver-highlighted-element');
+
+ // after filling the field, next step should be highlighted
+ cy.fill_field('last_name', 'Test Last Name', 'Data');
+ cy.wait(500);
+ cy.get('@next_btn').click();
+ cy.wait(500);
+
+ // assert field is highlighted
+ cy.get('.frappe-control[data-fieldname="phone_nos"]').as('phone_nos');
+ cy.get('@phone_nos').should('have.class', 'driver-highlighted-element');
+
+ // move to next step
+ cy.wait(500);
+ cy.get('@next_btn').click();
+ cy.wait(500);
+
+ // assert add row btn is highlighted
+ cy.get('@phone_nos').find('.grid-add-row').as('add_row');
+ cy.get('@add_row').should('have.class', 'driver-highlighted-element');
+
+ // add a row & move to next step
+ cy.wait(500);
+ cy.get('@add_row').click();
+ cy.wait(500);
+
+ // assert table field is highlighted
+ cy.get('.grid-row-open .frappe-control[data-fieldname="phone"]').as('phone');
+ cy.get('@phone').should('have.class', 'driver-highlighted-element');
+ // enter value in a table field
+ let field = cy.fill_table_field('phone_nos', '1', 'phone', '1234567890');
+ field.blur();
+
+ // move to collapse row step
+ cy.wait(500);
+ cy.get('.driver-popover-title').contains('Test Title 4').siblings().get('@next_btn').click();
+ cy.wait(500);
+ // collapse row
+ cy.get('.grid-row-open .grid-collapse-row').click();
+ cy.wait(500);
+
+ // assert save btn is highlighted
+ cy.get('.primary-action').should('have.class', 'driver-highlighted-element');
+ cy.wait(500);
+ cy.get('.frappe-driver').findByRole('button', {name: 'Save'}).should('be.visible');
+
+ });
+});
diff --git a/cypress/integration/grid.js b/cypress/integration/grid.js
new file mode 100644
index 0000000000..4fa52712cf
--- /dev/null
+++ b/cypress/integration/grid.js
@@ -0,0 +1,92 @@
+context('Grid', () => {
+ beforeEach(() => {
+ cy.login();
+ cy.visit('/app/website');
+ });
+ before(() => {
+ cy.login();
+ cy.visit('/app/website');
+ return cy.window().its('frappe').then(frappe => {
+ return frappe.call("frappe.tests.ui_test_helpers.create_contact_phone_nos_records");
+ });
+ });
+ it('update docfield property using update_docfield_property', () => {
+ cy.visit('/app/contact/Test Contact');
+ cy.window().its("cur_frm").then(frm => {
+ cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
+ let field = frm.get_field("phone_nos");
+ field.grid.update_docfield_property("is_primary_phone", "hidden", true);
+
+ cy.get('@table').find('[data-idx="1"] .edit-grid-row').click();
+ cy.get('.grid-row-open').as('table-form');
+ cy.get('@table-form').find('.frappe-control[data-fieldname="is_primary_phone"]').should("be.hidden");
+ cy.get('@table-form').find('.grid-footer-toolbar').click();
+
+ cy.get('@table').find('[data-idx="2"] .edit-grid-row').click();
+ cy.get('.grid-row-open').as('table-form');
+ cy.get('@table-form').find('.frappe-control[data-fieldname="is_primary_phone"]').should("be.hidden");
+ cy.get('@table-form').find('.grid-footer-toolbar').click();
+ });
+ });
+ it('update docfield property using toggle_display', () => {
+ cy.visit('/app/contact/Test Contact');
+ cy.window().its("cur_frm").then(frm => {
+ cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
+ let field = frm.get_field("phone_nos");
+ field.grid.toggle_display("is_primary_mobile_no", false);
+
+ cy.get('@table').find('[data-idx="1"] .edit-grid-row').click();
+ cy.get('.grid-row-open').as('table-form');
+ cy.get('@table-form').find('.frappe-control[data-fieldname="is_primary_mobile_no"]').should("be.hidden");
+ cy.get('@table-form').find('.grid-footer-toolbar').click();
+
+ cy.get('@table').find('[data-idx="2"] .edit-grid-row').click();
+ cy.get('.grid-row-open').as('table-form');
+ cy.get('@table-form').find('.frappe-control[data-fieldname="is_primary_mobile_no"]').should("be.hidden");
+ cy.get('@table-form').find('.grid-footer-toolbar').click();
+ });
+ });
+ it('update docfield property using toggle_enable', () => {
+ cy.visit('/app/contact/Test Contact');
+ cy.window().its("cur_frm").then(frm => {
+ cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
+ let field = frm.get_field("phone_nos");
+ field.grid.toggle_enable("phone", false);
+
+
+ cy.get('@table').find('[data-idx="1"] .edit-grid-row').click();
+ cy.get('.grid-row-open').as('table-form');
+ cy.get('@table-form').find('.frappe-control[data-fieldname="phone"] .control-value').should('have.class', 'like-disabled-input');
+ cy.get('@table-form').find('.grid-footer-toolbar').click();
+
+ cy.get('@table').find('[data-idx="2"] .edit-grid-row').click();
+ cy.get('.grid-row-open').as('table-form');
+ cy.get('@table-form').find('.frappe-control[data-fieldname="phone"] .control-value').should('have.class', 'like-disabled-input');
+ cy.get('@table-form').find('.grid-footer-toolbar').click();
+ });
+ });
+ it('update docfield property using toggle_reqd', () => {
+ cy.visit('/app/contact/Test Contact');
+ cy.window().its("cur_frm").then(frm => {
+ cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
+ let field = frm.get_field("phone_nos");
+ field.grid.toggle_reqd("phone", false);
+
+ cy.get('@table').find('[data-idx="1"] .edit-grid-row').click();
+ cy.get('.grid-row-open').as('table-form');
+ cy.get_field("phone").as('phone-field');
+ cy.get('@phone-field').focus().clear().wait(500).blur();
+ cy.get('@phone-field').should("not.have.class", "has-error");
+ cy.get('@table-form').find('.grid-footer-toolbar').click();
+
+ cy.get('@table').find('[data-idx="2"] .edit-grid-row').click();
+ cy.get('.grid-row-open').as('table-form');
+ cy.get_field("phone").as('phone-field');
+ cy.get('@phone-field').focus().clear().wait(500).blur();
+ cy.get('@phone-field').should("not.have.class", "has-error");
+ cy.get('@table-form').find('.grid-footer-toolbar').click();
+
+ });
+ });
+});
+
diff --git a/cypress/integration/grid_configuration.js b/cypress/integration/grid_configuration.js
new file mode 100644
index 0000000000..7193d804c2
--- /dev/null
+++ b/cypress/integration/grid_configuration.js
@@ -0,0 +1,23 @@
+context('Grid Configuration', () => {
+ beforeEach(() => {
+ cy.login();
+ cy.visit('/app/doctype/User');
+ });
+ it('Set user wise grid settings', () => {
+ cy.wait(100);
+ cy.get('.frappe-control[data-fieldname="fields"]').as('table');
+ cy.get('@table').find('.icon-sm').click();
+ cy.wait(100);
+ cy.get('.frappe-control[data-fieldname="fields_html"]').as('modal');
+ cy.get('@modal').find('.add-new-fields').click();
+ cy.wait(100);
+ cy.get('[type="checkbox"][data-unit="read_only"]').check();
+ cy.findByRole('button', {name: 'Add'}).click();
+ cy.wait(100);
+ cy.get('[data-fieldname="options"]').invoke('attr', 'value', '1');
+ cy.get('.form-control.column-width[data-fieldname="options"]').trigger('change');
+ cy.findByRole('button', {name: 'Update'}).click();
+ cy.wait(200);
+ cy.get('[title="Read Only"').should('be.visible');
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/grid_keyboard_shortcut.js b/cypress/integration/grid_keyboard_shortcut.js
new file mode 100644
index 0000000000..9cf39165ad
--- /dev/null
+++ b/cypress/integration/grid_keyboard_shortcut.js
@@ -0,0 +1,40 @@
+context('Grid Keyboard Shortcut', () => {
+ let total_count = 0;
+ before(() => {
+ cy.login();
+ });
+ beforeEach(() => {
+ cy.reload();
+ cy.visit('/app/contact/new-contact-1');
+ cy.get('.frappe-control[data-fieldname="email_ids"]').find(".grid-add-row").click();
+ });
+ it('Insert new row at the end', () => {
+ cy.add_new_row_in_grid('{ctrl}{shift}{downarrow}', (cy, total_count) => {
+ cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', `${total_count+1}`);
+ }, total_count);
+ });
+ it('Insert new row at the top', () => {
+ cy.add_new_row_in_grid('{ctrl}{shift}{uparrow}', (cy) => {
+ cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', '2');
+ });
+ });
+ it('Insert new row below', () => {
+ cy.add_new_row_in_grid('{ctrl}{downarrow}', (cy) => {
+ cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', '1');
+ });
+ });
+ it('Insert new row above', () => {
+ cy.add_new_row_in_grid('{ctrl}{uparrow}', (cy) => {
+ cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', '2');
+ });
+ });
+});
+
+Cypress.Commands.add('add_new_row_in_grid', (shortcut_keys, callbackFn, total_count) => {
+ cy.get('.frappe-control[data-fieldname="email_ids"]').as('table');
+ cy.get('@table').find('.grid-body [data-fieldname="email_id"]').first().click();
+ cy.get('@table').find('.grid-body [data-fieldname="email_id"]')
+ .first().type(shortcut_keys);
+
+ callbackFn(cy, total_count);
+});
\ No newline at end of file
diff --git a/cypress/integration/grid_pagination.js b/cypress/integration/grid_pagination.js
index 8f6b79c1f4..84b3320282 100644
--- a/cypress/integration/grid_pagination.js
+++ b/cypress/integration/grid_pagination.js
@@ -13,7 +13,7 @@ context('Grid Pagination', () => {
it('creates pages for child table', () => {
cy.visit('/app/contact/Test Contact');
cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
- cy.get('@table').find('.current-page-number').should('contain', '1');
+ cy.get('@table').find('.current-page-number').should('have.value', '1');
cy.get('@table').find('.total-page-number').should('contain', '20');
cy.get('@table').find('.grid-body .grid-row').should('have.length', 50);
});
@@ -21,25 +21,46 @@ context('Grid Pagination', () => {
cy.visit('/app/contact/Test Contact');
cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
cy.get('@table').find('.next-page').click();
- cy.get('@table').find('.current-page-number').should('contain', '2');
+ cy.get('@table').find('.current-page-number').should('have.value', '2');
cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '51');
cy.get('@table').find('.prev-page').click();
- cy.get('@table').find('.current-page-number').should('contain', '1');
+ cy.get('@table').find('.current-page-number').should('have.value', '1');
cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '1');
});
it('adds and deletes rows and changes page', () => {
cy.visit('/app/contact/Test Contact');
cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
- cy.get('@table').find('button.grid-add-row').click();
+ cy.get('@table').findByRole('button', {name: 'Add Row'}).click();
cy.get('@table').find('.grid-body .row-index').should('contain', 1001);
- cy.get('@table').find('.current-page-number').should('contain', '21');
+ cy.get('@table').find('.current-page-number').should('have.value', '21');
cy.get('@table').find('.total-page-number').should('contain', '21');
cy.get('@table').find('.grid-body .grid-row .grid-row-check').click({ force: true });
- cy.get('@table').find('button.grid-remove-rows').click();
+ cy.get('@table').findByRole('button', {name: 'Delete'}).click();
cy.get('@table').find('.grid-body .row-index').last().should('contain', 1000);
- cy.get('@table').find('.current-page-number').should('contain', '20');
+ cy.get('@table').find('.current-page-number').should('have.value', '20');
cy.get('@table').find('.total-page-number').should('contain', '20');
});
+ it('go to specific page, use up and down arrow, type characters, 0 page and more than existing page', () => {
+ cy.visit('/app/contact/Test Contact');
+ cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
+ cy.get('@table').find('.current-page-number').focus().clear().type('17').blur();
+ cy.get('@table').find('.grid-body .row-index').should('contain', 801);
+
+ cy.get('@table').find('.current-page-number').focus().type('{uparrow}{uparrow}');
+ cy.get('@table').find('.current-page-number').should('have.value', '19');
+
+ cy.get('@table').find('.current-page-number').focus().type('{downarrow}{downarrow}');
+ cy.get('@table').find('.current-page-number').should('have.value', '17');
+
+ cy.get('@table').find('.current-page-number').focus().clear().type('700').blur();
+ cy.get('@table').find('.current-page-number').should('have.value', '20');
+
+ cy.get('@table').find('.current-page-number').focus().clear().type('0').blur();
+ cy.get('@table').find('.current-page-number').should('have.value', '1');
+
+ cy.get('@table').find('.current-page-number').focus().clear().type('abc').blur();
+ cy.get('@table').find('.current-page-number').should('have.value', '1');
+ });
// it('deletes all rows', ()=> {
// cy.visit('/app/contact/Test Contact');
// cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
diff --git a/cypress/integration/grid_search.js b/cypress/integration/grid_search.js
new file mode 100644
index 0000000000..d30545a2e1
--- /dev/null
+++ b/cypress/integration/grid_search.js
@@ -0,0 +1,107 @@
+import doctype_with_child_table from '../fixtures/doctype_with_child_table';
+import child_table_doctype from '../fixtures/child_table_doctype';
+import child_table_doctype_1 from '../fixtures/child_table_doctype_1';
+const doctype_with_child_table_name = doctype_with_child_table.name;
+
+context('Grid Search', () => {
+ before(() => {
+ cy.visit('/login');
+ cy.login();
+ cy.visit('/app/website');
+ cy.insert_doc('DocType', child_table_doctype, true);
+ cy.insert_doc('DocType', child_table_doctype_1, true);
+ cy.insert_doc('DocType', doctype_with_child_table, true);
+ return cy.window().its('frappe').then(frappe => {
+ return frappe.xcall("frappe.tests.ui_test_helpers.insert_doctype_with_child_table_record", {
+ name: doctype_with_child_table_name
+ });
+ });
+ });
+
+ it('Test search row visibility', () => {
+ cy.window().its('frappe').then(frappe => {
+ frappe.model.user_settings.save('Doctype With Child Table', 'GridView', {
+ 'Child Table Doctype 1': [
+ {'fieldname': 'data', 'columns': 2},
+ {'fieldname': 'barcode', 'columns': 1},
+ {'fieldname': 'check', 'columns': 1},
+ {'fieldname': 'rating', 'columns': 2},
+ {'fieldname': 'duration', 'columns': 2},
+ {'fieldname': 'date', 'columns': 2}
+ ]
+ });
+ });
+
+ cy.visit(`/app/doctype-with-child-table/Test Grid Search`);
+
+ cy.get('.frappe-control[data-fieldname="child_table_1"]').as('table');
+ cy.get('@table').find('.grid-row-check:last').click();
+ cy.get('@table').find('.grid-footer').contains('Delete').click();
+ cy.get('.grid-heading-row .grid-row .search').should('not.exist');
+ });
+
+ it('test search field for different fieldtypes', () => {
+ cy.visit(`/app/doctype-with-child-table/Test Grid Search`);
+
+ cy.get('.frappe-control[data-fieldname="child_table_1"]').as('table');
+
+ // Index Column
+ cy.get('@table').find('.grid-heading-row .row-index.search input').type('3');
+ cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 2);
+ cy.get('@table').find('.grid-heading-row .row-index.search input').clear();
+
+ // Data Column
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Data"]').type('Data');
+ cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 1);
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Data"]').clear();
+
+ // Barcode Column
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Barcode"]').type('092');
+ cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 4);
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Barcode"]').clear();
+
+ // Check Column
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').type('1');
+ cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 9);
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').clear();
+
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').type('0');
+ cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 11);
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').clear();
+
+ // Rating Column
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Rating"]').type('3');
+ cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 3);
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Rating"]').clear();
+
+ // Duration Column
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Duration"]').type('3d');
+ cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 3);
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Duration"]').clear();
+
+ // Date Column
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Date"]').type('2022');
+ cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 4);
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Date"]').clear();
+ });
+
+ it('test with multiple filter', () => {
+ cy.get('.frappe-control[data-fieldname="child_table_1"]').as('table');
+
+ // Data Column
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Data"]').type('a');
+ cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 10);
+
+ // Barcode Column
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Barcode"]').type('0');
+ cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 8);
+
+ // Duration Column
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Duration"]').type('d');
+ cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 5);
+
+ // Date Column
+ cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Date"]').type('02-');
+ cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 2);
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/list_paging.js b/cypress/integration/list_paging.js
new file mode 100644
index 0000000000..4a59024a7b
--- /dev/null
+++ b/cypress/integration/list_paging.js
@@ -0,0 +1,38 @@
+context('List Paging', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/website');
+ return cy.window().its('frappe').then(frappe => {
+ return frappe.call("frappe.tests.ui_test_helpers.create_multiple_todo_records");
+ });
+ });
+
+ it('test load more with count selection buttons', () => {
+ cy.visit('/app/todo/view/report');
+
+ cy.get('.list-paging-area .list-count').should('contain.text', '20 of');
+ cy.get('.list-paging-area .btn-more').click();
+ cy.get('.list-paging-area .list-count').should('contain.text', '40 of');
+ cy.get('.list-paging-area .btn-more').click();
+ cy.get('.list-paging-area .list-count').should('contain.text', '60 of');
+
+ cy.get('.list-paging-area .btn-group .btn-paging[data-value="100"]').click();
+
+ cy.get('.list-paging-area .list-count').should('contain.text', '100 of');
+ cy.get('.list-paging-area .btn-more').click();
+ cy.get('.list-paging-area .list-count').should('contain.text', '200 of');
+ cy.get('.list-paging-area .btn-more').click();
+ cy.get('.list-paging-area .list-count').should('contain.text', '300 of');
+
+ // check if refresh works after load more
+ cy.get('.page-head .standard-actions [data-original-title="Refresh"]').click();
+ cy.get('.list-paging-area .list-count').should('contain.text', '300 of');
+
+ cy.get('.list-paging-area .btn-group .btn-paging[data-value="500"]').click();
+
+ cy.get('.list-paging-area .list-count').should('contain.text', '500 of');
+ cy.get('.list-paging-area .btn-more').click();
+
+ cy.get('.list-paging-area .list-count').should('contain.text', '1000 of');
+ });
+});
diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js
index 633d1335ab..3e0d1c9d50 100644
--- a/cypress/integration/list_view.js
+++ b/cypress/integration/list_view.js
@@ -6,12 +6,24 @@ context('List View', () => {
return frappe.xcall("frappe.tests.ui_test_helpers.setup_workflow");
});
});
+
+ it('Keep checkbox checked after Refresh', () => {
+ cy.go_to_list('ToDo');
+ cy.get('.list-row-container .list-row-checkbox').click({ multiple: true, force: true });
+ cy.get('.actions-btn-group button').contains('Actions').should('be.visible');
+ cy.intercept('/api/method/frappe.desk.reportview.get').as('list-refresh');
+ cy.wait(3000); // wait before you hit another refresh
+ cy.get('button[data-original-title="Refresh"]').click();
+ cy.wait('@list-refresh');
+ cy.get('.list-row-container .list-row-checkbox:checked').should('be.visible');
+ });
+
it('enables "Actions" button', () => {
- const actions = ['Approve', 'Reject', 'Edit', 'Assign To', 'Apply Assignment Rule', 'Add Tags', 'Print', 'Delete'];
+ const actions = ['Approve', 'Reject', 'Edit', 'Export', 'Assign To', 'Apply Assignment Rule', 'Add Tags', 'Print', 'Delete'];
cy.go_to_list('ToDo');
cy.get('.list-row-container:contains("Pending") .list-row-checkbox').click({ multiple: true, force: true });
cy.get('.actions-btn-group button').contains('Actions').should('be.visible').click();
- cy.get('.dropdown-menu li:visible .dropdown-item').should('have.length', 8).each((el, index) => {
+ cy.get('.dropdown-menu li:visible .dropdown-item').should('have.length', 9).each((el, index) => {
cy.wrap(el).contains(actions[index]);
}).then((elements) => {
cy.intercept({
@@ -24,10 +36,11 @@ context('List View', () => {
}).as('real-time-update');
cy.wrap(elements).contains('Approve').click();
cy.wait(['@bulk-approval', '@real-time-update']);
- cy.hide_dialog();
+ cy.wait(300);
+ cy.get_open_dialog().find('.btn-modal-close').click();
+ cy.reload();
cy.clear_filters();
cy.get('.list-row-container:visible').should('contain', 'Approved');
});
});
});
-
diff --git a/cypress/integration/list_view_settings.js b/cypress/integration/list_view_settings.js
index 52512b911e..61d4b8aae5 100644
--- a/cypress/integration/list_view_settings.js
+++ b/cypress/integration/list_view_settings.js
@@ -17,9 +17,9 @@ context('List View Settings', () => {
cy.get('.dropdown-menu li').filter(':visible').contains('List Settings').click();
cy.get('.modal-dialog').should('contain', 'DocType Settings');
- cy.get('input[data-fieldname="disable_count"]').check({ force: true });
- cy.get('input[data-fieldname="disable_sidebar_stats"]').check({ force: true });
- cy.get('button').filter(':visible').contains('Save').click();
+ cy.findByLabelText('Disable Count').check({ force: true });
+ cy.findByLabelText('Disable Sidebar Stats').check({ force: true });
+ cy.findByRole('button', {name: 'Save'}).click();
cy.reload({ force: true });
@@ -29,8 +29,8 @@ 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('input[data-fieldname="disable_count"]').uncheck({ force: true });
- cy.get('input[data-fieldname="disable_sidebar_stats"]').uncheck({ force: true });
- cy.get('button').filter(':visible').contains('Save').click();
+ cy.findByLabelText('Disable Count').uncheck({ force: true });
+ cy.findByLabelText('Disable Sidebar Stats').uncheck({ force: true });
+ cy.findByRole('button', {name: 'Save'}).click();
});
});
diff --git a/cypress/integration/login.js b/cypress/integration/login.js
index 6b109dd18d..98739bb4c9 100644
--- a/cypress/integration/login.js
+++ b/cypress/integration/login.js
@@ -11,13 +11,13 @@ context('Login', () => {
it('validates password', () => {
cy.get('#login_email').type('Administrator');
- cy.get('.btn-login:visible').click();
+ cy.findByRole('button', {name: 'Login'}).click();
cy.location('pathname').should('eq', '/login');
});
it('validates email', () => {
cy.get('#login_password').type('qwe');
- cy.get('.btn-login:visible').click();
+ cy.findByRole('button', {name: 'Login'}).click();
cy.location('pathname').should('eq', '/login');
});
@@ -25,8 +25,8 @@ context('Login', () => {
cy.get('#login_email').type('Administrator');
cy.get('#login_password').type('qwer');
- cy.get('.btn-login:visible').click();
- cy.get('.btn-login:visible').contains('Invalid Login. Try again.');
+ cy.findByRole('button', {name: 'Login'}).click();
+ cy.findByRole('button', {name: 'Invalid Login. Try again.'}).should('exist');
cy.location('pathname').should('eq', '/login');
});
@@ -34,7 +34,7 @@ context('Login', () => {
cy.get('#login_email').type('Administrator');
cy.get('#login_password').type(Cypress.config('adminPassword'));
- cy.get('.btn-login:visible').click();
+ cy.findByRole('button', {name: 'Login'}).click();
cy.location('pathname').should('eq', '/app');
cy.window().its('frappe.session.user').should('eq', 'Administrator');
});
@@ -60,7 +60,7 @@ context('Login', () => {
cy.get('#login_email').type('Administrator');
cy.get('#login_password').type(Cypress.config('adminPassword'));
- cy.get('.btn-login:visible').click();
+ cy.findByRole('button', {name: 'Login'}).click();
// verify redirected location and url params after login
cy.url().should('include', '/me?' + payload.toString().replace('+', '%20'));
diff --git a/cypress/integration/multi_select_dialog.js b/cypress/integration/multi_select_dialog.js
new file mode 100644
index 0000000000..607db506c7
--- /dev/null
+++ b/cypress/integration/multi_select_dialog.js
@@ -0,0 +1,99 @@
+context('MultiSelectDialog', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app');
+ const contact_template = {
+ "doctype": "Contact",
+ "first_name": "Test",
+ "status": "Passive",
+ "email_ids": [
+ {
+ "doctype": "Contact Email",
+ "email_id": "test@example.com",
+ "is_primary": 0
+ }
+ ]
+ };
+ const promises = Array.from({length: 25})
+ .map(() => cy.insert_doc('Contact', contact_template, true));
+ Promise.all(promises);
+ });
+
+ function open_multi_select_dialog() {
+ cy.window().its('frappe').then(frappe => {
+ new frappe.ui.form.MultiSelectDialog({
+ doctype: "Contact",
+ target: {},
+ setters: {
+ status: null,
+ gender: null
+ },
+ add_filters_group: 1,
+ allow_child_item_selection: 1,
+ child_fieldname: "email_ids",
+ child_columns: ["email_id", "is_primary"]
+ });
+ });
+ }
+
+ it('checks multi select dialog api works', () => {
+ open_multi_select_dialog();
+ cy.get_open_dialog().should('contain', 'Select Contacts');
+ });
+
+ it('checks for filters', () => {
+ ['search_term', 'status', 'gender'].forEach(fieldname => {
+ cy.get_open_dialog().get(`.frappe-control[data-fieldname="${fieldname}"]`).should('exist');
+ });
+
+ // add_filters_group: 1 should add a filter group
+ cy.get_open_dialog().get(`.frappe-control[data-fieldname="filter_area"]`).should('exist');
+
+ });
+
+ it('checks for child item selection', () => {
+ cy.get_open_dialog()
+ .get(`.dt-row-header`).should('not.exist');
+
+ cy.get_open_dialog()
+ .get(`.frappe-control[data-fieldname="allow_child_item_selection"]`)
+ .find('input[data-fieldname="allow_child_item_selection"]')
+ .should('exist')
+ .click({force: true});
+
+ cy.get_open_dialog()
+ .get(`.frappe-control[data-fieldname="child_selection_area"]`)
+ .should('exist');
+
+ cy.get_open_dialog()
+ .get(`.dt-row-header`).should('contain', 'Contact');
+
+ cy.get_open_dialog()
+ .get(`.dt-row-header`).should('contain', 'Email Id');
+
+ cy.get_open_dialog()
+ .get(`.dt-row-header`).should('contain', 'Is Primary');
+ });
+
+ it('tests more button', () => {
+ cy.get_open_dialog()
+ .get(`.frappe-control[data-fieldname="more_child_btn"]`)
+ .should('exist')
+ .as('more-btn');
+
+ cy.get_open_dialog().get('.datatable .dt-scrollable .dt-row').should(($rows) => {
+ expect($rows).to.have.length(20);
+ });
+
+ cy.intercept('POST', 'api/method/frappe.client.get_list').as('get-more-records');
+ cy.get('@more-btn').find('button').click({force: true});
+ cy.wait('@get-more-records');
+
+ cy.get_open_dialog().get('.datatable .dt-scrollable .dt-row').should(($rows) => {
+ if ($rows.length <= 20) {
+ throw new Error("More button doesn't work");
+ }
+ });
+
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/navigation.js b/cypress/integration/navigation.js
new file mode 100644
index 0000000000..b4e023c53e
--- /dev/null
+++ b/cypress/integration/navigation.js
@@ -0,0 +1,25 @@
+context('Navigation', () => {
+ before(() => {
+ cy.login();
+ });
+ it('Navigate to route with hash in document name', () => {
+ cy.insert_doc('ToDo', {'__newname': 'ABC#123', 'description': 'Test this', 'ignore_duplicate': true});
+ cy.visit('/app/todo/ABC#123');
+ cy.title().should('eq', 'Test this - ABC#123');
+ cy.get_field('description', 'Text Editor').contains('Test this');
+ cy.go('back');
+ cy.title().should('eq', 'Website');
+ });
+
+ it.only('Navigate to previous page after login', () => {
+ cy.visit('/app/todo');
+ cy.get('.page-head').findByTitle('To Do').should('be.visible');
+ cy.request('/api/method/logout');
+ cy.reload().as('reload');
+ cy.get('@reload').get('.page-card .btn-primary').contains('Login').click();
+ cy.location('pathname').should('eq', '/login');
+ cy.login();
+ cy.visit('/app');
+ cy.location('pathname').should('eq', '/app/todo');
+ });
+});
diff --git a/cypress/integration/number_card.js b/cypress/integration/number_card.js
new file mode 100644
index 0000000000..a01ff1152d
--- /dev/null
+++ b/cypress/integration/number_card.js
@@ -0,0 +1,22 @@
+context('Number Card', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/website');
+ });
+
+ it('Check filter populate for child table doctype', () => {
+ cy.visit('/app/number-card/new-number-card-1');
+ cy.get('[data-fieldname="parent_document_type"]').should('have.css', 'display', 'none');
+
+ cy.get_field('document_type', 'Link');
+ cy.fill_field('document_type', 'Workspace Link', 'Link').focus().blur();
+ cy.get_field('document_type', 'Link').should('have.value', 'Workspace Link');
+
+ cy.fill_field('label', 'Test Number Card', 'Data');
+
+ cy.get('[data-fieldname="filters_json"]').click().wait(200);
+ cy.get('.modal-body .filter-action-buttons .add-filter').click();
+ cy.get('.modal-body .fieldname-select-area').click();
+ cy.get('.modal-actions .btn-modal-close').click();
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/query_report.js b/cypress/integration/query_report.js
index e2a1c3fc79..43f26f8b50 100644
--- a/cypress/integration/query_report.js
+++ b/cypress/integration/query_report.js
@@ -2,32 +2,62 @@ context('Query Report', () => {
before(() => {
cy.login();
cy.visit('/app/website');
+ cy.insert_doc('Report', {
+ 'report_name': 'Test ToDo Report',
+ 'ref_doctype': 'ToDo',
+ 'report_type': 'Query Report',
+ 'query': 'select * from tabToDo'
+ }, true).as('doc');
+ cy.create_records({
+ doctype: 'ToDo',
+ description: 'this is a test todo for query report'
+ }).as('todos');
});
it('add custom column in report', () => {
cy.visit('/app/query-report/Permitted Documents For User');
cy.get('.page-form.flex', { timeout: 60000 }).should('have.length', 1).then(() => {
- cy.get('#page-query-report input[data-fieldname="user"]').as('input');
- cy.get('@input').focus().type('test@erpnext.com', { delay: 100 }).blur();
+ cy.get('#page-query-report input[data-fieldname="user"]').as('input-user');
+ cy.get('@input-user').focus().type('test@erpnext.com', { delay: 100 }).blur();
cy.wait(300);
- cy.get('#page-query-report input[data-fieldname="doctype"]').as('input-test');
- cy.get('@input-test').focus().type('Role', { delay: 100 }).blur();
+ cy.get('#page-query-report input[data-fieldname="doctype"]').as('input-role');
+ cy.get('@input-role').focus().type('Role', { delay: 100 }).blur();
cy.get('.datatable').should('exist');
- cy.get('.menu-btn-group button').click({ force: true });
- cy.get('.dropdown-menu li').contains('Add Column').click({ force: true });
- cy.get('.modal-dialog').should('contain', 'Add Column');
+ cy.get('#page-query-report .page-actions .menu-btn-group button').click({ force: true });
+ cy.get('#page-query-report .menu-btn-group .dropdown-menu').contains('Add Column').click({ force: true });
+ cy.get_open_dialog().get('.modal-title').should('contain', 'Add Column');
cy.get('select[data-fieldname="doctype"]').select("Role", { force: true });
cy.get('select[data-fieldname="field"]').select("Role Name", { force: true });
cy.get('select[data-fieldname="insert_after"]').select("Name", { force: true });
- cy.get('button').contains('Submit').click({ force: true });
- cy.get('.menu-btn-group button').click({ force: true });
- cy.get('.dropdown-menu li').contains('Save').click({ force: true });
- cy.get('.modal-dialog').should('contain', 'Save Report');
+ cy.get_open_dialog().findByRole('button', {name: 'Submit'}).click({ force: true });
+ cy.get('#page-query-report .page-actions .menu-btn-group button').click({ force: true });
+ cy.get('#page-query-report .menu-btn-group .dropdown-menu').contains('Save').click({ timeout: 100, force: true });
+ cy.get_open_dialog().get('.modal-title').should('contain', 'Save Report');
cy.get('input[data-fieldname="report_name"]').type("Test Report", { delay: 100, force: true });
- cy.get('button').contains('Submit').click({ timeout: 1000, force: true });
+ cy.get_open_dialog().findByRole('button', {name: 'Submit'}).click({ timeout: 1000, force: true });
});
});
+
+ let save_report_and_open = (report, update_name) => {
+ cy.get('#page-query-report .page-actions .menu-btn-group button').click({ force: true });
+ cy.get('#page-query-report .menu-btn-group .dropdown-menu').contains('Save').click({ timeout: 100, force: true });
+ cy.get_open_dialog().get('.modal-title').should('contain', 'Save Report');
+
+ cy.get('input[data-fieldname="report_name"]').type(update_name, { delay: 100, force: true });
+ cy.get_open_dialog().findByRole('button', {name: 'Submit'}).click({ timeout: 1000, force: true });
+
+ cy.visit('/app/query-report/'+report);
+ cy.get('.datatable').should('exist');
+ };
+
+ it('test multi level query report', () => {
+ cy.visit('/app/query-report/Test ToDo Report');
+ cy.get('.datatable').should('exist');
+
+ save_report_and_open('Test ToDo Report 1', ' 1');
+ save_report_and_open('Test ToDo Report 11', '1');
+ });
});
\ No newline at end of file
diff --git a/cypress/integration/recorder.js b/cypress/integration/recorder.js
index 7236200741..7d4c83abf5 100644
--- a/cypress/integration/recorder.js
+++ b/cypress/integration/recorder.js
@@ -3,70 +3,64 @@ context('Recorder', () => {
cy.login();
});
- it('Navigate to Recorder', () => {
- cy.visit('/app');
- cy.awesomebar('recorder');
- cy.get('h3').should('contain', 'Recorder');
- cy.url().should('include', '/recorder/detail');
+ beforeEach(() => {
+ cy.visit('/app/recorder');
+ return cy.window().its('frappe').then(frappe => {
+ // reset recorder
+ return frappe.xcall("frappe.recorder.stop").then(() => {
+ return frappe.xcall("frappe.recorder.delete");
+ });
+ });
});
it('Recorder Empty State', () => {
- cy.visit('/app/recorder');
- cy.get('.title-text').should('contain', 'Recorder');
+ cy.get('.page-head').findByTitle('Recorder').should('exist');
cy.get('.indicator-pill').should('contain', 'Inactive').should('have.class', 'red');
- cy.get('.primary-action').should('contain', 'Start');
- cy.get('.btn-secondary').should('contain', 'Clear');
+ cy.get('.page-actions').findByRole('button', {name: 'Start'}).should('exist');
+ cy.get('.page-actions').findByRole('button', {name: 'Clear'}).should('exist');
- cy.get('.msg-box').should('contain', 'Inactive');
- cy.get('.msg-box .btn-primary').should('contain', 'Start Recording');
+ cy.get('.msg-box').should('contain', 'Recorder is Inactive');
+ cy.get('.msg-box').findByRole('button', {name: 'Start Recording'}).should('exist');
});
it('Recorder Start', () => {
- cy.visit('/app/recorder');
- cy.get('.primary-action').should('contain', 'Start').click();
+ cy.get('.page-actions').findByRole('button', {name: 'Start'}).click();
cy.get('.indicator-pill').should('contain', 'Active').should('have.class', 'green');
- cy.get('.msg-box').should('contain', 'No Requests');
+ cy.get('.msg-box').should('contain', 'No Requests found');
cy.visit('/app/List/DocType/List');
cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh');
cy.wait('@list_refresh');
- cy.get('.title-text').should('contain', 'DocType');
+ cy.get('.page-head').findByTitle('DocType').should('exist');
cy.get('.list-count').should('contain', '20 of ');
cy.visit('/app/recorder');
- cy.get('.title-text').should('contain', 'Recorder');
- cy.get('.result-list').should('contain', '/api/method/frappe.desk.reportview.get');
-
- cy.get('#page-recorder .primary-action').should('contain', 'Stop').click();
- cy.wait(500);
- cy.get('#page-recorder .btn-secondary').should('contain', 'Clear').click();
- cy.get('.msg-box').should('contain', 'Inactive');
+ cy.get('.page-head').findByTitle('Recorder').should('exist');
+ cy.get('.frappe-list .result-list').should('contain', '/api/method/frappe.desk.reportview.get');
});
it('Recorder View Request', () => {
- cy.visit('/app/recorder');
- cy.get('.primary-action').should('contain', 'Start').click();
+ cy.get('.page-actions').findByRole('button', {name: 'Start'}).click();
cy.visit('/app/List/DocType/List');
cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh');
cy.wait('@list_refresh');
- cy.get('.title-text').should('contain', 'DocType');
+ cy.get('.page-head').findByTitle('DocType').should('exist');
cy.get('.list-count').should('contain', '20 of ');
cy.visit('/app/recorder');
- cy.get('.list-row-container span').contains('/api/method/frappe').click();
+ cy.get('.frappe-list .list-row-container span')
+ .contains('/api/method/frappe')
+ .should('be.visible')
+ .click({force: true});
cy.url().should('include', '/recorder/request');
cy.get('form').should('contain', '/api/method/frappe');
-
- cy.get('#page-recorder .primary-action').should('contain', 'Stop').click();
- cy.wait(200);
- cy.get('#page-recorder .btn-secondary').should('contain', 'Clear').click();
});
-});
\ No newline at end of file
+});
diff --git a/cypress/integration/relative_time_filters.js b/cypress/integration/relative_time_filters.js
index 80e6387d99..362d3a219b 100644
--- a/cypress/integration/relative_time_filters.js
+++ b/cypress/integration/relative_time_filters.js
@@ -1,47 +1,47 @@
-context('Relative Timeframe', () => {
- beforeEach(() => {
- cy.login();
- });
- before(() => {
- cy.login();
- cy.visit('/app/website');
- cy.window().its('frappe').then(frappe => {
- frappe.call("frappe.tests.ui_test_helpers.create_todo_records");
- });
- });
- it('sets relative timespan filter for last week and filters list', () => {
- cy.visit('/app/List/ToDo/List');
- cy.clear_filters();
- cy.get('.list-row:contains("this is fourth todo")').should('exist');
- cy.add_filter();
- cy.get('.fieldname-select-area').should('exist');
- cy.get('.fieldname-select-area input').type("Due Date{enter}", { delay: 100 });
- cy.get('select.condition.form-control').select("Timespan");
- cy.get('.filter-field select.input-with-feedback.form-control').select("last week");
- cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh');
- cy.get('.filter-popover .apply-filters').click({ force: true });
- cy.wait('@list_refresh');
- cy.get('.list-row-container').its('length').should('eq', 1);
- cy.get('.list-row-container').should('contain', 'this is second todo');
- cy.intercept('POST', '/api/method/frappe.model.utils.user_settings.save')
- .as('save_user_settings');
- cy.clear_filters();
- cy.wait('@save_user_settings');
- });
- it('sets relative timespan filter for next week and filters list', () => {
- cy.visit('/app/List/ToDo/List');
- cy.clear_filters();
- cy.get('.list-row:contains("this is fourth todo")').should('exist');
- cy.add_filter();
- cy.get('.fieldname-select-area input').type("Due Date{enter}", { delay: 100 });
- cy.get('select.condition.form-control').select("Timespan");
- cy.get('.filter-field select.input-with-feedback.form-control').select("next week");
- cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh');
- cy.get('.filter-popover .apply-filters').click({ force: true });
- cy.wait('@list_refresh');
- cy.intercept('POST', '/api/method/frappe.model.utils.user_settings.save')
- .as('save_user_settings');
- cy.clear_filters();
- cy.wait('@save_user_settings');
- });
-});
+// TODO: Enable this again
+// currently this is flaky possibly because of different timezone in CI
+
+// context('Relative Timeframe', () => {
+// before(() => {
+// cy.login();
+// cy.visit('/app/website');
+// cy.window().its('frappe').then(frappe => {
+// frappe.call("frappe.tests.ui_test_helpers.create_todo_records");
+// });
+// });
+// it('sets relative timespan filter for last week and filters list', () => {
+// cy.visit('/app/List/ToDo/List');
+// cy.clear_filters();
+// cy.get('.list-row:contains("this is fourth todo")').should('exist');
+// cy.add_filter();
+// cy.get('.fieldname-select-area').should('exist');
+// cy.get('.fieldname-select-area input').type("Due Date{enter}", { delay: 100 });
+// cy.get('select.condition.form-control').select("Timespan");
+// cy.get('.filter-field select.input-with-feedback.form-control').select("last week");
+// cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh');
+// cy.get('.filter-popover .apply-filters').click({ force: true });
+// cy.wait('@list_refresh');
+// cy.get('.list-row-container').its('length').should('eq', 1);
+// cy.get('.list-row-container').should('contain', 'this is second todo');
+// cy.intercept('POST', '/api/method/frappe.model.utils.user_settings.save')
+// .as('save_user_settings');
+// cy.clear_filters();
+// cy.wait('@save_user_settings');
+// });
+// it('sets relative timespan filter for next week and filters list', () => {
+// cy.visit('/app/List/ToDo/List');
+// cy.clear_filters();
+// cy.get('.list-row:contains("this is fourth todo")').should('exist');
+// cy.add_filter();
+// cy.get('.fieldname-select-area input').type("Due Date{enter}", { delay: 100 });
+// cy.get('select.condition.form-control').select("Timespan");
+// cy.get('.filter-field select.input-with-feedback.form-control').select("next week");
+// cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh');
+// cy.get('.filter-popover .apply-filters').click({ force: true });
+// cy.wait('@list_refresh');
+// cy.intercept('POST', '/api/method/frappe.model.utils.user_settings.save')
+// .as('save_user_settings');
+// cy.clear_filters();
+// cy.wait('@save_user_settings');
+// });
+// });
diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js
index ea76246ae2..bacbf9c172 100644
--- a/cypress/integration/report_view.js
+++ b/cypress/integration/report_view.js
@@ -11,31 +11,33 @@ context('Report View', () => {
'title': 'Doc 1',
'description': 'Random Text',
'enabled': 0,
- // submit document
- 'docstatus': 1
- }, true).as('doc');
+ 'docstatus': 1 // submit document
+ }, true);
});
+
it('Field with enabled allow_on_submit should be editable.', () => {
cy.intercept('POST', 'api/method/frappe.client.set_value').as('value-update');
cy.visit(`/app/List/${doctype_name}/Report`);
+
// check status column added from docstatus
cy.get('.dt-row-0 > .dt-cell--col-3').should('contain', 'Submitted');
let cell = cy.get('.dt-row-0 > .dt-cell--col-4');
+
// select the cell
cell.dblclick();
- cell.find('input[data-fieldname="enabled"]').check({ force: true });
- cy.get('.dt-row-0 > .dt-cell--col-5').click();
+ cell.get('.dt-cell__edit--col-4').findByRole('checkbox').check({ force: true });
+ cy.get('.dt-row-0 > .dt-cell--col-3').click(); // click outside
+
cy.wait('@value-update');
- cy.get('@doc').then(doc => {
- cy.call('frappe.client.get_value', {
- doctype: doc.doctype,
- filters: {
- name: doc.name,
- },
- fieldname: 'enabled'
- }).then(r => {
- expect(r.message.enabled).to.equals(1);
- });
+
+ cy.call('frappe.client.get_value', {
+ doctype: doctype_name,
+ filters: {
+ title: 'Doc 1',
+ },
+ fieldname: 'enabled'
+ }).then(r => {
+ expect(r.message.enabled).to.equals(1);
});
});
-});
\ No newline at end of file
+});
diff --git a/cypress/integration/sidebar.js b/cypress/integration/sidebar.js
new file mode 100644
index 0000000000..2831c9bad5
--- /dev/null
+++ b/cypress/integration/sidebar.js
@@ -0,0 +1,55 @@
+context('Sidebar', () => {
+ before(() => {
+ cy.visit('/login');
+ cy.login();
+ cy.visit('/app/doctype');
+ });
+
+ it('Test for checking "Assigned To" counter value, adding filter and adding & removing an assignment', () => {
+ cy.click_sidebar_button("Assigned To");
+
+ //To check if no filter is available in "Assigned To" dropdown
+ cy.get('.empty-state').should('contain', 'No filters found');
+
+ cy.click_sidebar_button("Created By");
+
+ //To check if "Created By" dropdown contains filter
+ cy.get('.group-by-item > .dropdown-item').should('contain', 'Me');
+
+ //Assigning a doctype to a user
+ cy.visit('/app/doctype/ToDo');
+ cy.get('.form-assignments > .flex > .text-muted').click();
+ cy.get_field('assign_to_me', 'Check').click();
+ cy.get('.modal-footer > .standard-actions > .btn-primary').click();
+ cy.visit('/app/doctype');
+ cy.click_sidebar_button("Assigned To");
+
+ //To check if filter is added in "Assigned To" dropdown after assignment
+ cy.get('.group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item').should('contain', '1');
+
+ //To check if there is no filter added to the listview
+ cy.get('.filter-selector > .btn').should('contain', 'Filter');
+
+ //To add a filter to display data into the listview
+ cy.get('.group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item').click();
+
+ //To check if filter is applied
+ cy.click_filter_button().should('contain', '1 filter');
+ cy.get('.fieldname-select-area > .awesomplete > .form-control').should('have.value', 'Assigned To');
+ cy.get('.condition').should('have.value', 'like');
+ cy.get('.filter-field > .form-group > .input-with-feedback').should('have.value', '%Administrator%');
+ cy.click_filter_button();
+
+ //To remove the applied filter
+ cy.clear_filters();
+
+ //To remove the assignment
+ cy.visit('/app/doctype/ToDo');
+ cy.get('.assignments > .avatar-group > .avatar > .avatar-frame').click();
+ cy.get('.remove-btn').click({force: true});
+ cy.hide_dialog();
+ cy.visit('/app/doctype');
+ cy.click_sidebar_button("Assigned To");
+ cy.get('.empty-state').should('contain', 'No filters found');
+ });
+});
diff --git a/cypress/integration/table_multiselect.js b/cypress/integration/table_multiselect.js
index 8b83a0d914..f873461efb 100644
--- a/cypress/integration/table_multiselect.js
+++ b/cypress/integration/table_multiselect.js
@@ -1,5 +1,5 @@
context('Table MultiSelect', () => {
- beforeEach(() => {
+ before(() => {
cy.login();
});
@@ -8,7 +8,8 @@ context('Table MultiSelect', () => {
it('select value from multiselect dropdown', () => {
cy.new_form('Assignment Rule');
cy.fill_field('__newname', name);
- cy.fill_field('document_type', 'ToDo');
+ cy.fill_field('document_type', 'Blog Post');
+ 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');
cy.get('input[data-fieldname="users"] + ul').should('be.visible');
diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js
new file mode 100644
index 0000000000..6c4733400d
--- /dev/null
+++ b/cypress/integration/timeline.js
@@ -0,0 +1,89 @@
+import custom_submittable_doctype from '../fixtures/custom_submittable_doctype';
+
+context('Timeline', () => {
+ before(() => {
+ cy.visit('/login');
+ cy.login();
+ });
+
+ it('Adding new ToDo, adding new comment, verifying comment addition & deletion and deleting ToDo', () => {
+ //Adding new ToDo
+ cy.visit('/app/todo/new-todo-1');
+ cy.get('[data-fieldname="description"] .ql-editor.ql-blank').type('Test ToDo', {force: true}).wait(200);
+ cy.get('.page-head .page-actions').findByRole('button', {name: 'Save'}).click();
+
+ cy.visit('/app/todo');
+ cy.click_listview_row_item(0);
+
+ //To check if the comment box is initially empty and tying some text into it
+ cy.get('[data-fieldname="comment"] .ql-editor').should('contain', '').type('Testing Timeline');
+
+ //Adding new comment
+ cy.get('.comment-box').findByRole('button', {name: 'Comment'}).click();
+
+ //To check if the commented text is visible in the timeline content
+ cy.get('.timeline-content').should('contain', 'Testing Timeline');
+
+ //Editing comment
+ cy.click_timeline_action_btn("Edit");
+ cy.get('.timeline-content [data-fieldname="comment"] .ql-editor').first().type(' 123');
+ cy.click_timeline_action_btn("Save");
+
+ //To check if the edited comment text is visible in timeline content
+ cy.get('.timeline-content').should('contain', 'Testing Timeline 123');
+
+ //Discarding comment
+ cy.click_timeline_action_btn("Edit");
+ cy.click_timeline_action_btn("Dismiss");
+
+ //To check if after discarding the timeline content is same as previous
+ cy.get('.timeline-content').should('contain', 'Testing Timeline 123');
+
+ //Deleting the added comment
+ cy.get('.timeline-message-box .more-actions > .action-btn').click(); //Menu button in timeline item
+ cy.get('.timeline-message-box .more-actions .dropdown-item').contains('Delete').click({ force: true });
+ cy.get_open_dialog().findByRole('button', {name: 'Yes'}).click({ force: true });
+
+ cy.get('.timeline-content').should('not.contain', 'Testing Timeline 123');
+ });
+
+ it('Timeline should have submit and cancel activity information', () => {
+ cy.visit('/app/doctype');
+
+ //Creating custom doctype
+ cy.insert_doc('DocType', custom_submittable_doctype, true);
+
+ cy.visit('/app/custom-submittable-doctype');
+ cy.click_listview_primary_button('Add Custom Submittable DocType');
+
+ //Adding a new entry for the created custom doctype
+ cy.fill_field('title', 'Test');
+ cy.click_modal_primary_button('Save');
+ cy.click_modal_primary_button('Submit');
+
+ cy.visit('/app/custom-submittable-doctype');
+ cy.click_listview_row_item(0);
+
+ //To check if the submission of the documemt is visible in the timeline content
+ cy.get('.timeline-content').should('contain', 'Administrator submitted this document');
+ cy.get('[id="page-Custom Submittable DocType"] .page-actions').findByRole('button', {name: 'Cancel'}).click();
+ cy.get_open_dialog().findByRole('button', {name: 'Yes'}).click();
+
+ //To check if the cancellation of the documemt is visible in the timeline content
+ cy.get('.timeline-content').should('contain', 'Administrator cancelled this document');
+
+ //Deleting the document
+ cy.visit('/app/custom-submittable-doctype');
+ cy.select_listview_row_checkbox(0);
+ cy.get('.page-actions').findByRole('button', {name: 'Actions'}).click();
+ cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click();
+ cy.click_modal_primary_button('Yes');
+
+ //Deleting the custom doctype
+ cy.visit('/app/doctype');
+ cy.select_listview_row_checkbox(0);
+ cy.get('.page-actions').findByRole('button', {name: 'Actions'}).click();
+ cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click();
+ cy.click_modal_primary_button('Yes');
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/timeline_email.js b/cypress/integration/timeline_email.js
new file mode 100644
index 0000000000..5808bd52ef
--- /dev/null
+++ b/cypress/integration/timeline_email.js
@@ -0,0 +1,76 @@
+context('Timeline Email', () => {
+ before(() => {
+ cy.visit('/login');
+ cy.login();
+ cy.visit('/app/todo');
+ });
+
+ it('Adding new ToDo', () => {
+ cy.click_listview_primary_button('Add ToDo');
+ cy.get('.custom-actions:visible > .btn').contains("Edit in full page").click({delay: 500});
+ cy.fill_field("description", "Test ToDo", "Text Editor");
+ cy.wait(500);
+ cy.get('.primary-action').contains('Save').click({force: true});
+ cy.wait(700);
+ });
+
+ it('Adding email and verifying timeline content for email attachment', () => {
+ cy.visit('/app/todo');
+ cy.get('.list-row > .level-left > .list-subject').eq(0).click();
+
+ //Creating a new email
+ cy.get('.timeline-actions > .timeline-item > .action-buttons > .action-btn').click();
+ cy.fill_field('recipients', 'test@example.com', 'MultiSelect');
+ cy.get('.modal.show > .modal-dialog > .modal-content > .modal-body > :nth-child(1) > .form-layout > .form-page > :nth-child(3) > .section-body > .form-column > form > [data-fieldtype="Text Editor"] > .form-group > .control-input-wrapper > .control-input > .ql-container > .ql-editor').type('Test Mail');
+
+ //Adding attachment to the email
+ cy.get('.add-more-attachments > .btn').click();
+ cy.get('.mt-2 > .btn > .mt-1').eq(2).click();
+ cy.get('.input-group > .form-control').type('https://wallpaperplay.com/walls/full/8/2/b/72402.jpg');
+ cy.get('.btn-primary').contains('Upload').click();
+
+ //Sending the email
+ cy.click_modal_primary_button('Send', {delay: 500});
+
+ //To check if the sent mail content is shown in the timeline content
+ cy.get('[data-doctype="Communication"] > .timeline-content').should('contain', 'Test Mail');
+
+ //To check if the attachment of email is shown in the timeline content
+ cy.get('.timeline-content').should('contain', 'Added 72402.jpg');
+
+ //Deleting the sent email
+ cy.get('[title="Open Communication"] > .icon').first().click({force: true});
+ cy.get('#page-Communication > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .btn').click();
+ cy.get('#page-Communication > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .dropdown-menu > li > .grey-link').eq(9).click();
+ cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').click();
+ });
+
+ it('Deleting attachment and ToDo', () => {
+ cy.visit('/app/todo');
+ cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click();
+
+ //Removing the added attachment
+ cy.get('.attachment-row > .data-pill > .remove-btn > .icon').click();
+ cy.wait(500);
+ cy.get('.modal-footer:visible > .standard-actions > .btn-primary').contains('Yes').click();
+
+ //To check if the removed attachment is shown in the timeline content
+ cy.get('.timeline-content').should('contain', 'Removed 72402.jpg');
+ cy.wait(500);
+
+ //To check if the discard button functionality in email is working correctly
+ cy.get('.timeline-actions > .timeline-item > .action-buttons > .action-btn').click();
+ cy.fill_field('recipients', 'test@example.com', 'MultiSelect');
+ cy.get('.modal-footer > .standard-actions > .btn-secondary').contains('Discard').click();
+ cy.wait(500);
+ cy.get('.timeline-actions > .timeline-item > .action-buttons > .action-btn').click();
+ cy.wait(500);
+ cy.get_field('recipients', 'MultiSelect').should('have.text', '');
+ cy.get('.modal-header:visible > .modal-actions > .btn-modal-close > .icon').click();
+
+ //Deleting the added ToDo
+ cy.get('.menu-btn-group:visible > .btn').click();
+ cy.get('.menu-btn-group:visible > .dropdown-menu > li > .dropdown-item').contains('Delete').click();
+ cy.get('.modal-footer:visible > .standard-actions > .btn-primary').click();
+ });
+});
diff --git a/cypress/integration/url_data_field.js b/cypress/integration/url_data_field.js
new file mode 100644
index 0000000000..cf22c62363
--- /dev/null
+++ b/cypress/integration/url_data_field.js
@@ -0,0 +1,43 @@
+import data_field_validation_doctype from '../fixtures/data_field_validation_doctype';
+
+const doctype_name = data_field_validation_doctype.name;
+
+context('URL Data Field Input', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/website');
+ return cy.insert_doc('DocType', data_field_validation_doctype, true);
+ });
+
+
+ describe('URL Data Field Input ', () => {
+ it('should not show URL link button without focus', () => {
+ cy.new_form(doctype_name);
+ cy.get_field('url').clear().type('https://frappe.io');
+ cy.get_field('url').blur().wait(500);
+ cy.get('.link-btn').should('not.be.visible');
+ });
+
+ it('should show URL link button on focus', () => {
+ cy.get_field('url').focus().wait(500);
+ cy.get('.link-btn').should('be.visible');
+ });
+
+ it('should not show URL link button for invalid URL', () => {
+ cy.get_field('url').clear().type('fuzzbuzz');
+ cy.get('.link-btn').should('not.be.visible');
+ });
+
+ it('should have valid URL link with target _blank', () => {
+ cy.get_field('url').clear().type('https://frappe.io');
+ cy.get('.link-btn .btn-open').should('have.attr', 'href', 'https://frappe.io');
+ cy.get('.link-btn .btn-open').should('have.attr', 'target', '_blank');
+ });
+
+ it('should inject anchor tag in read-only URL data field', () => {
+ cy.get('[data-fieldname="read_only_url"]')
+ .find('a')
+ .should('have.attr', 'target', '_blank');
+ });
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/web_form.js b/cypress/integration/web_form.js
new file mode 100644
index 0000000000..8346c96313
--- /dev/null
+++ b/cypress/integration/web_form.js
@@ -0,0 +1,29 @@
+context('Web Form', () => {
+ before(() => {
+ cy.login();
+ });
+
+ it('Navigate and Submit a WebForm', () => {
+ cy.visit('/update-profile');
+ cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200);
+ cy.get('.web-form-actions .btn-primary').click();
+ cy.wait(500);
+ cy.get('.modal.show > .modal-dialog').should('be.visible');
+ });
+
+ it('Navigate and Submit a MultiStep WebForm', () => {
+ cy.call('frappe.tests.ui_test_helpers.update_webform_to_multistep').then(() => {
+ cy.visit('/update-profile-duplicate');
+ cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200);
+ cy.get('.btn-next').should('be.visible');
+ cy.get('.web-form-footer .btn-primary').should('not.be.visible');
+ cy.get('.btn-next').click();
+ cy.get('.btn-previous').should('be.visible');
+ cy.get('.btn-next').should('not.be.visible');
+ cy.get('.web-form-footer .btn-primary').should('be.visible');
+ cy.get('.web-form-actions .btn-primary').click();
+ cy.wait(500);
+ cy.get('.modal.show > .modal-dialog').should('be.visible');
+ });
+ });
+});
diff --git a/cypress/integration/workspace.js b/cypress/integration/workspace.js
new file mode 100644
index 0000000000..fbff451305
--- /dev/null
+++ b/cypress/integration/workspace.js
@@ -0,0 +1,88 @@
+context('Workspace 2.0', () => {
+ before(() => {
+ cy.visit('/login');
+ cy.login();
+ cy.visit('/app/website');
+ });
+
+ it('Navigate to page from sidebar', () => {
+ cy.visit('/app/build');
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.sidebar-item-container[item-name="Settings"]').first().click();
+ cy.location('pathname').should('eq', '/app/settings');
+ });
+
+ it('Create Private Page', () => {
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.custom-actions button[data-label="Create%20Workspace"]').click();
+ cy.fill_field('title', 'Test Private Page', 'Data');
+ cy.fill_field('icon', 'edit', 'Icon');
+ cy.get_open_dialog().find('.modal-header').click();
+ cy.get_open_dialog().find('.btn-primary').click();
+
+ // check if sidebar item is added in pubic section
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0');
+
+ cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
+ cy.wait(300);
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0');
+
+ cy.wait(500);
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.standard-actions .btn-secondary[data-label=Edit]').click();
+ });
+
+ it('Add New Block', () => {
+ cy.get('.ce-block').click().type('{enter}');
+ cy.get('.block-list-container .block-list-item').contains('Heading').click();
+ cy.get(":focus").type('Header');
+ cy.get(".ce-block:last").find('.ce-header').should('exist');
+
+ cy.get('.ce-block:last').click().type('{enter}');
+ cy.get('.block-list-container .block-list-item').contains('Text').click();
+ cy.get(":focus").type('Paragraph text');
+ cy.get(".ce-block:last").find('.ce-paragraph').should('exist');
+ });
+
+ it('Delete A Block', () => {
+ cy.get(":focus").click();
+ cy.get('.paragraph-control .setting-btn').click();
+ cy.get('.paragraph-control .dropdown-item').contains('Delete').click();
+ cy.get(".ce-block:last").find('.ce-paragraph').should('not.exist');
+ });
+
+ it('Shrink and Expand A Block', () => {
+ cy.get(":focus").click();
+ cy.get('.ce-block:last .setting-btn').click();
+ cy.get('.ce-block:last .dropdown-item').contains('Shrink').click();
+ cy.get(".ce-block:last").should('have.class', 'col-xs-11');
+ cy.get('.ce-block:last .dropdown-item').contains('Shrink').click();
+ cy.get(".ce-block:last").should('have.class', 'col-xs-10');
+ cy.get('.ce-block:last .dropdown-item').contains('Shrink').click();
+ cy.get(".ce-block:last").should('have.class', 'col-xs-9');
+ cy.get('.ce-block:last .dropdown-item').contains('Expand').click();
+ cy.get(".ce-block:last").should('have.class', 'col-xs-10');
+ cy.get('.ce-block:last .dropdown-item').contains('Expand').click();
+ cy.get(".ce-block:last").should('have.class', 'col-xs-11');
+ cy.get('.ce-block:last .dropdown-item').contains('Expand').click();
+ cy.get(".ce-block:last").should('have.class', 'col-xs-12');
+
+ cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
+ });
+
+ it('Delete Private Page', () => {
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.standard-actions .btn-secondary[data-label=Edit]').click();
+
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]')
+ .find('.sidebar-item-control .setting-btn').click();
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]')
+ .find('.dropdown-item[title="Delete Workspace"]').click({force: true});
+ cy.wait(300);
+ cy.get('.modal-footer > .standard-actions > .btn-modal-primary:visible').first().click();
+ cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('not.exist');
+ });
+
+});
\ No newline at end of file
diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js
index 07d9804a73..9720faa666 100644
--- a/cypress/plugins/index.js
+++ b/cypress/plugins/index.js
@@ -11,7 +11,7 @@
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
-module.exports = () => {
- // `on` is used to hook into various events Cypress emits
- // `config` is the resolved Cypress config
-};
+module.exports = (on, config) => {
+ require('@cypress/code-coverage/task')(on, config);
+ return config;
+};
\ No newline at end of file
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index 1964b96d70..4f273af21f 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -1,4 +1,5 @@
import 'cypress-file-upload';
+import '@testing-library/cypress/add-commands';
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
@@ -29,7 +30,7 @@ Cypress.Commands.add('login', (email, password) => {
email = 'Administrator';
}
if (!password) {
- password = Cypress.config('adminPassword');
+ password = Cypress.env('adminPassword');
}
cy.request({
url: '/api/method/login',
@@ -109,34 +110,6 @@ Cypress.Commands.add('get_doc', (doctype, name) => {
});
});
-Cypress.Commands.add('insert_doc', (doctype, args, ignore_duplicate) => {
- return cy
- .window()
- .its('frappe.csrf_token')
- .then(csrf_token => {
- return cy
- .request({
- method: 'POST',
- url: `/api/resource/${doctype}`,
- body: args,
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-Frappe-CSRF-Token': csrf_token
- },
- failOnStatusCode: !ignore_duplicate
- })
- .then(res => {
- let status_codes = [200];
- if (ignore_duplicate) {
- status_codes.push(409);
- }
- expect(res.status).to.be.oneOf(status_codes);
- return res.body;
- });
- });
-});
-
Cypress.Commands.add('remove_doc', (doctype, name) => {
return cy
.window()
@@ -160,7 +133,7 @@ Cypress.Commands.add('remove_doc', (doctype, name) => {
Cypress.Commands.add('create_records', doc => {
return cy
- .call('frappe.tests.ui_test_helpers.create_if_not_exists', {doc})
+ .call('frappe.tests.ui_test_helpers.create_if_not_exists', {doc: JSON.stringify(doc)})
.then(r => r.message);
});
@@ -186,22 +159,23 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype = 'Data') => {
if (fieldtype === 'Select') {
cy.get('@input').select(value);
} else {
- cy.get('@input').type(value, {waitForAnimations: false, force: true});
+ cy.get('@input').type(value, {waitForAnimations: false, force: true, delay: 100});
}
return cy.get('@input');
});
Cypress.Commands.add('get_field', (fieldname, fieldtype = 'Data') => {
- let selector = `.form-control[data-fieldname="${fieldname}"]`;
+ let field_element = fieldtype === 'Select' ? 'select': 'input';
+ let selector = `[data-fieldname="${fieldname}"] ${field_element}:visible`;
if (fieldtype === 'Text Editor') {
- selector = `[data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]`;
+ selector = `[data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]:visible`;
}
if (fieldtype === 'Code') {
selector = `[data-fieldname="${fieldname}"] .ace_text-input`;
}
- return cy.get(selector);
+ return cy.get(selector).first();
});
Cypress.Commands.add('fill_table_field', (tablefieldname, row_idx, fieldname, value, fieldtype = 'Data') => {
@@ -240,7 +214,7 @@ Cypress.Commands.add('get_table_field', (tablefieldname, row_idx, fieldname, fie
});
Cypress.Commands.add('awesomebar', text => {
- cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, {delay: 100});
+ cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, {delay: 700});
});
Cypress.Commands.add('new_form', doctype => {
@@ -251,7 +225,8 @@ Cypress.Commands.add('new_form', doctype => {
});
Cypress.Commands.add('go_to_list', doctype => {
- cy.visit(`/app/list/${doctype}/list`);
+ let dt_in_route = doctype.toLowerCase().replace(/ /g, '-');
+ cy.visit(`/app/${dt_in_route}`);
});
Cypress.Commands.add('clear_cache', () => {
@@ -315,7 +290,11 @@ Cypress.Commands.add('add_filter', () => {
});
Cypress.Commands.add('clear_filters', () => {
- cy.get('.filter-section .filter-button').click();
+ cy.intercept({
+ method: 'POST',
+ url: 'api/method/frappe.model.utils.user_settings.save'
+ }).as('filter-saved');
+ cy.get('.filter-section .filter-button').click({force: true});
cy.wait(300);
cy.get('.filter-popover').should('exist');
cy.get('.filter-popover').find('.clear-filters').click();
@@ -323,4 +302,33 @@ Cypress.Commands.add('clear_filters', () => {
cy.window().its('cur_list').then(cur_list => {
cur_list && cur_list.filter_area && cur_list.filter_area.clear();
});
+ cy.wait('@filter-saved');
+});
+
+Cypress.Commands.add('click_modal_primary_button', (btn_name) => {
+ cy.get('.modal-footer > .standard-actions > .btn-primary').contains(btn_name).trigger('click', {force: true});
+});
+
+Cypress.Commands.add('click_sidebar_button', (btn_name) => {
+ cy.get('.list-group-by-fields .list-link > a').contains(btn_name).click({force: true});
+});
+
+Cypress.Commands.add('click_listview_row_item', (row_no) => {
+ cy.get('.list-row > .level-left > .list-subject > .level-item > .ellipsis').eq(row_no).click({force: true});
+});
+
+Cypress.Commands.add('click_filter_button', () => {
+ cy.get('.filter-selector > .btn').click();
+});
+
+Cypress.Commands.add('click_listview_primary_button', (btn_name) => {
+ cy.get('.primary-action').contains(btn_name).click({force: true});
+});
+
+Cypress.Commands.add('click_timeline_action_btn', (btn_name) => {
+ cy.get('.timeline-message-box .actions .action-btn').contains(btn_name).click();
+});
+
+Cypress.Commands.add('select_listview_row_checkbox', (row_no) => {
+ cy.get('.frappe-list .select-like > .list-row-checkbox').eq(row_no).click();
});
diff --git a/cypress/support/index.js b/cypress/support/index.js
index 1bee72d2ca..9cd770a31e 100644
--- a/cypress/support/index.js
+++ b/cypress/support/index.js
@@ -15,6 +15,7 @@
// Import commands.js using ES2015 syntax:
import './commands';
+import '@cypress/code-coverage/support';
// Alternatively you can use CommonJS syntax:
diff --git a/dev-requirements.txt b/dev-requirements.txt
new file mode 100644
index 0000000000..f4045c6bed
--- /dev/null
+++ b/dev-requirements.txt
@@ -0,0 +1,4 @@
+coverage==5.5
+Faker~=8.1.0
+pyngrok~=5.0.5
+unittest-xml-reporting~=3.0.4
diff --git a/esbuild/build-cleanup.js b/esbuild/build-cleanup.js
new file mode 100644
index 0000000000..cf03606a34
--- /dev/null
+++ b/esbuild/build-cleanup.js
@@ -0,0 +1,38 @@
+/* eslint-disable no-console */
+const path = require("path");
+const fs = require("fs");
+const glob = require("fast-glob");
+
+module.exports = {
+ name: 'build_cleanup',
+ setup(build) {
+ build.onEnd(result => {
+ if (result.errors.length) return;
+ clean_dist_files(Object.keys(result.metafile.outputs));
+ });
+ },
+};
+
+function clean_dist_files(new_files) {
+ new_files.forEach(
+ file => {
+ if (file.endsWith(".map")) return;
+
+ const pattern = file.split(".").slice(0, -2).join(".") + "*";
+ glob.sync(pattern).forEach(
+ file_to_delete => {
+ if (file_to_delete.startsWith(file)) return;
+
+ fs.unlink(path.resolve(file_to_delete), err => {
+ if (!err) return;
+
+ console.error(
+ `Error deleting ${file.split(path.sep).pop()}`
+ );
+ });
+ }
+
+ );
+ }
+ );
+}
\ No newline at end of file
diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js
new file mode 100644
index 0000000000..ff31aa4b74
--- /dev/null
+++ b/esbuild/esbuild.js
@@ -0,0 +1,528 @@
+/* eslint-disable no-console */
+const path = require("path");
+const fs = require("fs");
+const glob = require("fast-glob");
+const esbuild = require("esbuild");
+const vue = require("esbuild-vue");
+const yargs = require("yargs");
+const cliui = require("cliui")();
+const chalk = require("chalk");
+const html_plugin = require("./frappe-html");
+const rtlcss = require('rtlcss');
+const postCssPlugin = require("@frappe/esbuild-plugin-postcss2").default;
+const ignore_assets = require("./ignore-assets");
+const sass_options = require("./sass_options");
+const build_cleanup_plugin = require("./build-cleanup");
+
+const {
+ app_list,
+ assets_path,
+ apps_path,
+ sites_path,
+ get_app_path,
+ get_public_path,
+ log,
+ log_warn,
+ log_error,
+ bench_path,
+ get_redis_subscriber
+} = require("./utils");
+
+const argv = yargs
+ .usage("Usage: node esbuild [options]")
+ .option("apps", {
+ type: "string",
+ description: "Run build for specific apps"
+ })
+ .option("skip_frappe", {
+ type: "boolean",
+ description: "Skip building frappe assets"
+ })
+ .option("files", {
+ type: "string",
+ description: "Run build for specified bundles"
+ })
+ .option("watch", {
+ type: "boolean",
+ description: "Run in watch mode and rebuild on file changes"
+ })
+ .option("live-reload", {
+ type: "boolean",
+ description: `Automatically reload Desk when assets are rebuilt.
+ Can only be used with the --watch flag.`
+ })
+ .option("production", {
+ type: "boolean",
+ description: "Run build in production mode"
+ })
+ .option("run-build-command", {
+ type: "boolean",
+ description: "Run build command for apps"
+ })
+ .example(
+ "node esbuild --apps frappe,erpnext",
+ "Run build only for frappe and erpnext"
+ )
+ .example(
+ "node esbuild --files frappe/website.bundle.js,frappe/desk.bundle.js",
+ "Run build only for specified bundles"
+ )
+ .version(false).argv;
+
+const APPS = (!argv.apps ? app_list : argv.apps.split(",")).filter(
+ app => !(argv.skip_frappe && app == "frappe")
+);
+const FILES_TO_BUILD = argv.files ? argv.files.split(",") : [];
+const WATCH_MODE = Boolean(argv.watch);
+const PRODUCTION = Boolean(argv.production);
+const RUN_BUILD_COMMAND = !WATCH_MODE && Boolean(argv["run-build-command"]);
+
+const TOTAL_BUILD_TIME = `${chalk.black.bgGreen(" DONE ")} Total Build Time`;
+const NODE_PATHS = [].concat(
+ // node_modules of apps directly importable
+ app_list
+ .map(app => path.resolve(get_app_path(app), "../node_modules"))
+ .filter(fs.existsSync),
+ // import js file of any app if you provide the full path
+ app_list
+ .map(app => path.resolve(get_app_path(app), ".."))
+ .filter(fs.existsSync)
+);
+
+execute()
+ .then(() => RUN_BUILD_COMMAND && run_build_command_for_apps(APPS))
+ .catch(e => console.error(e));
+
+if (WATCH_MODE) {
+ // listen for open files in editor event
+ open_in_editor();
+}
+
+async function execute() {
+ console.time(TOTAL_BUILD_TIME);
+
+ let results;
+ try {
+ results = await build_assets_for_apps(APPS, FILES_TO_BUILD);
+ } catch (e) {
+ log_error("There were some problems during build");
+ log();
+ log(chalk.dim(e.stack));
+ if (process.env.CI) {
+ process.kill(process.pid);
+ }
+ return;
+ }
+
+ if (!WATCH_MODE) {
+ log_built_assets(results);
+ console.timeEnd(TOTAL_BUILD_TIME);
+ log();
+ } else {
+ log("Watching for changes...");
+ }
+ for (const result of results) {
+ await write_assets_json(result.metafile);
+ }
+}
+
+function build_assets_for_apps(apps, files) {
+ let { include_patterns, ignore_patterns } = files.length
+ ? get_files_to_build(files)
+ : get_all_files_to_build(apps);
+
+ return glob(include_patterns, { ignore: ignore_patterns }).then(files => {
+ let output_path = assets_path;
+
+ let file_map = {};
+ let style_file_map = {};
+ let rtl_style_file_map = {};
+ for (let file of files) {
+ let relative_app_path = path.relative(apps_path, file);
+ let app = relative_app_path.split(path.sep)[0];
+
+ let extension = path.extname(file);
+ let output_name = path.basename(file, extension);
+ if (
+ [".css", ".scss", ".less", ".sass", ".styl"].includes(extension)
+ ) {
+ output_name = path.join("css", output_name);
+ } else if ([".js", ".ts"].includes(extension)) {
+ output_name = path.join("js", output_name);
+ }
+ output_name = path.join(app, "dist", output_name);
+
+ if (Object.keys(file_map).includes(output_name) || Object.keys(style_file_map).includes(output_name)) {
+ log_warn(
+ `Duplicate output file ${output_name} generated from ${file}`
+ );
+ }
+ if ([".css", ".scss", ".less", ".sass", ".styl"].includes(extension)) {
+ style_file_map[output_name] = file;
+ rtl_style_file_map[output_name.replace('/css/', '/css-rtl/')] = file;
+ } else {
+ file_map[output_name] = file;
+ }
+ }
+ let build = build_files({
+ files: file_map,
+ outdir: output_path
+ });
+ let style_build = build_style_files({
+ files: style_file_map,
+ outdir: output_path
+ });
+ let rtl_style_build = build_style_files({
+ files: rtl_style_file_map,
+ outdir: output_path,
+ rtl_style: true
+ });
+ return Promise.all([build, style_build, rtl_style_build]);
+ });
+}
+
+function get_all_files_to_build(apps) {
+ let include_patterns = [];
+ let ignore_patterns = [];
+
+ for (let app of apps) {
+ let public_path = get_public_path(app);
+ include_patterns.push(
+ path.resolve(
+ public_path,
+ "**",
+ "*.bundle.{js,ts,css,sass,scss,less,styl}"
+ )
+ );
+ ignore_patterns.push(
+ path.resolve(public_path, "node_modules"),
+ path.resolve(public_path, "dist")
+ );
+ }
+
+ return {
+ include_patterns,
+ ignore_patterns
+ };
+}
+
+function get_files_to_build(files) {
+ // files: ['frappe/website.bundle.js', 'erpnext/main.bundle.js']
+ let include_patterns = [];
+ let ignore_patterns = [];
+
+ for (let file of files) {
+ let [app, bundle] = file.split("/");
+ let public_path = get_public_path(app);
+ include_patterns.push(path.resolve(public_path, "**", bundle));
+ ignore_patterns.push(
+ path.resolve(public_path, "node_modules"),
+ path.resolve(public_path, "dist")
+ );
+ }
+
+ return {
+ include_patterns,
+ ignore_patterns
+ };
+}
+
+function build_files({ files, outdir }) {
+ let build_plugins = [
+ html_plugin,
+ build_cleanup_plugin,
+ vue(),
+ ];
+ return esbuild.build(get_build_options(files, outdir, build_plugins));
+}
+
+function build_style_files({ files, outdir, rtl_style = false }) {
+ let plugins = [];
+ if (rtl_style) {
+ plugins.push(rtlcss);
+ }
+
+ let build_plugins = [
+ ignore_assets,
+ build_cleanup_plugin,
+ postCssPlugin({
+ plugins: plugins,
+ sassOptions: sass_options
+ })
+ ];
+
+ plugins.push(require("autoprefixer"));
+ return esbuild.build(get_build_options(files, outdir, build_plugins));
+}
+
+function get_build_options(files, outdir, plugins) {
+ return {
+ entryPoints: files,
+ entryNames: "[dir]/[name].[hash]",
+ outdir,
+ sourcemap: true,
+ bundle: true,
+ metafile: true,
+ minify: PRODUCTION,
+ nodePaths: NODE_PATHS,
+ define: {
+ "process.env.NODE_ENV": JSON.stringify(
+ PRODUCTION ? "production" : "development"
+ )
+ },
+ plugins: plugins,
+ watch: get_watch_config()
+ };
+}
+
+function get_watch_config() {
+ if (WATCH_MODE) {
+ return {
+ async onRebuild(error, result) {
+ if (error) {
+ log_error("There was an error during rebuilding changes.");
+ log();
+ log(chalk.dim(error.stack));
+ notify_redis({ error });
+ } else {
+ let {
+ new_assets_json,
+ prev_assets_json
+ } = await write_assets_json(result.metafile);
+
+ let changed_files;
+ if (prev_assets_json) {
+ changed_files = get_rebuilt_assets(
+ prev_assets_json,
+ new_assets_json
+ );
+
+ let timestamp = new Date().toLocaleTimeString();
+ let message = `${timestamp}: Compiled ${changed_files.length} files...`;
+ log(chalk.yellow(message));
+ for (let filepath of changed_files) {
+ let filename = path.basename(filepath);
+ log(" " + filename);
+ }
+ log();
+ }
+ notify_redis({ success: true, changed_files });
+ }
+ }
+ };
+ }
+ return null;
+}
+
+function log_built_assets(results) {
+ let outputs = {};
+ for (const result of results) {
+ outputs = Object.assign(outputs, result.metafile.outputs);
+ }
+ let column_widths = [60, 20];
+ cliui.div(
+ {
+ text: chalk.cyan.bold("File"),
+ width: column_widths[0]
+ },
+ {
+ text: chalk.cyan.bold("Size"),
+ width: column_widths[1]
+ }
+ );
+ cliui.div("");
+
+ let output_by_dist_path = {};
+ for (let outfile in outputs) {
+ if (outfile.endsWith(".map")) continue;
+ let data = outputs[outfile];
+ outfile = path.resolve(outfile);
+ outfile = path.relative(assets_path, outfile);
+ let filename = path.basename(outfile);
+ let dist_path = outfile.replace(filename, "");
+ output_by_dist_path[dist_path] = output_by_dist_path[dist_path] || [];
+ output_by_dist_path[dist_path].push({
+ name: filename,
+ size: (data.bytes / 1000).toFixed(2) + " Kb"
+ });
+ }
+
+ for (let dist_path in output_by_dist_path) {
+ let files = output_by_dist_path[dist_path];
+ cliui.div({
+ text: dist_path,
+ width: column_widths[0]
+ });
+
+ for (let i in files) {
+ let file = files[i];
+ let branch = "";
+ if (i < files.length - 1) {
+ branch = "├─ ";
+ } else {
+ branch = "└─ ";
+ }
+ let color = file.name.endsWith(".js") ? "green" : "blue";
+ cliui.div(
+ {
+ text: branch + chalk[color]("" + file.name),
+ width: column_widths[0]
+ },
+ {
+ text: file.size,
+ width: column_widths[1]
+ }
+ );
+ }
+ cliui.div("");
+ }
+ log(cliui.toString());
+}
+
+// to store previous build's assets.json for comparison
+let prev_assets_json;
+let curr_assets_json;
+
+async function write_assets_json(metafile) {
+ let rtl = false;
+ prev_assets_json = curr_assets_json;
+ let out = {};
+ for (let output in metafile.outputs) {
+ let info = metafile.outputs[output];
+ let asset_path = "/" + path.relative(sites_path, output);
+ if (info.entryPoint) {
+ let key = path.basename(info.entryPoint);
+ if (key.endsWith('.css') && asset_path.includes('/css-rtl/')) {
+ rtl = true;
+ key = `rtl_${key}`;
+ }
+ out[key] = asset_path;
+ }
+ }
+
+ let assets_json_path = path.resolve(assets_path, `assets${rtl?'-rtl':''}.json`);
+ let assets_json;
+ try {
+ assets_json = await fs.promises.readFile(assets_json_path, "utf-8");
+ } catch (error) {
+ assets_json = "{}";
+ }
+ assets_json = JSON.parse(assets_json);
+ // update with new values
+ let new_assets_json = Object.assign({}, assets_json, out);
+ curr_assets_json = new_assets_json;
+
+ await fs.promises.writeFile(
+ assets_json_path,
+ JSON.stringify(new_assets_json, null, 4)
+ );
+ await update_assets_json_in_cache();
+ return {
+ new_assets_json,
+ prev_assets_json
+ };
+}
+
+function update_assets_json_in_cache() {
+ // update assets_json cache in redis, so that it can be read directly by python
+ return new Promise(resolve => {
+ let client = get_redis_subscriber("redis_cache");
+ // handle error event to avoid printing stack traces
+ client.on("error", _ => {
+ log_warn("Cannot connect to redis_cache to update assets_json");
+ });
+ client.del("assets_json", err => {
+ client.unref();
+ resolve();
+ });
+ });
+}
+
+function run_build_command_for_apps(apps) {
+ let cwd = process.cwd();
+ let { execSync } = require("child_process");
+
+ for (let app of apps) {
+ if (app === "frappe") continue;
+
+ let root_app_path = path.resolve(get_app_path(app), "..");
+ let package_json = path.resolve(root_app_path, "package.json");
+ if (fs.existsSync(package_json)) {
+ let { scripts } = require(package_json);
+ if (scripts && scripts.build) {
+ log("\nRunning build command for", chalk.bold(app));
+ process.chdir(root_app_path);
+ execSync("yarn build", { encoding: "utf8", stdio: "inherit" });
+ }
+ }
+ }
+
+ process.chdir(cwd);
+}
+
+async function notify_redis({ error, success, changed_files }) {
+ // notify redis which in turns tells socketio to publish this to browser
+ let subscriber = get_redis_subscriber("redis_socketio");
+ subscriber.on("error", _ => {
+ log_warn("Cannot connect to redis_socketio for browser events");
+ });
+
+ let payload = null;
+ if (error) {
+ let formatted = await esbuild.formatMessages(error.errors, {
+ kind: "error",
+ terminalWidth: 100
+ });
+ let stack = error.stack.replace(new RegExp(bench_path, "g"), "");
+ payload = {
+ error,
+ formatted,
+ stack
+ };
+ }
+ if (success) {
+ payload = {
+ success: true,
+ changed_files,
+ live_reload: argv["live-reload"]
+ };
+ }
+
+ subscriber.publish(
+ "events",
+ JSON.stringify({
+ event: "build_event",
+ message: payload
+ })
+ );
+}
+
+function open_in_editor() {
+ let subscriber = get_redis_subscriber("redis_socketio");
+ subscriber.on("error", _ => {
+ log_warn("Cannot connect to redis_socketio for open_in_editor events");
+ });
+ subscriber.on("message", (event, file) => {
+ if (event === "open_in_editor") {
+ file = JSON.parse(file);
+ let file_path = path.resolve(file.file);
+ log("Opening file in editor:", file_path);
+ let launch = require("launch-editor");
+ launch(`${file_path}:${file.line}:${file.column}`);
+ }
+ });
+ subscriber.subscribe("open_in_editor");
+}
+
+function get_rebuilt_assets(prev_assets, new_assets) {
+ let added_files = [];
+ let old_files = Object.values(prev_assets);
+ let new_files = Object.values(new_assets);
+
+ for (let filepath of new_files) {
+ if (!old_files.includes(filepath)) {
+ added_files.push(filepath);
+ }
+ }
+ return added_files;
+}
diff --git a/esbuild/frappe-html.js b/esbuild/frappe-html.js
new file mode 100644
index 0000000000..9a7edb144d
--- /dev/null
+++ b/esbuild/frappe-html.js
@@ -0,0 +1,44 @@
+module.exports = {
+ name: "frappe-html",
+ setup(build) {
+ let path = require("path");
+ let fs = require("fs/promises");
+
+ build.onResolve({ filter: /\.html$/ }, args => {
+ return {
+ path: path.join(args.resolveDir, args.path),
+ namespace: "frappe-html"
+ };
+ });
+
+ build.onLoad({ filter: /.*/, namespace: "frappe-html" }, args => {
+ let filepath = args.path;
+ let filename = path.basename(filepath).split(".")[0];
+
+ return fs
+ .readFile(filepath, "utf-8")
+ .then(content => {
+ content = scrub_html_template(content);
+ return {
+ contents: `\n\tfrappe.templates['${filename}'] = \`${content}\`;\n`,
+ watchFiles: [filepath]
+ };
+ })
+ .catch(() => {
+ return {
+ contents: "",
+ warnings: [
+ {
+ text: `There was an error importing ${filepath}`
+ }
+ ]
+ };
+ });
+ });
+ }
+};
+
+function scrub_html_template(content) {
+ content = content.replace(/`/g, "\\`");
+ return content;
+}
diff --git a/esbuild/ignore-assets.js b/esbuild/ignore-assets.js
new file mode 100644
index 0000000000..5edfef2110
--- /dev/null
+++ b/esbuild/ignore-assets.js
@@ -0,0 +1,11 @@
+module.exports = {
+ name: "frappe-ignore-asset",
+ setup(build) {
+ build.onResolve({ filter: /^\/assets\// }, args => {
+ return {
+ path: args.path,
+ external: true
+ };
+ });
+ }
+};
diff --git a/esbuild/index.js b/esbuild/index.js
new file mode 100644
index 0000000000..2721673702
--- /dev/null
+++ b/esbuild/index.js
@@ -0,0 +1 @@
+require("./esbuild");
diff --git a/esbuild/sass_options.js b/esbuild/sass_options.js
new file mode 100644
index 0000000000..fcc7e04ccd
--- /dev/null
+++ b/esbuild/sass_options.js
@@ -0,0 +1,29 @@
+let path = require("path");
+let { get_app_path, app_list } = require("./utils");
+
+let node_modules_path = path.resolve(
+ get_app_path("frappe"),
+ "..",
+ "node_modules"
+);
+let app_paths = app_list
+ .map(get_app_path)
+ .map(app_path => path.resolve(app_path, ".."));
+
+module.exports = {
+ includePaths: [node_modules_path, ...app_paths],
+ importer: function(url) {
+ if (url.startsWith("~")) {
+ // strip ~ so that it can resolve from node_modules
+ url = url.slice(1);
+ }
+ if (url.endsWith(".css")) {
+ // strip .css from end of path
+ url = url.slice(0, -4);
+ }
+ // normal file, let it go
+ return {
+ file: url
+ };
+ }
+};
diff --git a/esbuild/utils.js b/esbuild/utils.js
new file mode 100644
index 0000000000..82490adb36
--- /dev/null
+++ b/esbuild/utils.js
@@ -0,0 +1,145 @@
+const path = require("path");
+const fs = require("fs");
+const chalk = require("chalk");
+
+const frappe_path = path.resolve(__dirname, "..");
+const bench_path = path.resolve(frappe_path, "..", "..");
+const sites_path = path.resolve(bench_path, "sites");
+const apps_path = path.resolve(bench_path, "apps");
+const assets_path = path.resolve(sites_path, "assets");
+const app_list = get_apps_list();
+
+const app_paths = app_list.reduce((out, app) => {
+ out[app] = path.resolve(apps_path, app, app);
+ return out;
+}, {});
+const public_paths = app_list.reduce((out, app) => {
+ out[app] = path.resolve(app_paths[app], "public");
+ return out;
+}, {});
+const public_js_paths = app_list.reduce((out, app) => {
+ out[app] = path.resolve(app_paths[app], "public/js");
+ return out;
+}, {});
+
+const bundle_map = app_list.reduce((out, app) => {
+ const public_js_path = public_js_paths[app];
+ if (fs.existsSync(public_js_path)) {
+ const all_files = fs.readdirSync(public_js_path);
+ const js_files = all_files.filter(file => file.endsWith(".js"));
+
+ for (let js_file of js_files) {
+ const filename = path.basename(js_file).split(".")[0];
+ out[path.join(app, "js", filename)] = path.resolve(
+ public_js_path,
+ js_file
+ );
+ }
+ }
+
+ return out;
+}, {});
+
+const get_public_path = app => public_paths[app];
+
+const get_build_json_path = app =>
+ path.resolve(get_public_path(app), "build.json");
+
+function get_build_json(app) {
+ try {
+ return require(get_build_json_path(app));
+ } catch (e) {
+ // build.json does not exist
+ return null;
+ }
+}
+
+function delete_file(path) {
+ if (fs.existsSync(path)) {
+ fs.unlinkSync(path);
+ }
+}
+
+function run_serially(tasks) {
+ let result = Promise.resolve();
+ tasks.forEach(task => {
+ if (task) {
+ result = result.then ? result.then(task) : Promise.resolve();
+ }
+ });
+ return result;
+}
+
+const get_app_path = app => app_paths[app];
+
+function get_apps_list() {
+ return fs
+ .readFileSync(path.resolve(sites_path, "apps.txt"), {
+ encoding: "utf-8"
+ })
+ .split("\n")
+ .filter(Boolean);
+}
+
+function get_cli_arg(name) {
+ let args = process.argv.slice(2);
+ let arg = `--${name}`;
+ let index = args.indexOf(arg);
+
+ let value = null;
+ if (index != -1) {
+ value = true;
+ }
+ if (value && args[index + 1]) {
+ value = args[index + 1];
+ }
+ return value;
+}
+
+function log_error(message, badge = "ERROR") {
+ badge = chalk.white.bgRed(` ${badge} `);
+ console.error(`${badge} ${message}`); // eslint-disable-line no-console
+}
+
+function log_warn(message, badge = "WARN") {
+ badge = chalk.black.bgYellowBright(` ${badge} `);
+ console.warn(`${badge} ${message}`); // eslint-disable-line no-console
+}
+
+function log(...args) {
+ console.log(...args); // eslint-disable-line no-console
+}
+
+function get_redis_subscriber(kind) {
+ // get redis subscriber that aborts after 10 connection attempts
+ let { get_redis_subscriber: get_redis } = require("../node_utils");
+ return get_redis(kind, {
+ retry_strategy: function(options) {
+ // abort after 10 connection attempts
+ if (options.attempt > 10) {
+ return undefined;
+ }
+ return Math.min(options.attempt * 100, 2000);
+ }
+ });
+}
+
+module.exports = {
+ app_list,
+ bench_path,
+ assets_path,
+ sites_path,
+ apps_path,
+ bundle_map,
+ get_public_path,
+ get_build_json_path,
+ get_build_json,
+ get_app_path,
+ delete_file,
+ run_serially,
+ get_cli_arg,
+ log,
+ log_warn,
+ log_error,
+ get_redis_subscriber
+};
diff --git a/frappe/__init__.py b/frappe/__init__.py
index 9b3ffc4662..86f8be35ea 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -1,5 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
"""
Frappe - Low Code Open Source Framework in Python and JS
@@ -10,26 +10,35 @@ be used to build database driven apps.
Read the documentation: https://frappeframework.com/docs
"""
-from __future__ import unicode_literals, print_function
+import os, warnings
+
+STANDARD_USERS = ('Guest', 'Administrator')
+
+_dev_server = os.environ.get('DEV_SERVER', False)
+
+if _dev_server:
+ warnings.simplefilter('always', DeprecationWarning)
+ warnings.simplefilter('always', PendingDeprecationWarning)
-from six import iteritems, binary_type, text_type, string_types, PY2
from werkzeug.local import Local, release_local
-import os, sys, importlib, inspect, json
-from past.builtins import cmp
+import sys, importlib, inspect, json
+import typing
import click
-from faker import Faker
-# public
+# Local application imports
from .exceptions import *
from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader)
+from .utils.lazy_loader import lazy_import
-# Harmless for Python 3
-# For Python 2 set default encoding to utf-8
-if PY2:
- reload(sys)
- sys.setdefaultencoding("utf-8")
+from frappe.query_builder import (
+ get_query_builder,
+ patch_query_execute,
+ patch_query_aggregation,
+)
+from frappe.utils.data import cstr
+
+__version__ = '14.0.0-dev'
-__version__ = '13.0.0-dev'
__title__ = "Frappe Framework"
local = Local()
@@ -39,7 +48,8 @@ class _dict(dict):
"""dict like object that exposes keys as attributes"""
def __getattr__(self, key):
ret = self.get(key)
- if not ret and key.startswith("__"):
+ # "__deepcopy__" exception added to fix frappe#14833 via DFP
+ if not ret and key.startswith("__") and key != "__deepcopy__":
raise AttributeError()
return ret
def __setattr__(self, key, value):
@@ -91,14 +101,14 @@ def _(msg, lang=None, context=None):
def as_unicode(text, encoding='utf-8'):
'''Convert to unicode if required'''
- if isinstance(text, text_type):
+ if isinstance(text, str):
return text
- elif text==None:
+ elif text is None:
return ''
- elif isinstance(text, binary_type):
- return text_type(text, encoding)
+ elif isinstance(text, bytes):
+ return str(text, encoding)
else:
- return text_type(text)
+ return str(text)
def get_lang_dict(fortype, name=None):
"""Returns the translated language dict for the given type and name.
@@ -114,7 +124,9 @@ def set_user_lang(user, user_language=None):
local.lang = get_user_lang(user)
# local-globals
+
db = local("db")
+qb = local("qb")
conf = local("conf")
form = form_dict = local("form_dict")
request = local("request")
@@ -129,6 +141,21 @@ message_log = local("message_log")
lang = local("lang")
+# This if block is never executed when running the code. It is only used for
+# telling static code analyzer where to find dynamically defined attributes.
+if typing.TYPE_CHECKING:
+ from frappe.utils.redis_wrapper import RedisWrapper
+
+ from frappe.database.mariadb.database import MariaDBDatabase
+ from frappe.database.postgres.database import PostgresDatabase
+ from frappe.query_builder.builder import MariaDB, Postgres
+
+ db: typing.Union[MariaDBDatabase, PostgresDatabase]
+ qb: typing.Union[MariaDB, Postgres]
+
+
+# end: static analysis hack
+
def init(site, sites_path=None, new_site=False):
"""Initialize frappe for the current site. Reset thread locals `frappe.local`"""
if getattr(local, "initialised", None):
@@ -188,36 +215,44 @@ def init(site, sites_path=None, new_site=False):
local.cache = {}
local.document_cache = {}
local.meta_cache = {}
+ local.autoincremented_status_map = {site: -1}
local.form_dict = _dict()
local.session = _dict()
- local.dev_server = os.environ.get('DEV_SERVER', False)
+ local.dev_server = _dev_server
+ local.qb = get_query_builder(local.conf.db_type or "mariadb")
setup_module_map()
+ patch_query_execute()
+ patch_query_aggregation()
local.initialised = True
-def connect(site=None, db_name=None):
+def connect(site=None, db_name=None, set_admin_as_user=True):
"""Connect to site database instance.
:param site: If site is given, calls `frappe.init`.
- :param db_name: Optional. Will use from `site_config.json`."""
+ :param db_name: Optional. Will use from `site_config.json`.
+ :param set_admin_as_user: Set Administrator as current user.
+ """
from frappe.database import get_db
if site:
init(site)
local.db = get_db(user=db_name or local.conf.db_name)
- set_user("Administrator")
+ if set_admin_as_user:
+ set_user("Administrator")
def connect_replica():
from frappe.database import get_db
user = local.conf.db_name
password = local.conf.db_password
+ port = local.conf.replica_db_port
if local.conf.different_credentials_for_replica:
user = local.conf.replica_db_name
password = local.conf.replica_db_password
- local.replica_db = get_db(host=local.conf.replica_host, user=user, password=password)
+ local.replica_db = get_db(host=local.conf.replica_host, user=user, password=password, port=port)
# swap db connections
local.primary_db = local.db
@@ -264,7 +299,7 @@ def get_conf(site=None):
class init_site:
def __init__(self, site=None):
- '''If site==None, initialize it for empty site ('') to load common_site_config.json'''
+ '''If site is None, initialize it for empty site ('') to load common_site_config.json'''
self.site = site or ''
def __enter__(self):
@@ -281,9 +316,8 @@ def destroy():
release_local(local)
-# memcache
redis_server = None
-def cache():
+def cache() -> "RedisWrapper":
"""Returns redis connection."""
global redis_server
if not redis_server:
@@ -326,7 +360,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False,
response JSON and shown in a pop-up / modal.
:param msg: Message.
- :param title: [optional] Message title.
+ :param title: [optional] Message title. Default: "Message".
:param raise_exception: [optional] Raise given exception and show message.
:param as_table: [optional] If `msg` is a list of lists, render as HTML table.
:param as_list: [optional] If `msg` is a list, render as un-ordered list.
@@ -363,8 +397,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False,
if flags.print_messages and out.message:
print(f"Message: {strip_html_tags(out.message)}")
- if title:
- out.title = title
+ out.title = title or _("Message", context="Default title of the message dialog")
if not indicator and raise_exception:
indicator = 'red'
@@ -416,7 +449,7 @@ def throw(msg, exc=ValidationError, title=None, is_minimizable=None, wide=None,
msgprint(msg, raise_exception=exc, title=title, indicator='red', is_minimizable=is_minimizable, wide=wide, as_list=as_list)
def emit_js(js, user=False, **kwargs):
- if user == False:
+ if user is False:
user = session.user
publish_realtime('eval_js', js, user=user, **kwargs)
@@ -466,11 +499,11 @@ def get_request_header(key, default=None):
:param default: Default value."""
return request.headers.get(key, default)
-def sendmail(recipients=[], sender="", subject="No Subject", message="No Message",
+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,
- attachments=None, content=None, doctype=None, name=None, reply_to=None,
- cc=[], bcc=[], message_id=None, in_reply_to=None, send_after=None, expose_recipients=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):
"""Send email using user's default **Email Account** or global default **Email Account**.
@@ -500,6 +533,14 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message
: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)
@@ -513,16 +554,20 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message
if not delayed:
now = True
- from frappe.email import queue
- queue.send(recipients=recipients, sender=sender,
+ 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,
+ 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,
- communication=communication, now=now, read_receipt=read_receipt, is_notification=is_notification,
+ 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)
+ # build email queue and send the email if send_now is True.
+ builder.process(send_now=now)
+
+
whitelisted = []
guest_methods = []
xss_safe_methods = []
@@ -548,8 +593,15 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
def innerfn(fn):
global whitelisted, guest_methods, xss_safe_methods, allowed_http_methods_for_whitelisted_func
- whitelisted.append(fn)
+ # 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 = fn
+ fn = method.__func__
+
+ whitelisted.append(fn)
allowed_http_methods_for_whitelisted_func[fn] = methods
if allow_guest:
@@ -558,10 +610,24 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
if xss_safe:
xss_safe_methods.append(fn)
- return fn
+ return method or fn
return innerfn
+def is_whitelisted(method):
+ from frappe.utils import sanitize_html
+
+ is_guest = session['user'] == 'Guest'
+ if method not in whitelisted or is_guest and method not in guest_methods:
+ throw(_("Not permitted"), PermissionError)
+
+ if is_guest and method not in xss_safe_methods:
+ # strictly sanitize form_dict
+ # escapes html characters like <> except for predefined tags like a, b, ul etc.
+ for key, value in form_dict.items():
+ if isinstance(value, str):
+ form_dict[key] = sanitize_html(value)
+
def read_only():
def innfn(fn):
def wrapper_fn(*args, **kwargs):
@@ -570,8 +636,6 @@ def read_only():
try:
retval = fn(*args, **get_newargs(fn, kwargs))
- except:
- raise
finally:
if local and hasattr(local, 'primary_db'):
local.db.close()
@@ -581,6 +645,29 @@ def read_only():
return wrapper_fn
return innfn
+def write_only():
+ # if replica connection exists, we have to replace it momentarily with the primary connection
+ def innfn(fn):
+ def wrapper_fn(*args, **kwargs):
+ primary_db = getattr(local, "primary_db", None)
+ replica_db = getattr(local, "replica_db", None)
+ in_read_only = getattr(local, "db", None) != primary_db
+
+ # switch to primary connection
+ if in_read_only and primary_db:
+ local.db = local.primary_db
+
+ try:
+ retval = fn(*args, **get_newargs(fn, kwargs))
+ finally:
+ # switch back to replica connection
+ if in_read_only and replica_db:
+ local.db = replica_db
+
+ return retval
+ return wrapper_fn
+ return innfn
+
def only_for(roles, message=False):
"""Raise `frappe.PermissionError` if the user does not have any of the given **Roles**.
@@ -651,23 +738,34 @@ def only_has_select_perm(doctype, user=None, ignore_permissions=False):
else:
return False
-def has_permission(doctype=None, ptype="read", doc=None, user=None, verbose=False, throw=False):
+def has_permission(doctype=None, ptype="read", doc=None, user=None, verbose=False, throw=False, parent_doctype=None):
"""Raises `frappe.PermissionError` if not permitted.
:param doctype: DocType for which permission is to be check.
:param ptype: Permission type (`read`, `write`, `create`, `submit`, `cancel`, `amend`). Default: `read`.
:param doc: [optional] Checks User permissions for given doc.
- :param user: [optional] Check for given user. Default: current user."""
+ :param user: [optional] Check for given user. Default: current user.
+ :param parent_doctype: Required when checking permission for a child DocType (unless doc is specified)."""
+ import frappe.permissions
+
if not doctype and doc:
doctype = doc.doctype
- import frappe.permissions
- out = frappe.permissions.has_permission(doctype, ptype, doc=doc, verbose=verbose, user=user, raise_exception=throw)
+ out = frappe.permissions.has_permission(doctype, ptype, doc=doc, verbose=verbose, user=user,
+ raise_exception=throw, parent_doctype=parent_doctype)
+
if throw and not out:
- if doc:
- frappe.throw(_("No permission for {0}").format(doc.doctype + " " + doc.name))
- else:
- frappe.throw(_("No permission for {0}").format(doctype))
+ # mimics frappe.throw
+ document_label = f"{doc.doctype} {doc.name}" if doc else doctype
+ msgprint(
+ _("No permission for {0}").format(document_label),
+ raise_exception=ValidationError,
+ title=None,
+ indicator='red',
+ is_minimizable=None,
+ wide=None,
+ as_list=False
+ )
return out
@@ -683,7 +781,7 @@ def has_website_permission(doc=None, ptype='read', user=None, verbose=False, doc
user = session.user
if doc:
- if isinstance(doc, string_types):
+ if isinstance(doc, str):
doc = get_doc(doctype, doc)
doctype = doc.doctype
@@ -712,7 +810,9 @@ def has_website_permission(doc=None, ptype='read', user=None, verbose=False, doc
def is_table(doctype):
"""Returns True if `istable` property (indicating child Table) is set for given DocType."""
def get_tables():
- return db.sql_list("select name from tabDocType where istable=1")
+ return db.get_values(
+ "DocType", filters={"istable": 1}, order_by=None, pluck=True
+ )
tables = cache().get_value("is_table", get_tables)
return doctype in tables
@@ -752,8 +852,7 @@ def set_value(doctype, docname, fieldname, value=None):
return frappe.client.set_value(doctype, docname, fieldname, value)
def get_cached_doc(*args, **kwargs):
- if args and len(args) > 1 and isinstance(args[1], text_type):
- key = get_document_cache_key(args[0], args[1])
+ if key := can_cache_doc(args):
# local cache
doc = local.document_cache.get(key)
if doc:
@@ -771,8 +870,24 @@ def get_cached_doc(*args, **kwargs):
return doc
+def can_cache_doc(args):
+ """
+ Determine if document should be cached based on get_doc params.
+ Returns cache key if doc can be cached, None otherwise.
+ """
+
+ if not args:
+ return
+
+ doctype = args[0]
+ name = doctype if len(args) == 1 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, name):
- return '{0}::{1}'.format(doctype, name)
+ return f'{doctype}::{name}'
def clear_document_cache(doctype, name):
cache().hdel("last_modified", doctype)
@@ -783,7 +898,7 @@ def clear_document_cache(doctype, name):
def get_cached_value(doctype, name, fieldname, as_dict=False):
doc = get_cached_doc(doctype, name)
- if isinstance(fieldname, string_types):
+ if isinstance(fieldname, str):
if as_dict:
throw('Cannot make dict for single fieldname')
return doc.get(fieldname)
@@ -813,8 +928,7 @@ def get_doc(*args, **kwargs):
doc = frappe.model.document.get_doc(*args, **kwargs)
# set in cache
- if args and len(args) > 1:
- key = get_document_cache_key(args[0], args[1])
+ if key := can_cache_doc(args):
local.document_cache[key] = doc
cache().hset('document_cache', key, doc.as_dict())
@@ -847,8 +961,8 @@ def get_meta_module(doctype):
import frappe.modules
return frappe.modules.load_doctype_module(doctype)
-def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None,
- for_reload=False, ignore_permissions=False, flags=None, ignore_on_trash=False, ignore_missing=True):
+def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reload=False,
+ ignore_permissions=False, flags=None, ignore_on_trash=False, ignore_missing=True, delete_permanently=False):
"""Delete a document. Calls `frappe.model.delete_doc.delete_doc`.
:param doctype: DocType of document to be delete.
@@ -856,10 +970,11 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None,
:param force: Allow even if document is linked. Warning: This may lead to data integrity errors.
:param ignore_doctypes: Ignore if child table is one of these.
:param for_reload: Call `before_reload` trigger before deleting.
- :param ignore_permissions: Ignore user permissions."""
+ :param ignore_permissions: Ignore user permissions.
+ :param delete_permanently: Do not create a Deleted Document for the document."""
import frappe.model.delete_doc
frappe.model.delete_doc.delete_doc(doctype, name, force, ignore_doctypes, for_reload,
- ignore_permissions, flags, ignore_on_trash, ignore_missing)
+ ignore_permissions, flags, ignore_on_trash, ignore_missing, delete_permanently)
def delete_doc_if_exists(doctype, name, force=0):
"""Delete document if exists."""
@@ -902,7 +1017,7 @@ def get_module(modulename):
def scrub(txt):
"""Returns sluggified string. e.g. `Sales Order` becomes `sales_order`."""
- return txt.replace(' ', '_').replace('-', '_').lower()
+ return cstr(txt).replace(' ', '_').replace('-', '_').lower()
def unscrub(txt):
"""Returns titlified string. e.g. `sales_order` becomes `Sales Order`."""
@@ -936,7 +1051,7 @@ def get_pymodule_path(modulename, *joins):
:param *joins: Join additional path elements using `os.path.join`."""
if not "public" in joins:
joins = [scrub(part) for part in joins]
- return os.path.join(os.path.dirname(get_module(scrub(modulename)).__file__), *joins)
+ return os.path.join(os.path.dirname(get_module(scrub(modulename)).__file__ or ''), *joins)
def get_module_list(app_name):
"""Get list of modules for given all via `app/modules.txt`."""
@@ -988,7 +1103,7 @@ def get_doc_hooks():
if not hasattr(local, 'doc_events_hooks'):
hooks = get_hooks('doc_events', {})
out = {}
- for key, value in iteritems(hooks):
+ for key, value in hooks.items():
if isinstance(key, tuple):
for doctype in key:
append_hook(out, doctype, value)
@@ -1070,9 +1185,7 @@ def setup_module_map():
if not (local.app_modules and local.module_app):
local.module_app, local.app_modules = {}, {}
- for app in get_all_apps(True):
- if app == "webnotes":
- app = "frappe"
+ for app in get_all_apps(with_internal_apps=True):
local.app_modules.setdefault(app, [])
for module in get_module_list(app):
module = scrub(module)
@@ -1105,7 +1218,7 @@ def get_file_json(path):
def read_file(path, raise_not_found=False):
"""Open a file and return its content as Unicode."""
- if isinstance(path, text_type):
+ if isinstance(path, str):
path = path.encode("utf-8")
if os.path.exists(path):
@@ -1119,7 +1232,7 @@ def read_file(path, raise_not_found=False):
def get_attr(method_string):
"""Get python method object from its name."""
app_name = method_string.split(".")[0]
- if not local.flags.in_install and app_name not in get_installed_apps():
+ if not local.flags.in_uninstall and not local.flags.in_install and app_name not in get_installed_apps():
throw(_("App {0} is not installed").format(app_name), AppNotInstalledError)
modulename = '.'.join(method_string.split('.')[:-1])
@@ -1128,7 +1241,7 @@ def get_attr(method_string):
def call(fn, *args, **kwargs):
"""Call a function and match arguments."""
- if isinstance(fn, string_types):
+ if isinstance(fn, str):
fn = get_attr(fn)
newargs = get_newargs(fn, kwargs)
@@ -1139,13 +1252,9 @@ def get_newargs(fn, kwargs):
if hasattr(fn, 'fnargs'):
fnargs = fn.fnargs
else:
- try:
- fnargs, varargs, varkw, defaults = inspect.getargspec(fn)
- except ValueError:
- fnargs = inspect.getfullargspec(fn).args
- varargs = inspect.getfullargspec(fn).varargs
- varkw = inspect.getfullargspec(fn).varkw
- defaults = inspect.getfullargspec(fn).defaults
+ fnargs = inspect.getfullargspec(fn).args
+ fnargs.extend(inspect.getfullargspec(fn).kwonlyargs)
+ varkw = inspect.getfullargspec(fn).varkw
newargs = {}
for a in kwargs:
@@ -1195,10 +1304,10 @@ def make_property_setter(args, ignore_validate=False, validate_fields_for_doctyp
ps.validate_fieldtype_change()
ps.insert()
-def import_doc(path, ignore_links=False, ignore_insert=False, insert=False):
+def import_doc(path):
"""Import a file using Data Import."""
from frappe.core.doctype.data_import.data_import import import_doc
- import_doc(path, ignore_links=ignore_links, ignore_insert=ignore_insert, insert=insert)
+ import_doc(path)
def copy_doc(doc, ignore_no_copy=True):
""" No_copy fields also get copied."""
@@ -1370,7 +1479,7 @@ def get_list(doctype, *args, **kwargs):
frappe.get_list("ToDo", fields="*", filters = {"description": ("like", "test%")})
"""
import frappe.model.db_query
- return frappe.model.db_query.DatabaseQuery(doctype).execute(None, *args, **kwargs)
+ return frappe.model.db_query.DatabaseQuery(doctype).execute(*args, **kwargs)
def get_all(doctype, *args, **kwargs):
"""List database query via `frappe.model.db_query`. Will **not** check for permissions.
@@ -1415,7 +1524,10 @@ def get_value(*args, **kwargs):
def as_json(obj, indent=1):
from frappe.utils.response import json_handler
- return json.dumps(obj, indent=indent, sort_keys=True, default=json_handler, separators=(',', ': '))
+ try:
+ return json.dumps(obj, indent=indent, sort_keys=True, default=json_handler, separators=(',', ': '))
+ except TypeError:
+ return json.dumps(obj, indent=indent, default=json_handler, separators=(',', ': '))
def are_emails_muted():
from frappe.utils import cint
@@ -1447,8 +1559,8 @@ def format(*args, **kwargs):
import frappe.utils.formatters
return frappe.utils.formatters.format_value(*args, **kwargs)
-def get_print(doctype=None, name=None, print_format=None, style=None,
- html=None, as_pdf=False, doc=None, output=None, no_letterhead=0, password=None):
+def get_print(doctype=None, name=None, print_format=None, style=None, html=None,
+ as_pdf=False, doc=None, output=None, no_letterhead=0, password=None, pdf_options=None):
"""Get Print Format for given document.
:param doctype: DocType of document.
@@ -1457,7 +1569,7 @@ def get_print(doctype=None, name=None, print_format=None, style=None,
:param style: Print Format style.
:param as_pdf: Return as PDF. Default False.
:param password: Password to encrypt the pdf with. Default None"""
- from frappe.website.render import build_page
+ from frappe.website.serve import get_response_content
from frappe.utils.pdf import get_pdf
local.form_dict.doctype = doctype
@@ -1467,15 +1579,15 @@ def get_print(doctype=None, name=None, print_format=None, style=None,
local.form_dict.doc = doc
local.form_dict.no_letterhead = no_letterhead
- options = None
+ pdf_options = pdf_options or {}
if password:
- options = {'password': password}
+ pdf_options['password'] = password
if not html:
- html = build_page("printview")
+ html = get_response_content("printview")
if as_pdf:
- return get_pdf(html, output = output, options = options)
+ return get_pdf(html, options=pdf_options, output=output)
else:
return html
@@ -1566,7 +1678,7 @@ def local_cache(namespace, key, generator, regenerate_if_none=False):
if key not in local.cache[namespace]:
local.cache[namespace][key] = generator()
- elif local.cache[namespace][key]==None and regenerate_if_none:
+ elif local.cache[namespace][key] is None and regenerate_if_none:
# if key exists but the previous result was None
local.cache[namespace][key] = generator()
@@ -1587,6 +1699,12 @@ def enqueue(*args, **kwargs):
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)
+ return f
+ return decorator_task
+
def enqueue_doc(*args, **kwargs):
'''
Enqueue method to be executed using a background worker
@@ -1643,7 +1761,7 @@ def get_desk_link(doctype, name):
)
def bold(text):
- return '{0} '.format(text)
+ return '{0} '.format(text)
def safe_eval(code, eval_globals=None, eval_locals=None):
'''A safer `eval`'''
@@ -1654,6 +1772,23 @@ def safe_eval(code, eval_globals=None, eval_locals=None):
"round": round
}
+ UNSAFE_ATTRIBUTES = {
+ # Generator Attributes
+ "gi_frame", "gi_code",
+ # Coroutine Attributes
+ "cr_frame", "cr_code", "cr_origin",
+ # Async Generator Attributes
+ "ag_code", "ag_frame",
+ # Traceback Attributes
+ "tb_frame", "tb_next",
+ # Format Attributes
+ "format", "format_map",
+ }
+
+ for attribute in UNSAFE_ATTRIBUTES:
+ if attribute in code:
+ throw('Illegal rule {0}. Cannot use "{1}"'.format(bold(code), attribute))
+
if '__' in code:
throw('Illegal rule {0}. Cannot use "__"'.format(bold(code)))
@@ -1699,7 +1834,7 @@ def get_version(doctype, name, limit=None, head=False, raise_err=True):
'limit': limit
}, as_list=1)
- from frappe.chat.util import squashify, dictify, safe_json_loads
+ from frappe.utils import squashify, dictify, safe_json_loads
versions = []
@@ -1747,16 +1882,17 @@ def parse_json(val):
return parse_json(val)
def mock(type, size=1, locale='en'):
+ import faker
results = []
- faker = Faker(locale)
- if not type in dir(faker):
+ fake = faker.Faker(locale)
+ if type not in dir(fake):
raise ValueError('Not a valid mock type.')
else:
for i in range(size):
- data = getattr(faker, type)()
+ data = getattr(fake, type)()
results.append(data)
- from frappe.chat.util import squashify
+ from frappe.utils import squashify
return squashify(results)
def validate_and_sanitize_search_inputs(fn):
diff --git a/frappe/api.py b/frappe/api.py
index 6a09b795b0..226853c47b 100644
--- a/frappe/api.py
+++ b/frappe/api.py
@@ -1,18 +1,17 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import base64
import binascii
import json
-
-from six.moves.urllib.parse import urlencode, urlparse
+from urllib.parse import urlencode, urlparse
import frappe
import frappe.client
import frappe.handler
from frappe import _
from frappe.utils.response import build_response
+from frappe.utils.data import sbool
+
def handle():
"""
@@ -38,9 +37,6 @@ def handle():
`/api/resource/{doctype}/{name}?run_method={method}` will run a whitelisted controller method
"""
-
- validate_auth()
-
parts = frappe.request.path[1:].split("/",3)
call = doctype = name = None
@@ -86,7 +82,7 @@ def handle():
if frappe.local.request.method=="PUT":
data = get_request_form_data()
- doc = frappe.get_doc(doctype, name)
+ doc = frappe.get_doc(doctype, name, for_update=True)
if "flags" in data:
del data["flags"]
@@ -98,7 +94,8 @@ def handle():
"data": doc.save().as_dict()
})
- if doc.parenttype and doc.parent:
+ # check for child table doctype
+ if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
frappe.db.commit()
@@ -112,25 +109,40 @@ def handle():
elif doctype:
if frappe.local.request.method == "GET":
- if frappe.local.form_dict.get('fields'):
- frappe.local.form_dict['fields'] = json.loads(frappe.local.form_dict['fields'])
- frappe.local.form_dict.setdefault('limit_page_length', 20)
- frappe.local.response.update({
- "data": frappe.call(
- frappe.client.get_list,
- doctype,
- **frappe.local.form_dict
- )
- })
+ # set fields for frappe.get_list
+ if frappe.local.form_dict.get("fields"):
+ frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"])
+
+ # set limit of records for frappe.get_list
+ frappe.local.form_dict.setdefault(
+ "limit_page_length",
+ frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20,
+ )
+
+ # convert strings to native types - only as_dict and debug accept bool
+ for param in ["as_dict", "debug"]:
+ param_val = frappe.local.form_dict.get(param)
+ if param_val is not None:
+ frappe.local.form_dict[param] = sbool(param_val)
+
+ # evaluate frappe.get_list
+ data = frappe.call(frappe.client.get_list, doctype, **frappe.local.form_dict)
+
+ # set frappe.get_list result to response
+ frappe.local.response.update({"data": data})
if frappe.local.request.method == "POST":
+ # fetch data from from dict
data = get_request_form_data()
- data.update({
- "doctype": doctype
- })
- frappe.local.response.update({
- "data": frappe.get_doc(data).insert().as_dict()
- })
+ data.update({"doctype": doctype})
+
+ # insert document from request data
+ doc = frappe.get_doc(data).insert()
+
+ # set response data
+ frappe.local.response.update({"data": doc.as_dict()})
+
+ # commit for POST requests
frappe.db.commit()
else:
raise frappe.DoesNotExistError
@@ -140,33 +152,30 @@ def handle():
return build_response("json")
+
def get_request_form_data():
if frappe.local.form_dict.data is None:
data = frappe.safe_decode(frappe.local.request.get_data())
else:
data = frappe.local.form_dict.data
- return frappe.parse_json(data)
+ try:
+ return frappe.parse_json(data)
+ except ValueError:
+ return frappe.local.form_dict
+
def validate_auth():
- if frappe.get_request_header("Authorization") is None:
- return
-
- VALID_AUTH_PREFIX_TYPES = ['basic', 'bearer', 'token']
- VALID_AUTH_PREFIX_STRING = ", ".join(VALID_AUTH_PREFIX_TYPES).title()
-
+ """
+ Authenticate and sets user for the request.
+ """
authorization_header = frappe.get_request_header("Authorization", str()).split(" ")
- authorization_type = authorization_header[0].lower()
- if len(authorization_header) == 1:
- frappe.throw(_('Invalid Authorization headers, add a token with a prefix from one of the following: {0}.').format(VALID_AUTH_PREFIX_STRING), frappe.InvalidAuthorizationHeader)
-
- if authorization_type == "bearer":
+ if len(authorization_header) == 2:
validate_oauth(authorization_header)
- elif authorization_type in VALID_AUTH_PREFIX_TYPES:
validate_auth_via_api_keys(authorization_header)
- else:
- frappe.throw(_('Invalid Authorization Type {0}, must be one of {1}.').format(authorization_type, VALID_AUTH_PREFIX_STRING), frappe.InvalidAuthorizationPrefix)
+
+ validate_auth_via_hooks()
def validate_oauth(authorization_header):
@@ -177,8 +186,8 @@ def validate_oauth(authorization_header):
authorization_header (list of str): The 'Authorization' header containing the prefix and token
"""
- from frappe.oauth import get_url_delimiter
from frappe.integrations.oauth2 import get_oauth_server
+ from frappe.oauth import get_url_delimiter
form_dict = frappe.local.form_dict
token = authorization_header[1]
@@ -187,19 +196,19 @@ def validate_oauth(authorization_header):
access_token = {"access_token": token}
uri = parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + "?" + urlencode(access_token)
http_method = req.method
- body = req.get_data()
headers = req.headers
+ body = req.get_data()
+ if req.content_type and "multipart/form-data" in req.content_type:
+ body = None
try:
required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split(get_url_delimiter())
+ valid, oauthlib_request = get_oauth_server().verify_request(uri, http_method, body, headers, required_scopes)
+ if valid:
+ frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user"))
+ frappe.local.form_dict = form_dict
except AttributeError:
- frappe.throw(_("Invalid Bearer token, please provide a valid access token with prefix 'Bearer'."), frappe.InvalidAuthorizationToken)
-
- valid, oauthlib_request = get_oauth_server().verify_request(uri, http_method, body, headers, required_scopes)
-
- if valid:
- frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user"))
- frappe.local.form_dict = form_dict
+ pass
def validate_auth_via_api_keys(authorization_header):
@@ -222,8 +231,7 @@ def validate_auth_via_api_keys(authorization_header):
except binascii.Error:
frappe.throw(_("Failed to decode token, please provide a valid base64-encoded token."), frappe.InvalidAuthorizationToken)
except (AttributeError, TypeError, ValueError):
- frappe.throw(_("Invalid token, please provide a valid token with prefix 'Basic' or 'Token'."), frappe.InvalidAuthorizationToken)
-
+ pass
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
@@ -248,3 +256,8 @@ def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=Non
if frappe.local.login_manager.user in ('', 'Guest'):
frappe.set_user(user)
frappe.local.form_dict = form_dict
+
+
+def validate_auth_via_hooks():
+ for auth_hook in frappe.get_hooks('auth_hooks', []):
+ frappe.get_attr(auth_hook)()
diff --git a/frappe/app.py b/frappe/app.py
index 784db3d976..975a2e2002 100644
--- a/frappe/app.py
+++ b/frappe/app.py
@@ -1,10 +1,8 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import os
-from six import iteritems
import logging
from werkzeug.local import LocalManager
@@ -18,9 +16,9 @@ import frappe.handler
import frappe.auth
import frappe.api
import frappe.utils.response
-import frappe.website.render
from frappe.utils import get_site_name, sanitize_html
from frappe.middlewares import StaticDataMiddleware
+from frappe.website.serve import get_response
from frappe.utils.error import make_error_snapshot
from frappe.core.doctype.comment.comment import update_comments_in_parent_after_request
from frappe import _
@@ -56,6 +54,7 @@ def application(request):
frappe.recorder.record()
frappe.monitor.start()
frappe.rate_limiter.apply()
+ frappe.api.validate_auth()
if request.method == "OPTIONS":
response = Response()
@@ -73,7 +72,7 @@ def application(request):
response = frappe.utils.response.download_private_file(request.path)
elif request.method in ('GET', 'HEAD', 'POST'):
- response = frappe.website.render.render()
+ response = get_response()
else:
raise NotFound
@@ -98,17 +97,7 @@ def application(request):
frappe.monitor.stop(response)
frappe.recorder.dump()
- if hasattr(frappe.local, 'conf') and frappe.local.conf.enable_frappe_logger:
- frappe.logger("frappe.web", allow_site=frappe.local.site).info({
- "site": get_site_name(request.host),
- "remote_addr": getattr(request, "remote_addr", "NOTFOUND"),
- "base_url": getattr(request, "base_url", "NOTFOUND"),
- "full_path": getattr(request, "full_path", "NOTFOUND"),
- "method": getattr(request, "method", "NOTFOUND"),
- "scheme": getattr(request, "scheme", "NOTFOUND"),
- "http_status_code": getattr(response, "status_code", "NOTFOUND")
- })
-
+ log_request(request, response)
process_response(response)
frappe.destroy()
@@ -128,12 +117,29 @@ def init_request(request):
if frappe.local.conf.get('maintenance_mode'):
frappe.connect()
raise frappe.SessionStopped('Session Stopped')
+ else:
+ frappe.connect(set_admin_as_user=False)
+
+ request.max_content_length = frappe.local.conf.get('max_file_size') or 10 * 1024 * 1024
make_form_dict(request)
if request.method != "OPTIONS":
frappe.local.http_request = frappe.auth.HTTPRequest()
+def log_request(request, response):
+ if hasattr(frappe.local, 'conf') and frappe.local.conf.enable_frappe_logger:
+ frappe.logger("frappe.web", allow_site=frappe.local.site).info({
+ "site": get_site_name(request.host),
+ "remote_addr": getattr(request, "remote_addr", "NOTFOUND"),
+ "base_url": getattr(request, "base_url", "NOTFOUND"),
+ "full_path": getattr(request, "full_path", "NOTFOUND"),
+ "method": getattr(request, "method", "NOTFOUND"),
+ "scheme": getattr(request, "scheme", "NOTFOUND"),
+ "http_status_code": getattr(response, "status_code", "NOTFOUND")
+ })
+
+
def process_response(response):
if not response:
return
@@ -179,16 +185,14 @@ def make_form_dict(request):
if 'application/json' in (request.content_type or '') and request_data:
args = json.loads(request_data)
else:
- args = request.form or request.args
+ args = {}
+ args.update(request.args or {})
+ args.update(request.form or {})
if not isinstance(args, dict):
- frappe.throw("Invalid request arguments")
+ frappe.throw(_("Invalid request arguments"))
- try:
- frappe.local.form_dict = frappe._dict({ k:v[0] if isinstance(v, (list, tuple)) else v \
- for k, v in iteritems(args) })
- except IndexError:
- frappe.local.form_dict = frappe._dict(args)
+ frappe.local.form_dict = frappe._dict(args)
if "_" in frappe.local.form_dict:
# _ is passed by $.ajax so that the request is not cached by the browser. So, remove _ from form_dict
@@ -198,12 +202,20 @@ def handle_exception(e):
response = None
http_status_code = getattr(e, "http_status_code", 500)
return_as_message = False
+ accept_header = frappe.get_request_header("Accept") or ""
+ respond_as_json = (
+ frappe.get_request_header('Accept')
+ and (frappe.local.is_ajax or 'application/json' in accept_header)
+ or (
+ frappe.local.request.path.startswith("/api/") and not accept_header.startswith("text")
+ )
+ )
if frappe.conf.get('developer_mode'):
# don't fail silently
print(frappe.get_traceback())
- if frappe.get_request_header('Accept') and (frappe.local.is_ajax or 'application/json' in frappe.get_request_header('Accept')):
+ if respond_as_json:
# handle ajax responses first
# if the request is ajax, send back the trace or error message
response = frappe.utils.response.report_error(http_status_code)
@@ -253,8 +265,7 @@ def handle_exception(e):
make_error_snapshot(e)
if return_as_message:
- response = frappe.website.render.render("message",
- http_status_code=http_status_code)
+ response = get_response("message", http_status_code=http_status_code)
return response
@@ -284,7 +295,7 @@ def serve(port=8000, profile=False, no_reload=False, no_threading=False, site=No
from werkzeug.serving import run_simple
- if profile:
+ if profile or os.environ.get('USE_PROFILER'):
application = ProfilerMiddleware(application, sort_by=('cumtime', 'calls'))
if not os.environ.get('NO_STATICS'):
diff --git a/frappe/auth.py b/frappe/auth.py
index 2e0ec681d2..d4778eb0c1 100644
--- a/frappe/auth.py
+++ b/frappe/auth.py
@@ -1,35 +1,58 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See LICENSE
+from urllib.parse import quote
-from __future__ import unicode_literals
-import datetime
-
-from frappe import _
import frappe
import frappe.database
import frappe.utils
-from frappe.utils import cint, flt, get_datetime, datetime, date_diff, today
import frappe.utils.user
-from frappe import conf
-from frappe.sessions import Session, clear_sessions, delete_session
-from frappe.modules.patch_handler import check_session_stopped
-from frappe.translate import get_lang_code
-from frappe.utils.password import check_password, delete_login_failed_cache
+from frappe import _, conf
from frappe.core.doctype.activity_log.activity_log import add_authentication_log
-from frappe.twofactor import (should_run_2fa, authenticate_for_2factor,
- confirm_otp_token, get_cached_user_pass)
+from frappe.modules.patch_handler import check_session_stopped
+from frappe.sessions import Session, clear_sessions, delete_session
+from frappe.translate import get_language
+from frappe.twofactor import authenticate_for_2factor, confirm_otp_token, get_cached_user_pass, should_run_2fa
+from frappe.utils import cint, date_diff, datetime, get_datetime, today
+from frappe.utils.password import check_password
from frappe.website.utils import get_home_page
-from six.moves.urllib.parse import quote
-
class HTTPRequest:
def __init__(self):
- # Get Environment variables
- self.domain = frappe.request.host
- if self.domain and self.domain.startswith('www.'):
- self.domain = self.domain[4:]
+ # set frappe.local.request_ip
+ self.set_request_ip()
+ # load cookies
+ self.set_cookies()
+
+ # set frappe.local.db
+ self.connect()
+
+ # login and start/resume user session
+ self.set_session()
+
+ # set request language
+ self.set_lang()
+
+ # match csrf token from current session
+ self.validate_csrf_token()
+
+ # write out latest cookies
+ frappe.local.cookie_manager.init_cookies()
+
+ # check session status
+ check_session_stopped()
+
+ @property
+ def domain(self):
+ if not getattr(self, "_domain", None):
+ self._domain = frappe.request.host
+ if self._domain and self._domain.startswith('www.'):
+ self._domain = self._domain[4:]
+
+ return self._domain
+
+ def set_request_ip(self):
if frappe.get_request_header('X-Forwarded-For'):
frappe.local.request_ip = (frappe.get_request_header('X-Forwarded-For').split(",")[0]).strip()
@@ -39,37 +62,21 @@ class HTTPRequest:
else:
frappe.local.request_ip = '127.0.0.1'
- # language
- self.set_lang()
-
- # load cookies
+ def set_cookies(self):
frappe.local.cookie_manager = CookieManager()
- # set db
- self.connect()
-
- # login
+ def set_session(self):
frappe.local.login_manager = LoginManager()
- if frappe.form_dict._lang:
- lang = get_lang_code(frappe.form_dict._lang)
- if lang:
- frappe.local.lang = lang
-
- self.validate_csrf_token()
-
- # write out latest cookies
- frappe.local.cookie_manager.init_cookies()
-
- # check status
- check_session_stopped()
-
def validate_csrf_token(self):
if frappe.local.request and frappe.local.request.method in ("POST", "PUT", "DELETE"):
- if not frappe.local.session: return
- if not frappe.local.session.data.csrf_token \
- or frappe.local.session.data.device=="mobile" \
- or frappe.conf.get('ignore_csrf', None):
+ if not frappe.local.session:
+ return
+ if (
+ not frappe.local.session.data.csrf_token
+ or frappe.local.session.data.device == "mobile"
+ or frappe.conf.get('ignore_csrf', None)
+ ):
# not via boot
return
@@ -83,17 +90,18 @@ class HTTPRequest:
frappe.throw(_("Invalid Request"), frappe.CSRFTokenError)
def set_lang(self):
- from frappe.translate import guess_language
- frappe.local.lang = guess_language()
+ frappe.local.lang = get_language()
def get_db_name(self):
"""get database name from conf"""
return conf.db_name
- def connect(self, ac_name = None):
+ def connect(self):
"""connect to db, from ac_name or db_name"""
- frappe.local.db = frappe.database.get_db(user = self.get_db_name(), \
- password = getattr(conf, 'db_password', ''))
+ frappe.local.db = frappe.database.get_db(
+ user=self.get_db_name(),
+ password=getattr(conf, 'db_password', '')
+ )
class LoginManager:
def __init__(self):
@@ -103,7 +111,8 @@ class LoginManager:
self.user_type = None
if frappe.local.form_dict.get('cmd')=='login' or frappe.local.request.path=="/api/method/login":
- if self.login()==False: return
+ if self.login() is False:
+ return
self.resume = False
# run login triggers
@@ -146,7 +155,7 @@ class LoginManager:
self.setup_boot_cache()
self.set_user_info()
- def get_user_info(self, resume=False):
+ def get_user_info(self):
self.info = frappe.db.get_value("User", self.user,
["user_type", "first_name", "last_name", "user_image"], as_dict=1)
@@ -184,11 +193,13 @@ class LoginManager:
frappe.local.response["redirect_to"] = redirect_to
frappe.cache().hdel('redirect_after_login', self.user)
-
frappe.local.cookie_manager.set_cookie("full_name", self.full_name)
frappe.local.cookie_manager.set_cookie("user_id", self.user)
frappe.local.cookie_manager.set_cookie("user_image", self.info.user_image or "")
+ def clear_preferred_language(self):
+ frappe.local.cookie_manager.delete_cookie("preferred_language")
+
def make_session(self, resume=False):
# start session
frappe.local.session_obj = Session(user=self.user, resume=resume,
@@ -207,30 +218,40 @@ class LoginManager:
if frappe.session.user != "Guest":
clear_sessions(frappe.session.user, keep_current=True)
- def authenticate(self, user=None, pwd=None):
+ def authenticate(self, user: str = None, pwd: str = None):
+ from frappe.core.doctype.user.user import User
+
if not (user and pwd):
user, pwd = frappe.form_dict.get('usr'), frappe.form_dict.get('pwd')
if not (user and pwd):
self.fail(_('Incomplete login details'), user=user)
- if cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number")):
- user = frappe.db.get_value("User", filters={"mobile_no": user}, fieldname="name") or user
+ user = User.find_by_credentials(user, pwd)
- if cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_user_name")):
- user = frappe.db.get_value("User", filters={"username": user}, fieldname="name") or user
+ if not user:
+ self.fail('Invalid login credentials')
- self.check_if_enabled(user)
- if not frappe.form_dict.get('tmp_id'):
- self.user = self.check_password(user, pwd)
+ # Current login flow uses cached credentials for authentication while checking OTP.
+ # Incase of OTP check, tracker for auth needs to be disabled(If not, it can remove tracker history as it is going to succeed anyway)
+ # Tracker is activated for 2FA incase of OTP.
+ ignore_tracker = should_run_2fa(user.name) and ('otp' in frappe.form_dict)
+ tracker = None if ignore_tracker else get_login_attempt_tracker(user.name)
+
+ if not user.is_authenticated:
+ tracker and tracker.add_failure_attempt()
+ self.fail('Invalid login credentials', user=user.name)
+ elif not (user.name == 'Administrator' or user.enabled):
+ tracker and tracker.add_failure_attempt()
+ self.fail('User disabled or missing', user=user.name)
else:
- self.user = user
+ tracker and tracker.add_success_attempt()
+ self.user = user.name
def force_user_to_reset_password(self):
if not self.user:
return
- from frappe.core.doctype.user.user import STANDARD_USERS
- if self.user in STANDARD_USERS:
+ if self.user in frappe.STANDARD_USERS:
return False
reset_pwd_after_days = cint(frappe.db.get_single_value("System Settings",
@@ -245,23 +266,12 @@ class LoginManager:
if last_pwd_reset_days > reset_pwd_after_days:
return True
- def check_if_enabled(self, user):
- """raise exception if user not enabled"""
- doc = frappe.get_doc("System Settings")
- if cint(doc.allow_consecutive_login_attempts) > 0:
- check_consecutive_login_attempts(user, doc)
-
- if user=='Administrator': return
- if not cint(frappe.db.get_value('User', user, 'enabled')):
- self.fail('User disabled or missing', user=user)
-
def check_password(self, user, pwd):
"""check password"""
try:
# returns user in correct case
return check_password(user, pwd)
except frappe.AuthenticationError:
- self.update_invalid_login(user)
self.fail('Incorrect password', user=user)
def fail(self, message, user=None):
@@ -272,15 +282,6 @@ class LoginManager:
frappe.db.commit()
raise frappe.AuthenticationError
- def update_invalid_login(self, user):
- last_login_tried = get_last_tried_login_data(user)
-
- failed_count = 0
- if last_login_tried > get_datetime():
- failed_count = get_login_failed_count(user)
-
- frappe.cache().hset('login_failed_count', user, failed_count + 1)
-
def run_trigger(self, event='on_login'):
for method in frappe.get_hooks().get(event, []):
frappe.call(frappe.get_attr(method), login_manager=self)
@@ -383,38 +384,6 @@ def clear_cookies():
frappe.session.sid = ""
frappe.local.cookie_manager.delete_cookie(["full_name", "user_id", "sid", "user_image", "system_user"])
-def get_last_tried_login_data(user, get_last_login=False):
- locked_account_time = frappe.cache().hget('locked_account_time', user)
- if get_last_login and locked_account_time:
- return locked_account_time
-
- last_login_tried = frappe.cache().hget('last_login_tried', user)
- if not last_login_tried or last_login_tried < get_datetime():
- last_login_tried = get_datetime() + datetime.timedelta(seconds=60)
-
- frappe.cache().hset('last_login_tried', user, last_login_tried)
-
- return last_login_tried
-
-def get_login_failed_count(user):
- return cint(frappe.cache().hget('login_failed_count', user)) or 0
-
-def check_consecutive_login_attempts(user, doc):
- login_failed_count = get_login_failed_count(user)
- last_login_tried = (get_last_tried_login_data(user, True)
- + datetime.timedelta(seconds=doc.allow_login_after_fail))
-
- if login_failed_count >= cint(doc.allow_consecutive_login_attempts):
- locked_account_time = frappe.cache().hget('locked_account_time', user)
- if not locked_account_time:
- frappe.cache().hset('locked_account_time', user, get_datetime())
-
- if last_login_tried > get_datetime():
- frappe.throw(_("Your account has been locked and will resume after {0} seconds")
- .format(doc.allow_login_after_fail), frappe.SecurityException)
- else:
- delete_login_failed_cache(user)
-
def validate_ip_address(user):
"""check if IP Address is valid"""
user = frappe.get_cached_doc("User", user) if not frappe.flags.in_test else frappe.get_doc("User", user)
@@ -436,3 +405,108 @@ def validate_ip_address(user):
return
frappe.throw(_("Access not allowed from this IP Address"), frappe.AuthenticationError)
+
+def get_login_attempt_tracker(user_name: str, raise_locked_exception: bool = True):
+ """Get login attempt tracker instance.
+
+ :param user_name: Name of the loggedin user
+ :param raise_locked_exception: If set, raises an exception incase of user not allowed to login
+ """
+ sys_settings = frappe.get_doc("System Settings")
+ track_login_attempts = (sys_settings.allow_consecutive_login_attempts >0)
+ tracker_kwargs = {}
+
+ if track_login_attempts:
+ tracker_kwargs['lock_interval'] = sys_settings.allow_login_after_fail
+ tracker_kwargs['max_consecutive_login_attempts'] = sys_settings.allow_consecutive_login_attempts
+
+ tracker = LoginAttemptTracker(user_name, **tracker_kwargs)
+
+ if raise_locked_exception and track_login_attempts and not tracker.is_user_allowed():
+ frappe.throw(_("Your account has been locked and will resume after {0} seconds")
+ .format(sys_settings.allow_login_after_fail), frappe.SecurityException)
+ return tracker
+
+
+class LoginAttemptTracker(object):
+ """Track login attemts of a user.
+
+ Lock the account for s number of seconds if there have been n consecutive unsuccessful attempts to log in.
+ """
+ def __init__(self, user_name: str, max_consecutive_login_attempts: int=3, lock_interval:int = 5*60):
+ """ Initialize the tracker.
+
+ :param user_name: Name of the loggedin user
+ :param max_consecutive_login_attempts: Maximum allowed consecutive failed login attempts
+ :param lock_interval: Locking interval incase of maximum failed attempts
+ """
+ self.user_name = user_name
+ self.lock_interval = datetime.timedelta(seconds=lock_interval)
+ self.max_failed_logins = max_consecutive_login_attempts
+
+ @property
+ def login_failed_count(self):
+ return frappe.cache().hget('login_failed_count', self.user_name)
+
+ @login_failed_count.setter
+ def login_failed_count(self, count):
+ frappe.cache().hset('login_failed_count', self.user_name, count)
+
+ @login_failed_count.deleter
+ def login_failed_count(self):
+ frappe.cache().hdel('login_failed_count', self.user_name)
+
+ @property
+ def login_failed_time(self):
+ """First failed login attempt time within lock interval.
+
+ For every user we track only First failed login attempt time within lock interval of time.
+ """
+ return frappe.cache().hget('login_failed_time', self.user_name)
+
+ @login_failed_time.setter
+ def login_failed_time(self, timestamp):
+ frappe.cache().hset('login_failed_time', self.user_name, timestamp)
+
+ @login_failed_time.deleter
+ def login_failed_time(self):
+ frappe.cache().hdel('login_failed_time', self.user_name)
+
+ def add_failure_attempt(self):
+ """ Log user failure attempts into the system.
+
+ Increase the failure count if new failure is with in current lock interval time period, if not reset the login failure count.
+ """
+ login_failed_time = self.login_failed_time
+ login_failed_count = self.login_failed_count # Consecutive login failure count
+ current_time = get_datetime()
+
+ if not (login_failed_time and login_failed_count):
+ login_failed_time, login_failed_count = current_time, 0
+
+ if login_failed_time + self.lock_interval > current_time:
+ login_failed_count += 1
+ else:
+ login_failed_time, login_failed_count = current_time, 1
+
+ self.login_failed_time = login_failed_time
+ self.login_failed_count = login_failed_count
+
+ def add_success_attempt(self):
+ """Reset login failures.
+ """
+ del self.login_failed_count
+ del self.login_failed_time
+
+ def is_user_allowed(self) -> bool:
+ """Is user allowed to login
+
+ User is not allowed to login if login failures are greater than threshold within in lock interval from first login failure.
+ """
+ login_failed_time = self.login_failed_time
+ login_failed_count = self.login_failed_count or 0
+ current_time = get_datetime()
+
+ if login_failed_time and login_failed_time + self.lock_interval > current_time and login_failed_count > self.max_failed_logins:
+ return False
+ return True
diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.js b/frappe/automation/doctype/assignment_rule/assignment_rule.js
index ee1a076465..97bed4f8f3 100644
--- a/frappe/automation/doctype/assignment_rule/assignment_rule.js
+++ b/frappe/automation/doctype/assignment_rule/assignment_rule.js
@@ -9,6 +9,16 @@ frappe.ui.form.on('Assignment Rule', {
frm.events.rule(frm);
},
+ setup: function(frm) {
+ frm.set_query("document_type", () => {
+ return {
+ filters: {
+ name: ["!=", "ToDo"]
+ }
+ };
+ });
+ },
+
document_type: function(frm) {
frm.trigger('set_options');
},
diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.json b/frappe/automation/doctype/assignment_rule/assignment_rule.json
index 0a57e06da6..541d176967 100644
--- a/frappe/automation/doctype/assignment_rule/assignment_rule.json
+++ b/frappe/automation/doctype/assignment_rule/assignment_rule.json
@@ -72,6 +72,7 @@
"fieldtype": "Code",
"in_list_view": 1,
"label": "Assign Condition",
+ "options": "PythonExpression",
"reqd": 1
},
{
@@ -82,7 +83,8 @@
"description": "Simple Python Expression, Example: Status in (\"Closed\", \"Cancelled\")",
"fieldname": "unassign_condition",
"fieldtype": "Code",
- "label": "Unassign Condition"
+ "label": "Unassign Condition",
+ "options": "PythonExpression"
},
{
"fieldname": "assign_to_users_section",
@@ -120,7 +122,8 @@
"description": "Simple Python Expression, Example: Status in (\"Invalid\")",
"fieldname": "close_condition",
"fieldtype": "Code",
- "label": "Close Condition"
+ "label": "Close Condition",
+ "options": "PythonExpression"
},
{
"fieldname": "sb",
@@ -151,7 +154,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-10-20 14:47:20.662954",
+ "modified": "2021-07-16 22:51:35.505575",
"modified_by": "Administrator",
"module": "Automation",
"name": "Assignment Rule",
diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py
index d20398d564..90099eebb6 100644
--- a/frappe/automation/doctype/assignment_rule/assignment_rule.py
+++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py
@@ -1,32 +1,47 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# Copyright (c) 2022, Frappe Technologies and contributors
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
+from typing import Dict, Iterable, List
import frappe
-from frappe.model.document import Document
-from frappe.desk.form import assign_to
-import frappe.cache_manager
from frappe import _
+from frappe.cache_manager import clear_doctype_map, get_doctype_map
+from frappe.desk.form import assign_to
from frappe.model import log_types
+from frappe.model.document import Document
+
class AssignmentRule(Document):
-
def validate(self):
+ self.validate_document_types()
+ self.validate_assignment_days()
+
+ def clear_cache(self):
+ super().clear_cache()
+ clear_doctype_map(self.doctype, self.document_type)
+ clear_doctype_map(self.doctype, f"due_date_rules_for_{self.document_type}")
+
+ def validate_document_types(self):
+ if self.document_type == "ToDo":
+ frappe.throw(
+ _('Assignment Rule is not allowed on {0} document type').format(
+ frappe.bold("ToDo")
+ )
+ )
+
+ def validate_assignment_days(self):
assignment_days = self.get_assignment_days()
- if not len(set(assignment_days)) == len(assignment_days):
+
+ if len(set(assignment_days)) != len(assignment_days):
repeated_days = get_repeated(assignment_days)
- frappe.throw(_("Assignment Day {0} has been repeated.").format(frappe.bold(repeated_days)))
+ plural = "s" if len(repeated_days) > 1 else ""
- def on_update(self):
- clear_assignment_rule_cache(self)
-
- def after_rename(self, old, new, merge):
- clear_assignment_rule_cache(self)
-
- def on_trash(self):
- clear_assignment_rule_cache(self)
+ frappe.throw(
+ _("Assignment Day{0} {1} has been repeated.").format(
+ plural,
+ frappe.bold(", ".join(repeated_days))
+ )
+ )
def apply_unassign(self, doc, assignments):
if (self.unassign_condition and
@@ -35,7 +50,6 @@ class AssignmentRule(Document):
return False
-
def apply_assign(self, doc):
if self.safe_eval('assign_condition', doc):
return self.do_assignment(doc)
@@ -109,7 +123,7 @@ class AssignmentRule(Document):
user = d.user,
count = frappe.db.count('ToDo', dict(
reference_type = self.document_type,
- owner = d.user,
+ allocated_to = d.user,
status = "Open"))
))
@@ -141,65 +155,68 @@ class AssignmentRule(Document):
def is_rule_not_applicable_today(self):
today = frappe.flags.assignment_day or frappe.utils.get_weekday()
assignment_days = self.get_assignment_days()
- if assignment_days and not today in assignment_days:
- return True
+ return assignment_days and today not in assignment_days
- return False
-def get_assignments(doc):
+def get_assignments(doc) -> List[Dict]:
return frappe.get_all('ToDo', fields = ['name', 'assignment_rule'], filters = dict(
reference_type = doc.get('doctype'),
reference_name = doc.get('name'),
status = ('!=', 'Cancelled')
- ), limit = 5)
+ ), limit=5)
+
@frappe.whitelist()
def bulk_apply(doctype, docnames):
- import json
- docnames = json.loads(docnames)
-
+ docnames = frappe.parse_json(docnames)
background = len(docnames) > 5
+
for name in docnames:
if background:
frappe.enqueue('frappe.automation.doctype.assignment_rule.assignment_rule.apply', doc=None, doctype=doctype, name=name)
else:
- apply(None, doctype=doctype, name=name)
+ apply(doctype=doctype, name=name)
+
def reopen_closed_assignment(doc):
- todo_list = frappe.db.get_all('ToDo', filters = dict(
- reference_type = doc.doctype,
- reference_name = doc.name,
- status = 'Closed'
- ))
- if not todo_list:
- return False
+ todo_list = frappe.get_all("ToDo", filters={
+ "reference_type": doc.doctype,
+ "reference_name": doc.name,
+ "status": "Closed",
+ }, pluck="name")
+
for todo in todo_list:
- todo_doc = frappe.get_doc('ToDo', todo.name)
+ todo_doc = frappe.get_doc('ToDo', todo)
todo_doc.status = 'Open'
todo_doc.save(ignore_permissions=True)
- return True
-def apply(doc, method=None, doctype=None, name=None):
- if not doctype:
- doctype = doc.doctype
+ return bool(todo_list)
- if (frappe.flags.in_patch
+
+def apply(doc=None, method=None, doctype=None, name=None):
+ doctype = doctype or doc.doctype
+
+ skip_assignment_rules = (
+ frappe.flags.in_patch
or frappe.flags.in_install
or frappe.flags.in_setup_wizard
- or doctype in log_types):
+ or doctype in log_types
+ )
+
+ if skip_assignment_rules:
return
if not doc and doctype and name:
doc = frappe.get_doc(doctype, name)
- assignment_rules = frappe.cache_manager.get_doctype_map('Assignment Rule', doc.doctype, dict(
- document_type = doc.doctype, disabled = 0), order_by = 'priority desc')
-
- assignment_rule_docs = []
+ assignment_rules = get_doctype_map("Assignment Rule", doc.doctype, filters={
+ "document_type": doc.doctype, "disabled": 0
+ }, order_by="priority desc")
# multiple auto assigns
- for d in assignment_rules:
- assignment_rule_docs.append(frappe.get_cached_doc('Assignment Rule', d.get('name')))
+ assignment_rule_docs: List[AssignmentRule] = [
+ frappe.get_cached_doc("Assignment Rule", d.get('name')) for d in assignment_rules
+ ]
if not assignment_rule_docs:
return
@@ -235,6 +252,7 @@ def apply(doc, method=None, doctype=None, name=None):
# apply close rule only if assignments exists
assignments = get_assignments(doc)
+
if assignments:
for assignment_rule in assignment_rule_docs:
if assignment_rule.is_rule_not_applicable_today():
@@ -242,38 +260,74 @@ def apply(doc, method=None, doctype=None, name=None):
if not new_apply:
# only reopen if close condition is not satisfied
- if not assignment_rule.safe_eval('close_condition', doc):
- reopen = reopen_closed_assignment(doc)
- if reopen:
+ to_close_todos = assignment_rule.safe_eval('close_condition', doc)
+
+ if to_close_todos:
+ # close todo status
+ todos_to_close = frappe.get_all("ToDo", filters={
+ "reference_type": doc.doctype,
+ "reference_name": doc.name,
+ }, pluck="name")
+
+ for todo in todos_to_close:
+ _todo = frappe.get_doc("ToDo", todo)
+ _todo.status = "Closed"
+ _todo.save(ignore_permissions=True)
+ break
+
+ else:
+ reopened = reopen_closed_assignment(doc)
+ if reopened:
break
+
+ # print(f"Rule:{assignment_rule}\nDoc: {doc}\nReOpened: {reopened}")
+
assignment_rule.close_assignments(doc)
+
def update_due_date(doc, state=None):
- # called from hook
- if (frappe.flags.in_patch
- or frappe.flags.in_install
- or frappe.flags.in_migrate
+ """Run on_update on every Document (via hooks.py)
+ """
+ skip_document_update = (
+ frappe.flags.in_migrate
+ or frappe.flags.in_patch
or frappe.flags.in_import
- or frappe.flags.in_setup_wizard):
+ or frappe.flags.in_setup_wizard
+ or frappe.flags.in_install
+ )
+
+ if skip_document_update:
return
- assignment_rules = frappe.cache_manager.get_doctype_map('Assignment Rule', 'due_date_rules_for_' + doc.doctype, dict(
- document_type = doc.doctype,
- disabled = 0,
- due_date_based_on = ['is', 'set']
- ))
+
+ assignment_rules = get_doctype_map(
+ doctype="Assignment Rule",
+ name=f"due_date_rules_for_{doc.doctype}",
+ filters={
+ "due_date_based_on": ["is", "set"],
+ "document_type": doc.doctype,
+ "disabled": 0,
+ }
+ )
+
for rule in assignment_rules:
- rule_doc = frappe.get_cached_doc('Assignment Rule', rule.get('name'))
+ rule_doc = frappe.get_cached_doc("Assignment Rule", rule.get("name"))
due_date_field = rule_doc.due_date_based_on
- if doc.meta.has_field(due_date_field) and \
- doc.has_value_changed(due_date_field) and rule.get('name'):
- assignment_todos = frappe.get_all('ToDo', {
- 'assignment_rule': rule.get('name'),
- 'status': 'Open',
- 'reference_type': doc.doctype,
- 'reference_name': doc.name
- })
+ field_updated = (
+ doc.meta.has_field(due_date_field)
+ and doc.has_value_changed(due_date_field)
+ and rule.get("name")
+ )
+
+ if field_updated:
+ assignment_todos = frappe.get_all("ToDo", filters={
+ "assignment_rule": rule.get("name"),
+ "reference_type": doc.doctype,
+ "reference_name": doc.name,
+ "status": "Open",
+ }, pluck="name")
+
for todo in assignment_todos:
- todo_doc = frappe.get_doc('ToDo', todo.name)
+ todo_doc = frappe.get_doc('ToDo', todo)
todo_doc.date = doc.get(due_date_field)
todo_doc.flags.updater_reference = {
'doctype': 'Assignment Rule',
@@ -282,20 +336,19 @@ def update_due_date(doc, state=None):
}
todo_doc.save(ignore_permissions=True)
-def get_assignment_rules():
- return [d.document_type for d in frappe.db.get_all('Assignment Rule', fields=['document_type'], filters=dict(disabled = 0))]
-def get_repeated(values):
- unique_list = []
- diff = []
+def get_assignment_rules() -> List[str]:
+ return frappe.get_all("Assignment Rule", filters={"disabled": 0}, pluck="document_type")
+
+
+def get_repeated(values: Iterable) -> List:
+ unique = set()
+ repeated = set()
+
for value in values:
- if value not in unique_list:
- unique_list.append(str(value))
+ if value in unique:
+ repeated.add(value)
else:
- if value not in diff:
- diff.append(str(value))
- return " ".join(diff)
+ unique.add(value)
-def clear_assignment_rule_cache(rule):
- frappe.cache_manager.clear_doctype_map('Assignment Rule', rule.document_type)
- frappe.cache_manager.clear_doctype_map('Assignment Rule', 'due_date_rules_for_' + rule.document_type)
\ No newline at end of file
+ return [str(x) for x in repeated]
diff --git a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py
index cb1e0ff8f4..63dbf69d3b 100644
--- a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py
+++ b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py
@@ -1,14 +1,22 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# License: MIT. See LICENSE
+
+import unittest
import frappe
-import unittest
-from frappe.utils import random_string
from frappe.test_runner import make_test_records
+from frappe.utils import random_string
+
class TestAutoAssign(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ frappe.db.delete("Assignment Rule")
+
+ @classmethod
+ def tearDownClass(cls):
+ frappe.db.rollback()
+
def setUp(self):
make_test_records("User")
days = [
@@ -32,7 +40,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), 'test@example.com')
+ ), 'allocated_to'), 'test@example.com')
note = make_note(dict(public=1))
@@ -41,7 +49,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), 'test1@example.com')
+ ), 'allocated_to'), 'test1@example.com')
clear_assignments()
@@ -53,7 +61,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), 'test2@example.com')
+ ), 'allocated_to'), 'test2@example.com')
# check loop back to first user
note = make_note(dict(public=1))
@@ -62,7 +70,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), 'test@example.com')
+ ), 'allocated_to'), 'test@example.com')
def test_load_balancing(self):
self.assignment_rule.rule = 'Load Balancing'
@@ -73,12 +81,12 @@ class TestAutoAssign(unittest.TestCase):
# check if each user has 10 assignments (?)
for user in ('test@example.com', 'test1@example.com', 'test2@example.com'):
- self.assertEqual(len(frappe.get_all('ToDo', dict(owner = user, reference_type = 'Note'))), 10)
+ self.assertEqual(len(frappe.get_all('ToDo', dict(allocated_to = user, reference_type = 'Note'))), 10)
# clear 5 assignments for first user
# can't do a limit in "delete" since postgres does not support it
- for d in frappe.get_all('ToDo', dict(reference_type = 'Note', owner = 'test@example.com'), limit=5):
- frappe.db.sql("delete from tabToDo where name = %s", d.name)
+ for d in frappe.get_all('ToDo', dict(reference_type = 'Note', allocated_to = 'test@example.com'), limit=5):
+ frappe.db.delete("ToDo", {"name": d.name})
# add 5 more assignments
for i in range(5):
@@ -86,7 +94,7 @@ class TestAutoAssign(unittest.TestCase):
# check if each user still has 10 assignments
for user in ('test@example.com', 'test1@example.com', 'test2@example.com'):
- self.assertEqual(len(frappe.get_all('ToDo', dict(owner = user, reference_type = 'Note'))), 10)
+ self.assertEqual(len(frappe.get_all('ToDo', dict(allocated_to = user, reference_type = 'Note'))), 10)
def test_based_on_field(self):
self.assignment_rule.rule = 'Based on Field'
@@ -121,7 +129,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), None)
+ ), 'allocated_to'), None)
def test_clear_assignment(self):
note = make_note(dict(public=1))
@@ -131,10 +139,10 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ))[0]
+ ), limit=1)[0]
todo = frappe.get_doc('ToDo', todo['name'])
- self.assertEqual(todo.owner, 'test@example.com')
+ self.assertEqual(todo.allocated_to, 'test@example.com')
# test auto unassign
note.public = 0
@@ -153,10 +161,10 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ))[0]
+ ), limit=1)[0]
todo = frappe.get_doc('ToDo', todo['name'])
- self.assertEqual(todo.owner, 'test@example.com')
+ self.assertEqual(todo.allocated_to, 'test@example.com')
note.content="Closed"
note.save()
@@ -166,7 +174,7 @@ class TestAutoAssign(unittest.TestCase):
# check if todo is closed
self.assertEqual(todo.status, 'Closed')
# check if closed todo retained assignment
- self.assertEqual(todo.owner, 'test@example.com')
+ self.assertEqual(todo.allocated_to, 'test@example.com')
def check_multiple_rules(self):
note = make_note(dict(public=1, notify_on_login=1))
@@ -176,10 +184,10 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), 'test@example.com')
+ ), 'allocated_to'), 'test@example.com')
def check_assignment_rule_scheduling(self):
- frappe.db.sql("DELETE FROM `tabAssignment Rule`")
+ frappe.db.delete("Assignment Rule")
days_1 = [dict(day = 'Sunday'), dict(day = 'Monday'), dict(day = 'Tuesday')]
@@ -194,7 +202,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), ['test@example.com', 'test1@example.com', 'test2@example.com'])
+ ), 'allocated_to'), ['test@example.com', 'test1@example.com', 'test2@example.com'])
frappe.flags.assignment_day = "Friday"
note = make_note(dict(public=1))
@@ -203,10 +211,10 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), ['test3@example.com'])
+ ), 'allocated_to'), ['test3@example.com'])
def test_assignment_rule_condition(self):
- frappe.db.sql("DELETE FROM `tabAssignment Rule`")
+ frappe.db.delete("Assignment Rule")
# Add expiry_date custom field
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
@@ -255,7 +263,7 @@ class TestAutoAssign(unittest.TestCase):
assignment_rule.delete()
def clear_assignments():
- frappe.db.sql("delete from tabToDo where reference_type = 'Note'")
+ frappe.db.delete("ToDo", {"reference_type": "Note"})
def get_assignment_rule(days, assign=None):
frappe.delete_doc_if_exists('Assignment Rule', 'For Note 1')
diff --git a/frappe/automation/doctype/assignment_rule_day/assignment_rule_day.py b/frappe/automation/doctype/assignment_rule_day/assignment_rule_day.py
index 27f9aa40e1..836ae3d453 100644
--- a/frappe/automation/doctype/assignment_rule_day/assignment_rule_day.py
+++ b/frappe/automation/doctype/assignment_rule_day/assignment_rule_day.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py
index ee8081c6d8..1bb8953a7a 100644
--- a/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py
+++ b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js
index 7028ac486d..80f2255f47 100644
--- a/frappe/automation/doctype/auto_repeat/auto_repeat.js
+++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js
@@ -30,7 +30,7 @@ frappe.ui.form.on('Auto Repeat', {
refresh: function(frm) {
// auto repeat message
if (frm.is_new()) {
- let customize_form_link = `${__('Customize Form')} `;
+ let customize_form_link = `${__('Customize Form')} `;
frm.dashboard.set_headline(__('To configure Auto Repeat, enable "Allow Auto Repeat" from {0}.', [customize_form_link]));
}
@@ -103,7 +103,7 @@ frappe.ui.form.on('Auto Repeat', {
frappe.auto_repeat.render_schedule = function(frm) {
if (!frm.is_dirty() && frm.doc.status !== 'Disabled') {
frm.call("get_auto_repeat_schedule").then(r => {
- frm.dashboard.wrapper.empty();
+ frm.dashboard.reset();
frm.dashboard.add_section(
frappe.render_template("auto_repeat_schedule", {
schedule_details: r.message || []
diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py
index 281e699640..0277b8e402 100644
--- a/frappe/automation/doctype/auto_repeat/auto_repeat.py
+++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
from datetime import timedelta
@@ -97,7 +96,15 @@ class AutoRepeat(Document):
auto_repeat_days = self.get_auto_repeat_days()
if not len(set(auto_repeat_days)) == len(auto_repeat_days):
repeated_days = get_repeated(auto_repeat_days)
- frappe.throw(_('Auto Repeat Day {0} has been repeated.').format(frappe.bold(repeated_days)))
+ plural = "s" if len(repeated_days) > 1 else ""
+
+ frappe.throw(
+ _("Auto Repeat Day{0} {1} has been repeated.").format(
+ plural,
+ frappe.bold(", ".join(repeated_days))
+ )
+ )
+
def update_auto_repeat_id(self):
#check if document is already on auto repeat
@@ -118,6 +125,7 @@ class AutoRepeat(Document):
def is_completed(self):
return self.end_date and getdate(self.end_date) < getdate(today())
+ @frappe.whitelist()
def get_auto_repeat_schedule(self):
schedule_details = []
start_date = getdate(self.start_date)
@@ -328,11 +336,12 @@ class AutoRepeat(Document):
make(doctype=new_doc.doctype, name=new_doc.name, recipients=recipients,
subject=subject, content=message, attachments=attachments, send_email=1)
+ @frappe.whitelist()
def fetch_linked_contacts(self):
if self.reference_doctype and self.reference_document:
res = get_contacts_linking_to(self.reference_doctype, self.reference_document, fields=['email_id'])
res += get_contacts_linked_from(self.reference_doctype, self.reference_document, fields=['email_id'])
- email_ids = list(set([d.email_id for d in res]))
+ email_ids = {d.email_id for d in res}
if not email_ids:
frappe.msgprint(_('No contacts linked to document'), alert=True)
else:
diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.js b/frappe/automation/doctype/auto_repeat/test_auto_repeat.js
deleted file mode 100644
index cf7ce74ebb..0000000000
--- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Auto Repeat", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Auto Repeat
- () => frappe.tests.make('Auto Repeat', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py
index 0d6229cd9e..30a0310a92 100644
--- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py
+++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import unittest
import frappe
@@ -173,7 +171,7 @@ class TestAutoRepeat(unittest.TestCase):
fields=['docstatus'],
limit=1
)
- self.assertEquals(docnames[0].docstatus, 1)
+ self.assertEqual(docnames[0].docstatus, 1)
def make_auto_repeat(**args):
@@ -196,7 +194,7 @@ def make_auto_repeat(**args):
return doc
-def create_submittable_doctype(doctype):
+def create_submittable_doctype(doctype, submit_perms=1):
if frappe.db.exists('DocType', doctype):
return
else:
@@ -217,9 +215,9 @@ def create_submittable_doctype(doctype):
'write': 1,
'create': 1,
'delete': 1,
- 'submit': 1,
- 'cancel': 1,
- 'amend': 1
+ 'submit': submit_perms,
+ 'cancel': submit_perms,
+ 'amend': submit_perms
}]
}).insert()
diff --git a/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py
index 3a7ced1370..54fc0d14e9 100644
--- a/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py
+++ b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/automation/doctype/milestone/milestone.py b/frappe/automation/doctype/milestone/milestone.py
index 64c073a378..eff65571fd 100644
--- a/frappe/automation/doctype/milestone/milestone.py
+++ b/frappe/automation/doctype/milestone/milestone.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
from frappe.model.document import Document
diff --git a/frappe/automation/doctype/milestone/test_milestone.py b/frappe/automation/doctype/milestone/test_milestone.py
index 75602d48db..f8fb910072 100644
--- a/frappe/automation/doctype/milestone/test_milestone.py
+++ b/frappe/automation/doctype/milestone/test_milestone.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
#import frappe
import unittest
diff --git a/frappe/automation/doctype/milestone_tracker/milestone_tracker.py b/frappe/automation/doctype/milestone_tracker/milestone_tracker.py
index 388620bfb4..042e7b0391 100644
--- a/frappe/automation/doctype/milestone_tracker/milestone_tracker.py
+++ b/frappe/automation/doctype/milestone_tracker/milestone_tracker.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
from frappe.model.document import Document
diff --git a/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py b/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py
index 05db3b025e..f4d5f00d83 100644
--- a/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py
+++ b/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py
@@ -1,15 +1,13 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import frappe.cache_manager
import unittest
class TestMilestoneTracker(unittest.TestCase):
def test_milestone(self):
- frappe.db.sql('delete from `tabMilestone Tracker`')
+ frappe.db.delete("Milestone Tracker")
frappe.cache().delete_key('milestone_tracker_map')
@@ -46,5 +44,5 @@ class TestMilestoneTracker(unittest.TestCase):
self.assertEqual(milestones[0].value, 'Closed')
# cleanup
- frappe.db.sql('delete from tabMilestone')
+ frappe.db.delete("Milestone")
milestone_tracker.delete()
\ No newline at end of file
diff --git a/frappe/automation/workspace/tools/tools.json b/frappe/automation/workspace/tools/tools.json
index 4a0835657b..40b265b34f 100644
--- a/frappe/automation/workspace/tools/tools.json
+++ b/frappe/automation/workspace/tools/tools.json
@@ -1,22 +1,20 @@
{
- "category": "Administration",
"charts": [],
+ "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts \",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"ToDo\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Note\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Assignment Rule\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Auto Repeat\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters \",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Email\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Automation\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Event Streaming\",\"col\":4}}]",
"creation": "2020-03-02 14:53:24.980279",
- "developer_mode_only": 0,
- "disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
- "extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "tool",
"idx": 0,
- "is_standard": 1,
"label": "Tools",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Tools",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -25,6 +23,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "To Do",
+ "link_count": 0,
"link_to": "ToDo",
"link_type": "DocType",
"onboard": 1,
@@ -35,6 +34,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Calendar",
+ "link_count": 0,
"link_to": "Event",
"link_type": "DocType",
"onboard": 1,
@@ -45,6 +45,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Note",
+ "link_count": 0,
"link_to": "Note",
"link_type": "DocType",
"onboard": 1,
@@ -55,6 +56,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Files",
+ "link_count": 0,
"link_to": "File",
"link_type": "DocType",
"onboard": 0,
@@ -65,6 +67,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Activity",
+ "link_count": 0,
"link_to": "activity",
"link_type": "Page",
"onboard": 0,
@@ -74,6 +77,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -82,6 +86,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Newsletter",
+ "link_count": 0,
"link_to": "Newsletter",
"link_type": "DocType",
"onboard": 1,
@@ -92,6 +97,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email Group",
+ "link_count": 0,
"link_to": "Email Group",
"link_type": "DocType",
"onboard": 0,
@@ -101,6 +107,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Automation",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -109,6 +116,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Assignment Rule",
+ "link_count": 0,
"link_to": "Assignment Rule",
"link_type": "DocType",
"onboard": 0,
@@ -119,6 +127,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Milestone",
+ "link_count": 0,
"link_to": "Milestone",
"link_type": "DocType",
"onboard": 0,
@@ -129,6 +138,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Auto Repeat",
+ "link_count": 0,
"link_to": "Auto Repeat",
"link_type": "DocType",
"onboard": 0,
@@ -138,6 +148,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Streaming",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -146,6 +157,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Producer",
+ "link_count": 0,
"link_to": "Event Producer",
"link_type": "DocType",
"onboard": 0,
@@ -156,6 +168,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Consumer",
+ "link_count": 0,
"link_to": "Event Consumer",
"link_type": "DocType",
"onboard": 0,
@@ -166,6 +179,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Update Log",
+ "link_count": 0,
"link_to": "Event Update Log",
"link_type": "DocType",
"onboard": 0,
@@ -176,6 +190,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Sync Log",
+ "link_count": 0,
"link_to": "Event Sync Log",
"link_type": "DocType",
"onboard": 0,
@@ -186,19 +201,23 @@
"hidden": 0,
"is_query_report": 0,
"label": "Document Type Mapping",
+ "link_count": 0,
"link_to": "Document Type Mapping",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2020-12-01 13:38:39.950350",
+ "modified": "2022-01-13 17:48:48.456763",
"modified_by": "Administrator",
"module": "Automation",
"name": "Tools",
"owner": "Administrator",
- "pin_to_bottom": 0,
- "pin_to_top": 0,
+ "parent_page": "",
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 26.0,
"shortcuts": [
{
"label": "ToDo",
@@ -225,5 +244,6 @@
"link_to": "Auto Repeat",
"type": "DocType"
}
- ]
+ ],
+ "title": "Tools"
}
\ No newline at end of file
diff --git a/frappe/boot.py b/frappe/boot.py
index 0dfcb8d1b4..b5008f778a 100644
--- a/frappe/boot.py
+++ b/frappe/boot.py
@@ -1,10 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
-
-from six import iteritems, text_type
-
+# License: MIT. See LICENSE
"""
bootstrap client session
"""
@@ -12,6 +7,7 @@ bootstrap client session
import frappe
import frappe.defaults
import frappe.desk.desk_page
+from frappe.desk.doctype.route_history.route_history import frequently_visited_links
from frappe.desk.form.load import get_meta_bundle
from frappe.utils.change_log import get_versions
from frappe.translate import get_lang_dict
@@ -20,8 +16,8 @@ from frappe.social.doctype.energy_point_settings.energy_point_settings import is
from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabled
from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_points
from frappe.model.base_document import get_controller
-from frappe.social.doctype.post.post import frequently_visited_links
from frappe.core.doctype.navbar_settings.navbar_settings import get_navbar_settings, get_app_logo
+from frappe.utils import get_time_zone, add_user_info
def get_bootinfo():
"""build and return boot info"""
@@ -63,6 +59,7 @@ def get_bootinfo():
bootinfo.home_folder = frappe.db.get_value("File", {"is_home_folder": 1})
bootinfo.navbar_settings = get_navbar_settings()
bootinfo.notification_settings = get_notification_settings()
+ set_time_zone(bootinfo)
# ipinfo
if frappe.session.data.get('ipinfo'):
@@ -75,7 +72,7 @@ def get_bootinfo():
frappe.get_attr(method)(bootinfo)
if bootinfo.lang:
- bootinfo.lang = text_type(bootinfo.lang)
+ bootinfo.lang = str(bootinfo.lang)
bootinfo.versions = {k: v['version'] for k, v in get_versions().items()}
bootinfo.error_report_email = frappe.conf.error_report_email
@@ -92,6 +89,7 @@ def get_bootinfo():
bootinfo.additional_filters_config = get_additional_filters_from_hooks()
bootinfo.desk_settings = get_desk_settings()
bootinfo.app_logo_url = get_app_logo()
+ bootinfo.link_title_doctypes = get_link_title_doctypes()
return bootinfo
@@ -110,8 +108,8 @@ def load_conf_settings(bootinfo):
if key in conf: bootinfo[key] = conf.get(key)
def load_desktop_data(bootinfo):
- from frappe.desk.desktop import get_desk_sidebar_items
- bootinfo.allowed_workspaces = get_desk_sidebar_items()
+ from frappe.desk.desktop import get_workspace_sidebar_items
+ bootinfo.allowed_workspaces = get_workspace_sidebar_items().get('pages')
bootinfo.module_page_map = get_controller("Workspace").get_module_page_map()
bootinfo.dashboards = frappe.get_all("Dashboard")
@@ -220,22 +218,19 @@ def load_translations(bootinfo):
messages[name] = frappe._(name)
# only untranslated
- messages = {k:v for k, v in iteritems(messages) if k!=v}
+ messages = {k: v for k, v in messages.items() if k!=v}
bootinfo["__messages"] = messages
def get_user_info():
- user_info = frappe.db.get_all('User', fields=['`name`', 'full_name as fullname', 'user_image as image',
- 'gender', 'email', 'username', 'bio', 'location', 'interest', 'banner_image', 'allowed_in_mentions', 'user_type'],
- filters=dict(enabled=1))
+ # get info for current user
+ user_info = frappe._dict()
+ add_user_info(frappe.session.user, user_info)
- user_info_map = {d.name: d for d in user_info}
+ if frappe.session.user == 'Administrator' and user_info.Administrator.email:
+ user_info[user_info.Administrator.email] = user_info.Administrator
- admin_data = user_info_map.get('Administrator')
- if admin_data:
- user_info_map[admin_data.email] = admin_data
-
- return user_info_map
+ return user_info
def get_user(bootinfo):
"""get user info"""
@@ -329,3 +324,19 @@ def get_desk_settings():
def get_notification_settings():
return frappe.get_cached_doc('Notification Settings', frappe.session.user)
+
+@frappe.whitelist()
+def get_link_title_doctypes():
+ dts = frappe.get_all("DocType", {"show_title_field_in_link": 1})
+ custom_dts = frappe.get_all(
+ "Property Setter",
+ {"property": "show_title_field_in_link", "value": "1"},
+ ["doc_type as name"],
+ )
+ return [d.name for d in dts + custom_dts if d]
+
+def set_time_zone(bootinfo):
+ bootinfo.time_zone = {
+ "system": get_time_zone(),
+ "user": bootinfo.get("user_info", {}).get(frappe.session.user, {}).get("time_zone", None) or get_time_zone()
+ }
diff --git a/frappe/build.py b/frappe/build.py
index f47a7cb32b..7a06ee3a22 100644
--- a/frappe/build.py
+++ b/frappe/build.py
@@ -1,31 +1,37 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import print_function, unicode_literals
-
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
import os
-import re
-import json
import shutil
-import warnings
-import tempfile
+import re
+import subprocess
from distutils.spawn import find_executable
-
-import frappe
-from frappe.utils.minify import JavascriptMinify
+from subprocess import getoutput
+from tempfile import mkdtemp, mktemp
+from urllib.parse import urlparse
import click
-from requests import get
-from six import iteritems, text_type
-from six.moves.urllib.parse import urlparse
+import psutil
+from requests import head
+from requests.exceptions import HTTPError
+from semantic_version import Version
+import frappe
timestamps = {}
app_paths = None
sites_path = os.path.abspath(os.getcwd())
+class AssetsNotDownloadedError(Exception):
+ pass
+
+class AssetsDontExistError(HTTPError):
+ pass
+
+
def download_file(url, prefix):
+ from requests import get
+
filename = urlparse(url).path.split("/")[-1]
local_filename = os.path.join(prefix, filename)
with get(url, stream=True, allow_redirects=True) as r:
@@ -37,114 +43,126 @@ def download_file(url, prefix):
def build_missing_files():
- # check which files dont exist yet from the build.json and tell build.js to build only those!
+ '''Check which files dont exist yet from the assets.json and run build for those files'''
+
missing_assets = []
current_asset_files = []
- frappe_build = os.path.join("..", "apps", "frappe", "frappe", "public", "build.json")
for type in ["css", "js"]:
- current_asset_files.extend(
- [
- "{0}/{1}".format(type, name)
- for name in os.listdir(os.path.join(sites_path, "assets", type))
- ]
- )
+ folder = os.path.join(sites_path, "assets", "frappe", "dist", type)
+ current_asset_files.extend(os.listdir(folder))
- with open(frappe_build) as f:
- all_asset_files = json.load(f).keys()
+ development = frappe.local.conf.developer_mode or frappe.local.dev_server
+ build_mode = "development" if development else "production"
- for asset in all_asset_files:
- if asset.replace("concat:", "") not in current_asset_files:
- missing_assets.append(asset)
+ assets_json = frappe.read_file("assets/assets.json")
+ if assets_json:
+ assets_json = frappe.parse_json(assets_json)
- if missing_assets:
- from subprocess import check_call
- from shlex import split
+ for bundle_file, output_file in assets_json.items():
+ if not output_file.startswith('/assets/frappe'):
+ continue
- click.secho("\nBuilding missing assets...\n", fg="yellow")
- command = split(
- "node rollup/build.js --files {0} --no-concat".format(",".join(missing_assets))
- )
- check_call(command, cwd=os.path.join("..", "apps", "frappe"))
+ if os.path.basename(output_file) not in current_asset_files:
+ missing_assets.append(bundle_file)
+
+ if missing_assets:
+ click.secho("\nBuilding missing assets...\n", fg="yellow")
+ files_to_build = ["frappe/" + name for name in missing_assets]
+ bundle(build_mode, files=files_to_build)
+ else:
+ # no assets.json, run full build
+ bundle(build_mode, apps="frappe")
-def get_assets_link(frappe_head):
- from subprocess import getoutput
- from requests import head
-
+def get_assets_link(frappe_head) -> str:
tag = getoutput(
- "cd ../apps/frappe && git show-ref --tags -d | grep %s | sed -e 's,.*"
- " refs/tags/,,' -e 's/\^{}//'"
- % frappe_head
- )
+ r"cd ../apps/frappe && git show-ref --tags -d | grep %s | sed -e 's,.*"
+ r" refs/tags/,,' -e 's/\^{}//'"
+ % frappe_head
+ )
if tag:
# if tag exists, download assets from github release
- url = "https://github.com/frappe/frappe/releases/download/{0}/assets.tar.gz".format(tag)
+ url = f"https://github.com/frappe/frappe/releases/download/{tag}/assets.tar.gz"
else:
- url = "http://assets.frappeframework.com/{0}.tar.gz".format(frappe_head)
+ url = f"http://assets.frappeframework.com/{frappe_head}.tar.gz"
if not head(url):
- raise ValueError("URL {0} doesn't exist".format(url))
+ reference = f"Release {tag}" if tag else f"Commit {frappe_head}"
+ raise AssetsDontExistError(f"Assets for {reference} don't exist")
return url
+def fetch_assets(url, frappe_head):
+ click.secho("Retrieving assets...", fg="yellow")
+
+ prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head)
+ assets_archive = download_file(url, prefix)
+
+ if not assets_archive:
+ raise AssetsNotDownloadedError(f"Assets could not be retrived from {url}")
+
+ click.echo(click.style("✔", fg="green") + f" Downloaded Frappe assets from {url}")
+
+ return assets_archive
+
+
+def setup_assets(assets_archive):
+ import tarfile
+ directories_created = set()
+
+ click.secho("\nExtracting assets...\n", fg="yellow")
+ with tarfile.open(assets_archive) as tar:
+ for file in tar:
+ if not file.isdir():
+ dest = "." + file.name.replace("./frappe-bench/sites", "")
+ asset_directory = os.path.dirname(dest)
+ show = dest.replace("./assets/", "")
+
+ if asset_directory not in directories_created:
+ if not os.path.exists(asset_directory):
+ os.makedirs(asset_directory, exist_ok=True)
+ directories_created.add(asset_directory)
+
+ tar.makefile(file, dest)
+ click.echo(click.style("✔", fg="green") + f" Restored {show}")
+
+ return directories_created
+
+
def download_frappe_assets(verbose=True):
"""Downloads and sets up Frappe assets if they exist based on the current
commit HEAD.
Returns True if correctly setup else returns False.
"""
- from simple_chalk import green
- from subprocess import getoutput
- from tempfile import mkdtemp
-
- assets_setup = False
frappe_head = getoutput("cd ../apps/frappe && git rev-parse HEAD")
- if frappe_head:
+ if not frappe_head:
+ return False
+
+ try:
+ url = get_assets_link(frappe_head)
+ assets_archive = fetch_assets(url, frappe_head)
+ setup_assets(assets_archive)
+ build_missing_files()
+ return True
+
+ except AssetsDontExistError as e:
+ click.secho(str(e), fg="yellow")
+
+ except Exception as e:
+ # TODO: log traceback in bench.log
+ click.secho(str(e), fg="red")
+
+ finally:
try:
- url = get_assets_link(frappe_head)
- click.secho("Retrieving assets...", fg="yellow")
- prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head)
- assets_archive = download_file(url, prefix)
- print("\n{0} Downloaded Frappe assets from {1}".format(green('✔'), url))
-
- if assets_archive:
- import tarfile
- directories_created = set()
-
- click.secho("\nExtracting assets...\n", fg="yellow")
- with tarfile.open(assets_archive) as tar:
- for file in tar:
- if not file.isdir():
- dest = "." + file.name.replace("./frappe-bench/sites", "")
- asset_directory = os.path.dirname(dest)
- show = dest.replace("./assets/", "")
-
- if asset_directory not in directories_created:
- if not os.path.exists(asset_directory):
- os.makedirs(asset_directory, exist_ok=True)
- directories_created.add(asset_directory)
-
- tar.makefile(file, dest)
- print("{0} Restored {1}".format(green('✔'), show))
-
- build_missing_files()
- return True
- else:
- raise
+ shutil.rmtree(os.path.dirname(assets_archive))
except Exception:
- # TODO: log traceback in bench.log
- click.secho("An Error occurred while downloading assets...", fg="red")
- assets_setup = False
- finally:
- try:
- shutil.rmtree(os.path.dirname(assets_archive))
- except Exception:
- pass
+ pass
- return assets_setup
+ return False
def symlink(target, link_name, overwrite=False):
@@ -164,7 +182,7 @@ def symlink(target, link_name, overwrite=False):
# Create link to target with temporary filename
while True:
- temp_link_name = tempfile.mktemp(dir=link_dir)
+ temp_link_name = mktemp(dir=link_dir)
# os.* functions mimic as closely as possible system functions
# The POSIX symlink() returns EEXIST if link_name already exists
@@ -191,7 +209,8 @@ def symlink(target, link_name, overwrite=False):
def setup():
- global app_paths
+ global app_paths, assets_path
+
pymodules = []
for app in frappe.get_all_apps(True):
try:
@@ -199,251 +218,196 @@ def setup():
except ImportError:
pass
app_paths = [os.path.dirname(pymodule.__file__) for pymodule in pymodules]
+ assets_path = os.path.join(frappe.local.sites_path, "assets")
-def get_node_pacman():
- exec_ = find_executable("yarn")
- if exec_:
- return exec_
- raise ValueError("Yarn not found")
-
-
-def bundle(no_compress, app=None, make_copy=False, restore=False, verbose=False, skip_frappe=False):
+def bundle(mode, apps=None, hard_link=False, make_copy=False, restore=False, verbose=False, skip_frappe=False, files=None):
"""concat / minify js files"""
setup()
- make_asset_dirs(make_copy=make_copy, restore=restore)
+ make_asset_dirs(hard_link=hard_link)
- pacman = get_node_pacman()
- mode = "build" if no_compress else "production"
- command = "{pacman} run {mode}".format(pacman=pacman, mode=mode)
+ mode = "production" if mode == "production" else "build"
+ command = "yarn run {mode}".format(mode=mode)
- if app:
- command += " --app {app}".format(app=app)
+ if apps:
+ command += " --apps {apps}".format(apps=apps)
if skip_frappe:
command += " --skip_frappe"
- frappe_app_path = os.path.abspath(os.path.join(app_paths[0], ".."))
- check_yarn()
- frappe.commands.popen(command, cwd=frappe_app_path)
+ if files:
+ command += " --files {files}".format(files=','.join(files))
+
+ command += " --run-build-command"
+
+ check_node_executable()
+ frappe_app_path = frappe.get_app_path("frappe", "..")
+ frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env(), raise_err=True)
-def watch(no_compress):
+def watch(apps=None):
"""watch and rebuild if necessary"""
setup()
- pacman = get_node_pacman()
+ command = "yarn run watch"
+ if apps:
+ command += " --apps {apps}".format(apps=apps)
- frappe_app_path = os.path.abspath(os.path.join(app_paths[0], ".."))
- check_yarn()
+ live_reload = frappe.utils.cint(
+ os.environ.get("LIVE_RELOAD", frappe.conf.live_reload)
+ )
+
+ if live_reload:
+ command += " --live-reload"
+
+ check_node_executable()
frappe_app_path = frappe.get_app_path("frappe", "..")
- frappe.commands.popen("{pacman} run watch".format(pacman=pacman), cwd=frappe_app_path)
+ frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env())
-def check_yarn():
+def check_node_executable():
+ node_version = Version(subprocess.getoutput('node -v')[1:])
+ warn = '⚠️ '
+ if node_version.major < 14:
+ click.echo(f"{warn} Please update your node version to 14")
if not find_executable("yarn"):
- print("Please install yarn using below command and try again.\nnpm install -g yarn")
+ click.echo(f"{warn} Please install yarn using below command and try again.\nnpm install -g yarn")
+ click.echo()
-def make_asset_dirs(make_copy=False, restore=False):
- # don't even think of making assets_path absolute - rm -rf ahead.
- assets_path = os.path.join(frappe.local.sites_path, "assets")
+def get_node_env():
+ node_env = {
+ "NODE_OPTIONS": f"--max_old_space_size={get_safe_max_old_space_size()}"
+ }
+ return node_env
- for dir_path in [os.path.join(assets_path, "js"), os.path.join(assets_path, "css")]:
- if not os.path.exists(dir_path):
- os.makedirs(dir_path)
- for app_name in frappe.get_all_apps(True):
+def get_safe_max_old_space_size():
+ safe_max_old_space_size = 0
+ try:
+ total_memory = psutil.virtual_memory().total / (1024 * 1024)
+ # reference for the safe limit assumption
+ # https://nodejs.org/api/cli.html#cli_max_old_space_size_size_in_megabytes
+ # set minimum value 1GB
+ safe_max_old_space_size = max(1024, int(total_memory * 0.75))
+ except Exception:
+ pass
+
+ return safe_max_old_space_size
+
+
+def generate_assets_map():
+ symlinks = {}
+
+ for app_name in frappe.get_all_apps():
+ app_doc_path = None
+
pymodule = frappe.get_module(app_name)
app_base_path = os.path.abspath(os.path.dirname(pymodule.__file__))
-
- symlinks = []
app_public_path = os.path.join(app_base_path, "public")
- # app/public > assets/app
- symlinks.append([app_public_path, os.path.join(assets_path, app_name)])
- # app/node_modules > assets/app/node_modules
- if os.path.exists(os.path.abspath(app_public_path)):
- symlinks.append(
- [
- os.path.join(app_base_path, "..", "node_modules"),
- os.path.join(assets_path, app_name, "node_modules"),
- ]
- )
+ app_node_modules_path = os.path.join(app_base_path, "..", "node_modules")
+ app_docs_path = os.path.join(app_base_path, "docs")
+ app_www_docs_path = os.path.join(app_base_path, "www", "docs")
- app_doc_path = None
- if os.path.isdir(os.path.join(app_base_path, "docs")):
+ app_assets = os.path.abspath(app_public_path)
+ app_node_modules = os.path.abspath(app_node_modules_path)
+
+ # {app}/public > assets/{app}
+ if os.path.isdir(app_assets):
+ symlinks[app_assets] = os.path.join(assets_path, app_name)
+
+ # {app}/node_modules > assets/{app}/node_modules
+ if os.path.isdir(app_node_modules):
+ symlinks[app_node_modules] = os.path.join(assets_path, app_name, "node_modules")
+
+ # {app}/docs > assets/{app}_docs
+ if os.path.isdir(app_docs_path):
app_doc_path = os.path.join(app_base_path, "docs")
-
- elif os.path.isdir(os.path.join(app_base_path, "www", "docs")):
+ elif os.path.isdir(app_www_docs_path):
app_doc_path = os.path.join(app_base_path, "www", "docs")
-
if app_doc_path:
- symlinks.append([app_doc_path, os.path.join(assets_path, app_name + "_docs")])
+ app_docs = os.path.abspath(app_doc_path)
+ symlinks[app_docs] = os.path.join(assets_path, app_name + "_docs")
- for source, target in symlinks:
- source = os.path.abspath(source)
- if os.path.exists(source):
- if restore:
- if os.path.exists(target):
- if os.path.islink(target):
- os.unlink(target)
- else:
- shutil.rmtree(target)
- shutil.copytree(source, target)
- elif make_copy:
- if os.path.exists(target):
- warnings.warn("Target {target} already exists.".format(target=target))
- else:
- shutil.copytree(source, target)
- else:
- if os.path.exists(target):
- if os.path.islink(target):
- os.unlink(target)
- else:
- shutil.rmtree(target)
- try:
- symlink(source, target, overwrite=True)
- except OSError:
- print("Cannot link {} to {}".format(source, target))
- else:
- # warnings.warn('Source {source} does not exist.'.format(source = source))
- pass
+ return symlinks
-def build(no_compress=False, verbose=False):
- assets_path = os.path.join(frappe.local.sites_path, "assets")
-
- for target, sources in iteritems(get_build_maps()):
- pack(os.path.join(assets_path, target), sources, no_compress, verbose)
+def setup_assets_dirs():
+ for dir_path in (os.path.join(assets_path, x) for x in ("js", "css")):
+ os.makedirs(dir_path, exist_ok=True)
-def get_build_maps():
- """get all build.jsons with absolute paths"""
- # framework js and css files
-
- build_maps = {}
- for app_path in app_paths:
- path = os.path.join(app_path, "public", "build.json")
- if os.path.exists(path):
- with open(path) as f:
- try:
- for target, sources in iteritems(json.loads(f.read())):
- # update app path
- source_paths = []
- for source in sources:
- if isinstance(source, list):
- s = frappe.get_pymodule_path(source[0], *source[1].split("/"))
- else:
- s = os.path.join(app_path, source)
- source_paths.append(s)
-
- build_maps[target] = source_paths
- except ValueError as e:
- print(path)
- print("JSON syntax error {0}".format(str(e)))
- return build_maps
+def clear_broken_symlinks():
+ for path in os.listdir(assets_path):
+ path = os.path.join(assets_path, path)
+ if os.path.islink(path) and not os.path.exists(path):
+ os.remove(path)
-def pack(target, sources, no_compress, verbose):
- from six import StringIO
+def unstrip(message: str) -> str:
+ """Pads input string on the right side until the last available column in the terminal
+ """
+ _len = len(message)
+ try:
+ max_str = os.get_terminal_size().columns
+ except Exception:
+ max_str = 80
- outtype, outtxt = target.split(".")[-1], ""
- jsm = JavascriptMinify()
+ if _len < max_str:
+ _rem = max_str - _len
+ else:
+ _rem = max_str % _len
- for f in sources:
- suffix = None
- if ":" in f:
- f, suffix = f.split(":")
- if not os.path.exists(f) or os.path.isdir(f):
- print("did not find " + f)
- continue
- timestamps[f] = os.path.getmtime(f)
+ return f"{message}{' ' * _rem}"
+
+
+def make_asset_dirs(hard_link=False):
+ setup_assets_dirs()
+ clear_broken_symlinks()
+ symlinks = generate_assets_map()
+
+ for source, target in symlinks.items():
+ start_message = unstrip(f"{'Copying assets from' if hard_link else 'Linking'} {source} to {target}")
+ fail_message = unstrip(f"Cannot {'copy' if hard_link else 'link'} {source} to {target}")
+
+ # Used '\r' instead of '\x1b[1K\r' to print entire lines in smaller terminal sizes
try:
- with open(f, "r") as sourcefile:
- data = text_type(sourcefile.read(), "utf-8", errors="ignore")
-
- extn = f.rsplit(".", 1)[1]
-
- if (
- outtype == "js"
- and extn == "js"
- and (not no_compress)
- and suffix != "concat"
- and (".min." not in f)
- ):
- tmpin, tmpout = StringIO(data.encode("utf-8")), StringIO()
- jsm.minify(tmpin, tmpout)
- minified = tmpout.getvalue()
- if minified:
- outtxt += text_type(minified or "", "utf-8").strip("\n") + ";"
-
- if verbose:
- print("{0}: {1}k".format(f, int(len(minified) / 1024)))
- elif outtype == "js" and extn == "html":
- # add to frappe.templates
- outtxt += html_to_js_template(f, data)
- else:
- outtxt += "\n/*\n *\t%s\n */" % f
- outtxt += "\n" + data + "\n"
-
+ print(start_message, end="\r")
+ link_assets_dir(source, target, hard_link=hard_link)
except Exception:
- print("--Error in:" + f + "--")
- print(frappe.get_traceback())
+ print(fail_message, end="\r")
- with open(target, "w") as f:
- f.write(outtxt.encode("utf-8"))
+ click.echo(unstrip(click.style("✔", fg="green") + " Application Assets Linked") + "\n")
- print("Wrote %s - %sk" % (target, str(int(os.path.getsize(target) / 1024))))
+
+def link_assets_dir(source, target, hard_link=False):
+ if not os.path.exists(source):
+ return
+
+ if os.path.exists(target):
+ if os.path.islink(target):
+ os.unlink(target)
+ else:
+ shutil.rmtree(target)
+
+ if hard_link:
+ shutil.copytree(source, target, dirs_exist_ok=True)
+ else:
+ symlink(source, target, overwrite=True)
+
+
+def scrub_html_template(content):
+ """Returns HTML content with removed whitespace and comments"""
+ # remove whitespace to a single space
+ content = re.sub(r"\s+", " ", content)
+
+ # strip comments
+ content = re.sub(r"()", "", content)
+
+ return content.replace("'", "\'")
def html_to_js_template(path, content):
"""returns HTML template content as Javascript code, adding it to `frappe.templates`"""
return """frappe.templates["{key}"] = '{content}';\n""".format(
key=path.rsplit("/", 1)[-1][:-5], content=scrub_html_template(content))
-
-
-def scrub_html_template(content):
- """Returns HTML content with removed whitespace and comments"""
- # remove whitespace to a single space
- content = re.sub("\s+", " ", content)
-
- # strip comments
- content = re.sub("()", "", content)
-
- return content.replace("'", "\'")
-
-
-def files_dirty():
- for target, sources in iteritems(get_build_maps()):
- for f in sources:
- if ":" in f:
- f, suffix = f.split(":")
- if not os.path.exists(f) or os.path.isdir(f):
- continue
- if os.path.getmtime(f) != timestamps.get(f):
- print(f + " dirty")
- return True
- else:
- return False
-
-
-def compile_less():
- if not find_executable("lessc"):
- return
-
- for path in app_paths:
- less_path = os.path.join(path, "public", "less")
- if os.path.exists(less_path):
- for fname in os.listdir(less_path):
- if fname.endswith(".less") and fname != "variables.less":
- fpath = os.path.join(less_path, fname)
- mtime = os.path.getmtime(fpath)
- if fpath in timestamps and mtime == timestamps[fpath]:
- continue
-
- timestamps[fpath] = mtime
-
- print("compiling {0}".format(fpath))
-
- css_path = os.path.join(path, "public", "css", fname.rsplit(".", 1)[0] + ".css")
- os.system("lessc {0} > {1}".format(fpath, css_path))
diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py
index bad879d2fa..94a845639b 100644
--- a/frappe/cache_manager.py
+++ b/frappe/cache_manager.py
@@ -1,7 +1,5 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe, json
from frappe.model.document import Document
@@ -13,12 +11,14 @@ common_default_keys = ["__default", "__global"]
doctype_map_keys = ('energy_point_rule_map', 'assignment_rule_map',
'milestone_tracker_map', 'event_consumer_document_type_map')
+bench_cache_keys = ('assets_json',)
+
global_cache_keys = ("app_hooks", "installed_apps", 'all_apps',
"app_modules", "module_app", "system_settings",
'scheduler_events', 'time_zone', 'webhooks', 'active_domains',
'active_modules', 'assignment_rule', 'server_script_map', 'wkhtmltopdf_version',
'domain_restricted_doctypes', 'domain_restricted_pages', 'information_schema:counts',
- 'sitemap_routes', 'db_tables') + doctype_map_keys
+ 'sitemap_routes', 'db_tables', 'server_script_autocompletion_items') + doctype_map_keys
user_cache_keys = ("bootinfo", "user_recent", "roles", "user_doc", "lang",
"defaults", "user_permissions", "home_page", "linked_with",
@@ -53,11 +53,12 @@ def clear_domain_cache(user=None):
cache.delete_value(domain_cache_keys)
def clear_global_cache():
- from frappe.website.render import clear_cache as clear_website_cache
+ from frappe.website.utils import clear_website_cache
clear_doctype_cache()
clear_website_cache()
frappe.cache().delete_value(global_cache_keys)
+ frappe.cache().delete_value(bench_cache_keys)
frappe.setup_module_map()
def clear_defaults_cache(user=None):
@@ -140,19 +141,14 @@ def build_table_count_cache():
return
_cache = frappe.cache()
- data = frappe.db.multisql({
- "mariadb": """
- SELECT table_name AS name,
- table_rows AS count
- FROM information_schema.tables""",
- "postgres": """
- SELECT "relname" AS name,
- "n_tup_ins" AS count
- FROM "pg_stat_all_tables"
- """
- }, as_dict=1)
+ 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")
- counts = {d.get('name').lstrip('tab'): d.get('count', None) for d in data}
+ 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}
_cache.set_value("information_schema:counts", counts)
return counts
diff --git a/frappe/change_log/v13/v13_0_0.md b/frappe/change_log/v13/v13_0_0.md
new file mode 100644
index 0000000000..e1b6639076
--- /dev/null
+++ b/frappe/change_log/v13/v13_0_0.md
@@ -0,0 +1,54 @@
+# Version 13.0.0 Release Notes
+
+## Highlights
+
+- Re-branded UI 💎 ✨🎊 ([#12277](https://github.com/frappe/frappe/pull/12277))
+- New Page Builder in Web Page ([#10035](https://github.com/frappe/frappe/pull/10035))
+- Customizable desk ([#9617](https://github.com/frappe/frappe/pull/9617))
+- Custom Dashboard for DocTypes ([#9872](https://github.com/frappe/frappe/pull/9872))
+- Widgets to make dashboards ([#9693](https://github.com/frappe/frappe/pull/9693))
+- Events Streaming ([#8567](https://github.com/frappe/frappe/pull/8567))
+- Contextual translation and Translation Tool ([#9636](https://github.com/frappe/frappe/pull/9636))
+
+### Other Features & Enhancements
+
+- Added permission to grant only `Select` access ([#12063](https://github.com/frappe/frappe/pull/12063))
+- Add columns and filters for reports via configuration ([#11287](https://github.com/frappe/frappe/pull/11287))
+- Configurable Navbar logo and dropdowns ([#11213](https://github.com/frappe/frappe/pull/11213))
+- Rule based naming of documents ([#11439](https://github.com/frappe/frappe/pull/11439))
+- New routing style, not using hashes, also /desk -> /app ([#11917](https://github.com/frappe/frappe/pull/11917))
+- Web Page tracking ([#9959](https://github.com/frappe/frappe/pull/9959))
+- Introduced "Yesterday" and "Tomorrow" options for Timespan filter ([12179](https://github.com/frappe/frappe/pull/12179))
+- Child table pagination ([#8786](https://github.com/frappe/frappe/pull/8786))
+- Introduced Duration Control ([#10248](https://github.com/frappe/frappe/pull/10248))
+- Form Tour feature ([#10287](https://github.com/frappe/frappe/pull/10287))
+
+More
+
+- Introduced Map View ([#11202](https://github.com/frappe/frappe/pull/11202))
+- Custom JS & CSS support in Web Form ([#9121](https://github.com/frappe/frappe/pull/9121)) ([#9610](https://github.com/frappe/frappe/pull/9610))
+- Ability to attach photo from webcam ([#12160](https://github.com/frappe/frappe/pull/12160))
+- Added a System Console to help in debugging ([#11306](https://github.com/frappe/frappe/pull/11306))
+- Introduced System Settings to automatically delete old Prepared Reports ([#9751](https://github.com/frappe/frappe/pull/9751))
+- "Mandatory Depends On" and "Read Only Depends On" option for document fields ([#8820](https://github.com/frappe/frappe/pull/8820))
+- Added 2FA for LDAP users ([#10001](https://github.com/frappe/frappe/pull/10001))
+- Introduced Help Article Feedback system ([#10260](https://github.com/frappe/frappe/pull/10260))
+- Introduced Razorpay client ([#11418](https://github.com/frappe/frappe/pull/11418))
+- Rate Limiting ([#10310](https://github.com/frappe/frappe/pull/10310))
+- Introduced Log Settings ([#11699](https://github.com/frappe/frappe/pull/11699))
+- Enhancements in notifications ([#11398](https://github.com/frappe/frappe/pull/11398)) ([#11409](https://github.com/frappe/frappe/pull/11409))
+- Added a field-level permission check for report data ([12163](https://github.com/frappe/frappe/pull/12163))
+- Ability to cancel all linked document with a single click ([#8905](https://github.com/frappe/frappe/pull/8905))
+- Made checkboxes navigable via tab key ([#11030](https://github.com/frappe/frappe/pull/11030))
+- Renamed "Custom Script" to "Client Script" ([#12324](https://github.com/frappe/frappe/pull/12324))
+
+
+
+### Performance
+
+- Faster application load ([#12364](https://github.com/frappe/frappe/pull/12364)) ([#10229](https://github.com/frappe/frappe/pull/10229)) ([#10147](https://github.com/frappe/frappe/pull/10147)) ([#9930](https://github.com/frappe/frappe/pull/9930))
+- Theme files will now be compressed to make the website load faster ([#11048](https://github.com/frappe/frappe/pull/11048))
+- Confirmation emails will be sent instantly ([#10790](https://github.com/frappe/frappe/pull/10790))
+- Faster scheduled job processing ([#9928](https://github.com/frappe/frappe/pull/9928))
+- Faster data imports ([#12565](https://github.com/frappe/frappe/pull/12565))
+- Faster CLI commands ([#12447](https://github.com/frappe/frappe/pull/12447))
diff --git a/frappe/change_log/v13/v13_1_0.md b/frappe/change_log/v13/v13_1_0.md
new file mode 100644
index 0000000000..87c3bd0906
--- /dev/null
+++ b/frappe/change_log/v13/v13_1_0.md
@@ -0,0 +1,22 @@
+# Version 13.1.0 Release Notes
+
+### Features & Enhancements
+
+- Automated mail notifications will be shown in timeline ([#12693](https://github.com/frappe/frappe/pull/12693))
+- Introduced Client Script for List views ([#12590](https://github.com/frappe/frappe/pull/12590))
+- Introduced language switcher for guest users on website navbar ([#12813](https://github.com/frappe/frappe/pull/12813))
+- Option to give submit permission while sharing a document ([#12799](https://github.com/frappe/frappe/pull/12799))
+- Added option to set `autoname` in Customize Form ([#12413](https://github.com/frappe/frappe/pull/12413))
+- Virtual DocType ([#12121](https://github.com/frappe/frappe/pull/12121))
+
+### Fixes
+
+- Workspace fixes ([#12650](https://github.com/frappe/frappe/pull/12650)) ([#12655](https://github.com/frappe/frappe/pull/12655)) ([#12869](https://github.com/frappe/frappe/pull/12869))
+- Fixed an issue where select options were not getting updated in Grid ([#12839](https://github.com/frappe/frappe/pull/12839))
+- Webform Fixes ([#12630](https://github.com/frappe/frappe/pull/12630)) ([#12756](https://github.com/frappe/frappe/pull/12756)) ([#12819](https://github.com/frappe/frappe/pull/12819))
+- Fixed timespan filter for next and last timespans ([#12509](https://github.com/frappe/frappe/pull/12509))
+- System Notification fixes ([#12719](https://github.com/frappe/frappe/pull/12719))
+- Design Fixes ([#12669](https://github.com/frappe/frappe/pull/12669)) ([#12591](https://github.com/frappe/frappe/pull/12591)) ([#12557](https://github.com/frappe/frappe/pull/12557)) ([#12751](https://github.com/frappe/frappe/pull/12751)) ([#12864](https://github.com/frappe/frappe/pull/12864))
+- Fixed Multi-column paste in grid ([#12861](https://github.com/frappe/frappe/pull/12861))
+- Fixed grid validation ([#12744](https://github.com/frappe/frappe/pull/12744))
+- Fixed currency value formatting in dashboard chart ([#12613](https://github.com/frappe/frappe/pull/12613))
diff --git a/frappe/change_log/v13/v13_2_0.md b/frappe/change_log/v13/v13_2_0.md
new file mode 100644
index 0000000000..6fc3eec5e3
--- /dev/null
+++ b/frappe/change_log/v13/v13_2_0.md
@@ -0,0 +1,32 @@
+# Version 13.2.0 Release Notes
+
+### Features & Enhancements
+
+- Add option to mention a group of users ([#12844](https://github.com/frappe/frappe/pull/12844))
+- Copy DocType / documents across sites ([#12872](https://github.com/frappe/frappe/pull/12872))
+- Scheduler log in notifications ([#1135](https://github.com/frappe/frappe/pull/1135))
+- Add Enable/Disable Webhook via Check Field ([#12842](https://github.com/frappe/frappe/pull/12842))
+- Allow query/custom reports to save custom data in the json field ([#12534](https://github.com/frappe/frappe/pull/12534))
+
+### Fixes
+
+- Load server translations in boot (backport #12848) ([#12852](https://github.com/frappe/frappe/pull/12852))
+- Allow to override dashboard chart properties type/color ([#12846](https://github.com/frappe/frappe/pull/12846))
+- Multi-column paste in grid ([#12861](https://github.com/frappe/frappe/pull/12861))
+- Add log_error and FrappeClient to restricted python ([#12857](https://github.com/frappe/frappe/pull/12857))
+- Redirect Web Form user directly to success URL, if no amount is due ([#12661](https://github.com/frappe/frappe/pull/12661))
+- Attachment pill lock icon redirects to File ([#12864](https://github.com/frappe/frappe/pull/12864))
+- Redirect Web Form user directly to success URL, if no amount is due (backport #12661) ([#12856](https://github.com/frappe/frappe/pull/12856))
+- Remove events to redraw charts ([#12973](https://github.com/frappe/frappe/pull/12973))
+- Don't allow user to remove/change data source file in data import ([#12827](https://github.com/frappe/frappe/pull/12827))
+- Load server translations in boot ([#12848](https://github.com/frappe/frappe/pull/12848))
+- Newly created Workspace not being accessible unless a shortcut u… ([#12866](https://github.com/frappe/frappe/pull/12866))
+- Currency labels in grids ([#12974](https://github.com/frappe/frappe/pull/12974))
+- Handle error while session start ([#12933](https://github.com/frappe/frappe/pull/12933))
+- Add field type check in custom field validation ([#12858](https://github.com/frappe/frappe/pull/12858))
+- Make language select optional and fix breakpoint issues ([#12860](https://github.com/frappe/frappe/pull/12860))
+- Form Dashboard reference link ([#12945](https://github.com/frappe/frappe/pull/12945))
+- Invalid HTML generated by the base template ([#12953](https://github.com/frappe/frappe/pull/12953))
+- Default values were not triggering change event ([#12975](https://github.com/frappe/frappe/pull/12975))
+- Make strings translatable ([#12877](https://github.com/frappe/frappe/pull/12877))
+- Added build-message-files command ([#12950](https://github.com/frappe/frappe/pull/12950))
\ No newline at end of file
diff --git a/frappe/change_log/v13/v13_3_0.md b/frappe/change_log/v13/v13_3_0.md
new file mode 100644
index 0000000000..6ab181ef09
--- /dev/null
+++ b/frappe/change_log/v13/v13_3_0.md
@@ -0,0 +1,49 @@
+# Version 13.3.0 Release Notes
+
+### Features & Enhancements
+
+- Deletion Steps in Data Deletion Tool ([#13124](https://github.com/frappe/frappe/pull/13124))
+- Format Option for list-apps in bench CLI ([#13125](https://github.com/frappe/frappe/pull/13125))
+- Add password fieldtype option for Web Form ([#13093](https://github.com/frappe/frappe/pull/13093))
+- Add simple __repr__ for DocTypes ([#13151](https://github.com/frappe/frappe/pull/13151))
+- Switch theme with left/right keys ([#13077](https://github.com/frappe/frappe/pull/13077))
+- sourceURL for injected javascript ([#13022](https://github.com/frappe/frappe/pull/13022))
+
+### Fixes
+
+- Decode uri before importing file via weblink ([#13026](https://github.com/frappe/frappe/pull/13026))
+- Respond to /api requests as JSON by default ([#13053](https://github.com/frappe/frappe/pull/13053))
+- Disabled checkbox should be disabled ([#13021](https://github.com/frappe/frappe/pull/13021))
+- Moving Site folder across different FileSystems failed ([#13038](https://github.com/frappe/frappe/pull/13038))
+- Freeze screen till the background request is complete ([#13078](https://github.com/frappe/frappe/pull/13078))
+- Added conditional rendering for content field in split section w… ([#13075](https://github.com/frappe/frappe/pull/13075))
+- Show delete button on portal if user has permission to delete document ([#13149](https://github.com/frappe/frappe/pull/13149))
+- Dont disable dialog scroll on focusing a Link/Autocomplete field ([#13119](https://github.com/frappe/frappe/pull/13119))
+- Typo in RecorderDetail.vue ([#13086](https://github.com/frappe/frappe/pull/13086))
+- Error for bench drop-site. Added missing import. ([#13064](https://github.com/frappe/frappe/pull/13064))
+- Report column context ([#13090](https://github.com/frappe/frappe/pull/13090))
+- Different service name for push and pull request events ([#13094](https://github.com/frappe/frappe/pull/13094))
+- Moving Site folder across different FileSystems failed ([#13033](https://github.com/frappe/frappe/pull/13033))
+- Consistent checkboxes on all browsers ([#13042](https://github.com/frappe/frappe/pull/13042))
+- Changed shorcut widgets color picker to dropdown ([#13073](https://github.com/frappe/frappe/pull/13073))
+- Error while exporting reports with duration field ([#13118](https://github.com/frappe/frappe/pull/13118))
+- Add margin to download backup card ([#13079](https://github.com/frappe/frappe/pull/13079))
+- Move mention list generation logic to server-side ([#13074](https://github.com/frappe/frappe/pull/13074))
+- Make strings translatable ([#13046](https://github.com/frappe/frappe/pull/13046))
+- Don't evaluate dynamic properties to check if conflicts exist ([#13186](https://github.com/frappe/frappe/pull/13186))
+- Add __ function in vue global for translation in recorder ([#13089](https://github.com/frappe/frappe/pull/13089))
+- Make strings translatable ([#13076](https://github.com/frappe/frappe/pull/13076))
+- Show config in bench CLI ([#13128](https://github.com/frappe/frappe/pull/13128))
+- Add breadcrumbs for list view ([#13091](https://github.com/frappe/frappe/pull/13091))
+- Do not skip data in save while using shortcut ([#13182](https://github.com/frappe/frappe/pull/13182))
+- Use docfields from options if no docfields are returned from meta ([#13188](https://github.com/frappe/frappe/pull/13188))
+- Disable reloading files in `__pycache__` directory ([#13109](https://github.com/frappe/frappe/pull/13109))
+- RTL stylesheet route to load RTL style on demand. ([#13007](https://github.com/frappe/frappe/pull/13007))
+- Do not show messsage when exception is handled ([#13111](https://github.com/frappe/frappe/pull/13111))
+- Replace parseFloat by Number ([#13082](https://github.com/frappe/frappe/pull/13082))
+- Add margin to download backup card ([#13050](https://github.com/frappe/frappe/pull/13050))
+- Translate report column labels ([#13083](https://github.com/frappe/frappe/pull/13083))
+- Grid row color picker field not working ([#13040](https://github.com/frappe/frappe/pull/13040))
+- Improve oauthlib implementation ([#13045](https://github.com/frappe/frappe/pull/13045))
+- Replace filter_by like with full text filter ([#13126](https://github.com/frappe/frappe/pull/13126))
+- Focus jumps to first field ([#13067](https://github.com/frappe/frappe/pull/13067))
\ No newline at end of file
diff --git a/frappe/chat/__init__.py b/frappe/chat/__init__.py
deleted file mode 100644
index dea0030839..0000000000
--- a/frappe/chat/__init__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from __future__ import unicode_literals
-import frappe
-from frappe import _
-
-session = frappe.session
-
-def authenticate(user, raise_err = True):
- if session.user == 'Guest':
- if not frappe.db.exists('Chat Token', user):
- if raise_err:
- frappe.throw(_("Sorry, you're not authorized."))
- else:
- return False
- else:
- return True
- else:
- if user != session.user:
- if raise_err:
- frappe.throw(_("Sorry, you're not authorized."))
- else:
- return False
- else:
- return True
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_message/chat_message.js b/frappe/chat/doctype/chat_message/chat_message.js
deleted file mode 100644
index edaad011db..0000000000
--- a/frappe/chat/doctype/chat_message/chat_message.js
+++ /dev/null
@@ -1,10 +0,0 @@
-// Copyright (c) 2017, Frappe Technologies and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on('Chat Message', {
- onload: function(frm) {
- if(frm.doc.type == 'File') {
- frm.set_df_property('content', 'read_only', 1);
- }
- }
-});
diff --git a/frappe/chat/doctype/chat_message/chat_message.json b/frappe/chat/doctype/chat_message/chat_message.json
deleted file mode 100644
index 9d2d70c5e0..0000000000
--- a/frappe/chat/doctype/chat_message/chat_message.json
+++ /dev/null
@@ -1,91 +0,0 @@
-{
- "beta": 1,
- "creation": "2017-11-10 11:10:40.011099",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "room_type",
- "type",
- "user",
- "room",
- "content",
- "mentions",
- "urls"
- ],
- "fields": [
- {
- "fieldname": "room_type",
- "fieldtype": "Select",
- "in_list_view": 1,
- "label": "Room Type",
- "options": "Direct\nGroup\nVisitor",
- "reqd": 1
- },
- {
- "fieldname": "type",
- "fieldtype": "Data",
- "label": "Type",
- "options": "Content\nFile"
- },
- {
- "fieldname": "user",
- "fieldtype": "Link",
- "hidden": 1,
- "label": "User",
- "options": "User",
- "read_only": 1
- },
- {
- "fieldname": "room",
- "fieldtype": "Link",
- "label": "Room",
- "options": "Chat Room",
- "reqd": 1
- },
- {
- "fieldname": "content",
- "fieldtype": "Text",
- "label": "Content",
- "reqd": 1
- },
- {
- "fieldname": "mentions",
- "fieldtype": "Code",
- "hidden": 1,
- "label": "Mentions"
- },
- {
- "fieldname": "urls",
- "fieldtype": "Data",
- "hidden": 1,
- "label": "URLs"
- }
- ],
- "modified": "2020-09-18 17:26:09.703215",
- "modified_by": "Administrator",
- "module": "Chat",
- "name": "Chat Message",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "share": 1,
- "write": 1
- }
- ],
- "quick_entry": 1,
- "search_fields": "content, user",
- "sort_field": "modified",
- "sort_order": "DESC",
- "title_field": "content",
- "track_changes": 1,
- "track_seen": 1
-}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_message/chat_message.py b/frappe/chat/doctype/chat_message/chat_message.py
deleted file mode 100644
index 5549aaa657..0000000000
--- a/frappe/chat/doctype/chat_message/chat_message.py
+++ /dev/null
@@ -1,217 +0,0 @@
-from __future__ import unicode_literals
-
-# imports - standard imports
-import json
-
-# imports - third-party imports
-import requests
-from bs4 import BeautifulSoup as Soup
-
-# imports - module imports
-from frappe.model.document import Document
-from frappe import _, _dict
-import frappe
-
-# imports - frappe module imports
-from frappe.chat import authenticate
-from frappe.chat.util import (
- get_if_empty,
- check_url,
- dictify,
- get_emojis,
- safe_json_loads,
- get_user_doc,
- squashify
-)
-
-session = frappe.session
-
-class ChatMessage(Document):
- pass
-
-def get_message_urls(content):
- soup = Soup(content, 'html.parser')
- anchors = soup.find_all('a')
- urls = [ ]
-
- for anchor in anchors:
- text = anchor.text
-
- if check_url(text):
- urls.append(text)
-
- return urls
-
-def get_message_mentions(content):
- mentions = [ ]
- tokens = content.split(' ')
-
- for token in tokens:
- if token.startswith('@'):
- what = token[1:]
- if frappe.db.exists('User', what):
- mentions.append(what)
- else:
- if frappe.db.exists('User', token):
- mentions.append(token)
-
- return mentions
-
-def get_message_meta(content):
- '''
- Assumes content to be HTML. Sanitizes the content
- into a dict of metadata values.
- '''
- meta = _dict(
- links = [ ],
- mentions = [ ]
- )
-
- meta.content = content
- meta.urls = get_message_urls(content)
- meta.mentions = get_message_mentions(content)
-
- return meta
-
-def sanitize_message_content(content):
- emojis = get_emojis()
-
- tokens = content.split(' ')
- for token in tokens:
- if token.startswith(':') and token.endswith(':'):
- what = token[1:-1]
-
- # Expensive, I know.
- for emoji in emojis:
- for alias in emoji.aliases:
- if what == alias:
- content = content.replace(token, emoji.emoji)
-
- return content
-
-def get_new_chat_message_doc(user, room, content, type = "Content", link = True):
- user = get_user_doc(user)
- room = frappe.get_doc('Chat Room', room)
-
- meta = get_message_meta(content)
- mess = frappe.new_doc('Chat Message')
- mess.room = room.name
- mess.room_type = room.type
- mess.content = sanitize_message_content(content)
- mess.type = type
- mess.user = user.name
-
- mess.mentions = json.dumps(meta.mentions)
- mess.urls = ','.join(meta.urls)
- mess.save(ignore_permissions = True)
-
- if link:
- room.update(dict(
- last_message = mess.name
- ))
- room.save(ignore_permissions = True)
-
- return mess
-
-def get_new_chat_message(user, room, content, type = "Content"):
- mess = get_new_chat_message_doc(user, room, content, type)
-
- resp = dict(
- name = mess.name,
- user = mess.user,
- room = mess.room,
- room_type = mess.room_type,
- content = json.loads(mess.content) if mess.type in ["File"] else mess.content,
- urls = mess.urls,
- mentions = json.loads(mess.mentions),
- creation = mess.creation,
- seen = json.loads(mess._seen) if mess._seen else [ ],
- )
-
- return resp
-
-@frappe.whitelist(allow_guest = True)
-def send(user, room, content, type = "Content"):
- mess = get_new_chat_message(user, room, content, type)
-
- frappe.publish_realtime('frappe.chat.message:create', mess, room = room,
- after_commit = True)
-
-@frappe.whitelist(allow_guest = True)
-def seen(message, user = None):
- authenticate(user)
-
- has_message = frappe.db.exists('Chat Message', message)
-
- if has_message:
- mess = frappe.get_doc('Chat Message', message)
- mess.add_seen(user)
- mess.load_from_db()
- room = mess.room
- resp = dict(message = message, data = dict(seen = json.loads(mess._seen) if mess._seen else []))
-
- frappe.publish_realtime('frappe.chat.message:update', resp, room = room, after_commit = True)
-
-def history(room, fields = None, limit = 10, start = None, end = None):
- room = frappe.get_doc('Chat Room', room)
- mess = frappe.get_all('Chat Message',
- filters = [
- ('Chat Message', 'room', '=', room.name),
- ('Chat Message', 'room_type', '=', room.type)
- ],
- fields = fields if fields else [
- 'name', 'room_type', 'room', 'content', 'type', 'user', 'mentions', 'urls', 'creation', '_seen'
- ],
- order_by = 'creation'
- )
-
- if not fields or 'seen' in fields:
- for m in mess:
- m['seen'] = json.loads(m._seen) if m._seen else [ ]
- del m['_seen']
- if not fields or 'content' in fields:
- for m in mess:
- m['content'] = json.loads(m.content) if m.type in ["File"] else m.content
-
- frappe.enqueue('frappe.chat.doctype.chat_message.chat_message.mark_messages_as_seen',
- message_names=[m.name for m in mess], user=frappe.session.user)
-
- return mess
-
-def mark_messages_as_seen(message_names, user):
- '''
- Marks chat messages as seen, updates the _seen for each message
- (should be run in background process)
- '''
- for name in message_names:
- seen = frappe.db.get_value('Chat Message', name, '_seen') or '[]'
- seen = json.loads(seen)
- seen.append(user)
- seen = json.dumps(seen)
- frappe.db.set_value('Chat Message', name, '_seen', seen, update_modified=False)
-
- frappe.db.commit()
-
-
-@frappe.whitelist()
-def get(name, rooms = None, fields = None):
- rooms, fields = safe_json_loads(rooms, fields)
-
- has_message = frappe.db.exists('Chat Message', name)
-
- if has_message:
- dmess = frappe.get_doc('Chat Message', name)
- data = dict(
- name = dmess.name,
- user = dmess.user,
- room = dmess.room,
- room_type = dmess.room_type,
- content = json.loads(dmess.content) if dmess.type in ["File"] else dmess.content,
- type = dmess.type,
- urls = dmess.urls,
- mentions = dmess.mentions,
- creation = dmess.creation,
- seen = get_if_empty(dmess._seen, [ ])
- )
-
- return data
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_message/chat_message_list.js b/frappe/chat/doctype/chat_message/chat_message_list.js
deleted file mode 100644
index c5b717048b..0000000000
--- a/frappe/chat/doctype/chat_message/chat_message_list.js
+++ /dev/null
@@ -1,8 +0,0 @@
-frappe.listview_settings['Chat Message'] = {
- filters: [
- ['Chat Message', 'user', '==', frappe.session.user, true]
- // I need an or_filter here.
- // ['Chat Room', 'owner', '==', frappe.session.user, true],
- // ['Chat Room', frappe.session.user, 'in', 'users', true]
- ]
-};
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_profile/chat_profile.js b/frappe/chat/doctype/chat_profile/chat_profile.js
deleted file mode 100644
index b27a98faf5..0000000000
--- a/frappe/chat/doctype/chat_profile/chat_profile.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/* eslint semi: "never" */
-frappe.ui.form.on('Chat Profile', {
- refresh: function (form) {
- if ( form.doc.name !== frappe.session.user ) {
- form.disable_save()
- form.set_read_only(true)
- // There's one more that faris@frappe.io told me to add here. form.refresh_fields()?
- }
- }
-});
diff --git a/frappe/chat/doctype/chat_profile/chat_profile.json b/frappe/chat/doctype/chat_profile/chat_profile.json
deleted file mode 100644
index eb36f803fe..0000000000
--- a/frappe/chat/doctype/chat_profile/chat_profile.json
+++ /dev/null
@@ -1,98 +0,0 @@
-{
- "autoname": "field:user",
- "beta": 1,
- "creation": "2017-11-13 18:26:57.943027",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "user",
- "status",
- "chat_background",
- "notifications",
- "message_preview",
- "notification_tones",
- "conversation_tones",
- "settings",
- "enable_chat"
- ],
- "fields": [
- {
- "fieldname": "user",
- "fieldtype": "Link",
- "label": "User",
- "options": "User",
- "reqd": 1
- },
- {
- "default": "Online",
- "fieldname": "status",
- "fieldtype": "Select",
- "in_list_view": 1,
- "label": "Status",
- "options": "Online\nAway\nBusy\nOffline"
- },
- {
- "fieldname": "chat_background",
- "fieldtype": "Attach Image",
- "label": "Chat Background"
- },
- {
- "fieldname": "notifications",
- "fieldtype": "Section Break",
- "label": "Notifications"
- },
- {
- "default": "1",
- "fieldname": "message_preview",
- "fieldtype": "Check",
- "label": "Message Preview"
- },
- {
- "default": "1",
- "fieldname": "notification_tones",
- "fieldtype": "Check",
- "label": "Notification Tones"
- },
- {
- "default": "1",
- "fieldname": "conversation_tones",
- "fieldtype": "Check",
- "label": "Conversation Tones"
- },
- {
- "fieldname": "settings",
- "fieldtype": "Section Break",
- "label": "Settings"
- },
- {
- "default": "1",
- "fieldname": "enable_chat",
- "fieldtype": "Check",
- "label": "Enable Chat"
- }
- ],
- "in_create": 1,
- "modified": "2019-11-07 13:21:36.414961",
- "modified_by": "Administrator",
- "module": "Chat",
- "name": "Chat Profile",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "share": 1,
- "write": 1
- }
- ],
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_profile/chat_profile.py b/frappe/chat/doctype/chat_profile/chat_profile.py
deleted file mode 100644
index 698d992d35..0000000000
--- a/frappe/chat/doctype/chat_profile/chat_profile.py
+++ /dev/null
@@ -1,100 +0,0 @@
-from __future__ import unicode_literals
-
-# imports - module imports
-from frappe.model.document import Document
-from frappe import _
-import frappe
-
-# imports - frappe module imports
-from frappe.core.doctype.version.version import get_diff
-from frappe.chat.doctype.chat_room import chat_room
-from frappe.chat.util import (
- safe_json_loads,
- filter_dict,
- dictify
-)
-
-session = frappe.session
-
-class ChatProfile(Document):
- def on_update(self):
- if not self.is_new():
- b, a = self.get_doc_before_save(), self
- diff = dictify(get_diff(a, b))
- if diff:
- user = session.user
-
- fields = [changed[0] for changed in diff.changed]
-
- if 'status' in fields:
- rooms = chat_room.get(user, filters = ['Chat Room', 'type', '=', 'Direct'])
- update = dict(user = user, data = dict(status = self.status))
-
- for room in rooms:
- frappe.publish_realtime('frappe.chat.profile:update', update, room = room.name, after_commit = True)
-
- if 'enable_chat' in fields:
- update = dict(user = user, data = dict(enable_chat = bool(self.enable_chat)))
- frappe.publish_realtime('frappe.chat.profile:update', update, user = user, after_commit = True)
-
-def authenticate(user):
- if user != session.user:
- frappe.throw(_("Sorry, you're not authorized."))
-
-@frappe.whitelist()
-def get(user, fields = None):
- duser = frappe.get_doc('User', user)
-
- if frappe.db.exists('Chat Profile', user):
- dprof = frappe.get_doc('Chat Profile', user)
-
- # If you're adding something here, make sure the client recieves it.
- profile = dict(
- # User
- name = duser.name,
- email = duser.email,
- first_name = duser.first_name,
- last_name = duser.last_name,
- username = duser.username,
- avatar = duser.user_image,
- bio = duser.bio,
- # Chat Profile
- status = dprof.status,
- chat_background = dprof.chat_background,
- message_preview = bool(dprof.message_preview),
- notification_tones = bool(dprof.notification_tones),
- conversation_tones = bool(dprof.conversation_tones),
- enable_chat = bool(dprof.enable_chat)
- )
- profile = filter_dict(profile, fields)
-
- return dictify(profile)
-
-@frappe.whitelist()
-def create(user, exists_ok = False, fields = None):
- authenticate(user)
-
- exists_ok, fields = safe_json_loads(exists_ok, fields)
-
- try:
- dprof = frappe.new_doc('Chat Profile')
- dprof.user = user
- dprof.save(ignore_permissions = True)
- except frappe.DuplicateEntryError:
- frappe.clear_messages()
- if not exists_ok:
- frappe.throw(_('Chat Profile for User {0} exists.').format(user))
-
- profile = get(user, fields = fields)
-
- return profile
-
-@frappe.whitelist()
-def update(user, data):
- authenticate(user)
-
- data = safe_json_loads(data)
-
- dprof = frappe.get_doc('Chat Profile', user)
- dprof.update(data)
- dprof.save(ignore_permissions = True)
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_profile/chat_profile_list.js b/frappe/chat/doctype/chat_profile/chat_profile_list.js
deleted file mode 100644
index 4d97b75e65..0000000000
--- a/frappe/chat/doctype/chat_profile/chat_profile_list.js
+++ /dev/null
@@ -1,11 +0,0 @@
-frappe.listview_settings['Chat Profile'] =
-{
- get_indicator: function (doc)
- {
- const status = frappe.utils.squash(frappe.chat.profile.STATUSES.filter(
- s => s.name === doc.status
- ));
-
- return [__(status.name), status.color, `status,=,${status.name}`]
- }
-};
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_room/chat_room.js b/frappe/chat/doctype/chat_room/chat_room.js
deleted file mode 100644
index 00b9c8d8f7..0000000000
--- a/frappe/chat/doctype/chat_room/chat_room.js
+++ /dev/null
@@ -1,8 +0,0 @@
-// Copyright (c) 2017, Frappe Technologies and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on('Chat Room', {
- refresh: function (form) {
-
- }
-});
diff --git a/frappe/chat/doctype/chat_room/chat_room.json b/frappe/chat/doctype/chat_room/chat_room.json
deleted file mode 100644
index 1417306c45..0000000000
--- a/frappe/chat/doctype/chat_room/chat_room.json
+++ /dev/null
@@ -1,100 +0,0 @@
-{
- "autoname": "CR.#####",
- "beta": 1,
- "creation": "2017-11-08 15:27:21.156667",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "type",
- "room_name",
- "avatar",
- "last_message",
- "message_count",
- "owner",
- "user_list",
- "users"
- ],
- "fields": [
- {
- "default": "Direct",
- "fieldname": "type",
- "fieldtype": "Select",
- "in_list_view": 1,
- "label": "Type",
- "options": "Direct\nGroup\nVisitor",
- "reqd": 1,
- "set_only_once": 1
- },
- {
- "depends_on": "eval:doc.type==\"Group\"",
- "fieldname": "room_name",
- "fieldtype": "Data",
- "label": "Name"
- },
- {
- "depends_on": "eval:doc.type==\"Group\"",
- "fieldname": "avatar",
- "fieldtype": "Attach Image",
- "hidden": 1,
- "label": "Avatar"
- },
- {
- "fieldname": "last_message",
- "fieldtype": "Data",
- "hidden": 1,
- "label": "Last Message"
- },
- {
- "fieldname": "message_count",
- "fieldtype": "Int",
- "hidden": 1,
- "label": "Message Count"
- },
- {
- "fieldname": "owner",
- "fieldtype": "Data",
- "hidden": 1,
- "label": "Owner",
- "read_only": 1
- },
- {
- "fieldname": "user_list",
- "fieldtype": "Section Break",
- "label": "Users"
- },
- {
- "fieldname": "users",
- "fieldtype": "Table",
- "label": "Users",
- "options": "Chat Room User"
- }
- ],
- "image_field": "avatar",
- "modified": "2019-11-07 13:20:24.625329",
- "modified_by": "Administrator",
- "module": "Chat",
- "name": "Chat Room",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "set_user_permissions": 1,
- "share": 1,
- "write": 1
- }
- ],
- "search_fields": "room_name",
- "show_name_in_global_search": 1,
- "sort_field": "modified",
- "sort_order": "DESC",
- "title_field": "room_name",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_room/chat_room.py b/frappe/chat/doctype/chat_room/chat_room.py
deleted file mode 100644
index 609acaef7d..0000000000
--- a/frappe/chat/doctype/chat_room/chat_room.py
+++ /dev/null
@@ -1,229 +0,0 @@
-from __future__ import unicode_literals
-
-# imports - module imports
-from frappe.model.document import Document
-from frappe import _
-import frappe
-
-# imports - frappe module imports
-from frappe.chat import authenticate
-from frappe.core.doctype.version.version import get_diff
-from frappe.chat.doctype.chat_message import chat_message
-from frappe.chat.util import (
- safe_json_loads,
- dictify,
- listify,
- squashify,
- get_if_empty
-)
-
-session = frappe.session
-
-
-def is_direct(owner, other, bidirectional=False):
- def get_room(owner, other):
- room = frappe.get_all('Chat Room', filters=[
- ['Chat Room', 'type', 'in', ('Direct', 'Visitor')],
- ['Chat Room', 'owner', '=', owner],
- ['Chat Room User', 'user', '=', other]
- ], distinct=True)
-
- return room
-
- exists = len(get_room(owner, other)) == 1
- if bidirectional:
- exists = exists or len(get_room(other, owner)) == 1
-
- return exists
-
-
-def get_chat_room_user_set(users, filter_=None):
- seen, uset = set(), list()
-
- for u in users:
- if filter_(u) and u.user not in seen:
- uset.append(u)
- seen.add(u.user)
-
- return uset
-
-
-class ChatRoom(Document):
- def validate(self):
- if self.is_new():
- users = get_chat_room_user_set(self.users, filter_=lambda u: u.user != session.user)
- self.update(dict(
- users=users
- ))
-
- if self.type == "Direct":
- if len(self.users) != 1:
- frappe.throw(_('{0} room must have atmost one user.').format(self.type))
-
- other = squashify(self.users)
-
- if self.is_new():
- if is_direct(self.owner, other.user, bidirectional=True):
- frappe.throw(_('Direct room with {0} already exists.').format(other.user))
-
- if self.type == "Group" and not self.room_name:
- frappe.throw(_('Group name cannot be empty.'))
-
- def on_update(self):
- if not self.is_new():
- before = self.get_doc_before_save()
- if not before: return
-
- after = self
- diff = dictify(get_diff(before, after))
- if diff:
- update = {}
- for changed in diff.changed:
- field, old, new = changed
-
- if field == 'last_message':
- new = chat_message.get(new)
-
- update.update({field: new})
-
- if diff.added or diff.removed:
- update.update(dict(users=[u.user for u in self.users]))
-
- update = dict(room=self.name, data=update)
-
- frappe.publish_realtime('frappe.chat.room:update', update, room=self.name,
- after_commit=True)
-
-
-@frappe.whitelist(allow_guest=True)
-def get(user=None, token=None, rooms=None, fields=None, filters=None):
- # There is this horrible bug out here.
- # Looks like if frappe.call sends optional arguments (not in right order),
- # the argument turns to an empty string.
- # I'm not even going to think searching for it.
- # Hence, the hack was get_if_empty (previous assign_if_none)
- # - Achilles Rasquinha achilles@frappe.io
- data = user or token
- authenticate(data)
-
- rooms, fields, filters = safe_json_loads(rooms, fields, filters)
-
- rooms = listify(get_if_empty(rooms, []))
- fields = listify(get_if_empty(fields, []))
-
- const = [] # constraints
- if rooms:
- const.append(['Chat Room', 'name', 'in', rooms])
- if filters:
- if isinstance(filters[0], list):
- const = const + filters
- else:
- const.append(filters)
-
- default = ['name', 'type', 'room_name', 'creation', 'owner', 'avatar']
- handle = ['users', 'last_message']
-
- param = [f for f in fields if f not in handle]
-
- rooms = frappe.get_all('Chat Room',
- or_filters=[
- ['Chat Room', 'owner', '=', frappe.session.user],
- ['Chat Room User', 'user', '=', frappe.session.user]
- ],
- filters=const,
- fields=param + ['name'] if param else default,
- distinct=True
- )
-
- if not fields or 'users' in fields:
- for i, r in enumerate(rooms):
- droom = frappe.get_doc('Chat Room', r.name)
- rooms[i]['users'] = []
-
- for duser in droom.users:
- rooms[i]['users'].append(duser.user)
-
- if not fields or 'last_message' in fields:
- for i, r in enumerate(rooms):
- droom = frappe.get_doc('Chat Room', r.name)
- if droom.last_message:
- rooms[i]['last_message'] = chat_message.get(droom.last_message)
- else:
- rooms[i]['last_message'] = None
-
- rooms = squashify(dictify(rooms))
-
- return rooms
-
-
-@frappe.whitelist(allow_guest=True)
-def create(kind, token, users=None, name=None):
- authenticate(token)
-
- users = safe_json_loads(users)
- create = True
-
- if kind == 'Visitor':
- room = squashify(frappe.db.sql("""
- SELECT name
- FROM `tabChat Room`
- WHERE owner=%s
- """, (frappe.session.user), as_dict=True))
-
- if room:
- room = frappe.get_doc('Chat Room', room.name)
- create = False
-
- if create:
- room = frappe.new_doc('Chat Room')
- room.type = kind
- room.owner = frappe.session.user
- room.room_name = name
-
- dusers = []
-
- if kind != 'Visitor':
- if users:
- users = listify(users)
- for user in users:
- duser = frappe.new_doc('Chat Room User')
- duser.user = user
- dusers.append(duser)
-
- room.users = dusers
- else:
- dsettings = frappe.get_single('Website Settings')
- room.room_name = dsettings.chat_room_name
-
- users = [user for user in room.users] if hasattr(room, 'users') else []
-
- for user in dsettings.chat_operators:
- if user.user not in users:
- # appending user to room.users will remove the user from chat_operators
- # this is undesirable, create a new Chat Room User instead
- chat_room_user = {"doctype": "Chat Room User", "user": user.user}
- room.append('users', chat_room_user)
-
- room.save(ignore_permissions=True)
-
- room = get(token=token, rooms=room.name)
- if room:
- users = [room.owner] + [u for u in room.users]
-
- for user in users:
- frappe.publish_realtime('frappe.chat.room:create', room, user=user, after_commit=True)
-
- return room
-
-
-@frappe.whitelist(allow_guest=True)
-def history(room, user, fields=None, limit=10, start=None, end=None):
- if frappe.get_doc('Chat Room', room).type != 'Visitor':
- authenticate(user)
-
- fields = safe_json_loads(fields)
-
- mess = chat_message.history(room, limit=limit, start=start, end=end)
- mess = squashify(mess)
-
- return dictify(mess)
diff --git a/frappe/chat/doctype/chat_room/chat_room_list.js b/frappe/chat/doctype/chat_room/chat_room_list.js
deleted file mode 100644
index 70c708c7bd..0000000000
--- a/frappe/chat/doctype/chat_room/chat_room_list.js
+++ /dev/null
@@ -1,6 +0,0 @@
-frappe.listview_settings['Chat Room'] = {
- filters: [
- ['Chat Room', 'owner', '=', frappe.session.user, true],
- ['Chat Room User', 'user', '=', frappe.session.user, true]
- ]
-};
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_room_user/chat_room_user.py b/frappe/chat/doctype/chat_room_user/chat_room_user.py
deleted file mode 100644
index f8e13add82..0000000000
--- a/frappe/chat/doctype/chat_room_user/chat_room_user.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from __future__ import unicode_literals
-
-# imports - module imports
-from frappe.model.document import Document
-import frappe
-
-session = frappe.session
-
-class ChatRoomUser(Document):
- pass
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_token/chat_token.js b/frappe/chat/doctype/chat_token/chat_token.js
deleted file mode 100644
index 78f03026ec..0000000000
--- a/frappe/chat/doctype/chat_token/chat_token.js
+++ /dev/null
@@ -1,8 +0,0 @@
-// Copyright (c) 2018, Frappe Technologies and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on('Chat Token', {
- refresh: function(frm) {
-
- }
-});
diff --git a/frappe/chat/doctype/chat_token/chat_token.json b/frappe/chat/doctype/chat_token/chat_token.json
deleted file mode 100644
index b73505ac2c..0000000000
--- a/frappe/chat/doctype/chat_token/chat_token.json
+++ /dev/null
@@ -1,57 +0,0 @@
-{
- "autoname": "field:token",
- "beta": 1,
- "creation": "2018-03-26 18:20:13.825652",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "token",
- "ip_address",
- "country"
- ],
- "fields": [
- {
- "fieldname": "token",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Token",
- "reqd": 1
- },
- {
- "fieldname": "ip_address",
- "fieldtype": "Data",
- "label": "IP Address"
- },
- {
- "fieldname": "country",
- "fieldtype": "Data",
- "label": "Country"
- }
- ],
- "in_create": 1,
- "modified": "2019-11-07 13:21:24.514558",
- "modified_by": "Administrator",
- "module": "Chat",
- "name": "Chat Token",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "share": 1,
- "write": 1
- }
- ],
- "quick_entry": 1,
- "read_only": 1,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_token/chat_token.py b/frappe/chat/doctype/chat_token/chat_token.py
deleted file mode 100644
index 30a76ef5bd..0000000000
--- a/frappe/chat/doctype/chat_token/chat_token.py
+++ /dev/null
@@ -1,10 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2018, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-from __future__ import unicode_literals
-import frappe
-from frappe.model.document import Document
-
-class ChatToken(Document):
- pass
diff --git a/frappe/chat/util/__init__.py b/frappe/chat/util/__init__.py
deleted file mode 100644
index 15977af566..0000000000
--- a/frappe/chat/util/__init__.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from __future__ import unicode_literals
-
-# imports - module imports
-from frappe.chat.util.util import (
- get_user_doc,
- squashify,
- safe_json_loads,
- filter_dict,
- get_if_empty,
- listify,
- dictify,
- check_url,
- create_test_user,
- get_emojis
-)
\ No newline at end of file
diff --git a/frappe/chat/util/test_util.py b/frappe/chat/util/test_util.py
deleted file mode 100644
index 6d44a63d31..0000000000
--- a/frappe/chat/util/test_util.py
+++ /dev/null
@@ -1,38 +0,0 @@
-from __future__ import unicode_literals
-
-# imports - standard imports
-import unittest
-
-# imports - module imports
-from frappe.chat.util import (
- get_user_doc,
- safe_json_loads
-)
-import frappe
-import six
-
-class TestChatUtil(unittest.TestCase):
- def test_safe_json_loads(self):
- number = safe_json_loads("1")
- self.assertEqual(type(number), int)
-
- number = safe_json_loads("1.0")
- self.assertEqual(type(number), float)
-
- string = safe_json_loads("foobar")
- self.assertEqual(type(string), six.text_type)
-
- array = safe_json_loads('[{ "foo": "bar" }]')
- self.assertEqual(type(array), list)
-
- objekt = safe_json_loads('{ "foo": "bar" }')
- self.assertEqual(type(objekt), dict)
-
- true, null = safe_json_loads("true", "null")
- self.assertEqual(true, True)
- self.assertEqual(null, None)
-
- def test_get_user_doc(self):
- # Needs more test cases.
- user = get_user_doc()
- self.assertEqual(user.name, frappe.session.user)
\ No newline at end of file
diff --git a/frappe/chat/util/util.py b/frappe/chat/util/util.py
deleted file mode 100644
index 82df6dd127..0000000000
--- a/frappe/chat/util/util.py
+++ /dev/null
@@ -1,110 +0,0 @@
-from __future__ import unicode_literals
-
-# imports - standard imports
-import json
-from collections.abc import MutableMapping, MutableSequence, Sequence
-
-# imports - third-party imports
-import requests
-from urllib.parse import urlparse
-
-# imports - module imports
-import frappe
-from frappe.exceptions import DuplicateEntryError
-from frappe.model.document import Document
-
-session = frappe.session
-
-
-def get_user_doc(user = None):
- if isinstance(user, Document):
- return user
-
- user = user or session.user
- user = frappe.get_doc('User', user)
-
- return user
-
-def squashify(what):
- if isinstance(what, Sequence) and len(what) == 1:
- return what[0]
-
- return what
-
-def safe_json_loads(*args):
- results = []
-
- for arg in args:
- try:
- arg = json.loads(arg)
- except Exception:
- pass
-
- results.append(arg)
-
- return squashify(results)
-
-def filter_dict(what, keys, ignore = False):
- copy = dict()
-
- if keys:
- for k in keys:
- if k not in what and not ignore:
- raise KeyError('{key} not in dict.'.format(key = k))
- else:
- copy.update({
- k: what[k]
- })
- else:
- copy = what.copy()
-
- return copy
-
-def get_if_empty(a, b):
- if not a:
- a = b
- return a
-
-def listify(arg):
- if not isinstance(arg, list):
- arg = [arg]
- return arg
-
-def dictify(arg):
- if isinstance(arg, MutableSequence):
- for i, a in enumerate(arg):
- arg[i] = dictify(a)
- elif isinstance(arg, MutableMapping):
- arg = frappe._dict(arg)
-
- return arg
-
-def check_url(what, raise_err = False):
- if not urlparse(what).scheme:
- if raise_err:
- raise ValueError('{what} not a valid URL.')
- else:
- return False
-
- return True
-
-def create_test_user(module):
- try:
- test_user = frappe.new_doc('User')
- test_user.first_name = '{module}'.format(module = module)
- test_user.email = 'testuser.{module}@example.com'.format(module = module)
- test_user.save()
- except DuplicateEntryError:
- frappe.log('Test User Chat Profile exists.')
-
-def get_emojis():
- redis = frappe.cache()
- emojis = redis.hget('frappe_emojis', 'emojis')
-
- if not emojis:
- resp = requests.get('http://git.io/frappe-emoji')
- if resp.ok:
- emojis = resp.json()
- redis.hset('frappe_emojis', 'emojis', emojis)
-
- return dictify(emojis)
diff --git a/frappe/chat/website/__init__.py b/frappe/chat/website/__init__.py
deleted file mode 100644
index f33f531cbf..0000000000
--- a/frappe/chat/website/__init__.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from __future__ import unicode_literals
-import frappe
-from frappe.chat.util import filter_dict, safe_json_loads
-
-from frappe.sessions import get_geo_ip_country
-
-@frappe.whitelist(allow_guest = True)
-def settings(fields = None):
- fields = safe_json_loads(fields)
-
- dsettings = frappe.get_single('Website Settings')
- response = dict(
- socketio = dict(
- port = frappe.conf.socketio_port
- ),
- enable = bool(dsettings.chat_enable),
- enable_from = dsettings.chat_enable_from,
- enable_to = dsettings.chat_enable_to,
- room_name = dsettings.chat_room_name,
- welcome_message = dsettings.chat_welcome_message,
- operators = [
- duser.user for duser in dsettings.chat_operators
- ]
- )
-
- if fields:
- response = filter_dict(response, fields)
-
- return response
-
-@frappe.whitelist(allow_guest = True)
-def token():
- dtoken = frappe.new_doc('Chat Token')
-
- dtoken.token = frappe.generate_hash()
- dtoken.ip_address = frappe.local.request_ip
- country = get_geo_ip_country(dtoken.ip_address)
- if country:
- dtoken.country = country['iso_code']
- dtoken.save(ignore_permissions = True)
-
- return dtoken.token
\ No newline at end of file
diff --git a/frappe/client.py b/frappe/client.py
index 2217b53673..1898994afe 100644
--- a/frappe/client.py
+++ b/frappe/client.py
@@ -1,15 +1,14 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
from frappe import _
import frappe.model
import frappe.utils
import json, os
from frappe.utils import get_safe_filters
+from frappe.desk.reportview import validate_args
+from frappe.model.db_query import check_parent_permission
-from six import iteritems, string_types, integer_types
'''
Handle RESTful requests that are mapped to the `/api/resource` route.
@@ -19,7 +18,7 @@ Requests via FrappeClient are also handled here.
@frappe.whitelist()
def get_list(doctype, fields=None, filters=None, order_by=None,
- limit_start=None, limit_page_length=20, parent=None):
+ limit_start=None, limit_page_length=20, parent=None, debug=False, as_dict=True, or_filters=None):
'''Returns a list of records by filters, fields, ordering and limit
:param doctype: DocType of the data to be queried
@@ -31,8 +30,21 @@ def get_list(doctype, fields=None, filters=None, order_by=None,
if frappe.is_table(doctype):
check_parent_permission(parent, doctype)
- return frappe.get_list(doctype, fields=fields, filters=filters, order_by=order_by,
- limit_start=limit_start, limit_page_length=limit_page_length, ignore_permissions=False)
+ args = frappe._dict(
+ doctype=doctype,
+ parent_doctype=parent,
+ fields=fields,
+ filters=filters,
+ or_filters=or_filters,
+ order_by=order_by,
+ limit_start=limit_start,
+ limit_page_length=limit_page_length,
+ debug=debug,
+ as_list=not as_dict
+ )
+
+ validate_args(args)
+ return frappe.get_list(**args)
@frappe.whitelist()
def get_count(doctype, filters=None, debug=False, cache=False):
@@ -73,11 +85,11 @@ def get_value(doctype, fieldname, filters=None, as_dict=True, debug=False, paren
frappe.throw(_("No permission for {0}").format(doctype), frappe.PermissionError)
filters = get_safe_filters(filters)
- if isinstance(filters, string_types):
+ if isinstance(filters, str):
filters = {"name": filters}
try:
- fields = json.loads(fieldname)
+ fields = frappe.parse_json(fieldname)
except (TypeError, ValueError):
# name passed, not json
fields = [fieldname]
@@ -87,18 +99,18 @@ def get_value(doctype, fieldname, filters=None, as_dict=True, debug=False, paren
if not filters:
filters = None
-
if frappe.get_meta(doctype).issingle:
value = frappe.db.get_values_from_single(fields, filters, doctype, as_dict=as_dict, debug=debug)
else:
- value = frappe.get_list(doctype, filters=filters, fields=fields, debug=debug, limit=1)
+ value = get_list(doctype, filters=filters, fields=fields, debug=debug, limit_page_length=1, parent=parent, as_dict=as_dict)
if as_dict:
- value = value[0] if value else {}
- else:
- value = value[0].fieldname
+ return value[0] if value else {}
- return value
+ if not value:
+ return
+
+ return value[0] if len(fields) > 1 else value[0][0]
@frappe.whitelist()
def get_single_value(doctype, field):
@@ -116,12 +128,12 @@ def set_value(doctype, name, fieldname, value=None):
:param fieldname: fieldname string or JSON / dict with key value pair
:param value: value if fieldname is JSON / dict'''
- if fieldname!="idx" and fieldname in frappe.model.default_fields:
+ if fieldname in (frappe.model.default_fields + frappe.model.child_table_fields):
frappe.throw(_("Cannot edit standard fields"))
if not value:
values = fieldname
- if isinstance(fieldname, string_types):
+ if isinstance(fieldname, str):
try:
values = json.loads(fieldname)
except ValueError:
@@ -129,14 +141,15 @@ def set_value(doctype, name, fieldname, value=None):
else:
values = {fieldname: value}
- doc = frappe.db.get_value(doctype, name, ["parenttype", "parent"], as_dict=True)
- if doc and doc.parent and doc.parenttype:
+ # check for child table doctype
+ if not frappe.get_meta(doctype).istable:
+ doc = frappe.get_doc(doctype, name)
+ doc.update(values)
+ else:
+ doc = frappe.db.get_value(doctype, name, ["parenttype", "parent"], as_dict=True)
doc = frappe.get_doc(doc.parenttype, doc.parent)
child = doc.getone({"doctype": doctype, "name": name})
child.update(values)
- else:
- doc = frappe.get_doc(doctype, name)
- doc.update(values)
doc.save()
@@ -147,13 +160,13 @@ def insert(doc=None):
'''Insert a document
:param doc: JSON or dict object to be inserted'''
- if isinstance(doc, string_types):
+ if isinstance(doc, str):
doc = json.loads(doc)
- if doc.get("parent") and doc.get("parenttype"):
+ if doc.get("parenttype"):
# inserting a child record
- parent = frappe.get_doc(doc.get("parenttype"), doc.get("parent"))
- parent.append(doc.get("parentfield"), doc)
+ parent = frappe.get_doc(doc.parenttype, doc.parent)
+ parent.append(doc.parentfield, doc)
parent.save()
return parent.as_dict()
else:
@@ -165,7 +178,7 @@ def insert_many(docs=None):
'''Insert multiple documents
:param docs: JSON or list of dict objects to be inserted in one request'''
- if isinstance(docs, string_types):
+ if isinstance(docs, str):
docs = json.loads(docs)
out = []
@@ -174,10 +187,10 @@ def insert_many(docs=None):
frappe.throw(_('Only 200 inserts allowed in one request'))
for doc in docs:
- if doc.get("parent") and doc.get("parenttype"):
+ if doc.get("parenttype"):
# inserting a child record
- parent = frappe.get_doc(doc.get("parenttype"), doc.get("parent"))
- parent.append(doc.get("parentfield"), doc)
+ parent = frappe.get_doc(doc.parenttype, doc.parent)
+ parent.append(doc.parentfield, doc)
parent.save()
out.append(parent.name)
else:
@@ -191,7 +204,7 @@ def save(doc):
'''Update (save) an existing document
:param doc: JSON or dict object with the properties of the document to be updated'''
- if isinstance(doc, string_types):
+ if isinstance(doc, str):
doc = json.loads(doc)
doc = frappe.get_doc(doc)
@@ -214,7 +227,7 @@ def submit(doc):
'''Submit a document
:param doc: JSON or dict object to be submitted remotely'''
- if isinstance(doc, string_types):
+ if isinstance(doc, str):
doc = json.loads(doc)
doc = frappe.get_doc(doc)
@@ -247,12 +260,18 @@ def set_default(key, value, parent=None):
frappe.db.set_default(key, value, parent or frappe.session.user)
frappe.clear_cache(user=frappe.session.user)
+@frappe.whitelist()
+def get_default(key, parent=None):
+ """set a user default value"""
+ return frappe.db.get_default(key, parent)
+
+
@frappe.whitelist(methods=['POST', 'PUT'])
def make_width_property_setter(doc):
'''Set width Property Setter
:param doc: Property Setter document with `width` property'''
- if isinstance(doc, string_types):
+ if isinstance(doc, str):
doc = json.loads(doc)
if doc["doctype"]=="Property Setter" and doc["property"]=="width":
frappe.get_doc(doc).insert(ignore_permissions = True)
@@ -265,18 +284,17 @@ def bulk_update(docs):
docs = json.loads(docs)
failed_docs = []
for doc in docs:
+ doc.pop("flags", None)
try:
- ddoc = {key: val for key, val in iteritems(doc) if key not in ['doctype', 'docname']}
- doctype = doc['doctype']
- docname = doc['docname']
- doc = frappe.get_doc(doctype, docname)
- doc.update(ddoc)
- doc.save()
- except:
+ existing_doc = frappe.get_doc(doc["doctype"], doc["docname"])
+ existing_doc.update(doc)
+ existing_doc.save()
+ except Exception:
failed_docs.append({
'doc': doc,
'exc': frappe.utils.get_traceback()
})
+
return {'failed_docs': failed_docs}
@frappe.whitelist()
@@ -378,18 +396,6 @@ def attach_file(filename=None, filedata=None, doctype=None, docname=None, folder
def get_hooks(hook, app_name=None):
return frappe.get_hooks(hook, app_name)
-def check_parent_permission(parent, child_doctype):
- if parent:
- # User may pass fake parent and get the information from the child table
- if child_doctype and not frappe.db.exists('DocField',
- {'parent': parent, 'options': child_doctype}):
- raise frappe.PermissionError
-
- if frappe.permissions.has_permission(parent):
- return
- # Either parent not passed or the user doesn't have permission on parent doctype of child table!
- raise frappe.PermissionError
-
@frappe.whitelist()
def is_document_amended(doctype, docname):
if frappe.permissions.has_permission(doctype):
@@ -400,4 +406,46 @@ def is_document_amended(doctype, docname):
except frappe.db.InternalError:
pass
- return False
\ No newline at end of file
+ return False
+
+@frappe.whitelist()
+def validate_link(doctype: str, docname: str, fields=None):
+ if not isinstance(doctype, str):
+ frappe.throw(_("DocType must be a string"))
+
+ if not isinstance(docname, str):
+ frappe.throw(_("Document Name must be a string"))
+
+ if doctype != "DocType" and not (
+ frappe.has_permission(doctype, "select")
+ or frappe.has_permission(doctype, "read")
+ ):
+ frappe.throw(
+ _("You do not have Read or Select Permissions for {}")
+ .format(frappe.bold(doctype)),
+ frappe.PermissionError
+ )
+
+ values = frappe._dict()
+ values.name = frappe.db.get_value(doctype, docname, cache=True)
+
+ fields = frappe.parse_json(fields)
+ if not values.name or not fields:
+ return values
+
+ try:
+ values.update(get_value(doctype, fields, docname))
+ except frappe.PermissionError:
+ frappe.clear_last_message()
+ frappe.msgprint(
+ _("You need {0} permission to fetch values from {1} {2}")
+ .format(
+ frappe.bold(_("Read")),
+ frappe.bold(doctype),
+ frappe.bold(docname)
+ ),
+ title=_("Cannot Fetch Values"),
+ indicator="orange"
+ )
+
+ return values
diff --git a/frappe/commands/__init__.py b/frappe/commands/__init__.py
index b7294fff77..82a71ce7b4 100644
--- a/frappe/commands/__init__.py
+++ b/frappe/commands/__init__.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Web Notes Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals, absolute_import, print_function
import sys
import click
import cProfile
@@ -10,7 +9,8 @@ import frappe
import frappe.utils
import subprocess # nosec
from functools import wraps
-from six import StringIO
+from io import StringIO
+from os import environ
click.disable_unicode_literals_warning = True
@@ -27,6 +27,10 @@ def pass_context(f):
except frappe.exceptions.SiteNotSpecifiedError as e:
click.secho(str(e), fg='yellow')
sys.exit(1)
+ except frappe.exceptions.IncorrectSitePath:
+ site = ctx.obj.get("sites", "")[0]
+ click.secho(f'Site {site} does not exist!', fg='yellow')
+ sys.exit(1)
if profile:
pr.disable()
@@ -53,16 +57,33 @@ def get_site(context, raise_err=True):
return None
def popen(command, *args, **kwargs):
- output = kwargs.get('output', True)
- cwd = kwargs.get('cwd')
- shell = kwargs.get('shell', True)
+ output = kwargs.get('output', True)
+ cwd = kwargs.get('cwd')
+ shell = kwargs.get('shell', True)
raise_err = kwargs.get('raise_err')
+ env = kwargs.get('env')
+ if env:
+ env = dict(environ, **env)
+
+ def set_low_prio():
+ import psutil
+ if psutil.LINUX:
+ psutil.Process().nice(19)
+ psutil.Process().ionice(psutil.IOPRIO_CLASS_IDLE)
+ elif psutil.WINDOWS:
+ psutil.Process().nice(psutil.IDLE_PRIORITY_CLASS)
+ psutil.Process().ionice(psutil.IOPRIO_VERYLOW)
+ else:
+ psutil.Process().nice(19)
+ # ionice not supported
proc = subprocess.Popen(command,
- stdout = None if output else subprocess.PIPE,
- stderr = None if output else subprocess.PIPE,
- shell = shell,
- cwd = cwd
+ stdout=None if output else subprocess.PIPE,
+ stderr=None if output else subprocess.PIPE,
+ shell=shell,
+ cwd=cwd,
+ preexec_fn=set_low_prio,
+ env=env
)
return_ = proc.wait()
@@ -81,7 +102,24 @@ def get_commands():
from .site import commands as site_commands
from .translate import commands as translate_commands
from .utils import commands as utils_commands
+ from .redis_utils import commands as redis_commands
+
+ clickable_link = (
+ "\x1b]8;;https://frappeframework.com/docs\afrappeframework.com\x1b]8;;\a"
+ )
+ all_commands = (
+ scheduler_commands
+ + site_commands
+ + translate_commands
+ + utils_commands
+ + redis_commands
+ )
+
+ for command in all_commands:
+ if not command.help:
+ command.help = f"Refer to {clickable_link}"
+
+ return all_commands
- return list(set(scheduler_commands + site_commands + translate_commands + utils_commands))
commands = get_commands()
diff --git a/frappe/commands/redis_utils.py b/frappe/commands/redis_utils.py
new file mode 100644
index 0000000000..3556050782
--- /dev/null
+++ b/frappe/commands/redis_utils.py
@@ -0,0 +1,53 @@
+import os
+
+import click
+
+import frappe
+from frappe.utils.redis_queue import RedisQueue
+from frappe.installer import update_site_config
+
+@click.command('create-rq-users')
+@click.option('--set-admin-password', is_flag=True, default=False, help='Set new Redis admin(default user) password')
+@click.option('--use-rq-auth', is_flag=True, default=False, help='Enable Redis authentication for sites')
+def create_rq_users(set_admin_password=False, use_rq_auth=False):
+ """Create Redis Queue users and add to acl and app configs.
+
+ acl config file will be used by redis server while starting the server
+ and app config is used by app while connecting to redis server.
+ """
+ acl_file_path = os.path.abspath('../config/redis_queue.acl')
+
+ with frappe.init_site():
+ acl_list, user_credentials = RedisQueue.gen_acl_list(
+ set_admin_password=set_admin_password)
+
+ with open(acl_file_path, 'w') as f:
+ f.writelines([acl+'\n' for acl in acl_list])
+
+ sites_path = os.getcwd()
+ common_site_config_path = os.path.join(sites_path, 'common_site_config.json')
+ update_site_config("rq_username", user_credentials['bench'][0], validate=False,
+ site_config_path=common_site_config_path)
+ update_site_config("rq_password", user_credentials['bench'][1], validate=False,
+ site_config_path=common_site_config_path)
+ update_site_config("use_rq_auth", use_rq_auth, validate=False,
+ site_config_path=common_site_config_path)
+
+ click.secho('* ACL and site configs are updated with new user credentials. '
+ 'Please restart Redis Queue server to enable namespaces.',
+ fg='green')
+
+ if set_admin_password:
+ env_key = 'RQ_ADMIN_PASWORD'
+ click.secho('* Redis admin password is successfully set up. '
+ 'Include below line in .bashrc file for system to use',
+ fg='green')
+ click.secho(f"`export {env_key}={user_credentials['default'][1]}`")
+ click.secho('NOTE: Please save the admin password as you '
+ 'can not access redis server without the password',
+ fg='yellow')
+
+
+commands = [
+ create_rq_users
+]
diff --git a/frappe/commands/scheduler.py b/frappe/commands/scheduler.py
index bd9c9d2cb0..f82473fd55 100755
--- a/frappe/commands/scheduler.py
+++ b/frappe/commands/scheduler.py
@@ -1,4 +1,3 @@
-from __future__ import unicode_literals, absolute_import, print_function
import click
import sys
import frappe
@@ -18,22 +17,33 @@ def _is_scheduler_enabled():
return enable_scheduler
-@click.command('trigger-scheduler-event')
-@click.argument('event')
+
+@click.command("trigger-scheduler-event", help="Trigger a scheduler event")
+@click.argument("event")
@pass_context
def trigger_scheduler_event(context, event):
- "Trigger a scheduler event"
import frappe.utils.scheduler
+
+ exit_code = 0
+
for site in context.sites:
try:
frappe.init(site=site)
frappe.connect()
- frappe.utils.scheduler.trigger(site, event, now=True)
+ try:
+ frappe.get_doc("Scheduled Job Type", {"method": event}).execute()
+ except frappe.DoesNotExistError:
+ click.secho(f"Event {event} does not exist!", fg="red")
+ exit_code = 1
finally:
frappe.destroy()
+
if not context.sites:
raise SiteNotSpecifiedError
+ sys.exit(exit_code)
+
+
@click.command('enable-scheduler')
@pass_context
def enable_scheduler(context):
@@ -162,9 +172,13 @@ def start_scheduler():
@click.command('worker')
@click.option('--queue', type=str)
@click.option('--quiet', is_flag = True, default = False, help = 'Hide Log Outputs')
-def start_worker(queue, quiet = False):
+@click.option('-u', '--rq-username', default=None, help='Redis ACL user')
+@click.option('-p', '--rq-password', default=None, help='Redis ACL user password')
+def start_worker(queue, quiet = False, rq_username=None, rq_password=None):
+ """Site is used to find redis credentals.
+ """
from frappe.utils.background_jobs import start_worker
- start_worker(queue, quiet = quiet)
+ start_worker(queue, quiet = quiet, rq_username=rq_username, rq_password=rq_password)
@click.command('ready-for-migration')
@click.option('--site', help='site name')
diff --git a/frappe/commands/site.py b/frappe/commands/site.py
old mode 100755
new mode 100644
index 4a631be3ac..b54f369e34
--- a/frappe/commands/site.py
+++ b/frappe/commands/site.py
@@ -1,5 +1,6 @@
# imports - standard imports
import os
+import shutil
import sys
# imports - third party imports
@@ -9,7 +10,6 @@ import click
import frappe
from frappe.commands import get_site, pass_context
from frappe.exceptions import SiteNotSpecifiedError
-from frappe.installer import _new_site
@click.command('new-site')
@@ -19,71 +19,127 @@ from frappe.installer import _new_site
@click.option('--db-type', default='mariadb', type=click.Choice(['mariadb', 'postgres']), help='Optional "postgres" or "mariadb". Default is "mariadb"')
@click.option('--db-host', help='Database Host')
@click.option('--db-port', type=int, help='Database Port')
-@click.option('--mariadb-root-username', default='root', help='Root username for MariaDB')
-@click.option('--mariadb-root-password', help='Root password for MariaDB')
+@click.option('--db-root-username', '--mariadb-root-username', help='Root username for MariaDB or PostgreSQL, Default is "root"')
+@click.option('--db-root-password', '--mariadb-root-password', help='Root password for MariaDB or PostgreSQL')
@click.option('--no-mariadb-socket', is_flag=True, default=False, help='Set MariaDB host to % and use TCP/IP Socket instead of using the UNIX Socket')
@click.option('--admin-password', help='Administrator password for new site', default=None)
@click.option('--verbose', is_flag=True, default=False, help='Verbose')
@click.option('--force', help='Force restore if site/database already exists', is_flag=True, default=False)
@click.option('--source_sql', help='Initiate database with a SQL file')
@click.option('--install-app', multiple=True, help='Install app after installation')
-def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin_password=None,
- verbose=False, install_apps=None, source_sql=None, force=None, no_mariadb_socket=False,
- install_app=None, db_name=None, db_password=None, db_type=None, db_host=None, db_port=None):
+@click.option('--set-default', is_flag=True, default=False, help='Set the new site as default site')
+def new_site(site, db_root_username=None, db_root_password=None, admin_password=None,
+ verbose=False, install_apps=None, source_sql=None, force=None, no_mariadb_socket=False,
+ install_app=None, db_name=None, db_password=None, db_type=None, db_host=None, db_port=None,
+ set_default=False):
"Create a new site"
+ from frappe.installer import _new_site
+
frappe.init(site=site, new_site=True)
- _new_site(db_name, site, mariadb_root_username=mariadb_root_username,
- mariadb_root_password=mariadb_root_password, admin_password=admin_password,
- verbose=verbose, install_apps=install_app, source_sql=source_sql, force=force,
- no_mariadb_socket=no_mariadb_socket, db_password=db_password, db_type=db_type, db_host=db_host,
- db_port=db_port, new_site=True)
+ _new_site(db_name, site, db_root_username=db_root_username,
+ db_root_password=db_root_password, admin_password=admin_password,
+ verbose=verbose, install_apps=install_app, source_sql=source_sql, force=force,
+ no_mariadb_socket=no_mariadb_socket, db_password=db_password, db_type=db_type, db_host=db_host,
+ db_port=db_port, new_site=True)
- if len(frappe.utils.get_sites()) == 1:
+ if set_default:
use(site)
@click.command('restore')
@click.argument('sql-file-path')
-@click.option('--mariadb-root-username', default='root', help='Root username for MariaDB')
-@click.option('--mariadb-root-password', help='Root password for MariaDB')
+@click.option('--db-root-username', '--mariadb-root-username', help='Root username for MariaDB or PostgreSQL, Default is "root"')
+@click.option('--db-root-password', '--mariadb-root-password', help='Root password for MariaDB or PostgreSQL')
@click.option('--db-name', help='Database name for site in case it is a new one')
@click.option('--admin-password', help='Administrator password for new site')
@click.option('--install-app', multiple=True, help='Install app after installation')
@click.option('--with-public-files', help='Restores the public files of the site, given path to its tar file')
@click.option('--with-private-files', help='Restores the private files of the site, given path to its tar file')
@click.option('--force', is_flag=True, default=False, help='Ignore the validations and downgrade warnings. This action is not recommended')
+@click.option('--encryption-key', help='Backup encryption key')
@pass_context
-def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None):
+def restore(context, sql_file_path, encryption_key=None, db_root_username=None, db_root_password=None,
+ db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None,
+ with_private_files=None):
"Restore site database from an sql file"
from frappe.installer import (
- extract_sql_from_archive,
+ _new_site,
extract_files,
+ extract_sql_from_archive,
is_downgrade,
is_partial,
- validate_database_sql
+ validate_database_sql,
)
-
- force = context.force or force
- decompressed_file_name = extract_sql_from_archive(sql_file_path)
-
- # check if partial backup
- if is_partial(decompressed_file_name):
- click.secho(
- "Partial Backup file detected. You cannot use a partial file to restore a Frappe Site.",
- fg="red"
- )
- click.secho(
- "Use `bench partial-restore` to restore a partial backup to an existing site.",
- fg="yellow"
- )
+ from frappe.utils.backups import Backup
+ if not os.path.exists(sql_file_path):
+ print("Invalid path", sql_file_path)
sys.exit(1)
- # check if valid SQL file
- validate_database_sql(decompressed_file_name, _raise=not force)
+ _backup = Backup(sql_file_path)
site = get_site(context)
frappe.init(site=site)
+ force = context.force or force
+
+ try:
+ decompressed_file_name = extract_sql_from_archive(sql_file_path)
+ if is_partial(decompressed_file_name):
+ click.secho(
+ "Partial Backup file detected. You cannot use a partial file to restore a Frappe site.",
+ fg="red"
+ )
+ click.secho(
+ "Use `bench partial-restore` to restore a partial backup to an existing site.",
+ fg="yellow"
+ )
+ _backup.decryption_rollback()
+ sys.exit(1)
+
+ except UnicodeDecodeError:
+ _backup.decryption_rollback()
+ if encryption_key:
+ click.secho(
+ "Encrypted backup file detected. Decrypting using provided key.",
+ fg="yellow"
+ )
+ _backup.backup_decryption(encryption_key)
+
+ else:
+ click.secho(
+ "Encrypted backup file detected. Decrypting using site config.",
+ fg="yellow"
+ )
+ encryption_key = frappe.get_site_config().encryption_key
+ _backup.backup_decryption(encryption_key)
+
+ # Rollback on unsuccessful decryrption
+ if not os.path.exists(sql_file_path):
+ click.secho(
+ "Decryption failed. Please provide a valid key and try again.",
+ fg="red"
+ )
+
+ _backup.decryption_rollback()
+ sys.exit(1)
+
+ decompressed_file_name = extract_sql_from_archive(sql_file_path)
+
+ if is_partial(decompressed_file_name):
+ click.secho(
+ "Partial Backup file detected. You cannot use a partial file to restore a Frappe site.",
+ fg="red"
+ )
+ click.secho(
+ "Use `bench partial-restore` to restore a partial backup to an existing site.",
+ fg="yellow"
+ )
+ _backup.decryption_rollback()
+ sys.exit(1)
+
+
+
+ validate_database_sql(decompressed_file_name, _raise=not force)
# dont allow downgrading to older versions of frappe without force
if not force and is_downgrade(decompressed_file_name, verbose=True):
@@ -93,23 +149,51 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas
)
click.confirm(warn_message, abort=True)
- _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username,
- mariadb_root_password=mariadb_root_password, admin_password=admin_password,
- verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name,
- force=True, db_type=frappe.conf.db_type)
- # Extract public and/or private files to the restored site, if user has given the path
- if with_public_files:
- public = extract_files(site, with_public_files)
- os.remove(public)
- if with_private_files:
- private = extract_files(site, with_private_files)
- os.remove(private)
+ try:
+ _new_site(frappe.conf.db_name, site, db_root_username=db_root_username,
+ db_root_password=db_root_password, admin_password=admin_password,
+ verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name,
+ force=True, db_type=frappe.conf.db_type)
+
+ except Exception as err:
+ print(err.args[1])
+ _backup.decryption_rollback()
+ sys.exit(1)
# Removing temporarily created file
if decompressed_file_name != sql_file_path:
os.remove(decompressed_file_name)
+ _backup.decryption_rollback()
+
+ # Extract public and/or private files to the restored site, if user has given the path
+ if with_public_files:
+ # Decrypt data if there is a Key
+ if encryption_key:
+ _backup = Backup(with_public_files)
+ _backup.backup_decryption(encryption_key)
+ if not os.path.exists(with_public_files):
+ _backup.decryption_rollback()
+ public = extract_files(site, with_public_files)
+
+ # Removing temporarily created file
+ os.remove(public)
+ _backup.decryption_rollback()
+
+
+ if with_private_files:
+ # Decrypt data if there is a Key
+ if encryption_key:
+ _backup = Backup(with_private_files)
+ _backup.backup_decryption(encryption_key)
+ if not os.path.exists(with_private_files):
+ _backup.decryption_rollback()
+ private = extract_files(site, with_private_files)
+
+ # Removing temporarily created file
+ os.remove(private)
+ _backup.decryption_rollback()
success_message = "Site {0} has been restored{1}".format(
site,
@@ -117,34 +201,109 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas
)
click.secho(success_message, fg="green")
-
@click.command('partial-restore')
@click.argument('sql-file-path')
@click.option("--verbose", "-v", is_flag=True)
+@click.option('--encryption-key', help='Backup encryption key')
@pass_context
-def partial_restore(context, sql_file_path, verbose):
- from frappe.installer import partial_restore
- verbose = context.verbose or verbose
+def partial_restore(context, sql_file_path, verbose, encryption_key=None):
+ from frappe.installer import extract_sql_from_archive, partial_restore
+ from frappe.utils.backups import Backup
+
+ if not os.path.exists(sql_file_path):
+ print("Invalid path", sql_file_path)
+ sys.exit(1)
site = get_site(context)
frappe.init(site=site)
+
+ _backup = Backup(sql_file_path)
+
+ verbose = context.verbose or verbose
+
frappe.connect(site=site)
+ try:
+ decompressed_file_name = extract_sql_from_archive(sql_file_path)
+
+ with open(decompressed_file_name) as f:
+ header = " ".join(f.readline() for _ in range(5))
+
+ #Check for full backup file
+ if "Partial Backup" not in header:
+ click.secho(
+ "Full backup file detected.Use `bench restore` to restore a Frappe Site.",
+ fg="red"
+ )
+ _backup.decryption_rollback()
+ sys.exit(1)
+
+
+ except UnicodeDecodeError:
+ _backup.decryption_rollback()
+ if encryption_key:
+ click.secho(
+ "Encrypted backup file detected. Decrypting using provided key.",
+ fg="yellow"
+ )
+ key = encryption_key
+
+ else:
+ click.secho(
+ "Encrypted backup file detected. Decrypting using site config.",
+ fg="yellow"
+ )
+ key = frappe.get_site_config().encryption_key
+
+ _backup.backup_decryption(key)
+
+ # Rollback on unsuccessful decryrption
+ if not os.path.exists(sql_file_path):
+ click.secho(
+ "Decryption failed. Please provide a valid key and try again.",
+ fg="red"
+ )
+ _backup.decryption_rollback()
+ sys.exit(1)
+
+ decompressed_file_name = extract_sql_from_archive(sql_file_path)
+
+ with open(decompressed_file_name) as f:
+ header = " ".join(f.readline() for _ in range(5))
+
+ #Check for Full backup file.
+ if "Partial Backup" not in header:
+ click.secho(
+ "Full Backup file detected.Use `bench restore` to restore a Frappe Site.",
+ fg="red"
+ )
+ _backup.decryption_rollback()
+ sys.exit(1)
+
+
partial_restore(sql_file_path, verbose)
+
+ # Removing temporarily created file
+ _backup.decryption_rollback()
+ if os.path.exists(sql_file_path.rstrip(".gz")):
+ os.remove(sql_file_path.rstrip(".gz"))
+
frappe.destroy()
@click.command('reinstall')
@click.option('--admin-password', help='Administrator Password for reinstalled site')
-@click.option('--mariadb-root-username', help='Root username for MariaDB')
-@click.option('--mariadb-root-password', help='Root password for MariaDB')
+@click.option('--db-root-username', '--mariadb-root-username', help='Root username for MariaDB or PostgreSQL, Default is "root"')
+@click.option('--db-root-password', '--mariadb-root-password', help='Root password for MariaDB or PostgreSQL')
@click.option('--yes', is_flag=True, default=False, help='Pass --yes to skip confirmation')
@pass_context
-def reinstall(context, admin_password=None, mariadb_root_username=None, mariadb_root_password=None, yes=False):
+def reinstall(context, admin_password=None, db_root_username=None, db_root_password=None, yes=False):
"Reinstall site ie. wipe all data and start over"
site = get_site(context)
- _reinstall(site, admin_password, mariadb_root_username, mariadb_root_password, yes, verbose=context.verbose)
+ _reinstall(site, admin_password, db_root_username, db_root_password, yes, verbose=context.verbose)
+
+def _reinstall(site, admin_password=None, db_root_username=None, db_root_password=None, yes=False, verbose=False):
+ from frappe.installer import _new_site
-def _reinstall(site, admin_password=None, mariadb_root_username=None, mariadb_root_password=None, yes=False, verbose=False):
if not yes:
click.confirm('This will wipe your database. Are you sure you want to reinstall?', abort=True)
try:
@@ -162,7 +321,7 @@ def _reinstall(site, admin_password=None, mariadb_root_username=None, mariadb_ro
frappe.init(site=site)
_new_site(frappe.conf.db_name, site, verbose=verbose, force=True, reinstall=True, install_apps=installed,
- mariadb_root_username=mariadb_root_username, mariadb_root_password=mariadb_root_password,
+ db_root_username=db_root_username, db_root_password=db_root_password,
admin_password=admin_password)
@click.command('install-app')
@@ -188,7 +347,7 @@ def install_app(context, apps):
print("App {} is Incompatible with Site {}{}".format(app, site, err_msg))
exit_code = 1
except Exception as err:
- err_msg = ":\n{}".format(err if str(err) else frappe.get_traceback())
+ err_msg = ": {}\n{}".format(str(err), frappe.get_traceback())
print("An error occurred while installing {}{}".format(app, err_msg))
exit_code = 1
@@ -198,10 +357,13 @@ def install_app(context, apps):
@click.command("list-apps")
+@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text")
@pass_context
-def list_apps(context):
+def list_apps(context, format):
"List apps in site"
+ summary_dict = {}
+
def fix_whitespaces(text):
if site == context.sites[-1]:
text = text.rstrip()
@@ -230,18 +392,23 @@ def list_apps(context):
]
applications_summary = "\n".join(installed_applications)
summary = f"{site_title}\n{applications_summary}\n"
+ summary_dict[site] = [app.app_name for app in apps]
else:
- applications_summary = "\n".join(frappe.get_installed_apps())
+ installed_applications = frappe.get_installed_apps()
+ applications_summary = "\n".join(installed_applications)
summary = f"{site_title}\n{applications_summary}\n"
+ summary_dict[site] = installed_applications
summary = fix_whitespaces(summary)
- if applications_summary and summary:
+ if format == "text" and applications_summary and summary:
print(summary)
frappe.destroy()
+ if format == "json":
+ click.echo(frappe.as_json(summary_dict))
@click.command('add-system-manager')
@click.argument('email')
@@ -282,21 +449,17 @@ def disable_user(context, email):
@pass_context
def migrate(context, skip_failing=False, skip_search_index=False):
"Run patches, sync schema and rebuild files/translations"
- import re
- from frappe.migrate import migrate
+ from frappe.migrate import SiteMigration
for site in context.sites:
- print('Migrating', site)
- frappe.init(site=site)
- frappe.connect()
+ click.secho(f"Migrating {site}", fg="green")
try:
- migrate(
- context.verbose,
+ SiteMigration(
skip_failing=skip_failing,
- skip_search_index=skip_search_index
- )
+ skip_search_index=skip_search_index,
+ ).run(site=site)
finally:
- frappe.destroy()
+ print()
if not context.sites:
raise SiteNotSpecifiedError
@@ -382,7 +545,7 @@ def _use(site, sites_path='.'):
def use(site, sites_path='.'):
if os.path.exists(os.path.join(sites_path, site)):
- with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile:
+ with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile:
sitefile.write(site)
print("Current Site set to {}".format(site))
else:
@@ -405,6 +568,7 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac
backup_path_private_files=None, backup_path_conf=None, ignore_backup_conf=False, verbose=False,
compress=False, include="", exclude=""):
"Backup"
+
from frappe.utils.backups import scheduled_backup
verbose = verbose or context.verbose
exit_code = 0
@@ -428,14 +592,25 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac
force=True
)
except Exception:
- click.secho("Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), fg="red")
+ click.secho(
+ "Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site),
+ fg="red"
+ )
if verbose:
print(frappe.get_traceback())
exit_code = 1
continue
+ if frappe.get_system_settings("encrypt_backup") and frappe.get_site_config().encryption_key:
+ click.secho(
+ "Backup encryption is turned on. Please note the backup encryption key.",
+ fg="yellow"
+ )
odb.print_summary()
- click.secho("Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""), fg="green")
+ click.secho(
+ "Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""),
+ fg="green"
+ )
frappe.destroy()
if not context.sites:
@@ -443,6 +618,7 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac
sys.exit(exit_code)
+
@click.command('remove-from-installed-apps')
@click.argument('app')
@pass_context
@@ -461,7 +637,7 @@ def remove_from_installed_apps(context, app):
@click.command('uninstall-app')
@click.argument('app')
-@click.option('--yes', '-y', help='To bypass confirmation prompt for uninstalling the app', is_flag=True, default=False, multiple=True)
+@click.option('--yes', '-y', help='To bypass confirmation prompt for uninstalling the app', is_flag=True, default=False)
@click.option('--dry-run', help='List all doctypes that will be deleted', is_flag=True, default=False)
@click.option('--no-backup', help='Do not backup the site', is_flag=True, default=False)
@click.option('--force', help='Force remove app from site', is_flag=True, default=False)
@@ -482,16 +658,16 @@ def uninstall(context, app, dry_run, yes, no_backup, force):
@click.command('drop-site')
@click.argument('site')
-@click.option('--root-login', default='root')
-@click.option('--root-password')
+@click.option('--db-root-username', '--mariadb-root-username', '--root-login', help='Root username for MariaDB or PostgreSQL, Default is "root"')
+@click.option('--db-root-password', '--mariadb-root-password', '--root-password', help='Root password for MariaDB or PostgreSQL')
@click.option('--archived-sites-path')
@click.option('--no-backup', is_flag=True, default=False)
@click.option('--force', help='Force drop-site even if an error is encountered', is_flag=True, default=False)
-def drop_site(site, root_login='root', root_password=None, archived_sites_path=None, force=False, no_backup=False):
- _drop_site(site, root_login, root_password, archived_sites_path, force, no_backup)
+def drop_site(site, db_root_username='root', db_root_password=None, archived_sites_path=None, force=False, no_backup=False):
+ _drop_site(site, db_root_username, db_root_password, archived_sites_path, force, no_backup)
-def _drop_site(site, root_login='root', root_password=None, archived_sites_path=None, force=False, no_backup=False):
+def _drop_site(site, db_root_username=None, db_root_password=None, archived_sites_path=None, force=False, no_backup=False):
"Remove site from database and filesystem"
from frappe.database import drop_user_and_database
from frappe.utils.backups import scheduled_backup
@@ -516,13 +692,11 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path=
click.echo("\n".join(messages))
sys.exit(1)
- drop_user_and_database(frappe.conf.db_name, root_login, root_password)
+ drop_user_and_database(frappe.conf.db_name, db_root_username, db_root_password)
- if not archived_sites_path:
- archived_sites_path = os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived_sites')
+ archived_sites_path = archived_sites_path or os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived', 'sites')
- if not os.path.exists(archived_sites_path):
- os.mkdir(archived_sites_path)
+ os.makedirs(archived_sites_path, exist_ok=True)
move(archived_sites_path, site)
@@ -543,36 +717,61 @@ def move(dest_dir, site):
site_dump_exists = os.path.exists(final_new_path)
count = int(count or 0) + 1
- os.rename(old_path, final_new_path)
+ shutil.move(old_path, final_new_path)
frappe.destroy()
return final_new_path
-@click.command('set-admin-password')
-@click.argument('admin-password')
+@click.command('set-password')
+@click.argument('user')
+@click.argument('password', required=False)
@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False)
@pass_context
-def set_admin_password(context, admin_password, logout_all_sessions=False):
- "Set Administrator password for a site"
- import getpass
- from frappe.utils.password import update_password
-
- for site in context.sites:
- try:
- frappe.init(site=site)
-
- while not admin_password:
- admin_password = getpass.getpass("Administrator's password for {0}: ".format(site))
-
- frappe.connect()
- update_password(user='Administrator', pwd=admin_password, logout_all_sessions=logout_all_sessions)
- frappe.db.commit()
- admin_password = None
- finally:
- frappe.destroy()
+def set_password(context, user, password=None, logout_all_sessions=False):
+ "Set password for a user on a site"
if not context.sites:
raise SiteNotSpecifiedError
+ for site in context.sites:
+ set_user_password(site, user, password, logout_all_sessions)
+
+
+@click.command('set-admin-password')
+@click.argument('admin-password', required=False)
+@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False)
+@pass_context
+def set_admin_password(context, admin_password=None, logout_all_sessions=False):
+ "Set Administrator password for a site"
+ if not context.sites:
+ raise SiteNotSpecifiedError
+
+ for site in context.sites:
+ set_user_password(site, "Administrator", admin_password, logout_all_sessions)
+
+
+def set_user_password(site, user, password, logout_all_sessions=False):
+ import getpass
+
+ from frappe.utils.password import update_password
+
+ try:
+ frappe.init(site=site)
+
+ while not password:
+ password = getpass.getpass(f"{user}'s password for {site}: ")
+
+ frappe.connect()
+ if not frappe.db.exists("User", user):
+ print(f"User {user} does not exist")
+ sys.exit(1)
+
+ update_password(user=user, pwd=password, logout_all_sessions=logout_all_sessions)
+ frappe.db.commit()
+ password = None
+ finally:
+ frappe.destroy()
+
+
@click.command('set-last-active-for-user')
@click.option('--user', help="Setup last active date for user")
@pass_context
@@ -622,22 +821,41 @@ def publish_realtime(context, event, message, room, user, doctype, docname, afte
@click.command('browse')
@click.argument('site', required=False)
+@click.option('--user', required=False, help='Login as user')
@pass_context
-def browse(context, site):
+def browse(context, site, user=None):
'''Opens the site on web browser'''
- import webbrowser
- site = context.sites[0] if context.sites else site
+ from frappe.auth import CookieManager, LoginManager
+
+ site = get_site(context, raise_err=False) or site
if not site:
- click.echo('''Please provide site name\n\nUsage:\n\tbench browse [site-name]\nor\n\tbench --site [site-name] browse''')
- return
+ raise SiteNotSpecifiedError
- site = site.lower()
+ if site not in frappe.utils.get_sites():
+ click.echo(f"\nSite named {click.style(site, bold=True)} doesn't exist\n", err=True)
+ sys.exit(1)
- if site in frappe.utils.get_sites():
- webbrowser.open(frappe.utils.get_site_url(site), new=2)
- else:
- click.echo("\nSite named \033[1m{}\033[0m doesn't exist\n".format(site))
+ frappe.init(site=site)
+ frappe.connect()
+
+ sid = ''
+ if user:
+ if frappe.conf.developer_mode or user == "Administrator":
+ frappe.utils.set_request(path="/")
+ frappe.local.cookie_manager = CookieManager()
+ frappe.local.login_manager = LoginManager()
+ frappe.local.login_manager.login_as(user)
+ sid = f'/app?sid={frappe.session.sid}'
+ else:
+ click.echo("Please enable developer mode to login as a user")
+
+ url = f'{frappe.utils.get_site_url(site)}{sid}'
+
+ if user == "Administrator":
+ click.echo(f'Login URL: {url}')
+
+ click.launch(url)
@click.command('start-recording')
@@ -664,18 +882,17 @@ def stop_recording(context):
raise SiteNotSpecifiedError
@click.command('ngrok')
+@click.option('--bind-tls', is_flag=True, default=False, help='Returns a reference to the https tunnel.')
@pass_context
-def start_ngrok(context):
+def start_ngrok(context, bind_tls):
from pyngrok import ngrok
site = get_site(context)
frappe.init(site=site)
port = frappe.conf.http_port or frappe.conf.webserver_port
- public_url = ngrok.connect(port=port, options={
- 'host_header': site
- })
- print(f'Public URL: {public_url}')
+ tunnel = ngrok.connect(addr=str(port), host_header=site, bind_tls=bind_tls)
+ print(f'Public URL: {tunnel.public_url}')
print('Inspect logs at http://localhost:4040')
ngrok_process = ngrok.get_ngrok_process()
@@ -703,6 +920,131 @@ def build_search_index(context):
finally:
frappe.destroy()
+@click.command('trim-database')
+@click.option('--dry-run', is_flag=True, default=False, help='Show what would be deleted')
+@click.option('--format', '-f', default='text', type=click.Choice(['json', 'text']), help='Output format')
+@click.option('--no-backup', is_flag=True, default=False, help='Do not backup the site')
+@pass_context
+def trim_database(context, dry_run, format, no_backup):
+ if not context.sites:
+ raise SiteNotSpecifiedError
+
+ from frappe.utils.backups import scheduled_backup
+
+ ALL_DATA = {}
+
+ for site in context.sites:
+ frappe.init(site=site)
+ frappe.connect()
+
+ TABLES_TO_DROP = []
+ STANDARD_TABLES = get_standard_tables()
+ information_schema = frappe.qb.Schema("information_schema")
+ table_name = frappe.qb.Field("table_name").as_("name")
+
+ queried_result = frappe.qb.from_(
+ information_schema.tables
+ ).select(table_name).where(
+ information_schema.tables.table_schema == frappe.conf.db_name
+ ).run()
+
+ database_tables = [x[0] for x in queried_result]
+ doctype_tables = frappe.get_all("DocType", pluck="name")
+
+ for x in database_tables:
+ doctype = x.replace("tab", "", 1)
+ if not (doctype in doctype_tables or x.startswith("__") or x in STANDARD_TABLES):
+ TABLES_TO_DROP.append(x)
+
+ if not TABLES_TO_DROP:
+ if format == "text":
+ click.secho(f"No ghost tables found in {frappe.local.site}...Great!", fg="green")
+ else:
+ if not (no_backup or dry_run):
+ if format == "text":
+ print(f"Backing Up Tables: {', '.join(TABLES_TO_DROP)}")
+
+ odb = scheduled_backup(
+ ignore_conf=False,
+ include_doctypes=",".join(x.replace("tab", "", 1) for x in TABLES_TO_DROP),
+ ignore_files=True,
+ force=True,
+ )
+ if format == "text":
+ odb.print_summary()
+ print("\nTrimming Database")
+
+ for table in TABLES_TO_DROP:
+ if format == "text":
+ print(f"* Dropping Table '{table}'...")
+ if not dry_run:
+ frappe.db.sql_ddl(f"drop table `{table}`")
+
+ ALL_DATA[frappe.local.site] = TABLES_TO_DROP
+ frappe.destroy()
+
+ if format == "json":
+ import json
+ print(json.dumps(ALL_DATA, indent=1))
+
+
+def get_standard_tables():
+ import re
+
+ tables = []
+ sql_file = os.path.join(
+ "..", "apps", "frappe", "frappe", "database", frappe.conf.db_type, f'framework_{frappe.conf.db_type}.sql'
+ )
+ content = open(sql_file).read().splitlines()
+
+ for line in content:
+ table_found = re.search(r"""CREATE TABLE ("|`)(.*)?("|`) \(""", line)
+ if table_found:
+ tables.append(table_found.group(2))
+
+ return tables
+
+@click.command('trim-tables')
+@click.option('--dry-run', is_flag=True, default=False, help='Show what would be deleted')
+@click.option('--format', '-f', default='table', type=click.Choice(['json', 'table']), help='Output format')
+@click.option('--no-backup', is_flag=True, default=False, help='Do not backup the site')
+@pass_context
+def trim_tables(context, dry_run, format, no_backup):
+ if not context.sites:
+ raise SiteNotSpecifiedError
+
+ from frappe.model.meta import trim_tables
+ from frappe.utils.backups import scheduled_backup
+
+ for site in context.sites:
+ frappe.init(site=site)
+ frappe.connect()
+
+ if not (no_backup or dry_run):
+ click.secho(f"Taking backup for {frappe.local.site}", fg="green")
+ odb = scheduled_backup(ignore_files=False, force=True)
+ odb.print_summary()
+
+ try:
+ trimmed_data = trim_tables(dry_run=dry_run, quiet=format == 'json')
+
+ if format == 'table' and not dry_run:
+ click.secho(f"The following data have been removed from {frappe.local.site}", fg='green')
+
+ handle_data(trimmed_data, format=format)
+ finally:
+ frappe.destroy()
+
+def handle_data(data: dict, format='json'):
+ if format == 'json':
+ import json
+ print(json.dumps({frappe.local.site: data}, indent=1, sort_keys=True))
+ else:
+ from frappe.utils.commands import render_table
+ data = [["DocType", "Fields"]] + [[table, ", ".join(columns)] for table, columns in data.items()]
+ render_table(data)
+
+
commands = [
add_system_manager,
backup,
@@ -718,6 +1060,7 @@ commands = [
remove_from_installed_apps,
restore,
run_patch,
+ set_password,
set_admin_password,
uninstall,
disable_user,
@@ -730,5 +1073,7 @@ commands = [
add_to_hosts,
start_ngrok,
build_search_index,
- partial_restore
+ partial_restore,
+ trim_tables,
+ trim_database,
]
diff --git a/frappe/commands/translate.py b/frappe/commands/translate.py
index 48a7fd1db7..68d210eaaa 100644
--- a/frappe/commands/translate.py
+++ b/frappe/commands/translate.py
@@ -1,4 +1,3 @@
-from __future__ import unicode_literals, absolute_import, print_function
import click
from frappe.commands import pass_context, get_site
from frappe.exceptions import SiteNotSpecifiedError
diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py
index e9fa7217a8..c0bb44efab 100644
--- a/frappe/commands/utils.py
+++ b/frappe/commands/utils.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-
import json
import os
import subprocess
@@ -11,38 +9,63 @@ import click
import frappe
from frappe.commands import get_site, pass_context
from frappe.exceptions import SiteNotSpecifiedError
-from frappe.utils import get_bench_path, update_progress_bar
+from frappe.utils import update_progress_bar, cint
+from frappe.coverage import CodeCoverage
+
+DATA_IMPORT_DEPRECATION = (
+ "[DEPRECATED] The `import-csv` command used 'Data Import Legacy' which has been deprecated.\n"
+ "Use `data-import` command instead to import data via 'Data Import'."
+)
@click.command('build')
@click.option('--app', help='Build assets for app')
-@click.option('--make-copy', is_flag=True, default=False, help='Copy the files instead of symlinking')
-@click.option('--restore', is_flag=True, default=False, help='Copy the files instead of symlinking with force')
+@click.option('--apps', help='Build assets for specific apps')
+@click.option('--hard-link', is_flag=True, default=False, help='Copy the files instead of symlinking')
+@click.option('--make-copy', is_flag=True, default=False, help='[DEPRECATED] Copy the files instead of symlinking')
+@click.option('--restore', is_flag=True, default=False, help='[DEPRECATED] Copy the files instead of symlinking with force')
+@click.option('--production', is_flag=True, default=False, help='Build assets in production mode')
@click.option('--verbose', is_flag=True, default=False, help='Verbose')
@click.option('--force', is_flag=True, default=False, help='Force build assets instead of downloading available')
-def build(app=None, make_copy=False, restore=False, verbose=False, force=False):
- "Minify + concatenate JS and CSS files, build translations"
- import frappe.build
+def build(app=None, apps=None, hard_link=False, make_copy=False, restore=False, production=False, verbose=False, force=False):
+ "Compile JS and CSS source files"
+ from frappe.build import bundle, download_frappe_assets
frappe.init('')
- # don't minify in developer_mode for faster builds
- no_compress = frappe.local.conf.developer_mode or False
+
+ if not apps and app:
+ apps = app
# dont try downloading assets if force used, app specified or running via CI
- if not (force or app or os.environ.get('CI')):
+ if not (force or apps or os.environ.get('CI')):
# skip building frappe if assets exist remotely
- skip_frappe = frappe.build.download_frappe_assets(verbose=verbose)
+ skip_frappe = download_frappe_assets(verbose=verbose)
else:
skip_frappe = False
- frappe.build.bundle(no_compress, app=app, make_copy=make_copy, restore=restore, verbose=verbose, skip_frappe=skip_frappe)
+ # don't minify in developer_mode for faster builds
+ development = frappe.local.conf.developer_mode or frappe.local.dev_server
+ mode = "development" if development else "production"
+ if production:
+ mode = "production"
+
+ if make_copy or restore:
+ hard_link = make_copy or restore
+ click.secho(
+ "bench build: --make-copy and --restore options are deprecated in favour of --hard-link",
+ fg="yellow",
+ )
+
+ bundle(mode, apps=apps, hard_link=hard_link, verbose=verbose, skip_frappe=skip_frappe)
+
@click.command('watch')
-def watch():
- "Watch and concatenate JS and CSS files as and when they change"
- import frappe.build
+@click.option('--apps', help='Watch assets for specific apps')
+def watch(apps=None):
+ "Watch and compile JS and CSS files as and when they change"
+ from frappe.build import watch
frappe.init('')
- frappe.build.watch(True)
+ watch(apps)
@click.command('clear-cache')
@@ -50,14 +73,14 @@ def watch():
def clear_cache(context):
"Clear cache, doctype cache and defaults"
import frappe.sessions
- import frappe.website.render
+ from frappe.website.utils import clear_website_cache
from frappe.desk.notifications import clear_notifications
for site in context.sites:
try:
frappe.connect(site)
frappe.clear_cache()
clear_notifications()
- frappe.website.render.clear_cache()
+ clear_website_cache()
finally:
frappe.destroy()
if not context.sites:
@@ -67,12 +90,12 @@ def clear_cache(context):
@pass_context
def clear_website_cache(context):
"Clear website cache"
- import frappe.website.render
+ from frappe.website.utils import clear_website_cache
for site in context.sites:
try:
frappe.init(site=site)
frappe.connect()
- frappe.website.render.clear_cache()
+ clear_website_cache()
finally:
frappe.destroy()
if not context.sites:
@@ -96,22 +119,54 @@ def destroy_all_sessions(context, reason=None):
raise SiteNotSpecifiedError
@click.command('show-config')
+@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text")
@pass_context
-def show_config(context):
- "print configuration file"
- print("\t\033[92m{:<50}\033[0m \033[92m{:<15}\033[0m".format('Config','Value'))
- sites_path = os.path.join(frappe.utils.get_bench_path(), 'sites')
- site_path = context.sites[0]
- configuration = frappe.get_site_config(sites_path=sites_path, site_path=site_path)
- print_config(configuration)
+def show_config(context, format):
+ "Print configuration file to STDOUT in speified format"
+ if not context.sites:
+ raise SiteNotSpecifiedError
-def print_config(config):
- for conf, value in config.items():
- if isinstance(value, dict):
- print_config(value)
- else:
- print("\t{:<50} {:<15}".format(conf, value))
+ sites_config = {}
+ sites_path = os.getcwd()
+
+ from frappe.utils.commands import render_table
+
+ def transform_config(config, prefix=None):
+ prefix = f"{prefix}." if prefix else ""
+ site_config = []
+
+ for conf, value in config.items():
+ if isinstance(value, dict):
+ site_config += transform_config(value, prefix=f"{prefix}{conf}")
+ else:
+ log_value = json.dumps(value) if isinstance(value, list) else value
+ site_config += [[f"{prefix}{conf}", log_value]]
+
+ return site_config
+
+ for site in context.sites:
+ frappe.init(site)
+
+ if len(context.sites) != 1 and format == "text":
+ if context.sites.index(site) != 0:
+ click.echo()
+ click.secho(f"Site {site}", fg="yellow")
+
+ configuration = frappe.get_site_config(sites_path=sites_path, site_path=site)
+
+ if format == "text":
+ data = transform_config(configuration)
+ data.insert(0, ['Config','Value'])
+ render_table(data)
+
+ if format == "json":
+ sites_config[site] = configuration
+
+ frappe.destroy()
+
+ if format == "json":
+ click.echo(frappe.as_json(sites_config))
@click.command('reset-perms')
@@ -171,7 +226,7 @@ def execute(context, method, args=None, kwargs=None, profile=False):
if profile:
import pstats
- from six import StringIO
+ from io import StringIO
pr.disable()
s = StringIO()
@@ -293,13 +348,14 @@ def import_doc(context, path, force=False):
try:
frappe.init(site=site)
frappe.connect()
- import_doc(path, overwrite=context.force)
+ import_doc(path)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
-@click.command('import-csv')
+
+@click.command('import-csv', help=DATA_IMPORT_DEPRECATION)
@click.argument('path')
@click.option('--only-insert', default=False, is_flag=True, help='Do not overwrite existing records')
@click.option('--submit-after-import', default=False, is_flag=True, help='Submit document after importing it')
@@ -307,32 +363,8 @@ def import_doc(context, path, force=False):
@click.option('--no-email', default=True, is_flag=True, help='Send email if applicable')
@pass_context
def import_csv(context, path, only_insert=False, submit_after_import=False, ignore_encoding_errors=False, no_email=True):
- "Import CSV using data import"
- from frappe.core.doctype.data_import_legacy import importer
- from frappe.utils.csvutils import read_csv_content
- site = get_site(context)
-
- if not os.path.exists(path):
- path = os.path.join('..', path)
- if not os.path.exists(path):
- print('Invalid path {0}'.format(path))
- sys.exit(1)
-
- with open(path, 'r') as csvfile:
- content = read_csv_content(csvfile.read())
-
- frappe.init(site=site)
- frappe.connect()
-
- try:
- importer.upload(content, submit_after_import=submit_after_import, no_email=no_email,
- ignore_encoding_errors=ignore_encoding_errors, overwrite=not only_insert,
- via_console=True)
- frappe.db.commit()
- except Exception:
- print(frappe.get_traceback())
-
- frappe.destroy()
+ click.secho(DATA_IMPORT_DEPRECATION, fg="yellow")
+ sys.exit(1)
@click.command('data-import')
@@ -375,20 +407,47 @@ def bulk_rename(context, doctype, path):
frappe.destroy()
+@click.command('db-console')
+@pass_context
+def database(context):
+ """
+ Enter into the Database console for given site.
+ """
+ site = get_site(context)
+ if not site:
+ raise SiteNotSpecifiedError
+ frappe.init(site=site)
+ if not frappe.conf.db_type or frappe.conf.db_type == "mariadb":
+ _mariadb()
+ elif frappe.conf.db_type == "postgres":
+ _psql()
+
+
@click.command('mariadb')
@pass_context
def mariadb(context):
"""
Enter into mariadb console for a given site.
"""
- import os
-
site = get_site(context)
if not site:
raise SiteNotSpecifiedError
frappe.init(site=site)
+ _mariadb()
- # This is assuming you're within the bench instance.
+
+@click.command('postgres')
+@pass_context
+def postgres(context):
+ """
+ Enter into postgres console for a given site.
+ """
+ site = get_site(context)
+ frappe.init(site=site)
+ _psql()
+
+
+def _mariadb():
mysql = find_executable('mysql')
os.execv(mysql, [
mysql,
@@ -401,15 +460,7 @@ def mariadb(context):
"-A"])
-@click.command('postgres')
-@pass_context
-def postgres(context):
- """
- Enter into postgres console for a given site.
- """
- site = get_site(context)
- frappe.init(site=site)
- # This is assuming you're within the bench instance.
+def _psql():
psql = find_executable('psql')
subprocess.run([ psql, '-d', frappe.conf.db_name])
@@ -452,16 +503,36 @@ frappe.db.connect()
])
+def _console_cleanup():
+ # Execute rollback_observers on console close
+ frappe.db.rollback()
+ frappe.destroy()
+
+
@click.command('console')
+@click.option(
+ '--autoreload',
+ is_flag=True,
+ help="Reload changes to code automatically"
+)
@pass_context
-def console(context):
+def console(context, autoreload=False):
"Start ipython console for a site"
site = get_site(context)
frappe.init(site=site)
frappe.connect()
frappe.local.lang = frappe.db.get_default("lang")
- import IPython
+ from IPython.terminal.embed import InteractiveShellEmbed
+ from atexit import register
+
+ register(_console_cleanup)
+
+ terminal = InteractiveShellEmbed()
+ if autoreload:
+ terminal.extension_manager.load_extension("autoreload")
+ terminal.run_line_magic("autoreload", "2")
+
all_apps = frappe.get_installed_apps()
failed_to_import = []
@@ -470,20 +541,91 @@ def console(context):
locals()[app] = __import__(app)
except ModuleNotFoundError:
failed_to_import.append(app)
+ all_apps.remove(app)
print("Apps in this namespace:\n{}".format(", ".join(all_apps)))
if failed_to_import:
print("\nFailed to import:\n{}".format(", ".join(failed_to_import)))
- IPython.embed(display_banner="", header="", colors="neutral")
+ terminal.colors = "neutral"
+ terminal.display_banner = False
+ terminal()
+
+
+@click.command('transform-database', help="Change tables' internal settings changing engine and row formats")
+@click.option('--table', required=True, help="Comma separated name of tables to convert. To convert all tables, pass 'all'")
+@click.option('--engine', default=None, type=click.Choice(["InnoDB", "MyISAM"]), help="Choice of storage engine for said table(s)")
+@click.option('--row_format', default=None, type=click.Choice(["DYNAMIC", "COMPACT", "REDUNDANT", "COMPRESSED"]), help="Set ROW_FORMAT parameter for said table(s)")
+@click.option('--failfast', is_flag=True, default=False, help="Exit on first failure occurred")
+@pass_context
+def transform_database(context, table, engine, row_format, failfast):
+ "Transform site database through given parameters"
+ site = get_site(context)
+ check_table = []
+ add_line = False
+ skipped = 0
+ frappe.init(site=site)
+
+ if frappe.conf.db_type and frappe.conf.db_type != "mariadb":
+ click.secho("This command only has support for MariaDB databases at this point", fg="yellow")
+ sys.exit(1)
+
+ if not (engine or row_format):
+ click.secho("Values for `--engine` or `--row_format` must be set")
+ sys.exit(1)
+
+ frappe.connect()
+
+ if table == "all":
+ information_schema = frappe.qb.Schema("information_schema")
+ queried_tables = frappe.qb.from_(
+ information_schema.tables
+ ).select("table_name").where(
+ (information_schema.tables.row_format != row_format)
+ & (information_schema.tables.table_schema == frappe.conf.db_name)
+ ).run()
+ tables = [x[0] for x in queried_tables]
+ else:
+ tables = [x.strip() for x in table.split(",")]
+
+ total = len(tables)
+
+ for current, table in enumerate(tables):
+ values_to_set = ""
+ if engine:
+ values_to_set += f" ENGINE={engine}"
+ if row_format:
+ values_to_set += f" ROW_FORMAT={row_format}"
+
+ try:
+ frappe.db.sql(f"ALTER TABLE `{table}`{values_to_set}")
+ update_progress_bar("Updating table schema", current - skipped, total)
+ add_line = True
+
+ except Exception as e:
+ check_table.append([table, e.args])
+ skipped += 1
+
+ if failfast:
+ break
+
+ if add_line:
+ print()
+
+ for errored_table in check_table:
+ table, err = errored_table
+ err_msg = f"{table}: ERROR {err[0]}: {err[1]}"
+ click.secho(err_msg, fg="yellow")
+
+ frappe.destroy()
@click.command('run-tests')
@click.option('--app', help="For App")
@click.option('--doctype', help="For DocType")
+@click.option('--case', help="Select particular TestCase")
@click.option('--doctype-list-path', help="Path to .txt file for list of doctypes. Example erpnext/tests/server/agriculture.txt")
@click.option('--test', multiple=True, help="Specific test")
-@click.option('--driver', help="For Travis")
@click.option('--ui-tests', is_flag=True, default=False, help="Run UI Tests")
@click.option('--module', help="Run tests in a module")
@click.option('--profile', is_flag=True, default=False)
@@ -491,69 +633,66 @@ def console(context):
@click.option('--skip-test-records', is_flag=True, default=False, help="Don't create test records")
@click.option('--skip-before-tests', is_flag=True, default=False, help="Don't run before tests hook")
@click.option('--junit-xml-output', help="Destination file path for junit xml report")
-@click.option('--failfast', is_flag=True, default=False)
+@click.option('--failfast', is_flag=True, default=False, help="Stop the test run on the first error or failure")
@pass_context
-def run_tests(context, app=None, module=None, doctype=None, test=(),
- driver=None, profile=False, coverage=False, junit_xml_output=False, ui_tests = False,
- doctype_list_path=None, skip_test_records=False, skip_before_tests=False, failfast=False):
+def run_tests(context, app=None, module=None, doctype=None, test=(), profile=False,
+ coverage=False, junit_xml_output=False, ui_tests = False, doctype_list_path=None,
+ skip_test_records=False, skip_before_tests=False, failfast=False, case=None):
- "Run tests"
- import frappe.test_runner
- tests = test
+ with CodeCoverage(coverage, app):
+ import frappe
+ import frappe.test_runner
+ tests = test
+ site = get_site(context)
- site = get_site(context)
+ allow_tests = frappe.get_conf(site).allow_tests
- allow_tests = frappe.get_conf(site).allow_tests
+ if not (allow_tests or os.environ.get('CI')):
+ click.secho('Testing is disabled for the site!', bold=True)
+ click.secho('You can enable tests by entering following command:')
+ click.secho('bench --site {0} set-config allow_tests true'.format(site), fg='green')
+ return
- if not (allow_tests or os.environ.get('CI')):
- click.secho('Testing is disabled for the site!', bold=True)
- click.secho('You can enable tests by entering following command:')
- click.secho('bench --site {0} set-config allow_tests true'.format(site), fg='green')
- return
+ frappe.init(site=site)
- frappe.init(site=site)
+ frappe.flags.skip_before_tests = skip_before_tests
+ frappe.flags.skip_test_records = skip_test_records
- frappe.flags.skip_before_tests = skip_before_tests
- frappe.flags.skip_test_records = skip_test_records
+ ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests,
+ force=context.force, profile=profile, junit_xml_output=junit_xml_output,
+ ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast, case=case)
- if coverage:
- from coverage import Coverage
+ if len(ret.failures) == 0 and len(ret.errors) == 0:
+ ret = 0
- # Generate coverage report only for app that is being tested
- source_path = os.path.join(get_bench_path(), 'apps', app or 'frappe')
- cov = Coverage(source=[source_path], omit=[
- '*.html',
- '*.js',
- '*.xml',
- '*.css',
- '*.less',
- '*.scss',
- '*.vue',
- '*/doctype/*/*_dashboard.py',
- '*/patches/*'
- ])
- cov.start()
-
- ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests,
- force=context.force, profile=profile, junit_xml_output=junit_xml_output,
- ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast)
-
- if coverage:
- cov.stop()
- cov.save()
-
- if len(ret.failures) == 0 and len(ret.errors) == 0:
- ret = 0
-
- if os.environ.get('CI'):
- sys.exit(ret)
+ if os.environ.get('CI'):
+ sys.exit(ret)
+@click.command('run-parallel-tests')
+@click.option('--app', help="For App", default='frappe')
+@click.option('--build-number', help="Build number", default=1)
+@click.option('--total-builds', help="Total number of builds", default=1)
+@click.option('--with-coverage', is_flag=True, help="Build coverage file")
+@click.option('--use-orchestrator', is_flag=True, help="Use orchestrator to run parallel tests")
+@pass_context
+def run_parallel_tests(context, app, build_number, total_builds, with_coverage=False, use_orchestrator=False):
+ with CodeCoverage(with_coverage, app):
+ site = get_site(context)
+ if use_orchestrator:
+ from frappe.parallel_test_runner import ParallelTestWithOrchestrator
+ ParallelTestWithOrchestrator(app, site=site)
+ else:
+ from frappe.parallel_test_runner import ParallelTestRunner
+ ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds)
@click.command('run-ui-tests')
@click.argument('app')
@click.option('--headless', is_flag=True, help="Run UI Test in headless mode")
+@click.option('--parallel', is_flag=True, help="Run UI Test in parallel mode")
+@click.option('--with-coverage', is_flag=True, help="Generate coverage report")
+@click.option('--ci-build-id')
@pass_context
-def run_ui_tests(context, app, headless=False):
+def run_ui_tests(context, app, headless=False, parallel=True, with_coverage=False, ci_build_id=None):
"Run UI tests"
site = get_site(context)
app_base_path = os.path.abspath(os.path.join(frappe.get_app_path(app), '..'))
@@ -561,26 +700,39 @@ def run_ui_tests(context, app, headless=False):
admin_password = frappe.get_conf(site).admin_password
# override baseUrl using env variable
- site_env = 'CYPRESS_baseUrl={}'.format(site_url)
- password_env = 'CYPRESS_adminPassword={}'.format(admin_password) if admin_password else ''
+ site_env = f'CYPRESS_baseUrl={site_url}'
+ password_env = f'CYPRESS_adminPassword={admin_password}' if admin_password else ''
+ coverage_env = f'CYPRESS_coverage={str(with_coverage).lower()}'
os.chdir(app_base_path)
node_bin = subprocess.getoutput("npm bin")
- cypress_path = "{0}/cypress".format(node_bin)
- plugin_path = "{0}/cypress-file-upload".format(node_bin)
+ cypress_path = f"{node_bin}/cypress"
+ plugin_path = f"{node_bin}/../cypress-file-upload"
+ testing_library_path = f"{node_bin}/../@testing-library"
+ coverage_plugin_path = f"{node_bin}/../@cypress/code-coverage"
# check if cypress in path...if not, install it.
- if not (os.path.exists(cypress_path) or os.path.exists(plugin_path)) \
- or not subprocess.getoutput("npm view cypress version").startswith("6."):
+ if not (
+ os.path.exists(cypress_path)
+ and os.path.exists(plugin_path)
+ and os.path.exists(testing_library_path)
+ and os.path.exists(coverage_plugin_path)
+ and cint(subprocess.getoutput("npm view cypress version")[:1]) >= 6
+ ):
# install cypress
click.secho("Installing Cypress...", fg="yellow")
- frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 --no-lockfile")
+ frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 @testing-library/cypress@^8 @cypress/code-coverage@^3 --no-lockfile")
# run for headless mode
- run_or_open = 'run --browser chrome --record --key 4a48f41c-11b3-425b-aa88-c58048fa69eb' if headless else 'open'
- command = '{site_env} {password_env} {cypress} {run_or_open}'
- formatted_command = command.format(site_env=site_env, password_env=password_env, cypress=cypress_path, run_or_open=run_or_open)
+ run_or_open = 'run --browser chrome --record' if headless else 'open'
+ formatted_command = f'{site_env} {password_env} {coverage_env} {cypress_path} {run_or_open}'
+
+ if parallel:
+ formatted_command += ' --parallel'
+
+ if ci_build_id:
+ formatted_command += f' --ci-build-id {ci_build_id}'
click.secho("Running Cypress...", fg="yellow")
frappe.commands.popen(formatted_command, cwd=app_base_path, raise_err=True)
@@ -591,8 +743,9 @@ def run_ui_tests(context, app, headless=False):
@click.option('--profile', is_flag=True, default=False)
@click.option('--noreload', "no_reload", is_flag=True, default=False)
@click.option('--nothreading', "no_threading", is_flag=True, default=False)
+@click.option('--with-coverage', is_flag=True, default=False)
@pass_context
-def serve(context, port=None, profile=False, no_reload=False, no_threading=False, sites_path='.', site=None):
+def serve(context, port=None, profile=False, no_reload=False, no_threading=False, sites_path='.', site=None, with_coverage=False):
"Start development web server"
import frappe.app
@@ -600,8 +753,12 @@ def serve(context, port=None, profile=False, no_reload=False, no_threading=False
site = None
else:
site = context.sites[0]
-
- frappe.app.serve(port=port, profile=profile, no_reload=no_reload, no_threading=no_threading, site=site, sites_path='.')
+ with CodeCoverage(with_coverage, 'frappe'):
+ if with_coverage:
+ # unable to track coverage with threading enabled
+ no_threading = True
+ no_reload = True
+ frappe.app.serve(port=port, profile=profile, no_reload=no_reload, no_threading=no_threading, site=site, sites_path='.')
@click.command('request')
@@ -641,29 +798,37 @@ def request(context, args=None, path=None):
@click.command('make-app')
@click.argument('destination')
@click.argument('app_name')
-def make_app(destination, app_name):
+@click.option('--no-git', is_flag=True, default=False, help='Do not initialize git repository for the app')
+def make_app(destination, app_name, no_git=False):
"Creates a boilerplate app"
from frappe.utils.boilerplate import make_boilerplate
- make_boilerplate(destination, app_name)
+ make_boilerplate(destination, app_name, no_git=no_git)
@click.command('set-config')
@click.argument('key')
@click.argument('value')
-@click.option('-g', '--global', 'global_', is_flag = True, default = False, help = 'Set Global Site Config')
-@click.option('--as-dict', is_flag=True, default=False)
+@click.option('-g', '--global', 'global_', is_flag=True, default=False, help='Set value in bench config')
+@click.option('-p', '--parse', is_flag=True, default=False, help='Evaluate as Python Object')
+@click.option('--as-dict', is_flag=True, default=False, help='Legacy: Evaluate as Python Object')
@pass_context
-def set_config(context, key, value, global_ = False, as_dict=False):
+def set_config(context, key, value, global_=False, parse=False, as_dict=False):
"Insert/Update a value in site_config.json"
from frappe.installer import update_site_config
- import ast
+
if as_dict:
+ from frappe.utils.commands import warn
+ warn("--as-dict will be deprecated in v14. Use --parse instead", category=PendingDeprecationWarning)
+ parse = as_dict
+
+ if parse:
+ import ast
value = ast.literal_eval(value)
if global_:
- sites_path = os.getcwd() # big assumption.
+ sites_path = os.getcwd()
common_site_config_path = os.path.join(sites_path, 'common_site_config.json')
- update_site_config(key, value, validate = False, site_config_path = common_site_config_path)
+ update_site_config(key, value, validate=False, site_config_path=common_site_config_path)
else:
for site in context.sites:
frappe.init(site=site)
@@ -671,22 +836,49 @@ def set_config(context, key, value, global_ = False, as_dict=False):
frappe.destroy()
-@click.command('version')
-def get_version():
- "Show the versions of all the installed apps"
+@click.command("version")
+@click.option("-f", "--format", "output",
+ type=click.Choice(["plain", "table", "json", "legacy"]), help="Output format", default="legacy")
+def get_version(output):
+ """Show the versions of all the installed apps."""
+ from git import Repo
+ from frappe.utils.commands import render_table
from frappe.utils.change_log import get_app_branch
- frappe.init('')
- for m in sorted(frappe.get_all_apps()):
- branch_name = get_app_branch(m)
- module = frappe.get_module(m)
- app_hooks = frappe.get_module(m + ".hooks")
+ frappe.init("")
+ data = []
- if hasattr(app_hooks, '{0}_version'.format(branch_name)):
- print("{0} {1}".format(m, getattr(app_hooks, '{0}_version'.format(branch_name))))
+ for app in sorted(frappe.get_all_apps()):
+ module = frappe.get_module(app)
+ app_hooks = frappe.get_module(app + ".hooks")
+ repo = Repo(frappe.get_app_path(app, ".."))
- elif hasattr(module, "__version__"):
- print("{0} {1}".format(m, module.__version__))
+ app_info = frappe._dict()
+ app_info.app = app
+ app_info.branch = get_app_branch(app)
+ app_info.commit = repo.head.object.hexsha[:7]
+ app_info.version = getattr(app_hooks, f"{app_info.branch}_version", None) or module.__version__
+
+ data.append(app_info)
+
+ {
+ "legacy": lambda: [
+ click.echo(f"{app_info.app} {app_info.version}")
+ for app_info in data
+ ],
+ "plain": lambda: [
+ click.echo(f"{app_info.app} {app_info.version} {app_info.branch} ({app_info.commit})")
+ for app_info in data
+ ],
+ "table": lambda: render_table(
+ [["App", "Version", "Branch", "Commit"]] +
+ [
+ [app_info.app, app_info.version, app_info.branch, app_info.commit]
+ for app_info in data
+ ]
+ ),
+ "json": lambda: click.echo(json.dumps(data, indent=4)),
+ }[output]()
@click.command('rebuild-global-search')
@@ -720,55 +912,13 @@ def rebuild_global_search(context, static_pages=False):
if not context.sites:
raise SiteNotSpecifiedError
-@click.command('auto-deploy')
-@click.argument('app')
-@click.option('--migrate', is_flag=True, default=False, help='Migrate after pulling')
-@click.option('--restart', is_flag=True, default=False, help='Restart after migration')
-@click.option('--remote', default='upstream', help='Remote, default is "upstream"')
-@pass_context
-def auto_deploy(context, app, migrate=False, restart=False, remote='upstream'):
- '''Pull and migrate sites that have new version'''
- from frappe.utils.gitutils import get_app_branch
- from frappe.utils import get_sites
-
- branch = get_app_branch(app)
- app_path = frappe.get_app_path(app)
-
- # fetch
- subprocess.check_output(['git', 'fetch', remote, branch], cwd = app_path)
-
- # get diff
- if subprocess.check_output(['git', 'diff', '{0}..{1}/{0}'.format(branch, remote)], cwd = app_path):
- print('Updates found for {0}'.format(app))
- if app=='frappe':
- # run bench update
- import shlex
- subprocess.check_output(shlex.split('bench update --no-backup'), cwd = '..')
- else:
- updated = False
- subprocess.check_output(['git', 'pull', '--rebase', remote, branch],
- cwd = app_path)
- # find all sites with that app
- for site in get_sites():
- frappe.init(site)
- if app in frappe.get_installed_apps():
- print('Updating {0}'.format(site))
- updated = True
- subprocess.check_output(['bench', '--site', site, 'clear-cache'], cwd = '..')
- if migrate:
- subprocess.check_output(['bench', '--site', site, 'migrate'], cwd = '..')
- frappe.destroy()
-
- if updated or restart:
- subprocess.check_output(['bench', 'restart'], cwd = '..')
- else:
- print('No Updates')
-
commands = [
build,
clear_cache,
clear_website_cache,
+ database,
+ transform_database,
jupyter,
console,
destroy_all_sessions,
@@ -794,5 +944,6 @@ commands = [
watch,
bulk_rename,
add_to_email_queue,
- rebuild_global_search
+ rebuild_global_search,
+ run_parallel_tests
]
diff --git a/frappe/config/__init__.py b/frappe/config/__init__.py
index cc9d0e6c67..aa441b7d71 100644
--- a/frappe/config/__init__.py
+++ b/frappe/config/__init__.py
@@ -1,6 +1,3 @@
-from __future__ import unicode_literals
-import json
-from six import iteritems
import frappe
from frappe import _
from frappe.desk.moduleview import (get_data, get_onboard_items, config_exists, get_module_link_items_from_list)
@@ -36,62 +33,23 @@ def get_modules_from_all_apps():
return modules_list
def get_modules_from_app(app):
- try:
- modules = frappe.get_attr(app + '.config.desktop.get_data')() or {}
- except ImportError:
- return []
-
- active_domains = frappe.get_active_domains()
-
- if isinstance(modules, dict):
- active_modules_list = []
- for m, module in iteritems(modules):
- module['module_name'] = m
- module['app'] = app
- active_modules_list.append(module)
- else:
- for m in modules:
- if m.get("type") == "module" and "category" not in m:
- m["category"] = "Modules"
-
- # Only newly formatted modules that have a category to be shown on desk
- modules = [m for m in modules if m.get("category")]
- active_modules_list = []
-
- for m in modules:
- to_add = True
- module_name = m.get("module_name")
-
- # Check Domain
- if is_domain(m) and module_name not in active_domains:
- to_add = False
-
- # Check if config
- if is_module(m) and not config_exists(app, frappe.scrub(module_name)):
- to_add = False
-
- if "condition" in m and not m["condition"]:
- to_add = False
-
- if to_add:
- m["app"] = app
- active_modules_list.append(m)
-
- return active_modules_list
+ return frappe.get_all('Module Def',
+ filters={'app_name': app},
+ fields=['module_name', 'app_name as app']
+ )
def get_all_empty_tables_by_module():
- empty_tables = set(r[0] for r in frappe.db.multisql({
- "mariadb": """
- SELECT table_name
- FROM information_schema.tables
- WHERE table_rows = 0 and table_schema = "{}"
- """.format(frappe.conf.db_name),
- "postgres": """
- SELECT "relname" as "table_name"
- FROM "pg_stat_all_tables"
- WHERE n_tup_ins = 0
- """
- }))
+ table_rows = frappe.qb.Field("table_rows")
+ table_name = frappe.qb.Field("table_name")
+ information_schema = frappe.qb.Schema("information_schema")
+
+ empty_tables = (
+ frappe.qb.from_(information_schema.tables)
+ .select(table_name)
+ .where(table_rows == 0)
+ ).run()
+
+ empty_tables = {r[0] for r in empty_tables}
results = frappe.get_all("DocType", fields=["name", "module"])
empty_tables_by_module = {}
diff --git a/frappe/contacts/address_and_contact.py b/frappe/contacts/address_and_contact.py
index 3ca9547188..7824568a43 100644
--- a/frappe/contacts/address_and_contact.py
+++ b/frappe/contacts/address_and_contact.py
@@ -1,7 +1,6 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
@@ -17,7 +16,7 @@ def load_address_and_contact(doc, key=None):
["Dynamic Link", "link_name", "=", doc.name],
["Dynamic Link", "parenttype", "=", "Address"],
]
- address_list = frappe.get_all("Address", filters=filters, fields=["*"])
+ address_list = frappe.get_list("Address", filters=filters, fields=["*"])
address_list = [a.update({"display": get_address_display(a)})
for a in address_list]
@@ -35,16 +34,16 @@ def load_address_and_contact(doc, key=None):
["Dynamic Link", "link_name", "=", doc.name],
["Dynamic Link", "parenttype", "=", "Contact"],
]
- contact_list = frappe.get_all("Contact", filters=filters, fields=["*"])
+ contact_list = frappe.get_list("Contact", filters=filters, fields=["*"])
for contact in contact_list:
- contact["email_ids"] = frappe.get_list("Contact Email", filters={
+ contact["email_ids"] = frappe.get_all("Contact Email", filters={
"parenttype": "Contact",
"parent": contact.name,
"is_primary": 0
}, fields=["email_id"])
- contact["phone_nos"] = frappe.get_list("Contact Phone", filters={
+ contact["phone_nos"] = frappe.get_all("Contact Phone", filters={
"parenttype": "Contact",
"parent": contact.name,
"is_primary_phone": 0,
@@ -154,7 +153,7 @@ def filter_dynamic_link_doctypes(doctype, txt, searchfield, start, page_len, fil
doctypes = frappe.db.get_all("DocField", filters=filters, fields=["parent"],
distinct=True, as_list=True)
- doctypes = tuple([d for d in doctypes if re.search(txt+".*", _(d[0]), re.IGNORECASE)])
+ doctypes = tuple(d for d in doctypes if re.search(txt+".*", _(d[0]), re.IGNORECASE))
filters.update({
"dt": ("not in", [d[0] for d in doctypes])
@@ -179,4 +178,4 @@ def set_link_title(doc):
for link in doc.links:
if not link.link_title:
linked_doc = frappe.get_doc(link.link_doctype, link.link_name)
- link.link_title = linked_doc.get("title_field") or linked_doc.get("name")
+ link.link_title = linked_doc.get_title() or link.link_name
diff --git a/frappe/contacts/doctype/address/address.py b/frappe/contacts/doctype/address/address.py
index 84b925d50e..5d0ed18d5f 100644
--- a/frappe/contacts/doctype/address/address.py
+++ b/frappe/contacts/doctype/address/address.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import throw, _
@@ -10,15 +9,10 @@ from frappe.utils import cstr
from frappe.model.document import Document
from jinja2 import TemplateSyntaxError
-from frappe.utils.user import is_website_user
from frappe.model.naming import make_autoname
from frappe.core.doctype.dynamic_link.dynamic_link import deduplicate_dynamic_links
-from six import iteritems, string_types
-from past.builtins import cmp
from frappe.contacts.address_and_contact import set_link_title
-import functools
-
class Address(Document):
def __setup__(self):
@@ -71,7 +65,7 @@ class Address(Document):
def has_link(self, doctype, name):
for link in self.links:
- if link.link_doctype==doctype and link.link_name== name:
+ if link.link_doctype == doctype and link.link_name == name:
return True
def has_common_link(self, doc):
@@ -112,10 +106,13 @@ def get_default_address(doctype, name, sort_key='is_primary_address'):
WHERE
dl.parent = addr.name and dl.link_doctype = %s and
dl.link_name = %s and ifnull(addr.disabled, 0) = 0
- """ %(sort_key, '%s', '%s'), (doctype, name))
+ """ %(sort_key, '%s', '%s'), (doctype, name), as_dict=True)
if out:
- return sorted(out, key = functools.cmp_to_key(lambda x,y: cmp(y[1], x[1])))[0][0]
+ for contact in out:
+ if contact.get(sort_key):
+ return contact.name
+ return out[0].name
else:
return None
@@ -141,7 +138,7 @@ def get_territory_from_address(address):
if not address:
return
- if isinstance(address, string_types):
+ if isinstance(address, str):
address = frappe.get_cached_doc("Address", address)
territory = None
@@ -174,14 +171,11 @@ def get_address_list(doctype, txt, filters, limit_start, limit_page_length = 20,
def has_website_permission(doc, ptype, user, verbose=False):
"""Returns true if there is a related lead or contact related to this document"""
contact_name = frappe.db.get_value("Contact", {"email_id": frappe.session.user})
+
if contact_name:
contact = frappe.get_doc('Contact', contact_name)
return contact.has_common_link(doc)
- lead_name = frappe.db.get_value("Lead", {"email_id": frappe.session.user})
- if lead_name:
- return doc.has_link('Lead', lead_name)
-
return False
def get_address_templates(address):
@@ -214,7 +208,7 @@ def address_query(doctype, txt, searchfield, start, page_len, filters):
condition = ""
meta = frappe.get_meta("Address")
- for fieldname, value in iteritems(filters):
+ for fieldname, value in filters.items():
if meta.get_field(fieldname) or fieldname in frappe.db.DEFAULT_COLUMNS:
condition += " and {field}={value}".format(
field=fieldname,
@@ -263,7 +257,7 @@ def address_query(doctype, txt, searchfield, start, page_len, filters):
def get_condensed_address(doc):
fields = ["address_title", "address_line1", "address_line2", "city", "county", "state", "country"]
- return ", ".join([doc.get(d) for d in fields if doc.get(d)])
+ return ", ".join(doc.get(d) for d in fields if doc.get(d))
def update_preferred_address(address, field):
frappe.db.set_value('Address', address, field, 0)
diff --git a/frappe/contacts/doctype/address/test_address.py b/frappe/contacts/doctype/address/test_address.py
index d6d4e50491..dd6cd1ca83 100644
--- a/frappe/contacts/doctype/address/test_address.py
+++ b/frappe/contacts/doctype/address/test_address.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe, unittest
from frappe.contacts.doctype.address.address import get_address_display
diff --git a/frappe/contacts/doctype/address_template/address_template.py b/frappe/contacts/doctype/address_template/address_template.py
index 2ca9aebff5..005f414303 100644
--- a/frappe/contacts/doctype/address_template/address_template.py
+++ b/frappe/contacts/doctype/address_template/address_template.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe.utils import cint
diff --git a/frappe/contacts/doctype/address_template/test_address_template.py b/frappe/contacts/doctype/address_template/test_address_template.py
index f40b56e7d9..b86623b548 100644
--- a/frappe/contacts/doctype/address_template/test_address_template.py
+++ b/frappe/contacts/doctype/address_template/test_address_template.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe, unittest
class TestAddressTemplate(unittest.TestCase):
@@ -42,4 +40,4 @@ class TestAddressTemplate(unittest.TestCase):
"doctype": "Address Template",
"country": 'Brazil',
"template": template
- }).insert()
\ No newline at end of file
+ }).insert()
\ No newline at end of file
diff --git a/frappe/contacts/doctype/contact/contact.py b/frappe/contacts/doctype/contact/contact.py
index 42fa039f74..9152655b85 100644
--- a/frappe/contacts/doctype/contact/contact.py
+++ b/frappe/contacts/doctype/contact/contact.py
@@ -1,18 +1,13 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
-
-from __future__ import unicode_literals
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
import frappe
-from frappe.utils import cstr, has_gravatar, cint
+from frappe.utils import cstr, has_gravatar
from frappe import _
from frappe.model.document import Document
from frappe.core.doctype.dynamic_link.dynamic_link import deduplicate_dynamic_links
-from six import iteritems
-from past.builtins import cmp
from frappe.model.naming import append_number_if_name_exists
from frappe.contacts.address_and_contact import set_link_title
-import functools
class Contact(Document):
def autoname(self):
@@ -52,14 +47,14 @@ class Contact(Document):
def get_link_for(self, link_doctype):
'''Return the link name, if exists for the given link DocType'''
for link in self.links:
- if link.link_doctype==link_doctype:
+ if link.link_doctype == link_doctype:
return link.link_name
return None
def has_link(self, doctype, name):
for link in self.links:
- if link.link_doctype==doctype and link.link_name== name:
+ if link.link_doctype == doctype and link.link_name == name:
return True
def has_common_link(self, doc):
@@ -97,11 +92,16 @@ class Contact(Document):
if len([email.email_id for email in self.email_ids if email.is_primary]) > 1:
frappe.throw(_("Only one {0} can be set as primary.").format(frappe.bold("Email ID")))
+ primary_email_exists = False
for d in self.email_ids:
if d.is_primary == 1:
+ primary_email_exists = True
self.email_id = d.email_id.strip()
break
+ if not primary_email_exists:
+ self.email_id = ""
+
def set_primary(self, fieldname):
# Used to set primary mobile and phone no.
if len(self.phone_nos) == 0:
@@ -115,11 +115,16 @@ class Contact(Document):
if len(is_primary) > 1:
frappe.throw(_("Only one {0} can be set as primary.").format(frappe.bold(frappe.unscrub(fieldname))))
+ primary_number_exists = False
for d in self.phone_nos:
if d.get(field_name) == 1:
+ primary_number_exists = True
setattr(self, fieldname, d.phone)
break
+ if not primary_number_exists:
+ setattr(self, fieldname, "")
+
def get_default_contact(doctype, name):
'''Returns default contact for the given doctype, name'''
out = frappe.db.sql('''select parent,
@@ -130,10 +135,13 @@ def get_default_contact(doctype, name):
where
dl.link_doctype=%s and
dl.link_name=%s and
- dl.parenttype = "Contact"''', (doctype, name))
+ dl.parenttype = "Contact"''', (doctype, name), as_dict=True)
if out:
- return sorted(out, key = functools.cmp_to_key(lambda x,y: cmp(cint(y[1]), cint(x[1]))))[0][0]
+ for contact in out:
+ if contact.is_primary_contact:
+ return contact.parent
+ return out[0].parent
else:
return None
@@ -254,7 +262,7 @@ def get_contact_with_phone_number(number):
return contacts[0].parent if contacts else None
def get_contact_name(email_id):
- contact = frappe.get_list("Contact Email", filters={"email_id": email_id}, fields=["parent"], limit=1)
+ contact = frappe.get_all("Contact Email", filters={"email_id": email_id}, fields=["parent"], limit=1)
return contact[0].parent if contact else None
def get_contacts_linking_to(doctype, docname, fields=None):
diff --git a/frappe/contacts/doctype/contact/test_contact.js b/frappe/contacts/doctype/contact/test_contact.js
deleted file mode 100644
index 66ec061b35..0000000000
--- a/frappe/contacts/doctype/contact/test_contact.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Contact", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Contact
- () => frappe.tests.make('Contact', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/contacts/doctype/contact/test_contact.py b/frappe/contacts/doctype/contact/test_contact.py
index 4929873dc4..1170ba843a 100644
--- a/frappe/contacts/doctype/contact/test_contact.py
+++ b/frappe/contacts/doctype/contact/test_contact.py
@@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
-from frappe.exceptions import ValidationError
+
+test_dependencies = ['Contact', 'Salutation']
class TestContact(unittest.TestCase):
@@ -52,4 +51,4 @@ def create_contact(name, salutation, emails=None, phones=None, save=True):
if save:
doc.insert()
- return doc
\ No newline at end of file
+ return doc
diff --git a/frappe/contacts/doctype/contact_email/contact_email.py b/frappe/contacts/doctype/contact_email/contact_email.py
index 04e8b22989..58d37376b8 100644
--- a/frappe/contacts/doctype/contact_email/contact_email.py
+++ b/frappe/contacts/doctype/contact_email/contact_email.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/contacts/doctype/contact_phone/contact_phone.py b/frappe/contacts/doctype/contact_phone/contact_phone.py
index fe2f86a4bd..ed7d3b9911 100644
--- a/frappe/contacts/doctype/contact_phone/contact_phone.py
+++ b/frappe/contacts/doctype/contact_phone/contact_phone.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/contacts/doctype/gender/gender.py b/frappe/contacts/doctype/gender/gender.py
index bfca5830c1..b4efcb64b9 100644
--- a/frappe/contacts/doctype/gender/gender.py
+++ b/frappe/contacts/doctype/gender/gender.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
from frappe.model.document import Document
class Gender(Document):
diff --git a/frappe/contacts/doctype/gender/test_gender.py b/frappe/contacts/doctype/gender/test_gender.py
index fbe3473bc3..8549cc2130 100644
--- a/frappe/contacts/doctype/gender/test_gender.py
+++ b/frappe/contacts/doctype/gender/test_gender.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import unittest
class TestGender(unittest.TestCase):
diff --git a/frappe/contacts/doctype/salutation/salutation.py b/frappe/contacts/doctype/salutation/salutation.py
index d9e4528c7d..380af6de28 100644
--- a/frappe/contacts/doctype/salutation/salutation.py
+++ b/frappe/contacts/doctype/salutation/salutation.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
from frappe.model.document import Document
class Salutation(Document):
diff --git a/frappe/contacts/doctype/salutation/test_salutation.py b/frappe/contacts/doctype/salutation/test_salutation.py
index 63d603e6a4..59333fb61e 100644
--- a/frappe/contacts/doctype/salutation/test_salutation.py
+++ b/frappe/contacts/doctype/salutation/test_salutation.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import unittest
class TestSalutation(unittest.TestCase):
diff --git a/frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py b/frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py
index 1b3982f251..671e1c6bc8 100644
--- a/frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py
+++ b/frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py
@@ -1,8 +1,5 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
-
-from __future__ import unicode_literals
-from six import iteritems
+# License: MIT. See LICENSE
import frappe
from frappe import _
@@ -58,7 +55,7 @@ def get_reference_addresses_and_contact(reference_doctype, reference_name):
reference_details = get_reference_details(reference_doctype, "Address", reference_list, reference_details)
reference_details = get_reference_details(reference_doctype, "Contact", reference_list, reference_details)
- for reference_name, details in iteritems(reference_details):
+ for reference_name, details in reference_details.items():
addresses = details.get("address", [])
contacts = details.get("contact", [])
if not any([addresses, contacts]):
diff --git a/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py b/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py
index 2db395102a..f539722175 100644
--- a/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py
+++ b/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py
@@ -1,4 +1,4 @@
-from __future__ import unicode_literals
+
import frappe
import frappe.defaults
import unittest
@@ -103,7 +103,7 @@ class TestAddressesAndContacts(unittest.TestCase):
create_linked_contact(links_list, d)
report_data = get_data({"reference_doctype": "Test Custom Doctype"})
for idx, link in enumerate(links_list):
- test_item = [link, 'test address line 1', 'test address line 2', 'Milan', None, None, 'Italy', 0, '_Test First Name', '_Test Last Name', '_Test Address-Billing', '+91 0000000000', None, 'test_contact@example.com', 1]
+ test_item = [link, 'test address line 1', 'test address line 2', 'Milan', None, None, 'Italy', 0, '_Test First Name', '_Test Last Name', '_Test Address-Billing', '+91 0000000000', '', 'test_contact@example.com', 1]
self.assertListEqual(test_item, report_data[idx])
def tearDown(self):
diff --git a/frappe/core/__init__.py b/frappe/core/__init__.py
index 998a299158..98029dd956 100644
--- a/frappe/core/__init__.py
+++ b/frappe/core/__init__.py
@@ -1,4 +1,2 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
\ No newline at end of file
+# License: MIT. See LICENSE
diff --git a/frappe/core/doctype/__init__.py b/frappe/core/doctype/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/core/doctype/__init__.py
+++ b/frappe/core/doctype/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/core/doctype/access_log/access_log.py b/frappe/core/doctype/access_log/access_log.py
index 43381e7f2e..db2e64e868 100644
--- a/frappe/core/doctype/access_log/access_log.py
+++ b/frappe/core/doctype/access_log/access_log.py
@@ -1,12 +1,8 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-# imports - standard imports
-from __future__ import unicode_literals
-
-# imports - module imports
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
import frappe
+from frappe.utils import cstr
+from tenacity import retry, retry_if_exception_type, stop_after_attempt
from frappe.model.document import Document
@@ -15,24 +11,53 @@ class AccessLog(Document):
@frappe.whitelist()
-def make_access_log(doctype=None, document=None, method=None, file_type=None,
- report_name=None, filters=None, page=None, columns=None):
+def make_access_log(
+ doctype=None,
+ document=None,
+ method=None,
+ file_type=None,
+ report_name=None,
+ filters=None,
+ 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)
+)
+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"
- doc = frappe.get_doc({
- 'doctype': 'Access Log',
- 'user': user,
- 'export_from': doctype,
- 'reference_document': document,
- 'file_type': file_type,
- 'report_name': report_name,
- 'page': page,
- 'method': method,
- 'filters': frappe.utils.cstr(filters) if filters else None,
- 'columns': columns
- })
- doc.insert(ignore_permissions=True)
+ frappe.get_doc({
+ "doctype": "Access Log",
+ "user": user,
+ "export_from": doctype,
+ "reference_document": document,
+ "file_type": file_type,
+ "report_name": report_name,
+ "page": page,
+ "method": method,
+ "filters": cstr(filters) or None,
+ "columns": columns,
+ }).db_insert()
# `frappe.db.commit` added because insert doesnt `commit` when called in GET requests like `printview`
- frappe.db.commit()
+ # 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()
diff --git a/frappe/core/doctype/access_log/test_access_log.py b/frappe/core/doctype/access_log/test_access_log.py
index 9830507423..42878d0eb4 100644
--- a/frappe/core/doctype/access_log/test_access_log.py
+++ b/frappe/core/doctype/access_log/test_access_log.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
+# License: MIT. See LICENSE
# imports - standard imports
import unittest
diff --git a/frappe/core/doctype/activity_log/activity_log.json b/frappe/core/doctype/activity_log/activity_log.json
index a1ee4dafdb..ad12246a95 100644
--- a/frappe/core/doctype/activity_log/activity_log.json
+++ b/frappe/core/doctype/activity_log/activity_log.json
@@ -154,7 +154,7 @@
"icon": "fa fa-comment",
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-08-28 11:43:57.504565",
+ "modified": "2021-10-25 11:43:57.504565",
"modified_by": "Administrator",
"module": "Core",
"name": "Activity Log",
@@ -182,6 +182,5 @@
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "subject",
- "track_changes": 1,
"track_seen": 1
}
diff --git a/frappe/core/doctype/activity_log/activity_log.py b/frappe/core/doctype/activity_log/activity_log.py
index 98dc91806d..69565a2c2a 100644
--- a/frappe/core/doctype/activity_log/activity_log.py
+++ b/frappe/core/doctype/activity_log/activity_log.py
@@ -1,13 +1,15 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
from frappe import _
from frappe.utils import get_fullname, now
from frappe.model.document import Document
from frappe.core.utils import set_timeline_doc
import frappe
+from frappe.query_builder import DocType, Interval
+from frappe.query_builder.functions import Now
+from pypika.terms import PseudoColumn
class ActivityLog(Document):
def before_insert(self):
@@ -45,6 +47,7 @@ def clear_activity_logs(days=None):
if not days:
days = 90
-
- frappe.db.sql("""delete from `tabActivity Log` where \
- creation< (NOW() - INTERVAL '{0}' DAY)""".format(days))
\ No newline at end of file
+ doctype = DocType("Activity Log")
+ frappe.db.delete(doctype, filters=(
+ doctype.creation < PseudoColumn(f"({Now() - Interval(days=days)})")
+ ))
\ No newline at end of file
diff --git a/frappe/core/doctype/activity_log/feed.py b/frappe/core/doctype/activity_log/feed.py
index f51692fe9f..358272ac63 100644
--- a/frappe/core/doctype/activity_log/feed.py
+++ b/frappe/core/doctype/activity_log/feed.py
@@ -1,13 +1,11 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
import frappe.permissions
from frappe.utils import get_fullname
from frappe import _
from frappe.core.doctype.activity_log.activity_log import add_authentication_log
-from six import string_types
def update_feed(doc, method=None):
if frappe.flags.in_patch or frappe.flags.in_install or frappe.flags.in_import:
@@ -23,7 +21,7 @@ def update_feed(doc, method=None):
feed = doc.get_feed()
if feed:
- if isinstance(feed, string_types):
+ if isinstance(feed, str):
feed = {"subject": feed}
feed = frappe._dict(feed)
@@ -31,10 +29,12 @@ def update_feed(doc, method=None):
name = feed.name or doc.name
# delete earlier feed
- frappe.db.sql("""delete from `tabActivity Log`
- where
- reference_doctype=%s and reference_name=%s
- and link_doctype=%s""", (doctype, name,feed.link_doctype))
+ frappe.db.delete("Activity Log", {
+ "reference_doctype": doctype,
+ "reference_name": name,
+ "link_doctype": feed.link_doctype
+ })
+
frappe.get_doc({
"doctype": "Activity Log",
"reference_doctype": doctype,
diff --git a/frappe/core/doctype/activity_log/test_activity_log.py b/frappe/core/doctype/activity_log/test_activity_log.py
index 4dbfd6700e..87d3538cc7 100644
--- a/frappe/core/doctype/activity_log/test_activity_log.py
+++ b/frappe/core/doctype/activity_log/test_activity_log.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
import time
@@ -65,18 +63,22 @@ class TestActivityLog(unittest.TestCase):
frappe.local.login_manager = LoginManager()
auth_log = self.get_auth_log()
- self.assertEquals(auth_log.status, 'Success')
+ self.assertEqual(auth_log.status, 'Success')
# test user logout log
frappe.local.login_manager.logout()
auth_log = self.get_auth_log(operation='Logout')
- self.assertEquals(auth_log.status, 'Success')
+ self.assertEqual(auth_log.status, 'Success')
# test invalid login
frappe.form_dict.update({ 'pwd': 'password' })
self.assertRaises(frappe.AuthenticationError, LoginManager)
self.assertRaises(frappe.AuthenticationError, LoginManager)
self.assertRaises(frappe.AuthenticationError, LoginManager)
+
+ # REMOVE ME: current logic allows allow_consecutive_login_attempts+1 attempts
+ # before raising security exception, remove below line when that is fixed.
+ self.assertRaises(frappe.AuthenticationError, LoginManager)
self.assertRaises(frappe.SecurityException, LoginManager)
time.sleep(5)
self.assertRaises(frappe.AuthenticationError, LoginManager)
@@ -86,4 +88,5 @@ class TestActivityLog(unittest.TestCase):
def update_system_settings(args):
doc = frappe.get_doc('System Settings')
doc.update(args)
+ doc.flags.ignore_mandatory = 1
doc.save()
diff --git a/frappe/core/doctype/block_module/block_module.py b/frappe/core/doctype/block_module/block_module.py
index e7bb3cf045..cc6c222a04 100644
--- a/frappe/core/doctype/block_module/block_module.py
+++ b/frappe/core/doctype/block_module/block_module.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/comment/comment.py b/frappe/core/doctype/comment/comment.py
index e4fd181733..e28d350d04 100644
--- a/frappe/core/doctype/comment/comment.py
+++ b/frappe/core/doctype/comment/comment.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-from __future__ import unicode_literals, absolute_import
+# License: MIT. See LICENSE
import frappe
from frappe import _
import json
@@ -11,7 +9,7 @@ from frappe.core.doctype.user.user import extract_mentions
from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification,\
get_title, get_title_html
from frappe.utils import get_fullname
-from frappe.website.render import clear_cache
+from frappe.website.utils import clear_cache
from frappe.database.schema import add_column
from frappe.exceptions import ImplicitCommitError
@@ -159,7 +157,7 @@ def update_comments_in_parent(reference_doctype, reference_name, _comments):
"""Updates `_comments` property in parent Document with given dict.
:param _comments: Dict of comments."""
- if not reference_doctype or not reference_name or frappe.db.get_value("DocType", reference_doctype, "issingle"):
+ if not reference_doctype or not reference_name or frappe.db.get_value("DocType", reference_doctype, "issingle") or frappe.db.get_value("DocType", reference_doctype, "is_virtual"):
return
try:
diff --git a/frappe/core/doctype/comment/test_comment.py b/frappe/core/doctype/comment/test_comment.py
index 3cf8fbaa3f..33672a7dea 100644
--- a/frappe/core/doctype/comment/test_comment.py
+++ b/frappe/core/doctype/comment/test_comment.py
@@ -1,12 +1,19 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe, json
import unittest
class TestComment(unittest.TestCase):
+ def tearDown(self):
+ frappe.form_dict.comment = None
+ frappe.form_dict.comment_email = None
+ frappe.form_dict.comment_by = None
+ frappe.form_dict.reference_doctype = None
+ frappe.form_dict.reference_name = None
+ frappe.form_dict.route = None
+ frappe.local.request_ip = None
+
def test_comment_creation(self):
test_doc = frappe.get_doc(dict(doctype = 'ToDo', description = 'test'))
test_doc.insert()
@@ -32,27 +39,50 @@ class TestComment(unittest.TestCase):
from frappe.website.doctype.blog_post.test_blog_post import make_test_blog
test_blog = make_test_blog()
- frappe.db.sql("delete from `tabComment` where reference_doctype = 'Blog Post'")
+ frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
from frappe.templates.includes.comments.comments import add_comment
- add_comment('Good comment with 10 chars', 'test@test.com', 'Good Tester',
- 'Blog Post', test_blog.name, test_blog.route)
+
+ frappe.form_dict.comment = 'Good comment with 10 chars'
+ frappe.form_dict.comment_email = 'test@test.com'
+ frappe.form_dict.comment_by = 'Good Tester'
+ frappe.form_dict.reference_doctype = 'Blog Post'
+ frappe.form_dict.reference_name = test_blog.name
+ frappe.form_dict.route = test_blog.route
+ frappe.local.request_ip = '127.0.0.1'
+
+ add_comment()
self.assertEqual(frappe.get_all('Comment', fields = ['*'], filters = dict(
reference_doctype = test_blog.doctype,
reference_name = test_blog.name
))[0].published, 1)
- frappe.db.sql("delete from `tabComment` where reference_doctype = 'Blog Post'")
+ frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
- add_comment('pleez vizits my site http://mysite.com', 'test@test.com', 'bad commentor',
- 'Blog Post', test_blog.name, test_blog.route)
+ frappe.form_dict.comment = 'pleez vizits my site http://mysite.com'
+ frappe.form_dict.comment_by = 'bad commentor'
+
+ add_comment()
self.assertEqual(len(frappe.get_all('Comment', fields = ['*'], filters = dict(
reference_doctype = test_blog.doctype,
reference_name = test_blog.name
))), 0)
+ # test for filtering html and css injection elements
+ frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
+
+ frappe.form_dict.comment = 'Comment'
+ frappe.form_dict.comment_by = 'hacker'
+
+ add_comment()
+
+ self.assertEqual(frappe.get_all('Comment', fields = ['content'], filters = dict(
+ reference_doctype = test_blog.doctype,
+ reference_name = test_blog.name
+ ))[0]['content'], 'Comment')
+
test_blog.delete()
diff --git a/frappe/core/doctype/communication/__init__.py b/frappe/core/doctype/communication/__init__.py
index 0e57cb68c3..eb5ba62e5c 100644
--- a/frappe/core/doctype/communication/__init__.py
+++ b/frappe/core/doctype/communication/__init__.py
@@ -1,3 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
diff --git a/frappe/core/doctype/communication/communication.json b/frappe/core/doctype/communication/communication.json
index 58adc6187c..175c64b9eb 100644
--- a/frappe/core/doctype/communication/communication.json
+++ b/frappe/core/doctype/communication/communication.json
@@ -51,6 +51,7 @@
"email_inbox",
"message_id",
"uid",
+ "imap_folder",
"email_status",
"has_attachment",
"feedback_section",
@@ -152,7 +153,7 @@
"fieldname": "communication_type",
"fieldtype": "Select",
"label": "Communication Type",
- "options": "Communication\nComment\nChat\nBot\nNotification\nFeedback",
+ "options": "Communication\nComment\nChat\nBot\nNotification\nFeedback\nAutomated Message",
"read_only": 1,
"reqd": 1
},
@@ -382,12 +383,19 @@
"label": "Timeline Links",
"options": "Communication Link",
"permlevel": 2
+ },
+ {
+ "fieldname": "imap_folder",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "IMAP Folder",
+ "read_only": 1
}
],
"icon": "fa fa-comment",
"idx": 1,
"links": [],
- "modified": "2019-12-27 14:44:04.880373",
+ "modified": "2021-11-30 09:03:25.728637",
"modified_by": "Administrator",
"module": "Core",
"name": "Communication",
@@ -426,13 +434,13 @@
"write": 1
},
{
- "create": 1,
- "delete": 1,
- "email": 1,
- "export":1,
- "print":1,
- "read": 1,
- "role": "Inbox User"
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "role": "Inbox User"
},
{
"delete": 1,
@@ -450,4 +458,4 @@
"title_field": "subject",
"track_changes": 1,
"track_seen": 1
-}
\ No newline at end of file
+}
diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py
index 5ebf714645..475762f39d 100644
--- a/frappe/core/doctype/communication/communication.py
+++ b/frappe/core/doctype/communication/communication.py
@@ -1,29 +1,33 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals, absolute_import
from collections import Counter
+from typing import List
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import validate_email_address, strip_html, cstr, time_diff_in_seconds
-from frappe.core.doctype.communication.email import validate_email, notify, _notify
+from frappe.core.doctype.communication.email import validate_email
+from frappe.core.doctype.communication.mixins import CommunicationEmailMixin
from frappe.core.utils import get_parent_doc
from frappe.utils.bot import BotReply
-from frappe.utils import parse_addr
+from frappe.utils import parse_addr, split_emails
from frappe.core.doctype.comment.comment import update_comment_in_doc
-from email.utils import parseaddr
-from six.moves.urllib.parse import unquote
+from email.utils import getaddresses
+from urllib.parse import unquote
from frappe.utils.user import is_system_user
from frappe.contacts.doctype.contact.contact import get_contact_name
from frappe.automation.doctype.assignment_rule.assignment_rule import apply as apply_assignment_rule
+from parse import compile
exclude_from_linked_with = True
-class Communication(Document):
+class Communication(Document, CommunicationEmailMixin):
+ """Communication represents an external communication like Email.
+ """
no_feed_on_delete = True
+ DOCTYPE = 'Communication'
- """Communication represents an external communication like Email."""
def onload(self):
"""create email flag queue"""
if self.communication_type == "Communication" and self.communication_medium == "Email" \
@@ -111,6 +115,44 @@ class Communication(Document):
frappe.publish_realtime('new_message', self.as_dict(),
user=self.reference_name, after_commit=True)
+ def set_signature_in_email_content(self):
+ """Set sender's User.email_signature or default outgoing's EmailAccount.signature to the email
+ """
+ if not self.content:
+ return
+
+ quill_parser = compile('{}
')
+ email_body = quill_parser.parse(self.content)
+
+ if not email_body:
+ return
+
+ email_body = email_body[0]
+
+ user_email_signature = frappe.db.get_value(
+ "User",
+ self.sender,
+ "email_signature",
+ ) if self.sender else None
+
+ signature = user_email_signature or frappe.db.get_value(
+ "Email Account",
+ {"default_outgoing": 1, "add_signature": 1},
+ "signature",
+ )
+
+ if not signature:
+ return
+
+ _signature = quill_parser.parse(signature)[0] if "ql-editor" in signature else None
+
+ if (_signature or signature) not in self.content:
+ self.content = f'{self.content}
{signature}'
+
+ def before_save(self):
+ if not self.flags.skip_add_signature:
+ self.set_signature_in_email_content()
+
def on_update(self):
# add to _comment property of the doctype, so it shows up in
# comments count for the list view
@@ -124,6 +166,45 @@ class Communication(Document):
if self.communication_type == "Communication":
self.notify_change('delete')
+ @property
+ def sender_mailid(self):
+ return parse_addr(self.sender)[1] if self.sender else ""
+
+ @staticmethod
+ def _get_emails_list(emails=None, exclude_displayname = False):
+ """Returns list of emails from given email string.
+
+ * Removes duplicate mailids
+ * Removes display name from email address if exclude_displayname is True
+ """
+ emails = split_emails(emails) if isinstance(emails, str) else (emails or [])
+ if exclude_displayname:
+ return [email.lower() for email in set([parse_addr(email)[1] for email in emails]) if email]
+ return [email.lower() for email in set(emails) if email]
+
+ def to_list(self, exclude_displayname = True):
+ """Returns to list.
+ """
+ return self._get_emails_list(self.recipients, exclude_displayname=exclude_displayname)
+
+ def cc_list(self, exclude_displayname = True):
+ """Returns cc list.
+ """
+ return self._get_emails_list(self.cc, exclude_displayname=exclude_displayname)
+
+ def bcc_list(self, exclude_displayname = True):
+ """Returns bcc list.
+ """
+ return self._get_emails_list(self.bcc, exclude_displayname=exclude_displayname)
+
+ def get_attachments(self):
+ attachments = frappe.get_all(
+ "File",
+ fields=["name", "file_name", "file_url", "is_private"],
+ filters = {"attached_to_name": self.name, "attached_to_doctype": self.DOCTYPE}
+ )
+ return attachments
+
def notify_change(self, action):
frappe.publish_realtime('update_docinfo_for_{}_{}'.format(self.reference_doctype, self.reference_name), {
'doc': self.as_dict(),
@@ -149,6 +230,23 @@ class Communication(Document):
self.email_status = "Spam"
+ @classmethod
+ def find(cls, name, ignore_error=False):
+ try:
+ return frappe.get_doc(cls.DOCTYPE, name)
+ except frappe.DoesNotExistError:
+ if ignore_error:
+ return
+ raise
+
+ @classmethod
+ def find_one_by_filters(cls, *, order_by=None, **kwargs):
+ name = frappe.db.get_value(cls.DOCTYPE, kwargs, order_by=order_by)
+ return cls.find(name) if name else None
+
+ def update_db(self, **kwargs):
+ frappe.db.set_value(self.DOCTYPE, self.name, kwargs)
+
def set_sender_full_name(self):
if not self.sender_full_name and self.sender:
if self.sender == "Administrator":
@@ -180,36 +278,6 @@ class Communication(Document):
if not self.sender_full_name:
self.sender_full_name = sender_email
- def send(self, print_html=None, print_format=None, attachments=None,
- send_me_a_copy=False, recipients=None):
- """Send communication via Email.
-
- :param print_html: Send given value as HTML attachment.
- :param print_format: Attach print format of parent document."""
-
- self.send_me_a_copy = send_me_a_copy
- self.notify(print_html, print_format, attachments, recipients)
-
- def notify(self, print_html=None, print_format=None, attachments=None,
- recipients=None, cc=None, bcc=None,fetched_from_email_account=False):
- """Calls a delayed task 'sendmail' that enqueus email in Email Queue queue
-
- :param print_html: Send given value as HTML attachment
- :param print_format: Attach print format of parent document
- :param attachments: A list of filenames that should be attached when sending this email
- :param recipients: Email recipients
- :param cc: Send email as CC to
- :param fetched_from_email_account: True when pulling email, the notification shouldn't go to the main recipient
-
- """
- notify(self, print_html, print_format, attachments, recipients, cc, bcc,
- fetched_from_email_account)
-
- def _notify(self, print_html=None, print_format=None, attachments=None,
- recipients=None, cc=None, bcc=None):
-
- _notify(self, print_html, print_format, attachments, recipients, cc, bcc)
-
def bot_reply(self):
if self.comment_type == 'Bot' and self.communication_type == 'Chat':
reply = BotReply().get_reply(self.content)
@@ -227,7 +295,7 @@ class Communication(Document):
def set_delivery_status(self, commit=False):
'''Look into the status of Email Queue linked to this Communication and set the Delivery Status of this Communication'''
delivery_status = None
- status_counts = Counter(frappe.db.sql_list('''select status from `tabEmail Queue` where communication=%s''', self.name))
+ status_counts = Counter(frappe.get_all("Email Queue", pluck="status", filters={"communication": self.name}))
if self.sent_or_received == "Received":
return
@@ -339,16 +407,8 @@ def get_permission_query_conditions_for_communication(user):
return """`tabCommunication`.email_account in ({email_accounts})"""\
.format(email_accounts=','.join(email_accounts))
-def get_contacts(email_strings, auto_create_contact=False):
- email_addrs = []
-
- for email_string in email_strings:
- if email_string:
- for email in email_string.split(","):
- parsed_email = parseaddr(email)[1]
- if parsed_email:
- email_addrs.append(parsed_email)
-
+def get_contacts(email_strings: List[str], auto_create_contact=False) -> List[str]:
+ email_addrs = get_emails(email_strings)
contacts = []
for email in email_addrs:
email = get_email_without_link(email)
@@ -377,8 +437,19 @@ def get_contacts(email_strings, auto_create_contact=False):
return contacts
+def get_emails(email_strings: List[str]) -> List[str]:
+ email_addrs = []
+
+ for email_string in email_strings:
+ if email_string:
+ result = getaddresses([email_string])
+ for email in result:
+ email_addrs.append(email[1])
+
+ return email_addrs
+
def add_contact_links_to_communication(communication, contact_name):
- contact_links = frappe.get_list("Dynamic Link", filters={
+ contact_links = frappe.get_all("Dynamic Link", filters={
"parenttype": "Contact",
"parent": contact_name
}, fields=["link_doctype", "link_name"])
@@ -422,8 +493,12 @@ def get_email_without_link(email):
if not frappe.get_all("Email Account", filters={"enable_automatic_linking": 1}):
return email
- email_id = email.split("@")[0].split("+")[0]
- email_host = email.split("@")[1]
+ try:
+ _email = email.split("@")
+ email_id = _email[0].split("+")[0]
+ email_host = _email[1]
+ except IndexError:
+ return email
return "{0}@{1}".format(email_id, email_host)
@@ -460,10 +535,12 @@ def update_parent_document_on_communication(doc):
def update_first_response_time(parent, communication):
if parent.meta.has_field("first_response_time") and not parent.get("first_response_time"):
if is_system_user(communication.sender):
- first_responded_on = communication.creation
- if parent.meta.has_field("first_responded_on") and communication.sent_or_received == "Sent":
- parent.db_set("first_responded_on", first_responded_on)
- parent.db_set("first_response_time", round(time_diff_in_seconds(first_responded_on, parent.creation), 2))
+ if communication.sent_or_received == "Sent":
+ first_responded_on = communication.creation
+ if parent.meta.has_field("first_responded_on"):
+ parent.db_set("first_responded_on", first_responded_on)
+ first_response_time = round(time_diff_in_seconds(first_responded_on, parent.creation), 2)
+ parent.db_set("first_response_time", first_response_time)
def set_avg_response_time(parent, communication):
if parent.meta.has_field("avg_response_time") and communication.sent_or_received == "Sent":
@@ -485,4 +562,5 @@ def set_avg_response_time(parent, communication):
response_times.append(response_time)
if response_times:
avg_response_time = sum(response_times) / len(response_times)
- parent.db_set("avg_response_time", avg_response_time)
\ No newline at end of file
+ parent.db_set("avg_response_time", avg_response_time)
+
diff --git a/frappe/core/doctype/communication/communication_list.js b/frappe/core/doctype/communication/communication_list.js
index 454897b865..315b74a39c 100644
--- a/frappe/core/doctype/communication/communication_list.js
+++ b/frappe/core/doctype/communication/communication_list.js
@@ -20,6 +20,6 @@ frappe.listview_settings['Communication'] = {
},
primary_action: function() {
- new frappe.views.CommunicationComposer({ doc: {} });
+ new frappe.views.CommunicationComposer();
}
};
diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py
index 4c531fbac6..b51749ccb7 100755
--- a/frappe/core/doctype/communication/email.py
+++ b/frappe/core/doctype/communication/email.py
@@ -1,27 +1,51 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
-from __future__ import unicode_literals, absolute_import
-from six.moves import range
-from six import string_types
-import frappe
import json
-from email.utils import formataddr
-from frappe.core.utils import get_parent_doc
-from frappe.utils import (get_url, get_formatted_email, cint,
- validate_email_address, split_emails, parse_addr, get_datetime)
-from frappe.email.email_body import get_message_id
+from typing import TYPE_CHECKING, Dict
+
+import frappe
import frappe.email.smtp
-import time
from frappe import _
-from frappe.utils.background_jobs import enqueue
+from frappe.email.email_body import get_message_id
+from frappe.utils import (cint, get_datetime, get_formatted_email,
+ list_to_str, split_emails, validate_email_address)
+
+if TYPE_CHECKING:
+ from frappe.core.doctype.communication.communication import Communication
+
+
+OUTGOING_EMAIL_ACCOUNT_MISSING = _("""
+ Unable to send mail because of a missing email account.
+ Please setup default Email Account from Setup > Email > Email Account
+""")
+
@frappe.whitelist()
-def make(doctype=None, name=None, content=None, subject=None, sent_or_received = "Sent",
- sender=None, sender_full_name=None, recipients=None, communication_medium="Email", send_email=False,
- print_html=None, print_format=None, attachments='[]', send_me_a_copy=False, cc=None, bcc=None,
- flags=None, read_receipt=None, print_letterhead=True, email_template=None):
- """Make a new communication.
+def make(
+ doctype=None,
+ name=None,
+ content=None,
+ subject=None,
+ sent_or_received="Sent",
+ sender=None,
+ sender_full_name=None,
+ recipients=None,
+ communication_medium="Email",
+ send_email=False,
+ print_html=None,
+ print_format=None,
+ attachments="[]",
+ send_me_a_copy=False,
+ cc=None,
+ bcc=None,
+ read_receipt=None,
+ print_letterhead=True,
+ email_template=None,
+ communication_type=None,
+ **kwargs,
+) -> Dict[str, str]:
+ """Make a new communication. Checks for email permissions for specified Document.
:param doctype: Reference DocType.
:param name: Reference Document name.
@@ -38,21 +62,76 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received =
:param send_me_a_copy: Send a copy to the sender (default **False**).
:param email_template: Template which is used to compose mail .
"""
+ if kwargs:
+ from frappe.utils.commands import warn
+ warn(
+ f"Options {kwargs} used in frappe.core.doctype.communication.email.make "
+ "are deprecated or unsupported",
+ category=DeprecationWarning
+ )
- is_error_report = (doctype=="User" and name==frappe.session.user and subject=="Error Report")
- send_me_a_copy = cint(send_me_a_copy)
+ if doctype and name and not frappe.has_permission(doctype=doctype, ptype="email", doc=name):
+ raise frappe.PermissionError(
+ f"You are not allowed to send emails related to: {doctype} {name}"
+ )
- if doctype and name and not is_error_report and not frappe.has_permission(doctype, "email", name) and not (flags or {}).get('ignore_doctype_permissions'):
- raise frappe.PermissionError("You are not allowed to send emails related to: {doctype} {name}".format(
- doctype=doctype, name=name))
+ return _make(
+ doctype=doctype,
+ name=name,
+ content=content,
+ subject=subject,
+ sent_or_received=sent_or_received,
+ sender=sender,
+ sender_full_name=sender_full_name,
+ recipients=recipients,
+ communication_medium=communication_medium,
+ send_email=send_email,
+ print_html=print_html,
+ print_format=print_format,
+ attachments=attachments,
+ send_me_a_copy=cint(send_me_a_copy),
+ cc=cc,
+ bcc=bcc,
+ read_receipt=read_receipt,
+ print_letterhead=print_letterhead,
+ email_template=email_template,
+ communication_type=communication_type,
+ add_signature=False,
+ )
- if not sender:
- sender = get_formatted_email(frappe.session.user)
- if isinstance(recipients, list):
- recipients = ', '.join(recipients)
+def _make(
+ doctype=None,
+ name=None,
+ content=None,
+ subject=None,
+ sent_or_received="Sent",
+ sender=None,
+ sender_full_name=None,
+ recipients=None,
+ communication_medium="Email",
+ send_email=False,
+ print_html=None,
+ print_format=None,
+ attachments="[]",
+ send_me_a_copy=False,
+ cc=None,
+ bcc=None,
+ read_receipt=None,
+ print_letterhead=True,
+ email_template=None,
+ communication_type=None,
+ add_signature=True,
+) -> Dict[str, str]:
+ """Internal method to make a new communication that ignores Permission checks.
+ """
- comm = frappe.get_doc({
+ sender = sender or get_formatted_email(frappe.session.user)
+ recipients = list_to_str(recipients) if isinstance(recipients, list) else recipients
+ cc = list_to_str(cc) if isinstance(cc, list) else cc
+ bcc = list_to_str(bcc) if isinstance(bcc, list) else bcc
+
+ comm: "Communication" = frappe.get_doc({
"doctype":"Communication",
"subject": subject,
"content": content,
@@ -68,30 +147,37 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received =
"email_template": email_template,
"message_id":get_message_id().strip(" <>"),
"read_receipt":read_receipt,
- "has_attachment": 1 if attachments else 0
- }).insert(ignore_permissions=True)
-
- comm.save(ignore_permissions=True)
-
- if isinstance(attachments, string_types):
- attachments = json.loads(attachments)
+ "has_attachment": 1 if attachments else 0,
+ "communication_type": communication_type,
+ })
+ comm.flags.skip_add_signature = not add_signature
+ comm.insert(ignore_permissions=True)
# if not committed, delayed task doesn't find the communication
if attachments:
+ if isinstance(attachments, str):
+ attachments = json.loads(attachments)
add_attachments(comm.name, attachments)
- frappe.db.commit()
-
if cint(send_email):
- frappe.flags.print_letterhead = cint(print_letterhead)
- comm.send(print_html, print_format, attachments, send_me_a_copy=send_me_a_copy)
+ if not comm.get_outgoing_email_account():
+ frappe.throw(
+ msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError
+ )
- return {
- "name": comm.name,
- "emails_not_sent_to": ", ".join(comm.emails_not_sent_to) if hasattr(comm, "emails_not_sent_to") else None
- }
+ comm.send_email(
+ print_html=print_html,
+ print_format=print_format,
+ send_me_a_copy=send_me_a_copy,
+ print_letterhead=print_letterhead,
+ )
-def validate_email(doc):
+ emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy)
+
+ return {"name": comm.name, "emails_not_sent_to": ", ".join(emails_not_sent_to)}
+
+
+def validate_email(doc: "Communication") -> None:
"""Validate Email Addresses of Recipients and CC"""
if not (doc.communication_type=="Communication" and doc.communication_medium == "Email") or doc.flags.in_receive:
return
@@ -107,263 +193,25 @@ def validate_email(doc):
for email in split_emails(doc.bcc):
validate_email_address(email, throw=True)
- # validate sender
-
-def notify(doc, print_html=None, print_format=None, attachments=None,
- recipients=None, cc=None, bcc=None, fetched_from_email_account=False):
- """Calls a delayed task 'sendmail' that enqueus email in Email Queue queue
-
- :param print_html: Send given value as HTML attachment
- :param print_format: Attach print format of parent document
- :param attachments: A list of filenames that should be attached when sending this email
- :param recipients: Email recipients
- :param cc: Send email as CC to
- :param bcc: Send email as BCC to
- :param fetched_from_email_account: True when pulling email, the notification shouldn't go to the main recipient
-
- """
- recipients, cc, bcc = get_recipients_cc_and_bcc(doc, recipients, cc, bcc,
- fetched_from_email_account=fetched_from_email_account)
-
- if not recipients and not cc:
- return
-
- doc.emails_not_sent_to = set(doc.all_email_addresses) - set(doc.sent_email_addresses)
-
- if frappe.flags.in_test:
- # for test cases, run synchronously
- doc._notify(print_html=print_html, print_format=print_format, attachments=attachments,
- recipients=recipients, cc=cc, bcc=None)
- else:
- enqueue(sendmail, queue="default", timeout=300, event="sendmail",
- communication_name=doc.name,
- print_html=print_html, print_format=print_format, attachments=attachments,
- recipients=recipients, cc=cc, bcc=bcc, lang=frappe.local.lang,
- session=frappe.local.session, print_letterhead=frappe.flags.print_letterhead)
-
-def _notify(doc, print_html=None, print_format=None, attachments=None,
- recipients=None, cc=None, bcc=None):
-
- prepare_to_notify(doc, print_html, print_format, attachments)
-
- if doc.outgoing_email_account.send_unsubscribe_message:
- unsubscribe_message = _("Leave this conversation")
- else:
- unsubscribe_message = ""
-
- frappe.sendmail(
- recipients=(recipients or []),
- cc=(cc or []),
- bcc=(bcc or []),
- expose_recipients="header",
- sender=doc.sender,
- reply_to=doc.incoming_email_account,
- subject=doc.subject,
- content=doc.content,
- reference_doctype=doc.reference_doctype,
- reference_name=doc.reference_name,
- attachments=doc.attachments,
- message_id=doc.message_id,
- unsubscribe_message=unsubscribe_message,
- delayed=True,
- communication=doc.name,
- read_receipt=doc.read_receipt,
- is_notification=True if doc.sent_or_received =="Received" else False,
- print_letterhead=frappe.flags.print_letterhead
- )
-
-def get_recipients_cc_and_bcc(doc, recipients, cc, bcc, fetched_from_email_account=False):
- doc.all_email_addresses = []
- doc.sent_email_addresses = []
- doc.previous_email_sender = None
-
- if not recipients:
- recipients = get_recipients(doc, fetched_from_email_account=fetched_from_email_account)
-
- if not cc:
- cc = get_cc(doc, recipients, fetched_from_email_account=fetched_from_email_account)
-
- if not bcc:
- bcc = get_bcc(doc, recipients, fetched_from_email_account=fetched_from_email_account)
-
- if fetched_from_email_account:
- # email was already sent to the original recipient by the sender's email service
- original_recipients, recipients = recipients, []
-
- # send email to the sender of the previous email in the thread which this email is a reply to
- #provides erratic results and can send external
- #if doc.previous_email_sender:
- # recipients.append(doc.previous_email_sender)
-
- # cc that was received in the email
- original_cc = split_emails(doc.cc)
-
- # don't cc to people who already received the mail from sender's email service
- cc = list(set(cc) - set(original_cc) - set(original_recipients))
- remove_administrator_from_email_list(cc)
-
- original_bcc = split_emails(doc.bcc)
- bcc = list(set(bcc) - set(original_bcc) - set(original_recipients))
- remove_administrator_from_email_list(bcc)
-
- remove_administrator_from_email_list(recipients)
-
- return recipients, cc, bcc
-
-def remove_administrator_from_email_list(email_list):
- administrator_email = list(filter(lambda emails: "Administrator" in emails, email_list))
- if administrator_email:
- email_list.remove(administrator_email[0])
-
-def prepare_to_notify(doc, print_html=None, print_format=None, attachments=None):
- """Prepare to make multipart MIME Email
-
- :param print_html: Send given value as HTML attachment.
- :param print_format: Attach print format of parent document."""
-
- view_link = frappe.utils.cint(frappe.db.get_value("System Settings", "System Settings", "attach_view_link"))
-
- if print_format and view_link:
- doc.content += get_attach_link(doc, print_format)
-
- set_incoming_outgoing_accounts(doc)
-
- if not doc.sender:
- doc.sender = doc.outgoing_email_account.email_id
-
- if not doc.sender_full_name:
- doc.sender_full_name = doc.outgoing_email_account.name or _("Notification")
-
- if doc.sender:
- # combine for sending to get the format 'Jane '
- doc.sender = get_formatted_email(doc.sender_full_name, mail=doc.sender)
-
- doc.attachments = []
-
- if print_html or print_format:
- doc.attachments.append({"print_format_attachment":1, "doctype":doc.reference_doctype,
- "name":doc.reference_name, "print_format":print_format, "html":print_html})
-
- if attachments:
- if isinstance(attachments, string_types):
- attachments = json.loads(attachments)
-
- for a in attachments:
- if isinstance(a, string_types):
- # is it a filename?
- try:
- # check for both filename and file id
- file_id = frappe.db.get_list('File', or_filters={'file_name': a, 'name': a}, limit=1)
- if not file_id:
- frappe.throw(_("Unable to find attachment {0}").format(a))
- file_id = file_id[0]['name']
- _file = frappe.get_doc("File", file_id)
- _file.get_content()
- # these attachments will be attached on-demand
- # and won't be stored in the message
- doc.attachments.append({"fid": file_id})
- except IOError:
- frappe.throw(_("Unable to find attachment {0}").format(a))
- else:
- doc.attachments.append(a)
-
def set_incoming_outgoing_accounts(doc):
- doc.incoming_email_account = doc.outgoing_email_account = None
+ from frappe.email.doctype.email_account.email_account import EmailAccount
+ incoming_email_account = EmailAccount.find_incoming(
+ match_by_email=doc.sender, match_by_doctype=doc.reference_doctype)
+ doc.incoming_email_account = incoming_email_account.email_id if incoming_email_account else None
- if not doc.incoming_email_account and doc.sender:
- doc.incoming_email_account = frappe.db.get_value("Email Account",
- {"email_id": doc.sender, "enable_incoming": 1}, "email_id")
-
- if not doc.incoming_email_account and doc.reference_doctype:
- doc.incoming_email_account = frappe.db.get_value("Email Account",
- {"append_to": doc.reference_doctype, }, "email_id")
-
- if not doc.incoming_email_account:
- doc.incoming_email_account = frappe.db.get_value("Email Account",
- {"default_incoming": 1, "enable_incoming": 1}, "email_id")
-
- doc.outgoing_email_account = frappe.email.smtp.get_outgoing_email_account(raise_exception_not_set=False,
- append_to=doc.doctype, sender=doc.sender)
+ doc.outgoing_email_account = EmailAccount.find_outgoing(
+ match_by_email=doc.sender, match_by_doctype=doc.reference_doctype)
if doc.sent_or_received == "Sent":
doc.db_set("email_account", doc.outgoing_email_account.name)
-def get_recipients(doc, fetched_from_email_account=False):
- """Build a list of email addresses for To"""
- # [EDGE CASE] doc.recipients can be None when an email is sent as BCC
- recipients = split_emails(doc.recipients)
-
- #if fetched_from_email_account and doc.in_reply_to:
- # add sender of previous reply
- #doc.previous_email_sender = frappe.db.get_value("Communication", doc.in_reply_to, "sender")
- #recipients.append(doc.previous_email_sender)
-
- if recipients:
- recipients = filter_email_list(doc, recipients, [])
-
- return recipients
-
-def get_cc(doc, recipients=None, fetched_from_email_account=False):
- """Build a list of email addresses for CC"""
- # get a copy of CC list
- cc = split_emails(doc.cc)
-
- if doc.reference_doctype and doc.reference_name:
- if fetched_from_email_account:
- # if it is a fetched email, add follows to CC
- cc.append(get_owner_email(doc))
- cc += get_assignees(doc)
-
- if getattr(doc, "send_me_a_copy", False) and doc.sender not in cc:
- cc.append(doc.sender)
-
- if cc:
- # exclude unfollows, recipients and unsubscribes
- exclude = [] #added to remove account check
- exclude += [d[0] for d in frappe.db.get_all("User", ["email"], {"thread_notify": 0}, as_list=True)]
- exclude += [(parse_addr(email)[1] or "").lower() for email in recipients]
-
- if fetched_from_email_account:
- # exclude sender when pulling email
- exclude += [parse_addr(doc.sender)[1]]
-
- if doc.reference_doctype and doc.reference_name:
- exclude += [d[0] for d in frappe.db.get_all("Email Unsubscribe", ["email"],
- {"reference_doctype": doc.reference_doctype, "reference_name": doc.reference_name}, as_list=True)]
-
- cc = filter_email_list(doc, cc, exclude, is_cc=True)
-
- return cc
-
-def get_bcc(doc, recipients=None, fetched_from_email_account=False):
- """Build a list of email addresses for BCC"""
- bcc = split_emails(doc.bcc)
-
- if bcc:
- exclude = []
- exclude += [d[0] for d in frappe.db.get_all("User", ["email"], {"thread_notify": 0}, as_list=True)]
- exclude += [(parse_addr(email)[1] or "").lower() for email in recipients]
-
- if fetched_from_email_account:
- # exclude sender when pulling email
- exclude += [parse_addr(doc.sender)[1]]
-
- if doc.reference_doctype and doc.reference_name:
- exclude += [d[0] for d in frappe.db.get_all("Email Unsubscribe", ["email"],
- {"reference_doctype": doc.reference_doctype, "reference_name": doc.reference_name}, as_list=True)]
-
- bcc = filter_email_list(doc, bcc, exclude, is_bcc=True)
-
- return bcc
-
def add_attachments(name, attachments):
'''Add attachments to the given Communication'''
# loop through attachments
for a in attachments:
- if isinstance(a, string_types):
+ if isinstance(a, str):
attach = frappe.db.get_value("File", {"name":a},
["file_name", "file_url", "is_private"], as_dict=1)
-
# save attachments to new doc
_file = frappe.get_doc({
"doctype": "File",
@@ -375,122 +223,43 @@ def add_attachments(name, attachments):
})
_file.save(ignore_permissions=True)
-def filter_email_list(doc, email_list, exclude, is_cc=False, is_bcc=False):
- # temp variables
- filtered = []
- email_address_list = []
-
- for email in list(set(email_list)):
- email_address = (parse_addr(email)[1] or "").lower()
- if not email_address:
- continue
-
- # this will be used to eventually find email addresses that aren't sent to
- doc.all_email_addresses.append(email_address)
-
- if (email in exclude) or (email_address in exclude):
- continue
-
- if is_cc:
- is_user_enabled = frappe.db.get_value("User", email_address, "enabled")
- if is_user_enabled==0:
- # don't send to disabled users
- continue
-
- if is_bcc:
- is_user_enabled = frappe.db.get_value("User", email_address, "enabled")
- if is_user_enabled==0:
- continue
-
- # make sure of case-insensitive uniqueness of email address
- if email_address not in email_address_list:
- # append the full email i.e. "Human "
- filtered.append(email)
- email_address_list.append(email_address)
-
- doc.sent_email_addresses.extend(email_address_list)
-
- return filtered
-
-def get_owner_email(doc):
- owner = get_parent_doc(doc).owner
- return get_formatted_email(owner) or owner
-
-def get_assignees(doc):
- return [( get_formatted_email(d.owner) or d.owner ) for d in
- frappe.db.get_all("ToDo", filters={
- "reference_type": doc.reference_doctype,
- "reference_name": doc.reference_name,
- "status": "Open"
- }, fields=["owner"])
- ]
-
-def get_attach_link(doc, print_format):
- """Returns public link for the attachment via `templates/emails/print_link.html`."""
- return frappe.get_template("templates/emails/print_link.html").render({
- "url": get_url(),
- "doctype": doc.reference_doctype,
- "name": doc.reference_name,
- "print_format": print_format,
- "key": get_parent_doc(doc).get_signature()
- })
-
-def sendmail(communication_name, print_html=None, print_format=None, attachments=None,
- recipients=None, cc=None, bcc=None, lang=None, session=None, print_letterhead=None):
+@frappe.whitelist(allow_guest=True, methods=("GET",))
+def mark_email_as_seen(name: str = None):
try:
+ update_communication_as_read(name)
+ frappe.db.commit() # nosemgrep: this will be called in a GET request
- if lang:
- frappe.local.lang = lang
-
- if session:
- # hack to enable access to private files in PDF
- session['data'] = frappe._dict(session['data'])
- frappe.local.session.update(session)
-
- if print_letterhead:
- frappe.flags.print_letterhead = print_letterhead
-
- # upto 3 retries
- for i in range(3):
- try:
- communication = frappe.get_doc("Communication", communication_name)
- communication._notify(print_html=print_html, print_format=print_format, attachments=attachments,
- recipients=recipients, cc=cc, bcc=bcc)
-
- except frappe.db.InternalError as e:
- # deadlock, try again
- if frappe.db.is_deadlocked(e):
- frappe.db.rollback()
- time.sleep(1)
- continue
- else:
- raise
- else:
- break
-
- except:
- traceback = frappe.log_error("frappe.core.doctype.communication.email.sendmail")
- raise
-
-@frappe.whitelist(allow_guest=True)
-def mark_email_as_seen(name=None):
- try:
- if name and frappe.db.exists("Communication", name) and not frappe.db.get_value("Communication", name, "read_by_recipient"):
- frappe.db.set_value("Communication", name, "read_by_recipient", 1)
- frappe.db.set_value("Communication", name, "delivery_status", "Read")
- frappe.db.set_value("Communication", name, "read_by_recipient_on", get_datetime())
- frappe.db.commit()
except Exception:
frappe.log_error(frappe.get_traceback())
- finally:
- # Return image as response under all circumstances
- from PIL import Image
- import io
- im = Image.new('RGBA', (1, 1))
- im.putdata([(255,255,255,0)])
- buffered_obj = io.BytesIO()
- im.save(buffered_obj, format="PNG")
- frappe.response["type"] = 'binary'
- frappe.response["filename"] = "imaginary_pixel.png"
- frappe.response["filecontent"] = buffered_obj.getvalue()
+ finally:
+ frappe.response.update({
+ "type": "binary",
+ "filename": "imaginary_pixel.png",
+ "filecontent": (
+ b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00"
+ b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\r"
+ b"IDATx\x9cc\xf8\xff\xff?\x03\x00\x08\xfc\x02\xfe\xa7\x9a\xa0"
+ b"\xa0\x00\x00\x00\x00IEND\xaeB`\x82"
+ )
+ })
+
+def update_communication_as_read(name):
+ if not name or not isinstance(name, str):
+ return
+
+ communication = frappe.db.get_value(
+ "Communication",
+ name,
+ "read_by_recipient",
+ as_dict=True
+ )
+
+ if not communication or communication.read_by_recipient:
+ return
+
+ frappe.db.set_value("Communication", name, {
+ "read_by_recipient": 1,
+ "delivery_status": "Read",
+ "read_by_recipient_on": get_datetime()
+ })
diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py
new file mode 100644
index 0000000000..dd9f58342e
--- /dev/null
+++ b/frappe/core/doctype/communication/mixins.py
@@ -0,0 +1,291 @@
+from typing import List
+import frappe
+from frappe import _
+from frappe.core.utils import get_parent_doc
+from frappe.utils import parse_addr, get_formatted_email, get_url
+from frappe.email.doctype.email_account.email_account import EmailAccount
+from frappe.desk.doctype.todo.todo import ToDo
+
+class CommunicationEmailMixin:
+ """Mixin class to handle communication mails.
+ """
+ def is_email_communication(self):
+ return self.communication_type=="Communication" and self.communication_medium == "Email"
+
+ def get_owner(self):
+ """Get owner of the communication docs parent.
+ """
+ parent_doc = get_parent_doc(self)
+ return parent_doc.owner if parent_doc else None
+
+ def get_all_email_addresses(self, exclude_displayname=False):
+ """Get all Email addresses mentioned in the doc along with display name.
+ """
+ return self.to_list(exclude_displayname=exclude_displayname) + \
+ self.cc_list(exclude_displayname=exclude_displayname) + \
+ self.bcc_list(exclude_displayname=exclude_displayname)
+
+ def get_email_with_displayname(self, email_address):
+ """Returns email address after adding displayname.
+ """
+ display_name, email = parse_addr(email_address)
+ if display_name and display_name != email:
+ return email_address
+
+ # emailid to emailid with display name map.
+ email_map = {parse_addr(email)[1]: email for email in self.get_all_email_addresses()}
+ return email_map.get(email, email)
+
+ def mail_recipients(self, is_inbound_mail_communcation=False):
+ """Build to(recipient) list to send an email.
+ """
+ # Incase of inbound mail, recipients already received the mail, no need to send again.
+ if is_inbound_mail_communcation:
+ return []
+
+ if hasattr(self, '_final_recipients'):
+ return self._final_recipients
+
+ to = self.to_list()
+ self._final_recipients = list(filter(lambda id: id != 'Administrator', to))
+ return self._final_recipients
+
+ def get_mail_recipients_with_displayname(self, is_inbound_mail_communcation=False):
+ """Build to(recipient) list to send an email including displayname in email.
+ """
+ to_list = self.mail_recipients(is_inbound_mail_communcation=is_inbound_mail_communcation)
+ return [self.get_email_with_displayname(email) for email in to_list]
+
+ def mail_cc(self, is_inbound_mail_communcation=False, include_sender = False):
+ """Build cc list to send an email.
+
+ * if email copy is requested by sender, then add sender to CC.
+ * If this doc is created through inbound mail, then add doc owner to cc list
+ * remove all the thread_notify disabled users.
+ * Make sure that all users enabled in the system
+ * Remove admin from email list
+
+ * FixMe: Removed adding TODO owners to cc list. Check if that is needed.
+ """
+ if hasattr(self, '_final_cc'):
+ return self._final_cc
+
+ cc = self.cc_list()
+
+ # Need to inform parent document owner incase communication is created through inbound mail
+ if include_sender:
+ cc.append(self.sender_mailid)
+ if is_inbound_mail_communcation:
+ cc.append(self.get_owner())
+ cc = set(cc) - {self.sender_mailid}
+ cc.update(self.get_assignees())
+
+ cc = set(cc) - set(self.filter_thread_notification_disbled_users(cc))
+ cc = cc - set(self.mail_recipients(is_inbound_mail_communcation=is_inbound_mail_communcation))
+ cc = cc - set(self.filter_disabled_users(cc))
+
+ # # Incase of inbound mail, to and cc already received the mail, no need to send again.
+ if is_inbound_mail_communcation:
+ cc = cc - set(self.cc_list() + self.to_list())
+
+ self._final_cc = list(filter(lambda id: id != 'Administrator', cc))
+ return self._final_cc
+
+ def get_mail_cc_with_displayname(self, is_inbound_mail_communcation=False, include_sender = False):
+ cc_list = self.mail_cc(is_inbound_mail_communcation=is_inbound_mail_communcation, include_sender = include_sender)
+ return [self.get_email_with_displayname(email) for email in cc_list]
+
+ def mail_bcc(self, is_inbound_mail_communcation=False):
+ """
+ * Thread_notify check
+ * Email unsubscribe list
+ * User must be enabled in the system
+ * remove_administrator_from_email_list
+ """
+ if hasattr(self, '_final_bcc'):
+ return self._final_bcc
+
+ bcc = set(self.bcc_list())
+ if is_inbound_mail_communcation:
+ bcc = bcc - {self.sender_mailid}
+ bcc = bcc - set(self.filter_thread_notification_disbled_users(bcc))
+ bcc = bcc - set(self.mail_recipients(is_inbound_mail_communcation=is_inbound_mail_communcation))
+ bcc = bcc - set(self.filter_disabled_users(bcc))
+
+ # Incase of inbound mail, to and cc & bcc already received the mail, no need to send again.
+ if is_inbound_mail_communcation:
+ bcc = bcc - set(self.bcc_list() + self.to_list())
+
+ self._final_bcc = list(filter(lambda id: id != 'Administrator', bcc))
+ return self._final_bcc
+
+ def get_mail_bcc_with_displayname(self, is_inbound_mail_communcation=False):
+ bcc_list = self.mail_bcc(is_inbound_mail_communcation=is_inbound_mail_communcation)
+ return [self.get_email_with_displayname(email) for email in bcc_list]
+
+ def mail_sender(self):
+ email_account = self.get_outgoing_email_account()
+ if not self.sender_mailid and email_account:
+ return email_account.email_id
+ return self.sender_mailid
+
+ def mail_sender_fullname(self):
+ email_account = self.get_outgoing_email_account()
+ if not self.sender_full_name:
+ return (email_account and email_account.name) or _("Notification")
+ return self.sender_full_name
+
+ def get_mail_sender_with_displayname(self):
+ return get_formatted_email(self.mail_sender_fullname(), mail=self.mail_sender())
+
+ def get_content(self, print_format=None):
+ if print_format:
+ return self.content + self.get_attach_link(print_format)
+ return self.content
+
+ def get_attach_link(self, print_format):
+ """Returns public link for the attachment via `templates/emails/print_link.html`."""
+ return frappe.get_template("templates/emails/print_link.html").render({
+ "url": get_url(),
+ "doctype": self.reference_doctype,
+ "name": self.reference_name,
+ "print_format": print_format,
+ "key": get_parent_doc(self).get_signature()
+ })
+
+ def get_outgoing_email_account(self):
+ if not hasattr(self, '_outgoing_email_account'):
+ if self.email_account:
+ self._outgoing_email_account = EmailAccount.find(self.email_account)
+ else:
+ self._outgoing_email_account = EmailAccount.find_outgoing(
+ match_by_email=self.sender_mailid,
+ match_by_doctype=self.reference_doctype
+ )
+
+ if self.sent_or_received == "Sent" and self._outgoing_email_account:
+ self.db_set("email_account", self._outgoing_email_account.name)
+
+ return self._outgoing_email_account
+
+ def get_incoming_email_account(self):
+ if not hasattr(self, '_incoming_email_account'):
+ self._incoming_email_account = EmailAccount.find_incoming(
+ match_by_email=self.sender_mailid,
+ match_by_doctype=self.reference_doctype
+ )
+ return self._incoming_email_account
+
+ def mail_attachments(self, print_format=None, print_html=None):
+ final_attachments = []
+
+ if print_format or print_html:
+ d = {'print_format': print_format, 'html': print_html, 'print_format_attachment': 1,
+ 'doctype': self.reference_doctype, 'name': self.reference_name}
+ final_attachments.append(d)
+
+ for a in self.get_attachments() or []:
+ final_attachments.append({"fid": a['name']})
+
+ return final_attachments
+
+ def get_unsubscribe_message(self):
+ email_account = self.get_outgoing_email_account()
+ if email_account and email_account.send_unsubscribe_message:
+ return _("Leave this conversation")
+ return ''
+
+ def exclude_emails_list(self, is_inbound_mail_communcation=False, include_sender=False) -> List:
+ """List of mail id's excluded while sending mail.
+ """
+ all_ids = self.get_all_email_addresses(exclude_displayname=True)
+
+ final_ids = (
+ self.mail_recipients(is_inbound_mail_communcation=is_inbound_mail_communcation)
+ + self.mail_bcc(is_inbound_mail_communcation=is_inbound_mail_communcation)
+ + self.mail_cc(is_inbound_mail_communcation=is_inbound_mail_communcation, include_sender=include_sender)
+ )
+
+ return list(set(all_ids) - set(final_ids))
+
+ def get_assignees(self):
+ """Get owners of the reference document.
+ """
+ filters = {'status': 'Open', 'reference_name': self.reference_name,
+ 'reference_type': self.reference_doctype}
+ return ToDo.get_owners(filters)
+
+ @staticmethod
+ def filter_thread_notification_disbled_users(emails):
+ """Filter users based on notifications for email threads setting is disabled.
+ """
+ if not emails:
+ return []
+
+ return frappe.get_all("User", pluck="email", filters={"email": ["in", emails], "thread_notify": 0})
+
+ @staticmethod
+ def filter_disabled_users(emails):
+ """
+ """
+ if not emails:
+ return []
+
+ return frappe.get_all("User", pluck="email", filters={"email": ["in", emails], "enabled": 0})
+
+ def sendmail_input_dict(self, print_html=None, print_format=None,
+ send_me_a_copy=None, print_letterhead=None, is_inbound_mail_communcation=None):
+
+ outgoing_email_account = self.get_outgoing_email_account()
+ if not outgoing_email_account:
+ return {}
+
+ recipients = self.get_mail_recipients_with_displayname(
+ is_inbound_mail_communcation=is_inbound_mail_communcation
+ )
+ cc = self.get_mail_cc_with_displayname(
+ is_inbound_mail_communcation=is_inbound_mail_communcation,
+ include_sender = send_me_a_copy
+ )
+ bcc = self.get_mail_bcc_with_displayname(
+ is_inbound_mail_communcation=is_inbound_mail_communcation
+ )
+
+ if not (recipients or cc):
+ return {}
+
+ final_attachments = self.mail_attachments(print_format=print_format, print_html=print_html)
+ incoming_email_account = self.get_incoming_email_account()
+ return {
+ "recipients": recipients,
+ "cc": cc,
+ "bcc": bcc,
+ "expose_recipients": "header",
+ "sender": self.get_mail_sender_with_displayname(),
+ "reply_to": incoming_email_account and incoming_email_account.email_id,
+ "subject": self.subject,
+ "content": self.get_content(print_format=print_format),
+ "reference_doctype": self.reference_doctype,
+ "reference_name": self.reference_name,
+ "attachments": final_attachments,
+ "message_id": self.message_id,
+ "unsubscribe_message": self.get_unsubscribe_message(),
+ "delayed": True,
+ "communication": self.name,
+ "read_receipt": self.read_receipt,
+ "is_notification": (self.sent_or_received =="Received" and True) or False,
+ "print_letterhead": print_letterhead
+ }
+
+ def send_email(self, print_html=None, print_format=None,
+ send_me_a_copy=None, print_letterhead=None, is_inbound_mail_communcation=None):
+ input_dict = self.sendmail_input_dict(
+ print_html=print_html,
+ print_format=print_format,
+ send_me_a_copy=send_me_a_copy,
+ print_letterhead=print_letterhead,
+ is_inbound_mail_communcation=is_inbound_mail_communcation
+ )
+
+ if input_dict:
+ frappe.sendmail(**input_dict)
diff --git a/frappe/core/doctype/communication/test_communication.js b/frappe/core/doctype/communication/test_communication.js
deleted file mode 100644
index 2fd95b34b0..0000000000
--- a/frappe/core/doctype/communication/test_communication.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Communication", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Communication
- () => frappe.tests.make('Communication', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/communication/test_communication.py b/frappe/core/doctype/communication/test_communication.py
index 6df90baaae..d933c2f494 100644
--- a/frappe/core/doctype/communication/test_communication.py
+++ b/frappe/core/doctype/communication/test_communication.py
@@ -1,12 +1,13 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
+import unittest
+from urllib.parse import quote
import frappe
-import unittest
-from six.moves.urllib.parse import quote
-test_records = frappe.get_test_records('Communication')
+from frappe.email.doctype.email_queue.email_queue import EmailQueue
+from frappe.core.doctype.communication.communication import get_emails
+test_records = frappe.get_test_records('Communication')
class TestCommunication(unittest.TestCase):
@@ -201,6 +202,83 @@ class TestCommunication(unittest.TestCase):
self.assertIn(("Note", note.name), doc_links)
+ def parse_emails(self):
+ emails = get_emails(
+ [
+ 'comm_recipient+DocType+DocName@example.com',
+ '"First, LastName" ',
+ 'test@user.com'
+ ]
+ )
+
+ self.assertEqual(emails[0], "comm_recipient+DocType+DocName@example.com")
+ self.assertEqual(emails[1], "first.lastname@email.com")
+ self.assertEqual(emails[2], "test@user.com")
+
+class TestCommunicationEmailMixin(unittest.TestCase):
+ def new_communication(self, recipients=None, cc=None, bcc=None):
+ recipients = ', '.join(recipients or [])
+ cc = ', '.join(cc or [])
+ bcc = ', '.join(bcc or [])
+
+ comm = frappe.get_doc({
+ "doctype": "Communication",
+ "communication_type": "Communication",
+ "communication_medium": "Email",
+ "content": "Test content",
+ "recipients": recipients,
+ "cc": cc,
+ "bcc": bcc
+ }).insert(ignore_permissions=True)
+ return comm
+
+ def new_user(self, email, **user_data):
+ user_data.setdefault('first_name', 'first_name')
+ user = frappe.new_doc('User')
+ user.email = email
+ user.update(user_data)
+ user.insert(ignore_permissions=True, ignore_if_duplicate=True)
+ return user
+
+ def test_recipients(self):
+ to_list = ['to@test.com', 'receiver ', 'to@test.com']
+ comm = self.new_communication(recipients = to_list)
+ res = comm.get_mail_recipients_with_displayname()
+ self.assertCountEqual(res, ['to@test.com', 'receiver '])
+ comm.delete()
+
+ def test_cc(self):
+ to_list = ['to@test.com']
+ cc_list = ['cc+1@test.com', 'cc ', 'to@test.com']
+ user = self.new_user(email='cc+1@test.com', thread_notify=0)
+ comm = self.new_communication(recipients=to_list, cc=cc_list)
+ res = comm.get_mail_cc_with_displayname()
+ self.assertCountEqual(res, ['cc '])
+ user.delete()
+ comm.delete()
+
+ def test_bcc(self):
+ bcc_list = ['bcc+1@test.com', 'cc ', ]
+ user = self.new_user(email='bcc+2@test.com', enabled=0)
+ comm = self.new_communication(bcc=bcc_list)
+ res = comm.get_mail_bcc_with_displayname()
+ self.assertCountEqual(res, ['bcc+1@test.com'])
+ user.delete()
+ comm.delete()
+
+ def test_sendmail(self):
+ to_list = ['to ']
+ cc_list = ['cc ', 'cc ']
+
+ comm = self.new_communication(recipients=to_list, cc=cc_list)
+ comm.send_email()
+ doc = EmailQueue.find_one_by_filters(communication=comm.name)
+ mail_receivers = [each.recipient for each in doc.recipients]
+ self.assertIsNotNone(doc)
+ self.assertCountEqual(to_list+cc_list, mail_receivers)
+ doc.delete()
+ comm.delete()
+
def create_email_account():
frappe.delete_doc_if_exists("Email Account", "_Test Comm Account 1")
@@ -227,8 +305,9 @@ def create_email_account():
"unreplied_for_mins": 20,
"send_notification_to": "test_comm@example.com",
"pop3_server": "pop.test.example.com",
+ "imap_folder": [{"folder_name": "INBOX", "append_to": "ToDo"}],
"no_remaining":"0",
"enable_automatic_linking": 1
}).insert(ignore_permissions=True)
- return email_account
\ No newline at end of file
+ return email_account
diff --git a/frappe/core/doctype/communication_link/communication_link.py b/frappe/core/doctype/communication_link/communication_link.py
index d1612ef57e..a895ad3df5 100644
--- a/frappe/core/doctype/communication_link/communication_link.py
+++ b/frappe/core/doctype/communication_link/communication_link.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/custom_docperm/custom_docperm.py b/frappe/core/doctype/custom_docperm/custom_docperm.py
index cce9788b73..1790344776 100644
--- a/frappe/core/doctype/custom_docperm/custom_docperm.py
+++ b/frappe/core/doctype/custom_docperm/custom_docperm.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/custom_docperm/test_custom_docperm.py b/frappe/core/doctype/custom_docperm/test_custom_docperm.py
index bd6e17ccc9..422b711e5b 100644
--- a/frappe/core/doctype/custom_docperm/test_custom_docperm.py
+++ b/frappe/core/doctype/custom_docperm/test_custom_docperm.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/custom_role/custom_role.py b/frappe/core/doctype/custom_role/custom_role.py
index 25257e1a23..c6630baf6d 100644
--- a/frappe/core/doctype/custom_role/custom_role.py
+++ b/frappe/core/doctype/custom_role/custom_role.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/custom_role/test_custom_role.py b/frappe/core/doctype/custom_role/test_custom_role.py
index 670b494b10..21511a7408 100644
--- a/frappe/core/doctype/custom_role/test_custom_role.py
+++ b/frappe/core/doctype/custom_role/test_custom_role.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/data_export/data_export.py b/frappe/core/doctype/data_export/data_export.py
index fb4fae26d5..46fe3570a1 100644
--- a/frappe/core/doctype/data_export/data_export.py
+++ b/frappe/core/doctype/data_export/data_export.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
from frappe.model.document import Document
class DataExport(Document):
diff --git a/frappe/core/doctype/data_export/exporter.py b/frappe/core/doctype/data_export/exporter.py
index bec8cde7ea..9f1492af19 100644
--- a/frappe/core/doctype/data_export/exporter.py
+++ b/frappe/core/doctype/data_export/exporter.py
@@ -1,7 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
from frappe import _
@@ -9,8 +7,6 @@ import frappe.permissions
import re, csv, os
from frappe.utils.csvutils import UnicodeWriter
from frappe.utils import cstr, formatdate, format_datetime, parse_json, cint, format_duration
-from frappe.core.doctype.data_import_legacy.importer import get_data_keys
-from six import string_types
from frappe.core.doctype.access_log.access_log import make_access_log
reflags = {
@@ -23,6 +19,15 @@ reflags = {
"D": re.DEBUG
}
+def get_data_keys():
+ return frappe._dict({
+ "data_separator": _('Start entering data below this line'),
+ "main_table": _("Table") + ":",
+ "parent_table": _("Parent Table") + ":",
+ "columns": _("Column Name") + ":",
+ "doctype": _("DocType") + ":"
+ })
+
@frappe.whitelist()
def export_data(doctype=None, parent_doctype=None, all_doctypes=True, with_data=False,
select_columns=None, file_type='CSV', template=False, filters=None):
@@ -57,7 +62,7 @@ class DataExporter:
self.docs_to_export = {}
if self.doctype:
- if isinstance(self.doctype, string_types):
+ if isinstance(self.doctype, str):
self.doctype = [self.doctype]
if len(self.doctype) > 1:
@@ -256,6 +261,7 @@ class DataExporter:
self.writer.writerow([self.data_keys.data_separator])
def add_data(self):
+ from frappe.query_builder import DocType
if self.template and not self.with_data:
return
@@ -282,7 +288,7 @@ class DataExporter:
try:
sflags = self.docs_to_export.get("flags", "I,U").upper()
flags = 0
- for a in re.split('\W+',sflags):
+ for a in re.split(r'\W+', sflags):
flags = flags | reflags.get(a,0)
c = re.compile(names, flags)
@@ -300,9 +306,15 @@ class DataExporter:
if self.all_doctypes:
# add child tables
for c in self.child_doctypes:
- for ci, child in enumerate(frappe.db.sql("""select * from `tab{0}`
- where parent=%s and parentfield=%s order by idx""".format(c['doctype']),
- (doc.name, c['parentfield']), as_dict=1)):
+ child_doctype_table = DocType(c["doctype"])
+ data_row = (
+ frappe.qb.from_(child_doctype_table)
+ .select("*")
+ .where(child_doctype_table.parent == doc.name)
+ .where(child_doctype_table.parentfield == c["parentfield"])
+ .orderby(child_doctype_table.idx)
+ )
+ for ci, child in enumerate(data_row.run(as_dict=True)):
self.add_data_row(rows, c['doctype'], c['parentfield'], child, ci)
for row in rows:
@@ -312,7 +324,7 @@ class DataExporter:
d = doc.copy()
meta = frappe.get_meta(dt)
if self.all_doctypes:
- d.name = '"'+ d.name+'"'
+ d.name = f'"{d.name}"'
if len(rows) < rowidx + 1:
rows.append([""] * (len(self.columns) + 1))
diff --git a/frappe/core/doctype/data_export/test_data_exporter.py b/frappe/core/doctype/data_export/test_data_exporter.py
new file mode 100644
index 0000000000..8d05707cf1
--- /dev/null
+++ b/frappe/core/doctype/data_export/test_data_exporter.py
@@ -0,0 +1,105 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2019, Frappe Technologies and Contributors
+# License: MIT. See LICENSE
+import unittest
+import frappe
+from frappe.core.doctype.data_export.exporter import DataExporter
+
+class TestDataExporter(unittest.TestCase):
+ def setUp(self):
+ self.doctype_name = 'Test DocType for Export Tool'
+ self.doc_name = 'Test Data for Export Tool'
+ self.create_doctype_if_not_exists(doctype_name=self.doctype_name)
+ self.create_test_data()
+
+ def create_doctype_if_not_exists(self, doctype_name, force=False):
+ """
+ Helper Function for setting up doctypes
+ """
+ if force:
+ frappe.delete_doc_if_exists('DocType', doctype_name)
+ frappe.delete_doc_if_exists('DocType', 'Child 1 of ' + doctype_name)
+
+ if frappe.db.exists('DocType', doctype_name):
+ return
+
+ # Child Table 1
+ table_1_name = 'Child 1 of ' + doctype_name
+ frappe.get_doc({
+ 'doctype': 'DocType',
+ 'name': table_1_name,
+ 'module': 'Custom',
+ 'custom': 1,
+ 'istable': 1,
+ 'fields': [
+ {'label': 'Child Title', 'fieldname': 'child_title', 'reqd': 1, 'fieldtype': 'Data'},
+ {'label': 'Child Number', 'fieldname': 'child_number', 'fieldtype': 'Int'},
+ ]
+ }).insert()
+
+ # Main Table
+ frappe.get_doc({
+ 'doctype': 'DocType',
+ 'name': doctype_name,
+ 'module': 'Custom',
+ 'custom': 1,
+ 'autoname': 'field:title',
+ 'fields': [
+ {'label': 'Title', 'fieldname': 'title', 'reqd': 1, 'fieldtype': 'Data'},
+ {'label': 'Number', 'fieldname': 'number', 'fieldtype': 'Int'},
+ {'label': 'Table Field 1', 'fieldname': 'table_field_1', 'fieldtype': 'Table', 'options': table_1_name},
+ ],
+ 'permissions': [
+ {'role': 'System Manager'}
+ ]
+ }).insert()
+
+ def create_test_data(self, force=False):
+ """
+ Helper Function creating test data
+ """
+ if force:
+ frappe.delete_doc(self.doctype_name, self.doc_name)
+
+ if not frappe.db.exists(self.doctype_name, self.doc_name):
+ self.doc = frappe.get_doc(
+ doctype=self.doctype_name,
+ title=self.doc_name,
+ number="100",
+ table_field_1=[
+ {"child_title": "Child Title 1", "child_number": "50"},
+ {"child_title": "Child Title 2", "child_number": "51"},
+ ]
+ ).insert()
+ else:
+ self.doc = frappe.get_doc(self.doctype_name, self.doc_name)
+
+ def test_export_content(self):
+ exp = DataExporter(doctype=self.doctype_name, file_type='CSV')
+ exp.build_response()
+
+ self.assertEqual(frappe.response['type'],'csv')
+ self.assertEqual(frappe.response['doctype'], self.doctype_name)
+ self.assertTrue(frappe.response['result'])
+ self.assertIn('Child Title 1\",50',frappe.response['result'])
+ self.assertIn('Child Title 2\",51',frappe.response['result'])
+
+ def test_export_type(self):
+ for type in ['csv', 'Excel']:
+ with self.subTest(type=type):
+ exp = DataExporter(doctype=self.doctype_name, file_type=type)
+ exp.build_response()
+
+ self.assertEqual(frappe.response['doctype'], self.doctype_name)
+ self.assertTrue(frappe.response['result'])
+
+ if type == 'csv':
+ self.assertEqual(frappe.response['type'],'csv')
+ elif type == 'Excel':
+ self.assertEqual(frappe.response['type'],'binary')
+ self.assertEqual(frappe.response['filename'], self.doctype_name+'.xlsx') # 'Test DocType for Export Tool.xlsx')
+ self.assertTrue(frappe.response['filecontent'])
+
+ def tearDown(self):
+ pass
+
diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js
index 0e827a42d8..dfc560a98a 100644
--- a/frappe/core/doctype/data_import/data_import.js
+++ b/frappe/core/doctype/data_import/data_import.js
@@ -44,6 +44,7 @@ frappe.ui.form.on('Data Import', {
}
frm.dashboard.show_progress(__('Import Progress'), percent, message);
frm.page.set_indicator(__('In Progress'), 'orange');
+ frm.trigger('update_primary_action');
// hide progress when complete
if (data.current === data.total) {
@@ -80,7 +81,10 @@ frappe.ui.form.on('Data Import', {
frm.trigger('show_import_log');
frm.trigger('show_import_warnings');
frm.trigger('toggle_submit_after_import');
- frm.trigger('show_import_status');
+
+ if (frm.doc.status != 'Pending')
+ frm.trigger('show_import_status');
+
frm.trigger('show_report_error_button');
if (frm.doc.status === 'Partial Success') {
@@ -91,7 +95,7 @@ frappe.ui.form.on('Data Import', {
if (frm.doc.status.includes('Success')) {
frm.add_custom_button(
- __('Go to {0} List', [frm.doc.reference_doctype]),
+ __('Go to {0} List', [__(frm.doc.reference_doctype)]),
() => frappe.set_route('List', frm.doc.reference_doctype)
);
}
@@ -128,40 +132,49 @@ frappe.ui.form.on('Data Import', {
},
show_import_status(frm) {
- let import_log = JSON.parse(frm.doc.import_log || '[]');
- let successful_records = import_log.filter(log => log.success);
- let failed_records = import_log.filter(log => !log.success);
- if (successful_records.length === 0) return;
+ frappe.call({
+ 'method': 'frappe.core.doctype.data_import.data_import.get_import_status',
+ 'args': {
+ 'data_import_name': frm.doc.name
+ },
+ 'callback': function(r) {
+ let successful_records = cint(r.message.success);
+ let failed_records = cint(r.message.failed);
+ let total_records = cint(r.message.total_records);
- let message;
- if (failed_records.length === 0) {
- let message_args = [successful_records.length];
- if (frm.doc.import_type === 'Insert New Records') {
- message =
- successful_records.length > 1
- ? __('Successfully imported {0} records.', message_args)
- : __('Successfully imported {0} record.', message_args);
- } else {
- message =
- successful_records.length > 1
- ? __('Successfully updated {0} records.', message_args)
- : __('Successfully updated {0} record.', message_args);
+ if (!total_records) return;
+
+ let message;
+ if (failed_records === 0) {
+ let message_args = [successful_records];
+ if (frm.doc.import_type === 'Insert New Records') {
+ message =
+ successful_records > 1
+ ? __('Successfully imported {0} records.', message_args)
+ : __('Successfully imported {0} record.', message_args);
+ } else {
+ message =
+ successful_records > 1
+ ? __('Successfully updated {0} records.', message_args)
+ : __('Successfully updated {0} record.', message_args);
+ }
+ } else {
+ let message_args = [successful_records, total_records];
+ if (frm.doc.import_type === 'Insert New Records') {
+ message =
+ successful_records > 1
+ ? __('Successfully imported {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args)
+ : __('Successfully imported {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args);
+ } else {
+ message =
+ successful_records > 1
+ ? __('Successfully updated {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args)
+ : __('Successfully updated {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args);
+ }
+ }
+ frm.dashboard.set_headline(message);
}
- } else {
- let message_args = [successful_records.length, import_log.length];
- if (frm.doc.import_type === 'Insert New Records') {
- message =
- successful_records.length > 1
- ? __('Successfully imported {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args)
- : __('Successfully imported {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args);
- } else {
- message =
- successful_records.length > 1
- ? __('Successfully updated {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args)
- : __('Successfully updated {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args);
- }
- }
- frm.dashboard.set_headline(message);
+ });
},
show_report_error_button(frm) {
@@ -203,7 +216,7 @@ frappe.ui.form.on('Data Import', {
},
download_template(frm) {
- frappe.require('/assets/js/data_import_tools.min.js', () => {
+ frappe.require('data_import_tools.bundle.js', () => {
frm.data_exporter = new frappe.data_import.DataExporter(
frm.doc.reference_doctype,
frm.doc.import_type
@@ -275,7 +288,7 @@ frappe.ui.form.on('Data Import', {
},
show_import_preview(frm, preview_data) {
- let import_log = JSON.parse(frm.doc.import_log || '[]');
+ let import_log = preview_data.import_log;
if (
frm.import_preview &&
@@ -287,7 +300,7 @@ frappe.ui.form.on('Data Import', {
return;
}
- frappe.require('/assets/js/data_import_tools.min.js', () => {
+ frappe.require('data_import_tools.bundle.js', () => {
frm.import_preview = new frappe.data_import.ImportPreview({
wrapper: frm.get_field('import_preview').$wrapper,
doctype: frm.doc.reference_doctype,
@@ -316,6 +329,15 @@ frappe.ui.form.on('Data Import', {
);
},
+ export_import_log(frm) {
+ open_url_post(
+ '/api/method/frappe.core.doctype.data_import.data_import.download_import_log',
+ {
+ data_import_name: frm.doc.name
+ }
+ );
+ },
+
show_import_warnings(frm, preview_data) {
let columns = preview_data.columns;
let warnings = JSON.parse(frm.doc.template_warnings || '[]');
@@ -391,131 +413,131 @@ frappe.ui.form.on('Data Import', {
frm.trigger('show_import_log');
},
- show_import_log(frm) {
- let import_log = JSON.parse(frm.doc.import_log || '[]');
- let logs = import_log;
- frm.toggle_display('import_log', false);
- frm.toggle_display('import_log_section', logs.length > 0);
+ render_import_log(frm) {
+ frappe.call({
+ 'method': 'frappe.client.get_list',
+ 'args': {
+ 'doctype': 'Data Import Log',
+ 'filters': {
+ 'data_import': frm.doc.name
+ },
+ 'fields': ['success', 'docname', 'messages', 'exception', 'row_indexes'],
+ 'limit_page_length': 5000,
+ 'order_by': 'log_index'
+ },
+ callback: function(r) {
+ let logs = r.message;
- if (logs.length === 0) {
- frm.get_field('import_log_preview').$wrapper.empty();
+ if (logs.length === 0) return;
+
+ frm.toggle_display('import_log_section', true);
+
+ let rows = logs
+ .map(log => {
+ let html = '';
+ if (log.success) {
+ if (frm.doc.import_type === 'Insert New Records') {
+ html = __('Successfully imported {0}', [
+ `${frappe.utils.get_form_link(
+ frm.doc.reference_doctype,
+ log.docname,
+ true
+ )}`
+ ]);
+ } else {
+ html = __('Successfully updated {0}', [
+ `${frappe.utils.get_form_link(
+ frm.doc.reference_doctype,
+ log.docname,
+ true
+ )}`
+ ]);
+ }
+ } else {
+ let messages = (JSON.parse(log.messages || '[]'))
+ .map(JSON.parse)
+ .map(m => {
+ let title = m.title ? `${m.title} ` : '';
+ let message = m.message ? `${m.message}
` : '';
+ return title + message;
+ })
+ .join('');
+ let id = frappe.dom.get_unique_id();
+ html = `${messages}
+
+ ${__('Show Traceback')}
+
+ `;
+ }
+ let indicator_color = log.success ? 'green' : 'red';
+ let title = log.success ? __('Success') : __('Failure');
+
+ if (frm.doc.show_failed_logs && log.success) {
+ return '';
+ }
+
+ return `
+ ${JSON.parse(log.row_indexes).join(', ')}
+
+ ${title}
+
+
+ ${html}
+
+ `;
+ })
+ .join('');
+
+ if (!rows && frm.doc.show_failed_logs) {
+ rows = `
+ ${__('No failed logs')}
+ `;
+ }
+
+ frm.get_field('import_log_preview').$wrapper.html(`
+
+
+ ${__('Row Number')}
+ ${__('Status')}
+ ${__('Message')}
+
+ ${rows}
+
+ `);
+ }
+ });
+ },
+
+ show_import_log(frm) {
+ frm.toggle_display('import_log_section', false);
+
+ if (frm.import_in_progress) {
return;
}
- let rows = logs
- .map(log => {
- let html = '';
- if (log.success) {
- if (frm.doc.import_type === 'Insert New Records') {
- html = __('Successfully imported {0}', [
- `${frappe.utils.get_form_link(
- frm.doc.reference_doctype,
- log.docname,
- true
- )}`
- ]);
- } else {
- html = __('Successfully updated {0}', [
- `${frappe.utils.get_form_link(
- frm.doc.reference_doctype,
- log.docname,
- true
- )}`
- ]);
- }
+ frappe.call({
+ 'method': 'frappe.client.get_count',
+ 'args': {
+ 'doctype': 'Data Import Log',
+ 'filters': {
+ 'data_import': frm.doc.name
+ }
+ },
+ 'callback': function(r) {
+ let count = r.message;
+ if (count < 5000) {
+ frm.trigger('render_import_log');
} else {
- let messages = log.messages
- .map(JSON.parse)
- .map(m => {
- let title = m.title ? `${m.title} ` : '';
- let message = m.message ? `${m.message}
` : '';
- return title + message;
- })
- .join('');
- let id = frappe.dom.get_unique_id();
- html = `${messages}
-
- ${__('Show Traceback')}
-
- `;
+ frm.toggle_display('import_log_section', false);
+ frm.add_custom_button(__('Export Import Log'), () =>
+ frm.trigger('export_import_log')
+ );
}
- let indicator_color = log.success ? 'green' : 'red';
- let title = log.success ? __('Success') : __('Failure');
-
- if (frm.doc.show_failed_logs && log.success) {
- return '';
- }
-
- return `
- ${log.row_indexes.join(', ')}
-
- ${title}
-
-
- ${html}
-
- `;
- })
- .join('');
-
- if (!rows && frm.doc.show_failed_logs) {
- rows = `
- ${__('No failed logs')}
- `;
- }
-
- frm.get_field('import_log_preview').$wrapper.html(`
-
-
- ${__('Row Number')}
- ${__('Status')}
- ${__('Message')}
-
- ${rows}
-
- `);
+ }
+ });
},
-
- show_missing_link_values(frm, missing_link_values) {
- let can_be_created_automatically = missing_link_values.every(
- d => d.has_one_mandatory_field
- );
-
- let html = missing_link_values
- .map(d => {
- let doctype = d.doctype;
- let values = d.missing_values;
- return `
- ${doctype}
- ${values.map(v => `${v} `).join('')}
- `;
- })
- .join('');
-
- if (can_be_created_automatically) {
- // prettier-ignore
- let message = __('There are some linked records which needs to be created before we can import your file. Do you want to create the following missing records automatically?');
- frappe.confirm(message + html, () => {
- frm
- .call('create_missing_link_values', {
- missing_link_values
- })
- .then(r => {
- let records = r.message;
- frappe.msgprint(
- __('Created {0} records successfully.', [records.length])
- );
- });
- });
- } else {
- frappe.msgprint(
- // prettier-ignore
- __('The following records needs to be created before we can import your file.') + html
- );
- }
- }
});
diff --git a/frappe/core/doctype/data_import/data_import.json b/frappe/core/doctype/data_import/data_import.json
index 8b1b6c4e07..9e948dac8c 100644
--- a/frappe/core/doctype/data_import/data_import.json
+++ b/frappe/core/doctype/data_import/data_import.json
@@ -1,192 +1,197 @@
{
- "actions": [],
- "autoname": "format:{reference_doctype} Import on {creation}",
- "beta": 1,
- "creation": "2019-08-04 14:16:08.318714",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "reference_doctype",
- "import_type",
- "download_template",
- "import_file",
- "html_5",
- "google_sheets_url",
- "refresh_google_sheet",
- "column_break_5",
- "status",
- "submit_after_import",
- "mute_emails",
- "template_options",
- "import_warnings_section",
- "template_warnings",
- "import_warnings",
- "section_import_preview",
- "import_preview",
- "import_log_section",
- "import_log",
- "show_failed_logs",
- "import_log_preview"
- ],
- "fields": [
- {
- "fieldname": "reference_doctype",
- "fieldtype": "Link",
- "in_list_view": 1,
- "label": "Document Type",
- "options": "DocType",
- "reqd": 1,
- "set_only_once": 1
- },
- {
- "fieldname": "import_type",
- "fieldtype": "Select",
- "in_list_view": 1,
- "label": "Import Type",
- "options": "\nInsert New Records\nUpdate Existing Records",
- "reqd": 1,
- "set_only_once": 1
- },
- {
- "depends_on": "eval:!doc.__islocal",
- "fieldname": "import_file",
- "fieldtype": "Attach",
- "in_list_view": 1,
- "label": "Import File"
- },
- {
- "fieldname": "import_preview",
- "fieldtype": "HTML",
- "label": "Import Preview"
- },
- {
- "fieldname": "section_import_preview",
- "fieldtype": "Section Break",
- "label": "Preview"
- },
- {
- "fieldname": "column_break_5",
- "fieldtype": "Column Break"
- },
- {
- "fieldname": "template_options",
- "fieldtype": "Code",
- "hidden": 1,
- "label": "Template Options",
- "options": "JSON",
- "read_only": 1
- },
- {
- "fieldname": "import_log",
- "fieldtype": "Code",
- "label": "Import Log",
- "options": "JSON"
- },
- {
- "fieldname": "import_log_section",
- "fieldtype": "Section Break",
- "label": "Import Log"
- },
- {
- "fieldname": "import_log_preview",
- "fieldtype": "HTML",
- "label": "Import Log Preview"
- },
- {
- "default": "Pending",
- "fieldname": "status",
- "fieldtype": "Select",
- "hidden": 1,
- "label": "Status",
- "options": "Pending\nSuccess\nPartial Success\nError",
- "read_only": 1
- },
- {
- "fieldname": "template_warnings",
- "fieldtype": "Code",
- "hidden": 1,
- "label": "Template Warnings",
- "options": "JSON"
- },
- {
- "default": "0",
- "fieldname": "submit_after_import",
- "fieldtype": "Check",
- "label": "Submit After Import",
- "set_only_once": 1
- },
- {
- "fieldname": "import_warnings_section",
- "fieldtype": "Section Break",
- "label": "Import File Errors and Warnings"
- },
- {
- "fieldname": "import_warnings",
- "fieldtype": "HTML",
- "label": "Import Warnings"
- },
- {
- "depends_on": "eval:!doc.__islocal",
- "fieldname": "download_template",
- "fieldtype": "Button",
- "label": "Download Template"
- },
- {
- "default": "1",
- "fieldname": "mute_emails",
- "fieldtype": "Check",
- "label": "Don't Send Emails",
- "set_only_once": 1
- },
- {
- "default": "0",
- "fieldname": "show_failed_logs",
- "fieldtype": "Check",
- "label": "Show Failed Logs"
- },
- {
- "depends_on": "eval:!doc.__islocal && !doc.import_file",
- "fieldname": "html_5",
- "fieldtype": "HTML",
- "options": "Or "
- },
- {
- "depends_on": "eval:!doc.__islocal && !doc.import_file\n",
- "description": "Must be a publicly accessible Google Sheets URL",
- "fieldname": "google_sheets_url",
- "fieldtype": "Data",
- "label": "Import from Google Sheets"
- },
- {
- "depends_on": "eval:doc.google_sheets_url && !doc.__unsaved",
- "fieldname": "refresh_google_sheet",
- "fieldtype": "Button",
- "label": "Refresh Google Sheet"
- }
- ],
- "hide_toolbar": 1,
- "links": [],
- "modified": "2020-06-24 14:33:03.173876",
- "modified_by": "Administrator",
- "module": "Core",
- "name": "Data Import",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "share": 1,
- "write": 1
- }
- ],
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
+ "actions": [],
+ "autoname": "format:{reference_doctype} Import on {creation}",
+ "beta": 1,
+ "creation": "2019-08-04 14:16:08.318714",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "reference_doctype",
+ "import_type",
+ "download_template",
+ "import_file",
+ "payload_count",
+ "html_5",
+ "google_sheets_url",
+ "refresh_google_sheet",
+ "column_break_5",
+ "status",
+ "submit_after_import",
+ "mute_emails",
+ "template_options",
+ "import_warnings_section",
+ "template_warnings",
+ "import_warnings",
+ "section_import_preview",
+ "import_preview",
+ "import_log_section",
+ "show_failed_logs",
+ "import_log_preview"
+ ],
+ "fields": [
+ {
+ "fieldname": "reference_doctype",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Document Type",
+ "options": "DocType",
+ "reqd": 1,
+ "set_only_once": 1
+ },
+ {
+ "fieldname": "import_type",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Import Type",
+ "options": "\nInsert New Records\nUpdate Existing Records",
+ "reqd": 1,
+ "set_only_once": 1
+ },
+ {
+ "depends_on": "eval:!doc.__islocal",
+ "fieldname": "import_file",
+ "fieldtype": "Attach",
+ "in_list_view": 1,
+ "label": "Import File",
+ "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)"
+ },
+ {
+ "fieldname": "import_preview",
+ "fieldtype": "HTML",
+ "label": "Import Preview"
+ },
+ {
+ "fieldname": "section_import_preview",
+ "fieldtype": "Section Break",
+ "label": "Preview"
+ },
+ {
+ "fieldname": "column_break_5",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "template_options",
+ "fieldtype": "Code",
+ "hidden": 1,
+ "label": "Template Options",
+ "options": "JSON",
+ "read_only": 1
+ },
+ {
+ "fieldname": "import_log_section",
+ "fieldtype": "Section Break",
+ "label": "Import Log"
+ },
+ {
+ "fieldname": "import_log_preview",
+ "fieldtype": "HTML",
+ "label": "Import Log Preview"
+ },
+ {
+ "default": "Pending",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "hidden": 1,
+ "label": "Status",
+ "options": "Pending\nSuccess\nPartial Success\nError",
+ "read_only": 1
+ },
+ {
+ "fieldname": "template_warnings",
+ "fieldtype": "Code",
+ "hidden": 1,
+ "label": "Template Warnings",
+ "options": "JSON"
+ },
+ {
+ "default": "0",
+ "fieldname": "submit_after_import",
+ "fieldtype": "Check",
+ "label": "Submit After Import",
+ "set_only_once": 1
+ },
+ {
+ "fieldname": "import_warnings_section",
+ "fieldtype": "Section Break",
+ "label": "Import File Errors and Warnings"
+ },
+ {
+ "fieldname": "import_warnings",
+ "fieldtype": "HTML",
+ "label": "Import Warnings"
+ },
+ {
+ "depends_on": "eval:!doc.__islocal",
+ "fieldname": "download_template",
+ "fieldtype": "Button",
+ "label": "Download Template"
+ },
+ {
+ "default": "1",
+ "fieldname": "mute_emails",
+ "fieldtype": "Check",
+ "label": "Don't Send Emails",
+ "set_only_once": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "show_failed_logs",
+ "fieldtype": "Check",
+ "label": "Show Failed Logs"
+ },
+ {
+ "depends_on": "eval:!doc.__islocal && !doc.import_file",
+ "fieldname": "html_5",
+ "fieldtype": "HTML",
+ "options": "Or "
+ },
+ {
+ "depends_on": "eval:!doc.__islocal && !doc.import_file\n",
+ "description": "Must be a publicly accessible Google Sheets URL",
+ "fieldname": "google_sheets_url",
+ "fieldtype": "Data",
+ "label": "Import from Google Sheets",
+ "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)"
+ },
+ {
+ "depends_on": "eval:doc.google_sheets_url && !doc.__unsaved && ['Success', 'Partial Success'].includes(doc.status)",
+ "fieldname": "refresh_google_sheet",
+ "fieldtype": "Button",
+ "label": "Refresh Google Sheet"
+ },
+ {
+ "fieldname": "payload_count",
+ "fieldtype": "Int",
+ "hidden": 1,
+ "label": "Payload Count",
+ "read_only": 1
+ }
+ ],
+ "hide_toolbar": 1,
+ "links": [],
+ "modified": "2022-02-01 20:08:37.624914",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "Data Import",
+ "naming_rule": "Expression",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py
index 72de092461..5972e79b4d 100644
--- a/frappe/core/doctype/data_import/data_import.py
+++ b/frappe/core/doctype/data_import/data_import.py
@@ -1,17 +1,17 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import os
-import frappe
-from frappe.model.document import Document
-from frappe.core.doctype.data_import.importer import Importer
+import frappe
+from frappe import _
from frappe.core.doctype.data_import.exporter import Exporter
+from frappe.core.doctype.data_import.importer import Importer
+from frappe.model.document import Document
+from frappe.modules.import_file import import_file_by_path
from frappe.utils.background_jobs import enqueue
from frappe.utils.csvutils import validate_google_sheets_url
-from frappe import _
class DataImport(Document):
@@ -27,6 +27,7 @@ class DataImport(Document):
self.validate_import_file()
self.validate_google_sheets_url()
+ self.set_payload_count()
def validate_import_file(self):
if self.import_file:
@@ -38,6 +39,13 @@ class DataImport(Document):
return
validate_google_sheets_url(self.google_sheets_url)
+ def set_payload_count(self):
+ if self.import_file:
+ i = self.get_importer()
+ payloads = i.import_file.get_payloads_for_import()
+ self.payload_count = len(payloads)
+
+ @frappe.whitelist()
def get_preview_from_template(self, import_file=None, google_sheets_url=None):
if import_file:
self.import_file = import_file
@@ -66,7 +74,7 @@ class DataImport(Document):
enqueue(
start_import,
queue="default",
- timeout=6000,
+ timeout=10000,
event="data_import",
job_name=self.name,
data_import=self.name,
@@ -79,6 +87,9 @@ class DataImport(Document):
def export_errored_rows(self):
return self.get_importer().export_errored_rows()
+ def download_import_log(self):
+ return self.get_importer().export_import_log()
+
def get_importer(self):
return Importer(self.reference_doctype, data_import=self)
@@ -89,7 +100,6 @@ def get_preview_from_template(data_import, import_file=None, google_sheets_url=N
import_file, google_sheets_url
)
-
@frappe.whitelist()
def form_start_import(data_import):
return frappe.get_doc("Data Import", data_import).start_import()
@@ -144,6 +154,30 @@ def download_errored_template(data_import_name):
data_import = frappe.get_doc("Data Import", data_import_name)
data_import.export_errored_rows()
+@frappe.whitelist()
+def download_import_log(data_import_name):
+ data_import = frappe.get_doc("Data Import", data_import_name)
+ data_import.download_import_log()
+
+@frappe.whitelist()
+def get_import_status(data_import_name):
+ import_status = {}
+
+ logs = frappe.get_all('Data Import Log', fields=['count(*) as count', 'success'],
+ filters={'data_import': data_import_name},
+ group_by='success')
+
+ total_payload_count = frappe.db.get_value('Data Import', data_import_name, 'payload_count')
+
+ for log in logs:
+ if log.get('success'):
+ import_status['success'] = log.get('count')
+ else:
+ import_status['failed'] = log.get('count')
+
+ import_status['total_records'] = total_payload_count
+
+ return import_status
def import_file(
doctype, file_path, import_type, submit_after_import=False, console=False
@@ -170,18 +204,7 @@ def import_file(
i.import_data()
-##############
-
-
-def import_doc(
- path,
- overwrite=False,
- ignore_links=False,
- ignore_insert=False,
- insert=False,
- submit=False,
- pre_process=None,
-):
+def import_doc(path, pre_process=None):
if os.path.isdir(path):
files = [os.path.join(path, f) for f in os.listdir(path)]
else:
@@ -190,44 +213,29 @@ def import_doc(
for f in files:
if f.endswith(".json"):
frappe.flags.mute_emails = True
- frappe.modules.import_file.import_file_by_path(
- f, data_import=True, force=True, pre_process=pre_process, reset_permissions=True
+ import_file_by_path(
+ f,
+ data_import=True,
+ force=True,
+ pre_process=pre_process,
+ reset_permissions=True
)
frappe.flags.mute_emails = False
frappe.db.commit()
- elif f.endswith(".csv"):
- import_file_by_path(
- f,
- ignore_links=ignore_links,
- overwrite=overwrite,
- submit=submit,
- pre_process=pre_process,
- )
- frappe.db.commit()
-
-
-def import_file_by_path(
- path,
- ignore_links=False,
- overwrite=False,
- submit=False,
- pre_process=None,
- no_email=True,
-):
- if path.endswith(".csv"):
- print()
- print("This method is deprecated.")
- print('Import CSV files using the command "bench --site sitename data-import"')
- print("Or use the method frappe.core.doctype.data_import.data_import.import_file")
- print()
- raise Exception("Method deprecated")
+ else:
+ raise NotImplementedError("Only .json files can be imported")
def export_json(
doctype, path, filters=None, or_filters=None, name=None, order_by="creation asc"
):
def post_process(out):
- del_keys = ("modified_by", "creation", "owner", "idx")
+ # Note on Tree DocTypes:
+ # The tree structure is maintained in the database via the fields "lft"
+ # and "rgt". They are automatically set and kept up-to-date. Importing
+ # them would destroy any existing tree structure. For this reason they
+ # are not exported as well.
+ del_keys = ("modified_by", "creation", "owner", "idx", "lft", "rgt")
for doc in out:
for key in del_keys:
if key in doc:
diff --git a/frappe/core/doctype/data_import/data_import_list.js b/frappe/core/doctype/data_import/data_import_list.js
index 0eb05aa354..6ab750ba25 100644
--- a/frappe/core/doctype/data_import/data_import_list.js
+++ b/frappe/core/doctype/data_import/data_import_list.js
@@ -24,12 +24,14 @@ frappe.listview_settings['Data Import'] = {
'Error': 'red'
};
let status = doc.status;
+
if (imports_in_progress.includes(doc.name)) {
status = 'In Progress';
}
if (status == 'Pending') {
status = 'Not Started';
}
+
return [__(status), colors[status], 'status,=,' + doc.status];
},
formatters: {
diff --git a/frappe/core/doctype/data_import/exporter.py b/frappe/core/doctype/data_import/exporter.py
index 66e32a1270..c09bd58c25 100644
--- a/frappe/core/doctype/data_import/exporter.py
+++ b/frappe/core/doctype/data_import/exporter.py
@@ -1,14 +1,17 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
+
+import typing
import frappe
+from frappe import _
from frappe.model import (
display_fieldtypes,
no_value_fields,
table_fields as table_fieldtypes,
)
-from frappe.utils import flt, format_duration
+from frappe.utils import flt, format_duration, groupby_metric
from frappe.utils.csvutils import build_csv_response
from frappe.utils.xlsxutils import build_xlsx_response
@@ -116,7 +119,6 @@ class Exporter:
def get_data_to_export(self):
frappe.permissions.can_export(self.doctype, raise_exception=True)
- data_to_export = []
table_fields = [f for f in self.exportable_fields if f != self.doctype]
data = self.get_data_as_docs()
@@ -128,14 +130,13 @@ class Exporter:
if table_fields:
# add child table data
for f in table_fields:
- for i, child_row in enumerate(doc[f]):
+ for i, child_row in enumerate(doc.get(f, [])):
table_df = self.meta.get_field(f)
child_doctype = table_df.options
rows = self.add_data_row(child_doctype, child_row.parentfield, child_row, rows, i)
- data_to_export += rows
-
- return data_to_export
+ for row in rows:
+ yield row
def add_data_row(self, doctype, parentfield, doc, rows, row_idx):
if len(rows) < row_idx + 1:
@@ -191,7 +192,7 @@ class Exporter:
[format_column_name(df) for df in self.fields if df.parent == child_table_doctype]
)
)
- data = frappe.db.get_list(
+ data = frappe.db.get_all(
child_table_doctype,
filters={
"parent": ("in", parent_names),
@@ -204,24 +205,20 @@ class Exporter:
)
child_data[key] = data
- return self.merge_data(parent_data, child_data)
-
- def merge_data(self, parent_data, child_data):
+ # Group children data by parent name
+ grouped_children_data = self.group_children_data_by_parent(child_data)
for doc in parent_data:
- for table_field, table_rows in child_data.items():
- doc[table_field] = [row for row in table_rows if row.parent == doc.name]
-
- return parent_data
+ related_children_docs = grouped_children_data.get(doc.name, {})
+ yield {**doc, **related_children_docs}
def add_header(self):
-
header = []
for df in self.fields:
is_parent = not df.is_child_table_field
if is_parent:
- label = df.label
+ label = _(df.label)
else:
- label = "{0} ({1})".format(df.label, df.child_table_df.label)
+ label = "{0} ({1})".format(_(df.label), _(df.child_table_df.label))
if label in header:
# this label is already in the header,
@@ -231,6 +228,7 @@ class Exporter:
label = "{0}".format(df.fieldname)
else:
label = "{0}.{1}".format(df.child_table_df.fieldname, df.fieldname)
+
header.append(label)
self.csv_array.append(header)
@@ -257,7 +255,10 @@ class Exporter:
self.build_xlsx_response()
def build_csv_response(self):
- build_csv_response(self.get_csv_array_for_export(), self.doctype)
+ build_csv_response(self.get_csv_array_for_export(), _(self.doctype))
def build_xlsx_response(self):
- build_xlsx_response(self.get_csv_array_for_export(), self.doctype)
+ build_xlsx_response(self.get_csv_array_for_export(), _(self.doctype))
+
+ def group_children_data_by_parent(self, children_data: typing.Dict[str, list]):
+ return groupby_metric(children_data, key='parent')
diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py
index dde3dfaee9..f89eb31cc8 100644
--- a/frappe/core/doctype/data_import/importer.py
+++ b/frappe/core/doctype/data_import/importer.py
@@ -1,7 +1,6 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import os
import io
import frappe
@@ -48,7 +47,13 @@ class Importer:
)
def get_data_for_import_preview(self):
- return self.import_file.get_data_for_import_preview()
+ out = self.import_file.get_data_for_import_preview()
+
+ out.import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success"],
+ filters={"data_import": self.data_import.name},
+ order_by="log_index", limit=10)
+
+ return out
def before_import(self):
# set user lang for translations
@@ -59,7 +64,6 @@ class Importer:
frappe.flags.in_import = True
frappe.flags.mute_emails = self.data_import.mute_emails
- self.data_import.db_set("status", "Pending")
self.data_import.db_set("template_warnings", "")
def import_data(self):
@@ -80,20 +84,25 @@ class Importer:
return
# setup import log
- if self.data_import.import_log:
- import_log = frappe.parse_json(self.data_import.import_log)
- else:
- import_log = []
+ import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "log_index"],
+ filters={"data_import": self.data_import.name},
+ order_by="log_index") or []
- # remove previous failures from import log
- import_log = [log for log in import_log if log.get("success")]
+ log_index = 0
+
+ # Do not remove rows in case of retry after an error or pending data import
+ if self.data_import.status == "Partial Success" and len(import_log) >= self.data_import.payload_count:
+ # remove previous failures from import log only in case of retry after partial success
+ import_log = [log for log in import_log if log.get("success")]
# get successfully imported rows
imported_rows = []
for log in import_log:
log = frappe._dict(log)
- if log.success:
- imported_rows += log.row_indexes
+ if log.success or len(import_log) < self.data_import.payload_count:
+ imported_rows += json.loads(log.row_indexes)
+
+ log_index = log.log_index
# start import
total_payload_count = len(payloads)
@@ -147,25 +156,41 @@ class Importer:
},
)
- import_log.append(
- frappe._dict(success=True, docname=doc.name, row_indexes=row_indexes)
- )
+ create_import_log(self.data_import.name, log_index, {
+ 'success': True,
+ 'docname': doc.name,
+ 'row_indexes': row_indexes
+ })
+
+ log_index += 1
+
+ if not self.data_import.status == "Partial Success":
+ self.data_import.db_set("status", "Partial Success")
+
# commit after every successful import
frappe.db.commit()
except Exception:
- import_log.append(
- frappe._dict(
- success=False,
- exception=frappe.get_traceback(),
- messages=frappe.local.message_log,
- row_indexes=row_indexes,
- )
- )
+ messages = frappe.local.message_log
frappe.clear_messages()
+
# rollback if exception
frappe.db.rollback()
+ create_import_log(self.data_import.name, log_index, {
+ 'success': False,
+ 'exception': frappe.get_traceback(),
+ 'messages': messages,
+ 'row_indexes': row_indexes
+ })
+
+ log_index += 1
+
+ # Logs are db inserted directly so will have to be fetched again
+ import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "log_index"],
+ filters={"data_import": self.data_import.name},
+ order_by="log_index") or []
+
# set status
failures = [log for log in import_log if not log.get("success")]
if len(failures) == total_payload_count:
@@ -179,7 +204,6 @@ class Importer:
self.print_import_log(import_log)
else:
self.data_import.db_set("status", status)
- self.data_import.db_set("import_log", json.dumps(import_log))
self.after_import()
@@ -200,7 +224,7 @@ class Importer:
new_doc = frappe.new_doc(self.doctype)
new_doc.update(doc)
- if (meta.autoname or "").lower() != "prompt":
+ if not doc.name and (meta.autoname or "").lower() != "prompt":
# name can only be set directly if autoname is prompt
new_doc.set("name", None)
@@ -233,7 +257,7 @@ class Importer:
return updated_doc
else:
# throw if no changes
- frappe.throw("No changes to update")
+ frappe.throw(_("No changes to update"))
def get_eta(self, current, total, processing_time):
self.last_eta = getattr(self, "last_eta", 0)
@@ -249,11 +273,14 @@ class Importer:
if not self.data_import:
return
- import_log = frappe.parse_json(self.data_import.import_log or "[]")
+ import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success"],
+ filters={"data_import": self.data_import.name},
+ order_by="log_index") or []
+
failures = [log for log in import_log if not log.get("success")]
row_indexes = []
for f in failures:
- row_indexes.extend(f.get("row_indexes", []))
+ row_indexes.extend(json.loads(f.get("row_indexes", [])))
# de duplicate
row_indexes = list(set(row_indexes))
@@ -263,6 +290,30 @@ class Importer:
rows = [header_row]
rows += [row.data for row in self.import_file.data if row.row_number in row_indexes]
+ build_csv_response(rows, _(self.doctype))
+
+ def export_import_log(self):
+ from frappe.utils.csvutils import build_csv_response
+
+ if not self.data_import:
+ return
+
+ import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "messages", "exception", "docname"],
+ filters={"data_import": self.data_import.name},
+ order_by="log_index")
+
+ header_row = ["Row Numbers", "Status", "Message", "Exception"]
+
+ rows = [header_row]
+
+ for log in import_log:
+ row_number = json.loads(log.get("row_indexes"))[0]
+ status = "Success" if log.get('success') else "Failure"
+ message = "Successfully Imported {0}".format(log.get('docname')) if log.get('success') else \
+ log.get("messages")
+ exception = frappe.utils.cstr(log.get("exception", ''))
+ rows += [[row_number, status, message, exception]]
+
build_csv_response(rows, self.doctype)
def print_import_log(self, import_log):
@@ -319,7 +370,7 @@ class ImportFile:
self.warnings = []
self.file_doc = self.file_path = self.google_sheets_url = None
- if isinstance(file, frappe.string_types):
+ if isinstance(file, str):
if frappe.db.exists("File", {"file_url": file}):
self.file_doc = frappe.get_doc("File", {"file_url": file})
elif "docs.google.com/spreadsheets" in file:
@@ -450,7 +501,7 @@ class ImportFile:
for row in data_without_first_row:
row_values = row.get_values(parent_column_indexes)
# if the row is blank, it's a child row doc
- if all([v in INVALID_VALUES for v in row_values]):
+ if all(v in INVALID_VALUES for v in row_values):
rows.append(row)
continue
# if we encounter a row which has values in parent columns,
@@ -472,32 +523,6 @@ class ImportFile:
doc = parent_doc
- if self.import_type == INSERT:
- # check if there is atleast one row for mandatory table fields
- meta = frappe.get_meta(self.doctype)
- mandatory_table_fields = [
- df
- for df in meta.fields
- if df.fieldtype in table_fieldtypes
- and df.reqd
- and len(doc.get(df.fieldname, [])) == 0
- ]
- if len(mandatory_table_fields) == 1:
- self.warnings.append(
- {
- "row": first_row.row_number,
- "message": _("There should be atleast one row for {0} table").format(
- frappe.bold(mandatory_table_fields[0].label)
- ),
- }
- )
- elif mandatory_table_fields:
- fields_string = ", ".join([df.label for df in mandatory_table_fields])
- message = _("There should be atleast one row for the following tables: {0}").format(
- fields_string
- )
- self.warnings.append({"row": first_row.row_number, "message": message})
-
return doc, rows, data[len(rows) :]
def get_warnings(self):
@@ -593,7 +618,7 @@ class Row:
)
# remove standard fields and __islocal
- for key in frappe.model.default_fields + ("__islocal",):
+ for key in frappe.model.default_fields + frappe.model.child_table_fields + ("__islocal",):
doc.pop(key, None)
for col, value in zip(columns, values):
@@ -626,7 +651,6 @@ class Row:
new_doc.update(doc)
doc = new_doc
- self.check_mandatory_fields(doctype, doc, table_df)
return doc
def validate_value(self, value, col):
@@ -634,7 +658,7 @@ class Row:
if df.fieldtype == "Select":
select_options = get_select_options(df)
if select_options and value not in select_options:
- options_string = ", ".join([frappe.bold(d) for d in select_options])
+ options_string = ", ".join(frappe.bold(d) for d in select_options)
msg = _("Value must be one of {0}").format(options_string)
self.warnings.append(
{"row": self.row_number, "field": df_as_json(df), "message": msg,}
@@ -653,7 +677,7 @@ class Row:
return
elif df.fieldtype in ["Date", "Datetime"]:
value = self.get_date(value, col)
- if isinstance(value, frappe.string_types):
+ if isinstance(value, str):
# value was not parsed as datetime object
self.warnings.append(
{
@@ -668,7 +692,7 @@ class Row:
return
elif df.fieldtype == "Duration":
import re
- is_valid_duration = re.match("^(?:(\d+d)?((^|\s)\d+h)?((^|\s)\d+m)?((^|\s)\d+s)?)$", value)
+ is_valid_duration = re.match(r"^(?:(\d+d)?((^|\s)\d+h)?((^|\s)\d+m)?((^|\s)\d+s)?)$", value)
if not is_valid_duration:
self.warnings.append(
{
@@ -727,66 +751,6 @@ class Row:
pass
return value
- def check_mandatory_fields(self, doctype, doc, table_df=None):
- """If import type is Insert:
- Check for mandatory fields (except table fields) in doc
- if import type is Update:
- Check for name field or autoname field in doc
- """
- meta = frappe.get_meta(doctype)
- if self.import_type == UPDATE:
- if meta.istable:
- # when updating records with table rows,
- # there are two scenarios:
- # 1. if row 'name' is provided in the template
- # the table row will be updated
- # 2. if row 'name' is not provided
- # then a new row will be added
- # so we dont need to check for mandatory
- return
-
- # for update, only ID (name) field is mandatory
- id_field = get_id_field(doctype)
- if doc.get(id_field.fieldname) in INVALID_VALUES:
- self.warnings.append(
- {
- "row": self.row_number,
- "message": _("{0} is a mandatory field").format(id_field.label),
- }
- )
- return
-
- fields = [
- df
- for df in meta.fields
- if df.fieldtype not in table_fieldtypes
- and df.reqd
- and doc.get(df.fieldname) in INVALID_VALUES
- ]
-
- if not fields:
- return
-
- def get_field_label(df):
- return "{0}{1}".format(df.label, " ({})".format(table_df.label) if table_df else "")
-
- if len(fields) == 1:
- field_label = get_field_label(fields[0])
- self.warnings.append(
- {
- "row": self.row_number,
- "message": _("{0} is a mandatory field").format(frappe.bold(field_label)),
- }
- )
- else:
- fields_string = ", ".join([frappe.bold(get_field_label(df)) for df in fields])
- self.warnings.append(
- {
- "row": self.row_number,
- "message": _("{0} are mandatory fields").format(fields_string),
- }
- )
-
def get_values(self, indexes):
return [self.data[i] for i in indexes]
@@ -851,7 +815,9 @@ class Column:
seen = []
fields_column_map = {}
- def __init__(self, index, header, doctype, column_values, map_to_field=None, seen=[]):
+ def __init__(self, index, header, doctype, column_values, map_to_field=None, seen=None):
+ if seen is None:
+ seen = []
self.index = index
self.column_number = index + 1
self.doctype = doctype
@@ -990,7 +956,7 @@ class Column:
if self.df.fieldtype == "Link":
# find all values that dont exist
- values = list(set([cstr(v) for v in self.column_values[1:] if v]))
+ values = list({cstr(v) for v in self.column_values[1:] if v})
exists = [
d.name for d in frappe.db.get_all(self.df.options, filters={"name": ("in", values)})
]
@@ -1016,21 +982,18 @@ class Column:
self.warnings.append(
{
"col": self.column_number,
- "message": _(
- "Date format could not be determined from the values in"
- " this column. Defaulting to yyyy-mm-dd."
- ),
+ "message": _("Date format could not be determined from the values in this column. Defaulting to yyyy-mm-dd."),
"type": "info",
}
)
elif self.df.fieldtype == "Select":
options = get_select_options(self.df)
if options:
- values = list(set([cstr(v) for v in self.column_values[1:] if v]))
- invalid = list(set(values) - set(options))
+ values = {cstr(v) for v in self.column_values[1:] if v}
+ invalid = values - set(options)
if invalid:
- valid_values = ", ".join([frappe.bold(o) for o in options])
- invalid_values = ", ".join([frappe.bold(i) for i in invalid])
+ valid_values = ", ".join(frappe.bold(o) for o in options)
+ invalid_values = ", ".join(frappe.bold(i) for i in invalid)
self.warnings.append(
{
"col": self.column_number,
@@ -1098,18 +1061,14 @@ def build_fields_dict_for_column_matching(parent_doctype):
out = {}
# doctypes and fieldname if it is a child doctype
- doctypes = [[parent_doctype, None]] + [
- [df.options, df] for df in parent_meta.get_table_fields()
+ doctypes = [(parent_doctype, None)] + [
+ (df.options, df) for df in parent_meta.get_table_fields()
]
for doctype, table_df in doctypes:
+ translated_table_label = _(table_df.label) if table_df else None
+
# name field
- name_by_label = (
- "ID" if doctype == parent_doctype else "ID ({0})".format(table_df.label)
- )
- name_by_fieldname = (
- "name" if doctype == parent_doctype else "{0}.name".format(table_df.fieldname)
- )
name_df = frappe._dict(
{
"fieldtype": "Data",
@@ -1120,63 +1079,90 @@ def build_fields_dict_for_column_matching(parent_doctype):
}
)
- if doctype != parent_doctype:
+ if doctype == parent_doctype:
+ name_headers = (
+ "name", # fieldname
+ "ID", # label
+ _("ID"), # translated label
+ )
+ else:
+ name_headers = (
+ "{0}.name".format(table_df.fieldname), # fieldname
+ "ID ({0})".format(table_df.label), # label
+ "{0} ({1})".format(_("ID"), translated_table_label), # translated label
+ )
+
name_df.is_child_table_field = True
name_df.child_table_df = table_df
- out[name_by_label] = name_df
- out[name_by_fieldname] = name_df
+ for header in name_headers:
+ out[header] = name_df
- # other fields
fields = get_standard_fields(doctype) + frappe.get_meta(doctype).fields
for df in fields:
- label = (df.label or "").strip()
fieldtype = df.fieldtype or "Data"
+ if fieldtype in no_value_fields:
+ continue
+
+ label = (df.label or "").strip()
+ translated_label = _(label)
parent = df.parent or parent_doctype
- if fieldtype not in no_value_fields:
- if parent_doctype == doctype:
- # for parent doctypes keys will be
- # Label
- # label
- # Label (label)
- if not out.get(label):
- # if Label is already set, don't set it again
- # in case of duplicate column headers
- out[label] = df
- out[df.fieldname] = df
- label_with_fieldname = "{0} ({1})".format(label, df.fieldname)
- out[label_with_fieldname] = df
+
+ if parent_doctype == doctype:
+ # for parent doctypes keys will be
+ # Label, fieldname, Label (fieldname)
+
+ for header in (label, translated_label):
+ # if Label is already set, don't set it again
+ # in case of duplicate column headers
+ if header not in out:
+ out[header] = df
+
+ for header in (
+ df.fieldname,
+ f"{label} ({df.fieldname})",
+ f"{translated_label} ({df.fieldname})"
+ ):
+ out[header] = df
+
+ else:
+ # for child doctypes keys will be
+ # Label (Table Field Label)
+ # table_field.fieldname
+
+ # create a new df object to avoid mutation problems
+ if isinstance(df, dict):
+ new_df = frappe._dict(df.copy())
else:
- # in case there are multiple table fields with the same doctype
- # for child doctypes keys will be
- # Label (Table Field Label)
- # table_field.fieldname
- table_fields = parent_meta.get(
- "fields", {"fieldtype": ["in", table_fieldtypes], "options": parent}
- )
- for table_field in table_fields:
- by_label = "{0} ({1})".format(label, table_field.label)
- by_fieldname = "{0}.{1}".format(table_field.fieldname, df.fieldname)
+ new_df = df.as_dict()
- # create a new df object to avoid mutation problems
- if isinstance(df, dict):
- new_df = frappe._dict(df.copy())
- else:
- new_df = df.as_dict()
+ new_df.is_child_table_field = True
+ new_df.child_table_df = table_df
- new_df.is_child_table_field = True
- new_df.child_table_df = table_field
- out[by_label] = new_df
- out[by_fieldname] = new_df
+ for header in (
+ # fieldname
+ "{0}.{1}".format(table_df.fieldname, df.fieldname),
+ # label
+ "{0} ({1})".format(label, table_df.label),
+ # translated label
+ "{0} ({1})".format(translated_label, translated_table_label),
+ ):
+ out[header] = new_df
# if autoname is based on field
# add an entry for "ID (Autoname Field)"
autoname_field = get_autoname_field(parent_doctype)
if autoname_field:
- out["ID ({})".format(autoname_field.label)] = autoname_field
- # ID field should also map to the autoname field
- out["ID"] = autoname_field
- out["name"] = autoname_field
+ for header in (
+ "ID ({})".format(autoname_field.label), # label
+ "{0} ({1})".format(_("ID"), _(autoname_field.label)), # translated label
+
+ # ID field should also map to the autoname field
+ "ID",
+ _("ID"),
+ "name",
+ ):
+ out[header] = autoname_field
return out
@@ -1238,3 +1224,17 @@ def df_as_json(df):
def get_select_options(df):
return [d for d in (df.options or "").split("\n") if d]
+
+def create_import_log(data_import, log_index, log_details):
+ frappe.get_doc({
+ 'doctype': 'Data Import Log',
+ 'log_index': log_index,
+ 'success': log_details.get('success'),
+ 'data_import': data_import,
+ 'row_indexes': json.dumps(log_details.get('row_indexes')),
+ 'docname': log_details.get('docname'),
+ 'messages': json.dumps(log_details.get('messages', '[]')),
+ 'exception': log_details.get('exception')
+ }).db_insert()
+
+
diff --git a/frappe/core/doctype/data_import/test_data_import.py b/frappe/core/doctype/data_import/test_data_import.py
index 15fd57744a..c0e4f50d6d 100644
--- a/frappe/core/doctype/data_import/test_data_import.py
+++ b/frappe/core/doctype/data_import/test_data_import.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/core/doctype/data_import/test_exporter.py b/frappe/core/doctype/data_import/test_exporter.py
index 8415af2e63..cb9461451f 100644
--- a/frappe/core/doctype/data_import/test_exporter.py
+++ b/frappe/core/doctype/data_import/test_exporter.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import unittest
import frappe
from frappe.core.doctype.data_import.exporter import Exporter
diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py
index b083b9eaaa..11077ca58b 100644
--- a/frappe/core/doctype/data_import/test_importer.py
+++ b/frappe/core/doctype/data_import/test_importer.py
@@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import unittest
import frappe
from frappe.core.doctype.data_import.importer import Importer
+from frappe.tests.test_query_builder import db_type_is, run_only_if
from frappe.utils import getdate, format_duration
doctype_name = 'DocType for Import'
@@ -13,7 +12,7 @@ doctype_name = 'DocType for Import'
class TestImporter(unittest.TestCase):
@classmethod
def setUpClass(cls):
- create_doctype_if_not_exists(doctype_name)
+ create_doctype_if_not_exists(doctype_name,)
def test_data_import_from_file(self):
import_file = get_import_file('sample_import_file')
@@ -56,21 +55,27 @@ class TestImporter(unittest.TestCase):
self.assertEqual(len(preview.data), 4)
self.assertEqual(len(preview.columns), 16)
+ # ignored on postgres because myisam doesn't exist on pg
+ @run_only_if(db_type_is.MARIADB)
def test_data_import_without_mandatory_values(self):
import_file = get_import_file('sample_import_file_without_mandatory')
data_import = self.get_importer(doctype_name, import_file)
+ frappe.local.message_log = []
data_import.start_import()
data_import.reload()
- warnings = frappe.parse_json(data_import.template_warnings)
- self.assertEqual(warnings[0]['row'], 2)
- self.assertEqual(warnings[0]['message'], "Child Title (Table Field 1) is a mandatory field")
+ import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "messages", "exception", "docname"],
+ filters={"data_import": data_import.name},
+ order_by="log_index")
- self.assertEqual(warnings[1]['row'], 3)
- self.assertEqual(warnings[1]['message'], "Child Title (Table Field 1 Again) is a mandatory field")
+ self.assertEqual(frappe.parse_json(import_log[0]['row_indexes']), [2,3])
+ expected_error = "Error: Child 1 of DocType for Import Row #1: Value missing for: Child Title"
+ self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[0]['messages'])[0])['message'], expected_error)
+ expected_error = "Error: Child 1 of DocType for Import Row #2: Value missing for: Child Title"
+ self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[0]['messages'])[1])['message'], expected_error)
- self.assertEqual(warnings[2]['row'], 4)
- self.assertEqual(warnings[2]['message'], "Title is a mandatory field")
+ self.assertEqual(frappe.parse_json(import_log[1]['row_indexes']), [4])
+ self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[1]['messages'])[0])['message'], "Title is required")
def test_data_import_update(self):
existing_doc = frappe.get_doc(
@@ -104,6 +109,8 @@ class TestImporter(unittest.TestCase):
data_import.reference_doctype = doctype
data_import.import_file = import_file.file_url
data_import.insert()
+ # Commit so that the first import failure does not rollback the Data Import insert.
+ frappe.db.commit()
return data_import
diff --git a/frappe/core/doctype/data_import_legacy/data_import_legacy.js b/frappe/core/doctype/data_import_legacy/data_import_legacy.js
deleted file mode 100644
index 8e4f397171..0000000000
--- a/frappe/core/doctype/data_import_legacy/data_import_legacy.js
+++ /dev/null
@@ -1,324 +0,0 @@
-// Copyright (c) 2017, Frappe Technologies and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on('Data Import Legacy', {
- onload: function(frm) {
- if (frm.doc.__islocal) {
- frm.set_value("action", "");
- }
-
- frappe.call({
- method: "frappe.core.doctype.data_import_legacy.data_import_legacy.get_importable_doctypes",
- callback: function (r) {
- let importable_doctypes = r.message;
- frm.set_query("reference_doctype", function () {
- return {
- "filters": {
- "issingle": 0,
- "istable": 0,
- "name": ['in', importable_doctypes]
- }
- };
- });
- }
- }),
-
- // should never check public
- frm.fields_dict["import_file"].df.is_private = 1;
-
- frappe.realtime.on("data_import_progress", function(data) {
- if (data.data_import === frm.doc.name) {
- if (data.reload && data.reload === true) {
- frm.reload_doc();
- }
- if (data.progress) {
- let progress_bar = $(frm.dashboard.progress_area.body).find(".progress-bar");
- if (progress_bar) {
- $(progress_bar).removeClass("progress-bar-danger").addClass("progress-bar-success progress-bar-striped");
- $(progress_bar).css("width", data.progress + "%");
- }
- }
- }
- });
- },
-
- reference_doctype: function(frm){
- if (frm.doc.reference_doctype) {
- frappe.model.with_doctype(frm.doc.reference_doctype);
- }
- },
-
- refresh: function(frm) {
- frm.disable_save();
- frm.dashboard.clear_headline();
- if (frm.doc.reference_doctype && !frm.doc.import_file) {
- frm.page.set_indicator(__('Attach file'), 'orange');
- } else {
- if (frm.doc.import_status) {
- const listview_settings = frappe.listview_settings['Data Import Legacy'];
- const indicator = listview_settings.get_indicator(frm.doc);
-
- frm.page.set_indicator(indicator[0], indicator[1]);
-
- if (frm.doc.import_status === "In Progress") {
- frm.dashboard.add_progress("Data Import Progress", "0");
- frm.set_read_only();
- frm.refresh_fields();
- }
- }
- }
-
- if (frm.doc.reference_doctype) {
- frappe.model.with_doctype(frm.doc.reference_doctype);
- }
-
- if(frm.doc.action == "Insert new records" || frm.doc.action == "Update records") {
- frm.set_df_property("action", "read_only", 1);
- }
-
- frm.add_custom_button(__("Help"), function() {
- frappe.help.show_video("6wiriRKPhmg");
- });
-
- if (frm.doc.reference_doctype && frm.doc.docstatus === 0) {
- frm.add_custom_button(__("Download template"), function() {
- frappe.data_import.download_dialog(frm).show();
- });
- }
-
- if (frm.doc.reference_doctype && frm.doc.import_file && frm.doc.total_rows &&
- frm.doc.docstatus === 0 && (!frm.doc.import_status || frm.doc.import_status == "Failed")) {
- frm.page.set_primary_action(__("Start Import"), function() {
- frappe.call({
- btn: frm.page.btn_primary,
- method: "frappe.core.doctype.data_import_legacy.data_import_legacy.import_data",
- args: {
- data_import: frm.doc.name
- }
- });
- }).addClass('btn btn-primary');
- }
-
- if (frm.doc.log_details) {
- frm.events.create_log_table(frm);
- } else {
- $(frm.fields_dict.import_log.wrapper).empty();
- }
- },
-
- action: function(frm) {
- if(!frm.doc.action) return;
- if(!frm.doc.reference_doctype) {
- frappe.msgprint(__("Please select document type first."));
- frm.set_value("action", "");
- return;
- }
-
- if(frm.doc.action == "Insert new records") {
- frm.doc.insert_new = 1;
- } else if (frm.doc.action == "Update records"){
- frm.doc.overwrite = 1;
- }
- frm.save();
- },
-
- only_update: function(frm) {
- frm.save();
- },
-
- submit_after_import: function(frm) {
- frm.save();
- },
-
- skip_errors: function(frm) {
- frm.save();
- },
-
- ignore_encoding_errors: function(frm) {
- frm.save();
- },
-
- no_email: function(frm) {
- frm.save();
- },
-
- show_only_errors: function(frm) {
- frm.events.create_log_table(frm);
- },
-
- create_log_table: function(frm) {
- let msg = JSON.parse(frm.doc.log_details);
- var $log_wrapper = $(frm.fields_dict.import_log.wrapper).empty();
- $(frappe.render_template("log_details", {
- data: msg.messages,
- import_status: frm.doc.import_status,
- show_only_errors: frm.doc.show_only_errors,
- })).appendTo($log_wrapper);
- }
-});
-
-frappe.provide('frappe.data_import');
-frappe.data_import.download_dialog = function(frm) {
- var dialog;
- const filter_fields = df => frappe.model.is_value_type(df) && !df.hidden;
- const get_fields = dt => frappe.meta.get_docfields(dt).filter(filter_fields);
-
- const get_doctype_checkbox_fields = () => {
- return dialog.fields.filter(df => df.fieldname.endsWith('_fields'))
- .map(df => dialog.fields_dict[df.fieldname]);
- };
-
- const doctype_fields = get_fields(frm.doc.reference_doctype)
- .map(df => {
- let reqd = (df.reqd || df.fieldname == 'naming_series') ? 1 : 0;
- return {
- label: df.label,
- reqd: reqd,
- danger: reqd,
- value: df.fieldname,
- checked: 1
- };
- });
-
- let fields = [
- {
- "label": __("Select Columns"),
- "fieldname": "select_columns",
- "fieldtype": "Select",
- "options": "All\nMandatory\nManually",
- "reqd": 1,
- "onchange": function() {
- const fields = get_doctype_checkbox_fields();
- fields.map(f => f.toggle(true));
- if(this.value == 'Mandatory' || this.value == 'Manually') {
- checkbox_toggle(true);
- fields.map(multicheck_field => {
- multicheck_field.options.map(option => {
- if(!option.reqd) return;
- $(multicheck_field.$wrapper).find(`:checkbox[data-unit="${option.value}"]`)
- .prop('checked', false)
- .trigger('click');
- });
- });
- } else if(this.value == 'All'){
- $(dialog.body).find(`[data-fieldtype="MultiCheck"] :checkbox`)
- .prop('disabled', true);
- }
- }
- },
- {
- "label": __("File Type"),
- "fieldname": "file_type",
- "fieldtype": "Select",
- "options": "Excel\nCSV",
- "default": "Excel"
- },
- {
- "label": __("Download with Data"),
- "fieldname": "with_data",
- "fieldtype": "Check",
- "hidden": !frm.doc.overwrite,
- "default": 1
- },
- {
- "label": __("Select All"),
- "fieldname": "select_all",
- "fieldtype": "Button",
- "depends_on": "eval:doc.select_columns=='Manually'",
- click: function() {
- checkbox_toggle();
- }
- },
- {
- "label": __("Unselect All"),
- "fieldname": "unselect_all",
- "fieldtype": "Button",
- "depends_on": "eval:doc.select_columns=='Manually'",
- click: function() {
- checkbox_toggle(true);
- }
- },
- {
- "label": frm.doc.reference_doctype,
- "fieldname": "doctype_fields",
- "fieldtype": "MultiCheck",
- "options": doctype_fields,
- "columns": 2,
- "hidden": 1
- }
- ];
-
- const child_table_fields = frappe.meta.get_table_fields(frm.doc.reference_doctype)
- .map(df => {
- return {
- "label": df.options,
- "fieldname": df.fieldname + '_fields',
- "fieldtype": "MultiCheck",
- "options": frappe.meta.get_docfields(df.options)
- .filter(filter_fields)
- .map(df => ({
- label: df.label,
- reqd: df.reqd ? 1 : 0,
- value: df.fieldname,
- checked: 1,
- danger: df.reqd
- })),
- "columns": 2,
- "hidden": 1
- };
- });
-
- fields = fields.concat(child_table_fields);
-
- dialog = new frappe.ui.Dialog({
- title: __('Download Template'),
- fields: fields,
- primary_action: function(values) {
- var data = values;
- if (frm.doc.reference_doctype) {
- var export_params = () => {
- let columns = {};
- if(values.select_columns) {
- columns = get_doctype_checkbox_fields().reduce((columns, field) => {
- const options = field.get_checked_options();
- columns[field.df.label] = options;
- return columns;
- }, {});
- }
-
- return {
- doctype: frm.doc.reference_doctype,
- parent_doctype: frm.doc.reference_doctype,
- select_columns: JSON.stringify(columns),
- with_data: frm.doc.overwrite && data.with_data,
- all_doctypes: true,
- file_type: data.file_type,
- template: true
- };
- };
- let get_template_url = '/api/method/frappe.core.doctype.data_export.exporter.export_data';
- open_url_post(get_template_url, export_params());
- } else {
- frappe.msgprint(__("Please select the Document Type."));
- }
- dialog.hide();
- },
- primary_action_label: __('Download')
- });
-
- $(dialog.body).find('div[data-fieldname="select_all"], div[data-fieldname="unselect_all"]')
- .wrapAll('
');
- const button_container = $(dialog.body).find('.inline-buttons');
- button_container.addClass('flex');
- $(button_container).find('.frappe-control').map((index, button) => {
- $(button).css({"margin-right": "1em"});
- });
-
- function checkbox_toggle(checked=false) {
- $(dialog.body).find('[data-fieldtype="MultiCheck"]').map((index, element) => {
- $(element).find(`:checkbox`).prop("checked", checked).trigger('click');
- });
- }
-
- return dialog;
-};
diff --git a/frappe/core/doctype/data_import_legacy/data_import_legacy.json b/frappe/core/doctype/data_import_legacy/data_import_legacy.json
deleted file mode 100644
index 852ccba156..0000000000
--- a/frappe/core/doctype/data_import_legacy/data_import_legacy.json
+++ /dev/null
@@ -1,218 +0,0 @@
-{
- "actions": [],
- "allow_copy": 1,
- "creation": "2020-06-11 16:13:23.813709",
- "doctype": "DocType",
- "document_type": "Document",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "reference_doctype",
- "action",
- "insert_new",
- "overwrite",
- "only_update",
- "section_break_4",
- "import_file",
- "column_break_4",
- "error_file",
- "section_break_6",
- "skip_errors",
- "submit_after_import",
- "ignore_encoding_errors",
- "no_email",
- "import_detail",
- "import_status",
- "show_only_errors",
- "import_log",
- "log_details",
- "amended_from",
- "total_rows",
- "amended_from"
- ],
- "fields": [
- {
- "fieldname": "reference_doctype",
- "fieldtype": "Link",
- "ignore_user_permissions": 1,
- "in_list_view": 1,
- "label": "Document Type",
- "options": "DocType",
- "reqd": 1
- },
- {
- "fieldname": "action",
- "fieldtype": "Select",
- "label": "Action",
- "options": "Insert new records\nUpdate records",
- "reqd": 1
- },
- {
- "default": "0",
- "depends_on": "eval:!doc.overwrite",
- "description": "New data will be inserted.",
- "fieldname": "insert_new",
- "fieldtype": "Check",
- "hidden": 1,
- "label": "Insert new records",
- "set_only_once": 1
- },
- {
- "default": "0",
- "depends_on": "eval:!doc.insert_new",
- "description": "If you are updating/overwriting already created records.",
- "fieldname": "overwrite",
- "fieldtype": "Check",
- "hidden": 1,
- "label": "Update records",
- "set_only_once": 1
- },
- {
- "default": "0",
- "depends_on": "overwrite",
- "description": "If you don't want to create any new records while updating the older records.",
- "fieldname": "only_update",
- "fieldtype": "Check",
- "label": "Don't create new records"
- },
- {
- "depends_on": "eval:(!doc.__islocal)",
- "fieldname": "section_break_4",
- "fieldtype": "Section Break"
- },
- {
- "fieldname": "import_file",
- "fieldtype": "Attach",
- "label": "Attach file for Import"
- },
- {
- "fieldname": "column_break_4",
- "fieldtype": "Column Break"
- },
- {
- "depends_on": "eval: doc.import_status == \"Partially Successful\"",
- "description": "This is the template file generated with only the rows having some error. You should use this file for correction and import.",
- "fieldname": "error_file",
- "fieldtype": "Attach",
- "label": "Generated File"
- },
- {
- "depends_on": "eval:(!doc.__islocal)",
- "fieldname": "section_break_6",
- "fieldtype": "Section Break"
- },
- {
- "default": "0",
- "description": "If this is checked, rows with valid data will be imported and invalid rows will be dumped into a new file for you to import later.",
- "fieldname": "skip_errors",
- "fieldtype": "Check",
- "label": "Skip rows with errors"
- },
- {
- "default": "0",
- "fieldname": "submit_after_import",
- "fieldtype": "Check",
- "label": "Submit after importing"
- },
- {
- "default": "0",
- "fieldname": "ignore_encoding_errors",
- "fieldtype": "Check",
- "label": "Ignore encoding errors"
- },
- {
- "default": "1",
- "fieldname": "no_email",
- "fieldtype": "Check",
- "label": "Do not send Emails"
- },
- {
- "collapsible": 1,
- "collapsible_depends_on": "eval: doc.import_status == \"Failed\"",
- "depends_on": "import_status",
- "fieldname": "import_detail",
- "fieldtype": "Section Break",
- "label": "Import Log"
- },
- {
- "fieldname": "import_status",
- "fieldtype": "Select",
- "label": "Import Status",
- "options": "\nSuccessful\nFailed\nIn Progress\nPartially Successful",
- "read_only": 1
- },
- {
- "allow_on_submit": 1,
- "default": "1",
- "fieldname": "show_only_errors",
- "fieldtype": "Check",
- "label": "Show only errors",
- "no_copy": 1,
- "print_hide": 1
- },
- {
- "allow_on_submit": 1,
- "depends_on": "import_status",
- "fieldname": "import_log",
- "fieldtype": "HTML",
- "label": "Import Log"
- },
- {
- "allow_on_submit": 1,
- "fieldname": "log_details",
- "fieldtype": "Code",
- "hidden": 1,
- "label": "Log Details",
- "read_only": 1
- },
- {
- "fieldname": "amended_from",
- "fieldtype": "Link",
- "label": "Amended From",
- "no_copy": 1,
- "options": "Data Import",
- "print_hide": 1,
- "read_only": 1
- },
- {
- "fieldname": "total_rows",
- "fieldtype": "Int",
- "hidden": 1,
- "label": "Total Rows",
- "read_only": 1
- },
- {
- "fieldname": "amended_from",
- "fieldtype": "Link",
- "label": "Amended From",
- "no_copy": 1,
- "options": "Data Import Legacy",
- "print_hide": 1,
- "read_only": 1
- }
- ],
- "is_submittable": 1,
- "links": [],
- "max_attachments": 1,
- "modified": "2020-06-11 16:13:23.813709",
- "modified_by": "Administrator",
- "module": "Core",
- "name": "Data Import Legacy",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "read": 1,
- "role": "System Manager",
- "share": 1,
- "submit": 1,
- "write": 1
- }
- ],
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1,
- "track_seen": 1
-}
\ No newline at end of file
diff --git a/frappe/core/doctype/data_import_legacy/data_import_legacy.py b/frappe/core/doctype/data_import_legacy/data_import_legacy.py
deleted file mode 100644
index df3a3edd3a..0000000000
--- a/frappe/core/doctype/data_import_legacy/data_import_legacy.py
+++ /dev/null
@@ -1,123 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-from __future__ import unicode_literals
-import frappe, os
-from frappe import _
-import frappe.modules.import_file
-from frappe.model.document import Document
-from frappe.utils.data import format_datetime
-from frappe.core.doctype.data_import_legacy.importer import upload
-from frappe.utils.background_jobs import enqueue
-
-
-class DataImportLegacy(Document):
- def autoname(self):
- if not self.name:
- self.name = "Import on " +format_datetime(self.creation)
-
- def validate(self):
- if not self.import_file:
- self.db_set("total_rows", 0)
- if self.import_status == "In Progress":
- frappe.throw(_("Can't save the form as data import is in progress."))
-
- # validate the template just after the upload
- # if there is total_rows in the doc, it means that the template is already validated and error free
- if self.import_file and not self.total_rows:
- upload(data_import_doc=self, from_data_import="Yes", validate_template=True)
-
-
-@frappe.whitelist()
-def get_importable_doctypes():
- return frappe.cache().hget("can_import", frappe.session.user)
-
-@frappe.whitelist()
-def import_data(data_import):
- frappe.db.set_value("Data Import Legacy", data_import, "import_status", "In Progress", update_modified=False)
- frappe.publish_realtime("data_import_progress", {"progress": "0",
- "data_import": data_import, "reload": True}, user=frappe.session.user)
-
- from frappe.core.page.background_jobs.background_jobs import get_info
- enqueued_jobs = [d.get("job_name") for d in get_info()]
-
- if data_import not in enqueued_jobs:
- enqueue(upload, queue='default', timeout=6000, event='data_import', job_name=data_import,
- data_import_doc=data_import, from_data_import="Yes", user=frappe.session.user)
-
-
-def import_doc(path, overwrite=False, ignore_links=False, ignore_insert=False,
- insert=False, submit=False, pre_process=None):
- if os.path.isdir(path):
- files = [os.path.join(path, f) for f in os.listdir(path)]
- else:
- files = [path]
-
- for f in files:
- if f.endswith(".json"):
- frappe.flags.mute_emails = True
- frappe.modules.import_file.import_file_by_path(f, data_import=True, force=True, pre_process=pre_process, reset_permissions=True)
- frappe.flags.mute_emails = False
- frappe.db.commit()
- elif f.endswith(".csv"):
- import_file_by_path(f, ignore_links=ignore_links, overwrite=overwrite, submit=submit, pre_process=pre_process)
- frappe.db.commit()
-
-
-def import_file_by_path(path, ignore_links=False, overwrite=False, submit=False, pre_process=None, no_email=True):
- from frappe.utils.csvutils import read_csv_content
- print("Importing " + path)
- with open(path, "r") as infile:
- upload(rows = read_csv_content(infile.read()), ignore_links=ignore_links, no_email=no_email, overwrite=overwrite,
- submit_after_import=submit, pre_process=pre_process)
-
-
-def export_json(doctype, path, filters=None, or_filters=None, name=None, order_by="creation asc"):
- def post_process(out):
- del_keys = ('modified_by', 'creation', 'owner', 'idx')
- for doc in out:
- for key in del_keys:
- if key in doc:
- del doc[key]
- for k, v in doc.items():
- if isinstance(v, list):
- for child in v:
- for key in del_keys + ('docstatus', 'doctype', 'modified', 'name'):
- if key in child:
- del child[key]
-
- out = []
- if name:
- out.append(frappe.get_doc(doctype, name).as_dict())
- elif frappe.db.get_value("DocType", doctype, "issingle"):
- out.append(frappe.get_doc(doctype).as_dict())
- else:
- for doc in frappe.get_all(doctype, fields=["name"], filters=filters, or_filters=or_filters, limit_page_length=0, order_by=order_by):
- out.append(frappe.get_doc(doctype, doc.name).as_dict())
- post_process(out)
-
- dirname = os.path.dirname(path)
- if not os.path.exists(dirname):
- path = os.path.join('..', path)
-
- with open(path, "w") as outfile:
- outfile.write(frappe.as_json(out))
-
-
-def export_csv(doctype, path):
- from frappe.core.doctype.data_export.exporter import export_data
- with open(path, "wb") as csvfile:
- export_data(doctype=doctype, all_doctypes=True, template=True, with_data=True)
- csvfile.write(frappe.response.result.encode("utf-8"))
-
-
-@frappe.whitelist()
-def export_fixture(doctype, app):
- if frappe.session.user != "Administrator":
- raise frappe.PermissionError
-
- if not os.path.exists(frappe.get_app_path(app, "fixtures")):
- os.mkdir(frappe.get_app_path(app, "fixtures"))
-
- export_json(doctype, frappe.get_app_path(app, "fixtures", frappe.scrub(doctype) + ".json"), order_by="name asc")
diff --git a/frappe/core/doctype/data_import_legacy/data_import_legacy_list.js b/frappe/core/doctype/data_import_legacy/data_import_legacy_list.js
deleted file mode 100644
index fcf2391313..0000000000
--- a/frappe/core/doctype/data_import_legacy/data_import_legacy_list.js
+++ /dev/null
@@ -1,24 +0,0 @@
-frappe.listview_settings['Data Import Legacy'] = {
- add_fields: ["import_status"],
- has_indicator_for_draft: 1,
- get_indicator: function(doc) {
-
- let status = {
- 'Successful': [__("Success"), "green", "import_status,=,Successful"],
- 'Partially Successful': [__("Partial Success"), "blue", "import_status,=,Partially Successful"],
- 'In Progress': [__("In Progress"), "orange", "import_status,=,In Progress"],
- 'Failed': [__("Failed"), "red", "import_status,=,Failed"],
- 'Pending': [__("Pending"), "orange", "import_status,=,"]
- }
-
- if (doc.import_status) {
- return status[doc.import_status];
- }
-
- if (doc.docstatus == 0) {
- return status['Pending'];
- }
-
- return status['Pending'];
- }
-};
diff --git a/frappe/core/doctype/data_import_legacy/importer.py b/frappe/core/doctype/data_import_legacy/importer.py
deleted file mode 100644
index 35569c7186..0000000000
--- a/frappe/core/doctype/data_import_legacy/importer.py
+++ /dev/null
@@ -1,542 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals, print_function
-
-from six.moves import range
-import requests
-import frappe, json
-import frappe.permissions
-
-from frappe import _
-
-from frappe.utils.csvutils import getlink
-from frappe.utils.dateutils import parse_date
-
-from frappe.utils import cint, cstr, flt, getdate, get_datetime, get_url, get_absolute_url, duration_to_seconds
-from six import string_types
-
-
-@frappe.whitelist()
-def get_data_keys():
- return frappe._dict({
- "data_separator": _('Start entering data below this line'),
- "main_table": _("Table") + ":",
- "parent_table": _("Parent Table") + ":",
- "columns": _("Column Name") + ":",
- "doctype": _("DocType") + ":"
- })
-
-
-
-@frappe.whitelist()
-def upload(rows = None, submit_after_import=None, ignore_encoding_errors=False, no_email=True, overwrite=None,
- update_only = None, ignore_links=False, pre_process=None, via_console=False, from_data_import="No",
- skip_errors = True, data_import_doc=None, validate_template=False, user=None):
- """upload data"""
-
- # for translations
- if user:
- frappe.cache().hdel("lang", user)
- frappe.set_user_lang(user)
-
- if data_import_doc and isinstance(data_import_doc, string_types):
- data_import_doc = frappe.get_doc("Data Import Legacy", data_import_doc)
- if data_import_doc and from_data_import == "Yes":
- no_email = data_import_doc.no_email
- ignore_encoding_errors = data_import_doc.ignore_encoding_errors
- update_only = data_import_doc.only_update
- submit_after_import = data_import_doc.submit_after_import
- overwrite = data_import_doc.overwrite
- skip_errors = data_import_doc.skip_errors
- else:
- # extra input params
- params = json.loads(frappe.form_dict.get("params") or '{}')
- if params.get("submit_after_import"):
- submit_after_import = True
- if params.get("ignore_encoding_errors"):
- ignore_encoding_errors = True
- if not params.get("no_email"):
- no_email = False
- if params.get('update_only'):
- update_only = True
- if params.get('from_data_import'):
- from_data_import = params.get('from_data_import')
- if not params.get('skip_errors'):
- skip_errors = params.get('skip_errors')
-
- frappe.flags.in_import = True
- frappe.flags.mute_emails = no_email
-
- def get_data_keys_definition():
- return get_data_keys()
-
- def bad_template():
- frappe.throw(_("Please do not change the rows above {0}").format(get_data_keys_definition().data_separator))
-
- def check_data_length():
- if not data:
- frappe.throw(_("No data found in the file. Please reattach the new file with data."))
-
- def get_start_row():
- for i, row in enumerate(rows):
- if row and row[0]==get_data_keys_definition().data_separator:
- return i+1
- bad_template()
-
- def get_header_row(key):
- return get_header_row_and_idx(key)[0]
-
- def get_header_row_and_idx(key):
- for i, row in enumerate(header):
- if row and row[0]==key:
- return row, i
- return [], -1
-
- def filter_empty_columns(columns):
- empty_cols = list(filter(lambda x: x in ("", None), columns))
-
- if empty_cols:
- if columns[-1*len(empty_cols):] == empty_cols:
- # filter empty columns if they exist at the end
- columns = columns[:-1*len(empty_cols)]
- else:
- frappe.msgprint(_("Please make sure that there are no empty columns in the file."),
- raise_exception=1)
-
- return columns
-
- def make_column_map():
- doctype_row, row_idx = get_header_row_and_idx(get_data_keys_definition().doctype)
- if row_idx == -1: # old style
- return
-
- dt = None
- for i, d in enumerate(doctype_row[1:]):
- if d not in ("~", "-"):
- if d and doctype_row[i] in (None, '' ,'~', '-', _("DocType") + ":"):
- dt, parentfield = d, None
- # xls format truncates the row, so it may not have more columns
- if len(doctype_row) > i+2:
- parentfield = doctype_row[i+2]
- doctypes.append((dt, parentfield))
- column_idx_to_fieldname[(dt, parentfield)] = {}
- column_idx_to_fieldtype[(dt, parentfield)] = {}
- if dt:
- column_idx_to_fieldname[(dt, parentfield)][i+1] = rows[row_idx + 2][i+1]
- column_idx_to_fieldtype[(dt, parentfield)][i+1] = rows[row_idx + 4][i+1]
-
- def get_doc(start_idx):
- if doctypes:
- doc = {}
- attachments = []
- last_error_row_idx = None
- for idx in range(start_idx, len(rows)):
- last_error_row_idx = idx # pylint: disable=W0612
- if (not doc) or main_doc_empty(rows[idx]):
- for dt, parentfield in doctypes:
- d = {}
- for column_idx in column_idx_to_fieldname[(dt, parentfield)]:
- try:
- fieldname = column_idx_to_fieldname[(dt, parentfield)][column_idx]
- fieldtype = column_idx_to_fieldtype[(dt, parentfield)][column_idx]
-
- if not fieldname or not rows[idx][column_idx]:
- continue
-
- d[fieldname] = rows[idx][column_idx]
- if fieldtype in ("Int", "Check"):
- d[fieldname] = cint(d[fieldname])
- elif fieldtype in ("Float", "Currency", "Percent"):
- d[fieldname] = flt(d[fieldname])
- elif fieldtype == "Date":
- if d[fieldname] and isinstance(d[fieldname], string_types):
- d[fieldname] = getdate(parse_date(d[fieldname]))
- elif fieldtype == "Datetime":
- if d[fieldname]:
- if " " in d[fieldname]:
- _date, _time = d[fieldname].split()
- else:
- _date, _time = d[fieldname], '00:00:00'
- _date = parse_date(d[fieldname])
- d[fieldname] = get_datetime(_date + " " + _time)
- else:
- d[fieldname] = None
- elif fieldtype == "Duration":
- d[fieldname] = duration_to_seconds(cstr(d[fieldname]))
- elif fieldtype in ("Image", "Attach Image", "Attach"):
- # added file to attachments list
- attachments.append(d[fieldname])
-
- elif fieldtype in ("Link", "Dynamic Link", "Data") and d[fieldname]:
- # as fields can be saved in the number format(long type) in data import template
- d[fieldname] = cstr(d[fieldname])
-
- except IndexError:
- pass
-
- # scrub quotes from name and modified
- if d.get("name") and d["name"].startswith('"'):
- d["name"] = d["name"][1:-1]
-
- if sum([0 if not val else 1 for val in d.values()]):
- d['doctype'] = dt
- if dt == doctype:
- doc.update(d)
- else:
- if not overwrite and doc.get("name"):
- d['parent'] = doc["name"]
- d['parenttype'] = doctype
- d['parentfield'] = parentfield
- doc.setdefault(d['parentfield'], []).append(d)
- else:
- break
-
- return doc, attachments, last_error_row_idx
- else:
- doc = frappe._dict(zip(columns, rows[start_idx][1:]))
- doc['doctype'] = doctype
- return doc, [], None
-
- # used in testing whether a row is empty or parent row or child row
- # checked only 3 first columns since first two columns can be blank for example the case of
- # importing the item variant where item code and item name will be blank.
- def main_doc_empty(row):
- if row:
- for i in range(3,0,-1):
- if len(row) > i and row[i]:
- return False
- return True
-
- def validate_naming(doc):
- autoname = frappe.get_meta(doctype).autoname
- if autoname:
- if autoname[0:5] == 'field':
- autoname = autoname[6:]
- elif autoname == 'naming_series:':
- autoname = 'naming_series'
- else:
- return True
-
- if (autoname not in doc) or (not doc[autoname]):
- from frappe.model.base_document import get_controller
- if not hasattr(get_controller(doctype), "autoname"):
- frappe.throw(_("{0} is a mandatory field").format(autoname))
- return True
-
- users = frappe.db.sql_list("select name from tabUser")
- def prepare_for_insert(doc):
- # don't block data import if user is not set
- # migrating from another system
- if not doc.owner in users:
- doc.owner = frappe.session.user
- if not doc.modified_by in users:
- doc.modified_by = frappe.session.user
-
- def is_valid_url(url):
- is_valid = False
- if url.startswith("/files") or url.startswith("/private/files"):
- url = get_url(url)
-
- try:
- r = requests.get(url)
- is_valid = True if r.status_code == 200 else False
- except Exception:
- pass
-
- return is_valid
-
- def attach_file_to_doc(doctype, docname, file_url):
- # check if attachment is already available
- # check if the attachement link is relative or not
- if not file_url:
- return
- if not is_valid_url(file_url):
- return
-
- files = frappe.db.sql("""Select name from `tabFile` where attached_to_doctype='{doctype}' and
- attached_to_name='{docname}' and (file_url='{file_url}' or thumbnail_url='{file_url}')""".format(
- doctype=doctype,
- docname=docname,
- file_url=file_url
- ))
-
- if files:
- # file is already attached
- return
-
- _file = frappe.get_doc({
- "doctype": "File",
- "file_url": file_url,
- "attached_to_name": docname,
- "attached_to_doctype": doctype,
- "attached_to_field": 0,
- "folder": "Home/Attachments"})
- _file.save()
-
-
- # header
- filename, file_extension = ['','']
- if not rows:
- _file = frappe.get_doc("File", {"file_url": data_import_doc.import_file})
- fcontent = _file.get_content()
- filename, file_extension = _file.get_extension()
-
- if file_extension == '.xlsx' and from_data_import == 'Yes':
- from frappe.utils.xlsxutils import read_xlsx_file_from_attached_file
- rows = read_xlsx_file_from_attached_file(file_url=data_import_doc.import_file)
-
- elif file_extension == '.csv':
- from frappe.utils.csvutils import read_csv_content
- rows = read_csv_content(fcontent, ignore_encoding_errors)
-
- else:
- frappe.throw(_("Unsupported File Format"))
-
- start_row = get_start_row()
- header = rows[:start_row]
- data = rows[start_row:]
- try:
- doctype = get_header_row(get_data_keys_definition().main_table)[1]
- columns = filter_empty_columns(get_header_row(get_data_keys_definition().columns)[1:])
- except:
- frappe.throw(_("Cannot change header content"))
- doctypes = []
- column_idx_to_fieldname = {}
- column_idx_to_fieldtype = {}
-
- if skip_errors:
- data_rows_with_error = header
-
- if submit_after_import and not cint(frappe.db.get_value("DocType",
- doctype, "is_submittable")):
- submit_after_import = False
-
- parenttype = get_header_row(get_data_keys_definition().parent_table)
-
- if len(parenttype) > 1:
- parenttype = parenttype[1]
-
- # check permissions
- if not frappe.permissions.can_import(parenttype or doctype):
- frappe.flags.mute_emails = False
- return {"messages": [_("Not allowed to Import") + ": " + _(doctype)], "error": True}
-
- # Throw expception in case of the empty data file
- check_data_length()
- make_column_map()
- total = len(data)
-
- if validate_template:
- if total:
- data_import_doc.total_rows = total
- return True
-
- if overwrite==None:
- overwrite = params.get('overwrite')
-
- # delete child rows (if parenttype)
- parentfield = None
- if parenttype:
- parentfield = get_parent_field(doctype, parenttype)
-
- if overwrite:
- delete_child_rows(data, doctype)
-
- import_log = []
- def log(**kwargs):
- if via_console:
- print((kwargs.get("title") + kwargs.get("message")).encode('utf-8'))
- else:
- import_log.append(kwargs)
-
- def as_link(doctype, name):
- if via_console:
- return "{0}: {1}".format(doctype, name)
- else:
- return getlink(doctype, name)
-
- # publish realtime task update
- def publish_progress(achieved, reload=False):
- if data_import_doc:
- frappe.publish_realtime("data_import_progress", {"progress": str(int(100.0*achieved/total)),
- "data_import": data_import_doc.name, "reload": reload}, user=frappe.session.user)
-
-
- error_flag = rollback_flag = False
-
- batch_size = frappe.conf.data_import_batch_size or 1000
-
- for batch_start in range(0, total, batch_size):
- batch = data[batch_start:batch_start + batch_size]
-
- for i, row in enumerate(batch):
- # bypass empty rows
- if main_doc_empty(row):
- continue
-
- row_idx = i + start_row
- doc = None
-
- publish_progress(i)
-
- try:
- doc, attachments, last_error_row_idx = get_doc(row_idx)
- validate_naming(doc)
- if pre_process:
- pre_process(doc)
-
- original = None
- if parentfield:
- parent = frappe.get_doc(parenttype, doc["parent"])
- doc = parent.append(parentfield, doc)
- parent.save()
- else:
- if overwrite and doc.get("name") and frappe.db.exists(doctype, doc["name"]):
- original = frappe.get_doc(doctype, doc["name"])
- original_name = original.name
- original.update(doc)
- # preserve original name for case sensitivity
- original.name = original_name
- original.flags.ignore_links = ignore_links
- original.save()
- doc = original
- else:
- if not update_only:
- doc = frappe.get_doc(doc)
- prepare_for_insert(doc)
- doc.flags.ignore_links = ignore_links
- doc.insert()
- if attachments:
- # check file url and create a File document
- for file_url in attachments:
- attach_file_to_doc(doc.doctype, doc.name, file_url)
- if submit_after_import:
- doc.submit()
-
- # log errors
- if parentfield:
- log(**{"row": doc.idx, "title": 'Inserted row for "%s"' % (as_link(parenttype, doc.parent)),
- "link": get_absolute_url(parenttype, doc.parent), "message": 'Document successfully saved', "indicator": "green"})
- elif submit_after_import:
- log(**{"row": row_idx + 1, "title":'Submitted row for "%s"' % (as_link(doc.doctype, doc.name)),
- "message": "Document successfully submitted", "link": get_absolute_url(doc.doctype, doc.name), "indicator": "blue"})
- elif original:
- log(**{"row": row_idx + 1,"title":'Updated row for "%s"' % (as_link(doc.doctype, doc.name)),
- "message": "Document successfully updated", "link": get_absolute_url(doc.doctype, doc.name), "indicator": "green"})
- elif not update_only:
- log(**{"row": row_idx + 1, "title":'Inserted row for "%s"' % (as_link(doc.doctype, doc.name)),
- "message": "Document successfully saved", "link": get_absolute_url(doc.doctype, doc.name), "indicator": "green"})
- else:
- log(**{"row": row_idx + 1, "title":'Ignored row for %s' % (row[1]), "link": None,
- "message": "Document updation ignored", "indicator": "orange"})
-
- except Exception as e:
- error_flag = True
-
- # build error message
- if frappe.local.message_log:
- err_msg = "\n".join(['{}
'.format(json.loads(msg).get('message')) for msg in frappe.local.message_log])
- else:
- err_msg = '{}
'.format(cstr(e))
-
- error_trace = frappe.get_traceback()
- if error_trace:
- error_log_doc = frappe.log_error(error_trace)
- error_link = get_absolute_url("Error Log", error_log_doc.name)
- else:
- error_link = None
-
- log(**{
- "row": row_idx + 1,
- "title": 'Error for row %s' % (len(row)>1 and frappe.safe_decode(row[1]) or ""),
- "message": err_msg,
- "indicator": "red",
- "link":error_link
- })
-
- # data with error to create a new file
- # include the errored data in the last row as last_error_row_idx will not be updated for the last row
- if skip_errors:
- if last_error_row_idx == len(rows)-1:
- last_error_row_idx = len(rows)
- data_rows_with_error += rows[row_idx:last_error_row_idx]
- else:
- rollback_flag = True
- finally:
- frappe.local.message_log = []
-
- start_row += batch_size
- if rollback_flag:
- frappe.db.rollback()
- else:
- frappe.db.commit()
-
- frappe.flags.mute_emails = False
- frappe.flags.in_import = False
-
- log_message = {"messages": import_log, "error": error_flag}
- if data_import_doc:
- data_import_doc.log_details = json.dumps(log_message)
-
- import_status = None
- if error_flag and data_import_doc.skip_errors and len(data) != len(data_rows_with_error):
- import_status = "Partially Successful"
- # write the file with the faulty row
- file_name = 'error_' + filename + file_extension
- if file_extension == '.xlsx':
- from frappe.utils.xlsxutils import make_xlsx
- xlsx_file = make_xlsx(data_rows_with_error, "Data Import Template")
- file_data = xlsx_file.getvalue()
- else:
- from frappe.utils.csvutils import to_csv
- file_data = to_csv(data_rows_with_error)
- _file = frappe.get_doc({
- "doctype": "File",
- "file_name": file_name,
- "attached_to_doctype": "Data Import Legacy",
- "attached_to_name": data_import_doc.name,
- "folder": "Home/Attachments",
- "content": file_data})
- _file.save()
- data_import_doc.error_file = _file.file_url
-
- elif error_flag:
- import_status = "Failed"
- else:
- import_status = "Successful"
-
- data_import_doc.import_status = import_status
- data_import_doc.save()
- if data_import_doc.import_status in ["Successful", "Partially Successful"]:
- data_import_doc.submit()
- publish_progress(100, True)
- else:
- publish_progress(0, True)
- frappe.db.commit()
- else:
- return log_message
-
-def get_parent_field(doctype, parenttype):
- parentfield = None
-
- # get parentfield
- if parenttype:
- for d in frappe.get_meta(parenttype).get_table_fields():
- if d.options==doctype:
- parentfield = d.fieldname
- break
-
- if not parentfield:
- frappe.msgprint(_("Did not find {0} for {0} ({1})").format("parentfield", parenttype, doctype))
- raise Exception
-
- return parentfield
-
-def delete_child_rows(rows, doctype):
- """delete child rows for all parents"""
- for p in list(set([r[1] for r in rows])):
- if p:
- frappe.db.sql("""delete from `tab{0}` where parent=%s""".format(doctype), p)
diff --git a/frappe/core/doctype/data_import_legacy/log_details.html b/frappe/core/doctype/data_import_legacy/log_details.html
deleted file mode 100644
index aa160a742b..0000000000
--- a/frappe/core/doctype/data_import_legacy/log_details.html
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
-
- {{ __("Row No") }}
- {{ __("Row Status") }}
- {{ __("Message") }}
-
-
- {% for row in data %}
- {% if (!show_only_errors) || (show_only_errors && row.indicator == "red") %}
-
-
- {{ row.row }}
-
-
- {{ row.title }}
-
-
- {% if (import_status != "Failed" || (row.indicator == "red")) { %}
- {{ row.message }}
- {% if row.link %}
-
-
-
-
-
- {% endif %}
- {% } else { %}
- {{ __("Document can't saved.") }}
- {% } %}
-
-
- {% endif %}
- {% endfor %}
-
-
-
\ No newline at end of file
diff --git a/frappe/core/doctype/data_import_legacy/test_data_import_legacy.py b/frappe/core/doctype/data_import_legacy/test_data_import_legacy.py
deleted file mode 100644
index e5b244e6a0..0000000000
--- a/frappe/core/doctype/data_import_legacy/test_data_import_legacy.py
+++ /dev/null
@@ -1,10 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
-# import frappe
-import unittest
-
-class TestDataImportLegacy(unittest.TestCase):
- pass
diff --git a/frappe/chat/doctype/__init__.py b/frappe/core/doctype/data_import_log/__init__.py
similarity index 100%
rename from frappe/chat/doctype/__init__.py
rename to frappe/core/doctype/data_import_log/__init__.py
diff --git a/frappe/custom/doctype/test_rename_new/test_rename_new.js b/frappe/core/doctype/data_import_log/data_import_log.js
similarity index 79%
rename from frappe/custom/doctype/test_rename_new/test_rename_new.js
rename to frappe/core/doctype/data_import_log/data_import_log.js
index f38f9486f9..c376edeec9 100644
--- a/frappe/custom/doctype/test_rename_new/test_rename_new.js
+++ b/frappe/core/doctype/data_import_log/data_import_log.js
@@ -1,7 +1,7 @@
// Copyright (c) 2021, Frappe Technologies and contributors
// For license information, please see license.txt
-frappe.ui.form.on('Test rename new', {
+frappe.ui.form.on('Data Import Log', {
// refresh: function(frm) {
// }
diff --git a/frappe/core/doctype/data_import_log/data_import_log.json b/frappe/core/doctype/data_import_log/data_import_log.json
new file mode 100644
index 0000000000..b1d991f099
--- /dev/null
+++ b/frappe/core/doctype/data_import_log/data_import_log.json
@@ -0,0 +1,84 @@
+{
+ "actions": [],
+ "creation": "2021-12-25 16:12:20.205889",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "MyISAM",
+ "field_order": [
+ "data_import",
+ "row_indexes",
+ "success",
+ "docname",
+ "messages",
+ "exception",
+ "log_index"
+ ],
+ "fields": [
+ {
+ "fieldname": "data_import",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Data Import",
+ "options": "Data Import"
+ },
+ {
+ "fieldname": "docname",
+ "fieldtype": "Data",
+ "label": "Reference Name"
+ },
+ {
+ "fieldname": "exception",
+ "fieldtype": "Text",
+ "label": "Exception"
+ },
+ {
+ "fieldname": "row_indexes",
+ "fieldtype": "Code",
+ "label": "Row Indexes",
+ "options": "JSON"
+ },
+ {
+ "default": "0",
+ "fieldname": "success",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Success"
+ },
+ {
+ "fieldname": "log_index",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Log Index"
+ },
+ {
+ "fieldname": "messages",
+ "fieldtype": "Code",
+ "label": "Messages",
+ "options": "JSON"
+ }
+ ],
+ "in_create": 1,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-12-29 11:19:19.646076",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "Data Import 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
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC"
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/data_import_log/data_import_log.py b/frappe/core/doctype/data_import_log/data_import_log.py
new file mode 100644
index 0000000000..a71aefa8bc
--- /dev/null
+++ b/frappe/core/doctype/data_import_log/data_import_log.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2021, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+class DataImportLog(Document):
+ pass
diff --git a/frappe/core/doctype/data_import_log/test_data_import_log.py b/frappe/core/doctype/data_import_log/test_data_import_log.py
new file mode 100644
index 0000000000..244404936e
--- /dev/null
+++ b/frappe/core/doctype/data_import_log/test_data_import_log.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# See license.txt
+
+# import frappe
+import unittest
+
+class TestDataImportLog(unittest.TestCase):
+ pass
diff --git a/frappe/core/doctype/defaultvalue/__init__.py b/frappe/core/doctype/defaultvalue/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/core/doctype/defaultvalue/__init__.py
+++ b/frappe/core/doctype/defaultvalue/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/core/doctype/defaultvalue/defaultvalue.py b/frappe/core/doctype/defaultvalue/defaultvalue.py
index d9cc145053..1d597c7fc4 100644
--- a/frappe/core/doctype/defaultvalue/defaultvalue.py
+++ b/frappe/core/doctype/defaultvalue/defaultvalue.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/deleted_document/deleted_document.py b/frappe/core/doctype/deleted_document/deleted_document.py
index 116fc5caf5..b398ec5410 100644
--- a/frappe/core/doctype/deleted_document/deleted_document.py
+++ b/frappe/core/doctype/deleted_document/deleted_document.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
import json
from frappe.desk.doctype.bulk_update.bulk_update import show_progress
diff --git a/frappe/core/doctype/deleted_document/test_deleted_document.py b/frappe/core/doctype/deleted_document/test_deleted_document.py
index c45a2bd180..fb2376de90 100644
--- a/frappe/core/doctype/deleted_document/test_deleted_document.py
+++ b/frappe/core/doctype/deleted_document/test_deleted_document.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/docfield/__init__.py b/frappe/core/doctype/docfield/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/core/doctype/docfield/__init__.py
+++ b/frappe/core/doctype/docfield/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json
index ca134665b8..3267429298 100644
--- a/frappe/core/doctype/docfield/docfield.json
+++ b/frappe/core/doctype/docfield/docfield.json
@@ -17,47 +17,56 @@
"hide_days",
"hide_seconds",
"reqd",
+ "is_virtual",
"search_index",
- "in_list_view",
- "in_standard_filter",
- "in_global_search",
- "in_preview",
- "allow_in_quick_entry",
- "bold",
- "translatable",
- "collapsible",
- "collapsible_depends_on",
- "column_break_6",
+ "column_break_18",
"options",
+ "show_dashboard",
+ "defaults_section",
"default",
+ "column_break_6",
"fetch_from",
"fetch_if_empty",
- "permissions",
- "depends_on",
+ "visibility_section",
"hidden",
+ "bold",
+ "allow_in_quick_entry",
+ "translatable",
+ "print_hide",
+ "print_hide_if_no_value",
+ "report_hide",
+ "column_break_28",
+ "depends_on",
+ "collapsible",
+ "collapsible_depends_on",
+ "hide_border",
+ "list__search_settings_section",
+ "in_list_view",
+ "in_standard_filter",
+ "in_preview",
+ "column_break_35",
+ "in_filter",
+ "in_global_search",
+ "permissions",
"read_only",
- "unique",
- "set_only_once",
+ "allow_on_submit",
+ "ignore_user_permissions",
"allow_bulk_edit",
"column_break_13",
"permlevel",
- "ignore_user_permissions",
- "allow_on_submit",
- "report_hide",
- "remember_last_selected_value",
"ignore_xss_filter",
- "hide_border",
- "property_depends_on_section",
- "mandatory_depends_on",
+ "constraints_section",
+ "unique",
+ "no_copy",
+ "set_only_once",
+ "remember_last_selected_value",
"column_break_38",
+ "mandatory_depends_on",
"read_only_depends_on",
"display",
- "in_filter",
- "no_copy",
- "print_hide",
- "print_hide_if_no_value",
"print_width",
"width",
+ "max_height",
"columns",
"column_break_22",
"description",
@@ -90,7 +99,7 @@
"label": "Type",
"oldfieldname": "fieldtype",
"oldfieldtype": "Select",
- "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature",
+ "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime",
"reqd": 1,
"search_index": 1
},
@@ -153,7 +162,7 @@
"default": "0",
"fieldname": "in_standard_filter",
"fieldtype": "Check",
- "label": "In Standard Filter"
+ "label": "In List Filter"
},
{
"default": "0",
@@ -197,10 +206,11 @@
"length": 255
},
{
- "depends_on": "eval:doc.fieldtype==\"Section Break\"",
+ "depends_on": "eval:doc.fieldtype==\"Section Break\" && doc.collapsible",
"fieldname": "collapsible_depends_on",
"fieldtype": "Code",
- "label": "Collapsible Depends On",
+ "label": "Collapsible Depends On (JS)",
+ "max_height": "3rem",
"options": "JS"
},
{
@@ -220,6 +230,7 @@
"fieldname": "default",
"fieldtype": "Small Text",
"label": "Default",
+ "max_height": "3rem",
"oldfieldname": "default",
"oldfieldtype": "Text"
},
@@ -230,10 +241,9 @@
},
{
"default": "0",
- "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.",
"fieldname": "fetch_if_empty",
"fieldtype": "Check",
- "label": "Fetch If Empty"
+ "label": "Fetch only if value is not set"
},
{
"fieldname": "permissions",
@@ -243,8 +253,9 @@
{
"fieldname": "depends_on",
"fieldtype": "Code",
- "label": "Display Depends On",
+ "label": "Display Depends On (JS)",
"length": 255,
+ "max_height": "3rem",
"oldfieldname": "depends_on",
"oldfieldtype": "Data",
"options": "JS"
@@ -275,10 +286,9 @@
},
{
"default": "0",
- "description": "Do not allow user to change after set the first time",
"fieldname": "set_only_once",
"fieldtype": "Check",
- "label": "Set Only Once"
+ "label": "Set only once"
},
{
"default": "0",
@@ -303,7 +313,6 @@
},
{
"default": "0",
- "description": "User permissions should not apply for this Link",
"fieldname": "ignore_user_permissions",
"fieldtype": "Check",
"label": "Ignore User Permissions"
@@ -388,12 +397,14 @@
{
"fieldname": "print_width",
"fieldtype": "Data",
- "label": "Print Width"
+ "label": "Print Width",
+ "length": 10
},
{
"fieldname": "width",
"fieldtype": "Data",
"label": "Width",
+ "length": 10,
"oldfieldname": "width",
"oldfieldtype": "Data",
"print_width": "50px",
@@ -436,20 +447,17 @@
{
"fieldname": "mandatory_depends_on",
"fieldtype": "Code",
- "label": "Mandatory Depends On",
+ "label": "Mandatory Depends On (JS)",
+ "max_height": "3rem",
"options": "JS"
},
{
"fieldname": "read_only_depends_on",
"fieldtype": "Code",
- "label": "Read Only Depends On",
+ "label": "Read Only Depends On (JS)",
+ "max_height": "3rem",
"options": "JS"
},
- {
- "fieldname": "property_depends_on_section",
- "fieldtype": "Section Break",
- "label": "Property Depends On"
- },
{
"fieldname": "column_break_38",
"fieldtype": "Column Break"
@@ -481,18 +489,72 @@
"fieldname": "non_negative",
"fieldtype": "Check",
"label": "Non Negative"
+ },
+ {
+ "fieldname": "column_break_18",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "defaults_section",
+ "fieldtype": "Section Break",
+ "label": "Defaults",
+ "max_height": "2rem"
+ },
+ {
+ "fieldname": "visibility_section",
+ "fieldtype": "Section Break",
+ "label": "Visibility"
+ },
+ {
+ "fieldname": "column_break_28",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "constraints_section",
+ "fieldtype": "Section Break",
+ "label": "Constraints"
+ },
+ {
+ "fieldname": "max_height",
+ "fieldtype": "Data",
+ "label": "Max Height",
+ "length": 10
+ },
+ {
+ "fieldname": "list__search_settings_section",
+ "fieldtype": "Section Break",
+ "label": "List / Search Settings"
+ },
+ {
+ "fieldname": "column_break_35",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:doc.fieldtype===\"Tab Break\"",
+ "fieldname": "show_dashboard",
+ "fieldtype": "Check",
+ "label": "Show Dashboard"
+ },
+ {
+ "default": "0",
+ "fieldname": "is_virtual",
+ "fieldtype": "Check",
+ "label": "Virtual"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2020-10-29 06:09:26.454990",
+ "modified": "2022-02-14 11:56:19.812863",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",
+ "naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
- "sort_order": "ASC"
+ "sort_order": "ASC",
+ "states": []
}
\ No newline at end of file
diff --git a/frappe/core/doctype/docfield/docfield.py b/frappe/core/doctype/docfield/docfield.py
index b6e2d9b67d..4dd49631ae 100644
--- a/frappe/core/doctype/docfield/docfield.py
+++ b/frappe/core/doctype/docfield/docfield.py
@@ -1,7 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/docperm/__init__.py b/frappe/core/doctype/docperm/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/core/doctype/docperm/__init__.py
+++ b/frappe/core/doctype/docperm/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/core/doctype/docperm/docperm.py b/frappe/core/doctype/docperm/docperm.py
index 36ed9acbe6..4751816dc5 100644
--- a/frappe/core/doctype/docperm/docperm.py
+++ b/frappe/core/doctype/docperm/docperm.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/docshare/docshare.json b/frappe/core/doctype/docshare/docshare.json
index a4efb6bd4d..ca10b05dac 100644
--- a/frappe/core/doctype/docshare/docshare.json
+++ b/frappe/core/doctype/docshare/docshare.json
@@ -1,293 +1,110 @@
{
- "allow_copy": 0,
+ "actions": [],
"allow_import": 1,
- "allow_rename": 0,
"autoname": "hash",
- "beta": 0,
"creation": "2015-02-04 04:33:36.330477",
- "custom": 0,
"description": "Internal record of document shares",
- "docstatus": 0,
"doctype": "DocType",
"document_type": "System",
- "editable_grid": 0,
+ "engine": "InnoDB",
+ "field_order": [
+ "user",
+ "share_doctype",
+ "share_name",
+ "read",
+ "write",
+ "share",
+ "submit",
+ "everyone",
+ "notify_by_email"
+ ],
"fields": [
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"fieldname": "user",
"fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
"in_list_view": 1,
- "in_standard_filter": 0,
"label": "User",
- "length": 0,
- "no_copy": 0,
"options": "User",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 1,
- "set_only_once": 0,
- "unique": 0
+ "search_index": 1
},
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"fieldname": "share_doctype",
"fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
"in_list_view": 1,
- "in_standard_filter": 0,
"label": "Document Type",
- "length": 0,
- "no_copy": 0,
"options": "DocType",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
"reqd": 1,
- "search_index": 1,
- "set_only_once": 0,
- "unique": 0
+ "search_index": 1
},
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"fieldname": "share_name",
"fieldtype": "Dynamic Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
"in_list_view": 1,
- "in_standard_filter": 0,
"label": "Document Name",
- "length": 0,
- "no_copy": 0,
"options": "share_doctype",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
"reqd": 1,
- "search_index": 1,
- "set_only_once": 0,
- "unique": 0
+ "search_index": 1
},
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"default": "0",
"fieldname": "read",
"fieldtype": "Check",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Read",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "label": "Read"
},
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"default": "0",
"fieldname": "write",
"fieldtype": "Check",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Write",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "label": "Write"
},
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"default": "0",
"fieldname": "share",
"fieldtype": "Check",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Share",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "label": "Share"
},
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
+ "default": "0",
"fieldname": "everyone",
"fieldtype": "Check",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Everyone",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "label": "Everyone"
},
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"default": "1",
"fieldname": "notify_by_email",
"fieldtype": "Check",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
"label": "Notify by email",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 1,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "print_hide": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "submit",
+ "fieldtype": "Check",
+ "label": "Submit"
}
],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
"in_create": 1,
- "is_submittable": 0,
- "issingle": 0,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2017-09-15 15:58:34.126438",
+ "links": [],
+ "modified": "2021-04-04 11:38:50.813312",
"modified_by": "Administrator",
"module": "Core",
"name": "DocShare",
- "name_case": "",
"owner": "Administrator",
"permissions": [
{
- "amend": 0,
- "apply_user_permissions": 0,
- "cancel": 0,
"create": 1,
"delete": 1,
- "email": 0,
"export": 1,
- "if_owner": 0,
"import": 1,
- "permlevel": 0,
- "print": 0,
"read": 1,
"report": 1,
"role": "System Manager",
- "set_user_permissions": 0,
"share": 1,
- "submit": 0,
"write": 1
}
],
- "quick_entry": 0,
"read_only": 1,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
- "track_changes": 1,
- "track_seen": 0
-}
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/docshare/docshare.py b/frappe/core/doctype/docshare/docshare.py
index 28304fb636..6320fba60b 100644
--- a/frappe/core/doctype/docshare/docshare.py
+++ b/frappe/core/doctype/docshare/docshare.py
@@ -1,11 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe import _
-from frappe.utils import get_fullname
+from frappe.utils import get_fullname, cint
exclude_from_linked_with = True
@@ -15,12 +14,15 @@ class DocShare(Document):
def validate(self):
self.validate_user()
self.check_share_permission()
+ self.check_is_submittable()
self.cascade_permissions_downwards()
self.get_doc().run_method("validate_share", self)
def cascade_permissions_downwards(self):
- if self.share or self.write:
+ if self.share or self.write or self.submit:
self.read = 1
+ if self.submit:
+ self.write = 1
def get_doc(self):
if not getattr(self, "_doc", None):
@@ -39,6 +41,11 @@ class DocShare(Document):
frappe.throw(_('You need to have "Share" permission'), frappe.PermissionError)
+ def check_is_submittable(self):
+ if self.submit and not cint(frappe.db.get_value("DocType", self.share_doctype, "is_submittable")):
+ frappe.throw(_("Cannot share {0} with submit permission as the doctype {1} is not submittable").format(
+ frappe.bold(self.share_name), frappe.bold(self.share_doctype)))
+
def after_insert(self):
doc = self.get_doc()
owner = get_fullname(self.owner)
diff --git a/frappe/core/doctype/docshare/test_docshare.py b/frappe/core/doctype/docshare/test_docshare.py
index 697930d6b5..cbdaa8ebaf 100644
--- a/frappe/core/doctype/docshare/test_docshare.py
+++ b/frappe/core/doctype/docshare/test_docshare.py
@@ -1,10 +1,12 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
import frappe.share
import unittest
+from frappe.automation.doctype.auto_repeat.test_auto_repeat import create_submittable_doctype
+
+test_dependencies = ['User']
class TestDocShare(unittest.TestCase):
def setUp(self):
@@ -91,3 +93,24 @@ class TestDocShare(unittest.TestCase):
self.assertTrue(self.event.name not in frappe.share.get_shared("Event", self.user))
self.assertTrue(self.event.name not in frappe.share.get_shared("Event", "test1@example.com"))
self.assertTrue(self.event.name not in frappe.share.get_shared("Event", "Guest"))
+
+ def test_share_with_submit_perm(self):
+ doctype = "Test DocShare with Submit"
+ create_submittable_doctype(doctype, submit_perms=0)
+
+ submittable_doc = frappe.get_doc(dict(doctype=doctype, test="test docshare with submit")).insert()
+
+ frappe.set_user(self.user)
+ self.assertFalse(frappe.has_permission(doctype, "submit", user=self.user))
+
+ frappe.set_user("Administrator")
+ frappe.share.add(doctype, submittable_doc.name, self.user, submit=1)
+
+ frappe.set_user(self.user)
+ self.assertTrue(frappe.has_permission(doctype, "submit", doc=submittable_doc.name, user=self.user))
+
+ # test cascade
+ self.assertTrue(frappe.has_permission(doctype, "read", doc=submittable_doc.name, user=self.user))
+ self.assertTrue(frappe.has_permission(doctype, "write", doc=submittable_doc.name, user=self.user))
+
+ frappe.share.remove(doctype, submittable_doc.name, self.user)
diff --git a/frappe/core/doctype/doctype/__init__.py b/frappe/core/doctype/doctype/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/core/doctype/doctype/__init__.py
+++ b/frappe/core/doctype/doctype/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/core/doctype/doctype/boilerplate/controller._py b/frappe/core/doctype/doctype/boilerplate/controller._py
index 97e23c0037..6db99def55 100644
--- a/frappe/core/doctype/doctype/boilerplate/controller._py
+++ b/frappe/core/doctype/doctype/boilerplate/controller._py
@@ -1,10 +1,8 @@
-# -*- coding: utf-8 -*-
# Copyright (c) {year}, {app_publisher} and contributors
# For license information, please see license.txt
-from __future__ import unicode_literals
# import frappe
{base_class_import}
class {classname}({base_class}):
- pass
+ {custom_controller}
diff --git a/frappe/core/doctype/doctype/boilerplate/test_controller._py b/frappe/core/doctype/doctype/boilerplate/test_controller._py
index 8ed08ae15a..83a38c493d 100644
--- a/frappe/core/doctype/doctype/boilerplate/test_controller._py
+++ b/frappe/core/doctype/doctype/boilerplate/test_controller._py
@@ -1,10 +1,9 @@
-# -*- coding: utf-8 -*-
# Copyright (c) {year}, {app_publisher} and Contributors
# See license.txt
-from __future__ import unicode_literals
# import frappe
-import unittest
+from frappe.tests.utils import FrappeTestCase
-class Test{classname}(unittest.TestCase):
+
+class Test{classname}(FrappeTestCase):
pass
diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js
index 3e2a423b06..88cc5577a6 100644
--- a/frappe/core/doctype/doctype/doctype.js
+++ b/frappe/core/doctype/doctype/doctype.js
@@ -1,41 +1,48 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// MIT License. See license.txt
-// -------------
-// Menu Display
-// -------------
-
-// $(cur_frm.wrapper).on("grid-row-render", function(e, grid_row) {
-// if(grid_row.doc && grid_row.doc.fieldtype=="Section Break") {
-// $(grid_row.row).css({"font-weight": "bold"});
-// }
-// })
-
frappe.ui.form.on('DocType', {
refresh: function(frm) {
+ frm.set_query('role', 'permissions', function(doc) {
+ if (doc.custom && frappe.session.user != 'Administrator') {
+ return {
+ query: "frappe.core.doctype.role.role.role_query",
+ filters: [['Role', 'name', '!=', 'All']]
+ };
+ }
+ });
+
if(frappe.session.user !== "Administrator" || !frappe.boot.developer_mode) {
if(frm.is_new()) {
frm.set_value("custom", 1);
}
frm.toggle_enable("custom", 0);
+ frm.toggle_enable("is_virtual", 0);
frm.toggle_enable("beta", 0);
}
if (!frm.is_new() && !frm.doc.istable) {
if (frm.doc.issingle) {
- frm.add_custom_button(__('Go to {0}', [frm.doc.name]), () => {
+ frm.add_custom_button(__('Go to {0}', [__(frm.doc.name)]), () => {
window.open(`/app/${frappe.router.slug(frm.doc.name)}`);
});
} else {
- frm.add_custom_button(__('Go to {0} List', [frm.doc.name]), () => {
+ frm.add_custom_button(__('Go to {0} List', [__(frm.doc.name)]), () => {
window.open(`/app/${frappe.router.slug(frm.doc.name)}`);
});
}
}
+ const customize_form_link = "Customize Form ";
if(!frappe.boot.developer_mode && !frm.doc.custom) {
// make the document read-only
frm.set_read_only();
+ frm.dashboard.add_comment(__("DocTypes can not be modified, please use {0} instead", [customize_form_link]), "blue", true);
+ } else if (frappe.boot.developer_mode) {
+ let msg = __("This site is running in developer mode. Any change made here will be updated in code.");
+ msg += " ";
+ msg += __("If you just want to customize for your site, use {0} instead.", [customize_form_link]);
+ frm.dashboard.add_comment(msg, "yellow");
}
if(frm.is_new()) {
@@ -51,9 +58,180 @@ frappe.ui.form.on('DocType', {
__('In Grid View') : __('In List View');
frm.events.autoname(frm);
+ frm.events.set_naming_rule_description(frm);
+ },
+
+ istable: (frm) => {
+ if (frm.doc.istable && frm.is_new()) {
+ frm.set_value('autoname', 'autoincrement');
+ frm.set_value('allow_rename', 0);
+ }
+ },
+
+ naming_rule: function(frm) {
+ // set the "autoname" property based on naming_rule
+ if (frm.doc.naming_rule && !frm.__from_autoname) {
+
+ // flag to avoid recursion
+ frm.__from_naming_rule = true;
+
+ if (frm.doc.naming_rule=='Set by user') {
+ frm.set_value('autoname', 'Prompt');
+ } else if (frm.doc.naming_rule === 'Autoincrement') {
+ frm.set_value('autoname', 'autoincrement');
+ // set allow rename to be false when using autoincrement
+ frm.set_value('allow_rename', 0);
+ } else if (frm.doc.naming_rule=='By fieldname') {
+ frm.set_value('autoname', 'field:');
+ } else if (frm.doc.naming_rule=='By "Naming Series" field') {
+ frm.set_value('autoname', 'naming_series:');
+ } else if (frm.doc.naming_rule=='Expression') {
+ frm.set_value('autoname', 'format:');
+ } else if (frm.doc.naming_rule=='Expression (old style)') {
+ // pass
+ } else if (frm.doc.naming_rule=='Random') {
+ frm.set_value('autoname', 'hash');
+ }
+ setTimeout(() =>frm.__from_naming_rule = false, 500);
+
+ frm.events.set_naming_rule_description(frm);
+ }
+
+ },
+
+ set_naming_rule_description(frm) {
+ let naming_rule_description = {
+ 'Set by user': '',
+ 'Autoincrement': 'Uses Auto Increment feature of database.WARNING: After using this option, any other naming option will not be accessible. ',
+ 'By fieldname': 'Format: field:[fieldname]. Valid fieldname must exist',
+ 'By "Naming Series" field': 'Format: naming_series:[fieldname]. Fieldname called naming_series must exist',
+ 'Expression': 'Format: format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.',
+ 'Expression (old style)': 'Format: EXAMPLE-.##### Series by prefix (separated by a dot)',
+ 'Random': '',
+ 'By script': ''
+ };
+
+ if (frm.doc.naming_rule) {
+ frm.get_field('autoname').set_description(naming_rule_description[frm.doc.naming_rule]);
+ }
},
autoname: function(frm) {
+ // set naming_rule based on autoname (for old doctypes where its not been set)
+ if (frm.doc.autoname && !frm.doc.naming_rule && !frm.__from_naming_rule) {
+ // flag to avoid recursion
+ frm.__from_autoname = true;
+ if (frm.doc.autoname.toLowerCase() === 'prompt') {
+ frm.set_value('naming_rule', 'Set by user');
+ } else if (frm.doc.autoname.toLowerCase() === 'autoincrement') {
+ frm.set_value('naming_rule', 'Autoincrement');
+ } else if (frm.doc.autoname.startsWith('field:')) {
+ frm.set_value('naming_rule', 'By fieldname');
+ } else if (frm.doc.autoname.startsWith('naming_series:')) {
+ frm.set_value('naming_rule', 'By "Naming Series" field');
+ } else if (frm.doc.autoname.startsWith('format:')) {
+ frm.set_value('naming_rule', 'Expression');
+ } else if (frm.doc.autoname.toLowerCase() === 'hash') {
+ frm.set_value('naming_rule', 'Random');
+ } else {
+ frm.set_value('naming_rule', 'Expression (old style)');
+ }
+ setTimeout(() => frm.__from_autoname = false, 500);
+ }
+
frm.set_df_property('fields', 'reqd', frm.doc.autoname !== 'Prompt');
+ },
+});
+
+frappe.ui.form.on("DocField", {
+ form_render(frm, doctype, docname) {
+ // Render two select fields for Fetch From instead of Small Text for better UX
+ let field = frm.cur_grid.grid_form.fields_dict.fetch_from;
+ $(field.input_area).hide();
+
+ let $doctype_select = $(``);
+ let $field_select = $(``);
+ let $wrapper = $('');
+ $wrapper.append($doctype_select, $field_select);
+ field.$input_wrapper.append($wrapper);
+ $doctype_select.wrap('
');
+ $field_select.wrap('
');
+
+ let row = frappe.get_doc(doctype, docname);
+ let curr_value = { doctype: null, fieldname: null };
+ if (row.fetch_from) {
+ let [doctype, fieldname] = row.fetch_from.split(".");
+ curr_value.doctype = doctype;
+ curr_value.fieldname = fieldname;
+ }
+
+ let doctypes = frm.doc.fields
+ .filter(df => df.fieldtype == "Link")
+ .filter(df => df.options && df.fieldname != row.fieldname)
+ .map(df => ({
+ label: `${df.options} (${df.fieldname})`,
+ value: df.fieldname
+ }));
+ $doctype_select.add_options([
+ { label: __("Select DocType"), value: "", selected: true },
+ ...doctypes
+ ]);
+
+ $doctype_select.on("change", () => {
+ row.fetch_from = "";
+ frm.dirty();
+ update_fieldname_options();
+ });
+
+ function update_fieldname_options() {
+ $field_select.find("option").remove();
+
+ let link_fieldname = $doctype_select.val();
+ if (!link_fieldname) return;
+ let link_field = frm.doc.fields.find(
+ df => df.fieldname === link_fieldname
+ );
+ let link_doctype = link_field.options;
+ frappe.model.with_doctype(link_doctype, () => {
+ let fields = frappe.meta
+ .get_docfields(link_doctype, null, {
+ fieldtype: ["not in", frappe.model.no_value_type]
+ })
+ .map(df => ({
+ label: `${df.label} (${df.fieldtype})`,
+ value: df.fieldname
+ }));
+ $field_select.add_options([
+ {
+ label: __("Select Field"),
+ value: "",
+ selected: true,
+ disabled: true
+ },
+ ...fields
+ ]);
+
+ if (curr_value.fieldname) {
+ $field_select.val(curr_value.fieldname);
+ }
+ });
+ }
+
+ $field_select.on("change", () => {
+ let fetch_from = `${$doctype_select.val()}.${$field_select.val()}`;
+ row.fetch_from = fetch_from;
+ frm.dirty();
+ });
+
+ if (curr_value.doctype) {
+ $doctype_select.val(curr_value.doctype);
+ update_fieldname_options();
+ }
+ },
+
+ fieldtype: function(frm) {
+ frm.trigger("max_attachments");
}
-})
+});
+
+extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({frm: cur_frm}));
diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json
index 1533829b3c..8169a59566 100644
--- a/frappe/core/doctype/doctype/doctype.json
+++ b/frappe/core/doctype/doctype/doctype.json
@@ -22,11 +22,14 @@
"track_views",
"custom",
"beta",
+ "is_virtual",
"fields_section_break",
"fields",
"sb1",
+ "naming_rule",
"autoname",
"name_case",
+ "allow_rename",
"column_break_15",
"description",
"documentation",
@@ -38,12 +41,12 @@
"column_break_23",
"hide_toolbar",
"allow_copy",
- "allow_rename",
"allow_import",
"allow_events_in_timeline",
"allow_auto_repeat",
"view_settings",
"title_field",
+ "show_title_field_in_link",
"search_fields",
"default_print_format",
"sort_field",
@@ -55,6 +58,8 @@
"show_preview_popup",
"show_name_in_global_search",
"email_settings_sb",
+ "default_email_template",
+ "column_break_51",
"email_append_to",
"sender_field",
"subject_field",
@@ -67,14 +72,18 @@
"actions",
"links_section",
"links",
+ "document_states_section",
+ "states",
"web_view",
"has_web_view",
"allow_guest_to_view",
"index_web_pages_for_search",
"route",
"is_published_field",
+ "website_search_field",
"advanced",
- "engine"
+ "engine",
+ "migration_hash"
],
"fields": [
{
@@ -144,7 +153,7 @@
"fieldtype": "Column Break"
},
{
- "default": "1",
+ "default": "0",
"depends_on": "eval:!doc.istable",
"description": "If enabled, changes to the document are tracked and shown in timeline",
"fieldname": "track_changes",
@@ -199,7 +208,7 @@
"label": "Naming"
},
{
- "description": "Naming Options:\n
field:[fieldname] - By Fieldnaming_series: - By Naming Series (field called naming_series must be presentPrompt - Prompt user for a name[series] - Series by prefix (separated by a dot); for example PRE.##### \nformat:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.",
+ "description": "Naming Options:\n
field:[fieldname] - By Fieldautoincrement - Uses Databases' Auto Increment featurenaming_series: - By Naming Series (field called naming_series must be presentPrompt - Prompt user for a name[series] - Series by prefix (separated by a dot); for example PRE.##### \nformat:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.",
"fieldname": "autoname",
"fieldtype": "Data",
"label": "Auto Name",
@@ -207,6 +216,7 @@
"oldfieldtype": "Data"
},
{
+ "depends_on": "eval:doc.naming_rule !== \"Autoincrement\"",
"fieldname": "name_case",
"fieldtype": "Select",
"label": "Name Case",
@@ -272,7 +282,8 @@
"oldfieldtype": "Check"
},
{
- "default": "0",
+ "default": "1",
+ "depends_on": "eval:doc.naming_rule !== \"Autoincrement\"",
"fieldname": "allow_rename",
"fieldtype": "Check",
"label": "Allow Rename",
@@ -528,10 +539,63 @@
"fieldname": "index_web_pages_for_search",
"fieldtype": "Check",
"label": "Index Web Pages for Search"
+ },
+ {
+ "default": "0",
+ "fieldname": "is_virtual",
+ "fieldtype": "Check",
+ "label": "Is Virtual"
+ },
+ {
+ "fieldname": "default_email_template",
+ "fieldtype": "Link",
+ "label": "Default Email Template",
+ "options": "Email Template"
+ },
+ {
+ "fieldname": "column_break_51",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "has_web_view",
+ "fieldname": "website_search_field",
+ "fieldtype": "Data",
+ "label": "Website Search Field"
+ },
+ {
+ "fieldname": "naming_rule",
+ "fieldtype": "Select",
+ "label": "Naming Rule",
+ "length": 40,
+ "options": "\nSet by user\nAutoincrement\nBy fieldname\nBy \"Naming Series\" field\nExpression\nExpression (old style)\nRandom\nBy script"
+ },
+ {
+ "fieldname": "migration_hash",
+ "fieldtype": "Data",
+ "hidden": 1
+ },
+ {
+ "fieldname": "states",
+ "fieldtype": "Table",
+ "label": "States",
+ "options": "DocType State"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "document_states_section",
+ "fieldtype": "Section Break",
+ "label": "Document States"
+ },
+ {
+ "default": "0",
+ "fieldname": "show_title_field_in_link",
+ "fieldtype": "Check",
+ "label": "Show Title in Link Fields"
}
],
"icon": "fa fa-bolt",
"idx": 6,
+ "index_web_pages_for_search": 1,
"links": [
{
"group": "Views",
@@ -609,10 +673,11 @@
"link_fieldname": "reference_doctype"
}
],
- "modified": "2021-02-04 15:10:09.227205",
+ "modified": "2022-02-15 21:47:16.467217",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",
+ "naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
@@ -642,5 +707,6 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py
index cbcfa350f5..29b56fbff6 100644
--- a/frappe/core/doctype/doctype/doctype.py
+++ b/frappe/core/doctype/doctype/doctype.py
@@ -1,23 +1,20 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
# imports - standard imports
-from __future__ import unicode_literals
import re, copy, os, shutil
import json
from frappe.cache_manager import clear_user_cache, clear_controller_cache
-# imports - third party imports
-import six
-from six import iteritems
-
# imports - module imports
import frappe
-import frappe.website.render
from frappe import _
from frappe.utils import now, cint
-from frappe.model import no_value_fields, default_fields, data_fieldtypes, table_fields, data_field_options
+from frappe.model import (
+ no_value_fields, default_fields, table_fields, data_field_options, child_table_fields
+)
from frappe.model.document import Document
+from frappe.model.base_document import get_controller
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.desk.notifications import delete_notification_count_for
@@ -27,6 +24,8 @@ from frappe.model.docfield import supports_translation
from frappe.modules.import_file import get_file_path
from frappe.model.meta import Meta
from frappe.desk.utils import validate_route_conflict
+from frappe.website.utils import clear_cache
+from frappe.query_builder.functions import Concat
class InvalidFieldNameError(frappe.ValidationError): pass
class UniqueFieldnameError(frappe.ValidationError): pass
@@ -61,13 +60,14 @@ class DocType(Document):
self.check_developer_mode()
+ self.validate_autoname()
self.validate_name()
self.set_defaults_for_single_and_table()
self.scrub_field_names()
self.set_default_in_list_view()
self.set_default_translatable()
- self.validate_series()
+ validate_series(self)
self.validate_document_type()
validate_fields(self)
@@ -77,18 +77,66 @@ class DocType(Document):
self.make_amendable()
self.make_repeatable()
self.validate_nestedset()
+ self.validate_child_table()
self.validate_website()
+ self.ensure_minimum_max_attachment_limit()
validate_links_table_fieldnames(self)
if not self.is_new():
self.before_update = frappe.get_doc('DocType', self.name)
self.setup_fields_to_fetch()
+ self.validate_field_name_conflicts()
check_email_append_to(self)
if self.default_print_format and not self.custom:
frappe.throw(_('Standard DocType cannot have default print format, use Customize Form'))
+ def validate_field_name_conflicts(self):
+ """Check if field names dont conflict with controller properties and methods"""
+ core_doctypes = [
+ "Custom DocPerm",
+ "DocPerm",
+ "Custom Field",
+ "Customize Form Field",
+ "DocField",
+ ]
+
+ if self.name in core_doctypes:
+ return
+
+ try:
+ controller = get_controller(self.name)
+ except ImportError:
+ controller = Document
+
+ available_objects = {x for x in dir(controller) if isinstance(x, str)}
+ property_set = {
+ x for x in available_objects if isinstance(getattr(controller, x, None), property)
+ }
+ method_set = {
+ x for x in available_objects if x not in property_set and callable(getattr(controller, x, None))
+ }
+
+ for docfield in self.get("fields") or []:
+ if docfield.fieldtype in no_value_fields:
+ continue
+
+ conflict_type = None
+ field = docfield.fieldname
+ field_label = docfield.label or docfield.fieldname
+
+ if docfield.fieldname in method_set:
+ conflict_type = "controller method"
+ if docfield.fieldname in property_set:
+ conflict_type = "class property"
+
+ if conflict_type:
+ frappe.throw(
+ _("Fieldname '{0}' conflicting with a {1} of the name {2} in {3}")
+ .format(field_label, conflict_type, field, self.name)
+ )
+
def after_insert(self):
# clear user cache so that on the next reload this doctype is included in boot
clear_user_cache(frappe.session.user)
@@ -127,6 +175,9 @@ class DocType(Document):
if not frappe.conf.get("developer_mode") and not self.custom:
frappe.throw(_("Not in Developer Mode! Set in site_config.json or make 'Custom' DocType."), CannotCreateStandardDoctypeError)
+ if self.is_virtual and self.custom:
+ frappe.throw(_("Not allowed to create custom Virtual DocType."), CannotCreateStandardDoctypeError)
+
if frappe.conf.get('developer_mode'):
self.owner = 'Administrator'
self.modified_by = 'Administrator'
@@ -143,7 +194,7 @@ class DocType(Document):
self.flags.update_fields_to_fetch_queries = []
- if set(old_fields_to_fetch) != set([df.fieldname for df in new_meta.get_fields_to_fetch()]):
+ if set(old_fields_to_fetch) != set(df.fieldname for df in new_meta.get_fields_to_fetch()):
for df in new_meta.get_fields_to_fetch():
if df.fieldname not in old_fields_to_fetch:
link_fieldname, source_fieldname = df.fetch_from.split('.', 1)
@@ -198,7 +249,23 @@ class DocType(Document):
frappe.throw(_('Field "route" is mandatory for Web Views'), title='Missing Field')
# clear website cache
- frappe.website.render.clear_cache()
+ clear_cache()
+
+ def ensure_minimum_max_attachment_limit(self):
+ """Ensure that max_attachments is *at least* bigger than number of attach fields."""
+ from frappe.model import attachment_fieldtypes
+
+
+ if not self.max_attachments:
+ return
+
+ total_attach_fields = len([d for d in self.fields if d.fieldtype in attachment_fieldtypes])
+ if total_attach_fields > self.max_attachments:
+ self.max_attachments = total_attach_fields
+ field_label = frappe.bold(self.meta.get_field("max_attachments").label)
+ frappe.msgprint(_("Number of attachment fields are more than {}, limit updated to {}.")
+ .format(field_label, total_attach_fields),
+ title=_("Insufficient attachment limit"), alert=True)
def change_modified_of_parent(self):
"""Change the timestamp of parent DocType if the current one is a child to clear caches."""
@@ -207,7 +274,7 @@ class DocType(Document):
parent_list = frappe.db.get_all('DocField', 'parent',
dict(fieldtype=['in', frappe.model.table_fields], options=self.name))
for p in parent_list:
- frappe.db.sql('UPDATE `tabDocType` SET modified=%s WHERE `name`=%s', (now(), p.parent))
+ frappe.db.update("DocType", p.parent, {}, for_update=False)
def scrub_field_names(self):
"""Sluggify fieldnames if not set from Label."""
@@ -224,6 +291,8 @@ class DocType(Document):
d.fieldname = d.fieldname + '_section'
elif d.fieldtype=='Column Break':
d.fieldname = d.fieldname + '_column'
+ elif d.fieldtype=='Tab Break':
+ d.fieldname = d.fieldname + '_tab'
else:
d.fieldname = d.fieldtype.lower().replace(" ","_") + "_" + str(d.idx)
else:
@@ -238,44 +307,6 @@ class DocType(Document):
# unique is automatically an index
if d.unique: d.search_index = 0
- def validate_series(self, autoname=None, name=None):
- """Validate if `autoname` property is correctly set."""
- if not autoname: autoname = self.autoname
- if not name: name = self.name
-
- if not autoname and self.get("fields", {"fieldname":"naming_series"}):
- self.autoname = "naming_series:"
- elif self.autoname == "naming_series:" and not self.get("fields", {"fieldname":"naming_series"}):
- frappe.throw(_("Invalid fieldname '{0}' in autoname").format(self.autoname))
-
- # validate field name if autoname field:fieldname is used
- # Create unique index on autoname field automatically.
- if autoname and autoname.startswith('field:'):
- field = autoname.split(":")[1]
- if not field or field not in [ df.fieldname for df in self.fields ]:
- frappe.throw(_("Invalid fieldname '{0}' in autoname").format(field))
- else:
- for df in self.fields:
- if df.fieldname == field:
- df.unique = 1
- break
-
- if autoname and (not autoname.startswith('field:')) \
- and (not autoname.startswith('eval:')) \
- and (not autoname.lower() in ('prompt', 'hash')) \
- and (not autoname.startswith('naming_series:')) \
- and (not autoname.startswith('format:')):
-
- prefix = autoname.split('.')[0]
- used_in = frappe.db.sql("""
- SELECT `name`
- FROM `tabDocType`
- WHERE `autoname` LIKE CONCAT(%s, '.%%')
- AND `name`!=%s
- """, (prefix, name))
- if used_in:
- frappe.throw(_("Series {0} already used in {1}").format(prefix, used_in[0][0]))
-
def on_update(self):
"""Update database schema, make controller templates if `custom` is not set and clear cache."""
try:
@@ -300,9 +331,7 @@ class DocType(Document):
if allow_doctype_export:
self.export_doc()
self.make_controller_template()
-
- if self.has_web_view:
- self.set_base_class_for_controller()
+ self.set_base_class_for_controller()
# update index
if not self.custom:
@@ -340,23 +369,49 @@ class DocType(Document):
now=now, doctype=self.name)
def set_base_class_for_controller(self):
- '''Updates the controller class to subclass from `WebsiteGenertor`,
- if it is a subclass of `Document`'''
- controller_path = frappe.get_module_path(frappe.scrub(self.module),
- 'doctype', frappe.scrub(self.name), frappe.scrub(self.name) + '.py')
+ """If DocType.has_web_view has been changed, updates the controller class and import
+ from `WebsiteGenertor` to `Document` or viceversa"""
- with open(controller_path, 'r') as f:
+ if not self.has_value_changed("has_web_view"):
+ return
+
+ despaced_name = self.name.replace(" ", "_")
+ scrubbed_name = frappe.scrub(self.name)
+ scrubbed_module = frappe.scrub(self.module)
+ controller_path = frappe.get_module_path(
+ scrubbed_module, "doctype", scrubbed_name, f"{scrubbed_name}.py"
+ )
+
+ document_cls_tag = f"class {despaced_name}(Document)"
+ document_import_tag = "from frappe.model.document import Document"
+ website_generator_cls_tag = f"class {despaced_name}(WebsiteGenerator)"
+ website_generator_import_tag = "from frappe.website.website_generator import WebsiteGenerator"
+
+ with open(controller_path) as f:
code = f.read()
+ updated_code = code
- class_string = '\nclass {0}(Document)'.format(self.name.replace(' ', ''))
- if '\nfrom frappe.model.document import Document' in code and class_string in code:
- code = code.replace('from frappe.model.document import Document',
- 'from frappe.website.website_generator import WebsiteGenerator')
- code = code.replace('class {0}(Document)'.format(self.name.replace(' ', '')),
- 'class {0}(WebsiteGenerator)'.format(self.name.replace(' ', '')))
+ is_website_generator_class = all([
+ website_generator_cls_tag in code,
+ website_generator_import_tag in code
+ ])
- with open(controller_path, 'w') as f:
- f.write(code)
+ if self.has_web_view and not is_website_generator_class:
+ updated_code = updated_code.replace(
+ document_import_tag, website_generator_import_tag
+ ).replace(
+ document_cls_tag, website_generator_cls_tag
+ )
+ elif not self.has_web_view and is_website_generator_class:
+ updated_code = updated_code.replace(
+ website_generator_import_tag, document_import_tag
+ ).replace(
+ website_generator_cls_tag, document_cls_tag
+ )
+
+ if updated_code != code:
+ with open(controller_path, "w") as f:
+ f.write(updated_code)
def run_module_method(self, method):
from frappe.modules import load_doctype_module
@@ -384,10 +439,7 @@ class DocType(Document):
frappe.db.sql("""update tabSingles set value=%s
where doctype=%s and field='name' and value = %s""", (new, new, old))
else:
- frappe.db.multisql({
- "mariadb": f"RENAME TABLE `tab{old}` TO `tab{new}`",
- "postgres": f"ALTER TABLE `tab{old}` RENAME TO `tab{new}`"
- })
+ frappe.db.rename_table(old, new)
frappe.db.commit()
# Do not rename and move files and folders for custom doctype
@@ -454,7 +506,7 @@ class DocType(Document):
return
# check if atleast 1 record exists
- if not (frappe.db.table_exists(self.name) and frappe.db.sql("select name from `tab{}` limit 1".format(self.name))):
+ if not (frappe.db.table_exists(self.name) and frappe.get_all(self.name, fields=["name"], limit=1, as_list=True)):
return
existing_property_setter = frappe.db.get_value("Property Setter", {"doc_type": self.name,
@@ -469,7 +521,7 @@ class DocType(Document):
# remove null and empty fields
def remove_null_fields(o):
to_remove = []
- for attr, value in iteritems(o):
+ for attr, value in o.items():
if isinstance(value, list):
for v in value:
remove_null_fields(v)
@@ -484,6 +536,9 @@ class DocType(Document):
# retain order of 'fields' table and change order in 'field_order'
docdict["field_order"] = [f.fieldname for f in self.fields]
+ if self.custom:
+ return
+
path = get_file_path(self.module, "DocType", self.name)
if os.path.exists(path):
try:
@@ -538,11 +593,6 @@ class DocType(Document):
from frappe.modules.export_file import export_to_files
export_to_files(record_list=[['DocType', self.name]], create_init=True)
- def import_doc(self):
- """Import from standard folder `[module]/doctype/[name]/[name].json`."""
- from frappe.modules.import_module import import_from_files
- import_from_files(record_list=[[self.module, 'doctype', self.name]])
-
def make_controller_template(self):
"""Make boilerplate controller template."""
make_boilerplate("controller._py", self)
@@ -562,17 +612,17 @@ class DocType(Document):
def make_amendable(self):
"""If is_submittable is set, add amended_from docfields."""
if self.is_submittable:
- if not frappe.db.sql("""select name from tabDocField
- where fieldname = 'amended_from' and parent = %s""", self.name):
- self.append("fields", {
- "label": "Amended From",
- "fieldtype": "Link",
- "fieldname": "amended_from",
- "options": self.name,
- "read_only": 1,
- "print_hide": 1,
- "no_copy": 1
- })
+ docfield_exists = frappe.get_all("DocField", filters={"fieldname": "amended_from", "parent": self.name}, pluck="name", limit=1)
+ if not docfield_exists:
+ self.append("fields", {
+ "label": "Amended From",
+ "fieldtype": "Link",
+ "fieldname": "amended_from",
+ "options": self.name,
+ "read_only": 1,
+ "print_hide": 1,
+ "no_copy": 1
+ })
def make_repeatable(self):
"""If allow_auto_repeat is set, add auto_repeat custom field."""
@@ -643,41 +693,143 @@ class DocType(Document):
})
self.nsm_parent_field = parent_field_name
+ def validate_child_table(self):
+ if not self.get("istable") or self.is_new():
+ # if the doctype is not a child table then return
+ # if the doctype is a new doctype and also a child table then
+ # don't move forward as it will be handled via schema
+ return
+
+ self.add_child_table_fields()
+
+ def add_child_table_fields(self):
+ from frappe.database.schema import add_column
+
+ add_column(self.name, "parent", "Data")
+ add_column(self.name, "parenttype", "Data")
+ add_column(self.name, "parentfield", "Data")
+
def get_max_idx(self):
"""Returns the highest `idx`"""
max_idx = frappe.db.sql("""select max(idx) from `tabDocField` where parent = %s""",
self.name)
return max_idx and max_idx[0][0] or 0
+ def validate_autoname(self):
+ if not self.is_new():
+ doc_before_save = self.get_doc_before_save()
+ if doc_before_save:
+ if (self.autoname == "autoincrement" and doc_before_save.autoname != "autoincrement") \
+ or (self.autoname != "autoincrement" and doc_before_save.autoname == "autoincrement"):
+ frappe.throw(_("Cannot change to/from Autoincrement naming rule"))
+
+ else:
+ if self.autoname == "autoincrement":
+ self.allow_rename = 0
+
def validate_name(self, name=None):
if not name:
name = self.name
- flags = {"flags": re.ASCII} if six.PY3 else {}
+ # a Doctype name is the tablename created in database
+ # `tab
` the length of tablename is limited to 64 characters
+ max_length = frappe.db.MAX_COLUMN_LENGTH - 3
+ if len(name) > max_length:
+ # length(tab + ) should be equal to 64 characters hence doctype should be 61 characters
+ frappe.throw(_("Doctype name is limited to {0} characters ({1})").format(max_length, name), frappe.NameError)
+
+ flags = {"flags": re.ASCII}
# a DocType name should not start or end with an empty space
- if re.search("^[ \t\n\r]+|[ \t\n\r]+$", name, **flags):
+ if re.search(r"^[ \t\n\r]+|[ \t\n\r]+$", name, **flags):
frappe.throw(_("DocType's name should not start or end with whitespace"), frappe.NameError)
# a DocType's name should not start with a number or underscore
- # and should only contain letters, numbers and underscore
- if not re.match("^(?![\W])[^\d_\s][\w ]+$", name, **flags):
- frappe.throw(_("DocType's name should start with a letter and it can only consist of letters, numbers, spaces and underscores"), frappe.NameError)
+ # and should only contain letters, numbers, underscore, and hyphen
+ if not re.match(r"^(?![\W])[^\d_\s][\w -]+$", name, **flags):
+ frappe.throw(_(
+ "A DocType's name should start with a letter and can only "
+ "consist of letters, numbers, spaces, underscores and hyphens"
+ ), frappe.NameError, title="Invalid Name")
validate_route_conflict(self.doctype, self.name)
+def validate_series(dt, autoname=None, name=None):
+ """Validate if `autoname` property is correctly set."""
+ if not autoname:
+ autoname = dt.autoname
+ if not name:
+ name = dt.name
+
+ if not autoname and dt.get("fields", {"fieldname":"naming_series"}):
+ dt.autoname = "naming_series:"
+ elif dt.autoname == "naming_series:" and not dt.get("fields", {"fieldname":"naming_series"}):
+ frappe.throw(_("Invalid fieldname '{0}' in autoname").format(dt.autoname))
+
+ # validate field name if autoname field:fieldname is used
+ # Create unique index on autoname field automatically.
+ if autoname and autoname.startswith('field:'):
+ field = autoname.split(":")[1]
+ if not field or field not in [df.fieldname for df in dt.fields]:
+ frappe.throw(_("Invalid fieldname '{0}' in autoname").format(field))
+ else:
+ for df in dt.fields:
+ if df.fieldname == field:
+ df.unique = 1
+ break
+
+ if autoname and (not autoname.startswith('field:')) \
+ and (not autoname.startswith('eval:')) \
+ and (not autoname.lower() in ('prompt', 'hash')) \
+ and (not autoname.startswith('naming_series:')) \
+ and (not autoname.startswith('format:')):
+
+ prefix = autoname.split('.')[0]
+ doctype = frappe.qb.DocType("DocType")
+ used_in = (frappe.qb
+ .from_(doctype)
+ .select(doctype.name)
+ .where(doctype.autoname.like(Concat(prefix,".%")))
+ .where(doctype.name != name)
+ ).run()
+ if used_in:
+ frappe.throw(_("Series {0} already used in {1}").format(prefix, used_in[0][0]))
+
def validate_links_table_fieldnames(meta):
"""Validate fieldnames in Links table"""
- if frappe.flags.in_patch: return
- if frappe.flags.in_fixtures: return
- if not meta.links: return
+ if not meta.links or frappe.flags.in_patch or frappe.flags.in_fixtures:
+ return
- for index, link in enumerate(meta.links):
- link_meta = frappe.get_meta(link.link_doctype)
- if not link_meta.get_field(link.link_fieldname):
- message = _("Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype))
+ fieldnames = tuple(field.fieldname for field in meta.fields)
+ for index, link in enumerate(meta.links, 1):
+ if not frappe.get_meta(link.link_doctype).has_field(link.link_fieldname):
+ message = _("Document Links Row #{0}: Could not find field {1} in {2} DocType").format(
+ index, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype)
+ )
frappe.throw(message, InvalidFieldNameError, _("Invalid Fieldname"))
+ if not link.is_child_table:
+ continue
+
+ if not link.parent_doctype:
+ message = _("Document Links Row #{0}: Parent DocType is mandatory for internal links").format(index)
+ frappe.throw(message, frappe.ValidationError, _("Parent Missing"))
+
+ if not link.table_fieldname:
+ message = _("Document Links Row #{0}: Table Fieldname is mandatory for internal links").format(index)
+ frappe.throw(message, frappe.ValidationError, _("Table Fieldname Missing"))
+
+ if meta.name == link.parent_doctype:
+ field_exists = link.table_fieldname in fieldnames
+ else:
+ field_exists = frappe.get_meta(link.parent_doctype).has_field(link.table_fieldname)
+
+ if not field_exists:
+ message = _("Document Links Row #{0}: Could not find field {1} in {2} DocType").format(
+ index, frappe.bold(link.table_fieldname), frappe.bold(meta.name)
+ )
+ frappe.throw(message, frappe.ValidationError, _("Invalid Table Fieldname"))
+
def validate_fields_for_doctype(doctype):
meta = frappe.get_meta(doctype, cached=False)
validate_links_table_fieldnames(meta)
@@ -710,7 +862,7 @@ def validate_fields(meta):
invalid_fields = ('doctype',)
if fieldname in invalid_fields:
frappe.throw(_("{0}: Fieldname cannot be one of {1}")
- .format(docname, ", ".join([frappe.bold(d) for d in invalid_fields])))
+ .format(docname, ", ".join(frappe.bold(d) for d in invalid_fields)))
def check_unique_fieldname(docname, fieldname):
duplicates = list(filter(None, map(lambda df: df.fieldname==fieldname and str(df.idx) or None, fields)))
@@ -880,6 +1032,16 @@ def validate_fields(meta):
if meta.is_published_field not in fieldname_list:
frappe.throw(_("Is Published Field must be a valid fieldname"), InvalidFieldNameError)
+ def check_website_search_field(meta):
+ if not meta.website_search_field:
+ return
+
+ if meta.website_search_field not in fieldname_list:
+ frappe.throw(_("Website Search Field must be a valid fieldname"), InvalidFieldNameError)
+
+ if "title" not in fieldname_list:
+ frappe.throw(_('Field "title" is mandatory if "Website Search Field" is set.'), title=_("Missing Field"))
+
def check_timeline_field(meta):
if not meta.timeline_field:
return
@@ -899,7 +1061,7 @@ def validate_fields(meta):
sort_fields = [d.split()[0] for d in meta.sort_field.split(',')]
for fieldname in sort_fields:
- if not fieldname in fieldname_list + list(default_fields):
+ if fieldname not in (fieldname_list + list(default_fields) + list(child_table_fields)):
frappe.throw(_("Sort field {0} must be a valid fieldname").format(fieldname),
InvalidFieldNameError)
@@ -909,7 +1071,7 @@ def validate_fields(meta):
for field in depends_on_fields:
depends_on = docfield.get(field, None)
if depends_on and ("=" in depends_on) and \
- re.match("""[\w\.:_]+\s*={1}\s*[\w\.@'"]+""", depends_on):
+ re.match(r'[\w\.:_]+\s*={1}\s*[\w\.@\'"]+', depends_on):
frappe.throw(_("Invalid {0} condition").format(frappe.unscrub(field)), frappe.ValidationError)
def check_table_multiselect_option(docfield):
@@ -940,11 +1102,14 @@ def validate_fields(meta):
field.fetch_from = field.fetch_from.strip('\n').strip()
def validate_data_field_type(docfield):
+ if docfield.get("is_virtual"):
+ return
+
if docfield.fieldtype == "Data" and not (docfield.oldfieldtype and docfield.oldfieldtype != "Data"):
if docfield.options and (docfield.options not in data_field_options):
df_str = frappe.bold(_(docfield.label))
text_str = _("{0} is an invalid Data field.").format(df_str) + " " * 2 + _("Only Options allowed for Data field are:") + " "
- df_options_str = "" + " ".join([_(x) for x in data_field_options]) + " "
+ df_options_str = "" + " ".join(_(x) for x in data_field_options) + " "
frappe.msgprint(text_str + df_options_str, title="Invalid Data Field", raise_exception=True)
@@ -960,6 +1125,14 @@ def validate_fields(meta):
frappe.throw(_('Option {0} for field {1} is not a child table')
.format(frappe.bold(doctype), frappe.bold(docfield.fieldname)), title=_("Invalid Option"))
+ def check_max_height(docfield):
+ if getattr(docfield, 'max_height', None) and (docfield.max_height[-2:] not in ('px', 'em')):
+ frappe.throw('Max for {} height must be in px, em, rem'.format(frappe.bold(docfield.fieldname)))
+
+ def check_no_of_ratings(docfield):
+ if docfield.fieldtype == "Rating":
+ if docfield.options and (int(docfield.options) > 10 or int(docfield.options) < 3):
+ frappe.throw(_('Options for Rating field can range from 3 to 10'))
fields = meta.get("fields")
fieldname_list = [d.fieldname for d in fields]
@@ -993,12 +1166,15 @@ def validate_fields(meta):
scrub_options_in_select(d)
scrub_fetch_from(d)
validate_data_field_type(d)
+ check_max_height(d)
+ check_no_of_ratings(d)
check_fold(fields)
check_search_fields(meta, fields)
check_title_field(meta)
check_timeline_field(meta)
check_is_published_field(meta)
+ check_website_search_field(meta)
check_sort_field(meta)
check_image_field(meta)
@@ -1110,6 +1286,21 @@ def validate_permissions(doctype, for_remove=False, alert=False):
if d.get("import") and not isimportable:
frappe.throw(_("{0}: Cannot set import as {1} is not importable").format(get_txt(d), doctype))
+ def validate_permission_for_all_role(d):
+ if frappe.session.user == 'Administrator':
+ return
+
+ if doctype.custom:
+ if d.role == 'All':
+ frappe.throw(_('Row # {0}: Non administrator user can not set the role {1} to the custom doctype')
+ .format(d.idx, frappe.bold(_('All'))), title=_('Permissions Error'))
+
+ roles = [row.name for row in frappe.get_all('Role', filters={'is_custom': 1})]
+
+ if d.role in roles:
+ frappe.throw(_('Row # {0}: Non administrator user can not set the role {1} to the custom doctype')
+ .format(d.idx, frappe.bold(_(d.role))), title=_('Permissions Error'))
+
for d in permissions:
if not d.permlevel:
d.permlevel=0
@@ -1121,6 +1312,7 @@ def validate_permissions(doctype, for_remove=False, alert=False):
check_if_importable(d)
check_level_zero_is_set(d)
remove_rights_for_single(d)
+ validate_permission_for_all_role(d)
def make_module_and_roles(doc, perm_fieldname="permissions"):
"""Make `Module Def` and `Role` records if already not made. Called while installing."""
@@ -1132,15 +1324,21 @@ def make_module_and_roles(doc, perm_fieldname="permissions"):
if ("tabModule Def" in frappe.db.get_tables()
and not frappe.db.exists("Module Def", doc.module)):
m = frappe.get_doc({"doctype": "Module Def", "module_name": doc.module})
- m.app_name = frappe.local.module_app[frappe.scrub(doc.module)]
+ if frappe.scrub(doc.module) in frappe.local.module_app:
+ m.app_name = frappe.local.module_app[frappe.scrub(doc.module)]
+ else:
+ m.app_name = 'frappe'
m.flags.ignore_mandatory = m.flags.ignore_permissions = True
+ if frappe.flags.package:
+ m.package = frappe.flags.package.name
+ m.custom = 1
m.insert()
default_roles = ["Administrator", "Guest", "All"]
roles = [p.role for p in doc.get("permissions") or []] + default_roles
for role in list(set(roles)):
- if not frappe.db.exists("Role", role):
+ if frappe.db.table_exists("Role", cached=False) and not frappe.db.exists("Role", role):
r = frappe.get_doc(dict(doctype= "Role", role_name=role, desk_access=1))
r.flags.ignore_mandatory = r.flags.ignore_permissions = True
r.insert()
@@ -1152,12 +1350,20 @@ def make_module_and_roles(doc, perm_fieldname="permissions"):
else:
raise
-def check_if_fieldname_conflicts_with_methods(doctype, fieldname):
- doc = frappe.get_doc({"doctype": doctype})
- method_list = [method for method in dir(doc) if isinstance(method, str) and callable(getattr(doc, method))]
+def check_fieldname_conflicts(docfield):
+ """Checks if fieldname conflicts with methods or properties"""
+ doc = frappe.get_doc({"doctype": docfield.dt})
+ available_objects = [x for x in dir(doc) if isinstance(x, str)]
+ property_list = [
+ x for x in available_objects if isinstance(getattr(type(doc), x, None), property)
+ ]
+ method_list = [
+ x for x in available_objects if x not in property_list and callable(getattr(doc, x))
+ ]
+ msg = _("Fieldname {0} conflicting with meta object").format(docfield.fieldname)
- if fieldname in method_list:
- frappe.throw(_("Fieldname {0} conflicting with meta object").format(fieldname))
+ if docfield.fieldname in method_list + property_list:
+ frappe.msgprint(msg, raise_exception=not docfield.is_virtual)
def clear_linked_doctype_cache():
frappe.cache().delete_value('linked_doctypes_without_ignore_user_permissions_enabled')
diff --git a/frappe/core/doctype/doctype/test_doctype.js b/frappe/core/doctype/doctype/test_doctype.js
deleted file mode 100644
index 721d865e54..0000000000
--- a/frappe/core/doctype/doctype/test_doctype.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: DocType", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new DocType
- () => frappe.tests.make('DocType', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py
index ec88a2d14c..dc6d14b451 100644
--- a/frappe/core/doctype/doctype/test_doctype.py
+++ b/frappe/core/doctype/doctype/test_doctype.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
from frappe.core.doctype.doctype.doctype import (UniqueFieldnameError,
@@ -17,11 +15,16 @@ from frappe.core.doctype.doctype.doctype import (UniqueFieldnameError,
# test_records = frappe.get_test_records('DocType')
class TestDocType(unittest.TestCase):
+
+ def tearDown(self):
+ frappe.db.rollback()
+
def test_validate_name(self):
self.assertRaises(frappe.NameError, new_doctype("_Some DocType").insert)
self.assertRaises(frappe.NameError, new_doctype("8Some DocType").insert)
self.assertRaises(frappe.NameError, new_doctype("Some (DocType)").insert)
- for name in ("Some DocType", "Some_DocType"):
+ self.assertRaises(frappe.NameError, new_doctype("Some Doctype with a name whose length is more than 61 characters").insert)
+ for name in ("Some DocType", "Some_DocType", "Some-DocType"):
if frappe.db.exists("DocType", name):
frappe.delete_doc("DocType", name)
@@ -44,6 +47,7 @@ class TestDocType(unittest.TestCase):
doc1.insert()
self.assertRaises(frappe.UniqueValidationError, doc2.insert)
+ frappe.db.rollback()
dt.fields[0].unique = 0
dt.save()
@@ -92,7 +96,7 @@ class TestDocType(unittest.TestCase):
fields=["parent", "depends_on", "collapsible_depends_on", "mandatory_depends_on",\
"read_only_depends_on", "fieldname", "fieldtype"])
- pattern = """[\w\.:_]+\s*={1}\s*[\w\.@'"]+"""
+ pattern = r'[\w\.:_]+\s*={1}\s*[\w\.@\'"]+'
for field in docfields:
for depends_on in ["depends_on", "collapsible_depends_on", "mandatory_depends_on", "read_only_depends_on"]:
condition = field.get(depends_on)
@@ -480,10 +484,44 @@ class TestDocType(unittest.TestCase):
'link_doctype': "User",
'link_fieldname': "a_field_that_does_not_exists"
})
+
self.assertRaises(InvalidFieldNameError, validate_links_table_fieldnames, doc)
+ def test_create_virtual_doctype(self):
+ """Test virtual DOcTYpe."""
+ virtual_doc = new_doctype('Test Virtual Doctype')
+ virtual_doc.is_virtual = 1
+ virtual_doc.insert()
+ virtual_doc.save()
+ doc = frappe.get_doc("DocType", "Test Virtual Doctype")
-def new_doctype(name, unique=0, depends_on='', fields=None):
+ self.assertEqual(doc.is_virtual, 1)
+ self.assertFalse(frappe.db.table_exists('Test Virtual Doctype'))
+
+ def test_default_fieldname(self):
+ fields = [{"label": "title", "fieldname": "title", "fieldtype": "Data", "default": "{some_fieldname}"}]
+ dt = new_doctype("DT with default field", fields=fields)
+ dt.insert()
+
+ dt.delete()
+
+ def test_autoincremented_doctype_transition(self):
+ frappe.delete_doc("testy_autoinc_dt")
+ dt = new_doctype("testy_autoinc_dt", autoincremented=True).insert(ignore_permissions=True)
+ dt.autoname = "hash"
+
+ try:
+ dt.save(ignore_permissions=True)
+ except frappe.ValidationError as e:
+ self.assertEqual(e.args[0], "Cannot change to/from Autoincrement naming rule")
+ else:
+ self.fail("Shouldnt be possible to transition autoincremented doctype to any other naming rule")
+ finally:
+ # cleanup
+ dt.delete(ignore_permissions=True)
+
+
+def new_doctype(name, unique=0, depends_on='', fields=None, autoincremented=False):
doc = frappe.get_doc({
"doctype": "DocType",
"module": "Core",
@@ -499,11 +537,12 @@ def new_doctype(name, unique=0, depends_on='', fields=None):
"role": "System Manager",
"read": 1,
}],
- "name": name
+ "name": name,
+ "autoname": "autoincrement" if autoincremented else ""
})
if fields:
for f in fields:
doc.append('fields', f)
- return doc
\ No newline at end of file
+ return doc
diff --git a/frappe/core/doctype/doctype_action/doctype_action.py b/frappe/core/doctype/doctype_action/doctype_action.py
index a745c7da40..807d1bf0b1 100644
--- a/frappe/core/doctype/doctype_action/doctype_action.py
+++ b/frappe/core/doctype/doctype_action/doctype_action.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/doctype_link/doctype_link.json b/frappe/core/doctype/doctype_link/doctype_link.json
index 0453894467..4baec6746d 100644
--- a/frappe/core/doctype/doctype_link/doctype_link.json
+++ b/frappe/core/doctype/doctype_link/doctype_link.json
@@ -7,8 +7,11 @@
"field_order": [
"link_doctype",
"link_fieldname",
+ "parent_doctype",
+ "table_fieldname",
"group",
"hidden",
+ "is_child_table",
"custom"
],
"fields": [
@@ -45,12 +48,33 @@
"fieldtype": "Check",
"hidden": 1,
"label": "Custom"
+ },
+ {
+ "depends_on": "is_child_table",
+ "fieldname": "parent_doctype",
+ "fieldtype": "Link",
+ "label": "Parent DocType",
+ "mandatory_depends_on": "is_child_table",
+ "options": "DocType"
+ },
+ {
+ "default": "0",
+ "fetch_from": "link_doctype.istable",
+ "fieldname": "is_child_table",
+ "fieldtype": "Check",
+ "label": "Is Child Table",
+ "read_only": 1
+ },
+ {
+ "fieldname": "table_fieldname",
+ "fieldtype": "Data",
+ "label": "Table Fieldname"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2020-09-24 14:19:25.189511",
+ "modified": "2021-07-31 15:23:12.237491",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType Link",
diff --git a/frappe/core/doctype/doctype_link/doctype_link.py b/frappe/core/doctype/doctype_link/doctype_link.py
index efe8b09809..ca2c4efa16 100644
--- a/frappe/core/doctype/doctype_link/doctype_link.py
+++ b/frappe/core/doctype/doctype_link/doctype_link.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/chat/doctype/chat_message/__init__.py b/frappe/core/doctype/doctype_state/__init__.py
similarity index 100%
rename from frappe/chat/doctype/chat_message/__init__.py
rename to frappe/core/doctype/doctype_state/__init__.py
diff --git a/frappe/core/doctype/doctype_state/doctype_state.json b/frappe/core/doctype/doctype_state/doctype_state.json
new file mode 100644
index 0000000000..79797b41c5
--- /dev/null
+++ b/frappe/core/doctype/doctype_state/doctype_state.json
@@ -0,0 +1,50 @@
+{
+ "actions": [],
+ "creation": "2021-08-23 17:21:28.345841",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "color",
+ "custom"
+ ],
+ "fields": [
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "default": "Blue",
+ "fieldname": "color",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Color",
+ "options": "Blue\nCyan\nGray\nGreen\nLight Blue\nOrange\nPink\nPurple\nRed\nYellow",
+ "reqd": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "custom",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Custom"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-12-14 14:14:55.716378",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "DocType State",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/doctype_state/doctype_state.py b/frappe/core/doctype/doctype_state/doctype_state.py
new file mode 100644
index 0000000000..3172834180
--- /dev/null
+++ b/frappe/core/doctype/doctype_state/doctype_state.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2021, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+class DocTypeState(Document):
+ pass
diff --git a/frappe/core/doctype/document_naming_rule/document_naming_rule.js b/frappe/core/doctype/document_naming_rule/document_naming_rule.js
index c7413a9b09..097a4e9a6e 100644
--- a/frappe/core/doctype/document_naming_rule/document_naming_rule.js
+++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.js
@@ -4,6 +4,7 @@
frappe.ui.form.on('Document Naming Rule', {
refresh: function(frm) {
frm.trigger('document_type');
+ if (!frm.doc.__islocal) frm.trigger("add_update_counter_button");
},
document_type: (frm) => {
// update the select field options with fieldnames
@@ -15,9 +16,49 @@ frappe.ui.form.on('Document Naming Rule', {
}).map((d) => {
return {label: `${d.label} (${d.fieldname})`, value: d.fieldname};
});
- frappe.meta.get_docfield('Document Naming Rule Condition', 'field', frm.doc.name).options = fieldnames;
- frm.refresh_field('conditions');
+ frm.fields_dict.conditions.grid.update_docfield_property(
+ 'field', 'options', fieldnames
+ );
});
}
+ },
+ add_update_counter_button: (frm) => {
+ frm.add_custom_button(__('Update Counter'), function() {
+
+ const fields = [{
+ fieldtype: 'Data',
+ fieldname: 'new_counter',
+ label: __('New Counter'),
+ default: frm.doc.counter,
+ reqd: 1,
+ description: __('Warning: Updating counter may lead to document name conflicts if not done properly')
+ }];
+
+ let primary_action_label = __('Save');
+
+ let primary_action = (fields) => {
+ frappe.call({
+ method: 'frappe.core.doctype.document_naming_rule.document_naming_rule.update_current',
+ args: {
+ name: frm.doc.name,
+ new_counter: fields.new_counter
+ },
+ callback: function() {
+ frm.set_value("counter", fields.new_counter);
+ dialog.hide();
+ }
+ });
+ };
+
+ const dialog = new frappe.ui.Dialog({
+ title: __('Update Counter Value for Prefix: {0}', [frm.doc.prefix]),
+ fields,
+ primary_action_label,
+ primary_action
+ });
+
+ dialog.show();
+
+ });
}
});
diff --git a/frappe/core/doctype/document_naming_rule/document_naming_rule.json b/frappe/core/doctype/document_naming_rule/document_naming_rule.json
index 4a88e3be6e..4e6f3f3fd1 100644
--- a/frappe/core/doctype/document_naming_rule/document_naming_rule.json
+++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.json
@@ -41,6 +41,7 @@
"fieldname": "counter",
"fieldtype": "Int",
"label": "Counter",
+ "no_copy": 1,
"read_only": 1
},
{
@@ -79,7 +80,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-11-04 14:38:14.836056",
+ "modified": "2021-09-13 20:07:47.617615",
"modified_by": "Administrator",
"module": "Core",
"name": "Document Naming Rule",
diff --git a/frappe/core/doctype/document_naming_rule/document_naming_rule.py b/frappe/core/doctype/document_naming_rule/document_naming_rule.py
index 4b34293af6..5c445fd058 100644
--- a/frappe/core/doctype/document_naming_rule/document_naming_rule.py
+++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.py
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe.utils.data import evaluate_filters
+from frappe.model.naming import parse_naming_series
from frappe import _
class DocumentNamingRule(Document):
@@ -28,5 +28,12 @@ class DocumentNamingRule(Document):
return
counter = frappe.db.get_value(self.doctype, self.name, 'counter', for_update=True) or 0
- doc.name = self.prefix + ('%0'+str(self.prefix_digits)+'d') % (counter + 1)
+ naming_series = parse_naming_series(self.prefix, doc=doc)
+
+ doc.name = naming_series + ('%0'+str(self.prefix_digits)+'d') % (counter + 1)
frappe.db.set_value(self.doctype, self.name, 'counter', counter + 1)
+
+@frappe.whitelist()
+def update_current(name, new_counter):
+ frappe.only_for('System Manager')
+ frappe.db.set_value('Document Naming Rule', name, 'counter', new_counter)
diff --git a/frappe/core/doctype/document_naming_rule/test_document_naming_rule.py b/frappe/core/doctype/document_naming_rule/test_document_naming_rule.py
index 1b91f6a0cf..50f1386758 100644
--- a/frappe/core/doctype/document_naming_rule/test_document_naming_rule.py
+++ b/frappe/core/doctype/document_naming_rule/test_document_naming_rule.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.py b/frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.py
index 0895c9f93f..4706492cea 100644
--- a/frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.py
+++ b/frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/document_naming_rule_condition/test_document_naming_rule_condition.py b/frappe/core/doctype/document_naming_rule_condition/test_document_naming_rule_condition.py
index 6f1376dc62..3d0565234c 100644
--- a/frappe/core/doctype/document_naming_rule_condition/test_document_naming_rule_condition.py
+++ b/frappe/core/doctype/document_naming_rule_condition/test_document_naming_rule_condition.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/core/doctype/domain/domain.py b/frappe/core/doctype/domain/domain.py
index a4e9f503ab..ebd6e3ac9e 100644
--- a/frappe/core/doctype/domain/domain.py
+++ b/frappe/core/doctype/domain/domain.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
@@ -111,7 +110,7 @@ class Domain(Document):
# enable
frappe.db.sql('''update `tabPortal Menu Item` set enabled=1
- where route in ({0})'''.format(', '.join(['"{0}"'.format(d) for d in self.data.allow_sidebar_items])))
+ where route in ({0})'''.format(', '.join('"{0}"'.format(d) for d in self.data.allow_sidebar_items)))
if self.data.remove_sidebar_items:
# disable all
@@ -119,4 +118,4 @@ class Domain(Document):
# enable
frappe.db.sql('''update `tabPortal Menu Item` set enabled=0
- where route in ({0})'''.format(', '.join(['"{0}"'.format(d) for d in self.data.remove_sidebar_items])))
+ where route in ({0})'''.format(', '.join('"{0}"'.format(d) for d in self.data.remove_sidebar_items)))
diff --git a/frappe/core/doctype/domain/test_domain.js b/frappe/core/doctype/domain/test_domain.js
deleted file mode 100644
index 6d8bd8039d..0000000000
--- a/frappe/core/doctype/domain/test_domain.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Domain", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially('Domain', [
- // insert a new Domain
- () => frappe.tests.make([
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/domain/test_domain.py b/frappe/core/doctype/domain/test_domain.py
index 8e0bc65c54..d7924ebc90 100644
--- a/frappe/core/doctype/domain/test_domain.py
+++ b/frappe/core/doctype/domain/test_domain.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/domain_settings/domain_settings.py b/frappe/core/doctype/domain_settings/domain_settings.py
index d4d394a5cb..276411c2ab 100644
--- a/frappe/core/doctype/domain_settings/domain_settings.py
+++ b/frappe/core/doctype/domain_settings/domain_settings.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
@@ -35,7 +34,7 @@ class DomainSettings(Document):
all_domains = list((frappe.get_hooks('domains') or {}))
def remove_role(role):
- frappe.db.sql('delete from `tabHas Role` where role=%s', role)
+ frappe.db.delete("Has Role", {"role": role})
frappe.set_value('Role', role, 'disabled', 1)
for domain in all_domains:
diff --git a/frappe/core/doctype/dynamic_link/dynamic_link.py b/frappe/core/doctype/dynamic_link/dynamic_link.py
index 30e0ef1f1f..c0502824c6 100644
--- a/frappe/core/doctype/dynamic_link/dynamic_link.py
+++ b/frappe/core/doctype/dynamic_link/dynamic_link.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/error_log/error_log.json b/frappe/core/doctype/error_log/error_log.json
index cdc7a63001..35ca3ceeef 100644
--- a/frappe/core/doctype/error_log/error_log.json
+++ b/frappe/core/doctype/error_log/error_log.json
@@ -112,7 +112,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
- "modified": "2017-03-14 12:21:44.292471",
+ "modified": "2021-10-25 12:21:44.292471",
"modified_by": "Administrator",
"module": "Core",
"name": "Error Log",
@@ -144,6 +144,5 @@
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_order": "ASC",
- "track_changes": 1,
"track_seen": 0
-}
\ No newline at end of file
+}
diff --git a/frappe/core/doctype/error_log/error_log.py b/frappe/core/doctype/error_log/error_log.py
index ec02aaf446..39c307520f 100644
--- a/frappe/core/doctype/error_log/error_log.py
+++ b/frappe/core/doctype/error_log/error_log.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
@@ -21,4 +20,4 @@ def set_old_logs_as_seen():
def clear_error_logs():
'''Flush all Error Logs'''
frappe.only_for('System Manager')
- frappe.db.sql('''DELETE FROM `tabError Log`''')
\ No newline at end of file
+ frappe.db.truncate("Error Log")
diff --git a/frappe/core/doctype/error_log/test_error_log.py b/frappe/core/doctype/error_log/test_error_log.py
index d93fe07c61..54a41cd4a9 100644
--- a/frappe/core/doctype/error_log/test_error_log.py
+++ b/frappe/core/doctype/error_log/test_error_log.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/error_snapshot/error_snapshot.json b/frappe/core/doctype/error_snapshot/error_snapshot.json
index ea7a86d4f6..1333fe0d5b 100644
--- a/frappe/core/doctype/error_snapshot/error_snapshot.json
+++ b/frappe/core/doctype/error_snapshot/error_snapshot.json
@@ -359,7 +359,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
- "modified": "2016-12-29 14:40:38.619106",
+ "modified": "2021-10-25 14:40:38.619106",
"modified_by": "Administrator",
"module": "Core",
"name": "Error Snapshot",
@@ -394,6 +394,5 @@
"sort_field": "timestamp",
"sort_order": "DESC",
"title_field": "evalue",
- "track_changes": 1,
"track_seen": 0
-}
\ No newline at end of file
+}
diff --git a/frappe/core/doctype/error_snapshot/error_snapshot.py b/frappe/core/doctype/error_snapshot/error_snapshot.py
index 5badaad63f..85143b5aa6 100644
--- a/frappe/core/doctype/error_snapshot/error_snapshot.py
+++ b/frappe/core/doctype/error_snapshot/error_snapshot.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/error_snapshot/test_error_snapshot.py b/frappe/core/doctype/error_snapshot/test_error_snapshot.py
index b6438eae1d..86928db9cc 100644
--- a/frappe/core/doctype/error_snapshot/test_error_snapshot.py
+++ b/frappe/core/doctype/error_snapshot/test_error_snapshot.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/chat/doctype/chat_profile/__init__.py b/frappe/core/doctype/feedback/__init__.py
similarity index 100%
rename from frappe/chat/doctype/chat_profile/__init__.py
rename to frappe/core/doctype/feedback/__init__.py
diff --git a/frappe/core/doctype/feedback/feedback.js b/frappe/core/doctype/feedback/feedback.js
new file mode 100644
index 0000000000..131f0e19d8
--- /dev/null
+++ b/frappe/core/doctype/feedback/feedback.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2021, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Feedback', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/frappe/core/doctype/feedback/feedback.json b/frappe/core/doctype/feedback/feedback.json
new file mode 100644
index 0000000000..f8380cfda6
--- /dev/null
+++ b/frappe/core/doctype/feedback/feedback.json
@@ -0,0 +1,73 @@
+{
+ "actions": [],
+ "creation": "2021-06-03 19:02:55.328423",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "reference_doctype",
+ "reference_name",
+ "column_break_3",
+ "like",
+ "ip_address"
+ ],
+ "fields": [
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "reference_doctype",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Reference Document Type",
+ "options": "\nBlog Post"
+ },
+ {
+ "fieldname": "reference_name",
+ "fieldtype": "Dynamic Link",
+ "in_list_view": 1,
+ "label": "Reference Name",
+ "options": "reference_doctype",
+ "reqd": 1
+ },
+ {
+ "fieldname": "ip_address",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "IP Address",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "like",
+ "fieldtype": "Check",
+ "label": "Like"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-11-10 20:53:21.255593",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "Feedback",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "title_field": "reference_name",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/feedback/feedback.py b/frappe/core/doctype/feedback/feedback.py
new file mode 100644
index 0000000000..3704ee66e0
--- /dev/null
+++ b/frappe/core/doctype/feedback/feedback.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+# import frappe
+from frappe.model.document import Document
+
+class Feedback(Document):
+ pass
diff --git a/frappe/core/doctype/feedback/test_feedback.py b/frappe/core/doctype/feedback/test_feedback.py
new file mode 100644
index 0000000000..66f644ccd3
--- /dev/null
+++ b/frappe/core/doctype/feedback/test_feedback.py
@@ -0,0 +1,39 @@
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# License: MIT. See LICENSE
+
+import frappe
+import unittest
+
+class TestFeedback(unittest.TestCase):
+ def tearDown(self):
+ frappe.form_dict.reference_doctype = None
+ frappe.form_dict.reference_name = None
+ frappe.form_dict.like = None
+ frappe.local.request_ip = None
+
+ def test_feedback_creation_updation(self):
+ from frappe.website.doctype.blog_post.test_blog_post import make_test_blog
+ test_blog = make_test_blog()
+
+ frappe.db.delete("Feedback", {"reference_doctype": "Blog Post"})
+
+ from frappe.templates.includes.feedback.feedback import give_feedback
+
+ frappe.form_dict.reference_doctype = 'Blog Post'
+ frappe.form_dict.reference_name = test_blog.name
+ frappe.form_dict.like = True
+ frappe.local.request_ip = '127.0.0.1'
+
+ feedback = give_feedback()
+
+ self.assertEqual(feedback.like, True)
+
+ frappe.form_dict.like = False
+
+ updated_feedback = give_feedback()
+
+ self.assertEqual(updated_feedback.like, False)
+
+ frappe.db.delete("Feedback", {"reference_doctype": "Blog Post"})
+
+ test_blog.delete()
\ No newline at end of file
diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js
index 6d77cb91ad..d40328d3cd 100644
--- a/frappe/core/doctype/file/file.js
+++ b/frappe/core/doctype/file/file.js
@@ -23,6 +23,18 @@ frappe.ui.form.on("File", "refresh", function(frm) {
wrapper.empty();
}
+ var is_raster_image = (/\.(gif|jpg|jpeg|tiff|png)$/i).test(frm.doc.file_url);
+ var is_optimizable = !frm.doc.is_folder && is_raster_image && frm.doc.file_size > 0;
+
+ if (is_optimizable) {
+ frm.add_custom_button(__("Optimize"), function() {
+ frappe.show_alert(__("Optimizing image..."));
+ frm.call("optimize_file").then(() => {
+ frappe.show_alert(__("Image optimized"));
+ });
+ });
+ }
+
if(frm.doc.file_name && frm.doc.file_name.split('.').splice(-1)[0]==='zip') {
frm.add_custom_button(__('Unzip'), function() {
frappe.call({
diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py
index 445ca1184d..50a7b31bca 100755
--- a/frappe/core/doctype/file/file.py
+++ b/frappe/core/doctype/file/file.py
@@ -1,5 +1,5 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
"""
record of files
@@ -7,9 +7,6 @@ record of files
naming for same name files: file.gif, file-1.gif, file-2.gif etc
"""
-from __future__ import unicode_literals
-
-import base64
import hashlib
import imghdr
import io
@@ -19,18 +16,25 @@ import os
import re
import shutil
import zipfile
+from typing import TYPE_CHECKING, Tuple
import requests
-import requests.exceptions
+from requests.exceptions import HTTPError, SSLError
from PIL import Image, ImageFile, ImageOps
-from six import PY2, StringIO, string_types, text_type
-from six.moves.urllib.parse import quote, unquote
+from io import BytesIO
+from urllib.parse import quote, unquote
import frappe
-from frappe import _, conf
+from frappe import _, conf, safe_decode
from frappe.model.document import Document
from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip
-from frappe.utils.image import strip_exif_data
+from frappe.utils.image import strip_exif_data, optimize_image
+from frappe.utils.file_manager import safe_b64decode
+
+if TYPE_CHECKING:
+ from PIL.ImageFile import ImageFile
+ from requests.models import Response
+
class MaxFileSizeReachedError(frappe.ValidationError):
pass
@@ -75,7 +79,7 @@ class File(Document):
self.add_comment_in_reference_doc('Attachment',
_('Added {0}').format("{file_name} {icon}".format(**{
"icon": ' ' if self.is_private else "",
- "file_url": quote(self.file_url) if self.file_url else self.file_name,
+ "file_url": quote(frappe.safe_encode(self.file_url)) if self.file_url else self.file_name,
"file_name": self.file_name or self.file_url
})))
@@ -94,52 +98,89 @@ class File(Document):
self.set_file_name()
self.validate_duplicate_entry()
self.validate_attachment_limit()
+
self.validate_folder()
- if not self.file_url and not self.flags.ignore_file_validate:
- if not self.is_folder:
+ if self.is_folder:
+ self.file_url = ""
+ else:
+ self.validate_url()
+
+ self.file_size = frappe.form_dict.file_size or self.file_size
+
+ def validate_url(self):
+ if not self.file_url or self.file_url.startswith(("http://", "https://")):
+ if not self.flags.ignore_file_validate:
self.validate_file()
- self.generate_content_hash()
- if frappe.db.exists('File', {'name': self.name, 'is_folder': 0}):
- old_file_url = self.file_url
- if not self.is_folder and (self.is_private != self.db_get('is_private')):
- private_files = frappe.get_site_path('private', 'files')
- public_files = frappe.get_site_path('public', 'files')
+ return
- file_name = self.file_url.split('/')[-1]
- if not self.is_private:
- shutil.move(os.path.join(private_files, file_name),
- os.path.join(public_files, file_name))
+ # Probably an invalid web URL
+ if not self.file_url.startswith(("/files/", "/private/files/")):
+ frappe.throw(
+ _("URL must start with http:// or https://"),
+ title=_('Invalid URL')
+ )
- self.file_url = "/files/{0}".format(file_name)
+ # Ensure correct formatting and type
+ self.file_url = unquote(self.file_url)
+ self.is_private = cint(self.is_private)
- else:
- shutil.move(os.path.join(public_files, file_name),
- os.path.join(private_files, file_name))
+ self.handle_is_private_changed()
- self.file_url = "/private/files/{0}".format(file_name)
+ base_path = os.path.realpath(get_files_path(is_private=self.is_private))
+ if not os.path.realpath(self.get_full_path()).startswith(base_path):
+ frappe.throw(
+ _("The File URL you've entered is incorrect"),
+ title=_('Invalid File URL')
+ )
- update_existing_file_docs(self)
+ def handle_is_private_changed(self):
+ if not frappe.db.exists(
+ 'File', {
+ 'name': self.name,
+ 'is_private': cint(not self.is_private)
+ }
+ ):
+ return
- # update documents image url with new file url
- if self.attached_to_doctype and self.attached_to_name:
- if not self.attached_to_field:
- field_name = None
- reference_dict = frappe.get_doc(self.attached_to_doctype, self.attached_to_name).as_dict()
- for key, value in reference_dict.items():
- if value == old_file_url:
- field_name = key
- break
- self.attached_to_field = field_name
- if self.attached_to_field:
- frappe.db.set_value(self.attached_to_doctype, self.attached_to_name,
- self.attached_to_field, self.file_url)
+ old_file_url = self.file_url
- self.validate_url()
+ file_name = self.file_url.split('/')[-1]
+ private_file_path = frappe.get_site_path('private', 'files', file_name)
+ public_file_path = frappe.get_site_path('public', 'files', file_name)
- if self.file_url and (self.is_private != self.file_url.startswith('/private')):
- frappe.throw(_('Invalid file URL. Please contact System Administrator.'))
+ if self.is_private:
+ shutil.move(public_file_path, private_file_path)
+ url_starts_with = "/private/files/"
+ else:
+ shutil.move(private_file_path, public_file_path)
+ url_starts_with = "/files/"
+
+ self.file_url = "{0}{1}".format(url_starts_with, file_name)
+ update_existing_file_docs(self)
+
+ if (
+ not self.attached_to_doctype
+ or not self.attached_to_name
+ or not self.fetch_attached_to_field(old_file_url)
+ ):
+ return
+
+ frappe.db.set_value(self.attached_to_doctype, self.attached_to_name,
+ self.attached_to_field, self.file_url)
+
+ def fetch_attached_to_field(self, old_file_url):
+ if self.attached_to_field:
+ return True
+
+ reference_dict = frappe.get_doc(
+ self.attached_to_doctype, self.attached_to_name).as_dict()
+
+ for key, value in reference_dict.items():
+ if value == old_file_url:
+ self.attached_to_field = key
+ return True
def validate_attachment_limit(self):
attachment_limit = 0
@@ -219,11 +260,11 @@ class File(Document):
return
file_name = self.file_url.split('/')[-1]
try:
- with open(get_files_path(file_name, is_private=self.is_private), "rb") as f:
+ file_path = get_files_path(file_name, is_private=self.is_private)
+ with open(file_path, "rb") as f:
self.content_hash = get_content_hash(f.read())
except IOError:
- frappe.msgprint(_("File {0} does not exist").format(self.file_url))
- raise
+ frappe.throw(_("File {0} does not exist").format(file_path))
def on_trash(self):
if self.is_home_folder or self.is_attachments_folder:
@@ -235,16 +276,12 @@ class File(Document):
def make_thumbnail(self, set_as_thumbnail=True, width=300, height=300, suffix="small", crop=False):
if self.file_url:
- if self.file_url.startswith("/files"):
- try:
+ try:
+ if self.file_url.startswith(("/files", "/private/files")):
image, filename, extn = get_local_image(self.file_url)
- except IOError:
- return
-
- else:
- try:
+ else:
image, filename, extn = get_web_image(self.file_url)
- except (requests.exceptions.HTTPError, requests.exceptions.SSLError, IOError, TypeError):
+ except (HTTPError, SSLError, IOError, TypeError):
return
size = width, height
@@ -254,16 +291,13 @@ class File(Document):
image.thumbnail(size, Image.ANTIALIAS)
thumbnail_url = filename + "_" + suffix + "." + extn
-
path = os.path.abspath(frappe.get_site_path("public", thumbnail_url.lstrip("/")))
try:
image.save(path)
-
if set_as_thumbnail:
self.db_set("thumbnail_url", thumbnail_url)
- self.db_set("thumbnail_url", thumbnail_url)
except IOError:
frappe.msgprint(_("Unable to write file format for {0}").format(path))
return
@@ -286,17 +320,23 @@ class File(Document):
self.delete_file_data_content(only_thumbnail=True)
def on_rollback(self):
- self.flags.on_rollback = True
- self.on_trash()
+ # if original_content flag is set, this rollback should revert the file to its original state
+ if self.flags.original_content:
+ file_path = self.get_full_path()
+ with open(file_path, "wb+") as f:
+ f.write(self.flags.original_content)
+
+ # following condition is only executed when an insert has been rolledback
+ else:
+ self.flags.on_rollback = True
+ self.on_trash()
def unzip(self):
'''Unzip current file and replace it by its children'''
- if not ".zip" in self.file_name:
- frappe.msgprint(_("Not a zip file"))
- return
+ if not self.file_url.endswith(".zip"):
+ frappe.throw(_("{0} is not a zip file").format(self.file_name))
- zip_path = frappe.get_site_path(self.file_url.strip('/'))
- base_url = os.path.dirname(self.file_url)
+ zip_path = self.get_full_path()
files = []
with zipfile.ZipFile(zip_path) as z:
@@ -324,10 +364,6 @@ class File(Document):
return files
- def get_file_url(self):
- data = frappe.db.get_value("File", self.file_data_name, ["file_name", "file_url"], as_dict=True)
- return data.file_url or data.file_name
-
def exists_on_disk(self):
exists = os.path.exists(self.get_full_path())
return exists
@@ -335,23 +371,24 @@ class File(Document):
def get_content(self):
"""Returns [`file_name`, `content`] for given file name `fname`"""
+ if self.is_folder:
+ frappe.throw(_("Cannot get file contents of a Folder"))
+
if self.get('content'):
return self.content
+
+ self.validate_url()
file_path = self.get_full_path()
# read the file
- if PY2:
- with open(encode(file_path)) as f:
- content = f.read()
- else:
- with io.open(encode(file_path), mode='rb') as f:
- content = f.read()
- try:
- # for plain text files
- content = content.decode()
- except UnicodeDecodeError:
- # for .png, .jpg, etc
- pass
+ with io.open(encode(file_path), mode='rb') as f:
+ content = f.read()
+ try:
+ # for plain text files
+ content = content.decode()
+ except UnicodeDecodeError:
+ # for .png, .jpg, etc
+ pass
return content
@@ -388,82 +425,24 @@ class File(Document):
frappe.create_folder(file_path)
# write the file
self.content = self.get_content()
- if isinstance(self.content, text_type):
+ if isinstance(self.content, str):
self.content = self.content.encode()
with open(os.path.join(file_path.encode('utf-8'), self.file_name.encode('utf-8')), 'wb+') as f:
f.write(self.content)
return get_files_path(self.file_name, is_private=self.is_private)
- def get_file_doc(self):
- '''returns File object (Document) from given parameters or form_dict'''
- r = frappe.form_dict
-
- if self.file_url is None: self.file_url = r.file_url
- if self.file_name is None: self.file_name = r.file_name
- if self.attached_to_doctype is None: self.attached_to_doctype = r.doctype
- if self.attached_to_name is None: self.attached_to_name = r.docname
- if self.attached_to_field is None: self.attached_to_field = r.docfield
- if self.folder is None: self.folder = r.folder
- if self.is_private is None: self.is_private = r.is_private
-
- if r.filedata:
- file_doc = self.save_uploaded()
-
- elif r.file_url:
- file_doc = self.save()
-
- return file_doc
-
-
- def save_uploaded(self):
- self.content = self.get_uploaded_content()
- if self.content:
- return self.save()
- else:
- raise Exception
-
-
- def validate_url(self, df=None):
- if self.file_url:
- if not self.file_url.startswith(("http://", "https://", "/files/", "/private/files/")):
- frappe.throw(_("URL must start with 'http://' or 'https://'"))
- return
-
- if not self.file_url.startswith(("http://", "https://")):
- # local file
- root_files_path = get_files_path(is_private=self.is_private)
- if not os.path.commonpath([root_files_path]) == os.path.commonpath([root_files_path, self.get_full_path()]):
- # basically the file url is skewed to not point to /files/ or /private/files
- frappe.throw(_("{0} is not a valid file url").format(self.file_url))
- self.file_url = unquote(self.file_url)
- self.file_size = frappe.form_dict.file_size or self.file_size
-
-
- def get_uploaded_content(self):
- # should not be unicode when reading a file, hence using frappe.form
- if 'filedata' in frappe.form_dict:
- if "," in frappe.form_dict.filedata:
- frappe.form_dict.filedata = frappe.form_dict.filedata.rsplit(",", 1)[1]
- frappe.uploaded_content = base64.b64decode(frappe.form_dict.filedata)
- return frappe.uploaded_content
- elif self.content:
- return self.content
- frappe.msgprint(_('No file attached'))
- return None
-
-
def save_file(self, content=None, decode=False, ignore_existing_file_check=False):
file_exists = False
self.content = content
if decode:
- if isinstance(content, text_type):
+ if isinstance(content, str):
self.content = content.encode("utf-8")
if b"," in self.content:
self.content = self.content.split(b",")[1]
- self.content = base64.b64decode(self.content)
+ self.content = safe_b64decode(self.content)
if not self.is_private:
self.is_private = 0
@@ -473,7 +452,7 @@ class File(Document):
self.file_size = self.check_max_file_size()
if (
- self.content_type and "image" in self.content_type
+ self.content_type and self.content_type == "image/jpeg"
and frappe.get_system_settings("strip_exif_metadata_from_uploaded_images")
):
self.content = strip_exif_data(self.content, self.content_type)
@@ -520,14 +499,6 @@ class File(Document):
'file_url': self.file_url
}
- def get_file_data_from_hash(self):
- for name in frappe.db.sql_list("select name from `tabFile` where content_hash=%s and is_private=%s",
- (self.content_hash, self.is_private)):
- b = frappe.get_doc('File', name)
- return {k: b.get(k) for k in frappe.get_hooks()['write_file_keys']}
- return False
-
-
def check_max_file_size(self):
max_file_size = get_max_file_size()
file_size = len(self.content)
@@ -575,6 +546,51 @@ class File(Document):
if self.file_url:
self.is_private = cint(self.file_url.startswith('/private'))
+ @frappe.whitelist()
+ def optimize_file(self):
+ if self.is_folder:
+ raise TypeError('Folders cannot be optimized')
+
+ content_type = mimetypes.guess_type(self.file_name)[0]
+ is_local_image = content_type.startswith('image/') and self.file_size > 0
+ is_svg = content_type == 'image/svg+xml'
+
+ if not is_local_image:
+ raise NotImplementedError('Only local image files can be optimized')
+
+ if is_svg:
+ raise TypeError('Optimization of SVG images is not supported')
+
+ content = self.get_content()
+ file_path = self.get_full_path()
+ optimized_content = optimize_image(content, content_type)
+
+ with open(file_path, 'wb+') as f:
+ f.write(optimized_content)
+
+ self.file_size = len(optimized_content)
+ self.content_hash = get_content_hash(optimized_content)
+ # if rolledback, revert back to original
+ self.flags.original_content = content
+ frappe.local.rollback_observers.append(self)
+ self.save()
+
+ @staticmethod
+ def zip_files(files):
+ zip_file = io.BytesIO()
+ zf = zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED)
+ for _file in files:
+ if isinstance(_file, str):
+ _file = frappe.get_doc("File", _file)
+ if not isinstance(_file, File):
+ continue
+ if _file.is_folder:
+ continue
+ zf.writestr(_file.file_name, _file.get_content())
+ zf.close()
+ return zip_file.getvalue()
+
+
def on_doctype_update():
frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"])
@@ -602,12 +618,13 @@ def create_new_folder(file_name, folder):
file.file_name = file_name
file.is_folder = 1
file.folder = folder
- file.insert()
+ file.insert(ignore_if_duplicate=True)
+ return file
@frappe.whitelist()
def move_file(file_list, new_parent, old_parent):
- if isinstance(file_list, string_types):
+ if isinstance(file_list, str):
file_list = json.loads(file_list)
for file_obj in file_list:
@@ -617,6 +634,16 @@ def move_file(file_list, new_parent, old_parent):
frappe.get_doc("File", old_parent).save()
frappe.get_doc("File", new_parent).save()
+
+@frappe.whitelist()
+def zip_files(files):
+ files = frappe.parse_json(files)
+ zipped_files = File.zip_files(files)
+ frappe.response["filename"] = "files.zip"
+ frappe.response["filecontent"] = zipped_files
+ frappe.response["type"] = "download"
+
+
def setup_folder_path(filename, new_parent):
file = frappe.get_doc("File", filename)
file.folder = new_parent
@@ -626,9 +653,17 @@ def setup_folder_path(filename, new_parent):
from frappe.model.rename_doc import rename_doc
rename_doc("File", file.name, file.get_name_based_on_parent_folder(), ignore_permissions=True)
-def get_extension(filename, extn, content):
+def get_extension(filename, extn, content: bytes = None, response: "Response" = None) -> str:
mimetype = None
+ if response:
+ content_type = response.headers.get("Content-Type")
+
+ if content_type:
+ _extn = mimetypes.guess_extension(content_type)
+ if _extn:
+ return _extn[1:]
+
if extn:
# remove '?' char and parameters from extn if present
if '?' in extn:
@@ -653,7 +688,7 @@ def get_local_image(file_url):
try:
image = Image.open(file_path)
except IOError:
- frappe.msgprint(_("Unable to read file format for {0}").format(file_url), raise_exception=True)
+ frappe.throw(_("Unable to read file format for {0}").format(file_url))
content = None
@@ -671,20 +706,23 @@ def get_local_image(file_url):
return image, filename, extn
-def get_web_image(file_url):
+def get_web_image(file_url: str) -> Tuple["ImageFile", str, str]:
# download
file_url = frappe.utils.get_url(file_url)
r = requests.get(file_url, stream=True)
try:
r.raise_for_status()
- except requests.exceptions.HTTPError as e:
- if "404" in e.args[0]:
+ except HTTPError:
+ if r.status_code == 404:
frappe.msgprint(_("File '{0}' not found").format(file_url))
else:
frappe.msgprint(_("Unable to read file format for {0}").format(file_url))
raise
- image = Image.open(StringIO(frappe.safe_decode(r.content)))
+ try:
+ image = Image.open(BytesIO(r.content))
+ except Exception as e:
+ frappe.msgprint(_("Image link '{0}' is not valid").format(file_url), raise_exception=e)
try:
filename, extn = file_url.rsplit("/", 1)[1].rsplit(".", 1)
@@ -694,7 +732,10 @@ def get_web_image(file_url):
filename = get_random_filename()
extn = None
- extn = get_extension(filename, extn, r.content)
+ extn = get_extension(filename, extn, response=r)
+ if extn == "bin":
+ extn = get_extension(filename, extn, content=r.content) or "png"
+
filename = "/files/" + strip(unquote(filename))
return image, filename, extn
@@ -704,7 +745,7 @@ def delete_file(path):
"""Delete file from `public folder`"""
if path:
if ".." in path.split("/"):
- frappe.msgprint(_("It is risky to delete this file: {0}. Please contact your System Manager.").format(path))
+ frappe.throw(_("It is risky to delete this file: {0}. Please contact your System Manager.").format(path))
parts = os.path.split(path.strip("/"))
if parts[0]=="files":
@@ -718,48 +759,11 @@ def delete_file(path):
os.remove(path)
-def remove_file(fid=None, attached_to_doctype=None, attached_to_name=None, from_delete=False):
- """Remove file and File entry"""
- file_name = None
- if not (attached_to_doctype and attached_to_name):
- attached = frappe.db.get_value("File", fid,
- ["attached_to_doctype", "attached_to_name", "file_name"])
- if attached:
- attached_to_doctype, attached_to_name, file_name = attached
-
- ignore_permissions, comment = False, None
- if attached_to_doctype and attached_to_name and not from_delete:
- doc = frappe.get_doc(attached_to_doctype, attached_to_name)
- ignore_permissions = doc.has_permission("write") or False
- if frappe.flags.in_web_form:
- ignore_permissions = True
- if not file_name:
- file_name = frappe.db.get_value("File", fid, "file_name")
- comment = doc.add_comment("Attachment Removed", _("Removed {0}").format(file_name))
- frappe.delete_doc("File", fid, ignore_permissions=ignore_permissions)
-
- return comment
-
-
+@frappe.whitelist()
def get_max_file_size():
return cint(conf.get('max_file_size')) or 10485760
-def remove_all(dt, dn, from_delete=False):
- """remove all files in a transaction"""
- try:
- for fid in frappe.db.sql_list("""select name from `tabFile` where
- attached_to_doctype=%s and attached_to_name=%s""", (dt, dn)):
- if from_delete:
- # If deleting a doc, directly delete files
- frappe.delete_doc("File", fid, ignore_permissions=True)
- else:
- # Removes file and adds a comment in the document it is attached to
- remove_file(fid=fid, attached_to_doctype=dt, attached_to_name=dn, from_delete=from_delete)
- except Exception as e:
- if e.args[0]!=1054: raise # (temp till for patched)
-
-
def has_permission(doc, ptype=None, user=None):
has_access = False
user = user or frappe.session.user
@@ -804,11 +808,12 @@ def remove_file_by_url(file_url, doctype=None, name=None):
fid = frappe.db.get_value("File", {"file_url": file_url})
if fid:
+ from frappe.utils.file_manager import remove_file
return remove_file(fid=fid)
def get_content_hash(content):
- if isinstance(content, text_type):
+ if isinstance(content, str):
content = content.encode()
return hashlib.md5(content).hexdigest() #nosec
@@ -849,26 +854,33 @@ def extract_images_from_doc(doc, fieldname):
doc.set(fieldname, content)
-def extract_images_from_html(doc, content):
+def extract_images_from_html(doc, content, is_private=False):
frappe.flags.has_dataurl = False
def _save_file(match):
data = match.group(1)
data = data.split("data:")[1]
headers, content = data.split(",")
+ mtype = headers.split(";")[0]
+
+ if isinstance(content, str):
+ content = content.encode("utf-8")
+ if b"," in content:
+ content = content.split(b",")[1]
+ content = safe_b64decode(content)
+
+ content = optimize_image(content, mtype)
if "filename=" in headers:
filename = headers.split("filename=")[-1]
+ filename = safe_decode(filename).split(";")[0]
- # decode filename
- if not isinstance(filename, text_type):
- filename = text_type(filename, 'utf-8')
else:
- mtype = headers.split(";")[0]
filename = get_random_filename(content_type=mtype)
- doctype = doc.parenttype if doc.parent else doc.doctype
- name = doc.parent or doc.name
+ # attaching a file to a child table doc, attaches it to the parent doc
+ doctype = doc.parenttype if doc.get("parent") else doc.doctype
+ name = doc.get("parent") or doc.name
_file = frappe.get_doc({
"doctype": "File",
@@ -876,7 +888,8 @@ def extract_images_from_html(doc, content):
"attached_to_doctype": doctype,
"attached_to_name": name,
"content": content,
- "decode": True
+ "decode": False,
+ "is_private": is_private
})
_file.save(ignore_permissions=True)
file_url = _file.file_url
@@ -885,18 +898,15 @@ def extract_images_from_html(doc, content):
return ' ]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, content)
+ if content and isinstance(content, str):
+ content = re.sub(r' ]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, content)
return content
-def get_random_filename(extn=None, content_type=None):
- if extn:
- if not extn.startswith("."):
- extn = "." + extn
-
- elif content_type:
+def get_random_filename(content_type=None):
+ extn = None
+ if content_type:
extn = mimetypes.guess_extension(content_type)
return random_string(7) + (extn or "")
@@ -907,7 +917,7 @@ def unzip_file(name):
'''Unzip the given file and make file records for each of the extracted files'''
file_obj = frappe.get_doc('File', name)
files = file_obj.unzip()
- return len(files)
+ return files
@frappe.whitelist()
@@ -915,7 +925,7 @@ def get_attached_images(doctype, names):
'''get list of image urls attached in form
returns {name: ['image.jpg', 'image.png']}'''
- if isinstance(names, string_types):
+ if isinstance(names, str):
names = json.loads(names)
img_urls = frappe.db.get_list('File', filters={
@@ -932,24 +942,27 @@ def get_attached_images(doctype, names):
return out
-@frappe.whitelist()
-def validate_filename(filename):
- from frappe.utils import now_datetime
- timestamp = now_datetime().strftime(" %Y-%m-%d %H:%M:%S")
- fname = get_file_name(filename, timestamp)
- return fname
-
@frappe.whitelist()
def get_files_in_folder(folder, start=0, page_length=20):
start = cint(start)
page_length = cint(page_length)
- files = frappe.db.get_all('File',
+ attachment_folder = frappe.db.get_value('File',
+ 'Home/Attachments',
+ ['name', 'file_name', 'file_url', 'is_folder', 'modified'],
+ as_dict=1
+ )
+
+ files = frappe.db.get_list('File',
{ 'folder': folder },
['name', 'file_name', 'file_url', 'is_folder', 'modified'],
start=start,
page_length=page_length + 1
)
+
+ if folder == 'Home' and attachment_folder not in files:
+ files.insert(0, attachment_folder)
+
return {
'files': files[:page_length],
'has_more': len(files) > page_length
@@ -971,20 +984,14 @@ def get_files_by_search_text(text):
def update_existing_file_docs(doc):
# Update is private and file url of all file docs that point to the same file
- frappe.db.sql("""
- UPDATE `tabFile`
- SET
- file_url = %(file_url)s,
- is_private = %(is_private)s
- WHERE
- content_hash = %(content_hash)s
- and name != %(file_name)s
- """, dict(
- file_url=doc.file_url,
- is_private=doc.is_private,
- content_hash=doc.content_hash,
- file_name=doc.name
- ))
+ file_doctype = frappe.qb.DocType("File")
+ (
+ frappe.qb.update(file_doctype)
+ .set(file_doctype.file_url, doc.file_url)
+ .set(file_doctype.is_private, doc.is_private)
+ .where(file_doctype.content_hash == doc.content_hash)
+ .where(file_doctype.name != doc.name)
+ ).run()
def attach_files_to_document(doc, event):
""" Runs on on_update hook of all documents.
diff --git a/frappe/core/doctype/file/test_file.js b/frappe/core/doctype/file/test_file.js
deleted file mode 100644
index efa40b4e98..0000000000
--- a/frappe/core/doctype/file/test_file.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: File", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new File
- () => frappe.tests.make('File', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py
index e627558680..d8e748a518 100644
--- a/frappe/core/doctype/file/test_file.py
+++ b/frappe/core/doctype/file/test_file.py
@@ -1,16 +1,14 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
import base64
+import json
import frappe
import os
import unittest
+
from frappe import _
-from frappe.core.doctype.file.file import move_file
+from frappe.core.doctype.file.file import File, get_attached_images, move_file, get_files_in_folder, unzip_file
from frappe.utils import get_files_path
-# test_records = frappe.get_test_records('File')
test_content1 = 'Hello'
test_content2 = 'Hello World'
@@ -19,13 +17,12 @@ test_content2 = 'Hello World'
def make_test_doc():
d = frappe.new_doc('ToDo')
d.description = 'Test'
+ d.assigned_by = frappe.session.user
d.save()
return d.doctype, d.name
class TestSimpleFile(unittest.TestCase):
-
-
def setUp(self):
self.attached_to_doctype, self.attached_to_docname = make_test_doc()
self.test_content = test_content1
@@ -38,21 +35,13 @@ class TestSimpleFile(unittest.TestCase):
_file.save()
self.saved_file_url = _file.file_url
-
def test_save(self):
_file = frappe.get_doc("File", {"file_url": self.saved_file_url})
content = _file.get_content()
self.assertEqual(content, self.test_content)
- def tearDown(self):
- # File gets deleted on rollback, so blank
- pass
-
-
class TestBase64File(unittest.TestCase):
-
-
def setUp(self):
self.attached_to_doctype, self.attached_to_docname = make_test_doc()
self.test_content = base64.b64encode(test_content1.encode('utf-8'))
@@ -66,18 +55,12 @@ class TestBase64File(unittest.TestCase):
_file.save()
self.saved_file_url = _file.file_url
-
def test_saved_content(self):
_file = frappe.get_doc("File", {"file_url": self.saved_file_url})
content = _file.get_content()
self.assertEqual(content, test_content1)
- def tearDown(self):
- # File gets deleted on rollback, so blank
- pass
-
-
class TestSameFileName(unittest.TestCase):
def test_saved_content(self):
self.attached_to_doctype, self.attached_to_docname = make_test_doc()
@@ -130,8 +113,6 @@ class TestSameFileName(unittest.TestCase):
class TestSameContent(unittest.TestCase):
-
-
def setUp(self):
self.attached_to_doctype1, self.attached_to_docname1 = make_test_doc()
self.attached_to_doctype2, self.attached_to_docname2 = make_test_doc()
@@ -186,19 +167,13 @@ class TestSameContent(unittest.TestCase):
limit_property.delete()
frappe.clear_cache(doctype='ToDo')
- def tearDown(self):
- # File gets deleted on rollback, so blank
- pass
-
class TestFile(unittest.TestCase):
-
-
def setUp(self):
+ frappe.set_user('Administrator')
self.delete_test_data()
self.upload_file()
-
def tearDown(self):
try:
frappe.get_doc("File", {"file_name": "file_copy.txt"}).delete()
@@ -207,10 +182,14 @@ class TestFile(unittest.TestCase):
def delete_test_data(self):
- for f in frappe.db.sql('''select name, file_name from tabFile where
- is_home_folder = 0 and is_attachments_folder = 0 order by creation desc'''):
- frappe.delete_doc("File", f[0])
-
+ test_file_data = frappe.db.get_all(
+ "File",
+ pluck="name",
+ filters={"is_home_folder": 0, "is_attachments_folder": 0},
+ order_by="creation desc",
+ )
+ for f in test_file_data:
+ frappe.delete_doc("File", f)
def upload_file(self):
_file = frappe.get_doc({
@@ -352,6 +331,107 @@ class TestFile(unittest.TestCase):
self.assertEqual(file1.file_url, file2.file_url)
self.assertTrue(os.path.exists(file2.get_full_path()))
+ def test_parent_directory_validation_in_file_url(self):
+ file1 = frappe.get_doc({
+ "doctype": "File",
+ "file_name": 'parent_dir.txt',
+ "attached_to_doctype": "",
+ "attached_to_name": "",
+ "is_private": 1,
+ "content": test_content1}).insert()
+
+ file1.file_url = '/private/files/../test.txt'
+ self.assertRaises(frappe.exceptions.ValidationError, file1.save)
+
+ # No validation to see if file exists
+ file1.reload()
+ file1.file_url = '/private/files/parent_dir2.txt'
+ file1.save()
+
+ def test_file_url_validation(self):
+ test_file = frappe.get_doc({
+ "doctype": "File",
+ "file_name": 'logo',
+ "file_url": 'https://frappe.io/files/frappe.png'
+ })
+
+ self.assertIsNone(test_file.validate())
+
+ # bad path
+ test_file.file_url = "/usr/bin/man"
+ self.assertRaisesRegex(frappe.exceptions.ValidationError, "URL must start with http:// or https://", test_file.validate)
+
+ test_file.file_url = None
+ test_file.file_name = "/usr/bin/man"
+ self.assertRaisesRegex(frappe.exceptions.ValidationError, "There is some problem with the file url", test_file.validate)
+
+ test_file.file_url = None
+ test_file.file_name = "_file"
+ self.assertRaisesRegex(IOError, "does not exist", test_file.validate)
+
+ test_file.file_url = None
+ test_file.file_name = "/private/files/_file"
+ self.assertRaisesRegex(IOError, "does not exist", test_file.validate)
+
+ def test_make_thumbnail(self):
+ # test web image
+ test_file: File = frappe.get_doc({
+ "doctype": "File",
+ "file_name": 'logo',
+ "file_url": frappe.utils.get_url('/_test/assets/image.jpg'),
+ }).insert(ignore_permissions=True)
+
+ test_file.make_thumbnail()
+ self.assertEquals(test_file.thumbnail_url, '/files/image_small.jpg')
+
+ # test web image without extension
+ test_file = frappe.get_doc({
+ "doctype": "File",
+ "file_name": 'logo',
+ "file_url": frappe.utils.get_url('/_test/assets/image'),
+ }).insert(ignore_permissions=True)
+
+ test_file.make_thumbnail()
+ self.assertTrue(test_file.thumbnail_url.endswith("_small.jpeg"))
+
+ # test local image
+ test_file.db_set('thumbnail_url', None)
+ test_file.reload()
+ test_file.file_url = "/files/image_small.jpg"
+ test_file.make_thumbnail(suffix="xs", crop=True)
+ self.assertEquals(test_file.thumbnail_url, '/files/image_small_xs.jpg')
+
+ frappe.clear_messages()
+ test_file.db_set('thumbnail_url', None)
+ test_file.reload()
+ test_file.file_url = frappe.utils.get_url('unknown.jpg')
+ test_file.make_thumbnail(suffix="xs")
+ self.assertEqual(json.loads(frappe.message_log[0]).get("message"), f"File '{frappe.utils.get_url('unknown.jpg')}' not found")
+ self.assertEquals(test_file.thumbnail_url, None)
+
+ def test_file_unzip(self):
+ file_path = frappe.get_app_path('frappe', 'www/_test/assets/file.zip')
+ public_file_path = frappe.get_site_path('public', 'files')
+ try:
+ import shutil
+ shutil.copy(file_path, public_file_path)
+ except Exception:
+ pass
+
+ test_file = frappe.get_doc({
+ "doctype": "File",
+ "file_url": '/files/file.zip',
+ }).insert(ignore_permissions=True)
+
+ self.assertListEqual([file.file_name for file in unzip_file(test_file.name)],
+ ['css_asset.css', 'image.jpg', 'js_asset.min.js'])
+
+ test_file = frappe.get_doc({
+ "doctype": "File",
+ "file_url": frappe.utils.get_url('/_test/assets/image.jpg'),
+ }).insert(ignore_permissions=True)
+ self.assertRaisesRegex(frappe.exceptions.ValidationError, 'not a zip file', test_file.unzip)
+
class TestAttachment(unittest.TestCase):
test_doctype = 'Test For Attachment'
@@ -399,3 +479,151 @@ class TestAttachment(unittest.TestCase):
})
self.assertTrue(exists)
+
+
+class TestAttachmentsAccess(unittest.TestCase):
+
+ def test_attachments_access(self):
+
+ frappe.set_user('test4@example.com')
+ self.attached_to_doctype, self.attached_to_docname = make_test_doc()
+
+ frappe.get_doc({
+ "doctype": "File",
+ "file_name": 'test_user.txt',
+ "attached_to_doctype": self.attached_to_doctype,
+ "attached_to_name": self.attached_to_docname,
+ "content": 'Testing User'
+ }).insert()
+
+ frappe.get_doc({
+ "doctype": "File",
+ "file_name": "test_user_home.txt",
+ "content": 'User Home',
+ }).insert()
+
+ frappe.set_user('test@example.com')
+
+ frappe.get_doc({
+ "doctype": "File",
+ "file_name": 'test_system_manager.txt',
+ "attached_to_doctype": self.attached_to_doctype,
+ "attached_to_name": self.attached_to_docname,
+ "content": 'Testing System Manager'
+ }).insert()
+
+ frappe.get_doc({
+ "doctype": "File",
+ "file_name": "test_sm_home.txt",
+ "content": 'System Manager Home',
+ }).insert()
+
+ system_manager_files = [file.file_name for file in get_files_in_folder('Home')['files']]
+ system_manager_attachments_files = [file.file_name for file in get_files_in_folder('Home/Attachments')['files']]
+
+ frappe.set_user('test4@example.com')
+ user_files = [file.file_name for file in get_files_in_folder('Home')['files']]
+ user_attachments_files = [file.file_name for file in get_files_in_folder('Home/Attachments')['files']]
+
+ self.assertIn('test_sm_home.txt', system_manager_files)
+ self.assertNotIn('test_sm_home.txt', user_files)
+ self.assertIn('test_user_home.txt', system_manager_files)
+ self.assertIn('test_user_home.txt', user_files)
+
+ self.assertIn('test_system_manager.txt', system_manager_attachments_files)
+ self.assertNotIn('test_system_manager.txt', user_attachments_files)
+ self.assertIn('test_user.txt', system_manager_attachments_files)
+ self.assertIn('test_user.txt', user_attachments_files)
+
+ frappe.set_user('Administrator')
+ frappe.db.rollback()
+
+
+class TestFileUtils(unittest.TestCase):
+ def test_extract_images_from_doc(self):
+ # with filename in data URI
+ todo = frappe.get_doc({
+ "doctype": "ToDo",
+ "description": 'Test '
+ }).insert()
+ self.assertTrue(frappe.db.exists("File", {"attached_to_name": todo.name}))
+ self.assertIn(' ', todo.description)
+ self.assertListEqual(get_attached_images('ToDo', [todo.name])[todo.name], ['/files/pix.png'])
+
+ # without filename in data URI
+ todo = frappe.get_doc({
+ "doctype": "ToDo",
+ "description": 'Test '
+ }).insert()
+ filename = frappe.db.exists("File", {"attached_to_name": todo.name})
+ self.assertIn(f' Error Logs ')
}
- if frappe.db.sql_list("select name from `tabError Log` where seen = 0 limit 1"):
+ if frappe.get_all("Error Log", filters={"seen": 0}, limit=1):
log_settings = frappe.get_cached_doc('Log Settings')
if log_settings.users_to_notify:
diff --git a/frappe/core/doctype/log_settings/test_log_settings.py b/frappe/core/doctype/log_settings/test_log_settings.py
index 2824c71c88..40287948fd 100644
--- a/frappe/core/doctype/log_settings/test_log_settings.py
+++ b/frappe/core/doctype/log_settings/test_log_settings.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/core/doctype/module_def/__init__.py b/frappe/core/doctype/module_def/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/core/doctype/module_def/__init__.py
+++ b/frappe/core/doctype/module_def/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/core/doctype/module_def/module_def.js b/frappe/core/doctype/module_def/module_def.js
index c7a6cf85f9..73d2d6562c 100644
--- a/frappe/core/doctype/module_def/module_def.js
+++ b/frappe/core/doctype/module_def/module_def.js
@@ -5,6 +5,9 @@ frappe.ui.form.on('Module Def', {
refresh: function(frm) {
frappe.xcall('frappe.core.doctype.module_def.module_def.get_installed_apps').then(r => {
frm.set_df_property('app_name', 'options', JSON.parse(r));
+ if (!frm.doc.app_name) {
+ frm.set_value('app_name', 'frappe');
+ }
});
}
});
diff --git a/frappe/core/doctype/module_def/module_def.json b/frappe/core/doctype/module_def/module_def.json
index 7a8bfd76a7..12830c8b4f 100644
--- a/frappe/core/doctype/module_def/module_def.json
+++ b/frappe/core/doctype/module_def/module_def.json
@@ -8,8 +8,10 @@
"field_order": [
"module_name",
"custom",
+ "package",
"app_name",
- "restrict_to_domain"
+ "restrict_to_domain",
+ "connections_tab"
],
"fields": [
{
@@ -23,6 +25,7 @@
"unique": 1
},
{
+ "depends_on": "eval:!doc.custom",
"fieldname": "app_name",
"fieldtype": "Select",
"in_list_view": 1,
@@ -41,24 +44,90 @@
"fieldname": "custom",
"fieldtype": "Check",
"label": "Custom"
+ },
+ {
+ "depends_on": "custom",
+ "fieldname": "package",
+ "fieldtype": "Link",
+ "label": "Package",
+ "options": "Package"
+ },
+ {
+ "fieldname": "connections_tab",
+ "fieldtype": "Tab Break",
+ "label": "Connections",
+ "show_dashboard": 1
}
],
"icon": "fa fa-sitemap",
"idx": 1,
"links": [
{
+ "group": "DocType",
"link_doctype": "DocType",
"link_fieldname": "module"
},
{
+ "group": "DocType",
+ "link_doctype": "Client Script",
+ "link_fieldname": "module"
+ },
+ {
+ "group": "DocType",
+ "link_doctype": "Server Script",
+ "link_fieldname": "module"
+ },
+ {
+ "group": "Website",
+ "link_doctype": "Web Page",
+ "link_fieldname": "module"
+ },
+ {
+ "group": "Website",
+ "link_doctype": "Web Template",
+ "link_fieldname": "module"
+ },
+ {
+ "group": "Website",
+ "link_doctype": "Website Theme",
+ "link_fieldname": "module"
+ },
+ {
+ "group": "Website",
+ "link_doctype": "Web Form",
+ "link_fieldname": "module"
+ },
+ {
+ "group": "Customization",
"link_doctype": "Workspace",
"link_fieldname": "module"
+ },
+ {
+ "group": "Customization",
+ "link_doctype": "Custom Field",
+ "link_fieldname": "module"
+ },
+ {
+ "group": "Customization",
+ "link_doctype": "Property Setter",
+ "link_fieldname": "module"
+ },
+ {
+ "group": "Customization",
+ "link_doctype": "Print Format",
+ "link_fieldname": "module"
+ },
+ {
+ "group": "Customization",
+ "link_doctype": "Notification",
+ "link_fieldname": "module"
}
],
- "modified": "2020-08-06 12:39:30.740379",
+ "modified": "2022-01-03 13:56:52.817954",
"modified_by": "Administrator",
"module": "Core",
"name": "Module Def",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -69,6 +138,7 @@
"read": 1,
"report": 1,
"role": "Administrator",
+ "select": 1,
"share": 1,
"write": 1
},
@@ -78,11 +148,19 @@
"read": 1,
"report": 1,
"role": "System Manager",
+ "select": 1,
"write": 1
+ },
+ {
+ "read": 1,
+ "report": 1,
+ "role": "All",
+ "select": 1
}
],
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/core/doctype/module_def/module_def.py b/frappe/core/doctype/module_def/module_def.py
index 7e63572162..6b420430b8 100644
--- a/frappe/core/doctype/module_def/module_def.py
+++ b/frappe/core/doctype/module_def/module_def.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe, os, json
from frappe.model.document import Document
diff --git a/frappe/core/doctype/module_def/test_module_def.py b/frappe/core/doctype/module_def/test_module_def.py
index 1f9bea4768..69a114d765 100644
--- a/frappe/core/doctype/module_def/test_module_def.py
+++ b/frappe/core/doctype/module_def/test_module_def.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/module_profile/module_profile.js b/frappe/core/doctype/module_profile/module_profile.js
index 9c92042dda..3714d31ade 100644
--- a/frappe/core/doctype/module_profile/module_profile.js
+++ b/frappe/core/doctype/module_profile/module_profile.js
@@ -1,19 +1,23 @@
// Copyright (c) 2020, Frappe Technologies and contributors
// For license information, please see license.txt
-frappe.ui.form.on('Module Profile', {
- refresh: function(frm) {
+frappe.ui.form.on("Module Profile", {
+ refresh: function (frm) {
if (has_common(frappe.user_roles, ["Administrator", "System Manager"])) {
if (!frm.module_editor && frm.doc.__onload && frm.doc.__onload.all_modules) {
- let module_area = $('')
- .appendTo(frm.fields_dict.module_html.wrapper);
-
+ const module_area = $(frm.fields_dict.module_html.wrapper);
frm.module_editor = new frappe.ModuleEditor(frm, module_area);
}
}
if (frm.module_editor) {
- frm.module_editor.refresh();
+ frm.module_editor.show();
+ }
+ },
+
+ validate: function (frm) {
+ if (frm.module_editor) {
+ frm.module_editor.set_modules_in_table();
}
}
});
diff --git a/frappe/core/doctype/module_profile/module_profile.json b/frappe/core/doctype/module_profile/module_profile.json
index 0e4e56962e..32bc757427 100644
--- a/frappe/core/doctype/module_profile/module_profile.json
+++ b/frappe/core/doctype/module_profile/module_profile.json
@@ -34,11 +34,17 @@
}
],
"index_web_pages_for_search": 1,
- "links": [],
- "modified": "2021-01-03 15:36:52.622696",
+ "links": [
+ {
+ "link_doctype": "User",
+ "link_fieldname": "module_profile"
+ }
+ ],
+ "modified": "2021-12-03 15:47:21.296443",
"modified_by": "Administrator",
"module": "Core",
"name": "Module Profile",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
diff --git a/frappe/core/doctype/module_profile/module_profile.py b/frappe/core/doctype/module_profile/module_profile.py
index 4f392353ac..930c3879b6 100644
--- a/frappe/core/doctype/module_profile/module_profile.py
+++ b/frappe/core/doctype/module_profile/module_profile.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
from frappe.model.document import Document
class ModuleProfile(Document):
diff --git a/frappe/core/doctype/module_profile/test_module_profile.py b/frappe/core/doctype/module_profile/test_module_profile.py
index 400053d22c..e676767db6 100644
--- a/frappe/core/doctype/module_profile/test_module_profile.py
+++ b/frappe/core/doctype/module_profile/test_module_profile.py
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/navbar_item/navbar_item.py b/frappe/core/doctype/navbar_item/navbar_item.py
index 614aee8eaf..d4952a75f2 100644
--- a/frappe/core/doctype/navbar_item/navbar_item.py
+++ b/frappe/core/doctype/navbar_item/navbar_item.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/navbar_item/test_navbar_item.py b/frappe/core/doctype/navbar_item/test_navbar_item.py
index 192e8fe42a..bb4b2a837a 100644
--- a/frappe/core/doctype/navbar_item/test_navbar_item.py
+++ b/frappe/core/doctype/navbar_item/test_navbar_item.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/core/doctype/navbar_settings/navbar_settings.py b/frappe/core/doctype/navbar_settings/navbar_settings.py
index 2244bc9e4e..c46d0081b6 100644
--- a/frappe/core/doctype/navbar_settings/navbar_settings.py
+++ b/frappe/core/doctype/navbar_settings/navbar_settings.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe import _
@@ -14,6 +13,9 @@ class NavbarSettings(Document):
def validate_standard_navbar_items(self):
doc_before_save = self.get_doc_before_save()
+ if not doc_before_save:
+ return
+
before_save_items = [item for item in \
doc_before_save.help_dropdown + doc_before_save.settings_dropdown if item.is_standard]
@@ -23,7 +25,6 @@ class NavbarSettings(Document):
if not frappe.flags.in_patch and (len(before_save_items) > len(after_save_items)):
frappe.throw(_("Please hide the standard navbar items instead of deleting them"))
-@frappe.whitelist(allow_guest=True)
def get_app_logo():
app_logo = frappe.db.get_single_value('Navbar Settings', 'app_logo', cache=True)
if not app_logo:
@@ -34,7 +35,3 @@ def get_app_logo():
def get_navbar_settings():
navbar_settings = frappe.get_single('Navbar Settings')
return navbar_settings
-
-
-
-
diff --git a/frappe/core/doctype/navbar_settings/test_navbar_settings.py b/frappe/core/doctype/navbar_settings/test_navbar_settings.py
index ed423b0f27..01497d9035 100644
--- a/frappe/core/doctype/navbar_settings/test_navbar_settings.py
+++ b/frappe/core/doctype/navbar_settings/test_navbar_settings.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/chat/doctype/chat_room/__init__.py b/frappe/core/doctype/package/__init__.py
similarity index 100%
rename from frappe/chat/doctype/chat_room/__init__.py
rename to frappe/core/doctype/package/__init__.py
diff --git a/frappe/core/doctype/package/licenses/GNU Affero General Public License.md b/frappe/core/doctype/package/licenses/GNU Affero General Public License.md
new file mode 100644
index 0000000000..c7f159aed8
--- /dev/null
+++ b/frappe/core/doctype/package/licenses/GNU Affero General Public License.md
@@ -0,0 +1,614 @@
+### GNU AFFERO GENERAL PUBLIC LICENSE
+
+Version 3, 19 November 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc.
+
+
+Everyone is permitted to copy and distribute verbatim copies of this
+license document, but changing it is not allowed.
+
+### Preamble
+
+The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains
+free software for all its users.
+
+When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing
+under this license.
+
+The precise terms and conditions for copying, distribution and
+modification follow.
+
+### TERMS AND CONDITIONS
+
+#### 0. Definitions.
+
+"This License" refers to version 3 of the GNU Affero General Public
+License.
+
+"Copyright" also means copyright-like laws that apply to other kinds
+of works, such as semiconductor masks.
+
+"The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of
+an exact copy. The resulting work is called a "modified version" of
+the earlier work or a work "based on" the earlier work.
+
+A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user
+through a computer network, with no transfer of a copy, is not
+conveying.
+
+An interactive user interface displays "Appropriate Legal Notices" to
+the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+#### 1. Source Code.
+
+The "source code" for a work means the preferred form of the work for
+making modifications to it. "Object code" means any non-source form of
+a work.
+
+A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users can
+regenerate automatically from other parts of the Corresponding Source.
+
+The Corresponding Source for a work in source code form is that same
+work.
+
+#### 2. Basic Permissions.
+
+All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not convey,
+without conditions so long as your license otherwise remains in force.
+You may convey covered works to others for the sole purpose of having
+them make modifications exclusively for you, or provide you with
+facilities for running those works, provided that you comply with the
+terms of this License in conveying all material for which you do not
+control copyright. Those thus making or running the covered works for
+you must do so exclusively on your behalf, under your direction and
+control, on terms that prohibit them from making any copies of your
+copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under the
+conditions stated below. Sublicensing is not allowed; section 10 makes
+it unnecessary.
+
+#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such
+circumvention is effected by exercising rights under this License with
+respect to the covered work, and you disclaim any intention to limit
+operation or modification of the work as a means of enforcing, against
+the work's users, your or third parties' legal rights to forbid
+circumvention of technological measures.
+
+#### 4. Conveying Verbatim Copies.
+
+You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+#### 5. Conveying Modified Source Versions.
+
+You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these
+conditions:
+
+- a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+- b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under
+ section 7. This requirement modifies the requirement in section 4
+ to "keep intact all notices".
+- c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+- d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+#### 6. Conveying Non-Source Forms.
+
+You may convey a covered work in object code form under the terms of
+sections 4 and 5, provided that you also convey the machine-readable
+Corresponding Source under the terms of this License, in one of these
+ways:
+
+- a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+- b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the Corresponding
+ Source from a network server at no charge.
+- c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+- d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+- e) Convey the object code using peer-to-peer transmission,
+ provided you inform other peers where the object code and
+ Corresponding Source of the work are being offered to the general
+ public at no charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal,
+family, or household purposes, or (2) anything designed or sold for
+incorporation into a dwelling. In determining whether a product is a
+consumer product, doubtful cases shall be resolved in favor of
+coverage. For a particular product received by a particular user,
+"normally used" refers to a typical or common use of that class of
+product, regardless of the status of the particular user or of the way
+in which the particular user actually uses, or expects or is expected
+to use, the product. A product is a consumer product regardless of
+whether the product has substantial commercial, industrial or
+non-consumer uses, unless such uses represent the only significant
+mode of use of the product.
+
+"Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to
+install and execute modified versions of a covered work in that User
+Product from a modified version of its Corresponding Source. The
+information must suffice to ensure that the continued functioning of
+the modified object code is in no case prevented or interfered with
+solely because modification has been made.
+
+If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or
+updates for a work that has been modified or installed by the
+recipient, or for the User Product in which it has been modified or
+installed. Access to a network may be denied when the modification
+itself materially and adversely affects the operation of the network
+or violates the rules and protocols for communication across the
+network.
+
+Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+#### 7. Additional Terms.
+
+"Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders
+of that material) supplement the terms of this License with terms:
+
+- a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+- b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+- c) Prohibiting misrepresentation of the origin of that material,
+ or requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+- d) Limiting the use for publicity purposes of names of licensors
+ or authors of the material; or
+- e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+- f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions
+ of it) with contractual assumptions of liability to the recipient,
+ for any liability that these contractual assumptions directly
+ impose on those licensors and authors.
+
+All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions; the
+above requirements apply either way.
+
+#### 8. Termination.
+
+You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+However, if you cease all violation of this License, then your license
+from a particular copyright holder is reinstated (a) provisionally,
+unless and until the copyright holder explicitly and finally
+terminates your license, and (b) permanently, if the copyright holder
+fails to notify you of the violation by some reasonable means prior to
+60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+#### 9. Acceptance Not Required for Having Copies.
+
+You are not required to accept this License in order to receive or run
+a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+#### 10. Automatic Licensing of Downstream Recipients.
+
+Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+#### 11. Patents.
+
+A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+A contributor's "essential patent claims" are all patent claims owned
+or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+A patent license is "discriminatory" if it does not include within the
+scope of its coverage, prohibits the exercise of, or is conditioned on
+the non-exercise of one or more of the rights that are specifically
+granted under this License. You may not convey a covered work if you
+are a party to an arrangement with a third party that is in the
+business of distributing software, under which you make payment to the
+third party based on the extent of your activity of conveying the
+work, and under which the third party grants, to any of the parties
+who would receive the covered work from you, a discriminatory patent
+license (a) in connection with copies of the covered work conveyed by
+you (or copies made from those copies), or (b) primarily for and in
+connection with specific products or compilations that contain the
+covered work, unless you entered into that arrangement, or that patent
+license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+#### 12. No Surrender of Others' Freedom.
+
+If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under
+this License and any other pertinent obligations, then as a
+consequence you may not convey it at all. For example, if you agree to
+terms that obligate you to collect a royalty for further conveying
+from those to whom you convey the Program, the only way you could
+satisfy both those terms and this License would be to refrain entirely
+from conveying the Program.
+
+#### 13. Remote Network Interaction; Use with the GNU General Public License.
+
+Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your
+version supports such interaction) an opportunity to receive the
+Corresponding Source of your version by providing access to the
+Corresponding Source from a network server at no charge, through some
+standard or customary means of facilitating copying of software. This
+Corresponding Source shall include the Corresponding Source for any
+work covered by version 3 of the GNU General Public License that is
+incorporated pursuant to the following paragraph.
+
+Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+#### 14. Revised Versions of this License.
+
+The Free Software Foundation may publish revised and/or new versions
+of the GNU Affero General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever
+published by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future versions
+of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+#### 15. Disclaimer of Warranty.
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
+WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
+PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
+DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
+CORRECTION.
+
+#### 16. Limitation of Liability.
+
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
+CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
+ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
+NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
+LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
+TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
+PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+#### 17. Interpretation of Sections 15 and 16.
+
+If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
diff --git a/frappe/core/doctype/package/licenses/GNU General Public License.md b/frappe/core/doctype/package/licenses/GNU General Public License.md
new file mode 100644
index 0000000000..c4580f2eb6
--- /dev/null
+++ b/frappe/core/doctype/package/licenses/GNU General Public License.md
@@ -0,0 +1,617 @@
+### GNU GENERAL PUBLIC LICENSE
+
+Version 3, 29 June 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc.
+
+
+Everyone is permitted to copy and distribute verbatim copies of this
+license document, but changing it is not allowed.
+
+### Preamble
+
+The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom
+to share and change all versions of a program--to make sure it remains
+free software for all its users. We, the Free Software Foundation, use
+the GNU General Public License for most of our software; it applies
+also to any other work released this way by its authors. You can apply
+it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you
+have certain responsibilities if you distribute copies of the
+software, or if you modify it: responsibilities to respect the freedom
+of others.
+
+For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the
+manufacturer can do so. This is fundamentally incompatible with the
+aim of protecting users' freedom to change the software. The
+systematic pattern of such abuse occurs in the area of products for
+individuals to use, which is precisely where it is most unacceptable.
+Therefore, we have designed this version of the GPL to prohibit the
+practice for those products. If such problems arise substantially in
+other domains, we stand ready to extend this provision to those
+domains in future versions of the GPL, as needed to protect the
+freedom of users.
+
+Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish
+to avoid the special danger that patents applied to a free program
+could make it effectively proprietary. To prevent this, the GPL
+assures that patents cannot be used to render the program non-free.
+
+The precise terms and conditions for copying, distribution and
+modification follow.
+
+### TERMS AND CONDITIONS
+
+#### 0. Definitions.
+
+"This License" refers to version 3 of the GNU General Public License.
+
+"Copyright" also means copyright-like laws that apply to other kinds
+of works, such as semiconductor masks.
+
+"The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of
+an exact copy. The resulting work is called a "modified version" of
+the earlier work or a work "based on" the earlier work.
+
+A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user
+through a computer network, with no transfer of a copy, is not
+conveying.
+
+An interactive user interface displays "Appropriate Legal Notices" to
+the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+#### 1. Source Code.
+
+The "source code" for a work means the preferred form of the work for
+making modifications to it. "Object code" means any non-source form of
+a work.
+
+A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users can
+regenerate automatically from other parts of the Corresponding Source.
+
+The Corresponding Source for a work in source code form is that same
+work.
+
+#### 2. Basic Permissions.
+
+All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not convey,
+without conditions so long as your license otherwise remains in force.
+You may convey covered works to others for the sole purpose of having
+them make modifications exclusively for you, or provide you with
+facilities for running those works, provided that you comply with the
+terms of this License in conveying all material for which you do not
+control copyright. Those thus making or running the covered works for
+you must do so exclusively on your behalf, under your direction and
+control, on terms that prohibit them from making any copies of your
+copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under the
+conditions stated below. Sublicensing is not allowed; section 10 makes
+it unnecessary.
+
+#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such
+circumvention is effected by exercising rights under this License with
+respect to the covered work, and you disclaim any intention to limit
+operation or modification of the work as a means of enforcing, against
+the work's users, your or third parties' legal rights to forbid
+circumvention of technological measures.
+
+#### 4. Conveying Verbatim Copies.
+
+You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+#### 5. Conveying Modified Source Versions.
+
+You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these
+conditions:
+
+- a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+- b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under
+ section 7. This requirement modifies the requirement in section 4
+ to "keep intact all notices".
+- c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+- d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+#### 6. Conveying Non-Source Forms.
+
+You may convey a covered work in object code form under the terms of
+sections 4 and 5, provided that you also convey the machine-readable
+Corresponding Source under the terms of this License, in one of these
+ways:
+
+- a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+- b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the Corresponding
+ Source from a network server at no charge.
+- c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+- d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+- e) Convey the object code using peer-to-peer transmission,
+ provided you inform other peers where the object code and
+ Corresponding Source of the work are being offered to the general
+ public at no charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal,
+family, or household purposes, or (2) anything designed or sold for
+incorporation into a dwelling. In determining whether a product is a
+consumer product, doubtful cases shall be resolved in favor of
+coverage. For a particular product received by a particular user,
+"normally used" refers to a typical or common use of that class of
+product, regardless of the status of the particular user or of the way
+in which the particular user actually uses, or expects or is expected
+to use, the product. A product is a consumer product regardless of
+whether the product has substantial commercial, industrial or
+non-consumer uses, unless such uses represent the only significant
+mode of use of the product.
+
+"Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to
+install and execute modified versions of a covered work in that User
+Product from a modified version of its Corresponding Source. The
+information must suffice to ensure that the continued functioning of
+the modified object code is in no case prevented or interfered with
+solely because modification has been made.
+
+If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or
+updates for a work that has been modified or installed by the
+recipient, or for the User Product in which it has been modified or
+installed. Access to a network may be denied when the modification
+itself materially and adversely affects the operation of the network
+or violates the rules and protocols for communication across the
+network.
+
+Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+#### 7. Additional Terms.
+
+"Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders
+of that material) supplement the terms of this License with terms:
+
+- a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+- b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+- c) Prohibiting misrepresentation of the origin of that material,
+ or requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+- d) Limiting the use for publicity purposes of names of licensors
+ or authors of the material; or
+- e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+- f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions
+ of it) with contractual assumptions of liability to the recipient,
+ for any liability that these contractual assumptions directly
+ impose on those licensors and authors.
+
+All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions; the
+above requirements apply either way.
+
+#### 8. Termination.
+
+You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+However, if you cease all violation of this License, then your license
+from a particular copyright holder is reinstated (a) provisionally,
+unless and until the copyright holder explicitly and finally
+terminates your license, and (b) permanently, if the copyright holder
+fails to notify you of the violation by some reasonable means prior to
+60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+#### 9. Acceptance Not Required for Having Copies.
+
+You are not required to accept this License in order to receive or run
+a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+#### 10. Automatic Licensing of Downstream Recipients.
+
+Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+#### 11. Patents.
+
+A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+A contributor's "essential patent claims" are all patent claims owned
+or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+A patent license is "discriminatory" if it does not include within the
+scope of its coverage, prohibits the exercise of, or is conditioned on
+the non-exercise of one or more of the rights that are specifically
+granted under this License. You may not convey a covered work if you
+are a party to an arrangement with a third party that is in the
+business of distributing software, under which you make payment to the
+third party based on the extent of your activity of conveying the
+work, and under which the third party grants, to any of the parties
+who would receive the covered work from you, a discriminatory patent
+license (a) in connection with copies of the covered work conveyed by
+you (or copies made from those copies), or (b) primarily for and in
+connection with specific products or compilations that contain the
+covered work, unless you entered into that arrangement, or that patent
+license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+#### 12. No Surrender of Others' Freedom.
+
+If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under
+this License and any other pertinent obligations, then as a
+consequence you may not convey it at all. For example, if you agree to
+terms that obligate you to collect a royalty for further conveying
+from those to whom you convey the Program, the only way you could
+satisfy both those terms and this License would be to refrain entirely
+from conveying the Program.
+
+#### 13. Use with the GNU Affero General Public License.
+
+Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+#### 14. Revised Versions of this License.
+
+The Free Software Foundation may publish revised and/or new versions
+of the GNU General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in
+detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies that a certain numbered version of the GNU General Public
+License "or any later version" applies to it, you have the option of
+following the terms and conditions either of that numbered version or
+of any later version published by the Free Software Foundation. If the
+Program does not specify a version number of the GNU General Public
+License, you may choose any version ever published by the Free
+Software Foundation.
+
+If the Program specifies that a proxy can decide which future versions
+of the GNU General Public License can be used, that proxy's public
+statement of acceptance of a version permanently authorizes you to
+choose that version for the Program.
+
+Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+#### 15. Disclaimer of Warranty.
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
+WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
+PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
+DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
+CORRECTION.
+
+#### 16. Limitation of Liability.
+
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
+CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
+ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
+NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
+LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
+TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
+PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+#### 17. Interpretation of Sections 15 and 16.
+
+If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
diff --git a/frappe/core/doctype/package/licenses/MIT License.md b/frappe/core/doctype/package/licenses/MIT License.md
new file mode 100644
index 0000000000..c038ee76ae
--- /dev/null
+++ b/frappe/core/doctype/package/licenses/MIT License.md
@@ -0,0 +1,17 @@
+### MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this
+software and associated documentation files (the "Software"), to deal in the Software
+without restriction, including without limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies
+or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/frappe/core/doctype/package/package.js b/frappe/core/doctype/package/package.js
new file mode 100644
index 0000000000..90e2eed1e3
--- /dev/null
+++ b/frappe/core/doctype/package/package.js
@@ -0,0 +1,17 @@
+// Copyright (c) 2021, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Package', {
+ validate: function(frm) {
+ if (!frm.doc.package_name) {
+ frm.set_value('package_name', frm.doc.name.toLowerCase().replace(' ', '-'));
+ }
+ },
+
+ license_type: function(frm) {
+ frappe.call('frappe.core.doctype.package.package.get_license_text',
+ {'license_type': frm.doc.license_type}).then(r => {
+ frm.set_value('license', r.message);
+ });
+ }
+});
diff --git a/frappe/core/doctype/package/package.json b/frappe/core/doctype/package/package.json
new file mode 100644
index 0000000000..285e17a5bb
--- /dev/null
+++ b/frappe/core/doctype/package/package.json
@@ -0,0 +1,76 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "Prompt",
+ "creation": "2021-09-04 11:54:35.155687",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "package_name",
+ "readme",
+ "license_type",
+ "license"
+ ],
+ "fields": [
+ {
+ "fieldname": "readme",
+ "fieldtype": "Markdown Editor",
+ "label": "Readme"
+ },
+ {
+ "fieldname": "package_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Package Name",
+ "reqd": 1
+ },
+ {
+ "fieldname": "license_type",
+ "fieldtype": "Select",
+ "label": "License Type",
+ "options": "\nMIT License\nGNU General Public License\nGNU Affero General Public License"
+ },
+ {
+ "fieldname": "license",
+ "fieldtype": "Markdown Editor",
+ "label": "License"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [
+ {
+ "group": "Modules",
+ "link_doctype": "Module Def",
+ "link_fieldname": "package"
+ },
+ {
+ "group": "Release",
+ "link_doctype": "Package Release",
+ "link_fieldname": "package"
+ }
+ ],
+ "modified": "2021-09-05 13:15:01.130982",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "Package",
+ "naming_rule": "Set by user",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/package/package.py b/frappe/core/doctype/package/package.py
new file mode 100644
index 0000000000..aa9735c061
--- /dev/null
+++ b/frappe/core/doctype/package/package.py
@@ -0,0 +1,18 @@
+# Copyright (c) 2021, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+import frappe
+import os
+from frappe.model.document import Document
+
+class Package(Document):
+ def validate(self):
+ if not self.package_name:
+ self.package_name = self.name.lower().replace(' ', '-')
+
+@frappe.whitelist()
+def get_license_text(license_type):
+ with open(os.path.join(os.path.dirname(__file__), 'licenses',
+ license_type + '.md'), 'r') as textfile:
+ return textfile.read()
+
diff --git a/frappe/core/doctype/package/test_package.py b/frappe/core/doctype/package/test_package.py
new file mode 100644
index 0000000000..3fb8d48274
--- /dev/null
+++ b/frappe/core/doctype/package/test_package.py
@@ -0,0 +1,89 @@
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# See license.txt
+
+import frappe
+import os
+import json
+import unittest
+
+class TestPackage(unittest.TestCase):
+ def test_package_release(self):
+ make_test_package()
+ make_test_module()
+ make_test_doctype()
+ make_test_server_script()
+ make_test_web_page()
+
+ # make release
+ frappe.get_doc(dict(
+ doctype = 'Package Release',
+ package = 'Test Package',
+ publish = 1
+ )).insert()
+
+ self.assertTrue(os.path.exists(frappe.get_site_path('packages', 'test-package')))
+ self.assertTrue(os.path.exists(frappe.get_site_path('packages', 'test-package', 'test_module_for_package')))
+ self.assertTrue(os.path.exists(frappe.get_site_path('packages', 'test-package', 'test_module_for_package', 'doctype', 'test_doctype_for_package')))
+ with open(frappe.get_site_path('packages', 'test-package', 'test_module_for_package',
+ 'doctype', 'test_doctype_for_package', 'test_doctype_for_package.json')) as f:
+ doctype = json.loads(f.read())
+ self.assertEqual(doctype['doctype'], 'DocType')
+ self.assertEqual(doctype['name'], 'Test DocType for Package')
+ self.assertEqual(doctype['fields'][0]['fieldname'], 'test_field')
+
+
+def make_test_package():
+ if not frappe.db.exists('Package', 'Test Package'):
+ frappe.get_doc(dict(
+ doctype = 'Package',
+ name = 'Test Package',
+ package_name = 'test-package',
+ readme = '# Test Package'
+ )).insert()
+
+def make_test_module():
+ if not frappe.db.exists('Module Def', 'Test Module for Package'):
+ frappe.get_doc(dict(
+ doctype = 'Module Def',
+ module_name = 'Test Module for Package',
+ custom = 1,
+ app_name = 'frappe',
+ package = 'Test Package'
+ )).insert()
+
+def make_test_doctype():
+ if not frappe.db.exists('DocType', 'Test DocType for Package'):
+ frappe.get_doc(dict(
+ doctype = 'DocType',
+ name = 'Test DocType for Package',
+ custom = 1,
+ module = 'Test Module for Package',
+ autoname = 'Prompt',
+ fields = [dict(
+ fieldname = 'test_field',
+ fieldtype = 'Data',
+ label = 'Test Field'
+ )]
+ )).insert()
+
+def make_test_server_script():
+ if not frappe.db.exists('Server Script', 'Test Script for Package'):
+ frappe.get_doc(dict(
+ doctype = 'Server Script',
+ name = 'Test Script for Package',
+ module = 'Test Module for Package',
+ script_type = 'DocType Event',
+ reference_doctype = 'Test DocType for Package',
+ doctype_event = 'Before Save',
+ script = 'frappe.msgprint("Test")'
+ )).insert()
+
+def make_test_web_page():
+ if not frappe.db.exists('Web Page', 'test-web-page-for-package'):
+ frappe.get_doc(dict(
+ doctype = "Web Page",
+ module = 'Test Module for Package',
+ main_section = "Some content",
+ published = 1,
+ title = "Test Web Page for Package"
+ )).insert()
diff --git a/frappe/chat/doctype/chat_room_user/__init__.py b/frappe/core/doctype/package_import/__init__.py
similarity index 100%
rename from frappe/chat/doctype/chat_room_user/__init__.py
rename to frappe/core/doctype/package_import/__init__.py
diff --git a/frappe/core/doctype/package_import/package_import.js b/frappe/core/doctype/package_import/package_import.js
new file mode 100644
index 0000000000..c01a6266cc
--- /dev/null
+++ b/frappe/core/doctype/package_import/package_import.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2021, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Package Import', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/frappe/core/doctype/package_import/package_import.json b/frappe/core/doctype/package_import/package_import.json
new file mode 100644
index 0000000000..f3c6168f8d
--- /dev/null
+++ b/frappe/core/doctype/package_import/package_import.json
@@ -0,0 +1,65 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "format:Package Import at {creation}",
+ "creation": "2021-09-05 16:36:46.680094",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "attach_package",
+ "activate",
+ "force",
+ "log"
+ ],
+ "fields": [
+ {
+ "fieldname": "attach_package",
+ "fieldtype": "Attach",
+ "label": "Attach Package"
+ },
+ {
+ "default": "0",
+ "fieldname": "activate",
+ "fieldtype": "Check",
+ "label": "Activate"
+ },
+ {
+ "fieldname": "log",
+ "fieldtype": "Code",
+ "label": "Log",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "force",
+ "fieldtype": "Check",
+ "label": "Force"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-09-05 21:30:04.796090",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "Package Import",
+ "naming_rule": "Expression",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/package_import/package_import.py b/frappe/core/doctype/package_import/package_import.py
new file mode 100644
index 0000000000..f4a2d666dd
--- /dev/null
+++ b/frappe/core/doctype/package_import/package_import.py
@@ -0,0 +1,58 @@
+# Copyright (c) 2021, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+import frappe
+import os
+import json
+import subprocess
+from frappe.model.document import Document
+from frappe.desk.form.load import get_attachments
+from frappe.model.sync import get_doc_files
+from frappe.modules.import_file import import_file_by_path, import_doc
+
+class PackageImport(Document):
+ def validate(self):
+ if self.activate:
+ self.import_package()
+
+ def import_package(self):
+ attachment = get_attachments(self.doctype, self.name)
+
+ if not attachment:
+ frappe.throw(frappe._('Please attach the package'))
+
+ attachment = attachment[0]
+
+ # get package_name from file (package_name-0.0.0.tar.gz)
+ package_name = attachment.file_name.split('.')[0].rsplit('-', 1)[0]
+ if not os.path.exists(frappe.get_site_path('packages')):
+ os.makedirs(frappe.get_site_path('packages'))
+
+ # extract
+ subprocess.check_output(['tar', 'xzf',
+ frappe.get_site_path(attachment.file_url.strip('/')), '-C',
+ frappe.get_site_path('packages')])
+
+ package_path = frappe.get_site_path('packages', package_name)
+
+ # import Package
+ with open(os.path.join(package_path, package_name + '.json'), 'r') as packagefile:
+ doc_dict = json.loads(packagefile.read())
+
+ frappe.flags.package = import_doc(doc_dict)
+
+ # collect modules
+ files = []
+ log = []
+ for module in os.listdir(package_path):
+ module_path = os.path.join(package_path, module)
+ if os.path.isdir(module_path):
+ get_doc_files(files, module_path)
+
+ # import files
+ for file in files:
+ import_file_by_path(file, force=self.force, ignore_version=True,
+ for_sync=True)
+ log.append('Imported {}'.format(file))
+
+ self.log = '\n'.join(log)
diff --git a/frappe/core/doctype/package_import/test_package_import.py b/frappe/core/doctype/package_import/test_package_import.py
new file mode 100644
index 0000000000..04628fed93
--- /dev/null
+++ b/frappe/core/doctype/package_import/test_package_import.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# See license.txt
+
+# import frappe
+import unittest
+
+class TestPackageImport(unittest.TestCase):
+ pass
diff --git a/frappe/chat/doctype/chat_token/__init__.py b/frappe/core/doctype/package_release/__init__.py
similarity index 100%
rename from frappe/chat/doctype/chat_token/__init__.py
rename to frappe/core/doctype/package_release/__init__.py
diff --git a/frappe/core/doctype/package_release/package_release.js b/frappe/core/doctype/package_release/package_release.js
new file mode 100644
index 0000000000..9eabe36839
--- /dev/null
+++ b/frappe/core/doctype/package_release/package_release.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2021, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Package Release', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/frappe/core/doctype/package_release/package_release.json b/frappe/core/doctype/package_release/package_release.json
new file mode 100644
index 0000000000..b651d699c4
--- /dev/null
+++ b/frappe/core/doctype/package_release/package_release.json
@@ -0,0 +1,95 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2021-09-05 12:59:01.932327",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "package",
+ "publish",
+ "path",
+ "column_break_3",
+ "major",
+ "minor",
+ "patch",
+ "section_break_7",
+ "release_notes"
+ ],
+ "fields": [
+ {
+ "fieldname": "package",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Package",
+ "options": "Package",
+ "reqd": 1
+ },
+ {
+ "fieldname": "major",
+ "fieldtype": "Int",
+ "label": "Major"
+ },
+ {
+ "fieldname": "minor",
+ "fieldtype": "Int",
+ "label": "Minor"
+ },
+ {
+ "fieldname": "patch",
+ "fieldtype": "Int",
+ "label": "Patch",
+ "no_copy": 1
+ },
+ {
+ "fieldname": "path",
+ "fieldtype": "Small Text",
+ "label": "Path",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_7",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "release_notes",
+ "fieldtype": "Markdown Editor",
+ "label": "Release Notes"
+ },
+ {
+ "default": "0",
+ "fieldname": "publish",
+ "fieldtype": "Check",
+ "label": "Publish"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-09-05 16:04:32.860988",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "Package Release",
+ "naming_rule": "By script",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/package_release/package_release.py b/frappe/core/doctype/package_release/package_release.py
new file mode 100644
index 0000000000..d23ae917c4
--- /dev/null
+++ b/frappe/core/doctype/package_release/package_release.py
@@ -0,0 +1,92 @@
+# Copyright (c) 2021, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+from frappe.modules.export_file import export_doc
+import os
+import subprocess
+from frappe.query_builder.functions import Max
+
+
+class PackageRelease(Document):
+ def set_version(self):
+ # set the next patch release by default
+ doctype = frappe.qb.DocType("Package Release")
+ if not self.major:
+ self.major = frappe.qb.from_(doctype) \
+ .where(doctype.package == self.package) \
+ .select(Max(doctype.minor)).run()[0][0] or 0
+
+ if not self.minor:
+ self.minor = frappe.qb.from_(doctype) \
+ .where(doctype.package == self.package) \
+ .select(Max("minor")).run()[0][0] or 0
+ if not self.patch:
+ value = frappe.qb.from_(doctype) \
+ .where(doctype.package == self.package) \
+ .select(Max("patch")).run()[0][0] or 0
+ self.patch = value + 1
+
+ def autoname(self):
+ self.set_version()
+ self.name = '{}-{}.{}.{}'.format(
+ frappe.db.get_value('Package', self.package, 'package_name'),
+ self.major, self.minor, self.patch)
+
+ def validate(self):
+ if self.publish:
+ self.export_files()
+
+ def export_files(self):
+ '''Export all the documents in this package to site/packages folder'''
+ package = frappe.get_doc('Package', self.package)
+
+ self.export_modules()
+ self.export_package_files(package)
+ self.make_tarfile(package)
+
+ def export_modules(self):
+ for m in frappe.db.get_all('Module Def', dict(package=self.package)):
+ module = frappe.get_doc('Module Def', m.name)
+ for l in module.meta.links:
+ if l.link_doctype == 'Module Def':
+ continue
+ # all documents of the type in the module
+ for d in frappe.get_all(l.link_doctype, dict(module=m.name)):
+ export_doc(frappe.get_doc(l.link_doctype, d.name))
+
+ def export_package_files(self, package):
+ # write readme
+ with open(frappe.get_site_path('packages', package.package_name, 'README.md'), 'w') as readme:
+ readme.write(package.readme)
+
+ # write license
+ if package.license:
+ with open(frappe.get_site_path('packages', package.package_name, 'LICENSE.md'), 'w') as license:
+ license.write(package.license)
+
+ # write package.json as `frappe_package.json`
+ with open(frappe.get_site_path('packages', package.package_name, package.package_name + '.json'), 'w') as packagefile:
+ packagefile.write(frappe.as_json(package.as_dict(no_nulls=True)))
+
+ def make_tarfile(self, package):
+ # make tarfile
+ filename = '{}.tar.gz'.format(self.name)
+ subprocess.check_output(['tar', 'czf', filename, package.package_name],
+ cwd=frappe.get_site_path('packages'))
+
+ # move file
+ subprocess.check_output(['mv', frappe.get_site_path('packages', filename),
+ frappe.get_site_path('public', 'files')])
+
+ # make attachment
+ file = frappe.get_doc(dict(
+ doctype = 'File',
+ file_url = '/' + os.path.join('files', filename),
+ attached_to_doctype = self.doctype,
+ attached_to_name = self.name
+ ))
+
+ file.flags.ignore_duplicate_entry_error = True
+ file.insert()
diff --git a/frappe/core/doctype/package_release/test_package_release.py b/frappe/core/doctype/package_release/test_package_release.py
new file mode 100644
index 0000000000..6a15e8625b
--- /dev/null
+++ b/frappe/core/doctype/package_release/test_package_release.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# See license.txt
+
+# import frappe
+import unittest
+
+class TestPackageRelease(unittest.TestCase):
+ pass
diff --git a/frappe/core/doctype/page/__init__.py b/frappe/core/doctype/page/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/core/doctype/page/__init__.py
+++ b/frappe/core/doctype/page/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/core/doctype/page/page.py b/frappe/core/doctype/page/page.py
index bdec350efd..894e180bb1 100644
--- a/frappe/core/doctype/page/page.py
+++ b/frappe/core/doctype/page/page.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
import os
from frappe.model.document import Document
@@ -11,7 +10,6 @@ from frappe import conf, _, safe_decode
from frappe.desk.form.meta import get_code_files_via_hooks, get_js
from frappe.desk.utils import validate_route_conflict
from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
-from six import text_type
class Page(Document):
def autoname(self):
@@ -111,6 +109,7 @@ class Page(Document):
if os.path.exists(fpath):
with open(fpath, 'r') as f:
self.script = render_include(f.read())
+ self.script += f"\n\n//# sourceURL={page_name}.js"
# css
fpath = os.path.join(path, page_name + '.css')
diff --git a/frappe/core/doctype/page/test_page.js b/frappe/core/doctype/page/test_page.js
deleted file mode 100644
index 7e45fd8639..0000000000
--- a/frappe/core/doctype/page/test_page.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Page", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Page
- () => frappe.tests.make('Page', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/page/test_page.py b/frappe/core/doctype/page/test_page.py
index f7b3952a5b..7db32497a8 100644
--- a/frappe/core/doctype/page/test_page.py
+++ b/frappe/core/doctype/page/test_page.py
@@ -1,7 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/patch_log/__init__.py b/frappe/core/doctype/patch_log/__init__.py
index 0e57cb68c3..eb5ba62e5c 100644
--- a/frappe/core/doctype/patch_log/__init__.py
+++ b/frappe/core/doctype/patch_log/__init__.py
@@ -1,3 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
diff --git a/frappe/core/doctype/patch_log/patch_log.py b/frappe/core/doctype/patch_log/patch_log.py
index 3103d44af4..9a5da24e37 100644
--- a/frappe/core/doctype/patch_log/patch_log.py
+++ b/frappe/core/doctype/patch_log/patch_log.py
@@ -1,9 +1,8 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/patch_log/test_patch_log.py b/frappe/core/doctype/patch_log/test_patch_log.py
index 0a7f22a78b..df1ca16b22 100644
--- a/frappe/core/doctype/patch_log/test_patch_log.py
+++ b/frappe/core/doctype/patch_log/test_patch_log.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/payment_gateway/payment_gateway.json b/frappe/core/doctype/payment_gateway/payment_gateway.json
index b97d72c771..7195b3949e 100644
--- a/frappe/core/doctype/payment_gateway/payment_gateway.json
+++ b/frappe/core/doctype/payment_gateway/payment_gateway.json
@@ -1,154 +1,55 @@
{
- "allow_copy": 0,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "autoname": "field:gateway",
- "beta": 0,
- "creation": "2015-12-15 22:26:45.221162",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 1,
+ "actions": [],
+ "autoname": "field:gateway",
+ "creation": "2022-01-24 21:09:47.229371",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "gateway",
+ "gateway_settings",
+ "gateway_controller"
+ ],
"fields": [
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "gateway",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Gateway",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "gateway",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Gateway",
+ "reqd": 1,
+ "unique": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "gateway_settings",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Gateway Settings",
- "length": 0,
- "no_copy": 0,
- "options": "DocType",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "gateway_settings",
+ "fieldtype": "Link",
+ "label": "Gateway Settings",
+ "options": "DocType"
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "gateway_controller",
- "fieldtype": "Dynamic Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Gateway Controller",
- "length": 0,
- "no_copy": 0,
- "options": "gateway_settings",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "fieldname": "gateway_controller",
+ "fieldtype": "Dynamic Link",
+ "label": "Gateway Controller",
+ "options": "gateway_settings"
}
- ],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
- "in_create": 1,
- "is_submittable": 0,
- "issingle": 0,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2018-02-05 14:24:33.526645",
- "modified_by": "Administrator",
- "module": "Core",
- "name": "Payment Gateway",
- "name_case": "",
- "owner": "Administrator",
+ ],
+ "links": [],
+ "modified": "2022-01-24 21:17:03.864719",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "Payment Gateway",
+ "naming_rule": "By fieldname",
+ "owner": "Administrator",
"permissions": [
{
- "amend": 0,
- "apply_user_permissions": 0,
- "cancel": 0,
- "create": 0,
- "delete": 0,
- "email": 0,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 0,
- "read": 1,
- "report": 0,
- "role": "System Manager",
- "set_user_permissions": 0,
- "share": 0,
- "submit": 0,
- "write": 0
+ "create": 1,
+ "delete": 1,
+ "read": 1,
+ "role": "System Manager",
+ "write": 1
}
- ],
- "quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 0,
- "track_seen": 0
+ ],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/frappe/core/doctype/payment_gateway/payment_gateway.py b/frappe/core/doctype/payment_gateway/payment_gateway.py
index 80799e311b..d0fa550ea1 100644
--- a/frappe/core/doctype/payment_gateway/payment_gateway.py
+++ b/frappe/core/doctype/payment_gateway/payment_gateway.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/payment_gateway/test_payment_gateway.js b/frappe/core/doctype/payment_gateway/test_payment_gateway.js
deleted file mode 100644
index 36168ec887..0000000000
--- a/frappe/core/doctype/payment_gateway/test_payment_gateway.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Payment Gateway", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Payment Gateway
- () => frappe.tests.make('Payment Gateway', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/payment_gateway/test_payment_gateway.py b/frappe/core/doctype/payment_gateway/test_payment_gateway.py
index 2faf1a7fb4..e2ad081cfa 100644
--- a/frappe/core/doctype/payment_gateway/test_payment_gateway.py
+++ b/frappe/core/doctype/payment_gateway/test_payment_gateway.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py
index 1d0d6ebb09..2d1b026572 100644
--- a/frappe/core/doctype/prepared_report/prepared_report.py
+++ b/frappe/core/doctype/prepared_report/prepared_report.py
@@ -1,10 +1,8 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
-
import json
import frappe
@@ -13,8 +11,6 @@ from frappe.desk.query_report import generate_report_result
from frappe.model.document import Document
from frappe.utils import gzip_compress, gzip_decompress
from frappe.utils.background_jobs import enqueue
-from frappe.core.doctype.file.file import remove_all
-
class PreparedReport(Document):
def before_insert(self):
@@ -24,8 +20,6 @@ class PreparedReport(Document):
def enqueue_report(self):
enqueue(run_background, prepared_report=self.name, timeout=6000)
- def on_trash(self):
- remove_all("Prepared Report", self.name)
def run_background(prepared_report):
@@ -39,7 +33,10 @@ def run_background(prepared_report):
custom_report_doc = report
reference_report = custom_report_doc.reference_report
report = frappe.get_doc("Report", reference_report)
- report.custom_columns = custom_report_doc.json
+ if custom_report_doc.json:
+ data = json.loads(custom_report_doc.json)
+ if data:
+ report.custom_columns = data["columns"]
result = generate_report_result(
report=report,
@@ -100,7 +97,7 @@ def delete_expired_prepared_reports():
def delete_prepared_reports(reports):
reports = frappe.parse_json(reports)
for report in reports:
- frappe.delete_doc('Prepared Report', report['name'], ignore_permissions=True)
+ frappe.delete_doc('Prepared Report', report['name'], ignore_permissions=True, delete_permanently=True)
def create_json_gz_file(data, dt, dn):
# Storing data in CSV file causes information loss
diff --git a/frappe/core/doctype/prepared_report/test_prepared_report.js b/frappe/core/doctype/prepared_report/test_prepared_report.js
deleted file mode 100644
index eeffa89ca7..0000000000
--- a/frappe/core/doctype/prepared_report/test_prepared_report.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Prepared Report", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Prepared Report
- () => frappe.tests.make('Prepared Report', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/prepared_report/test_prepared_report.py b/frappe/core/doctype/prepared_report/test_prepared_report.py
index 17845be521..5b12990f64 100644
--- a/frappe/core/doctype/prepared_report/test_prepared_report.py
+++ b/frappe/core/doctype/prepared_report/test_prepared_report.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
import json
diff --git a/frappe/core/doctype/report/__init__.py b/frappe/core/doctype/report/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/core/doctype/report/__init__.py
+++ b/frappe/core/doctype/report/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/core/doctype/report/boilerplate/controller.js b/frappe/core/doctype/report/boilerplate/controller.js
index 5148f34462..9cf71a8c09 100644
--- a/frappe/core/doctype/report/boilerplate/controller.js
+++ b/frappe/core/doctype/report/boilerplate/controller.js
@@ -1,4 +1,4 @@
-// Copyright (c) 2016, {app_publisher} and contributors
+// Copyright (c) {year}, {app_publisher} and contributors
// For license information, please see license.txt
/* eslint-disable */
diff --git a/frappe/core/doctype/report/boilerplate/controller.py b/frappe/core/doctype/report/boilerplate/controller.py
index 55c01e4f75..72da0c7ce5 100644
--- a/frappe/core/doctype/report/boilerplate/controller.py
+++ b/frappe/core/doctype/report/boilerplate/controller.py
@@ -1,7 +1,6 @@
-# Copyright (c) 2013, {app_publisher} and contributors
+# Copyright (c) {year}, {app_publisher} and contributors
# For license information, please see license.txt
-from __future__ import unicode_literals
# import frappe
def execute(filters=None):
diff --git a/frappe/core/doctype/report/report.js b/frappe/core/doctype/report/report.js
index f78fd3e812..71ed0dac64 100644
--- a/frappe/core/doctype/report/report.js
+++ b/frappe/core/doctype/report/report.js
@@ -25,7 +25,7 @@ frappe.ui.form.on('Report', {
}
}, "fa fa-table");
- if (doc.is_standard === "Yes") {
+ if (doc.is_standard === "Yes" && frm.perm[0].write) {
frm.add_custom_button(doc.disabled ? __("Enable Report") : __("Disable Report"), function() {
frm.call('toggle_disable', {
disable: doc.disabled ? 0 : 1
diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py
index 01c32bcb57..9cb40dffd4 100644
--- a/frappe/core/doctype/report/report.py
+++ b/frappe/core/doctype/report/report.py
@@ -1,7 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
import json, datetime
from frappe import _, scrub
@@ -13,7 +11,6 @@ from frappe.modules import make_boilerplate
from frappe.core.doctype.page.page import delete_custom_role
from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
from frappe.desk.reportview import append_totals_row
-from six import iteritems
from frappe.utils.safe_exec import safe_exec
@@ -54,10 +51,19 @@ class Report(Document):
and not frappe.flags.in_patch):
frappe.throw(_("You are not allowed to delete Standard Report"))
delete_custom_role('report', self.name)
+ self.delete_prepared_reports()
+
+ def delete_prepared_reports(self):
+ prepared_reports = frappe.get_all("Prepared Report", filters={'ref_report_doctype': self.name}, pluck='name')
+
+ for report in prepared_reports:
+ frappe.delete_doc("Prepared Report", report, ignore_missing=True, force=True,
+ delete_permanently=True)
def get_columns(self):
- return [d.as_dict(no_default_fields = True) for d in self.columns]
+ return [d.as_dict(no_default_fields=True, no_child_table_fields=True) for d in self.columns]
+ @frappe.whitelist()
def set_doctype_roles(self):
if not self.get('roles') and self.is_standard == 'No':
meta = frappe.get_meta(self.ref_doctype)
@@ -107,7 +113,7 @@ class Report(Document):
if not self.query.lower().startswith("select"):
frappe.throw(_("Query must be a SELECT"), title=_('Report Document Error'))
- result = [list(t) for t in frappe.db.sql(self.query, filters, debug=True)]
+ result = [list(t) for t in frappe.db.sql(self.query, filters)]
columns = self.get_columns() or [cstr(c[0]) for c in frappe.db.get_description()]
return [columns, result]
@@ -237,7 +243,7 @@ class Report(Document):
_filters = params.get('filters') or []
if filters:
- for key, value in iteritems(filters):
+ for key, value in filters.items():
condition, _value = '=', value
if isinstance(value, (list, tuple)):
condition, _value = value
@@ -304,8 +310,11 @@ class Report(Document):
return data
- @Document.whitelist
+ @frappe.whitelist()
def toggle_disable(self, disable):
+ if not self.has_permission('write'):
+ frappe.throw(_("You are not allowed to edit the report."))
+
self.db_set("disabled", cint(disable))
@frappe.whitelist()
@@ -321,9 +330,8 @@ def get_group_by_field(args, doctype):
if args['aggregate_function'] == 'count':
group_by_field = 'count(*) as _aggregate_column'
else:
- group_by_field = '{0}(`tab{1}`.{2}) as _aggregate_column'.format(
+ group_by_field = '{0}({1}) as _aggregate_column'.format(
args.aggregate_function,
- doctype,
args.aggregate_on
)
diff --git a/frappe/core/doctype/report/test_query_report.js b/frappe/core/doctype/report/test_query_report.js
deleted file mode 100644
index c51884cd21..0000000000
--- a/frappe/core/doctype/report/test_query_report.js
+++ /dev/null
@@ -1,33 +0,0 @@
-// Test for creating query report
-QUnit.test("Test Query Report", function(assert){
- assert.expect(2);
- let done = assert.async();
- let random = frappe.utils.get_random(10);
- frappe.run_serially([
- () => frappe.set_route('List', 'ToDo'),
- () => frappe.new_doc('ToDo'),
- () => frappe.quick_entry.dialog.set_value('description', random),
- () => frappe.quick_entry.insert(),
- () => {
- return frappe.tests.make('Report', [
- {report_name: 'ToDo List Report'},
- {report_type: 'Query Report'},
- {ref_doctype: 'ToDo'}
- ]);
- },
- () => frappe.set_route('Form','Report', 'ToDo List Report'),
-
- //Query
- () => cur_frm.set_value('query','select description,owner,status from `tabToDo`'),
- () => cur_frm.save(),
- () => frappe.set_route('query-report','ToDo List Report'),
- () => frappe.timeout(5),
- () => {
- assert.ok($('div.slick-header-column').length == 4,'Correct numbers of columns visible');
- //To check if the result is present
- assert.ok($('div.r1:contains('+random+')').is(':visible'),'Result is visible in report');
- frappe.timeout(3);
- },
- () => done()
- ]);
-});
diff --git a/frappe/core/doctype/report/test_report.js b/frappe/core/doctype/report/test_report.js
deleted file mode 100644
index 65515dcd5b..0000000000
--- a/frappe/core/doctype/report/test_report.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Report", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Report
- () => frappe.tests.make('Report', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/report/test_report.py b/frappe/core/doctype/report/test_report.py
index d76a1470e4..a077956d71 100644
--- a/frappe/core/doctype/report/test_report.py
+++ b/frappe/core/doctype/report/test_report.py
@@ -1,11 +1,12 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe, json, os
import unittest
-from frappe.desk.query_report import run, save_report
+from frappe.desk.query_report import run, save_report, add_total_row
+from frappe.desk.reportview import delete_report, save_report as _save_report
from frappe.custom.doctype.customize_form.customize_form import reset_customization
+from frappe.core.doctype.user_permission.test_user_permission import create_user
test_records = frappe.get_test_records('Report')
test_dependencies = ['User']
@@ -31,6 +32,60 @@ class TestReport(unittest.TestCase):
self.assertEqual(columns[1].get('label'), 'Module')
self.assertTrue('User' in [d.get('name') for d in data])
+ def test_save_or_delete_report(self):
+ '''Test for validations when editing / deleting report of type Report Builder'''
+
+ try:
+ report = frappe.get_doc({
+ 'doctype': 'Report',
+ 'ref_doctype': 'User',
+ 'report_name': 'Test Delete Report',
+ 'report_type': 'Report Builder',
+ 'is_standard': 'No',
+ }).insert()
+
+ # Check for PermissionError
+ create_user("test_report_owner@example.com", "Website Manager")
+ frappe.set_user("test_report_owner@example.com")
+ self.assertRaises(frappe.PermissionError, delete_report, report.name)
+
+ # Check for Report Type
+ frappe.set_user("Administrator")
+ report.db_set("report_type", "Custom Report")
+ self.assertRaisesRegex(
+ frappe.ValidationError,
+ "Only reports of type Report Builder can be deleted",
+ delete_report,
+ report.name
+ )
+
+ # Check if creating and deleting works with proper validations
+ frappe.set_user("test@example.com")
+ report_name = _save_report(
+ 'Dummy Report',
+ 'User',
+ json.dumps([{
+ 'fieldname': 'email',
+ 'fieldtype': 'Data',
+ 'label': 'Email',
+ 'insert_after_index': 0,
+ 'link_field': 'name',
+ 'doctype': 'User',
+ 'options': 'Email',
+ 'width': 100,
+ 'id':'email',
+ 'name': 'Email'
+ }])
+ )
+
+ doc = frappe.get_doc("Report", report_name)
+ delete_report(doc.name)
+
+ finally:
+ frappe.set_user("Administrator")
+ frappe.db.rollback()
+
+
def test_custom_report(self):
reset_customization('User')
custom_report_name = save_report(
@@ -83,9 +138,11 @@ class TestReport(unittest.TestCase):
def test_report_permissions(self):
frappe.set_user('test@example.com')
- frappe.db.sql("""delete from `tabHas Role` where parent = %s
- and role = 'Test Has Role'""", frappe.session.user, auto_commit=1)
-
+ frappe.db.delete("Has Role", {
+ "parent": frappe.session.user,
+ "role": "Test Has Role"
+ })
+ frappe.db.commit()
if not frappe.db.exists('Role', 'Test Has Role'):
role = frappe.get_doc({
'doctype': 'Role',
@@ -106,7 +163,7 @@ class TestReport(unittest.TestCase):
else:
report = frappe.get_doc('Report', 'Test Report')
- self.assertNotEquals(report.is_permitted(), True)
+ self.assertNotEqual(report.is_permitted(), True)
frappe.set_user('Administrator')
# test for the `_format` method if report data doesn't have sort_by parameter
@@ -201,3 +258,80 @@ result = [
# check values
self.assertTrue('System User' in [d.get('type') for d in data[1]])
+
+ def test_toggle_disabled(self):
+ """Make sure that authorization is respected.
+ """
+ # Assuming that there will be reports in the system.
+ reports = frappe.get_all(doctype='Report', limit=1)
+ report_name = reports[0]['name']
+ doc = frappe.get_doc('Report', report_name)
+ status = doc.disabled
+
+ # User has write permission on reports and should pass through
+ frappe.set_user('test@example.com')
+ doc.toggle_disable(not status)
+ doc.reload()
+ self.assertNotEqual(status, doc.disabled)
+
+ # User has no write permission on reports, permission error is expected.
+ frappe.set_user('test1@example.com')
+ doc = frappe.get_doc('Report', report_name)
+ with self.assertRaises(frappe.exceptions.ValidationError):
+ doc.toggle_disable(1)
+
+ # Set user back to administrator
+ frappe.set_user('Administrator')
+
+ def test_add_total_row_for_tree_reports(self):
+ report_settings = {
+ 'tree': True,
+ 'parent_field': 'parent_value'
+ }
+
+ columns = [
+ {
+ "fieldname": "parent_column",
+ "label": "Parent Column",
+ "fieldtype": "Data",
+ "width": 10
+ },
+ {
+ "fieldname": "column_1",
+ "label": "Column 1",
+ "fieldtype": "Float",
+ "width": 10
+ },
+ {
+ "fieldname": "column_2",
+ "label": "Column 2",
+ "fieldtype": "Float",
+ "width": 10
+ }
+ ]
+
+ result = [
+ {
+ "parent_column": "Parent 1",
+ "column_1": 200,
+ "column_2": 150.50
+ },
+ {
+ "parent_column": "Child 1",
+ "column_1": 100,
+ "column_2": 75.25,
+ "parent_value": "Parent 1"
+ },
+ {
+ "parent_column": "Child 2",
+ "column_1": 100,
+ "column_2": 75.25,
+ "parent_value": "Parent 1"
+ }
+ ]
+
+ result = add_total_row(result, columns, meta=None, is_tree=report_settings['tree'],
+ parent_field=report_settings['parent_field'])
+ self.assertEqual(result[-1][0], "Total")
+ self.assertEqual(result[-1][1], 200)
+ self.assertEqual(result[-1][2], 150.50)
diff --git a/frappe/core/doctype/report_column/report_column.py b/frappe/core/doctype/report_column/report_column.py
index 69c88b7bda..3b2c1e130b 100644
--- a/frappe/core/doctype/report_column/report_column.py
+++ b/frappe/core/doctype/report_column/report_column.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/report_filter/report_filter.py b/frappe/core/doctype/report_filter/report_filter.py
index d85a1a5a65..b325985308 100644
--- a/frappe/core/doctype/report_filter/report_filter.py
+++ b/frappe/core/doctype/report_filter/report_filter.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/role/__init__.py b/frappe/core/doctype/role/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/core/doctype/role/__init__.py
+++ b/frappe/core/doctype/role/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py b/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py
index 375ea02e0e..dc17526047 100644
--- a/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py
+++ b/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py
@@ -2,9 +2,10 @@ import frappe
from ..role import desk_properties
def execute():
+ frappe.reload_doctype('user')
frappe.reload_doctype('role')
for role in frappe.get_all('Role', ['name', 'desk_access']):
role_doc = frappe.get_doc('Role', role.name)
for key in desk_properties:
role_doc.set(key, role_doc.desk_access)
- role_doc.save()
\ No newline at end of file
+ role_doc.save()
diff --git a/frappe/core/doctype/role/role.js b/frappe/core/doctype/role/role.js
index 6968607008..f436c8c166 100644
--- a/frappe/core/doctype/role/role.js
+++ b/frappe/core/doctype/role/role.js
@@ -3,6 +3,8 @@
frappe.ui.form.on('Role', {
refresh: function(frm) {
+ frm.set_df_property('is_custom', 'read_only', frappe.session.user !== 'Administrator');
+
frm.add_custom_button("Role Permissions Manager", function() {
frappe.route_options = {"role": frm.doc.name};
frappe.set_route("permission-manager");
diff --git a/frappe/core/doctype/role/role.json b/frappe/core/doctype/role/role.json
index e47dc7194b..e370082fb5 100644
--- a/frappe/core/doctype/role/role.json
+++ b/frappe/core/doctype/role/role.json
@@ -12,12 +12,12 @@
"restrict_to_domain",
"column_break_4",
"disabled",
+ "is_custom",
"desk_access",
"two_factor_auth",
"navigation_settings_section",
"search_bar",
"notifications",
- "chat",
"list_settings_section",
"list_sidebar",
"bulk_actions",
@@ -84,12 +84,6 @@
"fieldtype": "Check",
"label": "Search Bar"
},
- {
- "default": "1",
- "fieldname": "chat",
- "fieldtype": "Check",
- "label": "Chat"
- },
{
"fieldname": "list_settings_section",
"fieldtype": "Section Break",
@@ -141,16 +135,24 @@
"fieldname": "notifications",
"fieldtype": "Check",
"label": "Notifications"
+ },
+ {
+ "default": "0",
+ "fieldname": "is_custom",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Is Custom"
}
],
"icon": "fa fa-bookmark",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-12-03 14:08:38.181035",
+ "modified": "2022-01-12 20:18:18.496230",
"modified_by": "Administrator",
"module": "Core",
"name": "Role",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -168,5 +170,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py
index 7adfeba8d9..f955c29462 100644
--- a/frappe/core/doctype/role/role.py
+++ b/frappe/core/doctype/role/role.py
@@ -1,17 +1,23 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
-
from frappe.model.document import Document
-desk_properties = ("search_bar", "notifications", "chat", "list_sidebar",
+desk_properties = ("search_bar", "notifications", "list_sidebar",
"bulk_actions", "view_switcher", "form_sidebar", "timeline", "dashboard")
+STANDARD_ROLES = (
+ "Administrator",
+ "System Manager",
+ "Script Manager",
+ "All",
+ "Guest"
+)
+
class Role(Document):
def before_rename(self, old, new, merge=False):
- if old in ("Guest", "Administrator", "System Manager", "All"):
+ if old in STANDARD_ROLES:
frappe.throw(frappe._("Standard roles cannot be renamed"))
def after_insert(self):
@@ -24,7 +30,7 @@ class Role(Document):
self.set_desk_properties()
def disable_role(self):
- if self.name in ("Guest", "Administrator", "System Manager", "All"):
+ if self.name in STANDARD_ROLES:
frappe.throw(frappe._("Standard roles cannot be disabled"))
else:
self.remove_roles()
@@ -33,13 +39,13 @@ class Role(Document):
# set if desk_access is not allowed, unset all desk properties
if self.name == 'Guest':
self.desk_access = 0
-
+
if not self.desk_access:
for key in desk_properties:
self.set(key, 0)
def remove_roles(self):
- frappe.db.sql("delete from `tabHas Role` where role = %s", self.name)
+ frappe.db.delete("Has Role", {"role": self.name})
frappe.clear_cache()
def on_update(self):
@@ -53,10 +59,9 @@ class Role(Document):
if user_type != user.user_type:
user.save()
-
def get_info_based_on_role(role, field='email'):
''' Get information of all users that have been assigned this role '''
- users = frappe.get_list("Has Role", filters={"role": role, "parenttype": "User"},
+ users = frappe.get_list("Has Role", filters={"role": role}, parent_doctype="User",
fields=["parent as user_name"])
return get_user_info(users, field)
@@ -73,3 +78,15 @@ def get_user_info(users, field='email'):
def get_users(role):
return [d.parent for d in frappe.get_all("Has Role", filters={"role": role, "parenttype": "User"},
fields=["parent"])]
+
+
+# searches for active employees
+@frappe.whitelist()
+@frappe.validate_and_sanitize_search_inputs
+def role_query(doctype, txt, searchfield, start, page_len, filters):
+ report_filters = [['Role', 'name', 'like', '%{}%'.format(txt)], ['Role', 'is_custom', '=', 0]]
+ if filters and isinstance(filters, list):
+ report_filters.extend(filters)
+
+ return frappe.get_all('Role', limit_start=start, limit_page_length=page_len,
+ filters=report_filters, as_list=1)
diff --git a/frappe/core/doctype/role/test_role.py b/frappe/core/doctype/role/test_role.py
index 6459a72c98..1671f9a9c8 100644
--- a/frappe/core/doctype/role/test_role.py
+++ b/frappe/core/doctype/role/test_role.py
@@ -1,7 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.py b/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.py
index f5081ef595..cd9a6dc0fa 100644
--- a/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.py
+++ b/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.py
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.core.doctype.report.report import is_prepared_report_disabled
from frappe.model.document import Document
class RolePermissionforPageandReport(Document):
+ @frappe.whitelist()
def set_report_page_data(self):
self.set_custom_roles()
self.check_prepared_report_disabled()
@@ -35,12 +35,14 @@ class RolePermissionforPageandReport(Document):
doc = frappe.get_doc(doctype, docname)
return doc.roles
+ @frappe.whitelist()
def reset_roles(self):
roles = self.get_standard_roles()
self.set('roles', roles)
self.update_custom_roles()
self.update_disable_prepared_report()
+ @frappe.whitelist()
def update_report_page_data(self):
self.update_custom_roles()
self.update_disable_prepared_report()
diff --git a/frappe/core/doctype/role_profile/role_profile.json b/frappe/core/doctype/role_profile/role_profile.json
index 4b3f35aa57..7cd60a16d1 100644
--- a/frappe/core/doctype/role_profile/role_profile.json
+++ b/frappe/core/doctype/role_profile/role_profile.json
@@ -1,175 +1,80 @@
{
- "allow_copy": 0,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "autoname": "role_profile",
- "beta": 0,
- "creation": "2017-08-31 04:16:38.764465",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 1,
- "engine": "InnoDB",
+ "actions": [],
+ "autoname": "role_profile",
+ "creation": "2017-08-31 04:16:38.764465",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "role_profile",
+ "roles_html",
+ "roles"
+ ],
"fields": [
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "role_profile",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Role Name",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
+ "fieldname": "role_profile",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Role Name",
+ "reqd": 1,
"unique": 1
- },
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "roles_html",
- "fieldtype": "HTML",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Roles HTML",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "roles_html",
+ "fieldtype": "HTML",
+ "label": "Roles HTML",
+ "read_only": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "roles",
- "fieldtype": "Table",
- "hidden": 1,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Roles Assigned",
- "length": 0,
- "no_copy": 0,
- "options": "Has Role",
- "permlevel": 1,
- "precision": "",
- "print_hide": 1,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "fieldname": "roles",
+ "fieldtype": "Table",
+ "hidden": 1,
+ "label": "Roles Assigned",
+ "options": "Has Role",
+ "permlevel": 1,
+ "print_hide": 1,
+ "read_only": 1
}
- ],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
- "in_create": 0,
- "is_submittable": 0,
- "issingle": 0,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2017-10-17 11:05:11.183066",
- "modified_by": "Administrator",
- "module": "Core",
- "name": "Role Profile",
- "name_case": "",
- "owner": "Administrator",
+ ],
+ "links": [
+ {
+ "link_doctype": "User",
+ "link_fieldname": "role_profile_name"
+ }
+ ],
+ "modified": "2021-12-03 15:45:45.270963",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "Role Profile",
+ "naming_rule": "Expression (old style)",
+ "owner": "Administrator",
"permissions": [
{
- "amend": 0,
- "apply_user_permissions": 0,
- "cancel": 0,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "set_user_permissions": 0,
- "share": 1,
- "submit": 0,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
"write": 1
- },
+ },
{
- "amend": 0,
- "apply_user_permissions": 0,
- "cancel": 0,
- "create": 0,
- "delete": 0,
- "email": 1,
- "export": 1,
- "if_owner": 0,
- "import": 0,
- "permlevel": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "set_user_permissions": 0,
- "share": 1,
- "submit": 0,
+ "email": 1,
+ "export": 1,
+ "permlevel": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
"write": 1
}
- ],
- "quick_entry": 0,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "title_field": "role_profile",
- "track_changes": 1,
- "track_seen": 0
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "title_field": "role_profile",
+ "track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/core/doctype/role_profile/role_profile.py b/frappe/core/doctype/role_profile/role_profile.py
index 4def834adb..cb0a43d68f 100644
--- a/frappe/core/doctype/role_profile/role_profile.py
+++ b/frappe/core/doctype/role_profile/role_profile.py
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
from frappe.model.document import Document
+import frappe
class RoleProfile(Document):
def autoname(self):
@@ -12,5 +12,9 @@ class RoleProfile(Document):
def on_update(self):
""" Changes in role_profile reflected across all its user """
- from frappe.core.doctype.user.user import update_roles
- update_roles(self.name)
+ users = frappe.get_all('User', filters={'role_profile_name': self.name})
+ roles = [role.role for role in self.roles]
+ for d in users:
+ user = frappe.get_doc('User', d)
+ user.set('roles', [])
+ user.add_roles(*roles)
diff --git a/frappe/core/doctype/role_profile/test_role_profile.js b/frappe/core/doctype/role_profile/test_role_profile.js
deleted file mode 100644
index 559a5fc0ac..0000000000
--- a/frappe/core/doctype/role_profile/test_role_profile.js
+++ /dev/null
@@ -1,33 +0,0 @@
-QUnit.module('Core');
-
-QUnit.test("test: Role Profile", function (assert) {
- let done = assert.async();
-
- assert.expect(3);
-
- frappe.run_serially([
- // insert a new user
- () => frappe.tests.make('Role Profile', [
- {role_profile: 'Test 2'}
- ]),
-
- () => {
- $('input.box')[0].checked = true;
- $('input.box')[2].checked = true;
- $('input.box')[4].checked = true;
- cur_frm.save();
- },
-
- () => frappe.timeout(1),
- () => cur_frm.refresh(),
- () => frappe.timeout(2),
- () => {
- assert.equal($('input.box')[0].checked, true);
- assert.equal($('input.box')[2].checked, true);
- assert.equal($('input.box')[4].checked, true);
- },
-
- () => done()
- ]);
-
-});
\ No newline at end of file
diff --git a/frappe/core/doctype/role_profile/test_role_profile.py b/frappe/core/doctype/role_profile/test_role_profile.py
index 624b85c315..b208a186de 100644
--- a/frappe/core/doctype/role_profile/test_role_profile.py
+++ b/frappe/core/doctype/role_profile/test_role_profile.py
@@ -1,12 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
import unittest
+test_dependencies = ['Role']
+
class TestRoleProfile(unittest.TestCase):
def test_make_new_role_profile(self):
+ frappe.delete_doc_if_exists('Role Profile', 'Test 1', force=1)
new_role_profile = frappe.get_doc(dict(doctype='Role Profile', role_profile='Test 1')).insert()
self.assertEqual(new_role_profile.role_profile, 'Test 1')
@@ -18,7 +20,25 @@ class TestRoleProfile(unittest.TestCase):
new_role_profile.save()
self.assertEqual(new_role_profile.roles[0].role, '_Test Role 2')
+ # user with a role profile
+ random_user = frappe.mock("email")
+ random_user_name = frappe.mock("name")
+
+ random_user = frappe.get_doc({
+ "doctype": "User",
+ "email": random_user,
+ "enabled": 1,
+ "first_name": random_user_name,
+ "new_password": "Eastern_43A1W",
+ "role_profile_name": 'Test 1'
+ }).insert(ignore_permissions=True, ignore_if_duplicate=True)
+ self.assertListEqual([role.role for role in random_user.roles], [role.role for role in new_role_profile.roles])
+
# clear roles
new_role_profile.roles = []
new_role_profile.save()
- self.assertEqual(new_role_profile.roles, [])
\ No newline at end of file
+ self.assertEqual(new_role_profile.roles, [])
+
+ # user roles with the role profile should also be updated
+ random_user.reload()
+ self.assertListEqual(random_user.roles, [])
\ No newline at end of file
diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json
index f86a4c8884..396b32bdf9 100644
--- a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json
+++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json
@@ -38,7 +38,7 @@
}
],
"links": [],
- "modified": "2020-01-22 00:00:00.000000",
+ "modified": "2021-10-25 00:00:00.000000",
"modified_by": "Administrator",
"module": "Core",
"name": "Scheduled Job Log",
@@ -59,6 +59,5 @@
],
"quick_entry": 1,
"sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
+ "sort_order": "DESC"
}
diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py
index 26871c9adf..bd5c15bc31 100644
--- a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py
+++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py b/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py
index 1e5290425b..9957f6c34c 100644
--- a/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py
+++ b/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
index e02d9e5db0..1a795bab82 100644
--- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
+++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
@@ -1,15 +1,15 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
+import json
+from datetime import datetime
from typing import Dict, List
-import frappe, json
-from frappe.model.document import Document
-from frappe.utils import now_datetime, get_datetime
-from datetime import datetime
from croniter import croniter
+
+import frappe
+from frappe.model.document import Document
+from frappe.utils import get_datetime, now_datetime
from frappe.utils.background_jobs import enqueue, get_jobs
@@ -109,7 +109,7 @@ class ScheduledJobType(Document):
return 'long' if ('Long' in self.frequency) else 'default'
def on_trash(self):
- frappe.db.sql('delete from `tabScheduled Job Log` where scheduled_job_type=%s', self.name)
+ frappe.db.delete("Scheduled Job Log", {"scheduled_job_type": self.name})
@frappe.whitelist()
@@ -117,6 +117,7 @@ def execute_event(doc: str):
frappe.only_for("System Manager")
doc = json.loads(doc)
frappe.get_doc("Scheduled Job Type", doc.get("name")).enqueue(force=True)
+ return doc
def run_scheduled_job(job_type: str):
diff --git a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py
index d0a65defa4..a11966c47e 100644
--- a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py
+++ b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
from frappe.utils import get_datetime
@@ -12,7 +10,7 @@ from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
class TestScheduledJobType(unittest.TestCase):
def setUp(self):
frappe.db.rollback()
- frappe.db.sql('truncate `tabScheduled Job Type`')
+ frappe.db.truncate("Scheduled Job Type")
sync_jobs()
frappe.db.commit()
diff --git a/frappe/core/doctype/server_script/server_script.js b/frappe/core/doctype/server_script/server_script.js
index 95a63780f8..ca34af11ab 100644
--- a/frappe/core/doctype/server_script/server_script.js
+++ b/frappe/core/doctype/server_script/server_script.js
@@ -9,6 +9,19 @@ frappe.ui.form.on('Server Script', {
if (frm.doc.script_type != 'Scheduler Event') {
frm.dashboard.hide();
}
+
+ if (!frm.is_new()) {
+ frm.add_custom_button(__('Compare Versions'), () => {
+ new frappe.ui.DiffView("Server Script", "script", frm.doc.name);
+ });
+ }
+
+
+ frm.call('get_autocompletion_items')
+ .then(r => r.message)
+ .then(items => {
+ frm.set_df_property('script', 'autocompletions', items);
+ });
},
setup_help(frm) {
diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json
index b7e49673f8..520c0008c5 100644
--- a/frappe/core/doctype/server_script/server_script.json
+++ b/frappe/core/doctype/server_script/server_script.json
@@ -13,6 +13,7 @@
"api_method",
"allow_guest",
"column_break_3",
+ "module",
"disabled",
"section_break_8",
"script",
@@ -93,6 +94,12 @@
"label": "Event Frequency",
"mandatory_depends_on": "eval:doc.script_type == \"Scheduler Event\"",
"options": "All\nHourly\nDaily\nWeekly\nMonthly\nYearly\nHourly Long\nDaily Long\nWeekly Long\nMonthly Long"
+ },
+ {
+ "fieldname": "module",
+ "fieldtype": "Link",
+ "label": "Module (for export)",
+ "options": "Module Def"
}
],
"index_web_pages_for_search": 1,
@@ -102,7 +109,7 @@
"link_fieldname": "server_script"
}
],
- "modified": "2021-02-18 12:36:19.803425",
+ "modified": "2021-09-04 12:02:43.671240",
"modified_by": "Administrator",
"module": "Core",
"name": "Server Script",
diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py
index 8838d9e954..5b1aab1241 100644
--- a/frappe/core/doctype/server_script/server_script.py
+++ b/frappe/core/doctype/server_script/server_script.py
@@ -1,22 +1,20 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import ast
+from types import FunctionType, MethodType, ModuleType
from typing import Dict, List
import frappe
from frappe.model.document import Document
-from frappe.utils.safe_exec import safe_exec
+from frappe.utils.safe_exec import get_safe_globals, safe_exec, NamespaceDict
from frappe import _
class ServerScript(Document):
def validate(self):
frappe.only_for("Script Manager", True)
- self.validate_script()
self.sync_scheduled_jobs()
self.clear_scheduled_events()
@@ -29,6 +27,11 @@ class ServerScript(Document):
for job in self.scheduled_jobs:
frappe.delete_doc("Scheduled Job Type", job.name)
+ def get_code_fields(self):
+ return {
+ 'script': 'py'
+ }
+
@property
def scheduled_jobs(self) -> List[Dict[str, str]]:
return frappe.get_all(
@@ -37,10 +40,6 @@ class ServerScript(Document):
fields=["name", "stopped"],
)
- def validate_script(self):
- """Utilizes the ast module to check for syntax errors
- """
- ast.parse(self.script)
def sync_scheduled_jobs(self):
"""Sync Scheduled Job Type statuses if Server Script's disabled status is changed
@@ -95,7 +94,7 @@ class ServerScript(Document):
Args:
doc (Document): Executes script with for a certain document's events
"""
- safe_exec(self.script, _locals={"doc": doc})
+ safe_exec(self.script, _locals={"doc": doc}, restrict_commit_rollback=True)
def execute_scheduled_method(self):
"""Specific to Scheduled Jobs via Server Scripts
@@ -122,6 +121,51 @@ class ServerScript(Document):
if locals["conditions"]:
return locals["conditions"]
+ @frappe.whitelist()
+ def get_autocompletion_items(self):
+ """Generates a list of a autocompletion strings from the context dict
+ that is used while executing a Server Script.
+
+ Returns:
+ list: Returns list of autocompletion items.
+ For e.g., ["frappe.utils.cint", "frappe.db.get_all", ...]
+ """
+ def get_keys(obj):
+ out = []
+ for key in obj:
+ if key.startswith('_'):
+ continue
+ value = obj[key]
+ if isinstance(value, (NamespaceDict, dict)) and value:
+ if key == 'form_dict':
+ out.append(['form_dict', 7])
+ continue
+ for subkey, score in get_keys(value):
+ fullkey = f'{key}.{subkey}'
+ out.append([fullkey, score])
+ else:
+ if isinstance(value, type) and issubclass(value, Exception):
+ score = 0
+ elif isinstance(value, ModuleType):
+ score = 10
+ elif isinstance(value, (FunctionType, MethodType)):
+ score = 9
+ elif isinstance(value, type):
+ score = 8
+ elif isinstance(value, dict):
+ score = 7
+ else:
+ score = 6
+ out.append([key, score])
+ return out
+
+ items = frappe.cache().get_value('server_script_autocompletion_items')
+ if not items:
+ items = get_keys(get_safe_globals())
+ items = [{'value': d[0], 'score': d[1]} for d in items]
+ frappe.cache().set_value('server_script_autocompletion_items', items)
+ return items
+
@frappe.whitelist()
def setup_scheduler_events(script_name, frequency):
diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py
index 12a8fa47fa..b5f3ba7168 100644
--- a/frappe/core/doctype/server_script/server_script_utils.py
+++ b/frappe/core/doctype/server_script/server_script_utils.py
@@ -19,13 +19,6 @@ EVENT_MAP = {
'on_update_after_submit': 'After Save (Submitted Document)'
}
-def run_server_script_api(method):
- # called via handler, execute an API script
- script_name = get_server_script_map().get('_api', {}).get(method)
- if script_name:
- frappe.get_doc('Server Script', script_name).execute_method()
- return True
-
def run_server_script_for_doc_event(doc, event):
# run document event method
if not event in EVENT_MAP:
diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py
index aac8b3deed..aa4507b858 100644
--- a/frappe/core/doctype/server_script/test_server_script.py
+++ b/frappe/core/doctype/server_script/test_server_script.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
import requests
@@ -61,6 +59,16 @@ conditions = '1 = 1'
reference_doctype = 'Note',
script = '''
frappe.method_that_doesnt_exist("do some magic")
+'''
+ ),
+ dict(
+ name='test_todo_commit',
+ script_type = 'DocType Event',
+ doctype_event = 'Before Save',
+ reference_doctype = 'ToDo',
+ disabled = 1,
+ script = '''
+frappe.db.commit()
'''
)
]
@@ -68,7 +76,7 @@ class TestServerScript(unittest.TestCase):
@classmethod
def setUpClass(cls):
frappe.db.commit()
- frappe.db.sql('truncate `tabServer Script`')
+ frappe.db.truncate("Server Script")
frappe.get_doc('User', 'Administrator').add_roles('Script Manager')
for script in scripts:
script_doc = frappe.get_doc(doctype ='Server Script')
@@ -80,7 +88,7 @@ class TestServerScript(unittest.TestCase):
@classmethod
def tearDownClass(cls):
frappe.db.commit()
- frappe.db.sql('truncate `tabServer Script`')
+ frappe.db.truncate("Server Script")
frappe.cache().delete_value('server_script_map')
def setUp(self):
@@ -104,10 +112,72 @@ class TestServerScript(unittest.TestCase):
self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello')
def test_permission_query(self):
- self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', return_query=1))
+ if frappe.conf.db_type == "mariadb":
+ self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', run=False))
+ else:
+ self.assertTrue('where (1 = \'1\')' in frappe.db.get_list('ToDo', run=False))
self.assertTrue(isinstance(frappe.db.get_list('ToDo'), list))
def test_attribute_error(self):
"""Raise AttributeError if method not found in Namespace"""
note = frappe.get_doc({"doctype": "Note", "title": "Test Note: Server Script"})
self.assertRaises(AttributeError, note.insert)
+
+ def test_syntax_validation(self):
+ server_script = scripts[0]
+ server_script["script"] = "js || code.?"
+
+ with self.assertRaises(frappe.ValidationError) as se:
+ frappe.get_doc(doctype="Server Script", **server_script).insert()
+
+ self.assertTrue("invalid python code" in str(se.exception).lower(),
+ msg="Python code validation not working")
+
+ def test_commit_in_doctype_event(self):
+ server_script = frappe.get_doc('Server Script', 'test_todo_commit')
+ server_script.disabled = 0
+ server_script.save()
+
+ self.assertRaises(AttributeError, frappe.get_doc(dict(doctype='ToDo', description='test me')).insert)
+
+ server_script.disabled = 1
+ server_script.save()
+
+ def test_restricted_qb(self):
+ todo = frappe.get_doc(doctype="ToDo", description="QbScriptTestNote")
+ todo.insert()
+
+ script = frappe.get_doc(
+ doctype='Server Script',
+ name='test_qb_restrictions',
+ script_type = 'API',
+ api_method = 'test_qb_restrictions',
+ allow_guest = 1,
+ # whitelisted update
+ script = f'''
+frappe.db.set_value("ToDo", "{todo.name}", "description", "safe")
+'''
+ )
+ script.insert()
+ script.execute_method()
+
+ todo.reload()
+ self.assertEqual(todo.description, "safe")
+
+ # unsafe update
+ script.script = f"""
+todo = frappe.qb.DocType("ToDo")
+frappe.qb.update(todo).set(todo.description, "unsafe").where(todo.name == "{todo.name}").run()
+"""
+ script.save()
+ self.assertRaises(frappe.PermissionError, script.execute_method)
+ todo.reload()
+ self.assertEqual(todo.description, "safe")
+
+ # safe select
+ script.script = f"""
+todo = frappe.qb.DocType("ToDo")
+frappe.qb.from_(todo).select(todo.name).where(todo.name == "{todo.name}").run()
+"""
+ script.save()
+ script.execute_method()
diff --git a/frappe/core/doctype/session_default/session_default.py b/frappe/core/doctype/session_default/session_default.py
index 8a8db46ff1..9470a1bb38 100644
--- a/frappe/core/doctype/session_default/session_default.py
+++ b/frappe/core/doctype/session_default/session_default.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/session_default_settings/session_default_settings.py b/frappe/core/doctype/session_default_settings/session_default_settings.py
index 7b4bd19e9a..52c917223e 100644
--- a/frappe/core/doctype/session_default_settings/session_default_settings.py
+++ b/frappe/core/doctype/session_default_settings/session_default_settings.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
import json
diff --git a/frappe/core/doctype/session_default_settings/test_session_default_settings.py b/frappe/core/doctype/session_default_settings/test_session_default_settings.py
index 12aa14d343..7a7e971aed 100644
--- a/frappe/core/doctype/session_default_settings/test_session_default_settings.py
+++ b/frappe/core/doctype/session_default_settings/test_session_default_settings.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
from frappe.core.doctype.session_default_settings.session_default_settings import set_session_default_values, clear_session_defaults
diff --git a/frappe/core/doctype/sms_parameter/__init__.py b/frappe/core/doctype/sms_parameter/__init__.py
index baffc48825..8b13789179 100755
--- a/frappe/core/doctype/sms_parameter/__init__.py
+++ b/frappe/core/doctype/sms_parameter/__init__.py
@@ -1 +1 @@
-from __future__ import unicode_literals
+
diff --git a/frappe/core/doctype/sms_parameter/sms_parameter.py b/frappe/core/doctype/sms_parameter/sms_parameter.py
index 08b220b61a..fb8466eac6 100644
--- a/frappe/core/doctype/sms_parameter/sms_parameter.py
+++ b/frappe/core/doctype/sms_parameter/sms_parameter.py
@@ -1,7 +1,6 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/doctype/sms_settings/__init__.py b/frappe/core/doctype/sms_settings/__init__.py
index baffc48825..8b13789179 100755
--- a/frappe/core/doctype/sms_settings/__init__.py
+++ b/frappe/core/doctype/sms_settings/__init__.py
@@ -1 +1 @@
-from __future__ import unicode_literals
+
diff --git a/frappe/core/doctype/sms_settings/sms_settings.json b/frappe/core/doctype/sms_settings/sms_settings.json
index 3bb89604af..d29949af45 100755
--- a/frappe/core/doctype/sms_settings/sms_settings.json
+++ b/frappe/core/doctype/sms_settings/sms_settings.json
@@ -1,238 +1,80 @@
{
- "allow_copy": 1,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "beta": 0,
- "creation": "2013-01-10 16:34:24",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "editable_grid": 0,
+ "actions": [],
+ "allow_copy": 1,
+ "creation": "2013-01-10 16:34:24",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "sms_gateway_url",
+ "message_parameter",
+ "receiver_parameter",
+ "static_parameters_section",
+ "parameters",
+ "use_post"
+ ],
"fields": [
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "description": "Eg. smsgateway.com/api/send_sms.cgi",
- "fieldname": "sms_gateway_url",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "SMS Gateway URL",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "description": "Eg. smsgateway.com/api/send_sms.cgi",
+ "fieldname": "sms_gateway_url",
+ "fieldtype": "Small Text",
+ "in_list_view": 1,
+ "label": "SMS Gateway URL",
+ "reqd": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "description": "Enter url parameter for message",
- "fieldname": "message_parameter",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Message Parameter",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "description": "Enter url parameter for message",
+ "fieldname": "message_parameter",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Message Parameter",
+ "reqd": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "description": "Enter url parameter for receiver nos",
- "fieldname": "receiver_parameter",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Receiver Parameter",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "description": "Enter url parameter for receiver nos",
+ "fieldname": "receiver_parameter",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Receiver Parameter",
+ "reqd": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "static_parameters_section",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "fieldname": "static_parameters_section",
+ "fieldtype": "Column Break",
"width": "50%"
- },
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "description": "Enter static url parameters here (Eg. sender=ERPNext, username=ERPNext, password=1234 etc.)",
- "fieldname": "parameters",
- "fieldtype": "Table",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Static Parameters",
- "length": 0,
- "no_copy": 0,
- "options": "SMS Parameter",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "description": "Enter static url parameters here (Eg. sender=ERPNext, username=ERPNext, password=1234 etc.)",
+ "fieldname": "parameters",
+ "fieldtype": "Table",
+ "label": "Static Parameters",
+ "options": "SMS Parameter"
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "use_post",
- "fieldtype": "Check",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Use POST",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "default": "0",
+ "fieldname": "use_post",
+ "fieldtype": "Check",
+ "label": "Use POST"
}
- ],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "icon": "fa fa-cog",
- "idx": 1,
- "image_view": 0,
- "in_create": 0,
- "is_submittable": 0,
- "issingle": 1,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2017-11-01 12:57:20.943845",
- "modified_by": "Administrator",
- "module": "Core",
- "name": "SMS Settings",
- "owner": "Administrator",
+ ],
+ "icon": "fa fa-cog",
+ "idx": 1,
+ "issingle": 1,
+ "links": [],
+ "modified": "2021-09-21 19:45:26.809793",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "SMS Settings",
+ "owner": "Administrator",
"permissions": [
{
- "amend": 0,
- "apply_user_permissions": 0,
- "cancel": 0,
- "create": 1,
- "delete": 0,
- "email": 0,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 0,
- "read": 1,
- "report": 0,
- "role": "System Manager",
- "set_user_permissions": 0,
- "share": 1,
- "submit": 0,
+ "create": 1,
+ "read": 1,
+ "role": "System Manager",
+ "share": 1,
"write": 1
}
- ],
- "quick_entry": 0,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
- "track_changes": 0,
- "track_seen": 0
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/core/doctype/sms_settings/sms_settings.py b/frappe/core/doctype/sms_settings/sms_settings.py
index ac835108c1..f15ba7e4f6 100644
--- a/frappe/core/doctype/sms_settings/sms_settings.py
+++ b/frappe/core/doctype/sms_settings/sms_settings.py
@@ -1,16 +1,13 @@
# -*- coding: utf-8 -*-
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _, throw, msgprint
from frappe.utils import nowdate
from frappe.model.document import Document
-import six
-from six import string_types
class SMSSettings(Document):
pass
@@ -35,20 +32,20 @@ def validate_receiver_nos(receiver_list):
@frappe.whitelist()
def get_contact_number(contact_name, ref_doctype, ref_name):
"returns mobile number of the contact"
- number = frappe.db.sql("""select mobile_no, phone from tabContact
- where name=%s
+ number = frappe.db.sql("""select mobile_no, phone from tabContact
+ where name=%s
and exists(
select name from `tabDynamic Link` where link_doctype=%s and link_name=%s
)
""", (contact_name, ref_doctype, ref_name))
-
+
return number and (number[0][0] or number[0][1]) or ''
@frappe.whitelist()
def send_sms(receiver_list, msg, sender_name = '', success_msg = True):
import json
- if isinstance(receiver_list, string_types):
+ if isinstance(receiver_list, str):
receiver_list = json.loads(receiver_list)
if not isinstance(receiver_list, list):
receiver_list = [receiver_list]
diff --git a/frappe/core/doctype/sms_settings/test_sms_settings.js b/frappe/core/doctype/sms_settings/test_sms_settings.js
deleted file mode 100644
index c090d167f5..0000000000
--- a/frappe/core/doctype/sms_settings/test_sms_settings.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: SMS Settings", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially('SMS Settings', [
- // insert a new SMS Settings
- () => frappe.tests.make([
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/sms_settings/test_sms_settings.py b/frappe/core/doctype/sms_settings/test_sms_settings.py
index b14fd3e4a0..b3be912f9e 100644
--- a/frappe/core/doctype/sms_settings/test_sms_settings.py
+++ b/frappe/core/doctype/sms_settings/test_sms_settings.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/success_action/success_action.py b/frappe/core/doctype/success_action/success_action.py
index f8b99f1fea..afb3a87485 100644
--- a/frappe/core/doctype/success_action/success_action.py
+++ b/frappe/core/doctype/success_action/success_action.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
from frappe.model.document import Document
class SuccessAction(Document):
diff --git a/frappe/core/doctype/system_settings/system_settings.js b/frappe/core/doctype/system_settings/system_settings.js
index c0c9074cbc..5128ae24cb 100644
--- a/frappe/core/doctype/system_settings/system_settings.js
+++ b/frappe/core/doctype/system_settings/system_settings.js
@@ -10,6 +10,10 @@ frappe.ui.form.on("System Settings", {
frm.set_value(key, val);
frappe.sys_defaults[key] = val;
});
+ if (frm.re_setup_moment) {
+ frappe.app.setup_moment();
+ delete frm.re_setup_moment;
+ }
}
});
},
@@ -32,5 +36,14 @@ frappe.ui.form.on("System Settings", {
frm.set_value('prepared_report_expiry_period', 7);
}
}
- }
+ },
+ on_update: function(frm) {
+ if (frappe.boot.time_zone && frappe.boot.time_zone.system !== frm.doc.time_zone) {
+ // Clear cache after saving to refresh the values of boot.
+ frappe.ui.toolbar.clear_cache();
+ }
+ },
+ first_day_of_the_week(frm) {
+ frm.re_setup_moment = true;
+ },
});
diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json
index 13dbc32620..61410fb1a8 100644
--- a/frappe/core/doctype/system_settings/system_settings.json
+++ b/frappe/core/doctype/system_settings/system_settings.json
@@ -17,12 +17,14 @@
"date_and_number_format",
"date_format",
"time_format",
- "column_break_7",
"number_format",
+ "column_break_7",
"float_precision",
"currency_precision",
+ "first_day_of_the_week",
"sec_backup_limit",
"backup_limit",
+ "encrypt_backup",
"background_workers",
"enable_scheduler",
"dormant_days",
@@ -66,9 +68,8 @@
"prepared_report_section",
"enable_prepared_report_auto_deletion",
"prepared_report_expiry_period",
- "chat",
- "enable_chat",
- "use_socketio_to_upload_file"
+ "system_updates_section",
+ "disable_system_update_notification"
],
"fields": [
{
@@ -97,6 +98,7 @@
"fieldname": "time_zone",
"fieldtype": "Select",
"label": "Time Zone",
+ "read_only": 1,
"reqd": 1
},
{
@@ -382,24 +384,6 @@
"fieldtype": "Check",
"label": "Hide footer in auto email reports"
},
- {
- "collapsible": 1,
- "fieldname": "chat",
- "fieldtype": "Section Break",
- "label": "Chat"
- },
- {
- "default": "1",
- "fieldname": "enable_chat",
- "fieldtype": "Check",
- "label": "Enable Chat"
- },
- {
- "default": "1",
- "fieldname": "use_socketio_to_upload_file",
- "fieldtype": "Check",
- "label": "Use socketio to upload file"
- },
{
"fieldname": "column_break_21",
"fieldtype": "Column Break"
@@ -446,7 +430,7 @@
{
"default": "30",
"depends_on": "enable_prepared_report_auto_deletion",
- "description": "System will automatically delete Prepared Reports after these many days since creation",
+ "description": "System will auto-delete Prepared Reports permanently after these many days since creation",
"fieldname": "prepared_report_expiry_period",
"fieldtype": "Int",
"label": "Prepared Report Expiry Period (Days)"
@@ -465,28 +449,48 @@
},
{
"default": "Frappe",
+ "description": "The application name will be used in the Login page.",
"fieldname": "app_name",
"fieldtype": "Data",
- "label": "App Name"
- },
- {
- "default": "3",
- "description": "Hourly rate limit for generating password reset links",
- "fieldname": "password_reset_limit",
- "fieldtype": "Int",
- "label": "Password Reset Link Generation Limit"
+ "hidden": 1,
+ "label": "Application Name"
},
{
"default": "1",
"fieldname": "strip_exif_metadata_from_uploaded_images",
"fieldtype": "Check",
"label": "Strip EXIF tags from uploaded images"
+ },
+ {
+ "default": "0",
+ "fieldname": "encrypt_backup",
+ "fieldtype": "Check",
+ "label": "Encrypt Backups"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "system_updates_section",
+ "fieldtype": "Section Break",
+ "label": "System Updates"
+ },
+ {
+ "default": "0",
+ "fieldname": "disable_system_update_notification",
+ "fieldtype": "Check",
+ "label": "Disable System Update Notification"
+ },
+ {
+ "default": "Sunday",
+ "fieldname": "first_day_of_the_week",
+ "fieldtype": "Select",
+ "label": "First Day of the Week",
+ "options": "Sunday\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
- "modified": "2020-12-30 18:52:22.161391",
+ "modified": "2022-01-04 11:28:34.881192",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",
@@ -503,5 +507,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"track_changes": 1
-}
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/system_settings/system_settings.py b/frappe/core/doctype/system_settings/system_settings.py
index d102526a9e..1ae8e9e79e 100644
--- a/frappe/core/doctype/system_settings/system_settings.py
+++ b/frappe/core/doctype/system_settings/system_settings.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.model.document import Document
@@ -42,7 +41,7 @@ class SystemSettings(Document):
def on_update(self):
for df in self.meta.get("fields"):
- if df.fieldtype not in no_value_fields:
+ if df.fieldtype not in no_value_fields and self.has_value_changed(df.fieldname):
frappe.db.set_default(df.fieldname, self.get(df.fieldname))
if self.language:
diff --git a/frappe/core/doctype/system_settings/test_system_settings.js b/frappe/core/doctype/system_settings/test_system_settings.js
deleted file mode 100644
index 53edaba99d..0000000000
--- a/frappe/core/doctype/system_settings/test_system_settings.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: System Settings", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially('System Settings', [
- // insert a new System Settings
- () => frappe.tests.make([
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/system_settings/test_system_settings.py b/frappe/core/doctype/system_settings/test_system_settings.py
index 82d0ddbd7c..f95e26b793 100644
--- a/frappe/core/doctype/system_settings/test_system_settings.py
+++ b/frappe/core/doctype/system_settings/test_system_settings.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/core/doctype/data_import_legacy/__init__.py b/frappe/core/doctype/test/__init__.py
similarity index 100%
rename from frappe/core/doctype/data_import_legacy/__init__.py
rename to frappe/core/doctype/test/__init__.py
diff --git a/frappe/core/doctype/test/test.js b/frappe/core/doctype/test/test.js
new file mode 100644
index 0000000000..e423c58686
--- /dev/null
+++ b/frappe/core/doctype/test/test.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2021, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('test', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/frappe/core/doctype/test/test.json b/frappe/core/doctype/test/test.json
new file mode 100644
index 0000000000..31a57c9964
--- /dev/null
+++ b/frappe/core/doctype/test/test.json
@@ -0,0 +1,42 @@
+{
+ "actions": [],
+ "creation": "2021-03-31 10:06:57.919697",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "test"
+ ],
+ "fields": [
+ {
+ "fieldname": "test",
+ "fieldtype": "Data",
+ "label": "Test"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "is_virtual": 1,
+ "links": [],
+ "modified": "2021-03-31 10:06:57.919697",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "test",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/test/test.py b/frappe/core/doctype/test/test.py
new file mode 100644
index 0000000000..ab6fcb6de4
--- /dev/null
+++ b/frappe/core/doctype/test/test.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+# import frappe
+from frappe.model.document import Document
+import json
+
+
+class test(Document):
+
+ def db_insert(self):
+ d = self.get_valid_dict(convert_dates_to_str=True)
+ with open("data_file.json", "w+") as read_file:
+ json.dump(d, read_file)
+
+ def load_from_db(self):
+ with open("data_file.json", "r") as read_file:
+ d = json.load(read_file)
+ super(Document, self).__init__(d)
+
+ def db_update(self):
+ d = self.get_valid_dict(convert_dates_to_str=True)
+ with open("data_file.json", "w+") as read_file:
+ json.dump(d, read_file)
+
+ def get_list(self, args):
+ with open("data_file.json", "r") as read_file:
+ return [json.load(read_file)]
+
+ def get_value(self, fields, filters, **kwargs):
+ # return []
+ with open("data_file.json", "r") as read_file:
+ return [json.load(read_file)]
+
+ def get_count(self, args):
+ # return []
+ with open("data_file.json", "r") as read_file:
+ return [json.load(read_file)]
+
+ def get_stats(self, args):
+ # return []
+ with open("data_file.json", "r") as read_file:
+ return [json.load(read_file)]
+
diff --git a/frappe/core/doctype/test/test_test.py b/frappe/core/doctype/test/test_test.py
new file mode 100644
index 0000000000..d8508b8651
--- /dev/null
+++ b/frappe/core/doctype/test/test_test.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# License: MIT. See LICENSE
+# import frappe
+import unittest
+
+class Testtest(unittest.TestCase):
+ pass
diff --git a/frappe/core/doctype/transaction_log/test_transaction_log.js b/frappe/core/doctype/transaction_log/test_transaction_log.js
deleted file mode 100644
index d212a8238c..0000000000
--- a/frappe/core/doctype/transaction_log/test_transaction_log.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Transaction Log", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Transaction Log
- () => frappe.tests.make('Transaction Log', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/transaction_log/test_transaction_log.py b/frappe/core/doctype/transaction_log/test_transaction_log.py
index 164a683c38..c332a82f65 100644
--- a/frappe/core/doctype/transaction_log/test_transaction_log.py
+++ b/frappe/core/doctype/transaction_log/test_transaction_log.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
import hashlib
@@ -35,7 +33,7 @@ class TestTransactionLog(unittest.TestCase):
sha = hashlib.sha256()
sha.update(
- frappe.safe_encode(str(third_log.transaction_hash)) +
+ frappe.safe_encode(str(third_log.transaction_hash)) +
frappe.safe_encode(str(second_log.chaining_hash))
)
diff --git a/frappe/core/doctype/transaction_log/transaction_log.py b/frappe/core/doctype/transaction_log/transaction_log.py
index b7ea6cac60..0a480f6660 100644
--- a/frappe/core/doctype/transaction_log/transaction_log.py
+++ b/frappe/core/doctype/transaction_log/transaction_log.py
@@ -1,13 +1,14 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2018, Frappe Technologies and contributors
-# For license information, please see license.txt
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+import hashlib
-from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.model.document import Document
+from frappe.query_builder import DocType
from frappe.utils import cint, now_datetime
-import hashlib
+
class TransactionLog(Document):
def before_insert(self):
@@ -15,10 +16,9 @@ class TransactionLog(Document):
self.row_index = index
self.timestamp = now_datetime()
if index != 1:
- prev_hash = frappe.db.sql(
- "SELECT `chaining_hash` FROM `tabTransaction Log` WHERE `row_index` = '{0}'".format(index - 1))
+ prev_hash = frappe.get_all("Transaction Log", filters={"row_index":str(index-1)}, pluck="chaining_hash", limit=1)
if prev_hash:
- self.previous_hash = prev_hash[0][0]
+ self.previous_hash = prev_hash[0]
else:
self.previous_hash = "Indexing broken"
else:
@@ -30,26 +30,27 @@ class TransactionLog(Document):
def hash_line(self):
sha = hashlib.sha256()
sha.update(
- frappe.safe_encode(str(self.row_index)) + \
- frappe.safe_encode(str(self.timestamp)) + \
- frappe.safe_encode(str(self.data))
+ frappe.safe_encode(str(self.row_index))
+ + frappe.safe_encode(str(self.timestamp))
+ + frappe.safe_encode(str(self.data))
)
return sha.hexdigest()
def hash_chain(self):
sha = hashlib.sha256()
- sha.update(
- frappe.safe_encode(str(self.transaction_hash)) + \
- frappe.safe_encode(str(self.previous_hash))
- )
+ sha.update(frappe.safe_encode(str(self.transaction_hash)) + frappe.safe_encode(str(self.previous_hash)))
return sha.hexdigest()
def get_current_index():
- current = frappe.db.sql("""SELECT `current`
- FROM `tabSeries`
- WHERE `name` = 'TRANSACTLOG'
- FOR UPDATE""")
+ series = DocType("Series")
+ current = (
+ frappe.qb.from_(series)
+ .where(series.name == "TRANSACTLOG")
+ .for_update()
+ .select("current")
+ ).run()
+
if current and current[0][0] is not None:
current = current[0][0]
diff --git a/frappe/core/doctype/translation/test_translation.py b/frappe/core/doctype/translation/test_translation.py
index 12899dddf7..982d9bf976 100644
--- a/frappe/core/doctype/translation/test_translation.py
+++ b/frappe/core/doctype/translation/test_translation.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
@@ -10,7 +8,7 @@ from frappe import _
class TestTranslation(unittest.TestCase):
def setUp(self):
- frappe.db.sql('delete from tabTranslation')
+ frappe.db.delete("Translation")
def tearDown(self):
frappe.local.lang = 'en'
diff --git a/frappe/core/doctype/translation/translation.json b/frappe/core/doctype/translation/translation.json
index e91ffc2941..560f3b2ce2 100644
--- a/frappe/core/doctype/translation/translation.json
+++ b/frappe/core/doctype/translation/translation.json
@@ -43,8 +43,7 @@
{
"fieldname": "context",
"fieldtype": "Data",
- "label": "Context",
- "read_only": 1
+ "label": "Context"
},
{
"default": "0",
@@ -83,7 +82,7 @@
}
],
"links": [],
- "modified": "2020-03-12 13:28:48.223409",
+ "modified": "2021-12-31 10:19:52.541055",
"modified_by": "Administrator",
"module": "Core",
"name": "Translation",
diff --git a/frappe/core/doctype/translation/translation.py b/frappe/core/doctype/translation/translation.py
index 177dea401f..a01552903c 100644
--- a/frappe/core/doctype/translation/translation.py
+++ b/frappe/core/doctype/translation/translation.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe.utils import strip_html_tags, is_html
diff --git a/frappe/core/doctype/user/test_records.json b/frappe/core/doctype/user/test_records.json
index 93fcca5517..21fe3ff69d 100644
--- a/frappe/core/doctype/user/test_records.json
+++ b/frappe/core/doctype/user/test_records.json
@@ -38,6 +38,13 @@
"new_password": "Eastern_43A1W",
"enabled": 1
},
+ {
+ "doctype": "User",
+ "email": "test4@example.com",
+ "first_name": "_Test4",
+ "new_password": "Eastern_43A1W",
+ "enabled": 1
+ },
{
"doctype": "User",
"email": "testperm@example.com",
@@ -63,5 +70,19 @@
"role": "System Manager"
}
]
- }
+ },
+ {
+ "doctype": "User",
+ "email": "testpassword@example.com",
+ "enabled": 1,
+ "first_name": "_Test",
+ "new_password": "Eastern_43A1W",
+ "roles": [
+ {
+ "doctype": "Has Role",
+ "parentfield": "roles",
+ "role": "System Manager"
+ }
+ ]
+ }
]
diff --git a/frappe/core/doctype/user/test_user.js b/frappe/core/doctype/user/test_user.js
deleted file mode 100644
index 923a39c3a5..0000000000
--- a/frappe/core/doctype/user/test_user.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: User", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new User
- () => frappe.tests.make('User', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
\ No newline at end of file
diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py
index d16db5fecd..3e6e1ec7e2 100644
--- a/frappe/core/doctype/user/test_user.py
+++ b/frappe/core/doctype/user/test_user.py
@@ -1,17 +1,18 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-from __future__ import unicode_literals
-
-import frappe, unittest, uuid
+# License: MIT. See LICENSE
+import json
+import unittest
+from unittest.mock import patch
+import frappe
+import frappe.exceptions
+from frappe.core.doctype.user.user import (extract_mentions, reset_password,
+ sign_up, test_password_strength, update_password, verify_password)
+from frappe.frappeclient import FrappeClient
from frappe.model.delete_doc import delete_doc
-from frappe.utils.data import today, add_to_date
-from frappe import _dict
from frappe.utils import get_url
-from frappe.core.doctype.user.user import get_total_users
-from frappe.core.doctype.user.user import MaxUsersReachedError, test_password_strength
-from frappe.core.doctype.user.user import extract_mentions
+user_module = frappe.core.doctype.user.user
test_records = frappe.get_test_records('User')
class TestUser(unittest.TestCase):
@@ -24,7 +25,7 @@ class TestUser(unittest.TestCase):
def test_user_type(self):
new_user = frappe.get_doc(dict(doctype='User', email='test-for-type@example.com',
- first_name='Tester')).insert()
+ first_name='Tester')).insert(ignore_if_duplicate=True)
self.assertEqual(new_user.user_type, 'Website User')
# social login userid for frappe
@@ -53,7 +54,7 @@ class TestUser(unittest.TestCase):
def test_delete(self):
frappe.get_doc("User", "test@example.com").add_roles("_Test Role 2")
self.assertRaises(frappe.LinkExistsError, delete_doc, "Role", "_Test Role 2")
- frappe.db.sql("""delete from `tabHas Role` where role='_Test Role 2'""")
+ frappe.db.delete("Has Role", {"role": "_Test Role 2"})
delete_doc("Role","_Test Role 2")
if frappe.db.exists("User", "_test@example.com"):
@@ -69,7 +70,7 @@ class TestUser(unittest.TestCase):
delete_contact("_test@example.com")
delete_doc("User", "_test@example.com")
- self.assertTrue(not frappe.db.sql("""select * from `tabToDo` where owner=%s""",
+ self.assertTrue(not frappe.db.sql("""select * from `tabToDo` where allocated_to=%s""",
("_test@example.com",)))
from frappe.core.doctype.role.test_role import test_records as role_records
@@ -120,40 +121,9 @@ class TestUser(unittest.TestCase):
# system manager now added by Administrator
self.assertTrue("System Manager" in [d.role for d in me.get("roles")])
- # def test_deny_multiple_sessions(self):
- # from frappe.installer import update_site_config
- # clear_limit('users')
- #
- # # allow one session
- # user = frappe.get_doc('User', 'test@example.com')
- # user.simultaneous_sessions = 1
- # user.new_password = 'Eastern_43A1W'
- # user.save()
- #
- # def test_request(conn):
- # value = conn.get_value('User', 'first_name', {'name': 'test@example.com'})
- # self.assertTrue('first_name' in value)
- #
- # from frappe.frappeclient import FrappeClient
- # update_site_config('deny_multiple_sessions', 0)
- #
- # conn1 = FrappeClient(get_url(), "test@example.com", "Eastern_43A1W", verify=False)
- # test_request(conn1)
- #
- # conn2 = FrappeClient(get_url(), "test@example.com", "Eastern_43A1W", verify=False)
- # test_request(conn2)
- #
- # update_site_config('deny_multiple_sessions', 1)
- # conn3 = FrappeClient(get_url(), "test@example.com", "Eastern_43A1W", verify=False)
- # test_request(conn3)
- #
- # # first connection should fail
- # test_request(conn1)
-
-
def test_delete_user(self):
new_user = frappe.get_doc(dict(doctype='User', email='test-for-delete@example.com',
- first_name='Tester Delete User')).insert()
+ first_name='Tester Delete User')).insert(ignore_if_duplicate=True)
self.assertEqual(new_user.user_type, 'Website User')
# role with desk access
@@ -175,7 +145,7 @@ class TestUser(unittest.TestCase):
self.assertFalse(frappe.db.exists('User', new_user.name))
def test_password_strength(self):
- # Test Password without Password Strenth Policy
+ # Test Password without Password Strength Policy
frappe.db.set_value("System Settings", "System Settings", "enable_password_policy", 0)
# password policy is disabled, test_password_strength should be ignored
@@ -194,6 +164,17 @@ class TestUser(unittest.TestCase):
result = test_password_strength("Eastern_43A1W")
self.assertEqual(result['feedback']['password_policy_validation_passed'], True)
+
+ # test password strength while saving user with new password
+ user = frappe.get_doc("User", "test@example.com")
+ frappe.flags.in_test = False
+ user.new_password = "password"
+ self.assertRaisesRegex(frappe.exceptions.ValidationError, "Invalid Password", user.save)
+ user.reload()
+ user.new_password = "Eastern_43A1W"
+ user.save()
+ frappe.flags.in_test = True
+
def test_comment_mentions(self):
comment = '''
@@ -228,42 +209,174 @@ class TestUser(unittest.TestCase):
self.assertEqual(extract_mentions(comment)[0], "test_user@example.com")
self.assertEqual(extract_mentions(comment)[1], "test.again@example1.com")
+ frappe.delete_doc("User Group", "Team")
+ doc = frappe.get_doc({
+ 'doctype': 'User Group',
+ 'name': 'Team',
+ 'user_group_members': [{
+ 'user': 'test@example.com'
+ }, {
+ 'user': 'test1@example.com'
+ }]
+ })
+
+ doc.insert()
+
+ comment = '''
+
+ Testing comment for
+
+ @ Team
+ and
+
+ @ Unknown Team
+
+ please check
+
+ '''
+ self.assertListEqual(extract_mentions(comment), ['test@example.com', 'test1@example.com'])
+
def test_rate_limiting_for_reset_password(self):
- from frappe.utils.password import delete_password_reset_cache
- delete_password_reset_cache()
-
+ # Allow only one reset request for a day
frappe.db.set_value("System Settings", "System Settings", "password_reset_limit", 1)
-
- user = frappe.get_doc("User", "testperm@example.com")
- link = user.reset_password()
- self.assertRegex(link, "\/update-password\?key=[A-Za-z0-9]*")
-
- self.assertRaises(frappe.ValidationError, user.reset_password, False)
-
- def test_user_rollback(self):
- """ """
frappe.db.commit()
- frappe.db.begin()
- user_id = str(uuid.uuid4())
- email = f'{user_id}@example.com'
- try:
- frappe.flags.in_import = True # disable throttling
- frappe.get_doc(dict(
- doctype='User',
- email=email,
- first_name=user_id,
- )).insert()
- finally:
- frappe.flags.in_import = False
- # Check user has been added
- self.assertIsNotNone(frappe.db.get("User", {"email": email}))
+ url = get_url()
+ data={'cmd': 'frappe.core.doctype.user.user.reset_password', 'user': 'test@test.com'}
- # Check that rollback works
- frappe.db.rollback()
- self.assertIsNone(frappe.db.get("User", {"email": email}))
+ # Clear rate limit tracker to start fresh
+ key = f"rl:{data['cmd']}:{data['user']}"
+ frappe.cache().delete(key)
+
+ c = FrappeClient(url)
+ res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
+ res2 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
+ self.assertEqual(res1.status_code, 400)
+ self.assertEqual(res2.status_code, 417)
+
+ def test_user_rename(self):
+ old_name = "test_user_rename@example.com"
+ new_name = "test_user_rename_new@example.com"
+ user = frappe.get_doc({
+ "doctype": "User",
+ "email": old_name,
+ "enabled": 1,
+ "first_name": "_Test",
+ "new_password": "Eastern_43A1W",
+ "roles": [
+ {
+ "doctype": "Has Role",
+ "parentfield": "roles",
+ "role": "System Manager"
+ }]
+ }).insert(ignore_permissions=True, ignore_if_duplicate=True)
+
+ frappe.rename_doc('User', user.name, new_name)
+ self.assertTrue(frappe.db.exists("Notification Settings", new_name))
+
+ frappe.delete_doc("User", new_name)
+
+ def test_signup(self):
+ import frappe.website.utils
+ random_user = frappe.mock('email')
+ random_user_name = frappe.mock('name')
+ # disabled signup
+ with patch.object(user_module, "is_signup_disabled", return_value=True):
+ self.assertRaisesRegex(frappe.exceptions.ValidationError, "Sign Up is disabled",
+ sign_up, random_user, random_user_name, "/signup")
+
+ self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (1, "Please check your email for verification"))
+ self.assertEqual(frappe.cache().hget('redirect_after_login', random_user), "/welcome")
+
+ # re-register
+ self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (0, "Already Registered"))
+
+ # disabled user
+ user = frappe.get_doc("User", random_user)
+ user.enabled = 0
+ user.save()
+
+ self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (0, "Registered but disabled"))
+
+ # throttle user creation
+ with patch.object(user_module.frappe.db, "get_creation_count", return_value=301):
+ self.assertRaisesRegex(frappe.exceptions.ValidationError, "Throttled",
+ sign_up, frappe.mock('email'), random_user_name, "/signup")
+
+
+ def test_reset_password(self):
+ from frappe.auth import CookieManager, LoginManager
+ from frappe.utils import set_request
+ old_password = "Eastern_43A1W"
+ new_password = "easy_password"
+
+ set_request(path="/random")
+ frappe.local.cookie_manager = CookieManager()
+ frappe.local.login_manager = LoginManager()
+
+ frappe.set_user("testpassword@example.com")
+ test_user = frappe.get_doc("User", "testpassword@example.com")
+ test_user.reset_password()
+ self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/app")
+ self.assertEqual(update_password(new_password, key="wrong_key"), "The Link specified has either been used before or Invalid")
+
+ # password verification should fail with old password
+ self.assertRaises(frappe.exceptions.AuthenticationError, verify_password, old_password)
+ verify_password(new_password)
+
+ # reset password
+ update_password(old_password, old_password=new_password)
+
+ self.assertRaisesRegex(frappe.exceptions.ValidationError, "Invalid key type", update_password, "test", 1, ['like', '%'])
+
+ password_strength_response = {
+ "feedback": {
+ "password_policy_validation_passed": False,
+ "suggestions": ["Fix password"]
+ }
+ }
+
+ # password strength failure test
+ with patch.object(user_module, "test_password_strength", return_value=password_strength_response):
+ self.assertRaisesRegex(frappe.exceptions.ValidationError, "Fix password", update_password, new_password, 0, test_user.reset_password_key)
+
+
+ # test redirect URL for website users
+ frappe.set_user("test2@example.com")
+ self.assertEqual(update_password(new_password, old_password=old_password), "/")
+ # reset password
+ update_password(old_password, old_password=new_password)
+
+ # test API endpoint
+ with patch.object(user_module.frappe, 'sendmail') as sendmail:
+ frappe.clear_messages()
+ test_user = frappe.get_doc("User", "test2@example.com")
+ self.assertEqual(reset_password(user="test2@example.com"), None)
+ test_user.reload()
+ self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/")
+ update_password(old_password, old_password=new_password)
+ self.assertEqual(
+ json.loads(frappe.message_log[0]).get("message"),
+ "Password reset instructions have been sent to your email"
+ )
+
+ sendmail.assert_called_once()
+ self.assertEqual(sendmail.call_args[1]["recipients"], "test2@example.com")
+
+ self.assertEqual(reset_password(user="test2@example.com"), None)
+ self.assertEqual(reset_password(user="Administrator"), "not allowed")
+ self.assertEqual(reset_password(user="random"), "not found")
+
+ def test_user_onload_modules(self):
+ from frappe.config import get_modules_from_all_apps
+ from frappe.desk.form.load import getdoc
+ frappe.response.docs = []
+ getdoc("User", "Administrator")
+ doc = frappe.response.docs[0]
+ self.assertListEqual(doc.get("__onload").get('all_modules', []),
+ [m.get("module_name") for m in get_modules_from_all_apps()])
def delete_contact(user):
- frappe.db.sql("DELETE FROM `tabContact` WHERE `email_id`= %s", user)
- frappe.db.sql("DELETE FROM `tabContact Email` WHERE `email_id`= %s", user)
+ frappe.db.delete("Contact", {"email_id": user})
+ frappe.db.delete("Contact Email", {"email_id": user})
diff --git a/frappe/core/doctype/user/test_user_with_role_profile.js b/frappe/core/doctype/user/test_user_with_role_profile.js
deleted file mode 100644
index 5fd6f72410..0000000000
--- a/frappe/core/doctype/user/test_user_with_role_profile.js
+++ /dev/null
@@ -1,35 +0,0 @@
-QUnit.module('Core');
-
-QUnit.test("test: Set role profile in user", function (assert) {
- let done = assert.async();
-
- assert.expect(3);
-
- frappe.run_serially([
-
- // Insert a new user
- () => frappe.tests.make('User', [
- {email: 'test@test2.com'},
- {first_name: 'Test 2'},
- {send_welcome_email: 0}
- ]),
-
- () => frappe.timeout(2),
- () => {
- return frappe.tests.set_form_values(cur_frm, [
- {role_profile_name:'Test 2'}
- ]);
- },
-
- () => cur_frm.save(),
- () => frappe.timeout(2),
-
- () => {
- assert.equal($('input.box')[0].checked, true);
- assert.equal($('input.box')[2].checked, true);
- assert.equal($('input.box')[4].checked, true);
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js
index 3548b4c913..77c199cdd4 100644
--- a/frappe/core/doctype/user/user.js
+++ b/frappe/core/doctype/user/user.js
@@ -50,7 +50,7 @@ frappe.ui.form.on('User', {
let d = frm.add_child("block_modules");
d.module = v.module;
});
- frm.module_editor && frm.module_editor.refresh();
+ frm.module_editor && frm.module_editor.show();
}
});
}
@@ -59,23 +59,32 @@ frappe.ui.form.on('User', {
onload: function(frm) {
frm.can_edit_roles = has_access_to_edit_user();
- if (frm.can_edit_roles && !frm.is_new()) {
+ if (frm.can_edit_roles && !frm.is_new() && in_list(['System User', 'Website User'], frm.doc.user_type)) {
if (!frm.roles_editor) {
const role_area = $('')
.appendTo(frm.fields_dict.roles_html.wrapper);
+
frm.roles_editor = new frappe.RoleEditor(role_area, frm, frm.doc.role_profile_name ? 1 : 0);
- var module_area = $('
')
- .appendTo(frm.fields_dict.modules_html.wrapper);
- frm.module_editor = new frappe.ModuleEditor(frm, module_area);
+ if (frm.doc.user_type == 'System User') {
+ var module_area = $('
')
+ .appendTo(frm.fields_dict.modules_html.wrapper);
+ frm.module_editor = new frappe.ModuleEditor(frm, module_area);
+ }
} else {
frm.roles_editor.show();
}
}
},
refresh: function(frm) {
- var doc = frm.doc;
- if(!frm.is_new() && !frm.roles_editor && frm.can_edit_roles) {
+ let doc = frm.doc;
+
+ if (frm.is_new()) {
+ frm.set_value("time_zone", frappe.sys_defaults.time_zone);
+ }
+
+ if (in_list(['System User', 'Website User'], frm.doc.user_type)
+ && !frm.is_new() && !frm.roles_editor && frm.can_edit_roles) {
frm.reload_doc();
return;
}
@@ -162,7 +171,7 @@ frappe.ui.form.on('User', {
frm.add_custom_button(__("Reset OTP Secret"), function() {
frappe.call({
- method: "frappe.core.doctype.user.user.reset_otp_secret",
+ method: "frappe.twofactor.reset_otp_secret",
args: {
"user": frm.doc.name
}
@@ -176,7 +185,7 @@ frappe.ui.form.on('User', {
frm.roles_editor.show();
}
- frm.module_editor && frm.module_editor.refresh();
+ frm.module_editor && frm.module_editor.show();
if(frappe.session.user==doc.name) {
// update display settings
@@ -250,18 +259,25 @@ frappe.ui.form.on('User', {
}
});
},
- generate_keys: function(frm){
+ generate_keys: function(frm) {
frappe.call({
method: 'frappe.core.doctype.user.user.generate_keys',
args: {
user: frm.doc.name
},
- callback: function(r){
- if(r.message){
- frappe.msgprint(__("Save API Secret: ") + r.message.api_secret);
+ callback: function(r) {
+ if (r.message) {
+ frappe.msgprint(__("Save API Secret: {0}", [r.message.api_secret]));
+ frm.reload_doc();
}
}
});
+ },
+ on_update: function(frm) {
+ if (frappe.boot.time_zone && frappe.boot.time_zone.user !== frm.doc.time_zone) {
+ // Clear cache after saving to refresh the values of boot.
+ frappe.ui.toolbar.clear_cache();
+ }
}
});
diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json
index 747ace5de6..9e9529cd5e 100644
--- a/frappe/core/doctype/user/user.json
+++ b/frappe/core/doctype/user/user.json
@@ -10,15 +10,15 @@
"enabled",
"section_break_3",
"email",
- "last_name",
- "language",
- "column_break0",
"first_name",
- "full_name",
- "time_zone",
- "column_break_11",
"middle_name",
+ "last_name",
+ "column_break0",
+ "full_name",
"username",
+ "column_break_11",
+ "language",
+ "time_zone",
"send_welcome_email",
"unsubscribed",
"user_image",
@@ -191,7 +191,7 @@
"print_hide": 1
},
{
- "depends_on": "enabled",
+ "depends_on": "eval:in_list(['System User', 'Website User'], doc.user_type) && doc.enabled == 1",
"fieldname": "sb1",
"fieldtype": "Section Break",
"label": "Roles",
@@ -202,7 +202,8 @@
"fieldname": "role_profile_name",
"fieldtype": "Link",
"label": "Role Profile",
- "options": "Role Profile"
+ "options": "Role Profile",
+ "permlevel": 1
},
{
"fieldname": "roles_html",
@@ -391,6 +392,7 @@
},
{
"collapsible": 1,
+ "depends_on": "eval:in_list(['System User'], doc.user_type)",
"fieldname": "sb_allow_modules",
"fieldtype": "Section Break",
"label": "Allow Modules",
@@ -453,18 +455,18 @@
"label": "Simultaneous Sessions"
},
{
+ "bold": 1,
"default": "System User",
"description": "If the user has any role checked, then the user becomes a \"System User\". \"System User\" has access to the desktop",
"fieldname": "user_type",
- "fieldtype": "Select",
+ "fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "User Type",
"oldfieldname": "user_type",
"oldfieldtype": "Select",
- "options": "System User\nWebsite User",
- "permlevel": 1,
- "read_only": 1
+ "options": "User Type",
+ "permlevel": 1
},
{
"description": "Allow user to login only after this hour (0-24)",
@@ -553,20 +555,22 @@
"collapsible": 1,
"fieldname": "api_access",
"fieldtype": "Section Break",
- "label": "Api Access"
+ "label": "API Access"
},
{
- "description": "API Key cannot be regenerated",
+ "description": "API Key cannot be regenerated",
"fieldname": "api_key",
"fieldtype": "Data",
"label": "API Key",
+ "permlevel": 1,
"read_only": 1,
"unique": 1
},
{
"fieldname": "generate_keys",
"fieldtype": "Button",
- "label": "Generate Keys"
+ "label": "Generate Keys",
+ "permlevel": 1
},
{
"fieldname": "column_break_65",
@@ -576,6 +580,7 @@
"fieldname": "api_secret",
"fieldtype": "Password",
"label": "API Secret",
+ "permlevel": 1,
"read_only": 1
},
{
@@ -594,7 +599,7 @@
"fieldname": "desk_theme",
"fieldtype": "Select",
"label": "Desk Theme",
- "options": "Light\nDark"
+ "options": "Light\nDark\nAutomatic"
},
{
"fieldname": "module_profile",
@@ -612,11 +617,6 @@
"link_doctype": "Contact",
"link_fieldname": "user"
},
- {
- "group": "Profile",
- "link_doctype": "Chat Profile",
- "link_fieldname": "user"
- },
{
"group": "Profile",
"link_doctype": "Blogger",
@@ -660,7 +660,7 @@
{
"group": "Activity",
"link_doctype": "ToDo",
- "link_fieldname": "owner"
+ "link_fieldname": "allocated_to"
},
{
"group": "Integrations",
@@ -668,8 +668,7 @@
"link_fieldname": "user"
}
],
- "max_attachments": 5,
- "modified": "2021-02-01 16:11:06.037543",
+ "modified": "2022-03-09 01:47:56.745069",
"modified_by": "Administrator",
"module": "Core",
"name": "User",
@@ -702,6 +701,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "full_name",
"track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py
index 142cc1ee26..d08755f9a8 100644
--- a/frappe/core/doctype/user/user.py
+++ b/frappe/core/doctype/user/user.py
@@ -1,28 +1,25 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals, print_function
-import frappe
-from frappe.model.document import Document
-from frappe.utils import cint, flt, has_gravatar, escape_html, format_datetime, now_datetime, get_formatted_email, today
-from frappe import throw, msgprint, _
-from frappe.utils.password import update_password as _update_password
-from frappe.desk.notifications import clear_notifications
-from frappe.desk.doctype.notification_settings.notification_settings import create_notification_settings
-from frappe.utils.user import get_system_managers
+# License: MIT. See LICENSE
from bs4 import BeautifulSoup
-import frappe.permissions
+import frappe
import frappe.share
import frappe.defaults
-from frappe.website.utils import is_signup_enabled
-from frappe.utils.background_jobs import enqueue
-
-STANDARD_USERS = ("Guest", "Administrator")
+import frappe.permissions
+from frappe.model.document import Document
+from frappe.utils import (cint, flt, has_gravatar, escape_html, format_datetime,
+ now_datetime, get_formatted_email, today, get_time_zone)
+from frappe import throw, msgprint, _
+from frappe.utils.password import update_password as _update_password, check_password, get_password_reset_limit
+from frappe.desk.notifications import clear_notifications
+from frappe.desk.doctype.notification_settings.notification_settings import create_notification_settings, toggle_notifications
+from frappe.utils.user import get_system_managers
+from frappe.website.utils import is_signup_disabled
+from frappe.rate_limiter import rate_limit
+from frappe.core.doctype.user_type.user_type import user_linked_with_permission_on_doctype
+from frappe.query_builder import DocType
-class MaxUsersReachedError(frappe.ValidationError):
- pass
-
+STANDARD_USERS = frappe.STANDARD_USERS
class User(Document):
__new_password = None
@@ -50,10 +47,10 @@ class User(Document):
def after_insert(self):
create_notification_settings(self.name)
+ frappe.cache().delete_key('users_for_mentions')
+ frappe.cache().delete_key('enabled_users')
def validate(self):
- self.check_demo()
-
# clear new password
self.__new_password = self.new_password
self.new_password = ""
@@ -77,6 +74,7 @@ class User(Document):
self.validate_roles()
self.validate_allowed_modules()
self.validate_user_image()
+ self.set_time_zone()
if self.language == "Loading...":
self.language = None
@@ -123,14 +121,16 @@ class User(Document):
if self.time_zone:
frappe.defaults.set_default("time_zone", self.time_zone, self.name)
+ if self.has_value_changed('allow_in_mentions') or self.has_value_changed('user_type'):
+ frappe.cache().delete_key('users_for_mentions')
+
+ if self.has_value_changed('enabled'):
+ frappe.cache().delete_key('enabled_users')
+
def has_website_permission(self, ptype, user, verbose=False):
"""Returns true if current user is the session user"""
return self.name == frappe.session.user
- def check_demo(self):
- if frappe.session.user == 'demo@erpnext.com':
- frappe.throw(_('Cannot change user details in demo. Please signup for a new account at https://erpnext.com'), title=_('Not Allowed'))
-
def set_full_name(self):
self.full_name = " ".join(filter(None, [self.first_name, self.last_name]))
@@ -146,6 +146,9 @@ class User(Document):
if not cint(self.enabled) and getattr(frappe.local, "login_manager", None):
frappe.local.login_manager.logout(user=self.name)
+ # toggle notifications based on the user's status
+ toggle_notifications(self.name, enable=cint(self.enabled))
+
def add_system_manager_role(self):
# if adding system manager, do nothing
if not cint(self.enabled) or ("System Manager" in [user_role.role for user_role in
@@ -179,22 +182,44 @@ class User(Document):
_update_password(user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions)
def set_system_user(self):
- '''Set as System User if any of the given roles has desk_access'''
- if self.has_desk_access() or self.name == 'Administrator':
- self.user_type = 'System User'
+ '''For the standard users like admin and guest, the user type is fixed.'''
+ user_type_mapper = {
+ 'Administrator': 'System User',
+ 'Guest': 'Website User'
+ }
+
+ if self.user_type and not frappe.get_cached_value('User Type', self.user_type, 'is_standard'):
+ if user_type_mapper.get(self.name):
+ self.user_type = user_type_mapper.get(self.name)
+ else:
+ self.set_roles_and_modules_based_on_user_type()
else:
- self.user_type = 'Website User'
+ '''Set as System User if any of the given roles has desk_access'''
+ self.user_type = 'System User' if self.has_desk_access() else 'Website User'
+
+ def set_roles_and_modules_based_on_user_type(self):
+ user_type_doc = frappe.get_cached_doc('User Type', self.user_type)
+ if user_type_doc.role:
+ self.roles = []
+
+ # Check whether User has linked with the 'Apply User Permission On' doctype or not
+ if user_linked_with_permission_on_doctype(user_type_doc, self.name):
+ self.append('roles', {
+ 'role': user_type_doc.role
+ })
+
+ frappe.msgprint(_('Role has been set as per the user type {0}')
+ .format(self.user_type), alert=True)
+
+ user_type_doc.update_modules_in_user(self)
def has_desk_access(self):
- '''Return true if any of the set roles has desk access'''
+ """Return true if any of the set roles has desk access"""
if not self.roles:
return False
- return len(frappe.db.sql("""select name
- from `tabRole` where desk_access=1
- and name in ({0}) limit 1""".format(', '.join(['%s'] * len(self.roles))),
- [d.role for d in self.roles]))
-
+ role_table = DocType("Role")
+ return frappe.db.count(role_table, ((role_table.desk_access == 1) & (role_table.name.isin([d.role for d in self.roles]))))
def share_with_self(self):
frappe.share.add(self.doctype, self.name, self.name, write=1, share=1,
@@ -203,11 +228,11 @@ class User(Document):
def validate_share(self, docshare):
pass
# if docshare.user == self.name:
- # if self.user_type=="System User":
- # if docshare.share != 1:
- # frappe.throw(_("Sorry! User should have complete access to their own record."))
- # else:
- # frappe.throw(_("Sorry! Sharing with Website User is prohibited."))
+ # if self.user_type=="System User":
+ # if docshare.share != 1:
+ # frappe.throw(_("Sorry! User should have complete access to their own record."))
+ # else:
+ # frappe.throw(_("Sorry! Sharing with Website User is prohibited."))
def send_password_notification(self, new_password):
try:
@@ -238,11 +263,6 @@ class User(Document):
def reset_password(self, send_email=False, password_expired=False):
from frappe.utils import random_string, get_url
- rate_limit = frappe.db.get_single_value("System Settings", "password_reset_limit")
-
- if rate_limit:
- check_password_reset_limit(self.name, rate_limit)
-
key = random_string(32)
self.db_set("reset_password_key", key)
@@ -254,16 +274,23 @@ class User(Document):
if send_email:
self.password_reset_mail(link)
- update_password_reset_limit(self.name)
return link
def get_other_system_managers(self):
- return frappe.db.sql("""select distinct `user`.`name` from `tabHas Role` as `user_role`, `tabUser` as `user`
- where user_role.role='System Manager'
- and `user`.docstatus<2
- and `user`.enabled=1
- and `user_role`.parent = `user`.name
- and `user_role`.parent not in ('Administrator', %s) limit 1""", (self.name,))
+ user_doctype = DocType("User").as_("user")
+ user_role_doctype = DocType("Has Role").as_("user_role")
+ return (
+ frappe.qb.from_(user_doctype)
+ .from_(user_role_doctype)
+ .select(user_doctype.name)
+ .where(user_role_doctype.role == 'System Manager')
+ .where(user_doctype.docstatus < 2)
+ .where(user_doctype.enabled == 1)
+ .where(user_role_doctype.parent == user_doctype.name)
+ .where(user_role_doctype.parent.notin(["Administrator", self.name]))
+ .limit(1)
+ .distinct()
+ ).run()
def get_fullname(self):
"""get first_name space last_name"""
@@ -317,7 +344,7 @@ class User(Document):
frappe.sendmail(recipients=self.email, sender=sender, subject=subject,
template=template, args=args, header=[subject, "green"],
- delayed=(not now) if now!=None else self.flags.delay_emails, retry=3)
+ delayed=(not now) if now is not None else self.flags.delay_emails, retry=3)
def a_system_manager_should_exist(self):
if not self.get_other_system_managers():
@@ -336,31 +363,46 @@ class User(Document):
frappe.local.login_manager.logout(user=self.name)
# delete todos
- frappe.db.sql("""DELETE FROM `tabToDo` WHERE `owner`=%s""", (self.name,))
- frappe.db.sql("""UPDATE `tabToDo` SET `assigned_by`=NULL WHERE `assigned_by`=%s""",
- (self.name,))
+ frappe.db.delete("ToDo", {"allocated_to": self.name})
+ todo_table = DocType("ToDo")
+ (
+ frappe.qb.update(todo_table)
+ .set(todo_table.assigned_by, None)
+ .where(todo_table.assigned_by == self.name)
+ ).run()
# delete events
- frappe.db.sql("""delete from `tabEvent` where owner=%s
- and event_type='Private'""", (self.name,))
+ frappe.db.delete("Event", {"owner": self.name, "event_type": "Private"})
# delete shares
- frappe.db.sql("""delete from `tabDocShare` where user=%s""", self.name)
-
+ frappe.db.delete("DocShare", {"user": self.name})
# delete messages
- frappe.db.sql("""delete from `tabCommunication`
- where communication_type in ('Chat', 'Notification')
- and reference_doctype='User'
- and (reference_name=%s or owner=%s)""", (self.name, self.name))
-
+ table = DocType("Communication")
+ frappe.db.delete(
+ table,
+ filters=(
+ (table.communication_type.isin(["Chat", "Notification"]))
+ & (table.reference_doctype == "User")
+ & ((table.reference_name == self.name) | table.owner == self.name)
+ ),
+ run=False,
+ )
# unlink contact
- frappe.db.sql("""update `tabContact`
- set `user`=null
- where `user`=%s""", (self.name))
+ table = DocType("Contact")
+ frappe.qb.update(table).where(
+ table.user == self.name
+ ).set(table.user, None).run()
+
+ # delete notification settings
+ frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True)
+
+ if self.get('allow_in_mentions'):
+ frappe.cache().delete_key('users_for_mentions')
+
+ frappe.cache().delete_key('enabled_users')
def before_rename(self, old_name, new_name, merge=False):
- self.check_demo()
frappe.clear_cache(user=old_name)
self.validate_rename(old_name, new_name)
@@ -389,16 +431,11 @@ class User(Document):
WHERE `%s` = %s""" %
(tab, field, '%s', field, '%s'), (new_name, old_name))
- if frappe.db.exists("Chat Profile", old_name):
- frappe.rename_doc("Chat Profile", old_name, new_name, force=True, show_alert=False)
-
if frappe.db.exists("Notification Settings", old_name):
frappe.rename_doc("Notification Settings", old_name, new_name, force=True, show_alert=False)
# set email
- frappe.db.sql("""UPDATE `tabUser`
- SET email = %s
- WHERE name = %s""", (new_name, new_name))
+ frappe.db.update("User", new_name, "email", new_name)
def append_roles(self, *roles):
"""Add roles to user"""
@@ -527,6 +564,43 @@ class User(Document):
return [i.strip() for i in self.restrict_ip.split(",")]
+ @classmethod
+ def find_by_credentials(cls, user_name: str, password: str, validate_password: bool = True):
+ """Find the user by credentials.
+
+ This is a login utility that needs to check login related system settings while finding the user.
+ 1. Find user by email ID by default
+ 2. If allow_login_using_mobile_number is set, you can use mobile number while finding the user.
+ 3. If allow_login_using_user_name is set, you can use username while finding the user.
+ """
+
+ login_with_mobile = cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number"))
+ login_with_username = cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_user_name"))
+
+ or_filters = [{"name": user_name}]
+ if login_with_mobile:
+ or_filters.append({"mobile_no": user_name})
+ if login_with_username:
+ or_filters.append({"username": user_name})
+
+ users = frappe.db.get_all('User', fields=['name', 'enabled'], or_filters=or_filters, limit=1)
+ if not users:
+ return
+
+ user = users[0]
+ user['is_authenticated'] = True
+ if validate_password:
+ try:
+ check_password(user['name'], password, delete_tracker_cache=False)
+ except frappe.AuthenticationError:
+ user['is_authenticated'] = False
+
+ return user
+
+ def set_time_zone(self):
+ if not self.time_zone:
+ self.time_zone = get_time_zone()
+
@frappe.whitelist()
def get_timezones():
import pytz
@@ -635,127 +709,36 @@ def has_email_account(email):
@frappe.whitelist(allow_guest=False)
def get_email_awaiting(user):
- waiting = frappe.db.sql("""select email_account,email_id
- from `tabUser Email`
- where awaiting_password = 1
- and parent = %(user)s""", {"user":user}, as_dict=1)
+ waiting = frappe.get_all("User Email", fields=["email_account", "email_id"], filters={"awaiting_password": 1, "parent": user})
if waiting:
return waiting
else:
- frappe.db.sql("""update `tabUser Email`
- set awaiting_password =0
- where parent = %(user)s""",{"user":user})
+ user_email_table = DocType("User Email")
+ frappe.qb.update(user_email_table).set(user_email_table.user_email_table, 0).where(user_email_table.parent == user).run()
return False
-@frappe.whitelist(allow_guest=False)
-def set_email_password(email_account, user, password):
- account = frappe.get_doc("Email Account", email_account)
- if account.awaiting_password:
- account.awaiting_password = 0
- account.password = password
- try:
- account.save(ignore_permissions=True)
- except Exception:
- frappe.db.rollback()
- return False
-
- return True
-
-def setup_user_email_inbox(email_account, awaiting_password, email_id, enable_outgoing):
- """ setup email inbox for user """
- def add_user_email(user):
- user = frappe.get_doc("User", user)
- row = user.append("user_emails", {})
-
- row.email_id = email_id
- row.email_account = email_account
- row.awaiting_password = awaiting_password or 0
- row.enable_outgoing = enable_outgoing or 0
-
- user.save(ignore_permissions=True)
-
- udpate_user_email_settings = False
- if not all([email_account, email_id]):
- return
-
- user_names = frappe.db.get_values("User", { "email": email_id }, as_dict=True)
- if not user_names:
- return
-
- for user in user_names:
- user_name = user.get("name")
-
- # check if inbox is alreay configured
- user_inbox = frappe.db.get_value("User Email", {
- "email_account": email_account,
- "parent": user_name
- }, ["name"]) or None
-
- if not user_inbox:
- add_user_email(user_name)
- else:
- # update awaiting password for email account
- udpate_user_email_settings = True
-
- if udpate_user_email_settings:
- frappe.db.sql("""UPDATE `tabUser Email` SET awaiting_password = %(awaiting_password)s,
- enable_outgoing = %(enable_outgoing)s WHERE email_account = %(email_account)s""", {
- "email_account": email_account,
- "enable_outgoing": enable_outgoing,
- "awaiting_password": awaiting_password or 0
- })
- else:
- users = " and ".join([frappe.bold(user.get("name")) for user in user_names])
- frappe.msgprint(_("Enabled email inbox for user {0}").format(users))
-
- ask_pass_update()
-
-def remove_user_email_inbox(email_account):
- """ remove user email inbox settings if email account is deleted """
- if not email_account:
- return
-
- users = frappe.get_all("User Email", filters={
- "email_account": email_account
- }, fields=["parent as name"])
-
- for user in users:
- doc = frappe.get_doc("User", user.get("name"))
- to_remove = [ row for row in doc.user_emails if row.email_account == email_account ]
- [ doc.remove(row) for row in to_remove ]
-
- doc.save(ignore_permissions=True)
-
def ask_pass_update():
# update the sys defaults as to awaiting users
from frappe.utils import set_default
- users = frappe.db.sql("""SELECT DISTINCT(parent) as user FROM `tabUser Email`
- WHERE awaiting_password = 1""", as_dict=True)
-
- password_list = [ user.get("user") for user in users ]
+ password_list = frappe.get_all("User Email", filters={"awaiting_password": True}, pluck="parent", distinct=True)
set_default("email_user_password", u','.join(password_list))
def _get_user_for_update_password(key, old_password):
# verify old password
+ result = frappe._dict()
if key:
- user = frappe.db.get_value("User", {"reset_password_key": key})
- if not user:
- return {
- 'message': _("The Link specified has either been used before or Invalid")
- }
+ result.user = frappe.db.get_value("User", {"reset_password_key": key})
+ if not result.user:
+ result.message = _("The Link specified has either been used before or Invalid")
elif old_password:
# verify old password
frappe.local.login_manager.check_password(frappe.session.user, old_password)
user = frappe.session.user
+ result.user = user
- else:
- return
-
- return {
- 'user': user
- }
+ return result
def reset_user_data(user):
user_doc = frappe.get_doc("User", user)
@@ -772,19 +755,17 @@ def verify_password(password):
@frappe.whitelist(allow_guest=True)
def sign_up(email, full_name, redirect_to):
- if not is_signup_enabled():
- frappe.throw(_('Sign Up is disabled'), title='Not Allowed')
+ if is_signup_disabled():
+ frappe.throw(_("Sign Up is disabled"), title=_("Not Allowed"))
user = frappe.db.get("User", {"email": email})
if user:
- if user.disabled:
- return 0, _("Registered but disabled")
- else:
+ if user.enabled:
return 0, _("Already Registered")
+ else:
+ return 0, _("Registered but disabled")
else:
- if frappe.db.sql("""select count(*) from tabUser where
- HOUR(TIMEDIFF(CURRENT_TIMESTAMP, TIMESTAMP(modified)))=1""")[0][0] > 300:
-
+ if frappe.db.get_creation_count('User', 60) > 300:
frappe.respond_as_web_page(_('Temporarily Disabled'),
_('Too many users signed up recently, so the registration is disabled. Please try back in an hour'),
http_status_code=429)
@@ -816,6 +797,7 @@ def sign_up(email, full_name, redirect_to):
return 2, _("Please ask your administrator to verify your sign-up")
@frappe.whitelist(allow_guest=True)
+@rate_limit(limit=get_password_reset_limit, seconds = 24*60*60, methods=['POST'])
def reset_password(user):
if user=="Administrator":
return 'not allowed'
@@ -828,20 +810,25 @@ def reset_password(user):
user.validate_reset_password()
user.reset_password(send_email=True)
- return frappe.msgprint(_("Password reset instructions have been sent to your email"))
-
+ return frappe.msgprint(
+ msg=_("Password reset instructions have been sent to your email"),
+ title=_("Password Email Sent")
+ )
except frappe.DoesNotExistError:
+ frappe.local.response['http_status_code'] = 400
frappe.clear_messages()
return 'not found'
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def user_query(doctype, txt, searchfield, start, page_len, filters):
- from frappe.desk.reportview import get_match_cond
+ from frappe.desk.reportview import get_match_cond, get_filters_cond
+ conditions=[]
- user_type_condition = "and user_type = 'System User'"
+ user_type_condition = "and user_type != 'Website User'"
if filters and filters.get('ignore_user_type'):
user_type_condition = ''
+ filters.pop('ignore_user_type')
txt = "%{}%".format(txt)
return frappe.db.sql("""SELECT `name`, CONCAT_WS(' ', first_name, middle_name, last_name)
@@ -852,17 +839,22 @@ def user_query(doctype, txt, searchfield, start, page_len, filters):
AND `name` NOT IN ({standard_users})
AND ({key} LIKE %(txt)s
OR CONCAT_WS(' ', first_name, middle_name, last_name) LIKE %(txt)s)
- {mcond}
+ {fcond} {mcond}
ORDER BY
CASE WHEN `name` LIKE %(txt)s THEN 0 ELSE 1 END,
CASE WHEN concat_ws(' ', first_name, middle_name, last_name) LIKE %(txt)s
THEN 0 ELSE 1 END,
NAME asc
- LIMIT %(page_len)s OFFSET %(start)s""".format(
+ LIMIT %(page_len)s OFFSET %(start)s
+ """.format(
user_type_condition = user_type_condition,
- standard_users=", ".join([frappe.db.escape(u) for u in STANDARD_USERS]),
- key=searchfield, mcond=get_match_cond(doctype)),
- dict(start=start, page_len=page_len, txt=txt))
+ standard_users=", ".join(frappe.db.escape(u) for u in STANDARD_USERS),
+ key=searchfield,
+ fcond=get_filters_cond(doctype, filters, conditions),
+ mcond=get_match_cond(doctype)
+ ),
+ dict(start=start, page_len=page_len, txt=txt)
+ )
def get_total_users():
"""Returns total no. of system users"""
@@ -900,8 +892,7 @@ def get_active_users():
def get_website_users():
"""Returns total no. of website users"""
- return frappe.db.sql("""select count(*) from `tabUser`
- where enabled = 1 and user_type = 'Website User'""")[0][0]
+ return frappe.db.count("User", filters={"enabled": True, "user_type": "Website User"})
def get_active_website_users():
"""Returns No. of website users who logged in, in the last 3 days"""
@@ -946,8 +937,16 @@ def extract_mentions(txt):
soup = BeautifulSoup(txt, 'html.parser')
emails = []
for mention in soup.find_all(class_='mention'):
+ if mention.get('data-is-group') == 'true':
+ try:
+ user_group = frappe.get_cached_doc('User Group', mention['data-id'])
+ emails += [d.user for d in user_group.user_group_members]
+ except frappe.DoesNotExistError:
+ pass
+ continue
email = mention['data-id']
emails.append(email)
+
return emails
def handle_password_test_fail(result):
@@ -961,91 +960,6 @@ def update_gravatar(name):
if gravatar:
frappe.db.set_value('User', name, 'user_image', gravatar)
-@frappe.whitelist(allow_guest=True)
-def send_token_via_sms(tmp_id,phone_no=None,user=None):
- try:
- from frappe.core.doctype.sms_settings.sms_settings import send_request
- except:
- return False
-
- if not frappe.cache().ttl(tmp_id + '_token'):
- return False
- ss = frappe.get_doc('SMS Settings', 'SMS Settings')
- if not ss.sms_gateway_url:
- return False
-
- token = frappe.cache().get(tmp_id + '_token')
- args = {ss.message_parameter: 'verification code is {}'.format(token)}
-
- for d in ss.get("parameters"):
- args[d.parameter] = d.value
-
- if user:
- user_phone = frappe.db.get_value('User', user, ['phone','mobile_no'], as_dict=1)
- usr_phone = user_phone.mobile_no or user_phone.phone
- if not usr_phone:
- return False
- else:
- if phone_no:
- usr_phone = phone_no
- else:
- return False
-
- args[ss.receiver_parameter] = usr_phone
- status = send_request(ss.sms_gateway_url, args, use_post=ss.use_post)
-
- if 200 <= status < 300:
- frappe.cache().delete(tmp_id + '_token')
- return True
- else:
- return False
-
-@frappe.whitelist(allow_guest=True)
-def send_token_via_email(tmp_id,token=None):
- import pyotp
-
- user = frappe.cache().get(tmp_id + '_user')
- count = token or frappe.cache().get(tmp_id + '_token')
-
- if ((not user) or (user == 'None') or (not count)):
- return False
- user_email = frappe.db.get_value('User',user, 'email')
- if not user_email:
- return False
-
- otpsecret = frappe.cache().get(tmp_id + '_otp_secret')
- hotp = pyotp.HOTP(otpsecret)
-
- frappe.sendmail(
- recipients=user_email,
- sender=None,
- subject="Verification Code",
- template="verification_code",
- args=dict(code=hotp.at(int(count))),
- delayed=False,
- retry=3
- )
-
- return True
-
-@frappe.whitelist(allow_guest=True)
-def reset_otp_secret(user):
- otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name')
- user_email = frappe.db.get_value('User',user, 'email')
- if frappe.session.user in ["Administrator", user] :
- frappe.defaults.clear_default(user + '_otplogin')
- frappe.defaults.clear_default(user + '_otpsecret')
- email_args = {
- 'recipients':user_email, 'sender':None, 'subject':'OTP Secret Reset - {}'.format(otp_issuer or "Frappe Framework"),
- 'message':'
Your OTP secret on {} has been reset. If you did not perform this reset and did not request it, please contact your System Administrator immediately.
'.format(otp_issuer or "Frappe Framework"),
- 'delayed':False,
- 'retry':3
- }
- enqueue(method=frappe.sendmail, queue='short', timeout=300, event=None, is_async=True, job_name=None, now=False, **email_args)
- return frappe.msgprint(_("OTP Secret has been reset. Re-registration will be required on next login."))
- else:
- return frappe.throw(_("OTP secret can only be reset by the Administrator."))
-
def throttle_user_creation():
if frappe.flags.in_import:
return
@@ -1063,15 +977,6 @@ def get_module_profile(module_profile):
module_profile = frappe.get_doc('Module Profile', {'module_profile_name': module_profile})
return module_profile.get('block_modules')
-def update_roles(role_profile):
- users = frappe.get_all('User', filters={'role_profile_name': role_profile})
- role_profile = frappe.get_doc('Role Profile', role_profile)
- roles = [role.role for role in role_profile.roles]
- for d in users:
- user = frappe.get_doc('User', d)
- user.set('roles', [])
- user.add_roles(*roles)
-
def create_contact(user, ignore_links=False, ignore_mandatory=False):
from frappe.contacts.doctype.contact.contact import get_contact_name
if user.name in ["Administrator", "Guest"]: return
@@ -1130,33 +1035,27 @@ def generate_keys(user):
:param user: str
"""
- if "System Manager" in frappe.get_roles():
- user_details = frappe.get_doc("User", user)
- api_secret = frappe.generate_hash(length=15)
- # if api key is not set generate api key
- if not user_details.api_key:
- api_key = frappe.generate_hash(length=15)
- user_details.api_key = api_key
- user_details.api_secret = api_secret
- user_details.save()
+ frappe.only_for("System Manager")
+ user_details = frappe.get_doc("User", user)
+ api_secret = frappe.generate_hash(length=15)
+ # if api key is not set generate api key
+ if not user_details.api_key:
+ api_key = frappe.generate_hash(length=15)
+ user_details.api_key = api_key
+ user_details.api_secret = api_secret
+ user_details.save()
+
+ return {"api_secret": api_secret}
- return {"api_secret": api_secret}
- frappe.throw(frappe._("Not Permitted"), frappe.PermissionError)
@frappe.whitelist()
def switch_theme(theme):
- if theme in ["Dark", "Light"]:
+ if theme in ["Dark", "Light", "Automatic"]:
frappe.db.set_value("User", frappe.session.user, "desk_theme", theme)
-def update_password_reset_limit(user):
- generated_link_count = get_generated_link_count(user)
- generated_link_count += 1
- frappe.cache().hset("password_reset_link_count", user, generated_link_count)
+def get_enabled_users():
+ def _get_enabled_users():
+ enabled_users = frappe.get_all("User", filters={"enabled": "1"}, pluck="name")
+ return enabled_users
-def check_password_reset_limit(user, rate_limit):
- generated_link_count = get_generated_link_count(user)
- if generated_link_count >= rate_limit:
- frappe.throw(_("You have reached the hourly limit for generating password reset links. Please try again later."))
-
-def get_generated_link_count(user):
- return cint(frappe.cache().hget("password_reset_link_count", user)) or 0
+ return frappe.cache().get_value("enabled_users", _get_enabled_users)
diff --git a/frappe/patches/v4_0/__init__.py b/frappe/core/doctype/user_document_type/__init__.py
similarity index 100%
rename from frappe/patches/v4_0/__init__.py
rename to frappe/core/doctype/user_document_type/__init__.py
diff --git a/frappe/core/doctype/user_document_type/user_document_type.json b/frappe/core/doctype/user_document_type/user_document_type.json
new file mode 100644
index 0000000000..69983a2891
--- /dev/null
+++ b/frappe/core/doctype/user_document_type/user_document_type.json
@@ -0,0 +1,109 @@
+{
+ "actions": [],
+ "creation": "2021-01-13 01:51:40.158521",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "document_type",
+ "column_break_2",
+ "is_custom",
+ "permissions_section",
+ "read",
+ "write",
+ "create",
+ "column_break_8",
+ "submit",
+ "cancel",
+ "amend",
+ "delete"
+ ],
+ "fields": [
+ {
+ "fieldname": "document_type",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Document Type",
+ "options": "DocType",
+ "reqd": 1
+ },
+ {
+ "fieldname": "permissions_section",
+ "fieldtype": "Section Break",
+ "label": "Role Permissions"
+ },
+ {
+ "default": "1",
+ "fieldname": "read",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Read"
+ },
+ {
+ "default": "0",
+ "fieldname": "write",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Write"
+ },
+ {
+ "default": "0",
+ "fieldname": "create",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Create"
+ },
+ {
+ "fieldname": "column_break_2",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "fetch_from": "document_type.custom",
+ "fieldname": "is_custom",
+ "fieldtype": "Check",
+ "label": "Is Custom",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_8",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "fieldname": "submit",
+ "fieldtype": "Check",
+ "label": "Submit"
+ },
+ {
+ "default": "0",
+ "fieldname": "cancel",
+ "fieldtype": "Check",
+ "label": "Cancel"
+ },
+ {
+ "default": "0",
+ "fieldname": "amend",
+ "fieldtype": "Check",
+ "label": "Amend"
+ },
+ {
+ "default": "0",
+ "fieldname": "delete",
+ "fieldtype": "Check",
+ "label": "Delete"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-03-16 00:32:24.414313",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "User Document Type",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/user_document_type/user_document_type.py b/frappe/core/doctype/user_document_type/user_document_type.py
new file mode 100644
index 0000000000..a14d735e6a
--- /dev/null
+++ b/frappe/core/doctype/user_document_type/user_document_type.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+# import frappe
+from frappe.model.document import Document
+
+class UserDocumentType(Document):
+ pass
diff --git a/frappe/core/doctype/user_email/user_email.py b/frappe/core/doctype/user_email/user_email.py
index a0ce2e169d..daad083577 100644
--- a/frappe/core/doctype/user_email/user_email.py
+++ b/frappe/core/doctype/user_email/user_email.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/patches/v4_1/__init__.py b/frappe/core/doctype/user_group/__init__.py
similarity index 100%
rename from frappe/patches/v4_1/__init__.py
rename to frappe/core/doctype/user_group/__init__.py
diff --git a/frappe/core/doctype/user_group/test_user_group.py b/frappe/core/doctype/user_group/test_user_group.py
new file mode 100644
index 0000000000..b5d642ae9c
--- /dev/null
+++ b/frappe/core/doctype/user_group/test_user_group.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# License: MIT. See LICENSE
+# import frappe
+import unittest
+
+class TestUserGroup(unittest.TestCase):
+ pass
diff --git a/frappe/core/doctype/user_group/user_group.js b/frappe/core/doctype/user_group/user_group.js
new file mode 100644
index 0000000000..2aa9b68658
--- /dev/null
+++ b/frappe/core/doctype/user_group/user_group.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2021, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('User Group', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/frappe/core/doctype/user_group/user_group.json b/frappe/core/doctype/user_group/user_group.json
new file mode 100644
index 0000000000..e807372061
--- /dev/null
+++ b/frappe/core/doctype/user_group/user_group.json
@@ -0,0 +1,48 @@
+{
+ "actions": [],
+ "autoname": "Prompt",
+ "creation": "2021-04-12 15:17:24.751710",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "user_group_members"
+ ],
+ "fields": [
+ {
+ "fieldname": "user_group_members",
+ "fieldtype": "Table MultiSelect",
+ "label": "User Group Members",
+ "options": "User Group Member",
+ "reqd": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-04-15 16:12:31.455401",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "User Group",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "read": 1,
+ "role": "All"
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/user_group/user_group.py b/frappe/core/doctype/user_group/user_group.py
new file mode 100644
index 0000000000..05ff71e353
--- /dev/null
+++ b/frappe/core/doctype/user_group/user_group.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+# import frappe
+from frappe.model.document import Document
+import frappe
+
+class UserGroup(Document):
+ def after_insert(self):
+ frappe.cache().delete_key('user_groups')
+
+ def on_trash(self):
+ frappe.cache().delete_key('user_groups')
diff --git a/frappe/patches/v4_2/__init__.py b/frappe/core/doctype/user_group_member/__init__.py
similarity index 100%
rename from frappe/patches/v4_2/__init__.py
rename to frappe/core/doctype/user_group_member/__init__.py
diff --git a/frappe/core/doctype/user_group_member/test_user_group_member.py b/frappe/core/doctype/user_group_member/test_user_group_member.py
new file mode 100644
index 0000000000..6d4650a3d0
--- /dev/null
+++ b/frappe/core/doctype/user_group_member/test_user_group_member.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# License: MIT. See LICENSE
+# import frappe
+import unittest
+
+class TestUserGroupMember(unittest.TestCase):
+ pass
diff --git a/frappe/core/doctype/user_group_member/user_group_member.js b/frappe/core/doctype/user_group_member/user_group_member.js
new file mode 100644
index 0000000000..0b2dbe0d46
--- /dev/null
+++ b/frappe/core/doctype/user_group_member/user_group_member.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2021, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('User Group Member', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/frappe/chat/doctype/chat_room_user/chat_room_user.json b/frappe/core/doctype/user_group_member/user_group_member.json
similarity index 55%
rename from frappe/chat/doctype/chat_room_user/chat_room_user.json
rename to frappe/core/doctype/user_group_member/user_group_member.json
index f7bdf6706b..d2ff149366 100644
--- a/frappe/chat/doctype/chat_room_user/chat_room_user.json
+++ b/frappe/core/doctype/user_group_member/user_group_member.json
@@ -1,12 +1,11 @@
{
- "beta": 1,
- "creation": "2017-11-08 15:24:21.029314",
+ "actions": [],
+ "creation": "2021-04-12 15:16:29.279107",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
- "user",
- "is_admin"
+ "user"
],
"fields": [
{
@@ -16,24 +15,17 @@
"label": "User",
"options": "User",
"reqd": 1
- },
- {
- "default": "0",
- "fieldname": "is_admin",
- "fieldtype": "Check",
- "label": "Admin"
}
],
- "in_create": 1,
+ "index_web_pages_for_search": 1,
"istable": 1,
- "modified": "2019-11-07 13:21:05.297337",
+ "links": [],
+ "modified": "2021-04-12 15:17:18.773046",
"modified_by": "Administrator",
- "module": "Chat",
- "name": "Chat Room User",
+ "module": "Core",
+ "name": "User Group Member",
"owner": "Administrator",
"permissions": [],
- "quick_entry": 1,
- "read_only": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
diff --git a/frappe/core/doctype/user_group_member/user_group_member.py b/frappe/core/doctype/user_group_member/user_group_member.py
new file mode 100644
index 0000000000..69718d8d91
--- /dev/null
+++ b/frappe/core/doctype/user_group_member/user_group_member.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+# import frappe
+from frappe.model.document import Document
+
+class UserGroupMember(Document):
+ pass
diff --git a/frappe/core/doctype/user_permission/test_user_permission.js b/frappe/core/doctype/user_permission/test_user_permission.js
deleted file mode 100644
index 1770dddf81..0000000000
--- a/frappe/core/doctype/user_permission/test_user_permission.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: User Permission", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially('User Permission', [
- // insert a new User Permission
- () => frappe.tests.make([
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py
index 7e0b4a49c6..d4a9d68fd5 100644
--- a/frappe/core/doctype/user_permission/test_user_permission.py
+++ b/frappe/core/doctype/user_permission/test_user_permission.py
@@ -1,22 +1,27 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-from frappe.core.doctype.user_permission.user_permission import add_user_permissions
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# See LICENSE
+from frappe.core.doctype.user_permission.user_permission import add_user_permissions, remove_applicable
from frappe.permissions import has_user_permission
+from frappe.core.doctype.doctype.test_doctype import new_doctype
+from frappe.website.doctype.blog_post.test_blog_post import make_test_blog
import frappe
import unittest
class TestUserPermission(unittest.TestCase):
def setUp(self):
- frappe.db.sql("""DELETE FROM `tabUser Permission`
- WHERE `user` in (
- 'test_bulk_creation_update@example.com',
- 'test_user_perm1@example.com',
- 'nested_doc_user@example.com')""")
+ test_users = (
+ "test_bulk_creation_update@example.com",
+ "test_user_perm1@example.com",
+ "nested_doc_user@example.com",
+ )
+ frappe.db.delete("User Permission", {
+ "user": ("in", test_users)
+ })
frappe.delete_doc_if_exists("DocType", "Person")
frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabPerson`")
+ frappe.delete_doc_if_exists("DocType", "Doc A")
+ frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabDoc A`")
def test_default_user_permission_validation(self):
user = create_user('test_default_permission@example.com')
@@ -27,6 +32,18 @@ class TestUserPermission(unittest.TestCase):
param = get_params(user, 'User', perm_user.name, is_default=1)
self.assertRaises(frappe.ValidationError, add_user_permissions, param)
+ def test_default_user_permission_corectness(self):
+ user = create_user('test_default_corectness_permission_1@example.com')
+ param = get_params(user, 'User', user.name, is_default=1, hide_descendants= 1)
+ add_user_permissions(param)
+ #create a duplicate entry with default
+ perm_user = create_user('test_default_corectness2@example.com')
+ test_blog = make_test_blog()
+ param = get_params(perm_user, 'Blog Post', test_blog.name, is_default=1, hide_descendants= 1)
+ add_user_permissions(param)
+ frappe.db.delete('User Permission', filters={'for_value': test_blog.name})
+ frappe.delete_doc('Blog Post', test_blog.name)
+
def test_default_user_permission(self):
frappe.set_user('Administrator')
user = create_user('test_user_perm1@example.com', 'Website Manager')
@@ -43,7 +60,7 @@ class TestUserPermission(unittest.TestCase):
frappe.set_user('test_user_perm1@example.com')
doc = frappe.new_doc("Blog Post")
- self.assertEquals(doc.blog_category, 'general')
+ self.assertEqual(doc.blog_category, 'general')
frappe.set_user('Administrator')
def test_apply_to_all(self):
@@ -51,7 +68,7 @@ class TestUserPermission(unittest.TestCase):
user = create_user('test_bulk_creation_update@example.com')
param = get_params(user, 'User', user.name)
is_created = add_user_permissions(param)
- self.assertEquals(is_created, 1)
+ self.assertEqual(is_created, 1)
def test_for_apply_to_all_on_update_from_apply_all(self):
user = create_user('test_bulk_creation_update@example.com')
@@ -60,28 +77,28 @@ class TestUserPermission(unittest.TestCase):
# Initially create User Permission document with apply_to_all checked
is_created = add_user_permissions(param)
- self.assertEquals(is_created, 1)
+ self.assertEqual(is_created, 1)
is_created = add_user_permissions(param)
# User Permission should not be changed
- self.assertEquals(is_created, 0)
+ self.assertEqual(is_created, 0)
def test_for_applicable_on_update_from_apply_to_all(self):
''' Update User Permission from all to some applicable Doctypes'''
user = create_user('test_bulk_creation_update@example.com')
- param = get_params(user,'User', user.name, applicable = ["Chat Room", "Chat Message"])
+ param = get_params(user,'User', user.name, applicable = ["Comment", "Contact"])
# Initially create User Permission document with apply_to_all checked
is_created = add_user_permissions(get_params(user, 'User', user.name))
- self.assertEquals(is_created, 1)
+ self.assertEqual(is_created, 1)
is_created = add_user_permissions(param)
frappe.db.commit()
removed_apply_to_all = frappe.db.exists("User Permission", get_exists_param(user))
- is_created_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Room"))
- is_created_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Message"))
+ is_created_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Comment"))
+ is_created_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Contact"))
# Check that apply_to_all is removed
self.assertIsNone(removed_apply_to_all)
@@ -89,7 +106,7 @@ class TestUserPermission(unittest.TestCase):
# Check that User Permissions for applicable is created
self.assertIsNotNone(is_created_applicable_first)
self.assertIsNotNone(is_created_applicable_second)
- self.assertEquals(is_created, 1)
+ self.assertEqual(is_created, 1)
def test_for_apply_to_all_on_update_from_applicable(self):
''' Update User Permission from some to all applicable Doctypes'''
@@ -97,14 +114,14 @@ class TestUserPermission(unittest.TestCase):
param = get_params(user, 'User', user.name)
# create User permissions that with applicable
- is_created = add_user_permissions(get_params(user, 'User', user.name, applicable = ["Chat Room", "Chat Message"]))
+ is_created = add_user_permissions(get_params(user, 'User', user.name, applicable = ["Comment", "Contact"]))
- self.assertEquals(is_created, 1)
+ self.assertEqual(is_created, 1)
is_created = add_user_permissions(param)
is_created_apply_to_all = frappe.db.exists("User Permission", get_exists_param(user))
- removed_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Room"))
- removed_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Message"))
+ removed_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Comment"))
+ removed_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Contact"))
# To check that a User permission with apply_to_all exists
self.assertIsNotNone(is_created_apply_to_all)
@@ -112,7 +129,7 @@ class TestUserPermission(unittest.TestCase):
# Check that all User Permission with applicable is removed
self.assertIsNone(removed_applicable_first)
self.assertIsNone(removed_applicable_second)
- self.assertEquals(is_created, 1)
+ self.assertEqual(is_created, 1)
def test_user_perm_for_nested_doctype(self):
"""Test if descendants' visibility is controlled for a nested DocType."""
@@ -153,16 +170,98 @@ class TestUserPermission(unittest.TestCase):
self.assertTrue(has_user_permission(frappe.get_doc("Person", parent_record.name), user.name))
self.assertFalse(has_user_permission(frappe.get_doc("Person", child_record.name), user.name))
-def create_user(email, role="System Manager"):
+ def test_user_perm_on_new_doc_with_field_default(self):
+ """Test User Perm impact on frappe.new_doc. with *field* default value"""
+ frappe.set_user('Administrator')
+ user = create_user("new_doc_test@example.com", "Blogger")
+
+ # make a doctype "Doc A" with 'doctype' link field and default value ToDo
+ if not frappe.db.exists("DocType", "Doc A"):
+ doc = new_doctype("Doc A",
+ fields=[
+ {
+ "label": "DocType",
+ "fieldname": "doc",
+ "fieldtype": "Link",
+ "options": "DocType",
+ "default": "ToDo"
+ }
+ ], unique=0)
+ doc.insert()
+
+ # make User Perm on DocType 'ToDo' in Assignment Rule (unrelated doctype)
+ add_user_permissions(get_params(user, "DocType", "ToDo", applicable=["Assignment Rule"]))
+ frappe.set_user("new_doc_test@example.com")
+
+ new_doc = frappe.new_doc("Doc A")
+
+ # User perm is created on ToDo but for doctype Assignment Rule only
+ # it should not have impact on Doc A
+ self.assertEqual(new_doc.doc, "ToDo")
+
+ frappe.set_user('Administrator')
+ remove_applicable(["Assignment Rule"], "new_doc_test@example.com", "DocType", "ToDo")
+
+ def test_user_perm_on_new_doc_with_user_default(self):
+ """Test User Perm impact on frappe.new_doc. with *user* default value"""
+ from frappe.core.doctype.session_default_settings.session_default_settings import (clear_session_defaults,
+ set_session_default_values)
+
+ frappe.set_user('Administrator')
+ user = create_user("user_default_test@example.com", "Blogger")
+
+ # make a doctype "Doc A" with 'doctype' link field
+ if not frappe.db.exists("DocType", "Doc A"):
+ doc = new_doctype("Doc A",
+ fields=[
+ {
+ "label": "DocType",
+ "fieldname": "doc",
+ "fieldtype": "Link",
+ "options": "DocType",
+ }
+ ], unique=0)
+ doc.insert()
+
+ # create a 'DocType' session default field
+ if not frappe.db.exists("Session Default", {"ref_doctype": "DocType"}):
+ settings = frappe.get_single('Session Default Settings')
+ settings.append("session_defaults", {
+ "ref_doctype": "DocType"
+ })
+ settings.save()
+
+ # make User Perm on DocType 'ToDo' in Assignment Rule (unrelated doctype)
+ add_user_permissions(get_params(user, "DocType", "ToDo", applicable=["Assignment Rule"]))
+
+ # User default Doctype value is ToDo via Session Defaults
+ frappe.set_user("user_default_test@example.com")
+ set_session_default_values({"doc": "ToDo"})
+
+ new_doc = frappe.new_doc("Doc A")
+
+ # User perm is created on ToDo but for doctype Assignment Rule only
+ # it should not have impact on Doc A
+ self.assertEqual(new_doc.doc, "ToDo")
+
+ frappe.set_user('Administrator')
+ clear_session_defaults()
+ remove_applicable(["Assignment Rule"], "user_default_test@example.com", "DocType", "ToDo")
+
+def create_user(email, *roles):
''' create user with role system manager '''
if frappe.db.exists('User', email):
return frappe.get_doc('User', email)
- else:
- user = frappe.new_doc('User')
- user.email = email
- user.first_name = email.split("@")[0]
- user.add_roles(role)
- return user
+
+ user = frappe.new_doc('User')
+ user.email = email
+ user.first_name = email.split("@")[0]
+
+ if not roles:
+ roles = ('System Manager',)
+
+ user.add_roles(*roles)
+ return user
def get_params(user, doctype, docname, is_default=0, hide_descendants=0, applicable=None):
''' Return param to insert '''
diff --git a/frappe/core/doctype/user_permission/user_permission.js b/frappe/core/doctype/user_permission/user_permission.js
index 6c6b74c5df..f6989db5d8 100644
--- a/frappe/core/doctype/user_permission/user_permission.js
+++ b/frappe/core/doctype/user_permission/user_permission.js
@@ -44,7 +44,8 @@ frappe.ui.form.on('User Permission', {
set_applicable_for_constraint: frm => {
frm.toggle_reqd('applicable_for', !frm.doc.apply_to_all_doctypes);
- if (frm.doc.apply_to_all_doctypes) {
+
+ if (frm.doc.apply_to_all_doctypes && frm.doc.applicable_for) {
frm.set_value('applicable_for', null, null, true);
}
},
diff --git a/frappe/core/doctype/user_permission/user_permission.json b/frappe/core/doctype/user_permission/user_permission.json
index 9cea0856c9..60b6779bfd 100644
--- a/frappe/core/doctype/user_permission/user_permission.json
+++ b/frappe/core/doctype/user_permission/user_permission.json
@@ -8,8 +8,8 @@
"field_order": [
"user",
"allow",
- "column_break_3",
"for_value",
+ "column_break_3",
"is_default",
"advanced_control_section",
"apply_to_all_doctypes",
@@ -37,10 +37,6 @@
"options": "DocType",
"reqd": 1
},
- {
- "fieldname": "column_break_3",
- "fieldtype": "Column Break"
- },
{
"fieldname": "for_value",
"fieldtype": "Dynamic Link",
@@ -87,10 +83,14 @@
"fieldtype": "Check",
"hidden": 1,
"label": "Hide Descendants"
+ },
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
}
],
"links": [],
- "modified": "2021-01-21 18:14:10.839381",
+ "modified": "2022-01-03 11:25:03.726150",
"modified_by": "Administrator",
"module": "Core",
"name": "User Permission",
@@ -111,6 +111,7 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "user",
"track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py
index fbc788f6bf..fb658481b2 100644
--- a/frappe/core/doctype/user_permission/user_permission.py
+++ b/frappe/core/doctype/user_permission/user_permission.py
@@ -1,8 +1,6 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe, json
from frappe.model.document import Document
from frappe.permissions import (get_valid_perms, update_permission_property)
@@ -17,11 +15,11 @@ class UserPermission(Document):
self.validate_default_permission()
def on_update(self):
- frappe.cache().delete_value('user_permissions')
+ frappe.cache().hdel('user_permissions', self.user)
frappe.publish_realtime('update_user_permissions')
def on_trash(self): # pylint: disable=no-self-use
- frappe.cache().delete_value('user_permissions')
+ frappe.cache().hdel('user_permissions', self.user)
frappe.publish_realtime('update_user_permissions')
def validate_user_permission(self):
@@ -50,13 +48,12 @@ class UserPermission(Document):
}, or_filters={
'applicable_for': cstr(self.applicable_for),
'apply_to_all_doctypes': 1,
- 'hide_descendants': cstr(self.hide_descendants)
}, limit=1)
if overlap_exists:
ref_link = frappe.get_desk_link(self.doctype, overlap_exists[0].name)
frappe.throw(_("{0} has already assigned default value for {1}.").format(ref_link, self.allow))
-@frappe.whitelist(allow_guest=True)
+@frappe.whitelist()
def get_user_permissions(user=None):
'''Get all users permissions for the user as a dict of doctype'''
# if this is called from client-side,
@@ -180,18 +177,23 @@ def check_applicable_doc_perm(user, doctype, docname):
@frappe.whitelist()
def clear_user_permissions(user, for_doctype):
- frappe.only_for('System Manager')
- total = frappe.db.count('User Permission', filters = dict(user=user, allow=for_doctype))
+ frappe.only_for("System Manager")
+ total = frappe.db.count("User Permission", {"user": user, "allow": for_doctype})
+
if total:
- frappe.db.sql('DELETE FROM `tabUser Permission` WHERE `user`=%s AND `allow`=%s', (user, for_doctype))
+ frappe.db.delete("User Permission", {
+ "allow": for_doctype,
+ "user": user,
+ })
frappe.clear_cache()
+
return total
@frappe.whitelist()
def add_user_permissions(data):
''' Add and update the user permissions '''
frappe.only_for('System Manager')
- if isinstance(data, frappe.string_types):
+ if isinstance(data, str):
data = json.loads(data)
data = frappe._dict(data)
@@ -226,7 +228,7 @@ def insert_user_perm(user, doctype, docname, is_default=0, hide_descendants=0, a
user_perm.is_default = is_default
user_perm.hide_descendants = hide_descendants
if applicable:
- user_perm.applicable_for = applicable
+ user_perm.applicable_for = applicable
user_perm.apply_to_all_doctypes = 0
else:
user_perm.apply_to_all_doctypes = 1
@@ -234,27 +236,27 @@ def insert_user_perm(user, doctype, docname, is_default=0, hide_descendants=0, a
def remove_applicable(perm_applied_docs, user, doctype, docname):
for applicable_for in perm_applied_docs:
- frappe.db.sql("""DELETE FROM `tabUser Permission`
- WHERE `user`=%s
- AND `applicable_for`=%s
- AND `allow`=%s
- AND `for_value`=%s
- """, (user, applicable_for, doctype, docname))
+ frappe.db.delete("User Permission", {
+ "applicable_for": applicable_for,
+ "for_value": docname,
+ "allow": doctype,
+ "user": user,
+ })
def remove_apply_to_all(user, doctype, docname):
- frappe.db.sql("""DELETE from `tabUser Permission`
- WHERE `user`=%s
- AND `apply_to_all_doctypes`=1
- AND `allow`=%s
- AND `for_value`=%s
- """,(user, doctype, docname))
+ frappe.db.delete("User Permission", {
+ "apply_to_all_doctypes": 1,
+ "for_value": docname,
+ "allow": doctype,
+ "user": user,
+ })
def update_applicable(already_applied, to_apply, user, doctype, docname):
for applied in already_applied:
if applied not in to_apply:
- frappe.db.sql("""DELETE FROM `tabUser Permission`
- WHERE `user`=%s
- AND `applicable_for`=%s
- AND `allow`=%s
- AND `for_value`=%s
- """,(user, applied, doctype, docname))
+ frappe.db.delete("User Permission", {
+ "applicable_for": applied,
+ "for_value": docname,
+ "allow": doctype,
+ "user": user,
+ })
diff --git a/frappe/patches/v4_3/__init__.py b/frappe/core/doctype/user_select_document_type/__init__.py
similarity index 100%
rename from frappe/patches/v4_3/__init__.py
rename to frappe/core/doctype/user_select_document_type/__init__.py
diff --git a/frappe/core/doctype/user_select_document_type/user_select_document_type.json b/frappe/core/doctype/user_select_document_type/user_select_document_type.json
new file mode 100644
index 0000000000..86e19422c3
--- /dev/null
+++ b/frappe/core/doctype/user_select_document_type/user_select_document_type.json
@@ -0,0 +1,33 @@
+{
+ "actions": [],
+ "creation": "2021-01-17 18:28:14.208576",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "document_type"
+ ],
+ "fields": [
+ {
+ "fieldname": "document_type",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Document Type",
+ "options": "DocType",
+ "reqd": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-01-17 18:45:44.993190",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "User Select Document Type",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/user_select_document_type/user_select_document_type.py b/frappe/core/doctype/user_select_document_type/user_select_document_type.py
new file mode 100644
index 0000000000..18a21931e5
--- /dev/null
+++ b/frappe/core/doctype/user_select_document_type/user_select_document_type.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+# import frappe
+from frappe.model.document import Document
+
+class UserSelectDocumentType(Document):
+ pass
diff --git a/frappe/core/doctype/user_social_login/user_social_login.py b/frappe/core/doctype/user_social_login/user_social_login.py
index cc6c3d0e05..80c0c89383 100644
--- a/frappe/core/doctype/user_social_login/user_social_login.py
+++ b/frappe/core/doctype/user_social_login/user_social_login.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
from frappe.model.document import Document
class UserSocialLogin(Document):
diff --git a/frappe/patches/v5_0/__init__.py b/frappe/core/doctype/user_type/__init__.py
similarity index 100%
rename from frappe/patches/v5_0/__init__.py
rename to frappe/core/doctype/user_type/__init__.py
diff --git a/frappe/core/doctype/user_type/test_user_type.py b/frappe/core/doctype/user_type/test_user_type.py
new file mode 100644
index 0000000000..6807f8fc9e
--- /dev/null
+++ b/frappe/core/doctype/user_type/test_user_type.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# License: MIT. See LICENSE
+import frappe
+import unittest
+
+from frappe.installer import update_site_config
+
+class TestUserType(unittest.TestCase):
+ def setUp(self):
+ create_role()
+
+ def test_add_select_perm_doctypes(self):
+ user_type = create_user_type('Test User Type')
+
+ # select perms added for all link fields
+ doc = frappe.get_meta('Contact')
+ link_fields = doc.get_link_fields()
+ select_doctypes = frappe.get_all('User Select Document Type', {'parent': user_type.name}, pluck='document_type')
+
+ for entry in link_fields:
+ self.assertTrue(entry.options in select_doctypes)
+
+ # select perms added for all child table link fields
+ link_fields = []
+ for child_table in doc.get_table_fields():
+ child_doc = frappe.get_meta(child_table.options)
+ link_fields.extend(child_doc.get_link_fields())
+
+ for entry in link_fields:
+ self.assertTrue(entry.options in select_doctypes)
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+
+def create_user_type(user_type):
+ if frappe.db.exists('User Type', user_type):
+ frappe.delete_doc('User Type', user_type)
+
+ user_type_limit = {frappe.scrub(user_type): 1}
+ update_site_config('user_type_doctype_limit', user_type_limit)
+
+ doc = frappe.get_doc({
+ 'doctype': 'User Type',
+ 'name': user_type,
+ 'role': '_Test User Type',
+ 'user_id_field': 'user',
+ 'apply_user_permission_on': 'User'
+ })
+
+ doc.append('user_doctypes', {
+ 'document_type': 'Contact',
+ 'read': 1,
+ 'write': 1
+ })
+
+ return doc.insert()
+
+
+def create_role():
+ if not frappe.db.exists('Role', '_Test User Type'):
+ frappe.get_doc({
+ 'doctype': 'Role',
+ 'role_name': '_Test User Type',
+ 'desk_access': 1,
+ 'is_custom': 1
+ }).insert()
\ No newline at end of file
diff --git a/frappe/core/doctype/user_type/user_type.js b/frappe/core/doctype/user_type/user_type.js
new file mode 100644
index 0000000000..c8bd499b58
--- /dev/null
+++ b/frappe/core/doctype/user_type/user_type.js
@@ -0,0 +1,77 @@
+// Copyright (c) 2021, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('User Type', {
+ refresh: function(frm) {
+ frm.toggle_display('is_standard', frappe.boot.developer_mode);
+ frm.set_df_property('is_standard', 'read_only', !frappe.boot.developer_mode);
+
+ const fields = ['role', 'apply_user_permission_on', 'user_id_field',
+ 'user_doctypes', 'user_type_modules'];
+
+ frm.toggle_display(fields, !frm.doc.is_standard);
+
+ frm.set_query('document_type', 'user_doctypes', function() {
+ return {
+ filters: {
+ istable: 0
+ }
+ };
+ });
+
+ frm.set_query('document_type', 'select_doctypes', function() {
+ return {
+ filters: {
+ istable: 0
+ }
+ };
+ });
+
+ frm.set_query('document_type', 'custom_select_doctypes', function() {
+ return {
+ filters: {
+ istable: 0
+ }
+ };
+ });
+
+ frm.set_query('role', function() {
+ return {
+ filters: {
+ is_custom: 1,
+ disabled: 0,
+ desk_access: 1
+ }
+ };
+ });
+
+ frm.set_query('apply_user_permission_on', function() {
+ return {
+ query: "frappe.core.doctype.user_type.user_type.get_user_linked_doctypes"
+ };
+ });
+ },
+
+ onload: function(frm) {
+ frm.trigger('get_user_id_fields');
+ },
+
+ apply_user_permission_on: function(frm) {
+ frm.set_value('user_id_field', '');
+ frm.trigger('get_user_id_fields');
+ },
+
+ get_user_id_fields: function(frm) {
+ if (frm.doc.apply_user_permission_on) {
+ frappe.call({
+ method: 'frappe.core.doctype.user_type.user_type.get_user_id',
+ args: {
+ parent: frm.doc.apply_user_permission_on
+ },
+ callback: function(r) {
+ set_field_options('user_id_field', [""].concat(r.message));
+ }
+ });
+ }
+ }
+});
diff --git a/frappe/core/doctype/user_type/user_type.json b/frappe/core/doctype/user_type/user_type.json
new file mode 100644
index 0000000000..9ea5d5be71
--- /dev/null
+++ b/frappe/core/doctype/user_type/user_type.json
@@ -0,0 +1,141 @@
+{
+ "actions": [],
+ "autoname": "Prompt",
+ "creation": "2021-01-13 01:48:02.378548",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "is_standard",
+ "section_break_2",
+ "role",
+ "column_break_4",
+ "apply_user_permission_on",
+ "user_id_field",
+ "section_break_6",
+ "user_doctypes",
+ "custom_select_doctypes",
+ "select_doctypes",
+ "allowed_modules_section",
+ "user_type_modules"
+ ],
+ "fields": [
+ {
+ "default": "0",
+ "fieldname": "is_standard",
+ "fieldtype": "Check",
+ "label": "Is Standard"
+ },
+ {
+ "depends_on": "eval: !doc.is_standard",
+ "fieldname": "section_break_2",
+ "fieldtype": "Section Break",
+ "label": "Document Types and Permissions"
+ },
+ {
+ "fieldname": "user_doctypes",
+ "fieldtype": "Table",
+ "label": "Document Types",
+ "mandatory_depends_on": "eval: !doc.is_standard",
+ "options": "User Document Type",
+ "read_only": 1
+ },
+ {
+ "fieldname": "role",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Role",
+ "mandatory_depends_on": "eval: !doc.is_standard",
+ "options": "Role",
+ "read_only": 1
+ },
+ {
+ "fieldname": "select_doctypes",
+ "fieldtype": "Table",
+ "hidden": 1,
+ "label": "Document Types (Select Permissions Only)",
+ "options": "User Select Document Type",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_4",
+ "fieldtype": "Column Break"
+ },
+ {
+ "description": "Can only list down the document types which has been linked to the User document type.",
+ "fieldname": "apply_user_permission_on",
+ "fieldtype": "Link",
+ "label": "Apply User Permission On",
+ "mandatory_depends_on": "eval: !doc.is_standard",
+ "options": "DocType",
+ "read_only": 1
+ },
+ {
+ "depends_on": "eval: !doc.is_standard",
+ "fieldname": "section_break_6",
+ "fieldtype": "Section Break",
+ "hide_border": 1
+ },
+ {
+ "depends_on": "apply_user_permission_on",
+ "fieldname": "user_id_field",
+ "fieldtype": "Select",
+ "label": "User Id Field",
+ "mandatory_depends_on": "eval: !doc.is_standard",
+ "read_only": 1
+ },
+ {
+ "depends_on": "eval: !doc.is_standard",
+ "fieldname": "allowed_modules_section",
+ "fieldtype": "Section Break",
+ "label": "Allowed Modules"
+ },
+ {
+ "fieldname": "user_type_modules",
+ "fieldtype": "Table",
+ "no_copy": 1,
+ "options": "User Type Module",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "custom_select_doctypes",
+ "fieldtype": "Table",
+ "label": "Custom Document Types (Select Permission)",
+ "options": "User Select Document Type"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-03-12 16:25:18.639050",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "User Type",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Administrator",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py
new file mode 100644
index 0000000000..c0dfd2e597
--- /dev/null
+++ b/frappe/core/doctype/user_type/user_type.py
@@ -0,0 +1,269 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+import frappe
+from frappe import _
+from frappe.utils import get_link_to_form
+from frappe.config import get_modules_from_app
+from frappe.permissions import add_permission, add_user_permission
+from frappe.model.document import Document
+
+class UserType(Document):
+ def validate(self):
+ self.set_modules()
+ self.add_select_perm_doctypes()
+
+ def on_update(self):
+ if self.is_standard:
+ return
+
+ self.validate_document_type_limit()
+ self.validate_role()
+ self.add_role_permissions_for_user_doctypes()
+ self.add_role_permissions_for_select_doctypes()
+ self.add_role_permissions_for_file()
+ self.update_users()
+ get_non_standard_user_type_details()
+ self.remove_permission_for_deleted_doctypes()
+
+ def on_trash(self):
+ if self.is_standard:
+ frappe.throw(_('Standard user type {0} can not be deleted.')
+ .format(frappe.bold(self.name)))
+
+ def set_modules(self):
+ if not self.user_doctypes:
+ return
+
+ modules = frappe.get_all("DocType",
+ filters={"name": ("in", [d.document_type for d in self.user_doctypes])},
+ distinct=True,
+ pluck="module",
+ )
+
+ self.set("user_type_modules", [])
+ for module in modules:
+ self.append("user_type_modules", {"module": module})
+
+ def validate_document_type_limit(self):
+ limit = frappe.conf.get('user_type_doctype_limit', {}).get(frappe.scrub(self.name))
+
+ if not limit and frappe.session.user != 'Administrator':
+ frappe.throw(_('User does not have permission to create the new {0}')
+ .format(frappe.bold(_('User Type'))), title=_('Permission Error'))
+
+ if not limit:
+ frappe.throw(_('The limit has not set for the user type {0} in the site config file.')
+ .format(frappe.bold(self.name)), title=_('Set Limit'))
+
+ if self.user_doctypes and len(self.user_doctypes) > limit:
+ frappe.throw(_('The total number of user document types limit has been crossed.'),
+ title=_('User Document Types Limit Exceeded'))
+
+ custom_doctypes = [row.document_type for row in self.user_doctypes if row.is_custom]
+ if custom_doctypes and len(custom_doctypes) > 3:
+ frappe.throw(_('You can only set the 3 custom doctypes in the Document Types table.'),
+ title=_('Custom Document Types Limit Exceeded'))
+
+ def validate_role(self):
+ if not self.role:
+ frappe.throw(_("The field {0} is mandatory")
+ .format(frappe.bold(_('Role'))))
+
+ if not frappe.db.get_value('Role', self.role, 'is_custom'):
+ frappe.throw(_("The role {0} should be a custom role.")
+ .format(frappe.bold(get_link_to_form('Role', self.role))))
+
+ def update_users(self):
+ for row in frappe.get_all('User', filters = {'user_type': self.name}):
+ user = frappe.get_cached_doc('User', row.name)
+ self.update_roles_in_user(user)
+ self.update_modules_in_user(user)
+ user.update_children()
+
+ def update_roles_in_user(self, user):
+ user.set('roles', [])
+ user.append('roles', {
+ 'role': self.role
+ })
+
+ def update_modules_in_user(self, user):
+ block_modules = frappe.get_all('Module Def', fields = ['name as module'],
+ filters={'name': ['not in', [d.module for d in self.user_type_modules]]})
+
+ if block_modules:
+ user.set('block_modules', block_modules)
+
+ def add_role_permissions_for_user_doctypes(self):
+ perms = ['read', 'write', 'create', 'submit', 'cancel', 'amend', 'delete']
+ for row in self.user_doctypes:
+ docperm = add_role_permissions(row.document_type, self.role)
+
+ values = {perm:row.get(perm) or 0 for perm in perms}
+ for perm in ['print', 'email', 'share']:
+ values[perm] = 1
+
+ frappe.db.set_value('Custom DocPerm', docperm, values)
+
+ def add_select_perm_doctypes(self):
+ if frappe.flags.ignore_select_perm:
+ return
+
+ self.select_doctypes = []
+
+ select_doctypes = []
+ user_doctypes = [row.document_type for row in self.user_doctypes]
+
+ for doctype in user_doctypes:
+ doc = frappe.get_meta(doctype)
+ self.prepare_select_perm_doctypes(doc, user_doctypes, select_doctypes)
+
+ for child_table in doc.get_table_fields():
+ child_doc = frappe.get_meta(child_table.options)
+ if child_doc:
+ self.prepare_select_perm_doctypes(child_doc, user_doctypes, select_doctypes)
+
+ if select_doctypes:
+ select_doctypes = set(select_doctypes)
+ for select_doctype in select_doctypes:
+ self.append('select_doctypes', {
+ 'document_type': select_doctype
+ })
+
+ def prepare_select_perm_doctypes(self, doc, user_doctypes, select_doctypes):
+ for field in doc.get_link_fields():
+ if field.options not in user_doctypes:
+ select_doctypes.append(field.options)
+
+ def add_role_permissions_for_select_doctypes(self):
+ for doctype in ['select_doctypes', 'custom_select_doctypes']:
+ for row in self.get(doctype):
+ docperm = add_role_permissions(row.document_type, self.role)
+ frappe.db.set_value('Custom DocPerm', docperm,
+ {'select': 1, 'read': 0, 'create': 0, 'write': 0})
+
+ def add_role_permissions_for_file(self):
+ docperm = add_role_permissions('File', self.role)
+ frappe.db.set_value('Custom DocPerm', docperm,
+ {'read': 1, 'create': 1, 'write': 1})
+
+ def remove_permission_for_deleted_doctypes(self):
+ doctypes = [d.document_type for d in self.user_doctypes]
+
+ # Do not remove the doc permission for the file doctype
+ doctypes.append('File')
+
+ for doctype in ['select_doctypes', 'custom_select_doctypes']:
+ for dt in self.get(doctype):
+ doctypes.append(dt.document_type)
+
+ for perm in frappe.get_all('Custom DocPerm',
+ filters = {'role': self.role, 'parent': ['not in', doctypes]}):
+ frappe.delete_doc('Custom DocPerm', perm.name)
+
+def add_role_permissions(doctype, role):
+ name = frappe.get_value('Custom DocPerm', dict(parent=doctype,
+ role=role, permlevel=0))
+
+ if not name:
+ name = add_permission(doctype, role, 0)
+
+ return name
+
+def get_non_standard_user_type_details():
+ user_types = frappe.get_all('User Type',
+ fields=['apply_user_permission_on', 'name', 'user_id_field'],
+ filters={'is_standard': 0})
+
+ if user_types:
+ user_type_details = {d.name: [d.apply_user_permission_on, d.user_id_field] for d in user_types}
+
+ frappe.cache().set_value('non_standard_user_types', user_type_details)
+
+ return user_type_details
+
+@frappe.whitelist()
+@frappe.validate_and_sanitize_search_inputs
+def get_user_linked_doctypes(doctype, txt, searchfield, start, page_len, filters):
+ modules = [d.get('module_name') for d in get_modules_from_app('frappe')]
+
+ filters = [['DocField', 'options', '=', 'User'], ['DocType', 'is_submittable', '=', 0],
+ ['DocType', 'issingle', '=', 0], ['DocType', 'module', 'not in', modules],
+ ['DocType', 'read_only', '=', 0], ['DocType', 'name', 'like', '%{0}%'.format(txt)]]
+
+ doctypes = frappe.get_all('DocType', fields = ['`tabDocType`.`name`'], filters=filters,
+ order_by='`tabDocType`.`idx` desc', limit_start=start, limit_page_length=page_len, as_list=1)
+
+ custom_dt_filters = [['Custom Field', 'dt', 'like', '%{0}%'.format(txt)],
+ ['Custom Field', 'options', '=', 'User'], ['Custom Field', 'fieldtype', '=', 'Link']]
+
+ custom_doctypes = frappe.get_all('Custom Field', fields = ['dt as name'],
+ filters= custom_dt_filters, as_list=1)
+
+ return doctypes + custom_doctypes
+
+@frappe.whitelist()
+def get_user_id(parent):
+ data = frappe.get_all('DocField', fields = ['label', 'fieldname as value'],
+ filters= {'options': 'User', 'fieldtype': 'Link', 'parent': parent}) or []
+
+ data.extend(frappe.get_all('Custom Field', fields = ['label', 'fieldname as value'],
+ filters= {'options': 'User', 'fieldtype': 'Link', 'dt': parent}))
+
+ return data
+
+def user_linked_with_permission_on_doctype(doc, user):
+ if not doc.apply_user_permission_on:
+ return True
+
+ if not doc.user_id_field:
+ frappe.throw(_('User Id Field is mandatory in the user type {0}')
+ .format(frappe.bold(doc.name)))
+
+ if frappe.db.get_value(doc.apply_user_permission_on,
+ {doc.user_id_field: user}, 'name'):
+ return True
+ else:
+ label = frappe.get_meta(doc.apply_user_permission_on).get_field(doc.user_id_field).label
+
+ frappe.msgprint(_("To set the role {0} in the user {1}, kindly set the {2} field as {3} in one of the {4} record.")
+ .format(frappe.bold(doc.role), frappe.bold(user), frappe.bold(label),
+ frappe.bold(user), frappe.bold(doc.apply_user_permission_on)))
+
+ return False
+
+def apply_permissions_for_non_standard_user_type(doc, method=None):
+ '''Create user permission for the non standard user type'''
+ if not frappe.db.table_exists('User Type'):
+ return
+
+ user_types = frappe.cache().get_value('non_standard_user_types')
+
+ if not user_types:
+ user_types = get_non_standard_user_type_details()
+
+ if not user_types:
+ return
+
+ for user_type, data in user_types.items():
+ if (not doc.get(data[1]) or doc.doctype != data[0]):
+ continue
+
+ if frappe.get_cached_value('User', doc.get(data[1]), 'user_type') != user_type:
+ return
+
+ if (doc.get(data[1]) and (not doc._doc_before_save or doc.get(data[1]) != doc._doc_before_save.get(data[1])
+ or not frappe.db.get_value('User Permission',
+ {'user': doc.get(data[1]), 'allow': data[0], 'for_value': doc.name}, 'name'))):
+
+ perm_data = frappe.db.get_value('User Permission',
+ {'allow': doc.doctype, 'for_value': doc.name}, ['name', 'user'])
+
+ if not perm_data:
+ user_doc = frappe.get_cached_doc('User', doc.get(data[1]))
+ user_doc.set_roles_and_modules_based_on_user_type()
+ user_doc.update_children()
+ add_user_permission(doc.doctype, doc.name, doc.get(data[1]))
+ else:
+ frappe.db.set_value('User Permission', perm_data[0], 'user', doc.get(data[1]))
diff --git a/frappe/core/doctype/user_type/user_type_dashboard.py b/frappe/core/doctype/user_type/user_type_dashboard.py
new file mode 100644
index 0000000000..6cdd2f82a5
--- /dev/null
+++ b/frappe/core/doctype/user_type/user_type_dashboard.py
@@ -0,0 +1,13 @@
+
+from frappe import _
+
+def get_data():
+ return {
+ 'fieldname': 'user_type',
+ 'transactions': [
+ {
+ 'label': _('Reference'),
+ 'items': ['User']
+ }
+ ]
+ }
\ No newline at end of file
diff --git a/frappe/core/doctype/user_type/user_type_list.js b/frappe/core/doctype/user_type/user_type_list.js
new file mode 100644
index 0000000000..9a9ef417ac
--- /dev/null
+++ b/frappe/core/doctype/user_type/user_type_list.js
@@ -0,0 +1,10 @@
+frappe.listview_settings['User Type'] = {
+ add_fields: ["is_standard"],
+ get_indicator: function (doc) {
+ if (doc.is_standard) {
+ return [__("Standard"), "green", "is_standard,=,1"];
+ } else {
+ return [__("Custom"), "blue", "is_standard,=,0"];
+ }
+ }
+};
diff --git a/frappe/patches/v5_2/__init__.py b/frappe/core/doctype/user_type_module/__init__.py
similarity index 100%
rename from frappe/patches/v5_2/__init__.py
rename to frappe/core/doctype/user_type_module/__init__.py
diff --git a/frappe/core/doctype/user_type_module/user_type_module.json b/frappe/core/doctype/user_type_module/user_type_module.json
new file mode 100644
index 0000000000..0f9cbefc25
--- /dev/null
+++ b/frappe/core/doctype/user_type_module/user_type_module.json
@@ -0,0 +1,33 @@
+{
+ "actions": [],
+ "creation": "2021-01-24 03:05:24.634719",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "module"
+ ],
+ "fields": [
+ {
+ "fieldname": "module",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Module",
+ "options": "Module Def",
+ "reqd": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-01-24 03:07:43.602927",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "User Type Module",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/user_type_module/user_type_module.py b/frappe/core/doctype/user_type_module/user_type_module.py
new file mode 100644
index 0000000000..d25479f869
--- /dev/null
+++ b/frappe/core/doctype/user_type_module/user_type_module.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+# import frappe
+from frappe.model.document import Document
+
+class UserTypeModule(Document):
+ pass
diff --git a/frappe/core/doctype/version/test_version.py b/frappe/core/doctype/version/test_version.py
index 97aa69fd9c..608dc9f0ab 100644
--- a/frappe/core/doctype/version/test_version.py
+++ b/frappe/core/doctype/version/test_version.py
@@ -1,7 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest, copy
from frappe.test_runner import make_test_objects
@@ -9,6 +7,7 @@ from frappe.core.doctype.version.version import get_diff
class TestVersion(unittest.TestCase):
def test_get_diff(self):
+ frappe.set_user('Administrator')
test_records = make_test_objects('Event', reset = True)
old_doc = frappe.get_doc("Event", test_records[0])
new_doc = copy.deepcopy(old_doc)
diff --git a/frappe/core/doctype/version/version.css b/frappe/core/doctype/version/version.css
deleted file mode 100644
index 769b352585..0000000000
--- a/frappe/core/doctype/version/version.css
+++ /dev/null
@@ -1,21 +0,0 @@
-.version-info {
- overflow: auto;
-}
-
-.version-info pre {
- border: 0px;
- margin: 0px;
- background-color: inherit;
-}
-
-.version-info .table {
- background-color: inherit;
-}
-
-.version-info .success {
- background-color: #dff0d8 !important;
-}
-
-.version-info .danger {
- background-color: #f2dede !important;
-}
diff --git a/frappe/core/doctype/version/version.py b/frappe/core/doctype/version/version.py
index 7654db4ae5..fcb558650a 100644
--- a/frappe/core/doctype/version/version.py
+++ b/frappe/core/doctype/version/version.py
@@ -1,9 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-# For license information, please see license.txt
-
-from __future__ import unicode_literals
import frappe, json
from frappe.model.document import Document
diff --git a/frappe/core/doctype/view_log/test_view_log.js b/frappe/core/doctype/view_log/test_view_log.js
deleted file mode 100644
index b6de94fe56..0000000000
--- a/frappe/core/doctype/view_log/test_view_log.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: View Log", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new View Log
- () => frappe.tests.make('View Log', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/core/doctype/view_log/test_view_log.py b/frappe/core/doctype/view_log/test_view_log.py
index 83967a39a4..efa9538fbf 100644
--- a/frappe/core/doctype/view_log/test_view_log.py
+++ b/frappe/core/doctype/view_log/test_view_log.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
@@ -25,11 +23,11 @@ class TestViewLog(unittest.TestCase):
# load the form
getdoc('Event', ev.name)
a = frappe.get_value(
- doctype="View Log",
+ doctype="View Log",
filters={
"reference_doctype": "Event",
"reference_name": ev.name
- },
+ },
fieldname=['viewed_by']
)
diff --git a/frappe/core/doctype/view_log/view_log.json b/frappe/core/doctype/view_log/view_log.json
index 6c3247c58f..3c4486c944 100644
--- a/frappe/core/doctype/view_log/view_log.json
+++ b/frappe/core/doctype/view_log/view_log.json
@@ -125,7 +125,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
- "modified": "2019-09-05 14:22:27.664645",
+ "modified": "2021-10-25 14:22:27.664645",
"modified_by": "Administrator",
"module": "Core",
"name": "View Log",
@@ -158,7 +158,6 @@
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
- "track_changes": 1,
"track_seen": 0,
"track_views": 0
-}
\ No newline at end of file
+}
diff --git a/frappe/core/doctype/view_log/view_log.py b/frappe/core/doctype/view_log/view_log.py
index 45e98e37c7..fbbd6e1154 100644
--- a/frappe/core/doctype/view_log/view_log.py
+++ b/frappe/core/doctype/view_log/view_log.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/core/form_tour/doctype/doctype.json b/frappe/core/form_tour/doctype/doctype.json
new file mode 100644
index 0000000000..391d3ecf40
--- /dev/null
+++ b/frappe/core/form_tour/doctype/doctype.json
@@ -0,0 +1,56 @@
+{
+ "creation": "2021-11-23 12:38:52.807353",
+ "docstatus": 0,
+ "doctype": "Form Tour",
+ "first_document": 0,
+ "idx": 0,
+ "include_name_field": 1,
+ "is_standard": 1,
+ "modified": "2021-11-25 17:03:01.646360",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "Doctype",
+ "owner": "Administrator",
+ "reference_doctype": "DocType",
+ "save_on_complete": 1,
+ "steps": [
+ {
+ "description": "Select a Module to which this DocType would belong",
+ "field": "",
+ "fieldname": "module",
+ "fieldtype": "Link",
+ "has_next_condition": 0,
+ "is_table_field": 0,
+ "label": "Module",
+ "parent_field": "",
+ "position": "Right",
+ "title": "Module"
+ },
+ {
+ "description": "Check this to make the DocType as Custom",
+ "field": "",
+ "fieldname": "custom",
+ "fieldtype": "Check",
+ "has_next_condition": 1,
+ "is_table_field": 0,
+ "label": "Custom?",
+ "next_step_condition": "eval: doc.custom",
+ "parent_field": "",
+ "position": "Left",
+ "title": "Custom "
+ },
+ {
+ "description": "A Field (or a docfield) defines a property of a DocType. You can define the column name, label, datatype and more for DocFields. For instance, a ToDo doctype has fields description, status and priority. These ultimately become columns in the database table tabToDo.",
+ "field": "",
+ "fieldname": "fields",
+ "fieldtype": "Table",
+ "has_next_condition": 0,
+ "is_table_field": 0,
+ "label": "Fields",
+ "parent_field": "",
+ "position": "Top",
+ "title": "Fields"
+ }
+ ],
+ "title": "Doctype"
+}
\ No newline at end of file
diff --git a/frappe/core/notifications.py b/frappe/core/notifications.py
index 771a15a2e7..5f41f217f0 100644
--- a/frappe/core/notifications.py
+++ b/frappe/core/notifications.py
@@ -1,8 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
+from frappe.query_builder import DocType, Interval
+from frappe.query_builder.functions import Now
+
def get_notification_config():
return {
@@ -21,7 +23,7 @@ def get_things_todo(as_list=False):
data = frappe.get_list("ToDo",
fields=["name", "description"] if as_list else "count(*)",
filters=[["ToDo", "status", "=", "Open"]],
- or_filters=[["ToDo", "owner", "=", frappe.session.user],
+ or_filters=[["ToDo", "allocated_to", "=", frappe.session.user],
["ToDo", "assigned_by", "=", frappe.session.user]],
as_list=True)
@@ -37,31 +39,3 @@ def get_todays_events(as_list=False):
today = nowdate()
events = get_events(today, today)
return events if as_list else len(events)
-
-def get_unseen_likes():
- """Returns count of unseen likes"""
- return frappe.db.sql("""select count(*) from `tabComment`
- where
- comment_type='Like'
- and modified >= (NOW() - INTERVAL '1' YEAR)
- and owner is not null and owner!=%(user)s
- and reference_owner=%(user)s
- and seen=0
- """, {"user": frappe.session.user})[0][0]
-
-def get_unread_emails():
- "returns unread emails for a user"
-
- return frappe.db.sql("""\
- SELECT count(*)
- FROM `tabCommunication`
- WHERE communication_type='Communication'
- AND communication_medium='Email'
- AND sent_or_received='Received'
- AND email_status not in ('Spam', 'Trash')
- AND email_account in (
- SELECT distinct email_account from `tabUser Email` WHERE parent=%(user)s
- )
- AND modified >= (NOW() - INTERVAL '1' YEAR)
- AND seen=0
- """, {"user": frappe.session.user})[0][0]
diff --git a/frappe/core/page/__init__.py b/frappe/core/page/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/core/page/__init__.py
+++ b/frappe/core/page/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/core/page/background_jobs/background_jobs.js b/frappe/core/page/background_jobs/background_jobs.js
index cabe91375f..0b4d6792dc 100644
--- a/frappe/core/page/background_jobs/background_jobs.js
+++ b/frappe/core/page/background_jobs/background_jobs.js
@@ -28,6 +28,16 @@ class BackgroundJobs {
}
});
+ // add a "Remove Failed Jobs button"
+ this.remove_failed_button = this.page.add_inner_button(__("Remove Failed Jobs"), () => {
+ frappe.call({
+ method: 'frappe.core.page.background_jobs.background_jobs.remove_failed_jobs',
+ callback: () => {
+ this.refresh_jobs();
+ }
+ });
+ });
+
$(frappe.render_template('background_jobs_outer')).appendTo(this.page.body);
this.content = $(this.page.body).find('.table-area');
}
@@ -62,4 +72,4 @@ class BackgroundJobs {
}
});
}
-}
\ No newline at end of file
+}
diff --git a/frappe/core/page/background_jobs/background_jobs.py b/frappe/core/page/background_jobs/background_jobs.py
index 4a94de4ace..4d9deca526 100644
--- a/frappe/core/page/background_jobs/background_jobs.py
+++ b/frappe/core/page/background_jobs/background_jobs.py
@@ -1,58 +1,89 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
+
+import json
+from typing import TYPE_CHECKING, Dict, List
+
+from rq import Worker
-from __future__ import unicode_literals
import frappe
-
-from rq import Queue, Worker
-from frappe.utils.background_jobs import get_redis_conn
-from frappe.utils import format_datetime, cint, convert_utc_to_user_timezone
-from frappe.utils.scheduler import is_scheduler_inactive
from frappe import _
+from frappe.utils import convert_utc_to_user_timezone, format_datetime
+from frappe.utils.background_jobs import get_redis_conn, get_queues
+from frappe.utils.scheduler import is_scheduler_inactive
-colors = {
+if TYPE_CHECKING:
+ from rq.job import Job
+
+JOB_COLORS = {
'queued': 'orange',
'failed': 'red',
'started': 'blue',
'finished': 'green'
}
+
@frappe.whitelist()
-def get_info(show_failed=False):
+def get_info(show_failed=False) -> List[Dict]:
+ if isinstance(show_failed, str):
+ show_failed = json.loads(show_failed)
+
conn = get_redis_conn()
- queues = Queue.all(conn)
+ queues = get_queues()
workers = Worker.all(conn)
jobs = []
- def add_job(j, name):
- if j.kwargs.get('site')==frappe.local.site:
- jobs.append({
- 'job_name': j.kwargs.get('kwargs', {}).get('playbook_method') \
- or j.kwargs.get('kwargs', {}).get('job_type') \
- or str(j.kwargs.get('job_name')),
- 'status': j.get_status(), 'queue': name,
- 'creation': format_datetime(convert_utc_to_user_timezone(j.created_at)),
- 'color': colors[j.get_status()]
- })
- if j.exc_info:
- jobs[-1]['exc_info'] = j.exc_info
+ def add_job(job: 'Job', name: str) -> None:
+ if job.kwargs.get('site') == frappe.local.site:
+ job_info = {
+ 'job_name': job.kwargs.get('kwargs', {}).get('playbook_method')
+ or job.kwargs.get('kwargs', {}).get('job_type')
+ or str(job.kwargs.get('job_name')),
+ 'status': job.get_status(),
+ 'queue': name,
+ 'creation': format_datetime(convert_utc_to_user_timezone(job.created_at)),
+ 'color': JOB_COLORS[job.get_status()]
+ }
- for w in workers:
- j = w.get_current_job()
- if j:
- add_job(j, w.name)
+ if job.exc_info:
+ job_info['exc_info'] = job.exc_info
- for q in queues:
- if q.name != 'failed':
- for j in q.get_jobs(): add_job(j, q.name)
+ jobs.append(job_info)
- if cint(show_failed):
- for q in queues:
- if q.name == 'failed':
- for j in q.get_jobs()[:10]: add_job(j, q.name)
+ # show worker jobs
+ for worker in workers:
+ job = worker.get_current_job()
+ if job:
+ add_job(job, worker.name)
+
+ for queue in queues:
+ # show active queued jobs
+ if queue.name != 'failed':
+ for job in queue.jobs:
+ add_job(job, queue.name)
+
+ # show failed jobs, if requested
+ if show_failed:
+ fail_registry = queue.failed_job_registry
+ for job_id in fail_registry.get_job_ids():
+ job = queue.fetch_job(job_id)
+ if job:
+ add_job(job, queue.name)
return jobs
+
+@frappe.whitelist()
+def remove_failed_jobs():
+ conn = get_redis_conn()
+ queues = get_queues()
+ for queue in queues:
+ fail_registry = queue.failed_job_registry
+ for job_id in fail_registry.get_job_ids():
+ job = queue.fetch_job(job_id)
+ fail_registry.remove(job, delete_job=True)
+
+
@frappe.whitelist()
def get_scheduler_status():
if is_scheduler_inactive():
diff --git a/frappe/core/page/dashboard_view/dashboard_view.js b/frappe/core/page/dashboard_view/dashboard_view.js
index 686d11c6bf..bf9fb2a286 100644
--- a/frappe/core/page/dashboard_view/dashboard_view.js
+++ b/frappe/core/page/dashboard_view/dashboard_view.js
@@ -30,23 +30,24 @@ class Dashboard {
show() {
this.route = frappe.get_route();
+ this.set_breadcrumbs();
if (this.route.length > 1) {
// from route
this.show_dashboard(this.route.slice(-1)[0]);
} else {
// last opened
if (frappe.last_dashboard) {
- frappe.set_route('dashboard-view', frappe.last_dashboard);
+ frappe.set_re_route('dashboard-view', frappe.last_dashboard);
} else {
// default dashboard
frappe.db.get_list('Dashboard', {filters: {is_default: 1}}).then(data => {
if (data && data.length) {
- frappe.set_route('dashboard-view', data[0].name);
+ frappe.set_re_route('dashboard-view', data[0].name);
} else {
// no default, get the latest one
frappe.db.get_list('Dashboard', {limit: 1}).then(data => {
if (data && data.length) {
- frappe.set_route('dashboard-view', data[0].name);
+ frappe.set_re_route('dashboard-view', data[0].name);
} else {
// create a new dashboard!
frappe.new_doc('Dashboard');
@@ -75,6 +76,10 @@ class Dashboard {
frappe.last_dashboard = current_dashboard_name;
}
+ set_breadcrumbs() {
+ frappe.breadcrumbs.add("Desk", "Dashboard");
+ }
+
refresh() {
frappe.run_serially([
() => this.render_cards(),
diff --git a/frappe/core/page/permission_manager/__init__.py b/frappe/core/page/permission_manager/__init__.py
index 0e57cb68c3..eb5ba62e5c 100644
--- a/frappe/core/page/permission_manager/__init__.py
+++ b/frappe/core/page/permission_manager/__init__.py
@@ -1,3 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
diff --git a/frappe/core/page/permission_manager/permission_manager.js b/frappe/core/page/permission_manager/permission_manager.js
index 41cc900a97..cb218b2eae 100644
--- a/frappe/core/page/permission_manager/permission_manager.js
+++ b/frappe/core/page/permission_manager/permission_manager.js
@@ -325,15 +325,15 @@ frappe.PermissionEngine = class PermissionEngine {
.attr("data-doctype", d.parent)
.attr("data-role", d.role)
.attr("data-permlevel", d.permlevel)
- .click(function () {
+ .on("click", () => {
return frappe.call({
module: "frappe.core",
page: "permission_manager",
method: "remove",
args: {
- doctype: $(this).attr("data-doctype"),
- role: $(this).attr("data-role"),
- permlevel: $(this).attr("data-permlevel")
+ doctype: d.parent,
+ role: d.role,
+ permlevel: d.permlevel
},
callback: (r) => {
if (r.exc) {
@@ -347,6 +347,7 @@ frappe.PermissionEngine = class PermissionEngine {
}
add_check_events() {
+ let me = this;
this.body.on("click", ".show-user-permissions", () => {
frappe.route_options = { allow: this.get_doctype() || "" };
frappe.set_route('List', 'User Permission');
@@ -373,7 +374,7 @@ frappe.PermissionEngine = class PermissionEngine {
// exception: reverse
chk.prop("checked", !chk.prop("checked"));
} else {
- this.get_perm(args.role)[args.ptype] = args.value;
+ me.get_perm(args.role)[args.ptype] = args.value;
}
}
});
diff --git a/frappe/core/page/permission_manager/permission_manager.py b/frappe/core/page/permission_manager/permission_manager.py
index be8921e2ff..08642c599e 100644
--- a/frappe/core/page/permission_manager/permission_manager.py
+++ b/frappe/core/page/permission_manager/permission_manager.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
import frappe.defaults
@@ -30,8 +29,16 @@ def get_roles_and_doctypes():
"restrict_to_domain": ("in", active_domains)
}, fields=["name"])
+ restricted_roles = ['Administrator']
+ if frappe.session.user != 'Administrator':
+ custom_user_type_roles = frappe.get_all('User Type', filters = {'is_standard': 0}, fields=['role'])
+ for row in custom_user_type_roles:
+ restricted_roles.append(row.role)
+
+ restricted_roles.append('All')
+
roles = frappe.get_all("Role", filters={
- "name": ("not in", "Administrator"),
+ "name": ("not in", restricted_roles),
"disabled": 0,
}, or_filters={
"ifnull(restrict_to_domain, '')": "",
@@ -54,9 +61,14 @@ def get_permissions(doctype=None, role=None):
if doctype:
out = [p for p in out if p.parent == doctype]
else:
- out = frappe.get_all('Custom DocPerm', fields='*', filters=dict(parent = doctype), order_by="permlevel")
+ filters=dict(parent = doctype)
+ if frappe.session.user != 'Administrator':
+ custom_roles = frappe.get_all('Role', filters={'is_custom': 1})
+ filters['role'] = ['not in', [row.name for row in custom_roles]]
+
+ out = frappe.get_all('Custom DocPerm', fields='*', filters=filters, order_by="permlevel")
if not out:
- out = frappe.get_all('DocPerm', fields='*', filters=dict(parent = doctype), order_by="permlevel")
+ out = frappe.get_all('DocPerm', fields='*', filters=filters, order_by="permlevel")
linked_doctypes = {}
for d in out:
@@ -78,16 +90,16 @@ def add(parent, role, permlevel):
@frappe.whitelist()
def update(doctype, role, permlevel, ptype, value=None):
"""Update role permission params
-
+
Args:
- doctype (str): Name of the DocType to update params for
- role (str): Role to be updated for, eg "Website Manager".
- permlevel (int): perm level the provided rule applies to
- ptype (str): permission type, example "read", "delete", etc.
- value (None, optional): value for ptype, None indicates False
-
+ doctype (str): Name of the DocType to update params for
+ role (str): Role to be updated for, eg "Website Manager".
+ permlevel (int): perm level the provided rule applies to
+ ptype (str): permission type, example "read", "delete", etc.
+ value (None, optional): value for ptype, None indicates False
+
Returns:
- str: Refresh flag is permission is updated successfully
+ str: Refresh flag is permission is updated successfully
"""
frappe.only_for("System Manager")
out = update_permission_property(doctype, role, permlevel, ptype, value)
@@ -98,10 +110,9 @@ def remove(doctype, role, permlevel):
frappe.only_for("System Manager")
setup_custom_perms(doctype)
- name = frappe.get_value('Custom DocPerm', dict(parent=doctype, role=role, permlevel=permlevel))
+ frappe.db.delete("Custom DocPerm", {"parent": doctype, "role": role, "permlevel": permlevel})
- frappe.db.sql('delete from `tabCustom DocPerm` where name=%s', name)
- if not frappe.get_all('Custom DocPerm', dict(parent=doctype)):
+ if not frappe.get_all('Custom DocPerm', {"parent": doctype}):
frappe.throw(_('There must be atleast one permission rule.'), title=_('Cannot Remove'))
validate_permissions_for_doctype(doctype, for_remove=True, alert=True)
diff --git a/frappe/core/page/recorder/recorder.js b/frappe/core/page/recorder/recorder.js
index 4d6d6aa84c..f1f74daf71 100644
--- a/frappe/core/page/recorder/recorder.js
+++ b/frappe/core/page/recorder/recorder.js
@@ -1,7 +1,7 @@
frappe.pages['recorder'].on_page_load = function(wrapper) {
frappe.ui.make_app_page({
parent: wrapper,
- title: 'Recorder',
+ title: __('Recorder'),
single_column: true,
card_layout: true
});
@@ -11,7 +11,7 @@ frappe.pages['recorder'].on_page_load = function(wrapper) {
frappe.recorder.show();
});
- frappe.require('/assets/js/frappe-recorder.min.js');
+ frappe.require('recorder.bundle.js');
};
class Recorder {
@@ -22,6 +22,7 @@ class Recorder {
}
show() {
-
+ if (!this.view || this.view.$route.name == "recorder-detail") return;
+ this.view.$router.replace({name: "recorder-detail"});
}
}
diff --git a/frappe/core/report/__init__.py b/frappe/core/report/__init__.py
index 0e57cb68c3..eb5ba62e5c 100644
--- a/frappe/core/report/__init__.py
+++ b/frappe/core/report/__init__.py
@@ -1,3 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
diff --git a/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py b/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py
index c928939119..535d354250 100644
--- a/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py
+++ b/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _, throw
import frappe.utils.user
diff --git a/frappe/core/report/transaction_log_report/transaction_log_report.py b/frappe/core/report/transaction_log_report/transaction_log_report.py
index 9d84901f22..e9c68cb0c7 100644
--- a/frappe/core/report/transaction_log_report/transaction_log_report.py
+++ b/frappe/core/report/transaction_log_report/transaction_log_report.py
@@ -1,7 +1,6 @@
-# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
import hashlib
from frappe import _
@@ -13,13 +12,17 @@ def execute(filters=None):
return columns, data
def get_data(filters=None):
-
- logs = frappe.db.sql("SELECT * FROM `tabTransaction Log` order by creation desc ", as_dict=1)
result = []
+ logs = frappe.get_all("Transaction Log", fields=["*"], order_by="creation desc")
+
for l in logs:
row_index = int(l.row_index)
if row_index > 1:
- previous_hash = frappe.db.sql("SELECT chaining_hash FROM `tabTransaction Log` WHERE row_index = {0}".format(row_index - 1))
+ previous_hash = frappe.get_all(
+ "Transaction Log",
+ fields=["chaining_hash"],
+ filters={"row_index": row_index - 1},
+ )
if not previous_hash:
integrity = False
else:
diff --git a/frappe/core/utils.py b/frappe/core/utils.py
index 55cfbc34d7..d4690cae89 100644
--- a/frappe/core/utils.py
+++ b/frappe/core/utils.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
diff --git a/frappe/core/web_form/edit_profile/edit_profile.py b/frappe/core/web_form/edit_profile/edit_profile.py
index 2334f8b26d..e1ada61927 100644
--- a/frappe/core/web_form/edit_profile/edit_profile.py
+++ b/frappe/core/web_form/edit_profile/edit_profile.py
@@ -1,5 +1,3 @@
-from __future__ import unicode_literals
-
import frappe
def get_context(context):
diff --git a/frappe/core/workspace/build/build.json b/frappe/core/workspace/build/build.json
index aefda698b1..c1c506ae3a 100644
--- a/frappe/core/workspace/build/build.json
+++ b/frappe/core/workspace/build/build.json
@@ -1,24 +1,20 @@
{
- "cards_label": "Elements",
- "category": "Modules",
"charts": [],
+ "content": "[{\"type\":\"header\",\"data\":{\"text\":\"
Your Shortcuts \",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"DocType\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Workspace\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Report\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"
Elements \",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Modules\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Models\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Views\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Scripting\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Packages\",\"col\":4}}]",
"creation": "2021-01-02 10:51:16.579957",
- "developer_mode_only": 0,
- "disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
- "extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "tool",
"idx": 0,
- "is_default": 0,
- "is_standard": 1,
"label": "Build",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Modules",
+ "link_count": 0,
"link_type": "DocType",
"onboard": 0,
"only_for": "",
@@ -28,6 +24,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Module Def",
+ "link_count": 0,
"link_to": "Module Def",
"link_type": "DocType",
"onboard": 0,
@@ -38,6 +35,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workspace",
+ "link_count": 0,
"link_to": "Workspace",
"link_type": "DocType",
"onboard": 0,
@@ -48,6 +46,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Module Onboarding",
+ "link_count": 0,
"link_to": "Module Onboarding",
"link_type": "DocType",
"onboard": 0,
@@ -58,6 +57,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Block Module",
+ "link_count": 0,
"link_to": "Block Module",
"link_type": "DocType",
"onboard": 0,
@@ -68,6 +68,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Models",
+ "link_count": 0,
"link_type": "DocType",
"onboard": 0,
"only_for": "",
@@ -77,6 +78,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "DocType",
+ "link_count": 0,
"link_to": "DocType",
"link_type": "DocType",
"onboard": 0,
@@ -87,6 +89,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow",
+ "link_count": 0,
"link_to": "Workflow",
"link_type": "DocType",
"onboard": 0,
@@ -97,6 +100,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Views",
+ "link_count": 0,
"link_type": "DocType",
"onboard": 0,
"only_for": "",
@@ -106,6 +110,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Report",
+ "link_count": 0,
"link_to": "Report",
"link_type": "DocType",
"onboard": 0,
@@ -116,6 +121,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Format",
+ "link_count": 0,
"link_to": "Print Format",
"link_type": "DocType",
"onboard": 0,
@@ -126,6 +132,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workspace",
+ "link_count": 0,
"link_to": "Workspace",
"link_type": "DocType",
"onboard": 0,
@@ -136,6 +143,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dashboard",
+ "link_count": 0,
"link_to": "Dashboard",
"link_type": "DocType",
"onboard": 0,
@@ -146,6 +154,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Scripting",
+ "link_count": 0,
"link_type": "DocType",
"onboard": 0,
"only_for": "",
@@ -155,6 +164,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Server Script",
+ "link_count": 0,
"link_to": "Server Script",
"link_type": "DocType",
"onboard": 0,
@@ -165,6 +175,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Client Script",
+ "link_count": 0,
"link_to": "Client Script",
"link_type": "DocType",
"onboard": 0,
@@ -175,20 +186,52 @@
"hidden": 0,
"is_query_report": 0,
"label": "Scheduled Job Type",
+ "link_count": 0,
"link_to": "Scheduled Job Type",
"link_type": "DocType",
"onboard": 0,
"only_for": "",
"type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Packages",
+ "link_count": 2,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Package",
+ "link_count": 0,
+ "link_to": "Package",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Package Import",
+ "link_count": 0,
+ "link_to": "Package Import",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
}
],
- "modified": "2021-02-04 13:48:48.493146",
+ "modified": "2022-01-13 17:26:02.736366",
"modified_by": "Administrator",
"module": "Core",
"name": "Build",
"owner": "Administrator",
- "pin_to_bottom": 0,
- "pin_to_top": 0,
+ "parent_page": "",
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 5.0,
"shortcuts": [
{
"doc_view": "",
@@ -208,5 +251,6 @@
"link_to": "Report",
"type": "DocType"
}
- ]
+ ],
+ "title": "Build"
}
\ No newline at end of file
diff --git a/frappe/core/workspace/settings/settings.json b/frappe/core/workspace/settings/settings.json
index fb26b73cfc..5aadbc42d5 100644
--- a/frappe/core/workspace/settings/settings.json
+++ b/frappe/core/workspace/settings/settings.json
@@ -1,22 +1,20 @@
{
- "category": "Modules",
"charts": [],
+ "content": "[{\"type\":\"header\",\"data\":{\"text\":\"
Settings \",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"System Settings\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Print Settings\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Website Settings\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"
Reports & Masters \",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Data\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Email / Notifications\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Website\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Core\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Printing\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Workflow\",\"col\":4}}]",
"creation": "2020-03-02 15:09:40.527211",
- "developer_mode_only": 0,
- "disable_user_customization": 1,
"docstatus": 0,
"doctype": "Workspace",
- "extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "setting",
"idx": 0,
- "is_standard": 1,
"label": "Settings",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Data",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -25,6 +23,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Import Data",
+ "link_count": 0,
"link_to": "Data Import",
"link_type": "DocType",
"onboard": 0,
@@ -35,6 +34,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Export Data",
+ "link_count": 0,
"link_to": "Data Export",
"link_type": "DocType",
"onboard": 0,
@@ -45,6 +45,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Bulk Update",
+ "link_count": 0,
"link_to": "Bulk Update",
"link_type": "DocType",
"onboard": 0,
@@ -55,6 +56,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Download Backups",
+ "link_count": 0,
"link_to": "backups",
"link_type": "Page",
"onboard": 0,
@@ -65,6 +67,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Deleted Documents",
+ "link_count": 0,
"link_to": "Deleted Document",
"link_type": "DocType",
"onboard": 0,
@@ -74,6 +77,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email / Notifications",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -82,6 +86,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email Account",
+ "link_count": 0,
"link_to": "Email Account",
"link_type": "DocType",
"onboard": 0,
@@ -92,6 +97,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email Domain",
+ "link_count": 0,
"link_to": "Email Domain",
"link_type": "DocType",
"onboard": 0,
@@ -102,6 +108,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Notification",
+ "link_count": 0,
"link_to": "Notification",
"link_type": "DocType",
"onboard": 0,
@@ -112,6 +119,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email Template",
+ "link_count": 0,
"link_to": "Email Template",
"link_type": "DocType",
"onboard": 0,
@@ -122,6 +130,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Auto Email Report",
+ "link_count": 0,
"link_to": "Auto Email Report",
"link_type": "DocType",
"onboard": 0,
@@ -132,6 +141,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Newsletter",
+ "link_count": 0,
"link_to": "Newsletter",
"link_type": "DocType",
"onboard": 0,
@@ -142,6 +152,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Notification Settings",
+ "link_count": 0,
"link_to": "Notification Settings",
"link_type": "DocType",
"onboard": 0,
@@ -151,6 +162,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Website",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -159,6 +171,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Website Settings",
+ "link_count": 0,
"link_to": "Website Settings",
"link_type": "DocType",
"onboard": 1,
@@ -169,6 +182,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Website Theme",
+ "link_count": 0,
"link_to": "Website Theme",
"link_type": "DocType",
"onboard": 1,
@@ -179,6 +193,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Website Script",
+ "link_count": 0,
"link_to": "Website Script",
"link_type": "DocType",
"onboard": 0,
@@ -189,6 +204,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "About Us Settings",
+ "link_count": 0,
"link_to": "About Us Settings",
"link_type": "DocType",
"onboard": 0,
@@ -199,6 +215,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Contact Us Settings",
+ "link_count": 0,
"link_to": "Contact Us Settings",
"link_type": "DocType",
"onboard": 0,
@@ -208,6 +225,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Core",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -216,6 +234,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "System Settings",
+ "link_count": 0,
"link_to": "System Settings",
"link_type": "DocType",
"onboard": 0,
@@ -226,6 +245,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Error Log",
+ "link_count": 0,
"link_to": "Error Log",
"link_type": "DocType",
"onboard": 0,
@@ -236,6 +256,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Error Snapshot",
+ "link_count": 0,
"link_to": "Error Snapshot",
"link_type": "DocType",
"onboard": 0,
@@ -246,6 +267,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Domain Settings",
+ "link_count": 0,
"link_to": "Domain Settings",
"link_type": "DocType",
"onboard": 0,
@@ -255,6 +277,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Printing",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -263,6 +286,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Format Builder",
+ "link_count": 0,
"link_to": "print-format-builder",
"link_type": "Page",
"onboard": 0,
@@ -273,6 +297,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Settings",
+ "link_count": 0,
"link_to": "Print Settings",
"link_type": "DocType",
"onboard": 0,
@@ -283,6 +308,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Format",
+ "link_count": 0,
"link_to": "Print Format",
"link_type": "DocType",
"onboard": 0,
@@ -293,6 +319,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Style",
+ "link_count": 0,
"link_to": "Print Style",
"link_type": "DocType",
"onboard": 0,
@@ -302,6 +329,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -310,6 +338,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow",
+ "link_count": 0,
"link_to": "Workflow",
"link_type": "DocType",
"onboard": 0,
@@ -320,6 +349,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow State",
+ "link_count": 0,
"link_to": "Workflow State",
"link_type": "DocType",
"onboard": 0,
@@ -330,19 +360,23 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow Action",
+ "link_count": 0,
"link_to": "Workflow Action",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2020-12-01 13:38:40.235323",
+ "modified": "2022-01-13 17:49:59.586909",
"modified_by": "Administrator",
"module": "Core",
"name": "Settings",
"owner": "Administrator",
- "pin_to_bottom": 1,
- "pin_to_top": 0,
+ "parent_page": "",
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 29.0,
"shortcuts": [
{
"icon": "setting",
@@ -363,5 +397,5 @@
"type": "DocType"
}
],
- "shortcuts_label": "Settings"
+ "title": "Settings"
}
\ No newline at end of file
diff --git a/frappe/core/workspace/users/users.json b/frappe/core/workspace/users/users.json
index 05746a00c2..5741c54eeb 100644
--- a/frappe/core/workspace/users/users.json
+++ b/frappe/core/workspace/users/users.json
@@ -1,22 +1,20 @@
{
- "category": "Administration",
"charts": [],
+ "content": "[{\"type\":\"header\",\"data\":{\"text\":\"
Your Shortcuts \",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Role\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Permission Manager\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User Profile\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User Type\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"
Reports & Masters \",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Users\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Logs\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Permissions\",\"col\":4}}]",
"creation": "2020-03-02 15:12:16.754449",
- "developer_mode_only": 0,
- "disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
- "extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "users",
"idx": 0,
- "is_standard": 1,
"label": "Users",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Users",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -25,6 +23,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "User",
+ "link_count": 0,
"link_to": "User",
"link_type": "DocType",
"onboard": 0,
@@ -35,6 +34,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Role",
+ "link_count": 0,
"link_to": "Role",
"link_type": "DocType",
"onboard": 0,
@@ -45,6 +45,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Role Profile",
+ "link_count": 0,
"link_to": "Role Profile",
"link_type": "DocType",
"onboard": 0,
@@ -54,6 +55,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Logs",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -62,6 +64,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Activity Log",
+ "link_count": 0,
"link_to": "Activity Log",
"link_type": "DocType",
"onboard": 0,
@@ -72,6 +75,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Access Log",
+ "link_count": 0,
"link_to": "Access Log",
"link_type": "DocType",
"onboard": 0,
@@ -81,6 +85,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Permissions",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -89,6 +94,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Role Permissions Manager",
+ "link_count": 0,
"link_to": "permission-manager",
"link_type": "Page",
"onboard": 0,
@@ -99,6 +105,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "User Permissions",
+ "link_count": 0,
"link_to": "User Permission",
"link_type": "DocType",
"onboard": 0,
@@ -109,6 +116,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Role Permission for Page and Report",
+ "link_count": 0,
"link_to": "Role Permission for Page and Report",
"link_type": "DocType",
"onboard": 0,
@@ -119,6 +127,7 @@
"hidden": 0,
"is_query_report": 1,
"label": "Permitted Documents For User",
+ "link_count": 0,
"link_to": "Permitted Documents For User",
"link_type": "Report",
"onboard": 0,
@@ -129,19 +138,23 @@
"hidden": 0,
"is_query_report": 0,
"label": "Document Share Report",
+ "link_count": 0,
"link_to": "Document Share Report",
"link_type": "Report",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2020-12-01 13:38:40.085519",
+ "modified": "2022-01-13 17:49:08.912772",
"modified_by": "Administrator",
"module": "Core",
"name": "Users",
"owner": "Administrator",
- "pin_to_bottom": 0,
- "pin_to_top": 0,
+ "parent_page": "",
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 27.0,
"shortcuts": [
{
"label": "User",
@@ -162,6 +175,13 @@
"label": "User Profile",
"link_to": "user-profile",
"type": "Page"
+ },
+ {
+ "doc_view": "",
+ "label": "User Type",
+ "link_to": "User Type",
+ "type": "DocType"
}
- ]
+ ],
+ "title": "Users"
}
\ No newline at end of file
diff --git a/frappe/coverage.py b/frappe/coverage.py
new file mode 100644
index 0000000000..5f89800deb
--- /dev/null
+++ b/frappe/coverage.py
@@ -0,0 +1,63 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See LICENSE
+"""
+ frappe.coverage
+ ~~~~~~~~~~~~~~~~
+
+ Coverage settings for frappe
+"""
+
+STANDARD_INCLUSIONS = ["*.py"]
+
+STANDARD_EXCLUSIONS = [
+ '*.js',
+ '*.xml',
+ '*.pyc',
+ '*.css',
+ '*.less',
+ '*.scss',
+ '*.vue',
+ '*.html',
+ '*/test_*',
+ '*/node_modules/*',
+ '*/doctype/*/*_dashboard.py',
+ '*/patches/*',
+]
+
+FRAPPE_EXCLUSIONS = [
+ "*/tests/*",
+ "*/commands/*",
+ "*/frappe/change_log/*",
+ "*/frappe/exceptions*",
+ "*/frappe/coverage.py",
+ "*frappe/setup.py",
+ "*/doctype/*/*_dashboard.py",
+ "*/patches/*",
+]
+
+class CodeCoverage():
+ def __init__(self, with_coverage, app):
+ self.with_coverage = with_coverage
+ self.app = app or 'frappe'
+
+ def __enter__(self):
+ if self.with_coverage:
+ import os
+ from coverage import Coverage
+ from frappe.utils import get_bench_path
+
+ # Generate coverage report only for app that is being tested
+ source_path = os.path.join(get_bench_path(), 'apps', self.app)
+ omit = STANDARD_EXCLUSIONS[:]
+
+ if self.app == 'frappe':
+ omit.extend(FRAPPE_EXCLUSIONS)
+
+ self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS)
+ self.coverage.start()
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ if self.with_coverage:
+ self.coverage.stop()
+ self.coverage.save()
+ self.coverage.xml_report()
\ No newline at end of file
diff --git a/frappe/custom/doctype/client_script/__init__.py b/frappe/custom/doctype/client_script/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/custom/doctype/client_script/__init__.py
+++ b/frappe/custom/doctype/client_script/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/custom/doctype/client_script/client_script.js b/frappe/custom/doctype/client_script/client_script.js
index 21e7334b82..18786c62cf 100644
--- a/frappe/custom/doctype/client_script/client_script.js
+++ b/frappe/custom/doctype/client_script/client_script.js
@@ -2,46 +2,57 @@
// For license information, please see license.txt
frappe.ui.form.on('Client Script', {
+ setup(frm) {
+ frm.get_field("sample").html(SAMPLE_HTML);
+ },
refresh(frm) {
if (frm.doc.dt && frm.doc.script) {
frm.add_custom_button(__('Go to {0}', [frm.doc.dt]),
() => frappe.set_route('List', frm.doc.dt, 'List'));
}
- frm.add_custom_button(__('Add script for Child Table'), () => {
- frappe.model.with_doctype(frm.doc.dt, () => {
- const child_tables = frappe.meta.get_docfields(frm.doc.dt, null, {
- fieldtype: 'Table'
- }).map(df => df.options);
+ if (frm.doc.view == 'Form') {
+ frm.add_custom_button(__('Add script for Child Table'), () => {
+ frappe.model.with_doctype(frm.doc.dt, () => {
+ const child_tables = frappe.meta.get_docfields(frm.doc.dt, null, {
+ fieldtype: 'Table'
+ }).map(df => df.options);
- const d = new frappe.ui.Dialog({
- title: __('Select Child Table'),
- fields: [
- {
- label: __('Select Child Table'),
- fieldtype: 'Link',
- fieldname: 'cdt',
- options: 'DocType',
- get_query: () => {
- return {
- filters: {
- istable: 1,
- name: ['in', child_tables]
- }
- };
+ const d = new frappe.ui.Dialog({
+ title: __('Select Child Table'),
+ fields: [
+ {
+ label: __('Select Child Table'),
+ fieldtype: 'Link',
+ fieldname: 'cdt',
+ options: 'DocType',
+ get_query: () => {
+ return {
+ filters: {
+ istable: 1,
+ name: ['in', child_tables]
+ }
+ };
+ }
}
+ ],
+ primary_action: ({ cdt }) => {
+ cdt = d.get_field('cdt').value;
+ frm.events.add_script_for_doctype(frm, cdt);
+ d.hide();
}
- ],
- primary_action: ({ cdt }) => {
- cdt = d.get_field('cdt').value;
- frm.events.add_script_for_doctype(frm, cdt);
- d.hide();
- }
- });
+ });
- d.show();
+ d.show();
+ });
});
- });
+
+ if (!frm.is_new()) {
+ frm.add_custom_button(__('Compare Versions'), () => {
+ new frappe.ui.DiffView("Client Script", "script", frm.doc.name);
+ });
+ }
+ }
frm.set_query('dt', {
filters: {
@@ -51,6 +62,8 @@ frappe.ui.form.on('Client Script', {
},
dt(frm) {
+ frm.toggle_display('view', !frappe.boot.single_types.includes(frm.doc.dt));
+
if (!frm.doc.script) {
frm.events.add_script_for_doctype(frm, frm.doc.dt);
}
@@ -61,7 +74,18 @@ frappe.ui.form.on('Client Script', {
}
},
+ view(frm) {
+ let has_form_boilerplate = frm.doc.script.includes('frappe.ui.form.on')
+ if (frm.doc.view === 'List' && has_form_boilerplate) {
+ frm.set_value('script', '');
+ }
+ if (frm.doc.view === 'Form' && !has_form_boilerplate) {
+ frm.trigger('dt');
+ }
+ },
+
add_script_for_doctype(frm, doctype) {
+ if (!doctype) return;
let boilerplate = `
frappe.ui.form.on('${doctype}', {
refresh(frm) {
@@ -76,3 +100,56 @@ frappe.ui.form.on('${doctype}', {
frm.set_value('script', script + boilerplate);
}
});
+
+const SAMPLE_HTML = `
Client Script Help
+
Client Scripts are executed only on the client-side (i.e. in Forms). Here are some examples to get you started
+
+
+// fetch local_tax_no on selection of customer
+// cur_frm.add_fetch(link_field, source_fieldname, target_fieldname);
+cur_frm.add_fetch("customer", "local_tax_no', 'local_tax_no');
+
+// additional validation on dates
+frappe.ui.form.on('Task', 'validate', function(frm) {
+ if (frm.doc.from_date < get_today()) {
+ msgprint('You can not select past date in From Date');
+ validated = false;
+ }
+});
+
+// make a field read-only after saving
+frappe.ui.form.on('Task', {
+ refresh: function(frm) {
+ // use the __islocal value of doc, to check if the doc is saved or not
+ frm.set_df_property('myfield', 'read_only', frm.doc.__islocal ? 0 : 1);
+ }
+});
+
+// additional permission check
+frappe.ui.form.on('Task', {
+ validate: function(frm) {
+ if(user=='user1@example.com' && frm.doc.purpose!='Material Receipt') {
+ msgprint('You are only allowed Material Receipt');
+ validated = false;
+ }
+ }
+});
+
+// calculate sales incentive
+frappe.ui.form.on('Sales Invoice', {
+ validate: function(frm) {
+ // calculate incentives for each person on the deal
+ total_incentive = 0
+ $.each(frm.doc.sales_team, function(i, d) {
+ // calculate incentive
+ var incentive_percent = 2;
+ if(frm.doc.base_grand_total > 400) incentive_percent = 4;
+ // actual incentive
+ d.incentives = flt(frm.doc.base_grand_total) * incentive_percent / 100;
+ total_incentive += flt(d.incentives)
+ });
+ frm.doc.total_incentive = total_incentive;
+ }
+})
+
+`;
diff --git a/frappe/custom/doctype/client_script/client_script.json b/frappe/custom/doctype/client_script/client_script.json
index 57e6c68094..eca84b4dec 100644
--- a/frappe/custom/doctype/client_script/client_script.json
+++ b/frappe/custom/doctype/client_script/client_script.json
@@ -8,7 +8,11 @@
"engine": "InnoDB",
"field_order": [
"dt",
+ "view",
+ "column_break_3",
+ "module",
"enabled",
+ "section_break_6",
"script",
"sample"
],
@@ -22,7 +26,8 @@
"oldfieldname": "dt",
"oldfieldtype": "Link",
"options": "DocType",
- "reqd": 1
+ "reqd": 1,
+ "set_only_once": 1
},
{
"fieldname": "script",
@@ -35,21 +40,42 @@
{
"fieldname": "sample",
"fieldtype": "HTML",
- "label": "Sample",
- "options": "
Client Script Help \n
Client Scripts are executed only on the client-side (i.e. in Forms). Here are some examples to get you started
\n
\n\n// fetch local_tax_no on selection of customer \n// cur_frm.add_fetch(link_field, source_fieldname, target_fieldname); \ncur_frm.add_fetch('customer', 'local_tax_no', 'local_tax_no');\n\n// additional validation on dates \nfrappe.ui.form.on('Task', 'validate', function(frm) {\n if (frm.doc.from_date < get_today()) {\n msgprint('You can not select past date in From Date');\n validated = false;\n } \n});\n\n// make a field read-only after saving \nfrappe.ui.form.on('Task', {\n refresh: function(frm) {\n // use the __islocal value of doc, to check if the doc is saved or not\n frm.set_df_property('myfield', 'read_only', frm.doc.__islocal ? 0 : 1);\n } \n});\n\n// additional permission check\nfrappe.ui.form.on('Task', {\n validate: function(frm) {\n if(user=='user1@example.com' && frm.doc.purpose!='Material Receipt') {\n msgprint('You are only allowed Material Receipt');\n validated = false;\n }\n } \n});\n\n// calculate sales incentive\nfrappe.ui.form.on('Sales Invoice', {\n validate: function(frm) {\n // calculate incentives for each person on the deal\n total_incentive = 0\n $.each(frm.doc.sales_team, function(i, d) {\n // calculate incentive\n var incentive_percent = 2;\n if(frm.doc.base_grand_total > 400) incentive_percent = 4;\n // actual incentive\n d.incentives = flt(frm.doc.base_grand_total) * incentive_percent / 100;\n total_incentive += flt(d.incentives)\n });\n frm.doc.total_incentive = total_incentive;\n } \n})\n\n"
+ "label": "Sample"
},
{
"default": "0",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
+ },
+ {
+ "default": "Form",
+ "fieldname": "view",
+ "fieldtype": "Select",
+ "label": "Apply To",
+ "options": "List\nForm",
+ "set_only_once": 1
+ },
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "module",
+ "fieldtype": "Link",
+ "label": "Module (for export)",
+ "options": "Module Def"
+ },
+ {
+ "fieldname": "section_break_6",
+ "fieldtype": "Section Break"
}
],
"icon": "fa fa-glass",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2021-02-04 13:57:56.509437",
+ "modified": "2022-02-18 00:43:33.941466",
"modified_by": "Administrator",
"module": "Custom",
"name": "Client Script",
@@ -80,5 +106,6 @@
],
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/custom/doctype/client_script/client_script.py b/frappe/custom/doctype/client_script/client_script.py
index e252e2a750..fd6bc9accd 100644
--- a/frappe/custom/doctype/client_script/client_script.py
+++ b/frappe/custom/doctype/client_script/client_script.py
@@ -1,17 +1,30 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
+from frappe import _
from frappe.model.document import Document
+
class ClientScript(Document):
def autoname(self):
- self.name = self.dt
+ self.name = f"{self.dt}-{self.view}"
+
+ def validate(self):
+ if not self.is_new():
+ return
+
+ exists = frappe.db.exists(
+ "Client Script", {"dt": self.dt, "view": self.view}
+ )
+ if exists:
+ frappe.throw(
+ _("Client Script for {0} {1} already exists").format(frappe.bold(self.dt), self.view),
+ frappe.DuplicateEntryError,
+ )
def on_update(self):
frappe.clear_cache(doctype=self.dt)
def on_trash(self):
frappe.clear_cache(doctype=self.dt)
-
diff --git a/frappe/custom/doctype/client_script/test_client_script.py b/frappe/custom/doctype/client_script/test_client_script.py
index de113c1ce7..4887956001 100644
--- a/frappe/custom/doctype/client_script/test_client_script.py
+++ b/frappe/custom/doctype/client_script/test_client_script.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/custom/doctype/custom_field/__init__.py b/frappe/custom/doctype/custom_field/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/custom/doctype/custom_field/__init__.py
+++ b/frappe/custom/doctype/custom_field/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json
index 2f0819ab68..f09829a688 100644
--- a/frappe/custom/doctype/custom_field/custom_field.json
+++ b/frappe/custom/doctype/custom_field/custom_field.json
@@ -8,6 +8,7 @@
"engine": "InnoDB",
"field_order": [
"dt",
+ "module",
"label",
"label_help",
"fieldname",
@@ -33,6 +34,7 @@
"non_negative",
"reqd",
"unique",
+ "is_virtual",
"read_only",
"ignore_user_permissions",
"hidden",
@@ -120,7 +122,7 @@
"label": "Field Type",
"oldfieldname": "fieldtype",
"oldfieldtype": "Select",
- "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nGeolocation\nHTML\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature",
+ "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature\nTab Break",
"reqd": 1
},
{
@@ -239,6 +241,12 @@
"fieldtype": "Check",
"label": "Unique"
},
+ {
+ "default": "0",
+ "fieldname": "is_virtual",
+ "fieldtype": "Check",
+ "label": "Is Virtual"
+ },
{
"default": "0",
"fieldname": "read_only",
@@ -411,13 +419,19 @@
"fieldname": "non_negative",
"fieldtype": "Check",
"label": "Non Negative"
+ },
+ {
+ "fieldname": "module",
+ "fieldtype": "Link",
+ "label": "Module (for export)",
+ "options": "Module Def"
}
],
"icon": "fa fa-glass",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-10-29 06:14:43.073329",
+ "modified": "2022-02-14 15:42:21.885999",
"modified_by": "Administrator",
"module": "Custom",
"name": "Custom Field",
@@ -449,5 +463,6 @@
"search_fields": "dt,label,fieldtype,options",
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py
index ee6e3b9c61..cb1ea2c54d 100644
--- a/frappe/custom/doctype/custom_field/custom_field.py
+++ b/frappe/custom/doctype/custom_field/custom_field.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
import json
from frappe.utils import cstr
@@ -9,6 +8,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.model.docfield import supports_translation
from frappe.model import core_doctypes_list
+from frappe.query_builder.functions import IfNull
class CustomField(Document):
def autoname(self):
@@ -19,7 +19,7 @@ class CustomField(Document):
if not self.fieldname:
label = self.label
if not label:
- if self.fieldtype in ["Section Break", "Column Break"]:
+ if self.fieldtype in ["Section Break", "Column Break", "Tab Break"]:
label = self.fieldtype + "_" + str(self.idx)
else:
frappe.throw(_("Label is mandatory"))
@@ -40,6 +40,8 @@ class CustomField(Document):
frappe.throw(_("A field with the name '{}' already exists in doctype {}.").format(self.fieldname, self.dt))
def validate(self):
+ from frappe.custom.doctype.customize_form.customize_form import CustomizeForm
+
meta = frappe.get_meta(self.dt, cached=False)
fieldnames = [df.fieldname for df in meta.get("fields")]
@@ -49,7 +51,11 @@ class CustomField(Document):
if self.insert_after and self.insert_after in fieldnames:
self.idx = fieldnames.index(self.insert_after) + 1
- self._old_fieldtype = self.db_get('fieldtype')
+ old_fieldtype = self.db_get('fieldtype')
+ is_fieldtype_changed = (not self.is_new()) and (old_fieldtype != self.fieldtype)
+
+ if not self.is_virtual and is_fieldtype_changed and not CustomizeForm.allow_fieldtype_change(old_fieldtype, self.fieldtype):
+ frappe.throw(_("Fieldtype cannot be changed from {0} to {1}").format(old_fieldtype, self.fieldtype))
if not self.fieldname:
frappe.throw(_("Fieldname not set for Custom Field"))
@@ -58,18 +64,19 @@ class CustomField(Document):
self.translatable = 0
if not self.flags.ignore_validate:
- from frappe.core.doctype.doctype.doctype import check_if_fieldname_conflicts_with_methods
- check_if_fieldname_conflicts_with_methods(self.dt, self.fieldname)
+ from frappe.core.doctype.doctype.doctype import check_fieldname_conflicts
+ check_fieldname_conflicts(self)
def on_update(self):
- frappe.clear_cache(doctype=self.dt)
+ if not frappe.flags.in_setup_wizard:
+ frappe.clear_cache(doctype=self.dt)
if not self.flags.ignore_validate:
# validate field
from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype
validate_fields_for_doctype(self.dt)
# update the schema
- if not frappe.db.get_value('DocType', self.dt, 'issingle'):
+ if not frappe.db.get_value('DocType', self.dt, 'issingle') and not frappe.flags.in_setup_wizard:
frappe.db.updatedb(self.dt)
def on_trash(self):
@@ -79,12 +86,10 @@ class CustomField(Document):
frappe.bold(self.label)))
# delete property setter entries
- frappe.db.sql("""\
- DELETE FROM `tabProperty Setter`
- WHERE doc_type = %s
- AND field_name = %s""",
- (self.dt, self.fieldname))
-
+ frappe.db.delete("Property Setter", {
+ "doc_type": self.dt,
+ "field_name": self.fieldname
+ })
frappe.clear_cache(doctype=self.dt)
def validate_insert_after(self, meta):
@@ -111,9 +116,7 @@ def get_fields_label(doctype=None):
def create_custom_field_if_values_exist(doctype, df):
df = frappe._dict(df)
if df.fieldname in frappe.db.get_table_columns(doctype) and \
- frappe.db.sql("""select count(*) from `tab{doctype}`
- where ifnull({fieldname},'')!=''""".format(doctype=doctype, fieldname=df.fieldname))[0][0]:
-
+ frappe.db.count(dt=doctype, filters=IfNull(df.fieldname, "") != ""):
create_custom_field(doctype, df)
def create_custom_field(doctype, df, ignore_validate=False):
@@ -127,7 +130,7 @@ def create_custom_field(doctype, df, ignore_validate=False):
"permlevel": 0,
"fieldtype": 'Data',
"hidden": 0,
- # Looks like we always use this programatically?
+ # Looks like we always use this programatically?
# "is_standard": 1
})
custom_field.update(df)
@@ -138,24 +141,37 @@ def create_custom_fields(custom_fields, ignore_validate = False, update=True):
'''Add / update multiple custom fields
:param custom_fields: example `{'Sales Invoice': [dict(fieldname='test')]}`'''
- for doctype, fields in custom_fields.items():
+
+ if not ignore_validate and frappe.flags.in_setup_wizard:
+ ignore_validate = True
+
+ for doctypes, fields in custom_fields.items():
if isinstance(fields, dict):
# only one field
fields = [fields]
- for df in fields:
- field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": df["fieldname"]})
- if not field:
- try:
- df["owner"] = "Administrator"
- create_custom_field(doctype, df, ignore_validate=ignore_validate)
- except frappe.exceptions.DuplicateEntryError:
- pass
- elif update:
- custom_field = frappe.get_doc("Custom Field", field)
- custom_field.flags.ignore_validate = ignore_validate
- custom_field.update(df)
- custom_field.save()
+ if isinstance(doctypes, str):
+ # only one doctype
+ doctypes = (doctypes,)
+
+ for doctype in doctypes:
+ for df in fields:
+ field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": df["fieldname"]})
+ if not field:
+ try:
+ df["owner"] = "Administrator"
+ create_custom_field(doctype, df, ignore_validate=ignore_validate)
+ except frappe.exceptions.DuplicateEntryError:
+ pass
+ elif update:
+ custom_field = frappe.get_doc("Custom Field", field)
+ custom_field.flags.ignore_validate = ignore_validate
+ custom_field.update(df)
+ custom_field.save()
+
+ frappe.clear_cache(doctype=doctype)
+ frappe.db.updatedb(doctype)
+
@frappe.whitelist()
diff --git a/frappe/custom/doctype/custom_field/test_custom_field.js b/frappe/custom/doctype/custom_field/test_custom_field.js
deleted file mode 100644
index 4ca743a395..0000000000
--- a/frappe/custom/doctype/custom_field/test_custom_field.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Custom Field", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Custom Field
- () => frappe.tests.make('Custom Field', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/custom/doctype/custom_field/test_custom_field.py b/frappe/custom/doctype/custom_field/test_custom_field.py
index 819917050a..ad3cf27eea 100644
--- a/frappe/custom/doctype/custom_field/test_custom_field.py
+++ b/frappe/custom/doctype/custom_field/test_custom_field.py
@@ -1,14 +1,47 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
import unittest
-test_records = frappe.get_test_records('Custom Field')
+test_records = frappe.get_test_records("Custom Field")
+
class TestCustomField(unittest.TestCase):
- pass
+ def test_create_custom_fields(self):
+ from .custom_field import create_custom_fields
+
+ create_custom_fields(
+ {
+ "Address": [
+ {
+ "fieldname": "_test_custom_field_1",
+ "label": "_Test Custom Field 1",
+ "fieldtype": "Data",
+ "insert_after": "phone",
+ },
+ ],
+ ("Address", "Contact"): [
+ {
+ "fieldname": "_test_custom_field_2",
+ "label": "_Test Custom Field 2",
+ "fieldtype": "Data",
+ "insert_after": "phone",
+ },
+ ],
+ }
+ )
+
+ frappe.db.commit()
+
+ self.assertTrue(
+ frappe.db.exists("Custom Field", "Address-_test_custom_field_1")
+ )
+ self.assertTrue(
+ frappe.db.exists("Custom Field", "Address-_test_custom_field_2")
+ )
+ self.assertTrue(
+ frappe.db.exists("Custom Field", "Contact-_test_custom_field_2")
+ )
diff --git a/frappe/custom/doctype/customize_form/__init__.py b/frappe/custom/doctype/customize_form/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/custom/doctype/customize_form/__init__.py
+++ b/frappe/custom/doctype/customize_form/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js
index 79978a49d7..4862185b99 100644
--- a/frappe/custom/doctype/customize_form/customize_form.js
+++ b/frappe/custom/doctype/customize_form/customize_form.js
@@ -76,6 +76,7 @@ frappe.ui.form.on("Customize Form", {
frm.trigger("setup_sortable");
}
}
+ localStorage["customize_doctype"] = frm.doc.doc_type;
}
});
} else {
@@ -113,10 +114,11 @@ frappe.ui.form.on("Customize Form", {
frm.page.clear_icons();
if (frm.doc.doc_type) {
+ frm.page.set_title(__('Customize Form - {0}', [frm.doc.doc_type]));
frappe.customize_form.set_primary_action(frm);
frm.add_custom_button(
- __("Go to {0} List", [frm.doc.doc_type]),
+ __("Go to {0} List", [__(frm.doc.doc_type)]),
function() {
frappe.set_route("List", frm.doc.doc_type);
},
@@ -275,6 +277,21 @@ frappe.ui.form.on("DocType Action", {
}
});
+// can't delete standard states
+frappe.ui.form.on("DocType State", {
+ before_states_remove: function(frm, doctype, name) {
+ let row = frappe.get_doc(doctype, name);
+ if (!(row.custom || row.__islocal)) {
+ frappe.msgprint(__("Cannot delete standard document state."));
+ throw "cannot delete standard document state";
+ }
+ },
+ states_add: function(frm, cdt, cdn) {
+ let f = frappe.model.get_doc(cdt, cdn);
+ f.custom = 1;
+ }
+});
+
frappe.customize_form.set_primary_action = function(frm) {
frm.page.set_primary_action(__("Update"), function() {
if (frm.doc.doc_type) {
@@ -331,3 +348,4 @@ frappe.customize_form.clear_locals_and_refresh = function(frm) {
frm.refresh();
}
+extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({frm: cur_frm}));
diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json
index ff102b3c08..1ee9d4a02a 100644
--- a/frappe/custom/doctype/customize_form/customize_form.json
+++ b/frappe/custom/doctype/customize_form/customize_form.json
@@ -23,14 +23,18 @@
"allow_import",
"fields_section_break",
"fields",
+ "naming_section",
+ "autoname",
"view_settings_section",
"title_field",
+ "show_title_field_in_link",
"image_field",
"default_print_format",
"column_break_29",
"show_preview_popup",
- "image_view",
"email_settings_section",
+ "default_email_template",
+ "column_break_26",
"email_append_to",
"sender_field",
"subject_field",
@@ -38,6 +42,8 @@
"actions",
"document_links_section",
"links",
+ "document_states_section",
+ "states",
"section_break_8",
"sort_field",
"column_break_10",
@@ -105,13 +111,6 @@
"fieldtype": "Check",
"label": "Track Changes"
},
- {
- "default": "0",
- "depends_on": "eval: doc.image_field",
- "fieldname": "image_view",
- "fieldtype": "Check",
- "label": "Image View"
- },
{
"fieldname": "column_break_5",
"fieldtype": "Column Break"
@@ -261,6 +260,49 @@
"fieldtype": "Table",
"label": "Actions",
"options": "DocType Action"
+ },
+ {
+ "fieldname": "default_email_template",
+ "fieldtype": "Link",
+ "label": "Default Email Template",
+ "options": "Email Template"
+ },
+ {
+ "fieldname": "column_break_26",
+ "fieldtype": "Column Break"
+ },
+ {
+ "collapsible": 1,
+ "depends_on": "doc_type",
+ "fieldname": "naming_section",
+ "fieldtype": "Section Break",
+ "label": "Naming"
+ },
+ {
+ "description": "Naming Options:\n
field:[fieldname] - By Fieldnaming_series: - By Naming Series (field called naming_series must be presentPrompt - Prompt user for a name[series] - Series by prefix (separated by a dot); for example PRE.##### \nformat:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.",
+ "fieldname": "autoname",
+ "fieldtype": "Data",
+ "label": "Auto Name"
+ },
+ {
+ "collapsible": 1,
+ "collapsible_depends_on": "states",
+ "depends_on": "doc_type",
+ "fieldname": "document_states_section",
+ "fieldtype": "Section Break",
+ "label": "Document States"
+ },
+ {
+ "fieldname": "states",
+ "fieldtype": "Table",
+ "label": "States",
+ "options": "DocType State"
+ },
+ {
+ "default": "0",
+ "fieldname": "show_title_field_in_link",
+ "fieldtype": "Check",
+ "label": "Show Title in Link Fields"
}
],
"hide_toolbar": 1,
@@ -269,10 +311,11 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2020-09-24 14:16:49.594012",
+ "modified": "2022-01-07 16:07:06.196534",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",
+ "naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -289,5 +332,6 @@
"search_fields": "doc_type",
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py
index 50acab46b5..81cd38ff87 100644
--- a/frappe/custom/doctype/customize_form/customize_form.py
+++ b/frappe/custom/doctype/customize_form/customize_form.py
@@ -1,7 +1,6 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See LICENSE
-from __future__ import unicode_literals
"""
Customize Form is a Single DocType used to mask the Property Setter
Thus providing a better UI from user perspective
@@ -17,12 +16,15 @@ from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype, che
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter
from frappe.model.docfield import supports_translation
+from frappe.core.doctype.doctype.doctype import validate_series
+
class CustomizeForm(Document):
def on_update(self):
- frappe.db.sql("delete from tabSingles where doctype='Customize Form'")
- frappe.db.sql("delete from `tabCustomize Form Field`")
+ frappe.db.delete("Singles", {"doctype": "Customize Form"})
+ frappe.db.delete("Customize Form Field")
+ @frappe.whitelist()
def fetch_to_customize(self):
self.clear_existing_doc()
if not self.doc_type:
@@ -70,7 +72,7 @@ class CustomizeForm(Document):
new_d[prop] = d.get(prop)
self.append("fields", new_d)
- for fieldname in ('links', 'actions'):
+ for fieldname in ('links', 'actions', 'states'):
for d in meta.get(fieldname):
self.append(fieldname, d)
@@ -105,20 +107,26 @@ class CustomizeForm(Document):
def set_name_translation(self):
'''Create, update custom translation for this doctype'''
current = self.get_name_translation()
- if current:
- if self.label and current.translated_text != self.label:
- frappe.db.set_value('Translation', current.name, 'translated_text', self.label)
- frappe.translate.clear_cache()
- else:
+ if not self.label:
+ if current:
# clear translation
frappe.delete_doc('Translation', current.name)
+ return
- else:
- if self.label:
- frappe.get_doc(dict(doctype='Translation',
- source_text=self.doc_type,
- translated_text=self.label,
- language_code=frappe.local.lang or 'en')).insert()
+ if not current:
+ frappe.get_doc(
+ {
+ "doctype": 'Translation',
+ "source_text": self.doc_type,
+ "translated_text": self.label,
+ "language_code": frappe.local.lang or 'en'
+ }
+ ).insert()
+ return
+
+ if self.label != current.translated_text:
+ frappe.db.set_value('Translation', current.name, 'translated_text', self.label)
+ frappe.translate.clear_cache()
def clear_existing_doc(self):
doc_type = self.doc_type
@@ -132,10 +140,11 @@ class CustomizeForm(Document):
self.doc_type = doc_type
self.name = "Customize Form"
+ @frappe.whitelist()
def save_customization(self):
if not self.doc_type:
return
-
+ validate_series(self, self.autoname, self.doc_type)
self.flags.update_db = False
self.flags.rebuild_doctype_for_global_search = False
self.set_property_setters()
@@ -190,6 +199,16 @@ class CustomizeForm(Document):
if prop == "fieldtype":
self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop))
+ elif prop == "length":
+ old_value_length = cint(meta_df[0].get(prop))
+ new_value_length = cint(df.get(prop))
+
+ if new_value_length and (old_value_length > new_value_length):
+ self.check_length_for_fieldtypes.append({'df': df, 'old_value': meta_df[0].get(prop)})
+ self.validate_fieldtype_length()
+ else:
+ self.flags.update_db = True
+
elif prop == "allow_on_submit" and df.get(prop):
if not frappe.db.get_value("DocField",
{"parent": self.doc_type, "fieldname": df.fieldname}, "allow_on_submit"):
@@ -245,7 +264,8 @@ class CustomizeForm(Document):
'''
for doctype, fieldname, field_map in (
('DocType Link', 'links', doctype_link_properties),
- ('DocType Action', 'actions', doctype_action_properties)
+ ('DocType Action', 'actions', doctype_action_properties),
+ ('DocType State', 'states', doctype_state_properties),
):
has_custom = False
items = []
@@ -353,9 +373,9 @@ class CustomizeForm(Document):
def delete_custom_fields(self):
meta = frappe.get_meta(self.doc_type)
- fields_to_remove = (set([df.fieldname for df in meta.get("fields")])
- - set(df.fieldname for df in self.get("fields")))
-
+ fields_to_remove = (
+ {df.fieldname for df in meta.get("fields")} - {df.fieldname for df in self.get("fields")}
+ )
for fieldname in fields_to_remove:
df = meta.get("fields", {"fieldname": fieldname})[0]
if df.get("is_custom_field"):
@@ -363,7 +383,7 @@ class CustomizeForm(Document):
def make_property_setter(self, prop, value, property_type, fieldname=None,
apply_on=None, row_name = None):
- delete_property_setter(self.doc_type, prop, fieldname)
+ delete_property_setter(self.doc_type, prop, fieldname, row_name)
property_value = self.get_existing_property_value(prop, fieldname)
@@ -398,23 +418,23 @@ class CustomizeForm(Document):
return property_value
def validate_fieldtype_change(self, df, old_value, new_value):
- allowed = False
- self.check_length_for_fieldtypes = []
- for allowed_changes in ALLOWED_FIELDTYPE_CHANGE:
- if (old_value in allowed_changes and new_value in allowed_changes):
- allowed = True
- old_value_length = cint(frappe.db.type_map.get(old_value)[1])
- new_value_length = cint(frappe.db.type_map.get(new_value)[1])
+ if df.is_virtual:
+ return
- # Ignore fieldtype check validation if new field type has unspecified maxlength
- # Changes like DATA to TEXT, where new_value_lenth equals 0 will not be validated
- if new_value_length and (old_value_length > new_value_length):
- self.check_length_for_fieldtypes.append({'df': df, 'old_value': old_value})
- self.validate_fieldtype_length()
- else:
- self.flags.update_db = True
- break
- if not allowed:
+ allowed = self.allow_fieldtype_change(old_value, new_value)
+ if allowed:
+ old_value_length = cint(frappe.db.type_map.get(old_value)[1])
+ new_value_length = cint(frappe.db.type_map.get(new_value)[1])
+
+ # Ignore fieldtype check validation if new field type has unspecified maxlength
+ # Changes like DATA to TEXT, where new_value_lenth equals 0 will not be validated
+ if new_value_length and (old_value_length > new_value_length):
+ self.check_length_for_fieldtypes.append({'df': df, 'old_value': old_value})
+ self.validate_fieldtype_length()
+ else:
+ self.flags.update_db = True
+
+ else:
frappe.throw(_("Fieldtype cannot be changed from {0} to {1} in row {2}").format(old_value, new_value, df.idx))
def validate_fieldtype_length(self):
@@ -447,6 +467,7 @@ class CustomizeForm(Document):
self.flags.update_db = True
+ @frappe.whitelist()
def reset_to_defaults(self):
if not self.doc_type:
return
@@ -454,6 +475,14 @@ class CustomizeForm(Document):
reset_customization(self.doc_type)
self.fetch_to_customize()
+ @classmethod
+ def allow_fieldtype_change(self, old_type: str, new_type: str) -> bool:
+ """ allow type change, if both old_type and new_type are in same field group.
+ field groups are defined in ALLOWED_FIELDTYPE_CHANGE variables.
+ """
+ in_field_group = lambda group: (old_type in group) and (new_type in group)
+ return any(map(in_field_group, ALLOWED_FIELDTYPE_CHANGE))
+
def reset_customization(doctype):
setters = frappe.get_all("Property Setter", filters={
'doc_type': doctype,
@@ -483,9 +512,12 @@ doctype_properties = {
'allow_auto_repeat': 'Check',
'allow_import': 'Check',
'show_preview_popup': 'Check',
+ 'default_email_template': 'Data',
'email_append_to': 'Check',
'subject_field': 'Data',
- 'sender_field': 'Data'
+ 'sender_field': 'Data',
+ 'autoname': 'Data',
+ 'show_title_field_in_link': 'Check'
}
docfield_properties = {
@@ -495,6 +527,7 @@ docfield_properties = {
'options': 'Text',
'fetch_from': 'Small Text',
'fetch_if_empty': 'Check',
+ 'show_dashboard': 'Check',
'permlevel': 'Int',
'width': 'Data',
'print_width': 'Data',
@@ -507,6 +540,7 @@ docfield_properties = {
'in_global_search': 'Check',
'in_preview': 'Check',
'bold': 'Check',
+ 'no_copy': 'Check',
'hidden': 'Check',
'collapsible': 'Check',
'collapsible_depends_on': 'Data',
@@ -530,7 +564,8 @@ docfield_properties = {
'allow_in_quick_entry': 'Check',
'hide_border': 'Check',
'hide_days': 'Check',
- 'hide_seconds': 'Check'
+ 'hide_seconds': 'Check',
+ 'is_virtual': 'Check',
}
doctype_link_properties = {
@@ -548,6 +583,11 @@ doctype_action_properties = {
'hidden': 'Check'
}
+doctype_state_properties = {
+ 'title': 'Data',
+ 'color': 'Select'
+}
+
ALLOWED_FIELDTYPE_CHANGE = (
('Currency', 'Float', 'Percent'),
@@ -560,4 +600,4 @@ ALLOWED_FIELDTYPE_CHANGE = (
('Code', 'Geolocation'),
('Table', 'Table MultiSelect'))
-ALLOWED_OPTIONS_CHANGE = ('Read Only', 'HTML', 'Select', 'Data')
+ALLOWED_OPTIONS_CHANGE = ('Read Only', 'HTML', 'Data')
diff --git a/frappe/custom/doctype/customize_form/test_customize_form.js b/frappe/custom/doctype/customize_form/test_customize_form.js
deleted file mode 100644
index d37afa5580..0000000000
--- a/frappe/custom/doctype/customize_form/test_customize_form.js
+++ /dev/null
@@ -1,44 +0,0 @@
-// try and delete a standard row, it should fail
-
-QUnit.module('Customize Form');
-
-QUnit.test("test customize form", function(assert) {
- assert.expect(2);
- let done = assert.async();
- frappe.run_serially([
- () => frappe.set_route('Form', 'Customize Form'),
- () => frappe.timeout(1),
- () => cur_frm.set_value('doc_type', 'ToDo'),
- () => frappe.timeout(2),
- () => {
- // find the status column as there may be other custom fields like
- // kanban etc.
- frappe.row_idx = 0;
- cur_frm.doc.fields.every((d, i) => {
- if(d.fieldname==='status') {
- frappe.row_idx = i;
- return false;
- } else {
- return true;
- }
- });
- assert.equal(cur_frm.doc.fields[frappe.row_idx].fieldname, 'status',
- 'check if selected field is "status"');
- },
- // open "status" row
- () => cur_frm.fields_dict.fields.grid.grid_rows[frappe.row_idx].toggle_view(),
- () => frappe.timeout(0.5),
-
- // try deleting it
- () => $('.grid-delete-row:visible').click(),
-
- () => frappe.timeout(0.5),
- () => frappe.hide_msgprint(),
- () => frappe.timeout(0.5),
-
- // status still exists
- () => assert.equal(cur_frm.doc.fields[frappe.row_idx].fieldname, 'status',
- 'check if selected field is still "status"'),
- () => done()
- ]);
-});
diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py
index f5e0371c1f..2cae69ca21 100644
--- a/frappe/custom/doctype/customize_form/test_customize_form.py
+++ b/frappe/custom/doctype/customize_form/test_customize_form.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe, unittest, json
from frappe.test_runner import make_test_records_for_doctype
from frappe.core.doctype.doctype.doctype import InvalidFieldNameError
@@ -47,64 +46,69 @@ class TestCustomizeForm(unittest.TestCase):
self.assertEqual(len(d.get("fields")), 0)
d = self.get_customize_form("Event")
- self.assertEquals(d.doc_type, "Event")
- self.assertEquals(len(d.get("fields")), 36)
+ self.assertEqual(d.doc_type, "Event")
+ self.assertEqual(len(d.get("fields")), 36)
d = self.get_customize_form("Event")
- self.assertEquals(d.doc_type, "Event")
+ self.assertEqual(d.doc_type, "Event")
self.assertEqual(len(d.get("fields")),
len(frappe.get_doc("DocType", d.doc_type).fields) + 1)
- self.assertEquals(d.get("fields")[-1].fieldname, "test_custom_field")
- self.assertEquals(d.get("fields", {"fieldname": "event_type"})[0].in_list_view, 1)
+ self.assertEqual(d.get("fields")[-1].fieldname, "test_custom_field")
+ self.assertEqual(d.get("fields", {"fieldname": "event_type"})[0].in_list_view, 1)
return d
def test_save_customization_property(self):
d = self.get_customize_form("Event")
- self.assertEquals(frappe.db.get_value("Property Setter",
+ self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "allow_copy"}, "value"), None)
d.allow_copy = 1
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Property Setter",
+ self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "allow_copy"}, "value"), '1')
d.allow_copy = 0
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Property Setter",
+ self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "allow_copy"}, "value"), None)
def test_save_customization_field_property(self):
d = self.get_customize_form("Event")
- self.assertEquals(frappe.db.get_value("Property Setter",
+ self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "reqd", "field_name": "repeat_this_event"}, "value"), None)
repeat_this_event_field = d.get("fields", {"fieldname": "repeat_this_event"})[0]
repeat_this_event_field.reqd = 1
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Property Setter",
+ self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "reqd", "field_name": "repeat_this_event"}, "value"), '1')
repeat_this_event_field = d.get("fields", {"fieldname": "repeat_this_event"})[0]
repeat_this_event_field.reqd = 0
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Property Setter",
+ self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "reqd", "field_name": "repeat_this_event"}, "value"), None)
def test_save_customization_custom_field_property(self):
d = self.get_customize_form("Event")
- self.assertEquals(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
+ self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
custom_field = d.get("fields", {"fieldname": "test_custom_field"})[0]
custom_field.reqd = 1
+ custom_field.no_copy = 1
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 1)
+ self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 1)
+ self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "no_copy"), 1)
custom_field = d.get("fields", {"is_custom_field": True})[0]
custom_field.reqd = 0
+ custom_field.no_copy = 0
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
+ self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
+ self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "no_copy"), 0)
+
def test_save_customization_new_field(self):
d = self.get_customize_form("Event")
@@ -115,14 +119,14 @@ class TestCustomizeForm(unittest.TestCase):
"is_custom_field": 1
})
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Custom Field",
+ self.assertEqual(frappe.db.get_value("Custom Field",
"Event-test_add_custom_field_via_customize_form", "fieldtype"), "Data")
- self.assertEquals(frappe.db.get_value("Custom Field",
+ self.assertEqual(frappe.db.get_value("Custom Field",
"Event-test_add_custom_field_via_customize_form", 'insert_after'), last_fieldname)
frappe.delete_doc("Custom Field", "Event-test_add_custom_field_via_customize_form")
- self.assertEquals(frappe.db.get_value("Custom Field",
+ self.assertEqual(frappe.db.get_value("Custom Field",
"Event-test_add_custom_field_via_customize_form"), None)
@@ -142,7 +146,7 @@ class TestCustomizeForm(unittest.TestCase):
d.doc_type = "Event"
d.run_method('reset_to_defaults')
- self.assertEquals(d.get("fields", {"fieldname": "repeat_this_event"})[0].in_list_view, 0)
+ self.assertEqual(d.get("fields", {"fieldname": "repeat_this_event"})[0].in_list_view, 0)
frappe.local.test_objects["Property Setter"] = []
make_test_records_for_doctype("Property Setter")
@@ -156,7 +160,7 @@ class TestCustomizeForm(unittest.TestCase):
d = self.get_customize_form("Event")
# don't allow for standard fields
- self.assertEquals(d.get("fields", {"fieldname": "subject"})[0].allow_on_submit or 0, 0)
+ self.assertEqual(d.get("fields", {"fieldname": "subject"})[0].allow_on_submit or 0, 0)
# allow for custom field
self.assertEqual(d.get("fields", {"fieldname": "test_custom_field"})[0].allow_on_submit, 1)
@@ -189,6 +193,26 @@ class TestCustomizeForm(unittest.TestCase):
def test_core_doctype_customization(self):
self.assertRaises(frappe.ValidationError, self.get_customize_form, 'User')
+ def test_save_customization_length_field_property(self):
+ # Using Notification Log doctype as it doesn't have any other custom fields
+ d = self.get_customize_form("Notification Log")
+
+ document_name = d.get("fields", {"fieldname": "document_name"})[0]
+ document_name.length = 255
+ d.run_method("save_customization")
+
+ self.assertEqual(frappe.db.get_value("Property Setter",
+ {"doc_type": "Notification Log", "property": "length", "field_name": "document_name"}, "value"), '255')
+
+ self.assertTrue(d.flags.update_db)
+
+ length = frappe.db.sql("""SELECT character_maximum_length
+ FROM information_schema.columns
+ WHERE table_name = 'tabNotification Log'
+ AND column_name = 'document_name'""")[0][0]
+
+ self.assertEqual(length, 255)
+
def test_custom_link(self):
try:
# create a dummy doctype linked to Event
@@ -233,6 +257,32 @@ class TestCustomizeForm(unittest.TestCase):
testdt.delete()
testdt1.delete()
+ def test_custom_internal_links(self):
+ # add a custom internal link
+ frappe.clear_cache()
+ d = self.get_customize_form("User Group")
+
+ d.append('links', dict(link_doctype='User Group Member', parent_doctype='User Group',
+ link_fieldname='user', table_fieldname='user_group_members', group='Tests', custom=1))
+
+ d.run_method("save_customization")
+
+ frappe.clear_cache()
+ user_group = frappe.get_meta('User Group')
+
+ # check links exist
+ self.assertTrue([d.name for d in user_group.links if d.link_doctype == 'User Group Member'])
+ self.assertTrue([d.name for d in user_group.links if d.parent_doctype == 'User Group'])
+
+ # remove the link
+ d = self.get_customize_form("User Group")
+ d.links = []
+ d.run_method("save_customization")
+
+ frappe.clear_cache()
+ user_group = frappe.get_meta('Event')
+ self.assertFalse([d.name for d in (user_group.links or []) if d.link_doctype == 'User Group Member'])
+
def test_custom_action(self):
test_route = '/app/List/DocType'
@@ -259,3 +309,25 @@ class TestCustomizeForm(unittest.TestCase):
action = [d for d in event.actions if d.label=='Test Action']
self.assertEqual(len(action), 0)
+
+ def test_custom_label(self):
+ d = self.get_customize_form("Event")
+
+ # add label
+ d.label = "Test Rename"
+ d.run_method("save_customization")
+ self.assertEqual(d.label, "Test Rename")
+
+ # change label
+ d.label = "Test Rename 2"
+ d.run_method("save_customization")
+ self.assertEqual(d.label, "Test Rename 2")
+
+ # saving again to make sure existing label persists
+ d.run_method("save_customization")
+ self.assertEqual(d.label, "Test Rename 2")
+
+ # clear label
+ d.label = ""
+ d.run_method("save_customization")
+ self.assertEqual(d.label, "")
diff --git a/frappe/custom/doctype/customize_form_field/__init__.py b/frappe/custom/doctype/customize_form_field/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/custom/doctype/customize_form_field/__init__.py
+++ b/frappe/custom/doctype/customize_form_field/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json
index 227114137c..1cc4c9f623 100644
--- a/frappe/custom/doctype/customize_form_field/customize_form_field.json
+++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json
@@ -14,11 +14,13 @@
"non_negative",
"reqd",
"unique",
+ "is_virtual",
"in_list_view",
"in_standard_filter",
"in_global_search",
"in_preview",
"bold",
+ "no_copy",
"allow_in_quick_entry",
"translatable",
"column_break_7",
@@ -28,6 +30,7 @@
"options",
"fetch_from",
"fetch_if_empty",
+ "show_dashboard",
"permissions",
"depends_on",
"permlevel",
@@ -82,7 +85,7 @@
"label": "Type",
"oldfieldname": "fieldtype",
"oldfieldtype": "Select",
- "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime",
+ "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime",
"reqd": 1,
"search_index": 1
},
@@ -114,6 +117,12 @@
"fieldtype": "Check",
"label": "Unique"
},
+ {
+ "default": "0",
+ "fieldname": "is_virtual",
+ "fieldtype": "Check",
+ "label": "Is Virtual"
+ },
{
"default": "0",
"fieldname": "in_list_view",
@@ -422,18 +431,33 @@
"fieldname": "non_negative",
"fieldtype": "Check",
"label": "Non Negative"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:doc.fieldtype=='Tab Break'",
+ "fieldname": "show_dashboard",
+ "fieldtype": "Check",
+ "label": "Show Dashboard"
+ },
+ {
+ "default": "0",
+ "fieldname": "no_copy",
+ "fieldtype": "Check",
+ "label": "No Copy"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2020-10-29 06:11:57.661039",
+ "modified": "2022-02-25 16:01:12.616736",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form Field",
+ "naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
- "sort_order": "ASC"
+ "sort_order": "ASC",
+ "states": []
}
\ No newline at end of file
diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.py b/frappe/custom/doctype/customize_form_field/customize_form_field.py
index 20c206328c..67563cf048 100644
--- a/frappe/custom/doctype/customize_form_field/customize_form_field.py
+++ b/frappe/custom/doctype/customize_form_field/customize_form_field.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.js b/frappe/custom/doctype/doctype_layout/doctype_layout.js
index 679330e065..533efea9b8 100644
--- a/frappe/custom/doctype/doctype_layout/doctype_layout.js
+++ b/frappe/custom/doctype/doctype_layout/doctype_layout.js
@@ -23,7 +23,7 @@ frappe.ui.form.on('DocType Layout', {
set_button(frm) {
if (!frm.is_new()) {
frm.add_custom_button(__('Go to {0} List', [frm.doc.name]), () => {
- window.open(`/app/list/${frappe.router.slug(frm.doc.name)}/list`);
+ window.open(`/app/${frappe.router.slug(frm.doc.name)}`);
});
}
}
diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.py b/frappe/custom/doctype/doctype_layout/doctype_layout.py
index a4fe9a9bce..fa285ddb62 100644
--- a/frappe/custom/doctype/doctype_layout/doctype_layout.py
+++ b/frappe/custom/doctype/doctype_layout/doctype_layout.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
from frappe.model.document import Document
diff --git a/frappe/custom/doctype/doctype_layout/test_doctype_layout.py b/frappe/custom/doctype/doctype_layout/test_doctype_layout.py
index 5765c86262..a63dd7ee16 100644
--- a/frappe/custom/doctype/doctype_layout/test_doctype_layout.py
+++ b/frappe/custom/doctype/doctype_layout/test_doctype_layout.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.json b/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.json
index a1a36216c3..006c01ae4e 100644
--- a/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.json
+++ b/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.json
@@ -20,14 +20,13 @@
"fieldname": "label",
"fieldtype": "Data",
"in_list_view": 1,
- "label": "Label",
- "reqd": 1
+ "label": "Label"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2020-11-16 17:13:01.892345",
+ "modified": "2021-05-19 16:27:40.585865",
"modified_by": "Administrator",
"module": "Custom",
"name": "DocType Layout Field",
@@ -36,4 +35,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.py b/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.py
index 7f8c8edfce..3f8487b659 100644
--- a/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.py
+++ b/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/custom/doctype/property_setter/__init__.py b/frappe/custom/doctype/property_setter/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/custom/doctype/property_setter/__init__.py
+++ b/frappe/custom/doctype/property_setter/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/custom/doctype/property_setter/property_setter.json b/frappe/custom/doctype/property_setter/property_setter.json
index b318d92c5a..9707f1ee1c 100644
--- a/frappe/custom/doctype/property_setter/property_setter.json
+++ b/frappe/custom/doctype/property_setter/property_setter.json
@@ -13,6 +13,8 @@
"field_name",
"row_name",
"column_break0",
+ "module",
+ "section_break_9",
"property",
"property_type",
"value",
@@ -35,7 +37,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Applied On",
- "options": "\nDocField\nDocType\nDocType Link\nDocType Action",
+ "options": "\nDocField\nDocType\nDocType Link\nDocType Action\nDocType State",
"read_only_depends_on": "eval:!doc.__islocal",
"reqd": 1
},
@@ -91,13 +93,23 @@
"fieldname": "row_name",
"fieldtype": "Data",
"label": "Row Name"
+ },
+ {
+ "fieldname": "module",
+ "fieldtype": "Link",
+ "label": "Module (for export)",
+ "options": "Module Def"
+ },
+ {
+ "fieldname": "section_break_9",
+ "fieldtype": "Section Break"
}
],
"icon": "fa fa-glass",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-09-24 14:42:38.599684",
+ "modified": "2021-12-14 14:15:41.929071",
"modified_by": "Administrator",
"module": "Custom",
"name": "Property Setter",
@@ -129,5 +141,6 @@
"search_fields": "doc_type,property",
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/custom/doctype/property_setter/property_setter.py b/frappe/custom/doctype/property_setter/property_setter.py
index 56e5829271..a86cf5efd6 100644
--- a/frappe/custom/doctype/property_setter/property_setter.py
+++ b/frappe/custom/doctype/property_setter/property_setter.py
@@ -1,7 +1,6 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
@@ -19,45 +18,19 @@ class PropertySetter(Document):
def validate(self):
self.validate_fieldtype_change()
- if self.is_new():
- delete_property_setter(self.doc_type, self.property, self.field_name)
- # clear cache
+ if self.is_new():
+ delete_property_setter(self.doc_type, self.property, self.field_name, self.row_name)
frappe.clear_cache(doctype = self.doc_type)
def validate_fieldtype_change(self):
- if self.field_name in not_allowed_fieldtype_change and \
- self.property == 'fieldtype':
- frappe.throw(_("Field type cannot be changed for {0}").format(self.field_name))
-
- def get_property_list(self, dt):
- return frappe.db.get_all('DocField',
- fields=['fieldname', 'label', 'fieldtype'],
- filters={
- 'parent': dt,
- 'fieldtype': ['not in', ('Section Break', 'Column Break', 'HTML', 'Read Only', 'Fold') + frappe.model.table_fields],
- 'fieldname': ['!=', '']
- },
- order_by='label asc',
- as_dict=1
- )
-
- def get_setup_data(self):
- return {
- 'doctypes': [d[0] for d in frappe.db.sql("select name from tabDocType")],
- 'dt_properties': self.get_property_list('DocType'),
- 'df_properties': self.get_property_list('DocField')
- }
-
- def get_field_ids(self):
- return frappe.db.sql("select name, fieldtype, label, fieldname from tabDocField where parent=%s", self.doc_type, as_dict = 1)
-
- def get_defaults(self):
- if not self.field_name:
- return frappe.db.sql("select * from `tabDocType` where name=%s", self.doc_type, as_dict = 1)[0]
- else:
- return frappe.db.sql("select * from `tabDocField` where fieldname=%s and parent=%s",
- (self.field_name, self.doc_type), as_dict = 1)[0]
+ if (
+ self.property == 'fieldtype'
+ and self.field_name in not_allowed_fieldtype_change
+ ):
+ frappe.throw(
+ _("Field type cannot be changed for {0}").format(self.field_name)
+ )
def on_update(self):
if frappe.flags.in_patch:
@@ -67,6 +40,7 @@ class PropertySetter(Document):
from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype
validate_fields_for_doctype(self.doc_type)
+
def make_property_setter(doctype, fieldname, property, value, property_type, for_doctype = False,
validate_fields_for_doctype=True):
# WARNING: Ignores Permissions
@@ -84,11 +58,13 @@ def make_property_setter(doctype, fieldname, property, value, property_type, for
property_setter.insert()
return property_setter
-def delete_property_setter(doc_type, property, field_name=None):
+
+def delete_property_setter(doc_type, property, field_name=None, row_name=None):
"""delete other property setters on this, if this is new"""
- filters = dict(doc_type = doc_type, property=property)
+ filters = dict(doc_type=doc_type, property=property)
if field_name:
filters['field_name'] = field_name
+ if row_name:
+ filters["row_name"] = row_name
frappe.db.delete('Property Setter', filters)
-
diff --git a/frappe/custom/doctype/property_setter/test_property_setter.py b/frappe/custom/doctype/property_setter/test_property_setter.py
index 33e7d288a4..1bbbe59a0f 100644
--- a/frappe/custom/doctype/property_setter/test_property_setter.py
+++ b/frappe/custom/doctype/property_setter/test_property_setter.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/custom/doctype/test_rename_new/test_rename_new.py b/frappe/custom/doctype/test_rename_new/test_rename_new.py
index aa5984e466..fc4ab97cfe 100644
--- a/frappe/custom/doctype/test_rename_new/test_rename_new.py
+++ b/frappe/custom/doctype/test_rename_new/test_rename_new.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/custom/doctype/test_rename_new/test_test_rename_new.py b/frappe/custom/doctype/test_rename_new/test_test_rename_new.py
index 554efbae45..03202669ed 100644
--- a/frappe/custom/doctype/test_rename_new/test_test_rename_new.py
+++ b/frappe/custom/doctype/test_rename_new/test_test_rename_new.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/custom/fixtures/temp_doctype.json b/frappe/custom/fixtures/temp_doctype.json
new file mode 100644
index 0000000000..343aa2cb37
--- /dev/null
+++ b/frappe/custom/fixtures/temp_doctype.json
@@ -0,0 +1,168 @@
+{
+ "docstatus": 0,
+ "doctype": "DocType",
+ "name": "new-doctype-2",
+ "__islocal": 1,
+ "__unsaved": 1,
+ "owner": "Administrator",
+ "is_submittable": 0,
+ "istable": 0,
+ "issingle": 0,
+ "is_tree": 0,
+ "editable_grid": 1,
+ "quick_entry": 1,
+ "track_changes": 1,
+ "track_seen": 0,
+ "track_views": 0,
+ "custom": 1,
+ "beta": 0,
+ "is_virtual": 0,
+ "naming_rule": "",
+ "name_case": "",
+ "allow_rename": 1,
+ "hide_toolbar": 0,
+ "allow_copy": 0,
+ "allow_import": 0,
+ "allow_events_in_timeline": 0,
+ "allow_auto_repeat": 0,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "document_type": "",
+ "show_preview_popup": 0,
+ "show_name_in_global_search": 0,
+ "email_append_to": 0,
+ "read_only": 0,
+ "in_create": 0,
+ "has_web_view": 0,
+ "allow_guest_to_view": 0,
+ "index_web_pages_for_search": 1,
+ "engine": "InnoDB",
+ "permissions": [
+ {
+ "docstatus": 0,
+ "doctype": "DocPerm",
+ "name": "new-docperm-2",
+ "__islocal": 1,
+ "__unsaved": 1,
+ "owner": "Administrator",
+ "if_owner": 0,
+ "permlevel": 0,
+ "select": 0,
+ "read": 1,
+ "write": 1,
+ "create": 1,
+ "delete": 1,
+ "submit": 0,
+ "cancel": 0,
+ "amend": 0,
+ "report": 1,
+ "export": 1,
+ "import": 0,
+ "set_user_permissions": 0,
+ "share": 1,
+ "print": 1,
+ "email": 1,
+ "parent": "new-doctype-2",
+ "parentfield": "permissions",
+ "parenttype": "DocType",
+ "idx": 1,
+ "role": "System Manager"
+ }
+ ],
+ "__newname": "temp_doctype",
+ "module": "Custom",
+ "fields": [
+ {
+ "docstatus": 0,
+ "doctype": "DocField",
+ "name": "new-docfield-1",
+ "__islocal": 1,
+ "__unsaved": 1,
+ "owner": "Administrator",
+ "fieldtype": "Data",
+ "precision": "",
+ "non_negative": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "reqd": 1,
+ "search_index": 0,
+ "fetch_if_empty": 0,
+ "hidden": 0,
+ "bold": 0,
+ "allow_in_quick_entry": 0,
+ "translatable": 0,
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "report_hide": 0,
+ "collapsible": 0,
+ "hide_border": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "in_preview": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "read_only": 0,
+ "allow_on_submit": 0,
+ "ignore_user_permissions": 0,
+ "allow_bulk_edit": 0,
+ "permlevel": 0,
+ "ignore_xss_filter": 0,
+ "unique": 0,
+ "no_copy": 0,
+ "set_only_once": 0,
+ "remember_last_selected_value": 0,
+ "parent": "new-doctype-2",
+ "parentfield": "fields",
+ "parenttype": "DocType",
+ "idx": 1,
+ "__unedited": false,
+ "label": "member_name"
+ },
+ {
+ "docstatus": 0,
+ "doctype": "DocField",
+ "name": "new-docfield-2",
+ "__islocal": 1,
+ "__unsaved": 1,
+ "owner": "Administrator",
+ "fieldtype": "Data",
+ "precision": "",
+ "non_negative": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "fetch_if_empty": 0,
+ "hidden": 0,
+ "bold": 0,
+ "allow_in_quick_entry": 0,
+ "translatable": 0,
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "report_hide": 0,
+ "collapsible": 0,
+ "hide_border": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "in_preview": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "read_only": 0,
+ "allow_on_submit": 0,
+ "ignore_user_permissions": 0,
+ "allow_bulk_edit": 0,
+ "permlevel": 0,
+ "ignore_xss_filter": 0,
+ "unique": 0,
+ "no_copy": 0,
+ "set_only_once": 0,
+ "remember_last_selected_value": 0,
+ "parent": "new-doctype-2",
+ "parentfield": "fields",
+ "parenttype": "DocType",
+ "idx": 2,
+ "__unedited": false,
+ "label": "email"
+ }
+ ]
+}
diff --git a/frappe/custom/fixtures/temp_singles.json b/frappe/custom/fixtures/temp_singles.json
new file mode 100644
index 0000000000..b7e2536f25
--- /dev/null
+++ b/frappe/custom/fixtures/temp_singles.json
@@ -0,0 +1,168 @@
+{
+ "docstatus": 0,
+ "doctype": "DocType",
+ "name": "new-doctype-1",
+ "__islocal": 1,
+ "__unsaved": 1,
+ "owner": "Administrator",
+ "is_submittable": 0,
+ "istable": 0,
+ "issingle": 1,
+ "is_tree": 0,
+ "editable_grid": 1,
+ "quick_entry": 0,
+ "track_changes": 1,
+ "track_seen": 0,
+ "track_views": 0,
+ "custom": 1,
+ "beta": 0,
+ "is_virtual": 0,
+ "naming_rule": "",
+ "name_case": "",
+ "allow_rename": 1,
+ "hide_toolbar": 0,
+ "allow_copy": 0,
+ "allow_import": 0,
+ "allow_events_in_timeline": 0,
+ "allow_auto_repeat": 0,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "document_type": "",
+ "show_preview_popup": 0,
+ "show_name_in_global_search": 0,
+ "email_append_to": 0,
+ "read_only": 0,
+ "in_create": 0,
+ "has_web_view": 0,
+ "allow_guest_to_view": 0,
+ "index_web_pages_for_search": 1,
+ "engine": "InnoDB",
+ "permissions": [
+ {
+ "docstatus": 0,
+ "doctype": "DocPerm",
+ "name": "new-docperm-1",
+ "__islocal": 1,
+ "__unsaved": 1,
+ "owner": "Administrator",
+ "if_owner": 0,
+ "permlevel": 0,
+ "select": 0,
+ "read": 1,
+ "write": 1,
+ "create": 1,
+ "delete": 1,
+ "submit": 0,
+ "cancel": 0,
+ "amend": 0,
+ "report": 1,
+ "export": 1,
+ "import": 0,
+ "set_user_permissions": 0,
+ "share": 1,
+ "print": 1,
+ "email": 1,
+ "parent": "new-doctype-1",
+ "parentfield": "permissions",
+ "parenttype": "DocType",
+ "idx": 1,
+ "role": "System Manager"
+ }
+ ],
+ "__newname": "temp_singles",
+ "module": "Custom",
+ "fields": [
+ {
+ "docstatus": 0,
+ "doctype": "DocField",
+ "name": "new-docfield-1",
+ "__islocal": 1,
+ "__unsaved": 1,
+ "owner": "Administrator",
+ "fieldtype": "Data",
+ "precision": "",
+ "non_negative": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "fetch_if_empty": 0,
+ "hidden": 0,
+ "bold": 0,
+ "allow_in_quick_entry": 0,
+ "translatable": 0,
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "report_hide": 0,
+ "collapsible": 0,
+ "hide_border": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "in_preview": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "read_only": 0,
+ "allow_on_submit": 0,
+ "ignore_user_permissions": 0,
+ "allow_bulk_edit": 0,
+ "permlevel": 0,
+ "ignore_xss_filter": 0,
+ "unique": 0,
+ "no_copy": 0,
+ "set_only_once": 0,
+ "remember_last_selected_value": 0,
+ "parent": "new-doctype-1",
+ "parentfield": "fields",
+ "parenttype": "DocType",
+ "idx": 1,
+ "__unedited": false,
+ "label": "member_name"
+ },
+ {
+ "docstatus": 0,
+ "doctype": "DocField",
+ "name": "new-docfield-2",
+ "__islocal": 1,
+ "__unsaved": 1,
+ "owner": "Administrator",
+ "fieldtype": "Data",
+ "precision": "",
+ "non_negative": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "fetch_if_empty": 0,
+ "hidden": 0,
+ "bold": 0,
+ "allow_in_quick_entry": 0,
+ "translatable": 0,
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "report_hide": 0,
+ "collapsible": 0,
+ "hide_border": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "in_preview": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "read_only": 0,
+ "allow_on_submit": 0,
+ "ignore_user_permissions": 0,
+ "allow_bulk_edit": 0,
+ "permlevel": 0,
+ "ignore_xss_filter": 0,
+ "unique": 0,
+ "no_copy": 0,
+ "set_only_once": 0,
+ "remember_last_selected_value": 0,
+ "parent": "new-doctype-1",
+ "parentfield": "fields",
+ "parenttype": "DocType",
+ "idx": 2,
+ "__unedited": false,
+ "label": "email"
+ }
+ ]
+}
diff --git a/frappe/custom/form_tour/custom_field/custom_field.json b/frappe/custom/form_tour/custom_field/custom_field.json
new file mode 100644
index 0000000000..3279449e7c
--- /dev/null
+++ b/frappe/custom/form_tour/custom_field/custom_field.json
@@ -0,0 +1,79 @@
+{
+ "creation": "2021-11-23 12:22:32.922700",
+ "docstatus": 0,
+ "doctype": "Form Tour",
+ "first_document": 0,
+ "idx": 0,
+ "include_name_field": 0,
+ "is_standard": 1,
+ "modified": "2021-11-24 19:15:34.244244",
+ "modified_by": "Administrator",
+ "module": "Custom",
+ "name": "Custom Field",
+ "owner": "Administrator",
+ "reference_doctype": "Custom Field",
+ "save_on_complete": 1,
+ "steps": [
+ {
+ "description": "Select a Document for which you want the Custom Field",
+ "field": "",
+ "fieldname": "dt",
+ "fieldtype": "Link",
+ "has_next_condition": 0,
+ "is_table_field": 0,
+ "label": "Document",
+ "parent_field": "",
+ "position": "Right",
+ "title": "Document"
+ },
+ {
+ "description": "Enter a Label for this field",
+ "field": "",
+ "fieldname": "label",
+ "fieldtype": "Data",
+ "has_next_condition": 0,
+ "is_table_field": 0,
+ "label": "Label",
+ "parent_field": "",
+ "position": "Right",
+ "title": "Label"
+ },
+ {
+ "description": "Select the label after which you want to insert new field.",
+ "field": "",
+ "fieldname": "insert_after",
+ "fieldtype": "Select",
+ "has_next_condition": 0,
+ "is_table_field": 0,
+ "label": "Insert After",
+ "parent_field": "",
+ "position": "Right",
+ "title": "Insert After"
+ },
+ {
+ "description": "Select an appropriate Field Type that suits your requirements",
+ "field": "",
+ "fieldname": "fieldtype",
+ "fieldtype": "Select",
+ "has_next_condition": 0,
+ "is_table_field": 0,
+ "label": "Field Type",
+ "parent_field": "",
+ "position": "Left",
+ "title": "Field Type"
+ },
+ {
+ "description": "Check this to make it a mandatory field",
+ "field": "",
+ "fieldname": "reqd",
+ "fieldtype": "Check",
+ "has_next_condition": 0,
+ "is_table_field": 0,
+ "label": "Is Mandatory Field",
+ "parent_field": "",
+ "position": "Left",
+ "title": "Is Mandatory Field"
+ }
+ ],
+ "title": "Custom Field"
+}
\ No newline at end of file
diff --git a/frappe/custom/module_onboarding/customization/customization.json b/frappe/custom/module_onboarding/customization/customization.json
new file mode 100644
index 0000000000..99b7cc1f2b
--- /dev/null
+++ b/frappe/custom/module_onboarding/customization/customization.json
@@ -0,0 +1,44 @@
+{
+ "allow_roles": [
+ {
+ "role": "All"
+ }
+ ],
+ "creation": "2021-11-23 12:21:11.384229",
+ "docstatus": 0,
+ "doctype": "Module Onboarding",
+ "documentation_url": "https://docs.erpnext.com/docs/v13/user/manual/en/customize-erpnext",
+ "idx": 0,
+ "is_complete": 0,
+ "modified": "2021-11-24 17:04:31.523715",
+ "modified_by": "Administrator",
+ "module": "Custom",
+ "name": "Customization",
+ "owner": "Administrator",
+ "steps": [
+ {
+ "step": "Custom Field"
+ },
+ {
+ "step": "Custom Doctype"
+ },
+ {
+ "step": "Naming Series"
+ },
+ {
+ "step": "Workflows"
+ },
+ {
+ "step": "Role Permissions"
+ },
+ {
+ "step": "Print Format"
+ },
+ {
+ "step": "Report Builder"
+ }
+ ],
+ "subtitle": "Custom Field, Custom Doctype, Naming Series, Role Permission, Workflow, Print Formats, Reports",
+ "success_message": "Customization onboarding is all done!",
+ "title": "Customization"
+}
\ No newline at end of file
diff --git a/frappe/custom/onboarding_step/custom_doctype/custom_doctype.json b/frappe/custom/onboarding_step/custom_doctype/custom_doctype.json
new file mode 100644
index 0000000000..1f8601abee
--- /dev/null
+++ b/frappe/custom/onboarding_step/custom_doctype/custom_doctype.json
@@ -0,0 +1,21 @@
+{
+ "action": "Create Entry",
+ "action_label": "Learn more about creating new DocTypes",
+ "creation": "2021-11-23 12:30:04.407568",
+ "description": "A DocType (Document Type) is used to insert forms in ERPNext. Forms such as Customer, Orders, and Invoices are Doctypes in the backend. You can also create new DocTypes to create new forms in ERPNext as per your business needs.",
+ "docstatus": 0,
+ "doctype": "Onboarding Step",
+ "idx": 0,
+ "is_complete": 0,
+ "is_single": 0,
+ "is_skipped": 0,
+ "modified": "2021-11-23 12:30:04.407568",
+ "modified_by": "Administrator",
+ "name": "Custom Doctype",
+ "owner": "Administrator",
+ "reference_document": "DocType",
+ "show_form_tour": 1,
+ "show_full_form": 1,
+ "title": "Custom Document Types",
+ "validate_action": 1
+}
\ No newline at end of file
diff --git a/frappe/custom/onboarding_step/custom_field/custom_field.json b/frappe/custom/onboarding_step/custom_field/custom_field.json
new file mode 100644
index 0000000000..4044cf2456
--- /dev/null
+++ b/frappe/custom/onboarding_step/custom_field/custom_field.json
@@ -0,0 +1,21 @@
+{
+ "action": "Create Entry",
+ "action_label": "Learn how to add Custom Fields",
+ "creation": "2021-11-23 12:21:09.479808",
+ "description": "Every form in ERPNext has a standard set of fields. If you need to capture some information, but there is no standard Field available for it, you can insert Custom Field for it.\n\nOnce custom fields are added, you can use them for reports and analytics charts as well.\n",
+ "docstatus": 0,
+ "doctype": "Onboarding Step",
+ "idx": 0,
+ "is_complete": 0,
+ "is_single": 0,
+ "is_skipped": 0,
+ "modified": "2021-11-23 12:21:09.479808",
+ "modified_by": "Administrator",
+ "name": "Custom Field",
+ "owner": "Administrator",
+ "reference_document": "Custom Field",
+ "show_form_tour": 1,
+ "show_full_form": 1,
+ "title": "Create Custom Fields",
+ "validate_action": 1
+}
\ No newline at end of file
diff --git a/frappe/custom/onboarding_step/naming_series/naming_series.json b/frappe/custom/onboarding_step/naming_series/naming_series.json
new file mode 100644
index 0000000000..3b15e4afde
--- /dev/null
+++ b/frappe/custom/onboarding_step/naming_series/naming_series.json
@@ -0,0 +1,20 @@
+{
+ "action": "Watch Video",
+ "creation": "2021-11-23 13:57:45.091427",
+ "description": "Each document created in ERPNext can have a unique ID generated for it, using a prefix defined for it. Though each document has some prefix pre-configured, you can further customize it using tools like Naming Series Tool and Document Naming Rule.\n",
+ "docstatus": 0,
+ "doctype": "Onboarding Step",
+ "idx": 0,
+ "is_complete": 0,
+ "is_single": 0,
+ "is_skipped": 0,
+ "modified": "2021-11-24 15:04:14.662684",
+ "modified_by": "Administrator",
+ "name": "Naming Series",
+ "owner": "Administrator",
+ "show_form_tour": 0,
+ "show_full_form": 0,
+ "title": "Setup Naming Series",
+ "validate_action": 1,
+ "video_url": "https://youtu.be/IGyISSfI1qU"
+}
\ No newline at end of file
diff --git a/frappe/custom/onboarding_step/print_format/print_format.json b/frappe/custom/onboarding_step/print_format/print_format.json
new file mode 100644
index 0000000000..681ef85b95
--- /dev/null
+++ b/frappe/custom/onboarding_step/print_format/print_format.json
@@ -0,0 +1,21 @@
+{
+ "action": "Create Entry",
+ "action_label": "Learn about Standard and Custom Print Formats",
+ "creation": "2021-11-23 15:04:12.728513",
+ "description": "Print Formats allow you can define looks for documents when printed or converted to PDF. You can also create a custom Print Format using drag-and-drop tools.",
+ "docstatus": 0,
+ "doctype": "Onboarding Step",
+ "idx": 0,
+ "is_complete": 0,
+ "is_single": 0,
+ "is_skipped": 0,
+ "modified": "2021-11-23 15:04:12.728513",
+ "modified_by": "Administrator",
+ "name": "Print Format",
+ "owner": "Administrator",
+ "reference_document": "Print Format",
+ "show_form_tour": 1,
+ "show_full_form": 1,
+ "title": "Customize Print Formats",
+ "validate_action": 1
+}
\ No newline at end of file
diff --git a/frappe/custom/onboarding_step/report_builder/report_builder.json b/frappe/custom/onboarding_step/report_builder/report_builder.json
new file mode 100644
index 0000000000..4a0b5f9130
--- /dev/null
+++ b/frappe/custom/onboarding_step/report_builder/report_builder.json
@@ -0,0 +1,22 @@
+{
+ "action": "Watch Video",
+ "action_label": "Learn more about Report Builders",
+ "creation": "2021-11-24 17:04:18.762838",
+ "description": "In each module, you will find a host of single-click reports, ranging from financial statements to sales and purchase analytics and stock tracking reports. If a required new report is not available out-of-the-box, you can create custom reports in ERPNext by pulling values from the same multiple ERPNext tables.\n",
+ "docstatus": 0,
+ "doctype": "Onboarding Step",
+ "idx": 0,
+ "is_complete": 0,
+ "is_single": 0,
+ "is_skipped": 0,
+ "modified": "2021-11-24 17:04:18.762838",
+ "modified_by": "Administrator",
+ "name": "Report Builder",
+ "owner": "Administrator",
+ "reference_document": "Report",
+ "show_form_tour": 0,
+ "show_full_form": 0,
+ "title": "Generate Custom Reports",
+ "validate_action": 1,
+ "video_url": "https://youtu.be/TxJGUNarcQs"
+}
\ No newline at end of file
diff --git a/frappe/custom/onboarding_step/role_permissions/role_permissions.json b/frappe/custom/onboarding_step/role_permissions/role_permissions.json
new file mode 100644
index 0000000000..a817126989
--- /dev/null
+++ b/frappe/custom/onboarding_step/role_permissions/role_permissions.json
@@ -0,0 +1,20 @@
+{
+ "action": "Watch Video",
+ "creation": "2021-11-23 14:00:27.208500",
+ "description": "In ERPNext, you can add your Employees as Users, and give them restricted access. Tools like Role Permission and User Permission allow you to define rules which give restricted access to the user to masters and transactions.",
+ "docstatus": 0,
+ "doctype": "Onboarding Step",
+ "idx": 0,
+ "is_complete": 0,
+ "is_single": 0,
+ "is_skipped": 0,
+ "modified": "2021-11-24 15:04:14.615232",
+ "modified_by": "Administrator",
+ "name": "Role Permissions",
+ "owner": "Administrator",
+ "show_form_tour": 0,
+ "show_full_form": 0,
+ "title": "Setup Limited Access for a User",
+ "validate_action": 1,
+ "video_url": "https://youtu.be/g3mk45o1zAg"
+}
\ No newline at end of file
diff --git a/frappe/custom/onboarding_step/workflows/workflows.json b/frappe/custom/onboarding_step/workflows/workflows.json
new file mode 100644
index 0000000000..683b7a398a
--- /dev/null
+++ b/frappe/custom/onboarding_step/workflows/workflows.json
@@ -0,0 +1,20 @@
+{
+ "action": "Watch Video",
+ "creation": "2021-11-23 13:58:58.530044",
+ "description": "Workflows allow you to define custom rules for the approval process of a particular document in ERPNext. You can also set complex Workflow Rules and set approval conditions.",
+ "docstatus": 0,
+ "doctype": "Onboarding Step",
+ "idx": 0,
+ "is_complete": 0,
+ "is_single": 0,
+ "is_skipped": 0,
+ "modified": "2021-11-24 15:04:14.632144",
+ "modified_by": "Administrator",
+ "name": "Workflows",
+ "owner": "Administrator",
+ "show_form_tour": 0,
+ "show_full_form": 0,
+ "title": "Setup Approval Workflows",
+ "validate_action": 1,
+ "video_url": "https://youtu.be/yObJUg9FxFs"
+}
\ No newline at end of file
diff --git a/frappe/custom/workspace/customization/customization.json b/frappe/custom/workspace/customization/customization.json
index cdc3b73366..1756abcb1d 100644
--- a/frappe/custom/workspace/customization/customization.json
+++ b/frappe/custom/workspace/customization/customization.json
@@ -1,23 +1,20 @@
{
- "category": "Administration",
"charts": [],
+ "content": "[{\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Customization\",\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"
Your Shortcuts \",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customize Form\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Custom Role\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Client Script\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Server Script\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"
Reports & Masters \",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Dashboards\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Form Customization\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Other\",\"col\":4}}]",
"creation": "2020-03-02 15:15:03.839594",
- "developer_mode_only": 0,
- "disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
- "extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "customization",
"idx": 0,
- "is_default": 0,
- "is_standard": 1,
"label": "Customization",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Dashboards",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -26,6 +23,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dashboard",
+ "link_count": 0,
"link_to": "Dashboard",
"link_type": "DocType",
"onboard": 0,
@@ -36,6 +34,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dashboard Chart",
+ "link_count": 0,
"link_to": "Dashboard Chart",
"link_type": "DocType",
"onboard": 0,
@@ -46,6 +45,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dashboard Chart Source",
+ "link_count": 0,
"link_to": "Dashboard Chart Source",
"link_type": "DocType",
"onboard": 0,
@@ -55,6 +55,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Form Customization",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -63,6 +64,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Customize Form",
+ "link_count": 0,
"link_to": "Customize Form",
"link_type": "DocType",
"onboard": 0,
@@ -73,6 +75,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Custom Field",
+ "link_count": 0,
"link_to": "Custom Field",
"link_type": "DocType",
"onboard": 0,
@@ -83,6 +86,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Client Script",
+ "link_count": 0,
"link_to": "Client Script",
"link_type": "DocType",
"onboard": 0,
@@ -93,6 +97,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "DocType",
+ "link_count": 0,
"link_to": "DocType",
"link_type": "DocType",
"onboard": 0,
@@ -102,6 +107,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Other",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -110,19 +116,23 @@
"hidden": 0,
"is_query_report": 0,
"label": "Custom Translations",
+ "link_count": 0,
"link_to": "Translation",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2021-02-04 13:50:35.750463",
+ "modified": "2022-01-13 17:28:08.345794",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customization",
"owner": "Administrator",
- "pin_to_bottom": 0,
- "pin_to_top": 0,
+ "parent_page": "",
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 8.0,
"shortcuts": [
{
"label": "Customize Form",
@@ -145,5 +155,6 @@
"link_to": "Server Script",
"type": "DocType"
}
- ]
+ ],
+ "title": "Customization"
}
\ No newline at end of file
diff --git a/frappe/data/google_fonts.json b/frappe/data/google_fonts.json
new file mode 100644
index 0000000000..232e509e77
--- /dev/null
+++ b/frappe/data/google_fonts.json
@@ -0,0 +1,56 @@
+[
+ "Alegreya Sans",
+ "Alegreya",
+ "Andada Pro",
+ "Anton",
+ "Archivo Narrow",
+ "Archivo",
+ "BioRhyme",
+ "Cardo",
+ "Chivo",
+ "Cormorant",
+ "Crimson Text",
+ "DM Sans",
+ "Eczar",
+ "Encode Sans",
+ "Epilogue ",
+ "Fira Sans",
+ "Hahmlet",
+ "IBM Plex Sans",
+ "Inconsolata",
+ "Inknut Antiqua",
+ "Inter",
+ "JetBrains Mono",
+ "Karla",
+ "Lato",
+ "Libre Baskerville",
+ "Libre Franklin",
+ "Lora",
+ "Manrope",
+ "Merriweather",
+ "Montserrat",
+ "Neuton",
+ "Nunito",
+ "Old Standard TT",
+ "Open Sans",
+ "Oswald",
+ "Oxygen",
+ "Playfair Display",
+ "Poppins",
+ "Proza Libre",
+ "PT Sans",
+ "PT Serif",
+ "Raleway",
+ "Roboto Slab",
+ "Roboto",
+ "Rubik",
+ "Sora",
+ "Source Sans Pro",
+ "Source Serif Pro",
+ "Space Grotesk",
+ "Space Mono",
+ "Spectral",
+ "Syne",
+ "Work Sans"
+]
+
diff --git a/frappe/data/sample_site_config.json b/frappe/data/sample_site_config.json
deleted file mode 100644
index 715cd7b9fa..0000000000
--- a/frappe/data/sample_site_config.json
+++ /dev/null
@@ -1,45 +0,0 @@
-{
- "db_name": "testdb",
- "db_password": "password",
- "mute_emails": true,
-
- "limits": {
- "emails": 1500,
- "space": 0.157,
- "expiry": "2016-07-25",
- "users": 1
- },
-
- "developer_mode": 1,
- "auto_cache_clear": true,
- "disable_website_cache": true,
- "max_file_size": 1000000,
-
- "mail_server": "localhost",
- "mail_login": null,
- "mail_password": null,
- "mail_port": 25,
- "use_ssl": 0,
- "auto_email_id": "hello@example.com",
-
- "google_analytics_id": "google_analytics_id",
- "google_analytics_anonymize_ip": 1,
-
- "google_login": {
- "client_id": "google_client_id",
- "client_secret": "google_client_secret"
- },
- "github_login": {
- "client_id": "github_client_id",
- "client_secret": "github_client_secret"
- },
- "facebook_login": {
- "client_id": "facebook_client_id",
- "client_secret": "facebook_client_secret"
- },
-
- "celery_broker": "redis://localhost",
- "celery_result_backend": null,
- "scheduler_interval": 300,
- "celery_queue_per_site": true
-}
diff --git a/frappe/data_migration/doctype/data_migration_connector/connectors/base.py b/frappe/data_migration/doctype/data_migration_connector/connectors/base.py
index 97f9f5f4a3..5eca7cfac5 100644
--- a/frappe/data_migration/doctype/data_migration_connector/connectors/base.py
+++ b/frappe/data_migration/doctype/data_migration_connector/connectors/base.py
@@ -1,10 +1,7 @@
-from __future__ import unicode_literals
-from six import with_metaclass
from abc import ABCMeta, abstractmethod
from frappe.utils.password import get_decrypted_password
-class BaseConnection(with_metaclass(ABCMeta)):
-
+class BaseConnection(metaclass=ABCMeta):
@abstractmethod
def get(self, remote_objectname, fields=None, filters=None, start=0, page_length=10):
pass
diff --git a/frappe/data_migration/doctype/data_migration_connector/connectors/frappe_connection.py b/frappe/data_migration/doctype/data_migration_connector/connectors/frappe_connection.py
index 6ee41afdf2..473a15c2dc 100644
--- a/frappe/data_migration/doctype/data_migration_connector/connectors/frappe_connection.py
+++ b/frappe/data_migration/doctype/data_migration_connector/connectors/frappe_connection.py
@@ -1,4 +1,4 @@
-from __future__ import unicode_literals
+
import frappe
from frappe.frappeclient import FrappeClient
from .base import BaseConnection
diff --git a/frappe/data_migration/doctype/data_migration_connector/data_migration_connector.py b/frappe/data_migration/doctype/data_migration_connector/data_migration_connector.py
index 793dfe6694..2e4e4d45b3 100644
--- a/frappe/data_migration/doctype/data_migration_connector/data_migration_connector.py
+++ b/frappe/data_migration/doctype/data_migration_connector/data_migration_connector.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe, os
from frappe.model.document import Document
from frappe import _
@@ -76,8 +75,7 @@ def get_connection_class(python_module):
return _class
-connection_boilerplate = """from __future__ import unicode_literals
-from frappe.data_migration.doctype.data_migration_connector.connectors.base import BaseConnection
+connection_boilerplate = """from frappe.data_migration.doctype.data_migration_connector.connectors.base import BaseConnection
class {connection_class}(BaseConnection):
def __init__(self, connector):
diff --git a/frappe/data_migration/doctype/data_migration_connector/test_data_migration_connector.js b/frappe/data_migration/doctype/data_migration_connector/test_data_migration_connector.js
deleted file mode 100644
index b933deb433..0000000000
--- a/frappe/data_migration/doctype/data_migration_connector/test_data_migration_connector.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Data Migration Connector", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Data Migration Connector
- () => frappe.tests.make('Data Migration Connector', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/data_migration/doctype/data_migration_connector/test_data_migration_connector.py b/frappe/data_migration/doctype/data_migration_connector/test_data_migration_connector.py
index a6e30fbe44..ffc96c8266 100644
--- a/frappe/data_migration/doctype/data_migration_connector/test_data_migration_connector.py
+++ b/frappe/data_migration/doctype/data_migration_connector/test_data_migration_connector.py
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import unittest
class TestDataMigrationConnector(unittest.TestCase):
diff --git a/frappe/data_migration/doctype/data_migration_mapping/data_migration_mapping.py b/frappe/data_migration/doctype/data_migration_mapping/data_migration_mapping.py
index 1cc54a0d1a..46d33eaca9 100644
--- a/frappe/data_migration/doctype/data_migration_mapping/data_migration_mapping.py
+++ b/frappe/data_migration/doctype/data_migration_mapping/data_migration_mapping.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe.utils.safe_exec import get_safe_globals
diff --git a/frappe/data_migration/doctype/data_migration_mapping/test_data_migration_mapping.js b/frappe/data_migration/doctype/data_migration_mapping/test_data_migration_mapping.js
deleted file mode 100644
index e6966ef131..0000000000
--- a/frappe/data_migration/doctype/data_migration_mapping/test_data_migration_mapping.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Data Migration Mapping", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Data Migration Mapping
- () => frappe.tests.make('Data Migration Mapping', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/data_migration/doctype/data_migration_mapping/test_data_migration_mapping.py b/frappe/data_migration/doctype/data_migration_mapping/test_data_migration_mapping.py
index e6f0ce2796..b1040aaa58 100644
--- a/frappe/data_migration/doctype/data_migration_mapping/test_data_migration_mapping.py
+++ b/frappe/data_migration/doctype/data_migration_mapping/test_data_migration_mapping.py
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import unittest
class TestDataMigrationMapping(unittest.TestCase):
diff --git a/frappe/data_migration/doctype/data_migration_mapping_detail/data_migration_mapping_detail.py b/frappe/data_migration/doctype/data_migration_mapping_detail/data_migration_mapping_detail.py
index 1ccdf76eed..ce46f60f67 100644
--- a/frappe/data_migration/doctype/data_migration_mapping_detail/data_migration_mapping_detail.py
+++ b/frappe/data_migration/doctype/data_migration_mapping_detail/data_migration_mapping_detail.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
from frappe.model.document import Document
class DataMigrationMappingDetail(Document):
diff --git a/frappe/data_migration/doctype/data_migration_plan/data_migration_plan.py b/frappe/data_migration/doctype/data_migration_plan/data_migration_plan.py
index 5cd195f4fe..d13912b431 100644
--- a/frappe/data_migration/doctype/data_migration_plan/data_migration_plan.py
+++ b/frappe/data_migration/doctype/data_migration_plan/data_migration_plan.py
@@ -1,14 +1,26 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.modules import get_module_path, scrub_dt_dn
from frappe.modules.export_file import export_to_files, create_init_py
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.model.document import Document
+
+def get_mapping_module(module, mapping_name):
+ app_name = frappe.db.get_value("Module Def", module, "app_name")
+ mapping_name = frappe.scrub(mapping_name)
+ module = frappe.scrub(module)
+
+ try:
+ return frappe.get_module(
+ f"{app_name}.{module}.data_migration_mapping.{mapping_name}"
+ )
+ except ImportError:
+ return None
+
+
class DataMigrationPlan(Document):
def on_update(self):
# update custom fields in mappings
@@ -55,26 +67,14 @@ class DataMigrationPlan(Document):
frappe.flags.ignore_in_install = False
def pre_process_doc(self, mapping_name, doc):
- module = self.get_mapping_module(mapping_name)
+ module = get_mapping_module(self.module, mapping_name)
if module and hasattr(module, 'pre_process'):
return module.pre_process(doc)
return doc
def post_process_doc(self, mapping_name, local_doc=None, remote_doc=None):
- module = self.get_mapping_module(mapping_name)
+ module = get_mapping_module(self.module, mapping_name)
if module and hasattr(module, 'post_process'):
return module.post_process(local_doc=local_doc, remote_doc=remote_doc)
-
- def get_mapping_module(self, mapping_name):
- try:
- module_def = frappe.get_doc("Module Def", self.module)
- module = frappe.get_module('{app}.{module}.data_migration_mapping.{mapping_name}'.format(
- app= module_def.app_name,
- module=frappe.scrub(self.module),
- mapping_name=frappe.scrub(mapping_name)
- ))
- return module
- except ImportError:
- return None
diff --git a/frappe/data_migration/doctype/data_migration_plan/test_data_migration_plan.js b/frappe/data_migration/doctype/data_migration_plan/test_data_migration_plan.js
deleted file mode 100644
index 9943cd6ec1..0000000000
--- a/frappe/data_migration/doctype/data_migration_plan/test_data_migration_plan.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Data Migration Plan", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Data Migration Plan
- () => frappe.tests.make('Data Migration Plan', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/data_migration/doctype/data_migration_plan/test_data_migration_plan.py b/frappe/data_migration/doctype/data_migration_plan/test_data_migration_plan.py
index 3a33039c3d..649f7db903 100644
--- a/frappe/data_migration/doctype/data_migration_plan/test_data_migration_plan.py
+++ b/frappe/data_migration/doctype/data_migration_plan/test_data_migration_plan.py
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import unittest
class TestDataMigrationPlan(unittest.TestCase):
diff --git a/frappe/data_migration/doctype/data_migration_plan_mapping/data_migration_plan_mapping.py b/frappe/data_migration/doctype/data_migration_plan_mapping/data_migration_plan_mapping.py
index 85f879069c..7939a68d97 100644
--- a/frappe/data_migration/doctype/data_migration_plan_mapping/data_migration_plan_mapping.py
+++ b/frappe/data_migration/doctype/data_migration_plan_mapping/data_migration_plan_mapping.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
from frappe.model.document import Document
class DataMigrationPlanMapping(Document):
diff --git a/frappe/data_migration/doctype/data_migration_run/data_migration_run.py b/frappe/data_migration/doctype/data_migration_run/data_migration_run.py
index 473acfb3d0..deb14baf27 100644
--- a/frappe/data_migration/doctype/data_migration_run/data_migration_run.py
+++ b/frappe/data_migration/doctype/data_migration_run/data_migration_run.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe, json, math
from frappe.model.document import Document
from frappe import _
@@ -10,6 +9,7 @@ from frappe.utils import cstr
from frappe.data_migration.doctype.data_migration_mapping.data_migration_mapping import get_source_value
class DataMigrationRun(Document):
+ @frappe.whitelist()
def run(self):
self.begin()
if self.total_pages > 0:
diff --git a/frappe/data_migration/doctype/data_migration_run/test_data_migration_run.js b/frappe/data_migration/doctype/data_migration_run/test_data_migration_run.js
deleted file mode 100644
index 04a127f730..0000000000
--- a/frappe/data_migration/doctype/data_migration_run/test_data_migration_run.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Data Migration Run", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Data Migration Run
- () => frappe.tests.make('Data Migration Run', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/data_migration/doctype/data_migration_run/test_data_migration_run.py b/frappe/data_migration/doctype/data_migration_run/test_data_migration_run.py
index c6c3ea138c..485f86a7f9 100644
--- a/frappe/data_migration/doctype/data_migration_run/test_data_migration_run.py
+++ b/frappe/data_migration/doctype/data_migration_run/test_data_migration_run.py
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe, unittest
class TestDataMigrationRun(unittest.TestCase):
diff --git a/frappe/database/__init__.py b/frappe/database/__init__.py
index 1f0d3f9bf5..7b26ac31b3 100644
--- a/frappe/database/__init__.py
+++ b/frappe/database/__init__.py
@@ -1,10 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
# Database Module
# --------------------
-from __future__ import unicode_literals
+from frappe.database.database import savepoint
def setup_database(force, source_sql=None, verbose=None, no_mariadb_socket=False):
import frappe
diff --git a/frappe/database/database.py b/frappe/database/database.py
index 179206a4af..1251a323d3 100644
--- a/frappe/database/database.py
+++ b/frappe/database/database.py
@@ -1,32 +1,30 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
# Database Module
# --------------------
-from __future__ import unicode_literals
-
-import re
-import time
-import frappe
import datetime
+import random
+import re
+import string
+from contextlib import contextmanager
+from time import time
+from typing import Dict, List, Tuple, Union
+
+from pypika.terms import Criterion, NullValue, PseudoColumn
+
+import frappe
import frappe.defaults
import frappe.model.meta
-
from frappe import _
-from time import time
-from frappe.utils import now, getdate, cast_fieldtype, get_datetime
-from frappe.utils.background_jobs import execute_job, get_queue
from frappe.model.utils.link_count import flush_local_link_count
-from frappe.utils import cint
+from frappe.query_builder.functions import Count
+from frappe.query_builder.utils import DocType
+from frappe.utils import cast, get_datetime, getdate, now, sbool
+
+from .query import Query
-# imports - compatibility imports
-from six import (
- integer_types,
- string_types,
- text_type,
- iteritems
-)
class Database(object):
"""
@@ -39,9 +37,10 @@ class Database(object):
OPTIONAL_COLUMNS = ["_user_tags", "_comments", "_assign", "_liked_by"]
DEFAULT_SHORTCUTS = ['_Login', '__user', '_Full Name', 'Today', '__today', "now", "Now"]
- STANDARD_VARCHAR_COLUMNS = ('name', 'owner', 'modified_by', 'parent', 'parentfield', 'parenttype')
- DEFAULT_COLUMNS = ['name', 'creation', 'modified', 'modified_by', 'owner', 'docstatus', 'parent',
- 'parentfield', 'parenttype', 'idx']
+ STANDARD_VARCHAR_COLUMNS = ('name', 'owner', 'modified_by')
+ DEFAULT_COLUMNS = ['name', 'creation', 'modified', 'modified_by', 'owner', 'docstatus', 'idx']
+ CHILD_TABLE_COLUMNS = ('parent', 'parenttype', 'parentfield')
+ MAX_WRITES_PER_TRANSACTION = 200_000
class InvalidColumnName(frappe.ValidationError): pass
@@ -65,6 +64,7 @@ class Database(object):
self.password = password or frappe.conf.db_password
self.value_cache = {}
+ self.query = Query()
def setup_type_map(self):
pass
@@ -87,7 +87,8 @@ class Database(object):
pass
def sql(self, query, values=(), as_dict = 0, as_list = 0, formatted = 0,
- debug=0, ignore_ddl=0, as_utf8=0, auto_commit=0, update=None, explain=False):
+ debug=0, ignore_ddl=0, as_utf8=0, auto_commit=0, update=None,
+ explain=False, run=True, pluck=False):
"""Execute a SQL query and fetch all rows.
:param query: SQL query.
@@ -100,7 +101,7 @@ class Database(object):
:param as_utf8: Encode values as UTF 8.
:param auto_commit: Commit after executing the query.
:param update: Update this dict to all rows (if returned `as_dict`).
-
+ :param run: Returns query without executing it if False.
Examples:
# return customer names as dicts
@@ -114,6 +115,10 @@ class Database(object):
{"name": "a%", "owner":"test@example.com"})
"""
+ query = str(query)
+ if not run:
+ return query
+
if re.search(r'ifnull\(', query, flags=re.IGNORECASE):
# replaces ifnull in query with coalesce
query = re.sub(r'ifnull\(', 'coalesce(', query, flags=re.IGNORECASE)
@@ -137,8 +142,6 @@ class Database(object):
self.log_query(query, values, debug, explain)
if values!=():
- if isinstance(values, dict):
- values = dict(values)
# MySQL-python==1.2.5 hack!
if not isinstance(values, (dict, tuple, list)):
@@ -160,15 +163,23 @@ class Database(object):
frappe.errprint(("Execution time: {0} sec").format(round(time_end - time_start, 2)))
except Exception as e:
- if frappe.conf.db_type == 'postgres':
- self.rollback()
-
- elif self.is_syntax_error(e):
+ if self.is_syntax_error(e):
# only for mariadb
frappe.errprint('Syntax error in query:')
frappe.errprint(query)
- if ignore_ddl and (self.is_missing_column(e) or self.is_missing_table(e) or self.cant_drop_field_or_key(e)):
+ elif self.is_deadlocked(e):
+ raise frappe.QueryDeadlockError(e)
+
+ elif self.is_timedout(e):
+ raise frappe.QueryTimeoutError(e)
+
+ elif frappe.conf.db_type == 'postgres':
+ # TODO: added temporarily
+ print(e)
+ raise
+
+ if ignore_ddl and (self.is_missing_column(e) or self.is_table_missing(e) or self.cant_drop_field_or_key(e)):
pass
else:
raise
@@ -178,6 +189,9 @@ class Database(object):
if not self._cursor.description:
return ()
+ if pluck:
+ return [r[0] for r in self._cursor.fetchall()]
+
# scrub output if required
if as_dict:
ret = self.fetch_as_dict(formatted, as_utf8)
@@ -233,7 +247,7 @@ class Database(object):
except Exception:
frappe.errprint("error in query explain")
- def sql_list(self, query, values=(), debug=False):
+ def sql_list(self, query, values=(), debug=False, **kwargs):
"""Return data as list of single elements (first column).
Example:
@@ -241,7 +255,7 @@ class Database(object):
# doctypes = ["DocType", "DocField", "User", ...]
doctypes = frappe.db.sql_list("select name from DocType")
"""
- return [r[0] for r in self.sql(query, values, debug=debug)]
+ return [r[0] for r in self.sql(query, values, **kwargs, debug=debug)]
def sql_ddl(self, query, values=(), debug=False):
"""Commit and execute a query. DDL (Data Definition Language) queries that alter schema
@@ -249,24 +263,30 @@ class Database(object):
self.commit()
self.sql(query, debug=debug)
+
def check_transaction_status(self, query):
"""Raises exception if more than 20,000 `INSERT`, `UPDATE` queries are
executed in one transaction. This is to ensure that writes are always flushed otherwise this
could cause the system to hang."""
- if self.transaction_writes and \
- query and query.strip().split()[0].lower() in ['start', 'alter', 'drop', 'create', "begin", "truncate"]:
- raise Exception('This statement can cause implicit commit')
+ self.check_implicit_commit(query)
if query and query.strip().lower() in ('commit', 'rollback'):
self.transaction_writes = 0
if query[:6].lower() in ('update', 'insert', 'delete'):
self.transaction_writes += 1
- if self.transaction_writes > 200000:
+ if self.transaction_writes > self.MAX_WRITES_PER_TRANSACTION:
if self.auto_commit_on_many_writes:
self.commit()
else:
- frappe.throw(_("Too many writes in one request. Please send smaller requests"), frappe.ValidationError)
+ msg = "
" + _("Too many changes to database in single action.") + "
"
+ msg += _("The changes have been reverted.") + "
"
+ raise frappe.TooManyWritesError(msg)
+
+ def check_implicit_commit(self, query):
+ if self.transaction_writes and \
+ query and query.strip().split()[0].lower() in ['start', 'alter', 'drop', 'create', "begin", "truncate"]:
+ raise Exception('This statement can cause implicit commit')
def fetch_as_dict(self, formatted=0, as_utf8=0):
"""Internal. Converts results to dict."""
@@ -278,7 +298,7 @@ class Database(object):
for r in result:
values = []
for value in r:
- if as_utf8 and isinstance(value, text_type):
+ if as_utf8 and isinstance(value, str):
value = value.encode('utf-8')
values.append(value)
@@ -295,7 +315,7 @@ class Database(object):
"""Returns true if the first row in the result has a Date, Datetime, Long Int."""
if result and result[0]:
for v in result[0]:
- if isinstance(v, (datetime.date, datetime.timedelta, datetime.datetime, integer_types)):
+ if isinstance(v, (datetime.date, datetime.timedelta, datetime.datetime, int)):
return True
if formatted and isinstance(v, (int, float)):
return True
@@ -313,71 +333,31 @@ class Database(object):
for r in res:
nr = []
for val in r:
- if as_utf8 and isinstance(val, text_type):
+ if as_utf8 and isinstance(val, str):
val = val.encode('utf-8')
nr.append(val)
nres.append(nr)
return nres
- def build_conditions(self, filters):
- """Convert filters sent as dict, lists to SQL conditions. filter's key
- is passed by map function, build conditions like:
-
- * ifnull(`fieldname`, default_value) = %(fieldname)s
- * `fieldname` [=, !=, >, >=, <, <=] %(fieldname)s
- """
- conditions = []
- values = {}
- def _build_condition(key):
- """
- filter's key is passed by map function
- build conditions like:
- * ifnull(`fieldname`, default_value) = %(fieldname)s
- * `fieldname` [=, !=, >, >=, <, <=] %(fieldname)s
- """
- _operator = "="
- _rhs = " %(" + key + ")s"
- value = filters.get(key)
- values[key] = value
- if isinstance(value, (list, tuple)):
- # value is a tuple like ("!=", 0)
- _operator = value[0]
- values[key] = value[1]
- if isinstance(value[1], (tuple, list)):
- # value is a list in tuple ("in", ("A", "B"))
- _rhs = " ({0})".format(", ".join([self.escape(v) for v in value[1]]))
- del values[key]
-
- if _operator not in ["=", "!=", ">", ">=", "<", "<=", "like", "in", "not in", "not like"]:
- _operator = "="
-
- if "[" in key:
- split_key = key.split("[")
- condition = "coalesce(`" + split_key[0] + "`, " + split_key[1][:-1] + ") " \
- + _operator + _rhs
- else:
- condition = "`" + key + "` " + _operator + _rhs
-
- conditions.append(condition)
-
- if isinstance(filters, int):
- # docname is a number, convert to string
- filters = str(filters)
-
- if isinstance(filters, string_types):
- filters = { "name": filters }
-
- for f in filters:
- _build_condition(f)
-
- return " and ".join(conditions), values
-
def get(self, doctype, filters=None, as_dict=True, cache=False):
"""Returns `get_value` with fieldname='*'"""
return self.get_value(doctype, filters, "*", as_dict=as_dict, cache=cache)
- def get_value(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False,
- debug=False, order_by=None, cache=False, for_update=False):
+ def get_value(
+ self,
+ doctype,
+ filters=None,
+ fieldname="name",
+ ignore=None,
+ as_dict=False,
+ debug=False,
+ order_by="KEEP_DEFAULT_ORDERING",
+ cache=False,
+ for_update=False,
+ run=True,
+ pluck=False,
+ distinct=False,
+ ):
"""Returns a document property or list of properties.
:param doctype: DocType name.
@@ -404,12 +384,16 @@ class Database(object):
"""
ret = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug,
- order_by, cache=cache, for_update=for_update)
+ order_by, cache=cache, for_update=for_update, run=run, pluck=pluck, distinct=distinct)
+
+ if not run:
+ return ret
return ((len(ret[0]) > 1 or as_dict) and ret[0] or ret[0][0]) if ret else None
def get_values(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False,
- debug=False, order_by=None, update=None, cache=False, for_update=False):
+ debug=False, order_by="KEEP_DEFAULT_ORDERING", update=None, cache=False, for_update=False,
+ run=True, pluck=False, distinct=False):
"""Returns multiple document properties.
:param doctype: DocType name.
@@ -418,7 +402,8 @@ class Database(object):
:param ignore: Don't raise exception if table, column is missing.
:param as_dict: Return values as dict.
:param debug: Print query in error log.
- :param order_by: Column to order by
+ :param order_by: Column to order by,
+ :param distinct: Get Distinct results.
Example:
@@ -429,44 +414,78 @@ class Database(object):
user = frappe.db.get_values("User", "test@example.com", "*")[0]
"""
out = None
- if cache and isinstance(filters, string_types) and \
+ if cache and isinstance(filters, str) and \
(doctype, filters, fieldname) in self.value_cache:
return self.value_cache[(doctype, filters, fieldname)]
- if not order_by: order_by = 'modified desc'
+ if distinct:
+ order_by = None
if isinstance(filters, list):
- out = self._get_value_for_many_names(doctype, filters, fieldname, debug=debug)
+ out = self._get_value_for_many_names(
+ doctype,
+ filters,
+ fieldname,
+ order_by,
+ debug=debug,
+ run=run,
+ pluck=pluck,
+ distinct=distinct,
+ )
else:
fields = fieldname
- if fieldname!="*":
- if isinstance(fieldname, string_types):
+ if fieldname != "*":
+ if isinstance(fieldname, str):
fields = [fieldname]
- else:
- fields = fieldname
if (filters is not None) and (filters!=doctype or doctype=="DocType"):
try:
- out = self._get_values_from_table(fields, filters, doctype, as_dict, debug, order_by, update, for_update=for_update)
+ if order_by:
+ order_by = "modified" if order_by == "KEEP_DEFAULT_ORDERING" else order_by
+ out = self._get_values_from_table(
+ fields,
+ filters,
+ doctype,
+ as_dict,
+ debug,
+ order_by,
+ update,
+ for_update=for_update,
+ run=run,
+ pluck=pluck,
+ distinct=distinct
+ )
except Exception as e:
if ignore and (frappe.db.is_missing_column(e) or frappe.db.is_table_missing(e)):
# table or column not found, return None
out = None
elif (not ignore) and frappe.db.is_table_missing(e):
# table not found, look in singles
- out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update)
+ out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run, distinct=distinct)
+
else:
raise
else:
- out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update)
+ out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run, pluck=pluck, distinct=distinct)
- if cache and isinstance(filters, string_types):
+ if cache and isinstance(filters, str):
self.value_cache[(doctype, filters, fieldname)] = out
return out
- def get_values_from_single(self, fields, filters, doctype, as_dict=False, debug=False, update=None):
+ def get_values_from_single(
+ self,
+ fields,
+ filters,
+ doctype,
+ as_dict=False,
+ debug=False,
+ update=None,
+ run=True,
+ pluck=False,
+ distinct=False,
+ ):
"""Get values from `tabSingles` (Single DocTypes) (internal).
:param fields: List of fields,
@@ -492,11 +511,15 @@ class Database(object):
return [map(values.get, fields)]
else:
- r = self.sql("""select field, value
- from `tabSingles` where field in (%s) and doctype=%s"""
- % (', '.join(['%s'] * len(fields)), '%s'),
- tuple(fields) + (doctype,), as_dict=False, debug=debug)
+ r = self.query.get_sql(
+ "Singles",
+ filters={"field": ("in", tuple(fields)), "doctype": doctype},
+ fields=["field", "value"],
+ distinct=distinct,
+ ).run(pluck=pluck, debug=debug, as_dict=False)
+ if not run:
+ return r
if as_dict:
if r:
r = frappe._dict(r)
@@ -508,6 +531,7 @@ class Database(object):
else:
return r and [[i[1] for i in r]] or []
+
def get_singles_dict(self, doctype, debug = False):
"""Get Single DocType as dict.
@@ -518,15 +542,10 @@ class Database(object):
# Get coulmn and value of the single doctype Accounts Settings
account_settings = frappe.db.get_singles_dict("Accounts Settings")
"""
- result = self.sql("""
- SELECT field, value
- FROM `tabSingles`
- WHERE doctype = %s
- """, doctype)
- # result = _cast_result(doctype, result)
-
+ result = self.query.get_sql(
+ "Singles", filters={"doctype": doctype}, fields=["field", "value"]
+ ).run()
dict_ = frappe._dict(result)
-
return dict_
@staticmethod
@@ -537,7 +556,21 @@ class Database(object):
def get_list(*args, **kwargs):
return frappe.get_list(*args, **kwargs)
- def get_single_value(self, doctype, fieldname, cache=False):
+ def set_single_value(self, doctype, fieldname, value, *args, **kwargs):
+ """Set field value of Single DocType.
+
+ :param doctype: DocType of the single object
+ :param fieldname: `fieldname` of the property
+ :param value: `value` of the property
+
+ Example:
+
+ # Update the `deny_multiple_sessions` field in System Settings DocType.
+ company = frappe.db.set_single_value("System Settings", "deny_multiple_sessions", True)
+ """
+ return self.set_value(doctype, doctype, fieldname, value, *args, **kwargs)
+
+ def get_single_value(self, doctype, fieldname, cache=True):
"""Get property of Single DocType. Cache locally by default
:param doctype: DocType of the single object whose value is requested
@@ -549,14 +582,17 @@ class Database(object):
company = frappe.db.get_single_value('Global Defaults', 'default_company')
"""
- if not doctype in self.value_cache:
- self.value_cache = self.value_cache[doctype] = {}
+ if doctype not in self.value_cache:
+ self.value_cache[doctype] = {}
- if fieldname in self.value_cache[doctype]:
+ if cache and fieldname in self.value_cache[doctype]:
return self.value_cache[doctype][fieldname]
- val = self.sql("""select `value` from
- `tabSingles` where `doctype`=%s and `field`=%s""", (doctype, fieldname))
+ val = self.query.get_sql(
+ table="Singles",
+ filters={"doctype": doctype, "field": fieldname},
+ fields="value",
+ ).run()
val = val[0][0] if val else None
df = frappe.get_meta(doctype).get_field(fieldname)
@@ -564,8 +600,7 @@ class Database(object):
if not df:
frappe.throw(_('Invalid field name: {0}').format(frappe.bold(fieldname)), self.InvalidColumnName)
- if df.fieldtype in frappe.model.numeric_fieldtypes:
- val = cint(val)
+ val = cast(df.fieldtype, val)
self.value_cache[doctype][fieldname] = val
@@ -575,44 +610,64 @@ class Database(object):
"""Alias for get_single_value"""
return self.get_single_value(*args, **kwargs)
- def _get_values_from_table(self, fields, filters, doctype, as_dict, debug, order_by=None, update=None, for_update=False):
- fl = []
- if isinstance(fields, (list, tuple)):
- for f in fields:
- if "(" in f or " as " in f: # function
- fl.append(f)
+ def _get_values_from_table(
+ self,
+ fields,
+ filters,
+ doctype,
+ as_dict,
+ debug,
+ order_by=None,
+ update=None,
+ for_update=False,
+ run=True,
+ pluck=False,
+ distinct=False,
+ ):
+ field_objects = []
+
+ if not isinstance(fields, Criterion):
+ for field in fields:
+ if "(" in str(field) or " as " in str(field):
+ field_objects.append(PseudoColumn(field))
else:
- fl.append("`" + f + "`")
- fl = ", ".join(fl)
- else:
- fl = fields
- if fields=="*":
- as_dict = True
+ field_objects.append(field)
- conditions, values = self.build_conditions(filters)
-
- order_by = ("order by " + order_by) if order_by else ""
-
- r = self.sql("select {fields} from `tab{doctype}` {where} {conditions} {order_by} {for_update}"
- .format(
- for_update = 'for update' if for_update else '',
- fields = fl,
- doctype = doctype,
- where = "where" if conditions else "",
- conditions = conditions,
- order_by = order_by),
- values, as_dict=as_dict, debug=debug, update=update)
+ query = self.query.get_sql(
+ table=doctype,
+ filters=filters,
+ orderby=order_by,
+ for_update=for_update,
+ field_objects=field_objects,
+ fields=fields,
+ distinct=distinct,
+ )
+ if (
+ fields == "*"
+ and not isinstance(fields, (list, tuple))
+ and not isinstance(fields, Criterion)
+ ):
+ as_dict = True
+ r = self.sql(
+ query, as_dict=as_dict, debug=debug, update=update, run=run, pluck=pluck
+ )
return r
- def _get_value_for_many_names(self, doctype, names, field, debug=False):
+ def _get_value_for_many_names(self, doctype, names, field, order_by, debug=False, run=True, pluck=False, distinct=False):
names = list(filter(None, names))
-
if names:
- return self.get_all(doctype,
- fields=['name', field],
- filters=[['name', 'in', names]],
- debug=debug, as_list=1)
+ return self.get_all(
+ doctype,
+ fields=field,
+ filters=names,
+ order_by=order_by,
+ pluck=pluck,
+ debug=debug,
+ as_list=1,
+ run=run,
+ distinct=distinct,
+ )
else:
return {}
@@ -637,50 +692,55 @@ class Database(object):
:param debug: Print the query in the developer / js console.
:param for_update: Will add a row-level lock to the value that is being set so that it can be released on commit.
"""
- if not modified:
- modified = now()
- if not modified_by:
- modified_by = frappe.session.user
+ is_single_doctype = not (dn and dt != dn)
+ to_update = field if isinstance(field, dict) else {field: val}
- to_update = {}
if update_modified:
- to_update = {"modified": modified, "modified_by": modified_by}
+ modified = modified or now()
+ modified_by = modified_by or frappe.session.user
+ to_update.update({"modified": modified, "modified_by": modified_by})
+
+ if is_single_doctype:
+ frappe.db.delete(
+ "Singles",
+ filters={"field": ("in", tuple(to_update)), "doctype": dt}, debug=debug
+ )
+
+ singles_data = ((dt, key, sbool(value)) for key, value in to_update.items())
+ query = (
+ frappe.qb.into("Singles")
+ .columns("doctype", "field", "value")
+ .insert(*singles_data)
+ ).run(debug=debug)
+ frappe.clear_document_cache(dt, dt)
- if isinstance(field, dict):
- to_update.update(field)
else:
- to_update.update({field: val})
+ table = DocType(dt)
- if dn and dt!=dn:
- # with table
- set_values = []
- for key in to_update:
- set_values.append('`{0}`=%({0})s'.format(key))
+ if for_update:
+ docnames = tuple(
+ self.get_values(dt, dn, "name", debug=debug, for_update=for_update, pluck=True)
+ ) or (NullValue(),)
+ query = frappe.qb.update(table).where(table.name.isin(docnames))
- for name in self.get_values(dt, dn, 'name', for_update=for_update):
- values = dict(name=name[0])
- values.update(to_update)
+ for docname in docnames:
+ frappe.clear_document_cache(dt, docname)
- self.sql("""update `tab{0}`
- set {1} where name=%(name)s""".format(dt, ', '.join(set_values)),
- values, debug=debug)
- else:
- # for singles
- keys = list(to_update)
- self.sql('''
- delete from `tabSingles`
- where field in ({0}) and
- doctype=%s'''.format(', '.join(['%s']*len(keys))),
- list(keys) + [dt], debug=debug)
- for key, value in iteritems(to_update):
- self.sql('''insert into `tabSingles` (doctype, field, value) values (%s, %s, %s)''',
- (dt, key, value), debug=debug)
+ else:
+ query = self.query.build_conditions(table=dt, filters=dn, update=True)
+ # TODO: Fix this; doesn't work rn - gavin@frappe.io
+ # frappe.cache().hdel_keys(dt, "document_cache")
+ # Workaround: clear all document caches
+ frappe.cache().delete_value('document_cache')
+
+ for column, value in to_update.items():
+ query = query.set(column, value)
+
+ query.run(debug=debug)
if dt in self.value_cache:
del self.value_cache[dt]
- frappe.clear_document_cache(dt, dn)
-
@staticmethod
def set(doc, field, val):
"""Set value in document. **Avoid**"""
@@ -766,14 +826,30 @@ class Database(object):
frappe.local.realtime_log = []
- def rollback(self):
- """`ROLLBACK` current transaction."""
- self.sql("rollback")
- self.begin()
- for obj in frappe.local.rollback_observers:
- if hasattr(obj, "on_rollback"):
- obj.on_rollback()
- frappe.local.rollback_observers = []
+ def savepoint(self, save_point):
+ """Savepoints work as a nested transaction.
+
+ Changes can be undone to a save point by doing frappe.db.rollback(save_point)
+
+ Note: rollback watchers can not work with save points.
+ so only changes to database are undone when rolling back to a savepoint.
+ Avoid using savepoints when writing to filesystem."""
+ self.sql(f"savepoint {save_point}")
+
+ def release_savepoint(self, save_point):
+ self.sql(f"release savepoint {save_point}")
+
+ def rollback(self, *, save_point=None):
+ """`ROLLBACK` current transaction. Optionally rollback to a known save_point."""
+ if save_point:
+ self.sql(f"rollback to savepoint {save_point}")
+ else:
+ self.sql("rollback")
+ self.begin()
+ for obj in frappe.local.rollback_observers:
+ if hasattr(obj, "on_rollback"):
+ obj.on_rollback()
+ frappe.local.rollback_observers = []
def field_exists(self, dt, fn):
"""Return true of field exists."""
@@ -782,16 +858,16 @@ class Database(object):
'parent': dt
})
- def table_exists(self, doctype):
+ def table_exists(self, doctype, cached=True):
"""Returns True if table for given doctype exists."""
- return ("tab" + doctype) in self.get_tables()
+ return ("tab" + doctype) in self.get_tables(cached=cached)
def has_table(self, doctype):
return self.table_exists(doctype)
- def get_tables(self):
+ def get_tables(self, cached=True):
tables = frappe.cache().get_value('db_tables')
- if not tables:
+ if not tables or not cached:
table_rows = self.sql("""
SELECT table_name
FROM information_schema.tables
@@ -810,7 +886,7 @@ class Database(object):
:param dt: DocType name.
:param dn: Document name or filter dict."""
- if isinstance(dt, string_types):
+ if isinstance(dt, str):
if dt!="DocType" and dt==dn:
return True # single always exists (!)
try:
@@ -834,18 +910,14 @@ class Database(object):
cache_count = frappe.cache().get_value('doctype:count:{}'.format(dt))
if cache_count is not None:
return cache_count
+ query = self.query.get_sql(table=dt, filters=filters, fields=Count("*"))
if filters:
- conditions, filters = self.build_conditions(filters)
- count = self.sql("""select count(*)
- from `tab%s` where %s""" % (dt, conditions), filters, debug=debug)[0][0]
+ count = self.sql(query, debug=debug)[0][0]
return count
else:
- count = self.sql("""select count(*)
- from `tab%s`""" % (dt,))[0][0]
-
+ count = self.sql(query, debug=debug)[0][0]
if cache:
frappe.cache().set_value('doctype:count:{}'.format(dt), count, expires_in_sec = 86400)
-
return count
@staticmethod
@@ -857,7 +929,7 @@ class Database(object):
if not datetime:
return '0001-01-01 00:00:00.000000'
- if isinstance(datetime, frappe.string_types):
+ if isinstance(datetime, str):
if ':' not in datetime:
datetime = datetime + ' 00:00:00.000000'
else:
@@ -904,13 +976,13 @@ class Database(object):
WHERE table_name = 'tab{0}' AND column_name = '{1}' '''.format(doctype, column))[0][0]
def has_index(self, table_name, index_name):
- pass
+ raise NotImplementedError
def add_index(self, doctype, fields, index_name=None):
- pass
+ raise NotImplementedError
def add_unique(self, doctype, fields, constraint_name=None):
- pass
+ raise NotImplementedError
@staticmethod
def get_index_name(fields):
@@ -936,7 +1008,7 @@ class Database(object):
def escape(s, percent=True):
"""Excape quotes and percent in given string."""
# implemented in specific class
- pass
+ raise NotImplementedError
@staticmethod
def is_column_missing(e):
@@ -954,22 +1026,37 @@ class Database(object):
return []
def is_missing_table_or_column(self, e):
- return self.is_missing_column(e) or self.is_missing_table(e)
+ return self.is_missing_column(e) or self.is_table_missing(e)
def multisql(self, sql_dict, values=(), **kwargs):
current_dialect = frappe.db.db_type or 'mariadb'
query = sql_dict.get(current_dialect)
return self.sql(query, values, **kwargs)
- def delete(self, doctype, conditions, debug=False):
- if conditions:
- conditions, values = self.build_conditions(conditions)
- return self.sql("DELETE FROM `tab{doctype}` where {conditions}".format(
- doctype=doctype,
- conditions=conditions
- ), values, debug=debug)
- else:
- frappe.throw(_('No conditions provided'))
+ def delete(self, doctype: str, filters: Union[Dict, List] = None, debug=False, **kwargs):
+ """Delete rows from a table in site which match the passed filters. This
+ does trigger DocType hooks. Simply runs a DELETE query in the database.
+
+ Doctype name can be passed directly, it will be pre-pended with `tab`.
+ """
+ values = ()
+ filters = filters or kwargs.get("conditions")
+ query = self.query.build_conditions(table=doctype, filters=filters).delete()
+ if "debug" not in kwargs:
+ kwargs["debug"] = debug
+ return self.sql(query, values, **kwargs)
+
+ def truncate(self, doctype: str):
+ """Truncate a table in the database. This runs a DDL command `TRUNCATE TABLE`.
+ This cannot be rolled back.
+
+ Doctype name can be passed directly, it will be pre-pended with `tab`.
+ """
+ table = doctype if doctype.startswith("__") else f"tab{doctype}"
+ return self.sql_ddl(f"truncate `{table}`")
+
+ def clear_table(self, doctype):
+ return self.truncate(doctype)
def get_last_created(self, doctype):
last_record = self.get_all(doctype, ('creation'), limit=1, order_by='creation desc')
@@ -978,13 +1065,10 @@ class Database(object):
else:
return None
- def clear_table(self, doctype):
- self.sql('truncate `tab{}`'.format(doctype))
-
def log_touched_tables(self, query, values=None):
if values:
query = frappe.safe_decode(self._cursor.mogrify(query, values))
- if query.strip().lower().split()[0] in ('insert', 'delete', 'update', 'alter'):
+ if query.strip().lower().split()[0] in ('insert', 'delete', 'update', 'alter', 'drop', 'rename'):
# single_word_regex is designed to match following patterns
# `tabXxx`, tabXxx and "tabXxx"
@@ -1018,7 +1102,7 @@ class Database(object):
:params values: list of list of values
"""
insert_list = []
- fields = ", ".join(["`"+field+"`" for field in fields])
+ fields = ", ".join("`"+field+"`" for field in fields)
for idx, value in enumerate(values):
insert_list.append(tuple(value))
@@ -1031,7 +1115,10 @@ class Database(object):
), tuple(insert_list))
insert_list = []
+
def enqueue_jobs_after_commit():
+ from frappe.utils.background_jobs import execute_job, get_queue
+
if frappe.flags.enqueue_after_commit and len(frappe.flags.enqueue_after_commit) > 0:
for job in frappe.flags.enqueue_after_commit:
q = get_queue(job.get("queue"), is_async=job.get("is_async"))
@@ -1039,18 +1126,27 @@ def enqueue_jobs_after_commit():
kwargs=job.get("queue_args"))
frappe.flags.enqueue_after_commit = []
-# Helpers
-def _cast_result(doctype, result):
- batch = [ ]
+@contextmanager
+def savepoint(catch: Union[type, Tuple[type, ...]] = Exception):
+ """ Wrapper for wrapping blocks of DB operations in a savepoint.
+ as contextmanager:
+
+ for doc in docs:
+ with savepoint(catch=DuplicateError):
+ doc.insert()
+
+ as decorator (wraps FULL function call):
+
+ @savepoint(catch=DuplicateError)
+ def process_doc(doc):
+ doc.insert()
+ """
try:
- for field, value in result:
- df = frappe.get_meta(doctype).get_field(field)
- if df:
- value = cast_fieldtype(df.fieldtype, value)
-
- batch.append(tuple([field, value]))
- except frappe.exceptions.DoesNotExistError:
- return result
-
- return tuple(batch)
+ savepoint = ''.join(random.sample(string.ascii_lowercase, 10))
+ frappe.db.savepoint(savepoint)
+ yield # control back to calling function
+ except catch:
+ frappe.db.rollback(save_point=savepoint)
+ else:
+ frappe.db.release_savepoint(savepoint)
diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py
index 3cbb2e4f0e..a6d5e7b3f2 100644
--- a/frappe/database/mariadb/database.py
+++ b/frappe/database/mariadb/database.py
@@ -1,18 +1,13 @@
-from __future__ import unicode_literals
-
-import frappe
-import warnings
+from typing import List, Tuple, Union
import pymysql
-from pymysql.times import TimeDelta
-from pymysql.constants import ER, FIELD_TYPE
-from pymysql.converters import conversions
+from pymysql.constants import ER, FIELD_TYPE
+from pymysql.converters import conversions, escape_string
-from frappe.utils import get_datetime, cstr
-from markdown2 import UnicodeWithAttrs
+import frappe
from frappe.database.database import Database
-from six import PY2, binary_type, text_type, string_types
from frappe.database.mariadb.schema import MariaDBTable
+from frappe.utils import UnicodeWithAttrs, cstr, get_datetime, get_table_name
class MariaDBDatabase(Database):
@@ -27,11 +22,11 @@ class MariaDBDatabase(Database):
def setup_type_map(self):
self.db_type = 'mariadb'
self.type_map = {
- 'Currency': ('decimal', '18,6'),
+ 'Currency': ('decimal', '21,9'),
'Int': ('int', '11'),
'Long Int': ('bigint', '20'),
- 'Float': ('decimal', '18,6'),
- 'Percent': ('decimal', '18,6'),
+ 'Float': ('decimal', '21,9'),
+ 'Percent': ('decimal', '21,9'),
'Check': ('int', '1'),
'Small Text': ('text', ''),
'Long Text': ('longtext', ''),
@@ -48,7 +43,7 @@ class MariaDBDatabase(Database):
'Dynamic Link': ('varchar', self.VARCHAR_LEN),
'Password': ('text', ''),
'Select': ('varchar', self.VARCHAR_LEN),
- 'Rating': ('int', '1'),
+ 'Rating': ('decimal', '3,2'),
'Read Only': ('varchar', self.VARCHAR_LEN),
'Attach': ('text', ''),
'Attach Image': ('text', ''),
@@ -56,11 +51,12 @@ class MariaDBDatabase(Database):
'Color': ('varchar', self.VARCHAR_LEN),
'Barcode': ('longtext', ''),
'Geolocation': ('longtext', ''),
- 'Duration': ('decimal', '18,6')
+ 'Duration': ('decimal', '21,9'),
+ 'Icon': ('varchar', self.VARCHAR_LEN),
+ 'Autocomplete': ('varchar', self.VARCHAR_LEN),
}
def get_connection(self):
- warnings.filterwarnings('ignore', category=pymysql.Warning)
usessl = 0
if frappe.conf.db_ssl_ca and frappe.conf.db_ssl_cert and frappe.conf.db_ssl_key:
usessl = 1
@@ -73,22 +69,20 @@ class MariaDBDatabase(Database):
conversions.update({
FIELD_TYPE.NEWDECIMAL: float,
FIELD_TYPE.DATETIME: get_datetime,
- UnicodeWithAttrs: conversions[text_type]
+ UnicodeWithAttrs: conversions[str]
})
- if PY2:
- conversions.update({
- TimeDelta: conversions[binary_type]
- })
-
- if usessl:
- conn = pymysql.connect(self.host, self.user or '', self.password or '',
- port=self.port, charset='utf8mb4', use_unicode = True, ssl=ssl_params,
- conv = conversions, local_infile = frappe.conf.local_infile)
- else:
- conn = pymysql.connect(self.host, self.user or '', self.password or '',
- port=self.port, charset='utf8mb4', use_unicode = True, conv = conversions,
- local_infile = frappe.conf.local_infile)
+ conn = pymysql.connect(
+ user=self.user or '',
+ password=self.password or '',
+ host=self.host,
+ port=self.port,
+ charset='utf8mb4',
+ use_unicode=True,
+ ssl=ssl_params if usessl else None,
+ conv=conversions,
+ local_infile=frappe.conf.local_infile
+ )
# MYSQL_OPTION_MULTI_STATEMENTS_OFF = 1
# # self._conn.set_server_option(MYSQL_OPTION_MULTI_STATEMENTS_OFF)
@@ -112,7 +106,7 @@ class MariaDBDatabase(Database):
def escape(s, percent=True):
"""Excape quotes and percent in given string."""
# pymysql expects unicode argument to escape_string with Python 3
- s = frappe.as_unicode(pymysql.escape_string(frappe.as_unicode(s)), "utf-8").replace("`", "\\`")
+ s = frappe.as_unicode(escape_string(frappe.as_unicode(s)), "utf-8").replace("`", "\\`")
# NOTE separating % escape, because % escape should only be done when using LIKE operator
# or when you use python format string to generate query that already has a %s
@@ -133,6 +127,20 @@ class MariaDBDatabase(Database):
def is_type_datetime(code):
return code in (pymysql.DATE, pymysql.DATETIME)
+ def rename_table(self, old_name: str, new_name: str) -> Union[List, Tuple]:
+ old_name = get_table_name(old_name)
+ new_name = get_table_name(new_name)
+ return self.sql(f"RENAME TABLE `{old_name}` TO `{new_name}`")
+
+ def describe(self, doctype: str) -> Union[List, Tuple]:
+ table_name = get_table_name(doctype)
+ return self.sql(f"DESC `{table_name}`")
+
+ def change_column_type(self, doctype: str, column: str, type: str, nullable: bool = False) -> Union[List, Tuple]:
+ table_name = get_table_name(doctype)
+ null_constraint = "NOT NULL" if not nullable else ""
+ return self.sql(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} {null_constraint}")
+
# exception types
@staticmethod
def is_deadlocked(e):
@@ -146,6 +154,10 @@ class MariaDBDatabase(Database):
def is_table_missing(e):
return e.args[0] == ER.NO_SUCH_TABLE
+ @staticmethod
+ def is_missing_table(e):
+ return MariaDBDatabase.is_table_missing(e)
+
@staticmethod
def is_missing_column(e):
return e.args[0] == ER.BAD_FIELD_ERROR
@@ -189,7 +201,7 @@ class MariaDBDatabase(Database):
`password` TEXT NOT NULL,
`encrypted` INT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`doctype`, `name`, `fieldname`)
- ) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci""")
+ ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci""")
def create_global_search_table(self):
if not '__global_search' in self.get_tables():
@@ -238,9 +250,16 @@ class MariaDBDatabase(Database):
column_name as 'name',
column_type as 'type',
column_default as 'default',
- column_key = 'MUL' as 'index',
+ COALESCE(
+ (select 1
+ from information_schema.statistics
+ where table_name="{table_name}"
+ and column_name=columns.column_name
+ and NON_UNIQUE=1
+ limit 1
+ ), 0) as 'index',
column_key = 'UNI' as 'unique'
- from information_schema.columns
+ from information_schema.columns as columns
where table_name = '{table_name}' '''.format(table_name=table_name), as_dict=1)
def has_index(self, table_name, index_name):
@@ -250,18 +269,18 @@ class MariaDBDatabase(Database):
index_name=index_name
))
- def add_index(self, doctype, fields, index_name=None):
+ def add_index(self, doctype: str, fields: List, index_name: str = None):
"""Creates an index with given fields if not already created.
Index name will be `fieldname1_fieldname2_index`"""
index_name = index_name or self.get_index_name(fields)
- table_name = 'tab' + doctype
+ table_name = get_table_name(doctype)
if not self.has_index(table_name, index_name):
self.commit()
self.sql("""ALTER TABLE `%s`
ADD INDEX `%s`(%s)""" % (table_name, index_name, ", ".join(fields)))
def add_unique(self, doctype, fields, constraint_name=None):
- if isinstance(fields, string_types):
+ if isinstance(fields, str):
fields = [fields]
if not constraint_name:
constraint_name = "unique_" + "_".join(fields)
diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql
index a52efd01e3..f2a1206c7c 100644
--- a/frappe/database/mariadb/framework_mariadb.sql
+++ b/frappe/database/mariadb/framework_mariadb.sql
@@ -25,6 +25,7 @@ CREATE TABLE `tabDocField` (
`oldfieldtype` varchar(255) DEFAULT NULL,
`options` text,
`search_index` int(1) NOT NULL DEFAULT 0,
+ `show_dashboard` int(1) NOT NULL DEFAULT 0,
`hidden` int(1) NOT NULL DEFAULT 0,
`set_only_once` int(1) NOT NULL DEFAULT 0,
`allow_in_quick_entry` int(1) NOT NULL DEFAULT 0,
@@ -61,6 +62,7 @@ CREATE TABLE `tabDocField` (
`in_preview` int(1) NOT NULL DEFAULT 0,
`read_only` int(1) NOT NULL DEFAULT 0,
`precision` varchar(255) DEFAULT NULL,
+ `max_height` varchar(10) DEFAULT NULL,
`length` int(11) NOT NULL DEFAULT 0,
`translatable` int(1) NOT NULL DEFAULT 0,
`hide_border` int(1) NOT NULL DEFAULT 0,
@@ -71,7 +73,7 @@ CREATE TABLE `tabDocField` (
KEY `label` (`label`),
KEY `fieldtype` (`fieldtype`),
KEY `fieldname` (`fieldname`)
-) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
@@ -108,7 +110,7 @@ CREATE TABLE `tabDocPerm` (
`email` int(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`name`),
KEY `parent` (`parent`)
-) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
-- Table structure for table `tabDocType Action`
@@ -132,7 +134,7 @@ CREATE TABLE `tabDocType Action` (
PRIMARY KEY (`name`),
KEY `parent` (`parent`),
KEY `modified` (`modified`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=COMPRESSED;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
--
-- Table structure for table `tabDocType Action`
@@ -155,7 +157,7 @@ CREATE TABLE `tabDocType Link` (
PRIMARY KEY (`name`),
KEY `parent` (`parent`),
KEY `modified` (`modified`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=COMPRESSED;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
--
-- Table structure for table `tabDocType`
@@ -169,9 +171,6 @@ CREATE TABLE `tabDocType` (
`modified_by` varchar(255) DEFAULT NULL,
`owner` varchar(255) DEFAULT NULL,
`docstatus` int(1) NOT NULL DEFAULT 0,
- `parent` varchar(255) DEFAULT NULL,
- `parentfield` varchar(255) DEFAULT NULL,
- `parenttype` varchar(255) DEFAULT NULL,
`idx` int(8) NOT NULL DEFAULT 0,
`search_fields` varchar(255) DEFAULT NULL,
`issingle` int(1) NOT NULL DEFAULT 0,
@@ -183,6 +182,7 @@ CREATE TABLE `tabDocType` (
`restrict_to_domain` varchar(255) DEFAULT NULL,
`app` varchar(255) DEFAULT NULL,
`autoname` varchar(255) DEFAULT NULL,
+ `naming_rule` varchar(40) DEFAULT NULL,
`name_case` varchar(255) DEFAULT NULL,
`title_field` varchar(255) DEFAULT NULL,
`image_field` varchar(255) DEFAULT NULL,
@@ -220,12 +220,14 @@ CREATE TABLE `tabDocType` (
`allow_guest_to_view` int(1) NOT NULL DEFAULT 0,
`route` varchar(255) DEFAULT NULL,
`is_published_field` varchar(255) DEFAULT NULL,
+ `website_search_field` varchar(255) DEFAULT NULL,
`email_append_to` int(1) NOT NULL DEFAULT 0,
`subject_field` varchar(255) DEFAULT NULL,
`sender_field` varchar(255) DEFAULT NULL,
- PRIMARY KEY (`name`),
- KEY `parent` (`parent`)
-) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+ `show_title_field_in_link` int(1) NOT NULL DEFAULT 0,
+ `migration_hash` varchar(255) DEFAULT NULL,
+ PRIMARY KEY (`name`)
+) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
-- Table structure for table `tabSeries`
@@ -236,7 +238,7 @@ CREATE TABLE `tabSeries` (
`name` varchar(100),
`current` int(10) NOT NULL DEFAULT 0,
PRIMARY KEY(`name`)
-) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
@@ -253,7 +255,7 @@ CREATE TABLE `tabSessions` (
`device` varchar(255) DEFAULT 'desktop',
`status` varchar(20) DEFAULT NULL,
KEY `sid` (`sid`)
-) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
@@ -266,7 +268,7 @@ CREATE TABLE `tabSingles` (
`field` varchar(255) DEFAULT NULL,
`value` text,
KEY `singles_doctype_field_index` (`doctype`, `field`)
-) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
-- Table structure for table `__Auth`
@@ -280,7 +282,7 @@ CREATE TABLE `__Auth` (
`password` TEXT NOT NULL,
`encrypted` INT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`doctype`, `name`, `fieldname`)
-) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
-- Table structure for table `tabFile`
@@ -308,7 +310,7 @@ CREATE TABLE `tabFile` (
KEY `parent` (`parent`),
KEY `attached_to_name` (`attached_to_name`),
KEY `attached_to_doctype` (`attached_to_doctype`)
-) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
-- Table structure for table `tabDefaultValue`
@@ -331,4 +333,4 @@ CREATE TABLE `tabDefaultValue` (
PRIMARY KEY (`name`),
KEY `parent` (`parent`),
KEY `defaultvalue_parent_defkey_index` (`parent`,`defkey`)
-) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/frappe/database/mariadb/schema.py b/frappe/database/mariadb/schema.py
index 4bbecd2a2e..3b7aa443f2 100644
--- a/frappe/database/mariadb/schema.py
+++ b/frappe/database/mariadb/schema.py
@@ -1,40 +1,73 @@
-from __future__ import unicode_literals
-
import frappe
from frappe import _
from frappe.database.schema import DBTable
+from frappe.database.sequence import create_sequence
+from frappe.model import log_types
+
class MariaDBTable(DBTable):
def create(self):
- add_text = ''
+ additional_definitions = ""
+ engine = self.meta.get("engine") or "InnoDB"
+ varchar_len = frappe.db.VARCHAR_LEN
+ name_column = f"name varchar({varchar_len}) primary key"
# columns
column_defs = self.get_column_definitions()
- if column_defs: add_text += ',\n'.join(column_defs) + ',\n'
+ if column_defs:
+ additional_definitions += ',\n'.join(column_defs) + ',\n'
# index
index_defs = self.get_index_definitions()
- if index_defs: add_text += ',\n'.join(index_defs) + ',\n'
+ if index_defs:
+ additional_definitions += ',\n'.join(index_defs) + ',\n'
+
+ # child table columns
+ if self.meta.get("istable") or 0:
+ additional_definitions += ',\n'.join(
+ (
+ f"parent varchar({varchar_len})",
+ f"parentfield varchar({varchar_len})",
+ f"parenttype varchar({varchar_len})",
+ "index parent(parent)"
+ )
+ ) + ',\n'
+
+ # creating sequence(s)
+ if (not self.meta.issingle and self.meta.autoname == "autoincrement")\
+ or self.doctype in log_types:
+
+ # NOTE: using a very small cache - as during backup, if the sequence was used in anyform,
+ # it drops the cache and uses the next non cached value in setval func and
+ # puts that in the backup file, which will start the counter
+ # from that value when inserting any new record in the doctype.
+ # By default the cache is 1000 which will mess up the sequence when
+ # using the system after a restore.
+ # issue link: https://jira.mariadb.org/browse/MDEV-21786
+ create_sequence(self.doctype, check_not_exists=True, cache=50)
+
+ # NOTE: not used nextval func as default as the ability to restore
+ # database with sequences has bugs in mariadb and gives a scary error.
+ # issue link: https://jira.mariadb.org/browse/MDEV-21786
+ name_column = "name bigint primary key"
# create table
- frappe.db.sql("""create table `%s` (
- name varchar({varchar_len}) not null primary key,
+ query = f"""create table `{self.table_name}` (
+ {name_column},
creation datetime(6),
modified datetime(6),
modified_by varchar({varchar_len}),
owner varchar({varchar_len}),
docstatus int(1) not null default '0',
- parent varchar({varchar_len}),
- parentfield varchar({varchar_len}),
- parenttype varchar({varchar_len}),
idx int(8) not null default '0',
- %sindex parent(parent),
+ {additional_definitions}
index modified(modified))
ENGINE={engine}
- ROW_FORMAT=COMPRESSED
+ ROW_FORMAT=DYNAMIC
CHARACTER SET=utf8mb4
- COLLATE=utf8mb4_unicode_ci""".format(varchar_len=frappe.db.VARCHAR_LEN,
- engine=self.meta.get("engine") or 'InnoDB') % (self.table_name, add_text))
+ COLLATE=utf8mb4_unicode_ci"""
+
+ frappe.db.sql(query)
def alter(self):
for col in self.columns.values():
@@ -54,18 +87,34 @@ class MariaDBTable(DBTable):
modify_column_query.append("MODIFY `{}` {}".format(col.fieldname, col.get_definition()))
for col in self.add_index:
- # if index key not exists
- if not frappe.db.sql("SHOW INDEX FROM `%s` WHERE key_name = %s" %
- (self.table_name, '%s'), col.fieldname):
- add_index_query.append("ADD INDEX `{}`(`{}`)".format(col.fieldname, col.fieldname))
+ # if index key does not exists
+ if not frappe.db.has_index(self.table_name, col.fieldname + '_index'):
+ add_index_query.append("ADD INDEX `{}_index`(`{}`)".format(col.fieldname, col.fieldname))
- for col in self.drop_index:
+ for col in self.drop_index + self.drop_unique:
if col.fieldname != 'name': # primary key
+ current_column = self.current_columns.get(col.fieldname.lower())
+ unique_constraint_changed = current_column.unique != col.unique
+ if unique_constraint_changed and not col.unique:
+ # nosemgrep
+ unique_index_record = frappe.db.sql("""
+ SHOW INDEX FROM `{0}`
+ WHERE Key_name=%s
+ AND Non_unique=0
+ """.format(self.table_name), (col.fieldname), as_dict=1)
+ if unique_index_record:
+ drop_index_query.append("DROP INDEX `{}`".format(unique_index_record[0].Key_name))
+ index_constraint_changed = current_column.index != col.set_index
# if index key exists
- if frappe.db.sql("""SHOW INDEX FROM `{0}`
- WHERE key_name=%s
- AND Non_unique=%s""".format(self.table_name), (col.fieldname, col.unique)):
- drop_index_query.append("drop index `{}`".format(col.fieldname))
+ if index_constraint_changed and not col.set_index:
+ # nosemgrep
+ index_record = frappe.db.sql("""
+ SHOW INDEX FROM `{0}`
+ WHERE Key_name=%s
+ AND Non_unique=1
+ """.format(self.table_name), (col.fieldname + '_index'), as_dict=1)
+ if index_record:
+ drop_index_query.append("DROP INDEX `{}`".format(index_record[0].Key_name))
try:
for query_parts in [add_column_query, modify_column_query, add_index_query, drop_index_query]:
diff --git a/frappe/database/mariadb/setup_db.py b/frappe/database/mariadb/setup_db.py
index 9b73d77171..1585e4537b 100644
--- a/frappe/database/mariadb/setup_db.py
+++ b/frappe/database/mariadb/setup_db.py
@@ -1,5 +1,3 @@
-from __future__ import unicode_literals
-
import frappe
import os
from frappe.database.db_manager import DbManager
@@ -36,25 +34,23 @@ def setup_database(force, source_sql, verbose, no_mariadb_socket=False):
db_name = frappe.local.conf.db_name
root_conn = get_root_connection(frappe.flags.root_login, frappe.flags.root_password)
dbman = DbManager(root_conn)
+ dbman_kwargs = {}
+ if no_mariadb_socket:
+ dbman_kwargs["host"] = "%"
+
if force or (db_name not in dbman.get_database_list()):
- dbman.delete_user(db_name)
- if no_mariadb_socket:
- dbman.delete_user(db_name, host="%")
+ dbman.delete_user(db_name, **dbman_kwargs)
dbman.drop_database(db_name)
else:
raise Exception("Database %s already exists" % (db_name,))
- dbman.create_user(db_name, frappe.conf.db_password)
- if no_mariadb_socket:
- dbman.create_user(db_name, frappe.conf.db_password, host="%")
+ dbman.create_user(db_name, frappe.conf.db_password, **dbman_kwargs)
if verbose: print("Created user %s" % db_name)
dbman.create_database(db_name)
if verbose: print("Created database %s" % db_name)
- dbman.grant_all_privileges(db_name, db_name)
- if no_mariadb_socket:
- dbman.grant_all_privileges(db_name, db_name, host="%")
+ dbman.grant_all_privileges(db_name, db_name, **dbman_kwargs)
dbman.flush_privileges()
if verbose: print("Granted privileges to user %s and database %s" % (db_name, db_name))
@@ -96,7 +92,7 @@ def bootstrap_database(db_name, verbose, source_sql=None):
import_db_from_sql(source_sql, verbose)
frappe.connect(db_name=db_name)
- if 'tabDefaultValue' not in frappe.db.get_tables():
+ if 'tabDefaultValue' not in frappe.db.get_tables(cached=False):
from click import secho
secho(
diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py
index 4faea78551..eb3e33d39c 100644
--- a/frappe/database/postgres/database.py
+++ b/frappe/database/postgres/database.py
@@ -1,21 +1,21 @@
-from __future__ import unicode_literals
-
import re
-import frappe
+from typing import List, Tuple, Union
+
import psycopg2
import psycopg2.extensions
-from six import string_types
-from frappe.utils import cstr
-from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
+from psycopg2.extensions import ISOLATION_LEVEL_REPEATABLE_READ
+from psycopg2.errorcodes import STRING_DATA_RIGHT_TRUNCATION
+import frappe
from frappe.database.database import Database
from frappe.database.postgres.schema import PostgresTable
+from frappe.utils import cstr, get_table_name
# cast decimals as floats
DEC2FLOAT = psycopg2.extensions.new_type(
- psycopg2.extensions.DECIMAL.values,
- 'DEC2FLOAT',
- lambda value, curs: float(value) if value is not None else None)
+ psycopg2.extensions.DECIMAL.values,
+ 'DEC2FLOAT',
+ lambda value, curs: float(value) if value is not None else None)
psycopg2.extensions.register_type(DEC2FLOAT)
@@ -32,11 +32,11 @@ class PostgresDatabase(Database):
def setup_type_map(self):
self.db_type = 'postgres'
self.type_map = {
- 'Currency': ('decimal', '18,6'),
+ 'Currency': ('decimal', '21,9'),
'Int': ('bigint', None),
'Long Int': ('bigint', None),
- 'Float': ('decimal', '18,6'),
- 'Percent': ('decimal', '18,6'),
+ 'Float': ('decimal', '21,9'),
+ 'Percent': ('decimal', '21,9'),
'Check': ('smallint', None),
'Small Text': ('text', ''),
'Long Text': ('text', ''),
@@ -53,7 +53,7 @@ class PostgresDatabase(Database):
'Dynamic Link': ('varchar', self.VARCHAR_LEN),
'Password': ('text', ''),
'Select': ('varchar', self.VARCHAR_LEN),
- 'Rating': ('smallint', None),
+ 'Rating': ('decimal', '3,2'),
'Read Only': ('varchar', self.VARCHAR_LEN),
'Attach': ('text', ''),
'Attach Image': ('text', ''),
@@ -61,23 +61,30 @@ class PostgresDatabase(Database):
'Color': ('varchar', self.VARCHAR_LEN),
'Barcode': ('text', ''),
'Geolocation': ('text', ''),
- 'Duration': ('decimal', '18,6')
+ 'Duration': ('decimal', '21,9'),
+ 'Icon': ('varchar', self.VARCHAR_LEN),
+ 'Autocomplete': ('varchar', self.VARCHAR_LEN),
}
def get_connection(self):
- # warnings.filterwarnings('ignore', category=psycopg2.Warning)
conn = psycopg2.connect("host='{}' dbname='{}' user='{}' password='{}' port={}".format(
self.host, self.user, self.user, self.password, self.port
))
- conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) # TODO: Remove this
+ conn.set_isolation_level(ISOLATION_LEVEL_REPEATABLE_READ)
return conn
def escape(self, s, percent=True):
- """Excape quotes and percent in given string."""
+ """Escape quotes and percent in given string."""
if isinstance(s, bytes):
s = s.decode('utf-8')
+ # MariaDB's driver treats None as an empty string
+ # So Postgres should do the same
+
+ if s is None:
+ s = ''
+
if percent:
s = s.replace("%", "%%")
@@ -92,18 +99,15 @@ class PostgresDatabase(Database):
return db_size[0].get('database_size')
# pylint: disable=W0221
- def sql(self, *args, **kwargs):
- if args:
- # since tuple is immutable
- args = list(args)
- args[0] = modify_query(args[0])
- args = tuple(args)
- elif kwargs.get('query'):
- kwargs['query'] = modify_query(kwargs.get('query'))
+ def sql(self, query, values=(), *args, **kwargs):
+ return super(PostgresDatabase, self).sql(
+ modify_query(query),
+ modify_values(values),
+ *args,
+ **kwargs
+ )
- return super(PostgresDatabase, self).sql(*args, **kwargs)
-
- def get_tables(self):
+ def get_tables(self, cached=True):
return [d[0] for d in self.sql("""select table_name
from information_schema.tables
where table_catalog='{0}'
@@ -114,7 +118,7 @@ class PostgresDatabase(Database):
if not date:
return '0001-01-01'
- if not isinstance(date, frappe.string_types):
+ if not isinstance(date, str):
date = date.strftime('%Y-%m-%d')
return date
@@ -138,10 +142,18 @@ class PostgresDatabase(Database):
# http://initd.org/psycopg/docs/extensions.html?highlight=datatype#psycopg2.extensions.QueryCanceledError
return isinstance(e, psycopg2.extensions.QueryCanceledError)
+ @staticmethod
+ def is_syntax_error(e):
+ return isinstance(e, psycopg2.errors.SyntaxError)
+
@staticmethod
def is_table_missing(e):
return getattr(e, 'pgcode', None) == '42P01'
+ @staticmethod
+ def is_missing_table(e):
+ return PostgresDatabase.is_table_missing(e)
+
@staticmethod
def is_missing_column(e):
return getattr(e, 'pgcode', None) == '42703'
@@ -160,11 +172,11 @@ class PostgresDatabase(Database):
@staticmethod
def is_primary_key_violation(e):
- return e.pgcode == '23505' and '_pkey' in cstr(e.args[0])
+ return getattr(e, "pgcode", None) == '23505' and '_pkey' in cstr(e.args[0])
@staticmethod
def is_unique_key_violation(e):
- return e.pgcode == '23505' and '_key' in cstr(e.args[0])
+ return getattr(e, "pgcode", None) == '23505' and '_key' in cstr(e.args[0])
@staticmethod
def is_duplicate_fieldname(e):
@@ -172,7 +184,23 @@ class PostgresDatabase(Database):
@staticmethod
def is_data_too_long(e):
- return e.pgcode == '22001'
+ return e.pgcode == STRING_DATA_RIGHT_TRUNCATION
+
+ def rename_table(self, old_name: str, new_name: str) -> Union[List, Tuple]:
+ old_name = get_table_name(old_name)
+ new_name = get_table_name(new_name)
+ return self.sql(f"ALTER TABLE `{old_name}` RENAME TO `{new_name}`")
+
+ def describe(self, doctype: str)-> Union[List, Tuple]:
+ table_name = get_table_name(doctype)
+ return self.sql(f"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME = '{table_name}'")
+
+ def change_column_type(self, doctype: str, column: str, type: str, nullable: bool = False) -> Union[List, Tuple]:
+ table_name = get_table_name(doctype)
+ null_constraint = "SET NOT NULL" if not nullable else "DROP NOT NULL"
+ return self.sql(f"""ALTER TABLE "{table_name}"
+ ALTER COLUMN "{column}" TYPE {type},
+ ALTER COLUMN "{column}" {null_constraint}""")
def create_auth_table(self):
self.sql_ddl("""create table if not exists "__Auth" (
@@ -239,24 +267,24 @@ class PostgresDatabase(Database):
key=key
)
- def check_transaction_status(self, query):
- pass
+ def check_implicit_commit(self, query):
+ pass # postgres can run DDL in transactions without implicit commits
def has_index(self, table_name, index_name):
return self.sql("""SELECT 1 FROM pg_indexes WHERE tablename='{table_name}'
and indexname='{index_name}' limit 1""".format(table_name=table_name, index_name=index_name))
- def add_index(self, doctype, fields, index_name=None):
+ def add_index(self, doctype: str, fields: List, index_name: str = None):
"""Creates an index with given fields if not already created.
Index name will be `fieldname1_fieldname2_index`"""
+ table_name = get_table_name(doctype)
index_name = index_name or self.get_index_name(fields)
- table_name = 'tab' + doctype
+ fields_str = '", "'.join(re.sub(r"\(.*\)", "", field) for field in fields)
- self.commit()
- self.sql("""CREATE INDEX IF NOT EXISTS "{}" ON `{}`("{}")""".format(index_name, table_name, '", "'.join(fields)))
+ self.sql_ddl(f'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}` ("{fields_str}")')
def add_unique(self, doctype, fields, constraint_name=None):
- if isinstance(fields, string_types):
+ if isinstance(fields, str):
fields = [fields]
if not constraint_name:
constraint_name = "unique_" + "_".join(fields)
@@ -282,18 +310,20 @@ class PostgresDatabase(Database):
WHEN 'timestamp without time zone' THEN 'timestamp'
ELSE a.data_type
END AS type,
- COUNT(b.indexdef) AS Index,
+ BOOL_OR(b.index) AS index,
SPLIT_PART(COALESCE(a.column_default, NULL), '::', 1) AS default,
BOOL_OR(b.unique) AS unique
FROM information_schema.columns a
LEFT JOIN
- (SELECT indexdef, tablename, indexdef LIKE '%UNIQUE INDEX%' AS unique
+ (SELECT indexdef, tablename,
+ indexdef LIKE '%UNIQUE INDEX%' AS unique,
+ indexdef NOT LIKE '%UNIQUE INDEX%' AS index
FROM pg_indexes
WHERE tablename='{table_name}') b
- ON SUBSTRING(b.indexdef, '\(.*\)') LIKE CONCAT('%', a.column_name, '%')
+ ON SUBSTRING(b.indexdef, '(.*)') LIKE CONCAT('%', a.column_name, '%')
WHERE a.table_name = '{table_name}'
- GROUP BY a.column_name, a.data_type, a.column_default, a.character_maximum_length;'''
- .format(table_name=table_name), as_dict=1)
+ GROUP BY a.column_name, a.data_type, a.column_default, a.character_maximum_length;
+ '''.format(table_name=table_name), as_dict=1)
def get_database_list(self, target):
return [d[0] for d in self.sql("SELECT datname FROM pg_database;")]
@@ -301,16 +331,52 @@ class PostgresDatabase(Database):
def modify_query(query):
""""Modifies query according to the requirements of postgres"""
# replace ` with " for definitions
+ query = str(query)
query = query.replace('`', '"')
query = replace_locate_with_strpos(query)
# select from requires ""
if re.search('from tab', query, flags=re.IGNORECASE):
- query = re.sub('from tab([a-zA-Z]*)', r'from "tab\1"', query, flags=re.IGNORECASE)
+ query = re.sub(r'from tab([\w-]*)', r'from "tab\1"', query, flags=re.IGNORECASE)
+ # only find int (with/without signs), ignore decimals (with/without signs), ignore hashes (which start with numbers),
+ # drop .0 from decimals and add quotes around them
+ #
+ # >>> query = "c='abcd' , a >= 45, b = -45.0, c = 40, d=4500.0, e=3500.53, f=40psdfsd, g=9092094312, h=12.00023"
+ # >>> re.sub(r"([=><]+)\s*(?!\d+[a-zA-Z])(?![+-]?\d+\.\d\d+)([+-]?\d+)(\.0)?", r"\1 '\2'", query)
+ # "c='abcd' , a >= '45', b = '-45', c = '40', d= '4500', e=3500.53, f=40psdfsd, g= '9092094312', h=12.00023
+
+ query = re.sub(r"([=><]+)\s*(?!\d+[a-zA-Z])(?![+-]?\d+\.\d\d+)([+-]?\d+)(\.0)?", r"\1 '\2'", query)
return query
+def modify_values(values):
+ def stringify_value(value):
+ if isinstance(value, int):
+ value = str(value)
+ elif isinstance(value, float):
+ truncated_float = int(value)
+ if value == truncated_float:
+ value = str(truncated_float)
+
+ return value
+
+ if not values:
+ return values
+
+ if isinstance(values, dict):
+ for k, v in values.items():
+ values[k] = stringify_value(v)
+ elif isinstance(values, (tuple, list)):
+ new_values = []
+ for val in values:
+ new_values.append(stringify_value(val))
+ values = new_values
+ else:
+ values = stringify_value(values)
+
+ return values
+
def replace_locate_with_strpos(query):
# strpos is the locate equivalent in postgres
if re.search(r'locate\(', query, flags=re.IGNORECASE):
- query = re.sub(r'locate\(([^,]+),([^)]+)\)', r'strpos(\2, \1)', query, flags=re.IGNORECASE)
+ query = re.sub(r'locate\(([^,]+),([^)]+)(\)?)\)', r'strpos(\2\3, \1)', query, flags=re.IGNORECASE)
return query
diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql
index eeb0eecd3f..1e79bf67d8 100644
--- a/frappe/database/postgres/framework_postgres.sql
+++ b/frappe/database/postgres/framework_postgres.sql
@@ -27,6 +27,7 @@ CREATE TABLE "tabDocField" (
"search_index" smallint NOT NULL DEFAULT 0,
"hidden" smallint NOT NULL DEFAULT 0,
"set_only_once" smallint NOT NULL DEFAULT 0,
+ "show_dashboard" smallint NOT NULL DEFAULT 0,
"allow_in_quick_entry" smallint NOT NULL DEFAULT 0,
"print_hide" smallint NOT NULL DEFAULT 0,
"report_hide" smallint NOT NULL DEFAULT 0,
@@ -61,6 +62,7 @@ CREATE TABLE "tabDocField" (
"in_preview" smallint NOT NULL DEFAULT 0,
"read_only" smallint NOT NULL DEFAULT 0,
"precision" varchar(255) DEFAULT NULL,
+ "max_height" varchar(10) DEFAULT NULL,
"length" bigint NOT NULL DEFAULT 0,
"translatable" smallint NOT NULL DEFAULT 0,
"hide_border" smallint NOT NULL DEFAULT 0,
@@ -174,9 +176,6 @@ CREATE TABLE "tabDocType" (
"modified_by" varchar(255) DEFAULT NULL,
"owner" varchar(255) DEFAULT NULL,
"docstatus" smallint NOT NULL DEFAULT 0,
- "parent" varchar(255) DEFAULT NULL,
- "parentfield" varchar(255) DEFAULT NULL,
- "parenttype" varchar(255) DEFAULT NULL,
"idx" bigint NOT NULL DEFAULT 0,
"search_fields" varchar(255) DEFAULT NULL,
"issingle" smallint NOT NULL DEFAULT 0,
@@ -188,6 +187,7 @@ CREATE TABLE "tabDocType" (
"restrict_to_domain" varchar(255) DEFAULT NULL,
"app" varchar(255) DEFAULT NULL,
"autoname" varchar(255) DEFAULT NULL,
+ "naming_rule" varchar(40) DEFAULT NULL,
"name_case" varchar(255) DEFAULT NULL,
"title_field" varchar(255) DEFAULT NULL,
"image_field" varchar(255) DEFAULT NULL,
@@ -225,9 +225,12 @@ CREATE TABLE "tabDocType" (
"allow_guest_to_view" smallint NOT NULL DEFAULT 0,
"route" varchar(255) DEFAULT NULL,
"is_published_field" varchar(255) DEFAULT NULL,
+ "website_search_field" varchar(255) DEFAULT NULL,
"email_append_to" smallint NOT NULL DEFAULT 0,
"subject_field" varchar(255) DEFAULT NULL,
"sender_field" varchar(255) DEFAULT NULL,
+ "show_title_field_in_link" smallint NOT NULL DEFAULT 0,
+ "migration_hash" varchar(255) DEFAULT NULL,
PRIMARY KEY ("name")
) ;
diff --git a/frappe/database/postgres/schema.py b/frappe/database/postgres/schema.py
index 58153ca6ce..b09f73300e 100644
--- a/frappe/database/postgres/schema.py
+++ b/frappe/database/postgres/schema.py
@@ -2,34 +2,78 @@ import frappe
from frappe import _
from frappe.utils import cint, flt
from frappe.database.schema import DBTable, get_definition
+from frappe.database.sequence import create_sequence
+from frappe.model import log_types
+
class PostgresTable(DBTable):
def create(self):
- add_text = ''
+ varchar_len = frappe.db.VARCHAR_LEN
+ name_column = f"name varchar({varchar_len}) primary key"
+ additional_definitions = ""
# columns
column_defs = self.get_column_definitions()
- if column_defs: add_text += ',\n'.join(column_defs)
+ if column_defs:
+ additional_definitions += ",\n".join(column_defs)
+
+ # child table columns
+ if self.meta.get("istable") or 0:
+ if column_defs:
+ additional_definitions += ",\n"
+
+ additional_definitions += ",\n".join(
+ (
+ f"parent varchar({varchar_len})",
+ f"parentfield varchar({varchar_len})",
+ f"parenttype varchar({varchar_len})",
+ )
+ )
+
+ # creating sequence(s)
+ if (not self.meta.issingle and self.meta.autoname == "autoincrement")\
+ or self.doctype in log_types:
+
+ # The sequence cache is per connection.
+ # Since we're opening and closing connections for every transaction this results in skipping the cache
+ # to the next non-cached value hence not using cache in postgres.
+ # ref: https://stackoverflow.com/questions/21356375/postgres-9-0-4-sequence-skipping-numbers
+ create_sequence(self.doctype, check_not_exists=True)
+ name_column = "name bigint primary key"
- # index
- # index_defs = self.get_index_definitions()
# TODO: set docstatus length
# create table
- frappe.db.sql("""create table `%s` (
- name varchar({varchar_len}) not null primary key,
+ frappe.db.sql(f"""create table `{self.table_name}` (
+ {name_column},
creation timestamp(6),
modified timestamp(6),
modified_by varchar({varchar_len}),
owner varchar({varchar_len}),
docstatus smallint not null default '0',
- parent varchar({varchar_len}),
- parentfield varchar({varchar_len}),
- parenttype varchar({varchar_len}),
idx bigint not null default '0',
- %s)""".format(varchar_len=frappe.db.VARCHAR_LEN) % (self.table_name, add_text))
+ {additional_definitions}
+ )"""
+ )
+ self.create_indexes()
frappe.db.commit()
+ def create_indexes(self):
+ create_index_query = ""
+ for key, col in self.columns.items():
+ if (col.set_index
+ and col.fieldtype in frappe.db.type_map
+ and frappe.db.type_map.get(col.fieldtype)[0]
+ not in ('text', 'longtext')):
+ create_index_query += 'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}`(`{field}`);'.format(
+ index_name=col.fieldname,
+ table_name=self.table_name,
+ field=col.fieldname
+ )
+ if create_index_query:
+ # nosemgrep
+ frappe.db.sql(create_index_query)
+
def alter(self):
for col in self.columns.values():
col.build_for_alter_table(self.current_columns.get(col.fieldname.lower()))
@@ -52,8 +96,8 @@ class PostgresTable(DBTable):
query.append("ALTER COLUMN `{0}` TYPE {1} {2}".format(
col.fieldname,
get_definition(col.fieldtype, precision=col.precision, length=col.length),
- using_clause)
- )
+ using_clause
+ ))
for col in self.set_default:
if col.fieldname=="name":
@@ -73,37 +117,54 @@ class PostgresTable(DBTable):
query.append("ALTER COLUMN `{}` SET DEFAULT {}".format(col.fieldname, col_default))
- create_index_query = ""
+ create_contraint_query = ""
for col in self.add_index:
# if index key not exists
- create_index_query += 'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}`(`{field}`);'.format(
+ create_contraint_query += 'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}`(`{field}`);'.format(
index_name=col.fieldname,
table_name=self.table_name,
field=col.fieldname)
- drop_index_query = ""
+ for col in self.add_unique:
+ # if index key not exists
+ create_contraint_query += 'CREATE UNIQUE INDEX IF NOT EXISTS "unique_{index_name}" ON `{table_name}`(`{field}`);'.format(
+ index_name=col.fieldname,
+ table_name=self.table_name,
+ field=col.fieldname
+ )
+
+ drop_contraint_query = ""
for col in self.drop_index:
# primary key
if col.fieldname != 'name':
# if index key exists
- if not frappe.db.has_index(self.table_name, col.fieldname):
- drop_index_query += 'DROP INDEX IF EXISTS "{}" ;'.format(col.fieldname)
+ drop_contraint_query += 'DROP INDEX IF EXISTS "{}" ;'.format(col.fieldname)
- if query:
- try:
+ for col in self.drop_unique:
+ # primary key
+ if col.fieldname != 'name':
+ # if index key exists
+ drop_contraint_query += 'DROP INDEX IF EXISTS "unique_{}" ;'.format(col.fieldname)
+ try:
+ if query:
final_alter_query = "ALTER TABLE `{}` {}".format(self.table_name, ", ".join(query))
- if final_alter_query: frappe.db.sql(final_alter_query)
- if create_index_query: frappe.db.sql(create_index_query)
- if drop_index_query: frappe.db.sql(drop_index_query)
- except Exception as e:
- # sanitize
- if frappe.db.is_duplicate_fieldname(e):
- frappe.throw(str(e))
- elif frappe.db.is_duplicate_entry(e):
- fieldname = str(e).split("'")[-2]
- frappe.throw(_("""{0} field cannot be set as unique in {1},
- as there are non-unique existing values""".format(
- fieldname, self.table_name)))
- raise e
- else:
- raise e
+ # nosemgrep
+ frappe.db.sql(final_alter_query)
+ if create_contraint_query:
+ # nosemgrep
+ frappe.db.sql(create_contraint_query)
+ if drop_contraint_query:
+ # nosemgrep
+ frappe.db.sql(drop_contraint_query)
+ except Exception as e:
+ # sanitize
+ if frappe.db.is_duplicate_fieldname(e):
+ frappe.throw(str(e))
+ elif frappe.db.is_duplicate_entry(e):
+ fieldname = str(e).split("'")[-2]
+ frappe.throw(
+ _("{0} field cannot be set as unique in {1}, as there are non-unique existing values")
+ .format(fieldname, self.table_name)
+ )
+ else:
+ raise e
diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py
index 3ee6b6a286..b3b2e0fd41 100644
--- a/frappe/database/postgres/setup_db.py
+++ b/frappe/database/postgres/setup_db.py
@@ -4,7 +4,7 @@ import frappe
def setup_database(force, source_sql=None, verbose=False):
- root_conn = get_root_connection()
+ root_conn = get_root_connection(frappe.flags.root_login, frappe.flags.root_password)
root_conn.commit()
root_conn.sql("DROP DATABASE IF EXISTS `{0}`".format(frappe.conf.db_name))
root_conn.sql("DROP USER IF EXISTS {0}".format(frappe.conf.db_name))
@@ -70,7 +70,7 @@ def import_db_from_sql(source_sql=None, verbose=False):
print(f"\nSTDOUT by psql:\n{restore_proc.stdout.decode()}\nImported from Database File: {source_sql}")
def setup_help_database(help_db_name):
- root_conn = get_root_connection()
+ root_conn = get_root_connection(frappe.flags.root_login, frappe.flags.root_password)
root_conn.sql("DROP DATABASE IF EXISTS `{0}`".format(help_db_name))
root_conn.sql("DROP USER IF EXISTS {0}".format(help_db_name))
root_conn.sql("CREATE DATABASE `{0}`".format(help_db_name))
@@ -83,7 +83,6 @@ def get_root_connection(root_login=None, root_password=None):
root_login = frappe.conf.get("root_login") or None
if not root_login:
- from six.moves import input
root_login = input("Enter postgres super user: ")
if not root_password:
diff --git a/frappe/database/query.py b/frappe/database/query.py
new file mode 100644
index 0000000000..15ab85ff56
--- /dev/null
+++ b/frappe/database/query.py
@@ -0,0 +1,335 @@
+import operator
+import re
+from typing import Any, Dict, List, Tuple, Union
+
+import frappe
+from frappe import _
+from frappe.query_builder import Criterion, Field, Order
+
+
+def like(key: str, value: str) -> frappe.qb:
+ """Wrapper method for `LIKE`
+
+ Args:
+ key (str): field
+ value (str): criterion
+
+ Returns:
+ frappe.qb: `frappe.qb object with `LIKE`
+ """
+ return Field(key).like(value)
+
+
+def func_in(key: str, value: Union[List, Tuple]) -> frappe.qb:
+ """Wrapper method for `IN`
+
+ Args:
+ key (str): field
+ value (Union[int, str]): criterion
+
+ Returns:
+ frappe.qb: `frappe.qb object with `IN`
+ """
+ return Field(key).isin(value)
+
+
+def not_like(key: str, value: str) -> frappe.qb:
+ """Wrapper method for `NOT LIKE`
+
+ Args:
+ key (str): field
+ value (str): criterion
+
+ Returns:
+ frappe.qb: `frappe.qb object with `NOT LIKE`
+ """
+ return Field(key).not_like(value)
+
+
+def func_not_in(key: str, value: Union[List, Tuple]):
+ """Wrapper method for `NOT IN`
+
+ Args:
+ key (str): field
+ value (Union[int, str]): criterion
+
+ Returns:
+ frappe.qb: `frappe.qb object with `NOT IN`
+ """
+ return Field(key).notin(value)
+
+
+def func_regex(key: str, value: str) -> frappe.qb:
+ """Wrapper method for `REGEX`
+
+ Args:
+ key (str): field
+ value (str): criterion
+
+ Returns:
+ frappe.qb: `frappe.qb object with `REGEX`
+ """
+ return Field(key).regex(value)
+
+
+def func_between(key: str, value: Union[List, Tuple]) -> frappe.qb:
+ """Wrapper method for `BETWEEN`
+
+ Args:
+ key (str): field
+ value (Union[int, str]): criterion
+
+ Returns:
+ frappe.qb: `frappe.qb object with `BETWEEN`
+ """
+ return Field(key)[slice(*value)]
+
+def make_function(key: Any, value: Union[int, str]):
+ """returns fucntion query
+
+ Args:
+ key (Any): field
+ value (Union[int, str]): criterion
+
+ Returns:
+ frappe.qb: frappe.qb object
+ """
+ return OPERATOR_MAP[value[0]](key, value[1])
+
+
+def change_orderby(order: str):
+ """Convert orderby to standart Order object
+
+ Args:
+ order (str): Field, order
+
+ Returns:
+ tuple: field, order
+ """
+ order = order.split()
+ if order[1].lower() == "asc":
+ orderby, order = order[0], Order.asc
+ return orderby, order
+ orderby, order = order[0], Order.desc
+ return orderby, order
+
+
+OPERATOR_MAP = {
+ "+": operator.add,
+ "=": operator.eq,
+ "-": operator.sub,
+ "!=": operator.ne,
+ "<": operator.lt,
+ ">": operator.gt,
+ "<=": operator.le,
+ ">=": operator.ge,
+ "in": func_in,
+ "not in": func_not_in,
+ "like": like,
+ "not like": not_like,
+ "regex": func_regex,
+ "between": func_between
+ }
+
+
+class Query:
+ def get_condition(self, table: str, **kwargs) -> frappe.qb:
+ """Get initial table object
+
+ Args:
+ table (str): DocType
+
+ Returns:
+ frappe.qb: DocType with initial condition
+ """
+ if kwargs.get("update"):
+ return frappe.qb.update(table)
+ if kwargs.get("into"):
+ return frappe.qb.into(table)
+ return frappe.qb.from_(table)
+
+ def criterion_query(self, table: str, criterion: Criterion, **kwargs) -> frappe.qb:
+ """Generate filters from Criterion objects
+
+ Args:
+ table (str): DocType
+ criterion (Criterion): Filters
+
+ Returns:
+ frappe.qb: condition object
+ """
+ condition = self.add_conditions(self.get_condition(table, **kwargs), **kwargs)
+ return condition.where(criterion)
+
+ def add_conditions(self, conditions: frappe.qb, **kwargs):
+ """Adding additional conditions
+
+ Args:
+ conditions (frappe.qb): built conditions
+
+ Returns:
+ conditions (frappe.qb): frappe.qb object
+ """
+ if kwargs.get("orderby"):
+ orderby = kwargs.get("orderby")
+ order = kwargs.get("order") if kwargs.get("order") else Order.desc
+ if isinstance(orderby, str) and len(orderby.split()) > 1:
+ orderby, order = change_orderby(orderby)
+ conditions = conditions.orderby(orderby, order=order)
+
+ if kwargs.get("limit"):
+ conditions = conditions.limit(kwargs.get("limit"))
+
+ if kwargs.get("distinct"):
+ conditions = conditions.distinct()
+
+ if kwargs.get("for_update"):
+ conditions = conditions.for_update()
+
+ return conditions
+
+ def misc_query(self, table: str, filters: Union[List, Tuple] = None, **kwargs):
+ """Build conditions using the given Lists or Tuple filters
+
+ Args:
+ table (str): DocType
+ filters (Union[List, Tuple], optional): Filters. Defaults to None.
+ """
+ conditions = self.get_condition(table, **kwargs)
+ if not filters:
+ return conditions
+ if isinstance(filters, list):
+ for f in filters:
+ if not isinstance(f, (list, tuple)):
+ _operator = OPERATOR_MAP[filters[1]]
+ if not isinstance(filters[0], str):
+ conditions = make_function(filters[0], filters[2])
+ break
+ conditions = conditions.where(_operator(Field(filters[0]), filters[2]))
+ break
+ else:
+ _operator = OPERATOR_MAP[f[1]]
+ conditions = conditions.where(_operator(Field(f[0]), f[2]))
+
+ conditions = self.add_conditions(conditions, **kwargs)
+ return conditions
+
+ def dict_query(self, table: str, filters: Dict[str, Union[str, int]] = None, **kwargs) -> frappe.qb:
+ """Build conditions using the given dictionary filters
+
+ Args:
+ table (str): DocType
+ filters (Dict[str, Union[str, int]], optional): Filters. Defaults to None.
+
+ Returns:
+ frappe.qb: conditions object
+ """
+ conditions = self.get_condition(table, **kwargs)
+ if not filters:
+ conditions = self.add_conditions(conditions, **kwargs)
+ return conditions
+
+ for key in filters:
+ value = filters.get(key)
+ _operator = OPERATOR_MAP["="]
+
+ if not isinstance(key, str):
+ conditions = conditions.where(make_function(key, value))
+ continue
+ if isinstance(value, (list, tuple)):
+ if isinstance(value[1], (list, tuple)) or value[0] in list(OPERATOR_MAP.keys())[-4:]:
+ _operator = OPERATOR_MAP[value[0]]
+ conditions = conditions.where(_operator(key, value[1]))
+ else:
+ _operator = OPERATOR_MAP[value[0]]
+ conditions = conditions.where(_operator(Field(key), value[1]))
+ else:
+ if value is not None:
+ conditions = conditions.where(_operator(Field(key), value))
+ else:
+ _table = conditions._from[0]
+ field = getattr(_table, key)
+ conditions = conditions.where(field.isnull())
+
+ conditions = self.add_conditions(conditions, **kwargs)
+ return conditions
+
+ def build_conditions(
+ self,
+ table: str,
+ filters: Union[Dict[str, Union[str, int]], str, int] = None,
+ **kwargs
+ ) -> frappe.qb:
+ """Build conditions for sql query
+
+ Args:
+ filters (Union[Dict[str, Union[str, int]], str, int]): conditions in Dict
+ table (str): DocType
+
+ Returns:
+ frappe.qb: frappe.qb conditions object
+ """
+ if isinstance(filters, int) or isinstance(filters, str):
+ filters = {"name": str(filters)}
+
+ if isinstance(filters, Criterion):
+ criterion = self.criterion_query(table, filters, **kwargs)
+
+ elif isinstance(filters, (list, tuple)):
+ criterion = self.misc_query(table, filters, **kwargs)
+
+ else:
+ criterion = self.dict_query(filters=filters, table=table, **kwargs)
+
+ return criterion
+
+ def get_sql(
+ self,
+ table: str,
+ fields: Union[List, Tuple],
+ filters: Union[Dict[str, Union[str, int]], str, int] = None,
+ **kwargs
+ ):
+ criterion = self.build_conditions(table, filters, **kwargs)
+ if isinstance(fields, (list, tuple)):
+ query = criterion.select(*kwargs.get("field_objects", fields))
+
+ elif isinstance(fields, Criterion):
+ query = criterion.select(fields)
+
+ else:
+ query = criterion.select(fields)
+
+ return query
+
+
+class Permission:
+ @classmethod
+ def check_permissions(cls, query, **kwargs):
+ if not isinstance(query, str):
+ query = query.get_sql()
+
+ doctype = cls.get_tables_from_query(query)
+ if isinstance(doctype, str):
+ doctype = [doctype]
+
+ for dt in doctype:
+ dt = re.sub("^tab", "", dt)
+ if not frappe.has_permission(
+ dt,
+ "select",
+ user=kwargs.get("user"),
+ parent_doctype=kwargs.get("parent_doctype"),
+ ) and not frappe.has_permission(
+ dt,
+ "read",
+ user=kwargs.get("user"),
+ parent_doctype=kwargs.get("parent_doctype"),
+ ):
+ frappe.throw(
+ _("Insufficient Permission for {0}").format(frappe.bold(dt))
+ )
+
+ @staticmethod
+ def get_tables_from_query(query: str):
+ return [table for table in re.findall(r"\w+", query) if table.startswith("tab")]
diff --git a/frappe/database/schema.py b/frappe/database/schema.py
index daabbaa61c..7cab8d42b2 100644
--- a/frappe/database/schema.py
+++ b/frappe/database/schema.py
@@ -1,5 +1,3 @@
-from __future__ import unicode_literals
-
import re
import frappe
@@ -23,6 +21,7 @@ class DBTable:
self.change_name = []
self.add_unique = []
self.add_index = []
+ self.drop_unique = []
self.drop_index = []
self.set_default = []
@@ -30,6 +29,9 @@ class DBTable:
self.get_columns_from_docfields()
def sync(self):
+ if self.meta.get('is_virtual'):
+ # no schema to sync for virtual doctypes
+ return
if self.is_new():
self.create()
else:
@@ -65,7 +67,7 @@ class DBTable:
"""
get columns from docfields and custom fields
"""
- fields = self.meta.get_fieldnames_with_value(True)
+ fields = self.meta.get_fieldnames_with_value(with_field_meta=True)
# optional fields like _comments
if not self.meta.get('istable'):
@@ -83,6 +85,9 @@ class DBTable:
})
for field in fields:
+ if field.get("is_virtual"):
+ continue
+
self.columns[field.get('fieldname')] = DbColumn(
self,
field.get('fieldname'),
@@ -104,6 +109,9 @@ class DBTable:
columns = [frappe._dict({"fieldname": f, "fieldtype": "Data"}) for f in
frappe.db.STANDARD_VARCHAR_COLUMNS]
+ if self.meta.get("istable"):
+ columns += [frappe._dict({"fieldname": f, "fieldtype": "Data"}) for f in
+ frappe.db.CHILD_TABLE_COLUMNS]
columns += self.columns.values()
for col in columns:
@@ -205,6 +213,12 @@ class DbColumn:
if not current_def:
self.fieldname = validate_column_name(self.fieldname)
self.table.add_column.append(self)
+
+ if column_type not in ('text', 'longtext'):
+ if self.unique:
+ self.table.add_unique.append(self)
+ if self.set_index:
+ self.table.add_index.append(self)
return
# type
@@ -212,8 +226,10 @@ class DbColumn:
self.table.change_type.append(self)
# unique
- if((self.unique and not current_def['unique']) and column_type not in ('text', 'longtext')):
+ if ((self.unique and not current_def['unique']) and column_type not in ('text', 'longtext')):
self.table.add_unique.append(self)
+ elif (current_def['unique'] and not self.unique) and column_type not in ('text', 'longtext'):
+ self.table.drop_unique.append(self)
# default
if (self.default_changed(current_def)
@@ -223,9 +239,7 @@ class DbColumn:
self.table.set_default.append(self)
# index should be applied or dropped irrespective of type change
- if ((current_def['index'] and not self.set_index and not self.unique)
- or (current_def['unique'] and not self.unique)):
- # to drop unique you have to drop index
+ if (current_def['index'] and not self.set_index) and column_type not in ('text', 'longtext'):
self.table.drop_index.append(self)
elif (not current_def['index'] and self.set_index) and not (column_type in ('text', 'longtext')):
@@ -292,32 +306,60 @@ def validate_column_length(fieldname):
def get_definition(fieldtype, precision=None, length=None):
d = frappe.db.type_map.get(fieldtype)
- # convert int to long int if the length of the int is greater than 11
- if fieldtype == "Int" and length and length > 11:
- d = frappe.db.type_map.get("Long Int")
+ if not d:
+ return
- if not d: return
+ if fieldtype == "Int" and length and length > 11:
+ # convert int to long int if the length of the int is greater than 11
+ d = frappe.db.type_map.get("Long Int")
coltype = d[0]
size = d[1] if d[1] else None
if size:
+ # This check needs to exist for backward compatibility.
+ # Till V13, default size used for float, currency and percent are (18, 6).
if fieldtype in ["Float", "Currency", "Percent"] and cint(precision) > 6:
size = '21,9'
- if coltype == "varchar" and length:
- size = length
+ if length:
+ if coltype == "varchar":
+ size = length
+ elif coltype == "int" and length < 11:
+ # allow setting custom length for int if length provided is less than 11
+ # NOTE: this will only be applicable for mariadb as frappe implements int
+ # in postgres as bigint (as seen in type_map)
+ size = length
if size is not None:
coltype = "{coltype}({size})".format(coltype=coltype, size=size)
return coltype
-def add_column(doctype, column_name, fieldtype, precision=None):
+def add_column(
+ doctype,
+ column_name,
+ fieldtype,
+ precision=None,
+ length=None,
+ default=None,
+ not_null=False
+):
if column_name in frappe.db.get_table_columns(doctype):
# already exists
return
frappe.db.commit()
- frappe.db.sql("alter table `tab%s` add column %s %s" % (doctype,
- column_name, get_definition(fieldtype, precision)))
+
+ query = "alter table `tab%s` add column %s %s" % (
+ doctype,
+ column_name,
+ get_definition(fieldtype, precision, length)
+ )
+
+ if not_null:
+ query += " not null"
+ if default:
+ query += f" default '{default}'"
+
+ frappe.db.sql(query)
diff --git a/frappe/database/sequence.py b/frappe/database/sequence.py
new file mode 100644
index 0000000000..334fd3d71e
--- /dev/null
+++ b/frappe/database/sequence.py
@@ -0,0 +1,80 @@
+from frappe import db, scrub
+
+
+def create_sequence(
+ doctype_name: str,
+ *,
+ slug: str = "_id_seq",
+ check_not_exists: bool = False,
+ cycle: bool = False,
+ cache: int = 0,
+ start_value: int = 0,
+ increment_by: int = 0,
+ min_value: int = 0,
+ max_value: int = 0
+) -> str:
+
+ query = "create sequence"
+ sequence_name = scrub(doctype_name + slug)
+
+ if check_not_exists:
+ query += " if not exists"
+
+ query += f" {sequence_name}"
+
+ if cache:
+ query += f" cache {cache}"
+ else:
+ # in postgres, the default is cache 1
+ if db.db_type == "mariadb":
+ query += " nocache"
+
+ if start_value:
+ # default is 1
+ query += f" start with {start_value}"
+
+ if increment_by:
+ # default is 1
+ query += f" increment by {increment_by}"
+
+ if min_value:
+ # default is 1
+ query += f" min value {min_value}"
+
+ if max_value:
+ query += f" max value {max_value}"
+
+ if not cycle:
+ if db.db_type == "mariadb":
+ query += " nocycle"
+ else:
+ query += " cycle"
+
+ db.sql(query)
+
+ return sequence_name
+
+
+def get_next_val(doctype_name: str, slug: str = "_id_seq") -> int:
+ if db.db_type == "postgres":
+ return db.sql(f"select nextval(\'\"{scrub(doctype_name + slug)}\"\')")[0][0]
+ return db.sql(f"select nextval(`{scrub(doctype_name + slug)}`)")[0][0]
+
+
+def set_next_val(
+ doctype_name: str,
+ next_val: int,
+ *,
+ slug: str = "_id_seq",
+ is_val_used :bool = False
+) -> None:
+
+ if not is_val_used:
+ is_val_used = 0 if db.db_type == "mariadb" else "f"
+ else:
+ is_val_used = 1 if db.db_type == "mariadb" else "t"
+
+ if db.db_type == "postgres":
+ db.sql(f"SELECT SETVAL('\"{scrub(doctype_name + slug)}\"', {next_val}, '{is_val_used}')")
+ else:
+ db.sql(f"SELECT SETVAL(`{scrub(doctype_name + slug)}`, {next_val}, {is_val_used})")
diff --git a/frappe/defaults.py b/frappe/defaults.py
index 4bec6677c7..e249ef2099 100644
--- a/frappe/defaults.py
+++ b/frappe/defaults.py
@@ -1,10 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.desk.notifications import clear_notifications
from frappe.cache_manager import clear_defaults_cache, common_default_keys
+from frappe.query_builder import DocType
# Note: DefaultValue records are identified by parenttype
# __default, __global or 'User Permission'
@@ -117,20 +117,16 @@ def set_default(key, value, parent, parenttype="__default"):
:param value: Default value.
:param parent: Usually, **User** to whom the default belongs.
:param parenttype: [optional] default is `__default`."""
- if frappe.db.sql('''
- select
- defkey
- from
- `tabDefaultValue`
- where
- defkey=%s and parent=%s
- for update''', (key, parent)):
- frappe.db.sql("""
- delete from
- `tabDefaultValue`
- where
- defkey=%s and parent=%s""", (key, parent))
- if value != None:
+ table = DocType("DefaultValue")
+ key_exists = frappe.qb.from_(table).where(
+ (table.defkey == key) & (table.parent == parent)
+ ).select(table.defkey).for_update().run()
+ if key_exists:
+ frappe.db.delete("DefaultValue", {
+ "defkey": key,
+ "parent": parent
+ })
+ if value is not None:
add_default(key, value, parent)
else:
_clear_cache(parent)
@@ -156,29 +152,23 @@ def clear_default(key=None, value=None, parent=None, name=None, parenttype=None)
:param name: Default ID.
:param parenttype: Clear defaults table for a particular type e.g. **User**.
"""
- conditions = []
- values = []
+ filters = {}
if name:
- conditions.append("name=%s")
- values.append(name)
+ filters.update({"name": name})
else:
if key:
- conditions.append("defkey=%s")
- values.append(key)
+ filters.update({"defkey": key})
if value:
- conditions.append("defvalue=%s")
- values.append(value)
+ filters.update({"defvalue": value})
if parent:
- conditions.append("parent=%s")
- values.append(parent)
+ filters.update({"parent": parent})
if parenttype:
- conditions.append("parenttype=%s")
- values.append(parenttype)
+ filters.update({"parenttype": parenttype})
if parent:
clear_defaults_cache(parent)
@@ -186,11 +176,10 @@ def clear_default(key=None, value=None, parent=None, name=None, parenttype=None)
clear_defaults_cache("__default")
clear_defaults_cache("__global")
- if not conditions:
+ if not filters:
raise Exception("[clear_default] No key specified.")
- frappe.db.sql("""delete from tabDefaultValue where {0}""".format(" and ".join(conditions)),
- tuple(values))
+ frappe.db.delete("DefaultValue", filters)
_clear_cache(parent)
@@ -198,10 +187,14 @@ def get_defaults_for(parent="__default"):
"""get all defaults"""
defaults = frappe.cache().hget("defaults", parent)
- if defaults==None:
+ if defaults is None:
# sort descending because first default must get precedence
- res = frappe.db.sql("""select defkey, defvalue from `tabDefaultValue`
- where parent = %s order by creation""", (parent,), as_dict=1)
+ table = DocType("DefaultValue")
+ res = frappe.qb.from_(table).where(
+ table.parent == parent
+ ).select(
+ table.defkey, table.defvalue
+ ).orderby("creation").run(as_dict=True)
defaults = frappe._dict({})
for d in res:
diff --git a/frappe/deferred_insert.py b/frappe/deferred_insert.py
index 499fc5e41b..b1338a73b0 100644
--- a/frappe/deferred_insert.py
+++ b/frappe/deferred_insert.py
@@ -5,7 +5,6 @@ from frappe.utils import cstr
queue_prefix = 'insert_queue_for_'
-@frappe.whitelist()
def deferred_insert(doctype, records):
frappe.cache().rpush(queue_prefix + doctype, records)
diff --git a/frappe/desk/__init__.py b/frappe/desk/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/desk/__init__.py
+++ b/frappe/desk/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/desk/calendar.py b/frappe/desk/calendar.py
index 064d870092..66e6dd8434 100644
--- a/frappe/desk/calendar.py
+++ b/frappe/desk/calendar.py
@@ -1,7 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
from frappe import _
@@ -27,7 +25,6 @@ def get_event_conditions(doctype, filters=None):
@frappe.whitelist()
def get_events(doctype, start, end, field_map, filters=None, fields=None):
-
field_map = frappe._dict(json.loads(field_map))
fields = frappe.parse_json(fields)
@@ -38,8 +35,7 @@ def get_events(doctype, start, end, field_map, filters=None, fields=None):
"color": d.fieldname
})
- if filters:
- filters = json.loads(filters or '')
+ filters = json.loads(filters) if filters else []
if not fields:
fields = [field_map.start, field_map.end, field_map.title, 'name']
@@ -54,5 +50,5 @@ def get_events(doctype, start, end, field_map, filters=None, fields=None):
[doctype, start_date, '<=', end],
[doctype, end_date, '>=', start],
]
-
+ fields = list({field for field in fields if field})
return frappe.get_list(doctype, fields=fields, filters=filters)
diff --git a/frappe/desk/desk_page.py b/frappe/desk/desk_page.py
index 6c5fdc6821..a01008280c 100644
--- a/frappe/desk/desk_page.py
+++ b/frappe/desk/desk_page.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.translate import send_translations
diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py
index 0ded8e0717..4164db679d 100644
--- a/frappe/desk/desktop.py
+++ b/frappe/desk/desktop.py
@@ -1,13 +1,12 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
# Author - Shivam Mishra
-from __future__ import unicode_literals
import frappe
from json import loads, dumps
from frappe import _, DoesNotExistError, ValidationError, _dict
from frappe.boot import get_allowed_pages, get_allowed_reports
-from six import string_types
+from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
from functools import wraps
from frappe.cache_manager import (
build_domain_restriced_doctype_cache,
@@ -29,18 +28,18 @@ def handle_not_exist(fn):
class Workspace:
- def __init__(self, page_name, minimal=False):
- self.page_name = page_name
- self.extended_links = []
- self.extended_charts = []
- self.extended_shortcuts = []
+ def __init__(self, page, minimal=False):
+ self.page_name = page.get('name')
+ self.page_title = page.get('title')
+ self.public_page = page.get('public')
+ self.workspace_manager = "Workspace Manager" in frappe.get_roles()
self.user = frappe.get_user()
self.allowed_modules = self.get_cached('user_allowed_modules', self.get_allowed_modules)
- self.doc = self.get_page_for_user()
+ self.doc = frappe.get_cached_doc("Workspace", self.page_name)
- if self.doc.module and self.doc.module not in self.allowed_modules:
+ if self.doc and self.doc.module and self.doc.module not in self.allowed_modules and not self.workspace_manager:
raise frappe.PermissionError
self.can_read = self.get_cached('user_perm_can_read', self.get_can_read_items)
@@ -49,34 +48,30 @@ class Workspace:
self.allowed_reports = get_allowed_reports(cache=True)
if not minimal:
- self.onboarding_doc = self.get_onboarding_doc()
- self.onboarding = None
+ if self.doc.content:
+ self.onboarding_list = [x['data']['onboarding_name'] for x in loads(self.doc.content) if x['type'] == 'onboarding']
+ self.onboardings = []
self.table_counts = get_table_with_counts()
self.restricted_doctypes = frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache()
self.restricted_pages = frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache()
- def is_page_allowed(self):
- cards = self.doc.get_link_groups() + get_custom_reports_and_doctypes(self.doc.module) + self.extended_links
- shortcuts = self.doc.shortcuts + self.extended_shortcuts
+ def is_permitted(self):
+ """Returns true if Has Role is not set or the user is allowed."""
+ from frappe.utils import has_common
- for section in cards:
- links = loads(section.get('links')) if isinstance(section.get('links'), string_types) else section.get('links')
- for item in links:
- if self.is_item_allowed(item.get('name'), item.get('type')):
- return True
+ allowed = [d.role for d in frappe.get_all("Has Role", fields=["role"], filters={"parent": self.doc.name})]
- def _in_active_domains(item):
- if not item.restrict_to_domain:
- return True
- else:
- return item.restrict_to_domain in frappe.get_active_domains()
+ custom_roles = get_custom_allowed_roles('page', self.doc.name)
+ allowed.extend(custom_roles)
- for item in shortcuts:
- if self.is_item_allowed(item.link_to, item.type) and _in_active_domains(item):
- return True
+ if not allowed:
+ return True
- return False
+ roles = frappe.get_roles()
+
+ if has_common(roles, allowed):
+ return True
def get_cached(self, cache_key, fallback_fn):
_cache = frappe.cache()
@@ -103,39 +98,18 @@ class Workspace:
return self.user.allow_modules
- def get_page_for_user(self):
- filters = {
- 'extends': self.page_name,
- 'for_user': frappe.session.user
- }
- user_pages = frappe.get_all("Workspace", filters=filters, limit=1)
- if user_pages:
- return frappe.get_cached_doc("Workspace", user_pages[0])
-
- filters = {
- 'extends_another_page': 1,
- 'extends': self.page_name,
- 'is_default': 1
- }
- default_page = frappe.get_all("Workspace", filters=filters, limit=1)
- if default_page:
- return frappe.get_cached_doc("Workspace", default_page[0])
-
- self.get_pages_to_extend()
- return frappe.get_cached_doc("Workspace", self.page_name)
-
- def get_onboarding_doc(self):
+ def get_onboarding_doc(self, onboarding):
# Check if onboarding is enabled
if not frappe.get_system_settings("enable_onboarding"):
return None
- if not self.doc.onboarding:
+ if not self.onboarding_list:
return None
- if frappe.db.get_value("Module Onboarding", self.doc.onboarding, "is_complete"):
+ if frappe.db.get_value("Module Onboarding", onboarding, "is_complete"):
return None
- doc = frappe.get_doc("Module Onboarding", self.doc.onboarding)
+ doc = frappe.get_doc("Module Onboarding", onboarding)
# Check if user is allowed
allowed_roles = set(doc.get_allowed_roles())
@@ -149,21 +123,6 @@ class Workspace:
return doc
- def get_pages_to_extend(self):
- pages = frappe.get_all("Workspace", filters={
- "extends": self.page_name,
- 'restrict_to_domain': ['in', frappe.get_active_domains()],
- 'for_user': '',
- 'module': ['in', self.allowed_modules]
- })
-
- pages = [frappe.get_cached_doc("Workspace", page['name']) for page in pages]
-
- for page in pages:
- self.extended_links = self.extended_links + page.get_link_groups()
- self.extended_charts = self.extended_charts + page.charts
- self.extended_shortcuts = self.extended_shortcuts + page.shortcuts
-
def is_item_allowed(self, name, item_type):
if frappe.session.user == "Administrator":
return True
@@ -185,28 +144,20 @@ class Workspace:
def build_workspace(self):
self.cards = {
- 'label': _(self.doc.cards_label),
'items': self.get_links()
}
self.charts = {
- 'label': _(self.doc.charts_label),
'items': self.get_charts()
}
self.shortcuts = {
- 'label': _(self.doc.shortcuts_label),
'items': self.get_shortcuts()
}
- if self.onboarding_doc:
- self.onboarding = {
- 'label': _(self.onboarding_doc.title),
- 'subtitle': _(self.onboarding_doc.subtitle),
- 'success': _(self.onboarding_doc.success_message),
- 'docs_url': self.onboarding_doc.documentation_url,
- 'items': self.get_onboarding_steps()
- }
+ self.onboardings = {
+ 'items': self.get_onboardings()
+ }
def _doctype_contains_a_record(self, name):
exists = self.table_counts.get(name, False)
@@ -252,9 +203,6 @@ class Workspace:
if not self.doc.hide_custom:
cards = cards + get_custom_reports_and_doctypes(self.doc.module)
- if len(self.extended_links):
- cards = merge_cards_based_on_label(cards + self.extended_links)
-
default_country = frappe.db.get_default("country")
new_data = []
@@ -292,8 +240,6 @@ class Workspace:
all_charts = []
if frappe.has_permission("Dashboard Chart", throw=False):
charts = self.doc.charts
- if len(self.extended_charts):
- charts = charts + self.extended_charts
for chart in charts:
if frappe.has_permission('Dashboard Chart', doc=chart.chart_name):
@@ -314,8 +260,6 @@ class Workspace:
items = []
shortcuts = self.doc.shortcuts
- if len(self.extended_shortcuts):
- shortcuts = shortcuts + self.extended_shortcuts
for item in shortcuts:
new_item = item.as_dict().copy()
@@ -335,9 +279,26 @@ class Workspace:
return items
@handle_not_exist
- def get_onboarding_steps(self):
+ def get_onboardings(self):
+ if self.onboarding_list:
+ for onboarding in self.onboarding_list:
+ onboarding_doc = self.get_onboarding_doc(onboarding)
+ if onboarding_doc:
+ item = {
+ 'label': _(onboarding),
+ 'title': _(onboarding_doc.title),
+ 'subtitle': _(onboarding_doc.subtitle),
+ 'success': _(onboarding_doc.success_message),
+ 'docs_url': onboarding_doc.documentation_url,
+ 'items': self.get_onboarding_steps(onboarding_doc)
+ }
+ self.onboardings.append(item)
+ return self.onboardings
+
+ @handle_not_exist
+ def get_onboarding_steps(self, onboarding_doc):
steps = []
- for doc in self.onboarding_doc.get_steps():
+ for doc in onboarding_doc.get_steps():
step = doc.as_dict().copy()
step.label = _(doc.title)
if step.action == "Create Entry":
@@ -354,55 +315,64 @@ def get_desktop_page(page):
on desk.
Args:
- page (string): page name
+ page (json): page data
Returns:
dict: dictionary of cards, charts and shortcuts to be displayed on website
"""
- wspace = Workspace(page)
- wspace.build_workspace()
- return {
- 'charts': wspace.charts,
- 'shortcuts': wspace.shortcuts,
- 'cards': wspace.cards,
- 'onboarding': wspace.onboarding,
- 'allow_customization': not wspace.doc.disable_user_customization
- }
+ try:
+ workspace = Workspace(loads(page))
+ workspace.build_workspace()
+ return {
+ 'charts': workspace.charts,
+ 'shortcuts': workspace.shortcuts,
+ 'cards': workspace.cards,
+ 'onboardings': workspace.onboardings
+ }
+ except DoesNotExistError:
+ frappe.log_error(frappe.get_traceback())
+ return {}
@frappe.whitelist()
-def get_desk_sidebar_items():
+def get_workspace_sidebar_items():
"""Get list of sidebar items for desk"""
+ has_access = "Workspace Manager" in frappe.get_roles()
# don't get domain restricted pages
blocked_modules = frappe.get_doc('User', frappe.session.user).get_blocked_modules()
+ blocked_modules.append('Dummy Module')
filters = {
'restrict_to_domain': ['in', frappe.get_active_domains()],
- 'extends_another_page': 0,
- 'for_user': '',
'module': ['not in', blocked_modules]
}
- if not frappe.local.conf.developer_mode:
- filters['developer_mode_only'] = '0'
+ if has_access:
+ filters = []
- # pages sorted based on pinned to top and then by name
- order_by = "pin_to_top desc, pin_to_bottom asc, name asc"
- all_pages = frappe.get_all("Workspace", fields=["name", "category", "icon", "module"],
- filters=filters, order_by=order_by, ignore_permissions=True)
+ # pages sorted based on sequence id
+ order_by = "sequence_id asc"
+ fields = ["name", "title", "for_user", "parent_page", "content", "public", "module", "icon"]
+ all_pages = frappe.get_all("Workspace", fields=fields, filters=filters, order_by=order_by, ignore_permissions=True)
pages = []
+ private_pages = []
# Filter Page based on Permission
for page in all_pages:
try:
- wspace = Workspace(page.get('name'), True)
- if wspace.is_page_allowed():
- pages.append(page)
+ workspace = Workspace(page, True)
+ if has_access or workspace.is_permitted():
+ if page.public:
+ pages.append(page)
+ elif page.for_user == frappe.session.user:
+ private_pages.append(page)
page['label'] = _(page.get('name'))
except frappe.PermissionError:
pass
+ if private_pages:
+ pages.extend(private_pages)
- return pages
+ return {'pages': pages, 'has_access': has_access}
def get_table_with_counts():
counts = frappe.cache().get_value("information_schema:counts")
@@ -437,7 +407,6 @@ def get_custom_doctype_list(module):
return out
-
def get_custom_report_list(module):
"""Returns list on new style reports for modules."""
reports = frappe.get_all("Report", fields=["name", "ref_doctype", "report_type"], filters=
@@ -450,6 +419,7 @@ def get_custom_report_list(module):
"type": "Link",
"link_type": "report",
"doctype": r.ref_doctype,
+ "dependencies": r.ref_doctype,
"is_query_report": 1 if r.report_type in ("Query Report", "Script Report", "Custom Report") else 0,
"label": _(r.name),
"link_to": r.name,
@@ -457,73 +427,25 @@ def get_custom_report_list(module):
return out
-def get_custom_workspace_for_user(page):
- """Get custom page from workspace if exists or create one
+def save_new_widget(doc, page, blocks, new_widgets):
+ if loads(new_widgets):
+ widgets = _dict(loads(new_widgets))
- Args:
- page (stirng): Page name
+ if widgets.chart:
+ doc.charts.extend(new_widget(widgets.chart, "Workspace Chart", "charts"))
+ if widgets.shortcut:
+ doc.shortcuts.extend(new_widget(widgets.shortcut, "Workspace Shortcut", "shortcuts"))
+ if widgets.card:
+ doc.build_links_table_from_card(widgets.card)
- Returns:
- Object: Document object
- """
- filters = {
- 'extends': page,
- 'for_user': frappe.session.user
- }
- pages = frappe.get_list("Workspace", filters=filters)
- if pages:
- return frappe.get_doc("Workspace", pages[0])
- doc = frappe.new_doc("Workspace")
- doc.extends = page
- doc.for_user = frappe.session.user
- return doc
-
-
-@frappe.whitelist()
-def save_customization(page, config):
- """Save customizations as a separate doctype in Workspace per user
-
- Args:
- page (string): Name of the page to be edited
- config (dict): Dictionary config of al widgets
-
- Returns:
- Boolean: Customization saving status
- """
- original_page = frappe.get_doc("Workspace", page)
- page_doc = get_custom_workspace_for_user(page)
-
- # Update field values
- page_doc.update({
- "icon": original_page.icon,
- "charts_label": original_page.charts_label,
- "cards_label": original_page.cards_label,
- "shortcuts_label": original_page.shortcuts_label,
- "module": original_page.module,
- "onboarding": original_page.onboarding,
- "developer_mode_only": original_page.developer_mode_only,
- "category": original_page.category
- })
-
- config = _dict(loads(config))
- if config.charts:
- page_doc.charts = prepare_widget(config.charts, "Workspace Chart", "charts")
- if config.shortcuts:
- page_doc.shortcuts = prepare_widget(config.shortcuts, "Workspace Shortcut", "shortcuts")
- if config.cards:
- page_doc.build_links_table_from_cards(config.cards)
-
- # Set label
- page_doc.label = page + '-' + frappe.session.user
+ # remove duplicate and unwanted widgets
+ clean_up(doc, blocks)
try:
- if page_doc.is_new():
- page_doc.insert(ignore_permissions=True)
- else:
- page_doc.save(ignore_permissions=True)
+ doc.save(ignore_permissions=True)
except (ValidationError, TypeError) as e:
# Create a json string to log
- json_config = dumps(config, sort_keys=True, indent=4)
+ json_config = widgets and dumps(widgets, sort_keys=True, indent=4)
# Error log body
log = \
@@ -537,6 +459,48 @@ def save_customization(page, config):
return True
+def clean_up(original_page, blocks):
+ page_widgets = {}
+
+ for wid in ['shortcut', 'card', 'chart']:
+ # get list of widget's name from blocks
+ page_widgets[wid] = [x['data'][wid + '_name'] for x in loads(blocks) if x['type'] == wid]
+
+ # shortcut & chart cleanup
+ for wid in ['shortcut', 'chart']:
+ updated_widgets = []
+ original_page.get(wid+'s').reverse()
+
+ for w in original_page.get(wid+'s'):
+ if w.label in page_widgets[wid] and w.label not in [x.label for x in updated_widgets]:
+ updated_widgets.append(w)
+ original_page.set(wid+'s', updated_widgets)
+
+ # card cleanup
+ for i, v in enumerate(original_page.links):
+ if v.type == 'Card Break' and v.label not in page_widgets['card']:
+ del original_page.links[i : i+v.link_count+1]
+
+def new_widget(config, doctype, parentfield):
+ if not config:
+ return []
+ prepare_widget_list = []
+ for idx, widget in enumerate(config):
+ # Some cleanup
+ widget.pop("name", None)
+
+ # New Doc
+ doc = frappe.new_doc(doctype)
+ doc.update(widget)
+
+ # Manually Set IDX
+ doc.idx = idx + 1
+
+ # Set Parent Field
+ doc.parentfield = parentfield
+
+ prepare_widget_list.append(doc)
+ return prepare_widget_list
def prepare_widget(config, doctype, parentfield):
"""Create widget child table entries with parent details
@@ -572,39 +536,14 @@ def prepare_widget(config, doctype, parentfield):
prepare_widget_list.append(doc)
return prepare_widget_list
-
@frappe.whitelist()
def update_onboarding_step(name, field, value):
"""Update status of onboaridng step
Args:
- name (string): Name of the doc
- field (string): field to be updated
- value: Value to be updated
+ name (string): Name of the doc
+ field (string): field to be updated
+ value: Value to be updated
"""
frappe.db.set_value("Onboarding Step", name, field, value)
-
-@frappe.whitelist()
-def reset_customization(page):
- """Reset workspace customizations for a user
-
- Args:
- page (string): Name of the page to be reset
- """
- page_doc = get_custom_workspace_for_user(page)
- page_doc.delete()
-
-def merge_cards_based_on_label(cards):
- """Merge cards with common label."""
- cards_dict = {}
- for card in cards:
- label = card.get('label')
- if label in cards_dict:
- links = loads(cards_dict[label].links) + loads(card.links)
- cards_dict[label].update(dict(links=dumps(links)))
- cards_dict[label] = cards_dict.pop(label)
- else:
- cards_dict[label] = card
-
- return list(cards_dict.values())
diff --git a/frappe/desk/doctype/bulk_update/bulk_update.py b/frappe/desk/doctype/bulk_update/bulk_update.py
index 9b9f7d7a73..20887f8886 100644
--- a/frappe/desk/doctype/bulk_update/bulk_update.py
+++ b/frappe/desk/doctype/bulk_update/bulk_update.py
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe import _
from frappe.utils import cint
+
class BulkUpdate(Document):
pass
@@ -23,7 +23,7 @@ def update(doctype, field, value, condition='', limit=500):
frappe.throw(_('; not allowed in condition'))
docnames = frappe.db.sql_list(
- '''select name from `tab{0}`{1} limit 0, {2}'''.format(doctype, condition, limit)
+ '''select name from `tab{0}`{1} limit {2} offset 0'''.format(doctype, condition, limit)
)
data = {}
data[field] = value
@@ -42,13 +42,13 @@ def submit_cancel_or_update_docs(doctype, docnames, action='submit', data=None):
doc = frappe.get_doc(doctype, d)
try:
message = ''
- if action == 'submit' and doc.docstatus==0:
+ if action == 'submit' and doc.docstatus.is_draft():
doc.submit()
message = _('Submiting {0}').format(doctype)
- elif action == 'cancel' and doc.docstatus==1:
+ elif action == 'cancel' and doc.docstatus.is_submitted():
doc.cancel()
message = _('Cancelling {0}').format(doctype)
- elif action == 'update' and doc.docstatus < 2:
+ elif action == 'update' and not doc.docstatus.is_cancelled():
doc.update(data)
doc.save()
message = _('Updating {0}').format(doctype)
diff --git a/frappe/desk/doctype/calendar_view/calendar_view.py b/frappe/desk/doctype/calendar_view/calendar_view.py
index ae8ab1eb46..11612f5587 100644
--- a/frappe/desk/doctype/calendar_view/calendar_view.py
+++ b/frappe/desk/doctype/calendar_view/calendar_view.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
from frappe.model.document import Document
class CalendarView(Document):
diff --git a/frappe/desk/doctype/console_log/console_log.py b/frappe/desk/doctype/console_log/console_log.py
index 635c4c1ba7..e0b552ebfd 100644
--- a/frappe/desk/doctype/console_log/console_log.py
+++ b/frappe/desk/doctype/console_log/console_log.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/console_log/test_console_log.py b/frappe/desk/doctype/console_log/test_console_log.py
index 04dc4f241f..c41b9d68c8 100644
--- a/frappe/desk/doctype/console_log/test_console_log.py
+++ b/frappe/desk/doctype/console_log/test_console_log.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/desk/doctype/dashboard/dashboard.py b/frappe/desk/doctype/dashboard/dashboard.py
index 4e66318769..ac62796dc2 100644
--- a/frappe/desk/doctype/dashboard/dashboard.py
+++ b/frappe/desk/doctype/dashboard/dashboard.py
@@ -1,28 +1,37 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# Copyright (c) 2022, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+import json
-from __future__ import unicode_literals
-from frappe.model.document import Document
-from frappe.modules.export_file import export_to_files
-from frappe.config import get_modules_from_all_apps_for_user
import frappe
from frappe import _
-import json
+from frappe.config import get_modules_from_all_apps_for_user
+from frappe.model.document import Document
+from frappe.modules.export_file import export_to_files
+from frappe.query_builder import DocType
+
class Dashboard(Document):
def on_update(self):
if self.is_default:
# make all other dashboards non-default
- frappe.db.sql('''update
- tabDashboard set is_default = 0 where name != %s''', self.name)
+ DashBoard = DocType("Dashboard")
+
+ frappe.qb.update(DashBoard).set(
+ DashBoard.is_default, 0
+ ).where(
+ DashBoard.name != self.name
+ ).run()
if frappe.conf.developer_mode and self.is_standard:
- export_to_files(record_list=[['Dashboard', self.name, self.module + ' Dashboard']], record_module=self.module)
+ export_to_files(
+ record_list=[["Dashboard", self.name, f"{self.module} Dashboard"]],
+ record_module=self.module
+ )
def validate(self):
if not frappe.conf.developer_mode and self.is_standard:
- frappe.throw('Cannot edit Standard Dashboards')
+ frappe.throw(_("Cannot edit Standard Dashboards"))
if self.is_standard:
non_standard_docs_map = {
diff --git a/frappe/desk/doctype/dashboard/dashboard_list.js b/frappe/desk/doctype/dashboard/dashboard_list.js
new file mode 100644
index 0000000000..d60a324048
--- /dev/null
+++ b/frappe/desk/doctype/dashboard/dashboard_list.js
@@ -0,0 +1,16 @@
+frappe.listview_settings['Dashboard'] = {
+ button: {
+ show(doc) {
+ return doc.name;
+ },
+ get_label() {
+ return frappe.utils.icon("dashboard-list", "sm");
+ },
+ get_description(doc) {
+ return __('View {0}', [`${doc.name}`]);
+ },
+ action(doc) {
+ frappe.set_route('dashboard-view', doc.name);
+ }
+ },
+};
\ No newline at end of file
diff --git a/frappe/desk/doctype/dashboard/test_dashboard.py b/frappe/desk/doctype/dashboard/test_dashboard.py
index d5485d8f70..15c132c027 100644
--- a/frappe/desk/doctype/dashboard/test_dashboard.py
+++ b/frappe/desk/doctype/dashboard/test_dashboard.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import unittest
class TestDashboard(unittest.TestCase):
diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js
index f5d1ee0df5..0b93786e8e 100644
--- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js
+++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js
@@ -45,6 +45,7 @@ frappe.ui.form.on('Dashboard Chart', {
frm.set_df_property("filters_section", "hidden", 1);
frm.set_df_property("dynamic_filters_section", "hidden", 1);
+ frm.trigger('set_parent_document_type');
frm.trigger('set_time_series');
frm.set_query('document_type', function() {
return {
@@ -110,9 +111,11 @@ frappe.ui.form.on('Dashboard Chart', {
frm.set_value('source', '');
frm.set_value('based_on', '');
frm.set_value('value_based_on', '');
+ frm.set_value('parent_document_type', '');
frm.set_value('filters_json', '[]');
frm.set_value('dynamic_filters_json', '[]');
frm.trigger('update_options');
+ frm.trigger('set_parent_document_type');
},
report_name: function(frm) {
@@ -125,7 +128,6 @@ frappe.ui.form.on('Dashboard Chart', {
frm.trigger('set_chart_report_filters');
},
-
set_chart_report_filters: function(frm) {
let report_name = frm.doc.report_name;
@@ -148,6 +150,10 @@ frappe.ui.form.on('Dashboard Chart', {
}
},
+ use_report_chart: function(frm) {
+ !frm.doc.use_report_chart && frm.trigger('set_chart_field_options');
+ },
+
set_chart_field_options: function(frm) {
let filters = frm.doc.filters_json.length > 2 ? JSON.parse(frm.doc.filters_json) : null;
if (frm.doc.dynamic_filters_json && frm.doc.dynamic_filters_json.length > 2) {
@@ -179,6 +185,9 @@ frappe.ui.form.on('Dashboard Chart', {
} else {
frappe.msgprint(__('Report has no data, please modify the filters or change the Report Name'));
}
+ } else {
+ frm.set_value('use_report_chart', 1);
+ frm.set_df_property('use_report_chart', 'hidden', false);
}
});
},
@@ -204,7 +213,7 @@ frappe.ui.form.on('Dashboard Chart', {
{label: __('Last Modified On'), value: 'modified'}
];
let value_fields = [];
- let group_by_fields = [];
+ let group_by_fields = [{label: 'Created By', value: 'owner'}];
let aggregate_function_fields = [];
let update_form = function() {
// update select options
@@ -223,7 +232,7 @@ frappe.ui.form.on('Dashboard Chart', {
if (['Date', 'Datetime'].includes(df.fieldtype)) {
date_fields.push({label: df.label, value: df.fieldname});
}
- if (['Int', 'Float', 'Currency', 'Percent'].includes(df.fieldtype)) {
+ if (['Int', 'Float', 'Currency', 'Percent', 'Duration'].includes(df.fieldtype)) {
value_fields.push({label: df.label, value: df.fieldname});
aggregate_function_fields.push({label: df.label, value: df.fieldname});
}
@@ -365,6 +374,7 @@ frappe.ui.form.on('Dashboard Chart', {
frm.filter_group = new frappe.ui.FilterGroup({
parent: dialog.get_field('filter_area').$wrapper,
doctype: frm.doc.document_type,
+ parent_doctype: frm.doc.parent_document_type,
on_change: () => {},
});
@@ -481,6 +491,36 @@ frappe.ui.form.on('Dashboard Chart', {
frm.dynamic_filter_table.find('tbody').html(filter_rows);
}
+ },
+
+ set_parent_document_type: async function(frm) {
+ let document_type = frm.doc.document_type;
+ let doc_is_table = document_type &&
+ (await frappe.db.get_value('DocType', document_type, 'istable')).message.istable;
+
+ frm.set_df_property('parent_document_type', 'hidden', !doc_is_table);
+
+ if (document_type && doc_is_table) {
+ let parent = await frappe.db.get_list('DocField', {
+ filters: {
+ 'fieldtype': 'Table',
+ 'options': document_type
+ },
+ fields: ['parent']
+ });
+
+ parent && frm.set_query('parent_document_type', function() {
+ return {
+ filters: {
+ "name": ['in', parent.map(({ parent }) => parent)]
+ }
+ };
+ });
+
+ if (parent.length === 1) {
+ frm.set_value('parent_document_type', parent[0].parent);
+ }
+ }
}
});
diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json
index d4bba53068..a5d30c10e5 100644
--- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json
+++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json
@@ -17,6 +17,7 @@
"y_axis",
"source",
"document_type",
+ "parent_document_type",
"based_on",
"value_based_on",
"group_by_type",
@@ -268,10 +269,18 @@
"fieldname": "use_report_chart",
"fieldtype": "Check",
"label": "Use Report Chart"
+ },
+ {
+ "depends_on": "eval: doc.chart_type !== 'Custom' && doc.chart_type !== 'Report'",
+ "description": "The document type selected is a child table, so the parent document type is required.",
+ "fieldname": "parent_document_type",
+ "fieldtype": "Link",
+ "label": "Parent Document Type",
+ "options": "DocType"
}
],
"links": [],
- "modified": "2020-07-23 11:10:33.509497",
+ "modified": "2021-11-09 17:18:11.456145",
"modified_by": "Administrator",
"module": "Desk",
"name": "Dashboard Chart",
diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
index b19f6cf9f0..cb77ef7a1a 100644
--- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
+++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
@@ -1,16 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
import datetime
import json
from frappe.utils.dashboard import cache_source
from frappe.utils import nowdate, getdate, get_datetime, cint, now_datetime
-from frappe.utils.dateutils import\
- get_period, get_period_beginning, get_from_date_from_timespan, get_dates_from_timegrain
+from frappe.utils.dateutils import get_period, get_period_beginning, get_from_date_from_timespan, get_dates_from_timegrain
from frappe.model.naming import append_number_if_name_exists
from frappe.boot import get_allowed_reports
from frappe.config import get_modules_from_all_apps_for_user
@@ -171,7 +169,6 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
doctype = chart.document_type
datefield = chart.based_on
- aggregate_function = get_aggregate_function(chart.chart_type)
value_field = chart.value_based_on or '1'
from_date = from_date.strftime('%Y-%m-%d')
to_date = to_date
@@ -183,7 +180,8 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
doctype,
fields = [
'{} as _unit'.format(datefield),
- '{aggregate_function}({value_field})'.format(aggregate_function=aggregate_function, value_field=value_field),
+ 'SUM({})'.format(value_field),
+ 'COUNT(*)'
],
filters = filters,
group_by = '_unit',
@@ -192,7 +190,7 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
ignore_ifnull = True
)
- result = get_result(data, timegrain, from_date, to_date)
+ result = get_result(data, timegrain, from_date, to_date, chart.chart_type)
chart_config = {
"labels": [get_period(r[0], timegrain) for r in result],
@@ -288,15 +286,21 @@ def get_aggregate_function(chart_type):
}[chart_type]
-def get_result(data, timegrain, from_date, to_date):
+def get_result(data, timegrain, from_date, to_date, chart_type):
dates = get_dates_from_timegrain(from_date, to_date, timegrain)
result = [[date, 0] for date in dates]
data_index = 0
if data:
for i, d in enumerate(result):
+ count = 0
while data_index < len(data) and getdate(data[data_index][0]) <= d[0]:
d[1] += data[data_index][1]
+ count += data[data_index][2]
data_index += 1
+ if chart_type == 'Average' and not count == 0:
+ d[1] = d[1]/count
+ if chart_type == 'Count':
+ d[1] = count
return result
@@ -320,7 +324,7 @@ class DashboardChart(Document):
def validate(self):
if not frappe.conf.developer_mode and self.is_standard:
- frappe.throw('Cannot edit Standard charts')
+ frappe.throw(_("Cannot edit Standard charts"))
if self.chart_type != 'Custom' and self.chart_type != 'Report':
self.check_required_field()
self.check_document_type()
@@ -329,7 +333,10 @@ class DashboardChart(Document):
def check_required_field(self):
if not self.document_type:
- frappe.throw(_("Document type is required to create a dashboard chart"))
+ frappe.throw(_("Document type is required to create a dashboard chart"))
+
+ if self.document_type and frappe.get_meta(self.document_type).istable and not self.parent_document_type:
+ frappe.throw(_("Parent document type is required to create a dashboard chart"))
if self.chart_type == 'Group By':
if not self.group_by_based_on:
diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py
index 3c37ad4a09..5c986b5b7c 100644
--- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py
+++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import unittest, frappe
from frappe.utils import getdate, formatdate, get_last_day
from frappe.utils.dateutils import get_period_ending, get_period
@@ -10,6 +8,7 @@ from frappe.desk.doctype.dashboard_chart.dashboard_chart import get
from datetime import datetime
from dateutil.relativedelta import relativedelta
+from unittest.mock import patch
class TestDashboardChart(unittest.TestCase):
def test_period_ending(self):
@@ -17,8 +16,9 @@ class TestDashboardChart(unittest.TestCase):
getdate('2019-04-10'))
# week starts on monday
- self.assertEqual(get_period_ending('2019-04-10', 'Weekly'),
- getdate('2019-04-14'))
+ with patch.object(frappe.utils.data, "get_first_day_of_the_week", return_value="Monday"):
+ self.assertEqual(get_period_ending('2019-04-10', 'Weekly'),
+ getdate('2019-04-14'))
self.assertEqual(get_period_ending('2019-04-10', 'Monthly'),
getdate('2019-04-30'))
@@ -66,7 +66,7 @@ class TestDashboardChart(unittest.TestCase):
if frappe.db.exists('Dashboard Chart', 'Test Empty Dashboard Chart'):
frappe.delete_doc('Dashboard Chart', 'Test Empty Dashboard Chart')
- frappe.db.sql('delete from `tabError Log`')
+ frappe.db.delete("Error Log")
frappe.get_doc(dict(
doctype = 'Dashboard Chart',
@@ -96,7 +96,7 @@ class TestDashboardChart(unittest.TestCase):
if frappe.db.exists('Dashboard Chart', 'Test Empty Dashboard Chart 2'):
frappe.delete_doc('Dashboard Chart', 'Test Empty Dashboard Chart 2')
- frappe.db.sql('delete from `tabError Log`')
+ frappe.db.delete("Error Log")
# create one data point
frappe.get_doc(dict(doctype = 'Error Log', creation = '2018-06-01 00:00:00')).insert()
@@ -202,29 +202,63 @@ class TestDashboardChart(unittest.TestCase):
timeseries = 1
)).insert()
- result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1)
+ with patch.object(frappe.utils.data, "get_first_day_of_the_week", return_value="Monday"):
+ result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1)
- self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 300.0, 800.0, 0.0])
- self.assertEqual(
- result.get('labels'),
- ['30-12-18', '06-01-19', '13-01-19', '20-01-19']
- )
+ self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 300.0, 800.0, 0.0])
+ self.assertEqual(
+ result.get('labels'),
+ ['30-12-18', '06-01-19', '13-01-19', '20-01-19']
+ )
+
+ frappe.db.rollback()
+
+ def test_avg_dashboard_chart(self):
+ insert_test_records()
+
+ if frappe.db.exists('Dashboard Chart', 'Test Average Dashboard Chart'):
+ frappe.delete_doc('Dashboard Chart', 'Test Average Dashboard Chart')
+
+ frappe.get_doc(dict(
+ doctype = 'Dashboard Chart',
+ chart_name = 'Test Average Dashboard Chart',
+ chart_type = 'Average',
+ document_type = 'Communication',
+ based_on = 'communication_date',
+ value_based_on = 'rating',
+ timespan = 'Select Date Range',
+ time_interval = 'Weekly',
+ from_date = datetime(2018, 12, 30),
+ to_date = datetime(2019, 1, 15),
+ filters_json = '[]',
+ timeseries = 1
+ )).insert()
+
+ with patch.object(frappe.utils.data, "get_first_day_of_the_week", return_value="Monday"):
+ result = get(chart_name='Test Average Dashboard Chart', refresh = 1)
+ self.assertEqual(
+ result.get('labels'),
+ ['30-12-18', '06-01-19', '13-01-19', '20-01-19']
+ )
+ self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 150.0, 266.6666666666667, 0.0])
frappe.db.rollback()
def insert_test_records():
- create_new_communication(datetime(2018, 12, 30), 50)
- create_new_communication(datetime(2019, 1, 4), 100)
- create_new_communication(datetime(2019, 1, 6), 200)
- create_new_communication(datetime(2019, 1, 7), 400)
- create_new_communication(datetime(2019, 1, 8), 300)
- create_new_communication(datetime(2019, 1, 10), 100)
+ create_new_communication('Communication 1', datetime(2018, 12, 30), 50)
+ create_new_communication('Communication 2', datetime(2019, 1, 4), 100)
+ create_new_communication('Communication 3', datetime(2019, 1, 6), 200)
+ create_new_communication('Communication 4', datetime(2019, 1, 7), 400)
+ create_new_communication('Communication 5', datetime(2019, 1, 8), 300)
+ create_new_communication('Communication 6', datetime(2019, 1, 10), 100)
-def create_new_communication(date, rating):
+def create_new_communication(subject, date, rating):
communication = {
'doctype': 'Communication',
- 'subject': 'Test Communication',
+ 'subject': subject,
'rating': rating,
'communication_date': date
}
- frappe.get_doc(communication).insert()
+ comm = frappe.get_doc(communication)
+ if not frappe.db.exists("Communication", {'subject' : comm.subject}):
+ comm.insert()
diff --git a/frappe/desk/doctype/dashboard_chart_field/dashboard_chart_field.py b/frappe/desk/doctype/dashboard_chart_field/dashboard_chart_field.py
index 734f27cc28..8b2fba2e58 100644
--- a/frappe/desk/doctype/dashboard_chart_field/dashboard_chart_field.py
+++ b/frappe/desk/doctype/dashboard_chart_field/dashboard_chart_field.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/dashboard_chart_link/dashboard_chart_link.py b/frappe/desk/doctype/dashboard_chart_link/dashboard_chart_link.py
index 7cd4f9daa3..87d095d5d1 100644
--- a/frappe/desk/doctype/dashboard_chart_link/dashboard_chart_link.py
+++ b/frappe/desk/doctype/dashboard_chart_link/dashboard_chart_link.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.py b/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.py
index 6685009078..71ded32837 100644
--- a/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.py
+++ b/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe, os
from frappe import _
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/dashboard_chart_source/test_dashboard_chart_source.py b/frappe/desk/doctype/dashboard_chart_source/test_dashboard_chart_source.py
index 822526b591..6d6773d52e 100644
--- a/frappe/desk/doctype/dashboard_chart_source/test_dashboard_chart_source.py
+++ b/frappe/desk/doctype/dashboard_chart_source/test_dashboard_chart_source.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import unittest
class TestDashboardChartSource(unittest.TestCase):
diff --git a/frappe/desk/doctype/dashboard_settings/dashboard_settings.py b/frappe/desk/doctype/dashboard_settings/dashboard_settings.py
index 4697d897fc..2f29b3e989 100644
--- a/frappe/desk/doctype/dashboard_settings/dashboard_settings.py
+++ b/frappe/desk/doctype/dashboard_settings/dashboard_settings.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
import frappe
diff --git a/frappe/desk/doctype/desktop_icon/desktop_icon.py b/frappe/desk/doctype/desktop_icon/desktop_icon.py
index fcf10ef61d..194b0d0ca4 100644
--- a/frappe/desk/doctype/desktop_icon/desktop_icon.py
+++ b/frappe/desk/doctype/desktop_icon/desktop_icon.py
@@ -1,15 +1,12 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
from frappe import _
import json
import random
from frappe.model.document import Document
-from six import iteritems, string_types
from frappe.utils.user import UserPermissions
class DesktopIcon(Document):
@@ -173,7 +170,7 @@ def add_user_icon(_doctype, _report=None, label=None, link=None, type='link', st
@frappe.whitelist()
def set_order(new_order, user=None):
'''set new order by duplicating user icons (if user is set) or set global order'''
- if isinstance(new_order, string_types):
+ if isinstance(new_order, str):
new_order = json.loads(new_order)
for i, module_name in enumerate(new_order):
if module_name not in ('Explore',):
@@ -200,7 +197,7 @@ def set_desktop_icons(visible_list, ignore_duplicate=True):
# clear all custom only if setup is not complete
if not int(frappe.defaults.get_defaults().setup_complete or 0):
- frappe.db.sql('delete from `tabDesktop Icon` where standard=0')
+ frappe.db.delete("Desktop Icon", {"standard": 0})
# set standard as blocked and hidden if setting first active domain
if not frappe.flags.keep_desktop_icons:
@@ -232,7 +229,7 @@ def set_hidden_list(hidden_list, user=None):
'''Sets property `hidden`=1 in **Desktop Icon** for given user.
If user is None then it will set global values.
It will also set the rest of the icons as shown (`hidden` = 0)'''
- if isinstance(hidden_list, string_types):
+ if isinstance(hidden_list, str):
hidden_list = json.loads(hidden_list)
# set as hidden
@@ -329,7 +326,7 @@ def sync_from_app(app):
if isinstance(modules, dict):
modules_list = []
- for m, desktop_icon in iteritems(modules):
+ for m, desktop_icon in modules.items():
desktop_icon['module_name'] = m
modules_list.append(desktop_icon)
else:
diff --git a/frappe/desk/doctype/event/__init__.py b/frappe/desk/doctype/event/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/desk/doctype/event/__init__.py
+++ b/frappe/desk/doctype/event/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/desk/doctype/event/event.json b/frappe/desk/doctype/event/event.json
index 5768f00f32..2f67c36fc0 100644
--- a/frappe/desk/doctype/event/event.json
+++ b/frappe/desk/doctype/event/event.json
@@ -53,7 +53,7 @@
},
{
"fieldname": "subject",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"in_global_search": 1,
"in_list_view": 1,
"label": "Subject",
@@ -277,10 +277,11 @@
"icon": "fa fa-calendar",
"idx": 1,
"links": [],
- "modified": "2020-01-14 21:47:15.825287",
+ "modified": "2021-11-18 05:06:24.881742",
"modified_by": "Administrator",
"module": "Desk",
"name": "Event",
+ "naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
diff --git a/frappe/desk/doctype/event/event.py b/frappe/desk/doctype/event/event.py
index 54905bed6a..86f0656bc6 100644
--- a/frappe/desk/doctype/event/event.py
+++ b/frappe/desk/doctype/event/event.py
@@ -1,9 +1,7 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
+
-from __future__ import unicode_literals
-from six.moves import range
-from six import string_types
import frappe
import json
@@ -13,6 +11,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils.user import get_enabled_system_users
from frappe.desk.reportview import get_filters_cond
+from frappe.desk.doctype.notification_settings.notification_settings import is_email_notifications_enabled_for_type
weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
communication_mapping = {"": "Event", "Event": "Event", "Meeting": "Meeting", "Call": "Phone", "Sent/Received Email": "Email", "Other": "Other"}
@@ -106,7 +105,7 @@ class Event(Document):
@frappe.whitelist()
def delete_communication(event, reference_doctype, reference_docname):
deleted_participant = frappe.get_doc(reference_doctype, reference_docname)
- if isinstance(event, string_types):
+ if isinstance(event, str):
event = json.loads(event)
filters = [
@@ -143,7 +142,12 @@ def has_permission(doc, user):
def send_event_digest():
today = nowdate()
- for user in get_enabled_system_users():
+
+ # select only those users that have event reminder email notifications enabled
+ users = [user for user in get_enabled_system_users() if
+ is_email_notifications_enabled_for_type(user.name, 'Event Reminders')]
+
+ for user in users:
events = get_events(today, today, user.name, for_reminder=True)
if events:
frappe.set_user_lang(user.name, user.language)
@@ -168,7 +172,7 @@ def get_events(start, end, user=None, for_reminder=False, filters=None):
if not user:
user = frappe.session.user
- if isinstance(filters, string_types):
+ if isinstance(filters, str):
filters = json.loads(filters)
filter_condition = get_filters_cond('Event', filters, [])
@@ -340,9 +344,8 @@ def delete_events(ref_type, ref_name, delete_event=False):
total_participants = frappe.get_all("Event Participants", filters={"parenttype": "Event", "parent": participation.parent})
if len(total_participants) <= 1:
- frappe.db.sql("DELETE FROM `tabEvent` WHERE `name` = %(name)s", {'name': participation.parent})
-
- frappe.db.sql("DELETE FROM `tabEvent Participants ` WHERE `name` = %(name)s", {'name': participation.name})
+ frappe.db.delete("Event", {"name": participation.parent})
+ frappe.db.delete("Event Participants", {"name": participation.name})
# Close events if ends_on or repeat_till is less than now_datetime
def set_status_of_events():
diff --git a/frappe/desk/doctype/event/test_event.js b/frappe/desk/doctype/event/test_event.js
deleted file mode 100644
index 9e6a5ff349..0000000000
--- a/frappe/desk/doctype/event/test_event.js
+++ /dev/null
@@ -1,42 +0,0 @@
-
-QUnit.test("test: Event", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(4);
-
- const subject = '_Test Event 1';
- const datetime = frappe.datetime.now_datetime();
- const hex = '#6be273';
- const rgb = 'rgb(107, 226, 115)';
-
- frappe.run_serially([
- // insert a new Event
- () => frappe.tests.make('Event', [
- // values to be set
- {subject: subject},
- {starts_on: datetime},
- {color: hex},
- {event_type: 'Private'}
- ]),
- () => {
- assert.equal(cur_frm.doc.subject, subject, 'Subject correctly set');
- assert.equal(cur_frm.doc.starts_on, datetime, 'Date correctly set');
- assert.equal(cur_frm.doc.color, hex, 'Color correctly set');
-
- // set filters explicitly for list view
- frappe.route_options = {
- event_type: 'Private'
- };
- },
- () => frappe.set_route('List', 'Event', 'Calendar'),
- () => frappe.timeout(2),
- () => {
- const bg_color = $(`.result:visible .fc-day-grid-event:contains("${subject}")`)
- .css('background-color');
- assert.equal(bg_color, rgb, 'Event background color is set correctly');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/desk/doctype/event/test_event.py b/frappe/desk/doctype/event/test_event.py
index 2926a74a55..b0269a80cc 100644
--- a/frappe/desk/doctype/event/test_event.py
+++ b/frappe/desk/doctype/event/test_event.py
@@ -1,7 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
"""Use blog post test to test user permissions logic"""
import frappe
@@ -16,7 +14,7 @@ test_records = frappe.get_test_records('Event')
class TestEvent(unittest.TestCase):
def setUp(self):
- frappe.db.sql('delete from tabEvent')
+ frappe.db.delete("Event")
make_test_objects('Event', reset=True)
self.test_records = frappe.get_test_records('Event')
@@ -95,7 +93,7 @@ class TestEvent(unittest.TestCase):
# Remove an assignment
todo = frappe.get_doc("ToDo", {"reference_type": ev.doctype, "reference_name": ev.name,
- "owner": self.test_user})
+ "allocated_to": self.test_user})
todo.status = "Cancelled"
todo.save()
diff --git a/frappe/desk/doctype/event_participants/event_participants.py b/frappe/desk/doctype/event_participants/event_participants.py
index 18e4672140..b834ba3a82 100644
--- a/frappe/desk/doctype/event_participants/event_participants.py
+++ b/frappe/desk/doctype/event_participants/event_participants.py
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies and contributors
-# For license information, please see license.txt
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
from frappe.model.document import Document
class EventParticipants(Document):
diff --git a/frappe/patches/v5_3/__init__.py b/frappe/desk/doctype/form_tour/__init__.py
similarity index 100%
rename from frappe/patches/v5_3/__init__.py
rename to frappe/desk/doctype/form_tour/__init__.py
diff --git a/frappe/desk/doctype/form_tour/form_tour.js b/frappe/desk/doctype/form_tour/form_tour.js
new file mode 100644
index 0000000000..3f3fc0ff8a
--- /dev/null
+++ b/frappe/desk/doctype/form_tour/form_tour.js
@@ -0,0 +1,133 @@
+// Copyright (c) 2021, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Form Tour', {
+ setup: function(frm) {
+ if (!frm.doc.is_standard || frappe.boot.developer_mode) {
+ frm.trigger('setup_queries');
+ }
+ },
+
+ refresh(frm) {
+ if (frm.doc.is_standard && !frappe.boot.developer_mode) {
+ frm.trigger("disable_form");
+ }
+
+ frm.add_custom_button(__('Show Tour'), async () => {
+ const issingle = await check_if_single(frm.doc.reference_doctype);
+ let route_changed = null;
+
+ if (issingle) {
+ route_changed = frappe.set_route('Form', frm.doc.reference_doctype);
+ } else if (frm.doc.first_document) {
+ const name = await get_first_document(frm.doc.reference_doctype);
+ route_changed = frappe.set_route('Form', frm.doc.reference_doctype, name);
+ } else {
+ route_changed = frappe.set_route('Form', frm.doc.reference_doctype, 'new');
+ }
+ route_changed.then(() => {
+ const tour_name = frm.doc.name;
+ cur_frm.tour
+ .init({ tour_name })
+ .then(() => cur_frm.tour.start());
+ });
+ });
+ },
+
+ disable_form: function(frm) {
+ frm.set_read_only();
+ frm.fields
+ .filter((field) => field.has_input)
+ .forEach((field) => {
+ frm.set_df_property(field.df.fieldname, "read_only", "1");
+ });
+ frm.disable_save();
+ },
+
+ setup_queries(frm) {
+ frm.set_query("reference_doctype", function() {
+ return {
+ filters: {
+ istable: 0
+ }
+ };
+ });
+
+ frm.trigger('reference_doctype');
+ },
+
+ reference_doctype(frm) {
+ if (!frm.doc.reference_doctype) return;
+
+ frm.set_fields_as_options(
+ "fieldname",
+ frm.doc.reference_doctype,
+ df => !df.hidden
+ ).then(options => {
+ frm.fields_dict.steps.grid.update_docfield_property(
+ "fieldname",
+ "options",
+ [""].concat(options)
+ );
+ });
+
+ frm.set_fields_as_options(
+ 'parent_fieldname',
+ frm.doc.reference_doctype,
+ (df) => df.fieldtype == "Table" && !df.hidden,
+ ).then(options => {
+ frm.fields_dict.steps.grid.update_docfield_property(
+ "parent_fieldname",
+ "options",
+ [""].concat(options)
+ );
+ });
+
+ }
+});
+
+frappe.ui.form.on('Form Tour Step', {
+ form_render(frm, cdt, cdn) {
+ if (locals[cdt][cdn].is_table_field) {
+ frm.trigger('parent_fieldname', cdt, cdn);
+ }
+ },
+ parent_fieldname(frm, cdt, cdn) {
+ const child_row = locals[cdt][cdn];
+
+ const parent_fieldname_df = frappe
+ .get_meta(frm.doc.reference_doctype)
+ .fields.find(df => df.fieldname == child_row.parent_fieldname);
+
+ frm.set_fields_as_options(
+ 'fieldname',
+ parent_fieldname_df.options,
+ (df) => !df.hidden,
+ ).then(options => {
+ frm.fields_dict.steps.grid.update_docfield_property(
+ "fieldname",
+ "options",
+ [""].concat(options)
+ );
+ if (child_row.fieldname) {
+ frappe.model.set_value(cdt, cdn, 'fieldname', child_row.fieldname);
+ }
+ });
+ }
+});
+
+async function check_if_single(doctype) {
+ const { message } = await frappe.db.get_value('DocType', doctype, 'issingle');
+ return message.issingle || 0;
+}
+
+async function get_first_document(doctype) {
+ let docname;
+
+ await frappe.db.get_list(doctype, { order_by: "creation" }).then(res => {
+ if (Array.isArray(res) && res.length)
+ docname = res[0].name;
+ });
+
+ return docname || 'new';
+}
diff --git a/frappe/desk/doctype/form_tour/form_tour.json b/frappe/desk/doctype/form_tour/form_tour.json
new file mode 100644
index 0000000000..6f3bd56a4e
--- /dev/null
+++ b/frappe/desk/doctype/form_tour/form_tour.json
@@ -0,0 +1,112 @@
+{
+ "actions": [],
+ "autoname": "field:title",
+ "creation": "2021-05-21 23:02:52.242721",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "reference_doctype",
+ "module",
+ "column_break_6",
+ "is_standard",
+ "save_on_complete",
+ "first_document",
+ "include_name_field",
+ "section_break_3",
+ "steps"
+ ],
+ "fields": [
+ {
+ "fieldname": "reference_doctype",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Reference Document",
+ "options": "DocType",
+ "reqd": 1
+ },
+ {
+ "depends_on": "reference_doctype",
+ "fieldname": "steps",
+ "fieldtype": "Table",
+ "label": "Steps",
+ "options": "Form Tour Step",
+ "reqd": 1
+ },
+ {
+ "fieldname": "section_break_3",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "label": "Title",
+ "reqd": 1,
+ "unique": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "save_on_complete",
+ "fieldtype": "Check",
+ "label": "Save on Completion"
+ },
+ {
+ "default": "0",
+ "fieldname": "is_standard",
+ "fieldtype": "Check",
+ "label": "Is Standard"
+ },
+ {
+ "fetch_from": "reference_doctype.module",
+ "fieldname": "module",
+ "fieldtype": "Link",
+ "hidden": 1,
+ "label": "Module",
+ "options": "Module Def",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_6",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "fieldname": "first_document",
+ "fieldtype": "Check",
+ "label": "Show First Document Tour"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:!doc.first_document",
+ "fieldname": "include_name_field",
+ "fieldtype": "Check",
+ "label": "Include Name Field"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-11-24 12:03:45.449311",
+ "modified_by": "Administrator",
+ "module": "Desk",
+ "name": "Form Tour",
+ "naming_rule": "By fieldname",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/desk/doctype/form_tour/form_tour.py b/frappe/desk/doctype/form_tour/form_tour.py
new file mode 100644
index 0000000000..6248b43e62
--- /dev/null
+++ b/frappe/desk/doctype/form_tour/form_tour.py
@@ -0,0 +1,27 @@
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+import frappe
+from frappe.model.document import Document
+from frappe.modules.export_file import export_to_files
+
+
+class FormTour(Document):
+ def before_save(self):
+ meta = frappe.get_meta(self.reference_doctype)
+ for step in self.steps:
+ if step.is_table_field and step.parent_fieldname:
+ parent_field_df = meta.get_field(step.parent_fieldname)
+ step.child_doctype = parent_field_df.options
+
+ field_df = frappe.get_meta(step.child_doctype).get_field(step.fieldname)
+ step.label = field_df.label
+ step.fieldtype = field_df.fieldtype
+ else:
+ field_df = meta.get_field(step.fieldname)
+ step.label = field_df.label
+ step.fieldtype = field_df.fieldtype
+
+ def on_update(self):
+ if frappe.conf.developer_mode and self.is_standard:
+ export_to_files([["Form Tour", self.name]], self.module)
diff --git a/frappe/desk/doctype/form_tour/test_form_tour.py b/frappe/desk/doctype/form_tour/test_form_tour.py
new file mode 100644
index 0000000000..3670cbc218
--- /dev/null
+++ b/frappe/desk/doctype/form_tour/test_form_tour.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# License: MIT. See LICENSE
+
+# import frappe
+import unittest
+
+class TestFormTour(unittest.TestCase):
+ pass
diff --git a/frappe/patches/v6_0/__init__.py b/frappe/desk/doctype/form_tour_step/__init__.py
similarity index 100%
rename from frappe/patches/v6_0/__init__.py
rename to frappe/desk/doctype/form_tour_step/__init__.py
diff --git a/frappe/desk/doctype/form_tour_step/form_tour_step.json b/frappe/desk/doctype/form_tour_step/form_tour_step.json
new file mode 100644
index 0000000000..7eb6eab223
--- /dev/null
+++ b/frappe/desk/doctype/form_tour_step/form_tour_step.json
@@ -0,0 +1,128 @@
+{
+ "actions": [],
+ "creation": "2021-05-21 23:05:45.342114",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "is_table_field",
+ "section_break_2",
+ "parent_fieldname",
+ "fieldname",
+ "title",
+ "description",
+ "column_break_2",
+ "position",
+ "label",
+ "fieldtype",
+ "has_next_condition",
+ "next_step_condition",
+ "section_break_13",
+ "child_doctype"
+ ],
+ "fields": [
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "columns": 4,
+ "fieldname": "description",
+ "fieldtype": "HTML Editor",
+ "in_list_view": 1,
+ "label": "Description",
+ "reqd": 1
+ },
+ {
+ "depends_on": "eval: (!doc.is_table_field || (doc.is_table_field && doc.parent_fieldname))",
+ "fieldname": "fieldname",
+ "fieldtype": "Select",
+ "label": "Fieldname",
+ "reqd": 1
+ },
+ {
+ "fieldname": "label",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Label",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_2",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "Bottom",
+ "fieldname": "position",
+ "fieldtype": "Select",
+ "label": "Position",
+ "options": "Left\nLeft Center\nLeft Bottom\nTop\nTop Center\nTop Right\nRight\nRight Center\nRight Bottom\nBottom\nBottom Center\nBottom Right\nMid Center"
+ },
+ {
+ "depends_on": "has_next_condition",
+ "fieldname": "next_step_condition",
+ "fieldtype": "Code",
+ "label": "Next Step Condition",
+ "oldfieldname": "condition",
+ "options": "JS"
+ },
+ {
+ "default": "0",
+ "fieldname": "has_next_condition",
+ "fieldtype": "Check",
+ "label": "Has Next Condition"
+ },
+ {
+ "default": "0",
+ "fieldname": "fieldtype",
+ "fieldtype": "Data",
+ "label": "Fieldtype",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "is_table_field",
+ "fieldtype": "Check",
+ "label": "Is Table Field"
+ },
+ {
+ "fieldname": "section_break_2",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "section_break_13",
+ "fieldtype": "Section Break",
+ "hidden": 1,
+ "label": "Hidden Fields"
+ },
+ {
+ "fieldname": "child_doctype",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Child Doctype",
+ "read_only": 1
+ },
+ {
+ "depends_on": "is_table_field",
+ "fieldname": "parent_fieldname",
+ "fieldtype": "Select",
+ "label": "Parent Field",
+ "mandatory_depends_on": "is_table_field"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2022-01-27 15:18:36.481801",
+ "modified_by": "Administrator",
+ "module": "Desk",
+ "name": "Form Tour Step",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/desk/doctype/form_tour_step/form_tour_step.py b/frappe/desk/doctype/form_tour_step/form_tour_step.py
new file mode 100644
index 0000000000..bbc8edea08
--- /dev/null
+++ b/frappe/desk/doctype/form_tour_step/form_tour_step.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+# import frappe
+from frappe.model.document import Document
+
+class FormTourStep(Document):
+ pass
diff --git a/frappe/desk/doctype/global_search_doctype/global_search_doctype.py b/frappe/desk/doctype/global_search_doctype/global_search_doctype.py
index 4c9a948278..30a31f959f 100644
--- a/frappe/desk/doctype/global_search_doctype/global_search_doctype.py
+++ b/frappe/desk/doctype/global_search_doctype/global_search_doctype.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/global_search_settings/global_search_settings.py b/frappe/desk/doctype/global_search_settings/global_search_settings.py
index 85c9687ab3..e9a47cecd1 100644
--- a/frappe/desk/doctype/global_search_settings/global_search_settings.py
+++ b/frappe/desk/doctype/global_search_settings/global_search_settings.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe import _
@@ -22,7 +21,7 @@ class GlobalSearchSettings(Document):
dts.append(dt.document_type)
if core_dts:
- core_dts = (", ".join([frappe.bold(dt) for dt in core_dts]))
+ core_dts = ", ".join(frappe.bold(dt) for dt in core_dts)
frappe.throw(_("Core Modules {0} cannot be searched in Global Search.").format(core_dts))
if repeated_dts:
@@ -34,7 +33,7 @@ class GlobalSearchSettings(Document):
def get_doctypes_for_global_search():
def get_from_db():
- doctypes = frappe.get_list("Global Search DocType", fields=["document_type"], order_by="idx ASC")
+ doctypes = frappe.get_all("Global Search DocType", fields=["document_type"], order_by="idx ASC")
return [d.document_type for d in doctypes] or []
return frappe.cache().hget("global_search", "search_priorities", get_from_db)
@@ -61,7 +60,7 @@ def update_global_search_doctypes():
if search_doctypes.get(domain):
global_search_doctypes.extend(search_doctypes.get(domain))
- doctype_list = set([dt.name for dt in frappe.get_all("DocType")])
+ doctype_list = {dt.name for dt in frappe.get_all("DocType")}
allowed_in_global_search = []
for dt in global_search_doctypes:
diff --git a/frappe/desk/doctype/kanban_board/kanban_board.py b/frappe/desk/doctype/kanban_board/kanban_board.py
index a655e9e1da..97f529a061 100644
--- a/frappe/desk/doctype/kanban_board/kanban_board.py
+++ b/frappe/desk/doctype/kanban_board/kanban_board.py
@@ -1,13 +1,11 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
import json
from frappe import _
from frappe.model.document import Document
-from six import iteritems
class KanbanBoard(Document):
@@ -78,26 +76,6 @@ def archive_restore_column(board_name, column_title, status):
return doc.columns
-@frappe.whitelist()
-def update_doc(doc):
- '''Updates the doc when card is edited'''
- doc = json.loads(doc)
-
- try:
- to_update = doc
- doctype = doc['doctype']
- docname = doc['name']
- doc = frappe.get_doc(doctype, docname)
- doc.update(to_update)
- doc.save()
- except:
- return {
- 'doc': doc,
- 'exc': frappe.utils.get_traceback()
- }
- return doc
-
-
@frappe.whitelist()
def update_order(board_name, order):
'''Save the order of cards in columns'''
@@ -107,7 +85,7 @@ def update_order(board_name, order):
order_dict = json.loads(order)
updated_cards = []
- for col_name, cards in iteritems(order_dict):
+ for col_name, cards in order_dict.items():
order_list = []
for card in cards:
column = frappe.get_value(
diff --git a/frappe/desk/doctype/kanban_board/test_kanban_board.py b/frappe/desk/doctype/kanban_board/test_kanban_board.py
index 33947f4a54..f00446141a 100644
--- a/frappe/desk/doctype/kanban_board/test_kanban_board.py
+++ b/frappe/desk/doctype/kanban_board/test_kanban_board.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/desk/doctype/kanban_board_column/kanban_board_column.json b/frappe/desk/doctype/kanban_board_column/kanban_board_column.json
index 95d9294e9a..c0acde5da5 100644
--- a/frappe/desk/doctype/kanban_board_column/kanban_board_column.json
+++ b/frappe/desk/doctype/kanban_board_column/kanban_board_column.json
@@ -1,155 +1,55 @@
{
- "allow_copy": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "beta": 0,
- "creation": "2016-10-19 12:26:42.569185",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 1,
- "engine": "InnoDB",
+ "actions": [],
+ "creation": "2016-10-19 12:26:42.569185",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "column_name",
+ "status",
+ "indicator",
+ "order"
+ ],
"fields": [
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_name",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Column Name",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "column_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Column Name"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "default": "Active",
- "fieldname": "status",
- "fieldtype": "Select",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Status",
- "length": 0,
- "no_copy": 0,
- "options": "Active\nArchived",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "default": "Active",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Status",
+ "options": "Active\nArchived"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "default": "darkgrey",
- "fieldname": "indicator",
- "fieldtype": "Select",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Indicator",
- "length": 0,
- "no_copy": 0,
- "options": "blue\norange\nred\ngreen\ndarkgrey\npurple\nyellow\nlightblue",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "default": "Gray",
+ "fieldname": "indicator",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Indicator",
+ "options": "Blue\nCyan\nGray\nGreen\nLight Blue\nOrange\nPink\nPurple\nRed\nRed\nYellow"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "order",
- "fieldtype": "Code",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Order",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "fieldname": "order",
+ "fieldtype": "Code",
+ "label": "Order"
}
- ],
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
- "in_create": 0,
-
- "is_submittable": 0,
- "issingle": 0,
- "istable": 1,
- "max_attachments": 0,
- "modified": "2017-01-17 15:23:43.520379",
- "modified_by": "Administrator",
- "module": "Desk",
- "name": "Kanban Board Column",
- "name_case": "",
- "owner": "Administrator",
- "permissions": [],
- "quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1,
- "track_seen": 0
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2021-12-14 13:13:38.804259",
+ "modified_by": "Administrator",
+ "module": "Desk",
+ "name": "Kanban Board Column",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/desk/doctype/kanban_board_column/kanban_board_column.py b/frappe/desk/doctype/kanban_board_column/kanban_board_column.py
index 4ea30d21b2..d919fd6aed 100644
--- a/frappe/desk/doctype/kanban_board_column/kanban_board_column.py
+++ b/frappe/desk/doctype/kanban_board_column/kanban_board_column.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/list_filter/list_filter.py b/frappe/desk/doctype/list_filter/list_filter.py
index 035f7e90b9..d2b01d301e 100644
--- a/frappe/desk/doctype/list_filter/list_filter.py
+++ b/frappe/desk/doctype/list_filter/list_filter.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe, json
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/list_view_settings/list_view_settings.py b/frappe/desk/doctype/list_view_settings/list_view_settings.py
index 74e029f499..78b56fe7d5 100644
--- a/frappe/desk/doctype/list_view_settings/list_view_settings.py
+++ b/frappe/desk/doctype/list_view_settings/list_view_settings.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/list_view_settings/test_list_view_settings.py b/frappe/desk/doctype/list_view_settings/test_list_view_settings.py
index c1b2f4a0da..85872dd36e 100644
--- a/frappe/desk/doctype/list_view_settings/test_list_view_settings.py
+++ b/frappe/desk/doctype/list_view_settings/test_list_view_settings.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/desk/doctype/module_onboarding/module_onboarding.py b/frappe/desk/doctype/module_onboarding/module_onboarding.py
index 8315c0b304..aa268c792c 100644
--- a/frappe/desk/doctype/module_onboarding/module_onboarding.py
+++ b/frappe/desk/doctype/module_onboarding/module_onboarding.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe.modules.export_file import export_to_files
diff --git a/frappe/desk/doctype/module_onboarding/test_module_onboarding.py b/frappe/desk/doctype/module_onboarding/test_module_onboarding.py
index ef305667b1..42f472abc1 100644
--- a/frappe/desk/doctype/module_onboarding/test_module_onboarding.py
+++ b/frappe/desk/doctype/module_onboarding/test_module_onboarding.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/desk/doctype/note/note.css b/frappe/desk/doctype/note/note.css
deleted file mode 100644
index b5026d2e46..0000000000
--- a/frappe/desk/doctype/note/note.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.like-disabled-input{
- background-color: #fff;
-}
\ No newline at end of file
diff --git a/frappe/desk/doctype/note/note.json b/frappe/desk/doctype/note/note.json
index 8d476e83fe..69a9518ac4 100644
--- a/frappe/desk/doctype/note/note.json
+++ b/frappe/desk/doctype/note/note.json
@@ -1,322 +1,106 @@
{
- "allow_copy": 0,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 1,
- "beta": 0,
- "creation": "2013-05-24 13:41:00",
- "custom": 0,
- "description": "",
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "Document",
- "editable_grid": 0,
- "fields": [
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "title",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 1,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Title",
- "length": 0,
- "no_copy": 1,
- "permlevel": 0,
- "print_hide": 1,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 1,
- "collapsible": 0,
- "columns": 0,
- "description": "",
- "fieldname": "public",
- "fieldtype": "Check",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Public",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 1,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 1,
- "collapsible": 0,
- "columns": 0,
- "depends_on": "public",
- "fieldname": "notify_on_login",
- "fieldtype": "Check",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Notify users with a popup when they log in",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 1,
- "collapsible": 0,
- "columns": 0,
- "default": "0",
- "depends_on": "notify_on_login",
- "description": "If enabled, users will be notified every time they login. If not enabled, users will only be notified once.",
- "fieldname": "notify_on_every_login",
- "fieldtype": "Check",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Notify Users On Every Login",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "depends_on": "eval:doc.notify_on_login && doc.public",
- "fieldname": "expire_notification_on",
- "fieldtype": "Date",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Expire Notification On",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 1,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 1,
- "collapsible": 0,
- "columns": 0,
- "description": "Help: To link to another record in the system, use \"#Form/Note/[Note Name]\" as the Link URL. (don't use \"http://\")",
- "fieldname": "content",
- "fieldtype": "Text Editor",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 1,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Content",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 1,
- "columns": 0,
- "fieldname": "seen_by_section",
- "fieldtype": "Section Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Seen By",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "seen_by",
- "fieldtype": "Table",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Seen By Table",
- "length": 0,
- "no_copy": 0,
- "options": "Note Seen By",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
- }
- ],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "icon": "fa fa-file-text",
- "idx": 1,
- "image_view": 0,
- "in_create": 0,
- "is_submittable": 0,
- "issingle": 0,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2018-09-21 15:15:44.909636",
- "modified_by": "Administrator",
- "module": "Desk",
- "name": "Note",
- "owner": "Administrator",
- "permissions": [
- {
- "amend": 0,
- "cancel": 0,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 0,
- "role": "All",
- "set_user_permissions": 0,
- "share": 1,
- "submit": 0,
- "write": 1
- }
- ],
- "quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 1,
- "show_name_in_global_search": 0,
- "sort_order": "ASC",
- "track_changes": 1,
- "track_seen": 0,
- "track_views": 0
- }
\ No newline at end of file
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2013-05-24 13:41:00",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "public",
+ "notify_on_login",
+ "notify_on_every_login",
+ "expire_notification_on",
+ "content",
+ "seen_by_section",
+ "seen_by"
+ ],
+ "fields": [
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "in_global_search": 1,
+ "in_list_view": 1,
+ "label": "Title",
+ "no_copy": 1,
+ "print_hide": 1,
+ "reqd": 1
+ },
+ {
+ "bold": 1,
+ "default": "0",
+ "fieldname": "public",
+ "fieldtype": "Check",
+ "label": "Public",
+ "print_hide": 1
+ },
+ {
+ "bold": 1,
+ "default": "0",
+ "depends_on": "public",
+ "fieldname": "notify_on_login",
+ "fieldtype": "Check",
+ "label": "Notify users with a popup when they log in"
+ },
+ {
+ "bold": 1,
+ "default": "0",
+ "depends_on": "notify_on_login",
+ "description": "If enabled, users will be notified every time they login. If not enabled, users will only be notified once.",
+ "fieldname": "notify_on_every_login",
+ "fieldtype": "Check",
+ "label": "Notify Users On Every Login"
+ },
+ {
+ "depends_on": "eval:doc.notify_on_login && doc.public",
+ "fieldname": "expire_notification_on",
+ "fieldtype": "Date",
+ "label": "Expire Notification On",
+ "search_index": 1
+ },
+ {
+ "bold": 1,
+ "description": "Help: To link to another record in the system, use \"/app/note/[Note Name]\" as the Link URL. (don't use \"http://\")",
+ "fieldname": "content",
+ "fieldtype": "Text Editor",
+ "in_global_search": 1,
+ "label": "Content"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "seen_by_section",
+ "fieldtype": "Section Break",
+ "label": "Seen By"
+ },
+ {
+ "fieldname": "seen_by",
+ "fieldtype": "Table",
+ "label": "Seen By Table",
+ "options": "Note Seen By"
+ }
+ ],
+ "icon": "fa fa-file-text",
+ "idx": 1,
+ "links": [],
+ "modified": "2021-09-18 10:57:51.352643",
+ "modified_by": "Administrator",
+ "module": "Desk",
+ "name": "Note",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "role": "All",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "ASC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/desk/doctype/note/note.py b/frappe/desk/doctype/note/note.py
index c54689418e..ae7af07cd9 100644
--- a/frappe/desk/doctype/note/note.py
+++ b/frappe/desk/doctype/note/note.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/note/test_note.js b/frappe/desk/doctype/note/test_note.js
deleted file mode 100644
index b52c3cf7ea..0000000000
--- a/frappe/desk/doctype/note/test_note.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-QUnit.test("test: Note", function (assert) {
- let done = assert.async();
- // number of asserts
- assert.expect(1);
- frappe.run_serially([
- // insert a new Note
- () => frappe.tests.make('Note', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
- });
\ No newline at end of file
diff --git a/frappe/desk/doctype/note/test_note.py b/frappe/desk/doctype/note/test_note.py
index 38894a9c3d..ac2116c38a 100644
--- a/frappe/desk/doctype/note/test_note.py
+++ b/frappe/desk/doctype/note/test_note.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
-# See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
import unittest
@@ -9,9 +8,9 @@ test_records = frappe.get_test_records('Note')
class TestNote(unittest.TestCase):
def insert_note(self):
- frappe.db.sql('delete from tabVersion')
- frappe.db.sql('delete from tabNote')
- frappe.db.sql('delete from `tabNote Seen By`')
+ frappe.db.delete("Version")
+ frappe.db.delete("Note")
+ frappe.db.delete("Note Seen By")
return frappe.get_doc(dict(doctype='Note', title='test note',
content='test note content')).insert()
diff --git a/frappe/desk/doctype/note_seen_by/note_seen_by.py b/frappe/desk/doctype/note_seen_by/note_seen_by.py
index 6123f20929..01bee05a9f 100644
--- a/frappe/desk/doctype/note_seen_by/note_seen_by.py
+++ b/frappe/desk/doctype/note_seen_by/note_seen_by.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/notification_log/notification_log.json b/frappe/desk/doctype/notification_log/notification_log.json
index 9e802298e3..e188708277 100644
--- a/frappe/desk/doctype/notification_log/notification_log.json
+++ b/frappe/desk/doctype/notification_log/notification_log.json
@@ -120,7 +120,7 @@
"hide_toolbar": 1,
"in_create": 1,
"links": [],
- "modified": "2020-09-18 17:26:09.703215",
+ "modified": "2021-10-25 17:26:09.703215",
"modified_by": "Administrator",
"module": "Desk",
"name": "Notification Log",
@@ -139,6 +139,5 @@
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "subject",
- "track_changes": 1,
"track_seen": 1
-}
\ No newline at end of file
+}
diff --git a/frappe/desk/doctype/notification_log/notification_log.py b/frappe/desk/doctype/notification_log/notification_log.py
index a6126f1f9b..12e628ada2 100644
--- a/frappe/desk/doctype/notification_log/notification_log.py
+++ b/frappe/desk/doctype/notification_log/notification_log.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.model.document import Document
@@ -13,7 +12,10 @@ class NotificationLog(Document):
frappe.publish_realtime('notification', after_commit=True, user=self.for_user)
set_notifications_as_unseen(self.for_user)
if is_email_notifications_enabled_for_type(self.for_user, self.type):
- send_notification_email(self)
+ try:
+ send_notification_email(self)
+ except frappe.OutgoingEmailError:
+ frappe.log_error(message=frappe.get_traceback(), title=_("Failed to send notification email"))
def get_permission_query_conditions(for_user):
@@ -46,7 +48,7 @@ def enqueue_create_notification(users, doc):
doc = frappe._dict(doc)
- if isinstance(users, frappe.string_types):
+ if isinstance(users, str):
users = [user.strip() for user in users.split(',') if user.strip()]
users = list(set(users))
@@ -61,7 +63,7 @@ def make_notification_logs(doc, users):
from frappe.social.doctype.energy_point_settings.energy_point_settings import is_energy_point_enabled
for user in users:
- if frappe.db.exists('User', {"name": user, "enabled": 1}):
+ if frappe.db.exists('User', {"email": user, "enabled": 1}):
if is_notifications_enabled(user):
if doc.type == 'Energy Point' and not is_energy_point_enabled():
return
diff --git a/frappe/desk/doctype/notification_log/test_notification_log.py b/frappe/desk/doctype/notification_log/test_notification_log.py
index e59aee30c9..4c415a860c 100644
--- a/frappe/desk/doctype/notification_log/test_notification_log.py
+++ b/frappe/desk/doctype/notification_log/test_notification_log.py
@@ -1,9 +1,8 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
+from frappe.core.doctype.user.user import get_system_users
from frappe.desk.form.assign_to import add as assign_task
import unittest
@@ -56,7 +55,4 @@ def get_todo():
return frappe.get_cached_doc('ToDo', res[0].name)
def get_user():
- users = frappe.db.get_all('User',
- filters={'name': ('not in', ['Administrator', 'Guest'])},
- fields='name', limit=1)
- return users[0].name
+ return get_system_users(limit=1)[0]
diff --git a/frappe/desk/doctype/notification_settings/notification_settings.js b/frappe/desk/doctype/notification_settings/notification_settings.js
index 88dc145be2..cc2fd95204 100644
--- a/frappe/desk/doctype/notification_settings/notification_settings.js
+++ b/frappe/desk/doctype/notification_settings/notification_settings.js
@@ -19,7 +19,7 @@ frappe.ui.form.on('Notification Settings', {
refresh: (frm) => {
if (frappe.user.has_role('System Manager')) {
- frm.add_custom_button('Go to Notification Settings List', () => {
+ frm.add_custom_button(__('Go to Notification Settings List'), () => {
frappe.set_route('List', 'Notification Settings');
});
}
diff --git a/frappe/desk/doctype/notification_settings/notification_settings.json b/frappe/desk/doctype/notification_settings/notification_settings.json
index fc12022e89..1a6efd5a0d 100644
--- a/frappe/desk/doctype/notification_settings/notification_settings.json
+++ b/frappe/desk/doctype/notification_settings/notification_settings.json
@@ -14,8 +14,11 @@
"enable_email_assignment",
"enable_email_energy_point",
"enable_email_share",
+ "enable_email_event_reminders",
"user",
- "seen"
+ "seen",
+ "system_notifications_section",
+ "energy_points_system_notifications"
],
"fields": [
{
@@ -84,15 +87,34 @@
"fieldtype": "Check",
"hidden": 1,
"label": "Seen"
+ },
+ {
+ "fieldname": "system_notifications_section",
+ "fieldtype": "Section Break",
+ "label": "System Notifications"
+ },
+ {
+ "default": "1",
+ "fieldname": "energy_points_system_notifications",
+ "fieldtype": "Check",
+ "label": "Energy Points"
+ },
+ {
+ "default": "1",
+ "depends_on": "enable_email_notifications",
+ "fieldname": "enable_email_event_reminders",
+ "fieldtype": "Check",
+ "label": "Event Reminders"
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-11-04 12:54:57.989317",
+ "modified": "2021-11-24 14:45:31.931154",
"modified_by": "Administrator",
"module": "Desk",
"name": "Notification Settings",
+ "naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
diff --git a/frappe/desk/doctype/notification_settings/notification_settings.py b/frappe/desk/doctype/notification_settings/notification_settings.py
index 34726bdf8a..cf6bb2d78d 100644
--- a/frappe/desk/doctype/notification_settings/notification_settings.py
+++ b/frappe/desk/doctype/notification_settings/notification_settings.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
@@ -44,6 +43,11 @@ def create_notification_settings(user):
_doc.insert(ignore_permissions=True)
+def toggle_notifications(user, enable=False):
+ if frappe.db.exists("Notification Settings", user):
+ frappe.db.set_value("Notification Settings", user, 'enabled', enable)
+
+
@frappe.whitelist()
def get_subscribed_documents():
if not frappe.session.user:
@@ -75,4 +79,4 @@ def get_permission_query_conditions(user):
@frappe.whitelist()
def set_seen_value(value, user):
- frappe.db.set_value('Notification Settings', user, 'seen', value, update_modified=False)
\ No newline at end of file
+ frappe.db.set_value('Notification Settings', user, 'seen', value, update_modified=False)
diff --git a/frappe/desk/doctype/notification_settings/test_notification_settings.py b/frappe/desk/doctype/notification_settings/test_notification_settings.py
new file mode 100644
index 0000000000..e3dac0af5f
--- /dev/null
+++ b/frappe/desk/doctype/notification_settings/test_notification_settings.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# See license.txt
+
+# import frappe
+import unittest
+
+class TestNotificationSettings(unittest.TestCase):
+ pass
diff --git a/frappe/desk/doctype/notification_subscribed_document/notification_subscribed_document.py b/frappe/desk/doctype/notification_subscribed_document/notification_subscribed_document.py
index f005efae76..1fdba22779 100644
--- a/frappe/desk/doctype/notification_subscribed_document/notification_subscribed_document.py
+++ b/frappe/desk/doctype/notification_subscribed_document/notification_subscribed_document.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/number_card/number_card.js b/frappe/desk/doctype/number_card/number_card.js
index 6d1454a2cb..f548388a99 100644
--- a/frappe/desk/doctype/number_card/number_card.js
+++ b/frappe/desk/doctype/number_card/number_card.js
@@ -28,6 +28,7 @@ frappe.ui.form.on('Number Card', {
frm.trigger('render_filters_table');
}
frm.trigger('create_add_to_dashboard_button');
+ frm.trigger('set_parent_document_type');
},
create_add_to_dashboard_button: function(frm) {
@@ -141,7 +142,9 @@ frappe.ui.form.on('Number Card', {
frm.set_value('filters_json', '[]');
frm.set_value('dynamic_filters_json', '[]');
frm.set_value('aggregate_function_based_on', '');
+ frm.set_value('parent_document_type', '');
frm.trigger('set_options');
+ frm.trigger('set_parent_document_type');
},
set_options: function(frm) {
@@ -317,6 +320,7 @@ frappe.ui.form.on('Number Card', {
frm.filter_group = new frappe.ui.FilterGroup({
parent: dialog.get_field('filter_area').$wrapper,
doctype: frm.doc.document_type,
+ parent_doctype: frm.doc.parent_document_type,
on_change: () => {},
});
filters && frm.filter_group.add_filters_to_filter_group(filters);
@@ -436,6 +440,36 @@ frappe.ui.form.on('Number Card', {
frm.dynamic_filter_table.find('tbody').html(filter_rows);
}
+ },
+
+ set_parent_document_type: async function(frm) {
+ let document_type = frm.doc.document_type;
+ let doc_is_table = document_type &&
+ (await frappe.db.get_value('DocType', document_type, 'istable')).message.istable;
+
+ frm.set_df_property('parent_document_type', 'hidden', !doc_is_table);
+
+ if (document_type && doc_is_table) {
+ let parent = await frappe.db.get_list('DocField', {
+ filters: {
+ 'fieldtype': 'Table',
+ 'options': document_type
+ },
+ fields: ['parent']
+ });
+
+ parent && frm.set_query('parent_document_type', function() {
+ return {
+ filters: {
+ "name": ['in', parent.map(({ parent }) => parent)]
+ }
+ };
+ });
+
+ if (parent.length === 1) {
+ frm.set_value('parent_document_type', parent[0].parent);
+ }
+ }
}
});
diff --git a/frappe/desk/doctype/number_card/number_card.json b/frappe/desk/doctype/number_card/number_card.json
index d3e9598eb7..7975d878ba 100644
--- a/frappe/desk/doctype/number_card/number_card.json
+++ b/frappe/desk/doctype/number_card/number_card.json
@@ -16,6 +16,7 @@
"aggregate_function_based_on",
"column_break_2",
"document_type",
+ "parent_document_type",
"report_field",
"report_function",
"is_public",
@@ -188,10 +189,17 @@
"label": "Function",
"mandatory_depends_on": "eval: doc.type == 'Report'",
"options": "Sum\nAverage\nMinimum\nMaximum"
+ },
+ {
+ "description": "The document type selected is a child table, so the parent document type is required.",
+ "fieldname": "parent_document_type",
+ "fieldtype": "Link",
+ "label": "Parent Document Type",
+ "options": "DocType"
}
],
"links": [],
- "modified": "2020-07-23 11:11:03.391719",
+ "modified": "2022-03-10 15:34:38.210910",
"modified_by": "Administrator",
"module": "Desk",
"name": "Number Card",
@@ -234,6 +242,7 @@
"search_fields": "label, document_type",
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "label",
"track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py
index 7d1a697f6b..784f46bb19 100644
--- a/frappe/desk/doctype/number_card/number_card.py
+++ b/frappe/desk/doctype/number_card/number_card.py
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
+from frappe import _
from frappe.model.document import Document
from frappe.utils import cint
from frappe.model.naming import append_number_if_name_exists
@@ -18,6 +18,13 @@ class NumberCard(Document):
if frappe.db.exists("Number Card", self.name):
self.name = append_number_if_name_exists('Number Card', self.name)
+ def validate(self):
+ if not self.document_type:
+ frappe.throw(_("Document type is required to create a number card"))
+
+ if self.document_type and frappe.get_meta(self.document_type).istable and not self.parent_document_type:
+ frappe.throw(_("Parent document type is required to create a number card"))
+
def on_update(self):
if frappe.conf.developer_mode and self.is_standard:
export_to_files(record_list=[['Number Card', self.name]], record_module=self.module)
diff --git a/frappe/desk/doctype/number_card/test_number_card.py b/frappe/desk/doctype/number_card/test_number_card.py
index 4aa1ecf282..cc92e63341 100644
--- a/frappe/desk/doctype/number_card/test_number_card.py
+++ b/frappe/desk/doctype/number_card/test_number_card.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/desk/doctype/number_card_link/number_card_link.py b/frappe/desk/doctype/number_card_link/number_card_link.py
index 67ad7e70cd..0b55ae6dcd 100644
--- a/frappe/desk/doctype/number_card_link/number_card_link.py
+++ b/frappe/desk/doctype/number_card_link/number_card_link.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/onboarding_permission/onboarding_permission.py b/frappe/desk/doctype/onboarding_permission/onboarding_permission.py
index f8772480df..a0e87c3067 100644
--- a/frappe/desk/doctype/onboarding_permission/onboarding_permission.py
+++ b/frappe/desk/doctype/onboarding_permission/onboarding_permission.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/onboarding_permission/test_onboarding_permission.py b/frappe/desk/doctype/onboarding_permission/test_onboarding_permission.py
index 9a7e8ae6fd..c13fb29678 100644
--- a/frappe/desk/doctype/onboarding_permission/test_onboarding_permission.py
+++ b/frappe/desk/doctype/onboarding_permission/test_onboarding_permission.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.js b/frappe/desk/doctype/onboarding_step/onboarding_step.js
index 793e044d98..3c9bbab9ac 100644
--- a/frappe/desk/doctype/onboarding_step/onboarding_step.js
+++ b/frappe/desk/doctype/onboarding_step/onboarding_step.js
@@ -2,6 +2,17 @@
// For license information, please see license.txt
frappe.ui.form.on("Onboarding Step", {
+
+ setup: function(frm) {
+ frm.set_query("form_tour", function() {
+ return {
+ filters: {
+ reference_doctype: frm.doc.reference_document
+ }
+ };
+ });
+ },
+
refresh: function(frm) {
frappe.boot.developer_mode &&
frm.set_intro(
diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.json b/frappe/desk/doctype/onboarding_step/onboarding_step.json
index f71e821f65..b5d7851eca 100644
--- a/frappe/desk/doctype/onboarding_step/onboarding_step.json
+++ b/frappe/desk/doctype/onboarding_step/onboarding_step.json
@@ -20,6 +20,7 @@
"reference_document",
"show_full_form",
"show_form_tour",
+ "form_tour",
"is_single",
"reference_report",
"report_reference_doctype",
@@ -206,13 +207,21 @@
"fieldname": "show_form_tour",
"fieldtype": "Check",
"label": "Show Form Tour"
+ },
+ {
+ "depends_on": "show_form_tour",
+ "fieldname": "form_tour",
+ "fieldtype": "Link",
+ "label": "Form Tour",
+ "options": "Form Tour"
}
],
"links": [],
- "modified": "2020-10-30 14:54:06.646513",
+ "modified": "2021-12-02 10:56:04.448580",
"modified_by": "Administrator",
"module": "Desk",
"name": "Onboarding Step",
+ "naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.py b/frappe/desk/doctype/onboarding_step/onboarding_step.py
index e1cc5dfba4..45e0ca34fd 100644
--- a/frappe/desk/doctype/onboarding_step/onboarding_step.py
+++ b/frappe/desk/doctype/onboarding_step/onboarding_step.py
@@ -1,12 +1,27 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
-# import frappe
+import frappe
+from frappe import _
+import json
from frappe.model.document import Document
class OnboardingStep(Document):
def before_export(self, doc):
doc.is_complete = 0
doc.is_skipped = 0
+
+
+@frappe.whitelist()
+def get_onboarding_steps(ob_steps):
+ steps = []
+ for s in json.loads(ob_steps):
+ doc = frappe.get_doc('Onboarding Step', s.get('step'))
+ step = doc.as_dict().copy()
+ step.label = _(doc.title)
+ if step.action == "Create Entry":
+ step.is_submittable = frappe.db.get_value("DocType", step.reference_document, 'is_submittable', cache=True)
+ steps.append(step)
+
+ return steps
diff --git a/frappe/desk/doctype/onboarding_step/test_onboarding_step.py b/frappe/desk/doctype/onboarding_step/test_onboarding_step.py
index 66bd0c6660..b0651da4da 100644
--- a/frappe/desk/doctype/onboarding_step/test_onboarding_step.py
+++ b/frappe/desk/doctype/onboarding_step/test_onboarding_step.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/desk/doctype/onboarding_step_map/onboarding_step_map.py b/frappe/desk/doctype/onboarding_step_map/onboarding_step_map.py
index ea34de6088..7c20e220db 100644
--- a/frappe/desk/doctype/onboarding_step_map/onboarding_step_map.py
+++ b/frappe/desk/doctype/onboarding_step_map/onboarding_step_map.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/route_history/route_history.json b/frappe/desk/doctype/route_history/route_history.json
index 7390aa011b..09db2320ca 100644
--- a/frappe/desk/doctype/route_history/route_history.json
+++ b/frappe/desk/doctype/route_history/route_history.json
@@ -88,7 +88,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
- "modified": "2018-10-05 13:26:03.106050",
+ "modified": "2021-10-25 13:26:03.106050",
"modified_by": "Administrator",
"module": "Desk",
"name": "Route History",
@@ -121,7 +121,6 @@
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
- "track_changes": 1,
"track_seen": 0,
"track_views": 0
-}
\ No newline at end of file
+}
diff --git a/frappe/desk/doctype/route_history/route_history.py b/frappe/desk/doctype/route_history/route_history.py
index 12d898afa5..f0aa867c8a 100644
--- a/frappe/desk/doctype/route_history/route_history.py
+++ b/frappe/desk/doctype/route_history/route_history.py
@@ -1,14 +1,17 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2018, Frappe Technologies and contributors
-# For license information, please see license.txt
+# Copyright (c) 2021, Frappe Technologies and contributors
+# License: MIT. See LICENSE
+
+import json
-from __future__ import unicode_literals
import frappe
+from frappe.deferred_insert import deferred_insert as _deferred_insert
from frappe.model.document import Document
+
class RouteHistory(Document):
pass
+
def flush_old_route_records():
"""Deletes all route records except last 500 records per user"""
@@ -25,19 +28,33 @@ def flush_old_route_records():
for user in users:
user = user[0]
last_record_to_keep = frappe.db.get_all('Route History',
- filters={
- 'user': user,
- },
+ filters={'user': user},
limit=1,
limit_start=500,
fields=['modified'],
- order_by='modified desc')
+ order_by='modified desc'
+ )
- frappe.db.sql('''
- DELETE
- FROM `tabRoute History`
- WHERE `modified` <= %(modified)s and `user`=%(modified)s
- ''', {
- "modified": last_record_to_keep[0].modified,
+ frappe.db.delete("Route History", {
+ "modified": ("<=", last_record_to_keep[0].modified),
"user": user
- })
\ No newline at end of file
+ })
+
+@frappe.whitelist()
+def deferred_insert(routes):
+ routes = [
+ {
+ "user": frappe.session.user,
+ "route": route.get("route"),
+ "creation": route.get("creation"),
+ }
+ for route in frappe.parse_json(routes)
+ ]
+
+ _deferred_insert("Route History", json.dumps(routes))
+
+@frappe.whitelist()
+def frequently_visited_links():
+ return frappe.get_all('Route History', fields=['route', 'count(name) as count'], filters={
+ 'user': frappe.session.user
+ }, group_by="route", order_by="count desc", limit=5)
diff --git a/frappe/desk/doctype/system_console/system_console.js b/frappe/desk/doctype/system_console/system_console.js
index c7eac39490..7751ffe860 100644
--- a/frappe/desk/doctype/system_console/system_console.js
+++ b/frappe/desk/doctype/system_console/system_console.js
@@ -5,17 +5,101 @@ frappe.ui.form.on('System Console', {
onload: function(frm) {
frappe.ui.keys.add_shortcut({
shortcut: 'shift+enter',
- action: () => frm.execute_action('Execute'),
+ action: () => frm.page.btn_primary.trigger('click'),
page: frm.page,
description: __('Execute Console script'),
ignore_inputs: true,
});
+ frm.set_value("type", "Python");
},
refresh: function(frm) {
frm.disable_save();
- frm.page.set_primary_action(__("Execute"), () => {
- frm.execute_action('Execute');
+ frm.page.set_primary_action(__("Execute"), $btn => {
+ $btn.text(__("Executing..."));
+ return frm
+ .execute_action("Execute")
+ .then(() => frm.trigger("render_sql_output"))
+ .finally(() => $btn.text(__("Execute")));
});
- }
+ },
+
+ type: function(frm) {
+ if (frm.doc.type == "Python") {
+ frm.set_value("output", "");
+ if (frm.sql_output) {
+ frm.sql_output.destroy();
+ frm.get_field("sql_output").html("");
+ }
+ }
+ },
+
+ render_sql_output: function(frm) {
+ if (frm.doc.type !== "SQL") return;
+ if (frm.sql_output) {
+ frm.sql_output.destroy();
+ frm.get_field("sql_output").html("");
+ }
+
+ if (frm.doc.output.startsWith("Traceback")) {
+ return;
+ }
+
+ let result = JSON.parse(frm.doc.output);
+ frm.set_value("output", `${result.length} ${result.length == 1 ? 'row' : 'rows'}`);
+
+ if (result.length) {
+ let columns = Object.keys(result[0]);
+ frm.sql_output = new DataTable(
+ frm.get_field("sql_output").$wrapper.get(0),
+ {
+ columns,
+ data: result
+ }
+ );
+ }
+ },
+
+ show_processlist: function(frm) {
+ if (frm.doc.show_processlist) {
+ // keep refreshing every 5 seconds
+ frm.events.refresh_processlist(frm);
+ frm.processlist_interval = setInterval(() => frm.events.refresh_processlist(frm), 5000);
+ } else {
+ if (frm.processlist_interval) {
+
+ // end it
+ clearInterval(frm.processlist_interval);
+ frm.get_field("processlist").html('');
+ }
+ }
+ },
+
+ refresh_processlist: function(frm) {
+ let timestamp = new Date();
+ frappe.call('frappe.desk.doctype.system_console.system_console.show_processlist').then(r => {
+ let rows = '';
+ for (let row of r.message) {
+ rows += `
+ ${row.Id}
+ ${row.Time}
+ ${row.State}
+ ${row.Info}
+ ${row.Progress}
+ `
+ }
+
+ frm.get_field('processlist').html(`
+ Requested on: ${timestamp}
+
+
+ Id
+ Time
+ State
+ Info
+ Progress / Wait Event
+
+ ${rows}`);
+ });
+ },
});
diff --git a/frappe/desk/doctype/system_console/system_console.json b/frappe/desk/doctype/system_console/system_console.json
index 14e36e6fd3..657e9df89d 100644
--- a/frappe/desk/doctype/system_console/system_console.json
+++ b/frappe/desk/doctype/system_console/system_console.json
@@ -17,9 +17,15 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
+ "execute_section",
+ "type",
"console",
"commit",
- "output"
+ "output",
+ "sql_output",
+ "database_processes_section",
+ "show_processlist",
+ "processlist"
],
"fields": [
{
@@ -40,13 +46,47 @@
"fieldname": "commit",
"fieldtype": "Check",
"label": "Commit"
+ },
+ {
+ "fieldname": "execute_section",
+ "fieldtype": "Section Break",
+ "label": "Execute"
+ },
+ {
+ "fieldname": "database_processes_section",
+ "fieldtype": "Section Break",
+ "label": "Database Processes"
+ },
+ {
+ "default": "0",
+ "fieldname": "show_processlist",
+ "fieldtype": "Check",
+ "label": "Show Processlist"
+ },
+ {
+ "fieldname": "processlist",
+ "fieldtype": "HTML",
+ "label": "processlist"
+ },
+ {
+ "default": "Python",
+ "fieldname": "type",
+ "fieldtype": "Select",
+ "label": "Type",
+ "options": "Python\nSQL"
+ },
+ {
+ "depends_on": "eval:doc.type == 'SQL'",
+ "fieldname": "sql_output",
+ "fieldtype": "HTML",
+ "label": "SQL Output"
}
],
"hide_toolbar": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2020-08-21 14:44:35.296877",
+ "modified": "2021-09-15 17:17:44.844767",
"modified_by": "Administrator",
"module": "Desk",
"name": "System Console",
@@ -65,4 +105,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
-}
+}
\ No newline at end of file
diff --git a/frappe/desk/doctype/system_console/system_console.py b/frappe/desk/doctype/system_console/system_console.py
index 6c87ca8c36..bf0925e2d7 100644
--- a/frappe/desk/doctype/system_console/system_console.py
+++ b/frappe/desk/doctype/system_console/system_console.py
@@ -1,13 +1,11 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import json
import frappe
-from frappe.utils.safe_exec import safe_exec
+from frappe.utils.safe_exec import safe_exec, read_sql
from frappe.model.document import Document
class SystemConsole(Document):
@@ -15,8 +13,11 @@ class SystemConsole(Document):
frappe.only_for('System Manager')
try:
frappe.debug_log = []
- safe_exec(self.console)
- self.output = '\n'.join(frappe.debug_log)
+ if self.type == 'Python':
+ safe_exec(self.console)
+ self.output = '\n'.join(frappe.debug_log)
+ elif self.type == 'SQL':
+ self.output = frappe.as_json(read_sql(self.console, as_dict=1))
except: # noqa: E722
self.output = frappe.get_traceback()
@@ -35,4 +36,19 @@ class SystemConsole(Document):
def execute_code(doc):
console = frappe.get_doc(json.loads(doc))
console.run()
- return console.as_dict()
\ No newline at end of file
+ return console.as_dict()
+
+@frappe.whitelist()
+def show_processlist():
+ frappe.only_for('System Manager')
+
+ return frappe.db.multisql({
+ "postgres": """
+ SELECT pid AS "Id",
+ query_start AS "Time",
+ state AS "State",
+ query AS "Info",
+ wait_event AS "Progress"
+ FROM pg_stat_activity""",
+ "mariadb": "show full processlist"
+ }, as_dict=True)
diff --git a/frappe/desk/doctype/system_console/test_system_console.py b/frappe/desk/doctype/system_console/test_system_console.py
index 55ef199122..fa7c577faa 100644
--- a/frappe/desk/doctype/system_console/test_system_console.py
+++ b/frappe/desk/doctype/system_console/test_system_console.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/desk/doctype/tag/tag.py b/frappe/desk/doctype/tag/tag.py
index 7e016ee91b..d44c481210 100644
--- a/frappe/desk/doctype/tag/tag.py
+++ b/frappe/desk/doctype/tag/tag.py
@@ -1,11 +1,10 @@
-# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe.utils import unique
+from frappe.query_builder import DocType
class Tag(Document):
pass
@@ -13,7 +12,8 @@ class Tag(Document):
def check_user_tags(dt):
"if the user does not have a tags column, then it creates one"
try:
- frappe.db.sql("select `_user_tags` from `tab%s` limit 1" % dt)
+ doctype = DocType(dt)
+ frappe.qb.from_(doctype).select(doctype._user_tags).limit(1).run()
except Exception as e:
if frappe.db.is_column_missing(e):
DocTags(dt).setup()
@@ -44,10 +44,12 @@ def remove_tag(tag, dt, dn):
@frappe.whitelist()
def get_tagged_docs(doctype, tag):
frappe.has_permission(doctype, throw=True)
-
- return frappe.db.sql("""SELECT name
- FROM `tab{0}`
- WHERE _user_tags LIKE '%{1}%'""".format(doctype, tag))
+ doctype = DocType(doctype)
+ return (
+ frappe.qb.from_(doctype)
+ .where(doctype._user_tags.like(tag))
+ .select(doctype.name)
+ ).run()
@frappe.whitelist()
def get_tags(doctype, txt):
@@ -124,45 +126,39 @@ def delete_tags_for_document(doc):
if not frappe.db.table_exists("Tag Link"):
return
- frappe.db.sql("""DELETE FROM `tabTag Link` WHERE `document_type`=%s AND `document_name`=%s""", (doc.doctype, doc.name))
+ frappe.db.delete("Tag Link", {
+ "document_type": doc.doctype,
+ "document_name": doc.name
+ })
def update_tags(doc, tags):
+ """Adds tags for documents
+
+ :param doc: Document to be added to global tags
"""
- Adds tags for documents
- :param doc: Document to be added to global tags
- """
-
- new_tags = list(set([tag.strip() for tag in tags.split(",") if tag]))
-
- for tag in new_tags:
- if not frappe.db.exists("Tag Link", {"parenttype": doc.doctype, "parent": doc.name, "tag": tag}):
- frappe.get_doc({
- "doctype": "Tag Link",
- "document_type": doc.doctype,
- "document_name": doc.name,
- "parenttype": doc.doctype,
- "parent": doc.name,
- "title": doc.get_title() or '',
- "tag": tag
- }).insert(ignore_permissions=True)
-
+ new_tags = {tag.strip() for tag in tags.split(",") if tag}
existing_tags = [tag.tag for tag in frappe.get_list("Tag Link", filters={
"document_type": doc.doctype,
"document_name": doc.name
}, fields=["tag"])]
- deleted_tags = get_deleted_tags(new_tags, existing_tags)
+ added_tags = set(new_tags) - set(existing_tags)
+ for tag in added_tags:
+ frappe.get_doc({
+ "doctype": "Tag Link",
+ "document_type": doc.doctype,
+ "document_name": doc.name,
+ "title": doc.get_title() or '',
+ "tag": tag
+ }).insert(ignore_permissions=True)
- if deleted_tags:
- for tag in deleted_tags:
- delete_tag_for_document(doc.doctype, doc.name, tag)
-
-def get_deleted_tags(new_tags, existing_tags):
-
- return list(set(existing_tags) - set(new_tags))
-
-def delete_tag_for_document(dt, dn, tag):
- frappe.db.sql("""DELETE FROM `tabTag Link` WHERE `document_type`=%s AND `document_name`=%s AND tag=%s""", (dt, dn, tag))
+ deleted_tags = list(set(existing_tags) - set(new_tags))
+ for tag in deleted_tags:
+ frappe.db.delete("Tag Link", {
+ "document_type": doc.doctype,
+ "document_name": doc.name,
+ "tag": tag
+ })
@frappe.whitelist()
def get_documents_for_tag(tag):
@@ -187,4 +183,4 @@ def get_documents_for_tag(tag):
@frappe.whitelist()
def get_tags_list_for_awesomebar():
- return [t.name for t in frappe.get_list("Tag")]
\ No newline at end of file
+ return [t.name for t in frappe.get_list("Tag")]
diff --git a/frappe/desk/doctype/tag/test_tag.py b/frappe/desk/doctype/tag/test_tag.py
index 8efd692f43..b9c6e0b744 100644
--- a/frappe/desk/doctype/tag/test_tag.py
+++ b/frappe/desk/doctype/tag/test_tag.py
@@ -1,10 +1,26 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
-# import frappe
import unittest
+import frappe
+
+from frappe.desk.reportview import get_stats
+from frappe.desk.doctype.tag.tag import add_tag
class TestTag(unittest.TestCase):
- pass
+ def setUp(self) -> None:
+ frappe.db.delete("Tag")
+ frappe.db.sql("UPDATE `tabDocType` set _user_tags=''")
+
+ def test_tag_count_query(self):
+ self.assertDictEqual(get_stats('["_user_tags"]', 'DocType'),
+ {'_user_tags': [['No Tags', frappe.db.count('DocType')]]})
+ add_tag('Standard', 'DocType', 'User')
+ add_tag('Standard', 'DocType', 'ToDo')
+
+ # count with no filter
+ self.assertDictEqual(get_stats('["_user_tags"]', 'DocType'),
+ {'_user_tags': [['Standard', 2], ['No Tags', frappe.db.count('DocType') - 2]]})
+
+ # count with child table field filter
+ self.assertDictEqual(get_stats('["_user_tags"]',
+ 'DocType',
+ filters='[["DocField", "fieldname", "like", "%last_name%"], ["DocType", "name", "like", "%use%"]]'),
+ {'_user_tags': [['Standard', 1], ['No Tags', 0]]})
\ No newline at end of file
diff --git a/frappe/desk/doctype/tag_link/tag_link.json b/frappe/desk/doctype/tag_link/tag_link.json
index 00a7349c5c..9142279fa3 100644
--- a/frappe/desk/doctype/tag_link/tag_link.json
+++ b/frappe/desk/doctype/tag_link/tag_link.json
@@ -1,4 +1,5 @@
{
+ "actions": [],
"creation": "2019-09-24 13:25:36.435685",
"doctype": "DocType",
"editable_grid": 1,
@@ -44,7 +45,8 @@
"read_only": 1
}
],
- "modified": "2019-10-03 16:42:35.932409",
+ "links": [],
+ "modified": "2021-09-20 16:53:37.217998",
"modified_by": "Administrator",
"module": "Desk",
"name": "Tag Link",
@@ -61,6 +63,17 @@
"role": "System Manager",
"share": 1,
"write": 1
+ },
+ {
+ "create": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "All",
+ "share": 1,
+ "write": 1
}
],
"read_only": 1,
diff --git a/frappe/desk/doctype/tag_link/tag_link.py b/frappe/desk/doctype/tag_link/tag_link.py
index 87c8af7212..d07894989d 100644
--- a/frappe/desk/doctype/tag_link/tag_link.py
+++ b/frappe/desk/doctype/tag_link/tag_link.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/tag_link/test_tag_link.py b/frappe/desk/doctype/tag_link/test_tag_link.py
index 1c22ac18bc..fa6a22903f 100644
--- a/frappe/desk/doctype/tag_link/test_tag_link.py
+++ b/frappe/desk/doctype/tag_link/test_tag_link.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
# import frappe
import unittest
diff --git a/frappe/desk/doctype/todo/__init__.py b/frappe/desk/doctype/todo/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/desk/doctype/todo/__init__.py
+++ b/frappe/desk/doctype/todo/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/desk/doctype/todo/test_todo.js b/frappe/desk/doctype/todo/test_todo.js
deleted file mode 100644
index de508991cf..0000000000
--- a/frappe/desk/doctype/todo/test_todo.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: ToDo", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new ToDo
- () => frappe.tests.make('ToDo', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/desk/doctype/todo/test_todo.py b/frappe/desk/doctype/todo/test_todo.py
index d8ecdffb1e..34d3cee191 100644
--- a/frappe/desk/doctype/todo/test_todo.py
+++ b/frappe/desk/doctype/todo/test_todo.py
@@ -1,19 +1,20 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
+from frappe.model.db_query import DatabaseQuery
+from frappe.permissions import add_permission, reset_perms
+from frappe.core.doctype.doctype.doctype import clear_permissions_cache
-# test_records = frappe.get_test_records('ToDo')
+test_dependencies = ['User']
class TestToDo(unittest.TestCase):
def test_delete(self):
todo = frappe.get_doc(dict(doctype='ToDo', description='test todo',
assigned_by='Administrator')).insert()
- frappe.db.sql('delete from `tabDeleted Document`')
+ frappe.db.delete("Deleted Document")
todo.delete()
deleted = frappe.get_doc('Deleted Document', dict(deleted_doctype=todo.doctype, deleted_name=todo.name))
@@ -26,7 +27,7 @@ class TestToDo(unittest.TestCase):
frappe.db.get_value('User', todo.assigned_by, 'full_name'))
def test_fetch_setup(self):
- frappe.db.sql('delete from tabToDo')
+ frappe.db.delete("ToDo")
todo_meta = frappe.get_doc('DocType', 'ToDo')
todo_meta.get('fields', dict(fieldname='assigned_by_full_name'))[0].fetch_from = ''
@@ -47,8 +48,64 @@ class TestToDo(unittest.TestCase):
self.assertEqual(todo.assigned_by_full_name,
frappe.db.get_value('User', todo.assigned_by, 'full_name'))
-def test_fetch_if_empty(self):
- frappe.db.sql('delete from tabToDo')
+ def test_todo_list_access(self):
+ create_new_todo('Test1', 'testperm@example.com')
+
+ frappe.set_user('test4@example.com')
+ create_new_todo('Test2', 'test4@example.com')
+ test_user_data = DatabaseQuery('ToDo').execute()
+
+ frappe.set_user('testperm@example.com')
+ system_manager_data = DatabaseQuery('ToDo').execute()
+
+ self.assertNotEqual(test_user_data, system_manager_data)
+
+ frappe.set_user('Administrator')
+ frappe.db.rollback()
+
+ def test_doc_read_access(self):
+ #owner and assigned_by is testperm
+ todo1 = create_new_todo('Test1', 'testperm@example.com')
+ test_user = frappe.get_doc('User', 'test4@example.com')
+
+ #owner is testperm, but assigned_by is test4
+ todo2 = create_new_todo('Test2', 'test4@example.com')
+
+ frappe.set_user('test4@example.com')
+ #owner and assigned_by is test4
+ todo3 = create_new_todo('Test3', 'test4@example.com')
+
+ # user without any role to read or write todo document
+ self.assertFalse(todo1.has_permission("read"))
+ self.assertFalse(todo1.has_permission("write"))
+
+ # user without any role but he/she is assigned_by of that todo document
+ self.assertTrue(todo2.has_permission("read"))
+ self.assertTrue(todo2.has_permission("write"))
+
+ # user is the owner and assigned_by of the todo document
+ self.assertTrue(todo3.has_permission("read"))
+ self.assertTrue(todo3.has_permission("write"))
+
+ frappe.set_user('Administrator')
+
+ test_user.add_roles('Blogger')
+ add_permission('ToDo', 'Blogger')
+
+ frappe.set_user('test4@example.com')
+
+ # user with only read access to todo document, not an owner or assigned_by
+ self.assertTrue(todo1.has_permission("read"))
+ self.assertFalse(todo1.has_permission("write"))
+
+ frappe.set_user('Administrator')
+ test_user.remove_roles('Blogger')
+ reset_perms('ToDo')
+ clear_permissions_cache('ToDo')
+ frappe.db.rollback()
+
+ def test_fetch_if_empty(self):
+ frappe.db.delete("ToDo")
# Allow user changes
todo_meta = frappe.get_doc('DocType', 'ToDo')
@@ -65,12 +122,19 @@ def test_fetch_if_empty(self):
self.assertEqual(todo.assigned_by_full_name, 'Admin')
# Overwrite user changes
- todo_meta = frappe.get_doc('DocType', 'ToDo')
- todo_meta.get('fields', dict(fieldname='assigned_by_full_name'))[0].fetch_if_empty = 0
- todo_meta.save()
+ todo.meta.get('fields', dict(fieldname='assigned_by_full_name'))[0].fetch_if_empty = 0
+ todo.meta.save()
todo.reload()
todo.save()
self.assertEqual(todo.assigned_by_full_name,
frappe.db.get_value('User', todo.assigned_by, 'full_name'))
+
+def create_new_todo(description, assigned_by):
+ todo = {
+ 'doctype': 'ToDo',
+ 'description': description,
+ 'assigned_by': assigned_by
+ }
+ return frappe.get_doc(todo).insert()
diff --git a/frappe/desk/doctype/todo/todo.json b/frappe/desk/doctype/todo/todo.json
index 15e0e4abe1..518ca00374 100644
--- a/frappe/desk/doctype/todo/todo.json
+++ b/frappe/desk/doctype/todo/todo.json
@@ -13,7 +13,7 @@
"column_break_2",
"color",
"date",
- "owner",
+ "allocated_to",
"description_section",
"description",
"section_break_6",
@@ -69,15 +69,6 @@
"oldfieldname": "date",
"oldfieldtype": "Date"
},
- {
- "fieldname": "owner",
- "fieldtype": "Link",
- "ignore_user_permissions": 1,
- "in_global_search": 1,
- "in_standard_filter": 1,
- "label": "Allocated To",
- "options": "User"
- },
{
"fieldname": "description_section",
"fieldtype": "Section Break"
@@ -153,12 +144,21 @@
"label": "Assignment Rule",
"options": "Assignment Rule",
"read_only": 1
+ },
+ {
+ "fieldname": "allocated_to",
+ "fieldtype": "Link",
+ "ignore_user_permissions": 1,
+ "in_global_search": 1,
+ "in_standard_filter": 1,
+ "label": "Allocated To",
+ "options": "User"
}
],
"icon": "fa fa-check",
"idx": 2,
"links": [],
- "modified": "2020-01-14 17:04:36.971002",
+ "modified": "2021-09-16 11:36:34.586898",
"modified_by": "Administrator",
"module": "Desk",
"name": "ToDo",
diff --git a/frappe/desk/doctype/todo/todo.py b/frappe/desk/doctype/todo/todo.py
index 804174b56b..e689faafbe 100644
--- a/frappe/desk/doctype/todo/todo.py
+++ b/frappe/desk/doctype/todo/todo.py
@@ -1,24 +1,25 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
import json
from frappe.model.document import Document
-from frappe.utils import get_fullname
+from frappe.utils import get_fullname, parse_addr
exclude_from_linked_with = True
class ToDo(Document):
+ DocType = 'ToDo'
+
def validate(self):
self._assignment = None
if self.is_new():
- if self.assigned_by == self.owner:
+ if self.assigned_by == self.allocated_to:
assignment_message = frappe._("{0} self assigned this task: {1}").format(get_fullname(self.assigned_by), self.description)
else:
- assignment_message = frappe._("{0} assigned {1}: {2}").format(get_fullname(self.assigned_by), get_fullname(self.owner), self.description)
+ assignment_message = frappe._("{0} assigned {1}: {2}").format(get_fullname(self.assigned_by), get_fullname(self.allocated_to), self.description)
self._assignment = {
"text": assignment_message,
@@ -28,8 +29,15 @@ class ToDo(Document):
else:
# NOTE the previous value is only available in validate method
if self.get_db_value("status") != self.status:
+ if self.allocated_to == frappe.session.user:
+ removal_message = frappe._("{0} removed their assignment.").format(
+ get_fullname(frappe.session.user))
+ else:
+ removal_message = frappe._("Assignment of {0} removed by {1}").format(
+ get_fullname(self.allocated_to), get_fullname(frappe.session.user))
+
self._assignment = {
- "text": frappe._("Assignment closed by {0}").format(get_fullname(frappe.session.user)),
+ "text": removal_message,
"comment_type": "Assignment Completed"
}
@@ -40,13 +48,7 @@ class ToDo(Document):
self.update_in_reference()
def on_trash(self):
- # unlink todo from linked comments
- frappe.db.sql("""
- delete from `tabCommunication Link`
- where link_doctype=%(doctype)s and link_name=%(name)s""", {
- "doctype": self.doctype, "name": self.name
- })
-
+ self.delete_communication_links()
self.update_in_reference()
def add_assign_comment(self, text, comment_type):
@@ -55,20 +57,25 @@ class ToDo(Document):
frappe.get_doc(self.reference_type, self.reference_name).add_comment(comment_type, text)
+ def delete_communication_links(self):
+ # unlink todo from linked comments
+ return frappe.db.delete("Communication Link", {
+ "link_doctype": self.doctype,
+ "link_name": self.name
+ })
+
def update_in_reference(self):
if not (self.reference_type and self.reference_name):
return
try:
- assignments = [d[0] for d in frappe.get_all("ToDo",
- filters={
- "reference_type": self.reference_type,
- "reference_name": self.reference_name,
- "status": ("!=", "Cancelled")
- },
- fields=["owner"], as_list=True)]
-
+ assignments = frappe.get_all("ToDo", filters={
+ "reference_type": self.reference_type,
+ "reference_name": self.reference_name,
+ "status": ("!=", "Cancelled")
+ }, pluck="allocated_to")
assignments.reverse()
+
frappe.db.set_value(self.reference_type, self.reference_name,
"_assign", json.dumps(assignments), update_modified=False)
@@ -85,24 +92,40 @@ class ToDo(Document):
else:
raise
-# NOTE: todo is viewable if either owner or assigned_to or System Manager in roles
+ @classmethod
+ def get_owners(cls, filters=None):
+ """Returns list of owners after applying filters on todo's.
+ """
+ rows = frappe.get_all(cls.DocType, filters=filters or {}, fields=['allocated_to'])
+ return [parse_addr(row.allocated_to)[1] for row in rows if row.allocated_to]
+
+# NOTE: todo is viewable if a user is an owner, or set as assigned_to value, or has any role that is allowed to access ToDo doctype.
def on_doctype_update():
frappe.db.add_index("ToDo", ["reference_type", "reference_name"])
def get_permission_query_conditions(user):
if not user: user = frappe.session.user
- if "System Manager" in frappe.get_roles(user):
+ todo_roles = frappe.permissions.get_doctype_roles('ToDo')
+ if 'All' in todo_roles:
+ todo_roles.remove('All')
+
+ if any(check in todo_roles for check in frappe.get_roles(user)):
return None
else:
- return """(`tabToDo`.owner = {user} or `tabToDo`.assigned_by = {user})"""\
+ return """(`tabToDo`.allocated_to = {user} or `tabToDo`.assigned_by = {user})"""\
.format(user=frappe.db.escape(user))
-def has_permission(doc, user):
- if "System Manager" in frappe.get_roles(user):
+def has_permission(doc, ptype="read", user=None):
+ user = user or frappe.session.user
+ todo_roles = frappe.permissions.get_doctype_roles('ToDo', ptype)
+ if 'All' in todo_roles:
+ todo_roles.remove('All')
+
+ if any(check in todo_roles for check in frappe.get_roles(user)):
return True
else:
- return doc.owner==user or doc.assigned_by==user
+ return doc.allocated_to==user or doc.assigned_by==user
@frappe.whitelist()
def new_todo(description):
diff --git a/frappe/desk/doctype/todo/todo_calendar.js b/frappe/desk/doctype/todo/todo_calendar.js
index 4545846cf9..8ba020fac1 100644
--- a/frappe/desk/doctype/todo/todo_calendar.js
+++ b/frappe/desk/doctype/todo/todo_calendar.js
@@ -24,7 +24,7 @@ frappe.views.calendar["ToDo"] = {
"options": "reference_type",
"label": __("Task")
}
-
+
],
get_events_method: "frappe.desk.calendar.get_events"
};
diff --git a/frappe/desk/doctype/workspace/test_workspace.py b/frappe/desk/doctype/workspace/test_workspace.py
index 7a3f122ee2..6c16e69afe 100644
--- a/frappe/desk/doctype/workspace/test_workspace.py
+++ b/frappe/desk/doctype/workspace/test_workspace.py
@@ -1,10 +1,95 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
-# import frappe
+# License: MIT. See LICENSE
+import frappe
import unittest
-
class TestWorkspace(unittest.TestCase):
- pass
+ def setUp(self):
+ create_module("Test Module")
+
+ def tearDown(self):
+ frappe.db.delete("Workspace", {"module": "Test Module"})
+ frappe.db.delete("DocType", {"module": "Test Module"})
+ frappe.delete_doc("Module Def", "Test Module")
+
+ # TODO: FIX ME - flaky test!!!
+ # def test_workspace_with_cards_specific_to_a_country(self):
+ # workspace = create_workspace()
+ # insert_card(workspace, "Card Label 1", "DocType 1", "DocType 2", "France")
+ # insert_card(workspace, "Card Label 2", "DocType A", "DocType B")
+
+ # workspace.insert(ignore_if_duplicate = True)
+
+ # cards = workspace.get_link_groups()
+
+ # if frappe.get_system_settings('country') == "France":
+ # self.assertEqual(len(cards), 2)
+ # else:
+ # self.assertEqual(len(cards), 1)
+
+def create_module(module_name):
+ module = frappe.get_doc({
+ "doctype": "Module Def",
+ "module_name": module_name,
+ "app_name": "frappe"
+ })
+ module.insert(ignore_if_duplicate = True)
+
+ return module
+
+def create_workspace(**args):
+ workspace = frappe.new_doc("Workspace")
+ args = frappe._dict(args)
+
+ workspace.name = args.name or "Test Workspace"
+ workspace.label = args.label or "Test Workspace"
+ workspace.category = args.category or "Modules"
+ workspace.is_standard = args.is_standard or 1
+ workspace.module = "Test Module"
+
+ return workspace
+
+def insert_card(workspace, card_label, doctype1, doctype2, country=None):
+ workspace.append("links", {
+ "type": "Card Break",
+ "label": card_label,
+ "only_for": country
+ })
+
+ create_doctype(doctype1, "Test Module")
+ workspace.append("links", {
+ "type": "Link",
+ "label": doctype1,
+ "only_for": country,
+ "link_type": "DocType",
+ "link_to": doctype1
+ })
+
+ create_doctype(doctype2, "Test Module")
+ workspace.append("links", {
+ "type": "Link",
+ "label": doctype2,
+ "only_for": country,
+ "link_type": "DocType",
+ "link_to": doctype2
+ })
+
+def create_doctype(doctype_name, module):
+ frappe.get_doc({
+ 'doctype': 'DocType',
+ 'name': doctype_name,
+ 'module': module,
+ 'custom': 1,
+ 'autoname': 'field:title',
+ 'fields': [
+ {'label': 'Title', 'fieldname': 'title', 'reqd': 1, 'fieldtype': 'Data'},
+ {'label': 'Description', 'fieldname': 'description', 'fieldtype': 'Small Text'},
+ {'label': 'Date', 'fieldname': 'date', 'fieldtype': 'Date'},
+ {'label': 'Duration', 'fieldname': 'duration', 'fieldtype': 'Duration'},
+ {'label': 'Number', 'fieldname': 'number', 'fieldtype': 'Int'},
+ {'label': 'Number', 'fieldname': 'another_number', 'fieldtype': 'Int'}
+ ],
+ 'permissions': [
+ {'role': 'System Manager'}
+ ]
+ }).insert(ignore_if_duplicate = True)
diff --git a/frappe/desk/doctype/workspace/workspace.js b/frappe/desk/doctype/workspace/workspace.js
index 19d429f9f6..3f912127fc 100644
--- a/frappe/desk/doctype/workspace/workspace.js
+++ b/frappe/desk/doctype/workspace/workspace.js
@@ -8,14 +8,9 @@ frappe.ui.form.on('Workspace', {
refresh: function(frm) {
frm.enable_save();
- frm.get_field("is_standard").toggle(frappe.boot.developer_mode);
- frm.get_field("developer_mode_only").toggle(frappe.boot.developer_mode);
- if (frm.doc.for_user) {
- frm.set_df_property("extends", "read_only", true);
- }
-
- if (frm.doc.for_user || (frm.doc.is_standard && !frappe.boot.developer_mode)) {
+ if (frm.doc.for_user || (frm.doc.public && !frm.has_perm('write') &&
+ !frappe.user.has_role('Workspace Manager'))) {
frm.trigger('disable_form');
}
},
diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json
index fff766a3bf..fa8b81f5fd 100644
--- a/frappe/desk/doctype/workspace/workspace.json
+++ b/frappe/desk/doctype/workspace/workspace.json
@@ -1,5 +1,6 @@
{
"actions": [],
+ "allow_rename": 1,
"autoname": "field:label",
"beta": 1,
"creation": "2020-01-23 13:45:59.470592",
@@ -8,44 +9,39 @@
"engine": "InnoDB",
"field_order": [
"label",
+ "title",
+ "sequence_id",
"for_user",
- "extends",
+ "parent_page",
"module",
- "category",
+ "column_break_3",
"icon",
"restrict_to_domain",
- "onboarding",
- "column_break_3",
- "extends_another_page",
- "is_default",
- "is_standard",
- "developer_mode_only",
- "disable_user_customization",
- "pin_to_top",
- "pin_to_bottom",
"hide_custom",
- "section_break_2",
- "charts_label",
+ "public",
+ "content",
+ "tab_break_2",
"charts",
- "section_break_15",
- "shortcuts_label",
+ "tab_break_15",
"shortcuts",
- "section_break_18",
- "cards_label",
- "links"
+ "tab_break_18",
+ "links",
+ "roles_tab",
+ "roles"
],
"fields": [
{
"fieldname": "label",
"fieldtype": "Data",
"label": "Name",
+ "reqd": 1,
"unique": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "charts",
- "fieldname": "section_break_2",
- "fieldtype": "Section Break",
+ "fieldname": "tab_break_2",
+ "fieldtype": "Tab Break",
"label": "Dashboards"
},
{
@@ -55,7 +51,6 @@
"options": "Workspace Chart"
},
{
- "depends_on": "eval:!doc.extends_another_page || !doc.is_standard || frappe.boot.developer_mode",
"fieldname": "shortcuts",
"fieldtype": "Table",
"label": "Shortcuts",
@@ -66,7 +61,6 @@
"fieldtype": "Link",
"label": "Restrict to Domain",
"options": "Domain",
- "read_only_depends_on": "eval:doc.extends_another_page == 0",
"search_index": 1
},
{
@@ -81,115 +75,26 @@
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
- {
- "fieldname": "category",
- "fieldtype": "Select",
- "label": "Category",
- "options": "Modules\nDomains\nPlaces\nAdministration",
- "read_only_depends_on": "eval:doc.extends_another_page == 1",
- "search_index": 1
- },
- {
- "default": "0",
- "depends_on": "eval:doc.extends_another_page == 0",
- "fieldname": "developer_mode_only",
- "fieldtype": "Check",
- "label": "Developer Mode Only",
- "search_index": 1
- },
- {
- "default": "0",
- "depends_on": "eval:doc.pin_to_bottom!=1 && doc.extends_another_page == 0",
- "fieldname": "pin_to_top",
- "fieldtype": "Check",
- "label": "Pin To Top",
- "search_index": 1
- },
- {
- "default": "0",
- "depends_on": "eval:doc.extends_another_page == 0",
- "fieldname": "disable_user_customization",
- "fieldtype": "Check",
- "label": "Disable User Customization",
- "search_index": 1
- },
- {
- "default": "0",
- "depends_on": "eval:doc.pin_to_top!=1 && doc.extends_another_page == 0",
- "fieldname": "pin_to_bottom",
- "fieldtype": "Check",
- "label": "Pin To Bottom",
- "search_index": 1
- },
- {
- "depends_on": "eval:!doc.extends_another_page || !doc.is_standard",
- "fieldname": "charts_label",
- "fieldtype": "Data",
- "label": "Label"
- },
- {
- "depends_on": "eval:!doc.extends_another_page || !doc.is_standard",
- "fieldname": "shortcuts_label",
- "fieldtype": "Data",
- "label": "Label"
- },
- {
- "depends_on": "eval:!doc.extends_another_page || !doc.is_standard",
- "fieldname": "cards_label",
- "fieldtype": "Data",
- "label": "Label"
- },
{
"collapsible": 1,
"collapsible_depends_on": "shortcuts",
- "fieldname": "section_break_15",
- "fieldtype": "Section Break",
+ "fieldname": "tab_break_15",
+ "fieldtype": "Tab Break",
"label": "Shortcuts"
},
{
"collapsible": 1,
"collapsible_depends_on": "links",
- "fieldname": "section_break_18",
- "fieldtype": "Section Break",
+ "fieldname": "tab_break_18",
+ "fieldtype": "Tab Break",
"label": "Link Cards"
},
- {
- "default": "0",
- "fieldname": "is_standard",
- "fieldtype": "Check",
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Is Standard",
- "search_index": 1
- },
- {
- "default": "0",
- "fieldname": "extends_another_page",
- "fieldtype": "Check",
- "label": "Extends Another Page",
- "search_index": 1
- },
- {
- "depends_on": "eval:doc.extends_another_page == 1 || doc.for_user",
- "fieldname": "extends",
- "fieldtype": "Link",
- "in_standard_filter": 1,
- "label": "Extends",
- "options": "Workspace",
- "search_index": 1
- },
{
"fieldname": "for_user",
"fieldtype": "Data",
"label": "For User",
"read_only": 1
},
- {
- "fieldname": "onboarding",
- "fieldtype": "Link",
- "label": "Onboarding",
- "options": "Module Onboarding"
- },
{
"default": "0",
"description": "Checking this will hide custom doctypes and reports cards in Links section",
@@ -199,7 +104,7 @@
},
{
"fieldname": "icon",
- "fieldtype": "Data",
+ "fieldtype": "Icon",
"label": "Icon"
},
{
@@ -209,19 +114,56 @@
"options": "Workspace Link"
},
{
- "default": "0",
- "depends_on": "extends_another_page",
- "description": "Sets the current page as default for all users",
- "fieldname": "is_default",
- "fieldtype": "Check",
- "label": "Is Default"
- }
+ "default": "0",
+ "fieldname": "public",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Public",
+ "search_index": 1
+ },
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "fieldname": "parent_page",
+ "fieldtype": "Data",
+ "label": "Parent Page"
+ },
+ {
+ "default": "[]",
+ "fieldname": "content",
+ "fieldtype": "Long Text",
+ "hidden": 1,
+ "label": "Content"
+ },
+ {
+ "fieldname": "sequence_id",
+ "fieldtype": "Float",
+ "label": "Sequence Id"
+ },
+ {
+ "fieldname": "roles",
+ "fieldtype": "Table",
+ "label": "Roles",
+ "options": "Has Role"
+ },
+ {
+ "fieldname": "roles_tab",
+ "fieldtype": "Tab Break",
+ "label": "Roles"
+ }
],
+ "in_create": 1,
"links": [],
- "modified": "2021-01-21 12:09:36.156614",
+ "modified": "2022-01-27 12:06:13.111743",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -232,7 +174,7 @@
"print": 1,
"read": 1,
"report": 1,
- "role": "System Manager",
+ "role": "Workspace Manager",
"share": 1,
"write": 1
},
diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py
index 0934138821..ba3319b591 100644
--- a/frappe/desk/doctype/workspace/workspace.py
+++ b/frappe/desk/doctype/workspace/workspace.py
@@ -1,63 +1,57 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.modules.export_file import export_to_files
from frappe.model.document import Document
+from frappe.model.rename_doc import rename_doc
+from frappe.desk.desktop import save_new_widget
from frappe.desk.utils import validate_route_conflict
from json import loads
class Workspace(Document):
def validate(self):
- if (self.is_standard and not frappe.conf.developer_mode and not disable_saving_as_standard()):
- frappe.throw(_("You need to be in developer mode to edit this document"))
+ if (self.public and not is_workspace_manager() and not disable_saving_as_public()):
+ frappe.throw(_("You need to be Workspace Manager to edit this document"))
validate_route_conflict(self.doctype, self.name)
- duplicate_exists = frappe.db.exists("Workspace", {
- "name": ["!=", self.name], 'is_default': 1, 'extends': self.extends
- })
-
- if self.is_default and self.name and duplicate_exists:
- frappe.throw(_("You can only have one default page that extends a particular standard page."))
+ try:
+ if not isinstance(loads(self.content), list):
+ raise
+ except Exception:
+ frappe.throw(_("Content data shoud be a list"))
def on_update(self):
- if disable_saving_as_standard():
+ if disable_saving_as_public():
return
- if frappe.conf.developer_mode and self.is_standard:
+ if frappe.conf.developer_mode and self.module and self.public:
export_to_files(record_list=[['Workspace', self.name]], record_module=self.module)
@staticmethod
def get_module_page_map():
- filters = {
- 'extends_another_page': 0,
- 'for_user': '',
- }
-
- pages = frappe.get_all("Workspace", fields=["name", "module"], filters=filters, as_list=1)
+ pages = frappe.get_all("Workspace", fields=["name", "module"], filters={'for_user': ''}, as_list=1)
return { page[1]: page[0] for page in pages if page[1] }
def get_link_groups(self):
cards = []
- current_card = {
+ current_card = frappe._dict({
"label": "Link",
"type": "Card Break",
"icon": None,
"hidden": False,
- }
+ })
card_links = []
for link in self.links:
link = link.as_dict()
if link.type == "Card Break":
-
- if card_links:
+ if card_links and (not current_card.get('only_for') or current_card.get('only_for') == frappe.get_system_settings('country')):
current_card['links'] = card_links
cards.append(current_card)
@@ -71,21 +65,23 @@ class Workspace(Document):
return cards
- def build_links_table_from_cards(self, config):
- # Empty links table
- self.links = []
- order = config.get('order')
- widgets = config.get('widgets')
+ def build_links_table_from_card(self, config):
- for idx, name in enumerate(order):
- card = widgets[name].copy()
+ for idx, card in enumerate(config):
links = loads(card.get('links'))
+ # remove duplicate before adding
+ for idx, link in enumerate(self.links):
+ if link.label == card.get('label') and link.type == 'Card Break':
+ del self.links[idx : idx + link.link_count + 1]
+
self.append('links', {
"label": card.get('label'),
"type": "Card Break",
"icon": card.get('icon'),
- "hidden": card.get('hidden') or False
+ "hidden": card.get('hidden') or False,
+ "link_count": card.get('link_count'),
+ "idx": 1 if not self.links else self.links[-1].idx + 1
})
for link in links:
@@ -97,11 +93,11 @@ class Workspace(Document):
"onboard": link.get('onboard'),
"only_for": link.get('only_for'),
"dependencies": link.get('dependencies'),
- "is_query_report": link.get('is_query_report')
+ "is_query_report": link.get('is_query_report'),
+ "idx": self.links[-1].idx + 1
})
-
-def disable_saving_as_standard():
+def disable_saving_as_public():
return frappe.flags.in_install or \
frappe.flags.in_patch or \
frappe.flags.in_test or \
@@ -125,3 +121,189 @@ def get_link_type(key):
def get_report_type(report):
report_type = frappe.get_value("Report", report, "report_type")
return report_type in ["Query Report", "Script Report", "Custom Report"]
+
+@frappe.whitelist()
+def new_page(new_page):
+ if not loads(new_page):
+ return
+
+ page = loads(new_page)
+
+ if page.get("public") and not is_workspace_manager():
+ return
+
+ doc = frappe.new_doc('Workspace')
+ doc.title = page.get('title')
+ doc.icon = page.get('icon')
+ doc.content = page.get('content')
+ doc.parent_page = page.get('parent_page')
+ doc.label = page.get('label')
+ doc.for_user = page.get('for_user')
+ doc.public = page.get('public')
+ doc.sequence_id = last_sequence_id(doc) + 1
+ doc.save(ignore_permissions=True)
+
+ return doc
+
+@frappe.whitelist()
+def save_page(title, public, new_widgets, blocks):
+ public = frappe.parse_json(public)
+
+ filters = {
+ 'public': public,
+ 'label': title
+ }
+
+ if not public:
+ filters = {
+ 'for_user': frappe.session.user,
+ 'label': title + "-" + frappe.session.user
+ }
+ pages = frappe.get_list("Workspace", filters=filters)
+ if pages:
+ doc = frappe.get_doc("Workspace", pages[0])
+
+ doc.content = blocks
+ doc.save(ignore_permissions=True)
+
+ save_new_widget(doc, title, blocks, new_widgets)
+
+ return {"name": title, "public": public, "label": doc.label}
+
+@frappe.whitelist()
+def update_page(name, title, icon, parent, public):
+ public = frappe.parse_json(public)
+
+ doc = frappe.get_doc("Workspace", name)
+
+ filters = {
+ 'parent_page': doc.title,
+ 'public': doc.public
+ }
+ child_docs = frappe.get_list("Workspace", filters=filters)
+
+ if doc:
+ doc.title = title
+ doc.icon = icon
+ doc.parent_page = parent
+ if doc.public != public:
+ doc.sequence_id = frappe.db.count('Workspace', {'public':public}, cache=True)
+ doc.public = public
+ doc.for_user = '' if public else doc.for_user or frappe.session.user
+ doc.label = '{0}-{1}'.format(title, doc.for_user) if doc.for_user else title
+ doc.save(ignore_permissions=True)
+
+ if name != doc.label:
+ rename_doc("Workspace", name, doc.label, force=True, ignore_permissions=True)
+
+ # update new name and public in child pages
+ if child_docs:
+ for child in child_docs:
+ child_doc = frappe.get_doc("Workspace", child.name)
+ child_doc.parent_page = doc.title
+ child_doc.public = doc.public
+ child_doc.save(ignore_permissions=True)
+
+ return {"name": doc.title, "public": doc.public, "label": doc.label}
+
+@frappe.whitelist()
+def duplicate_page(page_name, new_page):
+ if not loads(new_page):
+ return
+
+ new_page = loads(new_page)
+
+ if new_page.get("is_public") and not is_workspace_manager():
+ return
+
+ old_doc = frappe.get_doc("Workspace", page_name)
+ doc = frappe.copy_doc(old_doc)
+ doc.title = new_page.get('title')
+ doc.icon = new_page.get('icon')
+ doc.parent_page = new_page.get('parent') or ''
+ doc.public = new_page.get('is_public')
+ doc.for_user = ''
+ doc.label = doc.title
+ if not doc.public:
+ doc.for_user = doc.for_user or frappe.session.user
+ doc.label = '{0}-{1}'.format(doc.title, doc.for_user)
+ doc.name = doc.label
+ if old_doc.public == doc.public:
+ doc.sequence_id += 0.1
+ else:
+ doc.sequence_id = last_sequence_id(doc) + 1
+ doc.insert(ignore_permissions=True)
+
+ return doc
+
+@frappe.whitelist()
+def delete_page(page):
+ if not loads(page):
+ return
+
+ page = loads(page)
+
+ if page.get("public") and not is_workspace_manager():
+ return
+
+ if frappe.db.exists("Workspace", page.get("name")):
+ frappe.get_doc("Workspace", page.get("name")).delete(ignore_permissions=True)
+
+ return {"name": page.get("name"), "public": page.get("public"), "title": page.get("title")}
+
+@frappe.whitelist()
+def sort_pages(sb_public_items, sb_private_items):
+ if not loads(sb_public_items) and not loads(sb_private_items):
+ return
+
+ sb_public_items = loads(sb_public_items)
+ sb_private_items = loads(sb_private_items)
+
+ workspace_public_pages = get_page_list(['name', 'title'], {'public': 1})
+ workspace_private_pages = get_page_list(['name', 'title'], {'for_user': frappe.session.user})
+
+ if sb_private_items:
+ return sort_page(workspace_private_pages, sb_private_items)
+
+ if sb_public_items and is_workspace_manager():
+ return sort_page(workspace_public_pages, sb_public_items)
+
+ return False
+
+def sort_page(workspace_pages, pages):
+ for seq, d in enumerate(pages):
+ for page in workspace_pages:
+ if page.title == d.get('title'):
+ doc = frappe.get_doc('Workspace', page.name)
+ doc.sequence_id = seq + 1
+ doc.parent_page = d.get('parent_page') or ""
+ doc.flags.ignore_links = True
+ doc.save(ignore_permissions=True)
+ break
+
+ return True
+
+def last_sequence_id(doc):
+ doc_exists = frappe.db.exists({
+ 'doctype': 'Workspace',
+ 'public': doc.public,
+ 'for_user': doc.for_user
+ })
+
+ if not doc_exists:
+ return 0
+
+ return frappe.db.get_list('Workspace',
+ fields=['sequence_id'],
+ filters={
+ 'public': doc.public,
+ 'for_user': doc.for_user
+ },
+ order_by="sequence_id desc"
+ )[0].sequence_id
+
+def get_page_list(fields, filters):
+ return frappe.get_list("Workspace", fields=fields, filters=filters, order_by='sequence_id asc')
+
+def is_workspace_manager():
+ return "Workspace Manager" in frappe.get_roles()
diff --git a/frappe/desk/doctype/workspace_chart/workspace_chart.py b/frappe/desk/doctype/workspace_chart/workspace_chart.py
index 0bb6194d2e..a3b66d99ab 100644
--- a/frappe/desk/doctype/workspace_chart/workspace_chart.py
+++ b/frappe/desk/doctype/workspace_chart/workspace_chart.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/workspace_link/workspace_link.json b/frappe/desk/doctype/workspace_link/workspace_link.json
index 010fb3f316..a7b217be9e 100644
--- a/frappe/desk/doctype/workspace_link/workspace_link.json
+++ b/frappe/desk/doctype/workspace_link/workspace_link.json
@@ -16,7 +16,8 @@
"dependencies",
"only_for",
"onboard",
- "is_query_report"
+ "is_query_report",
+ "link_count"
],
"fields": [
{
@@ -84,7 +85,7 @@
{
"fieldname": "only_for",
"fieldtype": "Link",
- "label": "Only for ",
+ "label": "Only for",
"options": "Country"
},
{
@@ -99,12 +100,19 @@
"fieldname": "is_query_report",
"fieldtype": "Check",
"label": "Is Query Report"
+ },
+ {
+ "depends_on": "eval:doc.type == \"Card Break\"",
+ "fieldname": "link_count",
+ "fieldtype": "Int",
+ "hidden": 1,
+ "label": "Link Count"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-01-12 13:13:12.379443",
+ "modified": "2021-06-01 11:23:28.990593",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Link",
diff --git a/frappe/desk/doctype/workspace_link/workspace_link.py b/frappe/desk/doctype/workspace_link/workspace_link.py
index 8a139077a6..72256ba490 100644
--- a/frappe/desk/doctype/workspace_link/workspace_link.py
+++ b/frappe/desk/doctype/workspace_link/workspace_link.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py
index d676f08b73..1dad4cca05 100644
--- a/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py
+++ b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
diff --git a/frappe/desk/form/__init__.py b/frappe/desk/form/__init__.py
index 4dbcd0d163..eb5ba62e5c 100644
--- a/frappe/desk/form/__init__.py
+++ b/frappe/desk/form/__init__.py
@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
diff --git a/frappe/desk/form/assign_to.py b/frappe/desk/form/assign_to.py
index aee7a8e52a..049d33c1ec 100644
--- a/frappe/desk/form/assign_to.py
+++ b/frappe/desk/form/assign_to.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
"""assign/unassign to ToDo"""
import frappe
@@ -20,11 +19,11 @@ def get(args=None):
if not args:
args = frappe.local.form_dict
- return frappe.get_all('ToDo', fields=['owner', 'name'], filters=dict(
- reference_type = args.get('doctype'),
- reference_name = args.get('name'),
- status = ('!=', 'Cancelled')
- ), limit=5)
+ return frappe.get_all("ToDo", fields=["allocated_to as owner", "name"], filters={
+ "reference_type": args.get("doctype"),
+ "reference_name": args.get("name"),
+ "status": ("!=", "Cancelled")
+ }, limit=5)
@frappe.whitelist()
def add(args=None):
@@ -49,7 +48,7 @@ def add(args=None):
"reference_type": args['doctype'],
"reference_name": args['name'],
"status": "Open",
- "owner": assign_to
+ "allocated_to": assign_to
}
if frappe.get_all("ToDo", filters=filters):
@@ -62,7 +61,7 @@ def add(args=None):
d = frappe.get_doc({
"doctype": "ToDo",
- "owner": assign_to,
+ "allocated_to": assign_to,
"reference_type": args['doctype'],
"reference_name": args['name'],
"description": args.get('description'),
@@ -88,7 +87,7 @@ def add(args=None):
follow_document(args['doctype'], args['name'], assign_to)
# notify
- notify_assignment(d.assigned_by, d.owner, d.reference_type, d.reference_name, action='ASSIGN',
+ notify_assignment(d.assigned_by, d.allocated_to, d.reference_type, d.reference_name, action='ASSIGN',
description=args.get("description"))
if shared_with_users:
@@ -113,13 +112,13 @@ def add_multiple(args=None):
add(args)
def close_all_assignments(doctype, name):
- assignments = frappe.db.get_all('ToDo', fields=['owner'], filters =
+ assignments = frappe.db.get_all('ToDo', fields=['allocated_to'], filters =
dict(reference_type = doctype, reference_name = name, status=('!=', 'Cancelled')))
if not assignments:
return False
for assign_to in assignments:
- set_status(doctype, name, assign_to.owner, status="Closed")
+ set_status(doctype, name, assign_to.allocated_to, status="Closed")
return True
@@ -131,13 +130,13 @@ def set_status(doctype, name, assign_to, status="Cancelled"):
"""remove from todo"""
try:
todo = frappe.db.get_value("ToDo", {"reference_type":doctype,
- "reference_name":name, "owner":assign_to, "status": ('!=', status)})
+ "reference_name":name, "allocated_to":assign_to, "status": ('!=', status)})
if todo:
todo = frappe.get_doc("ToDo", todo)
todo.status = status
todo.save(ignore_permissions=True)
- notify_assignment(todo.assigned_by, todo.owner, todo.reference_type, todo.reference_name)
+ notify_assignment(todo.assigned_by, todo.allocated_to, todo.reference_type, todo.reference_name)
except frappe.DoesNotExistError:
pass
@@ -151,25 +150,26 @@ def clear(doctype, name):
'''
Clears assignments, return False if not assigned.
'''
- assignments = frappe.db.get_all('ToDo', fields=['owner'], filters =
+ assignments = frappe.db.get_all('ToDo', fields=['allocated_to'], filters =
dict(reference_type = doctype, reference_name = name))
if not assignments:
return False
for assign_to in assignments:
- set_status(doctype, name, assign_to.owner, "Cancelled")
+ set_status(doctype, name, assign_to.allocated_to, "Cancelled")
return True
-def notify_assignment(assigned_by, owner, doc_type, doc_name, action='CLOSE',
+def notify_assignment(assigned_by, allocated_to, doc_type, doc_name, action='CLOSE',
description=None):
"""
Notify assignee that there is a change in assignment
"""
- if not (assigned_by and owner and doc_type and doc_name): return
+ if not (assigned_by and allocated_to and doc_type and doc_name):
+ return
# return if self assigned or user disabled
- if assigned_by == owner or not frappe.db.get_value('User', owner, 'enabled'):
+ if assigned_by == allocated_to or not frappe.db.get_value('User', allocated_to, 'enabled'):
return
# Search for email address in description -- i.e. assignee
@@ -195,7 +195,7 @@ def notify_assignment(assigned_by, owner, doc_type, doc_name, action='CLOSE',
'email_content': description_html
}
- enqueue_create_notification(owner, notification_doc)
+ enqueue_create_notification(allocated_to, notification_doc)
def format_message_for_assign_to(users):
return " " + " ".join(users)
\ No newline at end of file
diff --git a/frappe/desk/form/document_follow.py b/frappe/desk/form/document_follow.py
index f5ace4d732..14970092d0 100644
--- a/frappe/desk/form/document_follow.py
+++ b/frappe/desk/form/document_follow.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
import frappe.utils
from frappe.utils import get_url_to_form
@@ -147,6 +146,8 @@ def get_version(doctype, doc_name, frequency, user):
return timeline
def get_comments(doctype, doc_name, frequency, user):
+ from html2text import html2text
+
timeline = []
filters = get_filters("reference_name", doc_name, frequency, user)
comments = frappe.get_all("Comment",
@@ -166,7 +167,7 @@ def get_comments(doctype, doc_name, frequency, user):
"time": comment.modified,
"data": {
"time": time,
- "comment": frappe.utils.html2text(str(comment.content)),
+ "comment": html2text(str(comment.content)),
"by": by
},
"doctype": doctype,
@@ -197,6 +198,8 @@ def get_follow_users(doctype, doc_name):
)
def get_row_changed(row_changed, time, doctype, doc_name, v):
+ from html2text import html2text
+
items = []
for d in row_changed:
d[2] = d[2] if d[2] else ' '
@@ -209,8 +212,8 @@ def get_row_changed(row_changed, time, doctype, doc_name, v):
"table_field": d[0],
"row": str(d[1]),
"field": d[3][0][0],
- "from": frappe.utils.html2text(str(d[3][0][1])),
- "to": frappe.utils.html2text(str(d[3][0][2]))
+ "from": html2text(str(d[3][0][1])),
+ "to": html2text(str(d[3][0][2]))
},
"doctype": doctype,
"doc_name": doc_name,
@@ -236,6 +239,8 @@ def get_added_row(added, time, doctype, doc_name, v):
return items
def get_field_changed(changed, time, doctype, doc_name, v):
+ from html2text import html2text
+
items = []
for d in changed:
d[1] = d[1] if d[1] else ' '
@@ -246,8 +251,8 @@ def get_field_changed(changed, time, doctype, doc_name, v):
"data": {
"time": time,
"field": d[0],
- "from": frappe.utils.html2text(str(d[1])),
- "to": frappe.utils.html2text(str(d[2]))
+ "from": html2text(str(d[1])),
+ "to": html2text(str(d[2]))
},
"doctype": doctype,
"doc_name": doc_name,
diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py
index 733ee1774c..010d65c95b 100644
--- a/frappe/desk/form/linked_with.py
+++ b/frappe/desk/form/linked_with.py
@@ -1,9 +1,11 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-from __future__ import unicode_literals
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
+
import json
from collections import defaultdict
-from six import string_types
+import itertools
+from typing import Dict, List, Optional
+
import frappe
import frappe.desk.form.load
import frappe.desk.form.meta
@@ -11,103 +13,334 @@ from frappe import _
from frappe.model.meta import is_single
from frappe.modules import load_doctype_module
+
@frappe.whitelist()
-def get_submitted_linked_docs(doctype, name, docs=None, visited=None):
+def get_submitted_linked_docs(doctype: str, name: str) -> List[tuple]:
+ """ Get all the nested submitted documents those are present in referencing tables (dependent tables).
+
+ :param doctype: Document type
+ :param name: Name of the document
+
+ Usecase:
+ * User should be able to cancel the linked documents along with the one user trying to cancel.
+
+ Case1: If document sd1-n1 (document name n1 from sumittable doctype sd1) is linked to sd2-n2 and sd2-n2 is linked to sd3-n3,
+ Getting submittable linked docs of `sd1-n1`should give both sd2-n2 and sd3-n3.
+ Case2: If document sd1-n1 (document name n1 from sumittable doctype sd1) is linked to d2-n2 and d2-n2 is linked to sd3-n3,
+ Getting submittable linked docs of `sd1-n1`should give None. (because d2-n2 is not a submittable doctype)
+ Case3: If document sd1-n1 (document name n1 from submittable doctype sd1) is linked to d2-n2 & sd2-n2. d2-n2 is linked to sd3-n3.
+ Getting submittable linked docs of `sd1-n1`should give sd2-n2.
+
+ Logic:
+ -----
+ 1. We can find linked documents only if we know how the doctypes are related.
+ 2. As we need only submittable documents, we can limit doctype relations search to submittable doctypes by
+ finding the relationships(Foreign key references) across submittable doctypes.
+ 3. Searching for links is going to be a tree like structure where at every level,
+ you will be finding documents using parent document and parent document links.
"""
- Get all nested submitted linked doctype linkinfo
+ tree = SubmittableDocumentTree(doctype, name)
+ visited_documents = tree.get_all_children()
+ docs = []
- Arguments:
- doctype (str) - The doctype for which get all linked doctypes
- name (str) - The docname for which get all linked doctypes
+ for dt, names in visited_documents.items():
+ docs.extend([{'doctype': dt, 'name': name, 'docstatus': 1} for name in names])
- Keyword Arguments:
- docs (list of dict) - (Optional) Get list of dictionary for linked doctype.
-
- Returns:
- dict - Return list of documents and link count
- """
-
- if not docs:
- docs = []
-
- if not visited:
- visited = {}
-
- if doctype not in visited:
- visited[doctype] = []
-
- if name in visited[doctype]:
- return
-
- linkinfo = get_linked_doctypes(doctype)
- linked_docs = get_linked_docs(doctype, name, linkinfo)
-
- link_count = 0
- visited[doctype].append(name)
-
- for link_doctype, link_names in linked_docs.items():
-
- for link in link_names:
- if link['name'] == name:
- continue
-
- docinfo = link.update({"doctype": link_doctype})
- validated_doc = validate_linked_doc(docinfo)
-
- if not validated_doc:
- continue
-
- link_count += 1
-
- links = get_submitted_linked_docs(link_doctype, link.name, docs, visited)
- if links:
- docs.append({
- "doctype": link_doctype,
- "name": link.name,
- "docstatus": link.docstatus,
- "link_count": links.get("count")
- })
-
- # sort linked documents by ascending number of links
- docs.sort(key=lambda doc: doc.get("link_count"))
return {
"docs": docs,
- "count": link_count
+ "count": len(docs)
}
+class SubmittableDocumentTree:
+ def __init__(self, doctype: str, name: str):
+ """Construct a tree for the submitable linked documents.
+
+ * Node has properties like doctype and docnames. Represented as Node(doctype, docnames).
+ * Nodes are linked by doctype relationships like table, link and dynamic links.
+ * Node is referenced(linked) by many other documents and those are the child nodes.
+
+ NOTE: child document is a property of child node (not same as Frappe child docs of a table field).
+ """
+ self.root_doctype = doctype
+ self.root_docname = name
+
+ # Documents those are yet to be visited for linked documents.
+ self.to_be_visited_documents = {doctype: [name]}
+ self.visited_documents = defaultdict(list)
+
+ self._submittable_doctypes = None # All submittable doctypes in the system
+ self._references_across_doctypes = None # doctype wise links/references
+
+ def get_all_children(self):
+ """Get all nodes of a tree except the root node (all the nested submitted
+ documents those are present in referencing tables (dependent tables).
+ """
+ while self.to_be_visited_documents:
+ next_level_children = defaultdict(list)
+ for parent_dt in list(self.to_be_visited_documents):
+ parent_docs = self.to_be_visited_documents.get(parent_dt)
+ if not parent_docs:
+ del self.to_be_visited_documents[parent_dt]
+ continue
+
+ child_docs = self.get_next_level_children(parent_dt, parent_docs)
+ self.visited_documents[parent_dt].extend(parent_docs)
+ for linked_dt, linked_names in child_docs.items():
+ not_visited_child_docs = set(linked_names) - set(self.visited_documents.get(linked_dt, []))
+ next_level_children[linked_dt].extend(not_visited_child_docs)
+
+ self.to_be_visited_documents = next_level_children
+
+ # Remove root node from visited documents
+ if self.root_docname in self.visited_documents.get(self.root_doctype, []):
+ self.visited_documents[self.root_doctype].remove(self.root_docname)
+
+ return self.visited_documents
+
+ def get_next_level_children(self, parent_dt, parent_names):
+ """Get immediate children of a Node(parent_dt, parent_names)
+ """
+ referencing_fields = self.get_doctype_references(parent_dt)
+
+ child_docs = defaultdict(list)
+ for field in referencing_fields:
+ links = get_referencing_documents(parent_dt, parent_names.copy(), field, get_parent_if_child_table_doc=True,
+ parent_filters=[('docstatus', '=', 1)], allowed_parents=self.get_link_sources()) or {}
+ for dt, names in links.items():
+ child_docs[dt].extend(names)
+ return child_docs
+
+ def get_doctype_references(self, doctype):
+ """Get references for a given document.
+ """
+ if self._references_across_doctypes is None:
+ get_links_to = self.get_document_sources()
+ limit_link_doctypes = self.get_link_sources()
+ self._references_across_doctypes = get_references_across_doctypes(
+ get_links_to, limit_link_doctypes)
+ return self._references_across_doctypes.get(doctype, [])
+
+ def get_document_sources(self):
+ """Returns list of doctypes from where we access submittable documents.
+ """
+ return list(set(self.get_link_sources() + [self.root_doctype]))
+
+ def get_link_sources(self):
+ """limit doctype links to these doctypes.
+ """
+ return list(set(self.get_submittable_doctypes()) - set(get_exempted_doctypes() or []))
+
+ def get_submittable_doctypes(self) -> List[str]:
+ """Returns list of submittable doctypes.
+ """
+ if not self._submittable_doctypes:
+ self._submittable_doctypes = frappe.db.get_all('DocType', {'is_submittable': 1}, pluck='name')
+ return self._submittable_doctypes
+
+
+def get_child_tables_of_doctypes(doctypes: List[str]=None):
+ """Returns child tables by doctype.
+ """
+ filters=[['fieldtype','=', 'Table']]
+ filters_for_docfield = filters
+ filters_for_customfield = filters
+
+ if doctypes:
+ filters_for_docfield = filters + [['parent', 'in', tuple(doctypes)]]
+ filters_for_customfield = filters + [['dt', 'in', tuple(doctypes)]]
+
+ links = frappe.get_all("DocField",
+ fields=["parent", "fieldname", "options as child_table"],
+ filters=filters_for_docfield,
+ as_list=1)
+
+ links+= frappe.get_all("Custom Field",
+ fields=["dt as parent", "fieldname", "options as child_table"],
+ filters=filters_for_customfield,
+ as_list=1)
+
+ child_tables_by_doctype = defaultdict(list)
+ for doctype, fieldname, child_table in links:
+ child_tables_by_doctype[doctype].append(
+ {'doctype': doctype, 'fieldname': fieldname, 'child_table': child_table})
+ return child_tables_by_doctype
+
+
+def get_references_across_doctypes(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None) -> List:
+ """Find doctype wise foreign key references.
+
+ :param to_doctypes: Get links of these doctypes.
+ :param limit_link_doctypes: limit links to these doctypes.
+
+ * Include child table, link and dynamic link references.
+ """
+ if limit_link_doctypes:
+ child_tables_by_doctype = get_child_tables_of_doctypes(limit_link_doctypes)
+ all_child_tables = [each['child_table'] for each in itertools.chain(*child_tables_by_doctype.values())]
+ limit_link_doctypes = limit_link_doctypes + all_child_tables
+ else:
+ child_tables_by_doctype = get_child_tables_of_doctypes()
+ all_child_tables = [each['child_table'] for each in itertools.chain(*child_tables_by_doctype.values())]
+
+ references_by_link_fields = get_references_across_doctypes_by_link_field(to_doctypes, limit_link_doctypes)
+ references_by_dlink_fields = get_references_across_doctypes_by_dynamic_link_field(to_doctypes, limit_link_doctypes)
+
+ references = references_by_link_fields.copy()
+ for k, v in references_by_dlink_fields.items():
+ references.setdefault(k, []).extend(v)
+
+ for doctype, links in references.items():
+ for link in links:
+ link['is_child'] = (link['doctype'] in all_child_tables)
+ return references
+
+
+def get_references_across_doctypes_by_link_field(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None):
+ """Find doctype wise foreign key references based on link fields.
+
+ :param to_doctypes: Get links to these doctypes.
+ :param limit_link_doctypes: limit links to these doctypes.
+ """
+ filters=[['fieldtype','=', 'Link']]
+
+ if to_doctypes:
+ filters += [['options', 'in', tuple(to_doctypes)]]
+
+ filters_for_docfield = filters[:]
+ filters_for_customfield = filters[:]
+
+ if limit_link_doctypes:
+ filters_for_docfield += [['parent', 'in', tuple(limit_link_doctypes)]]
+ filters_for_customfield += [['dt', 'in', tuple(limit_link_doctypes)]]
+
+ links = frappe.get_all("DocField",
+ fields=["parent", "fieldname", "options as linked_to"],
+ filters=filters_for_docfield,
+ as_list=1)
+
+ links+= frappe.get_all("Custom Field",
+ fields=["dt as parent", "fieldname", "options as linked_to"],
+ filters=filters_for_customfield,
+ as_list=1)
+
+ links_by_doctype = defaultdict(list)
+ for doctype, fieldname, linked_to in links:
+ links_by_doctype[linked_to].append({'doctype': doctype, 'fieldname': fieldname})
+ return links_by_doctype
+
+
+def get_references_across_doctypes_by_dynamic_link_field(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None):
+ """Find doctype wise foreign key references based on dynamic link fields.
+
+ :param to_doctypes: Get links to these doctypes.
+ :param limit_link_doctypes: limit links to these doctypes.
+ """
+
+ filters=[['fieldtype','=', 'Dynamic Link']]
+
+ filters_for_docfield = filters[:]
+ filters_for_customfield = filters[:]
+
+ if limit_link_doctypes:
+ filters_for_docfield += [['parent', 'in', tuple(limit_link_doctypes)]]
+ filters_for_customfield += [['dt', 'in', tuple(limit_link_doctypes)]]
+
+ # find dynamic links of parents
+ links = frappe.get_all("DocField",
+ fields=["parent as doctype", "fieldname", "options as doctype_fieldname"],
+ filters=filters_for_docfield,
+ as_list=1)
+
+ links += frappe.get_all("Custom Field",
+ fields=["dt as doctype", "fieldname", "options as doctype_fieldname"],
+ filters=filters_for_customfield,
+ as_list=1)
+
+ links_by_doctype = defaultdict(list)
+ for doctype, fieldname, doctype_fieldname in links:
+ try:
+ filters = [[doctype_fieldname, 'in', to_doctypes]] if to_doctypes else []
+ for linked_to in frappe.db.get_all(doctype, pluck=doctype_fieldname, filters = filters, distinct=1):
+ if linked_to:
+ links_by_doctype[linked_to].append({'doctype': doctype, 'fieldname': fieldname, 'doctype_fieldname': doctype_fieldname})
+ except frappe.db.ProgrammingError:
+ # TODO: FIXME
+ continue
+ return links_by_doctype
+
+def get_referencing_documents(reference_doctype: str, reference_names: List[str],
+ link_info: dict, get_parent_if_child_table_doc: bool=True,
+ parent_filters: List[list]=None, child_filters=None, allowed_parents=None):
+ """Get linked documents based on link_info.
+
+ :param reference_doctype: reference doctype to find links
+ :param reference_names: reference document names to find links for
+ :param link_info: linking details to get the linked documents
+ Ex: {'doctype': 'Purchase Invoice Advance', 'fieldname': 'reference_name',
+ 'doctype_fieldname': 'reference_type', 'is_child': True}
+ :param get_parent_if_child_table_doc: Get parent record incase linked document is a child table record.
+ :param parent_filters: filters to apply on if not a child table.
+ :param child_filters: apply filters if it is a child table.
+ :param allowed_parents: list of parents allowed in case of get_parent_if_child_table_doc
+ is enabled.
+ """
+ from_table = link_info['doctype']
+ filters = [[link_info['fieldname'], 'in', tuple(reference_names)]]
+ if link_info.get('doctype_fieldname'):
+ filters.append([link_info['doctype_fieldname'], '=', reference_doctype])
+
+ if not link_info.get('is_child'):
+ filters.extend(parent_filters or [])
+ return {from_table: frappe.db.get_all(from_table, filters, pluck='name')}
+
+
+ filters.extend(child_filters or [])
+ res = frappe.db.get_all(from_table, filters = filters, fields = ['name', 'parenttype', 'parent'])
+ documents = defaultdict(list)
+
+ for parent, rows in itertools.groupby(res, key = lambda row: row['parenttype']):
+ if allowed_parents and parent not in allowed_parents:
+ continue
+ filters = (parent_filters or []) + [['name', 'in', tuple([row.parent for row in rows])]]
+ documents[parent].extend(frappe.db.get_all(parent, filters=filters, pluck='name') or [])
+ return documents
+
@frappe.whitelist()
-def cancel_all_linked_docs(docs, ignore_doctypes_on_cancel_all=[]):
+def cancel_all_linked_docs(docs, ignore_doctypes_on_cancel_all=None):
"""
- Cancel all linked doctype
+ Cancel all linked doctype, optionally ignore doctypes specified in a list.
Arguments:
- docs (str) - It contains all list of dictionaries of a linked documents.
+ docs (json str) - It contains list of dictionaries of a linked documents.
+ ignore_doctypes_on_cancel_all (list) - List of doctypes to ignore while cancelling.
"""
+ if ignore_doctypes_on_cancel_all is None:
+ ignore_doctypes_on_cancel_all = []
docs = json.loads(docs)
- if isinstance(ignore_doctypes_on_cancel_all, string_types):
+ if isinstance(ignore_doctypes_on_cancel_all, str):
ignore_doctypes_on_cancel_all = json.loads(ignore_doctypes_on_cancel_all)
for i, doc in enumerate(docs, 1):
- if validate_linked_doc(doc, ignore_doctypes_on_cancel_all) is True:
- frappe.publish_progress(percent=i * 100 / ((len(docs) - len(ignore_doctypes_on_cancel_all))), title=_("Cancelling documents"))
+ if validate_linked_doc(doc, ignore_doctypes_on_cancel_all):
linked_doc = frappe.get_doc(doc.get("doctype"), doc.get("name"))
linked_doc.cancel()
+ frappe.publish_progress(percent=i/len(docs) * 100, title=_("Cancelling documents"))
-def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=[]):
+def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=None):
"""
Validate a document to be submitted and non-exempted from auto-cancel.
- Args:
- docs (dict): The document to check for submitted and non-exempt from auto-cancel
+ Arguments:
+ docinfo (dict): The document to check for submitted and non-exempt from auto-cancel
+ ignore_doctypes_on_cancel_all (list) - List of doctypes to ignore while cancelling.
Returns:
bool: True if linked document passes all validations, else False
"""
-
#ignore doctype to cancel
- if docinfo.get("doctype") in ignore_doctypes_on_cancel_all:
+ if docinfo.get("doctype") in (ignore_doctypes_on_cancel_all or []):
return False
# skip non-submittable doctypes since they don't need to be cancelled
@@ -128,7 +361,6 @@ def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=[]):
def get_exempted_doctypes():
""" Get list of doctypes exempted from being auto-cancelled """
-
auto_cancel_exempt_doctypes = []
for doctypes in frappe.get_hooks('auto_cancel_exempted_doctypes'):
auto_cancel_exempt_doctypes.append(doctypes)
@@ -136,8 +368,8 @@ def get_exempted_doctypes():
@frappe.whitelist()
-def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
- if isinstance(linkinfo, string_types):
+def get_linked_docs(doctype: str, name: str, linkinfo: Optional[Dict] = None) -> Dict[str, List]:
+ if isinstance(linkinfo, str):
# additional fields are added in linkinfo
linkinfo = json.loads(linkinfo)
@@ -146,25 +378,21 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
if not linkinfo:
return results
- if for_doctype:
- links = frappe.get_doc(doctype, name).get_link_filters(for_doctype)
-
- if links:
- linkinfo = links
-
- if for_doctype in linkinfo:
- # only get linked with for this particular doctype
- linkinfo = { for_doctype: linkinfo.get(for_doctype) }
- else:
- return results
-
- me = frappe.db.get_value(doctype, name, ["parenttype", "parent"], as_dict=True)
-
for dt, link in linkinfo.items():
filters = []
link["doctype"] = dt
- link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt)
+ try:
+ link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt)
+ except Exception as e:
+ if isinstance(e, frappe.DoesNotExistError):
+ if frappe.local.message_log:
+ frappe.local.message_log.pop()
+ continue
linkmeta = link_meta_bundle[0]
+
+ if not linkmeta.has_permission():
+ continue
+
if not linkmeta.get("issingle"):
fields = [d.fieldname for d in linkmeta.get("fields", {
"in_list_view": 1,
@@ -179,14 +407,19 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
try:
if link.get("filters"):
- ret = frappe.get_list(doctype=dt, fields=fields, filters=link.get("filters"))
+ ret = frappe.get_all(doctype=dt, fields=fields, filters=link.get("filters"))
elif link.get("get_parent"):
- if me and me.parent and me.parenttype == dt:
- ret = frappe.get_list(doctype=dt, fields=fields,
+ ret = None
+
+ # check for child table
+ if not frappe.get_meta(doctype).istable:
+ continue
+
+ me = frappe.db.get_value(doctype, name, ["parenttype", "parent"], as_dict=True)
+ if me and me.parenttype == dt:
+ ret = frappe.get_all(doctype=dt, fields=fields,
filters=[[dt, "name", '=', me.parent]])
- else:
- ret = None
elif link.get("child_doctype"):
or_filters = [[link.get('child_doctype'), link_fieldnames, '=', name] for link_fieldnames in link.get("fieldname")]
@@ -195,17 +428,18 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
if link.get("doctype_fieldname"):
filters.append([link.get('child_doctype'), link.get("doctype_fieldname"), "=", doctype])
- ret = frappe.get_list(doctype=dt, fields=fields, filters=filters, or_filters=or_filters, distinct=True)
+ ret = frappe.get_all(doctype=dt, fields=fields, filters=filters, or_filters=or_filters, distinct=True)
else:
link_fieldnames = link.get("fieldname")
if link_fieldnames:
- if isinstance(link_fieldnames, string_types): link_fieldnames = [link_fieldnames]
+ if isinstance(link_fieldnames, str):
+ link_fieldnames = [link_fieldnames]
or_filters = [[dt, fieldname, '=', name] for fieldname in link_fieldnames]
# dynamic link
if link.get("doctype_fieldname"):
filters.append([dt, link.get("doctype_fieldname"), "=", doctype])
- ret = frappe.get_list(doctype=dt, fields=fields, filters=filters, or_filters=or_filters)
+ ret = frappe.get_all(doctype=dt, fields=fields, filters=filters, or_filters=or_filters)
else:
ret = None
@@ -221,6 +455,13 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
return results
+
+@frappe.whitelist()
+def get(doctype, docname):
+ linked_doctypes = get_linked_doctypes(doctype=doctype)
+ return get_linked_docs(doctype=doctype, name=docname, linkinfo=linked_doctypes)
+
+
@frappe.whitelist()
def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
"""add list of doctypes this doctype is 'linked' with.
@@ -235,13 +476,14 @@ def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
else:
return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype))
+
def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
ret = {}
# find fields where this doctype is linked
ret.update(get_linked_fields(doctype, without_ignore_user_permissions_enabled))
ret.update(get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled))
- filters=[['fieldtype', 'in', frappe.model.table_fields], ['options', '=', doctype]]
+ filters = [['fieldtype', 'in', frappe.model.table_fields], ['options', '=', doctype]]
if without_ignore_user_permissions_enabled: filters.append(['ignore_user_permissions', '!=', 1])
# find links of parents
links = frappe.get_all("DocField", fields=["parent as dt"], filters=filters)
@@ -264,14 +506,15 @@ def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False)
return ret
+
def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False):
- filters=[['fieldtype','=', 'Link'], ['options', '=', doctype]]
+ filters = [['fieldtype','=', 'Link'], ['options', '=', doctype]]
if without_ignore_user_permissions_enabled: filters.append(['ignore_user_permissions', '!=', 1])
# find links of parents
links = frappe.get_all("DocField", fields=["parent", "fieldname"], filters=filters, as_list=1)
- links+= frappe.get_all("Custom Field", fields=["dt as parent", "fieldname"], filters=filters, as_list=1)
+ links += frappe.get_all("Custom Field", fields=["dt as parent", "fieldname"], filters=filters, as_list=1)
ret = {}
@@ -294,37 +537,41 @@ def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False):
return ret
+
def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=False):
ret = {}
- filters=[['fieldtype','=', 'Dynamic Link']]
+ filters = [['fieldtype','=', 'Dynamic Link']]
if without_ignore_user_permissions_enabled: filters.append(['ignore_user_permissions', '!=', 1])
# find dynamic links of parents
links = frappe.get_all("DocField", fields=["parent as doctype", "fieldname", "options as doctype_fieldname"], filters=filters)
- links+= frappe.get_all("Custom Field", fields=["dt as doctype", "fieldname", "options as doctype_fieldname"], filters=filters)
+ links += frappe.get_all("Custom Field", fields=["dt as doctype", "fieldname", "options as doctype_fieldname"], filters=filters)
for df in links:
if is_single(df.doctype): continue
- # optimized to get both link exists and parenttype
- possible_link = frappe.get_all(df.doctype, filters={df.doctype_fieldname: doctype},
- fields=['parenttype'], distinct=True)
+ is_child = frappe.get_meta(df.doctype).istable
+ possible_link = frappe.get_all(
+ df.doctype,
+ filters={df.doctype_fieldname: doctype},
+ fields=["parenttype"] if is_child else None,
+ distinct=True
+ )
if not possible_link: continue
- for d in possible_link:
- # is child
- if d.parenttype:
+ if is_child:
+ for d in possible_link:
ret[d.parenttype] = {
"child_doctype": df.doctype,
"fieldname": [df.fieldname],
"doctype_fieldname": df.doctype_fieldname
}
- else:
- ret[df.doctype] = {
- "fieldname": [df.fieldname],
- "doctype_fieldname": df.doctype_fieldname
- }
+ else:
+ ret[df.doctype] = {
+ "fieldname": [df.fieldname],
+ "doctype_fieldname": df.doctype_fieldname
+ }
return ret
diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py
index 1f5c437330..0140157c9d 100644
--- a/frappe/desk/form/load.py
+++ b/frappe/desk/form/load.py
@@ -1,7 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
+from typing import Dict, List, Union
import frappe, json
import frappe.utils
import frappe.share
@@ -10,10 +10,13 @@ import frappe.desk.form.meta
from frappe.model.utils.user_settings import get_user_settings
from frappe.permissions import get_doc_permissions
from frappe.desk.form.document_follow import is_document_followed
+from frappe.utils.data import cstr
from frappe import _
-from six.moves.urllib.parse import quote
+from frappe import _dict
+from urllib.parse import quote
-@frappe.whitelist(allow_guest=True)
+
+@frappe.whitelist()
def getdoc(doctype, name, user=None):
"""
Loads a doclist for a given document. This method is called directly from the client.
@@ -49,10 +52,13 @@ def getdoc(doctype, name, user=None):
raise
doc.add_seen()
-
+ set_link_titles(doc)
+ if frappe.response.docs is None:
+ frappe.response = _dict({"docs": []})
frappe.response.docs.append(doc)
-@frappe.whitelist(allow_guest=True)
+
+@frappe.whitelist()
def getdoctype(doctype, with_parent=False, cached_timestamp=None):
"""load doctype"""
@@ -89,27 +95,83 @@ def get_docinfo(doc=None, doctype=None, name=None):
doc = frappe.get_doc(doctype, name)
if not doc.has_permission("read"):
raise frappe.PermissionError
- frappe.response["docinfo"] = {
+
+ all_communications = _get_communications(doc.doctype, doc.name)
+ automated_messages = [msg for msg in all_communications if msg['communication_type'] == 'Automated Message']
+ communications_except_auto_messages = [msg for msg in all_communications if msg['communication_type'] != 'Automated Message']
+
+ docinfo = frappe._dict(user_info = {})
+
+ add_comments(doc, docinfo)
+
+ docinfo.update({
"attachments": get_attachments(doc.doctype, doc.name),
- "attachment_logs": get_comments(doc.doctype, doc.name, 'attachment'),
- "communications": _get_communications(doc.doctype, doc.name),
- 'comments': get_comments(doc.doctype, doc.name),
+ "communications": communications_except_auto_messages,
+ "automated_messages": automated_messages,
'total_comments': len(json.loads(doc.get('_comments') or '[]')),
'versions': get_versions(doc),
"assignments": get_assignments(doc.doctype, doc.name),
- "assignment_logs": get_comments(doc.doctype, doc.name, 'assignment'),
"permissions": get_doc_permissions(doc),
"shared": frappe.share.get_users(doc.doctype, doc.name),
- "share_logs": get_comments(doc.doctype, doc.name, 'share'),
- "like_logs": get_comments(doc.doctype, doc.name, 'Like'),
"views": get_view_logs(doc.doctype, doc.name),
"energy_point_logs": get_point_logs(doc.doctype, doc.name),
"additional_timeline_content": get_additional_timeline_content(doc.doctype, doc.name),
"milestones": get_milestones(doc.doctype, doc.name),
"is_document_followed": is_document_followed(doc.doctype, doc.name, frappe.session.user),
"tags": get_tags(doc.doctype, doc.name),
- "document_email": get_document_email(doc.doctype, doc.name)
- }
+ "document_email": get_document_email(doc.doctype, doc.name),
+ })
+
+ update_user_info(docinfo)
+
+ frappe.response["docinfo"] = docinfo
+
+def add_comments(doc, docinfo):
+ # divide comments into separate lists
+ docinfo.comments = []
+ docinfo.shared = []
+ docinfo.assignment_logs = []
+ docinfo.attachment_logs = []
+ docinfo.info_logs = []
+ docinfo.like_logs = []
+ docinfo.workflow_logs = []
+
+ comments = frappe.get_all("Comment",
+ fields=["name", "creation", "content", "owner", "comment_type"],
+ filters={
+ "reference_doctype": doc.doctype,
+ "reference_name": doc.name
+ }
+ )
+
+ for c in comments:
+ if c.comment_type == "Comment":
+ c.content = frappe.utils.markdown(c.content)
+ docinfo.comments.append(c)
+
+ elif c.comment_type in ('Shared', 'Unshared'):
+ docinfo.shared.append(c)
+
+ elif c.comment_type in ('Assignment Completed', 'Assigned'):
+ docinfo.assignment_logs.append(c)
+
+ elif c.comment_type in ('Attachment', 'Attachment Removed'):
+ docinfo.attachment_logs.append(c)
+
+ elif c.comment_type in ('Info', 'Edit', 'Label'):
+ docinfo.info_logs.append(c)
+
+ elif c.comment_type == "Like":
+ docinfo.like_logs.append(c)
+
+ elif c.comment_type == "Workflow":
+ docinfo.workflow_logs.append(c)
+
+ frappe.utils.add_user_info(c.owner, docinfo.user_info)
+
+
+ return comments
+
def get_milestones(doctype, name):
return frappe.db.get_all('Milestone', fields = ['creation', 'owner', 'track_field', 'value'],
@@ -132,10 +194,11 @@ def get_communications(doctype, name, start=0, limit=20):
return _get_communications(doctype, name, start, limit)
-def get_comments(doctype, name, comment_type='Comment'):
- comment_types = [comment_type]
+def get_comments(doctype: str, name: str, comment_type : Union[str, List[str]] = "Comment") -> List[frappe._dict]:
+ if isinstance(comment_type, list):
+ comment_types = comment_type
- if comment_type == 'share':
+ elif comment_type == 'share':
comment_types = ['Shared', 'Unshared']
elif comment_type == 'assignment':
@@ -144,15 +207,21 @@ def get_comments(doctype, name, comment_type='Comment'):
elif comment_type == 'attachment':
comment_types = ['Attachment', 'Attachment Removed']
- comments = frappe.get_all('Comment', fields = ['name', 'creation', 'content', 'owner', 'comment_type'], filters=dict(
- reference_doctype = doctype,
- reference_name = name,
- comment_type = ['in', comment_types]
- ))
+ else:
+ comment_types = [comment_type]
+
+ comments = frappe.get_all("Comment",
+ fields=["name", "creation", "content", "owner", "comment_type"],
+ filters={
+ "reference_doctype": doctype,
+ "reference_name": name,
+ "comment_type": ['in', comment_types],
+ }
+ )
# convert to markdown (legacy ?)
- if comment_type == 'Comment':
- for c in comments:
+ for c in comments:
+ if c.comment_type == "Comment":
c.content = frappe.utils.markdown(c.content)
return comments
@@ -186,7 +255,7 @@ def get_communication_data(doctype, name, start=0, limit=20, after=None, fields=
C.sender, C.sender_full_name, C.cc, C.bcc,
C.creation AS creation, C.subject, C.delivery_status,
C._liked_by, C.reference_doctype, C.reference_name,
- C.read_by_recipient, C.rating
+ C.read_by_recipient, C.rating, C.recipients
'''
conditions = ''
@@ -205,7 +274,7 @@ def get_communication_data(doctype, name, start=0, limit=20, after=None, fields=
part1 = '''
SELECT {fields}
FROM `tabCommunication` as C
- WHERE C.communication_type IN ('Communication', 'Feedback')
+ WHERE C.communication_type IN ('Communication', 'Feedback', 'Automated Message')
AND (C.reference_doctype = %(doctype)s AND C.reference_name = %(name)s)
{conditions}
'''.format(fields=fields, conditions=conditions)
@@ -215,7 +284,7 @@ def get_communication_data(doctype, name, start=0, limit=20, after=None, fields=
SELECT {fields}
FROM `tabCommunication` as C
INNER JOIN `tabCommunication Link` ON C.name=`tabCommunication Link`.parent
- WHERE C.communication_type IN ('Communication', 'Feedback')
+ WHERE C.communication_type IN ('Communication', 'Feedback', 'Automated Message')
AND `tabCommunication Link`.link_doctype = %(doctype)s AND `tabCommunication Link`.link_name = %(name)s
{conditions}
'''.format(fields=fields, conditions=conditions)
@@ -237,16 +306,14 @@ def get_communication_data(doctype, name, start=0, limit=20, after=None, fields=
return communications
def get_assignments(dt, dn):
- cl = frappe.get_all("ToDo",
- fields=['name', 'owner', 'description', 'status'],
+ return frappe.get_all("ToDo",
+ fields=['name', 'allocated_to as owner', 'description', 'status'],
filters={
'reference_type': dt,
'reference_name': dn,
'status': ('!=', 'Cancelled'),
})
- return cl
-
@frappe.whitelist()
def get_badge_info(doctypes, filters):
filters = json.loads(filters)
@@ -289,7 +356,7 @@ def get_document_email(doctype, name):
return None
email = email.split("@")
- return "{0}+{1}+{2}@{3}".format(email[0], quote(doctype), quote(name), email[1])
+ return "{0}+{1}+{2}@{3}".format(email[0], quote(doctype), quote(cstr(name)), email[1])
def get_automatic_email_link():
return frappe.db.get_value("Email Account", {"enable_incoming": 1, "enable_automatic_linking": 1}, "email_id")
@@ -303,4 +370,80 @@ def get_additional_timeline_content(doctype, docname):
for method in methods_for_all_doctype + methods_for_current_doctype:
contents.extend(frappe.get_attr(method)(doctype, docname) or [])
- return contents
\ No newline at end of file
+ return contents
+
+def set_link_titles(doc):
+ link_titles = {}
+ link_titles.update(get_title_values_for_link_and_dynamic_link_fields(doc))
+ link_titles.update(get_title_values_for_table_and_multiselect_fields(doc))
+
+ send_link_titles(link_titles)
+
+def get_title_values_for_link_and_dynamic_link_fields(doc, link_fields=None):
+ link_titles = {}
+
+ if not link_fields:
+ meta = frappe.get_meta(doc.doctype)
+ link_fields = meta.get_link_fields() + meta.get_dynamic_link_fields()
+
+ for field in link_fields:
+ if not doc.get(field.fieldname):
+ continue
+
+ doctype = field.options if field.fieldtype == "Link" else doc.get(field.options)
+
+ meta = frappe.get_meta(doctype)
+ if not meta or not (meta.title_field and meta.show_title_field_in_link):
+ continue
+
+ link_title = frappe.db.get_value(
+ doctype, doc.get(field.fieldname), meta.title_field, cache=True
+ )
+ link_titles.update({doctype + "::" + doc.get(field.fieldname): link_title})
+
+ return link_titles
+
+def get_title_values_for_table_and_multiselect_fields(doc, table_fields=None):
+ link_titles = {}
+
+ if not table_fields:
+ meta = frappe.get_meta(doc.doctype)
+ table_fields = meta.get_table_fields()
+
+ for field in table_fields:
+ if not doc.get(field.fieldname):
+ continue
+
+ for value in doc.get(field.fieldname):
+ link_titles.update(get_title_values_for_link_and_dynamic_link_fields(value))
+
+ return link_titles
+
+def send_link_titles(link_titles):
+ """Append link titles dict in `frappe.local.response`."""
+ if "_link_titles" not in frappe.local.response:
+ frappe.local.response["_link_titles"] = {}
+
+ frappe.local.response["_link_titles"].update(link_titles)
+
+def update_user_info(docinfo):
+ for d in docinfo.communications:
+ frappe.utils.add_user_info(d.sender, docinfo.user_info)
+
+ for d in docinfo.shared:
+ frappe.utils.add_user_info(d.user, docinfo.user_info)
+
+ for d in docinfo.assignments:
+ frappe.utils.add_user_info(d.owner, docinfo.user_info)
+
+ for d in docinfo.views:
+ frappe.utils.add_user_info(d.owner, docinfo.user_info)
+
+@frappe.whitelist()
+def get_user_info_for_viewers(users):
+ user_info = {}
+ for user in json.loads(users):
+ frappe.utils.add_user_info(user, user_info)
+
+ return user_info
+
diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py
index c63da93a33..fa6a1f313b 100644
--- a/frappe/desk/form/meta.py
+++ b/frappe/desk/form/meta.py
@@ -1,20 +1,25 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-# metadata
-
-from __future__ import unicode_literals
-import frappe, os
-from frappe.model.meta import Meta
-from frappe.modules import scrub, get_module_path, load_doctype_module
-from frappe.utils import get_html_format
-from frappe.translate import make_dict_from_messages, extract_messages_from_code
-from frappe.model.utils import render_include
-from frappe.build import scrub_html_template
-
+# License: MIT. See LICENSE
import io
+import os
+
+import frappe
+from frappe.build import scrub_html_template
+from frappe.model.meta import Meta
+from frappe.model.utils import render_include
+from frappe.modules import get_module_path, load_doctype_module, scrub
+from frappe.translate import extract_messages_from_code, make_dict_from_messages
+from frappe.utils import get_html_format
+
+
+ASSET_KEYS = (
+ "__js", "__css", "__list_js", "__calendar_js", "__map_js",
+ "__linked_with", "__messages", "__print_formats", "__workflow_docs",
+ "__form_grid_templates", "__listview_template", "__tree_js",
+ "__dashboard", "__kanban_column_fields", '__templates',
+ '__custom_js', '__custom_list_js'
+)
-from six import iteritems
def get_meta(doctype, cached=True):
# don't cache for developer mode as js files, templates may be edited
@@ -38,6 +43,12 @@ class FormMeta(Meta):
super(FormMeta, self).__init__(doctype)
self.load_assets()
+ def set(self, key, value, *args, **kwargs):
+ if key in ASSET_KEYS:
+ self.__dict__[key] = value
+ else:
+ super(FormMeta, self).set(key, value, *args, **kwargs)
+
def load_assets(self):
if self.get('__assets_loaded', False):
return
@@ -59,11 +70,7 @@ class FormMeta(Meta):
def as_dict(self, no_nulls=False):
d = super(FormMeta, self).as_dict(no_nulls=no_nulls)
- for k in ("__js", "__css", "__list_js", "__calendar_js", "__map_js",
- "__linked_with", "__messages", "__print_formats", "__workflow_docs",
- "__form_grid_templates", "__listview_template", "__tree_js",
- "__dashboard", "__kanban_column_fields", '__templates',
- '__custom_js'):
+ for k in ASSET_KEYS:
d[k] = self.get(k)
# d['fields'] = d.get('fields', [])
@@ -109,8 +116,9 @@ class FormMeta(Meta):
def _add_code(self, path, fieldname):
js = get_js(path)
if js:
- self.set(fieldname, (self.get(fieldname) or "")
- + "\n\n/* Adding {0} */\n\n".format(path) + js)
+ comment = f"\n\n/* Adding {path} */\n\n"
+ sourceURL = f"\n\n//# sourceURL={scrub(self.name) + fieldname}"
+ self.set(fieldname, (self.get(fieldname) or "") + comment + js + sourceURL)
def add_html_templates(self, path):
if self.custom:
@@ -130,9 +138,27 @@ class FormMeta(Meta):
def add_custom_script(self):
"""embed all require files"""
# custom script
- custom = frappe.db.get_value("Client Script", {"dt": self.name, "enabled": 1}, "script") or ""
+ client_scripts = frappe.db.get_all("Client Script",
+ filters={"dt": self.name, "enabled": 1},
+ fields=["script", "view"],
+ order_by="creation asc"
+ ) or ""
- self.set("__custom_js", custom)
+ list_script = ''
+ form_script = ''
+ for script in client_scripts:
+ if script.view == 'List':
+ list_script += script.script
+
+ if script.view == 'Form':
+ form_script += script.script
+
+ file = scrub(self.name)
+ form_script += f"\n\n//# sourceURL={file}__custom_js"
+ list_script += f"\n\n//# sourceURL={file}__custom_list_js"
+
+ self.set("__custom_js", form_script)
+ self.set("__custom_list_js", list_script)
def add_search_fields(self):
"""add search fields found in the doctypes indicated by link fields' options"""
@@ -157,7 +183,7 @@ class FormMeta(Meta):
WHERE doc_type=%s AND docstatus<2 and disabled=0""", (self.name,), as_dict=1,
update={"doctype":"Print Format"})
- self.set("__print_formats", print_formats, as_value=True)
+ self.set("__print_formats", print_formats)
def load_workflows(self):
# get active workflow
@@ -171,7 +197,7 @@ class FormMeta(Meta):
for d in workflow.get("states"):
workflow_docs.append(frappe.get_doc("Workflow State", d.state))
- self.set("__workflow_docs", workflow_docs, as_value=True)
+ self.set("__workflow_docs", workflow_docs)
def load_templates(self):
@@ -180,7 +206,7 @@ class FormMeta(Meta):
app = module.__name__.split(".")[0]
templates = {}
if hasattr(module, "form_grid_templates"):
- for key, path in iteritems(module.form_grid_templates):
+ for key, path in module.form_grid_templates.items():
templates[key] = get_html_format(frappe.get_app_path(app, path))
self.set("__form_grid_templates", templates)
@@ -193,7 +219,7 @@ class FormMeta(Meta):
for content in self.get("__form_grid_templates").values():
messages = extract_messages_from_code(content)
messages = make_dict_from_messages(messages)
- self.get("__messages").update(messages, as_value=True)
+ self.get("__messages").update(messages)
def load_dashboard(self):
self.set('__dashboard', self.get_dashboard_data())
@@ -209,7 +235,7 @@ class FormMeta(Meta):
fields = [x['field_name'] for x in values]
fields = list(set(fields))
- self.set("__kanban_column_fields", fields, as_value=True)
+ self.set("__kanban_column_fields", fields)
except frappe.PermissionError:
# no access to kanban board
pass
diff --git a/frappe/desk/form/run_method.py b/frappe/desk/form/run_method.py
deleted file mode 100644
index 7952f3b68d..0000000000
--- a/frappe/desk/form/run_method.py
+++ /dev/null
@@ -1,81 +0,0 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
-import json, inspect
-import frappe
-from frappe import _
-from frappe.utils import cint
-from six import text_type, string_types
-
-@frappe.whitelist()
-def runserverobj(method, docs=None, dt=None, dn=None, arg=None, args=None):
- """run controller method - old style"""
- if not args: args = arg or ""
-
- if dt: # not called from a doctype (from a page)
- if not dn: dn = dt # single
- doc = frappe.get_doc(dt, dn)
-
- else:
- doc = frappe.get_doc(json.loads(docs))
- doc._original_modified = doc.modified
- doc.check_if_latest()
-
- if not doc.has_permission("read"):
- frappe.msgprint(_("Not permitted"), raise_exception = True)
-
- if doc:
- try:
- args = json.loads(args)
- except ValueError:
- args = args
-
- try:
- fnargs, varargs, varkw, defaults = inspect.getargspec(getattr(doc, method))
- except ValueError:
- fnargs = inspect.getfullargspec(getattr(doc, method)).args
- varargs = inspect.getfullargspec(getattr(doc, method)).varargs
- varkw = inspect.getfullargspec(getattr(doc, method)).varkw
- defaults = inspect.getfullargspec(getattr(doc, method)).defaults
-
- if not fnargs or (len(fnargs)==1 and fnargs[0]=="self"):
- r = doc.run_method(method)
-
- elif "args" in fnargs or not isinstance(args, dict):
- r = doc.run_method(method, args)
-
- else:
- r = doc.run_method(method, **args)
-
- if r:
- #build output as csv
- if cint(frappe.form_dict.get('as_csv')):
- make_csv_output(r, doc.doctype)
- else:
- frappe.response['message'] = r
-
- frappe.response.docs.append(doc)
-
-def make_csv_output(res, dt):
- """send method response as downloadable CSV file"""
- import frappe
-
- from six import StringIO
- import csv
-
- f = StringIO()
- writer = csv.writer(f)
- for r in res:
- row = []
- for v in r:
- if isinstance(v, string_types):
- v = v.encode("utf-8")
- row.append(v)
- writer.writerow(row)
-
- f.seek(0)
-
- frappe.response['result'] = text_type(f.read(), 'utf-8')
- frappe.response['type'] = 'csv'
- frappe.response['doctype'] = dt.replace(' ','')
diff --git a/frappe/desk/form/save.py b/frappe/desk/form/save.py
index da43b14fce..b580e2c769 100644
--- a/frappe/desk/form/save.py
+++ b/frappe/desk/form/save.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe, json
from frappe.desk.form.load import run_onload
diff --git a/frappe/desk/form/test_form.py b/frappe/desk/form/test_form.py
index ff0343b6e0..86c3aba29a 100644
--- a/frappe/desk/form/test_form.py
+++ b/frappe/desk/form/test_form.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe, unittest
from frappe.desk.form.linked_with import get_linked_docs, get_linked_doctypes
diff --git a/frappe/desk/form/utils.py b/frappe/desk/form/utils.py
index 395d2b9571..291767de10 100644
--- a/frappe/desk/form/utils.py
+++ b/frappe/desk/form/utils.py
@@ -1,15 +1,13 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe, json
import frappe.desk.form.meta
import frappe.desk.form.load
from frappe.desk.form.document_follow import follow_document
-from frappe.utils.file_manager import extract_images_from_html
+from frappe.core.doctype.file.file import extract_images_from_html
from frappe import _
-from six import string_types
@frappe.whitelist()
def remove_attach():
@@ -18,44 +16,6 @@ def remove_attach():
file_name = frappe.form_dict.get('file_name')
frappe.delete_doc('File', fid)
-@frappe.whitelist()
-def validate_link():
- """validate link when updated by user"""
- import frappe
- import frappe.utils
-
- value, options, fetch = frappe.form_dict.get('value'), frappe.form_dict.get('options'), frappe.form_dict.get('fetch')
-
- # no options, don't validate
- if not options or options=='null' or options=='undefined':
- frappe.response['message'] = 'Ok'
- return
-
- valid_value = frappe.db.get_all(options, filters=dict(name=value), as_list=1, limit=1)
-
- if valid_value:
- valid_value = valid_value[0][0]
-
- # get fetch values
- if fetch:
- # escape with "`"
- fetch = ", ".join(("`{0}`".format(f.strip()) for f in fetch.split(",")))
- fetch_value = None
- try:
- fetch_value = frappe.db.sql("select %s from `tab%s` where name=%s"
- % (fetch, options, '%s'), (value,))[0]
- except Exception as e:
- error_message = str(e).split("Unknown column '")
- fieldname = None if len(error_message)<=1 else error_message[1].split("'")[0]
- frappe.msgprint(_("Wrong fieldname {0} in add_fetch configuration of custom client script").format(fieldname))
- frappe.errprint(frappe.get_traceback())
-
- if fetch_value:
- frappe.response['fetch_values'] = [frappe.utils.parse_val(c) for c in fetch_value]
-
- frappe.response['valid_value'] = valid_value
- frappe.response['message'] = 'Ok'
-
@frappe.whitelist()
def add_comment(reference_doctype, reference_name, content, comment_email, comment_by):
@@ -68,7 +28,8 @@ def add_comment(reference_doctype, reference_name, content, comment_email, comme
comment_type='Comment',
comment_by=comment_by
))
- doc.content = extract_images_from_html(doc, content)
+ reference_doc = frappe.get_doc(reference_doctype, reference_name)
+ doc.content = extract_images_from_html(reference_doc, content, is_private=True)
doc.insert(ignore_permissions=True)
follow_document(doc.reference_doctype, doc.reference_name, frappe.session.user)
@@ -90,7 +51,7 @@ def get_next(doctype, value, prev, filters=None, sort_order='desc', sort_field='
prev = int(prev)
if not filters: filters = []
- if isinstance(filters, string_types):
+ if isinstance(filters, str):
filters = json.loads(filters)
# # condition based on sort order
diff --git a/frappe/desk/gantt.py b/frappe/desk/gantt.py
index 521884beaa..58ef3b836e 100644
--- a/frappe/desk/gantt.py
+++ b/frappe/desk/gantt.py
@@ -1,7 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe, json
diff --git a/frappe/desk/leaderboard.py b/frappe/desk/leaderboard.py
index 8d00ea9bc2..a98ae1a1c6 100644
--- a/frappe/desk/leaderboard.py
+++ b/frappe/desk/leaderboard.py
@@ -1,5 +1,3 @@
-
-from __future__ import unicode_literals, print_function
import frappe
from frappe.utils import get_fullname
@@ -16,8 +14,18 @@ def get_leaderboards():
@frappe.whitelist()
def get_energy_point_leaderboard(date_range, company = None, field = None, limit = None):
+ all_users = frappe.db.get_all('User',
+ filters = {
+ 'name': ['not in', ['Administrator', 'Guest']],
+ 'enabled': 1,
+ 'user_type': ['!=', 'Website User']
+ },
+ order_by = 'name ASC')
+ all_users_list = list(map(lambda x: x['name'], all_users))
+
filters = [
['type', '!=', 'Review'],
+ ['user', 'in', all_users_list]
]
if date_range:
date_range = frappe.parse_json(date_range)
@@ -28,15 +36,7 @@ def get_energy_point_leaderboard(date_range, company = None, field = None, limit
group_by = 'user',
order_by = 'value desc'
)
- all_users = frappe.db.get_all('User',
- filters = {
- 'name': ['not in', ['Administrator', 'Guest']],
- 'enabled': 1,
- 'user_type': ['!=', 'Website User']
- },
- order_by = 'name ASC')
- all_users_list = list(map(lambda x: x['name'], all_users))
energy_point_users_list = list(map(lambda x: x['name'], energy_point_users))
for user in all_users_list:
if user not in energy_point_users_list:
@@ -45,6 +45,6 @@ def get_energy_point_leaderboard(date_range, company = None, field = None, limit
for user in energy_point_users:
user_id = user['name']
user['name'] = get_fullname(user['name'])
- user['formatted_name'] = '{} '.format(user_id, get_fullname(user_id))
+ user['formatted_name'] = '{} '.format(user_id, get_fullname(user_id))
return energy_point_users
\ No newline at end of file
diff --git a/frappe/desk/like.py b/frappe/desk/like.py
index 6d2e9704af..4480ed8a1e 100644
--- a/frappe/desk/like.py
+++ b/frappe/desk/like.py
@@ -1,7 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
"""Allow adding of likes to documents"""
diff --git a/frappe/desk/link_preview.py b/frappe/desk/link_preview.py
index 9b4471aa8d..03f8368a3a 100644
--- a/frappe/desk/link_preview.py
+++ b/frappe/desk/link_preview.py
@@ -40,6 +40,10 @@ def get_preview_data(doctype, docname):
for key, val in preview_data.items():
if val and meta.has_field(key) and key not in [image_field, title_field, 'name']:
- formatted_preview_data[meta.get_field(key).label] = frappe.format(val, meta.get_field(key).fieldtype)
+ formatted_preview_data[meta.get_field(key).label] = frappe.format(
+ val,
+ meta.get_field(key).fieldtype,
+ translated=True,
+ )
return formatted_preview_data
diff --git a/frappe/desk/listview.py b/frappe/desk/listview.py
index 91dc0f3ba9..3d6f1254a2 100644
--- a/frappe/desk/listview.py
+++ b/frappe/desk/listview.py
@@ -1,10 +1,8 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
-@frappe.whitelist(allow_guest=True)
+@frappe.whitelist()
def get_list_settings(doctype):
try:
return frappe.get_cached_doc("List View Settings", doctype)
@@ -28,19 +26,19 @@ def get_group_by_count(doctype, current_filters, field):
current_filters = frappe.parse_json(current_filters)
subquery_condition = ''
- subquery = frappe.get_all(doctype, filters=current_filters, return_query = True)
+ subquery = frappe.get_all(doctype, filters=current_filters, run=False)
if field == 'assigned_to':
subquery_condition = ' and `tabToDo`.reference_name in ({subquery})'.format(subquery = subquery)
- return frappe.db.sql("""select `tabToDo`.owner as name, count(*) as count
+ return frappe.db.sql("""select `tabToDo`.allocated_to as name, count(*) as count
from
`tabToDo`, `tabUser`
where
`tabToDo`.status!='Cancelled' and
- `tabToDo`.owner = `tabUser`.name and
+ `tabToDo`.allocated_to = `tabUser`.name and
`tabUser`.user_type = 'System User'
{subquery_condition}
group by
- `tabToDo`.owner
+ `tabToDo`.allocated_to
order by
count desc
limit 50""".format(subquery_condition = subquery_condition), as_dict=True)
diff --git a/frappe/desk/moduleview.py b/frappe/desk/moduleview.py
index df25b77e2d..7a9c211c3c 100644
--- a/frappe/desk/moduleview.py
+++ b/frappe/desk/moduleview.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
import json
from frappe import _
@@ -525,7 +524,7 @@ def get_last_modified(doctype):
raise
# hack: save as -1 so that it is cached
- if last_modified==None:
+ if last_modified is None:
last_modified = -1
return last_modified
diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py
index 4b584a2429..3fa41790b4 100644
--- a/frappe/desk/notifications.py
+++ b/frappe/desk/notifications.py
@@ -1,11 +1,8 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
from frappe.desk.doctype.notification_settings.notification_settings import get_subscribed_documents
-from six import string_types
import json
@frappe.whitelist()
@@ -149,7 +146,7 @@ def clear_doctype_notifications(doc, method=None, *args, **kwargs):
config = get_notification_config()
if not config:
return
- if isinstance(doc, string_types):
+ if isinstance(doc, str):
doctype = doc # assuming doctype name was passed directly
else:
doctype = doc.doctype
@@ -213,13 +210,13 @@ def get_filters_for(doctype):
'''get open filters for doctype'''
config = get_notification_config()
doctype_config = config.get("for_doctype").get(doctype, {})
- filters = doctype_config if not isinstance(doctype_config, string_types) else None
+ filters = doctype_config if not isinstance(doctype_config, str) else None
return filters
@frappe.whitelist()
@frappe.read_only()
-def get_open_count(doctype, name, items=[]):
+def get_open_count(doctype, name, items=None):
'''Get open count for given transactions and filters
:param doctype: Reference DocType
@@ -238,7 +235,8 @@ def get_open_count(doctype, name, items=[]):
links = meta.get_dashboard_data()
# compile all items in a list
- if not items:
+ if items is None:
+ items = []
for group in links.transactions:
items.extend(group.get("items"))
diff --git a/frappe/desk/page/activity/__init__.py b/frappe/desk/page/activity/__init__.py
index baffc48825..8b13789179 100644
--- a/frappe/desk/page/activity/__init__.py
+++ b/frappe/desk/page/activity/__init__.py
@@ -1 +1 @@
-from __future__ import unicode_literals
+
diff --git a/frappe/desk/page/activity/activity.js b/frappe/desk/page/activity/activity.js
index 39de414122..7b4e8ddc1a 100644
--- a/frappe/desk/page/activity/activity.js
+++ b/frappe/desk/page/activity/activity.js
@@ -67,8 +67,8 @@ frappe.pages['activity'].on_page_show = function () {
}
frappe.activity.last_feed_date = false;
-frappe.activity.Feed = Class.extend({
- init: function (row, data) {
+frappe.activity.Feed = class Feed {
+ constructor(row, data) {
this.scrub_data(data);
this.add_date_separator(row, data);
if (!data.add_class)
@@ -97,8 +97,9 @@ frappe.activity.Feed = Class.extend({
$(row)
.append(frappe.render_template("activity_row", data))
.find("a").addClass("grey");
- },
- scrub_data: function (data) {
+ }
+
+ scrub_data(data) {
data.by = frappe.user.full_name(data.owner);
data.avatar = frappe.avatar(data.owner);
@@ -113,9 +114,9 @@ frappe.activity.Feed = Class.extend({
data.when = comment_when(data.creation);
data.feed_type = data.comment_type || data.communication_medium;
- },
+ }
- add_date_separator: function (row, data) {
+ add_date_separator(row, data) {
var date = frappe.datetime.str_to_obj(data.creation);
var last = frappe.activity.last_feed_date;
@@ -137,7 +138,7 @@ frappe.activity.Feed = Class.extend({
}
frappe.activity.last_feed_date = date;
}
-});
+};
frappe.activity.render_heatmap = function (page) {
$('\
diff --git a/frappe/desk/page/activity/activity.py b/frappe/desk/page/activity/activity.py
index 7de294d2f0..71130f2304 100644
--- a/frappe/desk/page/activity/activity.py
+++ b/frappe/desk/page/activity/activity.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.utils import cint
from frappe.core.doctype.activity_log.feed import get_feed_match_conditions
diff --git a/frappe/desk/page/backups/backups.css b/frappe/desk/page/backups/backups.css
index 13f093e0b1..32ccb88c37 100644
--- a/frappe/desk/page/backups/backups.css
+++ b/frappe/desk/page/backups/backups.css
@@ -5,6 +5,7 @@
.download-backup-card {
display: block;
text-decoration: none;
+ margin-bottom: var(--margin-lg);
}
.download-backup-card:hover {
diff --git a/frappe/desk/page/backups/backups.html b/frappe/desk/page/backups/backups.html
index e63481487c..ff10f1bd06 100644
--- a/frappe/desk/page/backups/backups.html
+++ b/frappe/desk/page/backups/backups.html
@@ -1,20 +1,27 @@
- {% for f in files %}
-
- {% endfor %}
+ {% for f in files %}
+
+ {% endfor %}
\ No newline at end of file
diff --git a/frappe/desk/page/backups/backups.js b/frappe/desk/page/backups/backups.js
index c82407c6bd..d6cab750f0 100644
--- a/frappe/desk/page/backups/backups.js
+++ b/frappe/desk/page/backups/backups.js
@@ -1,7 +1,7 @@
-frappe.pages['backups'].on_page_load = function(wrapper) {
+frappe.pages['backups'].on_page_load = function (wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
- title: 'Download Backups',
+ title: __('Download Backups'),
single_column: true
});
@@ -11,12 +11,35 @@ frappe.pages['backups'].on_page_load = function(wrapper) {
page.add_inner_button(__("Download Files Backup"), function () {
frappe.call({
- method:"frappe.desk.page.backups.backups.schedule_files_backup",
- args: {"user_email": frappe.session.user_email}
+ method: "frappe.desk.page.backups.backups.schedule_files_backup",
+ args: { "user_email": frappe.session.user_email }
});
});
+ page.add_inner_button(__("Get Backup Encryption Key"), function () {
+ if (frappe.user.has_role("System Manager")) {
+ frappe.verify_password(function () {
+ frappe.call({
+ method: "frappe.utils.backups.get_backup_encryption_key",
+ callback: function (r) {
+ frappe.msgprint({
+ title: __('Backup Encryption Key'),
+ message: __(r.message),
+ indicator: 'blue'
+ });
+ }
+ });
+ });
+ } else {
+ frappe.msgprint({
+ title: __('Error'),
+ message: __('System Manager privileges required.'),
+ indicator: 'red'
+ });
+ }
+ });
+
frappe.breadcrumbs.add("Setup");
$(frappe.render_template("backups")).appendTo(page.body.addClass("no-border"));
-}
+};
diff --git a/frappe/desk/page/backups/backups.py b/frappe/desk/page/backups/backups.py
index eaa0c65143..14ed025e08 100644
--- a/frappe/desk/page/backups/backups.py
+++ b/frappe/desk/page/backups/backups.py
@@ -1,4 +1,4 @@
-from __future__ import unicode_literals
+
import os
import frappe
from frappe import _
@@ -11,6 +11,10 @@ def get_context(context):
dt = os.path.getmtime(path)
return convert_utc_to_user_timezone(datetime.datetime.utcfromtimestamp(dt)).strftime('%a %b %d %H:%M %Y')
+ def get_encrytion_status(path):
+ if "-enc" in path:
+ return True
+
def get_size(path):
size = os.path.getsize(path)
if size > 1048576:
@@ -26,8 +30,9 @@ def get_context(context):
cleanup_old_backups(path, files, backup_limit)
files = [('/backups/' + _file,
- get_time(os.path.join(path, _file)),
- get_size(os.path.join(path, _file))) for _file in files if _file.endswith('sql.gz')]
+ get_time(os.path.join(path, _file)),
+ get_encrytion_status(os.path.join(path, _file)),
+ get_size(os.path.join(path, _file))) for _file in files if _file.endswith('sql.gz')]
files.sort(key=lambda x: x[1], reverse=True)
return {"files": files[:backup_limit]}
diff --git a/frappe/desk/page/leaderboard/leaderboard.js b/frappe/desk/page/leaderboard/leaderboard.js
index b3fccf84f9..aa1678af37 100644
--- a/frappe/desk/page/leaderboard/leaderboard.js
+++ b/frappe/desk/page/leaderboard/leaderboard.js
@@ -141,7 +141,7 @@ class Leaderboard {
}
create_date_range_field() {
- let timespan_field = $(this.parent).find(`.frappe-control[data-original-title=${__('Timespan')}]`);
+ let timespan_field = $(this.parent).find(`.frappe-control[data-original-title="${__('Timespan')}"]`);
this.date_range_field = $(`
`).insertAfter(timespan_field).hide();
let date_field = frappe.ui.form.make_control({
@@ -382,10 +382,10 @@ class Leaderboard {
let timespan = this.options.selected_timespan.toLowerCase();
let current_date = frappe.datetime.now_date();
let date_range_map = {
- "this week": [frappe.datetime.week_start(), current_date],
- "this month": [frappe.datetime.month_start(), current_date],
- "this quarter": [frappe.datetime.quarter_start(), current_date],
- "this year": [frappe.datetime.year_start(), current_date],
+ "this week": [frappe.datetime.week_start(), frappe.datetime.week_end()],
+ "this month": [frappe.datetime.month_start(), frappe.datetime.month_end()],
+ "this quarter": [frappe.datetime.quarter_start(), frappe.datetime.quarter_end()],
+ "this year": [frappe.datetime.year_start(), frappe.datetime.year_end()],
"last week": [frappe.datetime.add_days(current_date, -7), current_date],
"last month": [frappe.datetime.add_months(current_date, -1), current_date],
"last quarter": [frappe.datetime.add_months(current_date, -3), current_date],
diff --git a/frappe/desk/page/leaderboard/leaderboard.py b/frappe/desk/page/leaderboard/leaderboard.py
index 819e7fe9d1..ad22eb9199 100644
--- a/frappe/desk/page/leaderboard/leaderboard.py
+++ b/frappe/desk/page/leaderboard/leaderboard.py
@@ -1,7 +1,5 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals, print_function
+# License: MIT. See LICENSE
import frappe
@frappe.whitelist()
diff --git a/frappe/desk/page/setup_wizard/install_fixtures.py b/frappe/desk/page/setup_wizard/install_fixtures.py
index 6d3aaee22b..1ef83f7ba0 100644
--- a/frappe/desk/page/setup_wizard/install_fixtures.py
+++ b/frappe/desk/page/setup_wizard/install_fixtures.py
@@ -1,7 +1,5 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
-
-from __future__ import unicode_literals
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
import frappe
from frappe import _
diff --git a/frappe/desk/page/setup_wizard/setup_wizard.js b/frappe/desk/page/setup_wizard/setup_wizard.js
index f44a57e339..cc91a16345 100644
--- a/frappe/desk/page/setup_wizard/setup_wizard.js
+++ b/frappe/desk/page/setup_wizard/setup_wizard.js
@@ -46,14 +46,6 @@ frappe.pages['setup-wizard'].on_page_load = function (wrapper) {
slide_class: frappe.setup.SetupWizardSlide,
unidirectional: 1,
done_state: 1,
- before_load: ($footer) => {
- $footer.find('.next-btn').removeClass('btn-default')
- .addClass('btn-primary');
- $footer.find('.text-right').prepend(
- $(`
- ${__("Complete Setup")} `));
-
- }
}
frappe.wizard = new frappe.setup.SetupWizard(wizard_settings);
frappe.setup.run_event("after_load");
@@ -97,7 +89,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides {
super.make();
this.container.addClass("container setup-wizard-slide with-form");
this.$next_btn.addClass('action');
- this.$complete_btn = this.$footer.find('.complete-btn').addClass('action');
+ this.$complete_btn.addClass('action');
this.setup_keyboard_nav();
}
@@ -145,7 +137,6 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides {
this.$next_btn.removeClass("btn-primary").hide();
this.$complete_btn.addClass("btn-primary").show()
.on('click', () => this.action_on_complete());
-
} else {
this.$next_btn.addClass("btn-primary").show();
this.$complete_btn.removeClass("btn-primary").hide();
@@ -178,6 +169,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides {
this.setup();
this.show_slide(this.current_id);
+ this.refresh(this.current_id);
setTimeout(() => {
this.container.find('.form-control').first().focus();
}, 200);
@@ -197,6 +189,8 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides {
callback: (r) => {
if (r.message.status === 'ok') {
this.post_setup_success();
+ } else if (r.message.status === 'registered') {
+ this.update_setup_message(__("starting the setup..."));
} else if (r.message.fail !== undefined) {
this.abort_setup(r.message.fail);
}
@@ -238,6 +232,9 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides {
if (data.fail_msg) {
this.abort_setup(data.fail_msg);
}
+ if (data.status === 'ok') {
+ this.post_setup_success();
+ }
})
}
@@ -342,7 +339,6 @@ frappe.setup.SetupWizardSlide = class SetupWizardSlide extends frappe.ui.Slide {
// Frappe slides settings
// ======================================================
-
frappe.setup.slides_settings = [
{
// Welcome (language) slide
@@ -360,10 +356,10 @@ frappe.setup.slides_settings = [
onload: function (slide) {
this.setup_fields(slide);
+ let browser_language = frappe.setup.utils.get_language_name_from_code(navigator.language);
+ let language_field = slide.get_field("language");
- var language_field = slide.get_field("language");
-
- language_field.set_input(frappe.setup.data.default_language || "English");
+ language_field.set_input(browser_language || "English");
if (!frappe.setup._from_load_messages) {
language_field.$input.trigger("change");
@@ -387,17 +383,24 @@ frappe.setup.slides_settings = [
fields: [
{
fieldname: "country", label: __("Your Country"), reqd: 1,
- fieldtype: "Select"
+ fieldtype: "Autocomplete",
+ placeholder: __('Select Country')
},
{ fieldtype: "Section Break" },
{
- fieldname: "timezone", label: __("Time Zone"), reqd: 1,
- fieldtype: "Select"
+ fieldname: "timezone",
+ label: __("Time Zone"),
+ placeholder: __('Select Time Zone'),
+ reqd: 1,
+ fieldtype: "Select",
},
{ fieldtype: "Column Break" },
{
- fieldname: "currency", label: __("Currency"), reqd: 1,
- fieldtype: "Select"
+ fieldname: "currency",
+ label: __("Currency"),
+ placeholder: __('Select Currency'),
+ reqd: 1,
+ fieldtype: "Select",
}
],
@@ -507,7 +510,7 @@ frappe.setup.utils = {
frappe.setup.data.email = r.message.email;
callback(slide);
}
- })
+ });
},
setup_language_field: function (slide) {
@@ -520,20 +523,29 @@ frappe.setup.utils = {
/*
Set a slide's country, timezone and currency fields
*/
- var data = frappe.setup.data.regional_data;
+ let data = frappe.setup.data.regional_data;
+ let country_field = slide.get_field('country');
+ let translated_countries = [];
- var country_field = slide.get_field('country');
+ Object.keys(data.country_info).sort().forEach(country => {
+ translated_countries.push({
+ label: __(country),
+ value: country
+ });
+ });
- slide.get_input("country").empty()
- .add_options([""].concat(Object.keys(data.country_info).sort()));
+ country_field.set_data(translated_countries);
- slide.get_input("currency").empty()
- .add_options(frappe.utils.unique([""].concat(
- $.map(data.country_info, opts => opts.currency)
- )).sort());
+ slide.get_input("currency")
+ .empty()
+ .add_options(
+ frappe.utils.unique(
+ $.map(data.country_info, opts => opts.currency).sort()
+ )
+ );
slide.get_input("timezone").empty()
- .add_options([""].concat(data.all_timezones));
+ .add_options(data.all_timezones);
// set values if present
if (frappe.wizard.values.country) {
@@ -542,13 +554,9 @@ frappe.setup.utils = {
country_field.set_input(data.default_country);
}
- if (frappe.wizard.values.currency) {
- slide.get_field("currency").set_input(frappe.wizard.values.currency);
- }
+ slide.get_field("currency").set_input(frappe.wizard.values.currency);
- if (frappe.wizard.values.timezone) {
- slide.get_field("timezone").set_input(frappe.wizard.values.timezone);
- }
+ slide.get_field("timezone").set_input(frappe.wizard.values.timezone);
},
@@ -573,6 +581,10 @@ frappe.setup.utils = {
});
},
+ get_language_name_from_code: function (language_code) {
+ return frappe.setup.data.lang.codes_to_names[language_code] || "English";
+ },
+
bind_region_events: function (slide) {
/*
Bind a slide's country, timezone and currency fields
@@ -584,17 +596,15 @@ frappe.setup.utils = {
$timezone.empty();
+ if (!country) return;
// add country specific timezones first
- if (country) {
- var timezone_list = data.country_info[country].timezones || [];
- $timezone.add_options(timezone_list.sort());
- slide.get_field("currency").set_input(data.country_info[country].currency);
- slide.get_field("currency").$input.trigger("change");
- }
+ const timezone_list = data.country_info[country].timezones || [];
+ $timezone.add_options(timezone_list.sort());
+ slide.get_field("currency").set_input(data.country_info[country].currency);
+ slide.get_field("currency").$input.trigger("change");
// add all timezones at the end, so that user has the option to change it to any timezone
- $timezone.add_options([""].concat(data.all_timezones));
-
+ $timezone.add_options(data.all_timezones);
slide.get_field("timezone").set_input($timezone.val());
// temporarily set date format
@@ -612,7 +622,7 @@ frappe.setup.utils = {
if (number_format === "#.###") {
number_format = "#.###,##";
} else if (number_format === "#,###") {
- number_format = "#,###.##"
+ number_format = "#,###.##";
}
frappe.boot.sysdefaults.number_format = number_format;
diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py
index c38cf47626..0c32e886f4 100755
--- a/frappe/desk/page/setup_wizard/setup_wizard.py
+++ b/frappe/desk/page/setup_wizard/setup_wizard.py
@@ -1,16 +1,12 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: See license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe, json, os
from frappe.utils import strip, cint
from frappe.translate import (set_default_language, get_dict, send_translations)
from frappe.geo.country_info import get_country_info
from frappe.utils.password import update_password
-from werkzeug.useragents import UserAgent
from . import install_fixtures
-from six import string_types
def get_setup_stages(args):
@@ -57,9 +53,17 @@ def setup_complete(args):
return {'status': 'ok'}
args = parse_args(args)
-
stages = get_setup_stages(args)
+ is_background_task = frappe.conf.get('trigger_site_setup_in_background')
+ if is_background_task:
+ process_setup_stages.enqueue(stages=stages, user_input=args, is_background_task=True)
+ return {'status': 'registered'}
+ else:
+ return process_setup_stages(stages, args)
+
+@frappe.task()
+def process_setup_stages(stages, user_input, is_background_task=False):
try:
frappe.flags.in_setup_wizard = True
current_task = None
@@ -71,11 +75,16 @@ def setup_complete(args):
current_task = task
task.get('fn')(task.get('args'))
except Exception:
- handle_setup_exception(args)
- return {'status': 'fail', 'fail': current_task.get('fail_msg')}
+ handle_setup_exception(user_input)
+ if not is_background_task:
+ return {'status': 'fail', 'fail': current_task.get('fail_msg')}
+ frappe.publish_realtime('setup_task',
+ {'status': 'fail', "fail_msg": current_task.get('fail_msg')}, user=frappe.session.user)
else:
- run_setup_success(args)
- return {'status': 'ok'}
+ run_setup_success(user_input)
+ if not is_background_task:
+ return {'status': 'ok'}
+ frappe.publish_realtime('setup_task', {"status": 'ok'}, user=frappe.session.user)
finally:
frappe.flags.in_setup_wizard = False
@@ -124,6 +133,7 @@ def handle_setup_exception(args):
frappe.db.rollback()
if args:
traceback = frappe.get_traceback()
+ print(traceback)
for hook in frappe.get_hooks("setup_wizard_exception"):
frappe.get_attr(hook)(traceback, args)
@@ -140,7 +150,7 @@ def update_system_settings(args):
system_settings = frappe.get_doc("System Settings", "System Settings")
system_settings.update({
"country": args.get("country"),
- "language": get_language_code(args.get("language")),
+ "language": get_language_code(args.get("language")) or 'en',
"time_zone": args.get("timezone"),
"float_precision": 3,
'date_format': frappe.db.get_value("Country", args.get("country"), "date_format"),
@@ -207,14 +217,14 @@ def update_user_name(args):
def parse_args(args):
if not args:
args = frappe.local.form_dict
- if isinstance(args, string_types):
+ if isinstance(args, str):
args = json.loads(args)
args = frappe._dict(args)
# strip the whitespace
for key, value in args.items():
- if isinstance(value, string_types):
+ if isinstance(value, str):
args[key] = strip(value)
return args
@@ -293,7 +303,7 @@ def reset_is_first_startup():
def prettify_args(args):
# remove attachments
for key, val in args.items():
- if isinstance(val, string_types) and "data:image" in val:
+ if isinstance(val, str) and "data:image" in val:
filename = val.split("data:image", 1)[0].strip(", ")
size = round((len(val) * 3 / 4) / 1048576.0, 2)
args[key] = "Image Attached: '{0}' of size {1} MB".format(filename, size)
@@ -304,17 +314,10 @@ def prettify_args(args):
return pretty_args
def email_setup_wizard_exception(traceback, args):
- if not frappe.local.conf.setup_wizard_exception_email:
+ if not frappe.conf.setup_wizard_exception_email:
return
pretty_args = prettify_args(args)
-
- if frappe.local.request:
- user_agent = UserAgent(frappe.local.request.headers.get('User-Agent', ''))
-
- else:
- user_agent = frappe._dict()
-
message = """
#### Traceback
@@ -338,18 +341,15 @@ def email_setup_wizard_exception(traceback, args):
#### Basic Information
- **Site:** {site}
-- **User:** {user}
-- **Browser:** {user_agent.platform} {user_agent.browser} version: {user_agent.version} language: {user_agent.language}
-- **Browser Languages**: `{accept_languages}`""".format(
+- **User:** {user}""".format(
site=frappe.local.site,
traceback=traceback,
args="\n".join(pretty_args),
user=frappe.session.user,
- user_agent=user_agent,
- headers=frappe.local.request.headers,
- accept_languages=", ".join(frappe.local.request.accept_languages.values()))
+ headers=frappe.request.headers,
+ )
- frappe.sendmail(recipients=frappe.local.conf.setup_wizard_exception_email,
+ frappe.sendmail(recipients=frappe.conf.setup_wizard_exception_email,
sender=frappe.session.user,
subject="Setup failed: {}".format(frappe.local.site),
message=message,
@@ -377,7 +377,6 @@ def make_records(records, debug=False):
# LOG every success and failure
for record in records:
-
doctype = record.get("doctype")
condition = record.get('__condition')
@@ -394,6 +393,7 @@ def make_records(records, debug=False):
try:
doc.insert(ignore_permissions=True)
+ frappe.db.commit()
except frappe.DuplicateEntryError as e:
# print("Failed to insert duplicate {0} {1}".format(doctype, doc.name))
@@ -406,6 +406,7 @@ def make_records(records, debug=False):
raise
except Exception as e:
+ frappe.db.rollback()
exception = record.get('__exception')
if exception:
config = _dict(exception)
diff --git a/frappe/desk/page/translation_tool/translation_tool.js b/frappe/desk/page/translation_tool/translation_tool.js
index b3f0c032e3..13f68e647a 100644
--- a/frappe/desk/page/translation_tool/translation_tool.js
+++ b/frappe/desk/page/translation_tool/translation_tool.js
@@ -1,7 +1,7 @@
frappe.pages['translation-tool'].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
- title: 'Translation Tool',
+ title: __('Translation Tool'),
single_column: true,
card_layout: true,
});
diff --git a/frappe/desk/page/user_profile/user_profile.html b/frappe/desk/page/user_profile/user_profile.html
index 911ccc702d..f134441b74 100644
--- a/frappe/desk/page/user_profile/user_profile.html
+++ b/frappe/desk/page/user_profile/user_profile.html
@@ -8,7 +8,7 @@
@@ -19,7 +19,7 @@
@@ -30,7 +30,7 @@
@@ -41,4 +41,4 @@
-
\ No newline at end of file
+
diff --git a/frappe/desk/page/user_profile/user_profile.js b/frappe/desk/page/user_profile/user_profile.js
index 3443a33942..5890975e69 100644
--- a/frappe/desk/page/user_profile/user_profile.js
+++ b/frappe/desk/page/user_profile/user_profile.js
@@ -1,6 +1,6 @@
frappe.pages['user-profile'].on_page_load = function (wrapper) {
- frappe.require('assets/js/user_profile_controller.min.js', () => {
+ frappe.require('user_profile_controller.bundle.js', () => {
let user_profile = new frappe.ui.UserProfile(wrapper);
user_profile.show();
});
-};
\ No newline at end of file
+};
diff --git a/frappe/desk/page/user_profile/user_profile.py b/frappe/desk/page/user_profile/user_profile.py
index 73df6d78cb..0d91fd0d91 100644
--- a/frappe/desk/page/user_profile/user_profile.py
+++ b/frappe/desk/page/user_profile/user_profile.py
@@ -30,7 +30,7 @@ def get_energy_points_percentage_chart_data(user, field):
as_list = True)
return {
- "labels": [r[0] for r in result if r[0] != None],
+ "labels": [r[0] for r in result if r[0] is not None],
"datasets": [{
"values": [r[1] for r in result]
}]
diff --git a/frappe/desk/page/user_profile/user_profile_controller.js b/frappe/desk/page/user_profile/user_profile_controller.js
index c1a89f316e..40b542d5c3 100644
--- a/frappe/desk/page/user_profile/user_profile_controller.js
+++ b/frappe/desk/page/user_profile/user_profile_controller.js
@@ -17,21 +17,15 @@ class UserProfile {
show() {
let route = frappe.get_route();
this.user_id = route[1] || frappe.session.user;
-
- //validate if user
- if (route.length > 1) {
- frappe.dom.freeze(__('Loading user profile') + '...');
- frappe.db.exists('User', this.user_id).then(exists => {
- frappe.dom.unfreeze();
- if (exists) {
- this.make_user_profile();
- } else {
- frappe.msgprint(__('User does not exist'));
- }
- });
- } else {
- frappe.set_route('user-profile', frappe.session.user);
- }
+ frappe.dom.freeze(__('Loading user profile') + '...');
+ frappe.db.exists('User', this.user_id).then(exists => {
+ frappe.dom.unfreeze();
+ if (exists) {
+ this.make_user_profile();
+ } else {
+ frappe.msgprint(__('User does not exist'));
+ }
+ });
}
make_user_profile() {
@@ -74,8 +68,7 @@ class UserProfile {
primary_action_label: __('Go'),
primary_action: ({ user }) => {
dialog.hide();
- this.user_id = user;
- this.make_user_profile();
+ frappe.set_route('user-profile', user);
}
});
dialog.show();
diff --git a/frappe/desk/page/user_profile/user_profile_sidebar.html b/frappe/desk/page/user_profile/user_profile_sidebar.html
index 4a35c6cf9c..9f8889fd03 100644
--- a/frappe/desk/page/user_profile/user_profile_sidebar.html
+++ b/frappe/desk/page/user_profile/user_profile_sidebar.html
@@ -51,10 +51,10 @@
{%=__("Edit Profile") %}
{%=__("User Settings") %}
- {%=__("Leaderboard") %}
-
\ No newline at end of file
+
diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py
index 3008cf0e61..f5f50b14fe 100644
--- a/frappe/desk/query_report.py
+++ b/frappe/desk/query_report.py
@@ -1,7 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import frappe
import os
@@ -22,7 +20,6 @@ from frappe.model.utils import render_include
from frappe.translate import send_translations
import frappe.desk.reportview
from frappe.permissions import get_role_permissions
-from six import string_types, iteritems
from datetime import timedelta
from frappe.core.utils import ljust_list
@@ -36,7 +33,10 @@ def get_report_doc(report_name):
reference_report = custom_report_doc.reference_report
doc = frappe.get_doc("Report", reference_report)
doc.custom_report = report_name
- doc.custom_columns = custom_report_doc.json
+ if custom_report_doc.json:
+ data = json.loads(custom_report_doc.json)
+ if data:
+ doc.custom_columns = data["columns"]
doc.is_custom_report = True
if not doc.is_permitted():
@@ -59,21 +59,29 @@ def get_report_doc(report_name):
return doc
-def generate_report_result(report, filters=None, user=None, custom_columns=None):
- user = user or frappe.session.user
- filters = filters or []
-
- if filters and isinstance(filters, string_types):
- filters = json.loads(filters)
-
- res = []
-
+def get_report_result(report, filters):
if report.report_type == "Query Report":
res = report.execute_query_report(filters)
elif report.report_type == "Script Report":
res = report.execute_script_report(filters)
+ elif report.report_type == "Custom Report":
+ ref_report = get_report_doc(report.report_name)
+ res = get_report_result(ref_report, filters)
+
+ return res
+
+@frappe.read_only()
+def generate_report_result(report, filters=None, user=None, custom_columns=None, is_tree=False, parent_field=None):
+ user = user or frappe.session.user
+ filters = filters or []
+
+ if filters and isinstance(filters, str):
+ filters = json.loads(filters)
+
+ res = get_report_result(report, filters) or []
+
columns, result, message, chart, report_summary, skip_total_row = ljust_list(res, 6)
columns = [get_column_as_dict(col) for col in columns]
report_column_names = [col["fieldname"] for col in columns]
@@ -83,7 +91,7 @@ def generate_report_result(report, filters=None, user=None, custom_columns=None)
if report.custom_columns:
# saved columns (with custom columns / with different column order)
- columns = json.loads(report.custom_columns)
+ columns = report.custom_columns
# unsaved custom_columns
if custom_columns:
@@ -100,7 +108,7 @@ def generate_report_result(report, filters=None, user=None, custom_columns=None)
result = get_filtered_data(report.ref_doctype, columns, result, user)
if cint(report.add_total_row) and result and not skip_total_row:
- result = add_total_row(result, columns)
+ result = add_total_row(result, columns, is_tree=is_tree, parent_field=parent_field)
return {
"result": result,
@@ -164,20 +172,26 @@ def get_script(report_name):
module = report.module or frappe.db.get_value(
"DocType", report.ref_doctype, "module"
)
- module_path = get_module_path(module)
- report_folder = os.path.join(module_path, "report", scrub(report.name))
- script_path = os.path.join(report_folder, scrub(report.name) + ".js")
- print_path = os.path.join(report_folder, scrub(report.name) + ".html")
+
+ is_custom_module = frappe.get_cached_value("Module Def", module, "custom")
+
+ # custom modules are virtual modules those exists in DB but not in disk.
+ module_path = '' if is_custom_module else get_module_path(module)
+ report_folder = module_path and os.path.join(module_path, "report", scrub(report.name))
+ script_path = report_folder and os.path.join(report_folder, scrub(report.name) + ".js")
+ print_path = report_folder and os.path.join(report_folder, scrub(report.name) + ".html")
script = None
if os.path.exists(script_path):
with open(script_path, "r") as f:
script = f.read()
+ script += f"\n\n//# sourceURL={scrub(report.name)}.js"
html_format = get_html_format(print_path)
if not script and report.javascript:
script = report.javascript
+ script += f"\n\n//# sourceURL={scrub(report.name)}__custom"
if not script:
script = "frappe.query_reports['%s']={}" % report_name
@@ -196,7 +210,7 @@ def get_script(report_name):
@frappe.whitelist()
@frappe.read_only()
-def run(report_name, filters=None, user=None, ignore_prepared_report=False, custom_columns=None):
+def run(report_name, filters=None, user=None, ignore_prepared_report=False, custom_columns=None, is_tree=False, parent_field=None):
report = get_report_doc(report_name)
if not user:
user = frappe.session.user
@@ -215,7 +229,7 @@ def run(report_name, filters=None, user=None, ignore_prepared_report=False, cust
and not custom_columns
):
if filters:
- if isinstance(filters, string_types):
+ if isinstance(filters, str):
filters = json.loads(filters)
dn = filters.get("prepared_report_name")
@@ -224,7 +238,7 @@ def run(report_name, filters=None, user=None, ignore_prepared_report=False, cust
dn = ""
result = get_prepared_report_result(report, filters, dn, user)
else:
- result = generate_report_result(report, filters, user, custom_columns)
+ result = generate_report_result(report, filters, user, custom_columns, is_tree, parent_field)
result["add_total_row"] = report.add_total_row and not result.get(
"skip_total_row", False
@@ -310,7 +324,7 @@ def export_query():
data.pop("cmd", None)
data.pop("csrf_token", None)
- if isinstance(data.get("filters"), string_types):
+ if isinstance(data.get("filters"), str):
filters = json.loads(data["filters"])
if data.get("report_name"):
@@ -325,7 +339,7 @@ def export_query():
include_indentation = data.get("include_indentation")
visible_idx = data.get("visible_idx")
- if isinstance(visible_idx, string_types):
+ if isinstance(visible_idx, str):
visible_idx = json.loads(visible_idx)
if file_format_type == "Excel":
@@ -338,14 +352,10 @@ def export_query():
)
return
- columns = get_columns_dict(data.columns)
-
from frappe.utils.xlsxutils import make_xlsx
- data["result"] = handle_duration_fieldtype_values(
- data.get("result"), data.get("columns")
- )
- xlsx_data, column_widths = build_xlsx_data(columns, data, visible_idx, include_indentation)
+ format_duration_fields(data)
+ xlsx_data, column_widths = build_xlsx_data(data, visible_idx, include_indentation)
xlsx_file = make_xlsx(xlsx_data, "Query Report", column_widths=column_widths)
frappe.response["filename"] = report_name + ".xlsx"
@@ -353,39 +363,25 @@ def export_query():
frappe.response["type"] = "binary"
-def handle_duration_fieldtype_values(result, columns):
- for i, col in enumerate(columns):
- fieldtype = None
- if isinstance(col, string_types):
- col = col.split(":")
- if len(col) > 1:
- if col[1]:
- fieldtype = col[1]
- if "/" in fieldtype:
- fieldtype, options = fieldtype.split("/")
- else:
- fieldtype = "Data"
- else:
- fieldtype = col.get("fieldtype")
+def format_duration_fields(data: frappe._dict) -> None:
+ for i, col in enumerate(data.columns):
+ if col.get("fieldtype") != "Duration":
+ continue
- if fieldtype == "Duration":
- for entry in range(0, len(result)):
- val_in_seconds = result[entry][i]
- if val_in_seconds:
- duration_val = format_duration(val_in_seconds)
- result[entry][i] = duration_val
-
- return result
+ for row in data.result:
+ index = col.fieldname if isinstance(row, dict) else i
+ if row[index]:
+ row[index] = format_duration(row[index])
-def build_xlsx_data(columns, data, visible_idx, include_indentation):
+def build_xlsx_data(data, visible_idx, include_indentation, ignore_visible_idx=False):
result = [[]]
column_widths = []
for column in data.columns:
if column.get("hidden"):
continue
- result[0].append(column["label"])
+ result[0].append(_(column.get("label")))
column_width = cint(column.get('width', 0))
# to convert into scale accepted by openpyxl
column_width /= 10
@@ -394,7 +390,7 @@ def build_xlsx_data(columns, data, visible_idx, include_indentation):
# build table from result
for row_idx, row in enumerate(data.result):
# only pick up rows that are visible in the report
- if row_idx in visible_idx:
+ if ignore_visible_idx or row_idx in visible_idx:
row_data = []
if isinstance(row, dict):
for col_idx, column in enumerate(data.columns):
@@ -414,12 +410,13 @@ def build_xlsx_data(columns, data, visible_idx, include_indentation):
return result, column_widths
-def add_total_row(result, columns, meta=None):
+def add_total_row(result, columns, meta=None, is_tree=False, parent_field=None):
total_row = [""] * len(columns)
has_percent = []
+
for i, col in enumerate(columns):
fieldtype, options, fieldname = None, None, None
- if isinstance(col, string_types):
+ if isinstance(col, str):
if meta:
# get fieldtype from the meta
field = meta.get_field(col)
@@ -443,12 +440,12 @@ def add_total_row(result, columns, meta=None):
for row in result:
if i >= len(row):
continue
-
cell = row.get(fieldname) if isinstance(row, dict) else row[i]
if fieldtype in ["Currency", "Int", "Float", "Percent", "Duration"] and flt(
cell
):
- total_row[i] = flt(total_row[i]) + flt(cell)
+ if not (is_tree and row.get(parent_field)):
+ total_row[i] = flt(total_row[i]) + flt(cell)
if fieldtype == "Percent" and i not in has_percent:
has_percent.append(i)
@@ -469,7 +466,7 @@ def add_total_row(result, columns, meta=None):
total_row[i] = flt(total_row[i]) / len(result)
first_col_fieldtype = None
- if isinstance(columns[0], string_types):
+ if isinstance(columns[0], str):
first_col = columns[0].split(":")
if len(first_col) > 1:
first_col_fieldtype = first_col[1].split("/")[0]
@@ -520,9 +517,12 @@ def save_report(reference_report, report_name, columns):
"report_type": "Custom Report",
},
)
+
if docname:
report = frappe.get_doc("Report", docname)
- report.update({"json": columns})
+ existing_jd = json.loads(report.json)
+ existing_jd["columns"] = json.loads(columns)
+ report.update({"json": json.dumps(existing_jd, separators=(',', ':'))})
report.save()
frappe.msgprint(_("Report updated successfully"))
@@ -532,7 +532,7 @@ def save_report(reference_report, report_name, columns):
{
"doctype": "Report",
"report_name": report_name,
- "json": columns,
+ "json": f'{{"columns":{columns}}}',
"ref_doctype": report_doc.ref_doctype,
"is_standard": "No",
"report_type": "Custom Report",
@@ -684,7 +684,7 @@ def get_linked_doctypes(columns, data):
if val and col not in columns_with_value:
columns_with_value.append(col)
- items = list(iteritems(linked_doctypes))
+ items = list(linked_doctypes.items())
for doctype, key in items:
if key not in columns_with_value:
@@ -711,7 +711,7 @@ def get_column_as_dict(col):
col_dict = frappe._dict()
# string
- if isinstance(col, string_types):
+ if isinstance(col, str):
col = col.split(":")
if len(col) > 1:
if "/" in col[1]:
diff --git a/frappe/desk/report/todo/todo.py b/frappe/desk/report/todo/todo.py
index f4fe2dc805..b1e49bc95d 100644
--- a/frappe/desk/report/todo/todo.py
+++ b/frappe/desk/report/todo/todo.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.utils import getdate
diff --git a/frappe/desk/report_dump.py b/frappe/desk/report_dump.py
index 86b1765814..f57ed97fa5 100644
--- a/frappe/desk/report_dump.py
+++ b/frappe/desk/report_dump.py
@@ -1,8 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
+
-from __future__ import unicode_literals
-from six.moves import range
import frappe
import json
import copy
diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py
index 3003385601..1ec8ede62e 100644
--- a/frappe/desk/reportview.py
+++ b/frappe/desk/reportview.py
@@ -1,25 +1,57 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
"""build query for doclistview and return results"""
import frappe, json
-from six.moves import range
import frappe.permissions
from frappe.model.db_query import DatabaseQuery
+from frappe.model import default_fields, optional_fields, child_table_fields
from frappe import _
-from six import string_types, StringIO
+from io import StringIO
from frappe.core.doctype.access_log.access_log import make_access_log
from frappe.utils import cstr, format_duration
+from frappe.model.base_document import get_controller
+from frappe.utils import add_user_info
-
-@frappe.whitelist(allow_guest=True)
+@frappe.whitelist()
@frappe.read_only()
def get():
args = get_form_params()
+ # If virtual doctype get data from controller het_list method
+ if is_virtual_doctype(args.doctype):
+ controller = get_controller(args.doctype)
+ data = compress(controller(args.doctype).get_list(args))
+ else:
+ data = compress(execute(**args), args=args)
+ return data
- data = compress(execute(**args), args = args)
+@frappe.whitelist()
+@frappe.read_only()
+def get_list():
+ args = get_form_params()
+
+ if is_virtual_doctype(args.doctype):
+ controller = get_controller(args.doctype)
+ data = controller(args.doctype).get_list(args)
+ else:
+ # uncompressed (refactored from frappe.model.db_query.get_list)
+ data = execute(**args)
+
+ return data
+
+@frappe.whitelist()
+@frappe.read_only()
+def get_count():
+ args = get_form_params()
+
+ if is_virtual_doctype(args.doctype):
+ controller = get_controller(args.doctype)
+ data = controller(args.doctype).get_count(args)
+ else:
+ distinct = 'distinct ' if args.distinct=='true' else ''
+ args.fields = [f"count({distinct}`tab{args.doctype}`.name) as total_count"]
+ data = execute(**args)[0].get('total_count')
return data
@@ -29,70 +61,166 @@ def execute(doctype, *args, **kwargs):
def get_form_params():
"""Stringify GET request parameters."""
data = frappe._dict(frappe.local.form_dict)
+ clean_params(data)
+ validate_args(data)
+ return data
- is_report = data.get('view') == 'Report'
+def validate_args(data):
+ parse_json(data)
+ setup_group_by(data)
- data.pop('cmd', None)
- data.pop('data', None)
- data.pop('ignore_permissions', None)
- data.pop('view', None)
- data.pop('user', None)
+ validate_fields(data)
+ if data.filters:
+ validate_filters(data, data.filters)
+ if data.or_filters:
+ validate_filters(data, data.or_filters)
- if "csrf_token" in data:
- del data["csrf_token"]
-
- if isinstance(data.get("filters"), string_types):
- data["filters"] = json.loads(data["filters"])
- if isinstance(data.get("fields"), string_types):
- data["fields"] = json.loads(data["fields"])
- if isinstance(data.get("docstatus"), string_types):
- data["docstatus"] = json.loads(data["docstatus"])
- if isinstance(data.get("save_user_settings"), string_types):
- data["save_user_settings"] = json.loads(data["save_user_settings"])
- else:
- data["save_user_settings"] = True
-
- fields = data["fields"]
-
- if ((isinstance(fields, string_types) and fields == "*")
- or (isinstance(fields, (list, tuple)) and len(fields) == 1 and fields[0] == "*")):
- parenttype = data.doctype
- data["fields"] = frappe.db.get_table_columns(parenttype)
- fields = data["fields"]
-
- for field in fields:
- key = field.split(" as ")[0]
-
- if key.startswith('count('): continue
- if key.startswith('sum('): continue
- if key.startswith('avg('): continue
-
- parenttype, fieldname = get_parent_dt_and_field(key, data)
-
- if fieldname == "*":
- # * inside list is not allowed with other fields
- fields.remove(field)
-
- meta = frappe.get_meta(parenttype)
- df = meta.get_field(fieldname)
-
- report_hide = df.report_hide if df else None
-
- # remove the field from the query if the report hide flag is set and current view is Report
- if report_hide and is_report:
- fields.remove(field)
-
- if df and fieldname in [df.fieldname for df in meta.get_high_permlevel_fields()]:
- if df.get('permlevel') not in meta.get_permlevel_access(parenttype=data.doctype) and field in fields:
- fields.remove(field)
-
- # queries must always be server side
- data.query = None
data.strict = None
return data
-def get_parent_dt_and_field(field, data):
+def validate_fields(data):
+ wildcard = update_wildcard_field_param(data)
+
+ for field in data.fields or []:
+ fieldname = extract_fieldname(field)
+ if is_standard(fieldname):
+ continue
+
+ meta, df = get_meta_and_docfield(fieldname, data)
+
+ if not df:
+ if wildcard:
+ continue
+ else:
+ raise_invalid_field(fieldname)
+
+ # remove the field from the query if the report hide flag is set and current view is Report
+ if df.report_hide and data.view == 'Report':
+ data.fields.remove(field)
+ continue
+
+ if df.fieldname in [_df.fieldname for _df in meta.get_high_permlevel_fields()]:
+ if df.get('permlevel') not in meta.get_permlevel_access(parenttype=data.doctype):
+ data.fields.remove(field)
+
+def validate_filters(data, filters):
+ if isinstance(filters, list):
+ # filters as list
+ for condition in filters:
+ if len(condition)==3:
+ # [fieldname, condition, value]
+ fieldname = condition[0]
+ if is_standard(fieldname):
+ continue
+ meta, df = get_meta_and_docfield(fieldname, data)
+ if not df:
+ raise_invalid_field(condition[0])
+ else:
+ # [doctype, fieldname, condition, value]
+ fieldname = condition[1]
+ if is_standard(fieldname):
+ continue
+ meta = frappe.get_meta(condition[0])
+ if not meta.get_field(fieldname):
+ raise_invalid_field(fieldname)
+
+ else:
+ for fieldname in filters:
+ if is_standard(fieldname):
+ continue
+ meta, df = get_meta_and_docfield(fieldname, data)
+ if not df:
+ raise_invalid_field(fieldname)
+
+def setup_group_by(data):
+ '''Add columns for aggregated values e.g. count(name)'''
+ if data.group_by and data.aggregate_function:
+ if data.aggregate_function.lower() not in ('count', 'sum', 'avg'):
+ frappe.throw(_('Invalid aggregate function'))
+
+ if frappe.db.has_column(data.aggregate_on_doctype, data.aggregate_on_field):
+ data.fields.append('{aggregate_function}(`tab{aggregate_on_doctype}`.`{aggregate_on_field}`) AS _aggregate_column'.format(**data))
+ if data.aggregate_on_field:
+ data.fields.append(f"`tab{data.aggregate_on_doctype}`.`{data.aggregate_on_field}`")
+ else:
+ raise_invalid_field(data.aggregate_on_field)
+
+ data.pop('aggregate_on_doctype')
+ data.pop('aggregate_on_field')
+ data.pop('aggregate_function')
+
+def raise_invalid_field(fieldname):
+ frappe.throw(_('Field not permitted in query') + ': {0}'.format(fieldname), frappe.DataError)
+
+def is_standard(fieldname):
+ if '.' in fieldname:
+ parenttype, fieldname = get_parenttype_and_fieldname(fieldname, None)
+ return fieldname in default_fields or fieldname in optional_fields or fieldname in child_table_fields
+
+def extract_fieldname(field):
+ for text in (',', '/*', '#'):
+ if text in field:
+ raise_invalid_field(field)
+
+ fieldname = field
+ for sep in (' as ', ' AS '):
+ if sep in fieldname:
+ fieldname = fieldname.split(sep)[0]
+
+ # certain functions allowed, extract the fieldname from the function
+ if (fieldname.startswith('count(')
+ or fieldname.startswith('sum(')
+ or fieldname.startswith('avg(')):
+ if not fieldname.strip().endswith(')'):
+ raise_invalid_field(field)
+ fieldname = fieldname.split('(', 1)[1][:-1]
+
+ return fieldname
+
+def get_meta_and_docfield(fieldname, data):
+ parenttype, fieldname = get_parenttype_and_fieldname(fieldname, data)
+ meta = frappe.get_meta(parenttype)
+ df = meta.get_field(fieldname)
+ return meta, df
+
+def update_wildcard_field_param(data):
+ if ((isinstance(data.fields, str) and data.fields == "*")
+ or (isinstance(data.fields, (list, tuple)) and len(data.fields) == 1 and data.fields[0] == "*")):
+ data.fields = frappe.db.get_table_columns(data.doctype)
+ return True
+
+ return False
+
+
+def clean_params(data):
+ for param in (
+ "cmd",
+ "data",
+ "ignore_permissions",
+ "view",
+ "user",
+ "csrf_token",
+ "join"
+ ):
+ data.pop(param, None)
+
+def parse_json(data):
+ if isinstance(data.get("filters"), str):
+ data["filters"] = json.loads(data["filters"])
+ if isinstance(data.get("or_filters"), str):
+ data["or_filters"] = json.loads(data["or_filters"])
+ if isinstance(data.get("fields"), str):
+ data["fields"] = json.loads(data["fields"])
+ if isinstance(data.get("docstatus"), str):
+ data["docstatus"] = json.loads(data["docstatus"])
+ if isinstance(data.get("save_user_settings"), str):
+ data["save_user_settings"] = json.loads(data["save_user_settings"])
+ else:
+ data["save_user_settings"] = True
+
+
+def get_parenttype_and_fieldname(field, data):
if "." in field:
parenttype, fieldname = field.split(".")[0][4:-1], field.split(".")[1].strip("`")
else:
@@ -101,12 +229,15 @@ def get_parent_dt_and_field(field, data):
return parenttype, fieldname
-
-def compress(data, args = {}):
+def compress(data, args=None):
"""separate keys and values"""
from frappe.desk.query_report import add_total_row
+ user_info = {}
+
if not data: return data
+ if args is None:
+ args = {}
values = []
keys = list(data[0])
for row in data:
@@ -115,32 +246,82 @@ def compress(data, args = {}):
new_row.append(row.get(key))
values.append(new_row)
+ # add user info for assignments (avatar)
+ if row._assign:
+ for user in json.loads(row._assign):
+ add_user_info(user, user_info)
+
if args.get("add_total_row"):
meta = frappe.get_meta(args.doctype)
values = add_total_row(values, keys, meta)
return {
"keys": keys,
- "values": values
+ "values": values,
+ "user_info": user_info
}
@frappe.whitelist()
-def save_report():
- """save report"""
+def save_report(name, doctype, report_settings):
+ """Save reports of type Report Builder from Report View"""
- data = frappe.local.form_dict
- if frappe.db.exists('Report', data['name']):
- d = frappe.get_doc('Report', data['name'])
+ if frappe.db.exists('Report', name):
+ report = frappe.get_doc('Report', name)
+ if report.is_standard == "Yes":
+ frappe.throw(_("Standard Reports cannot be edited"))
+
+ if report.report_type != "Report Builder":
+ frappe.throw(_("Only reports of type Report Builder can be edited"))
+
+ if (
+ report.owner != frappe.session.user
+ and not frappe.has_permission("Report", "write")
+ ):
+ frappe.throw(
+ _("Insufficient Permissions for editing Report"),
+ frappe.PermissionError
+ )
else:
- d = frappe.new_doc('Report')
- d.report_name = data['name']
- d.ref_doctype = data['doctype']
+ report = frappe.new_doc('Report')
+ report.report_name = name
+ report.ref_doctype = doctype
- d.report_type = "Report Builder"
- d.json = data['json']
- frappe.get_doc(d).save()
- frappe.msgprint(_("{0} is saved").format(d.name), alert=True)
- return d.name
+ report.report_type = "Report Builder"
+ report.json = report_settings
+ report.save(ignore_permissions=True)
+ frappe.msgprint(
+ _("Report {0} saved").format(frappe.bold(report.name)),
+ indicator="green",
+ alert=True,
+ )
+ return report.name
+
+@frappe.whitelist()
+def delete_report(name):
+ """Delete reports of type Report Builder from Report View"""
+
+ report = frappe.get_doc("Report", name)
+ if report.is_standard == "Yes":
+ frappe.throw(_("Standard Reports cannot be deleted"))
+
+ if report.report_type != "Report Builder":
+ frappe.throw(_("Only reports of type Report Builder can be deleted"))
+
+ if (
+ report.owner != frappe.session.user
+ and not frappe.has_permission("Report", "delete")
+ ):
+ frappe.throw(
+ _("Insufficient Permissions for deleting Report"),
+ frappe.PermissionError
+ )
+
+ report.delete(ignore_permissions=True)
+ frappe.msgprint(
+ _("Report {0} deleted").format(frappe.bold(report.name)),
+ indicator="green",
+ alert=True,
+ )
@frappe.whitelist()
@frappe.read_only()
@@ -182,7 +363,7 @@ def export_query():
if add_totals_row:
ret = append_totals_row(ret)
- data = [['Sr'] + get_labels(db_query.fields, doctype)]
+ data = [[_('Sr')] + get_labels(db_query.fields, doctype)]
for i, row in enumerate(ret):
data.append([i+1] + list(row))
@@ -199,7 +380,7 @@ def export_query():
for r in data:
# encode only unicode type strings and not int, floats etc.
writer.writerow([handle_html(frappe.as_unicode(v)) \
- if isinstance(v, string_types) else v for v in r])
+ if isinstance(v, str) else v for v in r])
f.seek(0)
frappe.response['result'] = cstr(f.read())
@@ -241,7 +422,8 @@ def get_labels(fields, doctype):
for key in fields:
key = key.split(" as ")[0]
- if key.startswith(('count(', 'sum(', 'avg(')): continue
+ if key.startswith(('count(', 'sum(', 'avg(')):
+ continue
if "." in key:
parenttype, fieldname = key.split(".")[0][4:-1], key.split(".")[1].strip("`")
@@ -249,10 +431,16 @@ def get_labels(fields, doctype):
parenttype = doctype
fieldname = fieldname.strip("`")
- df = frappe.get_meta(parenttype).get_field(fieldname)
- label = df.label if df else fieldname.title()
- if label in labels:
- label = doctype + ": " + label
+ if parenttype == doctype and fieldname == "name":
+ label = _("ID", context="Label of name column in report")
+ else:
+ df = frappe.get_meta(parenttype).get_field(fieldname)
+ label = _(df.label if df else fieldname.title())
+ if parenttype != doctype:
+ # If the column is from a child table, append the child doctype.
+ # For example, "Item Code (Sales Invoice Item)".
+ label += f" ({ _(parenttype) })"
+
labels.append(label)
return labels
@@ -311,15 +499,27 @@ def delete_bulk(doctype, items):
@frappe.whitelist()
@frappe.read_only()
-def get_sidebar_stats(stats, doctype, filters=[]):
+def get_sidebar_stats(stats, doctype, filters=None):
+ if filters is None:
+ filters = []
- return {"stats": get_stats(stats, doctype, filters)}
+ if is_virtual_doctype(doctype):
+ controller = get_controller(doctype)
+ args = {"stats": stats, "filters": filters}
+ data = controller(doctype).get_stats(args)
+ else:
+ data = get_stats(stats, doctype, filters)
+
+ return {"stats": data}
@frappe.whitelist()
@frappe.read_only()
-def get_stats(stats, doctype, filters=[]):
+def get_stats(stats, doctype, filters=None):
"""get tag info"""
import json
+
+ if filters is None:
+ filters = []
tags = json.loads(stats)
if filters:
filters = json.loads(filters)
@@ -327,40 +527,53 @@ def get_stats(stats, doctype, filters=[]):
try:
columns = frappe.db.get_table_columns(doctype)
- except frappe.db.InternalError:
+ except (frappe.db.InternalError, frappe.db.ProgrammingError):
# raised when _user_tags column is added on the fly
+ # raised if its a virtual doctype
columns = []
for tag in tags:
- if not tag in columns: continue
+ if tag not in columns:
+ continue
try:
- tagcount = frappe.get_list(doctype, fields=[tag, "count(*)"],
- #filters=["ifnull(`%s`,'')!=''" % tag], group_by=tag, as_list=True)
- filters = filters + ["ifnull(`%s`,'')!=''" % tag], group_by = tag, as_list = True)
+ tag_count = frappe.get_list(doctype,
+ fields=[tag, "count(*)"],
+ filters=filters + [[tag, '!=', '']],
+ group_by=tag,
+ as_list=True,
+ distinct=1,
+ )
- if tag=='_user_tags':
- stats[tag] = scrub_user_tags(tagcount)
- stats[tag].append([_("No Tags"), frappe.get_list(doctype,
+ if tag == '_user_tags':
+ stats[tag] = scrub_user_tags(tag_count)
+ no_tag_count = frappe.get_list(doctype,
fields=[tag, "count(*)"],
- filters=filters +["({0} = ',' or {0} = '' or {0} is null)".format(tag)], as_list=True)[0][1]])
+ filters=filters + [[tag, "in", ('', ',')]],
+ as_list=True,
+ group_by=tag,
+ order_by=tag,
+ )
+
+ no_tag_count = no_tag_count[0][1] if no_tag_count else 0
+
+ stats[tag].append([_("No Tags"), no_tag_count])
else:
- stats[tag] = tagcount
+ stats[tag] = tag_count
except frappe.db.SQLError:
- # does not work for child tables
pass
- except frappe.db.InternalError:
+ except frappe.db.InternalError as e:
# raised when _user_tags column is added on the fly
pass
+
return stats
@frappe.whitelist()
-def get_filter_dashboard_data(stats, doctype, filters=[]):
+def get_filter_dashboard_data(stats, doctype, filters=None):
"""get tags info"""
import json
tags = json.loads(stats)
- if filters:
- filters = json.loads(filters)
+ filters = json.loads(filters or [])
stats = {}
columns = frappe.db.get_table_columns(doctype)
@@ -400,7 +613,7 @@ def scrub_user_tags(tagcount):
alltags = t.split(',')
for tag in alltags:
if tag:
- if not tag in rdict:
+ if tag not in rdict:
rdict[tag] = 0
rdict[tag] += tagdict[t]
@@ -420,14 +633,14 @@ def get_match_cond(doctype, as_condition=True):
return ((' and ' + cond) if cond else "").replace("%", "%%")
def build_match_conditions(doctype, user=None, as_condition=True):
- match_conditions = DatabaseQuery(doctype, user=user).build_match_conditions(as_condition=as_condition)
+ match_conditions = DatabaseQuery(doctype, user=user).build_match_conditions(as_condition=as_condition)
if as_condition:
return match_conditions.replace("%", "%%")
else:
return match_conditions
def get_filters_cond(doctype, filters, conditions, ignore_permissions=None, with_match_conditions=False):
- if isinstance(filters, string_types):
+ if isinstance(filters, str):
filters = json.loads(filters)
if filters:
@@ -436,10 +649,10 @@ def get_filters_cond(doctype, filters, conditions, ignore_permissions=None, with
filters = filters.items()
flt = []
for f in filters:
- if isinstance(f[1], string_types) and f[1][0] == '!':
+ if isinstance(f[1], str) and f[1][0] == '!':
flt.append([doctype, f[0], '!=', f[1][1:]])
elif isinstance(f[1], (list, tuple)) and \
- f[1][0] in (">", "<", ">=", "<=", "like", "not like", "in", "not in", "between"):
+ f[1][0] in (">", "<", ">=", "<=", "!=", "like", "not like", "in", "not in", "between"):
flt.append([doctype, f[0], f[1][0], f[1][1]])
else:
@@ -458,3 +671,7 @@ def get_filters_cond(doctype, filters, conditions, ignore_permissions=None, with
else:
cond = ''
return cond
+
+def is_virtual_doctype(doctype):
+ return frappe.db.get_value("DocType", doctype, "is_virtual")
+
diff --git a/frappe/desk/search.py b/frappe/desk/search.py
index 6faa827dde..b54ea46268 100644
--- a/frappe/desk/search.py
+++ b/frappe/desk/search.py
@@ -1,14 +1,11 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
# Search
-from __future__ import unicode_literals
import frappe, json
from frappe.utils import cstr, unique, cint
from frappe.permissions import has_permission
-from frappe.handler import is_whitelisted
-from frappe import _
-from six import string_types
+from frappe import _, is_whitelisted
import re
import wrapt
@@ -52,8 +49,10 @@ def sanitize_searchfield(searchfield):
# this is called by the Link Field
@frappe.whitelist()
def search_link(doctype, txt, query=None, filters=None, page_length=20, searchfield=None, reference_doctype=None, ignore_user_permissions=False):
- search_widget(doctype, txt.strip(), query, searchfield=searchfield, page_length=page_length, filters=filters, reference_doctype=reference_doctype, ignore_user_permissions=ignore_user_permissions)
- frappe.response['results'] = build_for_autosuggest(frappe.response["values"])
+ search_widget(doctype, txt.strip(), query, searchfield=searchfield, page_length=page_length, filters=filters,
+ reference_doctype=reference_doctype, ignore_user_permissions=ignore_user_permissions)
+
+ frappe.response["results"] = build_for_autosuggest(frappe.response["values"], doctype=doctype)
del frappe.response["values"]
# this is called by the search box
@@ -63,7 +62,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,
start = cint(start)
- if isinstance(filters, string_types):
+ if isinstance(filters, str):
filters = json.loads(filters)
if searchfield:
@@ -110,7 +109,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,
else:
filters.append([doctype, f[0], "=", f[1]])
- if filters==None:
+ if filters is None:
filters = []
or_filters = []
@@ -141,6 +140,12 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,
fields = list(set(fields + json.loads(filter_fields)))
formatted_fields = ['`tab%s`.`%s`' % (meta.name, f.strip()) for f in fields]
+ title_field_query = get_title_field_query(meta)
+
+ # Insert title field query after name
+ if title_field_query:
+ formatted_fields.insert(1, title_field_query)
+
# find relevance as location of search term from the beginning of string `name`. used for sorting results.
formatted_fields.append("""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format(
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), doctype=doctype))
@@ -171,7 +176,18 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,
strict=False)
if doctype in UNTRANSLATED_DOCTYPES:
- values = tuple([v for v in list(values) if re.search(re.escape(txt)+".*", (_(v.name) if as_dict else _(v[0])), re.IGNORECASE)])
+ # Filtering the values array so that query is included in very element
+ values = (
+ v for v in values
+ if re.search(
+ f"{re.escape(txt)}.*", _(v.name if as_dict else v[0]), re.IGNORECASE
+ )
+ )
+
+ # Sorting the values array so that relevant results always come first
+ # This will first bring elements on top in which query is a prefix of element
+ # Then it will bring the rest of the elements and sort them in lexicographical order
+ values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict))
# remove _relevance from results
if as_dict:
@@ -197,11 +213,38 @@ def get_std_fields_list(meta, key):
return sflist
-def build_for_autosuggest(res):
+def get_title_field_query(meta):
+ title_field = meta.title_field if meta.title_field else None
+ show_title_field_in_link = meta.show_title_field_in_link if meta.show_title_field_in_link else None
+ field = None
+
+ if title_field and show_title_field_in_link:
+ field = "`tab{0}`.{1} as `label`".format(meta.name, title_field)
+
+ return field
+
+def build_for_autosuggest(res, doctype):
results = []
- for r in res:
- out = {"value": r[0], "description": ", ".join(unique(cstr(d) for d in r if d)[1:])}
- results.append(out)
+ meta = frappe.get_meta(doctype)
+ if not (meta.title_field and meta.show_title_field_in_link):
+ for r in res:
+ r = list(r)
+ results.append({
+ "value": r[0],
+ "description": ", ".join(unique(cstr(d) for d in r[1:] if d))
+ })
+
+ else:
+ title_field_exists = meta.title_field and meta.show_title_field_in_link
+ _from = 2 if title_field_exists else 1 # to exclude title from description if title_field_exists
+ for r in res:
+ r = list(r)
+ results.append({
+ "value": r[0],
+ "label": r[1] if title_field_exists else None,
+ "description": ", ".join(unique(cstr(d) for d in r[_from:] if d))
+ })
+
return results
def scrub_custom_query(query, key, txt):
@@ -211,6 +254,13 @@ def scrub_custom_query(query, key, txt):
query = query.replace('%s', ((txt or '') + '%'))
return query
+def relevance_sorter(key, query, as_dict):
+ value = _(key.name if as_dict else key[0])
+ return (
+ cstr(value).lower().startswith(query.lower()) is not True,
+ value
+ )
+
@wrapt.decorator
def validate_and_sanitize_search_inputs(fn, instance, args, kwargs):
kwargs.update(dict(zip(fn.__code__.co_varnames, args)))
@@ -221,4 +271,48 @@ def validate_and_sanitize_search_inputs(fn, instance, args, kwargs):
if kwargs['doctype'] and not frappe.db.exists('DocType', kwargs['doctype']):
return []
- return fn(**kwargs)
\ No newline at end of file
+ return fn(**kwargs)
+
+
+@frappe.whitelist()
+def get_names_for_mentions(search_term):
+ users_for_mentions = frappe.cache().get_value('users_for_mentions', get_users_for_mentions)
+ user_groups = frappe.cache().get_value('user_groups', get_user_groups)
+
+ filtered_mentions = []
+ for mention_data in users_for_mentions + user_groups:
+ if search_term.lower() not in mention_data.value.lower():
+ continue
+
+ mention_data['link'] = frappe.utils.get_url_to_form(
+ 'User Group' if mention_data.get('is_group') else 'User Profile',
+ mention_data['id']
+ )
+
+ filtered_mentions.append(mention_data)
+
+ return sorted(filtered_mentions, key=lambda d: d['value'])
+
+def get_users_for_mentions():
+ return frappe.get_all('User',
+ fields=['name as id', 'full_name as value'],
+ filters={
+ 'name': ['not in', ('Administrator', 'Guest')],
+ 'allowed_in_mentions': True,
+ 'user_type': 'System User',
+ 'enabled': True,
+ })
+
+def get_user_groups():
+ return frappe.get_all('User Group', fields=['name as id', 'name as value'], update={
+ 'is_group': True
+ })
+
+@frappe.whitelist()
+def get_link_title(doctype, docname):
+ meta = frappe.get_meta(doctype)
+
+ if meta.title_field and meta.show_title_field_in_link:
+ return frappe.db.get_value(doctype, docname, meta.title_field)
+
+ return docname
\ No newline at end of file
diff --git a/frappe/desk/treeview.py b/frappe/desk/treeview.py
index e0b6ca240a..5e8fb18fe4 100644
--- a/frappe/desk/treeview.py
+++ b/frappe/desk/treeview.py
@@ -1,10 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
+
@frappe.whitelist()
def get_all_nodes(doctype, label, parent, tree_method, **filters):
'''Recursively gets all data from tree nodes'''
@@ -15,7 +15,7 @@ def get_all_nodes(doctype, label, parent, tree_method, **filters):
tree_method = frappe.get_attr(tree_method)
- if not tree_method in frappe.whitelisted:
+ if tree_method not in frappe.whitelisted:
frappe.throw(_("Not Permitted"), frappe.PermissionError)
data = tree_method(doctype, parent, **filters)
@@ -37,19 +37,26 @@ def get_all_nodes(doctype, label, parent, tree_method, **filters):
@frappe.whitelist()
def get_children(doctype, parent='', **filters):
+ return _get_children(doctype, parent)
+
+def _get_children(doctype, parent='', ignore_permissions=False):
parent_field = 'parent_' + doctype.lower().replace(' ', '_')
- filters=[['ifnull(`{0}`,"")'.format(parent_field), '=', parent],
- ['docstatus', '<' ,'2']]
+ filters = [["ifnull(`{0}`,'')".format(parent_field), '=', parent],
+ ['docstatus', '<' ,2]]
- doctype_meta = frappe.get_meta(doctype)
- data = frappe.get_list(doctype, fields=[
- 'name as value',
- '{0} as title'.format(doctype_meta.get('title_field') or 'name'),
- 'is_group as expandable'],
+ meta = frappe.get_meta(doctype)
+
+ return frappe.get_list(
+ doctype,
+ fields=[
+ 'name as value',
+ '{0} as title'.format(meta.get('title_field') or 'name'),
+ 'is_group as expandable'
+ ],
filters=filters,
- order_by='name')
-
- return data
+ order_by='name',
+ ignore_permissions=ignore_permissions
+ )
@frappe.whitelist()
def add_node():
@@ -59,17 +66,15 @@ def add_node():
doc.save()
def make_tree_args(**kwarg):
- del kwarg['cmd']
+ kwarg.pop('cmd', None)
doctype = kwarg['doctype']
parent_field = 'parent_' + doctype.lower().replace(' ', '_')
- name_field = kwarg.get('name_field', doctype.lower().replace(' ', '_') + '_name')
if kwarg['is_root'] == 'false': kwarg['is_root'] = False
if kwarg['is_root'] == 'true': kwarg['is_root'] = True
kwarg.update({
- name_field: kwarg[name_field],
parent_field: kwarg.get("parent") or kwarg.get(parent_field)
})
diff --git a/frappe/desk/utils.py b/frappe/desk/utils.py
index 01b47ac106..3328d47318 100644
--- a/frappe/desk/utils.py
+++ b/frappe/desk/utils.py
@@ -1,5 +1,5 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
import frappe
@@ -20,4 +20,4 @@ def validate_route_conflict(doctype, name):
raise frappe.NameError
def slug(name):
- return name.lower().replace(' ', '-')
\ No newline at end of file
+ return name.lower().replace(' ', '-')
diff --git a/frappe/email/__init__.py b/frappe/email/__init__.py
index b05aef7639..79dec977b7 100644
--- a/frappe/email/__init__.py
+++ b/frappe/email/__init__.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.desk.reportview import build_match_conditions
diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.py b/frappe/email/doctype/auto_email_report/auto_email_report.py
index d82caa7bd4..5ffde0c37b 100644
--- a/frappe/email/doctype/auto_email_report/auto_email_report.py
+++ b/frappe/email/doctype/auto_email_report/auto_email_report.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-from __future__ import unicode_literals
+# License: MIT. See LICENSE
import calendar
from datetime import timedelta
@@ -15,6 +13,7 @@ from frappe.utils import (format_time, get_link_to_form, get_url_to_report,
from frappe.model.naming import append_number_if_name_exists
from frappe.utils.csvutils import to_csv
from frappe.utils.xlsxutils import make_xlsx
+from frappe.desk.query_report import build_xlsx_data
max_reports_per_user = frappe.local.conf.max_reports_per_user or 3
@@ -101,13 +100,21 @@ class AutoEmailReport(Document):
return self.get_html_table(columns, data)
elif self.format == 'XLSX':
- spreadsheet_data = self.get_spreadsheet_data(columns, data)
- xlsx_file = make_xlsx(spreadsheet_data, "Auto Email Report")
+ report_data = frappe._dict()
+ report_data['columns'] = columns
+ report_data['result'] = data
+
+ xlsx_data, column_widths = build_xlsx_data(report_data, [], 1, ignore_visible_idx=True)
+ xlsx_file = make_xlsx(xlsx_data, "Auto Email Report", column_widths=column_widths)
return xlsx_file.getvalue()
elif self.format == 'CSV':
- spreadsheet_data = self.get_spreadsheet_data(columns, data)
- return to_csv(spreadsheet_data)
+ report_data = frappe._dict()
+ report_data['columns'] = columns
+ report_data['result'] = data
+
+ xlsx_data, column_widths = build_xlsx_data(report_data, [], 1, ignore_visible_idx=True)
+ return to_csv(xlsx_data)
else:
frappe.throw(_('Invalid Output Format'))
@@ -128,18 +135,6 @@ class AutoEmailReport(Document):
'edit_report_settings': get_link_to_form('Auto Email Report', self.name)
})
- @staticmethod
- def get_spreadsheet_data(columns, data):
- out = [[_(df.label) for df in columns], ]
- for row in data:
- new_row = []
- out.append(new_row)
- for df in columns:
- if df.fieldname not in row: continue
- new_row.append(frappe.format(row[df.fieldname], df, row))
-
- return out
-
def get_file_name(self):
return "{0}.{1}".format(self.report.replace(" ", "-").replace("/", "-"), self.format.lower())
@@ -245,16 +240,21 @@ def send_monthly():
def make_links(columns, data):
for row in data:
+ doc_name = row.get('name')
for col in columns:
- if col.fieldtype == "Link" and col.options != "Currency":
- if col.options and row.get(col.fieldname):
+ if not row.get(col.fieldname):
+ continue
+
+ if col.fieldtype == "Link":
+ if col.options and col.options != "Currency":
row[col.fieldname] = get_link_to_form(col.options, row[col.fieldname])
elif col.fieldtype == "Dynamic Link":
- if col.options and row.get(col.fieldname) and row.get(col.options):
+ if col.options and row.get(col.options):
row[col.fieldname] = get_link_to_form(row[col.options], row[col.fieldname])
elif col.fieldtype == "Currency":
- row[col.fieldname] = frappe.format_value(row[col.fieldname], col)
-
+ doc = frappe.get_doc(col.parent, doc_name) if doc_name and col.get("parent") else None
+ # Pass the Document to get the currency based on docfield option
+ row[col.fieldname] = frappe.format_value(row[col.fieldname], col, doc=doc)
return columns, data
def update_field_types(columns):
@@ -262,4 +262,4 @@ def update_field_types(columns):
if col.fieldtype in ("Link", "Dynamic Link", "Currency") and col.options != "Currency":
col.fieldtype = "Data"
col.options = ""
- return columns
\ No newline at end of file
+ return columns
diff --git a/frappe/email/doctype/auto_email_report/test_auto_email_report.py b/frappe/email/doctype/auto_email_report/test_auto_email_report.py
index e656ff18f7..559adfbe1a 100644
--- a/frappe/email/doctype/auto_email_report/test_auto_email_report.py
+++ b/frappe/email/doctype/auto_email_report/test_auto_email_report.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import json
import unittest
diff --git a/frappe/email/doctype/document_follow/document_follow.py b/frappe/email/doctype/document_follow/document_follow.py
index aaabffab6b..97f8237736 100644
--- a/frappe/email/doctype/document_follow/document_follow.py
+++ b/frappe/email/doctype/document_follow/document_follow.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
from frappe.model.document import Document
class DocumentFollow(Document):
diff --git a/frappe/email/doctype/document_follow/test_document_follow.js b/frappe/email/doctype/document_follow/test_document_follow.js
deleted file mode 100644
index b141480ae1..0000000000
--- a/frappe/email/doctype/document_follow/test_document_follow.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Document Follow", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Document Follow
- () => frappe.tests.make('Document Follow', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/email/doctype/document_follow/test_document_follow.py b/frappe/email/doctype/document_follow/test_document_follow.py
index 1ac2d19305..050add65e9 100644
--- a/frappe/email/doctype/document_follow/test_document_follow.py
+++ b/frappe/email/doctype/document_follow/test_document_follow.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
import frappe.desk.form.document_follow as document_follow
@@ -17,14 +15,14 @@ class TestDocumentFollow(unittest.TestCase):
document_follow.unfollow_document("Event", event_doc.name, user.name)
doc = document_follow.follow_document("Event", event_doc.name, user.name)
- self.assertEquals(doc.user, user.name)
+ self.assertEqual(doc.user, user.name)
document_follow.send_hourly_updates()
email_queue_entry_name = frappe.get_all("Email Queue", limit=1)[0].name
email_queue_entry_doc = frappe.get_doc("Email Queue", email_queue_entry_name)
- self.assertEquals((email_queue_entry_doc.recipients[0].recipient), user.name)
+ self.assertEqual((email_queue_entry_doc.recipients[0].recipient), user.name)
self.assertIn(event_doc.doctype, email_queue_entry_doc.message)
self.assertIn(event_doc.name, email_queue_entry_doc.message)
diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js
index 83896e0af7..54f0d2372d 100644
--- a/frappe/email/doctype/email_account/email_account.js
+++ b/frappe/email/doctype/email_account/email_account.js
@@ -109,6 +109,15 @@ frappe.ui.form.on("Email Account", {
onload: function(frm) {
frm.set_df_property("append_to", "only_select", true);
frm.set_query("append_to", "frappe.email.doctype.email_account.email_account.get_append_to");
+ frm.set_query("append_to", "imap_folder", function() {
+ return {
+ query: "frappe.email.doctype.email_account.email_account.get_append_to"
+ };
+ });
+ if (frm.doc.__islocal) {
+ frm.add_child("imap_folder", {"folder_name": "INBOX"});
+ frm.refresh_field("imap_folder");
+ }
},
refresh: function(frm) {
@@ -117,7 +126,7 @@ frappe.ui.form.on("Email Account", {
frm.events.notify_if_unreplied(frm);
frm.events.show_gmail_message_for_less_secure_apps(frm);
- if(frappe.route_flags.delete_user_from_locals && frappe.route_flags.linked_user) {
+ if (frappe.route_flags.delete_user_from_locals && frappe.route_flags.linked_user) {
delete frappe.route_flags.delete_user_from_locals;
delete locals['User'][frappe.route_flags.linked_user];
}
@@ -125,7 +134,7 @@ frappe.ui.form.on("Email Account", {
show_gmail_message_for_less_secure_apps: function(frm) {
frm.dashboard.clear_headline();
- if(frm.doc.service==="GMail") {
+ if (frm.doc.service==="GMail") {
frm.dashboard.set_headline_alert('Gmail will only work if you allow access for less secure \
apps in Gmail settings. Read this for details ');
@@ -137,8 +146,8 @@ frappe.ui.form.on("Email Account", {
frm.events.update_domain(frm);
},
- update_domain: function(frm){
- if (!frm.doc.email_id && !frm.doc.service){
+ update_domain: function(frm) {
+ if (!frm.doc.email_id && !frm.doc.service) {
return;
}
@@ -148,28 +157,16 @@ frappe.ui.form.on("Email Account", {
args: {
"email_id": frm.doc.email_id
},
- callback: function (r) {
+ callback: function(r) {
if (r.message) {
frm.events.set_domain_fields(frm, r.message);
- } else {
- frm.set_value("domain", "");
- frappe.confirm(__('Email Domain not configured for this account, Create one?'),
- function () {
- frappe.model.with_doctype("Email Domain", function() {
- frappe.route_options = { email_id: frm.doc.email_id };
- frappe.route_flags.return_to_email_account = 1;
- var doc = frappe.model.get_new_doc("Email Domain");
- frappe.set_route("Form", "Email Domain", doc.name);
- });
- }
- );
}
}
});
},
set_domain_fields: function(frm, args) {
- if(!args){
+ if (!args) {
args = frappe.route_flags.set_domain_values? frappe.route_options: {};
}
@@ -184,10 +181,8 @@ frappe.ui.form.on("Email Account", {
email_sync_option: function(frm) {
// confirm if the ALL sync option is selected
- if(frm.doc.email_sync_option == "ALL"){
- var msg = __("You are selecting Sync Option as ALL, It will resync all \
- read as well as unread message from server. This may also cause the duplication\
- of Communication (emails).");
+ if (frm.doc.email_sync_option == "ALL") {
+ var msg = __("You are selecting Sync Option as ALL, It will resync all read as well as unread message from server. This may also cause the duplication of Communication (emails).");
frappe.confirm(msg, null, function() {
frm.set_value("email_sync_option", "UNSEEN");
});
@@ -196,8 +191,7 @@ frappe.ui.form.on("Email Account", {
warn_autoreply_on_incoming: function(frm) {
if (frm.doc.enable_incoming && frm.doc.enable_auto_reply && frm.doc.__islocal) {
- var msg = __("Enabling auto reply on an incoming email account will send automated replies \
- to all the synchronized emails. Do you wish to continue?");
+ var msg = __("Enabling auto reply on an incoming email account will send automated replies to all the synchronized emails. Do you wish to continue?");
frappe.confirm(msg, null, function() {
frm.set_value("enable_auto_reply", 0);
frappe.show_alert({message: __("Disabled Auto Reply"), indicator: "blue"});
diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json
index 6d811b801f..65053bab3d 100644
--- a/frappe/email/doctype/email_account/email_account.json
+++ b/frappe/email/doctype/email_account/email_account.json
@@ -7,30 +7,36 @@
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
+ "account_section",
"email_id",
- "login_id_is_different",
- "login_id",
+ "email_account_name",
+ "column_break_3",
+ "domain",
+ "service",
+ "authentication_column",
"password",
"awaiting_password",
"ascii_encode_password",
- "email_account_name",
- "email_settings",
- "domain",
- "service",
+ "column_break_10",
+ "login_id_is_different",
+ "login_id",
"mailbox_settings",
"enable_incoming",
- "use_imap",
- "email_server",
- "use_ssl",
- "append_emails_to_sent_folder",
- "incoming_port",
- "attachment_limit",
- "append_to",
"default_incoming",
+ "use_imap",
+ "use_ssl",
+ "email_server",
+ "incoming_port",
+ "column_break_18",
+ "attachment_limit",
"email_sync_option",
"initial_sync_count",
- "create_contact",
+ "section_break_25",
+ "imap_folder",
"section_break_12",
+ "append_emails_to_sent_folder",
+ "append_to",
+ "create_contact",
"enable_automatic_linking",
"section_break_13",
"notify_if_unreplied",
@@ -42,6 +48,7 @@
"use_tls",
"use_ssl_for_outgoing",
"smtp_port",
+ "column_break_38",
"default_outgoing",
"always_use_account_email_id_as_sender",
"always_use_account_name_as_sender_name",
@@ -80,7 +87,7 @@
"fieldtype": "Check",
"hide_days": 1,
"hide_seconds": 1,
- "label": "Use Different Email Login ID"
+ "label": "Use different login"
},
{
"depends_on": "login_id_is_different",
@@ -122,12 +129,6 @@
"label": "Email Account Name",
"unique": 1
},
- {
- "fieldname": "email_settings",
- "fieldtype": "Section Break",
- "hide_days": 1,
- "hide_seconds": 1
- },
{
"depends_on": "eval:!doc.service",
"fieldname": "domain",
@@ -136,7 +137,7 @@
"hide_seconds": 1,
"in_list_view": 1,
"in_standard_filter": 1,
- "label": "Domain",
+ "label": "Domain (optional)",
"options": "Email Domain"
},
{
@@ -145,18 +146,18 @@
"fieldtype": "Select",
"hide_days": 1,
"hide_seconds": 1,
- "label": "Service",
+ "label": "Service (optional)",
"options": "\nGMail\nSendgrid\nSparkPost\nYahoo Mail\nOutlook.com\nYandex.Mail"
},
{
"fieldname": "mailbox_settings",
"fieldtype": "Section Break",
"hide_days": 1,
- "hide_seconds": 1
+ "hide_seconds": 1,
+ "label": "Incoming (POP/IMAP) Settings"
},
{
"default": "0",
- "description": "Check this to pull emails from your mailbox",
"fieldname": "enable_incoming",
"fieldtype": "Check",
"hide_days": 1,
@@ -205,7 +206,7 @@
"label": "Attachment Limit (MB)"
},
{
- "depends_on": "enable_incoming",
+ "depends_on": "eval: doc.enable_incoming && !doc.use_imap",
"description": "Append as communication against this DocType (must have fields, \"Status\", \"Subject\")",
"fieldname": "append_to",
"fieldtype": "Link",
@@ -227,7 +228,7 @@
},
{
"default": "UNSEEN",
- "depends_on": "eval: doc.enable_incoming",
+ "depends_on": "eval: doc.enable_incoming && doc.use_imap",
"fieldname": "email_sync_option",
"fieldtype": "Select",
"hide_days": 1,
@@ -237,6 +238,7 @@
},
{
"default": "250",
+ "depends_on": "eval: doc.enable_incoming && doc.use_imap",
"description": "Total number of emails to sync in initial sync process ",
"fieldname": "initial_sync_count",
"fieldtype": "Select",
@@ -248,7 +250,7 @@
{
"depends_on": "enable_incoming",
"fieldname": "section_break_13",
- "fieldtype": "Section Break",
+ "fieldtype": "Column Break",
"hide_days": 1,
"hide_seconds": 1
},
@@ -282,7 +284,8 @@
"fieldname": "outgoing_mail_settings",
"fieldtype": "Section Break",
"hide_days": 1,
- "hide_seconds": 1
+ "hide_seconds": 1,
+ "label": "Outgoing (SMTP) Settings"
},
{
"default": "0",
@@ -336,22 +339,20 @@
{
"default": "0",
"depends_on": "enable_outgoing",
- "description": "Uses the Email Address mentioned in this Account as the Sender for all emails sent using this Account. ",
"fieldname": "always_use_account_email_id_as_sender",
"fieldtype": "Check",
"hide_days": 1,
"hide_seconds": 1,
- "label": "Always use Account's Email Address as Sender"
+ "label": "Always use this email address as sender address"
},
{
"default": "0",
"depends_on": "enable_outgoing",
- "description": "Uses the Email Address Name mentioned in this Account as the Sender's Name for all emails sent using this Account.",
"fieldname": "always_use_account_name_as_sender_name",
"fieldtype": "Check",
"hide_days": 1,
"hide_seconds": 1,
- "label": "Always use Account's Name as Sender's Name"
+ "label": "Always use this name as sender name"
},
{
"default": "1",
@@ -379,10 +380,13 @@
"label": "Disable SMTP server authentication"
},
{
+ "collapsible": 1,
+ "collapsible_depends_on": "add_signature",
"fieldname": "signature_section",
"fieldtype": "Section Break",
"hide_days": 1,
- "hide_seconds": 1
+ "hide_seconds": 1,
+ "label": "Signature"
},
{
"default": "0",
@@ -401,10 +405,13 @@
"label": "Signature"
},
{
+ "collapsible": 1,
+ "collapsible_depends_on": "enable_auto_reply",
"fieldname": "auto_reply",
"fieldtype": "Section Break",
"hide_days": 1,
- "hide_seconds": 1
+ "hide_seconds": 1,
+ "label": "Auto Reply"
},
{
"default": "0",
@@ -424,17 +431,20 @@
"label": "Auto Reply Message"
},
{
+ "collapsible": 1,
+ "collapsible_depends_on": "eval:frappe.utils.html2text(doc.footer || '')!=''",
"fieldname": "set_footer",
"fieldtype": "Section Break",
"hide_days": 1,
- "hide_seconds": 1
+ "hide_seconds": 1,
+ "label": "Footer"
},
{
"fieldname": "footer",
"fieldtype": "Text Editor",
"hide_days": 1,
"hide_seconds": 1,
- "label": "Footer"
+ "label": "Footer Content"
},
{
"fieldname": "uidvalidity",
@@ -477,7 +487,8 @@
"fieldname": "section_break_12",
"fieldtype": "Section Break",
"hide_days": 1,
- "hide_seconds": 1
+ "hide_seconds": 1,
+ "label": "Document Linking"
},
{
"default": "0",
@@ -527,15 +538,54 @@
"fieldname": "brand_logo",
"fieldtype": "Attach Image",
"label": "Brand Logo"
+ },
+ {
+ "fieldname": "authentication_column",
+ "fieldtype": "Section Break",
+ "label": "Authentication"
+ },
+ {
+ "fieldname": "column_break_10",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_18",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_38",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "account_section",
+ "fieldtype": "Section Break",
+ "label": "Account"
+ },
+ {
+ "depends_on": "eval: doc.use_imap && doc.enable_incoming",
+ "fieldname": "imap_folder",
+ "fieldtype": "Table",
+ "label": "IMAP Folder",
+ "options": "IMAP Folder"
+ },
+ {
+ "fieldname": "section_break_25",
+ "fieldtype": "Section Break",
+ "label": "IMAP Details"
}
],
"icon": "fa fa-inbox",
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2021-01-21 10:05:24.820597",
+ "modified": "2021-11-30 09:03:25.728637",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Account",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -554,4 +604,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py
index ca4dbb83e2..3a1b683398 100755
--- a/frappe/email/doctype/email_account/email_account.py
+++ b/frappe/email/doctype/email_account/email_account.py
@@ -1,37 +1,55 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# License: MIT. See LICENSE
-from __future__ import unicode_literals, print_function
-import frappe
+import email.utils
+import functools
import imaplib
-import re
-import json
import socket
import time
-from frappe import _
-from frappe.model.document import Document
-from frappe.utils import validate_email_address, cint, cstr, get_datetime, DATE_FORMAT, strip, comma_or, sanitize_html, add_days
-from frappe.utils.user import is_system_user
-from frappe.utils.jinja import render_template
-from frappe.email.smtp import SMTPServer
-from frappe.email.receive import EmailServer, Email
-from poplib import error_proto
-from dateutil.relativedelta import relativedelta
from datetime import datetime, timedelta
+from poplib import error_proto
+from typing import List
+
+import frappe
+from frappe import _, are_emails_muted, safe_encode
from frappe.desk.form import assign_to
-from frappe.utils.user import get_system_managers
-from frappe.utils.background_jobs import enqueue, get_jobs
-from frappe.core.doctype.communication.email import set_incoming_outgoing_accounts
-from frappe.utils.html_utils import clean_email_html
+from frappe.email.receive import EmailServer, InboundMail, SentEmailInInboxError
+from frappe.email.smtp import SMTPServer
from frappe.email.utils import get_port
+from frappe.model.document import Document
+from frappe.utils import cint, comma_or, cstr, parse_addr, validate_email_address
+from frappe.utils.background_jobs import enqueue, get_jobs
+from frappe.utils.error import raise_error_on_no_output
+from frappe.utils.jinja import render_template
+from frappe.utils.user import get_system_managers
+
+OUTGOING_EMAIL_ACCOUNT_MISSING = _("Please setup default Email Account from Setup > Email > Email Account")
class SentEmailInInbox(Exception):
pass
-class InvalidEmailCredentials(frappe.ValidationError):
- pass
+def cache_email_account(cache_name):
+ def decorator_cache_email_account(func):
+ @functools.wraps(func)
+ def wrapper_cache_email_account(*args, **kwargs):
+ if not hasattr(frappe.local, cache_name):
+ setattr(frappe.local, cache_name, {})
+
+ cached_accounts = getattr(frappe.local, cache_name)
+ match_by = list(kwargs.values()) + ['default']
+ matched_accounts = list(filter(None, [cached_accounts.get(key) for key in match_by]))
+ if matched_accounts:
+ return matched_accounts[0]
+
+ matched_accounts = func(*args, **kwargs)
+ cached_accounts.update(matched_accounts or {})
+ return matched_accounts and list(matched_accounts.values())[0]
+ return wrapper_cache_email_account
+ return decorator_cache_email_account
class EmailAccount(Document):
+ DOCTYPE = 'Email Account'
+
def autoname(self):
"""Set name as `email_account_name` or make title from Email Address."""
if not self.email_account_name:
@@ -51,6 +69,10 @@ class EmailAccount(Document):
else:
self.login_id = None
+ # validate the imap settings
+ if self.enable_incoming and self.use_imap and len(self.imap_folder) <= 0:
+ frappe.throw(_("You need to set one IMAP folder for {0}").format(frappe.bold(self.email_id)))
+
duplicate_email_account = frappe.get_all("Email Account", filters={
"email_id": self.email_id,
"name": ("!=", self.name)
@@ -62,9 +84,6 @@ class EmailAccount(Document):
if frappe.local.flags.in_patch or frappe.local.flags.in_test:
return
- #if self.enable_incoming and not self.append_to:
- # frappe.throw(_("Append To is mandatory for incoming mails"))
-
if (not self.awaiting_password and not frappe.local.flags.in_install
and not frappe.local.flags.in_patch):
if self.password or self.smtp_server in ('127.0.0.1', 'localhost'):
@@ -72,9 +91,8 @@ class EmailAccount(Document):
self.get_incoming_server()
self.no_failed = 0
-
if self.enable_outgoing:
- self.check_smtp()
+ self.validate_smtp_conn()
else:
if self.enable_incoming or (self.enable_outgoing and not self.no_smtp_authentication):
frappe.throw(_("Password is required or select Awaiting Password"))
@@ -85,15 +103,44 @@ class EmailAccount(Document):
for e in self.get_unreplied_notification_emails():
validate_email_address(e, True)
- if self.enable_incoming and self.append_to:
- valid_doctypes = [d[0] for d in get_append_to()]
- if self.append_to not in valid_doctypes:
- frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes)))
+ for folder in self.imap_folder:
+ if self.enable_incoming and folder.append_to:
+ valid_doctypes = [d[0] for d in get_append_to()]
+ if folder.append_to not in valid_doctypes:
+ frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes)))
+
+ def validate_smtp_conn(self):
+ if not self.smtp_server:
+ frappe.throw(_("SMTP Server is required"))
+
+ server = self.get_smtp_server()
+ return server.session
+
+ def before_save(self):
+ messages = []
+ as_list = 1
+ if not self.enable_incoming and self.default_incoming:
+ self.default_incoming = False
+ messages.append(_("{} has been disabled. It can only be enabled if {} is checked.")
+ .format(
+ frappe.bold(_('Default Incoming')),
+ frappe.bold(_('Enable Incoming'))
+ )
+ )
+ if not self.enable_outgoing and self.default_outgoing:
+ self.default_outgoing = False
+ messages.append(_("{} has been disabled. It can only be enabled if {} is checked.")
+ .format(
+ frappe.bold(_('Default Outgoing')),
+ frappe.bold(_('Enable Outgoing'))
+ )
+ )
+ if messages:
+ if len(messages) == 1: (as_list, messages) = (0, messages[0])
+ frappe.msgprint(messages, as_list= as_list, indicator='orange', title=_("Defaults Updated"))
def on_update(self):
"""Check there is only one default of each type."""
- from frappe.core.doctype.user.user import setup_user_email_inbox
-
self.check_automatic_linking_email_account()
self.there_must_be_only_one_default()
setup_user_email_inbox(email_account=self.name, awaiting_password=self.awaiting_password,
@@ -128,37 +175,19 @@ class EmailAccount(Document):
except Exception:
pass
- def check_smtp(self):
- """Checks SMTP settings."""
- if self.enable_outgoing:
- if not self.smtp_server:
- frappe.throw(_("{0} is required").format("SMTP Server"))
-
- server = SMTPServer(
- login = getattr(self, "login_id", None) or self.email_id,
- server=self.smtp_server,
- port=cint(self.smtp_port),
- use_tls=cint(self.use_tls),
- use_ssl=cint(self.use_ssl_for_outgoing)
- )
- if self.password and not self.no_smtp_authentication:
- server.password = self.get_password()
-
- server.sess
-
def get_incoming_server(self, in_receive=False, email_sync_rule="UNSEEN"):
"""Returns logged in POP3/IMAP connection object."""
if frappe.cache().get_value("workers:no-internet") == True:
return None
args = frappe._dict({
+ "email_account_name": self.email_account_name,
"email_account": self.name,
"host": self.email_server,
"use_ssl": self.use_ssl,
"username": getattr(self, "login_id", None) or self.email_id,
"use_imap": self.use_imap,
"email_sync_rule": email_sync_rule,
- "uid_validity": self.uidvalidity,
"incoming_port": get_port(self),
"initial_sync_count": self.initial_sync_count or 100
})
@@ -208,7 +237,7 @@ class EmailAccount(Document):
return None
elif not in_receive and any(map(lambda t: t in message, auth_error_codes)):
- self.throw_invalid_credentials_exception()
+ SMTPServer.throw_invalid_credentials_exception()
else:
frappe.throw(cstr(e))
@@ -226,13 +255,142 @@ class EmailAccount(Document):
else:
raise
+ @property
+ def _password(self):
+ raise_exception = not (self.no_smtp_authentication or frappe.flags.in_test)
+ return self.get_password(raise_exception=raise_exception)
+
+ @property
+ def default_sender(self):
+ return email.utils.formataddr((self.name, self.get("email_id")))
+
+ def is_exists_in_db(self):
+ """Some of the Email Accounts we create from configs and those doesn't exists in DB.
+ This is is to check the specific email account exists in DB or not.
+ """
+ return self.find_one_by_filters(name=self.name)
+
@classmethod
- def throw_invalid_credentials_exception(cls):
- frappe.throw(
- _("Incorrect email or password. Please check your login credentials."),
- exc=InvalidEmailCredentials,
- title=_("Invalid Credentials")
- )
+ def from_record(cls, record):
+ email_account = frappe.new_doc(cls.DOCTYPE)
+ email_account.update(record)
+ return email_account
+
+ @classmethod
+ def find(cls, name):
+ return frappe.get_doc(cls.DOCTYPE, name)
+
+ @classmethod
+ def find_one_by_filters(cls, **kwargs):
+ name = frappe.db.get_value(cls.DOCTYPE, kwargs)
+ return cls.find(name) if name else None
+
+ @classmethod
+ def find_from_config(cls):
+ config = cls.get_account_details_from_site_config()
+ return cls.from_record(config) if config else None
+
+ @classmethod
+ def create_dummy(cls):
+ return cls.from_record({"sender": "notifications@example.com"})
+
+ @classmethod
+ @raise_error_on_no_output(
+ keep_quiet = lambda: not cint(frappe.get_system_settings('setup_complete')),
+ error_message = OUTGOING_EMAIL_ACCOUNT_MISSING, error_type = frappe.OutgoingEmailError) # noqa
+ @cache_email_account('outgoing_email_account')
+ def find_outgoing(cls, match_by_email=None, match_by_doctype=None, _raise_error=False):
+ """Find the outgoing Email account to use.
+
+ :param match_by_email: Find account using emailID
+ :param match_by_doctype: Find account by matching `Append To` doctype
+ :param _raise_error: This is used by raise_error_on_no_output decorator to raise error.
+ """
+ if match_by_email:
+ match_by_email = parse_addr(match_by_email)[1]
+ doc = cls.find_one_by_filters(enable_outgoing=1, email_id=match_by_email)
+ if doc:
+ return {match_by_email: doc}
+
+ if match_by_doctype:
+ doc = cls.find_one_by_filters(enable_outgoing=1, enable_incoming=1, append_to=match_by_doctype)
+ if doc:
+ return {match_by_doctype: doc}
+
+ doc = cls.find_default_outgoing()
+ if doc:
+ return {'default': doc}
+
+ @classmethod
+ def find_default_outgoing(cls):
+ """ Find default outgoing account.
+ """
+ doc = cls.find_one_by_filters(enable_outgoing=1, default_outgoing=1)
+ doc = doc or cls.find_from_config()
+ return doc or (are_emails_muted() and cls.create_dummy())
+
+ @classmethod
+ def find_incoming(cls, match_by_email=None, match_by_doctype=None):
+ """Find the incoming Email account to use.
+ :param match_by_email: Find account using emailID
+ :param match_by_doctype: Find account by matching `Append To` doctype
+ """
+ doc = cls.find_one_by_filters(enable_incoming=1, email_id=match_by_email)
+ if doc:
+ return doc
+
+ doc = cls.find_one_by_filters(enable_incoming=1, append_to=match_by_doctype)
+ if doc:
+ return doc
+
+ doc = cls.find_default_incoming()
+ return doc
+
+ @classmethod
+ def find_default_incoming(cls):
+ doc = cls.find_one_by_filters(enable_incoming=1, default_incoming=1)
+ return doc
+
+ @classmethod
+ def get_account_details_from_site_config(cls):
+ if not frappe.conf.get("mail_server"):
+ return {}
+
+ field_to_conf_name_map = {
+ 'smtp_server': {'conf_names': ('mail_server',)},
+ 'smtp_port': {'conf_names': ('mail_port',)},
+ 'use_tls': {'conf_names': ('use_tls', 'mail_login')},
+ 'login_id': {'conf_names': ('mail_login',)},
+ 'email_id': {'conf_names': ('auto_email_id', 'mail_login'), 'default': 'notifications@example.com'},
+ 'password': {'conf_names': ('mail_password',)},
+ 'always_use_account_email_id_as_sender':
+ {'conf_names': ('always_use_account_email_id_as_sender',), 'default': 0},
+ 'always_use_account_name_as_sender_name':
+ {'conf_names': ('always_use_account_name_as_sender_name',), 'default': 0},
+ 'name': {'conf_names': ('email_sender_name',), 'default': 'Frappe'},
+ 'from_site_config': {'default': True}
+ }
+
+ account_details = {}
+ for doc_field_name, d in field_to_conf_name_map.items():
+ conf_names, default = d.get('conf_names') or [], d.get('default')
+ value = [frappe.conf.get(k) for k in conf_names if frappe.conf.get(k)]
+ account_details[doc_field_name] = (value and value[0]) or default
+ return account_details
+
+ def sendmail_config(self):
+ return {
+ 'server': self.smtp_server,
+ 'port': cint(self.smtp_port),
+ 'login': getattr(self, "login_id", None) or self.email_id,
+ 'password': self._password,
+ 'use_ssl': cint(self.use_ssl_for_outgoing),
+ 'use_tls': cint(self.use_tls)
+ }
+
+ def get_smtp_server(self):
+ config = self.sendmail_config()
+ return SMTPServer(**config)
def handle_incoming_connect_error(self, description):
if test_internet():
@@ -263,91 +421,88 @@ class EmailAccount(Document):
def get_failed_attempts_count(self):
return cint(frappe.cache().get('{0}:email-account-failed-attempts'.format(self.name)))
- def receive(self, test_mails=None):
+ def receive(self):
"""Called by scheduler to receive emails from this EMail account using POP3/IMAP."""
- def get_seen(status):
- if not status:
- return None
- seen = 1 if status == "SEEN" else 0
- return seen
+ exceptions = []
+ inbound_mails = self.get_inbound_mails()
+ for mail in inbound_mails:
+ try:
+ communication = mail.process()
+ frappe.db.commit()
+ # If email already exists in the system
+ # then do not send notifications for the same email.
+ if communication and mail.flags.is_new_communication:
+ # notify all participants of this thread
+ if self.enable_auto_reply:
+ self.send_auto_reply(communication, mail)
- if self.enable_incoming:
- uid_list = []
- exceptions = []
- seen_status = []
- uid_reindexed = False
- email_server = None
-
- if frappe.local.flags.in_test:
- incoming_mails = test_mails or []
+ communication.send_email(is_inbound_mail_communcation=True)
+ except SentEmailInInboxError:
+ frappe.db.rollback()
+ except Exception:
+ frappe.db.rollback()
+ frappe.log_error(title="EmailAccount.receive")
+ if self.use_imap:
+ self.handle_bad_emails(mail.uid, mail.raw_message, frappe.get_traceback())
+ exceptions.append(frappe.get_traceback())
else:
- email_sync_rule = self.build_email_sync_rule()
+ frappe.db.commit()
- try:
- email_server = self.get_incoming_server(in_receive=True, email_sync_rule=email_sync_rule)
- except Exception:
- frappe.log_error(title=_("Error while connecting to email account {0}").format(self.name))
+ #notify if user is linked to account
+ if len(inbound_mails)>0 and not frappe.local.flags.in_test:
+ frappe.publish_realtime('new_email',
+ {"account":self.email_account_name, "number":len(inbound_mails)}
+ )
- if not email_server:
- return
+ if exceptions:
+ raise Exception(frappe.as_json(exceptions))
- emails = email_server.get_messages()
- if not emails:
- return
+ def get_inbound_mails(self) -> List[InboundMail]:
+ """retrive and return inbound mails.
- incoming_mails = emails.get("latest_messages", [])
- uid_list = emails.get("uid_list", [])
- seen_status = emails.get("seen_status", [])
- uid_reindexed = emails.get("uid_reindexed", False)
+ """
+ mails = []
- for idx, msg in enumerate(incoming_mails):
- uid = None if not uid_list else uid_list[idx]
- self.flags.notify = True
+ def process_mail(messages, append_to=None):
+ for index, message in enumerate(messages.get("latest_messages", [])):
+ uid = messages['uid_list'][index] if messages.get('uid_list') else None
+ seen_status = messages.get('seen_status', {}).get(uid)
+ if self.email_sync_option != 'UNSEEN' or seen_status != "SEEN":
+ # only append the emails with status != 'SEEN' if sync option is set to 'UNSEEN'
+ mails.append(InboundMail(message, self, uid, seen_status, append_to))
- try:
- args = {
- "uid": uid,
- "seen": None if not seen_status else get_seen(seen_status.get(uid, None)),
- "uid_reindexed": uid_reindexed
- }
- communication = self.insert_communication(msg, args=args)
+ if not self.enable_incoming:
+ return []
- except SentEmailInInbox:
- frappe.db.rollback()
+ email_sync_rule = self.build_email_sync_rule()
+ try:
+ email_server = self.get_incoming_server(in_receive=True, email_sync_rule=email_sync_rule)
+ if self.use_imap:
+ # process all given imap folder
+ for folder in self.imap_folder:
+ if email_server.select_imap_folder(folder.folder_name):
+ email_server.settings['uid_validity'] = folder.uidvalidity
+ messages = email_server.get_messages(folder=f'"{folder.folder_name}"') or {}
+ process_mail(messages, folder.append_to)
+ else:
+ # process the pop3 account
+ messages = email_server.get_messages() or {}
+ process_mail(messages)
+ # close connection to mailserver
+ email_server.logout()
+ except Exception:
+ frappe.log_error(title=_("Error while connecting to email account {0}").format(self.name))
+ return []
+ return mails
- except Exception:
- frappe.db.rollback()
- frappe.log_error('email_account.receive')
- if self.use_imap:
- self.handle_bad_emails(email_server, uid, msg, frappe.get_traceback())
- exceptions.append(frappe.get_traceback())
-
- else:
- frappe.db.commit()
- if communication and self.flags.notify:
-
- # If email already exists in the system
- # then do not send notifications for the same email.
-
- attachments = []
-
- if hasattr(communication, '_attachments'):
- attachments = [d.file_name for d in communication._attachments]
-
- communication.notify(attachments=attachments, fetched_from_email_account=True)
-
- #notify if user is linked to account
- if len(incoming_mails)>0 and not frappe.local.flags.in_test:
- frappe.publish_realtime('new_email', {"account":self.email_account_name, "number":len(incoming_mails)})
-
- if exceptions:
- raise Exception(frappe.as_json(exceptions))
-
- def handle_bad_emails(self, email_server, uid, raw, reason):
- if email_server and cint(email_server.settings.use_imap):
+ def handle_bad_emails(self, uid, raw, reason):
+ if cint(self.use_imap):
import email
try:
- mail = email.message_from_string(raw)
+ if isinstance(raw, bytes):
+ mail = email.message_from_bytes(raw)
+ else:
+ mail = email.message_from_string(raw)
message_id = mail.get('Message-ID')
except Exception:
@@ -359,278 +514,23 @@ class EmailAccount(Document):
"reason":reason,
"message_id": message_id,
"doctype": "Unhandled Email",
- "email_account": email_server.settings.email_account
+ "email_account": self.name
})
unhandled_email.insert(ignore_permissions=True)
frappe.db.commit()
- def insert_communication(self, msg, args=None):
- if isinstance(msg, list):
- raw, uid, seen = msg
- else:
- raw = msg
- uid = -1
- seen = 0
- if isinstance(args, dict):
- if args.get("uid", -1): uid = args.get("uid", -1)
- if args.get("seen", 0): seen = args.get("seen", 0)
-
- email = Email(raw)
-
- if email.from_email == self.email_id and not email.mail.get("Reply-To"):
- # gmail shows sent emails in inbox
- # and we don't want emails sent by us to be pulled back into the system again
- # dont count emails sent by the system get those
- if frappe.flags.in_test:
- print('WARN: Cannot pull email. Sender sames as recipient inbox')
- raise SentEmailInInbox
-
- if email.message_id:
- # https://stackoverflow.com/a/18367248
- names = frappe.db.sql("""SELECT DISTINCT `name`, `creation` FROM `tabCommunication`
- WHERE `message_id`='{message_id}'
- ORDER BY `creation` DESC LIMIT 1""".format(
- message_id=email.message_id
- ), as_dict=True)
-
- if names:
- name = names[0].get("name")
- # email is already available update communication uid instead
- frappe.db.set_value("Communication", name, "uid", uid, update_modified=False)
-
- self.flags.notify = False
-
- return frappe.get_doc("Communication", name)
-
- if email.content_type == 'text/html':
- email.content = clean_email_html(email.content)
-
- communication = frappe.get_doc({
- "doctype": "Communication",
- "subject": email.subject,
- "content": email.content,
- 'text_content': email.text_content,
- "sent_or_received": "Received",
- "sender_full_name": email.from_real_name,
- "sender": email.from_email,
- "recipients": email.mail.get("To"),
- "cc": email.mail.get("CC"),
- "email_account": self.name,
- "communication_medium": "Email",
- "uid": int(uid or -1),
- "message_id": email.message_id,
- "communication_date": email.date,
- "has_attachment": 1 if email.attachments else 0,
- "seen": seen or 0
- })
-
- self.set_thread(communication, email)
- if communication.seen:
- # get email account user and set communication as seen
- users = frappe.get_all("User Email", filters={ "email_account": self.name },
- fields=["parent"])
- users = list(set([ user.get("parent") for user in users ]))
- communication._seen = json.dumps(users)
-
- communication.flags.in_receive = True
- communication.insert(ignore_permissions=True)
-
- # save attachments
- communication._attachments = email.save_attachments_in_doc(communication)
-
- # replace inline images
- dirty = False
- for file in communication._attachments:
- if file.name in email.cid_map and email.cid_map[file.name]:
- dirty = True
-
- email.content = email.content.replace("cid:{0}".format(email.cid_map[file.name]),
- file.file_url)
-
- if dirty:
- # not sure if using save() will trigger anything
- communication.db_set("content", sanitize_html(email.content))
-
- # notify all participants of this thread
- if self.enable_auto_reply and getattr(communication, "is_first", False):
- self.send_auto_reply(communication, email)
-
- return communication
-
- def set_thread(self, communication, email):
- """Appends communication to parent based on thread ID. Will extract
- parent communication and will link the communication to the reference of that
- communication. Also set the status of parent transaction to Open or Replied.
-
- If no thread id is found and `append_to` is set for the email account,
- it will create a new parent transaction (e.g. Issue)"""
- parent = None
-
- parent = self.find_parent_from_in_reply_to(communication, email)
-
- if not parent and self.append_to:
- self.set_sender_field_and_subject_field()
-
- if not parent and self.append_to:
- parent = self.find_parent_based_on_subject_and_sender(communication, email)
-
- if not parent and self.append_to and self.append_to!="Communication":
- parent = self.create_new_parent(communication, email)
-
- if parent:
- communication.reference_doctype = parent.doctype
- communication.reference_name = parent.name
-
- # check if message is notification and disable notifications for this message
- isnotification = email.mail.get("isnotification")
- if isnotification:
- if "notification" in isnotification:
- communication.unread_notification_sent = 1
-
- def set_sender_field_and_subject_field(self):
- '''Identify the sender and subject fields from the `append_to` DocType'''
- # set subject_field and sender_field
- meta = frappe.get_meta(self.append_to)
- self.subject_field = None
- self.sender_field = None
-
- if hasattr(meta, "subject_field"):
- self.subject_field = meta.subject_field
-
- if hasattr(meta, "sender_field"):
- self.sender_field = meta.sender_field
-
- def find_parent_based_on_subject_and_sender(self, communication, email):
- '''Find parent document based on subject and sender match'''
- parent = None
-
- if self.append_to and self.sender_field:
- if self.subject_field:
- if '#' in email.subject:
- # try and match if ID is found
- # document ID is appended to subject
- # example "Re: Your email (#OPP-2020-2334343)"
- parent_id = email.subject.rsplit('#', 1)[-1].strip(' ()')
- if parent_id:
- parent = frappe.db.get_all(self.append_to, filters = dict(name = parent_id),
- fields = 'name')
-
- if not parent:
- # try and match by subject and sender
- # if sent by same sender with same subject,
- # append it to old coversation
- subject = frappe.as_unicode(strip(re.sub(r"(^\s*(fw|fwd|wg)[^:]*:|\s*(re|aw)[^:]*:\s*)*",
- "", email.subject, 0, flags=re.IGNORECASE)))
-
- parent = frappe.db.get_all(self.append_to, filters={
- self.sender_field: email.from_email,
- self.subject_field: ("like", "%{0}%".format(subject)),
- "creation": (">", (get_datetime() - relativedelta(days=60)).strftime(DATE_FORMAT))
- }, fields = "name", limit = 1)
-
- if not parent and len(subject) > 10 and is_system_user(email.from_email):
- # match only subject field
- # when the from_email is of a user in the system
- # and subject is atleast 10 chars long
- parent = frappe.db.get_all(self.append_to, filters={
- self.subject_field: ("like", "%{0}%".format(subject)),
- "creation": (">", (get_datetime() - relativedelta(days=60)).strftime(DATE_FORMAT))
- }, fields = "name", limit = 1)
-
-
-
- if parent:
- parent = frappe._dict(doctype=self.append_to, name=parent[0].name)
- return parent
-
- def create_new_parent(self, communication, email):
- '''If no parent found, create a new reference document'''
-
- # no parent found, but must be tagged
- # insert parent type doc
- parent = frappe.new_doc(self.append_to)
-
- if self.subject_field:
- parent.set(self.subject_field, frappe.as_unicode(email.subject)[:140])
-
- if self.sender_field:
- parent.set(self.sender_field, frappe.as_unicode(email.from_email))
-
- if parent.meta.has_field("email_account"):
- parent.email_account = self.name
-
- parent.flags.ignore_mandatory = True
-
- try:
- parent.insert(ignore_permissions=True)
- except frappe.DuplicateEntryError:
- # try and find matching parent
- parent_name = frappe.db.get_value(self.append_to, {self.sender_field: email.from_email})
- if parent_name:
- parent.name = parent_name
- else:
- parent = None
-
- # NOTE if parent isn't found and there's no subject match, it is likely that it is a new conversation thread and hence is_first = True
- communication.is_first = True
-
- return parent
-
- def find_parent_from_in_reply_to(self, communication, email):
- '''Returns parent reference if embedded in In-Reply-To header
-
- Message-ID is formatted as `{message_id}@{site}`'''
- parent = None
- in_reply_to = (email.mail.get("In-Reply-To") or "").strip(" <>")
-
- if in_reply_to:
- if "@{0}".format(frappe.local.site) in in_reply_to:
- # reply to a communication sent from the system
- email_queue = frappe.db.get_value('Email Queue', dict(message_id=in_reply_to), ['communication','reference_doctype', 'reference_name'])
- if email_queue:
- parent_communication, parent_doctype, parent_name = email_queue
- if parent_communication:
- communication.in_reply_to = parent_communication
- else:
- reference, domain = in_reply_to.split("@", 1)
- parent_doctype, parent_name = 'Communication', reference
-
- if frappe.db.exists(parent_doctype, parent_name):
- parent = frappe._dict(doctype=parent_doctype, name=parent_name)
-
- # set in_reply_to of current communication
- if parent_doctype=='Communication':
- # communication.in_reply_to = email_queue.communication
-
- if parent.reference_name:
- # the true parent is the communication parent
- parent = frappe.get_doc(parent.reference_doctype,
- parent.reference_name)
- else:
- comm = frappe.db.get_value('Communication',
- dict(
- message_id=in_reply_to,
- creation=['>=', add_days(get_datetime(), -30)]),
- ['reference_doctype', 'reference_name'], as_dict=1)
- if comm:
- parent = frappe._dict(doctype=comm.reference_doctype, name=comm.reference_name)
-
- return parent
-
def send_auto_reply(self, communication, email):
"""Send auto reply if set."""
+ from frappe.core.doctype.communication.email import set_incoming_outgoing_accounts
if self.enable_auto_reply:
set_incoming_outgoing_accounts(communication)
- if self.send_unsubscribe_message:
- unsubscribe_message = _("Leave this conversation")
- else:
- unsubscribe_message = ""
+ unsubscribe_message = (self.send_unsubscribe_message and _("Leave this conversation")) or ""
frappe.sendmail(recipients = [email.from_email],
sender = self.email_id,
reply_to = communication.incoming_email_account,
- subject = _("Re: ") + communication.subject,
+ subject = " ".join([_("Re:"), communication.subject]),
content = render_template(self.auto_reply_message or "", communication.as_dict()) or \
frappe.get_template("templates/emails/auto_reply.html").render(communication.as_dict()),
reference_doctype = communication.reference_doctype,
@@ -646,9 +546,11 @@ class EmailAccount(Document):
def on_trash(self):
"""Clear communications where email account is linked"""
- from frappe.core.doctype.user.user import remove_user_email_inbox
+ Communication = frappe.qb.DocType("Communication")
+ frappe.qb.update(Communication) \
+ .set(Communication.email_account, "") \
+ .where(Communication.email_account == self.name).run()
- frappe.db.sql("update `tabCommunication` set email_account='' where email_account=%s", self.name)
remove_user_email_inbox(email_account=self.name)
def after_rename(self, old, new, merge=False):
@@ -665,27 +567,30 @@ class EmailAccount(Document):
else:
return self.email_sync_option or "UNSEEN"
- def mark_emails_as_read_unread(self):
+ def mark_emails_as_read_unread(self, email_server=None, folder_name="INBOX"):
""" mark Email Flag Queue of self.email_account mails as read"""
-
if not self.use_imap:
return
- flags = frappe.db.sql("""select name, communication, uid, action from
- `tabEmail Flag Queue` where is_completed=0 and email_account={email_account}
- """.format(email_account=frappe.db.escape(self.name)), as_dict=True)
+ EmailFlagQ = frappe.qb.DocType("Email Flag Queue")
+ flags = (
+ frappe.qb.from_(EmailFlagQ)
+ .select(EmailFlagQ.name, EmailFlagQ.communication, EmailFlagQ.uid, EmailFlagQ.action)
+ .where(EmailFlagQ.is_completed == 0)
+ .where(EmailFlagQ.email_account == frappe.db.escape(self.name))
+ ).run(as_dict=True)
uid_list = { flag.get("uid", None): flag.get("action", "Read") for flag in flags }
if flags and uid_list:
- email_server = self.get_incoming_server()
+ if not email_server:
+ email_server = self.get_incoming_server()
if not email_server:
return
-
- email_server.update_flag(uid_list=uid_list)
+ email_server.update_flag(folder_name, uid_list=uid_list)
# mark communication as read
- docnames = ",".join([ "'%s'"%flag.get("communication") for flag in flags \
- if flag.get("action") == "Read" ])
+ docnames = ",".join("'%s'"%flag.get("communication") for flag in flags \
+ if flag.get("action") == "Read")
self.set_communication_seen_status(docnames, seen=1)
# mark communication as unread
@@ -694,16 +599,20 @@ class EmailAccount(Document):
self.set_communication_seen_status(docnames, seen=0)
docnames = ",".join([ "'%s'"%flag.get("name") for flag in flags ])
- frappe.db.sql(""" update `tabEmail Flag Queue` set is_completed=1
- where name in ({docnames})""".format(docnames=docnames))
+
+ EmailFlagQueue = frappe.qb.DocType("Email Flag Queue")
+ frappe.qb.update(EmailFlagQueue) \
+ .set(EmailFlagQueue.is_completed, 1) \
+ .where(EmailFlagQueue.name.isin(docnames)).run()
def set_communication_seen_status(self, docnames, seen=0):
""" mark Email Flag Queue of self.email_account mails as read"""
if not docnames:
return
-
- frappe.db.sql(""" update `tabCommunication` set seen={seen}
- where name in ({docnames})""".format(docnames=docnames, seen=seen))
+ Communication = frappe.qb.from_("Communication")
+ frappe.qb.update(Communication) \
+ .set(Communication.seen == seen) \
+ .where(Communication.name.isin(docnames)).run()
def check_automatic_linking_email_account(self):
if self.enable_automatic_linking:
@@ -713,9 +622,7 @@ class EmailAccount(Document):
if frappe.db.exists("Email Account", {"enable_automatic_linking": 1, "name": ('!=', self.name)}):
frappe.throw(_("Automatic Linking can be activated only for one Email Account."))
-
def append_email_to_sent_folder(self, message):
-
email_server = None
try:
email_server = self.get_incoming_server(in_receive=True)
@@ -729,9 +636,11 @@ class EmailAccount(Document):
if email_server.imap:
try:
- email_server.imap.append("Sent", "\\Seen", imaplib.Time2Internaldate(time.time()), message.encode())
+ message = safe_encode(message)
+ email_server.imap.append("Sent", "\\Seen", imaplib.Time2Internaldate(time.time()), message)
except Exception:
- frappe.log_error()
+ frappe.log_error(title="EmailAccount.append_email_to_sent_folder")
+
@frappe.whitelist()
def get_append_to(doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None):
@@ -769,15 +678,19 @@ def test_internet(host="8.8.8.8", port=53, timeout=3):
def notify_unreplied():
"""Sends email notifications if there are unreplied Communications
and `notify_if_unreplied` is set as true."""
-
for email_account in frappe.get_all("Email Account", "name", filters={"enable_incoming": 1, "notify_if_unreplied": 1}):
email_account = frappe.get_doc("Email Account", email_account.name)
- if email_account.append_to:
+ if email_account.use_imap:
+ append_to = [folder.get("append_to") for folder in email_account.imap_folder]
+ else:
+ append_to = email_account.append_to
+
+ if append_to:
# get open communications younger than x mins, for given doctype
for comm in frappe.get_all("Communication", "name", filters=[
{"sent_or_received": "Received"},
- {"reference_doctype": email_account.append_to},
+ {"reference_doctype": ("in", append_to)},
{"unread_notification_sent": 0},
{"email_account":email_account.name},
{"creation": ("<", datetime.now() - timedelta(seconds = (email_account.unreplied_for_mins or 30) * 60))},
@@ -820,9 +733,6 @@ def pull_from_email_account(email_account):
email_account = frappe.get_doc("Email Account", email_account)
email_account.receive()
- # mark Email Flag Queue mail as read
- email_account.mark_emails_as_read_unread()
-
def get_max_email_uid(email_account):
# get maximum uid of emails
max_uid = 1
@@ -838,3 +748,84 @@ def get_max_email_uid(email_account):
else:
max_uid = cint(result[0].get("uid", 0)) + 1
return max_uid
+
+
+def setup_user_email_inbox(email_account, awaiting_password, email_id, enable_outgoing):
+ """ setup email inbox for user """
+ from frappe.core.doctype.user.user import ask_pass_update
+
+ def add_user_email(user):
+ user = frappe.get_doc("User", user)
+ row = user.append("user_emails", {})
+
+ row.email_id = email_id
+ row.email_account = email_account
+ row.awaiting_password = awaiting_password or 0
+ row.enable_outgoing = enable_outgoing or 0
+
+ user.save(ignore_permissions=True)
+
+ update_user_email_settings = False
+ if not all([email_account, email_id]):
+ return
+
+ user_names = frappe.db.get_values("User", {"email": email_id}, as_dict=True)
+ if not user_names:
+ return
+
+ for user in user_names:
+ user_name = user.get("name")
+
+ # check if inbox is alreay configured
+ user_inbox = frappe.db.get_value("User Email", {
+ "email_account": email_account,
+ "parent": user_name
+ }, ["name"]) or None
+
+ if not user_inbox:
+ add_user_email(user_name)
+ else:
+ # update awaiting password for email account
+ update_user_email_settings = True
+
+ if update_user_email_settings:
+ UserEmail = frappe.qb.DocType("User Email")
+ frappe.qb.update(UserEmail) \
+ .set(UserEmail.awaiting_password, (awaiting_password or 0)) \
+ .set(UserEmail.enable_outgoing, enable_outgoing) \
+ .where(UserEmail.email_account == email_account).run()
+
+ else:
+ users = " and ".join([frappe.bold(user.get("name")) for user in user_names])
+ frappe.msgprint(_("Enabled email inbox for user {0}").format(users))
+ ask_pass_update()
+
+def remove_user_email_inbox(email_account):
+ """ remove user email inbox settings if email account is deleted """
+ if not email_account:
+ return
+
+ users = frappe.get_all("User Email", filters={
+ "email_account": email_account
+ }, fields=["parent as name"])
+
+ for user in users:
+ doc = frappe.get_doc("User", user.get("name"))
+ to_remove = [row for row in doc.user_emails if row.email_account == email_account]
+ [doc.remove(row) for row in to_remove]
+
+ doc.save(ignore_permissions=True)
+
+@frappe.whitelist(allow_guest=False)
+def set_email_password(email_account, user, password):
+ account = frappe.get_doc("Email Account", email_account)
+ if account.awaiting_password:
+ account.awaiting_password = 0
+ account.password = password
+ try:
+ account.save(ignore_permissions=True)
+ except Exception:
+ frappe.db.rollback()
+ return False
+
+ return True
diff --git a/frappe/email/doctype/email_account/test_email_account.py b/frappe/email/doctype/email_account/test_email_account.py
index f87ee32bb1..f609c2947d 100644
--- a/frappe/email/doctype/email_account/test_email_account.py
+++ b/frappe/email/doctype/email_account/test_email_account.py
@@ -1,45 +1,68 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
-import frappe, os
-import unittest, email
+import os
+import email
+import unittest
+from datetime import datetime, timedelta
+from frappe.email.receive import InboundMail, SentEmailInInboxError, Email
+from frappe.email.email_body import get_message_id
+import frappe
from frappe.test_runner import make_test_records
+from frappe.core.doctype.communication.email import make
+from frappe.desk.form.load import get_attachments
+from frappe.email.doctype.email_account.email_account import notify_unreplied
+
+from unittest.mock import patch
make_test_records("User")
make_test_records("Email Account")
-from frappe.core.doctype.communication.email import make
-from frappe.desk.form.load import get_attachments
-from frappe.email.doctype.email_account.email_account import notify_unreplied
-from datetime import datetime, timedelta
-
class TestEmailAccount(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ email_account.db_set("enable_incoming", 1)
+ email_account.db_set("enable_auto_reply", 1)
+ email_account.db_set("use_imap", 1)
+
+ @classmethod
+ def tearDownClass(cls):
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ email_account.db_set("enable_incoming", 0)
+
def setUp(self):
frappe.flags.mute_emails = False
frappe.flags.sent_mail = None
+ frappe.db.delete("Email Queue")
+ frappe.db.delete("Unhandled Email")
- email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
- email_account.db_set("enable_incoming", 1)
- frappe.db.sql('delete from `tabEmail Queue`')
-
- def tearDown(self):
- email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
- email_account.db_set("enable_incoming", 0)
+ def get_test_mail(self, fname):
+ with open(os.path.join(os.path.dirname(__file__), "test_mails", fname), "r") as f:
+ return f.read()
def test_incoming(self):
cleanup("test_sender@example.com")
- with open(os.path.join(os.path.dirname(__file__), "test_mails", "incoming-1.raw"), "r") as f:
- test_mails = [f.read()]
+ messages = {
+ # append_to = ToDo
+ '"INBOX"': {
+ 'latest_messages': [
+ self.get_test_mail('incoming-1.raw')
+ ],
+ 'seen_status': {
+ 2: 'UNSEEN'
+ },
+ 'uid_list': [2]
+ }
+ }
email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
- email_account.receive(test_mails=test_mails)
+ TestEmailAccount.mocked_email_receive(email_account, messages)
comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"})
self.assertTrue("test_receiver@example.com" in comm.recipients)
-
# check if todo is created
self.assertTrue(frappe.db.get_value(comm.reference_doctype, comm.reference_name, "name"))
@@ -49,7 +72,7 @@ class TestEmailAccount(unittest.TestCase):
comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"})
comm.db_set("creation", datetime.now() - timedelta(seconds = 30 * 60))
- frappe.db.sql("DELETE FROM `tabEmail Queue`")
+ frappe.db.delete("Email Queue")
notify_unreplied()
self.assertTrue(frappe.db.get_value("Email Queue", {"reference_doctype": comm.reference_doctype,
"reference_name": comm.reference_name, "status":"Not Sent"}))
@@ -60,11 +83,21 @@ class TestEmailAccount(unittest.TestCase):
existing_file = frappe.get_doc({'doctype': 'File', 'file_name': 'erpnext-conf-14.png'})
frappe.delete_doc("File", existing_file.name)
- with open(os.path.join(os.path.dirname(__file__), "test_mails", "incoming-2.raw"), "r") as testfile:
- test_mails = [testfile.read()]
+ messages = {
+ # append_to = ToDo
+ '"INBOX"': {
+ 'latest_messages': [
+ self.get_test_mail('incoming-2.raw')
+ ],
+ 'seen_status': {
+ 2: 'UNSEEN'
+ },
+ 'uid_list': [2]
+ }
+ }
email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
- email_account.receive(test_mails=test_mails)
+ TestEmailAccount.mocked_email_receive(email_account, messages)
comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"})
self.assertTrue("test_receiver@example.com" in comm.recipients)
@@ -81,27 +114,47 @@ class TestEmailAccount(unittest.TestCase):
def test_incoming_attached_email_from_outlook_plain_text_only(self):
cleanup("test_sender@example.com")
- with open(os.path.join(os.path.dirname(__file__), "test_mails", "incoming-3.raw"), "r") as f:
- test_mails = [f.read()]
+ messages = {
+ # append_to = ToDo
+ '"INBOX"': {
+ 'latest_messages': [
+ self.get_test_mail('incoming-3.raw')
+ ],
+ 'seen_status': {
+ 2: 'UNSEEN'
+ },
+ 'uid_list': [2]
+ }
+ }
email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
- email_account.receive(test_mails=test_mails)
+ TestEmailAccount.mocked_email_receive(email_account, messages)
comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"})
- self.assertTrue("From: \"Microsoft Outlook\" <test_sender@example.com>" in comm.content)
+ self.assertTrue("From: "Microsoft Outlook" <test_sender@example.com>" in comm.content)
self.assertTrue("This is an e-mail message sent automatically by Microsoft Outlook while" in comm.content)
def test_incoming_attached_email_from_outlook_layers(self):
cleanup("test_sender@example.com")
- with open(os.path.join(os.path.dirname(__file__), "test_mails", "incoming-4.raw"), "r") as f:
- test_mails = [f.read()]
+ messages = {
+ # append_to = ToDo
+ '"INBOX"': {
+ 'latest_messages': [
+ self.get_test_mail('incoming-4.raw')
+ ],
+ 'seen_status': {
+ 2: 'UNSEEN'
+ },
+ 'uid_list': [2]
+ }
+ }
email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
- email_account.receive(test_mails=test_mails)
+ TestEmailAccount.mocked_email_receive(email_account, messages)
comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"})
- self.assertTrue("From: \"Microsoft Outlook\" <test_sender@example.com>" in comm.content)
+ self.assertTrue("From: "Microsoft Outlook" <test_sender@example.com>" in comm.content)
self.assertTrue("This is an e-mail message sent automatically by Microsoft Outlook while" in comm.content)
def test_outgoing(self):
@@ -139,11 +192,23 @@ class TestEmailAccount(unittest.TestCase):
with open(os.path.join(os.path.dirname(__file__), "test_mails", "reply-1.raw"), "r") as f:
raw = f.read()
raw = raw.replace("<-- in-reply-to -->", sent_mail.get("Message-Id"))
- test_mails = [raw]
# parse reply
+ messages = {
+ # append_to = ToDo
+ '"INBOX"': {
+ 'latest_messages': [
+ raw
+ ],
+ 'seen_status': {
+ 2: 'UNSEEN'
+ },
+ 'uid_list': [2]
+ }
+ }
+
email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
- email_account.receive(test_mails=test_mails)
+ TestEmailAccount.mocked_email_receive(email_account, messages)
sent = frappe.get_doc("Communication", sent_name)
@@ -161,19 +226,30 @@ class TestEmailAccount(unittest.TestCase):
test_mails.append(f.read())
# parse reply
+ messages = {
+ # append_to = ToDo
+ '"INBOX"': {
+ 'latest_messages': test_mails,
+ 'seen_status': {
+ 2: 'UNSEEN',
+ 3: 'UNSEEN'
+ },
+ 'uid_list': [2, 3]
+ }
+ }
+
email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
- email_account.receive(test_mails=test_mails)
+ TestEmailAccount.mocked_email_receive(email_account, messages)
comm_list = frappe.get_all("Communication", filters={"sender":"test_sender@example.com"},
fields=["name", "reference_doctype", "reference_name"])
-
# both communications attached to the same reference
self.assertEqual(comm_list[0].reference_doctype, comm_list[1].reference_doctype)
self.assertEqual(comm_list[0].reference_name, comm_list[1].reference_name)
def test_threading_by_message_id(self):
cleanup()
- frappe.db.sql("""delete from `tabEmail Queue`""")
+ frappe.db.delete("Email Queue")
# reference document for testing
event = frappe.get_doc(dict(doctype='Event', subject='test-message')).insert()
@@ -186,11 +262,22 @@ class TestEmailAccount(unittest.TestCase):
# get test mail with message-id as in-reply-to
with open(os.path.join(os.path.dirname(__file__), "test_mails", "reply-4.raw"), "r") as f:
- test_mails = [f.read().replace('{{ message_id }}', last_mail.message_id)]
+ messages = {
+ # append_to = ToDo
+ '"INBOX"': {
+ 'latest_messages': [
+ f.read().replace('{{ message_id }}', last_mail.message_id)
+ ],
+ 'seen_status': {
+ 2: 'UNSEEN'
+ },
+ 'uid_list': [2]
+ }
+ }
# pull the mail
email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
- email_account.receive(test_mails=test_mails)
+ TestEmailAccount.mocked_email_receive(email_account, messages)
comm_list = frappe.get_all("Communication", filters={"sender":"test_sender@example.com"},
fields=["name", "reference_doctype", "reference_name"])
@@ -199,6 +286,327 @@ class TestEmailAccount(unittest.TestCase):
self.assertEqual(comm_list[0].reference_doctype, event.doctype)
self.assertEqual(comm_list[0].reference_name, event.name)
+ def test_auto_reply(self):
+ cleanup("test_sender@example.com")
+
+ messages = {
+ # append_to = ToDo
+ '"INBOX"': {
+ 'latest_messages': [
+ self.get_test_mail('incoming-1.raw')
+ ],
+ 'seen_status': {
+ 2: 'UNSEEN'
+ },
+ 'uid_list': [2]
+ }
+ }
+
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ TestEmailAccount.mocked_email_receive(email_account, messages)
+
+ comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"})
+ self.assertTrue(frappe.db.get_value("Email Queue", {"reference_doctype": comm.reference_doctype,
+ "reference_name": comm.reference_name}))
+
+ def test_handle_bad_emails(self):
+ mail_content = self.get_test_mail(fname="incoming-1.raw")
+ message_id = Email(mail_content).mail.get('Message-ID')
+
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ email_account.handle_bad_emails(uid=-1, raw=mail_content, reason="Testing")
+ self.assertTrue(frappe.db.get_value("Unhandled Email", {'message_id': message_id}))
+
+ def test_imap_folder(self):
+ # assert tests if imap_folder >= 1 and imap is checked
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+
+ self.assertTrue(email_account.use_imap)
+ self.assertTrue(email_account.enable_incoming)
+ self.assertTrue(len(email_account.imap_folder) > 0)
+
+ def test_imap_folder_missing(self):
+ # Test the Exception in validate() that verifies the imap_folder list
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ email_account.imap_folder = []
+
+ with self.assertRaises(Exception):
+ email_account.validate()
+
+ def test_append_to(self):
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ mail_content = self.get_test_mail(fname="incoming-2.raw")
+
+ inbound_mail = InboundMail(mail_content, email_account, 12345, 1, 'ToDo')
+ communication = inbound_mail.process()
+ # the append_to for the email is set to ToDO in "_Test Email Account 1"
+ self.assertEqual(communication.reference_doctype, 'ToDo')
+ self.assertTrue(communication.reference_name)
+ self.assertTrue(frappe.db.exists(communication.reference_doctype, communication.reference_name))
+
+ def test_append_to_with_imap_folders(self):
+ mail_content_1 = self.get_test_mail(fname="incoming-1.raw")
+ mail_content_2 = self.get_test_mail(fname="incoming-2.raw")
+ mail_content_3 = self.get_test_mail(fname="incoming-3.raw")
+
+ messages = {
+ # append_to = ToDo
+ '"INBOX"': {
+ 'latest_messages': [
+ mail_content_1,
+ mail_content_2
+ ],
+ 'seen_status': {
+ 0: 'UNSEEN',
+ 1: 'UNSEEN'
+ },
+ 'uid_list': [0,1]
+ },
+ # append_to = Communication
+ '"Test Folder"': {
+ 'latest_messages': [
+ mail_content_3
+ ],
+ 'seen_status': {
+ 2: 'UNSEEN'
+ },
+ 'uid_list': [2]
+ }
+ }
+
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ mails = TestEmailAccount.mocked_get_inbound_mails(email_account, messages)
+ self.assertEqual(len(mails), 3)
+
+ inbox_mails = 0
+ test_folder_mails = 0
+
+ for mail in mails:
+ communication = mail.process()
+ if mail.append_to == 'ToDo':
+ inbox_mails += 1
+ self.assertEqual(communication.reference_doctype, 'ToDo')
+ self.assertTrue(communication.reference_name)
+ self.assertTrue(frappe.db.exists(communication.reference_doctype, communication.reference_name))
+ else:
+ test_folder_mails += 1
+ self.assertEqual(communication.reference_doctype, None)
+
+ self.assertEqual(inbox_mails, 2)
+ self.assertEqual(test_folder_mails, 1)
+
+ @patch("frappe.email.receive.EmailServer.select_imap_folder", return_value=True)
+ @patch("frappe.email.receive.EmailServer.logout", side_effect=lambda: None)
+ def mocked_get_inbound_mails(email_account, messages={}, mocked_logout=None, mocked_select_imap_folder=None):
+ from frappe.email.receive import EmailServer
+
+ def get_mocked_messages(**kwargs):
+ return messages.get(kwargs["folder"], {})
+
+ with patch.object(EmailServer, "get_messages", side_effect=get_mocked_messages):
+ mails = email_account.get_inbound_mails()
+
+ return mails
+
+ @patch("frappe.email.receive.EmailServer.select_imap_folder", return_value=True)
+ @patch("frappe.email.receive.EmailServer.logout", side_effect=lambda: None)
+ def mocked_email_receive(email_account, messages={}, mocked_logout=None, mocked_select_imap_folder=None):
+ def get_mocked_messages(**kwargs):
+ return messages.get(kwargs["folder"], {})
+
+ from frappe.email.receive import EmailServer
+ with patch.object(EmailServer, "get_messages", side_effect=get_mocked_messages):
+ email_account.receive()
+
+class TestInboundMail(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ email_account.db_set("enable_incoming", 1)
+
+ @classmethod
+ def tearDownClass(cls):
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ email_account.db_set("enable_incoming", 0)
+
+ def setUp(self):
+ cleanup()
+ frappe.db.delete("Email Queue")
+ frappe.db.delete("ToDo")
+
+ def get_test_mail(self, fname):
+ with open(os.path.join(os.path.dirname(__file__), "test_mails", fname), "r") as f:
+ return f.read()
+
+ def new_doc(self, doctype, **data):
+ doc = frappe.new_doc(doctype)
+ for field, value in data.items():
+ setattr(doc, field, value)
+ doc.insert()
+ return doc
+
+ def new_communication(self, **kwargs):
+ defaults = {
+ 'subject': "Test Subject"
+ }
+ d = {**defaults, **kwargs}
+ return self.new_doc('Communication', **d)
+
+ def new_email_queue(self, **kwargs):
+ defaults = {
+ 'message_id': get_message_id().strip(" <>")
+ }
+ d = {**defaults, **kwargs}
+ return self.new_doc('Email Queue', **d)
+
+ def new_todo(self, **kwargs):
+ defaults = {
+ 'description': "Description"
+ }
+ d = {**defaults, **kwargs}
+ return self.new_doc('ToDo', **d)
+
+ def test_self_sent_mail(self):
+ """Check that we raise SentEmailInInboxError if the inbound mail is self sent mail.
+ """
+ mail_content = self.get_test_mail(fname="incoming-self-sent.raw")
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ inbound_mail = InboundMail(mail_content, email_account, 1, 1)
+ with self.assertRaises(SentEmailInInboxError):
+ inbound_mail.process()
+
+ def test_mail_exist_validation(self):
+ """Do not create communication record if the mail is already downloaded into the system.
+ """
+ mail_content = self.get_test_mail(fname="incoming-1.raw")
+ message_id = Email(mail_content).message_id
+ # Create new communication record in DB
+ communication = self.new_communication(message_id=message_id)
+
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ inbound_mail = InboundMail(mail_content, email_account, 12345, 1)
+ new_communication = inbound_mail.process()
+
+ # Make sure that uid is changed to new uid
+ self.assertEqual(new_communication.uid, 12345)
+ self.assertEqual(communication.name, new_communication.name)
+
+ def test_find_parent_email_queue(self):
+ """If the mail is reply to the already sent mail, there will be a email queue record.
+ """
+ # Create email queue record
+ queue_record = self.new_email_queue()
+
+ mail_content = self.get_test_mail(fname="reply-4.raw").replace(
+ "{{ message_id }}", queue_record.message_id
+ )
+
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ inbound_mail = InboundMail(mail_content, email_account, 12345, 1)
+ parent_queue = inbound_mail.parent_email_queue()
+ self.assertEqual(queue_record.name, parent_queue.name)
+
+ def test_find_parent_communication_through_queue(self):
+ """Find parent communication of an inbound mail.
+ Cases where parent communication does exist:
+ 1. No parent communication is the mail is not a reply.
+
+ Cases where parent communication does not exist:
+ 2. If mail is not a reply to system sent mail, then there can exist co
+ """
+ # Create email queue record
+ communication = self.new_communication()
+ queue_record = self.new_email_queue(communication=communication.name)
+ mail_content = self.get_test_mail(fname="reply-4.raw").replace(
+ "{{ message_id }}", queue_record.message_id
+ )
+
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ inbound_mail = InboundMail(mail_content, email_account, 12345, 1)
+ parent_communication = inbound_mail.parent_communication()
+ self.assertEqual(parent_communication.name, communication.name)
+
+ def test_find_parent_communication_for_self_reply(self):
+ """If the inbound email is a reply but not reply to system sent mail.
+
+ Ex: User replied to his/her mail.
+ """
+ message_id = "new-message-id"
+ mail_content = self.get_test_mail(fname="reply-4.raw").replace(
+ "{{ message_id }}", message_id
+ )
+
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ inbound_mail = InboundMail(mail_content, email_account, 12345, 1)
+ parent_communication = inbound_mail.parent_communication()
+ self.assertFalse(parent_communication)
+
+ communication = self.new_communication(message_id=message_id)
+ inbound_mail = InboundMail(mail_content, email_account, 12345, 1)
+ parent_communication = inbound_mail.parent_communication()
+ self.assertEqual(parent_communication.name, communication.name)
+
+ def test_find_parent_communication_from_header(self):
+ """Incase of header contains parent communication name
+ """
+ communication = self.new_communication()
+ mail_content = self.get_test_mail(fname="reply-4.raw").replace(
+ "{{ message_id }}", f"<{communication.name}@{frappe.local.site}>"
+ )
+
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ inbound_mail = InboundMail(mail_content, email_account, 12345, 1)
+ parent_communication = inbound_mail.parent_communication()
+ self.assertEqual(parent_communication.name, communication.name)
+
+ def test_reference_document(self):
+ # Create email queue record
+ todo = self.new_todo()
+ # communication = self.new_communication(reference_doctype='ToDo', reference_name=todo.name)
+ queue_record = self.new_email_queue(reference_doctype='ToDo', reference_name=todo.name)
+ mail_content = self.get_test_mail(fname="reply-4.raw").replace(
+ "{{ message_id }}", queue_record.message_id
+ )
+
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ inbound_mail = InboundMail(mail_content, email_account, 12345, 1)
+ reference_doc = inbound_mail.reference_document()
+ self.assertEqual(todo.name, reference_doc.name)
+
+ def test_reference_document_by_record_name_in_subject(self):
+ # Create email queue record
+ todo = self.new_todo()
+
+ mail_content = self.get_test_mail(fname="incoming-subject-placeholder.raw").replace(
+ "{{ subject }}", f"RE: (#{todo.name})"
+ )
+
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ inbound_mail = InboundMail(mail_content, email_account, 12345, 1)
+ reference_doc = inbound_mail.reference_document()
+ self.assertEqual(todo.name, reference_doc.name)
+
+ def test_reference_document_by_subject_match(self):
+ subject = "New todo"
+ todo = self.new_todo(sender='test_sender@example.com', description=subject)
+
+ mail_content = self.get_test_mail(fname="incoming-subject-placeholder.raw").replace(
+ "{{ subject }}", f"RE: {subject}"
+ )
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ inbound_mail = InboundMail(mail_content, email_account, 12345, 1)
+ reference_doc = inbound_mail.reference_document()
+ self.assertEqual(todo.name, reference_doc.name)
+
+ def test_create_communication_from_mail(self):
+ # Create email queue record
+ mail_content = self.get_test_mail(fname="incoming-2.raw")
+ email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
+ inbound_mail = InboundMail(mail_content, email_account, 12345, 1)
+ communication = inbound_mail.process()
+ self.assertTrue(communication.is_first)
+ self.assertTrue(communication._attachments)
+
def cleanup(sender=None):
filters = {}
if sender:
@@ -207,4 +615,4 @@ def cleanup(sender=None):
names = frappe.get_list("Communication", filters=filters, fields=["name"])
for name in names:
frappe.delete_doc_if_exists("Communication", name.name)
- frappe.delete_doc_if_exists("Communication Link", {"parent": name.name})
\ No newline at end of file
+ frappe.delete_doc_if_exists("Communication Link", {"parent": name.name})
diff --git a/frappe/email/doctype/email_account/test_mails/incoming-self-sent.raw b/frappe/email/doctype/email_account/test_mails/incoming-self-sent.raw
new file mode 100644
index 0000000000..a16eecccd5
--- /dev/null
+++ b/frappe/email/doctype/email_account/test_mails/incoming-self-sent.raw
@@ -0,0 +1,91 @@
+Delivered-To: test_receiver@example.com
+Received: by 10.96.153.227 with SMTP id vj3csp416144qdb;
+ Mon, 15 Sep 2014 03:35:07 -0700 (PDT)
+X-Received: by 10.66.119.103 with SMTP id kt7mr36981968pab.95.1410777306321;
+ Mon, 15 Sep 2014 03:35:06 -0700 (PDT)
+Return-Path:
+Received: from mail-pa0-x230.google.com (mail-pa0-x230.google.com [2607:f8b0:400e:c03::230])
+ by mx.google.com with ESMTPS id dg10si22178346pdb.115.2014.09.15.03.35.06
+ for
+ (version=TLSv1 cipher=ECDHE-RSA-RC4-SHA bits=128/128);
+ Mon, 15 Sep 2014 03:35:06 -0700 (PDT)
+Received-SPF: pass (google.com: domain of test@example.com designates 2607:f8b0:400e:c03::230 as permitted sender) client-ip=2607:f8b0:400e:c03::230;
+Authentication-Results: mx.google.com;
+ spf=pass (google.com: domain of test@example.com designates 2607:f8b0:400e:c03::230 as permitted sender) smtp.mail=test@example.com;
+ dkim=pass header.i=@gmail.com;
+ dmarc=pass (p=NONE dis=NONE) header.from=gmail.com
+Received: by mail-pa0-f48.google.com with SMTP id hz1so6118714pad.21
+ for ; Mon, 15 Sep 2014 03:35:06 -0700 (PDT)
+DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
+ d=gmail.com; s=20120113;
+ h=from:content-type:subject:message-id:date:to:mime-version;
+ bh=rwiLijtF3lfy9M6cP/7dv2Hm7NJuBwFZn1OFsN8Tlvs=;
+ b=x7U4Ny3Kz2ULRJ7a04NDBrBTVhP2ImIB9n3LVNGQDnDonPUM5Ro/wZcxPTVnBWZ2L1
+ o1bGfP+lhBrvYUlHsd5r4FYC0Uvpad6hbzLr0DGUQgPTxW4cGKbtDEAq+BR2JWd9f803
+ vdjSWdGk8w2dt2qbngTqIZkm5U2XWjICDOAYuPIseLUgCFwi9lLyOSARFB7mjAa2YL7Q
+ Nswk7mbWU1hbnHP6jaBb0m8QanTc7Up944HpNDRxIrB1ZHgKzYhXtx8nhnOx588ZGIAe
+ E6tyG8IwogR11vLkkrBhtMaOme9PohYx4F1CSTiwspmDCadEzJFGRe//lEXKmZHAYH6g
+ 90Zg==
+X-Received: by 10.70.38.135 with SMTP id g7mr22078275pdk.100.1410777305744;
+ Mon, 15 Sep 2014 03:35:05 -0700 (PDT)
+Return-Path:
+Received: from [192.168.0.100] ([27.106.4.70])
+ by mx.google.com with ESMTPSA id zr6sm11025126pbc.50.2014.09.15.03.35.02
+ for
+ (version=TLSv1 cipher=ECDHE-RSA-RC4-SHA bits=128/128);
+ Mon, 15 Sep 2014 03:35:04 -0700 (PDT)
+From: Rushabh Mehta
+Content-Type: multipart/alternative; boundary="Apple-Mail=_57F71261-5C3A-43F6-918B-4438B96F61AA"
+Subject: test mail 🦄🌈😎
+Message-Id: <9143999C-8456-4399-9CF1-4A2DA9DD7711@gmail.com>
+Date: Mon, 15 Sep 2014 16:04:57 +0530
+To: Rushabh Mehta
+Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\))
+X-Mailer: Apple Mail (2.1878.6)
+
+
+--Apple-Mail=_57F71261-5C3A-43F6-918B-4438B96F61AA
+Content-Transfer-Encoding: 7bit
+Content-Type: text/plain;
+ charset=us-ascii
+
+test mail
+
+
+
+@rushabh_mehta
+https://erpnext.org
+
+
+--Apple-Mail=_57F71261-5C3A-43F6-918B-4438B96F61AA
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/html;
+ charset=us-ascii
+
+ test =
+mail
+ =
+
+--Apple-Mail=_57F71261-5C3A-43F6-918B-4438B96F61AA--
diff --git a/frappe/email/doctype/email_account/test_mails/incoming-subject-placeholder.raw b/frappe/email/doctype/email_account/test_mails/incoming-subject-placeholder.raw
new file mode 100644
index 0000000000..35ddf06b01
--- /dev/null
+++ b/frappe/email/doctype/email_account/test_mails/incoming-subject-placeholder.raw
@@ -0,0 +1,183 @@
+Return-path:
+Envelope-to: test_receiver@example.com
+Delivery-date: Wed, 27 Jan 2016 16:24:20 +0800
+Received: from 23-59-23-10.perm.iinet.net.au ([23.59.23.10]:62191 helo=DESKTOP7C66I2M)
+ by webcloud85.au.syrahost.com with esmtp (Exim 4.86)
+ (envelope-from )
+ id 1aOLOj-002xFL-CP
+ for test_receiver@example.com; Wed, 27 Jan 2016 16:24:20 +0800
+From:
+To:
+References:
+In-Reply-To:
+Subject: RE: {{ subject }}
+Date: Wed, 27 Jan 2016 16:24:09 +0800
+Message-ID: <000001d158dc$1b8363a0$528a2ae0$@example.com>
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="----=_NextPart_000_0001_01D1591F.29A7DC20"
+X-Mailer: Microsoft Outlook 14.0
+Thread-Index: AQJZfZxrgcB9KnMqoZ+S4Qq9hcoSeZ3+vGiQ
+Content-Language: en-au
+
+This is a multipart message in MIME format.
+
+------=_NextPart_000_0001_01D1591F.29A7DC20
+Content-Type: multipart/alternative;
+ boundary="----=_NextPart_001_0002_01D1591F.29A7DC20"
+
+
+------=_NextPart_001_0002_01D1591F.29A7DC20
+Content-Type: text/plain;
+ charset="utf-8"
+Content-Transfer-Encoding: quoted-printable
+
+Test purely for testing with the debugger has email attached
+
+=20
+
+From: Notification [mailto:test_receiver@example.com]=20
+Sent: Wednesday, 27 January 2016 9:30 AM
+To: test_receiver@example.com
+Subject: Sales Invoice: SINV-12276
+
+=20
+
+test no 6 sent from bench to outlook to be replied to with messaging
+
+
+
+
+------=_NextPart_001_0002_01D1591F.29A7DC20
+Content-Type: text/html;
+ charset="utf-8"
+Content-Transfer-Encoding: quoted-printable
+
+hi there Test purely for testing with the debugger has email =
+attached
From: =
+ =
+Notification [mailto:test_receiver@example.com] Sent: Wednesday, 27 =
+January 2016 9:30 AMTo: =
+test_receiver@example.comSubject: Sales Invoice: =
+SINV-12276
test no 3 sent from bench to outlook to be replied to with =
+messaging
fizz buzz
+------=_NextPart_001_0002_01D1591F.29A7DC20--
+
+------=_NextPart_000_0001_01D1591F.29A7DC20
+Content-Type: message/rfc822
+Content-Transfer-Encoding: 7bit
+Content-Disposition: attachment
+
+Received: from 203-59-223-10.perm.iinet.net.au ([23.59.23.10]:49772 helo=DESKTOP7C66I2M)
+ by webcloud85.au.syrahost.com with esmtpsa (TLSv1.2:DHE-RSA-AES256-GCM-SHA384:256)
+ (Exim 4.86)
+ (envelope-from )
+ id 1aOEtO-003tI4-Kv
+ for test_receiver@example.com; Wed, 27 Jan 2016 09:27:30 +0800
+Return-Path:
+From: "Microsoft Outlook"
+To:
+Subject: Microsoft Outlook Test Message
+MIME-Version: 1.0
+Content-Type: text/plain;
+ charset="utf-8"
+Content-Transfer-Encoding: quoted-printable
+X-Mailer: Microsoft Outlook 14.0
+Thread-Index: AdFYoeN8x8wUI/+QSoCJkp33NKPVmw==
+
+This is an e-mail message sent automatically by Microsoft Outlook while =
+testing the settings for your account.
diff --git a/frappe/email/doctype/email_account/test_records.json b/frappe/email/doctype/email_account/test_records.json
index fbe7d9c281..66eb5a9b2e 100644
--- a/frappe/email/doctype/email_account/test_records.json
+++ b/frappe/email/doctype/email_account/test_records.json
@@ -4,7 +4,6 @@
"is_global": 1,
"doctype": "Email Account",
"domain":"example.com",
- "append_to": "ToDo",
"email_account_name": "_Test Email Account 1",
"enable_outgoing": 1,
"smtp_server": "test.example.com",
@@ -19,7 +18,10 @@
"unreplied_for_mins": 20,
"send_notification_to": "test_unreplied@example.com",
"pop3_server": "pop.test.example.com",
- "no_remaining":"0"
+ "no_remaining":"0",
+ "append_to": "ToDo",
+ "imap_folder": [{"folder_name": "INBOX", "append_to": "ToDo"}, {"folder_name": "Test Folder", "append_to": "Communication"}],
+ "track_email_status": 1
},
{
"doctype": "ToDo",
diff --git a/frappe/email/doctype/email_domain/email_domain.py b/frappe/email/doctype/email_domain/email_domain.py
index ce39523564..1611d32351 100644
--- a/frappe/email/doctype/email_domain/email_domain.py
+++ b/frappe/email/doctype/email_domain/email_domain.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.model.document import Document
diff --git a/frappe/email/doctype/email_domain/test_email_domain.py b/frappe/email/doctype/email_domain/test_email_domain.py
index 1c5306e9c2..7522dd5282 100644
--- a/frappe/email/doctype/email_domain/test_email_domain.py
+++ b/frappe/email/doctype/email_domain/test_email_domain.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
from frappe.test_runner import make_test_objects
@@ -22,11 +20,13 @@ class TestDomain(unittest.TestCase):
mail_domain = frappe.get_doc("Email Domain", "test.com")
mail_account = frappe.get_doc("Email Account", "Test")
- # Initially, incoming_port is different in domain and account
- self.assertNotEqual(mail_account.incoming_port, mail_domain.incoming_port)
+ # Ensure a different port
+ mail_account.incoming_port = int(mail_domain.incoming_port) + 5
+ mail_account.save()
# Trigger update of accounts using this domain
mail_domain.on_update()
- mail_account = frappe.get_doc("Email Account", "Test")
+
+ mail_account.reload()
# After update, incoming_port in account should match the domain
self.assertEqual(mail_account.incoming_port, mail_domain.incoming_port)
diff --git a/frappe/email/doctype/email_domain/test_records.json b/frappe/email/doctype/email_domain/test_records.json
index 32bc66e150..a6ccc99f06 100644
--- a/frappe/email/doctype/email_domain/test_records.json
+++ b/frappe/email/doctype/email_domain/test_records.json
@@ -10,7 +10,8 @@
"incoming_port": "993",
"attachment_limit": "1",
"smtp_server": "smtp.test.com",
- "smtp_port": "587"
+ "smtp_port": "587",
+ "password": "password"
},
{
"doctype": "Email Account",
@@ -25,6 +26,7 @@
"incoming_port": "143",
"attachment_limit": "1",
"smtp_server": "smtp.test.com",
- "smtp_port": "587"
+ "smtp_port": "587",
+ "password": "password"
}
]
diff --git a/frappe/email/doctype/email_flag_queue/email_flag_queue.json b/frappe/email/doctype/email_flag_queue/email_flag_queue.json
index 165e8f9ea9..14b1ec4f53 100644
--- a/frappe/email/doctype/email_flag_queue/email_flag_queue.json
+++ b/frappe/email/doctype/email_flag_queue/email_flag_queue.json
@@ -1,213 +1,67 @@
{
- "allow_copy": 1,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "beta": 0,
- "creation": "2016-04-20 15:29:39.785172",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 0,
+ "actions": [],
+ "allow_copy": 1,
+ "creation": "2016-04-20 15:29:39.785172",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "is_completed",
+ "communication",
+ "action",
+ "email_account",
+ "uid"
+ ],
"fields": [
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "is_completed",
- "fieldtype": "Check",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Is Completed",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "default": "0",
+ "fieldname": "is_completed",
+ "fieldtype": "Check",
+ "label": "Is Completed",
+ "read_only": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "communication",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Communication",
- "length": 0,
- "no_copy": 0,
- "options": "",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "communication",
+ "fieldtype": "Data",
+ "label": "Communication"
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "action",
- "fieldtype": "Select",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Action",
- "length": 0,
- "no_copy": 0,
- "options": "Read\nUnread",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "action",
+ "fieldtype": "Select",
+ "label": "Action",
+ "options": "Read\nUnread"
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "email_account",
- "fieldtype": "Data",
- "hidden": 1,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Email Account",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "email_account",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Email Account"
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "uid",
- "fieldtype": "Data",
- "hidden": 1,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "UID",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "fieldname": "uid",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "UID"
}
- ],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
- "in_create": 1,
- "is_submittable": 0,
- "issingle": 0,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2017-09-20 15:27:12.142079",
- "modified_by": "Administrator",
- "module": "Email",
- "name": "Email Flag Queue",
- "name_case": "",
- "owner": "Administrator",
+ ],
+ "in_create": 1,
+ "links": [],
+ "modified": "2021-11-30 09:51:34.489932",
+ "modified_by": "Administrator",
+ "module": "Email",
+ "name": "Email Flag Queue",
+ "owner": "Administrator",
"permissions": [
{
- "amend": 0,
- "apply_user_permissions": 0,
- "cancel": 0,
- "create": 0,
- "delete": 1,
- "email": 1,
- "export": 1,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "set_user_permissions": 0,
- "share": 1,
- "submit": 0,
- "write": 0
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1
}
- ],
- "quick_entry": 0,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 0,
- "track_seen": 0
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC"
}
\ No newline at end of file
diff --git a/frappe/email/doctype/email_flag_queue/email_flag_queue.py b/frappe/email/doctype/email_flag_queue/email_flag_queue.py
index 487ef7db50..886cf3c24b 100644
--- a/frappe/email/doctype/email_flag_queue/email_flag_queue.py
+++ b/frappe/email/doctype/email_flag_queue/email_flag_queue.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/email/doctype/email_flag_queue/test_email_flag_queue.py b/frappe/email/doctype/email_flag_queue/test_email_flag_queue.py
index 644a2a8ff7..b0e17b3b85 100644
--- a/frappe/email/doctype/email_flag_queue/test_email_flag_queue.py
+++ b/frappe/email/doctype/email_flag_queue/test_email_flag_queue.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/email/doctype/email_group/email_group.json b/frappe/email/doctype/email_group/email_group.json
index c49de841e6..cb74249143 100644
--- a/frappe/email/doctype/email_group/email_group.json
+++ b/frappe/email/doctype/email_group/email_group.json
@@ -1,6 +1,7 @@
{
"actions": [],
"allow_import": 1,
+ "allow_rename": 1,
"autoname": "field:title",
"creation": "2015-03-18 06:08:32.729800",
"doctype": "DocType",
@@ -50,7 +51,7 @@
"link_fieldname": "email_group"
}
],
- "modified": "2020-09-24 16:41:55.286377",
+ "modified": "2021-06-15 11:25:13.556201",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Group",
diff --git a/frappe/email/doctype/email_group/email_group.py b/frappe/email/doctype/email_group/email_group.py
index b19a134713..ad52d9a9ec 100755
--- a/frappe/email/doctype/email_group/email_group.py
+++ b/frappe/email/doctype/email_group/email_group.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.utils import validate_email_address
@@ -105,6 +104,6 @@ def send_welcome_email(welcome_email, email, email_group):
email=email,
email_group=email_group
)
-
- message = frappe.render_template(welcome_email.response, args)
+ email_message = welcome_email.response or welcome_email.response_html
+ message = frappe.render_template(email_message, args)
frappe.sendmail(email, subject=welcome_email.subject, message=message)
diff --git a/frappe/email/doctype/email_group/test_email_group.py b/frappe/email/doctype/email_group/test_email_group.py
index 09f4f4c32c..06341c128e 100644
--- a/frappe/email/doctype/email_group/test_email_group.py
+++ b/frappe/email/doctype/email_group/test_email_group.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/email/doctype/email_group_member/email_group_member.py b/frappe/email/doctype/email_group_member/email_group_member.py
index 23b279e755..a9fd26f710 100644
--- a/frappe/email/doctype/email_group_member/email_group_member.py
+++ b/frappe/email/doctype/email_group_member/email_group_member.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/email/doctype/email_group_member/test_email_group_member.py b/frappe/email/doctype/email_group_member/test_email_group_member.py
index 35259617c1..de006dccb9 100644
--- a/frappe/email/doctype/email_group_member/test_email_group_member.py
+++ b/frappe/email/doctype/email_group_member/test_email_group_member.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/email/doctype/email_queue/email_queue.json b/frappe/email/doctype/email_queue/email_queue.json
index 4529ea8211..f251786c90 100644
--- a/frappe/email/doctype/email_queue/email_queue.json
+++ b/frappe/email/doctype/email_queue/email_queue.json
@@ -24,7 +24,8 @@
"unsubscribe_method",
"expose_recipients",
"attachments",
- "retry"
+ "retry",
+ "email_account"
],
"fields": [
{
@@ -139,13 +140,19 @@
"fieldtype": "Int",
"label": "Retry",
"read_only": 1
+ },
+ {
+ "fieldname": "email_account",
+ "fieldtype": "Link",
+ "label": "Email Account",
+ "options": "Email Account"
}
],
"icon": "fa fa-envelope",
"idx": 1,
"in_create": 1,
"links": [],
- "modified": "2020-07-17 15:58:15.369419",
+ "modified": "2021-04-29 06:33:25.191729",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Queue",
diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py
index 267fbdfe9c..9b4f3b984c 100644
--- a/frappe/email/doctype/email_queue/email_queue.py
+++ b/frappe/email/doctype/email_queue/email_queue.py
@@ -1,16 +1,31 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
+
+import traceback
+import json
+
+from rq.timeouts import JobTimeoutException
+import smtplib
+import quopri
+from email.parser import Parser
+from email.policy import SMTPUTF8
+from html2text import html2text
-from __future__ import unicode_literals
import frappe
-from frappe import _
+from frappe import _, safe_encode, task
from frappe.model.document import Document
-from frappe.email.queue import send_one
-from frappe.utils import now_datetime
+from frappe.email.queue import get_unsubcribed_url, get_unsubscribe_message
+from frappe.email.email_body import add_attachment, get_formatted_html, get_email
+from frappe.utils import cint, split_emails, add_days, nowdate, cstr, get_hook_method
+from frappe.email.doctype.email_account.email_account import EmailAccount
+from frappe.query_builder.utils import DocType
+MAX_RETRY_COUNT = 3
class EmailQueue(Document):
+ DOCTYPE = 'Email Queue'
+
def set_recipients(self, recipients):
self.set("recipients", [])
for r in recipients:
@@ -30,6 +45,274 @@ class EmailQueue(Document):
duplicate.set_recipients(recipients)
return duplicate
+ @classmethod
+ def new(cls, doc_data, ignore_permissions=False):
+ data = doc_data.copy()
+ if not data.get('recipients'):
+ return
+
+ recipients = data.pop('recipients')
+ doc = frappe.new_doc(cls.DOCTYPE)
+ doc.update(data)
+ doc.set_recipients(recipients)
+ doc.insert(ignore_permissions=ignore_permissions)
+ return doc
+
+ @classmethod
+ def find(cls, name):
+ return frappe.get_doc(cls.DOCTYPE, name)
+
+ @classmethod
+ def find_one_by_filters(cls, **kwargs):
+ name = frappe.db.get_value(cls.DOCTYPE, kwargs)
+ return cls.find(name) if name else None
+
+ def update_db(self, commit=False, **kwargs):
+ frappe.db.set_value(self.DOCTYPE, self.name, kwargs)
+ if commit:
+ frappe.db.commit()
+
+ def update_status(self, status, commit=False, **kwargs):
+ self.update_db(status = status, commit = commit, **kwargs)
+ if self.communication:
+ communication_doc = frappe.get_doc('Communication', self.communication)
+ communication_doc.set_delivery_status(commit=commit)
+
+ @property
+ def cc(self):
+ return (self.show_as_cc and self.show_as_cc.split(",")) or []
+
+ @property
+ def to(self):
+ return [r.recipient for r in self.recipients if r.recipient not in self.cc]
+
+ @property
+ def attachments_list(self):
+ return json.loads(self.attachments) if self.attachments else []
+
+ def get_email_account(self):
+ if self.email_account:
+ return frappe.get_doc('Email Account', self.email_account)
+
+ return EmailAccount.find_outgoing(
+ match_by_email = self.sender, match_by_doctype = self.reference_doctype)
+
+ def is_to_be_sent(self):
+ return self.status in ['Not Sent','Partially Sent']
+
+ def can_send_now(self):
+ hold_queue = (cint(frappe.defaults.get_defaults().get("hold_queue"))==1)
+ if frappe.are_emails_muted() or not self.is_to_be_sent() or hold_queue:
+ return False
+
+ return True
+
+ def send(self, is_background_task=False):
+ """ Send emails to recipients.
+ """
+ if not self.can_send_now():
+ frappe.db.rollback()
+ return
+
+ with SendMailContext(self, is_background_task) as ctx:
+ message = None
+ for recipient in self.recipients:
+ if not recipient.is_mail_to_be_sent():
+ continue
+
+ message = ctx.build_message(recipient.recipient)
+ method = get_hook_method('override_email_send')
+ if method:
+ method(self, self.sender, recipient.recipient, message)
+ else:
+ if not frappe.flags.in_test:
+ ctx.smtp_session.sendmail(from_addr=self.sender, to_addrs=recipient.recipient, msg=message)
+ ctx.add_to_sent_list(recipient)
+
+ if frappe.flags.in_test:
+ frappe.flags.sent_mail = message
+ return
+
+ if ctx.email_account_doc.append_emails_to_sent_folder and ctx.sent_to:
+ ctx.email_account_doc.append_email_to_sent_folder(message)
+
+
+@task(queue = 'short')
+def send_mail(email_queue_name, is_background_task=False):
+ """This is equalent to EmqilQueue.send.
+
+ This provides a way to make sending mail as a background job.
+ """
+ record = EmailQueue.find(email_queue_name)
+ record.send(is_background_task=is_background_task)
+
+class SendMailContext:
+ def __init__(self, queue_doc: Document, is_background_task: bool = False):
+ self.queue_doc = queue_doc
+ self.is_background_task = is_background_task
+ self.email_account_doc = queue_doc.get_email_account()
+ self.smtp_server = self.email_account_doc.get_smtp_server()
+ self.sent_to = [rec.recipient for rec in self.queue_doc.recipients if rec.is_main_sent()]
+
+ def __enter__(self):
+ self.queue_doc.update_status(status='Sending', commit=True)
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ exceptions = [
+ smtplib.SMTPServerDisconnected,
+ smtplib.SMTPAuthenticationError,
+ smtplib.SMTPRecipientsRefused,
+ smtplib.SMTPConnectError,
+ smtplib.SMTPHeloError,
+ JobTimeoutException
+ ]
+
+ self.smtp_server.quit()
+ self.log_exception(exc_type, exc_val, exc_tb)
+
+ if exc_type in exceptions:
+ email_status = (self.sent_to and 'Partially Sent') or 'Not Sent'
+ self.queue_doc.update_status(status = email_status, commit = True)
+ elif exc_type:
+ if self.queue_doc.retry < MAX_RETRY_COUNT:
+ update_fields = {'status': 'Not Sent', 'retry': self.queue_doc.retry + 1}
+ else:
+ update_fields = {'status': (self.sent_to and 'Partially Errored') or 'Error'}
+ self.queue_doc.update_status(**update_fields, commit = True)
+ else:
+ email_status = self.is_mail_sent_to_all() and 'Sent'
+ email_status = email_status or (self.sent_to and 'Partially Sent') or 'Not Sent'
+
+ update_fields = {'status': email_status}
+ if self.email_account_doc.is_exists_in_db():
+ update_fields['email_account'] = self.email_account_doc.name
+ else:
+ update_fields['email_account'] = None
+
+ self.queue_doc.update_status(**update_fields, commit = True)
+
+ def log_exception(self, exc_type, exc_val, exc_tb):
+ if exc_type:
+ traceback_string = "".join(traceback.format_tb(exc_tb))
+ traceback_string += f"\n Queue Name: {self.queue_doc.name}"
+
+ if self.is_background_task:
+ frappe.log_error(title = 'frappe.email.queue.flush', message = traceback_string)
+ else:
+ frappe.log_error(message = traceback_string)
+
+ @property
+ def smtp_session(self):
+ if frappe.flags.in_test:
+ return
+ return self.smtp_server.session
+
+ def add_to_sent_list(self, recipient):
+ # Update recipient status
+ recipient.update_db(status='Sent', commit=True)
+ self.sent_to.append(recipient.recipient)
+
+ def is_mail_sent_to_all(self):
+ return sorted(self.sent_to) == sorted([rec.recipient for rec in self.queue_doc.recipients])
+
+ def get_message_object(self, message):
+ return Parser(policy=SMTPUTF8).parsestr(message)
+
+ def message_placeholder(self, placeholder_key):
+ map = {
+ 'tracker': '',
+ 'unsubscribe_url': '',
+ 'cc': '',
+ 'recipient': '',
+ }
+ return map.get(placeholder_key)
+
+ def build_message(self, recipient_email):
+ """Build message specific to the recipient.
+ """
+ message = self.queue_doc.message
+ if not message:
+ return ""
+
+ message = message.replace(self.message_placeholder('tracker'), self.get_tracker_str())
+ message = message.replace(self.message_placeholder('unsubscribe_url'),
+ self.get_unsubscribe_str(recipient_email))
+ message = message.replace(self.message_placeholder('cc'), self.get_receivers_str())
+ message = message.replace(self.message_placeholder('recipient'),
+ self.get_receipient_str(recipient_email))
+ message = self.include_attachments(message)
+ return message
+
+ def get_tracker_str(self):
+ tracker_url_html = \
+ ' '
+
+ message = ''
+ if frappe.conf.use_ssl and self.email_account_doc.track_email_status:
+ message = quopri.encodestring(
+ tracker_url_html.format(frappe.local.site, self.queue_doc.communication).encode()
+ ).decode()
+ return message
+
+ def get_unsubscribe_str(self, recipient_email):
+ unsubscribe_url = ''
+ if self.queue_doc.add_unsubscribe_link and self.queue_doc.reference_doctype:
+ doctype, doc_name = self.queue_doc.reference_doctype, self.queue_doc.reference_name
+ unsubscribe_url = get_unsubcribed_url(doctype, doc_name, recipient_email,
+ self.queue_doc.unsubscribe_method, self.queue_doc.unsubscribe_param)
+
+ return quopri.encodestring(unsubscribe_url.encode()).decode()
+
+ def get_receivers_str(self):
+ message = ''
+ if self.queue_doc.expose_recipients == "footer":
+ to_str = ', '.join(self.queue_doc.to)
+ cc_str = ', '.join(self.queue_doc.cc)
+ message = f"This email was sent to {to_str}"
+ message = message + f" and copied to {cc_str}" if cc_str else message
+ return message
+
+ def get_receipient_str(self, recipient_email):
+ message = ''
+ if self.queue_doc.expose_recipients != "header":
+ message = recipient_email
+ return message
+
+ def include_attachments(self, message):
+ message_obj = self.get_message_object(message)
+ attachments = self.queue_doc.attachments_list
+
+ for attachment in attachments:
+ if attachment.get('fcontent'):
+ continue
+
+ file_filters = {}
+ if attachment.get('fid'):
+ file_filters['name'] = attachment.get('fid')
+ elif attachment.get('file_url'):
+ file_filters['file_url'] = attachment.get('file_url')
+
+ if file_filters:
+ _file = frappe.get_doc("File", file_filters)
+ fcontent = _file.get_content()
+ attachment.update({
+ 'fname': _file.file_name,
+ 'fcontent': fcontent,
+ 'parent': message_obj
+ })
+ attachment.pop("fid", None)
+ attachment.pop("file_url", None)
+ add_attachment(**attachment)
+
+ elif attachment.get("print_format_attachment") == 1:
+ attachment.pop("print_format_attachment", None)
+ print_format_file = frappe.attach_print(**attachment)
+ print_format_file.update({"parent": message_obj})
+ add_attachment(**print_format_file)
+
+ return safe_encode(message_obj.as_string())
+
@frappe.whitelist()
def retry_sending(name):
doc = frappe.get_doc("Email Queue", name)
@@ -42,8 +325,291 @@ def retry_sending(name):
@frappe.whitelist()
def send_now(name):
- send_one(name, now=True)
+ record = EmailQueue.find(name)
+ if record:
+ record.send()
def on_doctype_update():
"""Add index in `tabCommunication` for `(reference_doctype, reference_name)`"""
frappe.db.add_index('Email Queue', ('status', 'send_after', 'priority', 'creation'), 'index_bulk_flush')
+
+class QueueBuilder:
+ """Builds Email Queue from the given data
+ """
+ def __init__(self, recipients=None, sender=None, subject=None, message=None,
+ text_content=None, reference_doctype=None, reference_name=None,
+ unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None,
+ attachments=None, reply_to=None, cc=None, bcc=None, message_id=None, in_reply_to=None,
+ send_after=None, expose_recipients=None, send_priority=1, communication=None,
+ read_receipt=None, queue_separately=False, is_notification=False,
+ add_unsubscribe_link=1, inline_images=None, header=None,
+ print_letterhead=False, with_container=False):
+ """Add email to sending queue (Email Queue)
+
+ :param recipients: List of recipients.
+ :param sender: Email sender.
+ :param subject: Email subject.
+ :param message: Email message.
+ :param text_content: Text version of email message.
+ :param reference_doctype: Reference DocType of caller document.
+ :param reference_name: Reference name of caller document.
+ :param send_priority: Priority for Email Queue, default 1.
+ :param unsubscribe_method: URL method for unsubscribe. Default is `/api/method/frappe.email.queue.unsubscribe`.
+ :param unsubscribe_params: additional params for unsubscribed links. default are name, doctype, email
+ :param attachments: Attachments to be sent.
+ :param reply_to: Reply to be captured here (default inbox)
+ :param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To.
+ :param send_after: Send this email after the given datetime. If value is in integer, then `send_after` will be the automatically set to no of days from current date.
+ :param communication: Communication link to be set in Email Queue record
+ :param queue_separately: Queue each email separately
+ :param is_notification: Marks email as notification so will not trigger notifications from system
+ :param add_unsubscribe_link: Send unsubscribe link in the footer of the Email, default 1.
+ :param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id
+ :param header: Append header in email (boolean)
+ :param with_container: Wraps email inside styled container
+ """
+
+ self._unsubscribe_method = unsubscribe_method
+ self._recipients = recipients
+ self._cc = cc
+ self._bcc = bcc
+ self._send_after = send_after
+ self._sender = sender
+ self._text_content = text_content
+ self._message = message
+ self._add_unsubscribe_link = add_unsubscribe_link
+ self._unsubscribe_message = unsubscribe_message
+ self._attachments = attachments
+
+ self._unsubscribed_user_emails = None
+ self._email_account = None
+
+ self.unsubscribe_params = unsubscribe_params
+ self.subject = subject
+ self.reference_doctype = reference_doctype
+ self.reference_name = reference_name
+ self.expose_recipients = expose_recipients
+ self.with_container = with_container
+ self.header = header
+ self.reply_to = reply_to
+ self.message_id = message_id
+ self.in_reply_to = in_reply_to
+ self.send_priority = send_priority
+ self.communication = communication
+ self.read_receipt = read_receipt
+ self.queue_separately = queue_separately
+ self.is_notification = is_notification
+ self.inline_images = inline_images
+ self.print_letterhead = print_letterhead
+
+ @property
+ def unsubscribe_method(self):
+ return self._unsubscribe_method or '/api/method/frappe.email.queue.unsubscribe'
+
+ def _get_emails_list(self, emails=None):
+ emails = split_emails(emails) if isinstance(emails, str) else (emails or [])
+ return [each for each in set(emails) if each]
+
+ @property
+ def recipients(self):
+ return self._get_emails_list(self._recipients)
+
+ @property
+ def cc(self):
+ return self._get_emails_list(self._cc)
+
+ @property
+ def bcc(self):
+ return self._get_emails_list(self._bcc)
+
+ @property
+ def send_after(self):
+ if isinstance(self._send_after, int):
+ return add_days(nowdate(), self._send_after)
+ return self._send_after
+
+ @property
+ def sender(self):
+ if not self._sender or self._sender == "Administrator":
+ email_account = self.get_outgoing_email_account()
+ return email_account.default_sender
+ return self._sender
+
+ def email_text_content(self):
+ unsubscribe_msg = self.unsubscribe_message()
+ unsubscribe_text_message = (unsubscribe_msg and unsubscribe_msg.text) or ''
+
+ if self._text_content:
+ return self._text_content + unsubscribe_text_message
+
+ try:
+ text_content = html2text(self._message)
+ except Exception:
+ text_content = "See html attachment"
+ return text_content + unsubscribe_text_message
+
+ def email_html_content(self):
+ email_account = self.get_outgoing_email_account()
+ return get_formatted_html(self.subject, self._message, header=self.header,
+ email_account=email_account, unsubscribe_link=self.unsubscribe_message(),
+ with_container=self.with_container)
+
+ def should_include_unsubscribe_link(self):
+ return (self._add_unsubscribe_link == 1
+ and self.reference_doctype
+ and (self._unsubscribe_message or self.reference_doctype=="Newsletter"))
+
+ def unsubscribe_message(self):
+ if self.should_include_unsubscribe_link():
+ return get_unsubscribe_message(self._unsubscribe_message, self.expose_recipients)
+
+ def get_outgoing_email_account(self):
+ if self._email_account:
+ return self._email_account
+
+ self._email_account = EmailAccount.find_outgoing(
+ match_by_doctype=self.reference_doctype, match_by_email=self._sender, _raise_error=True)
+ return self._email_account
+
+ def get_unsubscribed_user_emails(self):
+ if self._unsubscribed_user_emails is not None:
+ return self._unsubscribed_user_emails
+
+ all_ids = list(set(self.recipients + self.cc))
+
+ EmailUnsubscribe = DocType("Email Unsubscribe")
+
+ if len(all_ids) > 0:
+ unsubscribed = (
+ frappe.qb.from_(EmailUnsubscribe).select(
+ EmailUnsubscribe.email
+ ).where(
+ EmailUnsubscribe.email.isin(all_ids)
+ & (
+ (
+ (EmailUnsubscribe.reference_doctype == self.reference_doctype)
+ & (EmailUnsubscribe.reference_name == self.reference_name)
+ ) | (
+ EmailUnsubscribe.global_unsubscribe == 1
+ )
+ )
+ ).distinct()
+ ).run(pluck=True)
+ else:
+ unsubscribed = None
+
+ self._unsubscribed_user_emails = unsubscribed or []
+ return self._unsubscribed_user_emails
+
+ def final_recipients(self):
+ unsubscribed_emails = self.get_unsubscribed_user_emails()
+ return [mail_id for mail_id in self.recipients if mail_id not in unsubscribed_emails]
+
+ def final_cc(self):
+ unsubscribed_emails = self.get_unsubscribed_user_emails()
+ return [mail_id for mail_id in self.cc if mail_id not in unsubscribed_emails]
+
+ def get_attachments(self):
+ attachments = []
+ if self._attachments:
+ # store attachments with fid or print format details, to be attached on-demand later
+ for att in self._attachments:
+ if att.get('fid') or att.get('file_url'):
+ attachments.append(att)
+ elif att.get("print_format_attachment") == 1:
+ if not att.get('lang', None):
+ att['lang'] = frappe.local.lang
+ att['print_letterhead'] = self.print_letterhead
+ attachments.append(att)
+ return attachments
+
+ def prepare_email_content(self):
+ mail = get_email(recipients=self.final_recipients(),
+ sender=self.sender,
+ subject=self.subject,
+ formatted=self.email_html_content(),
+ text_content=self.email_text_content(),
+ attachments=self._attachments,
+ reply_to=self.reply_to,
+ cc=self.final_cc(),
+ bcc=self.bcc,
+ email_account=self.get_outgoing_email_account(),
+ expose_recipients=self.expose_recipients,
+ inline_images=self.inline_images,
+ header=self.header)
+
+ mail.set_message_id(self.message_id, self.is_notification)
+ if self.read_receipt:
+ mail.msg_root["Disposition-Notification-To"] = self.sender
+ if self.in_reply_to:
+ mail.set_in_reply_to(self.in_reply_to)
+ return mail
+
+ def process(self, send_now=False):
+ """Build and return the email queues those are created.
+
+ Sends email incase if it is requested to send now.
+ """
+ final_recipients = self.final_recipients()
+ queue_separately = (final_recipients and self.queue_separately) or len(final_recipients) > 20
+ if not (final_recipients + self.final_cc()):
+ return []
+
+ email_queues = []
+ queue_data = self.as_dict(include_recipients=False)
+ if not queue_data:
+ return []
+
+ if not queue_separately:
+ recipients = list(set(final_recipients + self.final_cc() + self.bcc))
+ q = EmailQueue.new({**queue_data, **{'recipients': recipients}}, ignore_permissions=True)
+ email_queues.append(q)
+ else:
+ for r in final_recipients:
+ recipients = [r] if email_queues else list(set([r] + self.final_cc() + self.bcc))
+ q = EmailQueue.new({**queue_data, **{'recipients': recipients}}, ignore_permissions=True)
+ email_queues.append(q)
+
+ if send_now:
+ for doc in email_queues:
+ doc.send()
+ return email_queues
+
+ def as_dict(self, include_recipients=True):
+ email_account = self.get_outgoing_email_account()
+ email_account_name = email_account and email_account.is_exists_in_db() and email_account.name
+
+ mail = self.prepare_email_content()
+ try:
+ mail_to_string = cstr(mail.as_string())
+ except frappe.InvalidEmailAddressError:
+ # bad Email Address - don't add to queue
+ frappe.log_error('Invalid Email ID Sender: {0}, Recipients: {1}, \nTraceback: {2} '
+ .format(self.sender, ', '.join(self.final_recipients()), traceback.format_exc()),
+ 'Email Not Sent'
+ )
+ return
+
+ d = {
+ 'priority': self.send_priority,
+ 'attachments': json.dumps(self.get_attachments()),
+ 'message_id': mail.msg_root["Message-Id"].strip(" <>"),
+ 'message': mail_to_string,
+ 'sender': self.sender,
+ 'reference_doctype': self.reference_doctype,
+ 'reference_name': self.reference_name,
+ 'add_unsubscribe_link': self._add_unsubscribe_link,
+ 'unsubscribe_method': self.unsubscribe_method,
+ 'unsubscribe_params': self.unsubscribe_params,
+ 'expose_recipients': self.expose_recipients,
+ 'communication': self.communication,
+ 'send_after': self.send_after,
+ 'show_as_cc': ",".join(self.final_cc()),
+ 'show_as_bcc': ','.join(self.bcc),
+ 'email_account': email_account_name or None
+ }
+
+ if include_recipients:
+ d['recipients'] = self.final_recipients()
+
+ return d
diff --git a/frappe/email/doctype/email_queue/test_email_queue.js b/frappe/email/doctype/email_queue/test_email_queue.js
deleted file mode 100644
index 91a33b3ee5..0000000000
--- a/frappe/email/doctype/email_queue/test_email_queue.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Email Queue", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Email Queue
- () => frappe.tests.make('Email Queue', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/email/doctype/email_queue/test_email_queue.py b/frappe/email/doctype/email_queue/test_email_queue.py
index 7cd79f9259..8ebcb68a38 100644
--- a/frappe/email/doctype/email_queue/test_email_queue.py
+++ b/frappe/email/doctype/email_queue/test_email_queue.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py b/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py
index 42956a1180..95b8593c4c 100644
--- a/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py
+++ b/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py
@@ -1,10 +1,21 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
class EmailQueueRecipient(Document):
- pass
+ DOCTYPE = 'Email Queue Recipient'
+
+ def is_mail_to_be_sent(self):
+ return self.status == 'Not Sent'
+
+ def is_main_sent(self):
+ return self.status == 'Sent'
+
+ def update_db(self, commit=False, **kwargs):
+ frappe.db.set_value(self.DOCTYPE, self.name, kwargs)
+ if commit:
+ frappe.db.commit()
+
diff --git a/frappe/email/doctype/email_rule/email_rule.py b/frappe/email/doctype/email_rule/email_rule.py
index 220798bbdc..b2a4be5421 100644
--- a/frappe/email/doctype/email_rule/email_rule.py
+++ b/frappe/email/doctype/email_rule/email_rule.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
diff --git a/frappe/email/doctype/email_rule/test_email_rule.py b/frappe/email/doctype/email_rule/test_email_rule.py
index 3c7f9c83e6..eef5448e57 100644
--- a/frappe/email/doctype/email_rule/test_email_rule.py
+++ b/frappe/email/doctype/email_rule/test_email_rule.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/email/doctype/email_template/email_template.json b/frappe/email/doctype/email_template/email_template.json
index dc73acacc1..c6ec971da4 100644
--- a/frappe/email/doctype/email_template/email_template.json
+++ b/frappe/email/doctype/email_template/email_template.json
@@ -12,7 +12,6 @@
"use_html",
"response_html",
"response",
- "owner",
"section_break_4",
"email_reply_help"
],
@@ -32,14 +31,6 @@
"label": "Response",
"mandatory_depends_on": "eval:!doc.use_html"
},
- {
- "default": "user",
- "fieldname": "owner",
- "fieldtype": "Link",
- "hidden": 1,
- "label": "Owner",
- "options": "User"
- },
{
"fieldname": "section_break_4",
"fieldtype": "Section Break"
@@ -66,7 +57,7 @@
],
"icon": "fa fa-comment",
"links": [],
- "modified": "2020-11-30 14:12:50.321633",
+ "modified": "2022-01-04 14:12:50.321633",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Template",
diff --git a/frappe/email/doctype/email_template/email_template.py b/frappe/email/doctype/email_template/email_template.py
index 6708e9dd3f..c51c46d72d 100644
--- a/frappe/email/doctype/email_template/email_template.py
+++ b/frappe/email/doctype/email_template/email_template.py
@@ -1,11 +1,9 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe, json
from frappe.model.document import Document
from frappe.utils.jinja import validate_template
-from six import string_types
class EmailTemplate(Document):
def validate(self):
@@ -24,7 +22,7 @@ class EmailTemplate(Document):
return frappe.render_template(self.response, doc)
def get_formatted_email(self, doc):
- if isinstance(doc, string_types):
+ if isinstance(doc, str):
doc = json.loads(doc)
return {
@@ -36,7 +34,7 @@ class EmailTemplate(Document):
@frappe.whitelist()
def get_email_template(template_name, doc):
'''Returns the processed HTML of a email template with the given doc'''
- if isinstance(doc, string_types):
+ if isinstance(doc, str):
doc = json.loads(doc)
email_template = frappe.get_doc("Email Template", template_name)
diff --git a/frappe/email/doctype/email_template/test_email_template.js b/frappe/email/doctype/email_template/test_email_template.js
deleted file mode 100644
index 529dd14184..0000000000
--- a/frappe/email/doctype/email_template/test_email_template.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable */
-// rename this file from _test_[name] to test_[name] to activate
-// and remove above this line
-
-QUnit.test("test: Email Template", function (assert) {
- let done = assert.async();
-
- // number of asserts
- assert.expect(1);
-
- frappe.run_serially([
- // insert a new Email Template
- () => frappe.tests.make('Email Template', [
- // values to be set
- {key: 'value'}
- ]),
- () => {
- assert.equal(cur_frm.doc.key, 'value');
- },
- () => done()
- ]);
-
-});
diff --git a/frappe/email/doctype/email_template/test_email_template.py b/frappe/email/doctype/email_template/test_email_template.py
index a48ce94ac5..a92ee9f9c3 100644
--- a/frappe/email/doctype/email_template/test_email_template.py
+++ b/frappe/email/doctype/email_template/test_email_template.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import unittest
class TestEmailTemplate(unittest.TestCase):
diff --git a/frappe/email/doctype/email_unsubscribe/email_unsubscribe.py b/frappe/email/doctype/email_unsubscribe/email_unsubscribe.py
index e532e2b7eb..d2ee828a55 100644
--- a/frappe/email/doctype/email_unsubscribe/email_unsubscribe.py
+++ b/frappe/email/doctype/email_unsubscribe/email_unsubscribe.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
+# License: MIT. See LICENSE
-from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe import _
diff --git a/frappe/email/doctype/email_unsubscribe/test_email_unsubscribe.py b/frappe/email/doctype/email_unsubscribe/test_email_unsubscribe.py
index ea84253ab6..fdea802fdf 100644
--- a/frappe/email/doctype/email_unsubscribe/test_email_unsubscribe.py
+++ b/frappe/email/doctype/email_unsubscribe/test_email_unsubscribe.py
@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
+# License: MIT. See LICENSE
import frappe
import unittest
diff --git a/frappe/patches/v6_1/__init__.py b/frappe/email/doctype/imap_folder/__init__.py
similarity index 100%
rename from frappe/patches/v6_1/__init__.py
rename to frappe/email/doctype/imap_folder/__init__.py
diff --git a/frappe/email/doctype/imap_folder/imap_folder.json b/frappe/email/doctype/imap_folder/imap_folder.json
new file mode 100644
index 0000000000..bab50dea39
--- /dev/null
+++ b/frappe/email/doctype/imap_folder/imap_folder.json
@@ -0,0 +1,53 @@
+{
+ "actions": [],
+ "creation": "2021-09-21 11:38:13.521979",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "folder_name",
+ "append_to",
+ "uidvalidity",
+ "uidnext"
+ ],
+ "fields": [
+ {
+ "fieldname": "folder_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Folder Name",
+ "reqd": 1
+ },
+ {
+ "fieldname": "append_to",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Append To",
+ "options": "DocType"
+ },
+ {
+ "fieldname": "uidvalidity",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "UIDVALIDITY"
+ },
+ {
+ "fieldname": "uidnext",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "UIDNEXT"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-09-21 11:53:00.811236",
+ "modified_by": "Administrator",
+ "module": "Email",
+ "name": "IMAP Folder",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
diff --git a/frappe/email/doctype/imap_folder/imap_folder.py b/frappe/email/doctype/imap_folder/imap_folder.py
new file mode 100644
index 0000000000..b0bb36b677
--- /dev/null
+++ b/frappe/email/doctype/imap_folder/imap_folder.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2021, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+class IMAPFolder(Document):
+ pass
diff --git a/frappe/email/doctype/newsletter/exceptions.py b/frappe/email/doctype/newsletter/exceptions.py
new file mode 100644
index 0000000000..a6c688dbe8
--- /dev/null
+++ b/frappe/email/doctype/newsletter/exceptions.py
@@ -0,0 +1,13 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See LICENSE
+
+from frappe.exceptions import ValidationError
+
+class NewsletterAlreadySentError(ValidationError):
+ pass
+
+class NoRecipientFoundError(ValidationError):
+ pass
+
+class NewsletterNotSavedError(ValidationError):
+ pass
diff --git a/frappe/email/doctype/newsletter/newsletter..json b/frappe/email/doctype/newsletter/newsletter..json
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/frappe/email/doctype/newsletter/newsletter.js b/frappe/email/doctype/newsletter/newsletter.js
index 3277d8e9ee..55805ad485 100644
--- a/frappe/email/doctype/newsletter/newsletter.js
+++ b/frappe/email/doctype/newsletter/newsletter.js
@@ -4,69 +4,137 @@
frappe.ui.form.on('Newsletter', {
refresh(frm) {
let doc = frm.doc;
- if (!doc.__islocal && !cint(doc.email_sent) && !doc.__unsaved
- && in_list(frappe.boot.user.can_write, doc.doctype)) {
- frm.add_custom_button(__('Send Now'), function() {
- frappe.confirm(__("Do you really want to send this email newsletter?"), function() {
- frm.call('send_emails').then(() => {
- frm.refresh();
- });
+ let can_write = in_list(frappe.boot.user.can_write, doc.doctype);
+ if (!frm.is_new() && !frm.is_dirty() && !doc.email_sent && can_write) {
+ frm.add_custom_button(__('Send a test email'), () => {
+ frm.events.send_test_email(frm);
+ }, __('Preview'));
+
+ frm.add_custom_button(__('Check broken links'), () => {
+ frm.dashboard.set_headline(__('Checking broken links...'));
+ frm.call('find_broken_links').then(r => {
+ frm.dashboard.set_headline('');
+ let links = r.message;
+ if (links && links.length) {
+ let html = '' + links.map(link => `${link} `).join('') + ' ';
+ frm.dashboard.set_headline(__("Following links are broken in the email content: {0}", [html]));
+ } else {
+ frm.dashboard.set_headline(__("No broken links found in the email content"));
+ setTimeout(() => {
+ frm.dashboard.set_headline('');
+ }, 3000);
+ }
});
- }, "fa fa-play", "btn-success");
+ }, __('Preview'));
+
+ frm.add_custom_button(__('Send now'), () => {
+ if (frm.doc.schedule_send) {
+ frappe.confirm(__("This newsletter was scheduled to send on a later date. Are you sure you want to send it now?"), function () {
+ frm.call('send_emails').then(() => frm.refresh());
+ });
+ return;
+ }
+ frappe.confirm(__("Are you sure you want to send this newsletter now?"), function () {
+ frm.call('send_emails').then(() => frm.refresh());
+ });
+ }, __('Send'));
+
+ frm.add_custom_button(__('Schedule sending'), () => {
+ frm.events.schedule_send_dialog(frm);
+ }, __('Send'));
}
frm.events.setup_dashboard(frm);
+ frm.events.setup_sending_status(frm);
- if (doc.__islocal && !doc.send_from) {
+ if (frm.is_new() && !doc.sender_email) {
let { fullname, email } = frappe.user_info(doc.owner);
- frm.set_value('send_from', `${fullname} <${email}>`);
+ frm.set_value('sender_email', email);
+ frm.set_value('sender_name', fullname);
}
+
+ frm.trigger('update_schedule_message');
},
- onload_post_render(frm) {
- frm.trigger('setup_schedule_send');
- },
-
- setup_schedule_send(frm) {
- let today = new Date();
-
- // setting datepicker options to set min date & min time
- today.setHours(today.getHours() + 1 );
- frm.get_field('schedule_send').$input.datepicker({
- maxMinutes: 0,
- minDate: today,
- timeFormat: 'hh:00:00',
- onSelect: function (fd, d, picker) {
- if (!d) return;
- var date = d.toDateString();
- if (date === today.toDateString()) {
- picker.update({
- minHours: (today.getHours() + 1)
- });
- } else {
- picker.update({
- minHours: 0
- });
- }
- frm.get_field('schedule_send').$input.trigger('change');
+ schedule_send_dialog(frm) {
+ let hours = frappe.utils.range(24);
+ let time_slots = hours.map(hour => {
+ return `${(hour + '').padStart(2, '0')}:00`;
+ });
+ let d = new frappe.ui.Dialog({
+ title: __('Schedule Newsletter'),
+ fields: [
+ {
+ label: __('Date'),
+ fieldname: 'date',
+ fieldtype: 'Date',
+ options: {
+ minDate: new Date()
+ }
+ },
+ {
+ label: __('Time'),
+ fieldname: 'time',
+ fieldtype: 'Select',
+ options: time_slots,
+ },
+ ],
+ primary_action_label: __('Schedule'),
+ primary_action({ date, time }) {
+ frm.set_value('schedule_sending', 1);
+ frm.set_value('schedule_send', `${date} ${time}:00`);
+ d.hide();
+ frm.save();
+ },
+ secondary_action_label: __('Cancel Scheduling'),
+ secondary_action() {
+ frm.set_value('schedule_sending', 0);
+ frm.set_value('schedule_send', '');
+ d.hide();
+ frm.save();
}
});
+ if (frm.doc.schedule_sending) {
+ let parts = frm.doc.schedule_send.split(' ');
+ if (parts.length === 2) {
+ let [date, time] = parts;
+ d.set_value('date', date);
+ d.set_value('time', time.slice(0, 5));
+ }
+ }
+ d.show();
+ },
-
- const $tp = frm.get_field('schedule_send').datepicker.timepicker;
- $tp.$minutes.parent().css('display', 'none');
- $tp.$minutesText.css('display', 'none');
- $tp.$minutesText.prev().css('display', 'none');
- $tp.$seconds.parent().css('display', 'none');
+ send_test_email(frm) {
+ let d = new frappe.ui.Dialog({
+ title: __('Send Test Email'),
+ fields: [
+ {
+ label: __('Email'),
+ fieldname: 'email',
+ fieldtype: 'Data',
+ options: 'Email',
+ }
+ ],
+ primary_action_label: __('Send'),
+ primary_action({ email }) {
+ d.get_primary_btn().text(__('Sending...')).prop('disabled', true);
+ frm.call('send_test_email', { email })
+ .then(() => {
+ d.get_primary_btn().text(__('Send again')).prop('disabled', false);
+ });
+ }
+ });
+ d.show();
},
setup_dashboard(frm) {
- if(!frm.doc.__islocal && cint(frm.doc.email_sent)
+ if (!frm.doc.__islocal && cint(frm.doc.email_sent)
&& frm.doc.__onload && frm.doc.__onload.status_count) {
var stat = frm.doc.__onload.status_count;
var total = frm.doc.scheduled_to_send;
- if(total) {
- $.each(stat, function(k, v) {
+ if (total) {
+ $.each(stat, function (k, v) {
stat[k] = flt(v * 100 / total, 2) + '%';
});
@@ -94,5 +162,58 @@ frappe.ui.form.on('Newsletter', {
]);
}
}
+ },
+
+ setup_sending_status(frm) {
+ frm.call('get_sending_status').then(r => {
+ if (r.message) {
+ frm.events.update_sending_progress(frm, r.message.sent, r.message.total);
+ }
+ if (r.message.sent >= r.message.total) {
+ return;
+ }
+ if (frm.sending_status) return;
+
+ frm.sending_status = setInterval(() => {
+ if (frm.doc.email_sent && frm.$wrapper.is(':visible')) {
+ frm.call('get_sending_status').then(r => {
+ if (r.message) {
+ let { sent, total } = r.message;
+ frm.events.update_sending_progress(frm, sent, total);
+
+ if (sent >= total) {
+ clearInterval(frm.sending_status);
+ frm.sending_status = null;
+ return;
+ }
+ }
+ });
+ }
+ }, 5000);
+ });
+ },
+
+ update_sending_progress(frm, sent, total) {
+ if (sent >= total) {
+ frm.dashboard.hide_progress();
+ return;
+ }
+ frm.dashboard.show_progress(__('Sending emails'), sent * 100 / total, __("{0} of {1} sent", [sent, total]));
+ },
+
+ on_hide(frm) {
+ if (frm.sending_status) {
+ clearInterval(frm.sending_status);
+ frm.sending_status = null;
+ }
+ },
+
+ update_schedule_message(frm) {
+ if (!frm.doc.email_sent && frm.doc.schedule_send) {
+ let datetime = frappe.datetime.global_date_format(frm.doc.schedule_send);
+ frm.dashboard.set_headline_alert(__('This newsletter is scheduled to be sent on {0}', [datetime.bold()]));
+ } else {
+ frm.dashboard.clear_headline();
+ }
}
});
diff --git a/frappe/email/doctype/newsletter/newsletter.json b/frappe/email/doctype/newsletter/newsletter.json
index 1dd6115b43..b42f4755cb 100644
--- a/frappe/email/doctype/newsletter/newsletter.json
+++ b/frappe/email/doctype/newsletter/newsletter.json
@@ -7,45 +7,59 @@
"document_type": "Other",
"engine": "InnoDB",
"field_order": [
+ "status_section",
+ "email_sent_at",
+ "column_break_3",
+ "total_recipients",
+ "column_break_12",
+ "email_sent",
+ "from_section",
+ "sender_name",
+ "column_break_5",
+ "sender_email",
+ "column_break_7",
"send_from",
- "schedule_sending",
- "schedule_send",
"recipients",
"email_group",
- "email_sent",
- "newsletter_content",
+ "subject_section",
"subject",
+ "newsletter_content",
"content_type",
"message",
"message_md",
"message_html",
+ "attachments",
"send_unsubscribe_link",
- "send_attachments",
+ "send_webview_link",
+ "schedule_settings_section",
+ "scheduled_to_send",
+ "schedule_sending",
+ "schedule_send",
+ "publish_as_a_web_page_section",
"published",
- "route",
- "test_the_newsletter",
- "test_email_id",
- "test_send",
- "scheduled_to_send"
+ "route"
],
"fields": [
{
"fieldname": "email_group",
"fieldtype": "Table",
"in_standard_filter": 1,
- "label": "Email Group",
- "options": "Newsletter Email Group"
+ "label": "Audience",
+ "options": "Newsletter Email Group",
+ "reqd": 1
},
{
"fieldname": "send_from",
"fieldtype": "Data",
"ignore_xss_filter": 1,
- "label": "Sender"
+ "label": "Sender",
+ "read_only": 1
},
{
"default": "0",
"fieldname": "email_sent",
"fieldtype": "Check",
+ "hidden": 1,
"label": "Email Sent",
"no_copy": 1,
"read_only": 1
@@ -84,32 +98,12 @@
"label": "Published"
},
{
+ "depends_on": "published",
"fieldname": "route",
"fieldtype": "Data",
- "hidden": 1,
"label": "Route",
"read_only": 1
},
- {
- "collapsible": 1,
- "fieldname": "test_the_newsletter",
- "fieldtype": "Section Break",
- "label": "Testing"
- },
- {
- "description": "A Lead with this Email Address should exist",
- "fieldname": "test_email_id",
- "fieldtype": "Data",
- "label": "Test Email Address",
- "options": "Email"
- },
- {
- "depends_on": "eval: doc.test_email_id",
- "fieldname": "test_send",
- "fieldtype": "Button",
- "label": "Test",
- "options": "test_send"
- },
{
"fieldname": "scheduled_to_send",
"fieldtype": "Int",
@@ -119,21 +113,16 @@
{
"fieldname": "recipients",
"fieldtype": "Section Break",
- "label": "Recipients"
+ "label": "To"
},
{
"depends_on": "eval: doc.schedule_sending",
"fieldname": "schedule_send",
"fieldtype": "Datetime",
- "label": "Schedule Send",
+ "label": "Send Email At",
+ "read_only": 1,
"read_only_depends_on": "eval: doc.email_sent"
},
- {
- "default": "0",
- "fieldname": "send_attachments",
- "fieldtype": "Check",
- "label": "Send Attachments"
- },
{
"fieldname": "content_type",
"fieldtype": "Select",
@@ -158,8 +147,87 @@
"default": "0",
"fieldname": "schedule_sending",
"fieldtype": "Check",
- "label": "Schedule Sending",
+ "label": "Schedule sending at a later time",
"read_only_depends_on": "eval: doc.email_sent"
+ },
+ {
+ "default": "0",
+ "fieldname": "send_webview_link",
+ "fieldtype": "Check",
+ "label": "Send Web View Link"
+ },
+ {
+ "fieldname": "from_section",
+ "fieldtype": "Section Break",
+ "label": "From"
+ },
+ {
+ "fieldname": "sender_name",
+ "fieldtype": "Data",
+ "label": "Sender Name"
+ },
+ {
+ "fieldname": "sender_email",
+ "fieldtype": "Data",
+ "label": "Sender Email",
+ "options": "Email",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_5",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_7",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "subject_section",
+ "fieldtype": "Section Break",
+ "label": "Subject"
+ },
+ {
+ "fieldname": "publish_as_a_web_page_section",
+ "fieldtype": "Section Break",
+ "label": "Publish as a web page"
+ },
+ {
+ "depends_on": "schedule_sending",
+ "fieldname": "schedule_settings_section",
+ "fieldtype": "Section Break",
+ "label": "Scheduled Sending"
+ },
+ {
+ "fieldname": "attachments",
+ "fieldtype": "Table",
+ "label": "Attachments",
+ "options": "Newsletter Attachment"
+ },
+ {
+ "fieldname": "email_sent_at",
+ "fieldtype": "Datetime",
+ "label": "Email Sent At",
+ "read_only": 1
+ },
+ {
+ "fieldname": "total_recipients",
+ "fieldtype": "Int",
+ "label": "Total Recipients",
+ "read_only": 1
+ },
+ {
+ "depends_on": "email_sent",
+ "fieldname": "status_section",
+ "fieldtype": "Section Break",
+ "label": "Status"
+ },
+ {
+ "fieldname": "column_break_12",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
}
],
"has_web_view": 1,
@@ -168,8 +236,7 @@
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
- "max_attachments": 3,
- "modified": "2020-08-24 19:59:37.262500",
+ "modified": "2022-03-09 01:48:16.741603",
"modified_by": "Administrator",
"module": "Email",
"name": "Newsletter",
diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py
old mode 100755
new mode 100644
index 2791ebb75b..aa6fa2c40a
--- a/frappe/email/doctype/newsletter/newsletter.py
+++ b/frappe/email/doctype/newsletter/newsletter.py
@@ -1,276 +1,355 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See LICENSE
-from __future__ import unicode_literals
+from typing import Dict, List
import frappe
import frappe.utils
-from frappe import throw, _
+
+from frappe import _
from frappe.website.website_generator import WebsiteGenerator
from frappe.utils.verified_command import get_signed_params, verify_request
-from frappe.email.queue import send
from frappe.email.doctype.email_group.email_group import add_subscribers
-from frappe.utils import parse_addr, now_datetime, markdown, validate_email_address
+
+from .exceptions import NewsletterAlreadySentError, NoRecipientFoundError, NewsletterNotSavedError
+
class Newsletter(WebsiteGenerator):
- def onload(self):
- if self.email_sent:
- self.get("__onload").status_count = dict(frappe.db.sql("""select status, count(name)
- from `tabEmail Queue` where reference_doctype=%s and reference_name=%s
- group by status""", (self.doctype, self.name))) or None
-
def validate(self):
- self.route = "newsletters/" + self.name
- if self.send_from:
- validate_email_address(self.send_from, True)
+ self.route = f"newsletters/{self.name}"
+ self.validate_sender_address()
+ self.validate_recipient_address()
+ self.validate_publishing()
- def test_send(self, doctype="Lead"):
- self.recipients = frappe.utils.split_emails(self.test_email_id)
- self.queue_all(test_email=True)
- frappe.msgprint(_("Test email sent to {0}").format(self.test_email_id))
+ @property
+ def newsletter_recipients(self) -> List[str]:
+ if getattr(self, "_recipients", None) is None:
+ self._recipients = self.get_recipients()
+ return self._recipients
+ @frappe.whitelist()
+ def get_sending_status(self):
+ count_by_status = frappe.get_all("Email Queue",
+ filters={"reference_doctype": self.doctype, "reference_name": self.name},
+ fields=["status", "count(name) as count"],
+ group_by="status",
+ order_by="status"
+ )
+ sent = 0
+ total = 0
+ for row in count_by_status:
+ if row.status == "Sent":
+ sent = row.count
+ total += row.count
+
+ return {'sent': sent, 'total': total}
+
+ @frappe.whitelist()
+ def send_test_email(self, email):
+ test_emails = frappe.utils.validate_email_address(email, throw=True)
+ self.send_newsletter(emails=test_emails)
+ frappe.msgprint(_("Test email sent to {0}").format(email), alert=True)
+
+ @frappe.whitelist()
+ def find_broken_links(self):
+ from bs4 import BeautifulSoup
+ import requests
+
+ html = self.get_message()
+ soup = BeautifulSoup(html, "html.parser")
+ links = soup.find_all("a")
+ images = soup.find_all("img")
+ broken_links = []
+ for el in links + images:
+ url = el.attrs.get("href") or el.attrs.get("src")
+ try:
+ response = requests.head(url, verify=False, timeout=5)
+ if response.status_code >= 400:
+ broken_links.append(url)
+ except:
+ broken_links.append(url)
+ return broken_links
+
+ @frappe.whitelist()
def send_emails(self):
- """send emails to leads and customers"""
- if self.email_sent:
- throw(_("Newsletter has already been sent"))
-
- self.recipients = self.get_recipients()
-
- if self.recipients:
- self.queue_all()
- frappe.msgprint(_("Email queued to {0} recipients").format(len(self.recipients)))
-
- else:
- frappe.msgprint(_("Newsletter should have atleast one recipient"))
-
- def queue_all(self, test_email=False):
- if not self.get("recipients"):
- # in case it is called via worker
- self.recipients = self.get_recipients()
-
- self.validate_send()
-
- sender = self.send_from or frappe.utils.get_formatted_email(self.owner)
-
- if not frappe.flags.in_test:
- frappe.db.auto_commit_on_many_writes = True
-
- attachments = []
- if self.send_attachments:
- files = frappe.get_all("File", fields=["name"], filters={"attached_to_doctype": "Newsletter",
- "attached_to_name": self.name}, order_by="creation desc")
-
- for file in files:
- try:
- # these attachments will be attached on-demand
- # and won't be stored in the message
- attachments.append({"fid": file.name})
- except IOError:
- frappe.throw(_("Unable to find attachment {0}").format(file.name))
-
- send(recipients=self.recipients, sender=sender,
- subject=self.subject, message=self.get_message(),
- reference_doctype=self.doctype, reference_name=self.name,
- add_unsubscribe_link=self.send_unsubscribe_link, attachments=attachments,
- unsubscribe_method="/unsubscribe",
- unsubscribe_params={"name": self.name},
- send_priority=0, queue_separately=True)
-
- if not frappe.flags.in_test:
- frappe.db.auto_commit_on_many_writes = False
-
- if not test_email:
- self.db_set("email_sent", 1)
- self.db_set("schedule_send", now_datetime())
- self.db_set("scheduled_to_send", len(self.recipients))
-
- def get_message(self):
- if self.content_type == "HTML":
- return frappe.render_template(self.message_html, {"doc": self.as_dict()})
- return {
- 'Rich Text': self.message,
- 'Markdown': markdown(self.message_md)
- }[self.content_type or 'Rich Text']
-
- def get_recipients(self):
- """Get recipients from Email Group"""
- recipients_list = []
- for email_group in get_email_groups(self.name):
- for d in frappe.db.get_all("Email Group Member", ["email"],
- {"unsubscribed": 0, "email_group": email_group.email_group}):
- recipients_list.append(d.email)
- return list(set(recipients_list))
+ """queue sending emails to recipients"""
+ self.schedule_sending = False
+ self.schedule_send = None
+ self.queue_all()
+ frappe.msgprint(_("Email queued to {0} recipients").format(self.total_recipients))
def validate_send(self):
+ """Validate if Newsletter can be sent.
+ """
+ self.validate_newsletter_status()
+ self.validate_newsletter_recipients()
+
+ def validate_newsletter_status(self):
+ if self.email_sent:
+ frappe.throw(_("Newsletter has already been sent"), exc=NewsletterAlreadySentError)
+
if self.get("__islocal"):
- throw(_("Please save the Newsletter before sending"))
+ frappe.throw(_("Please save the Newsletter before sending"), exc=NewsletterNotSavedError)
- if not self.recipients:
- frappe.throw(_("Newsletter should have at least one recipient"))
+ def validate_newsletter_recipients(self):
+ if not self.newsletter_recipients:
+ frappe.throw(_("Newsletter should have atleast one recipient"), exc=NoRecipientFoundError)
+ self.validate_recipient_address()
- def get_context(self, context):
- newsletters = get_newsletter_list("Newsletter", None, None, 0)
- if newsletters:
- newsletter_list = [d.name for d in newsletters]
- if self.name not in newsletter_list:
- frappe.redirect_to_message(_('Permission Error'),
- _("You are not permitted to view the newsletter."))
- frappe.local.flags.redirect_location = frappe.local.response.location
- raise frappe.Redirect
- else:
- context.attachments = get_attachments(self.name)
- context.no_cache = 1
- context.show_sidebar = True
+ def validate_sender_address(self):
+ """Validate self.send_from is a valid email address or not.
+ """
+ if self.sender_email:
+ frappe.utils.validate_email_address(self.sender_email, throw=True)
+ self.send_from = f"{self.sender_name} <{self.sender_email}>" if self.sender_name else self.sender_email
+ def validate_recipient_address(self):
+ """Validate if self.newsletter_recipients are all valid email addresses or not.
+ """
+ for recipient in self.newsletter_recipients:
+ frappe.utils.validate_email_address(recipient, throw=True)
-def get_attachments(name):
- return frappe.get_all("File",
+ def validate_publishing(self):
+ if self.send_webview_link and not self.published:
+ frappe.throw(_("Newsletter must be published to send webview link in email"))
+
+ def get_linked_email_queue(self) -> List[str]:
+ """Get list of email queue linked to this newsletter.
+ """
+ return frappe.get_all("Email Queue",
+ filters={
+ "reference_doctype": self.doctype,
+ "reference_name": self.name,
+ },
+ pluck="name",
+ )
+
+ def get_success_recipients(self) -> List[str]:
+ """Recipients who have already recieved the newsletter.
+
+ Couldn't think of a better name ;)
+ """
+ return frappe.get_all("Email Queue Recipient",
+ filters={
+ "status": ("in", ["Not Sent", "Sending", "Sent"]),
+ "parentfield": ("in", self.get_linked_email_queue()),
+ },
+ pluck="recipient",
+ )
+
+ def get_pending_recipients(self) -> List[str]:
+ """Get list of pending recipients of the newsletter. These
+ recipients may not have receive the newsletter in the previous iteration.
+ """
+ return [
+ x for x in self.newsletter_recipients if x not in self.get_success_recipients()
+ ]
+
+ def queue_all(self):
+ """Queue Newsletter to all the recipients generated from the `Email Group` table
+ """
+ self.validate()
+ self.validate_send()
+
+ recipients = self.get_pending_recipients()
+ self.send_newsletter(emails=recipients)
+
+ self.email_sent = True
+ self.email_sent_at = frappe.utils.now()
+ self.total_recipients = len(recipients)
+ self.save()
+
+ def get_newsletter_attachments(self) -> List[Dict[str, str]]:
+ """Get list of attachments on current Newsletter
+ """
+ return [{"file_url": row.attachment} for row in self.attachments]
+
+ def send_newsletter(self, emails: List[str]):
+ """Trigger email generation for `emails` and add it in Email Queue.
+ """
+ attachments = self.get_newsletter_attachments()
+ sender = self.send_from or frappe.utils.get_formatted_email(self.owner)
+ args = self.as_dict()
+ args["message"] = self.get_message()
+
+ is_auto_commit_set = bool(frappe.db.auto_commit_on_many_writes)
+ frappe.db.auto_commit_on_many_writes = not frappe.flags.in_test
+
+ frappe.sendmail(
+ subject=self.subject,
+ sender=sender,
+ recipients=emails,
+ attachments=attachments,
+ template="newsletter",
+ add_unsubscribe_link=self.send_unsubscribe_link,
+ unsubscribe_method="/unsubscribe",
+ unsubscribe_params={"name": self.name},
+ reference_doctype=self.doctype,
+ reference_name=self.name,
+ queue_separately=True,
+ send_priority=0,
+ args=args,
+ )
+
+ frappe.db.auto_commit_on_many_writes = is_auto_commit_set
+
+ def get_message(self) -> str:
+ message = self.message
+ if self.content_type == "Markdown":
+ message = frappe.utils.md_to_html(self.message_md)
+ if self.content_type == "HTML":
+ message = self.message_html
+
+ return frappe.render_template(message, {"doc": self.as_dict()})
+
+ def get_recipients(self) -> List[str]:
+ """Get recipients from Email Group"""
+ emails = frappe.get_all(
+ "Email Group Member",
+ filters={"unsubscribed": 0, "email_group": ("in", self.get_email_groups())},
+ pluck="email",
+ )
+ return list(set(emails))
+
+ def get_email_groups(self) -> List[str]:
+ # wondering why the 'or'? i can't figure out why both aren't equivalent - @gavin
+ return [
+ x.email_group for x in self.email_group
+ ] or frappe.get_all(
+ "Newsletter Email Group",
+ filters={"parent": self.name, "parenttype": "Newsletter"},
+ pluck="email_group",
+ )
+
+ def get_attachments(self) -> List[Dict[str, str]]:
+ return frappe.get_all(
+ "File",
fields=["name", "file_name", "file_url", "is_private"],
- filters = {"attached_to_name": name, "attached_to_doctype": "Newsletter", "is_private":0})
-
-
-def get_email_groups(name):
- return frappe.db.get_all("Newsletter Email Group", ["email_group"],{"parent":name, "parenttype":"Newsletter"})
+ filters={
+ "attached_to_name": self.name,
+ "attached_to_doctype": "Newsletter",
+ "is_private": 0,
+ },
+ )
@frappe.whitelist(allow_guest=True)
def confirmed_unsubscribe(email, group):
""" unsubscribe the email(user) from the mailing list(email_group) """
- frappe.flags.ignore_permissions=True
+ frappe.flags.ignore_permissions = True
doc = frappe.get_doc("Email Group Member", {"email": email, "email_group": group})
if not doc.unsubscribed:
doc.unsubscribed = 1
- doc.save(ignore_permissions = True)
-
-def create_lead(email_id):
- """create a lead if it does not exist"""
- from frappe.model.naming import get_default_naming_series
- full_name, email_id = parse_addr(email_id)
- if frappe.db.get_value("Lead", {"email_id": email_id}):
- return
-
- lead = frappe.get_doc({
- "doctype": "Lead",
- "email_id": email_id,
- "lead_name": full_name or email_id,
- "status": "Lead",
- "naming_series": get_default_naming_series("Lead"),
- "company": frappe.db.get_default("Company"),
- "source": "Email"
- })
- lead.insert()
+ doc.save(ignore_permissions=True)
@frappe.whitelist(allow_guest=True)
-def subscribe(email, email_group=_('Website')):
- url = frappe.utils.get_url("/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription") +\
- "?" + get_signed_params({"email": email, "email_group": email_group})
+def subscribe(email, email_group=_("Website")):
+ """API endpoint to subscribe an email to a particular email group. Triggers a confirmation email.
+ """
- email_template = frappe.db.get_value('Email Group', email_group, ['confirmation_email_template'])
+ # build subscription confirmation URL
+ api_endpoint = frappe.utils.get_url(
+ "/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription"
+ )
+ signed_params = get_signed_params({"email": email, "email_group": email_group})
+ confirm_subscription_url = f"{api_endpoint}?{signed_params}"
- content=''
- if email_template:
- args = dict(
- email=email,
- confirmation_url=url,
- email_group=email_group
- )
+ # fetch custom template if available
+ email_confirmation_template = frappe.db.get_value(
+ "Email Group", email_group, "confirmation_email_template"
+ )
- email_template = frappe.get_doc("Email Template", email_template)
+ # build email and send
+ if email_confirmation_template:
+ args = {"email": email, "confirmation_url": confirm_subscription_url, "email_group": email_group}
+ email_template = frappe.get_doc("Email Template", email_confirmation_template)
+ email_subject = email_template.subject
content = frappe.render_template(email_template.response, args)
-
- if not content:
- messages = (
+ else:
+ email_subject = _("Confirm Your Email")
+ translatable_content = (
_("Thank you for your interest in subscribing to our updates"),
_("Please verify your Email Address"),
- url,
- _("Click here to verify")
+ confirm_subscription_url,
+ _("Click here to verify"),
)
-
content = """
- {0}. {1}.
- {3}
- """.format(*messages)
+ {0}. {1}.
+ {3}
+ """.format(*translatable_content)
+
+ frappe.sendmail(
+ email,
+ subject=email_subject,
+ content=content,
+ now=True,
+ )
- frappe.sendmail(email, subject=getattr('email_template', 'subject', '') or _("Confirm Your Email"), content=content, now=True)
@frappe.whitelist(allow_guest=True)
-def confirm_subscription(email, email_group=_('Website')):
+def confirm_subscription(email, email_group=_("Website")):
+ """API endpoint to confirm email subscription.
+ This endpoint is called when user clicks on the link sent to their mail.
+ """
if not verify_request():
return
if not frappe.db.exists("Email Group", email_group):
- frappe.get_doc({
- "doctype": "Email Group",
- "title": email_group
- }).insert(ignore_permissions=True)
+ frappe.get_doc({"doctype": "Email Group", "title": email_group}).insert(
+ ignore_permissions=True
+ )
frappe.flags.ignore_permissions = True
add_subscribers(email_group, email)
frappe.db.commit()
- frappe.respond_as_web_page(_("Confirmed"),
+ frappe.respond_as_web_page(
+ _("Confirmed"),
_("{0} has been successfully added to the Email Group.").format(email),
- indicator_color='green')
-
-
-def send_newsletter(newsletter):
- try:
- doc = frappe.get_doc("Newsletter", newsletter)
- doc.queue_all()
-
- except:
- frappe.db.rollback()
-
- # wasn't able to send emails :(
- doc.db_set("email_sent", 0)
- frappe.db.commit()
-
- frappe.log_error(title='Send Newsletter')
-
- raise
-
- else:
- frappe.db.commit()
+ indicator_color="green",
+ )
def get_list_context(context=None):
context.update({
- "show_sidebar": True,
"show_search": True,
- 'no_breadcrumbs': True,
- "title": _("Newsletter"),
- "get_list": get_newsletter_list,
+ "no_breadcrumbs": True,
+ "title": _("Newsletters"),
+ "filters": {"published": 1},
"row_template": "email/doctype/newsletter/templates/newsletter_row.html",
})
-def get_newsletter_list(doctype, txt, filters, limit_start, limit_page_length=20, order_by="modified"):
- email_group_list = frappe.db.sql('''SELECT eg.name
- FROM `tabEmail Group` eg, `tabEmail Group Member` egm
- WHERE egm.unsubscribed=0
- AND eg.name=egm.email_group
- AND egm.email = %s''', frappe.session.user)
- email_group_list = [d[0] for d in email_group_list]
-
- if email_group_list:
- return frappe.db.sql('''SELECT n.name, n.subject, n.message, n.modified
- FROM `tabNewsletter` n, `tabNewsletter Email Group` neg
- WHERE n.name = neg.parent
- AND n.email_sent=1
- AND n.published=1
- AND neg.email_group in ({0})
- ORDER BY n.modified DESC LIMIT {1} OFFSET {2}
- '''.format(','.join(['%s'] * len(email_group_list)),
- limit_page_length, limit_start), email_group_list, as_dict=1)
-
def send_scheduled_email():
"""Send scheduled newsletter to the recipients."""
- scheduled_newsletter = frappe.get_all('Newsletter', filters = {
- 'schedule_send': ('<=', now_datetime()),
- 'email_sent': 0,
- 'schedule_sending': 1
- }, fields = ['name'], ignore_ifnull=True)
+ scheduled_newsletter = frappe.get_all(
+ "Newsletter",
+ filters={
+ "schedule_send": ("<=", frappe.utils.now_datetime()),
+ "email_sent": False,
+ "schedule_sending": True,
+ },
+ ignore_ifnull=True,
+ pluck="name",
+ )
+
for newsletter in scheduled_newsletter:
- send_newsletter(newsletter.name)
+ try:
+ frappe.get_doc("Newsletter", newsletter).queue_all()
+
+ except Exception:
+ frappe.db.rollback()
+
+ # wasn't able to send emails :(
+ frappe.db.set_value("Newsletter", newsletter, "email_sent", 0)
+ message = (
+ f"Newsletter {newsletter} failed to send"
+ "\n\n"
+ f"Traceback: {frappe.get_traceback()}"
+ )
+ frappe.log_error(title="Send Newsletter", message=message)
+
+ if not frappe.flags.in_test:
+ frappe.db.commit()
diff --git a/frappe/email/doctype/newsletter/templates/newsletter.html b/frappe/email/doctype/newsletter/templates/newsletter.html
index 733c7df6af..1244f4c49a 100644
--- a/frappe/email/doctype/newsletter/templates/newsletter.html
+++ b/frappe/email/doctype/newsletter/templates/newsletter.html
@@ -1,6 +1,6 @@
{% extends "templates/web.html" %}
-{% block title %} {{ _("Newsletter") }} {% endblock %}
+{% block title %} {{ doc.subject }} {% endblock %}
{% block page_content %}
- `
+ `
);
- if (frappe.utils.is_rtl(this.lang_code)) {
- this.$print_format_body.find('head').append(
- ` `
- );
- }
-
this.$print_format_body.find('body').html(
`${out.html}
`
);
@@ -467,72 +511,128 @@ frappe.ui.form.PrintView = class {
printit() {
let me = this;
- frappe.call({
- method:
- 'frappe.printing.doctype.print_settings.print_settings.is_print_server_enabled',
- callback: function(data) {
- if (data.message) {
- frappe.call({
- method: 'frappe.utils.print_format.print_by_server',
- args: {
- doctype: me.frm.doc.doctype,
- name: me.frm.doc.name,
- print_format: me.selected_format(),
- no_letterhead: me.with_letterhead(),
- letterhead: this.get_letterhead(),
- },
- callback: function() {},
- });
- } else if (me.get_mapped_printer().length === 1) {
- // printer is already mapped in localstorage (applies for both raw and pdf )
- if (me.is_raw_printing()) {
- me.get_raw_commands(function(out) {
- frappe.ui.form
- .qz_connect()
- .then(function() {
- let printer_map = me.get_mapped_printer()[0];
- let data = [out.raw_commands];
- let config = qz.configs.create(printer_map.printer);
- return qz.print(config, data);
- })
- .then(frappe.ui.form.qz_success)
- .catch((err) => {
- frappe.ui.form.qz_fail(err);
- });
+
+ if (cint(me.print_settings.enable_print_server)) {
+ if (localStorage.getItem('network_printer')) {
+ me.print_by_server();
+ } else {
+ me.network_printer_setting_dialog(() => me.print_by_server());
+ }
+ } else if (me.get_mapped_printer().length === 1) {
+ // printer is already mapped in localstorage (applies for both raw and pdf )
+ if (me.is_raw_printing()) {
+ me.get_raw_commands(function(out) {
+ frappe.ui.form
+ .qz_connect()
+ .then(function() {
+ let printer_map = me.get_mapped_printer()[0];
+ let data = [out.raw_commands];
+ let config = qz.configs.create(printer_map.printer);
+ return qz.print(config, data);
+ })
+ .then(frappe.ui.form.qz_success)
+ .catch((err) => {
+ frappe.ui.form.qz_fail(err);
});
- } else {
- frappe.show_alert(
+ });
+ } else {
+ frappe.show_alert(
+ {
+ message: __('PDF printing via "Raw Print" is not supported.'),
+ subtitle: __(
+ 'Please remove the printer mapping in Printer Settings and try again.'
+ ),
+ indicator: 'info',
+ },
+ 14
+ );
+ //Note: need to solve "Error: Cannot parse (FILE) as a PDF file" to enable qz pdf printing.
+ }
+ } else if (me.is_raw_printing()) {
+ // printer not mapped in localstorage and the current print format is raw printing
+ frappe.show_alert(
+ {
+ message: __('Printer mapping not set.'),
+ subtitle: __(
+ 'Please set a printer mapping for this print format in the Printer Settings'
+ ),
+ indicator: 'warning',
+ },
+ 14
+ );
+ me.printer_setting_dialog();
+ } else {
+ me.render_page('/printview?', true);
+ }
+ }
+
+ print_by_server() {
+ let me = this;
+ if (localStorage.getItem('network_printer')) {
+ frappe.call({
+ method: 'frappe.utils.print_format.print_by_server',
+ args: {
+ doctype: me.frm.doc.doctype,
+ name: me.frm.doc.name,
+ printer_setting: localStorage.getItem('network_printer'),
+ print_format: me.selected_format(),
+ no_letterhead: me.with_letterhead(),
+ letterhead: me.get_letterhead(),
+ },
+ callback: function() {},
+ });
+ }
+ }
+ network_printer_setting_dialog(callback) {
+ frappe.call({
+ method: 'frappe.printing.doctype.network_printer_settings.network_printer_settings.get_network_printer_settings',
+ callback: function(r) {
+ if (r.message) {
+ let d = new frappe.ui.Dialog({
+ title: __('Select Network Printer'),
+ fields: [
{
- message: __('PDF printing via "Raw Print" is not supported.'),
- subtitle: __(
- 'Please remove the printer mapping in Printer Settings and try again.'
- ),
- indicator: 'info',
- },
- 14
- );
- //Note: need to solve "Error: Cannot parse (FILE) as a PDF file" to enable qz pdf printing.
- }
- } else if (me.is_raw_printing()) {
- // printer not mapped in localstorage and the current print format is raw printing
- frappe.show_alert(
- {
- message: __('Printer mapping not set.'),
- subtitle: __(
- 'Please set a printer mapping for this print format in the Printer Settings'
- ),
- indicator: 'warning',
+ "label": "Printer",
+ "fieldname": "printer",
+ "fieldtype": "Select",
+ "reqd": 1,
+ "options": r.message
+ }
+ ],
+ primary_action: function() {
+ localStorage.setItem('network_printer', d.get_values().printer);
+ if (typeof callback == "function") {
+ callback();
+ }
+ d.hide();
},
- 14
- );
- me.printer_setting_dialog();
- } else {
- me.render_page('/printview?', true);
+ primary_action_label: __('Select')
+ });
+ d.show();
}
},
});
}
+ render_pdf() {
+ let print_format = this.get_print_format();
+ if (print_format.print_format_builder_beta) {
+ let params = new URLSearchParams({
+ doctype: this.frm.doc.doctype,
+ name: this.frm.doc.name,
+ print_format: print_format.name,
+ letterhead: this.get_letterhead()
+ });
+ let w = window.open(`/api/method/frappe.utils.weasyprint.download_pdf?${params}`);
+ if (!w) {
+ frappe.msgprint(__('Please enable pop-ups'));
+ return;
+ }
+ } else {
+ this.render_page('/api/method/frappe.utils.print_format.download_pdf?');
+ }
+ }
+
render_page(method, printit = false) {
let w = window.open(
frappe.urllib.get_full_url(
@@ -641,10 +741,13 @@ frappe.ui.form.PrintView = class {
refresh_print_options() {
this.print_formats = frappe.meta.get_print_formats(this.frm.doctype);
- return this.print_sel.empty().add_options([
+ const print_format_select_val = this.print_sel.val();
+ this.print_sel.empty().add_options([
this.get_default_option_for_select(__('Select Print Format')),
...this.print_formats
]);
+ return this.print_formats.includes(print_format_select_val)
+ && this.print_sel.val(print_format_select_val);
}
selected_format() {
diff --git a/frappe/printing/page/print_format_builder/print_format_builder.js b/frappe/printing/page/print_format_builder/print_format_builder.js
index eb87190ab5..313e8da539 100644
--- a/frappe/printing/page/print_format_builder/print_format_builder.js
+++ b/frappe/printing/page/print_format_builder/print_format_builder.js
@@ -12,9 +12,9 @@ frappe.pages['print-format-builder'].on_page_show = function(wrapper) {
});
} else if(frappe.route_options) {
if(frappe.route_options.make_new) {
- let { doctype, name, based_on } = frappe.route_options;
+ let { doctype, name, based_on, beta } = frappe.route_options;
frappe.route_options = null;
- frappe.print_format_builder.setup_new_print_format(doctype, name, based_on);
+ frappe.print_format_builder.setup_new_print_format(doctype, name, based_on, beta);
} else {
frappe.print_format_builder.print_format = frappe.route_options.doc;
frappe.route_options = null;
@@ -23,13 +23,13 @@ frappe.pages['print-format-builder'].on_page_show = function(wrapper) {
}
}
-frappe.PrintFormatBuilder = Class.extend({
- init: function(parent) {
+frappe.PrintFormatBuilder = class PrintFormatBuilder {
+ constructor(parent) {
this.parent = parent;
this.make();
this.refresh();
- },
- refresh: function() {
+ }
+ refresh() {
this.custom_html_count = 0;
if(!this.print_format) {
this.show_start();
@@ -37,8 +37,8 @@ frappe.PrintFormatBuilder = Class.extend({
this.page.set_title(this.print_format.name);
this.setup_print_format();
}
- },
- make: function() {
+ }
+ make() {
this.page = frappe.ui.make_app_page({
parent: this.parent,
title: __("Print Format Builder"),
@@ -56,15 +56,15 @@ frappe.PrintFormatBuilder = Class.extend({
this.setup_edit_custom_html();
// $(this.page.sidebar).css({"position": 'fixed'});
// $(this.page.main).parent().css({"margin-left": '16.67%'});
- },
- show_start: function() {
+ }
+ show_start() {
this.page.main.html(frappe.render_template("print_format_builder_start", {}));
this.page.clear_actions();
this.page.set_title(__("Print Format Builder"));
this.start_edit_print_format();
this.start_new_print_format();
- },
- start_edit_print_format: function() {
+ }
+ start_edit_print_format() {
// print format control
var me = this;
this.print_format_input = frappe.ui.form.make_control({
@@ -89,8 +89,8 @@ frappe.PrintFormatBuilder = Class.extend({
frappe.set_route('print-format-builder', name);
});
});
- },
- start_new_print_format: function() {
+ }
+ start_new_print_format() {
var me = this;
this.doctype_input = frappe.ui.form.make_control({
parent: this.page.main.find(".doctype-selector"),
@@ -125,26 +125,30 @@ frappe.PrintFormatBuilder = Class.extend({
me.setup_new_print_format(doctype, name);
});
- },
- setup_new_print_format: function(doctype, name, based_on) {
+ }
+ setup_new_print_format(doctype, name, based_on, beta) {
frappe.call({
method: 'frappe.printing.page.print_format_builder.print_format_builder.create_custom_format',
args: {
doctype: doctype,
name: name,
- based_on: based_on
+ based_on: based_on,
+ beta: Boolean(beta)
},
callback: (r) => {
- if(!r.exc) {
- if(r.message) {
- this.print_format = r.message;
+ if (r.message) {
+ let print_format = r.message;
+ if (print_format.print_format_builder_beta) {
+ frappe.set_route('print-format-builder-beta', print_format.name);
+ } else {
+ this.print_format = print_format;
this.refresh();
}
}
},
});
- },
- setup_print_format: function() {
+ }
+ setup_print_format() {
var me = this;
frappe.model.with_doctype(this.print_format.doc_type, function(doctype) {
me.meta = frappe.get_meta(me.print_format.doc_type);
@@ -163,23 +167,23 @@ frappe.PrintFormatBuilder = Class.extend({
frappe.set_route("Form", "Print Format", me.print_format.name);
});
});
- },
- setup_sidebar: function() {
+ }
+ setup_sidebar() {
// prepend custom HTML field
var fields = [this.get_custom_html_field()].concat(this.meta.fields);
this.page.sidebar.html(
$(frappe.render_template("print_format_builder_sidebar", {fields: fields}))
);
this.setup_field_filter();
- },
- get_custom_html_field: function() {
+ }
+ get_custom_html_field() {
return {
fieldtype: "Custom HTML",
fieldname: "_custom_html",
label: __("Custom HTML")
- }
- },
- render_layout: function() {
+ };
+ }
+ render_layout() {
this.page.main.empty();
this.prepare_data();
$(frappe.render_template("print_format_builder_layout", {
@@ -190,8 +194,8 @@ frappe.PrintFormatBuilder = Class.extend({
this.setup_edit_heading();
this.setup_field_settings();
this.setup_html_data();
- },
- prepare_data: function() {
+ }
+ prepare_data() {
this.print_heading_template = null;
this.data = JSON.parse(this.print_format.format_data || "[]");
if(!this.data.length) {
@@ -261,7 +265,7 @@ frappe.PrintFormatBuilder = Class.extend({
} else if(f.fieldtype==="Column Break") {
set_column();
- } else if(!in_list(["Section Break", "Column Break", "Fold"], f.fieldtype)
+ } else if (!in_list(["Section Break", "Column Break", "Tab Break", "Fold"], f.fieldtype)
&& f.label) {
if(!column) set_column();
@@ -280,25 +284,25 @@ frappe.PrintFormatBuilder = Class.extend({
this.layout_data = $.map(this.layout_data, function(s) {
return s.has_fields ? s : null
});
- },
- get_new_section: function() {
+ }
+ get_new_section() {
return {columns: [], no_of_columns: 0, label:''};
- },
- get_new_column: function() {
+ }
+ get_new_column() {
return {fields: []}
- },
- add_table_properties: function(f) {
+ }
+ add_table_properties(f) {
// build table columns and widths in a dict
// visible_columns
var me = this;
if(!f.visible_columns) {
me.init_visible_columns(f);
}
- },
- init_visible_columns: function(f) {
+ }
+ init_visible_columns(f) {
f.visible_columns = []
$.each(frappe.get_meta(f.options).fields, function(i, _f) {
- if(!in_list(["Section Break", "Column Break"], _f.fieldtype) &&
+ if (!in_list(["Section Break", "Column Break", "Tab Break"], _f.fieldtype) &&
!_f.print_hide && f.label) {
// column names set as fieldname|width
@@ -306,8 +310,8 @@ frappe.PrintFormatBuilder = Class.extend({
print_width: (_f.width || ""), print_hide:0});
}
});
- },
- setup_sortable: function() {
+ }
+ setup_sortable() {
var me = this;
// drag from fields library
@@ -332,8 +336,8 @@ frappe.PrintFormatBuilder = Class.extend({
Sortable.create(this.page.main.find(".print-format-builder-layout").get(0),
{ handle: ".print-format-builder-section-head" }
);
- },
- setup_sortable_for_column: function(col) {
+ }
+ setup_sortable_for_column(col) {
var me = this;
Sortable.create(col, {
group: {
@@ -363,8 +367,8 @@ frappe.PrintFormatBuilder = Class.extend({
}
});
- },
- setup_field_filter: function() {
+ }
+ setup_field_filter() {
var me = this;
this.page.sidebar.find(".filter-fields").on("keyup", function() {
var text = $(this).val();
@@ -373,8 +377,8 @@ frappe.PrintFormatBuilder = Class.extend({
$(this).parent().toggle(show);
})
});
- },
- setup_section_settings: function() {
+ }
+ setup_section_settings() {
var me = this;
this.page.main.on("click", ".section-settings", function() {
var section = $(this).parent().parent();
@@ -431,8 +435,8 @@ frappe.PrintFormatBuilder = Class.extend({
return false;
});
- },
- setup_field_settings: function() {
+ }
+ setup_field_settings() {
this.page.main.find(".field-settings").on("click", e => {
const field = $(e.currentTarget).parent();
// new dialog
@@ -482,8 +486,8 @@ frappe.PrintFormatBuilder = Class.extend({
return false;
});
- },
- setup_html_data: function() {
+ }
+ setup_html_data() {
// set JQuery `data` for Custom HTML fields, since editing the HTML
// directly causes problem becuase of HTML reformatting
//
@@ -496,8 +500,8 @@ frappe.PrintFormatBuilder = Class.extend({
var html = me.custom_html_dict[parseInt(content.attr('data-custom-html-id'))].options;
content.data('content', html);
})
- },
- update_columns_in_section: function(section, no_of_columns, new_no_of_columns) {
+ }
+ update_columns_in_section(section, no_of_columns, new_no_of_columns) {
var col_size = 12 / new_no_of_columns,
me = this,
resize = function() {
@@ -539,8 +543,8 @@ frappe.PrintFormatBuilder = Class.extend({
resize();
}
- },
- setup_add_section: function() {
+ }
+ setup_add_section() {
var me = this;
this.page.main.find(".print-format-builder-add-section").on("click", function() {
// boostrap new section info
@@ -554,8 +558,8 @@ frappe.PrintFormatBuilder = Class.extend({
me.setup_sortable_for_column($section.find(".print-format-builder-column").get(0));
});
- },
- setup_edit_heading: function() {
+ }
+ setup_edit_heading() {
var me = this;
var $heading = this.page.main.find(".print-format-builder-print-heading");
@@ -565,8 +569,8 @@ frappe.PrintFormatBuilder = Class.extend({
this.page.main.find(".edit-heading").on("click", function() {
var d = me.get_edit_html_dialog(__("Edit Heading"), __("Heading"), $heading);
})
- },
- setup_column_selector: function() {
+ }
+ setup_column_selector() {
var me = this;
this.page.main.on("click", ".select-columns", function() {
var parent = $(this).parents(".print-format-builder-field:first"),
@@ -606,7 +610,7 @@ frappe.PrintFormatBuilder = Class.extend({
// add remaining fields
$.each(doc_fields, function(j, f) {
if (f && !in_list(column_names, f.fieldname)
- && !in_list(["Section Break", "Column Break"], f.fieldtype) && f.label) {
+ && !in_list(["Section Break", "Column Break", "Tab Break"], f.fieldtype) && f.label) {
fields.push(f);
}
})
@@ -657,24 +661,24 @@ frappe.PrintFormatBuilder = Class.extend({
return false;
});
- },
- get_visible_columns_string: function(f) {
+ }
+ get_visible_columns_string(f) {
if(!f.visible_columns) {
this.init_visible_columns(f);
}
return $.map(f.visible_columns, function(v) { return v.fieldname + "|" + (v.print_width || "") }).join(",");
- },
- get_no_content: function() {
+ }
+ get_no_content() {
return __("Edit to add content")
- },
- setup_edit_custom_html: function() {
+ }
+ setup_edit_custom_html() {
var me = this;
this.page.main.on("click", ".edit-html", function() {
me.get_edit_html_dialog(__("Edit Custom HTML"), __("Custom HTML"),
$(this).parents(".print-format-builder-field:first").find(".html-content"));
});
- },
- get_edit_html_dialog: function(title, label, $content) {
+ }
+ get_edit_html_dialog(title, label, $content) {
var me = this;
var d = new frappe.ui.Dialog({
title: title,
@@ -710,8 +714,8 @@ frappe.PrintFormatBuilder = Class.extend({
d.show();
return d;
- },
- save_print_format: function() {
+ }
+ save_print_format() {
var data = [],
me = this;
@@ -784,8 +788,9 @@ frappe.PrintFormatBuilder = Class.extend({
btn: this.page.btn_primary,
callback: function(r) {
me.print_format = r.message;
+ locals['Print Format'][me.print_format.name] = r.message;
frappe.show_alert({message: __("Saved"), indicator: 'green'});
}
});
}
-});
+};
diff --git a/frappe/printing/page/print_format_builder/print_format_builder.py b/frappe/printing/page/print_format_builder/print_format_builder.py
index d9f57762b0..fae564d3c3 100644
--- a/frappe/printing/page/print_format_builder/print_format_builder.py
+++ b/frappe/printing/page/print_format_builder/print_format_builder.py
@@ -1,11 +1,16 @@
import frappe
@frappe.whitelist()
-def create_custom_format(doctype, name, based_on='Standard'):
+def create_custom_format(doctype, name, based_on='Standard', beta=False):
doc = frappe.new_doc('Print Format')
doc.doc_type = doctype
doc.name = name
- doc.print_format_builder = 1
+ beta = frappe.parse_json(beta)
+
+ if beta:
+ doc.print_format_builder_beta = 1
+ else:
+ doc.print_format_builder = 1
doc.format_data = frappe.db.get_value('Print Format', based_on, 'format_data') \
if based_on != 'Standard' else None
doc.insert()
diff --git a/frappe/printing/page/print_format_builder/print_format_builder_sidebar.html b/frappe/printing/page/print_format_builder/print_format_builder_sidebar.html
index 0cf8178f82..c608eecbbd 100644
--- a/frappe/printing/page/print_format_builder/print_format_builder_sidebar.html
+++ b/frappe/printing/page/print_format_builder/print_format_builder_sidebar.html
@@ -4,7 +4,7 @@