diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index 246bdbe096..3ef7db34f6 100644
--- a/.github/helper/install.sh
+++ b/.github/helper/install.sh
@@ -17,7 +17,7 @@ if [ "$TYPE" == "server" ]; then
fi
if [ "$DB" == "mariadb" ];then
- sudo apt update && sudo apt install mariadb-client-10.3
+ sudo apt install mariadb-client-10.3
mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL character_set_server = 'utf8mb4'";
mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'";
diff --git a/.github/helper/install_dependencies.sh b/.github/helper/install_dependencies.sh
index 666af13882..6a837f268a 100644
--- a/.github/helper/install_dependencies.sh
+++ b/.github/helper/install_dependencies.sh
@@ -16,10 +16,4 @@ sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf
sudo chmod o+x /usr/local/bin/wkhtmltopdf
# install cups
-sudo apt-get install libcups2-dev
-
-# install redis
-sudo apt-get install redis-server
-
-# install redis
-sudo apt-get install libmariadb-dev
+sudo apt update && sudo apt install libcups2-dev libmariadb-dev redis-server
diff --git a/.github/helper/roulette.py b/.github/helper/roulette.py
index f68ef5046f..9165198012 100644
--- a/.github/helper/roulette.py
+++ b/.github/helper/roulette.py
@@ -5,8 +5,10 @@ import shlex
import subprocess
import sys
import urllib.request
+from functools import cache
+@cache
def fetch_pr_data(pr_number, repo, endpoint):
api_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}"
@@ -26,7 +28,16 @@ def get_output(command, shell=True):
return subprocess.check_output(command, shell=shell, encoding="utf8").strip()
def has_skip_ci_label(pr_number, repo="frappe/frappe"):
- return any([label["name"] for label in fetch_pr_data(pr_number, repo, "")["labels"] if label["name"] == "Skip CI"])
+ return has_label(pr_number, "Skip CI", repo)
+
+def has_run_server_tests_label(pr_number, repo="frappe/frappe"):
+ return has_label(pr_number, "Run Server Tests", repo)
+
+def has_run_ui_tests_label(pr_number, repo="frappe/frappe"):
+ return has_label(pr_number, "Run UI Tests", repo)
+
+def has_label(pr_number, label, repo="frappe/frappe"):
+ return any([label["name"] for label in fetch_pr_data(pr_number, repo, "")["labels"] if label["name"] == label])
def is_py(file):
return file.endswith("py")
@@ -77,11 +88,11 @@ if __name__ == "__main__":
print("Only docs were updated, stopping build process.")
sys.exit(0)
- elif only_frontend_code_changed and build_type == "server":
+ elif only_frontend_code_changed and build_type == "server" and not has_run_server_tests_label(pr_number, repo):
print("Only Frontend code was updated; Stopping Python build process.")
sys.exit(0)
- elif build_type == "ui" and only_py_changed:
+ elif build_type == "ui" and only_py_changed and not has_run_ui_tests_label(pr_number, repo):
print("Only Python code was updated, stopping Cypress build process.")
sys.exit(0)
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index dba13f9358..988c2dcc6c 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -2,8 +2,13 @@ name: 'Trigger Docker build on release'
on:
release:
types: [released]
+permissions:
+ contents: read
+
jobs:
curl:
+ permissions:
+ contents: none
name: 'Trigger Docker build on release'
runs-on: ubuntu-latest
container:
diff --git a/.github/workflows/docs-checker.yml b/.github/workflows/docs-checker.yml
index 5e91063698..a0f77b43fd 100644
--- a/.github/workflows/docs-checker.yml
+++ b/.github/workflows/docs-checker.yml
@@ -3,6 +3,9 @@ on:
pull_request:
types: [ opened, synchronize, reopened, edited ]
+permissions:
+ contents: read
+
jobs:
docs-required:
name: 'Documentation Required'
diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml
index c8294886a0..7dffc30dc0 100644
--- a/.github/workflows/patch-mariadb-tests.yml
+++ b/.github/workflows/patch-mariadb-tests.yml
@@ -7,6 +7,9 @@ concurrency:
group: patch-mariadb-develop-${{ github.event.number }}
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ubuntu-latest
@@ -56,7 +59,7 @@ jobs:
uses: actions/cache@v2
with:
path: ~/.cache/pip
- key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
@@ -121,7 +124,7 @@ jobs:
git fetch --depth 1 upstream $branch_name:$branch_name
git checkout -q -f $branch_name
- pip install -q -r requirements.txt
+ bench setup requirements --python
bench --site test_site migrate
done
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9c7ecf989e..e9936482b0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,7 +2,10 @@ name: Generate Semantic Release
on:
push:
branches:
- - version-13
+ - version-14-beta
+permissions:
+ contents: read
+
jobs:
release:
name: Release
diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml
index 4edf74ba71..33fc221e80 100644
--- a/.github/workflows/server-mariadb-tests.yml
+++ b/.github/workflows/server-mariadb-tests.yml
@@ -11,6 +11,9 @@ concurrency:
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ubuntu-latest
@@ -67,7 +70,7 @@ jobs:
uses: actions/cache@v2
with:
path: ~/.cache/pip
- key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml
index 895af5184e..2b4a2edae0 100644
--- a/.github/workflows/server-postgres-tests.yml
+++ b/.github/workflows/server-postgres-tests.yml
@@ -10,6 +10,9 @@ concurrency:
group: server-postgres-develop-${{ github.event.number }}
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ubuntu-latest
@@ -70,7 +73,7 @@ jobs:
uses: actions/cache@v2
with:
path: ~/.cache/pip
- key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
index fc8093444e..08bf3584f5 100644
--- a/.github/workflows/ui-tests.yml
+++ b/.github/workflows/ui-tests.yml
@@ -10,6 +10,9 @@ concurrency:
group: ui-develop-${{ github.event.number }}
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ubuntu-latest
@@ -66,7 +69,7 @@ jobs:
uses: actions/cache@v2
with:
path: ~/.cache/pip
- key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
diff --git a/.mergify.yml b/.mergify.yml
index f1333362a8..97df91a927 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -7,6 +7,7 @@ pull_request_rules:
- author!=gavindsouza
- author!=deepeshgarg007
- author!=ankush
+ - author!=mergify[bot]
- or:
- base=version-13
- base=version-12
diff --git a/.releaserc b/.releaserc
index 530a6c0767..c9ca71bbf5 100644
--- a/.releaserc
+++ b/.releaserc
@@ -1,11 +1,8 @@
{
- "branches": ["version-13"],
+ "branches": ["develop", {"name": "version-14-beta", "channel": "beta", "prerelease": true}],
"plugins": [
"@semantic-release/commit-analyzer", {
- "preset": "angular",
- "releaseRules": [
- {"breaking": true, "release": false}
- ]
+ "preset": "angular"
},
"@semantic-release/release-notes-generator",
[
diff --git a/CODEOWNERS b/CODEOWNERS
index 170334a4b4..59832e8636 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -12,7 +12,7 @@ data_import* @netchampfaris
core/ @surajshetty3416
database @gavindsouza
model @gavindsouza
-requirements.txt @gavindsouza
+pyproject.toml @gavindsouza
query_builder/ @gavindsouza
commands/ @gavindsouza
workspace @shariquerik
diff --git a/cypress/integration/awesome_bar.js b/cypress/integration/awesome_bar.js
index 053d015366..e62ba6bec5 100644
--- a/cypress/integration/awesome_bar.js
+++ b/cypress/integration/awesome_bar.js
@@ -26,7 +26,7 @@ context('Awesome Bar', () => {
cy.get('.title-text').should('contain', 'To Do');
- cy.findByPlaceholderText('Name')
+ cy.findByPlaceholderText('ID')
.should('have.value', '%test%');
});
diff --git a/cypress/integration/control_data.js b/cypress/integration/control_data.js
index 01f9168667..78cece627b 100644
--- a/cypress/integration/control_data.js
+++ b/cypress/integration/control_data.js
@@ -34,6 +34,12 @@ context('Data Control', () => {
});
});
});
+
+ it('check custom formatters', () => {
+ cy.visit(`/app/doctype/User`);
+ cy.get('[data-fieldname="fields"] .grid-row[data-idx="2"] [data-fieldname="fieldtype"] .static-area').should('have.text', '🔵 Section Break');
+ });
+
it('Verifying data control by inputting different patterns for "Name" field', () => {
cy.new_form('Test Data Control');
@@ -54,7 +60,7 @@ context('Data Control', () => {
//Checking if the border color of the field changes to red
cy.get('.frappe-control[data-fieldname="name1"]').should('have.class', 'has-error');
- cy.findByRole('button', {name: 'Save'}).click();
+ cy.save();
//Checking for the error message
cy.get('.modal-title').should('have.text', 'Message');
@@ -64,7 +70,7 @@ context('Data Control', () => {
cy.get_field('name1', 'Data').clear({force: true});
cy.fill_field('name1', 'Komal{}/!', 'Data');
cy.get('.frappe-control[data-fieldname="name1"]').should('have.class', 'has-error');
- cy.findByRole('button', {name: 'Save'}).click();
+ cy.save();
cy.get('.modal-title').should('have.text', 'Message');
cy.get('.msgprint').should('have.text', 'Komal{}/! is not a valid Name');
cy.hide_dialog();
@@ -76,14 +82,14 @@ context('Data Control', () => {
cy.get_field('email', 'Data').clear({force: true});
cy.fill_field('email', 'komal', 'Data');
cy.get('.frappe-control[data-fieldname="email"]').should('have.class', 'has-error');
- cy.findByRole('button', {name: 'Save'}).click();
+ cy.save();
cy.get('.modal-title').should('have.text', 'Message');
cy.get('.msgprint').should('have.text', 'komal is not a valid Email Address');
cy.hide_dialog();
cy.get_field('email', 'Data').clear({force: true});
cy.fill_field('email', 'komal@test', 'Data');
cy.get('.frappe-control[data-fieldname="email"]').should('have.class', 'has-error');
- cy.findByRole('button', {name: 'Save'}).click();
+ cy.save();
cy.get('.modal-title').should('have.text', 'Message');
cy.get('.msgprint').should('have.text', 'komal@test is not a valid Email Address');
cy.hide_dialog();
@@ -125,4 +131,4 @@ context('Data Control', () => {
cy.get('.actions-btn-group > .dropdown-menu [data-label="Delete"]').click();
cy.click_modal_primary_button('Yes');
});
-});
\ No newline at end of file
+});
diff --git a/cypress/integration/custom_buttons.js b/cypress/integration/custom_buttons.js
index e2f02668e9..6045d009c2 100644
--- a/cypress/integration/custom_buttons.js
+++ b/cypress/integration/custom_buttons.js
@@ -4,6 +4,7 @@ const test_button_names = [
"Porcupine Tree (the GOAT)",
"AC / DC",
`Electronic Dance "music"`,
+ "l'imperatrice",
];
const add_button = (label, group = "TestGroup") => {
diff --git a/cypress/integration/customize_form.js b/cypress/integration/customize_form.js
index 70615085c3..3857d7ccd8 100644
--- a/cypress/integration/customize_form.js
+++ b/cypress/integration/customize_form.js
@@ -1,5 +1,6 @@
context('Customize Form', () => {
before(() => {
+ cy.login();
cy.visit('/app/customize-form');
});
it('Changing to naming rule should update autoname', () => {
@@ -19,4 +20,4 @@ context('Customize Form', () => {
cy.get_field("autoname", "Data").should("have.value", value);
});
});
-});
\ No newline at end of file
+});
diff --git a/cypress/integration/timeline_email.js b/cypress/integration/timeline_email.js
index f2a239401d..993847bcb8 100644
--- a/cypress/integration/timeline_email.js
+++ b/cypress/integration/timeline_email.js
@@ -7,7 +7,7 @@ context('Timeline Email', () => {
it('Adding new ToDo', () => {
cy.click_listview_primary_button('Add ToDo');
- cy.get('.custom-actions:visible > .btn').contains("Edit in full page").click({delay: 500});
+ cy.get('.custom-actions:visible > .btn').contains("Edit Full Form").click({delay: 500});
cy.fill_field("description", "Test ToDo", "Text Editor");
cy.wait(500);
cy.get('.primary-action').contains('Save').click({force: true});
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index 6398018e10..c168b0c201 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -27,6 +27,7 @@ import "cypress-real-events/support";
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... });
+
Cypress.Commands.add('login', (email, password) => {
if (!email) {
email = 'Administrator';
@@ -265,9 +266,14 @@ Cypress.Commands.add('get_open_dialog', () => {
return cy.get('.modal:visible').last();
});
+Cypress.Commands.add('save', () => {
+ cy.intercept('/api').as('api');
+ cy.get(`button[data-label="Save"]:visible`).click({scrollBehavior: false, force: true});
+ cy.wait('@api');
+});
Cypress.Commands.add('hide_dialog', () => {
cy.wait(300);
- cy.get_open_dialog().find('.btn-modal-close').click();
+ cy.get_open_dialog().focus().find('.btn-modal-close').click();
cy.get('.modal:visible').should('not.exist');
});
diff --git a/dev-requirements.txt b/dev-requirements.txt
index f4045c6bed..b67e915a16 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -1,4 +1,4 @@
coverage==5.5
-Faker~=8.1.0
+Faker~=13.12.1
pyngrok~=5.0.5
unittest-xml-reporting~=3.0.4
diff --git a/frappe/__init__.py b/frappe/__init__.py
index ec016c8b36..542c783319 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -10,18 +10,20 @@ be used to build database driven apps.
Read the documentation: https://frappeframework.com/docs
"""
+import functools
import importlib
import inspect
import json
import os
-import sys
+import re
import warnings
-from typing import TYPE_CHECKING, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
import click
from werkzeug.local import Local, release_local
from frappe.query_builder import get_query_builder, patch_query_aggregation, patch_query_execute
+from frappe.utils.caching import request_cache
from frappe.utils.data import cstr, sbool
# Local application imports
@@ -44,6 +46,11 @@ STANDARD_USERS = ("Guest", "Administrator")
DISABLE_DATABASE_POOLING = None
_dev_server = int(sbool(os.environ.get("DEV_SERVER", False)))
+_qb_patched = {}
+re._MAXCACHE = (
+ 50 # reduced from default 512 given we are already maintaining this on parent worker
+)
+
if _dev_server:
warnings.simplefilter("always", DeprecationWarning)
@@ -236,8 +243,10 @@ def init(site, sites_path=None, new_site=False):
local.qb = get_query_builder(local.conf.db_type or "mariadb")
setup_module_map()
- patch_query_execute()
- patch_query_aggregation()
+
+ if not _qb_patched.get(local.conf.db_type):
+ patch_query_execute()
+ patch_query_aggregation()
local.initialised = True
@@ -410,16 +419,22 @@ def msgprint(
:param is_minimizable: [optional] Allow users to minimize the modal
:param wide: [optional] Show wide modal
"""
+ 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 flags.rollback_on_exception:
db.rollback()
- import inspect
if inspect.isclass(raise_exception) and issubclass(raise_exception, Exception):
raise raise_exception(msg)
@@ -436,8 +451,11 @@ def msgprint(
if as_list and type(msg) in (list, tuple):
out.as_list = 1
+ if sys.stdin.isatty():
+ msg = _strip_html_tags(out.message)
+
if flags.print_messages and out.message:
- print(f"Message: {strip_html_tags(out.message)}")
+ print(f"Message: {_strip_html_tags(out.message)}")
out.title = title or _("Message", context="Default title of the message dialog")
@@ -835,6 +853,7 @@ def clear_cache(user=None, doctype=None):
:param user: If user is given, only user cache is cleared.
:param doctype: If doctype is given, only DocType cache is cleared."""
import frappe.cache_manager
+ import frappe.utils.caching
if doctype:
frappe.cache_manager.clear_doctype_cache(doctype)
@@ -854,7 +873,14 @@ def clear_cache(user=None, doctype=None):
for fn in get_hooks("clear_cache"):
get_attr(fn)()
+ frappe.utils.caching._SITE_CACHE.clear()
local.role_permissions = {}
+ if hasattr(local, "request_cache"):
+ local.request_cache.clear()
+ if hasattr(local, "system_settings"):
+ del local.system_settings
+ if hasattr(local, "website_settings"):
+ del local.website_settings
def only_has_select_perm(doctype, user=None, ignore_permissions=False):
@@ -1024,7 +1050,7 @@ def get_cached_doc(*args, **kwargs):
return doc
if key := can_cache_doc(args):
- # local cache
+ # local cache - has "ready" `Document` objects
if doc := local.document_cache.get(key):
return _respond(doc)
@@ -1032,9 +1058,22 @@ def get_cached_doc(*args, **kwargs):
if doc := cache().hget("document_cache", key):
return _respond(doc, True)
- # database
+ # Not found in local/redis, fetch from DB
doc = get_doc(*args, **kwargs)
+ # Store in cache
+ if not key:
+ key = get_document_cache_key(doc.doctype, doc.name)
+
+ local.document_cache[key] = doc
+
+ # Avoid setting in local.cache since we're already using local.document_cache above
+ # Try pickling the doc object as-is first, else fallback to doc.as_dict()
+ try:
+ cache().hset("document_cache", key, doc, cache_locally=False)
+ except Exception:
+ cache().hset("document_cache", key, doc.as_dict(), cache_locally=False)
+
return doc
@@ -1065,6 +1104,10 @@ def clear_document_cache(doctype, name):
if key in local.document_cache:
del local.document_cache[key]
cache().hdel("document_cache", key)
+ if doctype == "System Settings" and hasattr(local, "system_settings"):
+ delattr(local, "system_settings")
+ if doctype == "Website Settings" and hasattr(local, "website_settings"):
+ delattr(local, "website_settings")
def get_cached_value(doctype, name, fieldname="name", as_dict=False):
@@ -1105,10 +1148,13 @@ def get_doc(*args, **kwargs):
doc = frappe.model.document.get_doc(*args, **kwargs)
- # set in cache
+ # Replace cache
if key := can_cache_doc(args):
- local.document_cache[key] = doc
- cache().hset("document_cache", key, doc.as_dict())
+ if key in local.document_cache:
+ local.document_cache[key] = doc
+
+ if cache().hexists("document_cache", key):
+ cache().hset("document_cache", key, doc.as_dict())
return doc
@@ -1163,7 +1209,7 @@ def delete_doc(
:param delete_permanently: Do not create a Deleted Document for the document."""
import frappe.model.delete_doc
- frappe.model.delete_doc.delete_doc(
+ return frappe.model.delete_doc.delete_doc(
doctype,
name,
force,
@@ -1259,8 +1305,10 @@ def get_module_path(module, *joins):
:param module: Module name.
:param *joins: Join additional path elements using `os.path.join`."""
- module = scrub(module)
- return get_pymodule_path(local.module_app[module] + "." + module, *joins)
+ from frappe.modules.utils import get_module_app
+
+ app = get_module_app(module)
+ return get_pymodule_path(app + "." + scrub(module), *joins)
def get_app_path(app_name, *joins):
@@ -1312,6 +1360,7 @@ def get_all_apps(with_internal_apps=True, sites_path=None):
return apps
+@request_cache
def get_installed_apps(sort=False, frappe_last=False):
"""Get list of installed apps in current site."""
if getattr(flags, "in_install_db", True):
@@ -1353,47 +1402,49 @@ def get_doc_hooks():
return local.doc_events_hooks
-def get_hooks(hook=None, default=None, app_name=None):
+@request_cache
+def _load_app_hooks(app_name: Optional[str] = None):
+ hooks = {}
+ apps = [app_name] if app_name else get_installed_apps(sort=True)
+
+ for app in apps:
+ try:
+ app_hooks = get_module(f"{app}.hooks")
+ except ImportError:
+ if local.flags.in_install_app:
+ # if app is not installed while restoring
+ # ignore it
+ pass
+ print(f'Could not find app "{app}"')
+ if not request:
+ raise SystemExit
+ raise
+ for key in dir(app_hooks):
+ if not key.startswith("_"):
+ append_hook(hooks, key, getattr(app_hooks, key))
+ return hooks
+
+
+def get_hooks(
+ hook: str = None, default: Optional[Any] = "_KEEP_DEFAULT_LIST", app_name: str = None
+) -> _dict:
"""Get hooks via `app/hooks.py`
:param hook: Name of the hook. Will gather all hooks for this name and return as a list.
:param default: Default if no hook found.
:param app_name: Filter by app."""
- def load_app_hooks(app_name=None):
- hooks = {}
- for app in [app_name] if app_name else get_installed_apps(sort=True):
- app = "frappe" if app == "webnotes" else app
- try:
- app_hooks = get_module(app + ".hooks")
- except ImportError:
- if local.flags.in_install_app:
- # if app is not installed while restoring
- # ignore it
- pass
- print('Could not find app "{0}"'.format(app_name))
- if not request:
- sys.exit(1)
- raise
- for key in dir(app_hooks):
- if not key.startswith("_"):
- append_hook(hooks, key, getattr(app_hooks, key))
- return hooks
-
- no_cache = conf.developer_mode or False
-
if app_name:
- hooks = _dict(load_app_hooks(app_name))
+ hooks = _dict(_load_app_hooks(app_name))
else:
- if no_cache:
- hooks = _dict(load_app_hooks())
+ if conf.developer_mode:
+ hooks = _dict(_load_app_hooks())
else:
- hooks = _dict(cache().get_value("app_hooks", load_app_hooks))
+ hooks = _dict(cache().get_value("app_hooks", _load_app_hooks))
if hook:
- return hooks.get(hook) or (default if default is not None else [])
- else:
- return hooks
+ return hooks.get(hook, ([] if default == "_KEEP_DEFAULT_LIST" else default))
+ return hooks
def append_hook(target, key, value):
@@ -1501,19 +1552,35 @@ def call(fn, *args, **kwargs):
return fn(*args, **newargs)
-def get_newargs(fn, kwargs):
+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
+
+ >>> get_newargs(fn, {"a": 2, "c": 1})
+ {"a": 2}
+ """
+
+ # if function has any **kwargs parameter that capture arbitrary keyword arguments
+ # 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)
- varkw = "kwargs" in fnargs
- if varkw:
- fnargs.pop(-1)
+
+ 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:
+ if (a in fnargs) or varkw_exist:
newargs[a] = kwargs.get(a)
newargs.pop("ignore_permissions", None)
@@ -1809,18 +1876,21 @@ def get_value(*args, **kwargs):
return db.get_value(*args, **kwargs)
-def as_json(obj: Union[Dict, List], indent=1) -> str:
+def as_json(obj: Union[Dict, List], indent=1, separators=None) -> str:
from frappe.utils.response import json_handler
+ if separators is None:
+ separators = (",", ": ")
+
try:
return json.dumps(
- obj, indent=indent, sort_keys=True, default=json_handler, separators=(",", ": ")
+ obj, indent=indent, sort_keys=True, default=json_handler, separators=separators
)
except TypeError:
# this would break in case the keys are not all os "str" type - as defined in the JSON
# adding this to ensure keys are sorted (expected behaviour)
sorted_obj = dict(sorted(obj.items(), key=lambda kv: str(kv[0])))
- return json.dumps(sorted_obj, indent=indent, default=json_handler, separators=(",", ": "))
+ return json.dumps(sorted_obj, indent=indent, default=json_handler, separators=separators)
def are_emails_muted():
@@ -2158,8 +2228,18 @@ def safe_eval(code, eval_globals=None, eval_locals=None):
return eval(code, eval_globals, eval_locals)
+def get_website_settings(key):
+ if not hasattr(local, "website_settings"):
+ local.website_settings = db.get_singles_dict("Website Settings", cast=True)
+
+ return local.website_settings[key]
+
+
def get_system_settings(key):
- return db.get_single_value("System Settings", key, cache=True)
+ if not hasattr(local, "system_settings"):
+ local.system_settings = db.get_singles_dict("System Settings", cast=True)
+
+ return local.system_settings[key]
def get_active_domains():
diff --git a/frappe/app.py b/frappe/app.py
index e6df29fbd9..f8c81478c0 100644
--- a/frappe/app.py
+++ b/frappe/app.py
@@ -30,6 +30,8 @@ local_manager = LocalManager([frappe.local])
_site = None
_sites_path = os.environ.get("SITES_PATH", ".")
+SAFE_HTTP_METHODS = ("GET", "HEAD", "OPTIONS")
+UNSAFE_HTTP_METHODS = ("POST", "PUT", "DELETE", "PATCH")
class RequestContext(object):
@@ -292,7 +294,10 @@ def handle_exception(e):
def after_request(rollback):
- if (frappe.local.request.method in ("POST", "PUT") or frappe.local.flags.commit) and frappe.db:
+ # 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
+ ):
if frappe.db.transaction_writes:
frappe.db.commit()
rollback = False
diff --git a/frappe/auth.py b/frappe/auth.py
index dc53c20f28..80141d1d6c 100644
--- a/frappe/auth.py
+++ b/frappe/auth.py
@@ -165,7 +165,7 @@ class LoginManager:
self.set_user_info()
def get_user_info(self):
- self.info = frappe.db.get_value(
+ self.info = frappe.get_cached_value(
"User", self.user, ["user_type", "first_name", "last_name", "user_image"], as_dict=1
)
@@ -412,10 +412,16 @@ def clear_cookies():
def validate_ip_address(user):
"""check if IP Address is valid"""
- user = (
- frappe.get_cached_doc("User", user) if not frappe.flags.in_test else frappe.get_doc("User", user)
+ from frappe.core.doctype.user.user import get_restricted_ip_list
+
+ # Only fetch required fields - for perf
+ user_fields = ["restrict_ip", "bypass_restrict_ip_check_if_2fa_enabled"]
+ user_info = (
+ frappe.get_cached_value("User", user, user_fields, as_dict=True)
+ if not frappe.flags.in_test
+ else frappe.db.get_value("User", user, user_fields, as_dict=True)
)
- ip_list = user.get_restricted_ip_list()
+ ip_list = get_restricted_ip_list(user_info)
if not ip_list:
return
@@ -430,7 +436,7 @@ def validate_ip_address(user):
# check if two factor auth is enabled
if system_settings.enable_two_factor_auth and not bypass_restrict_ip_check:
# check if bypass restrict ip is enabled for login user
- bypass_restrict_ip_check = user.bypass_restrict_ip_check_if_2fa_enabled
+ bypass_restrict_ip_check = user_info.bypass_restrict_ip_check_if_2fa_enabled
for ip in ip_list:
if frappe.local.request_ip.startswith(ip) or bypass_restrict_ip_check:
diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py
index f3dfa4cf0a..0ca64e54c2 100644
--- a/frappe/automation/doctype/assignment_rule/assignment_rule.py
+++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py
@@ -298,8 +298,6 @@ def apply(doc=None, method=None, doctype=None, name=None):
if reopened:
break
- # print(f"Rule:{assignment_rule}\nDoc: {doc}\nReOpened: {reopened}")
-
assignment_rule.close_assignments(doc)
diff --git a/frappe/boot.py b/frappe/boot.py
index a23a7e6ac3..6cd86dc4fc 100644
--- a/frappe/boot.py
+++ b/frappe/boot.py
@@ -15,7 +15,7 @@ from frappe.geo.country_info import get_all
from frappe.model.base_document import get_controller
from frappe.query_builder import DocType
from frappe.query_builder.functions import Count
-from frappe.query_builder.terms import subqry
+from frappe.query_builder.terms import SubQuery
from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_points
from frappe.social.doctype.energy_point_settings.energy_point_settings import (
is_energy_point_enabled,
@@ -211,7 +211,7 @@ def get_user_pages_or_reports(parent, cache=False):
if parent == "Report":
has_role[p.name].update({"ref_doctype": p.ref_doctype})
- no_of_roles = (
+ no_of_roles = SubQuery(
frappe.qb.from_(hasRole).select(Count("*")).where(hasRole.parent == parentTable.name)
)
@@ -221,7 +221,7 @@ def get_user_pages_or_reports(parent, cache=False):
pages_with_no_roles = (
frappe.qb.from_(parentTable)
.select(parentTable.name, parentTable.modified, *columns)
- .where(subqry(no_of_roles) == 0)
+ .where(no_of_roles == 0)
).run(as_dict=True)
for p in pages_with_no_roles:
@@ -327,7 +327,7 @@ def get_unseen_notes():
(note.notify_on_every_login == 1)
& (note.expire_notification_on > frappe.utils.now())
& (
- subqry(frappe.qb.from_(nsb).select(nsb.user).where(nsb.parent == note.name)).notin(
+ SubQuery(frappe.qb.from_(nsb).select(nsb.user).where(nsb.parent == note.name)).notin(
[frappe.session.user]
)
)
diff --git a/frappe/build.py b/frappe/build.py
index e20ee0d698..5923bd05ec 100644
--- a/frappe/build.py
+++ b/frappe/build.py
@@ -20,6 +20,8 @@ import frappe
timestamps = {}
app_paths = None
sites_path = os.path.abspath(os.getcwd())
+WHITESPACE_PATTERN = re.compile(r"\s+")
+HTML_COMMENT_PATTERN = re.compile(r"()")
class AssetsNotDownloadedError(Exception):
@@ -406,10 +408,10 @@ def link_assets_dir(source, target, hard_link=False):
def scrub_html_template(content):
"""Returns HTML content with removed whitespace and comments"""
# remove whitespace to a single space
- content = re.sub(r"\s+", " ", content)
+ content = WHITESPACE_PATTERN.sub(" ", content)
# strip comments
- content = re.sub(r"()", "", content)
+ content = HTML_COMMENT_PATTERN.sub("", content)
return content.replace("'", "'")
diff --git a/frappe/client.py b/frappe/client.py
index f753da6f57..4afe0898bc 100644
--- a/frappe/client.py
+++ b/frappe/client.py
@@ -100,7 +100,7 @@ def get_value(doctype, fieldname, filters=None, as_dict=True, debug=False, paren
if frappe.is_table(doctype):
check_parent_permission(parent, doctype)
- if not frappe.has_permission(doctype):
+ if not frappe.has_permission(doctype, parent_doctype=parent):
frappe.throw(_("No permission for {0}").format(doctype), frappe.PermissionError)
filters = get_safe_filters(filters)
@@ -385,7 +385,7 @@ def attach_file(
is_private=None,
docfield=None,
):
- """Attach a file to Document (POST)
+ """Attach a file to Document
:param filename: filename e.g. test-file.txt
:param filedata: base64 encode filedata which must be urlencoded
@@ -396,17 +396,10 @@ def attach_file(
:param is_private: Attach file as private file (1 or 0)
:param docfield: file to attach to (optional)"""
- request_method = frappe.local.request.environ.get("REQUEST_METHOD")
-
- if request_method.upper() != "POST":
- frappe.throw(_("Invalid Request"))
-
doc = frappe.get_doc(doctype, docname)
+ doc.check_permission()
- if not doc.has_permission():
- frappe.throw(_("Not permitted"), frappe.PermissionError)
-
- _file = frappe.get_doc(
+ file = frappe.get_doc(
{
"doctype": "File",
"file_name": filename,
@@ -418,14 +411,13 @@ def attach_file(
"content": filedata,
"decode": decode_base64,
}
- )
- _file.save()
+ ).save()
if docfield and doctype:
- doc.set(docfield, _file.file_url)
+ doc.set(docfield, file.file_url)
doc.save()
- return _file.as_dict()
+ return file
@frappe.whitelist()
diff --git a/frappe/commands/site.py b/frappe/commands/site.py
index 80acf647e0..13d702d6f3 100644
--- a/frappe/commands/site.py
+++ b/frappe/commands/site.py
@@ -9,6 +9,7 @@ import click
# imports - module imports
import frappe
from frappe.commands import get_site, pass_context
+from frappe.core.doctype.log_settings.log_settings import LOG_DOCTYPES
from frappe.exceptions import SiteNotSpecifiedError
@@ -143,10 +144,6 @@ def restore(
)
from frappe.utils.backups import Backup
- if not os.path.exists(sql_file_path):
- print("Invalid path", sql_file_path)
- sys.exit(1)
-
_backup = Backup(sql_file_path)
site = get_site(context)
@@ -1092,6 +1089,51 @@ def build_search_index(context):
frappe.destroy()
+@click.command("clear-log-table")
+@click.option("--doctype", default="text", type=click.Choice(LOG_DOCTYPES), help="Log DocType")
+@click.option("--days", type=int, help="Keep records for days")
+@click.option("--no-backup", is_flag=True, default=False, help="Do not backup the table")
+@pass_context
+def clear_log_table(context, doctype, days, no_backup):
+ """If any logtype table grows too large then clearing it with DELETE query
+ is not feasible in reasonable time. This command copies recent data to new
+ table and replaces current table with new smaller table.
+
+
+ ref: https://mariadb.com/kb/en/big-deletes/#deleting-more-than-half-a-table
+ """
+ from frappe.core.doctype.log_settings.log_settings import clear_log_table as clear_logs
+ from frappe.utils.backups import scheduled_backup
+
+ if not context.sites:
+ raise SiteNotSpecifiedError
+
+ if doctype not in LOG_DOCTYPES:
+ raise frappe.ValidationError(f"Unsupported logging DocType: {doctype}")
+
+ for site in context.sites:
+ frappe.init(site=site)
+ frappe.connect()
+
+ if not no_backup:
+ scheduled_backup(
+ ignore_conf=False,
+ include_doctypes=doctype,
+ ignore_files=True,
+ force=True,
+ )
+ click.echo(f"Backed up {doctype}")
+
+ try:
+ click.echo(f"Copying {doctype} records from last {days} days to temporary table.")
+ clear_logs(doctype, days=days)
+ except Exception as e:
+ click.echo(f"Log cleanup for {doctype} failed:\n{e}")
+ sys.exit(1)
+ else:
+ click.secho(f"Cleared {doctype} records older than {days} days", fg="green")
+
+
@click.command("trim-database")
@click.option("--dry-run", is_flag=True, default=False, help="Show what would be deleted")
@click.option(
@@ -1264,4 +1306,5 @@ commands = [
partial_restore,
trim_tables,
trim_database,
+ clear_log_table,
]
diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py
index 2d3916914d..41a4b27bcf 100644
--- a/frappe/commands/utils.py
+++ b/frappe/commands/utils.py
@@ -730,6 +730,7 @@ def transform_database(context, table, engine, row_format, failfast):
@click.command("run-tests")
@click.option("--app", help="For App")
@click.option("--doctype", help="For DocType")
+@click.option("--module-def", help="For all Doctypes in Module Def")
@click.option("--case", help="Select particular TestCase")
@click.option(
"--doctype-list-path",
@@ -754,6 +755,7 @@ def run_tests(
app=None,
module=None,
doctype=None,
+ module_def=None,
test=(),
profile=False,
coverage=False,
@@ -790,6 +792,7 @@ def run_tests(
app,
module,
doctype,
+ module_def,
context.verbose,
tests=tests,
force=context.force,
diff --git a/frappe/contacts/address_and_contact.py b/frappe/contacts/address_and_contact.py
index 036594926e..a0f742c55a 100644
--- a/frappe/contacts/address_and_contact.py
+++ b/frappe/contacts/address_and_contact.py
@@ -3,6 +3,7 @@
import functools
import re
+from typing import Dict, List
import frappe
from frappe import _
@@ -169,29 +170,34 @@ def delete_contact_and_address(doctype, docname):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
-def filter_dynamic_link_doctypes(doctype, txt, searchfield, start, page_len, filters):
- if not txt:
- txt = ""
+def filter_dynamic_link_doctypes(txt: str, filters: Dict) -> List[List[str]]:
+ from frappe.permissions import get_doctypes_with_read
- doctypes = frappe.db.get_all(
- "DocField", filters=filters, fields=["parent"], distinct=True, as_list=True
+ txt = txt or ""
+ filters = filters or {}
+ TXT_PATTERN = re.compile(f"{txt}.*")
+
+ _doctypes_from_df = frappe.get_all(
+ "DocField",
+ filters=filters,
+ pluck="parent",
+ distinct=True,
+ order_by=None,
)
+ doctypes_from_df = {d for d in _doctypes_from_df if TXT_PATTERN.search(_(d), re.IGNORECASE)}
- doctypes = tuple(d for d in doctypes if re.search(txt + ".*", _(d[0]), re.IGNORECASE))
+ filters.update({"dt": ("not in", doctypes_from_df)})
+ _doctypes_from_cdf = frappe.get_all(
+ "Custom Field", filters=filters, pluck="dt", distinct=True, order_by=None
+ )
+ doctypes_from_cdf = {d for d in _doctypes_from_cdf if TXT_PATTERN.search(_(d), re.IGNORECASE)}
- filters.update({"dt": ("not in", [d[0] for d in doctypes])})
+ all_doctypes = doctypes_from_df.union(doctypes_from_cdf)
+ allowed_doctypes = set(get_doctypes_with_read())
- _doctypes = frappe.db.get_all("Custom Field", filters=filters, fields=["dt"], as_list=True)
+ valid_doctypes = sorted(all_doctypes.intersection(allowed_doctypes))
- _doctypes = tuple([d for d in _doctypes if re.search(txt + ".*", _(d[0]), re.IGNORECASE)])
-
- all_doctypes = [d[0] for d in doctypes + _doctypes]
- allowed_doctypes = frappe.permissions.get_doctypes_with_read()
-
- valid_doctypes = sorted(set(all_doctypes).intersection(set(allowed_doctypes)))
- valid_doctypes = [[doctype] for doctype in valid_doctypes]
-
- return valid_doctypes
+ return [[doctype] for doctype in valid_doctypes]
def set_link_title(doc):
diff --git a/frappe/data_migration/__init__.py b/frappe/core/api/__init__.py
similarity index 100%
rename from frappe/data_migration/__init__.py
rename to frappe/core/api/__init__.py
diff --git a/frappe/core/api/file.py b/frappe/core/api/file.py
new file mode 100644
index 0000000000..e558f2f7e3
--- /dev/null
+++ b/frappe/core/api/file.py
@@ -0,0 +1,122 @@
+import json
+from typing import Dict, List
+
+import frappe
+from frappe.core.doctype.file.file import File, setup_folder_path
+from frappe.utils import cint, cstr
+
+
+@frappe.whitelist()
+def unzip_file(name: str):
+ """Unzip the given file and make file records for each of the extracted files"""
+ file: File = frappe.get_doc("File", name)
+ return file.unzip()
+
+
+@frappe.whitelist()
+def get_attached_images(doctype: str, names: List[str]) -> frappe._dict:
+ """get list of image urls attached in form
+ returns {name: ['image.jpg', 'image.png']}"""
+
+ if isinstance(names, str):
+ names = json.loads(names)
+
+ img_urls = frappe.db.get_list(
+ "File",
+ filters={
+ "attached_to_doctype": doctype,
+ "attached_to_name": ("in", names),
+ "is_folder": 0,
+ },
+ fields=["file_url", "attached_to_name as docname"],
+ )
+
+ out = frappe._dict()
+ for i in img_urls:
+ out[i.docname] = out.get(i.docname, [])
+ out[i.docname].append(i.file_url)
+
+ return out
+
+
+@frappe.whitelist()
+def get_files_in_folder(folder: str, start: int = 0, page_length: int = 20) -> Dict:
+ start = cint(start)
+ page_length = cint(page_length)
+
+ attachment_folder = frappe.db.get_value(
+ "File",
+ "Home/Attachments",
+ ["name", "file_name", "file_url", "is_folder", "modified"],
+ as_dict=1,
+ )
+
+ files = frappe.get_list(
+ "File",
+ {"folder": folder},
+ ["name", "file_name", "file_url", "is_folder", "modified"],
+ start=start,
+ page_length=page_length + 1,
+ )
+
+ if folder == "Home" and attachment_folder not in files:
+ files.insert(0, attachment_folder)
+
+ return {"files": files[:page_length], "has_more": len(files) > page_length}
+
+
+@frappe.whitelist()
+def get_files_by_search_text(text: str) -> List[Dict]:
+ if not text:
+ return []
+
+ text = "%" + cstr(text).lower() + "%"
+ return frappe.get_list(
+ "File",
+ fields=["name", "file_name", "file_url", "is_folder", "modified"],
+ filters={"is_folder": False},
+ or_filters={
+ "file_name": ("like", text),
+ "file_url": text,
+ "name": ("like", text),
+ },
+ order_by="modified desc",
+ limit=20,
+ )
+
+
+@frappe.whitelist(allow_guest=True)
+def get_max_file_size() -> int:
+ return cint(frappe.conf.get("max_file_size")) or 10485760
+
+
+@frappe.whitelist()
+def create_new_folder(file_name: str, folder: str) -> File:
+ """create new folder under current parent folder"""
+ file = frappe.new_doc("File")
+ file.file_name = file_name
+ file.is_folder = 1
+ file.folder = folder
+ file.insert(ignore_if_duplicate=True)
+ return file
+
+
+@frappe.whitelist()
+def move_file(file_list: List[File], new_parent: str, old_parent: str) -> None:
+ if isinstance(file_list, str):
+ file_list = json.loads(file_list)
+
+ for file_obj in file_list:
+ setup_folder_path(file_obj.get("name"), new_parent)
+
+ # recalculate sizes
+ frappe.get_doc("File", old_parent).save()
+ frappe.get_doc("File", new_parent).save()
+
+
+@frappe.whitelist()
+def zip_files(files: str):
+ files = frappe.parse_json(files)
+ frappe.response["filename"] = "files.zip"
+ frappe.response["filecontent"] = File.zip_files(files)
+ frappe.response["type"] = "download"
diff --git a/frappe/core/doctype/access_log/access_log.json b/frappe/core/doctype/access_log/access_log.json
index c5f1030266..69803ef05a 100644
--- a/frappe/core/doctype/access_log/access_log.json
+++ b/frappe/core/doctype/access_log/access_log.json
@@ -36,6 +36,7 @@
"fieldname": "user",
"fieldtype": "Link",
"in_list_view": 1,
+ "in_standard_filter": 1,
"label": "User ",
"options": "User",
"read_only": 1
@@ -51,6 +52,7 @@
"fieldname": "reference_document",
"fieldtype": "Data",
"in_list_view": 1,
+ "in_standard_filter": 1,
"label": "Reference Document",
"read_only": 1
},
@@ -129,7 +131,7 @@
}
],
"links": [],
- "modified": "2022-05-03 09:34:19.337551",
+ "modified": "2022-06-13 05:59:26.866004",
"modified_by": "Administrator",
"module": "Core",
"name": "Access Log",
diff --git a/frappe/core/doctype/activity_log/activity_log.py b/frappe/core/doctype/activity_log/activity_log.py
index 61dedd7bc0..468b7f4473 100644
--- a/frappe/core/doctype/activity_log/activity_log.py
+++ b/frappe/core/doctype/activity_log/activity_log.py
@@ -25,6 +25,13 @@ class ActivityLog(Document):
if self.reference_doctype and self.reference_name:
self.status = "Linked"
+ @staticmethod
+ def clear_old_logs(days=None):
+ if not days:
+ days = 90
+ doctype = DocType("Activity Log")
+ frappe.db.delete(doctype, filters=(doctype.modified < (Now() - Interval(days=days))))
+
def on_doctype_update():
"""Add indexes in `tabActivity Log`"""
@@ -43,12 +50,3 @@ def add_authentication_log(subject, user, operation="Login", status="Success"):
"operation": operation,
}
).insert(ignore_permissions=True, ignore_links=True)
-
-
-def clear_activity_logs(days=None):
- """clear 90 day old authentication logs or configured in log settings"""
-
- if not days:
- days = 90
- doctype = DocType("Activity Log")
- frappe.db.delete(doctype, filters=(doctype.creation < (Now() - Interval(days=days))))
diff --git a/frappe/core/doctype/activity_log/activity_log_list.js b/frappe/core/doctype/activity_log/activity_log_list.js
index 111a230827..e3a75a1941 100644
--- a/frappe/core/doctype/activity_log/activity_log_list.js
+++ b/frappe/core/doctype/activity_log/activity_log_list.js
@@ -4,5 +4,10 @@ frappe.listview_settings['Activity Log'] = {
return [__(doc.status), "green"];
else if(doc.operation == "Login" && doc.status == "Failed")
return [__(doc.status), "red"];
- }
-};
\ No newline at end of file
+ },
+ onload: function(listview) {
+ frappe.require("logtypes.bundle.js", () => {
+ frappe.utils.logtypes.show_log_retention_message(cur_list.doctype);
+ })
+ },
+};
diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py
index 464bc35a1c..2c8a65fafe 100755
--- a/frappe/core/doctype/communication/email.py
+++ b/frappe/core/doctype/communication/email.py
@@ -12,6 +12,7 @@ from frappe.utils import (
cint,
get_datetime,
get_formatted_email,
+ get_string_between,
list_to_str,
split_emails,
validate_email_address,
@@ -21,14 +22,6 @@ if TYPE_CHECKING:
from frappe.core.doctype.communication.communication import Communication
-OUTGOING_EMAIL_ACCOUNT_MISSING = _(
- """
- Unable to send mail because of a missing email account.
- Please setup default Email Account from Setup > Email > Email Account
-"""
-)
-
-
@frappe.whitelist()
def make(
doctype=None,
@@ -152,7 +145,7 @@ def _make(
"reference_doctype": doctype,
"reference_name": name,
"email_template": email_template,
- "message_id": get_message_id().strip(" <>"),
+ "message_id": get_string_between("<", get_message_id(), ">"),
"read_receipt": read_receipt,
"has_attachment": 1 if attachments else 0,
"communication_type": communication_type,
@@ -169,7 +162,12 @@ def _make(
if cint(send_email):
if not comm.get_outgoing_email_account():
- frappe.throw(msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError)
+ frappe.throw(
+ _(
+ "Unable to send mail because of a missing email account. Please setup default Email Account from Setup > Email > Email Account"
+ ),
+ exc=frappe.OutgoingEmailError,
+ )
comm.send_email(
print_html=print_html,
diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py
index 68abba3c13..0263cfeac5 100644
--- a/frappe/core/doctype/communication/mixins.py
+++ b/frappe/core/doctype/communication/mixins.py
@@ -152,7 +152,7 @@ class CommunicationEmailMixin:
"doctype": self.reference_doctype,
"name": self.reference_name,
"print_format": print_format,
- "key": get_parent_doc(self).get_signature(),
+ "key": get_parent_doc(self).get_document_share_key(),
}
)
diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py
index 01be69ea16..06d7588aef 100644
--- a/frappe/core/doctype/data_import/importer.py
+++ b/frappe/core/doctype/data_import/importer.py
@@ -4,6 +4,7 @@
import io
import json
import os
+import re
import timeit
from datetime import date, datetime
@@ -22,6 +23,7 @@ INVALID_VALUES = ("", None)
MAX_ROWS_IN_PREVIEW = 10
INSERT = "Insert New Records"
UPDATE = "Update Existing Records"
+DURATION_PATTERN = re.compile(r"^(?:(\d+d)?((^|\s)\d+h)?((^|\s)\d+m)?((^|\s)\d+s)?)$")
class Importer:
@@ -725,10 +727,7 @@ class Row:
)
return
elif df.fieldtype == "Duration":
- import re
-
- is_valid_duration = re.match(r"^(?:(\d+d)?((^|\s)\d+h)?((^|\s)\d+m)?((^|\s)\d+s)?)$", value)
- if not is_valid_duration:
+ if not DURATION_PATTERN.match(value):
self.warnings.append(
{
"row": self.row_number,
diff --git a/frappe/core/doctype/deleted_document/deleted_document.json b/frappe/core/doctype/deleted_document/deleted_document.json
index 1a612c7411..6b95a523c1 100644
--- a/frappe/core/doctype/deleted_document/deleted_document.json
+++ b/frappe/core/doctype/deleted_document/deleted_document.json
@@ -1,256 +1,81 @@
{
- "allow_copy": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "beta": 0,
- "creation": "2016-12-29 12:59:48.638970",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 1,
- "engine": "InnoDB",
+ "actions": [],
+ "creation": "2016-12-29 12:59:48.638970",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "deleted_name",
+ "deleted_doctype",
+ "column_break_3",
+ "restored",
+ "new_name",
+ "section_break_6",
+ "data"
+ ],
"fields": [
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "deleted_name",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Deleted Name",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "deleted_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Deleted Name",
+ "read_only": 1
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "deleted_doctype",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Deleted DocType",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "deleted_doctype",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Deleted DocType",
+ "read_only": 1
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_3",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "restored",
- "fieldtype": "Check",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Restored",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "default": "0",
+ "fieldname": "restored",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Restored",
+ "read_only": 1
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "new_name",
- "fieldtype": "Read Only",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "New Name",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "new_name",
+ "fieldtype": "Read Only",
+ "label": "New Name"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "section_break_6",
- "fieldtype": "Section Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "section_break_6",
+ "fieldtype": "Section Break"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "data",
- "fieldtype": "Code",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Data",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "fieldname": "data",
+ "fieldtype": "Code",
+ "label": "Data",
+ "read_only": 1
}
- ],
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
- "in_create": 1,
-
- "is_submittable": 0,
- "issingle": 0,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2016-12-29 14:39:45.724494",
- "modified_by": "Administrator",
- "module": "Core",
- "name": "Deleted Document",
- "name_case": "",
- "owner": "Administrator",
+ ],
+ "in_create": 1,
+ "links": [],
+ "modified": "2022-06-13 05:50:58.314908",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "Deleted Document",
+ "owner": "Administrator",
"permissions": [
{
- "amend": 0,
- "apply_user_permissions": 0,
- "cancel": 0,
- "create": 0,
- "delete": 1,
- "email": 0,
- "export": 1,
- "if_owner": 0,
- "import": 0,
- "is_custom": 0,
- "permlevel": 0,
- "print": 0,
- "read": 1,
- "report": 0,
- "role": "System Manager",
- "set_user_permissions": 0,
- "share": 0,
- "submit": 0,
- "write": 0
+ "delete": 1,
+ "export": 1,
+ "read": 1,
+ "role": "System Manager"
}
- ],
- "quick_entry": 0,
- "read_only": 0,
- "read_only_onload": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "title_field": "deleted_name",
- "track_changes": 1,
- "track_seen": 0
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "deleted_name",
+ "track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py
index 047c48e9d5..e834b698d5 100644
--- a/frappe/core/doctype/doctype/doctype.py
+++ b/frappe/core/doctype/doctype/doctype.py
@@ -8,6 +8,7 @@ import os
# imports - standard imports
import re
import shutil
+from typing import TYPE_CHECKING, Union
# imports - module imports
import frappe
@@ -35,6 +36,15 @@ from frappe.query_builder.functions import Concat
from frappe.utils import cint
from frappe.website.utils import clear_cache
+if TYPE_CHECKING:
+ from frappe.custom.doctype.customize_form.customize_form import CustomizeForm
+
+DEPENDS_ON_PATTERN = re.compile(r'[\w\.:_]+\s*={1}\s*[\w\.@\'"]+')
+ILLEGAL_FIELDNAME_PATTERN = re.compile("""['",./%@()<>{}]""")
+WHITESPACE_PADDING_PATTERN = re.compile(r"^[ \t\n\r]+|[ \t\n\r]+$", flags=re.ASCII)
+START_WITH_LETTERS_PATTERN = re.compile(r"^(?![\W])[^\d_\s][\w -]+$", flags=re.ASCII)
+FIELD_PATTERN = re.compile("{(.*?)}", flags=re.UNICODE)
+
class InvalidFieldNameError(frappe.ValidationError):
pass
@@ -357,8 +367,7 @@ class DocType(Document):
else:
if d.fieldname in restricted:
frappe.throw(_("Fieldname {0} is restricted").format(d.fieldname), InvalidFieldNameError)
-
- d.fieldname = re.sub("""['",./%@()<>{}]""", "", d.fieldname)
+ d.fieldname = ILLEGAL_FIELDNAME_PATTERN.sub("", d.fieldname)
# fieldnames should be lowercase
d.fieldname = d.fieldname.lower()
@@ -842,15 +851,13 @@ class DocType(Document):
_("Doctype name is limited to {0} characters ({1})").format(max_length, name), frappe.NameError
)
- flags = {"flags": re.ASCII}
-
# a DocType name should not start or end with an empty space
- if re.search(r"^[ \t\n\r]+|[ \t\n\r]+$", name, **flags):
+ if WHITESPACE_PADDING_PATTERN.search(name):
frappe.throw(_("DocType's name should not start or end with whitespace"), frappe.NameError)
# a DocType's name should not start with a number or underscore
# and should only contain letters, numbers, underscore, and hyphen
- if not re.match(r"^(?![\W])[^\d_\s][\w -]+$", name, **flags):
+ if not START_WITH_LETTERS_PATTERN.match(name):
frappe.throw(
_(
"A DocType's name should start with a letter and can only "
@@ -913,11 +920,11 @@ def validate_series(dt, autoname=None, name=None):
frappe.throw(_("Series {0} already used in {1}").format(prefix, used_in[0][0]))
-def validate_autoincrement_autoname(dt: DocType) -> bool:
+def validate_autoincrement_autoname(dt: Union[DocType, "CustomizeForm"]) -> bool:
"""Checks if can doctype can change to/from autoincrement autoname"""
- def get_autoname_before_save(dt: DocType) -> str:
- if dt.name == "Customize Form":
+ def get_autoname_before_save(dt: Union[DocType, "CustomizeForm"]) -> str:
+ if dt.doctype == "Customize Form":
property_value = frappe.db.get_value(
"Property Setter", {"doc_type": dt.doc_type, "property": "autoname"}, "value"
)
@@ -940,10 +947,10 @@ def validate_autoincrement_autoname(dt: DocType) -> bool:
or (not is_autoname_autoincrement and autoname_before_save == "autoincrement")
):
- if frappe.get_meta(dt.name).issingle:
- if dt.name == "Customize Form":
- frappe.throw(_("Cannot change to/from autoincrement autoname in Customize Form"))
+ if dt.doctype == "Customize Form":
+ frappe.throw(_("Cannot change to/from autoincrement autoname in Customize Form"))
+ if frappe.get_meta(dt.name).issingle:
return False
if not frappe.get_all(dt.name, limit=1):
@@ -1254,7 +1261,7 @@ def validate_fields(meta):
if not pattern:
return
- for fieldname in re.findall("{(.*?)}", pattern, re.UNICODE):
+ for fieldname in FIELD_PATTERN.findall(pattern):
if fieldname.startswith("{"):
# edge case when double curlies are used for escape
continue
@@ -1336,9 +1343,7 @@ def validate_fields(meta):
]
for field in depends_on_fields:
depends_on = docfield.get(field, None)
- if (
- depends_on and ("=" in depends_on) and re.match(r'[\w\.:_]+\s*={1}\s*[\w\.@\'"]+', depends_on)
- ):
+ if depends_on and ("=" in depends_on) and DEPENDS_ON_PATTERN.match(depends_on):
frappe.throw(_("Invalid {0} condition").format(frappe.unscrub(field)), frappe.ValidationError)
def check_table_multiselect_option(docfield):
diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py
index 11f5ef8a69..0bcd972c68 100644
--- a/frappe/core/doctype/doctype/test_doctype.py
+++ b/frappe/core/doctype/doctype/test_doctype.py
@@ -1,10 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
+import random
+import string
import unittest
from typing import Dict, List, Optional
+from unittest.mock import patch
import frappe
+from frappe.cache_manager import clear_doctype_cache
from frappe.core.doctype.doctype.doctype import (
CannotIndexedError,
DoctypeLinkError,
@@ -15,8 +19,8 @@ from frappe.core.doctype.doctype.doctype import (
WrongOptionsDoctypeLinkError,
validate_links_table_fieldnames,
)
-
-# test_records = frappe.get_test_records('DocType')
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+from frappe.desk.form.load import getdoc
class TestDocType(unittest.TestCase):
@@ -628,10 +632,55 @@ class TestDocType(unittest.TestCase):
self.assertEqual(test_json.test_json_field["hello"], "world")
+ @patch.dict(frappe.conf, {"developer_mode": 1})
+ def test_delete_doctype_with_customization(self):
+ from frappe.custom.doctype.property_setter.property_setter import make_property_setter
+
+ custom_field = "customfield"
+
+ doctype = new_doctype(custom=0).insert().name
+
+ # Create property setter and custom field
+ field = "some_fieldname"
+ make_property_setter(doctype, field, "default", "DELETETHIS", "Data")
+ create_custom_fields({doctype: [{"fieldname": custom_field, "fieldtype": "Data"}]})
+
+ # Create 1 record
+ original_doc = frappe.get_doc(doctype=doctype, custom_field_name="wat").insert()
+ self.assertEqual(original_doc.some_fieldname, "DELETETHIS")
+
+ # delete doctype
+ frappe.delete_doc("DocType", doctype)
+ clear_doctype_cache(doctype)
+
+ # "restore" doctype by inserting doctype with same schema again
+ new_doctype(doctype, custom=0).insert()
+
+ # Ensure basically same doctype getting "restored"
+ restored_doc = frappe.get_last_doc(doctype)
+ verify_fields = ["doctype", field, custom_field]
+ for f in verify_fields:
+ self.assertEqual(original_doc.get(f), restored_doc.get(f))
+
+ # Check form load of restored doctype
+ getdoc(doctype, restored_doc.name)
+
+ # ensure meta - property setter
+ self.assertEqual(frappe.get_meta(doctype).get_field(field).default, "DELETETHIS")
+ frappe.delete_doc("DocType", doctype)
+
def new_doctype(
- name, unique: bool = False, depends_on: str = "", fields: Optional[List[Dict]] = None, **kwargs
+ name: Optional[str] = None,
+ unique: bool = False,
+ depends_on: str = "",
+ fields: Optional[List[Dict]] = None,
+ **kwargs,
):
+ if not name:
+ # Test prefix is required to avoid coverage
+ name = "Test " + "".join(random.sample(string.ascii_lowercase, 10))
+
doc = frappe.get_doc(
{
"doctype": "DocType",
diff --git a/frappe/data_migration/doctype/__init__.py b/frappe/core/doctype/document_naming_settings/__init__.py
similarity index 100%
rename from frappe/data_migration/doctype/__init__.py
rename to frappe/core/doctype/document_naming_settings/__init__.py
diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.js b/frappe/core/doctype/document_naming_settings/document_naming_settings.js
new file mode 100644
index 0000000000..2dc5fc4d58
--- /dev/null
+++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.js
@@ -0,0 +1,73 @@
+// Copyright (c) 2022, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Document Naming Settings", {
+ refresh: function(frm) {
+ frm.trigger("setup_transaction_autocomplete");
+ frm.disable_save();
+ },
+
+ setup_transaction_autocomplete: function(frm) {
+ frappe.call({
+ method: "get_transactions_and_prefixes",
+ doc: frm.doc,
+ callback: function(r) {
+ frm.fields_dict.transaction_type.set_data(r.message.transactions);
+ frm.fields_dict.prefix.set_data(r.message.prefixes);
+ },
+ });
+ },
+
+ transaction_type: function(frm) {
+ frm.set_value("user_must_always_select", 0);
+ frappe.call({
+ method: "get_options",
+ doc: frm.doc,
+ callback: function(r) {
+ frm.set_value("naming_series_options", r.message);
+ if (r.message && r.message.split("\n")[0] == "")
+ frm.set_value("user_must_always_select", 1);
+ },
+ });
+ },
+
+ prefix: function(frm) {
+ frappe.call({
+ method: "get_current",
+ doc: frm.doc,
+ callback: function(r) {
+ frm.refresh_field("current_value");
+ },
+ });
+ },
+
+ update: function(frm) {
+ frappe.call({
+ method: "update_series",
+ doc: frm.doc,
+ freeze: true,
+ freeze_msg: __("Updating naming series options"),
+ callback: function(r) {
+ frm.trigger("setup_transaction_autocomplete");
+ frm.trigger("transaction_type");
+ },
+ });
+ },
+
+ try_naming_series(frm) {
+ frappe.call({
+ method: "preview_series",
+ doc: frm.doc,
+ callback: function(r) {
+ if (!r.exc) {
+ frm.set_value("series_preview", r.message);
+ } else {
+ frm.set_value(
+ "series_preview",
+ __("Failed to generate preview of series")
+ );
+ }
+ },
+ });
+ },
+});
diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.json b/frappe/core/doctype/document_naming_settings/document_naming_settings.json
new file mode 100644
index 0000000000..4c86b2ec1d
--- /dev/null
+++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.json
@@ -0,0 +1,133 @@
+{
+ "actions": [],
+ "creation": "2022-05-30 07:24:07.736646",
+ "description": "Configure various aspects of how document naming works like naming series, current counter.",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "naming_series_tab",
+ "setup_series",
+ "transaction_type",
+ "naming_series_options",
+ "user_must_always_select",
+ "update",
+ "column_break_9",
+ "try_naming_series",
+ "series_preview",
+ "help_html",
+ "update_series",
+ "prefix",
+ "current_value",
+ "update_series_start"
+ ],
+ "fields": [
+ {
+ "collapsible": 1,
+ "description": "Set Naming Series options on your transactions.",
+ "fieldname": "setup_series",
+ "fieldtype": "Section Break",
+ "label": "Setup Series for transactions"
+ },
+ {
+ "depends_on": "transaction_type",
+ "fieldname": "help_html",
+ "fieldtype": "HTML",
+ "label": "Help HTML",
+ "options": "
\n Edit list of Series in the box. Rules:\n
\n
Each Series Prefix on a new line.
\n
Allowed special characters are \"/\" and \"-\"
\n
\n Optionally, set the number of digits in the series using dot (.)\n followed by hashes (#). For example, \".####\" means that the series\n will have four digits. Default is five digits.\n
\n
\n You can also use variables in the series name by putting them\n between (.) dots\n \n Supported Variables:\n
\n
.YYYY. - Year in 4 digits
\n
.YY. - Year in 2 digits
\n
.MM. - Month
\n
.DD. - Day of month
\n
.WW. - Week of the year
\n
.FY. - Fiscal Year
\n
\n .{fieldname}. - fieldname on the document e.g.\n branch\n
`;
+ }
+
+ this.step_body.html(content);
+ };
+
const toggle_video = () => {
this.step_body.empty();
this.step_footer.empty();
@@ -222,7 +232,7 @@ export default class OnboardingWidget extends Widget {
const on_finish = () => {
let msg_dialog = frappe.msgprint({
message: __("Let's take you back to onboarding"),
- title: __("Great Job"),
+ title: __("Onboarding complete"),
primary_action: {
action: () => {
frappe.set_route(current_route).then(() => {
@@ -265,7 +275,7 @@ export default class OnboardingWidget extends Widget {
if (success) {
args.message = __("Let's take you back to onboarding");
- args.title = __("Looks Great");
+ args.title = __("Action Complete");
args.primary_action = {
action: () => {
frappe.set_route(current_route).then(() => {
@@ -278,7 +288,7 @@ export default class OnboardingWidget extends Widget {
custom_onhide = () => args.primary_action.action();
} else {
args.message = __("Looks like you didn't change the value");
- args.title = __("Oops");
+ args.title = __("Try Again");
args.secondary_action = {
action: () => frappe.set_route(current_route),
label: __("Go Back"),
@@ -314,7 +324,7 @@ export default class OnboardingWidget extends Widget {
const on_finish = () => {
frappe.msgprint({
message: __("Awesome, now try making an entry yourself"),
- title: __("Great"),
+ title: __("Document Saved"),
primary_action: {
action: () => {
frappe.set_route(current_route).then(() => {
@@ -337,8 +347,8 @@ export default class OnboardingWidget extends Widget {
let callback = () => {
frappe.msgprint({
- message: __("You're doing great, let's take you back to the onboarding page."),
- title: __("Good Work 🎉"),
+ message: __("Let's take you back to onboarding"),
+ title: __("Action Complete"),
primary_action: {
action: () => {
frappe.set_route(current_route).then(() => {
@@ -358,7 +368,7 @@ export default class OnboardingWidget extends Widget {
frappe.route_hooks.after_save = () => {
frappe.msgprint({
message: __("Submit this document to complete this step."),
- title: __("Great")
+ title: __("Document Saved")
});
};
frappe.route_hooks.after_submit = callback;
@@ -377,7 +387,7 @@ export default class OnboardingWidget extends Widget {
if (frappe.get_route_str() != current_route) {
let success_dialog = frappe.msgprint({
message: __("Let's take you back to onboarding"),
- title: __("Looks Great"),
+ title: __("Document Saved"),
primary_action: {
action: () => {
success_dialog.hide();
@@ -397,7 +407,7 @@ export default class OnboardingWidget extends Widget {
} else {
frappe.msgprint({
message: __("Let us continue with the onboarding"),
- title: __("Looks Great")
+ title: __("Document Saved")
});
this.mark_complete(step);
}
diff --git a/frappe/public/js/logtypes.bundle.js b/frappe/public/js/logtypes.bundle.js
new file mode 100644
index 0000000000..775ac730ad
--- /dev/null
+++ b/frappe/public/js/logtypes.bundle.js
@@ -0,0 +1 @@
+import "./frappe/logtypes"
diff --git a/frappe/public/js/print_format_builder/PrintFormatControls.vue b/frappe/public/js/print_format_builder/PrintFormatControls.vue
index 2eefc22409..7a4e9c81e7 100644
--- a/frappe/public/js/print_format_builder/PrintFormatControls.vue
+++ b/frappe/public/js/print_format_builder/PrintFormatControls.vue
@@ -181,8 +181,8 @@ export default {
return [
{ label: __("Top"), fieldname: "margin_top" },
{ label: __("Bottom"), fieldname: "margin_bottom" },
- { label: __("Left"), fieldname: "margin_left" },
- { label: __("Right"), fieldname: "margin_right" }
+ { label: __("Left", null, 'alignment'), fieldname: "margin_left" },
+ { label: __("Right", null, 'alignment'), fieldname: "margin_right" }
];
},
fields() {
diff --git a/frappe/public/scss/common/controls.scss b/frappe/public/scss/common/controls.scss
index 1135fbb23d..9685c66ed9 100644
--- a/frappe/public/scss/common/controls.scss
+++ b/frappe/public/scss/common/controls.scss
@@ -127,6 +127,24 @@ select.form-control {
margin-bottom: 0;
}
}
+ .action-btn {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ padding: 3px;
+ z-index: 3;
+ }
+
+ button.action-btn {
+ padding: 3px 5px;
+ background-color: var(--fg-color);
+ }
+
+ .link-btn {
+ @extend .action-btn;
+ background-color: none;
+ display: none;
+ }
}
.frappe-control:not([data-fieldtype='MultiSelectPills']):not([data-fieldtype='Table MultiSelect']) {
@@ -289,15 +307,6 @@ textarea.form-control {
position: relative;
}
-.link-btn {
- position: absolute;
- top: 4px;
- right: 4px;
- padding: 3px;
- display: none;
- z-index: 3;
-}
-
.phone-btn {
position: absolute;
top: 4px;
diff --git a/frappe/public/scss/common/css_variables.scss b/frappe/public/scss/common/css_variables.scss
index a89ebb3b50..ab52c10e45 100644
--- a/frappe/public/scss/common/css_variables.scss
+++ b/frappe/public/scss/common/css_variables.scss
@@ -1,3 +1,5 @@
+$input-height: 28px !default;
+
:root,
[data-theme="light"] {
--brand-color: #0089FF;
@@ -16,7 +18,7 @@
--blue-900: #1A4469;
--blue-800: #154875;
--blue-700: #1366AE;
- --blue-600: #1579D0;
+ --blue-600: #1673C5;
--blue-500: #2490EF;
--blue-400: #50A6F2;
--blue-300: #7CBCF5;
@@ -143,7 +145,7 @@
--btn-shadow: var(--shadow-xs);
// Type Colors
- --text-muted: var(--gray-600);
+ --text-muted: var(--gray-700);
--text-light: var(--gray-800);
--text-color: var(--gray-900);
--heading-color: var(--gray-900);
@@ -177,19 +179,18 @@
--text-2xl: 20px;
--text-3xl: 22px;
- --text-on-blue: var(--blue-600);
- --text-on-light-blue: var(--blue-500);
- --text-on-dark-blue: var(--blue-700);
- --text-on-blue: var(--blue-600);
- --text-on-green: var(--dark-green-500);
- --text-on-yellow: var(--yellow-500);
- --text-on-orange: var(--orange-500);
- --text-on-red: var(--red-500);
+ --text-on-blue: var(--blue-700);
+ --text-on-light-blue: var(--blue-600);
+ --text-on-dark-blue: var(--blue-800);
+ --text-on-green: var(--dark-green-700);
+ --text-on-yellow: var(--yellow-700);
+ --text-on-orange: var(--orange-600);
+ --text-on-red: var(--red-600);
--text-on-gray: var(--gray-600);
--text-on-light-gray: var(--gray-800);
- --text-on-purple: var(--purple-500);
- --text-on-pink: var(--pink-500);
- --text-on-cyan: var(--cyan-600);
+ --text-on-purple: var(--purple-700);
+ --text-on-pink: var(--pink-600);
+ --text-on-cyan: var(--cyan-800);
// alert colors
--alert-text-danger: var(--red-600);
@@ -249,7 +250,11 @@
--border-radius-full: 999px;
--primary-color: #2490EF;
- --btn-height: 28px;
+ --btn-height: 30px;
+
+ // input
+ --input-height: #{$input-height};
+ --input-disabled-bg: var(--gray-200);
// Checkbox
--checkbox-right-margin: var(--margin-xs);
diff --git a/frappe/public/scss/common/form.scss b/frappe/public/scss/common/form.scss
index fcea603994..a01ff54c28 100644
--- a/frappe/public/scss/common/form.scss
+++ b/frappe/public/scss/common/form.scss
@@ -13,6 +13,7 @@
font-weight: normal;
font-size: var(--text-sm);
}
+ min-height: var(--input-height);
border-radius: $border-radius;
font-weight: 400;
padding: 6px 12px;
diff --git a/frappe/public/scss/common/grid.scss b/frappe/public/scss/common/grid.scss
index d1f89abbcd..07ab6d75a9 100644
--- a/frappe/public/scss/common/grid.scss
+++ b/frappe/public/scss/common/grid.scss
@@ -201,7 +201,7 @@
}
.link-btn {
- top: 8px;
+ top: 2px;
}
.form-control:focus {
diff --git a/frappe/public/scss/desk/css_variables.scss b/frappe/public/scss/desk/css_variables.scss
index a06ba3e9b0..aceaa3e1e6 100644
--- a/frappe/public/scss/desk/css_variables.scss
+++ b/frappe/public/scss/desk/css_variables.scss
@@ -1,7 +1,5 @@
@import '../common/css_variables.scss';
-$input-height: 28px !default;
-
:root,
[data-theme="light"] {
// breakpoints
@@ -31,10 +29,6 @@ $input-height: 28px !default;
--page-head-height: 75px;
--page-bottom-margin: 60px;
- // input
- --input-height: #{$input-height};
- --input-disabled-bg: var(--gray-200);
-
// checkbox
--checkbox-right-margin: var(--margin-xs);
--checkbox-size: 14px;
diff --git a/frappe/public/scss/desk/desktop.scss b/frappe/public/scss/desk/desktop.scss
index 8be8abed35..c0fef60162 100644
--- a/frappe/public/scss/desk/desktop.scss
+++ b/frappe/public/scss/desk/desktop.scss
@@ -421,8 +421,8 @@ body {
display: none;
}
- i {
- color: var(--green-600);
+ .icon {
+ stroke: var(--white);
}
span {
diff --git a/frappe/public/scss/desk/filters.scss b/frappe/public/scss/desk/filters.scss
index 0552311ee2..ffaea7a9bd 100644
--- a/frappe/public/scss/desk/filters.scss
+++ b/frappe/public/scss/desk/filters.scss
@@ -1,6 +1,6 @@
.filter-icon.active {
use {
- stroke: var(--blue-500);
+ stroke: var(--text-on-blue);
}
}
diff --git a/frappe/public/scss/desk/list.scss b/frappe/public/scss/desk/list.scss
index c71dbdca89..31d1661abb 100644
--- a/frappe/public/scss/desk/list.scss
+++ b/frappe/public/scss/desk/list.scss
@@ -204,8 +204,8 @@ $level-margin-right: 8px;
border: 1px solid var(--dark-border-color);
&.btn-info {
- background-color: var(--gray-400);
- border-color: var(--gray-400);
+ background-color: var(--gray-600);
+ border-color: var(--gray-600);
color: var(--white);
font-weight: var(--text-bold);
}
@@ -401,7 +401,7 @@ input.list-check-all {
}
.filter-button.btn-primary-light {
- color: var(--blue-500);
+ color: var(--text-on-blue);
}
.sort-selector {
diff --git a/frappe/public/scss/desk/report.scss b/frappe/public/scss/desk/report.scss
index f8666602ff..8ed0fb740c 100644
--- a/frappe/public/scss/desk/report.scss
+++ b/frappe/public/scss/desk/report.scss
@@ -104,12 +104,12 @@
}
.group-by-button.btn-primary-light {
- color: var(--blue-500);
+ color: var(--text-on-blue);
}
.group-by-icon.active {
use {
- stroke: var(--blue-500);
+ stroke: var(--text-on-blue);
}
}
diff --git a/frappe/public/scss/desk/sidebar.scss b/frappe/public/scss/desk/sidebar.scss
index e30e0c3b94..25dcceec5b 100644
--- a/frappe/public/scss/desk/sidebar.scss
+++ b/frappe/public/scss/desk/sidebar.scss
@@ -492,4 +492,4 @@ body[data-route^="Module"] .main-menu {
.shared-user {
margin-bottom: 10px;
-}
+}
\ No newline at end of file
diff --git a/frappe/public/scss/login.bundle.scss b/frappe/public/scss/login.bundle.scss
index 8d0a32846f..488dd4106e 100644
--- a/frappe/public/scss/login.bundle.scss
+++ b/frappe/public/scss/login.bundle.scss
@@ -16,7 +16,7 @@ body {
.for-forgot,
.for-signup,
.for-email-login {
- padding: max(15vh, 70px) 0;
+ padding: max(10vh, 60px) 0;
@include media-breakpoint-up(sm) {
.page-card {
@@ -177,6 +177,7 @@ body {
}
h4 {
+ margin-top: 1rem;
font-size: var(--text-xl);
color: var(--text-color);
}
diff --git a/frappe/public/scss/print.bundle.scss b/frappe/public/scss/print.bundle.scss
index 61f56beaf8..3e8baddcb6 100644
--- a/frappe/public/scss/print.bundle.scss
+++ b/frappe/public/scss/print.bundle.scss
@@ -1,5 +1,8 @@
@import "frappe/public/css/bootstrap.css";
@import "./common/quill";
+
+@import "./desk/variables";
+@import "~bootstrap/scss/utilities/spacing";
@import "./desk/css_variables";
@import "./element/checkbox";
@@ -12,4 +15,23 @@
svg[data-barcode-value] > g {
fill: black !important;
}
+ .print-hide {
+ display: none !important;
+ }
}
+
+.action-banner {
+ display: flex;
+ justify-content: flex-end;
+ padding-right: 20px;
+ font-size: var(--text-md);
+}
+
+.invalid-state {
+ display: grid;
+ place-content: center;
+ height: 100vh;
+ img {
+ margin: auto;
+ }
+}
\ No newline at end of file
diff --git a/frappe/public/scss/website/index.scss b/frappe/public/scss/website/index.scss
index f25e4d6cc6..109bc8cbb4 100644
--- a/frappe/public/scss/website/index.scss
+++ b/frappe/public/scss/website/index.scss
@@ -125,6 +125,10 @@
align-items: center;
}
+.page_content {
+ min-height: 50vh;
+}
+
.breadcrumb-container {
margin-top: 1rem;
padding-top: 0.25rem;
diff --git a/frappe/query_builder/terms.py b/frappe/query_builder/terms.py
index 8d64d2ddcd..64a4707983 100644
--- a/frappe/query_builder/terms.py
+++ b/frappe/query_builder/terms.py
@@ -100,7 +100,7 @@ class ParameterizedFunction(Function):
return function_sql
-class subqry(Criterion):
+class SubQuery(Criterion):
def __init__(
self,
subq: QueryBuilder,
@@ -112,3 +112,6 @@ class subqry(Criterion):
def get_sql(self, **kwg: Any) -> str:
kwg["subquery"] = True
return self.subq.get_sql(**kwg)
+
+
+subqry = SubQuery
diff --git a/frappe/query_builder/utils.py b/frappe/query_builder/utils.py
index 69aee9b350..10bab38a63 100644
--- a/frappe/query_builder/utils.py
+++ b/frappe/query_builder/utils.py
@@ -64,7 +64,6 @@ def patch_query_execute():
This excludes the use of `frappe.db.sql` method while
executing the query object
"""
- from frappe.utils.safe_exec import check_safe_sql_query
def execute_query(query, *args, **kwargs):
query, params = prepare_query(query)
@@ -73,6 +72,8 @@ def patch_query_execute():
def prepare_query(query):
import inspect
+ from frappe.utils.safe_exec import check_safe_sql_query
+
param_collector = NamedParameterWrapper()
query = query.get_sql(param_wrapper=param_collector)
if frappe.flags.in_safe_exec and not check_safe_sql_query(query, throw=False):
@@ -103,6 +104,7 @@ def patch_query_execute():
builder_class.run = execute_query
builder_class.walk = prepare_query
+ frappe._qb_patched[frappe.conf.db_type] = True
def patch_query_aggregation():
@@ -113,3 +115,4 @@ def patch_query_aggregation():
frappe.qb.min = _min
frappe.qb.avg = _avg
frappe.qb.sum = _sum
+ frappe._qb_patched[frappe.conf.db_type] = True
diff --git a/frappe/recorder.py b/frappe/recorder.py
index 95b78dd085..87e001fe31 100644
--- a/frappe/recorder.py
+++ b/frappe/recorder.py
@@ -16,6 +16,7 @@ from frappe import _
RECORDER_INTERCEPT_FLAG = "recorder-intercept"
RECORDER_REQUEST_SPARSE_HASH = "recorder-requests-sparse"
RECORDER_REQUEST_HASH = "recorder-requests"
+TRACEBACK_PATH_PATTERN = re.compile(".*/apps/")
def sql(*args, **kwargs):
@@ -58,7 +59,7 @@ def get_current_stack_frames():
for frame, filename, lineno, function, context, index in list(reversed(frames))[:-2]:
if "/apps/" in filename:
yield {
- "filename": re.sub(".*/apps/", "", filename),
+ "filename": TRACEBACK_PATH_PATTERN.sub("", filename),
"lineno": lineno,
"function": function,
}
diff --git a/frappe/sessions.py b/frappe/sessions.py
index 6e0ce73732..67b58e1d89 100644
--- a/frappe/sessions.py
+++ b/frappe/sessions.py
@@ -187,7 +187,7 @@ def get():
bootinfo["translated_search_doctypes"] = frappe.get_hooks("translated_search_doctypes")
bootinfo["disable_async"] = frappe.conf.disable_async
- bootinfo["setup_complete"] = cint(frappe.db.get_single_value("System Settings", "setup_complete"))
+ bootinfo["setup_complete"] = cint(frappe.get_system_settings("setup_complete"))
bootinfo["desk_theme"] = frappe.db.get_value("User", frappe.session.user, "desk_theme") or "Light"
diff --git a/frappe/share.py b/frappe/share.py
index 01d1412b8d..3edcb1be38 100644
--- a/frappe/share.py
+++ b/frappe/share.py
@@ -93,7 +93,7 @@ def set_permission(doctype, name, user, permission_to, value=1, everyone=0):
if not (share.read or share.write or share.submit or share.share):
share.delete()
- share = {}
+ share = None
return share
diff --git a/frappe/templates/includes/comments/comments.py b/frappe/templates/includes/comments/comments.py
index dd94cfa989..b98106c0dc 100644
--- a/frappe/templates/includes/comments/comments.py
+++ b/frappe/templates/includes/comments/comments.py
@@ -10,6 +10,11 @@ from frappe.utils.html_utils import clean_html
from frappe.website.doctype.blog_settings.blog_settings import get_comment_limit
from frappe.website.utils import clear_cache
+URLS_COMMENT_PATTERN = re.compile(
+ r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", re.IGNORECASE
+)
+EMAIL_PATTERN = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", re.IGNORECASE)
+
@frappe.whitelist(allow_guest=True)
@rate_limit(key="reference_name", limit=get_comment_limit, seconds=60 * 60)
@@ -23,12 +28,7 @@ def add_comment(comment, comment_email, comment_by, reference_doctype, reference
frappe.msgprint(_("The comment cannot be empty"))
return False
- url_regex = re.compile(
- r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", re.IGNORECASE
- )
- email_regex = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", re.IGNORECASE)
-
- if url_regex.search(comment) or email_regex.search(comment):
+ if URLS_COMMENT_PATTERN.search(comment) or EMAIL_PATTERN.search(comment):
frappe.msgprint(_("Comments cannot have links or email addresses"))
return False
diff --git a/frappe/templates/includes/integrations/razorpay_checkout.js b/frappe/templates/includes/integrations/razorpay_checkout.js
index 2986fcb0fc..3df6ed68ea 100644
--- a/frappe/templates/includes/integrations/razorpay_checkout.js
+++ b/frappe/templates/includes/integrations/razorpay_checkout.js
@@ -3,6 +3,7 @@ $(document).ready(function(){
var options = {
"key": "{{ api_key }}",
"amount": cint({{ amount }} * 100), // 2000 paise = INR 20
+ "currency": "{{ currency }}",
"name": "{{ title }}",
"description": "{{ description }}",
"subscription_id": "{{ subscription_id }}",
diff --git a/frappe/templates/pages/integrations/razorpay_checkout.py b/frappe/templates/pages/integrations/razorpay_checkout.py
index aed832119b..b4f9e74a03 100644
--- a/frappe/templates/pages/integrations/razorpay_checkout.py
+++ b/frappe/templates/pages/integrations/razorpay_checkout.py
@@ -17,6 +17,7 @@ expected_keys = (
"payer_name",
"payer_email",
"order_id",
+ "currency",
)
diff --git a/frappe/templates/styles/card_style.css b/frappe/templates/styles/card_style.css
index 9e38ad70bf..a9639d8133 100644
--- a/frappe/templates/styles/card_style.css
+++ b/frappe/templates/styles/card_style.css
@@ -2,33 +2,32 @@
background-color: var(--bg-color);
}
-
-
.page-card {
- max-width: 360px;
- padding: 15px;
- margin: 70px auto;
- border-radius: 4px;
- background-color: var(--fg-color);
- /* box-shadow: var(--shadow-base); */
+ max-width: 360px;
+ padding: 15px;
+ margin: 70px auto;
+ border-radius: 4px;
+ background-color: var(--fg-color);
+ box-shadow: var(--shadow-base);
}
.for-reset-password {
- margin: 80px 0;
+ margin: 80px 0;
}
.for-reset-password .page-card {
- border: 0;
- max-width: 450px;
- margin: auto;
- border-radius: 10px;
+ border: 0;
+ max-width: 450px;
+ margin: auto;
+ border-radius: var(--border-radius-md);
+ padding: 40px 60px;
}
-@media (min-width: 567px) {
+@media (max-width: 425px) {
.for-reset-password .page-card {
- box-shadow: var(--shadow-base);
- padding: 40px 60px;
-
+ box-shadow: none;
+ background: none;
+ padding: 0px;
}
}
diff --git a/frappe/test_runner.py b/frappe/test_runner.py
index 96feac532f..82179d8fac 100644
--- a/frappe/test_runner.py
+++ b/frappe/test_runner.py
@@ -41,6 +41,7 @@ def main(
app=None,
module=None,
doctype=None,
+ module_def=None,
verbose=False,
tests=(),
force=False,
@@ -97,6 +98,13 @@ def main(
ret = run_tests_for_doctype(
doctype, verbose, tests, force, profile, failfast=failfast, junit_xml_output=junit_xml_output
)
+ elif module_def:
+ doctypes = frappe.db.get_list(
+ "DocType", filters={"module": module_def, "istable": 0}, pluck="name"
+ )
+ ret = run_tests_for_doctype(
+ doctypes, verbose, tests, force, profile, failfast=failfast, junit_xml_output=junit_xml_output
+ )
elif module:
ret = run_tests_for_module(
module,
diff --git a/frappe/tests/__init__.py b/frappe/tests/__init__.py
index 5a44cae5f1..eda83bd14b 100644
--- a/frappe/tests/__init__.py
+++ b/frappe/tests/__init__.py
@@ -1,11 +1,13 @@
import frappe
-def update_system_settings(args):
+def update_system_settings(args, commit=False):
doc = frappe.get_doc("System Settings")
doc.update(args)
doc.flags.ignore_mandatory = 1
doc.save()
+ if commit:
+ frappe.db.commit()
def get_system_setting(key):
diff --git a/frappe/tests/test_caching.py b/frappe/tests/test_caching.py
new file mode 100644
index 0000000000..6a1390f10c
--- /dev/null
+++ b/frappe/tests/test_caching.py
@@ -0,0 +1,95 @@
+import time
+import unittest
+from typing import Dict, List, Tuple, Union
+from unittest.mock import MagicMock
+
+import frappe
+from frappe.tests.test_api import FrappeAPITestCase
+from frappe.utils.caching import request_cache, site_cache
+
+CACHE_TTL = 4
+external_service = MagicMock(return_value=30)
+register_with_external_service = MagicMock(return_value=True)
+
+
+@request_cache
+def request_specific_api(a: Union[List, Tuple, Dict, int], b: int) -> int:
+ # API that takes very long to return a result
+ todays_value = external_service()
+ if not isinstance(a, (int, float)):
+ a = 1
+ return a**b * todays_value
+
+
+@frappe.whitelist(allow_guest=True)
+@site_cache
+def ping() -> str:
+ register_with_external_service(frappe.local.site)
+ return frappe.local.site
+
+
+@frappe.whitelist(allow_guest=True)
+@site_cache(ttl=CACHE_TTL)
+def ping_with_ttl() -> str:
+ register_with_external_service(frappe.local.site)
+ return frappe.local.site
+
+
+class TestCachingUtils(unittest.TestCase):
+ def test_request_cache(self):
+ retval = []
+ acceptable_args = [
+ [1, 2, 3, 4],
+ range(10),
+ {"abc": "test-key"},
+ frappe.get_last_doc("DocType"),
+ frappe._dict(),
+ ]
+ same_output_received = lambda: all([x for x in set(retval) if x == retval[0]])
+
+ # ensure that external service was called only once
+ # thereby return value of request_specific_api is cached
+ for _ in range(5):
+ retval.append(request_specific_api(120, 23))
+ external_service.assert_called_once()
+ self.assertTrue(same_output_received())
+
+ # ensure that cache differentiates between int & float
+ # types. Giving different return values for both
+ retval.append(request_specific_api(120.0, 23))
+ self.assertTrue(external_service.call_count, 2)
+
+ # ensure that function is executed when call isn't
+ # already cached
+ retval.clear()
+ for _ in range(10):
+ request_specific_api(120, 13)
+ self.assertTrue(external_service.call_count, 3)
+ self.assertTrue(same_output_received())
+
+ # ensure key generation capacity for different types
+ retval.clear()
+ for arg in acceptable_args:
+ external_service.call_count = 0
+ for _ in range(2):
+ request_specific_api(arg, 13)
+ self.assertTrue(external_service.call_count, 1)
+ self.assertTrue(same_output_received())
+
+
+class TestSiteCache(FrappeAPITestCase):
+ def test_site_cache(self):
+ module = __name__
+ api_with_ttl = f"{module}.ping_with_ttl"
+ api_without_ttl = f"{module}.ping"
+
+ start = time.monotonic()
+ for _ in range(5):
+ self.get(f"/api/method/{api_with_ttl}")
+ self.get(f"/api/method/{api_without_ttl}")
+ end = time.monotonic()
+
+ self.assertEqual(register_with_external_service.call_count, 2)
+ time.sleep(CACHE_TTL - (end - start))
+ self.get(f"/api/method/{api_with_ttl}")
+ self.assertEqual(register_with_external_service.call_count, 3)
diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py
index be968511a8..aeb7f364bc 100644
--- a/frappe/tests/test_commands.py
+++ b/frappe/tests/test_commands.py
@@ -27,6 +27,8 @@ import frappe.commands.site
import frappe.commands.utils
import frappe.recorder
from frappe.installer import add_to_installed_apps, remove_app
+from frappe.query_builder.utils import db_type_is
+from frappe.tests.test_query_builder import run_only_if
from frappe.utils import add_to_date, get_bench_path, get_bench_relative_path, now
from frappe.utils.backups import fetch_latest_backups
@@ -518,6 +520,23 @@ class TestBackups(BaseTestCommands):
self.assertIsNotNone(after_backup["public"])
self.assertIsNotNone(after_backup["private"])
+ @run_only_if(db_type_is.MARIADB)
+ def test_clear_log_table(self):
+ d = frappe.get_doc(doctype="Error Log", title="Something").insert()
+ d.db_set("modified", "2010-01-01", update_modified=False)
+ frappe.db.commit()
+
+ tables_before = frappe.db.get_tables(cached=False)
+
+ self.execute("bench --site {site} clear-log-table --days=30 --doctype='Error Log'")
+ self.assertEqual(self.returncode, 0)
+ frappe.db.commit()
+
+ self.assertFalse(frappe.db.exists("Error Log", d.name))
+ tables_after = frappe.db.get_tables(cached=False)
+
+ self.assertEqual(set(tables_before), set(tables_after))
+
def test_backup_with_custom_path(self):
"""Backup to a custom path (--backup-path)"""
backup_path = os.path.join(self.home, "backups")
diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py
index 86e54cb866..73b5446404 100644
--- a/frappe/tests/test_db.py
+++ b/frappe/tests/test_db.py
@@ -182,10 +182,12 @@ class TestDB(unittest.TestCase):
self.assertIn("tabToDo", frappe.flags.touched_tables)
frappe.flags.touched_tables = set()
- create_custom_field("ToDo", {"label": "ToDo Custom Field"})
-
+ cf = create_custom_field("ToDo", {"label": "ToDo Custom Field"})
self.assertIn("tabToDo", frappe.flags.touched_tables)
self.assertIn("tabCustom Field", frappe.flags.touched_tables)
+ if cf:
+ cf.delete()
+ frappe.db.commit()
frappe.flags.in_migrate = False
frappe.flags.touched_tables.clear()
@@ -867,3 +869,18 @@ class TestDDLCommandsPost(unittest.TestCase):
self.assertIn(
"is null", frappe.db.get_values(user, filters={user.name: ("is", "not set")}, run=False).lower()
)
+
+
+@run_only_if(db_type_is.POSTGRES)
+class TestTransactionManagement(unittest.TestCase):
+ def test_create_proper_transactions(self):
+ def _get_transaction_id():
+ return frappe.db.sql("select txid_current()", pluck=True)
+
+ self.assertEqual(_get_transaction_id(), _get_transaction_id())
+
+ frappe.db.rollback()
+ self.assertEqual(_get_transaction_id(), _get_transaction_id())
+
+ frappe.db.commit()
+ self.assertEqual(_get_transaction_id(), _get_transaction_id())
diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py
index dd67d68cd2..c1b2e05266 100644
--- a/frappe/tests/test_db_query.py
+++ b/frappe/tests/test_db_query.py
@@ -35,6 +35,35 @@ class TestReportview(unittest.TestCase):
clear_custom_fields("DocType")
+ def test_child_table_field_syntax(self):
+ note = frappe.get_doc(
+ doctype="Note",
+ title=f"Test {frappe.utils.random_string(8)}",
+ content="test",
+ seen_by=[{"user": "Administrator"}],
+ ).insert()
+ result = frappe.db.get_all(
+ "Note",
+ filters={"name": note.name},
+ fields=["name", "seen_by.user as seen_by"],
+ limit=1,
+ )
+ self.assertEqual(result[0].seen_by, "Administrator")
+ note.delete()
+
+ def test_link_field_syntax(self):
+ todo = frappe.get_doc(
+ doctype="ToDo", description="Test ToDo", allocated_to="Administrator"
+ ).insert()
+ result = frappe.db.get_all(
+ "ToDo",
+ filters={"name": todo.name},
+ fields=["name", "allocated_to.email as allocated_user_email"],
+ limit=1,
+ )
+ self.assertEqual(result[0].allocated_user_email, "admin@example.com")
+ todo.delete()
+
def test_build_match_conditions(self):
clear_user_permissions_for_doctype("Blog Post", "test2@example.com")
diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py
index 5bda6a1d9d..00bca40268 100644
--- a/frappe/tests/test_document.py
+++ b/frappe/tests/test_document.py
@@ -6,9 +6,13 @@ from datetime import timedelta
from unittest.mock import patch
import frappe
+from frappe.app import make_form_dict
from frappe.desk.doctype.note.note import Note
from frappe.model.naming import make_autoname, parse_naming_series, revert_series_if_last
-from frappe.utils import cint, now_datetime
+from frappe.utils import cint, now_datetime, set_request
+from frappe.website.serve import get_response
+
+from . import update_system_settings
class CustomTestNote(Note):
@@ -357,3 +361,49 @@ class TestDocument(unittest.TestCase):
# setting None should init a table field to empty list
doc.set("user_emails", None)
self.assertEqual(doc.user_emails, [])
+
+
+class TestDocumentWebView(unittest.TestCase):
+ def get(self, path, user="Guest"):
+ frappe.set_user(user)
+ set_request(method="GET", path=path)
+ make_form_dict(frappe.local.request)
+ response = get_response()
+ frappe.set_user("Administrator")
+ return response
+
+ def test_web_view_link_authentication(self):
+ todo = frappe.get_doc({"doctype": "ToDo", "description": "Test"}).insert()
+ document_key = todo.get_document_share_key()
+
+ # with old-style signature key
+ update_system_settings({"allow_older_web_view_links": True}, True)
+ old_document_key = todo.get_signature()
+ url = f"/ToDo/{todo.name}?key={old_document_key}"
+ self.assertEqual(self.get(url).status, "200 OK")
+
+ update_system_settings({"allow_older_web_view_links": False}, True)
+ self.assertEqual(self.get(url).status, "401 UNAUTHORIZED")
+
+ # with valid key
+ url = f"/ToDo/{todo.name}?key={document_key}"
+ self.assertEqual(self.get(url).status, "200 OK")
+
+ # with invalid key
+ invalid_key_url = f"/ToDo/{todo.name}?key=INVALID_KEY"
+ self.assertEqual(self.get(invalid_key_url).status, "401 UNAUTHORIZED")
+
+ # expire the key
+ document_key_doc = frappe.get_doc("Document Share Key", {"key": document_key})
+ document_key_doc.expires_on = "2020-01-01"
+ document_key_doc.save(ignore_permissions=True)
+
+ # with expired key
+ self.assertEqual(self.get(url).status, "410 GONE")
+
+ # without key
+ url_without_key = f"/ToDo/{todo.name}"
+ self.assertEqual(self.get(url_without_key).status, "403 FORBIDDEN")
+
+ # Logged-in user can access the page without key
+ self.assertEqual(self.get(url_without_key, "Administrator").status, "200 OK")
diff --git a/frappe/tests/test_form_load.py b/frappe/tests/test_form_load.py
index e92b8c3ff2..22db56eeef 100644
--- a/frappe/tests/test_form_load.py
+++ b/frappe/tests/test_form_load.py
@@ -181,7 +181,7 @@ class TestFormLoad(unittest.TestCase):
self.assertEqual(len(docinfo.comments), 1)
self.assertIn("test", docinfo.comments[0].content)
- self.assertGreaterEqual(len(docinfo.versions), 2)
+ self.assertGreaterEqual(len(docinfo.versions), 1)
self.assertEqual(set(docinfo.tags.split(",")), {"more_tag", "test_tag"})
diff --git a/frappe/tests/test_naming.py b/frappe/tests/test_naming.py
index e217f24154..d966fd5ce8 100644
--- a/frappe/tests/test_naming.py
+++ b/frappe/tests/test_naming.py
@@ -1,20 +1,21 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
-import unittest
-
import frappe
from frappe.core.doctype.doctype.test_doctype import new_doctype
from frappe.model.naming import (
+ InvalidNamingSeriesError,
+ NamingSeries,
append_number_if_name_exists,
determine_consecutive_week_number,
getseries,
revert_series_if_last,
)
+from frappe.tests.utils import FrappeTestCase
from frappe.utils import now_datetime
-class TestNaming(unittest.TestCase):
+class TestNaming(FrappeTestCase):
def setUp(self):
frappe.db.delete("Note")
@@ -52,16 +53,13 @@ class TestNaming(unittest.TestCase):
self.assertEqual(country.name, country.country_name)
def test_child_table_naming(self):
- child_dt_with_naming = new_doctype(
- "childtable_with_autonaming", istable=1, autoname="field:some_fieldname"
- ).insert()
+ child_dt_with_naming = new_doctype(istable=1, autoname="field:some_fieldname").insert()
dt_with_child_autoname = new_doctype(
- "dt_with_childtable_naming",
fields=[
{
"label": "table with naming",
"fieldname": "table_with_naming",
- "options": "childtable_with_autonaming",
+ "options": child_dt_with_naming.name,
"fieldtype": "Table",
}
],
@@ -69,7 +67,7 @@ class TestNaming(unittest.TestCase):
name = frappe.generate_hash(length=10)
- doc = frappe.new_doc("dt_with_childtable_naming")
+ doc = frappe.new_doc(dt_with_child_autoname.name)
doc.append("table_with_naming", {"some_fieldname": name})
doc.save()
self.assertEqual(doc.table_with_naming[0].name, name)
@@ -89,31 +87,18 @@ class TestNaming(unittest.TestCase):
"""
Test if braced params are replaced in format autoname
"""
- doctype = "ToDo"
-
- todo_doctype = frappe.get_doc("DocType", doctype)
- todo_doctype.autoname = "format:TODO-{MM}-{status}-{##}"
- todo_doctype.save()
+ doctype = new_doctype(autoname="format:TODO-{MM}-{some_fieldname}-{##}").insert()
description = "Format"
- todo = frappe.new_doc(doctype)
- todo.description = description
- todo.insert()
+ doc = frappe.new_doc(doctype.name)
+ doc.some_fieldname = description
+ doc.insert()
series = getseries("", 2)
+ series = int(series) - 1
- series = str(int(series) - 1)
-
- if len(series) < 2:
- series = "0" + series
-
- self.assertEqual(
- todo.name,
- "TODO-{month}-{status}-{series}".format(
- month=now_datetime().strftime("%m"), status=todo.status, series=series
- ),
- )
+ self.assertEqual(doc.name, f"TODO-{now_datetime().strftime('%m')}-{description}-{series:02}")
def test_format_autoname_for_consecutive_week_number(self):
"""
@@ -303,6 +288,46 @@ class TestNaming(unittest.TestCase):
dt.delete(ignore_permissions=True)
+ def test_naming_series_prefix(self):
+ today = now_datetime()
+ year = today.strftime("%y")
+ month = today.strftime("%m")
+
+ prefix_test_cases = {
+ "SINV-.YY.-.####": f"SINV-{year}-",
+ "SINV-.YY.-.MM.-.####": f"SINV-{year}-{month}-",
+ "SINV": "SINV",
+ "SINV-.": "SINV-",
+ }
+
+ for series, prefix in prefix_test_cases.items():
+ self.assertEqual(prefix, NamingSeries(series).get_prefix())
+
+ def test_naming_series_validation(self):
+ dns = frappe.get_doc("Document Naming Settings")
+ exisiting_series = dns.get_transactions_and_prefixes()["prefixes"]
+ valid = ["SINV-", "SI-.{field}.", "SI-#.###", ""] + exisiting_series
+ invalid = ["$INV-", r"WINDOWS\NAMING"]
+
+ for series in valid:
+ if series.strip():
+ try:
+ NamingSeries(series).validate()
+ except Exception as e:
+ self.fail(f"{series} should be valid\n{e}")
+
+ for series in invalid:
+ self.assertRaises(InvalidNamingSeriesError, NamingSeries(series).validate)
+
+ def test_naming_using_fields(self):
+
+ webhook = frappe.new_doc("Webhook")
+ webhook.webhook_docevent = "on_update"
+ name = NamingSeries("KOOH-.{webhook_docevent}.").generate_next_name(webhook)
+ self.assertTrue(
+ name.startswith("KOOH-on_update"), f"incorrect name generated {name}, missing field value"
+ )
+
def make_invalid_todo():
frappe.get_doc({"doctype": "ToDo", "description": "Test"}).insert(set_name="ToDo")
diff --git a/frappe/tests/test_pdf.py b/frappe/tests/test_pdf.py
index 497546ebd5..8f2a2c1cfa 100644
--- a/frappe/tests/test_pdf.py
+++ b/frappe/tests/test_pdf.py
@@ -3,7 +3,7 @@
import io
import unittest
-from PyPDF2 import PdfFileReader
+from PyPDF2 import PdfReader
import frappe
import frappe.utils.pdf as pdfgen
@@ -42,7 +42,7 @@ class TestPdf(unittest.TestCase):
def test_pdf_encryption(self):
password = "qwe"
pdf = pdfgen.get_pdf(self.html, options={"password": password})
- reader = PdfFileReader(io.BytesIO(pdf))
+ reader = PdfReader(io.BytesIO(pdf))
self.assertTrue(reader.isEncrypted)
self.assertTrue(reader.decrypt(password))
diff --git a/frappe/tests/test_permissions.py b/frappe/tests/test_permissions.py
index 4164b0be36..26d5c714ef 100644
--- a/frappe/tests/test_permissions.py
+++ b/frappe/tests/test_permissions.py
@@ -672,3 +672,31 @@ class TestPermissions(FrappeTestCase):
doctype="Has Role",
parent_doctype="Has Role",
)
+
+ def test_select_user(self):
+ """If test3@example.com is restricted by a User Permission to see only
+ users linked to a certain doctype (in this case: Gender "Female"), he
+ should not be able to query other users (Gender "Male").
+ """
+ # ensure required genders exist
+ for gender in ("Male", "Female"):
+ if frappe.db.exists("Gender", gender):
+ continue
+
+ frappe.get_doc({"doctype": "Gender", "gender": gender}).insert()
+
+ # asssign gender to test users
+ frappe.db.set_value("User", "test1@example.com", "gender", "Male")
+ frappe.db.set_value("User", "test2@example.com", "gender", "Female")
+ frappe.db.set_value("User", "test3@example.com", "gender", "Female")
+
+ # restrict test3@example.com to see only female users
+ add_user_permission("Gender", "Female", "test3@example.com")
+
+ # become user test3@example.com and see what users he can query
+ frappe.set_user("test3@example.com")
+ users = frappe.get_list("User", pluck="name")
+
+ self.assertNotIn("test1@example.com", users)
+ self.assertIn("test2@example.com", users)
+ self.assertIn("test3@example.com", users)
diff --git a/frappe/tests/test_rename_doc.py b/frappe/tests/test_rename_doc.py
index 8bf76b3e13..928953fe1c 100644
--- a/frappe/tests/test_rename_doc.py
+++ b/frappe/tests/test_rename_doc.py
@@ -100,8 +100,6 @@ class TestRenameDoc(unittest.TestCase):
frappe.delete_doc("DocType", dt)
frappe.db.sql_ddl(f"DROP TABLE IF EXISTS `tab{dt}`")
- frappe.delete_doc_if_exists("Renamed Doc", "ToDo")
-
# reset original value of developer_mode conf
frappe.conf.developer_mode = self._original_developer_flag
diff --git a/frappe/tests/test_scheduler.py b/frappe/tests/test_scheduler.py
index 5161e1e80f..c6b381e487 100644
--- a/frappe/tests/test_scheduler.py
+++ b/frappe/tests/test_scheduler.py
@@ -1,18 +1,16 @@
+import os
import time
from unittest import TestCase
+from unittest.mock import patch
import frappe
-from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
+from frappe.core.doctype.scheduled_job_type.scheduled_job_type import ScheduledJobType, sync_jobs
from frappe.utils import add_days, get_datetime
from frappe.utils.background_jobs import enqueue
from frappe.utils.doctor import purge_pending_jobs
from frappe.utils.scheduler import enqueue_events, is_dormant, schedule_jobs_based_on_activity
-def test_timeout():
- time.sleep(100)
-
-
def test_timeout_10():
time.sleep(10)
@@ -23,6 +21,11 @@ def test_method():
class TestScheduler(TestCase):
def setUp(self):
+ frappe.db.rollback()
+
+ if not os.environ.get("CI"):
+ return
+
purge_pending_jobs()
if not frappe.get_all("Scheduled Job Type", limit=1):
sync_jobs()
@@ -44,15 +47,9 @@ class TestScheduler(TestCase):
def test_queue_peeking(self):
job = get_test_job()
- self.assertTrue(job.enqueue())
- job.db_set("last_execution", "2010-01-01 00:00:00")
- frappe.db.commit()
-
- time.sleep(0.5)
-
- # 1st job is in the queue (or running), don't enqueue it again
- self.assertFalse(job.enqueue())
- frappe.db.delete("Scheduled Job Log", {"scheduled_job_type": job.name})
+ with patch.object(job, "is_job_in_queue", return_value=True):
+ # 1st job is in the queue (or running), don't enqueue it again
+ self.assertFalse(job.enqueue())
def test_is_dormant(self):
self.assertTrue(is_dormant(check_time=get_datetime("2100-01-01 00:00:00")))
@@ -88,22 +85,10 @@ class TestScheduler(TestCase):
)
)
- frappe.db.rollback()
- def test_job_timeout(self):
- return
- job = enqueue(test_timeout, timeout=10)
- count = 5
- while count > 0:
- count -= 1
- time.sleep(5)
- if job.get_status() == "failed":
- break
-
- self.assertTrue(job.is_failed)
-
-
-def get_test_job(method="frappe.tests.test_scheduler.test_timeout_10", frequency="All"):
+def get_test_job(
+ method="frappe.tests.test_scheduler.test_timeout_10", frequency="All"
+) -> ScheduledJobType:
if not frappe.db.exists("Scheduled Job Type", dict(method=method)):
job = frappe.get_doc(
dict(
diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py
index 04f9d16fd1..2a8d27cd19 100644
--- a/frappe/tests/test_utils.py
+++ b/frappe/tests/test_utils.py
@@ -30,13 +30,17 @@ from frappe.utils import (
validate_url,
)
from frappe.utils.data import (
+ add_to_date,
cast,
+ get_first_day_of_week,
get_time,
get_timedelta,
+ getdate,
now_datetime,
nowtime,
validate_python_code,
)
+from frappe.utils.dateutils import get_dates_from_timegrain
from frappe.utils.diff import _get_value_from_version, get_version_diff, version_query
from frappe.utils.image import optimize_image, strip_exif_data
from frappe.utils.response import json_handler
@@ -445,6 +449,31 @@ class TestDateUtils(unittest.TestCase):
self.assertIsInstance(get_timedelta(str(timedelta_input)), timedelta)
self.assertIsInstance(get_timedelta(str(time_input)), timedelta)
+ def test_date_from_timegrain(self):
+ start_date = getdate("2021-01-01")
+
+ daily = get_dates_from_timegrain(start_date, add_to_date(start_date, days=6), "Daily")
+ self.assertEqual(len(daily), 7)
+ for idx, d in enumerate(daily):
+ self.assertEqual(d, add_to_date(start_date, days=idx))
+
+ start = get_first_day_of_week(start_date)
+ end = add_to_date(add_to_date(start, weeks=52), days=-1)
+ weekly = get_dates_from_timegrain(start, end, "Weekly")
+ self.assertEqual(len(weekly), 52)
+ for idx, d in enumerate(weekly, start=1):
+ self.assertEqual(d, add_to_date(start, days=7 * idx - 1))
+
+ quarterly = get_dates_from_timegrain(start_date, add_to_date(start_date, months=5), "Quarterly")
+ self.assertEqual(len(quarterly), 2)
+ for idx, d in enumerate(quarterly, start=1):
+ self.assertEqual(d, add_to_date(start_date, months=idx * 3, days=-1))
+
+ yearly = get_dates_from_timegrain(start_date, add_to_date(start_date, years=2), "Yearly")
+ self.assertEqual(len(yearly), 3)
+ for idx, d in enumerate(yearly, start=1):
+ self.assertEqual(d, add_to_date(start_date, years=idx, days=-1))
+
class TestResponse(unittest.TestCase):
def test_json_handler(self):
@@ -615,3 +644,29 @@ class TestAppParser(unittest.TestCase):
self.assertEqual("healthcare", parse_app_name("https://github.com/frappe/healthcare.git"))
self.assertEqual("healthcare", parse_app_name("git@github.com:frappe/healthcare.git"))
self.assertEqual("healthcare", parse_app_name("frappe/healthcare@develop"))
+
+
+class TestIntrospectionMagic(unittest.TestCase):
+ """Test utils that inspect live objects"""
+
+ def test_get_newargs(self):
+ # `kwargs` is just convention any **varname should work.
+ def f(a, b=2, **args):
+ pass
+
+ safe_kwargs = {"company": "Wind Power", "b": 1}
+ self.assertEqual(frappe.get_newargs(f, safe_kwargs), safe_kwargs)
+
+ unsafe_args = dict(safe_kwargs)
+ unsafe_args.update({"ignore_permissions": True, "flags": {"ignore_mandatory": True}})
+ self.assertEqual(frappe.get_newargs(f, unsafe_args), safe_kwargs)
+
+ def test_strip_off_kwargs_when_not_supported(self):
+ def f(a, b=2):
+ pass
+
+ args = {"company": "Wind Power", "b": 1}
+ self.assertEqual(frappe.get_newargs(f, args), {"b": 1})
+
+ # No args
+ self.assertEqual(frappe.get_newargs(lambda: None, args), {})
diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py
index 37ac611b4e..9478c4cf5f 100644
--- a/frappe/tests/test_website.py
+++ b/frappe/tests/test_website.py
@@ -118,7 +118,7 @@ class TestWebsite(unittest.TestCase):
def test_error_page(self):
set_request(method="GET", path="/_test/problematic_page")
response = get_response()
- self.assertEqual(response.status_code, 500)
+ self.assertEqual(response.status_code, 417)
def test_login(self):
set_request(method="GET", path="/login")
diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py
index 7d00a0c1f9..fc26694d46 100644
--- a/frappe/tests/utils.py
+++ b/frappe/tests/utils.py
@@ -82,6 +82,8 @@ def _restore_thread_locals(flags):
frappe.local.realtime_log = []
frappe.local.conf = frappe._dict(frappe.get_site_config())
frappe.local.cache = {}
+ frappe.local.lang = "en"
+ frappe.local.lang_full_dict = None
@contextmanager
diff --git a/frappe/translate.py b/frappe/translate.py
index 3123eade48..eb26124ba8 100644
--- a/frappe/translate.py
+++ b/frappe/translate.py
@@ -48,6 +48,8 @@ TRANSLATE_PATTERN = re.compile(
# END: JS context search
r"[\s\n]*\)" # Closing function call ignore leading whitespace/newlines
)
+REPORT_TRANSLATE_PATTERN = re.compile('"([^:,^"]*):')
+CSV_STRIP_WHITESPACE_PATTERN = re.compile(r"{\s?([0-9]+)\s?}")
def get_language(lang_list: List = None) -> str:
@@ -602,7 +604,7 @@ def get_messages_from_report(name):
messages.extend(
[
(None, message)
- for message in re.findall('"([^:,^"]*):', report.query)
+ for message in REPORT_TRANSLATE_PATTERN.findall(report.query)
if is_translatable(message)
]
)
@@ -801,7 +803,7 @@ def write_csv_file(path, app_messages, lang_dict):
t = lang_dict.get(message, "")
# strip whitespaces
- translated_string = re.sub(r"{\s?([0-9]+)\s?}", r"{\g<1>}", t)
+ translated_string = CSV_STRIP_WHITESPACE_PATTERN.sub(r"{\g<1>}", t)
if translated_string:
w.writerow([message, translated_string, context])
diff --git a/frappe/translations/fr.csv b/frappe/translations/fr.csv
index 06a2473d3b..69bc47d4f6 100644
--- a/frappe/translations/fr.csv
+++ b/frappe/translations/fr.csv
@@ -419,6 +419,7 @@ Also adding the dependent currency field {0},Ajout également du champ de devise
Always use Account's Email Address as Sender,Toujours utiliser l'adresse Email du compte comme Expéditeur,
Always use Account's Name as Sender's Name,Toujours utiliser le nom du compte comme nom de l'expéditeur,
Amend,Nouv. version
+amend,Nouv. version
Amending,Nouv. version en cours,
Amount Based On Field,Montant Basé sur le Champ,
Amount Field,Champ du Montant,
@@ -799,9 +800,9 @@ DESC,DESC,
Daily Event Digest is sent for Calendar Events where reminders are set.,Un Récapitulatif Quotidien est envoyé pour les Événements du Calendrier ayant des rappels.,
Danger,Danger,
Dark Color,Couleur sombre,
-Dashboard Chart,Tableau de bord,
-Dashboard Chart Link,Lien de tableau de bord,
-Dashboard Chart Source,Source du graphique du tableau de bord,
+Dashboard Chart,Tableau de bord - indicateur,
+Dashboard Chart Link,Lien de tableau de bord - indicateur,
+Dashboard Chart Source,Source du graphique du tableau de bord - indicateur,
Dashboard Name,Nom du tableau de bord,
Dashboards,Tableaux de bord,
Data,Données,
@@ -2296,7 +2297,7 @@ Show Line Breaks after Sections,Afficher les Sauts de Ligne après Sections,
Show Permissions,Afficher les Autorisations,
Show Preview Popup,Afficher l'aperçu Popup,
Show Relapses,Afficher les Rechutes,
-Show Report,Rapport d'émission,
+Show Report,Afficher le rapport,
Show Section Headings,Voir la Section Titres,
Show Sidebar,Afficher la Barre Latérale,
Show Title,Afficher le Titre,
@@ -4641,7 +4642,7 @@ Not permitted to view {0},Non autorisé à afficher {0},
Camera,Caméra,
Invalid filter: {0},Filtre non valide: {0},
Let's Get Started,Commençons,
-Reports & Masters,Rapports et masters,
+Reports & Masters,Ecrans principaux et Rapports,
New {0} {1} added to Dashboard {2},Nouveau {0} {1} ajouté au tableau de bord {2},
New {0} {1} created,Nouveau {0} {1} créé,
New {0} Created,Nouveau {0} créé,
@@ -4701,8 +4702,8 @@ Value cannot be negative for,La valeur ne peut pas être négative pour,
Value cannot be negative for {0}: {1},La valeur ne peut pas être négative pour {0}: {1},
Negative Value,Valeur négative,
Authentication failed while receiving emails from Email Account: {0}.,L'authentification a échoué lors de la réception des e-mails du compte de messagerie: {0}.,
-Message from server: {0},Message du serveur: {0},
-{0} edited this {1},{0} a édité {1},
+Message from server: {0},Message du serveur: {0}
+{0} edited this {1},{0} a édité {1}
{0} created this {1},{0} a créé {1}
Report an Issue,Signaler une anomalie
User Forum,Forum utilisateur
@@ -4717,3 +4718,11 @@ Document has been cancelled,Document annulé
Document is in draft state,Document au statut brouillon
Copy to Clipboard,Copier vers le presse-papiers
Don't have an account?,Vous n'avez pas de compte?
+Left:alignment,Gauche
+Right:alignment,Droite
+Set Properties,Gérer les proriétés
+Create Workspace,Créer un espace de travail
+Always use this email address as sender address,Toujours utiliser cet email comme expediteur
+Always use this name as sender name,Toujours utiliser ce nom comme expediteur
+Login to {0},Se connecter à {0}
+Add / Remove Fields,Ajouter / Supprimer des colonnes
diff --git a/frappe/translations/ru.csv b/frappe/translations/ru.csv
index 3fdeab5546..94a87bdcf8 100644
--- a/frappe/translations/ru.csv
+++ b/frappe/translations/ru.csv
@@ -840,7 +840,7 @@ Default Sending and Inbox,По умолчанию отправка и получ
Default Sort Field,Поле сортировки по умолчанию,
Default Sort Order,Порядок сортировки по умолчанию,
Default Value,Значение по умолчанию,
-"Default: ""Contact Us""","По умолчанию: ""Обратная связь""",
+"Default: ""Contact Us""","По умолчанию: ""Contact Us""",
DefaultValue,DefaultValue,
Define workflows for forms.,Определите рабочие процессы для форм.,
Defines actions on states and the next step and allowed roles.,"Определяет действия на статусах, следующий шаг и роли, обладающие правами перевода статусов.",
@@ -849,7 +849,7 @@ Delayed,Задерживается,
Delete Data,Удалить данные,
Delete comment?,Удалить комментарий?,
Delete this record to allow sending to this email address,"Удалить эту запись, чтобы разрешить отправку на этот адрес электронной почты",
-Delete {0} items permanently?,Удалить {0} продуктов навсегда?,
+Delete {0} items permanently?,Удалить {0} объектов навсегда?,
Deleted,Удаленный,
Deleted DocType,Удаленный DocType,
Deleted Document,Удаленный документ,
@@ -914,7 +914,7 @@ Document can't saved.,Документ не может быть сохранен
Document {0} has been set to state {1} by {2},Документ {0} установлен в состояние {1} на {2},
Documents,Документы,
Documents assigned to you and by you.,"Документы, назначенные вам и вами.",
-Domain Settings,Настройки домена,
+Domain Settings,Настройка сфер деятельности,
Domains HTML,Домены HTML,
"Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field","Не HTML Кодировать HTML-теги, такие как <скрипт> или просто символы, такие как <или>, так как они могут быть преднамеренно использованы в этой области",
Don't Override Status,Не переопределять статус,
@@ -990,7 +990,7 @@ Enable Auto Reply,Включить автоматический ответ,
Enable Automatic Backup,Включить автоматическое резервное копирование,
Enable Chat,Включить чат,
Enable Comments,Включить комментарии,
-Enable Incoming,Включение входящей,
+Enable Incoming,Включить входящие,
Enable Outgoing,Включить исходящие,
Enable Password Policy,Включить политику паролей,
Enable Print Server,Включить сервер печати,
@@ -1000,7 +1000,7 @@ Enable Scheduled Jobs,Включить запланированных задан
Enable Social Login,Включить социальный вход,
Enable Two Factor Auth,Включить двухфакторный аут,
Enabled email inbox for user {0},Включен почтовый ящик для пользователя {0},
-"Encryption key is invalid, Please check site_config.json","Ключ шифрования недействителен, проверьте сайт_config.json",
+"Encryption key is invalid, Please check site_config.json","Ключ шифрования недействителен, проверьте site_config.json",
End Date Field,Поле конечной даты,
End Date cannot be before Start Date!,Дата окончания не может быть до даты начала!,
Endpoint URL,URL конечной точки,
@@ -1029,8 +1029,8 @@ Error in Notification: {},Ошибка в уведомлении: {},
Error while connecting to email account {0},Ошибка при подключении к учетной записи электронной почты {0},
Error while evaluating Notification {0}. Please fix your template.,Ошибка при оценке уведомления {0}. Исправьте шаблон.,
Error: Document has been modified after you have opened it,"Ошибка: документ был изменен после того, как вы открыли его",
-Error: Value missing for {0}: {1},Ошибка: значение отсутствует для {0}: {1},
-Errors in Background Events,Ошибки в фоновых событий,
+Error: Value missing for {0}: {1},Ошибка: отсутствует значение для {0}: {1},
+Errors in Background Events,Ошибки в фоновых событиях,
Event Category,Категория события,
Event Participants,Участники мероприятия,
Event Type,Тип события,
@@ -1184,7 +1184,7 @@ Get Contacts,Получить контакты,
Get Fields,Получить поля,
Get your globally recognized avatar from Gravatar.com,Получить всемирно признанный аватара из Gravatar.com,
GitHub,GitHub,
-Give Review Points,Дайте очки обзора,
+Give Review Points,Дайте баллы обзора,
Global Unsubscribe,Глобальная отписка,
Go to the document,Перейти к документу,
Go to this URL after completing the form (only for Guest users),Перейдите по этому URL-адресу после заполнения формы (только для гостевых пользователей),
@@ -1461,8 +1461,8 @@ Letter Head Name,Название заголовка письма,
Letter Head in HTML,Заголовок письма в HTML,
Level Name,Название уровня,
Liked,Понравилось,
-Liked By,В избранное К,
-Liked by {0},В избранное {0},
+Liked By,Нравится,
+Liked by {0},Нравится {0},
Likes,Понравившееся,
Limit Number of DB Backups,Ограничение количества резервных копий БД,
Line,Линия,
@@ -1470,7 +1470,7 @@ Link DocType,Ссылка DocType,
Link Expired,Срок действия ссылки,
Link Name,Имя ссылки,
Link Title,Название ссылки,
-"Link that is the website home page. Standard Links (index, login, products, blog, about, contact)","Ссылка, которая является стартовой страницей сайта. Стандартные ссылки (индекс, логин, продукты, блог, о, контакт)",
+"Link that is the website home page. Standard Links (index, login, products, blog, about, contact)","Ссылка, которая является стартовой страницей сайта. Стандартные ссылки (index, login, products, blog, about, contact)",
Link to the page you want to open. Leave blank if you want to make it a group parent.,"Ссылка на страницу, которую вы хотите открыть. Оставьте пустым, если хотите сделать его родительским элементом группы.",
Linked,Связанный,
Linked With,Связанные с,
@@ -2096,7 +2096,7 @@ Revert Of,Вернуть из,
Reverted,Отменено,
Review Level,Уровень обзора,
Review Levels,Уровни обзора,
-Review Points,Очки обзора,
+Review Points,Баллы обзора,
Reviews,Отзывы,
Revoke,Аннулировать,
Revoked,Аннулировано,
@@ -2141,10 +2141,10 @@ SMS sent to following numbers: {0},SMS отправлено следующим
SMTP Server,SMTP-сервер,
SMTP Settings for outgoing emails,Настройки SMTP для исходящих писем,
"SQL Conditions. Example: status=""Open""",SQL условия. Пример: статус = "Открыть",
-SSL/TLS Mode,Режим SSL / TLS,
+SSL/TLS Mode,Режим SSL/TLS,
Salesforce,Salesforce,
Same Field is entered more than once,Одно и то же поле вводится не один раз,
-Save API Secret: ,Сохранить API-интерфейс:,
+Save API Secret: ,Сохранить API секрет: ,
Save As,Сохранить как,
Save Filter,Сохранить фильтр,
Save Report,Сохранить отчет,
@@ -2258,7 +2258,7 @@ Set Property After Alert,Задать свойство после оповеще
Set Quantity,Установите Количество,
Set Role For,Установить роль для,
Set User Permissions,Задание разрешений пользователя,
-Set Value,Задать значение,
+Set Value,Установить значение,
Set custom roles for page and report,Набор пользовательских ролей для страницы и отчета,
"Set default format, page size, print style etc.","Установить форму, размер страницы, стиль печати и т.д., используюмых по умолчанию",
Set non-standard precision for a Float or Currency field,Установите нестандартные точность для поплавка или валютной области,
@@ -2337,7 +2337,7 @@ Slideshow like display for the website,"Слайд-шоу, как дисплей
Small Text,Маленьикий текст,
Smallest Currency Fraction Value,Минимальное дробное значение,
Smallest circulating fraction unit (coin). For e.g. 1 cent for USD and it should be entered as 0.01,"Минимальная разменная денежная единица (монета). Например, для доллара — 1 цент, и его нужно ввести как 0,01",
-Snapshot View,Снимок Посмотреть,
+Snapshot View,Просмотр снимка,
Social,Сообщество,
Social Login Key,Ключ социального входа,
Social Login Provider,Социальный провайдер,
@@ -2418,7 +2418,7 @@ Suspend Sending,Приостановить Отправка,
Switch To Desk,Переключение на рабочий стол,
Symbol,Символ,
Sync,Синхронизация,
-Sync on Migrate,Синхронизация по Migrate,
+Sync on Migrate,Синхронизировать при переносе,
Syntax error in template,Синтаксическая ошибка в шаблоне,
System,Система,
System Page,Страница системы,
@@ -2450,7 +2450,7 @@ Thank you for your interest in subscribing to our updates,Спасибо за в
Thank you for your message,Спасибо за ваше сообщение,
The CSV format is case sensitive,Формат CSV чувствителен к регистру,
The Condition '{0}' is invalid,Условие '{0}' является недействительным,
-The First User: You,Первый пользователя: Вы,
+The First User: You,Первый пользователь: Вы,
"The application has been updated to a new version, please refresh this page","Приложение был обновлен до новой версии, пожалуйста, обновите эту страницу",
The attachments could not be correctly linked to the new document,Вложения не могут быть правильно связаны с новым документом,
The document could not be correctly assigned,Документ не может быть правильно назначен,
@@ -2653,7 +2653,7 @@ User Field,Поле пользователя,
User ID of a Blogger,ID пользователя-блоггера,
User Image,Изображение пользователя,
User Name,Имя пользователя,
-User Permission,Пользователь Введено,
+User Permission,Разрешения пользователя,
User Permissions,Разрешения пользователей,
User Permissions are used to limit users to specific records.,Пользовательские разрешения используются для ограничения пользователей конкретными записями.,
User Permissions created sucessfully,Пользовательские разрешения созданы успешно,
@@ -3068,8 +3068,8 @@ zoom-out,отдалить,
{0} or {1},{0} или {1},
{0} record deleted,{0} запись удалена,
{0} records deleted,{0} записей удалено,
-{0} reverted your point on {1},{0} вернул вашу точку на {1},
-{0} reverted your points on {1},{0} вернул ваши очки на {1},
+{0} reverted your point on {1},{0} вернул ваш балл на {1},
+{0} reverted your points on {1},{0} вернул ваши баллы на {1},
{0} reverted {1},{0} вернул {1},
{0} room must have atmost one user.,{0} номер должен иметь самого одного пользователя.,
{0} rows for {1},{0} строк для {1},
@@ -3148,7 +3148,7 @@ Access not allowed from this IP Address,Доступ с этого IP-адрес
Action Type,Тип действия,
Activity Log by ,Активность Журнал по,
Add Fields,Добавить поля,
-Administration,Администрация,
+Administration,Администрирование,
After Cancel,После отмены,
After Delete,После удаления,
After Save,После сохранения,
@@ -3157,7 +3157,7 @@ After Submit,После отправки,
Aggregate Function Based On,"Агрегатная функция, основанная на",
Aggregate Function field is required to create a dashboard chart,Поле Aggregate Function необходимо для создания диаграммы панели мониторинга.,
All Records,Все записи,
-Allot Points To Assigned Users,Выделить очки назначенным пользователям,
+Allot Points To Assigned Users,Выделить баллы назначенным пользователям,
Allow Auto Repeat,Разрешить автоматическое повторение,
Allow Google Calendar Access,Разрешить доступ к Календарю Google,
Allow Google Contacts Access,Разрешить доступ к контактам Google,
@@ -3380,7 +3380,7 @@ Invalid field name: {0},Неверное имя поля: {0},
Invalid file URL. Please contact System Administrator.,"Неверный URL файла. Пожалуйста, свяжитесь с системным администратором.",
Invalid include path,Неверный путь включения,
Invalid username or password,неправильное имя пользователя или пароль,
-Is Primary,Первичный,
+Is Primary,Основной,
Is Primary Mobile,Основной мобильный,
Is Primary Phone,Основной телефон,
Is Tree,Дерево,
@@ -3667,7 +3667,7 @@ via Data Import,через импорт данных,
{0} are mandatory fields,{0} обязательные поля,
{0} are required,{0} требуется,
{0} assigned a new task {1} {2} to you,{0} назначил вам новое задание {1} {2},
-{0} gained {1} point for {2} {3},{0} набрал {1} очко за {2} {3},
+{0} gained {1} point for {2} {3},{0} получил {1} балл за {2} {3},
{0} gained {1} points for {2} {3},{0} набрал {1} баллов за {2} {3},
{0} has no versions tracked.,{0} не отслеживает версии.,
{0} is not a valid report format. Report format should one of the following {1},{0} не является допустимым форматом отчета. Формат отчета должен быть одним из следующих {1},
@@ -4045,7 +4045,7 @@ No Permitted Charts on this Dashboard,На этой панели инструм
No Permitted Charts,Нет разрешенных графиков,
Reset Chart,Сбросить график,
via {0},через {0},
-{0} is not a valid Phone Number,{0} не является действительным номером телефона,
+{0} is not a valid Phone Number,{0} недействительный номер телефона,
Failed Transactions,Неудачные транзакции,
Value for field {0} is too long in {1}. Length should be lesser than {2} characters,Значение поля {0} слишком длинное в {1}. Длина должна быть меньше {2} симв.,
Data Too Long,Данные слишком длинные,
@@ -4121,8 +4121,8 @@ Using this console may allow attackers to impersonate you and steal your informa
{0} w,{0} н,
{0} M,{0} М,
{0} y,{0} г,
-yesterday,вчерашний день,
-{0} years ago,{0} лет назад,
+yesterday,вчера,
+{0} years ago,{0} год назад,
New Chart,Новый график,
New Shortcut,Новый ярлык,
Edit Chart,Изменить диаграмму,
@@ -4700,3 +4700,11 @@ Value cannot be negative for {0}: {1},Значение не может быть
Negative Value,Отрицательное значение,
Authentication failed while receiving emails from Email Account: {0}.,Ошибка аутентификации при получении писем из учетной записи электронной почты: {0}.,
Message from server: {0},Сообщение с сервера: {0},
+Documentation,Документация,
+User Forum,Форум пользователей,
+Report an issue,Сообщить об ошибке,
+My Profile,Мой профиль,
+My Settings,Мои настройки,
+Toggle Full Width,Переключить ширину,
+Toggle Theme,Переключить тему,
+Modules,Модули,
diff --git a/frappe/twofactor.py b/frappe/twofactor.py
index 5a2799bc54..9e9a2c5d76 100644
--- a/frappe/twofactor.py
+++ b/frappe/twofactor.py
@@ -277,7 +277,9 @@ def get_email_subject_for_qr_code(kwargs_dict):
def get_email_body_for_qr_code(kwargs_dict):
"""Get QRCode email body."""
- body_template = "Please click on the following link and follow the instructions on the page.
{{qrcode_link}}"
+ body_template = _(
+ "Please click on the following link and follow the instructions on the page. {0}"
+ ).format("
{{qrcode_link}}")
body = frappe.render_template(body_template, kwargs_dict)
return body
diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py
index 1af0ec6a39..6872ace7d8 100644
--- a/frappe/utils/__init__.py
+++ b/frappe/utils/__init__.py
@@ -27,6 +27,16 @@ import frappe
from frappe.utils.data import *
from frappe.utils.html_utils import sanitize_html
+EMAIL_NAME_PATTERN = re.compile(r"[^A-Za-z0-9\u00C0-\u024F\/\_\' ]+")
+EMAIL_STRING_PATTERN = re.compile(r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)")
+NON_MD_HTML_PATTERN = re.compile(r"
| ")
+HTML_TAGS_PATTERN = re.compile(r"\<[^>]*\>")
+INCLUDE_DIRECTIVE_PATTERN = re.compile("""({% include ['"]([^'"]*)['"] %})""")
+PHONE_NUMBER_PATTERN = re.compile(r"([0-9\ \+\_\-\,\.\*\#\(\)]){1,20}$")
+PERSON_NAME_PATTERN = re.compile(r"^[\w][\w\'\-]*( \w[\w\'\-]*)*$")
+WHITESPACE_PATTERN = re.compile(r"[\t\n\r]")
+MULTI_EMAIL_STRING_PATTERN = re.compile(r'[,\n](?=(?:[^"]|"[^"]*")*$)')
+
def get_fullname(user=None):
"""get the full name (first name + last name) of the user from User"""
@@ -116,7 +126,7 @@ def validate_phone_number(phone_number, throw=False):
return False
phone_number = phone_number.strip()
- match = re.match(r"([0-9\ \+\_\-\,\.\*\#\(\)]){1,20}$", phone_number)
+ match = PHONE_NUMBER_PATTERN.match(phone_number)
if not match and throw:
frappe.throw(
@@ -135,7 +145,7 @@ def validate_name(name, throw=False):
return False
name = name.strip()
- match = re.match(r"^[\w][\w\'\-]*( \w[\w\'\-]*)*$", name)
+ match = PERSON_NAME_PATTERN.match(name)
if not match and throw:
frappe.throw(frappe._("{0} is not a valid Name").format(name), frappe.InvalidNameError)
@@ -201,8 +211,8 @@ def split_emails(txt):
email_list = []
# emails can be separated by comma or newline
- s = re.sub(r"[\t\n\r]", " ", cstr(txt))
- for email in re.split(r'[,\n](?=(?:[^"]|"[^"]*")*$)', s):
+ s = WHITESPACE_PATTERN.sub(" ", cstr(txt))
+ for email in MULTI_EMAIL_STRING_PATTERN.split(s):
email = strip(cstr(email))
if email:
email_list.append(email)
@@ -360,7 +370,7 @@ def remove_blanks(d):
def strip_html_tags(text):
"""Remove html tags from text"""
- return re.sub(r"\<[^>]*\>", "", text)
+ return HTML_TAGS_PATTERN.sub("", text)
def get_file_timestamp(fn):
@@ -584,7 +594,7 @@ def get_html_format(print_path):
with open(print_path, "r") as f:
html_format = f.read()
- for include_directive, path in re.findall("""({% include ['"]([^'"]*)['"] %})""", html_format):
+ for include_directive, path in INCLUDE_DIRECTIVE_PATTERN.findall(html_format):
for app_name in frappe.get_installed_apps():
include_path = frappe.get_app_path(app_name, *path.split(os.path.sep))
if os.path.exists(include_path):
@@ -601,7 +611,7 @@ def is_markdown(text):
elif "" in text:
return False
else:
- return not re.search(r"
| ", text)
+ return not NON_MD_HTML_PATTERN.search(text)
def get_sites(sites_path=None):
@@ -670,8 +680,7 @@ def parse_addr(email_string):
name = get_name_from_email_string(email_string, email, name)
return (name, email)
else:
- email_regex = re.compile(r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)")
- email_list = re.findall(email_regex, email_string)
+ email_list = EMAIL_STRING_PATTERN.findall(email_string)
if len(email_list) > 0 and check_format(email_list[0]):
# take only first email address
email = email_list[0]
@@ -698,7 +707,7 @@ def check_format(email_id):
def get_name_from_email_string(email_string, email_id, name):
name = email_string.replace(email_id, "")
- name = re.sub(r"[^A-Za-z0-9\u00C0-\u024F\/\_\' ]+", "", name).strip()
+ name = EMAIL_NAME_PATTERN.sub("", name).strip()
if not name:
name = email_id
return name
diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py
index ce8e44665a..f49c641673 100755
--- a/frappe/utils/background_jobs.py
+++ b/frappe/utils/background_jobs.py
@@ -3,7 +3,7 @@ import socket
import time
from collections import defaultdict
from functools import lru_cache
-from typing import List
+from typing import TYPE_CHECKING, List
from uuid import uuid4
import redis
@@ -19,6 +19,9 @@ from frappe.utils import cstr, get_bench_id
from frappe.utils.commands import log
from frappe.utils.redis_queue import RedisQueue
+if TYPE_CHECKING:
+ from rq.job import Job
+
@lru_cache()
def get_queues_timeout():
@@ -52,7 +55,7 @@ def enqueue(
*,
at_front=False,
**kwargs,
-):
+) -> "Job":
"""
Enqueue method to be executed using a background worker
diff --git a/frappe/utils/boilerplate.py b/frappe/utils/boilerplate.py
index 22ca64eb1a..edb742feb4 100644
--- a/frappe/utils/boilerplate.py
+++ b/frappe/utils/boilerplate.py
@@ -11,6 +11,8 @@ import git
import frappe
from frappe.utils import touch_file
+APP_TITLE_PATTERN = re.compile(r"^(?![\W])[^\d_\s][\w -]+$", flags=re.UNICODE)
+
def make_boilerplate(dest, app_name, no_git=False):
if not os.path.exists(dest):
@@ -67,7 +69,7 @@ def _get_user_inputs(app_name):
def is_valid_title(title) -> bool:
- if not re.match(r"^(?![\W])[^\d_\s][\w -]+$", title, re.UNICODE):
+ if not APP_TITLE_PATTERN.match(title):
print(
"App Title should start with a letter and it can only consist of letters, numbers, spaces and underscores"
)
@@ -488,7 +490,7 @@ jobs:
uses: actions/cache@v2
with:
path: ~/.cache/pip
- key: ${{{{ runner.os }}}}-pip-${{{{ hashFiles('**/*requirements.txt') }}}}
+ key: ${{{{ runner.os }}}}-pip-${{{{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }}}}
restore-keys: |
${{{{ runner.os }}}}-pip-
${{{{ runner.os }}}}-
diff --git a/frappe/utils/caching.py b/frappe/utils/caching.py
new file mode 100644
index 0000000000..326dacfd5a
--- /dev/null
+++ b/frappe/utils/caching.py
@@ -0,0 +1,130 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. Check LICENSE
+
+import json
+from collections import defaultdict
+from datetime import datetime, timedelta
+from functools import wraps
+from typing import Callable, Dict, Optional, Tuple
+
+import frappe
+
+_SITE_CACHE = defaultdict(lambda: defaultdict(dict))
+
+
+def __generate_request_cache_key(args: Tuple, kwargs: Dict):
+ """Generate a key for the cache."""
+ if not kwargs:
+ return hash(args)
+ return hash((args, frozenset(kwargs.items())))
+
+
+def request_cache(func: Callable) -> Callable:
+ """Decorator to cache function calls mid-request. Cache is stored in
+ frappe.local.request_cache. The cache only persists for the current request
+ and is cleared when the request is over. The function is called just once
+ per request with the same set of (kw)arguments.
+
+ Usage:
+ from frappe.utils.caching import request_cache
+
+ @request_cache
+ def calculate_pi(num_terms=0):
+ import math, time
+ print(f"{num_terms = }")
+ time.sleep(10)
+ return math.pi
+
+ calculate_pi(10) # will calculate value
+ calculate_pi(10) # will return value from cache
+ """
+
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ if not getattr(frappe.local, "initialised", None):
+ return func(*args, **kwargs)
+ if not hasattr(frappe.local, "request_cache"):
+ frappe.local.request_cache = defaultdict(dict)
+
+ try:
+ args_key = __generate_request_cache_key(args, kwargs)
+ except Exception:
+ return func(*args, **kwargs)
+
+ try:
+ return frappe.local.request_cache[func][args_key]
+ except KeyError:
+ return_val = func(*args, **kwargs)
+ frappe.local.request_cache[func][args_key] = return_val
+ return return_val
+
+ return wrapper
+
+
+def site_cache(ttl: Optional[int] = None, maxsize: Optional[int] = None) -> Callable:
+ """Decorator to cache method calls across requests. The cache is stored in
+ frappe.utils.caching._SITE_CACHE. The cache persists on the parent process.
+ It offers a light-weight cache for the current process without the additional
+ overhead of serializing / deserializing Python objects.
+
+ Note: This cache isn't shared among workers. If you need to share data across
+ workers, use redis (frappe.cache API) instead.
+
+ Usage:
+ from frappe.utils.caching import site_cache
+
+ @site_cache
+ def calculate_pi():
+ import math, time
+ precision = get_precision("Math Constant", "Pi") # depends on site data
+ return round(math.pi, precision)
+
+ calculate_pi(10) # will calculate value
+ calculate_pi(10) # will return value from cache
+ calculate_pi.clear_cache() # clear this function's cache for all sites
+ calculate_pi(10) # will calculate value
+ """
+
+ def time_cache_wrapper(func: Callable = None) -> Callable:
+ func_key = f"{func.__module__}.{func.__name__}"
+
+ def clear_cache():
+ """Clear cache for this function for all sites if not specified."""
+ _SITE_CACHE[func_key].clear()
+
+ func.clear_cache = clear_cache
+
+ if ttl is not None and not callable(ttl):
+ func.ttl = ttl
+ func.expiration = datetime.utcnow() + timedelta(seconds=func.ttl)
+
+ if maxsize is not None and not callable(maxsize):
+ func.maxsize = maxsize
+
+ @wraps(func)
+ def site_cache_wrapper(*args, **kwargs):
+ if getattr(frappe.local, "initialised", None):
+ func_call_key = json.dumps((args, kwargs))
+
+ if hasattr(func, "ttl") and datetime.utcnow() >= func.expiration:
+ func.clear_cache()
+ func.expiration = datetime.utcnow() + timedelta(seconds=func.ttl)
+
+ if hasattr(func, "maxsize") and len(_SITE_CACHE[func_key][frappe.local.site]) >= func.maxsize:
+ _SITE_CACHE[func_key][frappe.local.site].pop(
+ next(iter(_SITE_CACHE[func_key][frappe.local.site])), None
+ )
+
+ if func_call_key not in _SITE_CACHE[func_key][frappe.local.site]:
+ _SITE_CACHE[func_key][frappe.local.site][func_call_key] = func(*args, **kwargs)
+
+ return _SITE_CACHE[func_key][frappe.local.site][func_call_key]
+
+ return func(*args, **kwargs)
+
+ return site_cache_wrapper
+
+ if callable(ttl):
+ return time_cache_wrapper(ttl)
+
+ return time_cache_wrapper
diff --git a/frappe/utils/data.py b/frappe/utils/data.py
index 6a9ffc81a6..49f9ead437 100644
--- a/frappe/utils/data.py
+++ b/frappe/utils/data.py
@@ -29,7 +29,22 @@ if typing.TYPE_CHECKING:
DATE_FORMAT = "%Y-%m-%d"
TIME_FORMAT = "%H:%M:%S.%f"
-DATETIME_FORMAT = DATE_FORMAT + " " + TIME_FORMAT
+DATETIME_FORMAT = f"{DATE_FORMAT} {TIME_FORMAT}"
+TIMEDELTA_DAY_PATTERN = re.compile(
+ r"(?P[-\d]+) day[s]*, (?P\d+):(?P\d+):(?P\d[\.\d+]*)"
+)
+TIMEDELTA_BASE_PATTERN = re.compile(r"(?P\d+):(?P\d+):(?P\d[\.\d+]*)")
+URLS_HTTP_TAG_PATTERN = re.compile(
+ r'(href|src){1}([\s]*=[\s]*[\'"]?)((?:http)[^\'">]+)([\'"]?)'
+) # href='https://...
+URLS_NOT_HTTP_TAG_PATTERN = re.compile(
+ r'(href|src){1}([\s]*=[\s]*[\'"]?)((?!http)[^\'" >]+)([\'"]?)'
+) # href=/assets/...
+URL_NOTATION_PATTERN = re.compile(
+ r'(:[\s]?url)(\([\'"]?)((?!http)[^\'" >]+)([\'"]?\))'
+) # background-image: url('/assets/...')
+DURATION_PATTERN = re.compile(r"^(?:(\d+d)?((^|\s)\d+h)?((^|\s)\d+m)?((^|\s)\d+s)?)$")
+HTML_TAG_PATTERN = re.compile("<[^>]+>")
class Weekday(Enum):
@@ -692,10 +707,7 @@ def duration_to_seconds(duration):
def validate_duration_format(duration):
- import re
-
- is_valid_duration = re.match(r"^(?:(\d+d)?((^|\s)\d+h)?((^|\s)\d+m)?((^|\s)\d+s)?)$", duration)
- if not is_valid_duration:
+ if not DURATION_PATTERN.match(duration):
frappe.throw(
frappe._("Value {0} must be in the valid duration format: d h m s").format(
frappe.bold(duration)
@@ -1297,7 +1309,7 @@ def in_words(integer: int, in_million=True) -> str:
def is_html(text: str) -> bool:
if not isinstance(text, str):
return False
- return re.search("<[^>]+>", text)
+ return HTML_TAG_PATTERN.search(text)
def is_image(filepath: str) -> bool:
@@ -1314,7 +1326,7 @@ def get_thumbnail_base64_for_image(src):
from PIL import Image
from frappe import cache, safe_decode
- from frappe.core.doctype.file.file import get_local_image
+ from frappe.core.doctype.file.utils import get_local_image
if not src:
frappe.throw("Invalid source for image: {0}".format(src))
@@ -1337,7 +1349,7 @@ def get_thumbnail_base64_for_image(src):
original_size = image.size
size = 50, 50
- image.thumbnail(size, Image.ANTIALIAS)
+ image.thumbnail(size, Image.Resampling.LANCZOS)
base64_string = image_to_base64(image, extn)
return {
@@ -1851,12 +1863,8 @@ def expand_relative_urls(html: str) -> str:
return "".join(to_expand)
- html = re.sub(
- r'(href|src){1}([\s]*=[\s]*[\'"]?)((?!http)[^\'" >]+)([\'"]?)', _expand_relative_urls, html
- )
-
- # background-image: url('/assets/...')
- html = re.sub(r'(:[\s]?url)(\([\'"]?)((?!http)[^\'" >]+)([\'"]?\))', _expand_relative_urls, html)
+ html = URLS_NOT_HTTP_TAG_PATTERN.sub(_expand_relative_urls, html)
+ html = URL_NOTATION_PATTERN.sub(_expand_relative_urls, html)
return html
@@ -1870,7 +1878,7 @@ def quote_urls(html: str) -> str:
groups[2] = quoted(groups[2])
return "".join(groups)
- return re.sub(r'(href|src){1}([\s]*=[\s]*[\'"]?)((?:http)[^\'">]+)([\'"]?)', _quote_url, html)
+ return URLS_HTTP_TAG_PATTERN.sub(_quote_url, html)
def unique(seq: typing.Sequence["T"]) -> List["T"]:
@@ -1887,6 +1895,15 @@ def strip(val: str, chars: Optional[str] = None) -> str:
return (val or "").replace("\ufeff", "").replace("\u200b", "").strip(chars)
+def get_string_between(start: str, string: str, end: str) -> str:
+ if not string:
+ return ""
+
+ out = re.search(f"{start}(.*){end}", string)
+
+ return out.group(1) if out else string
+
+
def to_markdown(html: str) -> str:
from html.parser import HTMLParser
@@ -2088,10 +2105,8 @@ def format_timedelta(o: datetime.timedelta) -> str:
def parse_timedelta(s: str) -> datetime.timedelta:
# ref: https://stackoverflow.com/a/21074460/10309266
if "day" in s:
- m = re.match(
- r"(?P[-\d]+) day[s]*, (?P\d+):(?P\d+):(?P\d[\.\d+]*)", s
- )
+ m = TIMEDELTA_DAY_PATTERN.match(s)
else:
- m = re.match(r"(?P\d+):(?P\d+):(?P\d[\.\d+]*)", s)
+ m = TIMEDELTA_BASE_PATTERN.match(s)
return datetime.timedelta(**{key: float(val) for key, val in m.groupdict().items()})
diff --git a/frappe/utils/dateutils.py b/frappe/utils/dateutils.py
index b8f22e7ed7..d7412a444f 100644
--- a/frappe/utils/dateutils.py
+++ b/frappe/utils/dateutils.py
@@ -106,11 +106,10 @@ def get_dates_from_timegrain(from_date, to_date, timegrain="Daily"):
months = 1
elif "Quarterly" == timegrain:
months = 3
+ elif "Yearly" == timegrain:
+ months = 1
- if "Weekly" == timegrain:
- dates = [get_last_day_of_week(from_date)]
- else:
- dates = [get_period_ending(from_date, timegrain)]
+ dates = [get_period_ending(from_date, timegrain)]
while getdate(dates[-1]) < getdate(to_date):
if "Weekly" == timegrain:
@@ -163,16 +162,12 @@ def get_period_beginning(date, timegrain, as_str=True):
def get_period_ending(date, timegrain):
- date = getdate(date)
- if timegrain == "Daily":
- return date
- else:
- return getdate(
- {
- "Daily": date,
- "Weekly": get_last_day_of_week(date),
- "Monthly": get_last_day(date),
- "Quarterly": get_quarter_ending(date),
- "Yearly": get_year_ending(date),
- }[timegrain]
- )
+ return getdate(
+ {
+ "Daily": date,
+ "Weekly": get_last_day_of_week(date),
+ "Monthly": get_last_day(date),
+ "Quarterly": get_quarter_ending(date),
+ "Yearly": get_year_ending(date),
+ }[timegrain]
+ )
diff --git a/frappe/utils/file_manager.py b/frappe/utils/file_manager.py
index ca5589c906..620962004a 100644
--- a/frappe/utils/file_manager.py
+++ b/frappe/utils/file_manager.py
@@ -449,3 +449,15 @@ def add_attachments(doctype, name, attachments):
files.append(f)
return files
+
+
+def is_safe_path(path: str) -> bool:
+ if path.startswith(("http://", "https://")):
+ return True
+
+ basedir = frappe.get_site_path()
+ # ref: https://docs.python.org/3/library/os.path.html#os.path.commonpath
+ matchpath = os.path.realpath(os.path.abspath(path))
+ basedir = os.path.realpath(os.path.abspath(basedir))
+
+ return basedir == os.path.commonpath((basedir, matchpath))
diff --git a/frappe/utils/formatters.py b/frappe/utils/formatters.py
index adf551580c..575a05a5c2 100644
--- a/frappe/utils/formatters.py
+++ b/frappe/utils/formatters.py
@@ -20,6 +20,8 @@ from frappe.utils import (
formatdate,
)
+BLOCK_TAGS_PATTERN = re.compile(r"( ")
elif df.get("fieldtype") == "Markdown Editor":
diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py
index 6e482baa78..af6d9a3c28 100644
--- a/frappe/utils/global_search.py
+++ b/frappe/utils/global_search.py
@@ -13,6 +13,8 @@ from frappe.utils import cint, strip_html_tags
from frappe.utils.data import cstr
from frappe.utils.html_utils import unescape_html
+HTML_TAGS_PATTERN = re.compile(r"(?s)<[\s]*(script|style).*?\1>")
+
def setup_global_search_table():
"""
@@ -360,7 +362,7 @@ def get_formatted_value(value, field):
if getattr(field, "fieldtype", None) in ["Text", "Text Editor"]:
value = unescape_html(frappe.safe_decode(value))
- value = re.subn(r"(?s)<[\s]*(script|style).*?\1>", "", str(value))[0]
+ value = HTML_TAGS_PATTERN.subn("", str(value))[0]
value = " ".join(value.split())
return field.label + " : " + strip_html_tags(str(value))
diff --git a/frappe/utils/html_utils.py b/frappe/utils/html_utils.py
index 8eac761220..b9d0e8dfe2 100644
--- a/frappe/utils/html_utils.py
+++ b/frappe/utils/html_utils.py
@@ -5,6 +5,16 @@ from bleach_allowlist import bleach_allowlist
import frappe
+EMOJI_PATTERN = re.compile(
+ "(\ud83d[\ude00-\ude4f])|"
+ "(\ud83c[\udf00-\uffff])|"
+ "(\ud83d[\u0000-\uddff])|"
+ "(\ud83d[\ude80-\udeff])|"
+ "(\ud83c[\udde0-\uddff])"
+ "+",
+ flags=re.UNICODE,
+)
+
def clean_html(html):
import bleach
@@ -181,28 +191,17 @@ def is_json(text):
def get_icon_html(icon, small=False):
from frappe.utils import is_image
- emoji_pattern = re.compile(
- "(\ud83d[\ude00-\ude4f])|"
- "(\ud83c[\udf00-\uffff])|"
- "(\ud83d[\u0000-\uddff])|"
- "(\ud83d[\ude80-\udeff])|"
- "(\ud83c[\udde0-\uddff])"
- "+",
- flags=re.UNICODE,
- )
-
icon = icon or ""
- if icon and emoji_pattern.match(icon):
- return '' + icon + ""
+
+ if icon and EMOJI_PATTERN.match(icon):
+ return f'{icon}'
if is_image(icon):
return (
- ''.format(icon=icon)
- if small
- else ''.format(icon=icon)
+ f'' if small else f''
)
else:
- return "".format(icon=icon)
+ return f""
def unescape_html(value):
diff --git a/frappe/utils/image.py b/frappe/utils/image.py
index 0cbc02fb31..8823ea3dfe 100644
--- a/frappe/utils/image.py
+++ b/frappe/utils/image.py
@@ -7,8 +7,6 @@ from PIL import Image
def resize_images(path, maxdim=700):
- from PIL import Image
-
size = (maxdim, maxdim)
for basepath, folders, files in os.walk(path):
for fname in files:
@@ -16,7 +14,7 @@ def resize_images(path, maxdim=700):
if extn in ("jpg", "jpeg", "png", "gif"):
im = Image.open(os.path.join(basepath, fname))
if im.size[0] > size[0] or im.size[1] > size[1]:
- im.thumbnail(size, Image.ANTIALIAS)
+ im.thumbnail(size, Image.Resampling.LANCZOS)
im.save(os.path.join(basepath, fname))
print("resized {0}".format(os.path.join(basepath, fname)))
@@ -56,7 +54,7 @@ def optimize_image(
image = Image.open(io.BytesIO(content))
image_format = content_type.split("/")[1]
size = max_width, max_height
- image.thumbnail(size, Image.LANCZOS)
+ image.thumbnail(size, Image.Resampling.LANCZOS)
output = io.BytesIO()
image.save(
diff --git a/frappe/utils/install.py b/frappe/utils/install.py
index 2ce067a018..fcf8f9d436 100644
--- a/frappe/utils/install.py
+++ b/frappe/utils/install.py
@@ -21,7 +21,7 @@ def after_install():
create_user_type()
install_basic_docs()
- from frappe.core.doctype.file.file import make_home_folder
+ from frappe.core.doctype.file.utils import make_home_folder
make_home_folder()
diff --git a/frappe/utils/logger.py b/frappe/utils/logger.py
index bdae92da51..f2ce8a14c3 100755
--- a/frappe/utils/logger.py
+++ b/frappe/utils/logger.py
@@ -7,10 +7,7 @@ from logging.handlers import RotatingFileHandler
import frappe
from frappe.utils import get_sites
-# imports - third party imports
-
-
-default_log_level = logging.DEBUG
+default_log_level = logging.WARNING if frappe._dev_server else logging.ERROR
def get_logger(
@@ -21,7 +18,7 @@ def get_logger(
max_size=100_000,
file_count=20,
stream_only=False,
-):
+) -> "logging.Logger":
"""Application Logger for your given module
Args:
@@ -90,7 +87,7 @@ def get_logger(
class SiteContextFilter(logging.Filter):
"""This is a filter which injects request information (if available) into the log."""
- def filter(self, record):
+ def filter(self, record) -> bool:
if "Form Dict" not in str(record.msg):
site = getattr(frappe.local, "site", None)
form_dict = getattr(frappe.local, "form_dict", None)
@@ -98,7 +95,7 @@ class SiteContextFilter(logging.Filter):
return True
-def set_log_level(level):
+def set_log_level(level: int) -> None:
"""Use this method to set log level to something other than the default DEBUG"""
frappe.log_level = getattr(logging, (level or "").upper(), None) or default_log_level
frappe.loggers = {}
diff --git a/frappe/utils/nestedset.py b/frappe/utils/nestedset.py
index d3067973ef..681cd6439d 100644
--- a/frappe/utils/nestedset.py
+++ b/frappe/utils/nestedset.py
@@ -17,6 +17,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.query_builder import Order
from frappe.query_builder.functions import Coalesce, Max
+from frappe.query_builder.terms import SubQuery
from frappe.query_builder.utils import DocType
@@ -336,14 +337,15 @@ class NestedSet(Document):
def get_root_of(doctype):
"""Get root element of a DocType with a tree structure"""
from frappe.query_builder.functions import Count
- from frappe.query_builder.terms import subqry
Table = DocType(doctype)
t1 = Table.as_("t1")
t2 = Table.as_("t2")
- subq = frappe.qb.from_(t2).select(Count("*")).where((t2.lft < t1.lft) & (t2.rgt > t1.rgt))
- result = frappe.qb.from_(t1).select(t1.name).where((subqry(subq) == 0) & (t1.rgt > t1.lft)).run()
+ node_query = SubQuery(
+ frappe.qb.from_(t2).select(Count("*")).where((t2.lft < t1.lft) & (t2.rgt > t1.rgt))
+ )
+ result = frappe.qb.from_(t1).select(t1.name).where((node_query == 0) & (t1.rgt > t1.lft)).run()
return result[0][0] if result else None
diff --git a/frappe/utils/password.py b/frappe/utils/password.py
index f2c4b9685a..c539891ac7 100644
--- a/frappe/utils/password.py
+++ b/frappe/utils/password.py
@@ -213,21 +213,16 @@ def decrypt(txt, encryption_key=None):
try:
cipher_suite = Fernet(encode(encryption_key or get_encryption_key()))
- plain_text = cstr(cipher_suite.decrypt(encode(txt)))
- return plain_text
+ return cstr(cipher_suite.decrypt(encode(txt)))
except InvalidToken:
# encryption_key in site_config is changed and not valid
- frappe.throw(
- _("Encryption key is invalid") + "!"
- if encryption_key
- else _(", please check site_config.json.")
- )
+ frappe.throw(_("Encryption key is invalid! Please check site_config.json"))
def get_encryption_key():
- from frappe.installer import update_site_config
-
if "encryption_key" not in frappe.local.conf:
+ from frappe.installer import update_site_config
+
encryption_key = Fernet.generate_key().decode()
update_site_config("encryption_key", encryption_key)
frappe.local.conf.encryption_key = encryption_key
diff --git a/frappe/utils/password_strength.py b/frappe/utils/password_strength.py
index 1f7a171ce9..59c784e5b4 100644
--- a/frappe/utils/password_strength.py
+++ b/frappe/utils/password_strength.py
@@ -177,9 +177,9 @@ def get_dictionary_match_feedback(match, is_sole_match):
word = match.get("token")
# Variations of the match like UPPERCASES
- if re.match(scoring.START_UPPER, word):
+ if scoring.START_UPPER.match(word):
suggestions.append(_("Capitalization doesn't help very much."))
- elif re.match(scoring.ALL_UPPER, word):
+ elif scoring.ALL_UPPER.match(word):
suggestions.append(_("All-uppercase is almost as easy to guess as all-lowercase."))
# Match contains l33t speak substitutions
diff --git a/frappe/utils/pdf.py b/frappe/utils/pdf.py
index 952717434c..811a6511fd 100644
--- a/frappe/utils/pdf.py
+++ b/frappe/utils/pdf.py
@@ -5,10 +5,11 @@ import os
import re
import subprocess
from distutils.version import LooseVersion
+from typing import Optional
import pdfkit
from bs4 import BeautifulSoup
-from PyPDF2 import PdfFileReader, PdfFileWriter
+from PyPDF2 import PdfReader, PdfWriter
import frappe
from frappe import _
@@ -23,7 +24,7 @@ PDF_CONTENT_ERRORS = [
]
-def get_pdf(html, options=None, output=None):
+def get_pdf(html, options=None, output: Optional[PdfWriter] = None):
html = scrub_urls(html)
html, options = prepare_options(html, options)
@@ -35,11 +36,10 @@ def get_pdf(html, options=None, output=None):
try:
# Set filename property to false, so no file is actually created
- filedata = pdfkit.from_string(html, False, options=options or {})
+ filedata = pdfkit.from_string(html, options=options or {}, verbose=True)
- # https://pythonhosted.org/PyPDF2/PdfFileReader.html
- # create in-memory binary streams from filedata and create a PdfFileReader object
- reader = PdfFileReader(io.BytesIO(filedata))
+ # create in-memory binary streams from filedata and create a PdfReader object
+ reader = PdfReader(io.BytesIO(filedata))
except OSError as e:
if any([error in str(e) for error in PDF_CONTENT_ERRORS]):
if not filedata:
@@ -47,8 +47,8 @@ def get_pdf(html, options=None, output=None):
frappe.throw(_("PDF generation failed because of broken image links"))
# allow pdfs with missing images if file got created
- if output: # output is a PdfFileWriter object
- output.appendPagesFromReader(reader)
+ if output:
+ output.append_pages_from_reader(reader)
else:
raise
finally:
@@ -58,11 +58,11 @@ def get_pdf(html, options=None, output=None):
password = options["password"]
if output:
- output.appendPagesFromReader(reader)
+ output.append_pages_from_reader(reader)
return output
- writer = PdfFileWriter()
- writer.appendPagesFromReader(reader)
+ writer = PdfWriter()
+ writer.append_pages_from_reader(reader)
if "password" in options:
writer.encrypt(password)
diff --git a/frappe/utils/print_format.py b/frappe/utils/print_format.py
index 13989490a5..a48d7ab84f 100644
--- a/frappe/utils/print_format.py
+++ b/frappe/utils/print_format.py
@@ -1,6 +1,6 @@
import os
-from PyPDF2 import PdfFileWriter
+from PyPDF2 import PdfWriter
import frappe
from frappe import _
@@ -12,6 +12,8 @@ no_cache = 1
base_template_path = "www/printview.html"
standard_format = "templates/print_formats/standard.html"
+from frappe.www.printview import validate_print_permission
+
@frappe.whitelist()
def download_multi_pdf(doctype, name, format=None, no_letterhead=False, options=None):
@@ -56,7 +58,7 @@ def download_multi_pdf(doctype, name, format=None, no_letterhead=False, options=
import json
- output = PdfFileWriter()
+ output = PdfWriter()
if isinstance(options, str):
options = json.loads(options)
@@ -115,8 +117,11 @@ def read_multi_pdf(output):
return filedata
-@frappe.whitelist()
+@frappe.whitelist(allow_guest=True)
def download_pdf(doctype, name, format=None, doc=None, no_letterhead=0):
+ doc = doc or frappe.get_doc(doctype, name)
+ validate_print_permission(doc)
+
html = frappe.get_print(doctype, name, format, doc=doc, no_letterhead=no_letterhead)
frappe.local.response.filename = "{name}.pdf".format(
name=name.replace(" ", "-").replace("/", "-")
@@ -147,7 +152,7 @@ def print_by_server(
cups.setServer(print_settings.server_ip)
cups.setPort(print_settings.port)
conn = cups.Connection()
- output = PdfFileWriter()
+ output = PdfWriter()
output = frappe.get_print(
doctype, name, print_format, doc=doc, no_letterhead=no_letterhead, as_pdf=True, output=output
)
diff --git a/frappe/utils/redis_wrapper.py b/frappe/utils/redis_wrapper.py
index 0101355174..06480f0b7b 100644
--- a/frappe/utils/redis_wrapper.py
+++ b/frappe/utils/redis_wrapper.py
@@ -30,7 +30,7 @@ class RedisWrapper(redis.Redis):
return "{0}|{1}".format(frappe.conf.db_name, key).encode("utf-8")
- def set_value(self, key, val, user=None, expires_in_sec=None, shared=False):
+ def set_value(self, key, val, user=None, expires_in_sec=None, shared=False, cache_locally=True):
"""Sets cache value.
:param key: Cache key
@@ -40,7 +40,7 @@ class RedisWrapper(redis.Redis):
"""
key = self.make_key(key, user, shared)
- if not expires_in_sec:
+ if not expires_in_sec and cache_locally:
frappe.local.cache[key] = val
try:
@@ -151,16 +151,15 @@ class RedisWrapper(redis.Redis):
def ltrim(self, key, start, stop):
return super(RedisWrapper, self).ltrim(self.make_key(key), start, stop)
- def hset(self, name, key, value, shared=False):
+ def hset(self, name: str, key: str, value, shared: bool = False, cache_locally: bool = True):
if key is None:
return
_name = self.make_key(name, shared=shared)
# set in local
- if _name not in frappe.local.cache:
- frappe.local.cache[_name] = {}
- frappe.local.cache[_name][key] = value
+ if cache_locally:
+ frappe.local.cache.setdefault(_name, {})[key] = value
# set in redis
try:
@@ -168,6 +167,15 @@ class RedisWrapper(redis.Redis):
except redis.exceptions.ConnectionError:
pass
+ def hexists(self, name: str, key: str, shared: bool = False) -> bool:
+ if key is None:
+ return False
+ _name = self.make_key(name, shared=shared)
+ try:
+ return super(RedisWrapper, self).hexists(_name, key)
+ except redis.exceptions.ConnectionError:
+ return False
+
def hgetall(self, name):
value = super(RedisWrapper, self).hgetall(self.make_key(name))
return {key: pickle.loads(value) for key, value in value.items()}
diff --git a/frappe/utils/response.py b/frappe/utils/response.py
index c537460713..80aeaa2ad0 100644
--- a/frappe/utils/response.py
+++ b/frappe/utils/response.py
@@ -6,6 +6,7 @@ import decimal
import json
import mimetypes
import os
+from typing import TYPE_CHECKING
from urllib.parse import quote
import werkzeug.utils
@@ -22,6 +23,9 @@ from frappe import _
from frappe.core.doctype.access_log.access_log import make_access_log
from frappe.utils import cint, format_timedelta
+if TYPE_CHECKING:
+ from frappe.core.doctype.file.file import File
+
def report_error(status_code):
"""Build error. Show traceback in developer mode"""
@@ -209,28 +213,27 @@ def download_backup(path):
return send_private_file(path)
-def download_private_file(path):
+def download_private_file(path: str) -> Response:
"""Checks permissions and sends back private file"""
- files = frappe.db.get_all("File", {"file_url": path})
can_access = False
+ files = frappe.get_all("File", filters={"file_url": path}, pluck="name")
# this file might be attached to multiple documents
# if the file is accessible from any one of those documents
# then it should be downloadable
- for f in files:
- _file = frappe.get_doc("File", f)
- can_access = _file.is_downloadable()
- if can_access:
- make_access_log(doctype="File", document=_file.name, file_type=os.path.splitext(path)[-1][1:])
+ for fname in files:
+ file: "File" = frappe.get_doc("File", fname)
+ if can_access := file.is_downloadable():
break
if not can_access:
raise Forbidden(_("You don't have permission to access this file"))
+ make_access_log(doctype="File", document=file.name, file_type=os.path.splitext(path)[-1][1:])
return send_private_file(path.split("/private", 1)[1])
-def send_private_file(path):
+def send_private_file(path: str) -> Response:
path = os.path.join(frappe.local.conf.get("private_path", "private"), path.strip("/"))
filename = os.path.basename(path)
diff --git a/frappe/utils/xlsxutils.py b/frappe/utils/xlsxutils.py
index ad02cd8327..1b898f69a2 100644
--- a/frappe/utils/xlsxutils.py
+++ b/frappe/utils/xlsxutils.py
@@ -40,7 +40,7 @@ def make_xlsx(data, sheet_name, wb=None, column_widths=None):
if isinstance(item, str) and next(ILLEGAL_CHARACTERS_RE.finditer(value), None):
# Remove illegal characters from the string
- value = re.sub(ILLEGAL_CHARACTERS_RE, "", value)
+ value = ILLEGAL_CHARACTERS_RE.sub("", value)
clean_row.append(value)
diff --git a/frappe/website/doctype/personal_data_download_request/test_personal_data_download_request.py b/frappe/website/doctype/personal_data_download_request/test_personal_data_download_request.py
index 4f115325df..6518cda5ed 100644
--- a/frappe/website/doctype/personal_data_download_request/test_personal_data_download_request.py
+++ b/frappe/website/doctype/personal_data_download_request/test_personal_data_download_request.py
@@ -2,17 +2,17 @@
# Copyright (c) 2019, Frappe Technologies and Contributors
# License: MIT. See LICENSE
import json
-import unittest
import frappe
from frappe.contacts.doctype.contact.contact import get_contact_name
from frappe.core.doctype.user.user import create_contact
+from frappe.tests.utils import FrappeTestCase
from frappe.website.doctype.personal_data_download_request.personal_data_download_request import (
get_user_data,
)
-class TestRequestPersonalData(unittest.TestCase):
+class TestRequestPersonalData(FrappeTestCase):
def setUp(self):
create_user_if_not_exists(email="test_privacy@example.com")
@@ -48,7 +48,7 @@ class TestRequestPersonalData(unittest.TestCase):
email_queue = frappe.get_all(
"Email Queue", fields=["message"], order_by="creation DESC", limit=1
)
- self.assertTrue("Subject: Download Your Data" in email_queue[0].message)
+ self.assertIn(frappe._("Download Your Data"), email_queue[0].message)
frappe.db.delete("Email Queue")
diff --git a/frappe/website/doctype/web_form/templates/web_form.html b/frappe/website/doctype/web_form/templates/web_form.html
index 7dd532421d..96072a19ea 100644
--- a/frappe/website/doctype/web_form/templates/web_form.html
+++ b/frappe/website/doctype/web_form/templates/web_form.html
@@ -22,7 +22,7 @@ data-web-form="{{ name }}" data-web-form-doctype="{{ doc_type }}" data-login-req
{% if is_list %}
-
{{ _(title) }}
+
{{ _(title) }}
diff --git a/frappe/website/doctype/web_form/web_form.py b/frappe/website/doctype/web_form/web_form.py
index 5304fbdfab..e07aa19162 100644
--- a/frappe/website/doctype/web_form/web_form.py
+++ b/frappe/website/doctype/web_form/web_form.py
@@ -6,7 +6,8 @@ import os
import frappe
from frappe import _, scrub
-from frappe.core.doctype.file.file import get_max_file_size, remove_file_by_url
+from frappe.core.api.file import get_max_file_size
+from frappe.core.doctype.file import remove_file_by_url
from frappe.custom.doctype.customize_form.customize_form import docfield_properties
from frappe.desk.form.meta import get_code_files_via_hooks
from frappe.integrations.utils import get_payment_gateway_controller
diff --git a/frappe/website/doctype/web_page/web_page.py b/frappe/website/doctype/web_page/web_page.py
index f74af1d8c7..bd7bcb8de4 100644
--- a/frappe/website/doctype/web_page/web_page.py
+++ b/frappe/website/doctype/web_page/web_page.py
@@ -19,6 +19,8 @@ from frappe.website.utils import (
)
from frappe.website.website_generator import WebsiteGenerator
+H_TAG_PATTERN = re.compile("")
+
class WebPage(WebsiteGenerator):
def validate(self):
@@ -114,7 +116,7 @@ class WebPage(WebsiteGenerator):
context.header = context.title
# add h1 tag to header
- if context.get("header") and not re.findall("", context.header):
+ if context.get("header") and not H_TAG_PATTERN.findall(context.header):
context.header = "
" + context.header + "
"
# if title not set, set title from header
diff --git a/frappe/website/doctype/website_settings/website_settings.json b/frappe/website/doctype/website_settings/website_settings.json
index b628437315..8c46ef30bf 100644
--- a/frappe/website/doctype/website_settings/website_settings.json
+++ b/frappe/website/doctype/website_settings/website_settings.json
@@ -21,6 +21,7 @@
"website_theme_image_link",
"brand",
"banner_image",
+ "splash_image",
"brand_html",
"set_banner_from_image",
"favicon",
@@ -271,7 +272,7 @@
},
{
"default": "0",
- "description": "To use Google Indexing, enable Google Settings.",
+ "description": "To use Google Indexing, enable Google Settings.",
"fieldname": "enable_google_indexing",
"fieldtype": "Check",
"label": "Enable Google Indexing"
@@ -413,6 +414,11 @@
"fieldname": "footer_powered",
"fieldtype": "Small Text",
"label": "Footer \"Powered By\""
+ },
+ {
+ "fieldname": "splash_image",
+ "fieldtype": "Attach Image",
+ "label": "Splash Image"
}
],
"icon": "fa fa-cog",
@@ -420,7 +426,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2022-03-09 01:47:31.094462",
+ "modified": "2022-05-27 12:33:29.019998",
"modified_by": "Administrator",
"module": "Website",
"name": "Website Settings",
diff --git a/frappe/website/doctype/website_settings/website_settings.py b/frappe/website/doctype/website_settings/website_settings.py
index e8f15290c4..f249778c58 100644
--- a/frappe/website/doctype/website_settings/website_settings.py
+++ b/frappe/website/doctype/website_settings/website_settings.py
@@ -1,5 +1,6 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
+from typing import Dict, List
from urllib.parse import quote
import frappe
@@ -7,7 +8,6 @@ from frappe import _
from frappe.integrations.doctype.google_settings.google_settings import get_auth_url
from frappe.model.document import Document
from frappe.utils import encode, get_request_site_address
-from frappe.website.doctype.website_theme.website_theme import add_website_theme
INDEXING_SCOPES = "https://www.googleapis.com/auth/indexing"
@@ -124,7 +124,9 @@ class WebsiteSettings(Document):
def get_website_settings(context=None):
hooks = frappe.get_hooks()
- context = context or frappe._dict()
+ context = frappe._dict(context or {})
+ settings: "WebsiteSettings" = frappe.get_single("Website Settings")
+
context = context.update(
{
"top_bar_items": get_items("top_bar_items"),
@@ -136,7 +138,6 @@ def get_website_settings(context=None):
}
)
- settings = frappe.get_single("Website Settings")
for k in [
"banner_html",
"banner_image",
@@ -161,11 +162,8 @@ def get_website_settings(context=None):
"show_language_picker",
"footer_powered",
]:
- if hasattr(settings, k):
- context[k] = settings.get(k)
-
- if settings.address:
- context["footer_address"] = settings.address
+ if setting_value := settings.get(k):
+ context[k] = setting_value
for k in [
"facebook_share",
@@ -176,6 +174,9 @@ def get_website_settings(context=None):
]:
context[k] = int(context.get(k) or 0)
+ if settings.address:
+ context["footer_address"] = settings.address
+
if frappe.request:
context.url = quote(str(get_request_site_address(full_address=True)), safe="/:")
@@ -185,7 +186,7 @@ def get_website_settings(context=None):
context.web_include_css = hooks.web_include_css or []
- via_hooks = frappe.get_hooks("website_context")
+ via_hooks = hooks.website_context or []
for key in via_hooks:
context[key] = via_hooks[key]
if key not in ("top_bar_items", "footer_items", "post_login") and isinstance(
@@ -193,7 +194,13 @@ def get_website_settings(context=None):
):
context[key] = context[key][-1]
- add_website_theme(context)
+ if context.disable_website_theme:
+ context.theme = frappe._dict()
+
+ else:
+ from frappe.website.doctype.website_theme.website_theme import get_active_theme
+
+ context.theme = get_active_theme() or frappe._dict()
if not context.get("favicon"):
context["favicon"] = "/assets/frappe/images/frappe-favicon.svg"
@@ -203,30 +210,37 @@ def get_website_settings(context=None):
context["hide_login"] = settings.hide_login
+ if settings.splash_image:
+ context["splash_image"] = settings.splash_image
+
return context
-def get_items(parentfield):
- all_top_items = frappe.db.sql(
- """\
- select * from `tabTop Bar Item`
- where parent='Website Settings' and parentfield= %s
- order by idx asc""",
- parentfield,
- as_dict=1,
+def get_items(parentfield: str) -> List[Dict]:
+ _items = frappe.get_all(
+ "Top Bar Item",
+ filters={"parent": "Website Settings", "parentfield": parentfield},
+ order_by="idx asc",
+ fields="*",
)
-
- top_items = all_top_items[:]
+ top_items = _items.copy()
# attach child items to top bar
- for d in all_top_items:
- if d["parent_label"]:
- for t in top_items:
- if t["label"] == d["parent_label"]:
- if not "child_items" in t:
- t["child_items"] = []
- t["child_items"].append(d)
- break
+ for item in _items:
+ if not item["parent_label"]:
+ continue
+
+ for top_bar_item in top_items:
+ if top_bar_item["label"] != item["parent_label"]:
+ continue
+
+ if "child_items" not in top_bar_item:
+ top_bar_item["child_items"] = []
+
+ top_bar_item["child_items"].append(item)
+
+ break
+
return top_items
diff --git a/frappe/website/doctype/website_theme/website_theme.py b/frappe/website/doctype/website_theme/website_theme.py
index c833430534..9cb7095f40 100644
--- a/frappe/website/doctype/website_theme/website_theme.py
+++ b/frappe/website/doctype/website_theme/website_theme.py
@@ -5,6 +5,7 @@ from os.path import abspath
from os.path import exists as path_exists
from os.path import join as join_path
from os.path import splitext
+from typing import Optional
import frappe
from frappe import _
@@ -131,17 +132,8 @@ class WebsiteTheme(Document):
return out
-def add_website_theme(context):
- context.theme = frappe._dict()
-
- if not context.disable_website_theme:
- website_theme = get_active_theme()
- context.theme = website_theme or frappe._dict()
-
-
-def get_active_theme():
- website_theme = frappe.db.get_single_value("Website Settings", "website_theme")
- if website_theme:
+def get_active_theme() -> Optional["WebsiteTheme"]:
+ if website_theme := frappe.db.get_single_value("Website Settings", "website_theme"):
try:
return frappe.get_doc("Website Theme", website_theme)
except frappe.DoesNotExistError:
diff --git a/frappe/website/page_renderers/error_page.py b/frappe/website/page_renderers/error_page.py
index 613809bfdc..6a3925967c 100644
--- a/frappe/website/page_renderers/error_page.py
+++ b/frappe/website/page_renderers/error_page.py
@@ -5,7 +5,13 @@ class ErrorPage(TemplatePage):
def __init__(self, path=None, http_status_code=None, exception=None):
path = "error"
super().__init__(path=path, http_status_code=http_status_code)
- self.http_status_code = getattr(exception, "http_status_code", None) or http_status_code or 500
+ self.exception = exception
def can_render(self):
return True
+
+ def init_context(self):
+ super().init_context()
+ self.context.http_status_code = getattr(self.exception, "http_status_code", None) or 500
+ self.context.error_title = getattr(self.exception, "title", None)
+ self.context.error_message = getattr(self.exception, "message", None)
diff --git a/frappe/website/page_renderers/template_page.py b/frappe/website/page_renderers/template_page.py
index 2ed8a62119..83f68d3716 100644
--- a/frappe/website/page_renderers/template_page.py
+++ b/frappe/website/page_renderers/template_page.py
@@ -212,19 +212,13 @@ class TemplatePage(BaseTemplatePage):
def run_pymodule_method(self, method_name):
if hasattr(self.pymodule, method_name):
- try:
- import inspect
+ import inspect
- method = getattr(self.pymodule, method_name)
- if inspect.getfullargspec(method).args:
- return method(self.context)
- else:
- return method()
- except (frappe.PermissionError, frappe.DoesNotExistError, frappe.Redirect):
- raise
- except Exception:
- if not frappe.flags.in_migrate:
- frappe.errprint(frappe.utils.get_traceback())
+ method = getattr(self.pymodule, method_name)
+ if inspect.getfullargspec(method).args:
+ return method(self.context)
+ else:
+ return method()
def render_template(self):
if self.template_path.endswith("min.js"):
diff --git a/frappe/website/router.py b/frappe/website/router.py
index e9f0d0f09c..8c21501a4e 100644
--- a/frappe/website/router.py
+++ b/frappe/website/router.py
@@ -8,7 +8,7 @@ import re
from werkzeug.routing import Map, NotFound, Rule
import frappe
-from frappe.website.utils import extract_title
+from frappe.website.utils import extract_title, get_frontmatter
def get_page_info_from_web_page_with_dynamic_routes(path):
@@ -161,26 +161,6 @@ def get_page_info(path, app, start, basepath=None, app_path=None, fname=None):
return page_info
-def get_frontmatter(string):
- """
- Reference: https://github.com/jonbeebe/frontmatter
- """
- import yaml
-
- fmatter = ""
- body = ""
- result = re.compile(r"^\s*(?:---|\+\+\+)(.*?)(?:---|\+\+\+)\s*(.+)$", re.S | re.M).search(string)
-
- if result:
- fmatter = result.group(1)
- body = result.group(2)
-
- return {
- "attributes": yaml.safe_load(fmatter),
- "body": body,
- }
-
-
def setup_source(page_info):
"""Get the HTML source of the template"""
jenv = frappe.get_jenv()
diff --git a/frappe/website/utils.py b/frappe/website/utils.py
index f673a20656..6e34c05d40 100644
--- a/frappe/website/utils.py
+++ b/frappe/website/utils.py
@@ -15,6 +15,13 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils import md_to_html
+FRONTMATTER_PATTERN = re.compile(r"^\s*(?:---|\+\+\+)(.*?)(?:---|\+\+\+)\s*(.+)$", re.S | re.M)
+H1_TAG_PATTERN = re.compile("
([^<]*)")
+IMAGE_TAG_PATTERN = re.compile(r"""]*src\s?=\s?['"]([^'"]*)['"]""")
+CLEANUP_PATTERN_1 = re.compile(r'[~!@#$%^&*+()<>,."\'\?]')
+CLEANUP_PATTERN_2 = re.compile("[:/]")
+CLEANUP_PATTERN_3 = re.compile(r"(-)\1+")
+
def delete_page_cache(path):
cache = frappe.cache()
@@ -29,7 +36,7 @@ def delete_page_cache(path):
def find_first_image(html):
- m = re.finditer(r"""]*src\s?=\s?['"]([^'"]*)['"]""", html)
+ m = IMAGE_TAG_PATTERN.finditer(html)
try:
return next(m).groups()[0]
except StopIteration:
@@ -156,17 +163,17 @@ def is_signup_disabled():
return frappe.db.get_single_value("Website Settings", "disable_signup", True)
-def cleanup_page_name(title):
+def cleanup_page_name(title: str) -> str:
"""make page name from title"""
if not title:
return ""
name = title.lower()
- name = re.sub(r'[~!@#$%^&*+()<>,."\'\?]', "", name)
- name = re.sub("[:/]", "-", name)
+ name = CLEANUP_PATTERN_1.sub("", name)
+ name = CLEANUP_PATTERN_2.sub("-", name)
name = "-".join(name.split())
# replace repeating hyphens
- name = re.sub(r"(-)\1+", r"\1", name)
+ name = CLEANUP_PATTERN_3.sub(r"\1", name)
return name[:140]
@@ -287,8 +294,8 @@ def extract_title(source, path):
if not title and "
" in source:
# extract title from h1
- match = re.findall("
([^<]*)", source)
- title_content = match[0].strip()[:300]
+ match = H1_TAG_PATTERN.search(source).group()
+ title_content = match.strip()[:300]
if "{{" not in title_content:
title = title_content
@@ -308,17 +315,14 @@ def extract_title(source, path):
return title
-def extract_comment_tag(source, tag):
+def extract_comment_tag(source: str, tag: str):
"""Extract custom tags in comments from source.
:param source: raw template source in HTML
:param title: tag to search, example "title"
"""
-
- if "".format(tag), source)[0].strip()
- else:
- return None
+ matched_pattern = re.search(f"", source)
+ return matched_pattern.groups()[0].strip() if matched_pattern else None
def get_html_content_based_on_type(doc, fieldname, content_type):
@@ -378,7 +382,8 @@ def get_frontmatter(string):
"Reference: https://github.com/jonbeebe/frontmatter"
frontmatter = ""
body = ""
- result = re.compile(r"^\s*(?:---|\+\+\+)(.*?)(?:---|\+\+\+)\s*(.+)$", re.S | re.M).search(string)
+ result = FRONTMATTER_PATTERN.search(string)
+
if result:
frontmatter = result.group(1)
body = result.group(2)
diff --git a/frappe/website/web_template/hero/hero.html b/frappe/website/web_template/hero/hero.html
index 4f2f8d89ed..454a3a8f98 100644
--- a/frappe/website/web_template/hero/hero.html
+++ b/frappe/website/web_template/hero/hero.html
@@ -1,21 +1,21 @@
-
{{ title }}
+
{{ _(title) }}
{%- if subtitle -%}
- {{ subtitle }}
+ {{ _(subtitle) }}
{%- endif -%}
{%- if primary_action or secondary_action -%}