diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index 5a96c3fea8..e87590b976 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -34,3 +34,9 @@ c0c5b2ebdddbe8898ce2d5e5365f4931ff73b6bf
# db.get_all -> get_all
2eec621e95564c359ad22da79501a855c1f32b03
+
+# minor formatting fix in `user.py`
+f223bc02490902dfcc32892058f13f343d51fbaf
+
+# frappe.cache() -> frappe.cache
+fa6dc03cc87ad74e11609e7373078366fdcb3e1b
diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml
index be343c1254..c563f9e43f 100644
--- a/.github/workflows/linters.yml
+++ b/.github/workflows/linters.yml
@@ -97,4 +97,4 @@ jobs:
pip install pip-audit
cd ${GITHUB_WORKSPACE}
sed -i '/dropbox/d' pyproject.toml # Remove dropbox temporarily https://github.com/dropbox/dropbox-sdk-python/pull/456
- pip-audit --desc on .
+ pip-audit --desc on --ignore-vuln GHSA-4xqq-73wg-5mjp .
diff --git a/README.md b/README.md
index 562437d5d1..d3b76648a2 100644
--- a/README.md
+++ b/README.md
@@ -56,10 +56,15 @@ Full-stack web application framework that uses Python and MariaDB on the server
## Installation
-* [Install via Docker](https://github.com/frappe/frappe_docker)
-* [Install via Frappe Bench](https://github.com/frappe/bench)
-* [Offical Documentation](https://frappeframework.com/docs/user/en/installation)
-* [Managed Hosting on Frappe Cloud](https://frappecloud.com/frappe/signup)
+### Production
+* [Managed Hosting on Frappe Cloud](https://frappecloud.com/)
+* [Easy install script using Docker images](https://github.com/frappe/bench/tree/develop#easy-install-script)
+* [Manual install using Docker images](https://github.com/frappe/frappe_docker)
+
+### Development
+* [Easy install script using Docker images](https://github.com/frappe/bench/tree/develop#easy-install-script)
+* [Development installlation on bare metal](https://frappeframework.com/docs/user/en/installation)
+
## Contributing
diff --git a/cypress/integration/form_builder.js b/cypress/integration/form_builder.js
index 968f6aaaf0..b298abdbe7 100644
--- a/cypress/integration/form_builder.js
+++ b/cypress/integration/form_builder.js
@@ -26,11 +26,11 @@ context("Form Builder", () => {
cy.get(".page-title").click();
cy.get(".frappe-control[data-fieldname='doctype'] input").click().as("input");
- cy.get("@input").type("{rightArrow} Field", { delay: 200 });
+ cy.get("@input").type("{rightArrow}Web Form Field", { delay: 200 });
cy.wait("@search_link");
cy.get("@input").type("{enter}").blur();
- cy.click_modal_primary_button("Change");
+ cy.click_modal_primary_button("Edit");
cy.get(".page-title .title-text").should("have.text", "Web Form Field");
});
diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js
index 3c5c305665..4804f0e25f 100644
--- a/esbuild/esbuild.js
+++ b/esbuild/esbuild.js
@@ -87,7 +87,10 @@ const NODE_PATHS = [].concat(
execute()
.then(() => RUN_BUILD_COMMAND && run_build_command_for_apps(APPS))
- .catch((e) => console.error(e));
+ .catch((e) => {
+ console.error(e);
+ throw e;
+ });
if (WATCH_MODE) {
// listen for open files in editor event
diff --git a/frappe/__init__.py b/frappe/__init__.py
index 5a03438a9e..5e87785e2d 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -16,8 +16,9 @@ import inspect
import json
import os
import re
+import unicodedata
import warnings
-from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, overload
+from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, TypeAlias, overload
import click
from werkzeug.local import Local, release_local
@@ -47,6 +48,7 @@ __title__ = "Frappe Framework"
controllers = {}
local = Local()
+cache = None
STANDARD_USERS = ("Guest", "Administrator")
_dev_server = int(sbool(os.environ.get("DEV_SERVER", False)))
@@ -177,6 +179,7 @@ if TYPE_CHECKING:
db: MariaDBDatabase | PostgresDatabase
qb: MariaDB | Postgres
+ cache: RedisWrapper
# end: static analysis hack
@@ -190,7 +193,6 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force=False)
local.error_log = []
local.message_log = []
local.debug_log = []
- local.realtime_log = []
local.flags = _dict(
{
"currently_saving": [],
@@ -207,9 +209,7 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force=False)
"read_only": False,
}
)
- local.rollback_observers = []
local.locked_documents = []
- local.before_commit = []
local.test_objects = {}
local.site = site
@@ -233,7 +233,6 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force=False)
local.role_permissions = {}
local.valid_columns = {}
local.new_doc_templates = {}
- local.link_count = {}
local.jenv = None
local.jloader = None
@@ -244,6 +243,7 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force=False)
local.dev_server = _dev_server
local.qb = get_query_builder(local.conf.db_type or "mariadb")
local.qb.get_query = get_query
+ setup_redis_cache_connection()
setup_module_map()
if not _qb_patched.get(local.conf.db_type):
@@ -351,17 +351,14 @@ def destroy():
release_local(local)
-redis_server = None
+def setup_redis_cache_connection():
+ """Defines `frappe.cache` as `RedisWrapper` instance"""
+ global cache
-
-def cache() -> "RedisWrapper":
- """Returns redis connection."""
- global redis_server
- if not redis_server:
+ if not cache:
from frappe.utils.redis_wrapper import RedisWrapper
- redis_server = RedisWrapper.from_url(conf.get("redis_cache") or "redis://localhost:11311")
- return redis_server
+ cache = RedisWrapper.from_url(conf.get("redis_cache") or "redis://localhost:11311")
def get_traceback(with_context: bool = False) -> str:
@@ -383,7 +380,7 @@ def errprint(msg: str) -> None:
def print_sql(enable: bool = True) -> None:
- return cache().set_value("flag_print_sql", enable)
+ return cache.set_value("flag_print_sql", enable)
def log(msg: str) -> None:
@@ -879,6 +876,7 @@ def clear_cache(user: str | None = None, doctype: str | None = None):
:param doctype: If doctype is given, only DocType cache is cleared."""
import frappe.cache_manager
import frappe.utils.caching
+ from frappe.website.router import clear_routing_cache
if doctype:
frappe.cache_manager.clear_doctype_cache(doctype)
@@ -907,6 +905,8 @@ def clear_cache(user: str | None = None, doctype: str | None = None):
if hasattr(local, "website_settings"):
del local.website_settings
+ clear_routing_cache()
+
def only_has_select_perm(doctype, user=None, ignore_permissions=False):
if ignore_permissions:
@@ -1016,7 +1016,7 @@ def is_table(doctype: str) -> bool:
def get_tables():
return db.get_values("DocType", filters={"istable": 1}, order_by=None, pluck=True)
- tables = cache().get_value("is_table", get_tables)
+ tables = cache.get_value("is_table", get_tables)
return doctype in tables
@@ -1043,24 +1043,32 @@ def generate_hash(txt: str | None = None, length: int = 56) -> str:
def reset_metadata_version():
"""Reset `metadata_version` (Client (Javascript) build ID) hash."""
v = generate_hash()
- cache().set_value("metadata_version", v)
+ cache.set_value("metadata_version", v)
return v
def new_doc(
doctype: str,
+ *,
parent_doc: Optional["Document"] = None,
parentfield: str | None = None,
as_dict: bool = False,
+ **kwargs,
) -> "Document":
"""Returns a new document of the given DocType with defaults set.
:param doctype: DocType of the new document.
:param parent_doc: [optional] add to parent document.
- :param parentfield: [optional] add against this `parentfield`."""
+ :param parentfield: [optional] add against this `parentfield`.
+ :param as_dict: [optional] return as dictionary instead of Document.
+ :param kwargs: [optional] You can specify fields as field=value pairs in function call.
+ """
+
from frappe.model.create_new import get_new_doc
- return get_new_doc(doctype, parent_doc, parentfield, as_dict=as_dict)
+ new_doc = get_new_doc(doctype, parent_doc, parentfield, as_dict=as_dict)
+
+ return new_doc.update(kwargs)
def set_value(doctype, docname, fieldname, value=None):
@@ -1071,7 +1079,7 @@ def set_value(doctype, docname, fieldname, value=None):
def get_cached_doc(*args, **kwargs) -> "Document":
- if (key := can_cache_doc(args)) and (doc := cache().hget("document_cache", key)):
+ if (key := can_cache_doc(args)) and (doc := cache.get_value(key)):
return doc
# Not found in cache, fetch from DB
@@ -1087,7 +1095,7 @@ def get_cached_doc(*args, **kwargs) -> "Document":
def _set_document_in_cache(key: str, doc: "Document") -> None:
- cache().hset("document_cache", key, doc)
+ cache.set_value(key, doc)
def can_cache_doc(args) -> str | None:
@@ -1108,12 +1116,20 @@ def can_cache_doc(args) -> str | None:
def get_document_cache_key(doctype: str, name: str):
- return f"{doctype}::{name}"
+ return f"document_cache::{doctype}::{name}"
-def clear_document_cache(doctype, name):
- cache().hdel("last_modified", doctype)
- cache().hdel("document_cache", get_document_cache_key(doctype, name))
+def clear_document_cache(doctype: str, name: str | None = None) -> None:
+ def clear_in_redis():
+ if name is not None:
+ cache.delete_value(get_document_cache_key(doctype, name))
+ else:
+ cache.delete_keys(get_document_cache_key(doctype, ""))
+
+ clear_in_redis()
+ if hasattr(db, "after_commit"):
+ db.after_commit.add(clear_in_redis)
+ db.after_rollback.add(clear_in_redis)
if doctype == "System Settings" and hasattr(local, "system_settings"):
delattr(local, "system_settings")
@@ -1142,7 +1158,42 @@ def get_cached_value(
return values
-def get_doc(*args, **kwargs) -> "Document":
+_SingleDocument: TypeAlias = "Document"
+_NewDocument: TypeAlias = "Document"
+
+
+@overload
+def get_doc(document: "Document", /) -> "Document":
+ pass
+
+
+@overload
+def get_doc(doctype: str, /) -> _SingleDocument:
+ """Retrieve Single DocType from DB, doctype must be positional argument."""
+ pass
+
+
+@overload
+def get_doc(doctype: str, name: str, /, for_update: bool | None = None) -> "Document":
+ """Retrieve DocType from DB, doctype and name must be positional argument."""
+ pass
+
+
+@overload
+def get_doc(**kwargs: dict) -> "_NewDocument":
+ """Initialize document from kwargs.
+ Not recommended. Use `frappe.new_doc` instead."""
+ pass
+
+
+@overload
+def get_doc(documentdict: dict) -> "_NewDocument":
+ """Create document from dict.
+ Not recommended. Use `frappe.new_doc` instead."""
+ pass
+
+
+def get_doc(*args, **kwargs):
"""Return a `frappe.model.document.Document` object of the given type and name.
:param arg1: DocType name as string **or** document JSON.
@@ -1163,7 +1214,7 @@ def get_doc(*args, **kwargs) -> "Document":
doc = frappe.model.document.get_doc(*args, **kwargs)
# Replace cache if stale one exists
- if (key := can_cache_doc(args)) and cache().hexists("document_cache", key):
+ if (key := can_cache_doc(args)) and cache.exists(key):
_set_document_in_cache(key, doc)
return doc
@@ -1397,13 +1448,13 @@ def get_installed_apps(sort=False, frappe_last=False, *, _ensure_on_bench=False)
if sort:
if not local.all_apps:
- local.all_apps = cache().get_value("all_apps", get_all_apps)
+ local.all_apps = cache.get_value("all_apps", get_all_apps)
deprecation_warning("`sort` argument is deprecated and will be removed in v15.")
installed = [app for app in local.all_apps if app in installed]
if _ensure_on_bench:
- all_apps = cache().get_value("all_apps", get_all_apps)
+ all_apps = cache.get_value("all_apps", get_all_apps)
installed = [app for app in installed if app in all_apps]
if frappe_last:
@@ -1474,7 +1525,7 @@ def get_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, ([] if default == "_KEEP_DEFAULT_LIST" else default))
@@ -1504,11 +1555,9 @@ def append_hook(target, key, value):
def setup_module_map():
"""Rebuild map of all modules (internal)."""
- _cache = cache()
-
if conf.db_name:
- local.app_modules = _cache.get_value("app_modules")
- local.module_app = _cache.get_value("module_app")
+ local.app_modules = cache.get_value("app_modules")
+ local.module_app = cache.get_value("module_app")
if not (local.app_modules and local.module_app):
local.module_app, local.app_modules = {}, {}
@@ -1520,8 +1569,8 @@ def setup_module_map():
local.app_modules[app].append(module)
if conf.db_name:
- _cache.set_value("app_modules", local.app_modules)
- _cache.set_value("module_app", local.module_app)
+ cache.set_value("app_modules", local.app_modules)
+ cache.set_value("module_app", local.module_app)
def get_file_items(path, raise_not_found=False, ignore_empty_lines=True):
@@ -1810,7 +1859,7 @@ def redirect_to_message(title, html, http_status_code=None, context=None, indica
if indicator_color:
message["context"].update({"indicator_color": indicator_color})
- cache().set_value(f"message_id:{message_id}", message, expires_in_sec=60)
+ cache.set_value(f"message_id:{message_id}", message, expires_in_sec=60)
location = f"/message?id={message_id}"
if not getattr(local, "is_ajax", False):
@@ -2228,6 +2277,7 @@ def bold(text):
def safe_eval(code, eval_globals=None, eval_locals=None):
"""A safer `eval`"""
whitelisted_globals = {"int": int, "float": float, "long": int, "round": round}
+ code = unicodedata.normalize("NFKC", code)
UNSAFE_ATTRIBUTES = {
# Generator Attributes
diff --git a/frappe/app.py b/frappe/app.py
index fab8facd3f..ddde313ace 100644
--- a/frappe/app.py
+++ b/frappe/app.py
@@ -19,7 +19,6 @@ import frappe.recorder
import frappe.utils.response
from frappe import _
from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest
-from frappe.core.doctype.comment.comment import update_comments_in_parent_after_request
from frappe.middlewares import StaticDataMiddleware
from frappe.utils import cint, get_site_name, sanitize_html
from frappe.utils.error import make_error_snapshot
@@ -158,6 +157,8 @@ def log_request(request, response):
{
"site": get_site_name(request.host),
"remote_addr": getattr(request, "remote_addr", "NOTFOUND"),
+ "pid": os.getpid(),
+ "user": getattr(frappe.local.session, "user", "NOTFOUND"),
"base_url": getattr(request, "base_url", "NOTFOUND"),
"full_path": getattr(request, "full_path", "NOTFOUND"),
"method": getattr(request, "method", "NOTFOUND"),
@@ -351,8 +352,6 @@ def sync_database(rollback: bool) -> bool:
frappe.db.commit()
rollback = False
- update_comments_in_parent_after_request()
-
return rollback
diff --git a/frappe/auth.py b/frappe/auth.py
index f1cdac52bd..29c3e41694 100644
--- a/frappe/auth.py
+++ b/frappe/auth.py
@@ -188,10 +188,10 @@ class LoginManager:
frappe.response["full_name"] = self.full_name
# redirect information
- redirect_to = frappe.cache().hget("redirect_after_login", self.user)
+ redirect_to = frappe.cache.hget("redirect_after_login", self.user)
if redirect_to:
frappe.local.response["redirect_to"] = redirect_to
- frappe.cache().hdel("redirect_after_login", self.user)
+ frappe.cache.hdel("redirect_after_login", self.user)
frappe.local.cookie_manager.set_cookie("full_name", self.full_name)
frappe.local.cookie_manager.set_cookie("user_id", self.user)
@@ -482,15 +482,15 @@ class LoginAttemptTracker:
@property
def login_failed_count(self):
- return frappe.cache().hget("login_failed_count", self.user_name)
+ return frappe.cache.hget("login_failed_count", self.user_name)
@login_failed_count.setter
def login_failed_count(self, count):
- frappe.cache().hset("login_failed_count", self.user_name, count)
+ frappe.cache.hset("login_failed_count", self.user_name, count)
@login_failed_count.deleter
def login_failed_count(self):
- frappe.cache().hdel("login_failed_count", self.user_name)
+ frappe.cache.hdel("login_failed_count", self.user_name)
@property
def login_failed_time(self):
@@ -498,15 +498,15 @@ class LoginAttemptTracker:
For every user we track only First failed login attempt time within lock interval of time.
"""
- return frappe.cache().hget("login_failed_time", self.user_name)
+ return frappe.cache.hget("login_failed_time", self.user_name)
@login_failed_time.setter
def login_failed_time(self, timestamp):
- frappe.cache().hset("login_failed_time", self.user_name, timestamp)
+ frappe.cache.hset("login_failed_time", self.user_name, timestamp)
@login_failed_time.deleter
def login_failed_time(self):
- frappe.cache().hdel("login_failed_time", self.user_name)
+ frappe.cache.hdel("login_failed_time", self.user_name)
def add_failure_attempt(self):
"""Log user failure attempts into the system.
diff --git a/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py b/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py
index 3242145bc4..4316edd1ca 100644
--- a/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py
+++ b/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py
@@ -9,7 +9,7 @@ class TestMilestoneTracker(FrappeTestCase):
def test_milestone(self):
frappe.db.delete("Milestone Tracker")
- frappe.cache().delete_key("milestone_tracker_map")
+ frappe.cache.delete_key("milestone_tracker_map")
milestone_tracker = frappe.get_doc(
dict(doctype="Milestone Tracker", document_type="ToDo", track_field="status")
diff --git a/frappe/automation/workspace/tools/tools.json b/frappe/automation/workspace/tools/tools.json
index d0e2c4fcfd..c3de151282 100644
--- a/frappe/automation/workspace/tools/tools.json
+++ b/frappe/automation/workspace/tools/tools.json
@@ -1,13 +1,15 @@
{
"charts": [],
- "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"ToDo\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Note\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Assignment Rule\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Auto Repeat\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Email\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Automation\",\"col\":4}}]",
+ "content": "[{\"id\":\"-P-RG1wVHg\",\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"id\":\"sR-UFcO7II\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Import Data\",\"col\":3}},{\"id\":\"IkcVmgWb3z\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"ToDo\",\"col\":3}},{\"id\":\"6wir-jZFRE\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File\",\"col\":3}},{\"id\":\"45a1jzQkTm\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Assignment Rule\",\"col\":3}},{\"id\":\"LdZrgvxxo7\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"yNSSTIaDWZ\",\"type\":\"header\",\"data\":{\"text\":\"Documents\",\"col\":12}},{\"id\":\"0yceBIfhHM\",\"type\":\"card\",\"data\":{\"card_name\":\"Data\",\"col\":4}},{\"id\":\"42WbBA9rpj\",\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"id\":\"7_U7_xCOos\",\"type\":\"card\",\"data\":{\"card_name\":\"Email\",\"col\":4}},{\"id\":\"SlYKJZj5r3\",\"type\":\"card\",\"data\":{\"card_name\":\"Automation\",\"col\":4}}]",
"creation": "2020-03-02 14:53:24.980279",
+ "custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "tool",
"idx": 0,
+ "is_hidden": 0,
"label": "Tools",
"links": [
{
@@ -132,28 +134,89 @@
"link_type": "DocType",
"onboard": 0,
"type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Data",
+ "link_count": 5,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Import Data",
+ "link_count": 0,
+ "link_to": "Data Import",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Export Data",
+ "link_count": 0,
+ "link_to": "Data Export",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Bulk Update",
+ "link_count": 0,
+ "link_to": "Bulk Update",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Download Backups",
+ "link_count": 0,
+ "link_to": "backups",
+ "link_type": "Page",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Deleted Documents",
+ "link_count": 0,
+ "link_to": "Deleted Document",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
}
],
- "modified": "2022-12-12 14:58:44.733393",
+ "modified": "2023-05-24 14:47:24.740856",
"modified_by": "Administrator",
"module": "Automation",
"name": "Tools",
+ "number_cards": [],
"owner": "Administrator",
"parent_page": "",
"public": 1,
"quick_lists": [],
"restrict_to_domain": "",
"roles": [],
- "sequence_id": 26.0,
+ "sequence_id": 17.0,
"shortcuts": [
{
- "label": "ToDo",
- "link_to": "ToDo",
+ "color": "Grey",
+ "doc_view": "List",
+ "label": "Import Data",
+ "link_to": "Data Import",
"type": "DocType"
},
{
- "label": "Note",
- "link_to": "Note",
+ "label": "ToDo",
+ "link_to": "ToDo",
"type": "DocType"
},
{
@@ -165,11 +228,6 @@
"label": "Assignment Rule",
"link_to": "Assignment Rule",
"type": "DocType"
- },
- {
- "label": "Auto Repeat",
- "link_to": "Auto Repeat",
- "type": "DocType"
}
],
"title": "Tools"
diff --git a/frappe/boot.py b/frappe/boot.py
index 83c9902020..8881d25bd6 100644
--- a/frappe/boot.py
+++ b/frappe/boot.py
@@ -8,6 +8,7 @@ import frappe
import frappe.defaults
import frappe.desk.desk_page
from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo, get_navbar_settings
+from frappe.desk.doctype.form_tour.form_tour import get_onboarding_ui_tours
from frappe.desk.doctype.route_history.route_history import frequently_visited_links
from frappe.desk.form.load import get_meta_bundle
from frappe.email.inbox import get_email_accounts
@@ -68,6 +69,7 @@ def get_bootinfo():
bootinfo.home_folder = frappe.db.get_value("File", {"is_home_folder": 1})
bootinfo.navbar_settings = get_navbar_settings()
bootinfo.notification_settings = get_notification_settings()
+ bootinfo.onboarding_tours = get_onboarding_ui_tours()
set_time_zone(bootinfo)
# ipinfo
@@ -147,10 +149,8 @@ def get_allowed_report_names(cache=False) -> set[str]:
def get_user_pages_or_reports(parent, cache=False):
- _cache = frappe.cache()
-
if cache:
- has_role = _cache.get_value("has_role:" + parent, user=frappe.session.user)
+ has_role = frappe.cache.get_value("has_role:" + parent, user=frappe.session.user)
if has_role:
return has_role
@@ -252,7 +252,7 @@ def get_user_pages_or_reports(parent, cache=False):
has_role.pop(r, None)
# Expire every six hours
- _cache.set_value("has_role:" + parent, has_role, frappe.session.user, 21600)
+ frappe.cache.set_value("has_role:" + parent, has_role, frappe.session.user, 21600)
return has_role
diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py
index 12e829ff09..6ee88d9d37 100644
--- a/frappe/cache_manager.py
+++ b/frappe/cache_manager.py
@@ -79,28 +79,25 @@ doctype_cache_keys = (
def clear_user_cache(user=None):
- cache = frappe.cache()
-
# this will automatically reload the global cache
# so it is important to clear this first
clear_notifications(user)
if user:
for name in user_cache_keys:
- cache.hdel(name, user)
- cache.delete_keys("user:" + user)
+ frappe.cache.hdel(name, user)
+ frappe.cache.delete_keys("user:" + user)
clear_defaults_cache(user)
else:
for name in user_cache_keys:
- cache.delete_key(name)
+ frappe.cache.delete_key(name)
clear_defaults_cache()
clear_global_cache()
def clear_domain_cache(user=None):
- cache = frappe.cache()
domain_cache_keys = ("domain_restricted_doctypes", "domain_restricted_pages")
- cache.delete_value(domain_cache_keys)
+ frappe.cache.delete_value(domain_cache_keys)
def clear_global_cache():
@@ -108,29 +105,36 @@ def clear_global_cache():
clear_doctype_cache()
clear_website_cache()
- frappe.cache().delete_value(global_cache_keys)
- frappe.cache().delete_value(bench_cache_keys)
+ frappe.cache.delete_value(global_cache_keys)
+ frappe.cache.delete_value(bench_cache_keys)
frappe.setup_module_map()
def clear_defaults_cache(user=None):
if user:
for p in [user] + common_default_keys:
- frappe.cache().hdel("defaults", p)
+ frappe.cache.hdel("defaults", p)
elif frappe.flags.in_install != "frappe":
- frappe.cache().delete_key("defaults")
+ frappe.cache.delete_key("defaults")
def clear_doctype_cache(doctype=None):
clear_controller_cache(doctype)
- cache = frappe.cache()
- for key in ("is_table", "doctype_modules", "document_cache"):
- cache.delete_value(key)
+ _clear_doctype_cache_form_redis()
+ if hasattr(frappe.db, "after_commit"):
+ frappe.db.after_commit.add(_clear_doctype_cache_form_redis)
+ frappe.db.after_rollback.add(_clear_doctype_cache_form_redis)
+
+
+def _clear_doctype_cache_form_redis(doctype: str | None = None):
+ for key in ("is_table", "doctype_modules"):
+ frappe.cache.delete_value(key)
def clear_single(dt):
+ frappe.clear_document_cache(dt)
for name in doctype_cache_keys:
- cache.hdel(name, dt)
+ frappe.cache.hdel(name, dt)
if doctype:
clear_single(doctype)
@@ -154,7 +158,8 @@ def clear_doctype_cache(doctype=None):
else:
# clear all
for name in doctype_cache_keys:
- cache.delete_value(name)
+ frappe.cache.delete_value(name)
+ frappe.cache.delete_keys("document_cache::")
def clear_controller_cache(doctype=None):
@@ -167,7 +172,7 @@ def clear_controller_cache(doctype=None):
def get_doctype_map(doctype, name, filters=None, order_by=None):
- return frappe.cache().hget(
+ return frappe.cache.hget(
get_doctype_map_key(doctype),
name,
lambda: frappe.get_all(doctype, filters=filters, order_by=order_by, ignore_ddl=True),
@@ -175,7 +180,7 @@ def get_doctype_map(doctype, name, filters=None, order_by=None):
def clear_doctype_map(doctype, name):
- frappe.cache().hdel(frappe.scrub(doctype) + "_map", name)
+ frappe.cache.hdel(frappe.scrub(doctype) + "_map", name)
def build_table_count_cache():
@@ -188,7 +193,6 @@ def build_table_count_cache():
):
return
- _cache = frappe.cache()
table_name = frappe.qb.Field("table_name").as_("name")
table_rows = frappe.qb.Field("table_rows").as_("count")
information_schema = frappe.qb.Schema("information_schema")
@@ -197,7 +201,7 @@ def build_table_count_cache():
as_dict=True
)
counts = {d.get("name").replace("tab", "", 1): d.get("count", None) for d in data}
- _cache.set_value("information_schema:counts", counts)
+ frappe.cache.set_value("information_schema:counts", counts)
return counts
@@ -211,11 +215,10 @@ def build_domain_restriced_doctype_cache(*args, **kwargs):
or frappe.flags.in_setup_wizard
):
return
- _cache = frappe.cache()
active_domains = frappe.get_active_domains()
doctypes = frappe.get_all("DocType", filters={"restrict_to_domain": ("IN", active_domains)})
doctypes = [doc.name for doc in doctypes]
- _cache.set_value("domain_restricted_doctypes", doctypes)
+ frappe.cache.set_value("domain_restricted_doctypes", doctypes)
return doctypes
@@ -229,10 +232,9 @@ def build_domain_restriced_page_cache(*args, **kwargs):
or frappe.flags.in_setup_wizard
):
return
- _cache = frappe.cache()
active_domains = frappe.get_active_domains()
pages = frappe.get_all("Page", filters={"restrict_to_domain": ("IN", active_domains)})
pages = [page.name for page in pages]
- _cache.set_value("domain_restricted_pages", pages)
+ frappe.cache.set_value("domain_restricted_pages", pages)
return pages
diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py
index 03374986d4..e44009a886 100644
--- a/frappe/commands/utils.py
+++ b/frappe/commands/utils.py
@@ -623,7 +623,7 @@ frappe.db.connect()
def _console_cleanup():
- # Execute rollback_observers on console close
+ # Execute after_rollback on console close
frappe.db.rollback()
frappe.destroy()
diff --git a/frappe/contacts/address_and_contact.py b/frappe/contacts/address_and_contact.py
index 1d3a5d644c..74cbcfa6b7 100644
--- a/frappe/contacts/address_and_contact.py
+++ b/frappe/contacts/address_and_contact.py
@@ -1,77 +1,17 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
-import functools
-import re
-
import frappe
from frappe import _
-def load_address_and_contact(doc, key=None):
+def load_address_and_contact(doc, key=None) -> None:
"""Loads address list and contact list in `__onload`"""
- from frappe.contacts.doctype.address.address import get_address_display, get_condensed_address
+ from frappe.contacts.doctype.address.address import get_address_display_list
+ from frappe.contacts.doctype.contact.contact import get_contact_display_list
- filters = [
- ["Dynamic Link", "link_doctype", "=", doc.doctype],
- ["Dynamic Link", "link_name", "=", doc.name],
- ["Dynamic Link", "parenttype", "=", "Address"],
- ]
- address_list = frappe.get_list("Address", filters=filters, fields=["*"], order_by="creation asc")
-
- address_list = [a.update({"display": get_address_display(a)}) for a in address_list]
-
- address_list = sorted(
- address_list,
- key=functools.cmp_to_key(
- lambda a, b: (int(a.is_primary_address - b.is_primary_address))
- or (1 if a.modified - b.modified else 0)
- ),
- reverse=True,
- )
-
- doc.set_onload("addr_list", address_list)
-
- contact_list = []
- filters = [
- ["Dynamic Link", "link_doctype", "=", doc.doctype],
- ["Dynamic Link", "link_name", "=", doc.name],
- ["Dynamic Link", "parenttype", "=", "Contact"],
- ]
- contact_list = frappe.get_list("Contact", filters=filters, fields=["*"])
-
- for contact in contact_list:
- contact["email_ids"] = frappe.get_all(
- "Contact Email",
- filters={"parenttype": "Contact", "parent": contact.name, "is_primary": 0},
- fields=["email_id"],
- )
-
- contact["phone_nos"] = frappe.get_all(
- "Contact Phone",
- filters={
- "parenttype": "Contact",
- "parent": contact.name,
- "is_primary_phone": 0,
- "is_primary_mobile_no": 0,
- },
- fields=["phone"],
- )
-
- if contact.address:
- address = frappe.get_doc("Address", contact.address)
- contact["address"] = get_condensed_address(address)
-
- contact_list = sorted(
- contact_list,
- key=functools.cmp_to_key(
- lambda a, b: (int(a.is_primary_contact - b.is_primary_contact))
- or (1 if a.modified - b.modified else 0)
- ),
- reverse=True,
- )
-
- doc.set_onload("contact_list", contact_list)
+ doc.set_onload("addr_list", get_address_display_list(doc.doctype, doc.name))
+ doc.set_onload("contact_list", get_contact_display_list(doc.doctype, doc.name))
def has_permission(doc, ptype, user):
diff --git a/frappe/contacts/doctype/address/address.py b/frappe/contacts/doctype/address/address.py
index 94bc65a115..78ae3d549b 100644
--- a/frappe/contacts/doctype/address/address.py
+++ b/frappe/contacts/doctype/address/address.py
@@ -291,3 +291,23 @@ def get_condensed_address(doc):
def update_preferred_address(address, field):
frappe.db.set_value("Address", address, field, 0)
+
+
+def get_address_display_list(doctype: str, name: str) -> list[dict]:
+ if not frappe.has_permission("Address", "read"):
+ return []
+
+ address_list = frappe.get_list(
+ "Address",
+ filters=[
+ ["Dynamic Link", "link_doctype", "=", doctype],
+ ["Dynamic Link", "link_name", "=", name],
+ ["Dynamic Link", "parenttype", "=", "Address"],
+ ],
+ fields=["*"],
+ order_by="is_primary_address DESC, creation ASC",
+ )
+ for a in address_list:
+ a["display"] = get_address_display(a)
+
+ return address_list
diff --git a/frappe/contacts/doctype/contact/contact.py b/frappe/contacts/doctype/contact/contact.py
index e7d250148b..e58a5a2b7a 100644
--- a/frappe/contacts/doctype/contact/contact.py
+++ b/frappe/contacts/doctype/contact/contact.py
@@ -341,3 +341,45 @@ def get_full_name(
full_name = company
return full_name
+
+
+def get_contact_display_list(doctype: str, name: str) -> list[dict]:
+ from frappe.contacts.doctype.address.address import get_condensed_address
+
+ if not frappe.has_permission("Contact", "read"):
+ return []
+
+ contact_list = frappe.get_list(
+ "Contact",
+ filters=[
+ ["Dynamic Link", "link_doctype", "=", doctype],
+ ["Dynamic Link", "link_name", "=", name],
+ ["Dynamic Link", "parenttype", "=", "Contact"],
+ ],
+ fields=["*"],
+ order_by="is_primary_contact DESC, creation ASC",
+ )
+
+ for contact in contact_list:
+ contact["email_ids"] = frappe.get_all(
+ "Contact Email",
+ filters={"parenttype": "Contact", "parent": contact.name, "is_primary": 0},
+ fields=["email_id"],
+ )
+
+ contact["phone_nos"] = frappe.get_all(
+ "Contact Phone",
+ filters={
+ "parenttype": "Contact",
+ "parent": contact.name,
+ "is_primary_phone": 0,
+ "is_primary_mobile_no": 0,
+ },
+ fields=["phone"],
+ )
+
+ if contact.address and frappe.has_permission("Address", "read"):
+ address = frappe.get_doc("Address", contact.address)
+ contact["address"] = get_condensed_address(address)
+
+ return contact_list
diff --git a/frappe/core/doctype/comment/comment.py b/frappe/core/doctype/comment/comment.py
index dff13e1170..c86c7811ad 100644
--- a/frappe/core/doctype/comment/comment.py
+++ b/frappe/core/doctype/comment/comment.py
@@ -152,14 +152,9 @@ def update_comments_in_parent(reference_doctype, reference_name, _comments):
except Exception as e:
if frappe.db.is_column_missing(e) and getattr(frappe.local, "request", None):
- # missing column and in request, add column and update after commit
- frappe.local._comments = getattr(frappe.local, "_comments", []) + [
- (reference_doctype, reference_name, _comments)
- ]
-
+ pass
elif frappe.db.is_data_too_long(e):
raise frappe.DataTooLongException
-
else:
raise
else:
@@ -169,13 +164,3 @@ def update_comments_in_parent(reference_doctype, reference_name, _comments):
# Clear route cache
if route := frappe.get_cached_value(reference_doctype, reference_name, "route"):
clear_cache(route)
-
-
-def update_comments_in_parent_after_request():
- """update _comments in parent if _comments column is missing"""
- if hasattr(frappe.local, "_comments"):
- for (reference_doctype, reference_name, _comments) in frappe.local._comments:
- add_column(reference_doctype, "_comments", "Text")
- update_comments_in_parent(reference_doctype, reference_name, _comments)
-
- frappe.db.commit()
diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py
index 20a8e7db9b..0b983d0be9 100644
--- a/frappe/core/doctype/data_import/importer.py
+++ b/frappe/core/doctype/data_import/importer.py
@@ -62,7 +62,7 @@ class Importer:
def before_import(self):
# set user lang for translations
- frappe.cache().hdel("lang", frappe.session.user)
+ frappe.cache.hdel("lang", frappe.session.user)
frappe.set_user_lang(frappe.session.user)
# set flags
@@ -1207,7 +1207,7 @@ def get_df_for_column_header(doctype, header):
def build_fields_dict_for_doctype():
return build_fields_dict_for_column_matching(doctype)
- df_by_labels_and_fieldname = frappe.cache().hget(
+ df_by_labels_and_fieldname = frappe.cache.hget(
"data_import_column_header_map", doctype, generator=build_fields_dict_for_doctype
)
return df_by_labels_and_fieldname.get(header)
diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py
index 9a0613e6ca..6abc34a035 100644
--- a/frappe/core/doctype/doctype/doctype.py
+++ b/frappe/core/doctype/doctype/doctype.py
@@ -33,7 +33,7 @@ from frappe.model.meta import Meta
from frappe.modules import get_doc_path, make_boilerplate
from frappe.modules.import_file import get_file_path
from frappe.query_builder.functions import Concat
-from frappe.utils import cint, random_string
+from frappe.utils import cint, flt, random_string
from frappe.website.utils import clear_cache
if TYPE_CHECKING:
@@ -1679,7 +1679,9 @@ def make_module_and_roles(doc, perm_fieldname="permissions"):
for role in list(set(roles)):
if frappe.db.table_exists("Role", cached=False) and not frappe.db.exists("Role", role):
- r = frappe.get_doc(dict(doctype="Role", role_name=role, desk_access=1))
+ r = frappe.new_doc("Role")
+ r.role_name = role
+ r.desk_access = 1
r.flags.ignore_mandatory = r.flags.ignore_permissions = True
r.insert()
except frappe.DoesNotExistError as e:
@@ -1708,7 +1710,7 @@ def check_fieldname_conflicts(docfield):
def clear_linked_doctype_cache():
- frappe.cache().delete_value("linked_doctypes_without_ignore_user_permissions_enabled")
+ frappe.cache.delete_value("linked_doctypes_without_ignore_user_permissions_enabled")
def check_email_append_to(doc):
@@ -1749,3 +1751,14 @@ def get_field(doc, fieldname):
for field in doc.fields:
if field.fieldname == fieldname:
return field
+
+
+@frappe.whitelist()
+def get_row_size_utilization(doctype: str) -> float:
+ """Get row size utilization in percentage"""
+
+ frappe.has_permission("DocType", throw=True)
+ try:
+ return flt(frappe.db.get_row_size(doctype) / frappe.db.MAX_ROW_SIZE_LIMIT * 100, 2)
+ except Exception:
+ return 0.0
diff --git a/frappe/core/doctype/doctype/doctype_list.js b/frappe/core/doctype/doctype/doctype_list.js
new file mode 100644
index 0000000000..f4811fa01d
--- /dev/null
+++ b/frappe/core/doctype/doctype/doctype_list.js
@@ -0,0 +1,28 @@
+frappe.listview_settings["DocType"] = {
+ onload: function (me) {
+ me.page.btn_primary.addClass("hidden");
+ this.setup_select_primary_button(me);
+ },
+
+ setup_select_primary_button: function (me) {
+ let actions = [
+ {
+ label: __("Add DocType (Form Builder)"),
+ description: __("Use the form builder to create a new DocType"),
+ action: () => frappe.set_route("form-builder", "new-doctype"),
+ },
+ {
+ label: __("Add DocType"),
+ description: __("Create a new DocType"),
+ action: () => frappe.new_doc("DocType"),
+ },
+ ];
+
+ frappe.utils.add_select_group_button(
+ me.page.btn_primary.parent(),
+ actions,
+ "btn-primary",
+ "add"
+ );
+ },
+};
diff --git a/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py b/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py
index 98ce9e738b..bcd3197112 100644
--- a/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py
+++ b/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py
@@ -2,6 +2,7 @@
# See license.txt
import frappe
+from frappe.core.doctype.doctype.test_doctype import new_doctype
from frappe.core.doctype.document_naming_settings.document_naming_settings import (
DocumentNamingSettings,
)
@@ -11,6 +12,25 @@ from frappe.utils import cint
class TestNamingSeries(FrappeTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.ns_doctype = (
+ new_doctype(
+ fields=[
+ {
+ "label": "Series",
+ "fieldname": "naming_series",
+ "fieldtype": "Select",
+ "options": f"\n{frappe.generate_hash()}-.###",
+ }
+ ],
+ autoname="naming_series:",
+ )
+ .insert()
+ .name
+ )
+
def setUp(self):
self.dns: DocumentNamingSettings = frappe.get_doc("Document Naming Settings")
@@ -23,7 +43,7 @@ class TestNamingSeries(FrappeTestCase):
return VALID_SERIES + exisiting_series
def test_naming_preview(self):
- self.dns.transaction_type = "Webhook"
+ self.dns.transaction_type = self.ns_doctype
self.dns.try_naming_series = "AXBZ.####"
serieses = self.dns.preview_series().split("\n")
@@ -35,23 +55,22 @@ class TestNamingSeries(FrappeTestCase):
def test_get_transactions(self):
naming_info = self.dns.get_transactions_and_prefixes()
- self.assertIn("Webhook", naming_info["transactions"])
+ self.assertIn(self.ns_doctype, naming_info["transactions"])
- existing_naming_series = frappe.get_meta("Webhook").get_field("naming_series").options
+ existing_naming_series = frappe.get_meta(self.ns_doctype).get_field("naming_series").options
for series in existing_naming_series.split("\n"):
self.assertIn(NamingSeries(series).get_prefix(), naming_info["prefixes"])
def test_default_naming_series(self):
- self.assertIn("HOOK", get_default_naming_series("Webhook"))
self.assertIsNone(get_default_naming_series("DocType"))
def test_updates_naming_options(self):
- self.dns.transaction_type = "Webhook"
+ self.dns.transaction_type = self.ns_doctype
test_series = "KOOHBEW.###"
self.dns.naming_series_options = self.dns.get_options() + "\n" + test_series
self.dns.update_series()
- self.assertIn(test_series, frappe.get_meta("Webhook").get_naming_series_options())
+ self.assertIn(test_series, frappe.get_meta(self.ns_doctype).get_naming_series_options())
def test_update_series_counter(self):
for series in self.get_valid_serieses():
diff --git a/frappe/core/doctype/domain_settings/domain_settings.py b/frappe/core/doctype/domain_settings/domain_settings.py
index 85b26f53dd..d963a14830 100644
--- a/frappe/core/doctype/domain_settings/domain_settings.py
+++ b/frappe/core/doctype/domain_settings/domain_settings.py
@@ -73,7 +73,7 @@ def get_active_domains():
active_domains.append("")
return active_domains
- return frappe.cache().get_value("active_domains", _get_active_domains)
+ return frappe.cache.get_value("active_domains", _get_active_domains)
def get_active_modules():
@@ -87,4 +87,4 @@ def get_active_modules():
active_modules.append(m.name)
return active_modules
- return frappe.cache().get_value("active_modules", _get_active_modules)
+ return frappe.cache.get_value("active_modules", _get_active_modules)
diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py
index 7c5b44fa3c..af408cfcdc 100755
--- a/frappe/core/doctype/file/file.py
+++ b/frappe/core/doctype/file/file.py
@@ -70,7 +70,7 @@ class File(Document):
else:
self.save_file(content=self.get_content())
self.flags.new_file = True
- frappe.local.rollback_observers.append(self)
+ frappe.db.after_rollback.add(self.on_rollback)
def after_insert(self):
if not self.is_folder:
@@ -122,10 +122,16 @@ class File(Document):
self.add_comment_in_reference_doc("Attachment Removed", _("Removed {0}").format(self.file_name))
def on_rollback(self):
+ rollback_flags = ("new_file", "original_content", "original_path")
+
+ def pop_rollback_flags():
+ for flag in rollback_flags:
+ self.flags.pop(flag, None)
+
# following condition is only executed when an insert has been rolledback
if self.flags.new_file:
self._delete_file_on_disk()
- self.flags.pop("new_file")
+ pop_rollback_flags()
return
# if original_content flag is set, this rollback should revert the file to its original state
@@ -140,14 +146,14 @@ class File(Document):
with open(file_path, mode) as f:
f.write(self.flags.original_content)
os.fsync(f.fileno())
- self.flags.pop("original_content")
+ pop_rollback_flags()
# used in case file path (File.file_url) has been changed
if self.flags.original_path:
target = self.flags.original_path["old"]
source = self.flags.original_path["new"]
shutil.move(source, target)
- self.flags.pop("original_path")
+ pop_rollback_flags()
def get_name_based_on_parent_folder(self) -> str | None:
if self.folder:
@@ -219,7 +225,7 @@ class File(Document):
# Uses os.rename which is an atomic operation
shutil.move(source, target)
self.flags.original_path = {"old": source, "new": target}
- frappe.local.rollback_observers.append(self)
+ frappe.db.after_rollback.add(self.on_rollback)
self.file_url = updated_file_url
update_existing_file_docs(self)
@@ -521,7 +527,7 @@ class File(Document):
f.write(self._content)
os.fsync(f.fileno())
- frappe.local.rollback_observers.append(self)
+ frappe.db.after_rollback.add(self.on_rollback)
return file_path
diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py
index dbab111257..b07e344dc0 100644
--- a/frappe/core/doctype/file/test_file.py
+++ b/frappe/core/doctype/file/test_file.py
@@ -17,7 +17,7 @@ from frappe.core.api.file import (
move_file,
unzip_file,
)
-from frappe.core.doctype.file.utils import get_extension
+from frappe.core.doctype.file.utils import delete_file, get_extension
from frappe.exceptions import ValidationError
from frappe.tests.utils import FrappeTestCase
from frappe.utils import get_files_path
@@ -77,6 +77,16 @@ class TestSimpleFile(FrappeTestCase):
self.assertEqual(content, self.test_content)
+class TestFSRollbacks(FrappeTestCase):
+ def test_rollback_from_file_system(self):
+ file_name = content = frappe.generate_hash()
+ file = frappe.new_doc("File", file_name=file_name, content=content).insert()
+ self.assertTrue(file.exists_on_disk())
+
+ frappe.db.rollback()
+ self.assertFalse(file.exists_on_disk())
+
+
class TestBase64File(FrappeTestCase):
def setUp(self):
self.attached_to_doctype, self.attached_to_docname = make_test_doc()
diff --git a/frappe/core/doctype/patch_log/patch_log.js b/frappe/core/doctype/patch_log/patch_log.js
index 171a1d3a0f..78580a0cb0 100644
--- a/frappe/core/doctype/patch_log/patch_log.js
+++ b/frappe/core/doctype/patch_log/patch_log.js
@@ -4,5 +4,9 @@
frappe.ui.form.on("Patch Log", {
refresh: function (frm) {
frm.disable_save();
+
+ frm.add_custom_button(__("Re-Run Patch"), () => {
+ frm.call("rerun_patch");
+ });
},
});
diff --git a/frappe/core/doctype/patch_log/patch_log.json b/frappe/core/doctype/patch_log/patch_log.json
index 53e85b99d3..6be3ce089e 100644
--- a/frappe/core/doctype/patch_log/patch_log.json
+++ b/frappe/core/doctype/patch_log/patch_log.json
@@ -22,6 +22,7 @@
"default": "0",
"fieldname": "skipped",
"fieldtype": "Check",
+ "in_list_view": 1,
"label": "Skipped",
"read_only": 1
},
@@ -36,7 +37,7 @@
"icon": "fa fa-cog",
"idx": 1,
"links": [],
- "modified": "2023-05-10 19:27:10.883330",
+ "modified": "2023-06-07 00:00:01.369265",
"modified_by": "Administrator",
"module": "Core",
"name": "Patch Log",
diff --git a/frappe/core/doctype/patch_log/patch_log.py b/frappe/core/doctype/patch_log/patch_log.py
index c7d619017e..284a80df35 100644
--- a/frappe/core/doctype/patch_log/patch_log.py
+++ b/frappe/core/doctype/patch_log/patch_log.py
@@ -4,11 +4,20 @@
# License: MIT. See LICENSE
import frappe
+from frappe import _
from frappe.model.document import Document
class PatchLog(Document):
- pass
+ @frappe.whitelist()
+ def rerun_patch(self):
+ from frappe.modules.patch_handler import run_single
+
+ if not frappe.conf.developer_mode:
+ frappe.throw(_("Re-running patch is only allowed in developer mode."))
+
+ run_single(self.patch, force=True)
+ frappe.msgprint(_("Successfully re-ran patch: {0}").format(self.patch), alert=True)
def before_migrate():
diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py
index 9b2a2ccc18..8cdbc24074 100644
--- a/frappe/core/doctype/report/report.py
+++ b/frappe/core/doctype/report/report.py
@@ -137,7 +137,7 @@ class Report(Document):
if execution_time > threshold and not self.prepared_report:
self.db_set("prepared_report", 1)
- frappe.cache().hset("report_execution_time", self.name, execution_time)
+ frappe.cache.hset("report_execution_time", self.name, execution_time)
return res
diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py
index 31b82501cb..8e5ec269ea 100644
--- a/frappe/core/doctype/role/role.py
+++ b/frappe/core/doctype/role/role.py
@@ -24,7 +24,7 @@ class Role(Document):
frappe.throw(frappe._("Standard roles cannot be renamed"))
def after_insert(self):
- frappe.cache().hdel("roles", "Administrator")
+ frappe.cache.hdel("roles", "Administrator")
def validate(self):
if self.disabled:
diff --git a/frappe/core/doctype/rq_job/test_rq_job.py b/frappe/core/doctype/rq_job/test_rq_job.py
index 265583fe83..09a90f7445 100644
--- a/frappe/core/doctype/rq_job/test_rq_job.py
+++ b/frappe/core/doctype/rq_job/test_rq_job.py
@@ -11,7 +11,7 @@ import frappe
from frappe.core.doctype.rq_job.rq_job import RQJob, remove_failed_jobs, stop_job
from frappe.tests.utils import FrappeTestCase, timeout
from frappe.utils import cstr, execute_in_shell
-from frappe.utils.background_jobs import is_job_enqueued
+from frappe.utils.background_jobs import get_job_status, is_job_enqueued
class TestRQJob(FrappeTestCase):
@@ -104,6 +104,26 @@ class TestRQJob(FrappeTestCase):
self.check_status(job, "finished")
self.assertFalse(is_job_enqueued(job_id))
+ @timeout(20)
+ def test_enqueue_after_commit(self):
+ job_id = frappe.generate_hash()
+
+ frappe.enqueue(self.BG_JOB, enqueue_after_commit=True, job_id=job_id)
+ self.assertIsNone(get_job_status(job_id))
+
+ frappe.db.commit()
+ self.assertIsNotNone(get_job_status(job_id))
+
+ job_id = frappe.generate_hash()
+ frappe.enqueue(self.BG_JOB, enqueue_after_commit=True, job_id=job_id)
+ self.assertIsNone(get_job_status(job_id))
+
+ frappe.db.rollback()
+ self.assertIsNone(get_job_status(job_id))
+
+ frappe.db.commit()
+ self.assertIsNone(get_job_status(job_id))
+
def test_func(fail=False, sleep=0):
if fail:
diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json
index 3aedd4f542..67cb6e75ea 100644
--- a/frappe/core/doctype/server_script/server_script.json
+++ b/frappe/core/doctype/server_script/server_script.json
@@ -9,6 +9,7 @@
"script_type",
"reference_doctype",
"event_frequency",
+ "cron_format",
"doctype_event",
"api_method",
"allow_guest",
@@ -99,7 +100,7 @@
"fieldtype": "Select",
"label": "Event Frequency",
"mandatory_depends_on": "eval:doc.script_type == \"Scheduler Event\"",
- "options": "All\nHourly\nDaily\nWeekly\nMonthly\nYearly\nHourly Long\nDaily Long\nWeekly Long\nMonthly Long"
+ "options": "All\nHourly\nDaily\nWeekly\nMonthly\nYearly\nHourly Long\nDaily Long\nWeekly Long\nMonthly Long\nCron"
},
{
"fieldname": "module",
@@ -132,6 +133,12 @@
"fieldname": "rate_limit_seconds",
"fieldtype": "Int",
"label": "Time Window (Seconds)"
+ },
+ {
+ "depends_on": "eval:doc.event_frequency==='Cron'",
+ "fieldname": "cron_format",
+ "fieldtype": "Data",
+ "label": "Cron Format"
}
],
"index_web_pages_for_search": 1,
@@ -141,7 +148,7 @@
"link_fieldname": "server_script"
}
],
- "modified": "2023-05-16 11:03:58.282680",
+ "modified": "2023-05-27 16:33:16.595424",
"modified_by": "Administrator",
"module": "Core",
"name": "Server Script",
diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py
index a9b870e240..758bd46a76 100644
--- a/frappe/core/doctype/server_script/server_script.py
+++ b/frappe/core/doctype/server_script/server_script.py
@@ -19,7 +19,7 @@ class ServerScript(Document):
self.check_if_compilable_in_restricted_context()
def on_update(self):
- frappe.cache().delete_value("server_script_map")
+ frappe.cache.delete_value("server_script_map")
self.sync_scheduler_events()
def on_trash(self):
@@ -52,11 +52,16 @@ class ServerScript(Document):
def sync_scheduler_events(self):
"""Create or update Scheduled Job Type documents for Scheduler Event Server Scripts"""
if not self.disabled and self.event_frequency and self.script_type == "Scheduler Event":
- setup_scheduler_events(script_name=self.name, frequency=self.event_frequency)
+ cron_format = self.cron_format if self.event_frequency == "Cron" else None
+ setup_scheduler_events(
+ script_name=self.name, frequency=self.event_frequency, cron_format=cron_format
+ )
def clear_scheduled_events(self):
- """Deletes existing scheduled jobs by Server Script if self.event_frequency has changed"""
- if self.script_type == "Scheduler Event" and self.has_value_changed("event_frequency"):
+ """Deletes existing scheduled jobs by Server Script if self.event_frequency or self.cron_format has changed"""
+ if self.script_type == "Scheduler Event" and (
+ self.has_value_changed("event_frequency") or self.has_value_changed("cron_format")
+ ):
for scheduled_job in self.scheduled_jobs:
frappe.delete_doc("Scheduled Job Type", scheduled_job.name)
@@ -163,15 +168,15 @@ class ServerScript(Document):
out.append([key, score])
return out
- items = frappe.cache().get_value("server_script_autocompletion_items")
+ items = frappe.cache.get_value("server_script_autocompletion_items")
if not items:
items = get_keys(get_safe_globals())
items = [{"value": d[0], "score": d[1]} for d in items]
- frappe.cache().set_value("server_script_autocompletion_items", items)
+ frappe.cache.set_value("server_script_autocompletion_items", items)
return items
-def setup_scheduler_events(script_name, frequency):
+def setup_scheduler_events(script_name: str, frequency: str, cron_format: str | None = None):
"""Creates or Updates Scheduled Job Type documents based on the specified script name and frequency
Args:
@@ -188,6 +193,7 @@ def setup_scheduler_events(script_name, frequency):
"method": method,
"frequency": frequency,
"server_script": script_name,
+ "cron_format": cron_format,
}
).insert()
@@ -200,6 +206,7 @@ def setup_scheduler_events(script_name, frequency):
return
doc.frequency = frequency
+ doc.cron_format = cron_format
doc.save()
frappe.msgprint(_("Scheduled execution for script {0} has updated").format(script_name))
diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py
index b807b43d10..6ba65e7353 100644
--- a/frappe/core/doctype/server_script/server_script_utils.py
+++ b/frappe/core/doctype/server_script/server_script_utils.py
@@ -55,7 +55,7 @@ def get_server_script_map():
if frappe.flags.in_patch and not frappe.db.table_exists("Server Script"):
return {}
- script_map = frappe.cache().get_value("server_script_map")
+ script_map = frappe.cache.get_value("server_script_map")
if script_map is None:
script_map = {"permission_query": {}}
enabled_server_scripts = frappe.get_all(
@@ -73,6 +73,6 @@ def get_server_script_map():
else:
script_map.setdefault("_api", {})[script.api_method] = script.name
- frappe.cache().set_value("server_script_map", script_map)
+ frappe.cache.set_value("server_script_map", script_map)
return script_map
diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py
index 3d11a02ca4..af1352f02b 100644
--- a/frappe/core/doctype/server_script/test_server_script.py
+++ b/frappe/core/doctype/server_script/test_server_script.py
@@ -3,6 +3,7 @@
import requests
import frappe
+from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
from frappe.frappeclient import FrappeClient, FrappeException
from frappe.tests.utils import FrappeTestCase
from frappe.utils import get_site_url
@@ -103,10 +104,10 @@ class TestServerScript(FrappeTestCase):
def tearDownClass(cls):
frappe.db.commit()
frappe.db.truncate("Server Script")
- frappe.cache().delete_value("server_script_map")
+ frappe.cache.delete_value("server_script_map")
def setUp(self):
- frappe.cache().delete_value("server_script_map")
+ frappe.cache.delete_value("server_script_map")
def test_doctype_event(self):
todo = frappe.get_doc(dict(doctype="ToDo", description="hello")).insert()
@@ -283,3 +284,37 @@ frappe.qb.from_(todo).select(todo.name).where(todo.name == "{todo.name}").run()
script1.delete()
script2.delete()
frappe.db.commit()
+
+ def test_server_script_scheduled(self):
+ scheduled_script = frappe.get_doc(
+ doctype="Server Script",
+ name="scheduled_script_wo_cron",
+ script_type="Scheduler Event",
+ script="""frappe.flags = {"test": True}""",
+ event_frequency="Hourly",
+ ).insert()
+
+ cron_script = frappe.get_doc(
+ doctype="Server Script",
+ name="scheduled_script_w_cron",
+ script_type="Scheduler Event",
+ script="""frappe.flags = {"test": True}""",
+ event_frequency="Cron",
+ cron_format="0 0 1 1 *", # 1st january
+ ).insert()
+
+ # Ensure that jobs remain in DB after migrate
+ sync_jobs()
+ self.assertTrue(frappe.db.exists("Scheduled Job Type", {"server_script": scheduled_script.name}))
+
+ cron_job_name = frappe.db.get_value("Scheduled Job Type", {"server_script": cron_script.name})
+ self.assertTrue(cron_job_name)
+
+ cron_job = frappe.get_doc("Scheduled Job Type", cron_job_name)
+ self.assertEqual(cron_job.next_execution.day, 1)
+ self.assertEqual(cron_job.next_execution.month, 1)
+
+ cron_script.cron_format = "0 0 2 1 *" # 2nd january
+ cron_script.save()
+ cron_job.reload()
+ self.assertEqual(cron_job.next_execution.day, 2)
diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json
index 4249f250b7..5efe87da25 100644
--- a/frappe/core/doctype/system_settings/system_settings.json
+++ b/frappe/core/doctype/system_settings/system_settings.json
@@ -72,6 +72,8 @@
"disable_standard_email_footer",
"hide_footer_in_auto_email_reports",
"attach_view_link",
+ "welcome_email_template",
+ "reset_password_template",
"prepared_report_section",
"max_auto_email_report_per_user",
"system_updates_section",
@@ -548,13 +550,25 @@
"default": "1",
"fieldname": "enable_telemetry",
"fieldtype": "Check",
- "label": "Allow Sending Usage Data for Improving applications"
+ "label": "Allow Sending Usage Data for Improving Applications"
+ },
+ {
+ "fieldname": "welcome_email_template",
+ "fieldtype": "Link",
+ "label": "Welcome Email Template",
+ "options": "Email Template"
+ },
+ {
+ "fieldname": "reset_password_template",
+ "fieldtype": "Link",
+ "label": "Reset Password Template",
+ "options": "Email Template"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
- "modified": "2023-04-23 11:14:59.302851",
+ "modified": "2023-05-25 13:02:54.808773",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",
diff --git a/frappe/core/doctype/system_settings/system_settings.py b/frappe/core/doctype/system_settings/system_settings.py
index c4f35f3cc0..2fec4e87af 100644
--- a/frappe/core/doctype/system_settings/system_settings.py
+++ b/frappe/core/doctype/system_settings/system_settings.py
@@ -64,8 +64,8 @@ class SystemSettings(Document):
def on_update(self):
self.set_defaults()
- frappe.cache().delete_value("system_settings")
- frappe.cache().delete_value("time_zone")
+ frappe.cache.delete_value("system_settings")
+ frappe.cache.delete_value("time_zone")
if frappe.flags.update_last_reset_password_date:
update_last_reset_password_date()
diff --git a/frappe/core/doctype/translation/translation.py b/frappe/core/doctype/translation/translation.py
index 6afad00fad..a285806589 100644
--- a/frappe/core/doctype/translation/translation.py
+++ b/frappe/core/doctype/translation/translation.py
@@ -89,5 +89,5 @@ def create_translations(translation_map, language):
def clear_user_translation_cache(lang):
- frappe.cache().hdel(USER_TRANSLATION_KEY, lang)
- frappe.cache().hdel(MERGED_TRANSLATION_KEY, lang)
+ frappe.cache.hdel(USER_TRANSLATION_KEY, lang)
+ frappe.cache.hdel(MERGED_TRANSLATION_KEY, lang)
diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py
index d39d2062eb..b4d69d23d5 100644
--- a/frappe/core/doctype/user/test_user.py
+++ b/frappe/core/doctype/user/test_user.py
@@ -283,7 +283,7 @@ class TestUser(FrappeTestCase):
# Clear rate limit tracker to start fresh
key = f"rl:{data['cmd']}:{data['user']}"
- frappe.cache().delete(key)
+ frappe.cache.delete(key)
c = FrappeClient(url)
res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
@@ -330,7 +330,7 @@ class TestUser(FrappeTestCase):
sign_up(random_user, random_user_name, "/welcome"),
(1, "Please check your email for verification"),
)
- self.assertEqual(frappe.cache().hget("redirect_after_login", random_user), "/welcome")
+ self.assertEqual(frappe.cache.hget("redirect_after_login", random_user), "/welcome")
# re-register
self.assertTupleEqual(
diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json
index 00e1cffa88..0396776183 100644
--- a/frappe/core/doctype/user/user.json
+++ b/frappe/core/doctype/user/user.json
@@ -92,6 +92,7 @@
"generate_keys",
"column_break_65",
"api_secret",
+ "onboarding_status",
"connections_tab"
],
"fields": [
@@ -211,6 +212,7 @@
"read_only": 1
},
{
+ "allow_in_quick_entry": 1,
"fieldname": "role_profile_name",
"fieldtype": "Link",
"label": "Role Profile",
@@ -691,6 +693,13 @@
"fieldname": "desk_settings_section",
"fieldtype": "Section Break",
"label": "Desk Settings"
+ },
+ {
+ "default": "{}",
+ "fieldname": "onboarding_status",
+ "fieldtype": "Small Text",
+ "hidden": 1,
+ "label": "Onboarding Status"
}
],
"icon": "fa fa-user",
@@ -753,7 +762,7 @@
"link_fieldname": "user"
}
],
- "modified": "2022-09-19 16:05:46.485242",
+ "modified": "2023-06-05 17:26:04.127555",
"modified_by": "Administrator",
"module": "Core",
"name": "User",
@@ -792,4 +801,4 @@
"states": [],
"title_field": "full_name",
"track_changes": 1
-}
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py
index 14266e4cd8..4c5cea3130 100644
--- a/frappe/core/doctype/user/user.py
+++ b/frappe/core/doctype/user/user.py
@@ -60,8 +60,8 @@ class User(Document):
def after_insert(self):
create_notification_settings(self.name)
- frappe.cache().delete_key("users_for_mentions")
- frappe.cache().delete_key("enabled_users")
+ frappe.cache.delete_key("users_for_mentions")
+ frappe.cache.delete_key("enabled_users")
def validate(self):
# clear new password
@@ -75,6 +75,7 @@ class User(Document):
self.validate_email_type(self.email)
self.validate_email_type(self.name)
self.add_system_manager_role()
+ self.check_roles_added()
self.set_system_user()
self.set_full_name()
self.check_enable_disable()
@@ -142,10 +143,10 @@ class User(Document):
frappe.defaults.set_default("time_zone", self.time_zone, self.name)
if self.has_value_changed("enabled"):
- frappe.cache().delete_key("users_for_mentions")
- frappe.cache().delete_key("enabled_users")
+ frappe.cache.delete_key("users_for_mentions")
+ frappe.cache.delete_key("enabled_users")
elif self.has_value_changed("allow_in_mentions") or self.has_value_changed("user_type"):
- frappe.cache().delete_key("users_for_mentions")
+ frappe.cache.delete_key("users_for_mentions")
def has_website_permission(self, ptype, user, verbose=False):
"""Returns true if current user is the session user"""
@@ -282,6 +283,10 @@ class User(Document):
self.email_new_password(new_password)
except frappe.OutgoingEmailError:
+ frappe.clear_last_message()
+ frappe.msgprint(
+ _("Please setup default outgoing Email Account from Settings > Email Account"), alert=True
+ )
# email server not set, don't send email
self.log_error("Unable to send new password notification")
@@ -325,7 +330,16 @@ class User(Document):
return (self.first_name or "") + (self.first_name and " " or "") + (self.last_name or "")
def password_reset_mail(self, link):
- self.send_login_mail(_("Password Reset"), "password_reset", {"link": link}, now=True)
+
+ reset_password_template = frappe.db.get_system_setting("reset_password_template")
+
+ self.send_login_mail(
+ _("Password Reset"),
+ "password_reset",
+ {"link": link},
+ now=True,
+ custom_template=reset_password_template,
+ )
def send_welcome_mail_to_user(self):
from frappe.utils import get_url
@@ -342,6 +356,8 @@ class User(Document):
else:
subject = _("Complete Registration")
+ welcome_email_template = frappe.db.get_system_setting("welcome_email_template")
+
self.send_login_mail(
subject,
"new_user",
@@ -349,9 +365,10 @@ class User(Document):
link=link,
site_url=get_url(),
),
+ custom_template=welcome_email_template,
)
- def send_login_mail(self, subject, template, add_args, now=None):
+ def send_login_mail(self, subject, template, add_args, now=None, custom_template=None):
"""send mail with login details"""
from frappe.utils import get_url
from frappe.utils.user import get_user_fullname
@@ -374,11 +391,19 @@ class User(Document):
frappe.session.user not in STANDARD_USERS and get_formatted_email(frappe.session.user) or None
)
+ if custom_template:
+ from frappe.email.doctype.email_template.email_template import get_email_template
+
+ email_template = get_email_template(custom_template, args)
+ subject = email_template.get("subject")
+ content = email_template.get("message")
+
frappe.sendmail(
recipients=self.email,
sender=sender,
subject=subject,
- template=template,
+ template=template if not custom_template else None,
+ content=content if custom_template else None,
args=args,
header=[subject, "green"],
delayed=(not now) if now is not None else self.flags.delay_emails,
@@ -437,9 +462,9 @@ class User(Document):
frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True)
if self.get("allow_in_mentions"):
- frappe.cache().delete_key("users_for_mentions")
+ frappe.cache.delete_key("users_for_mentions")
- frappe.cache().delete_key("enabled_users")
+ frappe.cache.delete_key("enabled_users")
# delete user permissions
frappe.db.delete("User Permission", {"user": self.name})
@@ -649,6 +674,21 @@ class User(Document):
if not self.time_zone:
self.time_zone = get_system_timezone()
+ def check_roles_added(self):
+ if self.user_type != "System User" or self.roles or not self.is_new():
+ return
+
+ frappe.msgprint(
+ _("Newly created user {0} has no roles enabled.").format(frappe.bold(self.name)),
+ title=_("No Roles Specified"),
+ indicator="orange",
+ primary_action={
+ "label": _("Add Roles"),
+ "client_action": "frappe.set_route",
+ "args": ["Form", self.doctype, self.name],
+ },
+ )
+
@frappe.whitelist()
def get_timezones():
@@ -720,10 +760,10 @@ def update_password(
user_doc, redirect_url = reset_user_data(user)
# get redirect url from cache
- redirect_to = frappe.cache().hget("redirect_after_login", user)
+ redirect_to = frappe.cache.hget("redirect_after_login", user)
if redirect_to:
redirect_url = redirect_to
- frappe.cache().hdel("redirect_after_login", user)
+ frappe.cache.hdel("redirect_after_login", user)
frappe.local.login_manager.login_as(user)
@@ -881,7 +921,7 @@ def sign_up(email: str, full_name: str, redirect_to: str) -> tuple[int, str]:
user.add_roles(default_role)
if redirect_to:
- frappe.cache().hset("redirect_after_login", user.name, redirect_to)
+ frappe.cache.hset("redirect_after_login", user.name, redirect_to)
if user.flags.email_sent:
return 1, _("Please check your email for verification")
@@ -1194,4 +1234,4 @@ def get_enabled_users():
enabled_users = frappe.get_all("User", filters={"enabled": "1"}, pluck="name")
return enabled_users
- return frappe.cache().get_value("enabled_users", _get_enabled_users)
+ return frappe.cache.get_value("enabled_users", _get_enabled_users)
diff --git a/frappe/core/doctype/user_group/user_group.py b/frappe/core/doctype/user_group/user_group.py
index 812f230f7a..7acdec3aaa 100644
--- a/frappe/core/doctype/user_group/user_group.py
+++ b/frappe/core/doctype/user_group/user_group.py
@@ -9,7 +9,7 @@ from frappe.model.document import Document
class UserGroup(Document):
def after_insert(self):
- frappe.cache().delete_key("user_groups")
+ frappe.cache.delete_key("user_groups")
def on_trash(self):
- frappe.cache().delete_key("user_groups")
+ frappe.cache.delete_key("user_groups")
diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py
index 8742d2e040..a38ec4d379 100644
--- a/frappe/core/doctype/user_permission/test_user_permission.py
+++ b/frappe/core/doctype/user_permission/test_user_permission.py
@@ -178,7 +178,7 @@ class TestUserPermission(FrappeTestCase):
frappe.db.set_value(
"User Permission", {"allow": "Person", "for_value": parent_record.name}, "hide_descendants", 1
)
- frappe.cache().delete_value("user_permissions")
+ frappe.cache.delete_value("user_permissions")
# check if adding perm on a group record with hide_descendants enabled,
# hides child records
diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py
index 63c1f40512..57214b82e2 100644
--- a/frappe/core/doctype/user_permission/user_permission.py
+++ b/frappe/core/doctype/user_permission/user_permission.py
@@ -17,11 +17,11 @@ class UserPermission(Document):
self.validate_default_permission()
def on_update(self):
- frappe.cache().hdel("user_permissions", self.user)
+ frappe.cache.hdel("user_permissions", self.user)
frappe.publish_realtime("update_user_permissions", user=self.user, after_commit=True)
def on_trash(self):
- frappe.cache().hdel("user_permissions", self.user)
+ frappe.cache.hdel("user_permissions", self.user)
frappe.publish_realtime("update_user_permissions", user=self.user, after_commit=True)
def validate_user_permission(self):
@@ -74,7 +74,7 @@ def get_user_permissions(user=None):
if not user or user in ("Administrator", "Guest"):
return {}
- cached_user_permissions = frappe.cache().hget("user_permissions", user)
+ cached_user_permissions = frappe.cache.hget("user_permissions", user)
if cached_user_permissions is not None:
return cached_user_permissions
@@ -110,7 +110,7 @@ def get_user_permissions(user=None):
add_doc_to_perm(perm, doc, False)
out = frappe._dict(out)
- frappe.cache().hset("user_permissions", user, out)
+ frappe.cache.hset("user_permissions", user, out)
except frappe.db.SQLError as e:
if frappe.db.is_table_missing(e):
# called from patch
diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py
index 39d9133412..9660963c19 100644
--- a/frappe/core/doctype/user_type/user_type.py
+++ b/frappe/core/doctype/user_type/user_type.py
@@ -18,7 +18,7 @@ class UserType(Document):
super().clear_cache()
if not self.is_standard:
- frappe.cache().delete_value("non_standard_user_types")
+ frappe.cache.delete_value("non_standard_user_types")
def on_update(self):
if self.is_standard:
@@ -290,7 +290,7 @@ def apply_permissions_for_non_standard_user_type(doc, method=None):
if not frappe.db.table_exists("User Type") or frappe.flags.in_migrate:
return
- user_types = frappe.cache().get_value(
+ user_types = frappe.cache.get_value(
"non_standard_user_types",
get_non_standard_user_types,
)
diff --git a/frappe/core/doctype/view_log/view_log.py b/frappe/core/doctype/view_log/view_log.py
index 8383af818e..5dde78d007 100644
--- a/frappe/core/doctype/view_log/view_log.py
+++ b/frappe/core/doctype/view_log/view_log.py
@@ -1,8 +1,15 @@
# Copyright (c) 2018, Frappe Technologies and contributors
# License: MIT. See LICENSE
+import frappe
from frappe.model.document import Document
class ViewLog(Document):
- pass
+ @staticmethod
+ def clear_old_logs(days=180):
+ from frappe.query_builder import Interval
+ from frappe.query_builder.functions import Now
+
+ table = frappe.qb.DocType("View Log")
+ frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days))))
diff --git a/frappe/core/form_tour/user_list_tour/user_list_tour.json b/frappe/core/form_tour/user_list_tour/user_list_tour.json
new file mode 100644
index 0000000000..83ae481d25
--- /dev/null
+++ b/frappe/core/form_tour/user_list_tour/user_list_tour.json
@@ -0,0 +1,95 @@
+{
+ "creation": "2023-05-24 12:53:02.844582",
+ "dashboard_name": "",
+ "docstatus": 0,
+ "doctype": "Form Tour",
+ "first_document": 0,
+ "idx": 0,
+ "include_name_field": 0,
+ "is_standard": 1,
+ "list_name": "List",
+ "modified": "2023-05-24 13:21:29.552864",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "User List Tour",
+ "new_document_form": 0,
+ "owner": "Administrator",
+ "page_name": "",
+ "page_route": "[\"List\",\"User\",\"List\"]",
+ "reference_doctype": "User",
+ "report_name": "",
+ "save_on_complete": 0,
+ "steps": [
+ {
+ "description": "List view shows all the documents for a particular DocType. Here you can see all the current enabled users in the system. ",
+ "element_selector": ".frappe-list",
+ "fieldtype": "0",
+ "has_next_condition": 0,
+ "hide_buttons": 0,
+ "is_table_field": 0,
+ "modal_trigger": 0,
+ "next_on_click": 0,
+ "offset_x": 0,
+ "offset_y": 0,
+ "popover_element": 0,
+ "position": "Top Center",
+ "title": "Users List",
+ "ui_tour": 1
+ },
+ {
+ "description": "These are filters. You can use them to narrow down list of records.",
+ "element_selector": ".standard-filter-section.flex",
+ "fieldtype": "0",
+ "has_next_condition": 0,
+ "hide_buttons": 0,
+ "is_table_field": 0,
+ "modal_trigger": 0,
+ "next_on_click": 1,
+ "offset_x": 0,
+ "offset_y": 0,
+ "popover_element": 0,
+ "position": "Bottom",
+ "title": "Filters",
+ "ui_tour": 1
+ },
+ {
+ "description": "When standard filters are not enough you can use advance filters. ",
+ "element_selector": ".filter-selector > .btn-group",
+ "fieldtype": "0",
+ "has_next_condition": 0,
+ "hide_buttons": 0,
+ "is_table_field": 0,
+ "modal_trigger": 0,
+ "next_on_click": 0,
+ "offset_x": 0,
+ "offset_y": 0,
+ "ondemand_description": "Advance filters are applied on fields with different operators. \n \nClick on \"Apply Filters\" to continue.",
+ "popover_element": 0,
+ "position": "Left",
+ "title": "Advanced Filters",
+ "ui_tour": 1
+ },
+ {
+ "description": "Let's create a new user.",
+ "element_selector": ".btn-primary.primary-action",
+ "fieldtype": "0",
+ "has_next_condition": 0,
+ "hide_buttons": 1,
+ "is_table_field": 0,
+ "modal_trigger": 0,
+ "next_on_click": 1,
+ "offset_x": 0,
+ "offset_y": 0,
+ "parent_element_selector": "",
+ "popover_element": 0,
+ "position": "Bottom",
+ "title": "New User",
+ "ui_tour": 1
+ }
+ ],
+ "title": "User List Tour",
+ "track_steps": 1,
+ "ui_tour": 1,
+ "view_name": "List",
+ "workspace_name": ""
+}
\ No newline at end of file
diff --git a/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py b/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py
index 2c92a72ab3..4b455e0ab4 100644
--- a/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py
+++ b/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py
@@ -52,12 +52,15 @@ def query_doctypes(doctype, txt, searchfield, start, page_len, filters):
user_perms = frappe.utils.user.UserPermissions(user)
user_perms.build_permissions()
can_read = user_perms.can_read # Does not include child tables
+ include_single_doctypes = filters.get("include_single_doctypes")
single_doctypes = [d[0] for d in frappe.db.get_values("DocType", {"issingle": 1})]
out = []
for dt in can_read:
- if txt.lower().replace("%", "") in dt.lower() and dt not in single_doctypes:
+ if txt.lower().replace("%", "") in dt.lower() and (
+ include_single_doctypes or dt not in single_doctypes
+ ):
out.append([dt])
return out
diff --git a/frappe/core/workspace/build/build.json b/frappe/core/workspace/build/build.json
index 67dfae650f..b917f88e27 100644
--- a/frappe/core/workspace/build/build.json
+++ b/frappe/core/workspace/build/build.json
@@ -1,13 +1,15 @@
{
"charts": [],
- "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"DocType\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Workspace\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Report\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Elements\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Modules\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Models\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Views\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Scripting\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Packages\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"System Logs\",\"col\":4}}]",
+ "content": "[{\"id\":\"5nnLaQeoFa\",\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"id\":\"HXRmktXYHy\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"DocType\",\"col\":3}},{\"id\":\"pYALX3MwBW\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customize Form\",\"col\":3}},{\"id\":\"XC78DuYB65\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Report\",\"col\":3}},{\"id\":\"XPm50Ppq3J\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Client Script\",\"col\":3}},{\"id\":\"yoU6nWiT83\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Server Script\",\"col\":3}},{\"id\":\"5UgFESBY0N\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Print Format Builder\",\"col\":3}},{\"id\":\"62hseENHbd\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"tOCrOgLW1G\",\"type\":\"header\",\"data\":{\"text\":\"Elements\",\"col\":12}},{\"id\":\"BIHjudL0T_\",\"type\":\"card\",\"data\":{\"card_name\":\"Modules\",\"col\":4}},{\"id\":\"cJ6CVsa8qW\",\"type\":\"card\",\"data\":{\"card_name\":\"Models\",\"col\":4}},{\"id\":\"MmEJpjEdGR\",\"type\":\"card\",\"data\":{\"card_name\":\"Views\",\"col\":4}},{\"id\":\"2ZdtgxQZqq\",\"type\":\"card\",\"data\":{\"card_name\":\"Customization\",\"col\":4}},{\"id\":\"NPFolijIcb\",\"type\":\"card\",\"data\":{\"card_name\":\"Scripting\",\"col\":4}},{\"id\":\"iK3JQ9RXJE\",\"type\":\"card\",\"data\":{\"card_name\":\"Packages\",\"col\":4}},{\"id\":\"TiO9FCUUeC\",\"type\":\"card\",\"data\":{\"card_name\":\"System Logs\",\"col\":4}}]",
"creation": "2021-01-02 10:51:16.579957",
+ "custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "tool",
"idx": 0,
+ "is_hidden": 0,
"label": "Build",
"links": [
{
@@ -153,57 +155,6 @@
"onboard": 0,
"type": "Link"
},
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Views",
- "link_count": 4,
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Report",
- "link_count": 0,
- "link_to": "Report",
- "link_type": "DocType",
- "onboard": 0,
- "only_for": "",
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Print Format",
- "link_count": 0,
- "link_to": "Print Format",
- "link_type": "DocType",
- "onboard": 0,
- "only_for": "",
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Dashboard",
- "link_count": 0,
- "link_to": "Dashboard",
- "link_type": "DocType",
- "onboard": 0,
- "only_for": "",
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Workspace",
- "link_count": 0,
- "link_to": "Workspace",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
{
"hidden": 0,
"is_query_report": 0,
@@ -271,20 +222,144 @@
"link_type": "DocType",
"onboard": 0,
"type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Views",
+ "link_count": 5,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Report",
+ "link_count": 0,
+ "link_to": "Report",
+ "link_type": "DocType",
+ "onboard": 0,
+ "only_for": "",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Print Format",
+ "link_count": 0,
+ "link_to": "Print Format",
+ "link_type": "DocType",
+ "onboard": 0,
+ "only_for": "",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Workspace",
+ "link_count": 0,
+ "link_to": "Workspace",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Dashboard",
+ "link_count": 0,
+ "link_to": "Dashboard",
+ "link_type": "DocType",
+ "onboard": 0,
+ "only_for": "",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Dashboard Chart",
+ "link_count": 0,
+ "link_to": "Dashboard Chart",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Customization",
+ "link_count": 4,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Customize Form",
+ "link_count": 0,
+ "link_to": "Customize Form",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Custom Field",
+ "link_count": 0,
+ "link_to": "Custom Field",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Custom Translation",
+ "link_count": 0,
+ "link_to": "Translation",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Navbar Settings",
+ "link_count": 0,
+ "link_to": "Navbar Settings",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
}
],
- "modified": "2022-09-11 06:41:31.095300",
+ "modified": "2023-05-24 14:47:24.395259",
"modified_by": "Administrator",
"module": "Core",
"name": "Build",
+ "number_cards": [],
"owner": "Administrator",
"parent_page": "",
"public": 1,
"quick_lists": [],
"restrict_to_domain": "",
"roles": [],
- "sequence_id": 5.0,
+ "sequence_id": 16.0,
"shortcuts": [
+ {
+ "color": "Grey",
+ "doc_view": "List",
+ "label": "Print Format Builder",
+ "link_to": "print-format-builder",
+ "type": "Page"
+ },
+ {
+ "color": "Grey",
+ "doc_view": "List",
+ "label": "Client Script",
+ "link_to": "Client Script",
+ "type": "DocType"
+ },
{
"doc_view": "",
"label": "DocType",
@@ -293,8 +368,15 @@
},
{
"doc_view": "",
- "label": "Workspace",
- "link_to": "Workspace",
+ "label": "Customize Form",
+ "link_to": "Customize Form",
+ "type": "DocType"
+ },
+ {
+ "color": "Grey",
+ "doc_view": "List",
+ "label": "Server Script",
+ "link_to": "Server Script",
"type": "DocType"
},
{
diff --git a/frappe/core/workspace/settings/settings.json b/frappe/core/workspace/settings/settings.json
index 1469892bd8..24e534ce19 100644
--- a/frappe/core/workspace/settings/settings.json
+++ b/frappe/core/workspace/settings/settings.json
@@ -1,13 +1,15 @@
{
"charts": [],
- "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Settings\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"System Settings\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Print Settings\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Website Settings\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Data\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Email / Notifications\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Website\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Core\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Printing\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Workflow\",\"col\":4}}]",
+ "content": "[{\"id\":\"bc3WecV0uU\",\"type\":\"header\",\"data\":{\"text\":\"Settings\",\"col\":12}},{\"id\":\"_6Jxax2I11\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"System Settings\",\"col\":3}},{\"id\":\"rbf1Om8zJG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Print Settings\",\"col\":3}},{\"id\":\"xMytWpIImZ\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Website Settings\",\"col\":3}},{\"id\":\"Q9DPlmrPpX\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"oVwctUh0gf\",\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"id\":\"hC0b24aSJG\",\"type\":\"card\",\"data\":{\"card_name\":\"Data\",\"col\":4}},{\"id\":\"JA_iI4Z0yI\",\"type\":\"card\",\"data\":{\"card_name\":\"Email / Notifications\",\"col\":4}},{\"id\":\"F1GxSqFKy9\",\"type\":\"card\",\"data\":{\"card_name\":\"Website\",\"col\":4}},{\"id\":\"vugObM_K_T\",\"type\":\"card\",\"data\":{\"card_name\":\"Core\",\"col\":4}},{\"id\":\"XwKthiuAAW\",\"type\":\"card\",\"data\":{\"card_name\":\"Printing\",\"col\":4}},{\"id\":\"EQY7Sfmdxn\",\"type\":\"card\",\"data\":{\"card_name\":\"Workflow\",\"col\":4}}]",
"creation": "2020-03-02 15:09:40.527211",
+ "custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "setting",
"idx": 0,
+ "is_hidden": 0,
"label": "Settings",
"links": [
{
@@ -345,17 +347,18 @@
"type": "Link"
}
],
- "modified": "2022-08-28 21:41:28.065190",
+ "modified": "2023-05-24 14:58:44.010999",
"modified_by": "Administrator",
"module": "Core",
"name": "Settings",
+ "number_cards": [],
"owner": "Administrator",
"parent_page": "",
"public": 1,
"quick_lists": [],
"restrict_to_domain": "",
"roles": [],
- "sequence_id": 29.0,
+ "sequence_id": 18.0,
"shortcuts": [
{
"icon": "setting",
diff --git a/frappe/core/workspace/users/users.json b/frappe/core/workspace/users/users.json
index 5741c54eeb..53ba10c0f9 100644
--- a/frappe/core/workspace/users/users.json
+++ b/frappe/core/workspace/users/users.json
@@ -2,12 +2,14 @@
"charts": [],
"content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Role\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Permission Manager\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User Profile\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User Type\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Users\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Logs\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Permissions\",\"col\":4}}]",
"creation": "2020-03-02 15:12:16.754449",
+ "custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "users",
"idx": 0,
+ "is_hidden": 0,
"label": "Users",
"links": [
{
@@ -145,16 +147,18 @@
"type": "Link"
}
],
- "modified": "2022-01-13 17:49:08.912772",
+ "modified": "2023-05-24 14:47:23.619182",
"modified_by": "Administrator",
"module": "Core",
"name": "Users",
+ "number_cards": [],
"owner": "Administrator",
"parent_page": "",
"public": 1,
+ "quick_lists": [],
"restrict_to_domain": "",
"roles": [],
- "sequence_id": 27.0,
+ "sequence_id": 13.0,
"shortcuts": [
{
"label": "User",
diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js
index 8549c239e5..3937079365 100644
--- a/frappe/custom/doctype/customize_form/customize_form.js
+++ b/frappe/custom/doctype/customize_form/customize_form.js
@@ -49,14 +49,6 @@ frappe.ui.form.on("Customize Form", {
grid_row.row.addClass("highlight");
}
});
-
- $(frm.wrapper).on("grid-make-sortable", function (e, frm) {
- frm.trigger("setup_sortable");
- });
-
- $(frm.wrapper).on("grid-move-row", function (e, frm) {
- frm.trigger("setup_sortable");
- });
},
doc_type: function (frm) {
@@ -71,7 +63,7 @@ frappe.ui.form.on("Customize Form", {
frm.set_value("doc_type", "");
} else {
frm.refresh();
- frm.trigger("setup_sortable");
+ frm.trigger("add_customize_child_table_button");
frm.trigger("setup_default_views");
}
}
@@ -87,23 +79,16 @@ frappe.ui.form.on("Customize Form", {
frm.trigger("setup_default_views");
},
- setup_sortable: function (frm) {
+ add_customize_child_table_button: function (frm) {
frm.doc.fields.forEach(function (f) {
- if (!f.is_custom_field || f.is_system_generated) {
- f._sortable = false;
- }
+ if (!in_list(["Table", "Table MultiSelect"], f.fieldtype)) return;
- if (f.fieldtype == "Table") {
- frm.add_custom_button(
- f.options,
- function () {
- frm.set_value("doc_type", f.options);
- },
- __("Customize Child Table")
- );
- }
+ frm.add_custom_button(
+ f.options,
+ () => frm.set_value("doc_type", f.options),
+ __("Customize Child Table")
+ );
});
- frm.fields_dict.fields.grid.refresh();
},
refresh: function (frm) {
@@ -141,6 +126,14 @@ frappe.ui.form.on("Customize Form", {
__("Actions")
);
+ frm.add_custom_button(
+ __("Reset Layout"),
+ () => {
+ frm.trigger("reset_layout");
+ },
+ __("Actions")
+ );
+
frm.add_custom_button(
__("Set Permissions"),
function () {
@@ -179,6 +172,28 @@ frappe.ui.form.on("Customize Form", {
}
},
+ reset_layout(frm) {
+ frappe.confirm(
+ __("Layout will be reset to standard layout, are you sure you want to do this?"),
+ null,
+ () => {
+ return frm.call({
+ doc: frm.doc,
+ method: "reset_to_defaults",
+ callback: function (r) {
+ if (!r.exc) {
+ frappe.show_alert({
+ message: __("Layout Reset"),
+ indicator: "green",
+ });
+ frappe.customize_form.clear_locals_and_refresh(frm);
+ }
+ },
+ });
+ }
+ );
+ },
+
setup_export(frm) {
if (frappe.boot.developer_mode) {
frm.add_custom_button(
diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py
index 42cbf33f4f..f403079cd8 100644
--- a/frappe/custom/doctype/customize_form/customize_form.py
+++ b/frappe/custom/doctype/customize_form/customize_form.py
@@ -35,7 +35,7 @@ class CustomizeForm(Document):
if not self.doc_type:
return
- meta = frappe.get_meta(self.doc_type)
+ meta = frappe.get_meta(self.doc_type, cached=False)
self.validate_doctype(meta)
@@ -172,7 +172,18 @@ class CustomizeForm(Document):
check_email_append_to(self)
if self.flags.update_db:
- frappe.db.updatedb(self.doc_type)
+ try:
+ frappe.db.updatedb(self.doc_type)
+ except Exception as e:
+ if frappe.db.is_db_table_size_limit(e):
+ frappe.throw(
+ _("You have hit the row size limit on database table: {0}").format(
+ ""
+ "Maximum Number of Fields in a Form"
+ ),
+ title=_("Database Table Row Size Limit"),
+ )
+ raise
if not hasattr(self, "hide_success") or not self.hide_success:
frappe.msgprint(_("{0} updated").format(_(self.doc_type)), alert=True)
@@ -181,7 +192,9 @@ class CustomizeForm(Document):
if self.flags.rebuild_doctype_for_global_search:
frappe.enqueue(
- "frappe.utils.global_search.rebuild_for_doctype", now=True, doctype=self.doc_type
+ "frappe.utils.global_search.rebuild_for_doctype",
+ doctype=self.doc_type,
+ enqueue_after_commit=True,
)
def set_property_setters(self):
@@ -201,11 +214,39 @@ class CustomizeForm(Document):
# action and links
self.set_property_setters_for_actions_and_links(meta)
+ def set_property_setter_for_field_order(self, meta):
+ new_order = [df.fieldname for df in self.fields]
+ existing_order = getattr(meta, "field_order", None)
+ default_order = [
+ fieldname for fieldname, df in meta._fields.items() if not getattr(df, "is_custom_field", False)
+ ]
+
+ if new_order == default_order:
+ if existing_order:
+ delete_property_setter(self.doc_type, "field_order")
+
+ return
+
+ if existing_order and new_order == json.loads(existing_order):
+ return
+
+ frappe.make_property_setter(
+ {
+ "doctype": self.doc_type,
+ "doctype_or_field": "DocType",
+ "property": "field_order",
+ "value": json.dumps(new_order),
+ },
+ is_system_generated=False,
+ )
+
def set_property_setters_for_doctype(self, meta):
for prop, prop_type in doctype_properties.items():
if self.get(prop) != meta.get(prop):
self.make_property_setter(prop, self.get(prop), prop_type)
+ self.set_property_setter_for_field_order(meta)
+
def set_property_setters_for_docfield(self, meta, df, meta_df):
for prop, prop_type in docfield_properties.items():
if prop != "idx" and (df.get(prop) or "") != (meta_df[0].get(prop) or ""):
@@ -527,6 +568,24 @@ class CustomizeForm(Document):
reset_customization(self.doc_type)
self.fetch_to_customize()
+ @frappe.whitelist()
+ def reset_layout(self):
+ if not self.doc_type:
+ return
+
+ property_setter = frappe.db.get_value(
+ "Property Setter",
+ filters={
+ "doc_type": self.doc_type,
+ "property": "field_order",
+ "is_system_generated": False,
+ },
+ )
+ if property_setter:
+ frappe.delete_doc("Property Setter", property_setter)
+
+ self.fetch_to_customize()
+
@classmethod
def allow_fieldtype_change(self, old_type: str, new_type: str) -> bool:
"""allow type change, if both old_type and new_type are in same field group.
diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py
index 8d98dc4149..149ef85e28 100644
--- a/frappe/custom/doctype/customize_form/test_customize_form.py
+++ b/frappe/custom/doctype/customize_form/test_customize_form.py
@@ -425,3 +425,15 @@ class TestCustomizeForm(FrappeTestCase):
self.assertEqual(
frappe.db.get_value("Property Setter", property_setter_filters, "value"), "Test Description"
)
+
+ def test_custom_field_order(self):
+ # shuffle fields
+ customize_form = self.get_customize_form(doctype="ToDo")
+ customize_form.fields.insert(0, customize_form.fields.pop())
+ customize_form.save_customization()
+
+ field_order_property = json.loads(
+ frappe.db.get_value("Property Setter", {"doc_type": "ToDo", "property": "field_order"}, "value")
+ )
+
+ self.assertEqual(field_order_property, [df.fieldname for df in frappe.get_meta("ToDo").fields])
diff --git a/frappe/custom/workspace/customization/customization.json b/frappe/custom/workspace/customization/customization.json
deleted file mode 100644
index 8985bf54ed..0000000000
--- a/frappe/custom/workspace/customization/customization.json
+++ /dev/null
@@ -1,171 +0,0 @@
-{
- "charts": [],
- "content": "[{\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Customization\",\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customize Form\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Custom Role\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Client Script\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Server Script\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Dashboards\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Form Customization\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Other\",\"col\":4}}]",
- "creation": "2020-03-02 15:15:03.839594",
- "docstatus": 0,
- "doctype": "Workspace",
- "for_user": "",
- "hide_custom": 0,
- "icon": "customization",
- "idx": 0,
- "label": "Customization",
- "links": [
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Dashboards",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Dashboard",
- "link_count": 0,
- "link_to": "Dashboard",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Dashboard Chart",
- "link_count": 0,
- "link_to": "Dashboard Chart",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Dashboard Chart Source",
- "link_count": 0,
- "link_to": "Dashboard Chart Source",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Form Customization",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Customize Form",
- "link_count": 0,
- "link_to": "Customize Form",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Custom Field",
- "link_count": 0,
- "link_to": "Custom Field",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Client Script",
- "link_count": 0,
- "link_to": "Client Script",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "DocType",
- "link_count": 0,
- "link_to": "DocType",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Other",
- "link_count": 2,
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Custom Translations",
- "link_count": 0,
- "link_to": "Translation",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Navbar Settings",
- "link_count": 0,
- "link_to": "Navbar Settings",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- }
- ],
- "modified": "2022-08-28 20:56:24.980719",
- "modified_by": "Administrator",
- "module": "Custom",
- "name": "Customization",
- "owner": "Administrator",
- "parent_page": "",
- "public": 1,
- "quick_lists": [],
- "restrict_to_domain": "",
- "roles": [],
- "sequence_id": 8.0,
- "shortcuts": [
- {
- "label": "Customize Form",
- "link_to": "Customize Form",
- "type": "DocType"
- },
- {
- "label": "Custom Role",
- "link_to": "Custom Role",
- "type": "DocType"
- },
- {
- "label": "Client Script",
- "link_to": "Client Script",
- "type": "DocType"
- },
- {
- "doc_view": "",
- "label": "Server Script",
- "link_to": "Server Script",
- "type": "DocType"
- }
- ],
- "title": "Customization"
-}
\ No newline at end of file
diff --git a/frappe/database/database.py b/frappe/database/database.py
index 2d38a6dea8..e4c735c595 100644
--- a/frappe/database/database.py
+++ b/frappe/database/database.py
@@ -29,8 +29,8 @@ from frappe.database.utils import (
is_query_type,
)
from frappe.exceptions import DoesNotExistError, ImplicitCommitError
-from frappe.model.utils.link_count import flush_local_link_count
from frappe.query_builder.functions import Count
+from frappe.utils import CallbackManager
from frappe.utils import cast as cast_fieldtype
from frappe.utils import cint, get_datetime, get_table_name, getdate, now, sbool
from frappe.utils.deprecations import deprecated, deprecation_warning
@@ -105,6 +105,14 @@ class Database:
self.password = password or frappe.conf.db_password
self.value_cache = {}
+ self.logger = frappe.logger("database")
+ self.logger.setLevel("WARNING")
+
+ self.before_commit = CallbackManager()
+ self.after_commit = CallbackManager()
+ self.before_rollback = CallbackManager()
+ self.after_rollback = CallbackManager()
+
# self.db_type: str
# self.last_query (lazy) attribute of last sql query executed
@@ -116,13 +124,12 @@ class Database:
self.cur_db_name = self.user
self._conn = self.get_connection()
self._cursor = self._conn.cursor()
- frappe.local.rollback_observers = []
try:
if execution_timeout := get_query_execution_timeout():
self.set_execution_timeout(execution_timeout)
except Exception as e:
- frappe.logger("database").warning(f"Couldn't set execution timeout {e}")
+ self.logger.warning(f"Couldn't set execution timeout {e}")
def set_execution_timeout(self, seconds: int):
"""Set session speicifc timeout on exeuction of statements.
@@ -285,11 +292,17 @@ class Database:
return self.convert_to_lists(self.last_result)
return self.last_result
- def _log_query(self, mogrified_query: str, debug: bool = False, explain: bool = False) -> None:
+ def _log_query(
+ self,
+ mogrified_query: str,
+ debug: bool = False,
+ explain: bool = False,
+ unmogrified_query: str = "",
+ ) -> None:
"""Takes the query and logs it to various interfaces according to the settings."""
_query = None
- if frappe.conf.allow_tests and frappe.cache().get_value("flag_print_sql"):
+ if frappe.conf.allow_tests and frappe.cache.get_value("flag_print_sql"):
_query = _query or str(mogrified_query)
print(_query)
@@ -303,6 +316,12 @@ class Database:
_query = _query or str(mogrified_query)
frappe.log(f"<<<< query\n{_query}\n>>>>")
+ if unmogrified_query and is_query_type(
+ unmogrified_query, ("alter", "drop", "create", "truncate", "rename")
+ ):
+ _query = _query or str(mogrified_query)
+ self.logger.warning("DDL Query made to DB:\n" + _query)
+
if frappe.flags.in_migrate:
_query = _query or str(mogrified_query)
self.log_touched_tables(_query)
@@ -314,7 +333,7 @@ class Database:
# like cursor._transformed_statement from the cursor object. We can also avoid setting
# mogrified_query if we don't need to log it.
mogrified_query = self.lazy_mogrify(query, values)
- self._log_query(mogrified_query, debug, explain)
+ self._log_query(mogrified_query, debug, explain, unmogrified_query=query)
return mogrified_query
def mogrify(self, query: Query, values: QueryValues):
@@ -400,7 +419,7 @@ class Database:
@staticmethod
def clear_db_table_cache(query):
if query and is_query_type(query, ("drop", "create")):
- frappe.cache().delete_key("db_tables")
+ frappe.cache.delete_key("db_tables")
def get_description(self):
"""Returns result metadata."""
@@ -617,10 +636,10 @@ class Database:
return []
if as_dict:
- return values and [values] or []
+ return [values] if values else []
if isinstance(fields, list):
- return [map(values.get, fields)]
+ return [list(map(values.get, fields))]
else:
r = frappe.qb.get_query(
@@ -812,6 +831,7 @@ class Database:
fields=fields,
distinct=distinct,
limit=limit,
+ validate_filters=True,
)
if isinstance(fields, str) and fields == "*":
as_dict = True
@@ -840,6 +860,7 @@ class Database:
order_by=order_by,
distinct=distinct,
limit=limit,
+ validate_filters=True,
).run(debug=debug, run=run, as_dict=as_dict, pluck=pluck)
return {}
@@ -889,15 +910,18 @@ class Database:
field, val, modified=modified, modified_by=modified_by, update_modified=update_modified
)
- query = frappe.qb.get_query(table=dt, filters=dn, update=True)
+ query = frappe.qb.get_query(
+ table=dt,
+ filters=dn,
+ update=True,
+ validate_filters=True,
+ )
if isinstance(dn, str):
frappe.clear_document_cache(dt, dn)
else:
- # TODO: Fix this; doesn't work rn - gavin@frappe.io
- # frappe.cache().hdel_keys(dt, "document_cache")
- # Workaround: clear all document caches
- frappe.cache().delete_value("document_cache")
+ # No way to guess which documents are modified, clear all of them
+ frappe.clear_document_cache(dt)
for column, value in to_update.items():
query = query.set(column, value)
@@ -949,26 +973,30 @@ class Database:
def commit(self):
"""Commit current transaction. Calls SQL `COMMIT`."""
- for method in frappe.local.before_commit:
- frappe.call(method[0], *(method[1] or []), **(method[2] or {}))
+ self.before_rollback.reset()
+ self.after_rollback.reset()
+
+ self.before_commit.run()
self.sql("commit")
self.begin() # explicitly start a new transaction
- frappe.local.rollback_observers = []
- self.flush_realtime_log()
- enqueue_jobs_after_commit()
- flush_local_link_count()
+ self.after_commit.run()
- def add_before_commit(self, method, args=None, kwargs=None):
- frappe.local.before_commit.append([method, args, kwargs])
+ def rollback(self, *, save_point=None):
+ """`ROLLBACK` current transaction. Optionally rollback to a known save_point."""
+ if save_point:
+ self.sql(f"rollback to savepoint {save_point}")
+ else:
+ self.before_commit.reset()
+ self.after_commit.reset()
- @staticmethod
- def flush_realtime_log():
- for args in frappe.local.realtime_log:
- frappe.realtime.emit_via_redis(*args)
+ self.before_rollback.run()
- frappe.local.realtime_log = []
+ self.sql("rollback")
+ self.begin()
+
+ self.after_rollback.run()
def savepoint(self, save_point):
"""Savepoints work as a nested transaction.
@@ -983,21 +1011,6 @@ class Database:
def release_savepoint(self, save_point):
self.sql(f"release savepoint {save_point}")
- def rollback(self, *, save_point=None):
- """`ROLLBACK` current transaction. Optionally rollback to a known save_point."""
- if save_point:
- self.sql(f"rollback to savepoint {save_point}")
- else:
- self.sql("rollback")
- self.begin()
- for obj in dict.fromkeys(frappe.local.rollback_observers):
- if hasattr(obj, "on_rollback"):
- obj.on_rollback()
- frappe.local.rollback_observers = []
-
- frappe.local.realtime_log = []
- frappe.flags.enqueue_after_commit = []
-
def field_exists(self, dt, fn):
"""Return true of field exists."""
return self.exists("DocField", {"fieldname": fn, "parent": dt})
@@ -1054,14 +1067,18 @@ class Database:
def count(self, dt, filters=None, debug=False, cache=False, distinct: bool = True):
"""Returns `COUNT(*)` for given DocType and filters."""
if cache and not filters:
- cache_count = frappe.cache().get_value(f"doctype:count:{dt}")
+ cache_count = frappe.cache.get_value(f"doctype:count:{dt}")
if cache_count is not None:
return cache_count
- count = frappe.qb.get_query(table=dt, filters=filters, fields=Count("*"), distinct=distinct).run(
- debug=debug
- )[0][0]
+ count = frappe.qb.get_query(
+ table=dt,
+ filters=filters,
+ fields=Count("*"),
+ distinct=distinct,
+ validate_filters=True,
+ ).run(debug=debug)[0][0]
if not filters and cache:
- frappe.cache().set_value(f"doctype:count:{dt}", count, expires_in_sec=86400)
+ frappe.cache.set_value(f"doctype:count:{dt}", count, expires_in_sec=86400)
return count
@staticmethod
@@ -1092,7 +1109,7 @@ class Database:
def get_db_table_columns(self, table) -> list[str]:
"""Returns list of column names from given table."""
- columns = frappe.cache().hget("table_columns", table)
+ columns = frappe.cache.hget("table_columns", table)
if columns is None:
information_schema = frappe.qb.Schema("information_schema")
@@ -1104,7 +1121,7 @@ class Database:
)
if columns:
- frappe.cache().hset("table_columns", table, columns)
+ frappe.cache.hset("table_columns", table, columns)
return columns
@@ -1119,21 +1136,6 @@ class Database:
"""Returns True if column exists in database."""
return column in self.get_table_columns(doctype)
- def get_column_type(self, doctype, column):
- """Returns column type from database."""
- information_schema = frappe.qb.Schema("information_schema")
- table = get_table_name(doctype)
-
- return (
- frappe.qb.from_(information_schema.columns)
- .select(information_schema.columns.column_type)
- .where(
- (information_schema.columns.table_name == table)
- & (information_schema.columns.column_name == column)
- )
- .run(pluck=True)[0]
- )
-
def has_index(self, table_name, index_name):
raise NotImplementedError
@@ -1194,7 +1196,12 @@ class Database:
Doctype name can be passed directly, it will be pre-pended with `tab`.
"""
filters = filters or kwargs.get("conditions")
- query = frappe.qb.get_query(table=doctype, filters=filters, delete=True)
+ query = frappe.qb.get_query(
+ table=doctype,
+ filters=filters,
+ delete=True,
+ validate_filters=True,
+ )
if "debug" not in kwargs:
kwargs["debug"] = debug
return query.run(**kwargs)
@@ -1284,27 +1291,9 @@ class Database:
return get_next_val(*args, **kwargs)
-
-def enqueue_jobs_after_commit():
- from frappe.utils.background_jobs import (
- RQ_JOB_FAILURE_TTL,
- RQ_RESULTS_TTL,
- execute_job,
- get_queue,
- )
-
- if frappe.flags.enqueue_after_commit and len(frappe.flags.enqueue_after_commit) > 0:
- for job in frappe.flags.enqueue_after_commit:
- q = get_queue(job.get("queue"), is_async=job.get("is_async"))
- q.enqueue_call(
- execute_job,
- timeout=job.get("timeout"),
- kwargs=job.get("queue_args"),
- failure_ttl=frappe.conf.get("rq_job_failure_ttl") or RQ_JOB_FAILURE_TTL,
- result_ttl=frappe.conf.get("rq_results_ttl") or RQ_RESULTS_TTL,
- job_id=job.get("job_id"),
- )
- frappe.flags.enqueue_after_commit = []
+ def get_row_size(self, doctype: str) -> int:
+ """Get estimated max row size of any table in bytes."""
+ raise NotImplementedError
@contextmanager
diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py
index 43540956e0..6a89966ee5 100644
--- a/frappe/database/mariadb/database.py
+++ b/frappe/database/mariadb/database.py
@@ -76,6 +76,10 @@ class MariaDBExceptionUtil:
def is_data_too_long(e: pymysql.Error) -> bool:
return e.args[0] == ER.DATA_TOO_LONG
+ @staticmethod
+ def is_db_table_size_limit(e: pymysql.Error) -> bool:
+ return e.args[0] == ER.TOO_BIG_ROWSIZE
+
@staticmethod
def is_primary_key_violation(e: pymysql.Error) -> bool:
return (
@@ -145,6 +149,7 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
UnicodeWithAttrs: escape_string,
}
default_port = "3306"
+ MAX_ROW_SIZE_LIMIT = 65_535 # bytes
def setup_type_map(self):
self.db_type = "mariadb"
@@ -200,8 +205,8 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
return db_size[0].get("database_size")
def log_query(self, query, values, debug, explain):
- self.last_query = query = self._cursor._executed
- self._log_query(query, debug, explain)
+ self.last_query = self._cursor._executed
+ self._log_query(self.last_query, debug, explain, query)
return self.last_query
@staticmethod
@@ -318,6 +323,21 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
as_dict=1,
)
+ def get_column_type(self, doctype, column):
+ """Returns column type from database."""
+ information_schema = frappe.qb.Schema("information_schema")
+ table = get_table_name(doctype)
+
+ return (
+ frappe.qb.from_(information_schema.columns)
+ .select(information_schema.columns.column_type)
+ .where(
+ (information_schema.columns.table_name == table)
+ & (information_schema.columns.column_name == column)
+ )
+ .run(pluck=True)[0]
+ )
+
def has_index(self, table_name, index_name):
return self.sql(
"""SHOW INDEX FROM `{table_name}`
@@ -415,7 +435,7 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
to_query = not cached
if cached:
- tables = frappe.cache().get_value("db_tables")
+ tables = frappe.cache.get_value("db_tables")
to_query = not tables
if to_query:
@@ -427,6 +447,59 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
.where(information_schema.tables.table_schema != "information_schema")
.run(pluck=True)
)
- frappe.cache().set_value("db_tables", tables)
+ frappe.cache.set_value("db_tables", tables)
return tables
+
+ def get_row_size(self, doctype: str) -> int:
+ """Get estimated max row size of any table in bytes."""
+
+ # Query reused from this answer: https://dba.stackexchange.com/a/313889/274503
+ # Modification: get values for particular table instead of full summary.
+ # Reference: https://mariadb.com/kb/en/data-type-storage-requirements/
+
+ est_row_size = frappe.db.sql(
+ """
+ SELECT SUM(col_sizes.col_size) AS EST_MAX_ROW_SIZE
+ FROM (
+ SELECT
+ cols.COLUMN_NAME,
+ CASE cols.DATA_TYPE
+ WHEN 'tinyint' THEN 1
+ WHEN 'smallint' THEN 2
+ WHEN 'mediumint' THEN 3
+ WHEN 'int' THEN 4
+ WHEN 'bigint' THEN 8
+ WHEN 'float' THEN IF(cols.NUMERIC_PRECISION > 24, 8, 4)
+ WHEN 'double' THEN 8
+ WHEN 'decimal' THEN ((cols.NUMERIC_PRECISION - cols.NUMERIC_SCALE) DIV 9)*4 + (cols.NUMERIC_SCALE DIV 9)*4 + CEIL(MOD(cols.NUMERIC_PRECISION - cols.NUMERIC_SCALE,9)/2) + CEIL(MOD(cols.NUMERIC_SCALE,9)/2)
+ WHEN 'bit' THEN (cols.NUMERIC_PRECISION + 7) DIV 8
+ WHEN 'year' THEN 1
+ WHEN 'date' THEN 3
+ WHEN 'time' THEN 3 + CEIL(cols.DATETIME_PRECISION /2)
+ WHEN 'datetime' THEN 5 + CEIL(cols.DATETIME_PRECISION /2)
+ WHEN 'timestamp' THEN 4 + CEIL(cols.DATETIME_PRECISION /2)
+ WHEN 'char' THEN cols.CHARACTER_OCTET_LENGTH
+ WHEN 'binary' THEN cols.CHARACTER_OCTET_LENGTH
+ WHEN 'varchar' THEN IF(cols.CHARACTER_OCTET_LENGTH > 255, 2, 1) + cols.CHARACTER_OCTET_LENGTH
+ WHEN 'varbinary' THEN IF(cols.CHARACTER_OCTET_LENGTH > 255, 2, 1) + cols.CHARACTER_OCTET_LENGTH
+ WHEN 'tinyblob' THEN 9
+ WHEN 'tinytext' THEN 9
+ WHEN 'blob' THEN 10
+ WHEN 'text' THEN 10
+ WHEN 'mediumblob' THEN 11
+ WHEN 'mediumtext' THEN 11
+ WHEN 'longblob' THEN 12
+ WHEN 'longtext' THEN 12
+ WHEN 'enum' THEN 2
+ WHEN 'set' THEN 8
+ ELSE 0
+ END AS col_size
+ FROM INFORMATION_SCHEMA.COLUMNS cols
+ WHERE cols.TABLE_NAME = %s
+ ) AS col_sizes;""",
+ (get_table_name(doctype),),
+ )
+
+ if est_row_size:
+ return int(est_row_size[0][0])
diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py
index d082afceaf..2d5b3a893f 100644
--- a/frappe/database/postgres/database.py
+++ b/frappe/database/postgres/database.py
@@ -107,6 +107,10 @@ class PostgresExceptionUtil:
def is_data_too_long(e):
return getattr(e, "pgcode", None) == STRING_DATA_RIGHT_TRUNCATION
+ @staticmethod
+ def is_db_table_size_limit(e) -> bool:
+ return False
+
class PostgresDatabase(PostgresExceptionUtil, Database):
REGEX_CHARACTER = "~"
@@ -394,6 +398,21 @@ class PostgresDatabase(PostgresExceptionUtil, Database):
as_dict=1,
)
+ def get_column_type(self, doctype, column):
+ """Returns column type from database."""
+ information_schema = frappe.qb.Schema("information_schema")
+ table = get_table_name(doctype)
+
+ return (
+ frappe.qb.from_(information_schema.columns)
+ .select(information_schema.columns.data_type)
+ .where(
+ (information_schema.columns.table_name == table)
+ & (information_schema.columns.column_name == column)
+ )
+ .run(pluck=True)[0]
+ )
+
def get_database_list(self):
return self.sql("SELECT datname FROM pg_database", pluck=True)
diff --git a/frappe/database/query.py b/frappe/database/query.py
index 595bd5a3ff..06295d33a6 100644
--- a/frappe/database/query.py
+++ b/frappe/database/query.py
@@ -1,4 +1,3 @@
-import itertools
import re
from ast import literal_eval
from types import BuiltinFunctionType
@@ -10,6 +9,7 @@ from pypika.queries import QueryBuilder, Table
import frappe
from frappe import _
from frappe.database.operator_map import OPERATOR_MAP
+from frappe.database.schema import SPECIAL_CHAR_PATTERN
from frappe.database.utils import DefaultOrderBy, get_doctype_name
from frappe.query_builder import Criterion, Field, Order, functions
from frappe.query_builder.functions import Function, SqlFunctions
@@ -25,6 +25,10 @@ BRACKETS_PATTERN = re.compile(r"\(.*?\)|$")
SQL_FUNCTIONS = [sql_function.value for sql_function in SqlFunctions]
COMMA_PATTERN = re.compile(r",\s*(?![^()]*\))")
+# less restrictive version of frappe.core.doctype.doctype.doctype.START_WITH_LETTERS_PATTERN
+# to allow table names like __Auth
+TABLE_NAME_PATTERN = re.compile(r"^[\w -]*$", flags=re.ASCII)
+
class Engine:
def get_query(
@@ -41,15 +45,19 @@ class Engine:
update: bool = False,
into: bool = False,
delete: bool = False,
+ *,
+ validate_filters: bool = False,
) -> QueryBuilder:
self.is_mariadb = frappe.db.db_type == "mariadb"
self.is_postgres = frappe.db.db_type == "postgres"
+ self.validate_filters = validate_filters
if isinstance(table, Table):
self.table = table
self.doctype = get_doctype_name(table.get_sql())
else:
self.doctype = table
+ self.validate_doctype()
self.table = frappe.qb.DocType(table)
if update:
@@ -82,6 +90,10 @@ class Engine:
return self.query
+ def validate_doctype(self):
+ if not TABLE_NAME_PATTERN.match(self.doctype):
+ frappe.throw(_("Invalid DocType: {0}").format(self.doctype))
+
def apply_fields(self, fields):
# add fields
self.fields = self.parse_fields(fields)
@@ -135,9 +147,12 @@ class Engine:
self._apply_filter(field, value, operator, doctype)
def apply_dict_filters(self, filters: dict[str, str | int | list]):
- for key in filters:
- value = filters.get(key)
- self._apply_filter(key, value)
+ for field, value in filters.items():
+ operator = "="
+ if isinstance(value, (list, tuple)):
+ operator, value = value
+
+ self._apply_filter(field, value, operator)
def _apply_filter(
self, field: str, value: str | int | list | None, operator: str = "=", doctype: str | None = None
@@ -146,14 +161,16 @@ class Engine:
_value = value
_operator = operator
- if isinstance(_field, Field):
+ if not isinstance(_field, str):
pass
- elif dynamic_field := DynamicTableField.parse(field, self.doctype):
+ elif not self.validate_filters and (
+ dynamic_field := DynamicTableField.parse(field, self.doctype)
+ ):
# apply implicit join if link field's field is referenced
self.query = dynamic_field.apply_join(self.query)
_field = dynamic_field.field
- elif has_function(field):
- _field = self.get_function_object(field)
+ elif self.validate_filters and SPECIAL_CHAR_PATTERN.search(_field):
+ frappe.throw(_("Invalid filter: {0}").format(_field))
elif not doctype or doctype == self.doctype:
_field = self.table[field]
elif doctype:
@@ -168,30 +185,28 @@ class Engine:
(table.parent == self.table.name) & (table.parenttype == self.doctype)
)
- if isinstance(_value, (list, tuple)):
- _operator, _value = _value
- elif isinstance(_value, bool):
+ if isinstance(_value, bool):
_value = int(_value)
- if isinstance(_value, str) and has_function(_value):
- _value = self.get_function_object(_value)
-
- if isinstance(_value, (list, tuple)) and not _value:
+ elif not _value and isinstance(_value, (list, tuple)):
_value = ("",)
# Nested set
if _operator in OPERATOR_MAP["nested_set"]:
hierarchy = _operator
docname = _value
- result = get_nested_set_hierarchy_result(self.doctype, docname, hierarchy)
+
+ _df = frappe.get_meta(self.doctype).get_field(field)
+ ref_doctype = _df.options if _df else self.doctype
+
+ nodes = get_nested_set_hierarchy_result(ref_doctype, docname, hierarchy)
operator_fn = (
OPERATOR_MAP["not in"]
if hierarchy in ("not ancestors of", "not descendants of")
else OPERATOR_MAP["in"]
)
- if result:
- result = list(itertools.chain.from_iterable(result))
- self.query = self.query.where(operator_fn(_field, result))
+ if nodes:
+ self.query = self.query.where(operator_fn(_field, nodes))
else:
self.query = self.query.where(operator_fn(_field, ("",)))
return
@@ -506,22 +521,25 @@ def has_function(field):
return True
-def get_nested_set_hierarchy_result(doctype: str, name: str, hierarchy: str):
+def get_nested_set_hierarchy_result(doctype: str, name: str, hierarchy: str) -> list[str]:
+ """Get matching nodes based on operator."""
table = frappe.qb.DocType(doctype)
try:
lft, rgt = frappe.qb.from_(table).select("lft", "rgt").where(table.name == name).run()[0]
except IndexError:
lft, rgt = None, None
- if hierarchy in ("descendants of", "not descendants of"):
+ if hierarchy in ("descendants of", "not descendants of", "descendants of (inclusive)"):
result = (
frappe.qb.from_(table)
.select(table.name)
.where(table.lft > lft)
.where(table.rgt < rgt)
.orderby(table.lft, order=Order.asc)
- .run()
+ .run(pluck=True)
)
+ if hierarchy == "descendants of (inclusive)":
+ result += [name]
else:
# Get ancestor elements of a DocType with a tree structure
result = (
@@ -530,6 +548,6 @@ def get_nested_set_hierarchy_result(doctype: str, name: str, hierarchy: str):
.where(table.lft < lft)
.where(table.rgt > rgt)
.orderby(table.lft, order=Order.desc)
- .run()
+ .run(pluck=True)
)
return result
diff --git a/frappe/database/schema.py b/frappe/database/schema.py
index 7a8330595e..ed7d1d16fc 100644
--- a/frappe/database/schema.py
+++ b/frappe/database/schema.py
@@ -40,7 +40,7 @@ class DBTable:
if self.is_new():
self.create()
else:
- frappe.cache().hdel("table_columns", self.table_name)
+ frappe.cache.hdel("table_columns", self.table_name)
self.alter()
def create(self):
@@ -205,7 +205,6 @@ class DbColumn:
self.default
and (self.default not in frappe.db.DEFAULT_SHORTCUTS)
and not cstr(self.default).startswith(":")
- and column_def not in ("text", "longtext")
):
column_def += f" default {frappe.db.escape(self.default)}"
@@ -248,7 +247,6 @@ class DbColumn:
self.default_changed(current_def)
and (self.default not in frappe.db.DEFAULT_SHORTCUTS)
and not cstr(self.default).startswith(":")
- and not (column_type in ["text", "longtext"])
):
self.table.set_default.append(self)
diff --git a/frappe/database/utils.py b/frappe/database/utils.py
index d1030ca6d7..61dd0016c5 100644
--- a/frappe/database/utils.py
+++ b/frappe/database/utils.py
@@ -23,6 +23,7 @@ NestedSetHierarchy = (
"descendants of",
"not ancestors of",
"not descendants of",
+ "descendants of (inclusive)",
)
diff --git a/frappe/defaults.py b/frappe/defaults.py
index edbf784200..0b86e99efa 100644
--- a/frappe/defaults.py
+++ b/frappe/defaults.py
@@ -230,7 +230,7 @@ def clear_default(key=None, value=None, parent=None, name=None, parenttype=None)
def get_defaults_for(parent="__default"):
"""get all defaults"""
- defaults = frappe.cache().hget("defaults", parent)
+ defaults = frappe.cache.hget("defaults", parent)
if defaults is None:
# sort descending because first default must get precedence
@@ -256,7 +256,7 @@ def get_defaults_for(parent="__default"):
elif d.defvalue is not None:
defaults[d.defkey] = d.defvalue
- frappe.cache().hset("defaults", parent, defaults)
+ frappe.cache.hset("defaults", parent, defaults)
return defaults
diff --git a/frappe/deferred_insert.py b/frappe/deferred_insert.py
index 328d8dd555..5c7e7a7f0d 100644
--- a/frappe/deferred_insert.py
+++ b/frappe/deferred_insert.py
@@ -19,20 +19,20 @@ def deferred_insert(doctype: str, records: list[Union[dict, "Document"]] | str):
_records = records
try:
- frappe.cache().rpush(f"{queue_prefix}{doctype}", _records)
+ frappe.cache.rpush(f"{queue_prefix}{doctype}", _records)
except redis.exceptions.ConnectionError:
for record in records:
insert_record(record, doctype)
def save_to_db():
- queue_keys = frappe.cache().get_keys(queue_prefix)
+ queue_keys = frappe.cache.get_keys(queue_prefix)
for key in queue_keys:
record_count = 0
queue_key = get_key_name(key)
doctype = get_doctype_name(key)
- while frappe.cache().llen(queue_key) > 0 and record_count <= 500:
- records = frappe.cache().lpop(queue_key)
+ while frappe.cache.llen(queue_key) > 0 and record_count <= 500:
+ records = frappe.cache.lpop(queue_key)
records = json.loads(records.decode("utf-8"))
if isinstance(records, dict):
record_count += 1
diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py
index 46cda8fe5d..cf9f223d2a 100644
--- a/frappe/desk/desktop.py
+++ b/frappe/desk/desktop.py
@@ -62,10 +62,10 @@ class Workspace:
self.table_counts = get_table_with_counts()
self.restricted_doctypes = (
- frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache()
+ frappe.cache.get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache()
)
self.restricted_pages = (
- frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache()
+ frappe.cache.get_value("domain_restricted_pages") or build_domain_restriced_page_cache()
)
def is_permitted(self):
@@ -88,16 +88,14 @@ class Workspace:
return True
def get_cached(self, cache_key, fallback_fn):
- _cache = frappe.cache()
-
- value = _cache.get_value(cache_key, user=frappe.session.user)
+ value = frappe.cache.get_value(cache_key, user=frappe.session.user)
if value:
return value
value = fallback_fn()
# Expire every six hour
- _cache.set_value(cache_key, value, frappe.session.user, 21600)
+ frappe.cache.set_value(cache_key, value, frappe.session.user, 21600)
return value
def get_can_read_items(self):
@@ -469,7 +467,7 @@ def get_workspace_sidebar_items():
def get_table_with_counts():
- counts = frappe.cache().get_value("information_schema:counts")
+ counts = frappe.cache.get_value("information_schema:counts")
if not counts:
counts = build_table_count_cache()
diff --git a/frappe/desk/doctype/custom_html_block/custom_html_block.js b/frappe/desk/doctype/custom_html_block/custom_html_block.js
index 727a73c92f..49f50a72be 100644
--- a/frappe/desk/doctype/custom_html_block/custom_html_block.js
+++ b/frappe/desk/doctype/custom_html_block/custom_html_block.js
@@ -3,47 +3,21 @@
frappe.ui.form.on("Custom HTML Block", {
refresh(frm) {
- render_custom_html_block(frm);
+ if (
+ !has_common(frappe.user_roles, [
+ "Administrator",
+ "System Manager",
+ "Workspace Manager",
+ ])
+ ) {
+ frm.set_value("private", true);
+ } else {
+ frm.set_df_property("private", "read_only", false);
+ }
+
+ let wrapper = frm.fields_dict["preview"].wrapper;
+ wrapper.classList.add("mb-3");
+
+ frappe.create_shadow_element(wrapper, frm.doc.html, frm.doc.style, frm.doc.script);
},
});
-
-function render_custom_html_block(frm) {
- let wrapper = frm.fields_dict["preview"].wrapper;
- wrapper.classList.add("mb-3");
-
- let random_id = "custom-block-" + frappe.utils.get_random(5).toLowerCase();
-
- class CustomBlock extends HTMLElement {
- constructor() {
- super();
-
- // html
- let div = document.createElement("div");
- div.innerHTML = frappe.dom.remove_script_and_style(frm.doc.html);
-
- // css
- let style = document.createElement("style");
- style.textContent = frm.doc.style;
-
- // javascript
- let script = document.createElement("script");
- script.textContent = `
- (function() {
- let cname = ${JSON.stringify(random_id)};
- let root_element = document.querySelector(cname).shadowRoot;
- ${frm.doc.script}
- })();
- `;
-
- this.attachShadow({ mode: "open" });
- this.shadowRoot?.appendChild(div);
- this.shadowRoot?.appendChild(style);
- this.shadowRoot?.appendChild(script);
- }
- }
-
- if (!customElements.get(random_id)) {
- customElements.define(random_id, CustomBlock);
- }
- wrapper.innerHTML = `<${random_id}>${random_id}>`;
-}
diff --git a/frappe/desk/doctype/custom_html_block/custom_html_block.json b/frappe/desk/doctype/custom_html_block/custom_html_block.json
index 6c3d80fba9..8fb06003ce 100644
--- a/frappe/desk/doctype/custom_html_block/custom_html_block.json
+++ b/frappe/desk/doctype/custom_html_block/custom_html_block.json
@@ -6,10 +6,10 @@
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
+ "private",
"preview_section",
"preview",
"html_section",
- "html_message",
"html",
"javascript_section",
"js_message",
@@ -71,12 +71,6 @@
"label": "JS Message",
"options": "
To interact with above HTML you will have to use `root_element` as a parent selector.
For example:
// here root_element is provided by default\nlet some_class_element = root_element.querySelector('.some-class');\nsome_class_element.textContent = \"New content\";\n
You cannot use global class on elements. The css for those classes will not be applied on this HTML, you will have to rewrite styles again in CSS field
For Example:
\n
// style for class m-3 will not work\n <div class=\"m-3\"></div> \n // You will have to add style of m-3 in CSS field below like\n .m-3 {\n margin: 14px!important\n }\n
+ `;
+ frappe.breadcrumbs.clear();
+ frappe.breadcrumbs.$breadcrumbs.append(breadcrumbs);
+ }
+
function get_state_df(data) {
let doc_status_map = {
Draft: 0,
diff --git a/frappe/public/scss/common/modal.scss b/frappe/public/scss/common/modal.scss
index 8e69a956e5..4b7f028c79 100644
--- a/frappe/public/scss/common/modal.scss
+++ b/frappe/public/scss/common/modal.scss
@@ -222,12 +222,30 @@ body.modal-open[style^="padding-right"] {
margin-bottom: -24px;
button {
// same as form-control input
- height: calc(1.5em + .75rem + 2px);
+ height: calc(1.5em + .7rem);
}
}
}
}
+.modal [data-fieldname="email_template_section_break"] {
+ form {
+ display: flex;
+ align-items: center;
+
+ .frappe-control:first-child {
+ &[data-fieldname="email_template"] {
+ margin-right: 10px;
+ }
+ flex: 1;
+ }
+
+ .frappe-control:last-child {
+ margin-bottom: -8px;
+ }
+ }
+}
+
// modal is xs (for grids)
.modal .hidden-xs {
display: none !important;
diff --git a/frappe/public/scss/desk.bundle.scss b/frappe/public/scss/desk.bundle.scss
index 10fd116d6c..6b192bb3ff 100644
--- a/frappe/public/scss/desk.bundle.scss
+++ b/frappe/public/scss/desk.bundle.scss
@@ -4,3 +4,8 @@
@import "~frappe-charts/dist/frappe-charts.min";
@import "~plyr/dist/plyr";
@import "./desk/index";
+
+@import "frappe/public/js/lib/leaflet/leaflet.css";
+@import "frappe/public/js/lib/leaflet_easy_button/easy-button.css";
+@import "frappe/public/js/lib/leaflet_control_locate/L.Control.Locate.css";
+@import "frappe/public/js/lib/leaflet_draw/leaflet.draw.css";
\ No newline at end of file
diff --git a/frappe/public/scss/desk/global.scss b/frappe/public/scss/desk/global.scss
index 765e51cab9..b355dbdec2 100644
--- a/frappe/public/scss/desk/global.scss
+++ b/frappe/public/scss/desk/global.scss
@@ -249,6 +249,32 @@ h2 {
}
}
+.select-group-btn {
+ .dropdown-toggle-split {
+ padding-left: 0.375rem !important;
+ padding-right: 0.375rem !important;
+ min-width: 0 !important;
+
+ &::after {
+ display: none;
+ }
+ }
+
+ .dropdown-item {
+ .tick-icon {
+ visibility: hidden;
+
+ &.selected {
+ visibility: visible;
+ }
+ }
+
+ .item-label {
+ font-weight: 500;
+ }
+ }
+}
+
.btn-xs {
@extend .btn-sm;
line-height: 1.2;
diff --git a/frappe/public/scss/desk/print_preview.scss b/frappe/public/scss/desk/print_preview.scss
index 468b37fe5a..ed85f8b933 100644
--- a/frappe/public/scss/desk/print_preview.scss
+++ b/frappe/public/scss/desk/print_preview.scss
@@ -45,9 +45,6 @@
.layout-side-section.print-preview-sidebar {
padding-right: var(--padding-md);
- .clearfix {
- display: none;
- }
.label-area {
white-space: nowrap;
diff --git a/frappe/public/scss/website/web_form.scss b/frappe/public/scss/website/web_form.scss
index f1889a7b8e..d606b38719 100644
--- a/frappe/public/scss/website/web_form.scss
+++ b/frappe/public/scss/website/web_form.scss
@@ -28,6 +28,10 @@
margin-top: 0;
margin-bottom: 0;
padding-bottom: 2px;
+
+ @include media-breakpoint-down(sm) {
+ font-size: 1.25rem;
+ }
}
.web-form-header {
@@ -38,6 +42,10 @@
background-color: var(--fg-color);
padding: 2rem 2rem 0;
+ @include media-breakpoint-down(sm) {
+ padding: 1.5rem 1.5rem 0;
+ }
+
.breadcrumb-container {
padding: 0px;
margin: 0 0 2rem;
@@ -83,6 +91,10 @@
p {
color: var(--text-muted);
+
+ @include media-breakpoint-down(sm) {
+ font-size: var(--text-xs);
+ }
}
}
}
@@ -96,10 +108,18 @@
border-bottom-left-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
+ @include media-breakpoint-down(sm) {
+ padding: 1rem 1.5rem 1.5rem;
+ }
+
.web-form-wrapper {
.form-control {
color: var(--text-color);
background-color: var(--control-bg);
+
+ @include media-breakpoint-down(sm) {
+ font-size: var(--text-sm);
+ }
}
.form-section {
@@ -113,9 +133,19 @@
.form-column {
padding: 0 var(--padding-sm);
+ .form-group {
+ @include media-breakpoint-down(sm) {
+ margin-bottom: 0.5rem;
+ }
+ }
+
.frappe-control {
position: relative;
+ @include media-breakpoint-down(sm) {
+ font-size: var(--text-sm);
+ }
+
&[data-fieldtype="Rating"] {
.like-disabled-input {
background-color: unset;
@@ -194,6 +224,10 @@
.web-form-footer {
margin-top: 1rem;
+ @include media-breakpoint-down(sm) {
+ margin-top: 0.5rem;
+ }
+
.web-form-actions {
display: flex;
justify-content: space-between;
@@ -201,6 +235,10 @@
.btn {
font-size: var(--text-base);
+
+ @include media-breakpoint-down(sm) {
+ font-size: var(--text-sm);
+ }
}
.btn-link {
@@ -294,6 +332,10 @@
width: 100%;
justify-content: center;
margin-bottom: 1.5rem;
+
+ &:empty {
+ margin: 0;
+ }
}
}
diff --git a/frappe/query_builder/functions.py b/frappe/query_builder/functions.py
index 512df8835c..aa25fa1215 100644
--- a/frappe/query_builder/functions.py
+++ b/frappe/query_builder/functions.py
@@ -41,6 +41,16 @@ class Timestamp(Function):
super().__init__("TIMESTAMP", term, alias=alias)
+class Round(Function):
+ def __init__(self, term, decimal=0, **kwargs):
+ super().__init__("ROUND", term, decimal, **kwargs)
+
+
+class Truncate(Function):
+ def __init__(self, term, decimal, **kwargs):
+ super().__init__("TRUNCATE", term, decimal, **kwargs)
+
+
GroupConcat = ImportMapper({db_type_is.MARIADB: GROUP_CONCAT, db_type_is.POSTGRES: STRING_AGG})
Match = ImportMapper({db_type_is.MARIADB: MATCH, db_type_is.POSTGRES: TO_TSVECTOR})
diff --git a/frappe/rate_limiter.py b/frappe/rate_limiter.py
index 0717124ba9..0448d7ea92 100644
--- a/frappe/rate_limiter.py
+++ b/frappe/rate_limiter.py
@@ -38,8 +38,8 @@ class RateLimiter:
timestamp = int(frappe.utils.now_datetime().timestamp())
self.window_number, self.spent = divmod(timestamp, self.window)
- self.key = frappe.cache().make_key(f"rate-limit-counter-{self.window_number}")
- self.counter = cint(frappe.cache().get(self.key))
+ self.key = frappe.cache.make_key(f"rate-limit-counter-{self.window_number}")
+ self.counter = cint(frappe.cache.get(self.key))
self.remaining = max(self.limit - self.counter, 0)
self.reset = self.window - self.spent
@@ -59,7 +59,7 @@ class RateLimiter:
self.end = datetime.utcnow()
self.duration = int((self.end - self.start).total_seconds() * 1000000)
- pipeline = frappe.cache().pipeline()
+ pipeline = frappe.cache.pipeline()
pipeline.incrby(self.key, self.duration)
pipeline.expire(self.key, self.window)
pipeline.execute()
@@ -137,11 +137,11 @@ def rate_limit(
cache_key = f"rl:{frappe.form_dict.cmd}:{identity}"
- value = frappe.cache().get(cache_key) or 0
+ value = frappe.cache.get(cache_key) or 0
if not value:
- frappe.cache().setex(cache_key, seconds, 0)
+ frappe.cache.setex(cache_key, seconds, 0)
- value = frappe.cache().incrby(cache_key, 1)
+ value = frappe.cache.incrby(cache_key, 1)
if value > _limit:
frappe.throw(
_("You hit the rate limit because of too many requests. Please try after sometime.")
diff --git a/frappe/realtime.py b/frappe/realtime.py
index e6980ef917..fdb86546f3 100644
--- a/frappe/realtime.py
+++ b/frappe/realtime.py
@@ -73,13 +73,29 @@ def publish_realtime(
room = get_site_room()
if after_commit:
+ if not hasattr(frappe.local, "_realtime_log"):
+ frappe.local._realtime_log = []
+ frappe.db.after_commit.add(flush_realtime_log)
+ frappe.db.after_rollback.add(clear_realtime_log)
+
params = [event, message, room]
- if params not in frappe.local.realtime_log:
- frappe.local.realtime_log.append(params)
+ if params not in frappe.local._realtime_log:
+ frappe.local._realtime_log.append(params)
else:
emit_via_redis(event, message, room)
+def flush_realtime_log():
+ for args in frappe.local._realtime_log:
+ frappe.realtime.emit_via_redis(*args)
+
+ frappe.local._realtime_log = []
+
+
+def clear_realtime_log():
+ frappe.local._realtime_log = []
+
+
def emit_via_redis(event, message, room):
"""Publish real-time updates via redis
diff --git a/frappe/recorder.py b/frappe/recorder.py
index 537d1ee996..8229b862af 100644
--- a/frappe/recorder.py
+++ b/frappe/recorder.py
@@ -65,7 +65,7 @@ def get_current_stack_frames():
def record(force=False):
if __debug__:
- if frappe.cache().get_value(RECORDER_INTERCEPT_FLAG) or force:
+ if frappe.cache.get_value(RECORDER_INTERCEPT_FLAG) or force:
frappe.local._recorder = Recorder()
@@ -109,7 +109,7 @@ class Recorder:
"duration": float(f"{(datetime.datetime.now() - self.time).total_seconds() * 1000:0.3f}"),
"method": self.method,
}
- frappe.cache().hset(RECORDER_REQUEST_SPARSE_HASH, self.uuid, request_data)
+ frappe.cache.hset(RECORDER_REQUEST_SPARSE_HASH, self.uuid, request_data)
frappe.publish_realtime(
event="recorder-dump-event",
message=json.dumps(request_data, default=str),
@@ -121,7 +121,7 @@ class Recorder:
request_data["calls"] = self.calls
request_data["headers"] = self.headers
request_data["form_dict"] = self.form_dict
- frappe.cache().hset(RECORDER_REQUEST_HASH, self.uuid, request_data)
+ frappe.cache.hset(RECORDER_REQUEST_HASH, self.uuid, request_data)
def mark_duplicates(self):
counts = Counter([call["query"] for call in self.calls])
@@ -162,21 +162,21 @@ def administrator_only(function):
@do_not_record
@administrator_only
def status(*args, **kwargs):
- return bool(frappe.cache().get_value(RECORDER_INTERCEPT_FLAG))
+ return bool(frappe.cache.get_value(RECORDER_INTERCEPT_FLAG))
@frappe.whitelist()
@do_not_record
@administrator_only
def start(*args, **kwargs):
- frappe.cache().set_value(RECORDER_INTERCEPT_FLAG, 1)
+ frappe.cache.set_value(RECORDER_INTERCEPT_FLAG, 1)
@frappe.whitelist()
@do_not_record
@administrator_only
def stop(*args, **kwargs):
- frappe.cache().delete_value(RECORDER_INTERCEPT_FLAG)
+ frappe.cache.delete_value(RECORDER_INTERCEPT_FLAG)
@frappe.whitelist()
@@ -184,9 +184,9 @@ def stop(*args, **kwargs):
@administrator_only
def get(uuid=None, *args, **kwargs):
if uuid:
- result = frappe.cache().hget(RECORDER_REQUEST_HASH, uuid)
+ result = frappe.cache.hget(RECORDER_REQUEST_HASH, uuid)
else:
- result = list(frappe.cache().hgetall(RECORDER_REQUEST_SPARSE_HASH).values())
+ result = list(frappe.cache.hgetall(RECORDER_REQUEST_SPARSE_HASH).values())
return result
@@ -194,15 +194,15 @@ def get(uuid=None, *args, **kwargs):
@do_not_record
@administrator_only
def export_data(*args, **kwargs):
- return list(frappe.cache().hgetall(RECORDER_REQUEST_HASH).values())
+ return list(frappe.cache.hgetall(RECORDER_REQUEST_HASH).values())
@frappe.whitelist()
@do_not_record
@administrator_only
def delete(*args, **kwargs):
- frappe.cache().delete_value(RECORDER_REQUEST_SPARSE_HASH)
- frappe.cache().delete_value(RECORDER_REQUEST_HASH)
+ frappe.cache.delete_value(RECORDER_REQUEST_SPARSE_HASH)
+ frappe.cache.delete_value(RECORDER_REQUEST_HASH)
def record_queries(func: Callable):
diff --git a/frappe/sessions.py b/frappe/sessions.py
index 9c739f3a96..64a1a6b663 100644
--- a/frappe/sessions.py
+++ b/frappe/sessions.py
@@ -85,8 +85,8 @@ def delete_session(sid=None, user=None, reason="Session Expired"):
# we should just ignore it till database is back up again.
return
- frappe.cache().hdel("session", sid)
- frappe.cache().hdel("last_db_session_update", sid)
+ frappe.cache.hdel("session", sid)
+ frappe.cache.hdel("last_db_session_update", sid)
if sid and not user:
table = DocType("Sessions")
user_details = (
@@ -139,17 +139,17 @@ def get():
bootinfo = None
if not getattr(frappe.conf, "disable_session_cache", None):
# check if cache exists
- bootinfo = frappe.cache().hget("bootinfo", frappe.session.user)
+ bootinfo = frappe.cache.hget("bootinfo", frappe.session.user)
if bootinfo:
bootinfo["from_cache"] = 1
- bootinfo["user"]["recent"] = json.dumps(frappe.cache().hget("user_recent", frappe.session.user))
+ bootinfo["user"]["recent"] = json.dumps(frappe.cache.hget("user_recent", frappe.session.user))
if not bootinfo:
# if not create it
bootinfo = get_bootinfo()
- frappe.cache().hset("bootinfo", frappe.session.user, bootinfo)
+ frappe.cache.hset("bootinfo", frappe.session.user, bootinfo)
try:
- frappe.cache().ping()
+ frappe.cache.ping()
except redis.exceptions.ConnectionError:
message = _("Redis cache server not running. Please contact Administrator / Tech support")
if "messages" in bootinfo:
@@ -161,7 +161,7 @@ def get():
if frappe.local.request:
bootinfo["change_log"] = get_change_log()
- bootinfo["metadata_version"] = frappe.cache().get_value("metadata_version")
+ bootinfo["metadata_version"] = frappe.cache.get_value("metadata_version")
if not bootinfo["metadata_version"]:
bootinfo["metadata_version"] = frappe.reset_metadata_version()
@@ -276,7 +276,7 @@ class Session:
)
# also add to memcache
- frappe.cache().hset("session", self.data.sid, self.data)
+ frappe.cache.hset("session", self.data.sid, self.data)
def resume(self):
"""non-login request: load a session"""
@@ -320,7 +320,7 @@ class Session:
return data
def get_session_data_from_cache(self):
- data = frappe.cache().hget("session", self.sid)
+ data = frappe.cache.hget("session", self.sid)
if data:
data = frappe._dict(data)
session_data = data.get("data", {})
@@ -377,7 +377,7 @@ class Session:
self.data["data"]["lang"] = str(frappe.lang)
# update session in db
- last_updated = frappe.cache().hget("last_db_session_update", self.sid)
+ last_updated = frappe.cache.hget("last_db_session_update", self.sid)
time_diff = frappe.utils.time_diff_in_seconds(now, last_updated) if last_updated else None
# database persistence is secondary, don't update it too often
@@ -397,11 +397,11 @@ class Session:
)
frappe.db.commit()
- frappe.cache().hset("last_db_session_update", self.sid, now)
+ frappe.cache.hset("last_db_session_update", self.sid, now)
updated_in_db = True
- frappe.cache().hset("session", self.sid, self.data)
+ frappe.cache.hset("session", self.sid, self.data)
return updated_in_db
diff --git a/frappe/social/doctype/energy_point_log/energy_point_log.py b/frappe/social/doctype/energy_point_log/energy_point_log.py
index 658d333c44..a9b013b0e1 100644
--- a/frappe/social/doctype/energy_point_log/energy_point_log.py
+++ b/frappe/social/doctype/energy_point_log/energy_point_log.py
@@ -38,7 +38,7 @@ class EnergyPointLog(Document):
"energy_point_alert", message=alert_dict, user=self.user, after_commit=True
)
- frappe.cache().hdel("energy_points", self.user)
+ frappe.cache.hdel("energy_points", self.user)
if self.type != "Review" and frappe.get_cached_value(
"Notification Settings", self.user, "energy_points_system_notifications"
@@ -222,9 +222,6 @@ def add_review_points(user, points):
@frappe.whitelist()
def get_energy_points(user):
- # points = frappe.cache().hget('energy_points', user,
- # lambda: get_user_energy_and_review_points(user))
- # TODO: cache properly
points = get_user_energy_and_review_points(user)
return frappe._dict(points.get(user, {}))
diff --git a/frappe/social/doctype/energy_point_log/test_energy_point_log.py b/frappe/social/doctype/energy_point_log/test_energy_point_log.py
index c97e2a44e4..2b88d33500 100644
--- a/frappe/social/doctype/energy_point_log/test_energy_point_log.py
+++ b/frappe/social/doctype/energy_point_log/test_energy_point_log.py
@@ -26,13 +26,13 @@ class TestEnergyPointLog(FrappeTestCase):
settings.save()
def setUp(self):
- frappe.cache().delete_value("energy_point_rule_map")
+ frappe.cache.delete_value("energy_point_rule_map")
def tearDown(self):
frappe.set_user("Administrator")
frappe.db.delete("Energy Point Log")
frappe.db.delete("Energy Point Rule")
- frappe.cache().delete_value("energy_point_rule_map")
+ frappe.cache.delete_value("energy_point_rule_map")
def test_user_energy_point(self):
frappe.set_user("test@example.com")
diff --git a/frappe/templates/includes/app_analytics/mixpanel_analytics.html b/frappe/templates/includes/app_analytics/mixpanel_analytics.html
deleted file mode 100644
index 286593be04..0000000000
--- a/frappe/templates/includes/app_analytics/mixpanel_analytics.html
+++ /dev/null
@@ -1,6 +0,0 @@
-{% if mixpanel_id %}
-
-{% endif %}
\ No newline at end of file
diff --git a/frappe/tests/test_boilerplate.py b/frappe/tests/test_boilerplate.py
index 0f58e84df4..717fdc7ab8 100644
--- a/frappe/tests/test_boilerplate.py
+++ b/frappe/tests/test_boilerplate.py
@@ -12,7 +12,7 @@ import git
import yaml
import frappe
-from frappe.modules.patch_handler import get_all_patches
+from frappe.modules.patch_handler import get_all_patches, parse_as_configfile
from frappe.utils.boilerplate import (
PatchCreator,
_create_app_boilerplate,
@@ -138,6 +138,11 @@ class TestBoilerPlate(unittest.TestCase):
app_repo = git.Repo(new_app_dir)
self.assertEqual(app_repo.active_branch.name, "develop")
+ patches_file = os.path.join(new_app_dir, app_name, "patches.txt")
+ self.assertTrue(os.path.exists(patches_file), msg=f"{patches_file} not found")
+
+ self.assertEqual(parse_as_configfile(patches_file), [])
+
def test_create_app_without_git_init(self):
app_name = "test_app_no_git"
diff --git a/frappe/tests/test_boot.py b/frappe/tests/test_boot.py
index 232c379e08..8ad2a94aeb 100644
--- a/frappe/tests/test_boot.py
+++ b/frappe/tests/test_boot.py
@@ -65,7 +65,7 @@ class TestBootData(FrappeTestCase):
).insert(ignore_permissions=True)
get_user_pages_or_reports("Report")
- allowed_reports = frappe.cache().get_value("has_role:Report", user=frappe.session.user)
+ allowed_reports = frappe.cache.get_value("has_role:Report", user=frappe.session.user)
# Test user must not see admin user's report
self.assertNotIn("Test Admin Report", allowed_reports)
diff --git a/frappe/tests/test_caching.py b/frappe/tests/test_caching.py
index 37f1583097..f3f9d52f25 100644
--- a/frappe/tests/test_caching.py
+++ b/frappe/tests/test_caching.py
@@ -163,3 +163,66 @@ class TestRedisCache(FrappeAPITestCase):
calculate_area(radius=10)
# kwargs should hit cache too
self.assertEqual(function_call_count, 4)
+
+
+class TestDocumentCache(FrappeAPITestCase):
+ TEST_DOCTYPE = "User"
+ TEST_DOCNAME = "Administrator"
+ TEST_FIELD = "middle_name"
+
+ def setUp(self) -> None:
+ self.test_value = frappe.generate_hash()
+
+ def test_caching(self):
+ doc = frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
+
+ with self.assertQueryCount(0):
+ doc = frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
+
+ doc.db_set(self.TEST_FIELD, self.test_value)
+ new_doc = frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
+
+ self.assertIsNot(doc, new_doc) # Shouldn't be same object from frappe.local
+ self.assertEqual(new_doc.get(self.TEST_FIELD), self.test_value) # Cache invalidated and fetched
+ frappe.db.rollback()
+
+ doc_after_rollback = frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
+ self.assertIsNot(new_doc, doc_after_rollback)
+ # Cache invalidated after rollback
+ self.assertNotEqual(doc_after_rollback.get(self.TEST_FIELD), self.test_value)
+
+ with self.assertQueryCount(0):
+ frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
+
+ def test_cache_invalidation_set_value(self):
+ doc = frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
+
+ frappe.db.set_value(
+ self.TEST_DOCTYPE,
+ {"name": ("like", "%Admin%")},
+ self.TEST_FIELD,
+ self.test_value,
+ )
+
+ new_doc = frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
+ self.assertIsNot(doc, new_doc)
+ self.assertEqual(new_doc.get(self.TEST_FIELD), self.test_value)
+
+ with self.assertQueryCount(0):
+ frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
+
+
+class TestRedisWrapper(FrappeAPITestCase):
+ def test_delete_keys(self):
+
+ prefix = "test_del_"
+
+ for i in range(5):
+ frappe.cache.set_value(f"{prefix}{i}", 1)
+
+ self.assertEqual(len(frappe.cache.get_keys(prefix)), 5)
+ frappe.cache.delete_keys(prefix)
+ self.assertEqual(len(frappe.cache.get_keys(prefix)), 0)
+
+ def test_backward_compat_cache(self):
+ self.assertEqual(frappe.cache, frappe.cache())
diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py
index ed01af655c..afc24ecf68 100644
--- a/frappe/tests/test_db.py
+++ b/frappe/tests/test_db.py
@@ -593,6 +593,37 @@ class TestDB(FrappeTestCase):
modify_values((23, 23.0, 23.00004345, "wow", [1, 2, 3, "abc"])),
)
+ def test_callbacks(self):
+
+ order_of_execution = []
+
+ def f(val):
+ nonlocal order_of_execution
+ order_of_execution.append(val)
+
+ frappe.db.before_commit.add(lambda: f(0))
+ frappe.db.before_commit.add(lambda: f(1))
+
+ frappe.db.after_commit.add(lambda: f(2))
+ frappe.db.after_commit.add(lambda: f(3))
+
+ frappe.db.before_rollback.add(lambda: f("IGNORED"))
+ frappe.db.before_rollback.add(lambda: f("IGNORED"))
+
+ frappe.db.commit()
+
+ frappe.db.after_commit.add(lambda: f("IGNORED"))
+ frappe.db.after_commit.add(lambda: f("IGNORED"))
+
+ frappe.db.before_rollback.add(lambda: f(4))
+ frappe.db.before_rollback.add(lambda: f(5))
+ frappe.db.after_rollback.add(lambda: f(6))
+ frappe.db.after_rollback.add(lambda: f(7))
+
+ frappe.db.rollback()
+
+ self.assertEqual(order_of_execution, list(range(0, 8)))
+
@run_only_if(db_type_is.MARIADB)
class TestDDLCommandsMaria(FrappeTestCase):
@@ -765,21 +796,20 @@ class TestDBSetValue(FrappeTestCase):
def test_set_value(self):
self.todo1.reload()
- with patch.object(Database, "sql") as sql_called:
- frappe.db.set_value(
- self.todo1.doctype,
- self.todo1.name,
- "description",
- f"{self.todo1.description}-edit by `test_for_update`",
- )
- first_query = sql_called.call_args_list[0].args[0]
+ frappe.db.set_value(
+ self.todo1.doctype,
+ self.todo1.name,
+ "description",
+ f"{self.todo1.description}-edit by `test_for_update`",
+ )
+ query = str(frappe.db.last_query)
- if frappe.conf.db_type == "postgres":
- from frappe.database.postgres.database import modify_query
+ if frappe.conf.db_type == "postgres":
+ from frappe.database.postgres.database import modify_query
- self.assertTrue(modify_query("UPDATE `tabToDo` SET") in first_query)
- if frappe.conf.db_type == "mariadb":
- self.assertTrue("UPDATE `tabToDo` SET" in first_query)
+ self.assertTrue(modify_query("UPDATE `tabToDo` SET") in query)
+ if frappe.conf.db_type == "mariadb":
+ self.assertTrue("UPDATE `tabToDo` SET" in query)
def test_cleared_cache(self):
self.todo2.reload()
diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py
index 474971c935..4e575528ab 100644
--- a/frappe/tests/test_document.py
+++ b/frappe/tests/test_document.py
@@ -169,6 +169,10 @@ class TestDocument(FrappeTestCase):
with self.assertQueryCount(0):
user.db_set("user_type", "Magical Wizard")
+ def test_new_doc_with_fields(self):
+ user = frappe.new_doc("User", first_name="wizard")
+ self.assertEqual(user.first_name, "wizard")
+
def test_update_after_submit(self):
d = self.test_insert()
d.starts_on = "2014-09-09"
diff --git a/frappe/tests/test_fixture_import.py b/frappe/tests/test_fixture_import.py
index b9bd4550b2..8e4fa16763 100644
--- a/frappe/tests/test_fixture_import.py
+++ b/frappe/tests/test_fixture_import.py
@@ -69,10 +69,12 @@ class TestFixtureImport(FrappeTestCase):
import_doc(path_to_exported_fixtures)
- delete_doc("DocType", "temp_singles", delete_permanently=True)
- os.remove(path_to_exported_fixtures)
-
data = frappe.db.get_single_value("temp_singles", "member_name")
truncate_query.run()
self.assertEqual(data, dummy_name_list[0])
+
+ delete_doc("DocType", "temp_singles", delete_permanently=True)
+ os.remove(path_to_exported_fixtures)
+
+ frappe.db.commit()
diff --git a/frappe/tests/test_hooks.py b/frappe/tests/test_hooks.py
index 3ecc2c2b89..41a734e7ad 100644
--- a/frappe/tests/test_hooks.py
+++ b/frappe/tests/test_hooks.py
@@ -26,7 +26,7 @@ class TestHooks(FrappeTestCase):
hooks.override_doctype_class = {"ToDo": ["frappe.tests.test_hooks.CustomToDo"]}
# Clear cache
- frappe.cache().delete_value("app_hooks")
+ frappe.cache.delete_value("app_hooks")
clear_controller_cache("ToDo")
todo = frappe.get_doc(doctype="ToDo", description="asdf")
@@ -45,7 +45,7 @@ class TestHooks(FrappeTestCase):
hooks.has_permission["Address"] = address_has_permission_hook
# Clear cache
- frappe.cache().delete_value("app_hooks")
+ frappe.cache.delete_value("app_hooks")
# Init User and Address
username = "test@example.com"
diff --git a/frappe/tests/test_monitor.py b/frappe/tests/test_monitor.py
index e59ebcde31..74c8c07b9f 100644
--- a/frappe/tests/test_monitor.py
+++ b/frappe/tests/test_monitor.py
@@ -12,7 +12,7 @@ from frappe.utils.response import build_response
class TestMonitor(FrappeTestCase):
def setUp(self):
frappe.conf.monitor = 1
- frappe.cache().delete_value(MONITOR_REDIS_KEY)
+ frappe.cache.delete_value(MONITOR_REDIS_KEY)
def test_enable_monitor(self):
set_request(method="GET", path="/api/method/frappe.ping")
@@ -21,7 +21,7 @@ class TestMonitor(FrappeTestCase):
frappe.monitor.start()
frappe.monitor.stop(response)
- logs = frappe.cache().lrange(MONITOR_REDIS_KEY, 0, -1)
+ logs = frappe.cache.lrange(MONITOR_REDIS_KEY, 0, -1)
self.assertEqual(len(logs), 1)
log = frappe.parse_json(logs[0].decode())
@@ -39,7 +39,7 @@ class TestMonitor(FrappeTestCase):
frappe.monitor.start()
frappe.monitor.stop(response=None)
- logs = frappe.cache().lrange(MONITOR_REDIS_KEY, 0, -1)
+ logs = frappe.cache.lrange(MONITOR_REDIS_KEY, 0, -1)
self.assertEqual(len(logs), 1)
log = frappe.parse_json(logs[0].decode())
@@ -52,7 +52,7 @@ class TestMonitor(FrappeTestCase):
frappe.local.site, "frappe.ping", None, None, {}, is_async=False
)
- logs = frappe.cache().lrange(MONITOR_REDIS_KEY, 0, -1)
+ logs = frappe.cache.lrange(MONITOR_REDIS_KEY, 0, -1)
self.assertEqual(len(logs), 1)
log = frappe.parse_json(logs[0].decode())
self.assertEqual(log.transaction_type, "job")
@@ -79,4 +79,4 @@ class TestMonitor(FrappeTestCase):
def tearDown(self):
frappe.conf.monitor = 0
- frappe.cache().delete_value(MONITOR_REDIS_KEY)
+ frappe.cache.delete_value(MONITOR_REDIS_KEY)
diff --git a/frappe/tests/test_nestedset.py b/frappe/tests/test_nestedset.py
index ef63fb66c2..340b53bf38 100644
--- a/frappe/tests/test_nestedset.py
+++ b/frappe/tests/test_nestedset.py
@@ -51,35 +51,35 @@ records = [
},
]
+TEST_DOCTYPE = "Test Tree DocType"
+
class NestedSetTestUtil:
def setup_test_doctype(self):
- frappe.db.sql("delete from `tabDocType` where `name` = 'Test Tree DocType'")
- frappe.db.sql_ddl("drop table if exists `tabTest Tree DocType`")
+ frappe.db.delete("DocType", TEST_DOCTYPE)
+ frappe.db.sql_ddl(f"drop table if exists `tab{TEST_DOCTYPE}`")
- self.tree_doctype = new_doctype(
- "Test Tree DocType", is_tree=True, autoname="field:some_fieldname"
- )
+ self.tree_doctype = new_doctype(TEST_DOCTYPE, is_tree=True, autoname="field:some_fieldname")
self.tree_doctype.insert()
for record in records:
- d = frappe.new_doc("Test Tree DocType")
+ d = frappe.new_doc(TEST_DOCTYPE)
d.update(record)
d.insert()
def teardown_test_doctype(self):
self.tree_doctype.delete()
- frappe.db.sql_ddl("drop table if exists `tabTest Tree DocType`")
+ frappe.db.sql_ddl(f"drop table if exists `{TEST_DOCTYPE}`")
def move_it_back(self):
- parent_1 = frappe.get_doc("Test Tree DocType", "Parent 1")
+ parent_1 = frappe.get_doc(TEST_DOCTYPE, "Parent 1")
parent_1.parent_test_tree_doctype = "Root Node"
parent_1.save()
def get_no_of_children(self, record_name: str) -> int:
if not record_name:
- return frappe.db.count("Test Tree DocType")
- return len(get_descendants_of("Test Tree DocType", record_name, ignore_permissions=True))
+ return frappe.db.count(TEST_DOCTYPE)
+ return len(get_descendants_of(TEST_DOCTYPE, record_name, ignore_permissions=True))
class TestNestedSet(FrappeTestCase):
@@ -101,18 +101,18 @@ class TestNestedSet(FrappeTestCase):
global records
min_lft = 1
- max_rgt = frappe.qb.from_("Test Tree DocType").select(Max(Field("rgt"))).run(pluck=True)[0]
+ max_rgt = frappe.qb.from_(TEST_DOCTYPE).select(Max(Field("rgt"))).run(pluck=True)[0]
for record in records:
lft, rgt, parent_test_tree_doctype = frappe.db.get_value(
- "Test Tree DocType",
+ TEST_DOCTYPE,
record["some_fieldname"],
["lft", "rgt", "parent_test_tree_doctype"],
)
if parent_test_tree_doctype:
parent_lft, parent_rgt = frappe.db.get_value(
- "Test Tree DocType", parent_test_tree_doctype, ["lft", "rgt"]
+ TEST_DOCTYPE, parent_test_tree_doctype, ["lft", "rgt"]
)
else:
# root
@@ -138,19 +138,19 @@ class TestNestedSet(FrappeTestCase):
self.assertTrue(parent_rgt == (parent_lft + 1 + (2 * no_of_children)))
def test_recursion(self):
- leaf_node = frappe.get_doc("Test Tree DocType", {"some_fieldname": "Parent 2"})
+ leaf_node = frappe.get_doc(TEST_DOCTYPE, {"some_fieldname": "Parent 2"})
leaf_node.parent_test_tree_doctype = "Child 3"
self.assertRaises(NestedSetRecursionError, leaf_node.save)
leaf_node.reload()
def test_rebuild_tree(self):
- rebuild_tree("Test Tree DocType", "parent_test_tree_doctype")
+ rebuild_tree(TEST_DOCTYPE, "parent_test_tree_doctype")
self.test_basic_tree()
def test_move_group_into_another(self):
- old_lft, old_rgt = frappe.db.get_value("Test Tree DocType", "Parent 2", ["lft", "rgt"])
+ old_lft, old_rgt = frappe.db.get_value(TEST_DOCTYPE, "Parent 2", ["lft", "rgt"])
- parent_1 = frappe.get_doc("Test Tree DocType", "Parent 1")
+ parent_1 = frappe.get_doc(TEST_DOCTYPE, "Parent 1")
lft, rgt = parent_1.lft, parent_1.rgt
parent_1.parent_test_tree_doctype = "Parent 2"
@@ -158,7 +158,7 @@ class TestNestedSet(FrappeTestCase):
self.test_basic_tree()
# after move
- new_lft, new_rgt = frappe.db.get_value("Test Tree DocType", "Parent 2", ["lft", "rgt"])
+ new_lft, new_rgt = frappe.db.get_value(TEST_DOCTYPE, "Parent 2", ["lft", "rgt"])
# lft should reduce
self.assertEqual(old_lft - new_lft, rgt - lft + 1)
@@ -170,12 +170,10 @@ class TestNestedSet(FrappeTestCase):
self.test_basic_tree()
def test_move_leaf_into_another_group(self):
- child_2 = frappe.get_doc("Test Tree DocType", "Child 2")
+ child_2 = frappe.get_doc(TEST_DOCTYPE, "Child 2")
# assert that child 2 is not already under parent 1
- parent_lft_old, parent_rgt_old = frappe.db.get_value(
- "Test Tree DocType", "Parent 2", ["lft", "rgt"]
- )
+ parent_lft_old, parent_rgt_old = frappe.db.get_value(TEST_DOCTYPE, "Parent 2", ["lft", "rgt"])
self.assertTrue((parent_lft_old > child_2.lft) and (parent_rgt_old > child_2.rgt))
child_2.parent_test_tree_doctype = "Parent 2"
@@ -183,22 +181,20 @@ class TestNestedSet(FrappeTestCase):
self.test_basic_tree()
# assert that child 2 is under parent 1
- parent_lft_new, parent_rgt_new = frappe.db.get_value(
- "Test Tree DocType", "Parent 2", ["lft", "rgt"]
- )
+ parent_lft_new, parent_rgt_new = frappe.db.get_value(TEST_DOCTYPE, "Parent 2", ["lft", "rgt"])
self.assertFalse((parent_lft_new > child_2.lft) and (parent_rgt_new > child_2.rgt))
def test_delete_leaf(self):
global records
el = {"some_fieldname": "Child 1", "parent_test_tree_doctype": "Parent 1", "is_group": 0}
- child_1 = frappe.get_doc("Test Tree DocType", "Child 1")
+ child_1 = frappe.get_doc(TEST_DOCTYPE, "Child 1")
child_1.delete()
records.remove(el)
self.test_basic_tree()
- n = frappe.new_doc("Test Tree DocType")
+ n = frappe.new_doc(TEST_DOCTYPE)
n.update(el)
n.insert()
records.append(el)
@@ -208,10 +204,10 @@ class TestNestedSet(FrappeTestCase):
def test_delete_group(self):
# cannot delete group with child, but can delete leaf
with self.assertRaises(NestedSetChildExistsError):
- frappe.delete_doc("Test Tree DocType", "Parent 1")
+ frappe.delete_doc(TEST_DOCTYPE, "Parent 1")
def test_remove_subtree(self):
- remove_subtree("Test Tree DocType", "Parent 2")
+ remove_subtree(TEST_DOCTYPE, "Parent 2")
self.test_basic_tree()
def test_rename_nestedset(self):
@@ -223,7 +219,7 @@ class TestNestedSet(FrappeTestCase):
def test_merge_groups(self):
global records
el = {"some_fieldname": "Parent 2", "parent_test_tree_doctype": "Root Node", "is_group": 1}
- frappe.rename_doc("Test Tree DocType", "Parent 2", "Parent 1", merge=True)
+ frappe.rename_doc(TEST_DOCTYPE, "Parent 2", "Parent 1", merge=True)
records.remove(el)
self.test_basic_tree()
@@ -232,7 +228,7 @@ class TestNestedSet(FrappeTestCase):
el = {"some_fieldname": "Child 3", "parent_test_tree_doctype": "Parent 2", "is_group": 0}
frappe.rename_doc(
- "Test Tree DocType",
+ TEST_DOCTYPE,
"Child 3",
"Child 2",
merge=True,
@@ -242,17 +238,17 @@ class TestNestedSet(FrappeTestCase):
def test_merge_leaf_into_group(self):
with self.assertRaises(NestedSetInvalidMergeError):
- frappe.rename_doc("Test Tree DocType", "Child 1", "Parent 1", merge=True)
+ frappe.rename_doc(TEST_DOCTYPE, "Child 1", "Parent 1", merge=True)
def test_merge_group_into_leaf(self):
with self.assertRaises(NestedSetInvalidMergeError):
- frappe.rename_doc("Test Tree DocType", "Parent 1", "Child 1", merge=True)
+ frappe.rename_doc(TEST_DOCTYPE, "Parent 1", "Child 1", merge=True)
def test_root_deletion(self):
for doc in ["Child 3", "Child 2", "Child 1", "Parent 2", "Parent 1"]:
- frappe.delete_doc("Test Tree DocType", doc)
+ frappe.delete_doc(TEST_DOCTYPE, doc)
- root_node = frappe.get_doc("Test Tree DocType", "Root Node")
+ root_node = frappe.get_doc(TEST_DOCTYPE, "Root Node")
# root deletion with allow_root_deletion
# patched as delete_doc create a new instance of Root Node (using get_doc)
@@ -263,4 +259,40 @@ class TestNestedSet(FrappeTestCase):
# root deletion without allow_root_deletion
root_node.delete()
- self.assertFalse(frappe.db.exists("Test Tree DocType", "Root Node"))
+ self.assertFalse(frappe.db.exists(TEST_DOCTYPE, "Root Node"))
+
+ def test_desc_filters(self):
+
+ linked_doctype = (
+ new_doctype(
+ fields=[
+ {
+ "fieldname": "link_field",
+ "fieldtype": "Link",
+ "options": TEST_DOCTYPE,
+ }
+ ]
+ )
+ .insert()
+ .name
+ )
+
+ record = "Child 1"
+
+ exclusive_filter = {"name": ("descendants of", record)}
+ inclusive_filter = {"name": ("descendants of (inclusive)", record)}
+ exclusive_link = {"link_field": ("descendants of", record)}
+ inclusive_link = {"link_field": ("descendants of (inclusive)", record)}
+
+ # db_query
+ self.assertNotIn(record, frappe.get_all(TEST_DOCTYPE, exclusive_filter, run=0))
+ self.assertIn(record, frappe.get_all(TEST_DOCTYPE, inclusive_filter, run=0))
+ self.assertNotIn(record, frappe.get_all(linked_doctype, exclusive_link, run=0))
+ self.assertIn(record, frappe.get_all(linked_doctype, inclusive_link, run=0))
+
+ # QB
+ self.assertNotIn(record, str(frappe.qb.get_query(TEST_DOCTYPE, filters=exclusive_filter)))
+ self.assertIn(record, str(frappe.qb.get_query(TEST_DOCTYPE, filters=inclusive_filter)))
+
+ self.assertNotIn(record, str(frappe.qb.get_query(table=linked_doctype, filters=exclusive_link)))
+ self.assertIn(record, str(frappe.qb.get_query(table=linked_doctype, filters=inclusive_link)))
diff --git a/frappe/tests/test_query.py b/frappe/tests/test_query.py
index dfebf5e890..9242630104 100644
--- a/frappe/tests/test_query.py
+++ b/frappe/tests/test_query.py
@@ -218,13 +218,6 @@ class TestQuery(FrappeTestCase):
@run_only_if(db_type_is.MARIADB)
def test_filters(self):
- self.assertEqual(
- frappe.qb.get_query(
- "User", filters={"IfNull(name, " ")": ("<", Now())}, fields=["Max(name)"]
- ).run(),
- frappe.qb.from_("User").select(Max(Field("name"))).where(Ifnull("name", "") < Now()).run(),
- )
-
self.assertEqual(
frappe.qb.get_query(
"DocType",
@@ -258,6 +251,17 @@ class TestQuery(FrappeTestCase):
),
)
+ self.assertRaisesRegex(
+ frappe.ValidationError,
+ "Invalid filter",
+ lambda: frappe.qb.get_query(
+ "DocType",
+ fields=["name"],
+ filters={"permissions.role": "System Manager"},
+ validate_filters=True,
+ ),
+ )
+
self.assertEqual(
frappe.qb.get_query(
"DocType",
diff --git a/frappe/tests/test_query_builder.py b/frappe/tests/test_query_builder.py
index a16c2a23ae..e3ca63abf1 100644
--- a/frappe/tests/test_query_builder.py
+++ b/frappe/tests/test_query_builder.py
@@ -13,6 +13,8 @@ from frappe.query_builder.functions import (
Date,
GroupConcat,
Match,
+ Round,
+ Truncate,
UnixTimestamp,
)
from frappe.query_builder.utils import db_type_is
@@ -153,6 +155,20 @@ class TestCustomFunctionsMariaDB(FrappeTestCase):
"SELECT `tabred`.`other`,CONCAT(`tabNote`.`name`,'') FROM `tabred`,`tabNote`",
)
+ def test_round(self):
+ note = frappe.qb.DocType("Note")
+
+ query = frappe.qb.from_(note).select(Round(note.price))
+ self.assertEqual("select round(`price`,0) from `tabnote`", str(query).lower())
+
+ query = frappe.qb.from_(note).select(Round(note.price, 3))
+ self.assertEqual("select round(`price`,3) from `tabnote`", str(query).lower())
+
+ def test_truncate(self):
+ note = frappe.qb.DocType("Note")
+ query = frappe.qb.from_(note).select(Truncate(note.price, 3))
+ self.assertEqual("select truncate(`price`,3) from `tabnote`", str(query).lower())
+
@run_only_if(db_type_is.POSTGRES)
class TestCustomFunctionsPostgres(FrappeTestCase):
@@ -283,6 +299,20 @@ class TestCustomFunctionsPostgres(FrappeTestCase):
'SELECT "tabred"."other",CAST("tabNote"."name" AS VARCHAR) FROM "tabred","tabNote"',
)
+ def test_round(self):
+ note = frappe.qb.DocType("Note")
+
+ query = frappe.qb.from_(note).select(Round(note.price))
+ self.assertEqual('select round("price",0) from "tabnote"', str(query).lower())
+
+ query = frappe.qb.from_(note).select(Round(note.price, 3))
+ self.assertEqual('select round("price",3) from "tabnote"', str(query).lower())
+
+ def test_truncate(self):
+ note = frappe.qb.DocType("Note")
+ query = frappe.qb.from_(note).select(Truncate(note.price, 3))
+ self.assertEqual('select truncate("price",3) from "tabnote"', str(query).lower())
+
class TestBuilderBase:
def test_adding_tabs(self):
diff --git a/frappe/tests/test_rate_limiter.py b/frappe/tests/test_rate_limiter.py
index c8485d6c69..292a688484 100644
--- a/frappe/tests/test_rate_limiter.py
+++ b/frappe/tests/test_rate_limiter.py
@@ -20,7 +20,7 @@ class TestRateLimiter(FrappeTestCase):
self.assertTrue(hasattr(frappe.local, "rate_limiter"))
self.assertIsInstance(frappe.local.rate_limiter, RateLimiter)
- frappe.cache().delete(frappe.local.rate_limiter.key)
+ frappe.cache.delete(frappe.local.rate_limiter.key)
delattr(frappe.local, "rate_limiter")
def test_apply_without_limit(self):
@@ -53,8 +53,8 @@ class TestRateLimiter(FrappeTestCase):
self.assertEqual(int(headers["X-RateLimit-Limit"]), 10000)
self.assertEqual(int(headers["X-RateLimit-Remaining"]), 0)
- frappe.cache().delete(limiter.key)
- frappe.cache().delete(frappe.local.rate_limiter.key)
+ frappe.cache.delete(limiter.key)
+ frappe.cache.delete(frappe.local.rate_limiter.key)
delattr(frappe.local, "rate_limiter")
def test_respond_under_limit(self):
@@ -64,7 +64,7 @@ class TestRateLimiter(FrappeTestCase):
response = frappe.rate_limiter.respond()
self.assertEqual(response, None)
- frappe.cache().delete(frappe.local.rate_limiter.key)
+ frappe.cache.delete(frappe.local.rate_limiter.key)
delattr(frappe.local, "rate_limiter")
def test_headers_under_limit(self):
@@ -79,7 +79,7 @@ class TestRateLimiter(FrappeTestCase):
self.assertEqual(int(headers["X-RateLimit-Limit"]), 10000)
self.assertEqual(int(headers["X-RateLimit-Remaining"]), 10000)
- frappe.cache().delete(frappe.local.rate_limiter.key)
+ frappe.cache.delete(frappe.local.rate_limiter.key)
delattr(frappe.local, "rate_limiter")
def test_reject_over_limit(self):
@@ -90,7 +90,7 @@ class TestRateLimiter(FrappeTestCase):
limiter = RateLimiter(0.01, 86400)
self.assertRaises(frappe.TooManyRequestsError, limiter.apply)
- frappe.cache().delete(limiter.key)
+ frappe.cache.delete(limiter.key)
def test_do_not_reject_under_limit(self):
limiter = RateLimiter(0.01, 86400)
@@ -100,13 +100,13 @@ class TestRateLimiter(FrappeTestCase):
limiter = RateLimiter(0.02, 86400)
self.assertEqual(limiter.apply(), None)
- frappe.cache().delete(limiter.key)
+ frappe.cache.delete(limiter.key)
def test_update_method(self):
limiter = RateLimiter(0.01, 86400)
time.sleep(0.01)
limiter.update()
- self.assertEqual(limiter.duration, cint(frappe.cache().get(limiter.key)))
+ self.assertEqual(limiter.duration, cint(frappe.cache.get(limiter.key)))
- frappe.cache().delete(limiter.key)
+ frappe.cache.delete(limiter.key)
diff --git a/frappe/tests/test_twofactor.py b/frappe/tests/test_twofactor.py
index 053b755b65..96c0a80ead 100644
--- a/frappe/tests/test_twofactor.py
+++ b/frappe/tests/test_twofactor.py
@@ -61,7 +61,7 @@ class TestTwoFactor(FrappeTestCase):
self.assertTrue(verification_obj)
self.assertTrue(tmp_id)
for k in ["_usr", "_pwd", "_otp_secret"]:
- self.assertTrue(frappe.cache().get(f"{tmp_id}{k}"), f"{k} not available")
+ self.assertTrue(frappe.cache.get(f"{tmp_id}{k}"), f"{k} not available")
def test_two_factor_is_enabled(self):
"""
diff --git a/frappe/tests/test_webform.py b/frappe/tests/test_webform.py
index cde963a915..d8b9254a09 100644
--- a/frappe/tests/test_webform.py
+++ b/frappe/tests/test_webform.py
@@ -80,4 +80,4 @@ def set_webform_hook(key, value):
delattr(hooks, hook)
setattr(hooks, key, value)
- frappe.cache().delete_key("app_hooks")
+ frappe.cache.delete_key("app_hooks")
diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py
index 01f6e4f7cc..841f7f1e71 100644
--- a/frappe/tests/test_website.py
+++ b/frappe/tests/test_website.py
@@ -46,7 +46,7 @@ class TestWebsite(FrappeTestCase):
frappe.db.set_value("Portal Settings", None, "default_portal_home", "test-portal-home")
frappe.set_user("test-user-for-home-page@example.com")
- frappe.cache().hdel("home_page", frappe.session.user)
+ frappe.cache.hdel("home_page", frappe.session.user)
self.assertEqual(get_home_page(), "test-portal-home")
frappe.db.set_value("Portal Settings", None, "default_portal_home", "")
@@ -210,7 +210,7 @@ class TestWebsite(FrappeTestCase):
self.assertEqual(response.headers.get("Location"), "/courses/data")
delattr(frappe.hooks, "website_redirects")
- frappe.cache().delete_key("app_hooks")
+ frappe.cache.delete_key("app_hooks")
def test_custom_page_renderer(self):
from frappe import get_hooks
diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py
index 2cdcfb5643..07003d3b8c 100644
--- a/frappe/tests/utils.py
+++ b/frappe/tests/utils.py
@@ -32,7 +32,7 @@ class FrappeTestCase(unittest.TestCase):
# flush changes done so far to avoid flake
frappe.db.commit()
if cls.SHOW_TRANSACTION_COMMIT_WARNINGS:
- frappe.db.add_before_commit(_commit_watcher)
+ frappe.db.before_commit.add(_commit_watcher)
# enqueue teardown actions (executed in LIFO order)
cls.addClassCleanup(_restore_thread_locals, copy.deepcopy(frappe.local.flags))
@@ -101,8 +101,6 @@ def _commit_watcher():
def _rollback_db():
- frappe.local.before_commit = []
- frappe.local.rollback_observers = []
frappe.db.value_cache = {}
frappe.db.rollback()
@@ -112,7 +110,6 @@ def _restore_thread_locals(flags):
frappe.local.error_log = []
frappe.local.message_log = []
frappe.local.debug_log = []
- frappe.local.realtime_log = []
frappe.local.conf = frappe._dict(frappe.get_site_config())
frappe.local.cache = {}
frappe.local.lang = "en"
diff --git a/frappe/translate.py b/frappe/translate.py
index 041e983432..f35a4b7ec3 100644
--- a/frappe/translate.py
+++ b/frappe/translate.py
@@ -125,7 +125,7 @@ def get_parent_language(lang: str) -> str:
def get_user_lang(user: str = None) -> str:
"""Set frappe.local.lang from user preferences on session beginning or resumption"""
user = user or frappe.session.user
- lang = frappe.cache().hget("lang", user)
+ lang = frappe.cache.hget("lang", user)
if not lang:
# User.language => Session Defaults => frappe.local.lang => 'en'
@@ -136,7 +136,7 @@ def get_user_lang(user: str = None) -> str:
or "en"
)
- frappe.cache().hset("lang", user, lang)
+ frappe.cache.hset("lang", user, lang)
return lang
@@ -168,9 +168,8 @@ def get_dict(fortype: str, name: str | None = None) -> dict[str, str]:
:param name: name of the document for which assets are to be returned.
"""
fortype = fortype.lower()
- cache = frappe.cache()
asset_key = fortype + ":" + (name or "-")
- translation_assets = cache.hget("translation_assets", frappe.local.lang) or {}
+ translation_assets = frappe.cache.hget("translation_assets", frappe.local.lang) or {}
if asset_key not in translation_assets:
messages = []
@@ -210,7 +209,7 @@ def get_dict(fortype: str, name: str | None = None) -> dict[str, str]:
# remove untranslated
message_dict = {k: v for k, v in message_dict.items() if k != v}
translation_assets[asset_key] = message_dict
- cache.hset("translation_assets", frappe.local.lang, translation_assets)
+ frappe.cache.hset("translation_assets", frappe.local.lang, translation_assets)
translation_map: dict = translation_assets[asset_key]
@@ -292,7 +291,7 @@ def get_all_translations(lang: str) -> dict[str, str]:
return all_translations
try:
- return frappe.cache().hget(MERGED_TRANSLATION_KEY, lang, generator=_merge_translations)
+ return frappe.cache.hget(MERGED_TRANSLATION_KEY, lang, generator=_merge_translations)
except Exception:
# People mistakenly call translation function on global variables
# where locals are not initalized, translations dont make much sense there
@@ -361,19 +360,18 @@ def get_user_translations(lang):
user_translations[key] = value
return user_translations
- return frappe.cache().hget(USER_TRANSLATION_KEY, lang, generator=_read_from_db)
+ return frappe.cache.hget(USER_TRANSLATION_KEY, lang, generator=_read_from_db)
def clear_cache():
"""Clear all translation assets from :meth:`frappe.cache`"""
- cache = frappe.cache()
- cache.delete_key("langinfo")
+ frappe.cache.delete_key("langinfo")
# clear translations saved in boot cache
- cache.delete_key("bootinfo")
- cache.delete_key("translation_assets")
- cache.delete_key(USER_TRANSLATION_KEY)
- cache.delete_key(MERGED_TRANSLATION_KEY)
+ frappe.cache.delete_key("bootinfo")
+ frappe.cache.delete_key("translation_assets")
+ frappe.cache.delete_key(USER_TRANSLATION_KEY)
+ frappe.cache.delete_key(MERGED_TRANSLATION_KEY)
def get_messages_for_app(app, deduplicate=True):
@@ -1273,9 +1271,9 @@ def get_all_languages(with_language_name: bool = False) -> list:
frappe.connect()
if with_language_name:
- return frappe.cache().get_value("languages_with_name", get_all_language_with_name)
+ return frappe.cache.get_value("languages_with_name", get_all_language_with_name)
else:
- return frappe.cache().get_value("languages", get_language_codes)
+ return frappe.cache.get_value("languages", get_language_codes)
@frappe.whitelist(allow_guest=True)
diff --git a/frappe/translations/de.csv b/frappe/translations/de.csv
index 1876b80720..d1f503be7c 100644
--- a/frappe/translations/de.csv
+++ b/frappe/translations/de.csv
@@ -3920,7 +3920,7 @@ Javascript,Javascript,
Ldap settings,LDAP Einstellungen,
Mobile number,Handynummer,
Mx,Mx,
-No,Kein,
+No,Nein,
Not found,Nicht gefunden,
Notes:,Anmerkungen:,
Notify by email,Per E-Mail benachrichtigen,
diff --git a/frappe/twofactor.py b/frappe/twofactor.py
index c4292b0533..65f94cae90 100644
--- a/frappe/twofactor.py
+++ b/frappe/twofactor.py
@@ -74,8 +74,8 @@ def get_cached_user_pass():
user = pwd = None
tmp_id = frappe.form_dict.get("tmp_id")
if tmp_id:
- user = frappe.safe_decode(frappe.cache().get(tmp_id + "_usr"))
- pwd = frappe.safe_decode(frappe.cache().get(tmp_id + "_pwd"))
+ user = frappe.safe_decode(frappe.cache.get(tmp_id + "_usr"))
+ pwd = frappe.safe_decode(frappe.cache.get(tmp_id + "_pwd"))
return (user, pwd)
@@ -101,13 +101,13 @@ def cache_2fa_data(user, token, otp_secret, tmp_id):
# set increased expiry time for SMS and Email
if verification_method in ["SMS", "Email"]:
expiry_time = frappe.flags.token_expiry or 300
- frappe.cache().set(tmp_id + "_token", token)
- frappe.cache().expire(tmp_id + "_token", expiry_time)
+ frappe.cache.set(tmp_id + "_token", token)
+ frappe.cache.expire(tmp_id + "_token", expiry_time)
else:
expiry_time = frappe.flags.otp_expiry or 180
for k, v in {"_usr": user, "_pwd": pwd, "_otp_secret": otp_secret}.items():
- frappe.cache().set(f"{tmp_id}{k}", v)
- frappe.cache().expire(f"{tmp_id}{k}", expiry_time)
+ frappe.cache.set(f"{tmp_id}{k}", v)
+ frappe.cache.expire(f"{tmp_id}{k}", expiry_time)
def two_factor_is_enabled_for_(user):
@@ -160,8 +160,8 @@ def confirm_otp_token(login_manager, otp=None, tmp_id=None):
return True
if not tmp_id:
tmp_id = frappe.form_dict.get("tmp_id")
- hotp_token = frappe.cache().get(tmp_id + "_token")
- otp_secret = frappe.cache().get(tmp_id + "_otp_secret")
+ hotp_token = frappe.cache.get(tmp_id + "_token")
+ otp_secret = frappe.cache.get(tmp_id + "_otp_secret")
if not otp_secret:
raise ExpiredLoginException(_("Login session expired, refresh page to retry"))
@@ -170,7 +170,7 @@ def confirm_otp_token(login_manager, otp=None, tmp_id=None):
hotp = pyotp.HOTP(otp_secret)
if hotp_token:
if hotp.verify(otp, int(hotp_token)):
- frappe.cache().delete(tmp_id + "_token")
+ frappe.cache.delete(tmp_id + "_token")
tracker.add_success_attempt()
return True
else:
@@ -308,8 +308,8 @@ def get_link_for_qrcode(user, totp_uri):
key_user = f"{key}_user"
key_uri = f"{key}_uri"
lifespan = int(frappe.db.get_single_value("System Settings", "lifespan_qrcode_image")) or 240
- frappe.cache().set_value(key_uri, totp_uri, expires_in_sec=lifespan)
- frappe.cache().set_value(key_user, user, expires_in_sec=lifespan)
+ frappe.cache.set_value(key_uri, totp_uri, expires_in_sec=lifespan)
+ frappe.cache.set_value(key_user, user, expires_in_sec=lifespan)
return get_url(f"/qrcode?k={key}")
diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py
index ef32ff5653..d6b8186a2f 100644
--- a/frappe/utils/__init__.py
+++ b/frappe/utils/__init__.py
@@ -9,6 +9,7 @@ import os
import re
import sys
import traceback
+from collections import deque
from collections.abc import (
Container,
Generator,
@@ -20,7 +21,7 @@ from collections.abc import (
from email.header import decode_header, make_header
from email.utils import formataddr, parseaddr
from gzip import GzipFile
-from typing import Any, Literal
+from typing import Any, Callable, Literal
from urllib.parse import quote, urlparse
from redis.exceptions import ConnectionError
@@ -969,7 +970,7 @@ def get_assets_json():
if not hasattr(frappe.local, "assets_json"):
if not frappe.conf.developer_mode:
- frappe.local.assets_json = frappe.cache().get_value(
+ frappe.local.assets_json = frappe.cache.get_value(
"assets_json",
_get_assets,
shared=True,
@@ -1092,3 +1093,42 @@ def is_git_url(url: str) -> bool:
# modified to allow without the tailing .git from https://github.com/jonschlinkert/is-git-url.git
pattern = r"(?:git|ssh|https?|\w*@[-\w.]+):(\/\/)?(.*?)(\.git)?(\/?|\#[-\d\w._]+?)$"
return bool(re.match(pattern, url))
+
+
+class CallbackManager:
+ """Manage callbacks.
+
+ ```
+ # Capture callacks
+ callbacks = CallbackManager()
+
+ # Put a function call in queue
+ callbacks.add(func)
+
+ # Run all pending functions in queue
+ callbacks.run()
+
+ # Reset queue
+ callbacks.reset()
+ ```
+
+ Example usage: frappe.db.after_commit
+ """
+
+ __slots__ = ("_functions",)
+
+ def __init__(self) -> None:
+ self._functions = deque()
+
+ def add(self, func: Callable) -> None:
+ """Add a function to queue, functions are executed in order of addition."""
+ self._functions.append(func)
+
+ def run(self):
+ """Run all functions in queue"""
+ while self._functions:
+ _func = self._functions.popleft()
+ _func()
+
+ def reset(self):
+ self._functions.clear()
diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py
index 0fbc9e15ec..6a203f8dc7 100755
--- a/frappe/utils/background_jobs.py
+++ b/frappe/utils/background_jobs.py
@@ -3,13 +3,14 @@ import socket
import time
from collections import defaultdict
from functools import lru_cache
-from typing import TYPE_CHECKING, Any, Literal, NoReturn, Union
+from typing import Any, Callable, Literal, NoReturn
from uuid import uuid4
import redis
from redis.exceptions import BusyLoadingError, ConnectionError
from rq import Connection, Queue, Worker
from rq.exceptions import NoSuchJobError
+from rq.job import Job, JobStatus
from rq.logutils import setup_loghandlers
from rq.worker import RandomWorker, RoundRobinWorker
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
@@ -22,10 +23,6 @@ from frappe.utils.commands import log
from frappe.utils.deprecations import deprecation_warning
from frappe.utils.redis_queue import RedisQueue
-if TYPE_CHECKING:
- from rq.job import Job
-
-
# TTL to keep RQ job logs in redis for.
RQ_JOB_FAILURE_TTL = 7 * 24 * 60 * 60 # 7 days instead of 1 year (default)
RQ_RESULTS_TTL = 10 * 60
@@ -54,21 +51,21 @@ redis_connection = None
def enqueue(
- method,
- queue="default",
- timeout=None,
- on_success=None,
- on_failure=None,
+ method: str | Callable,
+ queue: str = "default",
+ timeout: int | None = None,
event=None,
- is_async=True,
- job_name=None,
- now=False,
- enqueue_after_commit=False,
+ is_async: bool = True,
+ job_name: str | None = None,
+ now: bool = False,
+ enqueue_after_commit: bool = False,
*,
- at_front=False,
- job_id=None,
+ on_success: Callable = None,
+ on_failure: Callable = None,
+ at_front: bool = False,
+ job_id: str = None,
**kwargs,
-) -> Union["Job", Any]:
+) -> Job | Any:
"""
Enqueue method to be executed using a background worker
@@ -113,6 +110,7 @@ def enqueue(
if not timeout:
timeout = get_queues_timeout().get(queue) or 300
+
queue_args = {
"site": frappe.local.site,
"user": frappe.session.user,
@@ -122,32 +120,25 @@ def enqueue(
"is_async": is_async,
"kwargs": kwargs,
}
- if enqueue_after_commit:
- if not frappe.flags.enqueue_after_commit:
- frappe.flags.enqueue_after_commit = []
- frappe.flags.enqueue_after_commit.append(
- {
- "queue": queue,
- "is_async": is_async,
- "timeout": timeout,
- "queue_args": queue_args,
- "job_id": job_id,
- }
+ def enqueue_call():
+ return q.enqueue_call(
+ execute_job,
+ on_success=on_success,
+ on_failure=on_failure,
+ timeout=timeout,
+ kwargs=queue_args,
+ at_front=at_front,
+ failure_ttl=frappe.conf.get("rq_job_failure_ttl") or RQ_JOB_FAILURE_TTL,
+ result_ttl=frappe.conf.get("rq_results_ttl") or RQ_RESULTS_TTL,
+ job_id=job_id,
)
- return frappe.flags.enqueue_after_commit
- return q.enqueue_call(
- execute_job,
- on_success=on_success,
- on_failure=on_failure,
- timeout=timeout,
- kwargs=queue_args,
- at_front=at_front,
- failure_ttl=frappe.conf.get("rq_job_failure_ttl") or RQ_JOB_FAILURE_TTL,
- result_ttl=frappe.conf.get("rq_results_ttl") or RQ_RESULTS_TTL,
- job_id=job_id,
- )
+ if enqueue_after_commit:
+ frappe.db.after_commit.add(enqueue_call)
+ return
+
+ return enqueue_call()
def enqueue_doc(
@@ -437,12 +428,15 @@ def create_job_id(job_id: str) -> str:
return f"{frappe.local.site}::{job_id}"
-def is_job_enqueued(job_id: str) -> str:
- from rq.job import Job
+def is_job_enqueued(job_id: str) -> bool:
+ return get_job_status(job_id) in (JobStatus.QUEUED, JobStatus.STARTED)
+
+def get_job_status(job_id: str) -> JobStatus | None:
+ """Get RQ job status, returns None if job is not found."""
try:
job = Job.fetch(create_job_id(job_id), connection=get_redis_conn())
except NoSuchJobError:
- return False
+ return None
- return job.get_status() in ("queued", "started")
+ return job.get_status()
diff --git a/frappe/utils/boilerplate.py b/frappe/utils/boilerplate.py
index 2e8a5088ed..0d786972fb 100644
--- a/frappe/utils/boilerplate.py
+++ b/frappe/utils/boilerplate.py
@@ -138,7 +138,8 @@ def _create_app_boilerplate(dest, hooks, no_git=False):
with open(os.path.join(dest, hooks.app_name, hooks.app_name, "hooks.py"), "w") as f:
f.write(frappe.as_unicode(hooks_template.format(**hooks)))
- touch_file(os.path.join(dest, hooks.app_name, hooks.app_name, "patches.txt"))
+ with open(os.path.join(dest, hooks.app_name, hooks.app_name, "patches.txt"), "w") as f:
+ f.write(frappe.as_unicode(patches_template.format(**hooks)))
app_directory = os.path.join(dest, hooks.app_name)
@@ -631,3 +632,10 @@ jobs:
env:
TYPE: server
"""
+
+patches_template = """[pre_model_sync]
+# Patches added in this section will be executed before doctypes are migrated
+# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations
+
+[post_model_sync]
+# Patches added in this section will be executed after doctypes are migrated"""
diff --git a/frappe/utils/caching.py b/frappe/utils/caching.py
index 007582f25f..fbfbddbd88 100644
--- a/frappe/utils/caching.py
+++ b/frappe/utils/caching.py
@@ -143,20 +143,20 @@ def redis_cache(ttl: int | None = 3600, user: str | bool | None = None) -> Calla
func_key = f"{func.__module__}.{func.__qualname__}"
def clear_cache():
- frappe.cache().delete_keys(func_key)
+ frappe.cache.delete_keys(func_key)
func.clear_cache = clear_cache
func.ttl = ttl if not callable(ttl) else 3600
@wraps(func)
def redis_cache_wrapper(*args, **kwargs):
- func_call_key = func_key + str(__generate_request_cache_key(args, kwargs))
- if frappe.cache().exists(func_call_key):
- return frappe.cache().get_value(func_call_key, user=user)
+ func_call_key = func_key + "::" + str(__generate_request_cache_key(args, kwargs))
+ if frappe.cache.exists(func_call_key):
+ return frappe.cache.get_value(func_call_key, user=user)
else:
val = func(*args, **kwargs)
ttl = getattr(func, "ttl", 3600)
- frappe.cache().set_value(func_call_key, val, expires_in_sec=ttl, user=user)
+ frappe.cache.set_value(func_call_key, val, expires_in_sec=ttl, user=user)
return val
return redis_cache_wrapper
diff --git a/frappe/utils/change_log.py b/frappe/utils/change_log.py
index a4b56686c2..586024f931 100644
--- a/frappe/utils/change_log.py
+++ b/frappe/utils/change_log.py
@@ -267,19 +267,17 @@ def check_release_on_github(app: str):
def add_message_to_redis(update_json):
# "update-message" will store the update message string
# "update-user-set" will be a set of users
- cache = frappe.cache()
- cache.set_value("update-info", json.dumps(update_json))
+ frappe.cache.set_value("update-info", json.dumps(update_json))
user_list = [x.name for x in frappe.get_all("User", filters={"enabled": True})]
system_managers = [user for user in user_list if "System Manager" in frappe.get_roles(user)]
- cache.sadd("update-user-set", *system_managers)
+ frappe.cache.sadd("update-user-set", *system_managers)
@frappe.whitelist()
def show_update_popup():
- cache = frappe.cache()
user = frappe.session.user
- update_info = cache.get_value("update-info")
+ update_info = frappe.cache.get_value("update-info")
if not update_info:
return
@@ -287,7 +285,7 @@ def show_update_popup():
# Check if user is int the set of users to send update message to
update_message = ""
- if cache.sismember("update-user-set", user):
+ if frappe.cache.sismember("update-user-set", user):
for update_type in updates:
release_links = ""
for app in updates[update_type]:
@@ -308,4 +306,4 @@ def show_update_popup():
if update_message:
frappe.msgprint(update_message, title=_("New updates are available"), indicator="green")
- cache.srem("update-user-set", user)
+ frappe.cache.srem("update-user-set", user)
diff --git a/frappe/utils/dashboard.py b/frappe/utils/dashboard.py
index 980107ce2b..9066f3172c 100644
--- a/frappe/utils/dashboard.py
+++ b/frappe/utils/dashboard.py
@@ -25,7 +25,7 @@ def cache_source(function):
if int(kwargs.get("refresh") or 0):
results = generate_and_cache_results(kwargs, function, cache_key, chart)
else:
- cached_results = frappe.cache().get_value(cache_key)
+ cached_results = frappe.cache.get_value(cache_key)
if cached_results:
results = frappe.parse_json(frappe.safe_decode(cached_results))
else:
diff --git a/frappe/utils/data.py b/frappe/utils/data.py
index a51cdee04a..deb5ea486f 100644
--- a/frappe/utils/data.py
+++ b/frappe/utils/data.py
@@ -326,7 +326,7 @@ def get_system_timezone():
if frappe.local.flags.in_test:
return _get_system_timezone()
- return frappe.cache().get_value("time_zone", _get_system_timezone)
+ return frappe.cache.get_value("time_zone", _get_system_timezone)
def convert_utc_to_timezone(utc_timestamp, time_zone):
@@ -1766,6 +1766,7 @@ def get_filter(doctype: str, f: dict | list | tuple, filters_config=None) -> "fr
"fieldtype":
}
"""
+ from frappe.database.utils import NestedSetHierarchy
from frappe.model import child_table_fields, default_fields, optional_fields
if isinstance(f, dict):
@@ -1805,14 +1806,10 @@ def get_filter(doctype: str, f: dict | list | tuple, filters_config=None) -> "fr
"not in",
"is",
"between",
- "descendants of",
- "ancestors of",
- "not descendants of",
- "not ancestors of",
"timespan",
"previous",
"next",
- )
+ ) + NestedSetHierarchy
if filters_config:
additional_operators = []
diff --git a/frappe/utils/error.py b/frappe/utils/error.py
index 2c450750e1..47c5b055a8 100644
--- a/frappe/utils/error.py
+++ b/frappe/utils/error.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Maxwell Morais and contributors
# License: MIT. See LICENSE
-import cgitb
import datetime
import functools
import inspect
@@ -103,7 +102,7 @@ def get_snapshot(exception, context=10):
finally:
lnum[0] += 1
- vars = cgitb.scanvars(reader, frame, locals)
+ vars = _scanvars(reader, frame, locals)
# if it is a view, replace with generated code
# if file.endswith('html'):
@@ -123,7 +122,7 @@ def get_snapshot(exception, context=10):
for name, where, value in vars:
if name in f["dump"]:
continue
- if value is not cgitb.__UNDEF__:
+ if value is not __UNDEF__:
if where == "global":
name = f"global {name:s}"
elif where != "local":
@@ -257,3 +256,56 @@ def raise_error_on_no_output(error_message, error_type=None, keep_quiet=None):
return wrapper_raise_error_on_no_output
return decorator_raise_error_on_no_output
+
+
+# Vendored from cgitb standard library reused under PSF License:
+# https://github.com/python/cpython/blob/main/LICENSE
+
+
+import keyword
+import tokenize
+
+__UNDEF__ = [] # a special sentinel object
+
+
+def _scanvars(reader, frame, locals):
+ """Scan one logical line of Python and look up values of variables used."""
+ vars, lasttoken, parent, prefix, value = [], None, None, "", __UNDEF__
+ for ttype, token, start, end, line in tokenize.generate_tokens(reader):
+ if ttype == tokenize.NEWLINE:
+ break
+ if ttype == tokenize.NAME and token not in keyword.kwlist:
+ if lasttoken == ".":
+ if parent is not __UNDEF__:
+ value = getattr(parent, token, __UNDEF__)
+ vars.append((prefix + token, prefix, value))
+ else:
+ where, value = _lookup(token, frame, locals)
+ vars.append((token, where, value))
+ elif token == ".":
+ prefix += lasttoken + "."
+ parent = value
+ else:
+ parent, prefix = None, ""
+ lasttoken = token
+ return vars
+
+
+def _lookup(name, frame, locals):
+ """Find the value for a given name in the given environment."""
+ if name in locals:
+ return "local", locals[name]
+ if name in frame.f_globals:
+ return "global", frame.f_globals[name]
+ if "__builtins__" in frame.f_globals:
+ builtins = frame.f_globals["__builtins__"]
+ if type(builtins) is type({}): # noqa
+ if name in builtins:
+ return "builtin", builtins[name]
+ else:
+ if hasattr(builtins, name):
+ return "builtin", getattr(builtins, name)
+ return None, __UNDEF__
+
+
+# end: vendored code
diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py
index 5e5c1da141..af01692b94 100644
--- a/frappe/utils/global_search.py
+++ b/frappe/utils/global_search.py
@@ -61,7 +61,7 @@ def get_doctypes_with_global_search(with_child_tables=True):
return doctypes
- return frappe.cache().get_value("doctypes_with_global_search", _get)
+ return frappe.cache.get_value("doctypes_with_global_search", _get)
def rebuild_for_doctype(doctype):
@@ -371,17 +371,17 @@ def sync_global_search():
:param flags:
:return:
"""
- while frappe.cache().llen("global_search_queue") > 0:
+ while frappe.cache.llen("global_search_queue") > 0:
# rpop to follow FIFO
# Last one should override all previous contents of same document
- value = json.loads(frappe.cache().rpop("global_search_queue").decode("utf-8"))
+ value = json.loads(frappe.cache.rpop("global_search_queue").decode("utf-8"))
sync_value(value)
def sync_value_in_queue(value):
try:
# append to search queue if connected
- frappe.cache().lpush("global_search_queue", json.dumps(value))
+ frappe.cache.lpush("global_search_queue", json.dumps(value))
except redis.exceptions.ConnectionError:
# not connected, sync directly
sync_value(value)
diff --git a/frappe/utils/goal.py b/frappe/utils/goal.py
index 709fdc1644..01cd9d835e 100644
--- a/frappe/utils/goal.py
+++ b/frappe/utils/goal.py
@@ -31,6 +31,7 @@ def get_monthly_results(
Function(aggregation, goal_field),
],
filters=filters,
+ validate_filters=True,
)
.groupby("month_year")
.run()
diff --git a/frappe/utils/logger.py b/frappe/utils/logger.py
index ddb81f3d79..8976130a7c 100755
--- a/frappe/utils/logger.py
+++ b/frappe/utils/logger.py
@@ -1,6 +1,8 @@
# imports - standard imports
import logging
import os
+import sys
+from contextlib import contextmanager
from copy import deepcopy
from logging.handlers import RotatingFileHandler
from typing import Literal
@@ -123,3 +125,29 @@ def sanitized_dict(form_dict):
if secret_kw in k:
sanitized_dict[k] = "********"
return sanitized_dict
+
+
+@contextmanager
+def pipe_to_log(logger_fn, stream=None):
+ "Pass an existing logger function e.g. logger.info. Stream defaults to stdout"
+ # late bind source
+ if stream is None:
+ stream = sys.stdout
+
+ stream_int = stream.fileno()
+ r_int, w_int = os.pipe()
+
+ # copy stream_fd before it is overwritten
+ with os.fdopen(os.dup(stream_int), "wb") as copied:
+ stream.flush()
+ os.dup2(w_int, stream_int) # $ exec >&pipe
+ try:
+ with os.fdopen(w_int, "wb"):
+ yield stream
+ finally:
+ # restore stream to its previous value
+ stream.flush()
+ os.dup2(copied.fileno(), stream_int) # $ exec >&copied
+ with os.fdopen(r_int, newline="") as r:
+ text = r.read()
+ logger_fn(text)
diff --git a/frappe/utils/oauth.py b/frappe/utils/oauth.py
index d07011afd1..6a2b87d29f 100644
--- a/frappe/utils/oauth.py
+++ b/frappe/utils/oauth.py
@@ -210,7 +210,7 @@ def login_oauth_user(
if frappe.utils.cint(generate_login_token):
login_token = frappe.generate_hash(length=32)
- frappe.cache().set_value(
+ frappe.cache.set_value(
f"login_token:{login_token}", frappe.local.session.sid, expires_in_sec=120
)
diff --git a/frappe/utils/password.py b/frappe/utils/password.py
index fa2e03bde5..2bd477216d 100644
--- a/frappe/utils/password.py
+++ b/frappe/utils/password.py
@@ -128,9 +128,9 @@ def check_password(user, pwd, doctype="User", fieldname="password", delete_track
def delete_login_failed_cache(user):
- frappe.cache().hdel("last_login_tried", user)
- frappe.cache().hdel("login_failed_count", user)
- frappe.cache().hdel("locked_account_time", user)
+ frappe.cache.hdel("last_login_tried", user)
+ frappe.cache.hdel("login_failed_count", user)
+ frappe.cache.hdel("locked_account_time", user)
def update_password(user, pwd, doctype="User", fieldname="password", logout_all_sessions=False):
diff --git a/frappe/utils/pdf.py b/frappe/utils/pdf.py
index 0c273854f7..9b7c9a6ce4 100644
--- a/frappe/utils/pdf.py
+++ b/frappe/utils/pdf.py
@@ -14,6 +14,7 @@ import frappe
from frappe import _
from frappe.utils import scrub_urls
from frappe.utils.jinja_globals import bundled_asset, is_rtl
+from frappe.utils.logger import pipe_to_log
PDF_CONTENT_ERRORS = [
"ContentNotFoundError",
@@ -22,6 +23,9 @@ PDF_CONTENT_ERRORS = [
"RemoteHostClosedError",
]
+logger = frappe.logger("wkhtmltopdf", max_size=100000, file_count=3)
+logger.setLevel("INFO")
+
def pdf_header_html(soup, head, content, styles, html_id, css):
return frappe.render_template(
@@ -59,8 +63,13 @@ def get_pdf(html, options=None, output: PdfWriter | None = None):
options.update({"disable-smart-shrinking": ""})
try:
+ # wkhtmltopdf writes the pdf to stdout and errors to stderr
+ # pdfkit v1.0.0 writes the pdf to file or returns it
+ # stderr is written to sys.stdout if verbose=True is supplied
# Set filename property to false, so no file is actually created
- filedata = pdfkit.from_string(html, options=options or {}, verbose=True)
+ # defaults to redirecting stdout
+ with pipe_to_log(logger.info):
+ filedata = pdfkit.from_string(html, False, options=options or {}, verbose=True)
# create in-memory binary streams from filedata and create a PdfReader object
reader = PdfReader(io.BytesIO(filedata))
@@ -118,7 +127,6 @@ def prepare_options(html, options):
"print-media-type": None,
"background": None,
"images": None,
- "quiet": None,
# 'no-outline': None,
"encoding": "UTF-8",
# 'load-error-handling': 'ignore'
@@ -265,13 +273,13 @@ def toggle_visible_pdf(soup):
def get_wkhtmltopdf_version():
- wkhtmltopdf_version = frappe.cache().hget("wkhtmltopdf_version", None)
+ wkhtmltopdf_version = frappe.cache.hget("wkhtmltopdf_version", None)
if not wkhtmltopdf_version:
try:
res = subprocess.check_output(["wkhtmltopdf", "--version"])
wkhtmltopdf_version = res.decode("utf-8").split(" ")[1]
- frappe.cache().hset("wkhtmltopdf_version", None, wkhtmltopdf_version)
+ frappe.cache.hset("wkhtmltopdf_version", None, wkhtmltopdf_version)
except Exception:
pass
diff --git a/frappe/utils/redis_wrapper.py b/frappe/utils/redis_wrapper.py
index 3b335b2c1d..45be0c63e8 100644
--- a/frappe/utils/redis_wrapper.py
+++ b/frappe/utils/redis_wrapper.py
@@ -34,6 +34,10 @@ class RedisWrapper(redis.Redis):
except redis.exceptions.ConnectionError:
return False
+ def __call__(self):
+ """WARNING: Added for backward compatibility to support frappe.cache().method(...)"""
+ return self
+
def make_key(self, key, user=None, shared=False):
if shared:
return key
@@ -127,20 +131,22 @@ class RedisWrapper(redis.Redis):
def delete_value(self, keys, user=None, make_keys=True, shared=False):
"""Delete value, list of values."""
+ if not keys:
+ return
+
if not isinstance(keys, (list, tuple)):
keys = (keys,)
+ if make_keys:
+ keys = [self.make_key(k, shared=shared, user=user) for k in keys]
+
for key in keys:
- if make_keys:
- key = self.make_key(key, shared=shared)
+ frappe.local.cache.pop(key, None)
- if key in frappe.local.cache:
- del frappe.local.cache[key]
-
- try:
- self.delete(key)
- except redis.exceptions.ConnectionError:
- pass
+ try:
+ self.delete(*keys)
+ except redis.exceptions.ConnectionError:
+ pass
def lpush(self, key, value):
super().lpush(self.make_key(key), value)
@@ -197,7 +203,11 @@ class RedisWrapper(redis.Redis):
def exists(self, *names: str, user=None, shared=None) -> int:
names = [self.make_key(n, user=user, shared=shared) for n in names]
- return super().exists(*names)
+
+ try:
+ return super().exists(*names)
+ except redis.exceptions.ConnectionError:
+ return False
def hgetall(self, name):
value = super().hgetall(self.make_key(name))
@@ -241,7 +251,7 @@ class RedisWrapper(redis.Redis):
def hdel_keys(self, name_starts_with, key):
"""Delete hash names with wildcard `*` and key"""
- for name in frappe.cache().get_keys(name_starts_with):
+ for name in self.get_keys(name_starts_with):
name = name.split("|", 1)[1]
self.hdel(name, key)
diff --git a/frappe/utils/telemetry.py b/frappe/utils/telemetry.py
index b5bc13dd57..e15146c71d 100644
--- a/frappe/utils/telemetry.py
+++ b/frappe/utils/telemetry.py
@@ -8,6 +8,8 @@ from contextlib import suppress
from posthog import Posthog
import frappe
+from frappe.utils import getdate
+from frappe.utils.caching import site_cache
POSTHOG_PROJECT_FIELD = "posthog_project_id"
POSTHOG_HOST_FIELD = "posthog_host"
@@ -20,6 +22,16 @@ def add_bootinfo(bootinfo):
bootinfo.posthog_host = frappe.conf.get(POSTHOG_HOST_FIELD)
bootinfo.posthog_project_id = frappe.conf.get(POSTHOG_PROJECT_FIELD)
bootinfo.enable_telemetry = True
+ bootinfo.telemetry_site_age = site_age()
+
+
+@site_cache(ttl=60 * 60 * 12)
+def site_age():
+ try:
+ est_creation = frappe.db.get_value("User", "Administrator", "creation")
+ return (getdate() - getdate(est_creation)).days
+ except Exception:
+ pass
def init_telemetry():
@@ -45,3 +57,13 @@ def capture(event, app, **kwargs):
ph: Posthog = getattr(frappe.local, "posthog", None)
with suppress(Exception):
ph and ph.capture(distinct_id=frappe.local.site, event=f"{app}_{event}", **kwargs)
+
+
+def capture_doc(doc):
+ with suppress(Exception):
+ age = site_age()
+ if not age or age > 15:
+ return
+
+ if doc.get("__islocal") or not doc.get("name"):
+ capture("document_created", "frappe", properties={"doctype": doc.doctype})
diff --git a/frappe/utils/user.py b/frappe/utils/user.py
index 8dcb2b7ca3..35dec3aa60 100644
--- a/frappe/utils/user.py
+++ b/frappe/utils/user.py
@@ -59,7 +59,7 @@ class UserPermissions:
return user
if not frappe.flags.in_install_db and not frappe.flags.in_test:
- user_doc = frappe.cache().hget("user_doc", self.name, get_user_doc)
+ user_doc = frappe.cache.hget("user_doc", self.name, get_user_doc)
if user_doc:
self.doc = frappe.get_doc(user_doc)
@@ -186,7 +186,7 @@ class UserPermissions:
filters={"property": "allow_import", "value": "1"},
)
- frappe.cache().hset("can_import", frappe.session.user, self.can_import)
+ frappe.cache.hset("can_import", frappe.session.user, self.can_import)
def get_defaults(self):
import frappe.defaults
@@ -221,6 +221,7 @@ class UserPermissions:
"mute_sounds",
"send_me_a_copy",
"user_type",
+ "onboarding_status",
],
as_dict=True,
)
@@ -229,6 +230,7 @@ class UserPermissions:
self.build_permissions()
d.name = self.name
+ d.onboarding_status = frappe.parse_json(d.onboarding_status)
d.roles = self.get_roles()
d.defaults = self.get_defaults()
for key in (
diff --git a/frappe/website/doctype/discussion_topic/discussion_topic.py b/frappe/website/doctype/discussion_topic/discussion_topic.py
index 7eb661cd02..ddc4933548 100644
--- a/frappe/website/doctype/discussion_topic/discussion_topic.py
+++ b/frappe/website/doctype/discussion_topic/discussion_topic.py
@@ -39,10 +39,3 @@ def save_message(reply, topic):
frappe.get_doc({"doctype": "Discussion Reply", "reply": reply, "topic": topic}).save(
ignore_permissions=True
)
-
-
-@frappe.whitelist(allow_guest=True)
-def get_docname(route):
- if not route:
- route = frappe.db.get_single_value("Website Settings", "home_page")
- return frappe.db.get_value("Web Page", {"route": route}, ["name"])
diff --git a/frappe/website/doctype/help_article/help_article.py b/frappe/website/doctype/help_article/help_article.py
index e70de07703..108f3cb615 100644
--- a/frappe/website/doctype/help_article/help_article.py
+++ b/frappe/website/doctype/help_article/help_article.py
@@ -93,7 +93,7 @@ def get_sidebar_items():
as_dict=True,
)
- return frappe.cache().get_value("knowledge_base:category_sidebar", _get)
+ return frappe.cache.get_value("knowledge_base:category_sidebar", _get)
def clear_cache():
@@ -105,8 +105,8 @@ def clear_cache():
def clear_website_cache(path=None):
- frappe.cache().delete_value("knowledge_base:category_sidebar")
- frappe.cache().delete_value("knowledge_base:faq")
+ frappe.cache.delete_value("knowledge_base:category_sidebar")
+ frappe.cache.delete_value("knowledge_base:faq")
@frappe.whitelist(allow_guest=True)
diff --git a/frappe/website/doctype/web_form/web_form.json b/frappe/website/doctype/web_form/web_form.json
index 96749e460d..e0883ba439 100644
--- a/frappe/website/doctype/web_form/web_form.json
+++ b/frappe/website/doctype/web_form/web_form.json
@@ -31,6 +31,10 @@
"allow_incomplete",
"section_break_2",
"max_attachment_size",
+ "section_break_xzqr",
+ "condition",
+ "column_break_tjgl",
+ "condition_description",
"section_break_3",
"list_setting_message",
"show_list",
@@ -279,10 +283,6 @@
"fieldtype": "Tab Break",
"label": "Form"
},
- {
- "fieldname": "column_break_1",
- "fieldtype": "Column Break"
- },
{
"fieldname": "section_break_1",
"fieldtype": "Section Break"
@@ -297,7 +297,6 @@
"fieldtype": "Column Break"
},
{
- "collapsible": 1,
"fieldname": "section_break_2",
"fieldtype": "Section Break"
},
@@ -374,13 +373,33 @@
"fieldname": "anonymous",
"fieldtype": "Check",
"label": "Anonymous"
+ },
+ {
+ "fieldname": "condition",
+ "fieldtype": "Code",
+ "label": "Condition",
+ "max_height": "150px"
+ },
+ {
+ "fieldname": "section_break_xzqr",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_tjgl",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "condition_description",
+ "fieldtype": "HTML",
+ "label": "Condition Description",
+ "options": "
Multiple webforms can be created for a single doctype. Write a condition specific to this webform to display correct record after submission.
For Example:
\n
If you create a separate webform every year to capture feedback from employees add a \n field named year in doctype and add a condition doc.year==\"2023\"
\n"
}
],
"has_web_view": 1,
"icon": "icon-edit",
"is_published_field": "published",
"links": [],
- "modified": "2023-04-20 17:24:42.657731",
+ "modified": "2023-06-03 19:18:56.760479",
"modified_by": "Administrator",
"module": "Website",
"name": "Web Form",
diff --git a/frappe/website/doctype/web_form/web_form.py b/frappe/website/doctype/web_form/web_form.py
index 3e2705bdbe..fd9949c45f 100644
--- a/frappe/website/doctype/web_form/web_form.py
+++ b/frappe/website/doctype/web_form/web_form.py
@@ -12,6 +12,7 @@ from frappe.desk.form.meta import get_code_files_via_hooks
from frappe.modules.utils import export_module_json, get_doc_module
from frappe.rate_limiter import rate_limit
from frappe.utils import cstr, dict_with_keys, strip_html
+from frappe.utils.caching import redis_cache
from frappe.website.utils import get_boot_data, get_comment_list, get_sidebar_items
from frappe.website.website_generator import WebsiteGenerator
@@ -19,9 +20,6 @@ from frappe.website.website_generator import WebsiteGenerator
class WebForm(WebsiteGenerator):
website = frappe._dict(no_cache=1)
- def onload(self):
- super().onload()
-
def validate(self):
super().validate()
@@ -153,10 +151,16 @@ def get_context(context):
and not frappe.form_dict.name
and not frappe.form_dict.is_list
):
- name = frappe.db.get_value(self.doc_type, {"owner": frappe.session.user}, "name")
- if name:
- context.in_view_mode = True
- frappe.redirect(f"/{self.route}/{name}")
+ names = frappe.db.get_values(self.doc_type, {"owner": frappe.session.user}, pluck="name")
+ for name in names:
+ if self.condition:
+ doc = frappe.get_doc(self.doc_type, name)
+ if frappe.safe_eval(self.condition, None, {"doc": doc.as_dict()}):
+ context.in_view_mode = True
+ frappe.redirect(f"/{self.route}/{name}")
+ else:
+ context.in_view_mode = True
+ frappe.redirect(f"/{self.route}/{name}")
# Show new form when
# - User is Guest
@@ -633,3 +637,8 @@ def get_link_options(web_form_name, doctype, allow_read_on_all_link_options=Fals
raise frappe.PermissionError(
_("You don't have permission to access the {0} DocType.").format(doctype)
)
+
+
+@redis_cache(ttl=60 * 60)
+def get_published_web_forms() -> dict[str, str]:
+ return frappe.get_all("Web Form", ["name", "route", "modified"], {"published": 1})
diff --git a/frappe/website/doctype/web_page/web_page.py b/frappe/website/doctype/web_page/web_page.py
index 9a16654085..02e419001c 100644
--- a/frappe/website/doctype/web_page/web_page.py
+++ b/frappe/website/doctype/web_page/web_page.py
@@ -8,6 +8,7 @@ from jinja2.exceptions import TemplateSyntaxError
import frappe
from frappe import _
from frappe.utils import get_datetime, now, quoted, strip_html
+from frappe.utils.caching import redis_cache
from frappe.utils.jinja import render_template
from frappe.utils.safe_exec import safe_exec
from frappe.website.doctype.website_slideshow.website_slideshow import get_slideshow
@@ -30,12 +31,6 @@ class WebPage(WebsiteGenerator):
if not self.dynamic_route:
self.route = quoted(self.route)
- def on_update(self):
- super().on_update()
-
- def on_trash(self):
- super().on_trash()
-
def get_context(self, context):
context.main_section = get_html_content_based_on_type(self, "main_section", self.content_type)
context.source_content_type = self.content_type
@@ -247,3 +242,10 @@ def extract_script_and_style_tags(html):
style.extract()
return str(soup), scripts, styles
+
+
+@redis_cache(ttl=60 * 60)
+def get_dynamic_web_pages() -> dict[str, str]:
+ return frappe.get_all(
+ "Web Page", fields=["name", "route", "modified"], filters=dict(published=1, dynamic_route=1)
+ )
diff --git a/frappe/website/doctype/web_page_view/web_page_view.py b/frappe/website/doctype/web_page_view/web_page_view.py
index bbf2a394a6..b284dc095c 100644
--- a/frappe/website/doctype/web_page_view/web_page_view.py
+++ b/frappe/website/doctype/web_page_view/web_page_view.py
@@ -9,7 +9,13 @@ from frappe.model.document import Document
class WebPageView(Document):
- pass
+ @staticmethod
+ def clear_old_logs(days=180):
+ from frappe.query_builder import Interval
+ from frappe.query_builder.functions import Now
+
+ table = frappe.qb.DocType("Web Page View")
+ frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days))))
@frappe.whitelist(allow_guest=True)
diff --git a/frappe/website/page_renderers/document_page.py b/frappe/website/page_renderers/document_page.py
index abfd72ac6f..54ee58ddb9 100644
--- a/frappe/website/page_renderers/document_page.py
+++ b/frappe/website/page_renderers/document_page.py
@@ -1,5 +1,6 @@
import frappe
from frappe.model.document import get_controller
+from frappe.utils.caching import redis_cache
from frappe.website.page_renderers.base_template_page import BaseTemplatePage
from frappe.website.router import (
get_doctypes_with_web_view,
@@ -22,22 +23,9 @@ class DocumentPage(BaseTemplatePage):
return False
def search_in_doctypes_with_web_view(self):
- for doctype in get_doctypes_with_web_view():
- filters = dict(route=self.path)
- meta = frappe.get_meta(doctype)
- condition_field = self.get_condition_field(meta)
-
- if condition_field:
- filters[condition_field] = 1
-
- try:
- self.docname = frappe.db.get_value(doctype, filters, "name")
- if self.docname:
- self.doctype = doctype
- return True
- except Exception as e:
- if not frappe.db.is_missing_column(e):
- raise e
+ if document := _find_matching_document_webview(self.path):
+ self.doctype, self.docname = document
+ return True
def search_web_page_dynamic_routes(self):
d = get_page_info_from_web_page_with_dynamic_routes(self.path)
@@ -83,7 +71,8 @@ class DocumentPage(BaseTemplatePage):
if prop not in self.context:
self.context[prop] = getattr(self.doc, prop, False)
- def get_condition_field(self, meta):
+ @staticmethod
+ def get_condition_field(meta):
condition_field = None
if meta.is_published_field:
condition_field = meta.is_published_field
@@ -92,3 +81,22 @@ class DocumentPage(BaseTemplatePage):
condition_field = controller.website.condition_field
return condition_field
+
+
+@redis_cache(ttl=60 * 60)
+def _find_matching_document_webview(route: str) -> tuple[str, str] | None:
+ for doctype in get_doctypes_with_web_view():
+ filters = dict(route=route)
+ meta = frappe.get_meta(doctype)
+ condition_field = DocumentPage.get_condition_field(meta)
+
+ if condition_field:
+ filters[condition_field] = 1
+
+ try:
+ docname = frappe.db.get_value(doctype, filters, "name")
+ if docname:
+ return (doctype, docname)
+ except Exception as e:
+ if not frappe.db.is_missing_column(e):
+ raise e
diff --git a/frappe/website/page_renderers/not_found_page.py b/frappe/website/page_renderers/not_found_page.py
index 98aeb19057..704dca77d1 100644
--- a/frappe/website/page_renderers/not_found_page.py
+++ b/frappe/website/page_renderers/not_found_page.py
@@ -21,7 +21,7 @@ class NotFoundPage(TemplatePage):
def render(self):
if self.can_cache_404():
- frappe.cache().hset("website_404", self.request_url, True)
+ frappe.cache.hset("website_404", self.request_url, True)
return super().render()
def can_cache_404(self):
diff --git a/frappe/website/path_resolver.py b/frappe/website/path_resolver.py
index c7874b1671..37bfb3ee56 100644
--- a/frappe/website/path_resolver.py
+++ b/frappe/website/path_resolver.py
@@ -29,7 +29,7 @@ class PathResolver:
request = frappe.local.request or request
# check if the request url is in 404 list
- if request.url and can_cache() and frappe.cache().hget("website_404", request.url):
+ if request.url and can_cache() and frappe.cache.hget("website_404", request.url):
return self.path, NotFoundPage(self.path)
try:
@@ -110,7 +110,7 @@ def resolve_redirect(path, query_string=None):
if not redirects:
return
- redirect_to = frappe.cache().hget("website_redirects", path)
+ redirect_to = frappe.cache.hget("website_redirects", path)
if redirect_to:
frappe.flags.redirect_location = redirect_to
@@ -130,7 +130,7 @@ def resolve_redirect(path, query_string=None):
if match:
redirect_to = re.sub(pattern, rule["target"], path_to_match)
frappe.flags.redirect_location = redirect_to
- frappe.cache().hset("website_redirects", path_to_match, redirect_to)
+ frappe.cache.hset("website_redirects", path_to_match, redirect_to)
raise frappe.Redirect
@@ -177,4 +177,4 @@ def get_website_rules():
# dont cache in development
return _get()
- return frappe.cache().get_value("website_route_rules", _get)
+ return frappe.cache.get_value("website_route_rules", _get)
diff --git a/frappe/website/router.py b/frappe/website/router.py
index 655fcc1357..14648f15e9 100644
--- a/frappe/website/router.py
+++ b/frappe/website/router.py
@@ -16,12 +16,11 @@ def get_page_info_from_web_page_with_dynamic_routes(path):
"""
Query Web Page with dynamic_route = 1 and evaluate if any of the routes match
"""
+ from frappe.website.doctype.web_page.web_page import get_dynamic_web_pages
+
rules, page_info = [], {}
- # build rules from all web page with `dynamic_route = 1`
- for d in frappe.get_all(
- "Web Page", fields=["name", "route", "modified"], filters=dict(published=1, dynamic_route=1)
- ):
+ for d in get_dynamic_web_pages():
rules.append(Rule("/" + d.route, endpoint=d.name))
d.doctype = "Web Page"
page_info[d.name] = d
@@ -33,9 +32,10 @@ def get_page_info_from_web_page_with_dynamic_routes(path):
def get_page_info_from_web_form(path):
"""Query published web forms and evaluate if the route matches"""
+ from frappe.website.doctype.web_form.web_form import get_published_web_forms
+
rules, page_info = [], {}
- web_forms = frappe.get_all("Web Form", ["name", "route", "modified"], {"published": 1})
- for d in web_forms:
+ for d in get_published_web_forms():
rules.append(Rule(f"/{d.route}", endpoint=d.name))
rules.append(Rule(f"/{d.route}/list", endpoint=d.name))
rules.append(Rule(f"/{d.route}/new", endpoint=d.name))
@@ -100,7 +100,7 @@ def get_pages(app=None):
return pages
- return frappe.cache().get_value("website_pages", lambda: _build(app))
+ return frappe.cache.get_value("website_pages", lambda: _build(app))
def get_pages_from_path(start, app, app_path):
@@ -310,8 +310,18 @@ def get_doctypes_with_web_view():
]
return doctypes
- return frappe.cache().get_value("doctypes_with_web_view", _get)
+ return frappe.cache.get_value("doctypes_with_web_view", _get)
def get_start_folders():
return frappe.local.flags.web_pages_folders or ("www", "templates/pages")
+
+
+def clear_routing_cache():
+ from frappe.website.doctype.web_form.web_form import get_published_web_forms
+ from frappe.website.doctype.web_page.web_page import get_dynamic_web_pages
+ from frappe.website.page_renderers.document_page import _find_matching_document_webview
+
+ _find_matching_document_webview.clear_cache()
+ get_dynamic_web_pages.clear_cache()
+ get_published_web_forms.clear_cache()
diff --git a/frappe/website/utils.py b/frappe/website/utils.py
index 71af463c96..922abbb751 100644
--- a/frappe/website/utils.py
+++ b/frappe/website/utils.py
@@ -23,15 +23,14 @@ CLEANUP_PATTERN_3 = re.compile(r"(-)\1+")
def delete_page_cache(path):
- cache = frappe.cache()
- cache.delete_value("full_index")
+ frappe.cache.delete_value("full_index")
groups = ("website_page", "page_context")
if path:
for name in groups:
- cache.hdel(name, path)
+ frappe.cache.hdel(name, path)
else:
for name in groups:
- cache.delete_key(name)
+ frappe.cache.delete_key(name)
def find_first_image(html):
@@ -127,7 +126,7 @@ def get_home_page():
# dont return cached homepage in development
return _get_home_page()
- return frappe.cache().hget("home_page", frappe.session.user, _get_home_page)
+ return frappe.cache.hget("home_page", frappe.session.user, _get_home_page)
def get_home_page_via_hooks():
@@ -296,7 +295,7 @@ def get_full_index(route=None, app=None):
return children_map
- children_map = frappe.cache().get_value("website_full_index", _build)
+ children_map = frappe.cache.get_value("website_full_index", _build)
frappe.local.flags.children_map = children_map
@@ -360,12 +359,16 @@ def get_html_content_based_on_type(doc, fieldname, content_type):
def clear_cache(path=None):
"""Clear website caches
:param path: (optional) for the given path"""
- for key in ("website_generator_routes", "website_pages", "website_full_index", "sitemap_routes"):
- frappe.cache().delete_value(key)
+ from frappe.website.router import clear_routing_cache
- frappe.cache().delete_value("website_404")
+ for key in ("website_generator_routes", "website_pages", "website_full_index", "sitemap_routes"):
+ frappe.cache.delete_value(key)
+
+ clear_routing_cache()
+
+ frappe.cache.delete_value("website_404")
if path:
- frappe.cache().hdel("website_redirects", path)
+ frappe.cache.hdel("website_redirects", path)
delete_page_cache(path)
else:
clear_sitemap()
@@ -379,7 +382,7 @@ def clear_cache(path=None):
"page_context",
"website_page",
):
- frappe.cache().delete_value(key)
+ frappe.cache.delete_value(key)
for method in frappe.get_hooks("website_clear_cache"):
frappe.get_attr(method)(path)
@@ -435,7 +438,7 @@ def get_sidebar_items(parent_sidebar, basepath=None):
def get_portal_sidebar_items():
- sidebar_items = frappe.cache().hget("portal_menu_items", frappe.session.user)
+ sidebar_items = frappe.cache.hget("portal_menu_items", frappe.session.user)
if sidebar_items is None:
sidebar_items = []
roles = frappe.get_roles()
@@ -458,7 +461,7 @@ def get_portal_sidebar_items():
i["enabled"] = 1
add_items(sidebar_items, items_via_hooks)
- frappe.cache().hset("portal_menu_items", frappe.session.user, sidebar_items)
+ frappe.cache.hset("portal_menu_items", frappe.session.user, sidebar_items)
return sidebar_items
@@ -503,7 +506,7 @@ def cache_html(func):
def cache_html_decorator(*args, **kwargs):
if can_cache():
html = None
- page_cache = frappe.cache().hget("website_page", args[0].path)
+ page_cache = frappe.cache.hget("website_page", args[0].path)
if page_cache and frappe.local.lang in page_cache:
html = page_cache[frappe.local.lang]
if html:
@@ -512,9 +515,9 @@ def cache_html(func):
html = func(*args, **kwargs)
context = args[0].context
if can_cache(context.no_cache):
- page_cache = frappe.cache().hget("website_page", args[0].path) or {}
+ page_cache = frappe.cache.hget("website_page", args[0].path) or {}
page_cache[frappe.local.lang] = html
- frappe.cache().hset("website_page", args[0].path, page_cache)
+ frappe.cache.hset("website_page", args[0].path, page_cache)
return html
diff --git a/frappe/website/web_template/section_with_cards/section_with_cards.json b/frappe/website/web_template/section_with_cards/section_with_cards.json
index c891119f97..5501147d89 100644
--- a/frappe/website/web_template/section_with_cards/section_with_cards.json
+++ b/frappe/website/web_template/section_with_cards/section_with_cards.json
@@ -49,7 +49,7 @@
},
{
"fieldname": "card_1_url",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"label": "URL",
"reqd": 0
},
@@ -79,7 +79,7 @@
},
{
"fieldname": "card_2_url",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"label": "URL",
"reqd": 0
},
@@ -109,7 +109,7 @@
},
{
"fieldname": "card_3_url",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"label": "URL",
"reqd": 0
},
@@ -139,7 +139,7 @@
},
{
"fieldname": "card_4_url",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"label": "URL",
"reqd": 0
},
@@ -169,7 +169,7 @@
},
{
"fieldname": "card_5_url",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"label": "URL",
"reqd": 0
},
@@ -199,7 +199,7 @@
},
{
"fieldname": "card_6_url",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"label": "URL",
"reqd": 0
},
@@ -229,7 +229,7 @@
},
{
"fieldname": "card_7_url",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"label": "URL",
"reqd": 0
},
@@ -259,7 +259,7 @@
},
{
"fieldname": "card_8_url",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"label": "URL",
"reqd": 0
},
@@ -289,13 +289,13 @@
},
{
"fieldname": "card_9_url",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"label": "URL",
"reqd": 0
}
],
"idx": 0,
- "modified": "2021-05-03 13:26:34.470232",
+ "modified": "2023-06-05 13:26:34.470232",
"modified_by": "Administrator",
"module": "Website",
"name": "Section with Cards",
diff --git a/frappe/website/web_template/section_with_features/section_with_features.json b/frappe/website/web_template/section_with_features/section_with_features.json
index a5734aa293..2683e92aae 100644
--- a/frappe/website/web_template/section_with_features/section_with_features.json
+++ b/frappe/website/web_template/section_with_features/section_with_features.json
@@ -43,7 +43,7 @@
},
{
"fieldname": "url",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"label": "URL",
"reqd": 0
},
@@ -55,7 +55,7 @@
}
],
"idx": 2,
- "modified": "2020-10-26 17:43:08.219285",
+ "modified": "2023-06-05 13:26:34.470232",
"modified_by": "Administrator",
"module": "Website",
"name": "Section with Features",
diff --git a/frappe/website/web_template/section_with_testimonials/section_with_testimonials.json b/frappe/website/web_template/section_with_testimonials/section_with_testimonials.json
index c1ba071be2..dd1d3bd0bd 100644
--- a/frappe/website/web_template/section_with_testimonials/section_with_testimonials.json
+++ b/frappe/website/web_template/section_with_testimonials/section_with_testimonials.json
@@ -56,13 +56,13 @@
},
{
"fieldname": "url",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"label": "URL",
"reqd": 0
}
],
"idx": 0,
- "modified": "2022-03-21 15:39:39.044104",
+ "modified": "2023-06-05 13:26:34.470232",
"modified_by": "Administrator",
"module": "Website",
"name": "Section with Testimonials",
diff --git a/frappe/website/workspace/website/website.json b/frappe/website/workspace/website/website.json
index a0d9a817d4..61911c0b6b 100644
--- a/frappe/website/workspace/website/website.json
+++ b/frappe/website/workspace/website/website.json
@@ -2,12 +2,14 @@
"charts": [],
"content": "[{\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Website\",\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Blog Post\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Blogger\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Web Page\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Web Form\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Website Settings\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Setup\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Blog\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Web Site\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Portal\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Knowledge Base\",\"col\":4}}]",
"creation": "2020-03-02 14:13:51.089373",
+ "custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "website",
"idx": 0,
+ "is_hidden": 0,
"label": "Website",
"links": [
{
@@ -232,16 +234,18 @@
"type": "Link"
}
],
- "modified": "2022-01-13 17:49:41.527194",
+ "modified": "2023-05-24 14:47:23.879036",
"modified_by": "Administrator",
"module": "Website",
"name": "Website",
+ "number_cards": [],
"owner": "Administrator",
"parent_page": "",
"public": 1,
+ "quick_lists": [],
"restrict_to_domain": "",
"roles": [],
- "sequence_id": 28.0,
+ "sequence_id": 14.0,
"shortcuts": [
{
"color": "Green",
diff --git a/frappe/workflow/doctype/workflow/test_workflow.py b/frappe/workflow/doctype/workflow/test_workflow.py
index b7740242c3..7da7f60109 100644
--- a/frappe/workflow/doctype/workflow/test_workflow.py
+++ b/frappe/workflow/doctype/workflow/test_workflow.py
@@ -33,7 +33,7 @@ class TestWorkflow(FrappeTestCase):
"postgres": 'ALTER TABLE "tabWorkflow Action" ADD COLUMN "user" varchar(140)',
}
)
- frappe.cache().delete_value("table_columns")
+ frappe.cache.delete_value("table_columns")
def tearDown(self):
frappe.delete_doc("Workflow", "Test ToDo")
@@ -49,7 +49,7 @@ class TestWorkflow(FrappeTestCase):
"postgres": 'ALTER TABLE "tabWorkflow Action" DROP COLUMN "user"',
}
)
- frappe.cache().delete_value("table_columns")
+ frappe.cache.delete_value("table_columns")
def test_default_condition(self):
"""test default condition is set"""
diff --git a/frappe/workflow/doctype/workflow/workflow.py b/frappe/workflow/doctype/workflow/workflow.py
index 018b567ee9..56c17261b7 100644
--- a/frappe/workflow/doctype/workflow/workflow.py
+++ b/frappe/workflow/doctype/workflow/workflow.py
@@ -17,7 +17,7 @@ class Workflow(Document):
def on_update(self):
self.update_doc_status()
frappe.clear_cache(doctype=self.document_type)
- frappe.cache().delete_key("workflow_" + self.name) # clear cache created in model/workflow.py
+ frappe.cache.delete_key("workflow_" + self.name) # clear cache created in model/workflow.py
def create_custom_field_for_workflow_state(self):
frappe.clear_cache(doctype=self.document_type)
diff --git a/frappe/www/app.html b/frappe/www/app.html
index a7468cfc30..ceceaf3219 100644
--- a/frappe/www/app.html
+++ b/frappe/www/app.html
@@ -52,7 +52,6 @@
{% endfor %}
{% include "templates/includes/app_analytics/google_analytics.html" %}
- {% include "templates/includes/app_analytics/mixpanel_analytics.html" %}
{% for sound in (sounds or []) %}