Merge branch 'develop' into whats-new

This commit is contained in:
Ankush Menat 2024-04-08 16:32:15 +05:30
commit 8d9304abbe
1583 changed files with 843603 additions and 321944 deletions

23
.coveragerc Normal file
View file

@ -0,0 +1,23 @@
[run]
omit =
tests/*
.github/*
commands/*
**/test_*.py
[report]
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
exclude_also =
def __repr__
if self.debug:
if settings.DEBUG
raise AssertionError
raise NotImplementedError
if 0:
if __name__ == .__main__.:
if TYPE_CHECKING:
class .*\bProtocol\):
@(abc\.)?abstractmethod

75
.flake8
View file

@ -1,75 +0,0 @@
[flake8]
ignore =
B001,
B007,
B009,
B010,
B950,
E101,
E111,
E114,
E116,
E117,
E121,
E122,
E123,
E124,
E125,
E126,
E127,
E128,
E131,
E201,
E202,
E203,
E211,
E221,
E222,
E223,
E224,
E225,
E226,
E228,
E231,
E241,
E242,
E251,
E261,
E262,
E265,
E266,
E271,
E272,
E273,
E274,
E301,
E302,
E303,
E305,
E306,
E402,
E501,
E502,
E701,
E702,
E703,
E741,
F401,
F403,
F405,
W191,
W291,
W292,
W293,
W391,
W503,
W504,
E711,
E129,
F841,
E713,
E712,
B028,
max-line-length = 200
exclude=,test_*.py

View file

@ -40,3 +40,15 @@ f223bc02490902dfcc32892058f13f343d51fbaf
# frappe.cache() -> frappe.cache
fa6dc03cc87ad74e11609e7373078366fdcb3e1b
# Bulk refactor with sourcery
c35476256f85271fb57584eb0a26f4d9def3caf4
# black+isort -> ruff
de9ac897482013f5464a05f3c171da0072619c3a
# flake8 -> ruff + ruff config update
26ae0f3460f29116e0c083d57eee9f33763237ea
# some new ruff rules
48cf19d7e997896d12aee7c7d97f73c8df217204

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

18
.github/helper/ci.py vendored
View file

@ -1,5 +1,6 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See LICENSE
import json
import os
from pathlib import Path
@ -33,19 +34,7 @@ TESTED_VIA_CLI = [
"*/frappe/database/**/setup_db.py",
]
FRAPPE_EXCLUSIONS = [
"*/tests/*",
"*/commands/*",
"*/frappe/change_log/*",
"*/frappe/exceptions*",
"*/frappe/desk/page/setup_wizard/setup_wizard.py",
"*/frappe/coverage.py",
"*frappe/setup.py",
"*/frappe/hooks.py",
"*/doctype/*/*_dashboard.py",
"*/patches/*",
"*/.github/helper/ci.py",
] + TESTED_VIA_CLI
FRAPPE_EXCLUSIONS = ["*/tests/*", "*/commands/*", "*/frappe/change_log/*", "*/frappe/exceptions*", "*/frappe/desk/page/setup_wizard/setup_wizard.py", "*/frappe/coverage.py", "*frappe/setup.py", "*/frappe/hooks.py", "*/doctype/*/*_dashboard.py", "*/patches/*", "*/.github/helper/ci.py", *TESTED_VIA_CLI]
def get_bench_path():
@ -85,6 +74,7 @@ if __name__ == "__main__":
app = "frappe"
site = os.environ.get("SITE") or "test_site"
use_orchestrator = bool(os.environ.get("ORCHESTRATOR_URL"))
with_coverage = json.loads(os.environ.get("WITH_COVERAGE", "true").lower())
build_number = 1
total_builds = 1
@ -98,7 +88,7 @@ if __name__ == "__main__":
except Exception:
pass
with CodeCoverage(with_coverage=True, app=app):
with CodeCoverage(with_coverage=with_coverage, app=app):
if use_orchestrator:
from frappe.parallel_test_runner import ParallelTestWithOrchestrator

View file

@ -6,7 +6,8 @@
"allow_tests": true,
"db_type": "mariadb",
"auto_email_id": "test@example.com",
"mail_server": "smtp.example.com",
"mail_server": "localhost",
"mail_port": 2525,
"mail_login": "test@example.com",
"mail_password": "test",
"admin_password": "admin",

View file

@ -6,7 +6,8 @@
"db_type": "postgres",
"allow_tests": true,
"auto_email_id": "test@example.com",
"mail_server": "smtp.example.com",
"mail_server": "localhost",
"mail_port": 2525,
"mail_login": "test@example.com",
"mail_password": "test",
"admin_password": "admin",

View file

@ -1,7 +1,7 @@
import sys
import requests
from urllib.parse import urlparse
import requests
WEBSITE_REPOS = [
"erpnext_com",
@ -36,11 +36,7 @@ def is_documentation_link(word: str) -> bool:
def contains_documentation_link(body: str) -> bool:
return any(
is_documentation_link(word)
for line in body.splitlines()
for word in line.split()
)
return any(is_documentation_link(word) for line in body.splitlines() for word in line.split())
def check_pull_request(number: str) -> "tuple[int, str]":
@ -53,12 +49,7 @@ def check_pull_request(number: str) -> "tuple[int, str]":
head_sha = (payload.get("head") or {}).get("sha")
body = (payload.get("body") or "").lower()
if (
not title.startswith("feat")
or not head_sha
or "no-docs" in body
or "backport" in body
):
if not title.startswith("feat") or not head_sha or "no-docs" in body or "backport" in body:
return 0, "Skipping documentation checks... 🏃"
if contains_documentation_link(body):

View file

@ -2,9 +2,11 @@
set -e
cd ~ || exit
echo "Setting Up Bench..."
echo "::group::Install Bench"
pip install frappe-bench
echo "::endgroup::"
echo "::group::Init Bench"
bench -v init frappe-bench --skip-assets --python "$(which python)" --frappe-path "${GITHUB_WORKSPACE}"
cd ./frappe-bench || exit
@ -13,9 +15,9 @@ if [ "$TYPE" == "ui" ]
then
bench -v setup requirements --node;
fi
echo "::endgroup::"
echo "Setting Up Sites & Database..."
echo "::group::Create Test Site"
mkdir ~/frappe-bench/sites/test_site
cp "${GITHUB_WORKSPACE}/.github/helper/db/$DB.json" ~/frappe-bench/sites/test_site/site_config.json
@ -35,9 +37,9 @@ then
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
fi
echo "::endgroup::"
echo "Setting Up Procfile..."
echo "::group::Modify processes"
sed -i 's/^watch:/# watch:/g' Procfile
sed -i 's/^schedule:/# schedule:/g' Procfile
@ -51,11 +53,11 @@ if [ "$TYPE" == "ui" ]
then
sed -i 's/^web: bench serve/web: bench serve --with-coverage/g' Procfile
fi
echo "Starting Bench..."
echo "::endgroup::"
bench start &> ~/frappe-bench/bench_start.log &
echo "::group::Install site"
if [ "$TYPE" == "server" ]
then
CI=Yes bench build --app frappe &
@ -66,6 +68,7 @@ bench --site test_site reinstall --yes
if [ "$TYPE" == "server" ]
then
# wait till assets are built succesfully
# wait till assets are built successfully
wait $build_pid
fi
echo "::endgroup::"

View file

@ -3,6 +3,7 @@ set -e
echo "Setting Up System Dependencies..."
echo "::group::apt packages"
sudo apt update
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client-10.6
@ -12,3 +13,4 @@ install_wkhtmltopdf() {
sudo apt install ./wkhtmltox_0.12.6-1.focal_amd64.deb
}
install_wkhtmltopdf &
echo "::endgroup::"

View file

@ -6,11 +6,11 @@ import subprocess
import sys
import time
import urllib.request
from functools import lru_cache
from functools import cache
from urllib.error import HTTPError
@lru_cache(maxsize=None)
@cache
def fetch_pr_data(pr_number, repo, endpoint=""):
api_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}"
@ -73,8 +73,9 @@ def has_label(pr_number, label, repo="frappe/frappe"):
)
def is_py(file):
return file.endswith("py")
def is_server_side_code(file):
"""File exclusively affects server side code"""
return file.endswith("py") or file.endswith(".po")
def is_ci(file):
@ -82,9 +83,7 @@ def is_ci(file):
def is_frontend_code(file):
return file.lower().endswith(
(".css", ".scss", ".less", ".sass", ".styl", ".js", ".ts", ".vue", ".html")
)
return file.lower().endswith((".css", ".scss", ".less", ".sass", ".styl", ".js", ".ts", ".vue", ".html"))
def is_docs(file):
@ -112,7 +111,7 @@ if __name__ == "__main__":
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)))
updated_py_file_count = len(list(filter(is_server_side_code, files_list)))
only_py_changed = updated_py_file_count == len(files_list)
if has_skip_ci_label(pr_number, repo):

View file

@ -2,7 +2,9 @@ import re
import sys
errors_encounter = 0
pattern = re.compile(r"_\(([\"']{,3})(?P<message>((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P<py_context>((?!\5).)*)\5)*(\s*,(\s*?.*?\n*?)*(,\s*([\"'])(?P<js_context>((?!\11).)*)\11)*)*\)")
pattern = re.compile(
r"_\(([\"']{,3})(?P<message>((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P<py_context>((?!\5).)*)\5)*(\s*,(\s*?.*?\n*?)*(,\s*([\"'])(?P<js_context>((?!\11).)*)\11)*)*\)"
)
words_pattern = re.compile(r"_{1,2}\([\"'`]{1,3}.*?[a-zA-Z]")
start_pattern = re.compile(r"_{1,2}\([f\"'`]{1,3}")
f_string_pattern = re.compile(r"_\(f[\"']")
@ -10,44 +12,50 @@ starts_with_f_pattern = re.compile(r"_\(f")
# skip first argument
files = sys.argv[1:]
files_to_scan = [_file for _file in files if _file.endswith(('.py', '.js'))]
files_to_scan = [_file for _file in files if _file.endswith((".py", ".js"))]
for _file in files_to_scan:
with open(_file, 'r') as f:
print(f'Checking: {_file}')
with open(_file) as f:
print(f"Checking: {_file}")
file_lines = f.readlines()
for line_number, line in enumerate(file_lines, 1):
if 'frappe-lint: disable-translate' in line:
if "frappe-lint: disable-translate" in line:
continue
if start_matches := start_pattern.search(line):
if starts_with_f := starts_with_f_pattern.search(line):
if has_f_string := f_string_pattern.search(line):
errors_encounter += 1
print(f'\nF-strings are not supported for translations at line number {line_number}\n{line.strip()[:100]}')
print(
f"\nF-strings are not supported for translations at line number {line_number}\n{line.strip()[:100]}"
)
continue
match = pattern.search(line)
error_found = False
if not match and line.endswith((',\n', '[\n')):
if not match and line.endswith((",\n", "[\n")):
# concat remaining text to validate multiline pattern
line = "".join(file_lines[line_number - 1:])
line = line[start_matches.start() + 1:]
line = "".join(file_lines[line_number - 1 :])
line = line[start_matches.start() + 1 :]
match = pattern.match(line)
if not match:
error_found = True
print(f'\nTranslation syntax error at line number {line_number}\n{line.strip()[:100]}')
print(f"\nTranslation syntax error at line number {line_number}\n{line.strip()[:100]}")
if not error_found and not words_pattern.search(line):
error_found = True
print(f'\nTranslation is useless because it has no words at line number {line_number}\n{line.strip()[:100]}')
print(
f"\nTranslation is useless because it has no words at line number {line_number}\n{line.strip()[:100]}"
)
if error_found:
errors_encounter += 1
if errors_encounter > 0:
print('\nVisit "https://frappeframework.com/docs/user/en/translations" to learn about valid translation strings.')
print(
'\nVisit "https://frappeframework.com/docs/user/en/translations" to learn about valid translation strings.'
)
sys.exit(1)
else:
print('\nGood To Go!')
print("\nGood To Go!")

37
.github/helper/update_pot_file.sh vendored Normal file
View file

@ -0,0 +1,37 @@
#!/bin/bash
set -e
cd ~ || exit
echo "Setting Up Bench..."
pip install frappe-bench
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-path "${GITHUB_WORKSPACE}"
cd ./frappe-bench || exit
echo "Generating POT file..."
bench generate-pot-file --app frappe
cd ./apps/frappe || exit
echo "Configuring git user..."
git config user.email "developers@erpnext.com"
git config user.name "frappe-pr-bot"
echo "Setting the correct git remote..."
# Here, the git remote is a local file path by default. Let's change it to the upstream repo.
git remote set-url upstream https://github.com/frappe/frappe.git
echo "Creating a new branch..."
isodate=$(date -u +"%Y-%m-%d")
branch_name="pot_${BASE_BRANCH}_${isodate}"
git checkout -b "${branch_name}"
echo "Commiting changes..."
git add .
git commit -m "chore: update POT file"
gh auth setup-git
git push -u upstream "${branch_name}"
echo "Creating a PR..."
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/frappe

10
.github/labeler.yml vendored
View file

@ -1,4 +1,10 @@
# Any python files modifed but no test files modified
add-test-cases:
- any: ['frappe/**/*.py']
all: ['!frappe/**/test*.py']
- all:
- changed-files:
- any-glob-to-any-file: 'frappe/**/*.py'
- all-globs-to-all-files: '!frappe/**/test*.py'
# Add 'release' label to any PR that is opened against the `main` branch
release:
- base-branch: ['^version-\d+$']

View file

@ -17,9 +17,9 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save

38
.github/workflows/generate-pot-file.yml vendored Normal file
View file

@ -0,0 +1,38 @@
# This workflow is agnostic to branches. Only maintain on develop branch.
# To add/remove branches just modify the matrix.
name: Regenerate POT file (translatable strings)
on:
schedule:
# 9:30 UTC => 3 PM IST Sunday
- cron: "30 9 * * 0"
workflow_dispatch:
jobs:
regeneratee-pot-file:
name: Release
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
branch: ["develop"]
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ matrix.branch }}
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Run script to update POT file
run: |
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
BASE_BRANCH: ${{ matrix.branch }}

View file

@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
version: ["13", "14"]
version: ["14", "15"]
steps:
- uses: octokit/request-action@v2.x
@ -30,23 +30,3 @@ jobs:
head: version-${{ matrix.version }}-hotfix
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
beta-release:
name: Release
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: octokit/request-action@v2.x
with:
route: POST /repos/{owner}/{repo}/pulls
owner: frappe
repo: frappe
title: |-
"chore: release v15 beta"
body: "Automated beta release."
base: version-15-beta
head: develop
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

View file

@ -5,8 +5,14 @@ on:
jobs:
triage:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v4
- name: Clone
uses: actions/checkout@v4
- uses: actions/labeler@v5
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"

View file

@ -1,3 +1,4 @@
# When updating this file, please also update the linter_workflow_template in frappe/utils/boilerplate.py
name: Linters
on:
@ -21,7 +22,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 200
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 18
check-latest: true
@ -38,7 +39,7 @@ jobs:
steps:
- name: 'Setup Environment'
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/checkout@v4
@ -51,17 +52,16 @@ jobs:
python $GITHUB_WORKSPACE/.github/helper/documentation.py $PR_NUMBER
linter:
name: 'Frappe Linter'
name: 'Semgrep Rules'
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: pip
- uses: pre-commit/action@v3.0.0
- name: Download Semgrep rules
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules
@ -76,14 +76,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- uses: actions/checkout@v4
- name: Cache pip
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
@ -95,5 +95,4 @@ jobs:
run: |
pip install pip-audit
cd ${GITHUB_WORKSPACE}
sed -i '/dropbox/d' pyproject.toml # Remove dropbox temporarily https://github.com/dropbox/dropbox-sdk-python/pull/456
pip-audit --desc on --ignore-vuln GHSA-4xqq-73wg-5mjp .
pip-audit --desc on .

View file

@ -14,8 +14,8 @@ jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v4
- uses: dessant/lock-threads@v5
with:
github-token: ${{ github.token }}
issue-inactive-days: 14
pr-inactive-days: 14
pr-inactive-days: 14

View file

@ -20,11 +20,11 @@ jobs:
with:
path: 'frappe'
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 18
- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Set up bench and build assets

View file

@ -43,12 +43,12 @@ jobs:
services:
mariadb:
image: mariadb:10.6
image: mariadb:11.3
env:
MARIADB_ROOT_PASSWORD: travis
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
steps:
- name: Clone
@ -62,14 +62,12 @@ jobs:
fi
- name: Setup Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: |
3.7
3.10
python-version: "3.10"
- name: Setup Node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
check-latest: true
@ -78,7 +76,7 @@ jobs:
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
@ -90,7 +88,7 @@ jobs:
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@ -112,8 +110,9 @@ jobs:
- name: Run Patch Tests
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
sed -i 's/^worker:/# worker:/g' Procfile
wget https://frappeframework.com/files/v13-frappe.sql.gz
bench --site test_site --force restore ~/frappe-bench/v13-frappe.sql.gz
source env/bin/activate
cd apps/frappe/
@ -121,7 +120,6 @@ jobs:
function update_to_version() {
version=$1
py=$2
branch_name="version-$version-hotfix"
echo "Updating to v$version"
@ -129,23 +127,26 @@ jobs:
git checkout -q -f $branch_name
pgrep honcho | xargs kill
sleep 3
rm -rf ~/frappe-bench/env
bench -v setup env --python $py
bench start &> ~/frappe-bench/bench_start.log &
bench -v setup env
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate
}
update_to_version 12 python3.7
update_to_version 13 python3.7
update_to_version 14 python3.10
update_to_version 14
update_to_version 15
echo "Updating to last commit"
pgrep honcho | xargs kill
sleep 3
rm -rf ~/frappe-bench/env
git checkout -q -f "$GITHUB_SHA"
bench -v setup env
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate
bench --site test_site execute frappe.tests.utils.check_orpahned_doctypes
- name: Show bench output
if: ${{ always() }}

26
.github/workflows/pre-commit.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: Pre-commit
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: precommit-frappe-${{ github.event_name }}-${{ github.event.number }}
cancel-in-progress: true
jobs:
linter:
name: 'precommit'
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: pip
- uses: pre-commit/action@v3.0.1

View file

@ -14,10 +14,10 @@ jobs:
- uses: actions/checkout@v4
with:
path: 'frappe'
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 18
- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Set up bench and build assets

View file

@ -11,7 +11,6 @@ concurrency:
group: server-develop-${{ github.event_name }}-${{ github.event.number }}
cancel-in-progress: true
permissions:
# Do not change this as GITHUB_TOKEN is being used by roulette
contents: read
@ -43,22 +42,24 @@ jobs:
runs-on: ubuntu-latest
needs: checkrun
if: ${{ needs.checkrun.outputs.build == 'strawberry' }}
timeout-minutes: 60
timeout-minutes: 30
env:
NODE_ENV: "production"
strategy:
fail-fast: false
matrix:
db: ["mariadb", "postgres"]
container: [1, 2]
db: ["mariadb", "postgres"]
container: [1, 2]
services:
mariadb:
image: mariadb:10.6
image: mariadb:11.3
env:
MARIADB_ROOT_PASSWORD: travis
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
postgres:
image: postgres:12.4
@ -72,14 +73,20 @@ jobs:
ports:
- 5432:5432
smtp_server:
image: rnwood/smtp4dev
ports:
- 2525:25
- 3000:80
steps:
- name: Clone
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: "3.12"
- name: Check for valid Python & Merge Conflicts
run: |
@ -89,7 +96,7 @@ jobs:
exit 1
fi
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 18
check-latest: true
@ -99,7 +106,7 @@ jobs:
echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
@ -111,7 +118,7 @@ jobs:
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@ -126,23 +133,41 @@ jobs:
env:
BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
AFTER: ${{ env.GITHUB_EVENT_PATH.after }}
FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
TYPE: server
DB: ${{ matrix.db }}
- name: Run Tests
run: cd ~/frappe-bench/sites && ../env/bin/python3 ../apps/frappe/.github/helper/ci.py
run: ../env/bin/python3 ../apps/frappe/.github/helper/ci.py
working-directory: /home/runner/frappe-bench/sites
env:
SITE: test_site
CI_BUILD_ID: ${{ github.run_id }}
BUILD_NUMBER: ${{ matrix.container }}
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
TOTAL_BUILDS: 2
COVERAGE_RCFILE: /home/runner/frappe-bench/apps/frappe/.coveragerc
- name: Setup tmate session
uses: mxschmitt/action-tmate@v3
if: ${{ failure() && contains( github.event.pull_request.labels.*.name, 'debug-gha') }}
- name: Show bench output
if: ${{ always() }}
run: cat ~/frappe-bench/bench_start.log || true
run: |
cd ~/frappe-bench
cat bench_start.log || true
cd logs
for f in ./*.log*; do
echo "Printing log: $f";
cat $f
done
- name: Upload coverage data
uses: actions/upload-artifact@v3
if: github.event_name != 'pull_request'
with:
name: coverage-${{ matrix.db }}-${{ matrix.container }}
path: /home/runner/frappe-bench/sites/coverage.xml
@ -156,8 +181,8 @@ jobs:
strategy:
matrix:
db: ["mariadb", "postgres"]
container: [1, 2]
db: ["mariadb", "postgres"]
container: [1, 2]
steps:
- name: Pass skipped tests unconditionally
@ -167,7 +192,7 @@ jobs:
name: Coverage Wrap Up
needs: [test, checkrun]
runs-on: ubuntu-latest
if: ${{ needs.checkrun.outputs.build == 'strawberry' }}
if: ${{ needs.checkrun.outputs.build == 'strawberry' && github.event_name != 'pull_request' }}
steps:
- name: Clone
uses: actions/checkout@v4
@ -176,9 +201,10 @@ jobs:
uses: actions/download-artifact@v3
- name: Upload coverage data
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
name: Server
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
verbose: true
flags: server

View file

@ -42,32 +42,34 @@ jobs:
needs: checkrun
if: ${{ needs.checkrun.outputs.build == 'strawberry' && github.repository_owner == 'frappe' }}
timeout-minutes: 60
env:
NODE_ENV: "production"
strategy:
fail-fast: false
matrix:
# Make sure you modify coverage submission file list if changing this
container: [1, 2, 3]
# Make sure you modify coverage submission file list if changing this
container: [1, 2, 3]
name: UI Tests (Cypress)
services:
mariadb:
image: mariadb:10.6
image: mariadb:11.3
env:
MARIADB_ROOT_PASSWORD: travis
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
steps:
- name: Clone
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: "3.12"
- name: Check for valid Python & Merge Conflicts
run: |
@ -77,7 +79,7 @@ jobs:
exit 1
fi
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 18
check-latest: true
@ -87,7 +89,7 @@ jobs:
echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
@ -99,7 +101,7 @@ jobs:
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@ -108,7 +110,7 @@ jobs:
${{ runner.os }}-yarn-ui-
- name: Cache cypress binary
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/Cypress
key: ${{ runner.os }}-cypress
@ -120,6 +122,7 @@ jobs:
env:
BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
AFTER: ${{ env.GITHUB_EVENT_PATH.after }}
FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
TYPE: ui
DB: mariadb
@ -146,6 +149,7 @@ jobs:
CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb
- name: Stop server and wait for coverage file
if: github.event_name != 'pull_request'
run: |
ps -ef | grep "[f]rappe serve" | awk '{print $2}' | xargs kill -s SIGINT
sleep 5
@ -153,19 +157,28 @@ jobs:
- name: Upload JS coverage data
uses: actions/upload-artifact@v3
if: github.event_name != 'pull_request'
with:
name: coverage-js-${{ matrix.container }}
path: /home/runner/frappe-bench/apps/frappe/.cypress-coverage/clover.xml
- name: Upload python coverage data
uses: actions/upload-artifact@v3
if: github.event_name != 'pull_request'
with:
name: coverage-py-${{ matrix.container }}
path: /home/runner/frappe-bench/sites/coverage.xml
- name: Show bench output
if: ${{ always() }}
run: cat ~/frappe-bench/bench_start.log || true
run: |
cd ~/frappe-bench
cat bench_start.log || true
cd logs
for f in ./*.log*; do
echo "Printing log: $f";
cat $f
done
faux-test:
runs-on: ubuntu-latest
@ -174,7 +187,7 @@ jobs:
name: UI Tests (Cypress)
strategy:
matrix:
container: [1, 2, 3]
container: [1, 2, 3]
steps:
- name: Pass skipped tests unconditionally
@ -183,7 +196,7 @@ jobs:
coverage:
name: Coverage Wrap Up
needs: [test, checkrun]
if: ${{ needs.checkrun.outputs.build == 'strawberry' }}
if: ${{ needs.checkrun.outputs.build == 'strawberry' && github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Clone
@ -193,18 +206,20 @@ jobs:
uses: actions/download-artifact@v3
- name: Upload python coverage data
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
name: UIBackend
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
verbose: true
files: ./coverage-py-1/coverage.xml,./coverage-py-2/coverage.xml,./coverage-py-3/coverage.xml
flags: server-ui
- name: Upload JS coverage data
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
name: Cypress
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
files: ./coverage-js-1/clover.xml,./coverage-js-2/clover.xml,./coverage-js-3/clover.xml
verbose: true

2
.gitignore vendored
View file

@ -2,7 +2,6 @@
*.py~
*.comp.js
*.DS_Store
locale
.wnf-lang-status
*.swp
*.egg-info
@ -169,6 +168,7 @@ typings/
# Optional npm cache directory
.npm
.yarn
# Optional eslint cache
.eslintcache

View file

@ -9,6 +9,7 @@ pull_request_rules:
- author!=frappe-pr-bot
- author!=mergify[bot]
- or:
- base=version-16
- base=version-15
- base=version-14
- base=version-13
@ -20,27 +21,6 @@ pull_request_rules:
@{{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:
- label!=dont-merge
- label!=squash
- "#approved-reviews-by>=1"
actions:
merge:
method: merge
- name: Automatic squash on CI success and review
conditions:
- label!=dont-merge
- label=squash
- "#approved-reviews-by>=1"
actions:
merge:
method: squash
commit_message_template: |
{{ title }} (#{{ number }})
{{ body }}
- name: backport to develop
conditions:
- label="backport develop"
@ -71,3 +51,13 @@ pull_request_rules:
assignees:
- "{{ author }}"
- name: backport to version-15-hotfix
conditions:
- label="backport version-15-hotfix"
actions:
backport:
branches:
- version-15-hotfix
assignees:
- "{{ author }}"

View file

@ -20,22 +20,21 @@ repos:
- id: check-yaml
- id: debug-statements
- repo: https://github.com/asottile/pyupgrade
rev: v3.9.0
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.0
hooks:
- id: pyupgrade
args: ['--py310-plus']
- id: ruff
name: "Run ruff linter and apply fixes"
args: ["--fix"]
- repo: https://github.com/frappe/black
rev: 951ccf4d5bb0d692b457a5ebc4215d755618eb68
hooks:
- id: black
- id: ruff-format
name: "Format Python code"
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
hooks:
- id: prettier
types_or: [javascript]
types_or: [javascript, vue, scss]
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(
@ -44,7 +43,8 @@ repos:
.*boilerplate.*|
frappe/www/website_script.js|
frappe/templates/includes/.*|
frappe/public/js/lib/.*
frappe/public/js/lib/.*|
frappe/website/doctype/website_theme/website_theme_template.scss
)$
@ -66,17 +66,6 @@ repos:
frappe/public/js/lib/.*
)$
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
hooks:
- id: flake8
additional_dependencies: ['flake8-bugbear',]
ci:
autoupdate_schedule: weekly
skip: []

View file

@ -1,10 +1,8 @@
<div align="center">
<h1>
<br>
<a href="https://frappeframework.com">
<img src=".github/frappe-framework-logo.svg" height="50">
</a>
</h1>
<picture>
<source media="(prefers-color-scheme: dark)" srcset=".github/frappe-framework-logo-dark.svg">
<img src=".github/frappe-framework-logo.svg" height="50">
</picture>
<h3>
a web framework with <a href="https://www.youtube.com/watch?v=LOjk3m0wTwg">"batteries included"</a>
</h3>
@ -71,12 +69,12 @@ Full-stack web application framework that uses Python and MariaDB on the server
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)
## Resources
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.
1. [buildwithhussain.dev](https://buildwithhussain.dev) - Watch Frappe Framework being used in the wild to build world-class web apps.
## License
This repository has been released under the [MIT License](LICENSE).

9
babel_extractors.csv Normal file
View file

@ -0,0 +1,9 @@
hooks.py,frappe.gettext.extractors.navbar.extract
**/doctype/*/*.json,frappe.gettext.extractors.doctype.extract
**/workspace/*/*.json,frappe.gettext.extractors.workspace.extract
**/onboarding_step/*/*.json,frappe.gettext.extractors.onboarding_step.extract
**/module_onboarding/*/*.json,frappe.gettext.extractors.module_onboarding.extract
**/report/*/*.json,frappe.gettext.extractors.report.extract
**.py,frappe.gettext.extractors.python.extract
**.js,frappe.gettext.extractors.javascript.extract
**.html,frappe.gettext.extractors.html_template.extract
1 hooks.py frappe.gettext.extractors.navbar.extract
2 **/doctype/*/*.json frappe.gettext.extractors.doctype.extract
3 **/workspace/*/*.json frappe.gettext.extractors.workspace.extract
4 **/onboarding_step/*/*.json frappe.gettext.extractors.onboarding_step.extract
5 **/module_onboarding/*/*.json frappe.gettext.extractors.module_onboarding.extract
6 **/report/*/*.json frappe.gettext.extractors.report.extract
7 **.py frappe.gettext.extractors.python.extract
8 **.js frappe.gettext.extractors.javascript.extract
9 **.html frappe.gettext.extractors.html_template.extract

8
crowdin.yml Normal file
View file

@ -0,0 +1,8 @@
files:
- source: /frappe/locale/main.pot
translation: /frappe/locale/%two_letters_code%.po
pull_request_title: "fix: sync translations from crowdin"
pull_request_labels:
- translation
commit_message: "fix: %language% translations"
append_commit_message: false

View file

@ -1,4 +1,5 @@
const { defineConfig } = require("cypress");
const fs = require("fs");
module.exports = defineConfig({
projectId: "92odwv",
@ -7,7 +8,6 @@ module.exports = defineConfig({
defaultCommandTimeout: 20000,
pageLoadTimeout: 15000,
video: true,
videoUploadOnPasses: false,
viewportHeight: 960,
viewportWidth: 1400,
retries: {
@ -18,8 +18,22 @@ module.exports = defineConfig({
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
// Delete videos for specs without failing or retried tests
// https://docs.cypress.io/guides/guides/screenshots-and-videos#Delete-videos-for-specs-without-failing-or-retried-tests
on("after:spec", (spec, results) => {
if (results && results.video) {
const failures = results.tests.some((test) =>
test.attempts.some((attempt) => attempt.state === "failed")
);
if (!failures) {
fs.unlinkSync(results.video);
}
}
});
return require("./cypress/plugins/index.js")(on, config);
},
testIsolation: false,
baseUrl: "http://test_site_ui:8000",
specPattern: ["./cypress/integration/*.js", "**/ui_test_*.js"],
},

View file

@ -24,7 +24,7 @@ export default {
naming_rule: "By fieldname",
owner: "Administrator",
permissions: [],
sort_field: "modified",
sort_field: "creation",
sort_order: "ASC",
track_changes: 1,
};

View file

@ -53,7 +53,7 @@ export default {
naming_rule: "By fieldname",
owner: "Administrator",
permissions: [],
sort_field: "modified",
sort_field: "creation",
sort_order: "ASC",
track_changes: 1,
};

View file

@ -47,7 +47,7 @@ export default {
},
],
quick_entry: 1,
sort_field: "modified",
sort_field: "creation",
sort_order: "ASC",
track_changes: 1,
};

View file

@ -59,7 +59,7 @@ export default {
},
],
quick_entry: 1,
sort_field: "modified",
sort_field: "creation",
sort_order: "ASC",
track_changes: 1,
};

View file

@ -42,7 +42,7 @@ export default {
},
],
quick_entry: 1,
sort_field: "modified",
sort_field: "creation",
sort_order: "ASC",
track_changes: 1,
};

View file

@ -39,7 +39,7 @@ export default {
write: 1,
},
],
sort_field: "modified",
sort_field: "creation",
sort_order: "ASC",
track_changes: 1,
};

View file

@ -46,7 +46,7 @@ export default {
write: 1,
},
],
sort_field: "modified",
sort_field: "creation",
sort_order: "ASC",
track_changes: 1,
};

View file

@ -40,7 +40,7 @@ export default {
cancel: 1,
},
],
sort_field: "modified",
sort_field: "creation",
sort_order: "ASC",
track_changes: 1,
};

View file

@ -48,7 +48,7 @@ export default {
],
quick_entry: 1,
autoname: "format: Test-{####}",
sort_field: "modified",
sort_field: "creation",
sort_order: "ASC",
track_changes: 1,
};

View file

@ -10,6 +10,12 @@ export default {
fieldtype: "Data",
label: "Data 3",
},
{
fieldname: "gender",
fieldtype: "Link",
label: "Gender",
options: "Gender",
},
{
fieldname: "tab",
fieldtype: "Tab Break",
@ -59,7 +65,7 @@ export default {
write: 1,
},
],
sort_field: "modified",
sort_field: "creation",
sort_order: "ASC",
track_changes: 1,
};

View file

@ -1,44 +0,0 @@
context("API Resources", () => {
before(() => {
cy.visit("/login");
cy.login();
cy.visit("/app/website");
});
it("Creates two Comments", () => {
cy.insert_doc("Comment", { comment_type: "Comment", content: "hello" });
cy.insert_doc("Comment", { comment_type: "Comment", content: "world" });
});
it("Lists the Comments", () => {
cy.get_list("Comment")
.its("data")
.then((data) => expect(data.length).to.be.at.least(2));
cy.get_list("Comment", ["name", "content"], [["content", "=", "hello"]]).then((body) => {
expect(body).to.have.property("data");
expect(body.data).to.have.lengthOf(1);
expect(body.data[0]).to.have.property("content");
expect(body.data[0]).to.have.property("name");
});
});
it("Gets each Comment", () => {
cy.get_list("Comment").then((body) =>
body.data.forEach((comment) => {
cy.get_doc("Comment", comment.name);
})
);
});
it("Removes the Comments", () => {
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);
});
});
});
});

View file

@ -2,7 +2,11 @@ context("Awesome Bar", () => {
before(() => {
cy.visit("/login");
cy.login();
cy.visit("/app/website");
cy.visit("/app/todo"); // Make sure ToDo filters are cleared.
cy.clear_filters();
cy.visit("/app/blog-post"); // Make sure Blog Post filters are cleared.
cy.clear_filters();
cy.visit("/app/website"); // Go to some other page.
});
beforeEach(() => {
@ -11,36 +15,60 @@ context("Awesome Bar", () => {
cy.get("@awesome_bar").type("{selectall}");
});
after(() => {
cy.visit("/app/todo"); // Make sure we're not bleeding any filters to the next spec.
cy.clear_filters();
});
it("navigates to doctype list", () => {
cy.get("@awesome_bar").type("todo");
cy.wait(100);
cy.wait(100); // Wait a bit before hitting enter.
cy.get(".awesomplete").findByRole("listbox").should("be.visible");
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text").should("contain", "To Do");
cy.location("pathname").should("eq", "/app/todo");
});
it("find text in doctype list", () => {
it("finds text in doctype list", () => {
cy.get("@awesome_bar").type("test in todo");
cy.wait(100);
cy.wait(150); // Wait a bit before hitting enter.
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text").should("contain", "To Do");
cy.wait(200);
const name_filter = cy.findByPlaceholderText("ID");
name_filter.should("have.value", "%test%");
cy.clear_filters();
cy.wait(200); // Wait a bit longer before checking the filter.
cy.get('[data-original-title="ID"] > input').should("have.value", "%test%");
// filter preserved, now finds something else
cy.visit("/app/todo");
cy.get(".title-text").should("contain", "To Do");
cy.wait(200); // Wait a bit longer before checking the filter.
cy.get('[data-original-title="ID"] > input').as("filter");
cy.get("@filter").should("have.value", "%test%");
cy.get("@awesome_bar").type("anothertest in todo");
cy.wait(200); // Wait a bit longer before hitting enter.
cy.get("@awesome_bar").type("{enter}");
cy.wait(200); // Wait a bit longer before checking the filter.
cy.get("@filter").should("have.value", "%anothertest%");
});
it("navigates to another doctype, filter not bleeding", () => {
cy.get("@awesome_bar").type("blog post");
cy.wait(150); // Wait a bit before hitting enter.
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text").should("contain", "Blog Post");
cy.wait(200); // Wait a bit longer before checking the filter.
cy.location("search").should("be.empty");
});
it("navigates to new form", () => {
cy.get("@awesome_bar").type("new blog post");
cy.wait(100);
cy.wait(150); // Wait a bit before hitting enter
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text:visible").should("have.text", "New Blog Post");
});
it("calculates math expressions", () => {
cy.get("@awesome_bar").type("55 + 32");
cy.wait(100);
cy.wait(150); // Wait a bit before hitting enter
cy.get("@awesome_bar").type("{downarrow}{enter}");
cy.get(".modal-title").should("contain", "Result");
cy.get(".msgprint").should("contain", "55 + 32 = 87");

View file

@ -29,7 +29,8 @@ context("Attach Control", () => {
//Clicking on "Link" button to attach a file using the "Link" button
cy.findByRole("button", { name: "Link" }).click();
cy.findByPlaceholderText("Attach a web link").type(
"https://wallpaperplay.com/walls/full/8/2/b/72402.jpg"
"https://wallpaperplay.com/walls/full/8/2/b/72402.jpg",
{ force: true }
);
//Clicking on the Upload button to upload the file
@ -64,6 +65,25 @@ context("Attach Control", () => {
//Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype
cy.findByRole("button", { name: "Attach" }).click();
//Clicking on "Link" button to attach a file using the "Link" button
cy.findByRole("button", { name: "Link" }).click();
cy.findByPlaceholderText("Attach a web link").type(
"https://wallpaperplay.com/walls/full/8/2/b/72402.jpg",
{ force: true }
);
//Clicking on the Upload button to upload the file
cy.intercept("POST", "/api/method/upload_file").as("upload_image");
cy.get(".modal-footer").findByRole("button", { name: "Upload" }).click({ delay: 500 });
cy.wait("@upload_image");
cy.findByRole("button", { name: "Save" }).click();
//Navigating to the new form for the newly created doctype to check Library button
cy.new_form("Test Attach Control");
//Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype
cy.findByRole("button", { name: "Attach" }).click();
//Clicking on "Library" button to attach a file using the "Library" button
cy.findByRole("button", { name: "Library" }).click();
cy.contains("72402.jpg").click();
@ -85,9 +105,10 @@ context("Attach Control", () => {
//Checking if clicking on the clear button clears the field of the doctype form and again displays the attach button
cy.get(".control-input > .btn-sm").should("contain", "Attach");
//Deleting the doc
//Deleting both docs
cy.go_to_list("Test Attach Control");
cy.get(".list-row-checkbox").eq(0).click();
cy.get(".list-row-checkbox").eq(1).click();
cy.get(".actions-btn-group > .btn").contains("Actions").click();
cy.get('.actions-btn-group > .dropdown-menu [data-label="Delete"]').click();
cy.click_modal_primary_button("Yes");
@ -106,7 +127,10 @@ context("Attach Control", () => {
};
},
});
cy.get("body").should("have.attr", "data-route", `Form/${doctype}/new-${dt_in_route}-1`);
cy.get("body").should(($body) => {
const dataRoute = $body.attr("data-route");
expect(dataRoute).to.match(new RegExp(`^Form/${doctype}/new-${dt_in_route}-`));
});
cy.get("body").should("have.attr", "data-ajax-state", "complete");
//Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype
@ -126,7 +150,10 @@ context("Attach Control", () => {
delete win.navigator.mediaDevices;
},
});
cy.get("body").should("have.attr", "data-route", `Form/${doctype}/new-${dt_in_route}-1`);
cy.get("body").should(($body) => {
const dataRoute = $body.attr("data-route");
expect(dataRoute).to.match(new RegExp(`^Form/${doctype}/new-${dt_in_route}-`));
});
cy.get("body").should("have.attr", "data-ajax-state", "complete");
//Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype
@ -136,3 +163,89 @@ context("Attach Control", () => {
cy.findByRole("button", { name: "Camera" }).should("not.exist");
});
});
context("Attach Control with Failed Document Save", () => {
before(() => {
cy.login();
cy.visit("/app/doctype");
return cy
.window()
.its("frappe")
.then((frappe) => {
return frappe.xcall("frappe.tests.ui_test_helpers.create_doctype", {
name: "Test Mandatory Attach Control",
fields: [
{
label: "Attach File or Image",
fieldname: "attach",
fieldtype: "Attach",
in_list_view: 1,
},
{
label: "Mandatory Text Field",
fieldname: "text_field",
fieldtype: "Text Editor",
in_list_view: 1,
reqd: 1,
},
],
});
});
});
let temp_name = "";
let docname = "";
it("Attaching a file on an unsaved document", () => {
//Navigating to the new form for the newly created doctype
cy.new_form("Test Mandatory Attach Control");
cy.get("body").should(($body) => {
temp_name = $body.attr("data-route").split("/")[2];
});
//Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype
cy.findByRole("button", { name: "Attach" }).click();
//Clicking on "Link" button to attach a file using the "Link" button
cy.findByRole("button", { name: "Link" }).click();
cy.findByPlaceholderText("Attach a web link").type(
"https://wallpaperplay.com/walls/full/8/2/b/72402.jpg",
{ force: true }
);
//Clicking on the Upload button to upload the file
cy.intercept("POST", "/api/method/upload_file").as("upload_image");
cy.get(".modal-footer").findByRole("button", { name: "Upload" }).click({ delay: 500 });
cy.wait("@upload_image");
cy.get(".msgprint-dialog .modal-title").contains("Missing Fields").should("be.visible");
cy.hide_dialog();
cy.fill_field("text_field", "Random value", "Text Editor").wait(500);
cy.findByRole("button", { name: "Save" }).click().wait(500);
//Checking if the URL of the attached image is getting displayed in the field of the newly created doctype
cy.get(".attached-file > .ellipsis > .attached-file-link")
.should("have.attr", "href")
.and("equal", "https://wallpaperplay.com/walls/full/8/2/b/72402.jpg");
cy.get(".title-text").then(($value) => {
docname = $value.text();
});
});
it("Check if file was uploaded correctly", () => {
cy.go_to_list("File");
cy.open_list_filter();
cy.get(".fieldname-select-area .form-control")
.click()
.type("Attached To Name{enter}")
.blur()
.wait(500);
cy.get('input[data-fieldname="attached_to_name"]').click().type(docname).blur();
cy.get(".filter-popover .apply-filters").click({ force: true });
cy.get("header .level-right .list-count").should("contain.text", "1 of 1");
});
it("Check if file exists with temporary name", () => {
cy.open_list_filter();
cy.get('input[data-fieldname="attached_to_name"]').click().clear().type(temp_name).blur();
cy.get(".filter-popover .apply-filters").click({ force: true });
cy.get(".frappe-list > .no-result").should("be.visible");
});
});

View file

@ -9,6 +9,7 @@ context("Control Currency", () => {
function get_dialog_with_currency(df_options = {}) {
return cy.dialog({
title: "Currency Check",
animate: false,
fields: [
{
fieldname: fieldname,
@ -47,6 +48,17 @@ context("Control Currency", () => {
df_options: { precision: 0 },
blur_expected: "10",
},
{
input: "10.000",
number_format: "#.###,##",
df_options: { precision: 0 },
blur_expected: "10.000",
},
{
input: "10.000",
number_format: "#.###,##",
blur_expected: "10.000,00",
},
{
input: "10.101",
df_options: { precision: "" },
@ -61,9 +73,11 @@ context("Control Currency", () => {
.then((frappe) => {
frappe.boot.sysdefaults.currency = test_case.currency;
frappe.boot.sysdefaults.currency_precision = test_case.default_precision ?? 2;
frappe.boot.sysdefaults.number_format = test_case.number_format ?? "#,###.##";
});
get_dialog_with_currency(test_case.df_options).as("dialog");
cy.wait(300);
cy.get_field(fieldname, "Currency").clear();
cy.wait(300);
cy.fill_field(fieldname, test_case.input, "Currency").blur();

View file

@ -49,7 +49,7 @@ context("Data Control", () => {
cy.new_form("Test Data Control");
//Checking the URL for the new form of the doctype
cy.location("pathname").should("eq", "/app/test-data-control/new-test-data-control-1");
cy.location("pathname").should("contains", "/app/test-data-control/new-test-data-control");
cy.get(".title-text").should("have.text", "New Test Data Control");
cy.get('.frappe-control[data-fieldname="name1"]')
.find("label")
@ -128,7 +128,10 @@ context("Data Control", () => {
cy.fill_field("phone", "9432380001", "Data");
cy.findByRole("button", { name: "Save" }).click({ force: true });
//Checking if the fields contains the data which has been filled in
cy.location("pathname").should("not.be", "/app/test-data-control/new-test-data-control-1");
cy.location("pathname").should(
"not.contains",
"/app/test-data-control/new-test-data-control"
);
cy.get_field("name1").should("have.value", "Komal");
cy.get_field("email").should("have.value", "komal@test.com");
cy.get_field("phone").should("have.value", "9432380001");

View file

@ -7,6 +7,7 @@ context("Date Control", () => {
function get_dialog(date_field_options) {
return cy.dialog({
title: "Date",
animate: false,
fields: [
{
label: "Date",
@ -75,6 +76,8 @@ context("Date Control", () => {
//Verifying if clicking on "Today" button matches today's date
cy.window().then((win) => {
// `expect` can not wait like `should`
cy.wait(500);
expect(win.cur_dialog.fields_dict.date.value).to.be.equal(
win.frappe.datetime.get_today()
);

View file

@ -7,6 +7,7 @@ context("Control Float", () => {
function get_dialog_with_float() {
return cy.dialog({
title: "Float Check",
animate: false,
fields: [
{
fieldname: "float_number",
@ -19,6 +20,7 @@ context("Control Float", () => {
it("check value changes", () => {
get_dialog_with_float().as("dialog");
cy.wait(300);
let data = get_data();
data.forEach((x) => {
@ -32,10 +34,13 @@ context("Control Float", () => {
cy.wait(200);
cy.fill_field("float_number", d.input, "Float").blur();
cy.get_field("float_number", "Float").should("have.value", d.blur_expected);
cy.wait(100);
cy.get_field("float_number", "Float").focus();
cy.wait(100);
cy.get_field("float_number", "Float").blur();
cy.wait(100);
cy.get_field("float_number", "Float").focus();
cy.wait(100);
cy.get_field("float_number", "Float").should("have.value", d.focus_expected);
});
});
@ -49,17 +54,17 @@ context("Control Float", () => {
{
input: "364.87,334",
blur_expected: "36.487,334",
focus_expected: "36487.334",
focus_expected: "36.487,334",
},
{
input: "36487,334",
blur_expected: "36.487,334",
focus_expected: "36487.334",
input: "36487,335",
blur_expected: "36.487,335",
focus_expected: "36.487,335",
},
{
input: "100",
blur_expected: "100,000",
focus_expected: "100",
input: "2*(2+47)+1,5+1",
blur_expected: "100,500",
focus_expected: "100,500",
},
],
},
@ -67,19 +72,36 @@ context("Control Float", () => {
number_format: "#,###.##",
values: [
{
input: "364,87.334",
blur_expected: "36,487.334",
focus_expected: "36487.334",
input: "464,87.334",
blur_expected: "46,487.334",
focus_expected: "46,487.334",
},
{
input: "36487.334",
blur_expected: "36,487.334",
focus_expected: "36487.334",
input: "46487.335",
blur_expected: "46,487.335",
focus_expected: "46,487.335",
},
{
input: "100",
blur_expected: "100.000",
focus_expected: "100",
input: "3*(2+47)+1.5+1",
blur_expected: "149.500",
focus_expected: "149.500",
},
],
},
{
// '.' is the parseFloat's decimal separator
number_format: "#.###,##",
values: [
{
input: "12.345",
blur_expected: "12.345,000",
focus_expected: "12.345,000",
},
{
// parseFloat would reduce 12,340 to 12,34 if this string was ever to be parsed
input: "12.340",
blur_expected: "12.340,000",
focus_expected: "12.340,000",
},
],
},

View file

@ -152,7 +152,7 @@ context("Control Link", () => {
cy.get(".frappe-control[data-fieldname=link] input").focus().as("input");
cy.wait("@search_link");
cy.get("@input").type("todo for 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] input").type("{enter}", { delay: 100 });
@ -260,7 +260,7 @@ context("Control Link", () => {
cy.get(".frappe-control[data-fieldname=link] input").focus().as("input");
cy.wait("@search_link");
cy.get("@input").type("Sonstiges", { delay: 100 });
cy.get("@input").type("Sonstiges", { delay: 200 });
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 });
@ -291,7 +291,7 @@ context("Control Link", () => {
cy.get(".frappe-control[data-fieldname=link] input").focus().as("input");
cy.wait("@search_link");
cy.get("@input").type("Non-Conforming", { delay: 100 });
cy.get("@input").type("Non-Conforming", { delay: 200 });
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 });

View file

@ -6,6 +6,10 @@ context("Control Phone", () => {
cy.visit("/app/website");
});
afterEach(() => {
cy.clear_dialogs();
});
function get_dialog_with_phone() {
return cy.dialog({
title: "Phone",
@ -20,31 +24,37 @@ context("Control Phone", () => {
it("should set flag and data", () => {
get_dialog_with_phone().as("dialog");
cy.get(".selected-phone").click();
cy.wait(100);
cy.get(".phone-picker .phone-wrapper[id='afghanistan']").click();
cy.wait(100);
cy.get(".selected-phone .country").should("have.text", "+93");
cy.get(".selected-phone > img").should("have.attr", "src").and("include", "/af.svg");
cy.get(".selected-phone").click();
cy.wait(100);
cy.get(".phone-picker .phone-wrapper[id='india']").click();
cy.wait(100);
cy.get(".selected-phone .country").should("have.text", "+91");
cy.get(".selected-phone > img").should("have.attr", "src").and("include", "/in.svg");
let phone_number = "9312672712";
cy.get(".selected-phone > img").click().first();
cy.get_field("phone").first().click({ multiple: true });
cy.get_field("phone").first().click();
cy.get(".frappe-control[data-fieldname=phone]")
.findByRole("textbox")
.first()
.type(phone_number, { force: true });
.type(phone_number);
cy.get_field("phone").first().should("have.value", phone_number);
cy.get_field("phone").first().blur({ force: true });
cy.get_field("phone").first().blur();
cy.wait(100);
cy.get("@dialog").then((dialog) => {
let value = dialog.get_value("phone");
expect(value).to.equal("+91-" + phone_number);
});
});
it("case insensitive search for country and clear search", () => {
let search_text = "india";
cy.get(".selected-phone").click().first();
cy.get(".phone-picker").get(".search-phones").click().type(search_text);
@ -55,9 +65,6 @@ context("Control Phone", () => {
}
);
});
cy.get(".phone-picker").get(".search-phones").clear();
cy.get(".phone-section .phone-wrapper").should("not.have.class", "hidden");
});
it("existing document should render phone field with data", () => {

View file

@ -5,11 +5,12 @@ context("Customize Form", () => {
});
it("Changing to naming rule should update autoname", () => {
cy.fill_field("doc_type", "ToDo", "Link").blur();
cy.wait(2000);
cy.findByRole("tab", { name: "Details" }).click();
cy.click_form_section("Naming");
const naming_rule_default_autoname_map = {
"Set by user": "prompt",
"By fieldname": "field:",
'By "Naming Series" field': "naming_series:",
Expression: "format:",
"Expression (old style)": "",
Random: "hash",

View file

@ -5,7 +5,7 @@ context("Dashboard Chart", () => {
});
it("Check filter populate for child table doctype", () => {
cy.visit("/app/dashboard-chart/new-dashboard-chart-1");
cy.new_form("Dashboard Chart");
cy.get('[data-fieldname="parent_document_type"]').should("have.css", "display", "none");
cy.get_field("document_type", "Link");

View file

@ -7,7 +7,7 @@ const jump_to_field = (field_label) => {
.type("{enter}")
.wait(200)
.type("{enter}")
.wait(500);
.wait(1000);
};
const type_value = (value) => {
@ -35,7 +35,7 @@ context("Form", () => {
cy.visit("/app/todo/new");
cy.get_field("description", "Text Editor")
.type("this is a test todo", { force: true })
.wait(200);
.wait(1000);
cy.get(".page-title").should("contain", "Not Saved");
cy.intercept({
method: "POST",
@ -101,10 +101,6 @@ context("Form", () => {
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();
@ -120,50 +116,6 @@ context("Form", () => {
cy.get_field("location").should("have.value", "Bermuda");
});
it("let user undo/redo field value changes", { scrollBehavior: false }, () => {
const undo = () => cy.get("body").type("{esc}").type("{ctrl+z}").wait(500);
const redo = () => cy.get("body").type("{esc}").type("{ctrl+y}").wait(500);
cy.new_form("User");
jump_to_field("Email");
type_value("admin@example.com");
jump_to_field("Username");
type_value("admin42");
jump_to_field("Send Welcome Email");
cy.focused().uncheck();
// make a mistake
jump_to_field("Username");
type_value("admin24");
// undo behaviour
undo();
cy.get_field("username").should("have.value", "admin42");
// redo behaviour
redo();
cy.get_field("username").should("have.value", "admin24");
// undo everything & redo everything, ensure same values at the end
undo();
undo();
undo();
undo();
redo();
redo();
redo();
redo();
cy.compare_document({
username: "admin24",
email: "admin@example.com",
send_welcome_email: 0,
});
});
it("update docfield property using set_df_property in child table", () => {
cy.visit("/app/contact/Test Form Contact 1");
cy.window()
@ -186,7 +138,7 @@ context("Form", () => {
);
});
cy.get("@table").find('[data-idx="1"] .edit-grid-row').click();
cy.get("@table").find('[data-idx="1"] .btn-open-row').click();
cy.get(".grid-row-open").as("table-form");
cy.get("@table-form")
.find('.frappe-control[data-fieldname="is_primary_phone"]')
@ -194,7 +146,7 @@ context("Form", () => {
cy.get("@table-form").find(".grid-footer-toolbar").click();
// set property on form_render event of child table
cy.get("@table").find('[data-idx="1"] .edit-grid-row').click();
cy.get("@table").find('[data-idx="1"] .btn-open-row').click();
cy.get("@table")
.find('[data-idx="1"]')
.invoke("attr", "data-name")

View file

@ -35,6 +35,40 @@ context("Form Builder", () => {
cy.get(".title-area .indicator-pill.orange").should("have.text", "Not Saved");
});
it("Check if Filters are applied to the link field", () => {
// Visit the Form Builder
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
cy.get("[data-fieldname='gender']").click();
// click on filter action button
cy.get('[data-fieldname="gender"] .field-actions button:first').click();
// add filter
cy.get(".modal-body .clear-filters").click();
cy.get(".modal-body .filter-action-buttons .add-filter").click();
cy.wait(100);
cy.get(".modal-body .filter-box .list_filter .filter-field .link-field input").type(
"Male"
);
cy.get(".btn-modal-primary").click();
// Save the document
cy.click_doc_primary_button("Save");
// Open a new Form
cy.new_form(doctype_name);
// Click on the "salutation" field
cy.get_field("gender").clear().click();
cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link");
cy.wait("@search_link").then((data) => {
expect(data.response.body.message.length).to.eq(1);
expect(data.response.body.message[0].value).to.eq("Male");
});
});
it("Add empty section and save", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
@ -43,7 +77,8 @@ context("Form Builder", () => {
// add new section
cy.get(first_section).click(15, 10);
cy.get(first_section).find(".section-actions button:first").click();
cy.get(first_section).find(".dropdown-btn:first").click();
cy.get(".dropdown-options:visible .dropdown-item:first").click();
// save
cy.click_doc_primary_button("Save");
@ -56,12 +91,17 @@ context("Form Builder", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_field =
".tab-content.active .section-columns-container:first .column:first .field:first";
let first_column = ".tab-content.active .section-columns-container:first .column:first";
cy.get(".fields-container .field[title='Table']").drag(first_field, {
target: { x: 100, y: 10 },
});
let last_field = first_column + " .field:last";
let add_new_field_btn = first_column + " .add-new-field-btn button";
// add new field
cy.get(add_new_field_btn).click();
// type table and press enter
cy.get(".combo-box-options:visible .search-box > input").type("table{enter}");
// save
cy.click_doc_primary_button("Save");
@ -70,20 +110,18 @@ context("Form Builder", () => {
cy.get_open_dialog().find(".msgprint").should("contain", "Options is required");
cy.hide_dialog();
cy.get(first_field).click({ force: true });
cy.get(last_field).click({ force: true });
cy.get(".sidebar-container .frappe-control[data-fieldname='options'] input")
.click()
.as("input");
cy.get("@input").clear({ force: true }).type("Web Form Field", { delay: 200 });
cy.wait("@search_link");
cy.get("@input").type("{enter}").blur();
cy.get(first_field)
.find(".table-controls .table-column")
.contains("Field")
.should("exist");
cy.get(first_field)
cy.get(last_field).click({ force: true });
cy.get(last_field).find(".table-controls .table-column").contains("Field").should("exist");
cy.get(last_field)
.find(".table-controls .table-column")
.contains("Fieldtype")
.should("exist");
@ -97,7 +135,7 @@ context("Form Builder", () => {
cy.get_open_dialog().find(".msgprint").should("contain", "In List View");
cy.hide_dialog();
cy.get(first_field).click({ force: true });
cy.get(last_field).click({ force: true });
cy.get(".sidebar-container .field label .label-area").contains("In List View").click();
// validate In Global Search
@ -107,8 +145,8 @@ context("Form Builder", () => {
cy.get_open_dialog().find(".msgprint").should("contain", "In Global Search");
});
it("Drag Field/Column/Section & Tab", () => {
// not important and was flaky on CI
it.skip("Drag Field/Column/Section & Tab", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
@ -181,30 +219,34 @@ context("Form Builder", () => {
// add new section
cy.get(first_section).click(15, 10);
cy.get(first_section).find(".section-actions button:first").click();
cy.get(first_section).find(".dropdown-btn:first").click();
cy.get(".dropdown-options:visible .dropdown-item:first").click();
cy.get(".tab-content.active .form-section-container").should("have.length", 2);
// add new column
cy.get(first_section).find(".column:first").click(15, 10);
cy.get(first_section).find(".column:first .column-actions button:first").click();
cy.get(first_section).find(".column").should("have.length", 3);
cy.get(first_section).click(15, 10);
cy.get(first_section).find(".dropdown-btn:first").click();
cy.get(".dropdown-options:visible .dropdown-item:last").click();
cy.get(first_section).find(".column").should("have.length", 2);
});
it("Remove Tab/Section/Column", () => {
let first_section = ".tab-content.active .form-section-container:first";
// remove column
cy.get(first_section).find(".column:first").click(15, 10);
cy.get(first_section).find(".column:first .column-actions button:last").click();
cy.get(first_section).find(".column").should("have.length", 2);
cy.get(first_section).click(15, 10);
cy.get(first_section).find(".dropdown-btn:first").click();
cy.get(".dropdown-options:visible .dropdown-item:last").click();
cy.get(first_section).find(".column").should("have.length", 1);
// remove section
cy.get(first_section).click(15, 10);
cy.get(first_section).find(".section-actions button:last").click();
cy.get(first_section).find(".dropdown-btn:first").click();
cy.get(".dropdown-options:visible .dropdown-item").eq(1).click();
cy.get(".tab-content.active .form-section-container").should("have.length", 1);
// remove tab
cy.get(".tab-header").realHover().find(".tab-actions .remove-tab-btn").click();
cy.get(".tab-header .tab:last").realHover().find(".remove-tab-btn").click();
cy.get(".tab-header .tabs .tab").should("have.length", 2);
});
@ -230,26 +272,35 @@ context("Form Builder", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_field =
".tab-content.active .section-columns-container:first .column:first .field:first";
let first_column = ".tab-content.active .section-columns-container:first .column:first";
cy.get(".fields-container .field[title='Data']").drag(first_field, {
target: { x: 100, y: 10 },
});
let last_field = first_column + " .field:last";
cy.get(first_field).click();
let add_new_field_btn = first_column + " .add-new-field-btn button";
// add new field
cy.get(add_new_field_btn).click();
// type data and press enter
cy.get(".combo-box-options:visible .search-box > input").type("data{enter}");
cy.get(last_field).click();
// validate duplicate name
cy.get(".sidebar-container .frappe-control[data-fieldname='fieldname'] input")
.click()
.as("input");
cy.get("@input").clear({ force: true }).type("data3");
cy.get(".sidebar-container .frappe-control[data-fieldname='fieldname'] input")
.clear({ force: true })
.type("data3");
cy.click_doc_primary_button("Save");
cy.get_open_dialog().find(".msgprint").should("contain", "appears multiple times");
cy.hide_dialog();
cy.get(first_field).click();
cy.get("@input").clear({ force: true });
cy.get(last_field).click();
cy.get(".sidebar-container .frappe-control[data-fieldname='fieldname'] input").clear({
force: true,
});
// validate reqd + hidden without default
cy.get(".sidebar-container .field label .label-area").contains("Mandatory").click();
@ -263,7 +314,7 @@ context("Form Builder", () => {
.should("contain", "cannot be hidden and mandatory without any default value");
});
it("Undo/Redo", () => {
it.skip("Undo/Redo", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();

View file

@ -24,14 +24,14 @@ context("Grid", () => {
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("@table").find('[data-idx="1"] .btn-open-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("@table").find('[data-idx="2"] .btn-open-row').click();
cy.get(".grid-row-open").as("table-form");
cy.get("@table-form")
.find('.frappe-control[data-fieldname="is_primary_phone"]')
@ -48,14 +48,14 @@ context("Grid", () => {
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("@table").find('[data-idx="1"] .btn-open-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("@table").find('[data-idx="2"] .btn-open-row').click();
cy.get(".grid-row-open").as("table-form");
cy.get("@table-form")
.find('.frappe-control[data-fieldname="is_primary_mobile_no"]')
@ -72,14 +72,14 @@ context("Grid", () => {
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("@table").find('[data-idx="1"] .btn-open-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("@table").find('[data-idx="2"] .btn-open-row').click();
cy.get(".grid-row-open").as("table-form");
cy.get("@table-form")
.find('.frappe-control[data-fieldname="phone"] .control-value')
@ -96,14 +96,14 @@ context("Grid", () => {
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("@table").find('[data-idx="1"] .btn-open-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("@table").find('[data-idx="2"] .btn-open-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();

View file

@ -4,7 +4,7 @@ context("Grid Configuration", () => {
cy.visit("/app/doctype/User");
});
it("Set user wise grid settings", () => {
cy.findByRole("tab", { name: "Form" }).click();
cy.findByRole("tab", { name: "Settings" }).click();
cy.get('.form-section[data-fieldname="fields_section"]').click();
cy.wait(100);
cy.get('.frappe-control[data-fieldname="fields"]').as("table");

View file

@ -1,18 +1,25 @@
context("Grid Keyboard Shortcut", () => {
let total_count = 0;
let contact_email_name = null;
before(() => {
cy.login();
});
beforeEach(() => {
cy.reload();
cy.visit("/app/contact/new-contact-1");
cy.new_form("Contact");
cy.get('.frappe-control[data-fieldname="email_ids"]').find(".grid-add-row").click();
// as new names uses hash instead of numbers get row's data-name dynamically.
cy.get('.frappe-control[data-fieldname="email_ids"]')
.find(".grid-body .grid-row")
.should(($row) => {
contact_email_name = $row.attr("data-name");
});
});
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(
cy.get(`[data-name="${contact_email_name}"]`).should(
"have.attr",
"data-idx",
`${total_count + 1}`
@ -23,17 +30,17 @@ context("Grid Keyboard Shortcut", () => {
});
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");
cy.get(`[data-name="${contact_email_name}"]`).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");
cy.get(`[data-name^="${contact_email_name}"]`).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");
cy.get(`[data-name^="${contact_email_name}"]`).should("have.attr", "data-idx", "2");
});
});
});

View file

@ -12,7 +12,7 @@ context("List Paging", () => {
it("test load more with count selection buttons", () => {
cy.visit("/app/todo/view/report");
cy.get(".filter-x-button").click();
cy.clear_filters();
cy.get(".list-paging-area .list-count").should("contain.text", "20 of");
cy.get(".list-paging-area .btn-more").click();
@ -29,7 +29,7 @@ context("List Paging", () => {
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('.page-head .standard-actions [data-original-title="Reload List"]').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();
@ -37,6 +37,6 @@ context("List Paging", () => {
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");
cy.get(".list-paging-area .list-count").should("contain.text", "1,000 of");
});
});

View file

@ -14,7 +14,7 @@ context("List View", () => {
cy.go_to_list("ToDo");
cy.clear_filters();
cy.get(".list-header-subject > .list-subject > .list-check-all").click();
cy.get("button[data-original-title='Refresh']").click();
cy.get("button[data-original-title='Reload List']").click();
cy.get(".list-row-container .list-row-checkbox:checked").should("be.visible");
});
@ -25,6 +25,7 @@ context("List View", () => {
"Edit",
"Export",
"Assign To",
"Clear Assignment",
"Apply Assignment Rule",
"Add Tags",
"Print",
@ -35,7 +36,7 @@ context("List View", () => {
cy.get(".list-header-subject > .list-subject > .list-check-all").click();
cy.findByRole("button", { name: "Actions" }).click();
cy.get(".dropdown-menu li:visible .dropdown-item")
.should("have.length", 9)
.should("have.length", 10)
.each((el, index) => {
cy.wrap(el).contains(actions[index]);
})

View file

@ -15,7 +15,7 @@ context("List View Settings", () => {
cy.clear_filters();
cy.wait(300);
cy.get(".list-count").should("contain", "20 of");
cy.get("[href='#icon-small-message']").should("be.visible");
cy.get("[href='#es-line-chat-alt']").should("be.visible");
cy.get(".menu-btn-group button").click();
cy.get(".dropdown-menu li").filter(":visible").contains("List Settings").click();
cy.get(".modal-dialog").should("contain", "DocType Settings");
@ -29,7 +29,7 @@ context("List View Settings", () => {
cy.get(".list-count").should("be.empty");
cy.get(".list-sidebar .list-tags").should("not.exist");
cy.get("[href='#icon-small-message']").should("not.be.visible");
cy.get("[href='#es-line-chat-alt']").should("not.be.visible");
cy.get(".menu-btn-group button").click({ force: true });
cy.get(".dropdown-menu li").filter(":visible").contains("List Settings").click();

View file

@ -1,6 +1,7 @@
context("Login", () => {
beforeEach(() => {
cy.request("/api/method/logout");
cy.visit("/");
cy.call("logout");
cy.visit("/login");
cy.location("pathname").should("eq", "/login");
});
@ -35,7 +36,7 @@ context("Login", () => {
cy.get("#login_password").type(Cypress.env("adminPassword"));
cy.findByRole("button", { name: "Login" }).click();
cy.location("pathname").should("eq", "/app");
cy.location("pathname").should("match", /^\/app/);
cy.window().its("frappe.session.user").should("eq", "Administrator");
});
@ -48,7 +49,7 @@ context("Login", () => {
base64_string: "aGVsbG8gYWxs",
});
cy.request("/api/method/logout");
cy.call("logout");
// redirect-to /me page with params to mock OAuth 2.0 like request
cy.visit(

View file

@ -76,6 +76,11 @@ context("MultiSelectDialog", () => {
});
it("tests more button", () => {
cy.get_open_dialog()
.get(`.frappe-control[data-fieldname="search_term"]`)
.find('input[data-fieldname="search_term"]')
.should("exist")
.type("Test", { delay: 200 });
cy.get_open_dialog()
.get(`.frappe-control[data-fieldname="more_child_btn"]`)
.should("exist")

View file

@ -6,16 +6,17 @@ context("Navigation", () => {
});
it("Navigate to route with hash in document name", () => {
cy.insert_doc(
"ToDo",
"Client Script",
{
__newname: "ABC#123",
description: "Test this",
dt: "User",
script: "console.log('ran')",
enabled: 0,
},
true
);
cy.visit(`/app/todo/${encodeURIComponent("ABC#123")}`);
cy.title().should("eq", "Test this - ABC#123");
cy.get_field("description", "Text Editor").contains("Test this");
cy.visit(`/app/client-script/${encodeURIComponent("ABC#123")}`);
cy.title().should("eq", "ABC#123");
cy.go("back");
cy.title().should("eq", "Website");
});
@ -24,7 +25,7 @@ context("Navigation", () => {
cy.visit("/app/todo");
cy.get(".page-head").findByTitle("To Do").should("be.visible");
cy.clear_filters();
cy.request("/api/method/logout");
cy.call("logout");
cy.reload().as("reload");
cy.get("@reload").get(".page-card .btn-primary").contains("Login").click();
cy.location("pathname").should("eq", "/login");

View file

@ -5,7 +5,7 @@ context("Number Card", () => {
});
it("Check filter populate for child table doctype", () => {
cy.visit("/app/number-card/new-number-card-1");
cy.new_form("Number Card");
cy.get('[data-fieldname="parent_document_type"]').should("have.css", "display", "none");
cy.get_field("document_type", "Link");

View file

@ -1,4 +1,4 @@
context("Permissions API", () => {
context.skip("Permissions API", () => {
before(() => {
cy.visit("/login");
cy.remove_role("frappe@example.com", "System Manager");

View file

@ -49,7 +49,7 @@ context("Rounding behaviour", () => {
let rounding_method = "Banker's Rounding";
expect(flt("0.5", 0, null, rounding_method)).eq(0);
expect(flt("0.3", null, rounding_method)).eq(0.3);
expect(flt("0.3", null, null, rounding_method)).eq(0.3);
expect(flt("1.5", 0, null, rounding_method)).eq(2);

View file

@ -8,6 +8,7 @@ const test_queries = [
`?date=%5B">"%2C"2022-06-01"%5D`,
`?name=%5B"like"%2C"%2542%25"%5D`,
`?status=%5B"not%20in"%2C%5B"Open"%2C"Closed"%5D%5D`,
`?status=%5B%22%21%3D%22%2C%22Closed%22%5D&status=%5B%22%21%3D%22%2C%22Cancelled%22%5D`,
];
describe("SPA Routing", { scrollBehavior: false }, () => {

View file

@ -2,7 +2,7 @@ const verify_attachment_visibility = (document, is_private) => {
cy.visit(`/app/${document}`);
const assertion = is_private ? "be.checked" : "not.be.checked";
cy.findByRole("button", { name: "Attach File" }).click();
cy.get(".add-attachment-btn").click();
cy.get_open_dialog()
.find(".file-upload-area")
@ -27,7 +27,7 @@ const attach_file = (file, no_of_files = 1) => {
);
}
cy.findByRole("button", { name: "Attach File" }).click();
cy.get(".add-attachment-btn").click();
cy.get_open_dialog().find(".file-upload-area").selectFile(files, {
action: "drag-drop",
});
@ -36,9 +36,9 @@ const attach_file = (file, no_of_files = 1) => {
context("Sidebar", () => {
before(() => {
cy.visit("/login");
cy.visit("/");
cy.login();
cy.visit("/app");
return cy
.window()
.its("frappe")
@ -62,11 +62,8 @@ context("Sidebar", () => {
}).then((todo) => {
cy.visit(`/app/todo/${todo.message.name}`);
// explore icon btn should be hidden as there are no attachments
cy.get(".explore-btn").should("be.hidden");
attach_file("cypress/fixtures/sample_image.jpg");
cy.get(".explore-btn").should("be.visible");
cy.get(".explore-link").should("be.visible");
cy.get(".show-all-btn").should("be.hidden");
// attach 10 images
@ -75,13 +72,12 @@ context("Sidebar", () => {
// attach 1 more image to reach attachment limit
attach_file("cypress/fixtures/sample_attachments/attachment-11.txt");
cy.get(".explore-full-btn").should("be.visible");
cy.get(".attachments-actions").should("be.hidden");
cy.get(".explore-btn").should("be.hidden");
cy.get(".add-attachment-btn").should("be.hidden");
cy.get(".explore-link").should("be.visible");
// test "Show All" button
cy.get(".attachment-row").should("have.length", 10);
cy.get(".show-all-btn").click();
cy.get(".show-all-btn").click({ force: true });
cy.get(".attachment-row").should("have.length", 12);
});
});
@ -99,8 +95,9 @@ context("Sidebar", () => {
//Assigning a doctype to a user
cy.visit(`/app/todo/${todo_name}`);
cy.get(".form-assignments > .flex > .text-muted").click();
cy.get(".add-assignment-btn").click();
cy.get_field("assign_to_me", "Check").click();
cy.wait(1000);
cy.get(".modal-footer > .standard-actions > .btn-primary").click();
cy.visit("/app/todo");
cy.click_sidebar_button("Assigned To");
@ -119,7 +116,7 @@ context("Sidebar", () => {
).click();
//To check if filter is applied
cy.click_filter_button().should("contain", "1 filter");
cy.click_filter_button().get(".filter-label").should("contain", "1");
cy.get(".fieldname-select-area > .awesomplete > .form-control").should(
"have.value",
"Assigned To"

View file

@ -38,7 +38,7 @@ context("Realtime updates", () => {
});
});
it("Recieves msgprint from server", { scrollBehavior: false }, () => {
it("Receives msgprint from server", { scrollBehavior: false }, () => {
// required because immediately after load socket is still connecting.
// Not a deal breaker in prod
const msg = "msgprint sent via realtime";

View file

@ -1,29 +0,0 @@
context("Theme Switcher Shortcut", () => {
before(() => {
cy.login();
cy.visit("/app");
});
beforeEach(() => {
cy.reload();
});
it("Check Toggle", () => {
cy.open_theme_dialog("{ctrl+shift+g}");
cy.get(".modal-backdrop").should("exist");
cy.get(".theme-grid > div").first().click();
cy.close_theme("{ctrl+shift+g}");
cy.get(".modal-backdrop").should("not.exist");
});
it("Check Enter", () => {
cy.open_theme_dialog("{ctrl+shift+g}");
cy.get(".theme-grid > div").first().click();
cy.close_theme("{enter}");
cy.get(".modal-backdrop").should("not.exist");
});
});
Cypress.Commands.add("open_theme_dialog", (shortcut_keys) => {
cy.get("body").type(shortcut_keys);
});
Cypress.Commands.add("close_theme", (shortcut_keys) => {
cy.get(".modal-header").type(shortcut_keys);
});

View file

@ -1,91 +0,0 @@
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.go_to_list("ToDo");
cy.clear_filters();
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", "You 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", "You 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");
});
});

View file

@ -224,8 +224,8 @@ context("View", () => {
});
});
it("Route to Settings Workspace", () => {
cy.visit("/app/settings");
cy.get(".title-text").should("contain", "Settings");
it("Route to Website Workspace", () => {
cy.visit("/app/website");
cy.get(".title-text").should("contain", "Website");
});
});

View file

@ -34,7 +34,7 @@ context("Web Form", () => {
cy.url().should("include", "/note/new");
cy.request("/api/method/logout");
cy.call("logout");
cy.visit("/note");
cy.url().should("include", "/note/new");
@ -49,6 +49,7 @@ context("Web Form", () => {
});
it("Login Required", () => {
cy.call("logout");
cy.login("Administrator");
cy.visit("/app/web-form/note");
@ -155,6 +156,7 @@ context("Web Form", () => {
cy.findByRole("tab", { name: "Customization" }).click();
cy.fill_field("breadcrumbs", '[{"label": _("Notes"), "route":"note"}]', "Code");
cy.wait(2000);
cy.get(".form-tabs .nav-item .nav-link").contains("Customization").click();
cy.save();

View file

@ -7,8 +7,8 @@ context("Workspace 2.0", () => {
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");
cy.get('.sidebar-item-container[item-name="Website"]').first().click();
cy.location("pathname").should("eq", "/app/website");
});
it("Create Private Page", () => {
@ -20,7 +20,6 @@ context("Workspace 2.0", () => {
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();
@ -52,7 +51,6 @@ context("Workspace 2.0", () => {
cy.get('.custom-actions button[data-label="Create%20Workspace"]').click();
cy.fill_field("title", "Test Child Page", "Data");
cy.fill_field("parent", "Test Private Page", "Select");
cy.fill_field("icon", "edit", "Icon");
cy.get_open_dialog().find(".modal-header").click();
cy.get_open_dialog().find(".btn-primary").click();

View file

@ -20,7 +20,6 @@ context("Workspace Blocks", () => {
cy.get(".codex-editor__redactor .ce-block");
cy.get('.custom-actions button[data-label="Create%20Workspace"]').click();
cy.fill_field("title", "Test Block Page", "Data");
cy.fill_field("icon", "edit", "Icon");
cy.get_open_dialog().find(".modal-header").click();
cy.get_open_dialog().find(".btn-primary").click();

View file

@ -34,14 +34,24 @@ Cypress.Commands.add("login", (email, password) => {
if (!password) {
password = Cypress.env("adminPassword");
}
return cy.request({
url: "/api/method/login",
method: "POST",
body: {
usr: email,
pwd: password,
},
});
// cy.session clears all localStorage on new login, so we need to retain the last route
const session_last_route = window.localStorage.getItem("session_last_route");
return cy
.session([email, password] || "", () => {
return cy.request({
url: "/api/method/login",
method: "POST",
body: {
usr: email,
pwd: password,
},
});
})
.then(() => {
if (session_last_route) {
window.localStorage.setItem("session_last_route", session_last_route);
}
});
});
Cypress.Commands.add("call", (method, args) => {
@ -62,6 +72,9 @@ Cypress.Commands.add("call", (method, args) => {
})
.then((res) => {
expect(res.status).eq(200);
if (method === "logout") {
Cypress.session.clearAllSavedSessions();
}
return res.body;
});
});
@ -235,7 +248,10 @@ Cypress.Commands.add("awesomebar", (text) => {
Cypress.Commands.add("new_form", (doctype) => {
let dt_in_route = doctype.toLowerCase().replace(/ /g, "-");
cy.visit(`/app/${dt_in_route}/new`);
cy.get("body").should("have.attr", "data-route", `Form/${doctype}/new-${dt_in_route}-1`);
cy.get("body").should(($body) => {
const dataRoute = $body.attr("data-route");
expect(dataRoute).to.match(new RegExp(`^Form/${doctype}/new-${dt_in_route}-`));
});
cy.get("body").should("have.attr", "data-ajax-state", "complete");
});
@ -433,27 +449,8 @@ Cypress.Commands.add("click_menu_button", (name) => {
});
Cypress.Commands.add("clear_filters", () => {
let has_filter = false;
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").then((popover) => {
if (popover.find("input.input-with-feedback")[0].value != "") {
has_filter = true;
}
});
cy.get(".filter-popover").find(".clear-filters").click();
cy.get(".filter-section .filter-button").click();
cy.window()
.its("cur_list")
.then((cur_list) => {
cur_list && cur_list.filter_area && cur_list.filter_area.clear();
has_filter && cy.wait("@filter-saved");
});
cy.get(".filter-x-button").click({ force: true });
cy.wait(1000);
});
Cypress.Commands.add("click_modal_primary_button", (btn_name) => {

View file

@ -23,7 +23,3 @@ Cypress.on("uncaught:exception", (err, runnable) => {
// Alternatively you can use CommonJS syntax:
// require('./commands')
Cypress.Cookies.defaults({
preserve: "sid",
});

View file

@ -19,7 +19,6 @@ const {
assets_path,
apps_path,
sites_path,
get_app_path,
get_public_path,
log,
log_warn,
@ -64,6 +63,11 @@ const argv = yargs
description:
"Saves esbuild metafiles for built assets. Useful for analyzing bundle size. More info: https://esbuild.github.io/api/#metafile",
})
.option("using-cached", {
type: "boolean",
description:
"Skips build and uses cached build artifacts to update assets.json (used by Bench)",
})
.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",
@ -82,12 +86,11 @@ 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),
app_list.map((app) => path.resolve(apps_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)
app_list.map((app) => path.resolve(apps_path, app)).filter(fs.existsSync)
);
const USING_CACHED = Boolean(argv["using-cached"]);
execute().catch((e) => {
console.error(e);
@ -101,6 +104,12 @@ if (WATCH_MODE) {
async function execute() {
console.time(TOTAL_BUILD_TIME);
if (USING_CACHED) {
await update_assets_json_from_built_assets(APPS);
await update_assets_json_in_cache();
console.timeEnd(TOTAL_BUILD_TIME);
process.exit(0);
}
let results;
try {
@ -131,6 +140,44 @@ async function execute() {
}
}
async function update_assets_json_from_built_assets(apps) {
const assets = await get_assets_json_path_and_obj(false);
const assets_rtl = await get_assets_json_path_and_obj(true);
for (const app in apps) {
await update_assets_obj(app, assets.obj, assets_rtl.obj);
}
for (const { obj, path } of [assets, assets_rtl]) {
const data = JSON.stringify(obj, null, 4);
await fs.promises.writeFile(path, data);
}
}
async function update_assets_obj(app, assets, assets_rtl) {
const app_path = path.join(apps_path, app, app);
const dist_path = path.join(app_path, "public", "dist");
const files = await glob("**/*.bundle.*.{js,css}", { cwd: dist_path });
const prefix = path.join("/", "assets", app, "dist");
// eg: "js/marketplace.bundle.6SCSPSGQ.js"
for (const file of files) {
// eg: [ "marketplace", "bundle", "6SCSPSGQ", "js" ]
const parts = path.parse(file).base.split(".");
// eg: "marketplace.bundle.js"
const key = [...parts.slice(0, -2), parts.at(-1)].join(".");
// eg: "js/marketplace.bundle.6SCSPSGQ.js"
const value = path.join(prefix, file);
if (file.includes("-rtl")) {
assets_rtl[`rtl_${key}`] = value;
} else {
assets[key] = value;
}
}
}
function build_assets_for_apps(apps, files) {
let { include_patterns, ignore_patterns } = files.length
? get_files_to_build(files)
@ -393,14 +440,7 @@ async function write_assets_json(metafile) {
}
}
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);
let { obj: assets_json, path: assets_json_path } = await get_assets_json_path_and_obj(rtl);
// update with new values
let new_assets_json = Object.assign({}, assets_json, out);
curr_assets_json = new_assets_json;
@ -434,6 +474,19 @@ async function update_assets_json_in_cache() {
});
}
async function get_assets_json_path_and_obj(is_rtl) {
const file_name = is_rtl ? "assets-rtl.json" : "assets.json";
const assets_json_path = path.resolve(assets_path, file_name);
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);
return { obj: assets_json, path: assets_json_path };
}
function run_build_command_for_apps(apps) {
let cwd = process.cwd();
let { execSync } = require("child_process");
@ -441,16 +494,29 @@ function run_build_command_for_apps(apps) {
for (let app of apps) {
if (app === "frappe") continue;
let root_app_path = path.resolve(get_app_path(app), "..");
let root_app_path = path.resolve(apps_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" });
}
let node_modules = path.resolve(root_app_path, "node_modules");
if (!fs.existsSync(package_json)) {
continue;
}
let { scripts } = require(package_json);
if (!scripts?.build) {
continue;
}
process.chdir(root_app_path);
if (!fs.existsSync(node_modules)) {
log(
`\nInstalling dependencies for ${chalk.bold(app)} (because node_modules not found)`
);
execSync("yarn install", { encoding: "utf8", stdio: "inherit" });
}
log("\nRunning build command for", chalk.bold(app));
execSync("yarn build", { encoding: "utf8", stdio: "inherit" });
}
process.chdir(cwd);

View file

@ -1,11 +1,11 @@
let path = require("path");
let { get_app_path, app_list } = require("./utils");
let { apps_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, ".."));
let app_paths = app_list.map((app) => path.resolve(apps_path, app));
let node_modules_path = app_paths.map((app_path) => path.resolve(app_path, "node_modules"));
module.exports = {
includePaths: [node_modules_path, ...app_paths],
includePaths: [...node_modules_path, ...app_paths],
quietDeps: true,
importer: function (url) {
if (url.startsWith("~")) {

View file

@ -1,24 +1,25 @@
const path = require("path");
const fs = require("fs");
const chalk = require("chalk");
let bench_path;
if (process.env.FRAPPE_BENCH_ROOT) {
bench_path = process.env.FRAPPE_BENCH_ROOT;
} else {
const frappe_path = path.resolve(__dirname, "..");
bench_path = path.resolve(frappe_path, "..", "..");
}
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 sites_path = path.resolve(bench_path, "sites");
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");
out[app] = path.resolve(apps_path, app, app, "public");
return out;
}, {});
const public_js_paths = app_list.reduce((out, app) => {
out[app] = path.resolve(app_paths[app], "public/js");
out[app] = path.resolve(apps_path, app, app, "public/js");
return out;
}, {});
@ -66,8 +67,6 @@ function run_serially(tasks) {
return result;
}
const get_app_path = (app) => app_paths[app];
function get_apps_list() {
return fs
.readFileSync(path.resolve(sites_path, "apps.txt"), {
@ -135,7 +134,6 @@ module.exports = {
get_public_path,
get_build_json_path,
get_build_json,
get_app_path,
delete_file,
run_serially,
get_cli_arg,

View file

@ -10,6 +10,8 @@ be used to build database driven apps.
Read the documentation: https://frappeframework.com/docs
"""
import copy
import faulthandler
import functools
import gc
import importlib
@ -17,7 +19,8 @@ import inspect
import json
import os
import re
import unicodedata
import signal
import traceback
import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeAlias, overload
@ -25,6 +28,7 @@ from typing import TYPE_CHECKING, Any, Literal, Optional, TypeAlias, overload
import click
from werkzeug.local import Local, release_local
import frappe
from frappe.query_builder import (
get_query,
get_query_builder,
@ -43,9 +47,8 @@ from .utils.jinja import (
get_template,
render_template,
)
from .utils.lazy_loader import lazy_import
__version__ = "15.0.0-dev"
__version__ = "16.0.0-dev"
__title__ = "Frappe Framework"
controllers = {}
@ -85,7 +88,7 @@ class _dict(dict):
def _(msg: str, lang: str | None = None, context: str | None = None) -> str:
"""Returns translated string in current lang, if exists.
"""Return translated string in current lang, if exists.
Usage:
_('Change')
_('Change', context='Coins')
@ -120,8 +123,61 @@ def _(msg: str, lang: str | None = None, context: str | None = None) -> str:
return translated_string or non_translated_string
def as_unicode(text: str, encoding: str = "utf-8") -> str:
"""Convert to unicode if required"""
def _lt(msg: str, lang: str | None = None, context: str | None = None):
"""Lazily translate a string.
This function returns a "lazy string" which when casted to string via some operation applies
translation first before casting.
This is only useful for translating strings in global scope or anything that potentially runs
before `frappe.init()`
Note: Result is not guaranteed to equivalent to pure strings for all operations.
"""
return _LazyTranslate(msg, lang, context)
@functools.total_ordering
class _LazyTranslate:
__slots__ = ("msg", "lang", "context")
def __init__(self, msg: str, lang: str | None = None, context: str | None = None) -> None:
self.msg = msg
self.lang = lang
self.context = context
@property
def value(self) -> str:
return _(str(self.msg), self.lang, self.context)
def __str__(self):
return self.value
def __add__(self, other):
if isinstance(other, str | _LazyTranslate):
return self.value + str(other)
raise NotImplementedError
def __radd__(self, other):
if isinstance(other, str | _LazyTranslate):
return str(other) + self.value
return NotImplementedError
def __repr__(self) -> str:
return f"'{self.value}'"
# NOTE: it's required to override these methods and raise error as default behaviour will
# return `False` in all cases.
def __eq__(self, other):
raise NotImplementedError
def __lt__(self, other):
raise NotImplementedError
def as_unicode(text, encoding: str = "utf-8") -> str:
"""Convert to unicode if required."""
if isinstance(text, str):
return text
elif text is None:
@ -132,16 +188,6 @@ def as_unicode(text: str, encoding: str = "utf-8") -> str:
return str(text)
def get_lang_dict(fortype: str, name: str | None = None) -> dict[str, str]:
"""Returns the translated language dict for the given type and name.
:param fortype: must be one of `doctype`, `page`, `report`, `include`, `jsfile`, `boot`
:param name: name of the document for which assets are to be returned."""
from frappe.translate import get_dict
return get_dict(fortype, name)
def set_user_lang(user: str, user_language: str | None = None) -> None:
"""Guess and set user language for the session. `frappe.local.lang`"""
from frappe.translate import get_user_lang
@ -156,6 +202,7 @@ qb = local("qb")
conf = local("conf")
form = form_dict = local("form_dict")
request = local("request")
job = local("job")
response = local("response")
session = local("session")
user = local("user")
@ -169,9 +216,12 @@ 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 TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from werkzeug.wrappers import Request
from frappe.database.mariadb.database import MariaDBDatabase
from frappe.database.postgres.database import PostgresDatabase
from frappe.email.doctype.email_queue.email_queue import EmailQueue
from frappe.model.document import Document
from frappe.query_builder.builder import MariaDB, Postgres
from frappe.utils.redis_wrapper import RedisWrapper
@ -179,6 +229,15 @@ if TYPE_CHECKING:
db: MariaDBDatabase | PostgresDatabase
qb: MariaDB | Postgres
cache: RedisWrapper
response: _dict
conf: _dict
form_dict: _dict
flags: _dict
request: Request
session: _dict
user: str
flags: _dict
lang: str
# end: static analysis hack
@ -237,40 +296,59 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force=False)
local.jloader = None
local.cache = {}
local.form_dict = _dict()
local.preload_assets = {"style": [], "script": []}
local.preload_assets = {"style": [], "script": [], "icons": []}
local.session = _dict()
local.dev_server = _dev_server
local.qb = get_query_builder(local.conf.db_type)
local.qb.get_query = get_query
setup_redis_cache_connection()
setup_module_map()
if not _qb_patched.get(local.conf.db_type):
patch_query_execute()
patch_query_aggregation()
_register_fault_handler()
setup_module_map(include_all_apps=not (frappe.request or frappe.job or frappe.flags.in_migrate))
local.initialised = True
def connect(
site: str | None = None, db_name: str | None = None, set_admin_as_user: bool = True
) -> None:
def connect(site: str | None = None, db_name: str | None = None, set_admin_as_user: bool = True) -> None:
"""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 site: (Deprecated) If site is given, calls `frappe.init`.
:param db_name: (Deprecated) 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:
from frappe.utils.deprecations import deprecation_warning
deprecation_warning(
"Calling frappe.connect with the site argument is deprecated and will be removed in next major version. "
"Instead, explicitly invoke frappe.init(site) prior to calling frappe.connect(), if initializing the site is necessary."
)
init(site)
if db_name:
from frappe.utils.deprecations import deprecation_warning
deprecation_warning(
"Calling frappe.connect with the db_name argument is deprecated and will be removed in next major version. "
"Instead, explicitly invoke frappe.init(site) with the right config prior to calling frappe.connect(), if necessary."
)
assert db_name or local.conf.db_user, "site must be fully initialized, db_user missing"
assert db_name or local.conf.db_name, "site must be fully initialized, db_name missing"
assert local.conf.db_password, "site must be fully initialized, db_password missing"
local.db = get_db(
socket=local.conf.db_socket,
host=local.conf.db_host,
port=local.conf.db_port,
user=db_name or local.conf.db_name,
password=None,
user=local.conf.db_user or db_name,
password=local.conf.db_password,
cur_db_name=local.conf.db_name or db_name,
)
if set_admin_as_user:
set_user("Administrator")
@ -282,15 +360,22 @@ def connect_replica() -> bool:
if local and hasattr(local, "replica_db") and hasattr(local, "primary_db"):
return False
user = local.conf.db_name
user = local.conf.db_user
password = local.conf.db_password
port = local.conf.replica_db_port
if local.conf.different_credentials_for_replica:
user = local.conf.replica_db_name
user = local.conf.replica_db_user or 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, port=port)
local.replica_db = get_db(
socket=None,
host=local.conf.replica_host,
port=port,
user=user,
password=password,
cur_db_name=local.conf.db_name,
)
# swap db connections
local.primary_db = local.db
@ -300,15 +385,17 @@ def connect_replica() -> bool:
def get_site_config(sites_path: str | None = None, site_path: str | None = None) -> dict[str, Any]:
"""Returns `site_config.json` combined with `sites/common_site_config.json`.
"""Return `site_config.json` combined with `sites/common_site_config.json`.
`site_config` is a set of site wide settings like database name, password, email etc."""
config = _dict()
sites_path = sites_path or getattr(local, "sites_path", None)
site_path = site_path or getattr(local, "site_path", None)
common_config = get_common_site_config(sites_path)
if sites_path:
config.update(get_common_site_config(sites_path))
config.update(common_config)
if site_path:
site_config = os.path.join(site_path, "site_config.json")
@ -319,7 +406,15 @@ def get_site_config(sites_path: str | None = None, site_path: str | None = None)
click.secho(f"{local.site}/site_config.json is invalid", fg="red")
print(error)
elif local.site and not local.flags.new_site:
raise IncorrectSitePath(f"{local.site} does not exist")
error_msg = f"{local.site} does not exist."
if common_config.developer_mode:
from frappe.utils import get_sites
all_sites = get_sites()
error_msg += "\n\nSites on this bench:\n"
error_msg += "\n".join(f"* {site}" for site in all_sites)
raise IncorrectSitePath(error_msg)
# Generalized env variable overrides and defaults
def db_default_ports(db_type):
@ -337,16 +432,32 @@ def get_site_config(sites_path: str | None = None, site_path: str | None = None)
os.environ.get("FRAPPE_REDIS_CACHE") or config.get("redis_cache") or "redis://127.0.0.1:13311"
)
config["db_type"] = os.environ.get("FRAPPE_DB_TYPE") or config.get("db_type") or "mariadb"
config["db_socket"] = os.environ.get("FRAPPE_DB_SOCKET") or config.get("db_socket")
config["db_host"] = os.environ.get("FRAPPE_DB_HOST") or config.get("db_host") or "127.0.0.1"
config["db_port"] = (
os.environ.get("FRAPPE_DB_PORT") or config.get("db_port") or db_default_ports(config["db_type"])
)
# Set the user as database name if not set in config
config["db_user"] = os.environ.get("FRAPPE_DB_USER") or config.get("db_user") or config.get("db_name")
# Allow externally extending the config with hooks
if extra_config := config.get("extra_config"):
if isinstance(extra_config, str):
extra_config = [extra_config]
for hook in extra_config:
try:
module, method = hook.rsplit(".", 1)
config |= getattr(importlib.import_module(module), method)()
except Exception:
print(f"Config hook {hook} failed")
traceback.print_exc()
return config
def get_common_site_config(sites_path: str | None = None) -> dict[str, Any]:
"""Returns common site config as dictionary.
"""Return common site config as dictionary.
This is useful for:
- checking configuration which should only be allowed in common site config
@ -399,13 +510,13 @@ def setup_redis_cache_connection():
global cache
if not cache:
from frappe.utils.redis_wrapper import RedisWrapper
from frappe.utils.redis_wrapper import setup_cache
cache = RedisWrapper.from_url(conf.get("redis_cache"))
cache = setup_cache()
def get_traceback(with_context: bool = False) -> str:
"""Returns error traceback."""
"""Return error traceback."""
from frappe.utils import get_traceback
return get_traceback(with_context=with_context)
@ -416,7 +527,7 @@ def errprint(msg: str) -> None:
:param msg: Message."""
msg = as_unicode(msg)
if not request or (not "cmd" in local.form_dict) or conf.developer_mode:
if not request or ("cmd" not in local.form_dict) or conf.developer_mode:
print(msg)
error_log.append({"exc": msg})
@ -427,16 +538,22 @@ def print_sql(enable: bool = True) -> None:
def log(msg: str) -> None:
"""Add to `debug_log`.
"""Add to `debug_log`
:param msg: Message."""
if not request:
if conf.get("logging") or False:
print(repr(msg))
print(repr(msg))
debug_log.append(as_unicode(msg))
@functools.lru_cache(maxsize=1024)
def _strip_html_tags(message):
from frappe.utils import strip_html_tags
return strip_html_tags(message)
def msgprint(
msg: str,
title: str | None = None,
@ -445,9 +562,11 @@ def msgprint(
as_list: bool = False,
indicator: Literal["blue", "green", "orange", "red", "yellow"] | None = None,
alert: bool = False,
primary_action: str = None,
primary_action: str | None = None,
is_minimizable: bool = False,
wide: bool = False,
*,
realtime=False,
) -> None:
"""Print a message to the user (via HTTP response).
Messages are sent in the `__server_messages` property in the
@ -461,25 +580,23 @@ def msgprint(
:param primary_action: [optional] Bind a primary server/client side action.
:param is_minimizable: [optional] Allow users to minimize the modal
:param wide: [optional] Show wide modal
:param realtime: Publish message immediately using websocket.
"""
import inspect
import sys
from frappe.utils import strip_html_tags
msg = safe_decode(msg)
out = _dict(message=msg)
@functools.lru_cache(maxsize=1024)
def _strip_html_tags(message):
return strip_html_tags(message)
def _raise_exception():
if raise_exception:
if inspect.isclass(raise_exception) and issubclass(raise_exception, Exception):
raise raise_exception(msg)
exc = raise_exception(msg)
else:
raise ValidationError(msg)
exc = ValidationError(msg)
if out.__frappe_exc_id:
exc.__frappe_exc_id = out.__frappe_exc_id
raise exc
if flags.mute_messages:
_raise_exception()
@ -516,6 +633,7 @@ def msgprint(
if raise_exception:
out.raise_exception = 1
out.__frappe_exc_id = generate_hash()
if primary_action:
out.primary_action = primary_action
@ -523,11 +641,10 @@ def msgprint(
if wide:
out.wide = wide
message_log.append(json.dumps(out))
if raise_exception and hasattr(raise_exception, "__name__"):
local.response["exc_type"] = raise_exception.__name__
if realtime:
publish_realtime(event="msgprint", message=out)
else:
message_log.append(out)
_raise_exception()
@ -535,8 +652,8 @@ def clear_messages():
local.message_log = []
def get_message_log():
return [json.loads(msg_out) for msg_out in local.message_log]
def get_message_log() -> list[dict]:
return [msg_out for msg_out in local.message_log]
def clear_last_message():
@ -551,11 +668,18 @@ def throw(
is_minimizable: bool = False,
wide: bool = False,
as_list: bool = False,
primary_action=None,
) -> None:
"""Throw execption and show message (`msgprint`).
:param msg: Message.
:param exc: Exception class. Default `frappe.ValidationError`"""
:param exc: Exception class. Default `frappe.ValidationError`
:param title: [optional] Message title. Default: "Message".
:param is_minimizable: [optional] Allow users to minimize the modal
:param wide: [optional] Show wide modal
:param as_list: [optional] If `msg` is a list, render as un-ordered list.
:param primary_action: [optional] Bind a primary server/client side action.
"""
msgprint(
msg,
raise_exception=exc,
@ -564,6 +688,7 @@ def throw(
is_minimizable=is_minimizable,
wide=wide,
as_list=as_list,
primary_action=primary_action,
)
@ -605,7 +730,7 @@ def get_user():
def get_roles(username=None) -> list[str]:
"""Returns roles of current user."""
"""Return roles of current user."""
if not local.session or not local.session.user:
return ["Guest"]
import frappe.permissions
@ -659,7 +784,7 @@ def sendmail(
print_letterhead=False,
with_container=False,
email_read_tracker_url=None,
):
) -> Optional["EmailQueue"]:
"""Send email using user's default **Email Account** or global default **Email Account**.
@ -744,12 +869,12 @@ def sendmail(
)
# build email queue and send the email if send_now is True.
builder.process(send_now=now)
return builder.process(send_now=now)
whitelisted = []
guest_methods = []
xss_safe_methods = []
whitelisted = set()
guest_methods = set()
xss_safe_methods = set()
allowed_http_methods_for_whitelisted_func = {}
@ -788,14 +913,14 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
else:
fn = validate_argument_types(fn, apply_condition=in_request_or_test)
whitelisted.append(fn)
whitelisted.add(fn)
allowed_http_methods_for_whitelisted_func[fn] = methods
if allow_guest:
guest_methods.append(fn)
guest_methods.add(fn)
if xss_safe:
xss_safe_methods.append(fn)
xss_safe_methods.add(fn)
return method or fn
@ -808,9 +933,7 @@ def is_whitelisted(method):
is_guest = session["user"] == "Guest"
if method not in whitelisted or is_guest and method not in guest_methods:
summary = _("You are not permitted to access this resource.")
detail = _("Function {0} is not whitelisted.").format(
bold(f"{method.__module__}.{method.__name__}")
)
detail = _("Function {0} is not whitelisted.").format(bold(f"{method.__module__}.{method.__name__}"))
msg = f"<details><summary>{summary}</summary>{detail}</details>"
throw(msg, PermissionError, title="Method Not Allowed")
@ -824,8 +947,8 @@ def is_whitelisted(method):
def read_only():
def innfn(fn):
@functools.wraps(fn)
def wrapper_fn(*args, **kwargs):
# frappe.read_only could be called from nested functions, in such cases don't swap the
# connection again.
switched_connection = False
@ -968,10 +1091,12 @@ def has_permission(
throw=False,
*,
parent_doctype=None,
debug=False,
):
"""
Returns True if the user has permission `ptype` for given `doctype` or `doc`
Raises `frappe.PermissionError` if user isn't permitted and `throw` is truthy
Return True if the user has permission `ptype` for given `doctype` or `doc`.
Raise `frappe.PermissionError` if user isn't permitted and `throw` is truthy
:param doctype: DocType for which permission is to be check.
:param ptype: Permission type (`read`, `write`, `create`, `submit`, `cancel`, `amend`). Default: `read`.
@ -989,24 +1114,15 @@ def has_permission(
ptype,
doc=doc,
user=user,
raise_exception=throw,
print_logs=throw,
parent_doctype=parent_doctype,
debug=debug,
)
if throw and not out:
# mimics frappe.throw
document_label = (
f"{_(doctype)} {doc if isinstance(doc, str) else 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,
)
document_label = f"{_(doctype)} {doc if isinstance(doc, str) else doc.name}" if doc else _(doctype)
frappe.flags.error_message = _("No permission for {0}").format(document_label)
raise frappe.PermissionError
return out
@ -1051,7 +1167,7 @@ def has_website_permission(doc=None, ptype="read", user=None, verbose=False, doc
def is_table(doctype: str) -> bool:
"""Returns True if `istable` property (indicating child Table) is set for given DocType."""
"""Return True if `istable` property (indicating child Table) is set for given DocType."""
def get_tables():
return db.get_values("DocType", filters={"istable": 1}, order_by=None, pluck=True)
@ -1074,8 +1190,10 @@ def generate_hash(txt: str | None = None, length: int = 56) -> str:
import math
import secrets
if not length:
length = 56
if txt:
from frappe.utils.deprecations import deprecation_warning
deprecation_warning("The `txt` parameter is deprecated and will be removed in a future release.")
return secrets.token_hex(math.ceil(length / 2))[:length]
@ -1095,7 +1213,7 @@ def new_doc(
as_dict: bool = False,
**kwargs,
) -> "Document":
"""Returns a new document of the given DocType with defaults set.
"""Return a new document of the given DocType with defaults set.
:param doctype: DocType of the new document.
:param parent_doc: [optional] add to parent document.
@ -1119,6 +1237,7 @@ def set_value(doctype, docname, fieldname, value=None):
def get_cached_doc(*args, **kwargs) -> "Document":
"""Identical to `frappe.get_doc`, but return from cache if available."""
if (key := can_cache_doc(args)) and (doc := cache.get_value(key)):
return doc
@ -1141,7 +1260,7 @@ def _set_document_in_cache(key: str, doc: "Document") -> None:
def can_cache_doc(args) -> str | None:
"""
Determine if document should be cached based on get_doc params.
Returns cache key if doc can be cached, None otherwise.
Return cache key if doc can be cached, None otherwise.
"""
if not args:
@ -1178,9 +1297,7 @@ def clear_document_cache(doctype: str, name: str | None = None) -> None:
delattr(local, "website_settings")
def get_cached_value(
doctype: str, name: str, fieldname: str = "name", as_dict: bool = False
) -> Any:
def get_cached_value(doctype: str, name: str, fieldname: str = "name", as_dict: bool = False) -> Any:
try:
doc = get_cached_doc(doctype, name)
except DoesNotExistError:
@ -1194,7 +1311,7 @@ def get_cached_value(
values = [doc.get(f) for f in fieldname]
if as_dict:
return _dict(zip(fieldname, values))
return _dict(zip(fieldname, values, strict=False))
return values
@ -1214,7 +1331,7 @@ def get_doc(doctype: str, /) -> _SingleDocument:
@overload
def get_doc(doctype: str, name: str, /, for_update: bool | None = None) -> "Document":
def get_doc(doctype: str, name: str, /, *, for_update: bool | None = None) -> "Document":
"""Retrieve DocType from DB, doctype and name must be positional argument."""
pass
@ -1393,17 +1510,17 @@ def rename_doc(
def get_module(modulename):
"""Returns a module object for given Python module name using `importlib.import_module`."""
"""Return a module object for given Python module name using `importlib.import_module`."""
return importlib.import_module(modulename)
def scrub(txt: str) -> str:
"""Returns sluggified string. e.g. `Sales Order` becomes `sales_order`."""
"""Return sluggified string. e.g. `Sales Order` becomes `sales_order`."""
return cstr(txt).replace(" ", "_").replace("-", "_").lower()
def unscrub(txt: str) -> str:
"""Returns titlified string. e.g. `sales_order` becomes `Sales Order`."""
"""Return titlified string. e.g. `sales_order` becomes `Sales Order`."""
return txt.replace("_", " ").replace("-", " ").title()
@ -1438,9 +1555,9 @@ def get_site_path(*joins):
"""Return path of current site.
:param *joins: Join additional path elements using `os.path.join`."""
from os.path import abspath, join
from os.path import join
return abspath(join(local.site_path, *joins))
return join(local.site_path, *joins)
def get_pymodule_path(modulename, *joins):
@ -1503,7 +1620,7 @@ def get_installed_apps(*, _ensure_on_bench=False) -> list[str]:
def get_doc_hooks():
"""Returns hooked methods for given doc. It will expand the dict tuple if required."""
"""Return hooked methods for given doc. Expand the dict tuple if required."""
if not hasattr(local, "doc_events_hooks"):
hooks = get_hooks("doc_events", {})
out = {}
@ -1538,7 +1655,7 @@ def _load_app_hooks(app_name: str | None = None):
raise
def _is_valid_hook(obj):
return not isinstance(obj, (types.ModuleType, types.FunctionType, type))
return not isinstance(obj, types.ModuleType | types.FunctionType | type)
for key, value in inspect.getmembers(app_hooks, predicate=_is_valid_hook):
if not key.startswith("_"):
@ -1547,7 +1664,7 @@ def _load_app_hooks(app_name: str | None = None):
def get_hooks(
hook: str = None, default: Any | None = "_KEEP_DEFAULT_LIST", app_name: str = None
hook: str | None = None, default: Any | None = "_KEEP_DEFAULT_LIST", app_name: str | None = None
) -> _dict:
"""Get hooks via `app/hooks.py`
@ -1589,28 +1706,47 @@ def append_hook(target, key, value):
target[key].extend(value)
def setup_module_map():
"""Rebuild map of all modules (internal)."""
if conf.db_name:
def setup_module_map(include_all_apps: bool = True) -> None:
"""
Function to rebuild map of all modules
:param: include_all_apps: Include all apps on bench, or just apps installed on the site.
:return: Nothing
"""
if include_all_apps:
local.app_modules = cache.get_value("app_modules")
local.module_app = cache.get_value("module_app")
else:
local.app_modules = cache.get_value("installed_app_modules")
local.module_app = cache.get_value("module_installed_app")
if not (local.app_modules and local.module_app):
local.module_app, local.app_modules = {}, {}
for app in get_all_apps(with_internal_apps=True):
if include_all_apps:
apps = get_all_apps(with_internal_apps=True)
else:
apps = get_installed_apps(_ensure_on_bench=True)
for app in apps:
local.app_modules.setdefault(app, [])
for module in get_module_list(app):
module = scrub(module)
if module in local.module_app:
print(
f"WARNING: module `{module}` found in apps `{local.module_app[module]}` and `{app}`"
)
local.module_app[module] = app
local.app_modules[app].append(module)
if conf.db_name:
if include_all_apps:
cache.set_value("app_modules", local.app_modules)
cache.set_value("module_app", local.module_app)
else:
cache.set_value("installed_app_modules", local.app_modules)
cache.set_value("module_installed_app", local.module_app)
def get_file_items(path, raise_not_found=False, ignore_empty_lines=True):
"""Returns items from text file as a list. Ignores empty lines."""
"""Return items from text file as a list. Ignore empty lines."""
import frappe.utils
content = read_file(path, raise_not_found=raise_not_found)
@ -1649,11 +1785,7 @@ def read_file(path, raise_not_found=False):
def get_attr(method_string: str) -> Any:
"""Get python method object from its name."""
app_name = method_string.split(".", 1)[0]
if (
not local.flags.in_uninstall
and 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])
@ -1675,7 +1807,8 @@ def get_newargs(fn: Callable, kwargs: dict[str, Any]) -> dict[str, Any]:
"""Remove any kwargs that are not supported by the function.
Example:
>>> def fn(a=1, b=2): pass
>>> def fn(a=1, b=2):
... pass
>>> get_newargs(fn, {"a": 2, "c": 1})
{"a": 2}
@ -1685,23 +1818,21 @@ def get_newargs(fn: Callable, kwargs: dict[str, Any]) -> dict[str, Any]:
# Ref: https://docs.python.org/3/library/inspect.html#inspect.Parameter.kind
varkw_exist = False
if hasattr(fn, "fnargs"):
fnargs = fn.fnargs
else:
signature = inspect.signature(fn)
fnargs = list(signature.parameters)
signature = inspect.signature(fn)
fnargs = list(signature.parameters)
for param_name, parameter in signature.parameters.items():
if parameter.kind == inspect.Parameter.VAR_KEYWORD:
varkw_exist = True
fnargs.remove(param_name)
break
for param_name, parameter in signature.parameters.items():
if parameter.kind == inspect.Parameter.VAR_KEYWORD:
varkw_exist = True
fnargs.remove(param_name)
break
newargs = {}
for a in kwargs:
if (a in fnargs) or varkw_exist:
newargs[a] = kwargs.get(a)
# WARNING: This behaviour is now part of business logic in places, never remove.
newargs.pop("ignore_permissions", None)
newargs.pop("flags", None)
@ -1791,13 +1922,13 @@ def copy_doc(doc: "Document", ignore_no_copy: bool = True) -> "Document":
newdoc = get_doc(copy.deepcopy(d))
newdoc.set("__islocal", 1)
for fieldname in fields_to_clear + ["amended_from", "amendment_date"]:
for fieldname in [*fields_to_clear, "amended_from", "amendment_date"]:
newdoc.set(fieldname, None)
if not ignore_no_copy:
remove_no_copy_fields(newdoc)
for i, d in enumerate(newdoc.get_all_children()):
for d in newdoc.get_all_children():
d.set("__islocal", 1)
for fieldname in fields_to_clear:
@ -1919,7 +2050,7 @@ def get_list(doctype, *args, **kwargs):
:param doctype: DocType on which query is to be made.
:param fields: List of fields or `*`.
:param filters: List of filters (see example).
:param order_by: Order By e.g. `modified desc`.
:param order_by: Order By e.g. `creation desc`.
:param limit_start: Start results at record #. Default 0.
:param limit_page_length: No of records in the page. Default 20.
@ -1943,7 +2074,7 @@ def get_all(doctype, *args, **kwargs):
:param doctype: DocType on which query is to be made.
:param fields: List of fields or `*`. Default is: `["name"]`.
:param filters: List of filters (see example).
:param order_by: Order By e.g. `modified desc`.
:param order_by: Order By e.g. `creation desc`.
:param limit_start: Start results at record #. Default 0.
:param limit_page_length: No of records in the page. Default 20.
@ -1956,13 +2087,13 @@ def get_all(doctype, *args, **kwargs):
frappe.get_all("ToDo", fields=["*"], filters = [["modified", ">", "2014-01-01"]])
"""
kwargs["ignore_permissions"] = True
if not "limit_page_length" in kwargs:
if "limit_page_length" not in kwargs:
kwargs["limit_page_length"] = 0
return get_list(doctype, *args, **kwargs)
def get_value(*args, **kwargs):
"""Returns a document property or list of properties.
"""Return a document property or list of properties.
Alias for `frappe.db.get_value`
@ -1977,6 +2108,7 @@ def get_value(*args, **kwargs):
def as_json(obj: dict | list, indent=1, separators=None, ensure_ascii=True) -> str:
"""Return the JSON string representation of the given `obj`."""
from frappe.utils.response import json_handler
if separators is None:
@ -2005,11 +2137,11 @@ def as_json(obj: dict | list, indent=1, separators=None, ensure_ascii=True) -> s
def are_emails_muted():
return flags.mute_emails or cint(conf.get("mute_emails") or 0) or False
return flags.mute_emails or cint(conf.get("mute_emails"))
def get_test_records(doctype):
"""Returns list of objects from `test_records.json` in the given doctype's folder."""
"""Return list of objects from `test_records.json` in the given doctype's folder."""
from frappe.modules import get_doctype_module, get_module_path
path = os.path.join(
@ -2064,21 +2196,27 @@ def get_print(
:param as_pdf: Return as PDF. Default False.
:param password: Password to encrypt the pdf with. Default None"""
from frappe.utils.pdf import get_pdf
from frappe.website.serve import get_response_content
from frappe.website.serve import get_response_without_exception_handling
local.form_dict.doctype = doctype
local.form_dict.name = name
local.form_dict.format = print_format
local.form_dict.style = style
local.form_dict.doc = doc
local.form_dict.no_letterhead = no_letterhead
local.form_dict.letterhead = letterhead
original_form_dict = copy.deepcopy(local.form_dict)
try:
local.form_dict.doctype = doctype
local.form_dict.name = name
local.form_dict.format = print_format
local.form_dict.style = style
local.form_dict.doc = doc
local.form_dict.no_letterhead = no_letterhead
local.form_dict.letterhead = letterhead
pdf_options = pdf_options or {}
if password:
pdf_options["password"] = password
pdf_options = pdf_options or {}
if password:
pdf_options["password"] = password
response = get_response_without_exception_handling("printview", 200)
html = str(response.data, "utf-8")
finally:
local.form_dict = original_form_dict
html = get_response_content("printview")
return get_pdf(html, options=pdf_options, output=output) if as_pdf else html
@ -2239,10 +2377,8 @@ loggers = {}
log_level = None
def logger(
module=None, with_more_info=False, allow_site=True, filter=None, max_size=100_000, file_count=20
):
"""Returns a python logger that uses StreamHandler"""
def logger(module=None, with_more_info=False, allow_site=True, filter=None, max_size=100_000, file_count=20):
"""Return a python logger that uses StreamHandler."""
from frappe.utils.logger import get_logger
return get_logger(
@ -2256,13 +2392,12 @@ def logger(
def get_desk_link(doctype, name):
html = (
'<a href="/app/Form/{doctype}/{name}" style="font-weight: bold;">{doctype_local} {name}</a>'
)
html = '<a href="/app/Form/{doctype}/{name}" style="font-weight: bold;">{doctype_local} {name}</a>'
return html.format(doctype=doctype, name=name, doctype_local=_(doctype))
def bold(text):
def bold(text: str) -> str:
"""Return `text` wrapped in `<strong>` tags."""
return f"<strong>{text}</strong>"
@ -2285,7 +2420,8 @@ def get_website_settings(key):
return local.website_settings.get(key)
def get_system_settings(key):
def get_system_settings(key: str):
"""Return the value associated with the given `key` from System Settings DocType."""
if not hasattr(local, "system_settings"):
try:
local.system_settings = get_cached_doc("System Settings")
@ -2304,12 +2440,12 @@ def get_active_domains():
def get_version(doctype, name, limit=None, head=False, raise_err=True):
"""
Returns a list of version information of a given DocType.
Return a list of version information for the given DocType.
Note: Applicable only if DocType has changes tracked.
Example
>>> frappe.get_version('User', 'foobar@gmail.com')
>>> frappe.get_version("User", "foobar@gmail.com")
>>>
[
{
@ -2387,7 +2523,7 @@ def mock(type, size=1, locale="en"):
if type not in dir(fake):
raise ValueError("Not a valid mock type.")
else:
for i in range(size):
for _ in range(size):
data = getattr(fake, type)()
results.append(data)
@ -2401,7 +2537,7 @@ def validate_and_sanitize_search_inputs(fn):
def wrapper(*args, **kwargs):
from frappe.desk.search import sanitize_searchfield
kwargs.update(dict(zip(fn.__code__.co_varnames, args)))
kwargs.update(dict(zip(fn.__code__.co_varnames, args, strict=False)))
sanitize_searchfield(kwargs["searchfield"])
kwargs["start"] = cint(kwargs["start"])
kwargs["page_len"] = cint(kwargs["page_len"])
@ -2414,7 +2550,11 @@ def validate_and_sanitize_search_inputs(fn):
return wrapper
from frappe.utils.error import log_error # noqa: backward compatibility
def _register_fault_handler():
faulthandler.register(signal.SIGUSR1)
from frappe.utils.error import log_error
if _tune_gc:
# generational GC gets triggered after certain allocs (g0) which is 700 by default.

View file

@ -1,306 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import base64
import binascii
import json
from typing import Literal
from urllib.parse import urlencode, urlparse
import frappe
import frappe.client
import frappe.handler
from frappe import _
from frappe.utils.data import sbool
from frappe.utils.response import build_response
def handle():
"""
Handler for `/api` methods
### Examples:
`/api/method/{methodname}` will call a whitelisted method
`/api/resource/{doctype}` will query a table
examples:
- `?fields=["name", "owner"]`
- `?filters=[["Task", "name", "like", "%005"]]`
- `?limit_start=0`
- `?limit_page_length=20`
`/api/resource/{doctype}/{name}` will point to a resource
`GET` will return doclist
`POST` will insert
`PUT` will update
`DELETE` will delete
`/api/resource/{doctype}/{name}?run_method={method}` will run a whitelisted controller method
"""
parts = frappe.request.path[1:].split("/", 3)
call = doctype = name = None
if len(parts) > 1:
call = parts[1]
if len(parts) > 2:
doctype = parts[2]
if len(parts) > 3:
name = parts[3]
return _RESTAPIHandler(call, doctype, name).get_response()
class _RESTAPIHandler:
def __init__(self, call: Literal["method", "resource"], doctype: str | None, name: str | None):
self.call = call
self.doctype = doctype
self.name = name
def get_response(self):
"""Prepare and get response based on URL and form body.
Note: most methods of this class directly operate on the response local.
"""
match self.call:
case "method":
return self.handle_method()
case "resource":
self.handle_resource()
case _:
raise frappe.DoesNotExistError
return build_response("json")
def handle_method(self):
frappe.local.form_dict.cmd = self.doctype
return frappe.handler.handle()
def handle_resource(self):
if self.doctype and self.name:
self.handle_document_resource()
elif self.doctype:
self.handle_doctype_resource()
else:
raise frappe.DoesNotExistError
def handle_document_resource(self):
if "run_method" in frappe.local.form_dict:
self.execute_doc_method()
return
match frappe.local.request.method:
case "GET":
self.get_doc()
case "PUT":
self.update_doc()
case "DELETE":
self.delete_doc()
case _:
raise frappe.DoesNotExistError
def handle_doctype_resource(self):
match frappe.local.request.method:
case "GET":
self.get_doc_list()
case "POST":
self.create_doc()
case _:
raise frappe.DoesNotExistError
def execute_doc_method(self):
method = frappe.local.form_dict.pop("run_method")
doc = frappe.get_doc(self.doctype, self.name)
doc.is_whitelisted(method)
if frappe.local.request.method == "GET":
if not doc.has_permission("read"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
elif frappe.local.request.method == "POST":
if not doc.has_permission("write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
frappe.db.commit()
def get_doc(self):
doc = frappe.get_doc(self.doctype, self.name)
if not doc.has_permission("read"):
raise frappe.PermissionError
doc.apply_fieldlevel_read_permissions()
frappe.local.response.update({"data": doc})
def update_doc(self):
data = get_request_form_data()
doc = frappe.get_doc(self.doctype, self.name, for_update=True)
if "flags" in data:
del data["flags"]
# Not checking permissions here because it's checked in doc.save
doc.update(data)
frappe.local.response.update({"data": doc.save().as_dict()})
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
frappe.db.commit()
def delete_doc(self):
# Not checking permissions here because it's checked in delete_doc
frappe.delete_doc(self.doctype, self.name, ignore_missing=False)
frappe.local.response.http_status_code = 202
frappe.local.response.message = "ok"
frappe.db.commit()
def get_doc_list(self):
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, self.doctype, **frappe.local.form_dict)
# set frappe.get_list result to response
frappe.local.response.update({"data": data})
def create_doc(self):
data = get_request_form_data()
data.update({"doctype": self.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()
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
try:
return frappe.parse_json(data)
except ValueError:
return frappe.local.form_dict
def validate_auth():
"""
Authenticate and sets user for the request.
"""
authorization_header = frappe.get_request_header("Authorization", "").split(" ")
if len(authorization_header) == 2:
validate_oauth(authorization_header)
validate_auth_via_api_keys(authorization_header)
validate_auth_via_hooks()
def validate_oauth(authorization_header):
"""
Authenticate request using OAuth and set session user
Args:
authorization_header (list of str): The 'Authorization' header containing the prefix and token
"""
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]
req = frappe.request
parsed_url = urlparse(req.url)
access_token = {"access_token": token}
uri = (
parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + "?" + urlencode(access_token)
)
http_method = req.method
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:
pass
def validate_auth_via_api_keys(authorization_header):
"""
Authenticate request using API keys and set session user
Args:
authorization_header (list of str): The 'Authorization' header containing the prefix and token
"""
try:
auth_type, auth_token = authorization_header
authorization_source = frappe.get_request_header("Frappe-Authorization-Source")
if auth_type.lower() == "basic":
api_key, api_secret = frappe.safe_decode(base64.b64decode(auth_token)).split(":")
validate_api_key_secret(api_key, api_secret, authorization_source)
elif auth_type.lower() == "token":
api_key, api_secret = auth_token.split(":")
validate_api_key_secret(api_key, api_secret, authorization_source)
except binascii.Error:
frappe.throw(
_("Failed to decode token, please provide a valid base64-encoded token."),
frappe.InvalidAuthorizationToken,
)
except (AttributeError, TypeError, ValueError):
pass
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
"""frappe_authorization_source to provide api key and secret for a doctype apart from User"""
doctype = frappe_authorization_source or "User"
doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"])
form_dict = frappe.local.form_dict
doc_secret = frappe.utils.password.get_decrypted_password(doctype, doc, fieldname="api_secret")
if api_secret == doc_secret:
if doctype == "User":
user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"])
else:
user = frappe.db.get_value(doctype, doc, "user")
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)()

80
frappe/api/__init__.py Normal file
View file

@ -0,0 +1,80 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from enum import Enum
from werkzeug.exceptions import NotFound
from werkzeug.routing import Map, Submount
from werkzeug.wrappers import Request, Response
import frappe
import frappe.client
from frappe import _
from frappe.utils.response import build_response
class ApiVersion(str, Enum):
V1 = "v1"
V2 = "v2"
def handle(request: Request):
"""
Entry point for `/api` methods.
APIs are versioned using second part of path.
v1 -> `/api/v1/*`
v2 -> `/api/v2/*`
Different versions have different specification but broadly following things are supported:
- `/api/method/{methodname}` will call a whitelisted method
- `/api/resource/{doctype}` will query a table
examples:
- `?fields=["name", "owner"]`
- `?filters=[["Task", "name", "like", "%005"]]`
- `?limit_start=0`
- `?limit_page_length=20`
- `/api/resource/{doctype}/{name}` will point to a resource
`GET` will return document
`POST` will insert
`PUT` will update
`DELETE` will delete
"""
try:
endpoint, arguments = API_URL_MAP.bind_to_environ(request.environ).match()
except NotFound: # Wrap 404 - backward compatiblity
raise frappe.DoesNotExistError
data = endpoint(**arguments)
if isinstance(data, Response):
return data
if data is not None:
frappe.response["data"] = data
return build_response("json")
# Merge all API version routing rules
from frappe.api.v1 import url_rules as v1_rules
from frappe.api.v2 import url_rules as v2_rules
API_URL_MAP = Map(
[
# V1 routes
Submount("/api", v1_rules),
Submount(f"/api/{ApiVersion.V1.value}", v1_rules),
Submount(f"/api/{ApiVersion.V2.value}", v2_rules),
],
strict_slashes=False, # Allows skipping trailing slashes
merge_slashes=False,
)
def get_api_version() -> ApiVersion | None:
if not frappe.request:
return
if frappe.request.path.startswith(f"/api/{ApiVersion.V2.value}"):
return ApiVersion.V2
return ApiVersion.V1

118
frappe/api/v1.py Normal file
View file

@ -0,0 +1,118 @@
import json
from werkzeug.routing import Rule
import frappe
from frappe import _
from frappe.utils.data import sbool
def document_list(doctype: str):
if frappe.form_dict.get("fields"):
frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"])
# set limit of records for frappe.get_list
frappe.form_dict.setdefault(
"limit_page_length",
frappe.form_dict.limit or frappe.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.form_dict.get(param)
if param_val is not None:
frappe.form_dict[param] = sbool(param_val)
# evaluate frappe.get_list
return frappe.call(frappe.client.get_list, doctype, **frappe.form_dict)
def handle_rpc_call(method: str):
import frappe.handler
method = method.split("/")[0] # for backward compatiblity
frappe.form_dict.cmd = method
return frappe.handler.handle()
def create_doc(doctype: str):
data = get_request_form_data()
data.pop("doctype", None)
return frappe.new_doc(doctype, **data).insert()
def update_doc(doctype: str, name: str):
data = get_request_form_data()
doc = frappe.get_doc(doctype, name, for_update=True)
if "flags" in data:
del data["flags"]
doc.update(data)
doc.save()
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
return doc
def delete_doc(doctype: str, name: str):
# TODO: child doc handling
frappe.delete_doc(doctype, name, ignore_missing=False)
frappe.response.http_status_code = 202
return "ok"
def read_doc(doctype: str, name: str):
# Backward compatiblity
if "run_method" in frappe.form_dict:
return execute_doc_method(doctype, name)
doc = frappe.get_doc(doctype, name)
if not doc.has_permission("read"):
raise frappe.PermissionError
doc.apply_fieldlevel_read_permissions()
return doc
def execute_doc_method(doctype: str, name: str, method: str | None = None):
method = method or frappe.form_dict.pop("run_method")
doc = frappe.get_doc(doctype, name)
doc.is_whitelisted(method)
if frappe.request.method == "GET":
if not doc.has_permission("read"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
return doc.run_method(method, **frappe.form_dict)
elif frappe.request.method == "POST":
if not doc.has_permission("write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
return doc.run_method(method, **frappe.form_dict)
def get_request_form_data():
if frappe.form_dict.data is None:
data = frappe.safe_decode(frappe.request.get_data())
else:
data = frappe.form_dict.data
try:
return frappe.parse_json(data)
except ValueError:
return frappe.form_dict
url_rules = [
Rule("/method/<path:method>", endpoint=handle_rpc_call),
Rule("/resource/<doctype>", methods=["GET"], endpoint=document_list),
Rule("/resource/<doctype>", methods=["POST"], endpoint=create_doc),
Rule("/resource/<doctype>/<path:name>/", methods=["GET"], endpoint=read_doc),
Rule("/resource/<doctype>/<path:name>/", methods=["PUT"], endpoint=update_doc),
Rule("/resource/<doctype>/<path:name>/", methods=["DELETE"], endpoint=delete_doc),
Rule("/resource/<doctype>/<path:name>/", methods=["POST"], endpoint=execute_doc_method),
]

193
frappe/api/v2.py Normal file
View file

@ -0,0 +1,193 @@
"""REST API v2
This file defines routes and implementation for REST API.
Note:
- All functions in this file should be treated as "whitelisted" as they are exposed via routes
- None of the functions present here should be called from python code, their location and
internal implementation can change without treating it as "breaking change".
"""
import json
from typing import Any
from werkzeug.routing import Rule
import frappe
import frappe.client
from frappe import _, get_newargs, is_whitelisted
from frappe.core.doctype.server_script.server_script_utils import get_server_script_map
from frappe.handler import is_valid_http_method, run_server_script, upload_file
PERMISSION_MAP = {
"GET": "read",
"POST": "write",
}
def handle_rpc_call(method: str, doctype: str | None = None):
from frappe.modules.utils import load_doctype_module
if doctype:
# Expand to run actual method from doctype controller
module = load_doctype_module(doctype)
method = module.__name__ + "." + method
for hook in reversed(frappe.get_hooks("override_whitelisted_methods", {}).get(method, [])):
# override using the last hook
method = hook
break
# via server script
server_script = get_server_script_map().get("_api", {}).get(method)
if server_script:
return run_server_script(server_script)
try:
method = frappe.get_attr(method)
except Exception as e:
frappe.throw(_("Failed to get method {0} with {1}").format(method, e))
is_whitelisted(method)
is_valid_http_method(method)
return frappe.call(method, **frappe.form_dict)
def login():
"""Login happens implicitly, this function doesn't do anything."""
pass
def logout():
frappe.local.login_manager.logout()
frappe.db.commit()
def read_doc(doctype: str, name: str):
doc = frappe.get_doc(doctype, name)
doc.check_permission("read")
doc.apply_fieldlevel_read_permissions()
return doc
def document_list(doctype: str):
if frappe.form_dict.get("fields"):
frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"])
# set limit of records for frappe.get_list
frappe.form_dict.limit_page_length = frappe.form_dict.limit or 20
# evaluate frappe.get_list
return frappe.call(frappe.client.get_list, doctype, **frappe.form_dict)
def count(doctype: str) -> int:
from frappe.desk.reportview import get_count
frappe.form_dict.doctype = doctype
return get_count()
def create_doc(doctype: str):
data = frappe.form_dict
data.pop("doctype", None)
return frappe.new_doc(doctype, **data).insert()
def update_doc(doctype: str, name: str):
data = frappe.form_dict
doc = frappe.get_doc(doctype, name, for_update=True)
data.pop("flags", None)
doc.update(data)
doc.save()
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
return doc
def delete_doc(doctype: str, name: str):
frappe.client.delete_doc(doctype, name)
frappe.response.http_status_code = 202
return "ok"
def execute_doc_method(doctype: str, name: str, method: str | None = None):
"""Get a document from DB and execute method on it.
Use cases:
- Submitting/cancelling document
- Triggering some kind of update on a document
"""
method = method or frappe.form_dict.pop("run_method")
doc = frappe.get_doc(doctype, name)
doc.is_whitelisted(method)
doc.check_permission(PERMISSION_MAP[frappe.request.method])
return doc.run_method(method, **frappe.form_dict)
def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None):
"""run a whitelisted controller method on in-memory document.
This is useful for building clients that don't necessarily encode all the business logic but
call server side function on object to validate and modify the doc.
The doc CAN exists in DB too and can write to DB as well if method is POST.
"""
if isinstance(document, str):
document = frappe.parse_json(document)
if kwargs is None:
kwargs = {}
doc = frappe.get_doc(document)
doc._original_modified = doc.modified
doc.check_if_latest()
doc.check_permission(PERMISSION_MAP[frappe.request.method])
method_obj = getattr(doc, method)
fn = getattr(method_obj, "__func__", method_obj)
is_whitelisted(fn)
is_valid_http_method(fn)
new_kwargs = get_newargs(fn, kwargs)
response = doc.run_method(method, **new_kwargs)
frappe.response.docs.append(doc) # send modified document and result both.
return response
url_rules = [
# RPC calls
Rule("/method/login", endpoint=login),
Rule("/method/logout", endpoint=logout),
Rule("/method/ping", endpoint=frappe.ping),
Rule("/method/upload_file", endpoint=upload_file),
Rule("/method/<method>", endpoint=handle_rpc_call),
Rule(
"/method/run_doc_method",
methods=["GET", "POST"],
endpoint=lambda: frappe.call(run_doc_method, **frappe.form_dict),
),
Rule("/method/<doctype>/<method>", endpoint=handle_rpc_call),
# Document level APIs
Rule("/document/<doctype>", methods=["GET"], endpoint=document_list),
Rule("/document/<doctype>", methods=["POST"], endpoint=create_doc),
Rule("/document/<doctype>/<path:name>/", methods=["GET"], endpoint=read_doc),
Rule("/document/<doctype>/<path:name>/", methods=["PATCH", "PUT"], endpoint=update_doc),
Rule("/document/<doctype>/<path:name>/", methods=["DELETE"], endpoint=delete_doc),
Rule(
"/document/<doctype>/<path:name>/method/<method>/",
methods=["GET", "POST"],
endpoint=execute_doc_method,
),
# Collection level APIs
Rule("/doctype/<doctype>/meta", methods=["GET"], endpoint=frappe.get_meta),
Rule("/doctype/<doctype>/count", methods=["GET"], endpoint=count),
]

View file

@ -1,16 +1,18 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import functools
import gc
import logging
import os
import re
from werkzeug.exceptions import HTTPException, NotFound
from werkzeug.local import LocalManager
from werkzeug.middleware.profiler import ProfilerMiddleware
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.middleware.shared_data import SharedDataMiddleware
from werkzeug.wrappers import Request, Response
from werkzeug.wsgi import ClosingIterator
import frappe
import frappe.api
@ -20,14 +22,14 @@ import frappe.rate_limiter
import frappe.recorder
import frappe.utils.response
from frappe import _
from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest
from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest, validate_auth
from frappe.middlewares import StaticDataMiddleware
from frappe.utils import cint, get_site_name, sanitize_html
from frappe.utils import CallbackManager, cint, get_site_name
from frappe.utils.data import escape_html
from frappe.utils.deprecations import deprecation_warning
from frappe.utils.error import log_error_snapshot
from frappe.website.serve import get_response
local_manager = LocalManager(frappe.local)
_site = None
_sites_path = os.environ.get("SITES_PATH", ".")
@ -61,7 +63,28 @@ if frappe._tune_gc:
# end: module pre-loading
@local_manager.middleware
def after_response_wrapper(app):
"""Wrap a WSGI application to call after_response hooks after we have responded.
This is done to reduce response time by deferring expensive tasks."""
@functools.wraps(app)
def application(environ, start_response):
return ClosingIterator(
app(environ, start_response),
(
frappe.rate_limiter.update,
frappe.monitor.stop,
frappe.recorder.dump,
frappe.request.after_response.run,
frappe.destroy,
),
)
return application
@after_response_wrapper
@Request.application
def application(request: Request):
response = None
@ -71,16 +94,20 @@ def application(request: Request):
init_request(request)
frappe.api.validate_auth()
validate_auth()
if request.method == "OPTIONS":
response = Response()
elif frappe.form_dict.cmd:
response = frappe.handler.handle()
deprecation_warning(
f"{frappe.form_dict.cmd}: Sending `cmd` for RPC calls is deprecated, call REST API instead `/api/method/cmd`"
)
frappe.handler.handle()
response = frappe.utils.response.build_response("json")
elif request.path.startswith("/api/"):
response = frappe.api.handle()
response = frappe.api.handle(request)
elif request.path.startswith("/backups"):
response = frappe.utils.response.download_backup(request.path)
@ -108,19 +135,17 @@ def application(request: Request):
# this function *must* always return a response, hence any exception thrown outside of
# try..catch block like this finally block needs to be handled appropriately.
if request.method in UNSAFE_HTTP_METHODS and frappe.db and rollback:
if rollback and request.method in UNSAFE_HTTP_METHODS and frappe.db:
frappe.db.rollback()
try:
run_after_request_hooks(request, response)
except Exception as e:
except Exception:
# We can not handle exceptions safely here.
frappe.logger().error("Failed to run after request hook", exc_info=True)
log_request(request, response)
process_response(response)
if frappe.db:
frappe.db.close()
return response
@ -135,6 +160,8 @@ def run_after_request_hooks(request, response):
def init_request(request):
frappe.local.request = request
frappe.local.request.after_response = CallbackManager()
frappe.local.is_ajax = frappe.get_request_header("X-Requested-With") == "XMLHttpRequest"
site = _site or request.headers.get("X-Frappe-Site-Name") or get_site_name(request.host)
@ -152,9 +179,12 @@ def init_request(request):
raise frappe.SessionStopped("Session Stopped")
else:
frappe.connect(set_admin_as_user=False)
if request.path.startswith("/api/method/upload_file"):
from frappe.core.api.file import get_max_file_size
request.max_content_length = cint(frappe.local.conf.get("max_file_size")) or 10 * 1024 * 1024
request.max_content_length = get_max_file_size()
else:
request.max_content_length = cint(frappe.local.conf.get("max_file_size")) or 25 * 1024 * 1024
make_form_dict(request)
if request.method != "OPTIONS":
@ -242,9 +272,7 @@ def set_cors_headers(response):
# only required for preflight requests
if request.method == "OPTIONS":
cors_headers["Access-Control-Allow-Methods"] = request.headers.get(
"Access-Control-Request-Method"
)
cors_headers["Access-Control-Allow-Methods"] = request.headers.get("Access-Control-Request-Method")
if allowed_headers := request.headers.get("Access-Control-Request-Headers"):
cors_headers["Access-Control-Allow-Headers"] = allowed_headers
@ -256,25 +284,25 @@ def set_cors_headers(response):
response.headers.extend(cors_headers)
def make_form_dict(request):
def make_form_dict(request: Request):
import json
request_data = request.get_data(as_text=True)
if "application/json" in (request.content_type or "") and request_data:
if request_data and request.is_json:
args = json.loads(request_data)
else:
args = {}
args.update(request.args or {})
args.update(request.form or {})
if not isinstance(args, dict):
frappe.throw(_("Invalid request arguments"))
frappe.local.form_dict = frappe._dict(args)
if "_" in frappe.local.form_dict:
if isinstance(args, dict):
frappe.local.form_dict = frappe._dict(args)
# _ is passed by $.ajax so that the request is not cached by the browser. So, remove _ from form_dict
frappe.local.form_dict.pop("_")
frappe.local.form_dict.pop("_", None)
elif isinstance(args, list):
frappe.local.form_dict["data"] = args
else:
frappe.throw(_("Invalid request arguments"))
def handle_exception(e):
@ -342,7 +370,7 @@ def handle_exception(e):
response = frappe.rate_limiter.respond()
else:
traceback = "<pre>" + sanitize_html(frappe.get_traceback()) + "</pre>"
traceback = "<pre>" + escape_html(frappe.get_traceback()) + "</pre>"
# disable traceback in production if flag is set
if frappe.local.flags.disable_traceback or not allow_traceback and not frappe.local.dev_server:
traceback = ""
@ -371,11 +399,7 @@ def handle_exception(e):
def sync_database(rollback: bool) -> bool:
# if HTTP method would change server state, commit if necessary
if (
frappe.db
and (frappe.local.flags.commit or frappe.local.request.method in UNSAFE_HTTP_METHODS)
and frappe.db.transaction_writes
):
if frappe.db and (frappe.local.flags.commit or frappe.local.request.method in UNSAFE_HTTP_METHODS):
frappe.db.commit()
rollback = False
elif frappe.db:
@ -391,8 +415,58 @@ def sync_database(rollback: bool) -> bool:
return rollback
# Always initialize sentry SDK if the DSN is sent
if sentry_dsn := os.getenv("FRAPPE_SENTRY_DSN"):
import sentry_sdk
from sentry_sdk.integrations.argv import ArgvIntegration
from sentry_sdk.integrations.atexit import AtexitIntegration
from sentry_sdk.integrations.dedupe import DedupeIntegration
from sentry_sdk.integrations.excepthook import ExcepthookIntegration
from sentry_sdk.integrations.modules import ModulesIntegration
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
from frappe.utils.sentry import FrappeIntegration, before_send
integrations = [
AtexitIntegration(),
ExcepthookIntegration(),
DedupeIntegration(),
ModulesIntegration(),
ArgvIntegration(),
]
experiments = {}
kwargs = {}
if os.getenv("ENABLE_SENTRY_DB_MONITORING"):
integrations.append(FrappeIntegration())
experiments["record_sql_params"] = True
if tracing_sample_rate := os.getenv("SENTRY_TRACING_SAMPLE_RATE"):
kwargs["traces_sample_rate"] = float(tracing_sample_rate)
application = SentryWsgiMiddleware(application)
sentry_sdk.init(
dsn=sentry_dsn,
before_send=before_send,
attach_stacktrace=True,
release=frappe.__version__,
auto_enabling_integrations=False,
default_integrations=False,
integrations=integrations,
_experiments=experiments,
**kwargs,
)
def serve(
port=8000, profile=False, no_reload=False, no_threading=False, site=None, sites_path="."
port=8000,
profile=False,
no_reload=False,
no_threading=False,
site=None,
sites_path=".",
proxy=False,
):
global application, _site, _sites_path
_site = site
@ -406,8 +480,11 @@ def serve(
if not os.environ.get("NO_STATICS"):
application = application_with_statics()
if proxy or os.environ.get("USE_PROXY"):
application = ProxyFix(application, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1)
application.debug = True
application.config = {"SERVER_NAME": "localhost:8000"}
application.config = {"SERVER_NAME": "127.0.0.1:8000"}
log = logging.getLogger("werkzeug")
log.propagate = False
@ -431,9 +508,7 @@ def serve(
def application_with_statics():
global application, _sites_path
application = SharedDataMiddleware(
application, {"/assets": str(os.path.join(_sites_path, "assets"))}
)
application = SharedDataMiddleware(application, {"/assets": str(os.path.join(_sites_path, "assets"))})
application = StaticDataMiddleware(application, {"/files": str(os.path.abspath(_sites_path))})

View file

@ -1,6 +1,10 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See LICENSE
from urllib.parse import quote
import base64
import binascii
from urllib.parse import quote, urlencode, urlparse
from werkzeug.wrappers import Response
import frappe
import frappe.database
@ -8,7 +12,7 @@ import frappe.utils
import frappe.utils.user
from frappe import _
from frappe.core.doctype.activity_log.activity_log import add_authentication_log
from frappe.sessions import Session, clear_sessions, delete_session
from frappe.sessions import Session, clear_sessions, delete_session, get_expiry_in_seconds
from frappe.translate import get_language
from frappe.twofactor import (
authenticate_for_2factor,
@ -17,11 +21,13 @@ from frappe.twofactor import (
should_run_2fa,
)
from frappe.utils import cint, date_diff, datetime, get_datetime, today
from frappe.utils.password import check_password
from frappe.utils.deprecations import deprecation_warning
from frappe.utils.password import check_password, get_decrypted_password
from frappe.website.utils import get_home_page
SAFE_HTTP_METHODS = frozenset(("GET", "HEAD", "OPTIONS"))
UNSAFE_HTTP_METHODS = frozenset(("POST", "PUT", "DELETE", "PATCH"))
MAX_PASSWORD_SIZE = 512
class HTTPRequest:
@ -55,9 +61,7 @@ class HTTPRequest:
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(",", 1)[0]
).strip()
frappe.local.request_ip = (frappe.get_request_header("X-Forwarded-For").split(",", 1)[0]).strip()
elif frappe.get_request_header("REMOTE_ADDR"):
frappe.local.request_ip = frappe.get_request_header("REMOTE_ADDR")
@ -93,7 +97,6 @@ class HTTPRequest:
class LoginManager:
__slots__ = ("user", "info", "full_name", "user_type", "resume")
def __init__(self):
@ -102,9 +105,7 @@ class LoginManager:
self.full_name = None
self.user_type = None
if (
frappe.local.form_dict.get("cmd") == "login" or frappe.local.request.path == "/api/method/login"
):
if frappe.local.form_dict.get("cmd") == "login" or frappe.local.request.path == "/api/method/login":
if self.login() is False:
return
self.resume = False
@ -133,9 +134,7 @@ class LoginManager:
self.authenticate(user=user, pwd=pwd)
if self.force_user_to_reset_password():
doc = frappe.get_doc("User", self.user)
frappe.local.response["redirect_to"] = doc.reset_password(
send_email=False, password_expired=True
)
frappe.local.response["redirect_to"] = doc.reset_password(send_email=False, password_expired=True)
frappe.local.response["message"] = "Password Reset"
return False
@ -224,7 +223,7 @@ class LoginManager:
clear_sessions(frappe.session.user, keep_current=True)
def authenticate(self, user: str = None, pwd: str = None):
def authenticate(self, user: str | None = None, pwd: str | None = None):
from frappe.core.doctype.user.user import User
if not (user and pwd):
@ -232,26 +231,34 @@ class LoginManager:
if not (user and pwd):
self.fail(_("Incomplete login details"), user=user)
if len(pwd) > MAX_PASSWORD_SIZE:
self.fail(_("Password size exceeded the maximum allowed size"), user=user)
_raw_user_name = user
user = User.find_by_credentials(user, pwd)
ip_tracker = get_login_attempt_tracker(frappe.local.request_ip)
if not user:
ip_tracker and ip_tracker.add_failure_attempt()
self.fail("Invalid login credentials", user=_raw_user_name)
# 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)
user_tracker = None if ignore_tracker else get_login_attempt_tracker(user.name)
if not user.is_authenticated:
tracker and tracker.add_failure_attempt()
user_tracker and user_tracker.add_failure_attempt()
ip_tracker and ip_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()
user_tracker and user_tracker.add_failure_attempt()
ip_tracker and ip_tracker.add_failure_attempt()
self.fail("User disabled or missing", user=user.name)
else:
tracker and tracker.add_success_attempt()
user_tracker and user_tracker.add_success_attempt()
ip_tracker and ip_tracker.add_success_attempt()
self.user = user.name
def force_user_to_reset_password(self):
@ -261,9 +268,7 @@ class LoginManager:
if self.user in frappe.STANDARD_USERS:
return False
reset_pwd_after_days = cint(
frappe.db.get_single_value("System Settings", "force_user_to_reset_password")
)
reset_pwd_after_days = cint(frappe.get_system_settings("force_user_to_reset_password"))
if reset_pwd_after_days:
last_password_reset_date = (
@ -278,7 +283,7 @@ class LoginManager:
def check_password(self, user, pwd):
"""check password"""
try:
# returns user in correct case
# return user in correct case
return check_password(user, pwd)
except frappe.AuthenticationError:
self.fail("Incorrect password", user=user)
@ -297,8 +302,8 @@ class LoginManager:
def validate_hour(self):
"""check if user is logging in during restricted hours"""
login_before = int(frappe.db.get_value("User", self.user, "login_before", ignore=True) or 0)
login_after = int(frappe.db.get_value("User", self.user, "login_after", ignore=True) or 0)
login_before = cint(frappe.db.get_value("User", self.user, "login_before", ignore=True))
login_after = cint(frappe.db.get_value("User", self.user, "login_after", ignore=True))
if not (login_before or login_after):
return
@ -321,6 +326,12 @@ class LoginManager:
self.user = user
self.post_login()
def impersonate(self, user):
current_user = frappe.session.user
self.login_as(user)
# Flag this session as impersonated session, so other code can log this.
frappe.local.session_obj.set_impersonsated(current_user)
def logout(self, arg="", user=None):
if not user:
user = frappe.session.user
@ -345,12 +356,19 @@ class CookieManager:
if not frappe.local.session.get("sid"):
return
# sid expires in 3 days
expires = datetime.datetime.now() + datetime.timedelta(days=3)
if frappe.session.sid:
self.set_cookie("sid", frappe.session.sid, expires=expires, httponly=True)
self.set_cookie("sid", frappe.session.sid, max_age=get_expiry_in_seconds(), httponly=True)
def set_cookie(self, key, value, expires=None, secure=False, httponly=False, samesite="Lax"):
def set_cookie(
self,
key,
value,
expires=None,
secure=False,
httponly=False,
samesite="Lax",
max_age=None,
):
if not secure and hasattr(frappe.local, "request"):
secure = frappe.local.request.scheme == "https"
@ -360,15 +378,16 @@ class CookieManager:
"secure": secure,
"httponly": httponly,
"samesite": samesite,
"max_age": max_age,
}
def delete_cookie(self, to_delete):
if not isinstance(to_delete, (list, tuple)):
if not isinstance(to_delete, list | tuple):
to_delete = [to_delete]
self.to_delete.extend(to_delete)
def flush_cookies(self, response):
def flush_cookies(self, response: Response):
for key, opts in self.cookies.items():
response.set_cookie(
key,
@ -377,6 +396,7 @@ class CookieManager:
secure=opts.get("secure"),
httponly=opts.get("httponly"),
samesite=opts.get("samesite"),
max_age=opts.get("max_age"),
)
# expires yesterday!
@ -393,9 +413,7 @@ def get_logged_user():
def clear_cookies():
if hasattr(frappe.local, "session"):
frappe.session.sid = ""
frappe.local.cookie_manager.delete_cookie(
["full_name", "user_id", "sid", "user_image", "system_user"]
)
frappe.local.cookie_manager.delete_cookie(["full_name", "user_id", "sid", "user_image", "system_user"])
def validate_ip_address(user):
@ -433,7 +451,7 @@ def validate_ip_address(user):
frappe.throw(_("Access not allowed from this IP Address"), frappe.AuthenticationError)
def get_login_attempt_tracker(user_name: str, raise_locked_exception: bool = True):
def get_login_attempt_tracker(key: str, raise_locked_exception: bool = True):
"""Get login attempt tracker instance.
:param user_name: Name of the loggedin user
@ -447,7 +465,7 @@ def get_login_attempt_tracker(user_name: str, raise_locked_exception: bool = Tru
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)
tracker = LoginAttemptTracker(key, **tracker_kwargs)
if raise_locked_exception and track_login_attempts and not tracker.is_user_allowed():
frappe.throw(
@ -466,7 +484,12 @@ class LoginAttemptTracker:
"""
def __init__(
self, user_name: str, max_consecutive_login_attempts: int = 3, lock_interval: int = 5 * 60
self,
key: str,
max_consecutive_login_attempts: int = 3,
lock_interval: int = 5 * 60,
*,
user_name: str | None = None,
):
"""Initialize the tracker.
@ -474,21 +497,23 @@ class LoginAttemptTracker:
: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
if user_name:
deprecation_warning("`username` parameter is deprecated, use `key` instead.")
self.key = key or 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)
return frappe.cache.hget("login_failed_count", self.key)
@login_failed_count.setter
def login_failed_count(self, count):
frappe.cache.hset("login_failed_count", self.user_name, count)
frappe.cache.hset("login_failed_count", self.key, count)
@login_failed_count.deleter
def login_failed_count(self):
frappe.cache.hdel("login_failed_count", self.user_name)
frappe.cache.hdel("login_failed_count", self.key)
@property
def login_failed_time(self):
@ -496,15 +521,15 @@ class LoginAttemptTracker:
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)
return frappe.cache.hget("login_failed_time", self.key)
@login_failed_time.setter
def login_failed_time(self, timestamp):
frappe.cache.hset("login_failed_time", self.user_name, timestamp)
frappe.cache.hset("login_failed_time", self.key, timestamp)
@login_failed_time.deleter
def login_failed_time(self):
frappe.cache.hdel("login_failed_time", self.user_name)
frappe.cache.hdel("login_failed_time", self.key)
def add_failure_attempt(self):
"""Log user failure attempts into the system.
@ -547,3 +572,112 @@ class LoginAttemptTracker:
):
return False
return True
def validate_auth():
"""
Authenticate and sets user for the request.
"""
authorization_header = frappe.get_request_header("Authorization", "").split(" ")
if len(authorization_header) == 2:
validate_oauth(authorization_header)
validate_auth_via_api_keys(authorization_header)
validate_auth_via_hooks()
# If login via bearer, basic or keypair didn't work then authentication failed and we
# should terminate here.
if len(authorization_header) == 2 and frappe.session.user in ("", "Guest"):
raise frappe.AuthenticationError
def validate_oauth(authorization_header):
"""
Authenticate request using OAuth and set session user
Args:
authorization_header (list of str): The 'Authorization' header containing the prefix and token
"""
from frappe.integrations.oauth2 import get_oauth_server
from frappe.oauth import get_url_delimiter
if authorization_header[0].lower() != "bearer":
return
form_dict = frappe.local.form_dict
token = authorization_header[1]
req = frappe.request
parsed_url = urlparse(req.url)
access_token = {"access_token": token}
uri = parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + "?" + urlencode(access_token)
http_method = req.method
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:
pass
def validate_auth_via_api_keys(authorization_header):
"""
Authenticate request using API keys and set session user
Args:
authorization_header (list of str): The 'Authorization' header containing the prefix and token
"""
try:
auth_type, auth_token = authorization_header
authorization_source = frappe.get_request_header("Frappe-Authorization-Source")
if auth_type.lower() == "basic":
api_key, api_secret = frappe.safe_decode(base64.b64decode(auth_token)).split(":")
validate_api_key_secret(api_key, api_secret, authorization_source)
elif auth_type.lower() == "token":
api_key, api_secret = auth_token.split(":")
validate_api_key_secret(api_key, api_secret, authorization_source)
except binascii.Error:
frappe.throw(
_("Failed to decode token, please provide a valid base64-encoded token."),
frappe.InvalidAuthorizationToken,
)
except (AttributeError, TypeError, ValueError):
pass
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
"""frappe_authorization_source to provide api key and secret for a doctype apart from User"""
doctype = frappe_authorization_source or "User"
doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"])
if not doc:
raise frappe.AuthenticationError
form_dict = frappe.local.form_dict
doc_secret = get_decrypted_password(doctype, doc, fieldname="api_secret")
if api_secret == doc_secret:
if doctype == "User":
user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"])
else:
user = frappe.db.get_value(doctype, doc, "user")
if frappe.local.login_manager.user in ("", "Guest"):
frappe.set_user(user)
frappe.local.form_dict = form_dict
else:
raise frappe.AuthenticationError
def validate_auth_via_hooks():
for auth_hook in frappe.get_hooks("auth_hooks", []):
frappe.get_attr(auth_hook)()

View file

@ -154,10 +154,11 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-07-16 22:51:35.505575",
"modified": "2024-03-23 16:01:27.590910",
"modified_by": "Administrator",
"module": "Automation",
"name": "Assignment Rule",
"naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
@ -173,7 +174,8 @@
"write": 1
}
],
"sort_field": "modified",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -9,6 +9,7 @@ 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
from frappe.utils.data import comma_and
class AssignmentRule(Document):
@ -30,14 +31,15 @@ class AssignmentRule(Document):
description: DF.SmallText
disabled: DF.Check
document_type: DF.Link
due_date_based_on: DF.Literal
field: DF.Literal
due_date_based_on: DF.Literal[None]
field: DF.Literal[None]
last_user: DF.Link | None
priority: DF.Int
rule: DF.Literal["Round Robin", "Load Balancing", "Based on Field"]
unassign_condition: DF.Code | None
users: DF.TableMultiSelect[AssignmentRuleUser]
# end: auto-generated types
def validate(self):
self.validate_document_types()
self.validate_assignment_days()
@ -49,20 +51,14 @@ class AssignmentRule(Document):
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"))
)
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 len(set(assignment_days)) != len(assignment_days):
repeated_days = get_repeated(assignment_days)
plural = "s" if len(repeated_days) > 1 else ""
frappe.throw(
_("Assignment Day{0} {1} has been repeated.").format(
plural, frappe.bold(", ".join(repeated_days))
_("The following Assignment Days have been repeated: {0}").format(
comma_and([_(day) for day in get_repeated(assignment_days)], add_quotes=False)
)
)
@ -78,7 +74,7 @@ class AssignmentRule(Document):
def do_assignment(self, doc):
# clear existing assignment, to reassign
assign_to.clear(doc.get("doctype"), doc.get("name"))
assign_to.clear(doc.get("doctype"), doc.get("name"), ignore_permissions=True)
user = self.get_user(doc)
@ -92,7 +88,8 @@ class AssignmentRule(Document):
assignment_rule=self.name,
notify=True,
date=doc.get(self.due_date_based_on) if self.due_date_based_on else None,
)
),
ignore_permissions=True,
)
# set for reference in round robin
@ -104,12 +101,14 @@ class AssignmentRule(Document):
def clear_assignment(self, doc):
"""Clear assignments"""
if self.safe_eval("unassign_condition", doc):
return assign_to.clear(doc.get("doctype"), doc.get("name"))
return assign_to.clear(doc.get("doctype"), doc.get("name"), ignore_permissions=True)
def close_assignments(self, doc):
"""Close assignments"""
if self.safe_eval("close_condition", doc):
return assign_to.close_all_assignments(doc.get("doctype"), doc.get("name"))
return assign_to.close_all_assignments(
doc.get("doctype"), doc.get("name"), ignore_permissions=True
)
def get_user(self, doc):
"""
@ -357,9 +356,7 @@ def update_due_date(doc, state=None):
rule_doc = frappe.get_cached_doc("Assignment Rule", rule.get("name"))
due_date_field = rule_doc.due_date_based_on
field_updated = (
doc.meta.has_field(due_date_field)
and doc.has_value_changed(due_date_field)
and rule.get("name")
doc.meta.has_field(due_date_field) and doc.has_value_changed(due_date_field) and rule.get("name")
)
if field_updated:

View file

@ -104,7 +104,7 @@ class TestAutoAssign(FrappeTestCase):
frappe.db.delete("ToDo", {"name": d.name})
# add 5 more assignments
for i in range(5):
for _ in range(5):
_make_test_record(public=1)
# check if each user still has 10 assignments
@ -113,6 +113,20 @@ class TestAutoAssign(FrappeTestCase):
len(frappe.get_all("ToDo", dict(allocated_to=user, reference_type=TEST_DOCTYPE))), 10
)
def test_assingment_on_guest_submissions(self):
"""Sometimes documents are inserted as guest, check if assignment rules run on them. Use case: Web Forms"""
with self.set_user("Guest"):
doc = _make_test_record(ignore_permissions=True, public=1)
# check assignment to *anyone*
self.assertTrue(
frappe.db.get_value(
"ToDo",
{"reference_type": TEST_DOCTYPE, "reference_name": doc.name, "status": "Open"},
"allocated_to",
),
)
def test_based_on_field(self):
self.assignment_rule.rule = "Based on Field"
self.assignment_rule.field = "owner"
@ -124,7 +138,9 @@ class TestAutoAssign(FrappeTestCase):
# check if auto assigned to doc owner, test1@example.com
self.assertEqual(
frappe.db.get_value(
"ToDo", dict(reference_type=TEST_DOCTYPE, reference_name=note.name, status="Open"), "owner"
"ToDo",
dict(reference_type=TEST_DOCTYPE, reference_name=note.name, status="Open"),
"owner",
),
test_user,
)
@ -233,17 +249,15 @@ class TestAutoAssign(FrappeTestCase):
frappe.db.delete("Assignment Rule")
assignment_rule = frappe.get_doc(
dict(
name="Assignment with Due Date",
doctype="Assignment Rule",
document_type=TEST_DOCTYPE,
assign_condition="public == 0",
due_date_based_on="expiry_date",
assignment_days=self.days,
users=[
dict(user="test@example.com"),
],
)
name="Assignment with Due Date",
doctype="Assignment Rule",
document_type=TEST_DOCTYPE,
assign_condition="public == 0",
due_date_based_on="expiry_date",
assignment_days=self.days,
users=[
dict(user="test@example.com"),
],
).insert()
expiry_date = frappe.utils.add_days(frappe.utils.nowdate(), 2)
@ -335,51 +349,51 @@ def get_assignment_rule(days, assign=None):
assign = ["public == 1", "notify_on_login == 1"]
assignment_rule = frappe.get_doc(
dict(
name=f"For {TEST_DOCTYPE} 1",
doctype="Assignment Rule",
priority=0,
document_type=TEST_DOCTYPE,
assign_condition=assign[0],
unassign_condition="public == 0 or notify_on_login == 1",
close_condition='"Closed" in content',
rule="Round Robin",
assignment_days=days[0],
users=[
dict(user="test@example.com"),
dict(user="test1@example.com"),
dict(user="test2@example.com"),
],
)
name=f"For {TEST_DOCTYPE} 1",
doctype="Assignment Rule",
priority=0,
document_type=TEST_DOCTYPE,
assign_condition=assign[0],
unassign_condition="public == 0 or notify_on_login == 1",
close_condition='"Closed" in content',
rule="Round Robin",
assignment_days=days[0],
users=[
dict(user="test@example.com"),
dict(user="test1@example.com"),
dict(user="test2@example.com"),
],
).insert()
frappe.delete_doc_if_exists("Assignment Rule", f"For {TEST_DOCTYPE} 2")
# 2nd rule
frappe.get_doc(
dict(
name=f"For {TEST_DOCTYPE} 2",
doctype="Assignment Rule",
priority=1,
document_type=TEST_DOCTYPE,
assign_condition=assign[1],
unassign_condition="notify_on_login == 0",
rule="Round Robin",
assignment_days=days[1],
users=[dict(user="test3@example.com")],
)
name=f"For {TEST_DOCTYPE} 2",
doctype="Assignment Rule",
priority=1,
document_type=TEST_DOCTYPE,
assign_condition=assign[1],
unassign_condition="notify_on_login == 0",
rule="Round Robin",
assignment_days=days[1],
users=[dict(user="test3@example.com")],
).insert()
return assignment_rule
def _make_test_record(**kwargs):
def _make_test_record(
*,
ignore_permissions=False,
**kwargs,
):
doc = frappe.new_doc(TEST_DOCTYPE)
if kwargs:
doc.update(kwargs)
return doc.insert()
return doc.insert(ignore_permissions=ignore_permissions)
def create_test_doctype(doctype: str):

View file

@ -1,4 +1,5 @@
{
"actions": [],
"creation": "2019-09-21 16:52:01.705351",
"doctype": "DocType",
"editable_grid": 1,
@ -16,13 +17,15 @@
}
],
"istable": 1,
"modified": "2019-09-21 16:55:09.376291",
"links": [],
"modified": "2024-03-23 16:01:27.759155",
"modified_by": "Administrator",
"module": "Automation",
"name": "Assignment Rule Day",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -19,4 +19,5 @@ class AssignmentRuleDay(Document):
parentfield: DF.Data
parenttype: DF.Data
# end: auto-generated types
pass

View file

@ -1,6 +1,5 @@
{
"actions": [],
"allow_read": 1,
"creation": "2019-02-27 11:41:46.602400",
"doctype": "DocType",
"editable_grid": 1,
@ -21,14 +20,15 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-09-29 20:12:14.456785",
"modified": "2024-03-23 16:01:27.847608",
"modified_by": "Administrator",
"module": "Automation",
"name": "Assignment Rule User",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -19,4 +19,5 @@ class AssignmentRuleUser(Document):
parenttype: DF.Data
user: DF.Link
# end: auto-generated types
pass

View file

@ -211,7 +211,7 @@
}
],
"links": [],
"modified": "2021-01-12 09:24:49.719611",
"modified": "2024-03-23 16:01:28.502039",
"modified_by": "Administrator",
"module": "Automation",
"name": "Auto Repeat",
@ -255,8 +255,9 @@
}
],
"search_fields": "reference_document",
"sort_field": "modified",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "reference_document",
"track_changes": 1
}

View file

@ -70,6 +70,7 @@ class AutoRepeat(Document):
submit_on_creation: DF.Check
template: DF.Link | None
# end: auto-generated types
def validate(self):
self.update_status()
self.validate_reference_doctype()
@ -150,7 +151,7 @@ class AutoRepeat(Document):
def validate_auto_repeat_days(self):
auto_repeat_days = self.get_auto_repeat_days()
if not len(set(auto_repeat_days)) == len(auto_repeat_days):
if len(set(auto_repeat_days)) != len(auto_repeat_days):
repeated_days = get_repeated(auto_repeat_days)
plural = "s" if len(repeated_days) > 1 else ""
@ -297,11 +298,11 @@ class AutoRepeat(Document):
def get_next_schedule_date(self, schedule_date, for_full_schedule=False):
"""
Returns the next schedule date for auto repeat after a recurring document has been created.
Adds required offset to the schedule_date param and returns the next schedule date.
Return the next schedule date for auto repeat after a recurring document has been created.
Add required offset to the schedule_date param and return the next schedule date.
:param schedule_date: The date when the last recurring document was created.
:param for_full_schedule: If True, returns the immediate next schedule date, else the full schedule.
:param for_full_schedule: If True, return the immediate next schedule date, else the full schedule.
"""
if month_map.get(self.frequency):
month_count = month_map.get(self.frequency) + month_diff(schedule_date, self.start_date) - 1
@ -550,7 +551,7 @@ def get_auto_repeat_doctypes(doctype, txt, searchfield, start, page_len, filters
docs += [r.name for r in res]
docs = set(list(docs))
return [[d] for d in docs]
return [[d] for d in docs if txt in d]
@frappe.whitelist()

View file

@ -41,7 +41,7 @@ class TestAutoRepeat(FrappeTestCase):
def test_daily_auto_repeat(self):
todo = frappe.get_doc(
dict(doctype="ToDo", description="test recurring todo", assigned_by="Administrator")
doctype="ToDo", description="test recurring todo", assigned_by="Administrator"
).insert()
doc = make_auto_repeat(reference_document=todo.name)
@ -53,9 +53,7 @@ class TestAutoRepeat(FrappeTestCase):
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
self.assertEqual(todo.auto_repeat, doc.name)
new_todo = frappe.db.get_value(
"ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name"
)
new_todo = frappe.db.get_value("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
new_todo = frappe.get_doc("ToDo", new_todo)
@ -63,7 +61,7 @@ class TestAutoRepeat(FrappeTestCase):
def test_weekly_auto_repeat(self):
todo = frappe.get_doc(
dict(doctype="ToDo", description="test weekly todo", assigned_by="Administrator")
doctype="ToDo", description="test weekly todo", assigned_by="Administrator"
).insert()
doc = make_auto_repeat(
@ -81,9 +79,7 @@ class TestAutoRepeat(FrappeTestCase):
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
self.assertEqual(todo.auto_repeat, doc.name)
new_todo = frappe.db.get_value(
"ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name"
)
new_todo = frappe.db.get_value("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
new_todo = frappe.get_doc("ToDo", new_todo)
@ -91,7 +87,7 @@ class TestAutoRepeat(FrappeTestCase):
def test_weekly_auto_repeat_with_weekdays(self):
todo = frappe.get_doc(
dict(doctype="ToDo", description="test auto repeat with weekdays", assigned_by="Administrator")
doctype="ToDo", description="test auto repeat with weekdays", assigned_by="Administrator"
).insert()
weekdays = list(week_map.keys())
@ -121,15 +117,13 @@ class TestAutoRepeat(FrappeTestCase):
end_date = add_months(start_date, 12)
todo = frappe.get_doc(
dict(doctype="ToDo", description="test recurring todo", assigned_by="Administrator")
doctype="ToDo", description="test recurring todo", assigned_by="Administrator"
).insert()
self.monthly_auto_repeat("ToDo", todo.name, start_date, end_date)
# test without end_date
todo = frappe.get_doc(
dict(
doctype="ToDo", description="test recurring todo without end_date", assigned_by="Administrator"
)
doctype="ToDo", description="test recurring todo without end_date", assigned_by="Administrator"
).insert()
self.monthly_auto_repeat("ToDo", todo.name, start_date)
@ -165,11 +159,7 @@ class TestAutoRepeat(FrappeTestCase):
def test_email_notification(self):
todo = frappe.get_doc(
dict(
doctype="ToDo",
description="Test recurring notification attachment",
assigned_by="Administrator",
)
doctype="ToDo", description="Test recurring notification attachment", assigned_by="Administrator"
).insert()
doc = make_auto_repeat(
@ -183,21 +173,15 @@ class TestAutoRepeat(FrappeTestCase):
create_repeated_entries(data)
frappe.db.commit()
new_todo = frappe.db.get_value(
"ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name"
)
new_todo = frappe.db.get_value("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
email_queue = frappe.db.exists(
"Email Queue", dict(reference_doctype="ToDo", reference_name=new_todo)
)
email_queue = frappe.db.exists("Email Queue", dict(reference_doctype="ToDo", reference_name=new_todo))
self.assertTrue(email_queue)
def test_next_schedule_date(self):
current_date = getdate(today())
todo = frappe.get_doc(
dict(
doctype="ToDo", description="test next schedule date for monthly", assigned_by="Administrator"
)
doctype="ToDo", description="test next schedule date for monthly", assigned_by="Administrator"
).insert()
doc = make_auto_repeat(
frequency="Monthly", reference_document=todo.name, start_date=add_months(today(), -2)
@ -208,9 +192,7 @@ class TestAutoRepeat(FrappeTestCase):
self.assertTrue(doc.next_schedule_date >= current_date)
todo = frappe.get_doc(
dict(
doctype="ToDo", description="test next schedule date for daily", assigned_by="Administrator"
)
doctype="ToDo", description="test next schedule date for daily", assigned_by="Administrator"
).insert()
doc = make_auto_repeat(
frequency="Daily", reference_document=todo.name, start_date=add_days(today(), -2)
@ -222,7 +204,7 @@ class TestAutoRepeat(FrappeTestCase):
create_submittable_doctype(doctype)
current_date = getdate()
submittable_doc = frappe.get_doc(dict(doctype=doctype, test="test submit on creation")).insert()
submittable_doc = frappe.get_doc(doctype=doctype, test="test submit on creation").insert()
submittable_doc.submit()
doc = make_auto_repeat(
frequency="Daily",

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