Merge branch 'develop' into whats-new
This commit is contained in:
commit
8d9304abbe
1583 changed files with 843603 additions and 321944 deletions
23
.coveragerc
Normal file
23
.coveragerc
Normal 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
75
.flake8
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
5
.github/frappe-framework-logo-dark.svg
vendored
Normal file
5
.github/frappe-framework-logo-dark.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.9 KiB |
18
.github/helper/ci.py
vendored
18
.github/helper/ci.py
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
3
.github/helper/db/mariadb.json
vendored
3
.github/helper/db/mariadb.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
3
.github/helper/db/postgres.json
vendored
3
.github/helper/db/postgres.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
15
.github/helper/documentation.py
vendored
15
.github/helper/documentation.py
vendored
|
|
@ -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):
|
||||
|
|
|
|||
21
.github/helper/install.sh
vendored
21
.github/helper/install.sh
vendored
|
|
@ -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::"
|
||||
|
|
|
|||
2
.github/helper/install_dependencies.sh
vendored
2
.github/helper/install_dependencies.sh
vendored
|
|
@ -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::"
|
||||
|
|
|
|||
15
.github/helper/roulette.py
vendored
15
.github/helper/roulette.py
vendored
|
|
@ -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):
|
||||
|
|
|
|||
34
.github/helper/translation.py
vendored
34
.github/helper/translation.py
vendored
|
|
@ -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
37
.github/helper/update_pot_file.sh
vendored
Normal 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
10
.github/labeler.yml
vendored
|
|
@ -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+$']
|
||||
|
|
|
|||
4
.github/workflows/create-release.yml
vendored
4
.github/workflows/create-release.yml
vendored
|
|
@ -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
38
.github/workflows/generate-pot-file.yml
vendored
Normal 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 }}
|
||||
22
.github/workflows/initiate_release.yml
vendored
22
.github/workflows/initiate_release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
8
.github/workflows/labeller.yml
vendored
8
.github/workflows/labeller.yml
vendored
|
|
@ -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 }}"
|
||||
|
|
|
|||
17
.github/workflows/linters.yml
vendored
17
.github/workflows/linters.yml
vendored
|
|
@ -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 .
|
||||
|
|
|
|||
4
.github/workflows/lock.yml
vendored
4
.github/workflows/lock.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/on_release.yml
vendored
4
.github/workflows/on_release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
37
.github/workflows/patch-mariadb-tests.yml
vendored
37
.github/workflows/patch-mariadb-tests.yml
vendored
|
|
@ -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
26
.github/workflows/pre-commit.yml
vendored
Normal 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
|
||||
4
.github/workflows/publish-assets-develop.yml
vendored
4
.github/workflows/publish-assets-develop.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
60
.github/workflows/server-tests.yml
vendored
60
.github/workflows/server-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
45
.github/workflows/ui-tests.yml
vendored
45
.github/workflows/ui-tests.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
32
.mergify.yml
32
.mergify.yml
|
|
@ -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 }}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -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
9
babel_extractors.csv
Normal 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
|
||||
|
8
crowdin.yml
Normal file
8
crowdin.yml
Normal 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
|
||||
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export default {
|
|||
},
|
||||
],
|
||||
quick_entry: 1,
|
||||
sort_field: "modified",
|
||||
sort_field: "creation",
|
||||
sort_order: "ASC",
|
||||
track_changes: 1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export default {
|
|||
},
|
||||
],
|
||||
quick_entry: 1,
|
||||
sort_field: "modified",
|
||||
sort_field: "creation",
|
||||
sort_order: "ASC",
|
||||
track_changes: 1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export default {
|
|||
},
|
||||
],
|
||||
quick_entry: 1,
|
||||
sort_field: "modified",
|
||||
sort_field: "creation",
|
||||
sort_order: "ASC",
|
||||
track_changes: 1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export default {
|
|||
write: 1,
|
||||
},
|
||||
],
|
||||
sort_field: "modified",
|
||||
sort_field: "creation",
|
||||
sort_order: "ASC",
|
||||
track_changes: 1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export default {
|
|||
write: 1,
|
||||
},
|
||||
],
|
||||
sort_field: "modified",
|
||||
sort_field: "creation",
|
||||
sort_order: "ASC",
|
||||
track_changes: 1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export default {
|
|||
cancel: 1,
|
||||
},
|
||||
],
|
||||
sort_field: "modified",
|
||||
sort_field: "creation",
|
||||
sort_order: "ASC",
|
||||
track_changes: 1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export default {
|
|||
],
|
||||
quick_entry: 1,
|
||||
autoname: "format: Test-{####}",
|
||||
sort_field: "modified",
|
||||
sort_field: "creation",
|
||||
sort_order: "ASC",
|
||||
track_changes: 1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
context("Permissions API", () => {
|
||||
context.skip("Permissions API", () => {
|
||||
before(() => {
|
||||
cy.visit("/login");
|
||||
cy.remove_role("frappe@example.com", "System Manager");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }, () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,3 @@ Cypress.on("uncaught:exception", (err, runnable) => {
|
|||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
|
||||
Cypress.Cookies.defaults({
|
||||
preserve: "sid",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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("~")) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
306
frappe/api.py
306
frappe/api.py
|
|
@ -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
80
frappe/api/__init__.py
Normal 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
118
frappe/api/v1.py
Normal 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
193
frappe/api/v2.py
Normal 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),
|
||||
]
|
||||
151
frappe/app.py
151
frappe/app.py
|
|
@ -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))})
|
||||
|
||||
|
|
|
|||
220
frappe/auth.py
220
frappe/auth.py
|
|
@ -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)()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -19,4 +19,5 @@ class AssignmentRuleDay(Document):
|
|||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -19,4 +19,5 @@ class AssignmentRuleUser(Document):
|
|||
parenttype: DF.Data
|
||||
user: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue