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}>`; -} 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
" }, - { - "fieldname": "html_message", - "fieldtype": "HTML", - "label": "HTML Message", - "options": "

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
" - }, { "fieldname": "roles_section", "fieldtype": "Section Break", @@ -87,11 +81,19 @@ "fieldtype": "Table", "label": "Roles", "options": "Has Role" + }, + { + "default": "0", + "depends_on": "eval: doc.private || doc.__unsaved", + "fieldname": "private", + "fieldtype": "Check", + "label": "Private", + "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-05-17 17:17:04.232519", + "modified": "2023-05-30 14:33:31.994738", "modified_by": "Administrator", "module": "Desk", "name": "Custom HTML Block", diff --git a/frappe/desk/doctype/custom_html_block/custom_html_block.py b/frappe/desk/doctype/custom_html_block/custom_html_block.py index 7f85c2db5f..2b65ceeaf4 100644 --- a/frappe/desk/doctype/custom_html_block/custom_html_block.py +++ b/frappe/desk/doctype/custom_html_block/custom_html_block.py @@ -1,9 +1,25 @@ # Copyright (c) 2023, Frappe Technologies and contributors # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document +from frappe.query_builder.utils import DocType class CustomHTMLBlock(Document): pass + + +@frappe.whitelist() +def get_custom_blocks_for_user(doctype, txt, searchfield, start, page_len, filters): + # return logged in users private blocks and all public blocks + customHTMLBlock = DocType("Custom HTML Block") + + condition_query = frappe.qb.get_query(customHTMLBlock) + + return ( + condition_query.select(customHTMLBlock.name).where( + (customHTMLBlock.private == 0) + | ((customHTMLBlock.owner == frappe.session.user) & (customHTMLBlock.private == 1)) + ) + ).run() diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 5cbeb06e33..16f4efea9d 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -340,7 +340,7 @@ def get_charts_for_user(doctype, txt, searchfield, start, page_len, filters): class DashboardChart(Document): def on_update(self): - frappe.cache().delete_key(f"chart-data:{self.name}") + frappe.cache.delete_key(f"chart-data:{self.name}") if frappe.conf.developer_mode and self.is_standard: export_to_files(record_list=[["Dashboard Chart", self.name]], record_module=self.module) diff --git a/frappe/desk/doctype/desktop_icon/desktop_icon.py b/frappe/desk/doctype/desktop_icon/desktop_icon.py index 63fa12b8fb..0d6e5bb815 100644 --- a/frappe/desk/doctype/desktop_icon/desktop_icon.py +++ b/frappe/desk/doctype/desktop_icon/desktop_icon.py @@ -28,7 +28,7 @@ def get_desktop_icons(user=None): if not user: user = frappe.session.user - user_icons = frappe.cache().hget("desktop_icons", user) + user_icons = frappe.cache.hget("desktop_icons", user) if not user_icons: fields = [ @@ -120,7 +120,7 @@ def get_desktop_icons(user=None): if d.label: d.label = _(d.label) - frappe.cache().hset("desktop_icons", user, user_icons) + frappe.cache.hset("desktop_icons", user, user_icons) return user_icons @@ -313,8 +313,8 @@ def get_all_icons(): def clear_desktop_icons_cache(user=None): - frappe.cache().hdel("desktop_icons", user or frappe.session.user) - frappe.cache().hdel("bootinfo", user or frappe.session.user) + frappe.cache.hdel("desktop_icons", user or frappe.session.user) + frappe.cache.hdel("bootinfo", user or frappe.session.user) def get_user_copy(module_name, user=None): @@ -445,7 +445,7 @@ def get_module_icons(user=None): if not user: icons = frappe.get_all("Desktop Icon", fields="*", filters={"standard": 1}, order_by="idx") else: - frappe.cache().hdel("desktop_icons", user) + frappe.cache.hdel("desktop_icons", user) icons = get_user_icons(user) for icon in icons: diff --git a/frappe/desk/doctype/form_tour/form_tour.js b/frappe/desk/doctype/form_tour/form_tour.js index 1e67e10779..390f519367 100644 --- a/frappe/desk/doctype/form_tour/form_tour.js +++ b/frappe/desk/doctype/form_tour/form_tour.js @@ -2,36 +2,43 @@ // For license information, please see license.txt frappe.ui.form.on("Form Tour", { - setup: function (frm) { - if (!frm.doc.is_standard || frappe.boot.developer_mode) { - frm.trigger("setup_queries"); - } - }, - refresh(frm) { if (frm.doc.is_standard && !frappe.boot.developer_mode) { frm.trigger("disable_form"); } - - frm.add_custom_button(__("Show Tour"), async () => { - const issingle = await check_if_single(frm.doc.reference_doctype); - let route_changed = null; - - if (issingle) { - route_changed = frappe.set_route("Form", frm.doc.reference_doctype); - } else if (frm.doc.first_document) { - const name = await get_first_document(frm.doc.reference_doctype); - route_changed = frappe.set_route("Form", frm.doc.reference_doctype, name); - } else { - route_changed = frappe.set_route("Form", frm.doc.reference_doctype, "new"); - } - route_changed.then(() => { - const tour_name = frm.doc.name; - cur_frm.tour.init({ tour_name }).then(() => cur_frm.tour.start()); - }); + frm.set_query("reference_doctype", () => { + return { filters: { istable: 0 } }; }); + frm.set_query("report_name", () => { + if (frm.doc.reference_doctype) { + return { + filters: { + ref_doctype: frm.doc.reference_doctype, + }, + }; + } + return {}; + }); + !frm.is_new() && add_custom_button(frm); + }, + async report_name(frm) { + if (!frm.doc.ui_tour || !frm.doc.report_name) return; + let { message } = await frappe.db.get_value("Report", frm.doc.report_name, "ref_doctype"); + frm.set_value("reference_doctype", message?.ref_doctype || ""); + }, + async before_save(frm) { + if ( + frm.doc.select_view == "List" && + frm.doc.list_name == "Dashboard" && + frm.doc.dashboard_name && + frm.doc.reference_doctype + ) { + frappe.throw( + __("Referance Doctype and Dashboard Name both can't be used at the same time.") + ); + } + frm.doc.ui_tour && (frm.doc.page_route = JSON.stringify(await get_path(frm))); }, - disable_form: function (frm) { frm.set_read_only(); frm.fields @@ -42,18 +49,6 @@ frappe.ui.form.on("Form Tour", { frm.disable_save(); }, - setup_queries(frm) { - frm.set_query("reference_doctype", function () { - return { - filters: { - istable: 0, - }, - }; - }); - - frm.trigger("reference_doctype"); - }, - reference_doctype(frm) { if (!frm.doc.reference_doctype) return; @@ -78,9 +73,65 @@ frappe.ui.form.on("Form Tour", { [""].concat(options) ); }); + if (!frm.doc.ui_tour) { + // remove report name if reference doctype is changed and report name is not valid. + frappe.db + .get_list( + "Report", + { + filters: { + ref_doctype: frm.doc.reference_doctype, + }, + }, + { fields: ["name"] } + ) + .then((reports) => { + if (reports.findIndex((r) => r.name == frm.doc.report_name) == -1) { + frm.set_value("report_name", ""); + frm.refresh_field("report_name"); + } + }); + } }, }); +let add_custom_button = (frm) => { + if (frm.doc.ui_tour) { + frm.add_custom_button(__("Reset"), function () { + frappe.confirm( + __("This will reset this tour and show it to all users. Are you sure?"), + function () { + frappe.call({ + method: "frappe.desk.doctype.form_tour.form_tour.reset_tour", + args: { + tour_name: frm.doc.name, + }, + }); + }, + delete frappe.boot.user.onboarding_status[frm.doc.name] + ); + }); + } else { + frm.add_custom_button(__("Show Tour"), async () => { + const issingle = await check_if_single(frm.doc.reference_doctype); + let route_changed = null; + + if (issingle) { + route_changed = frappe.set_route("Form", frm.doc.reference_doctype); + } else if (frm.doc.first_document) { + const name = await get_first_document(frm.doc.reference_doctype); + route_changed = frappe.set_route("Form", frm.doc.reference_doctype, name); + } else { + route_changed = frappe.set_route("Form", frm.doc.reference_doctype, "new"); + } + route_changed.then(() => { + const tour_name = frm.doc.name; + cur_frm.tour.init({ tour_name }).then(() => cur_frm.tour.start()); + }); + }); + } +}; + frappe.ui.form.on("Form Tour Step", { form_render(frm, cdt, cdn) { if (locals[cdt][cdn].is_table_field) { @@ -115,6 +166,10 @@ async function check_if_single(doctype) { const { message } = await frappe.db.get_value("DocType", doctype, "issingle"); return message.issingle || 0; } +async function check_if_private_workspace(name) { + const { message } = await frappe.db.get_value("Workspace", name, "public"); + return !message.public || 0; +} async function get_first_document(doctype) { let docname; @@ -125,3 +180,75 @@ async function get_first_document(doctype) { return docname || "new"; } + +async function get_path(frm) { + let route = [frm.doc.view_name]; + switch (route[0]) { + case "Workspaces": + frm.doc.list_name = ""; + frm.doc.new_document_form = 0; + frm.doc.report_name = ""; + frm.doc.page_name = ""; + frm.doc.dashboard_name = ""; + frm.doc.reference_doctype = ""; + if (!frm.doc.workspace_name) { + route.push("*"); + return route; + } + if (await check_if_private_workspace(frm.doc.workspace_name)) { + route.push("private"); + } + route.push(frm.doc.workspace_name); + return route; + case "List": + frm.doc.workspace_name = ""; + frm.doc.new_document_form = 0; + frm.doc.list_name != "Report" && (frm.doc.report_name = ""); + frm.doc.list_name != "Dashboard" && (frm.doc.dashboard_name = ""); + frm.doc.page_name = ""; + if (frm.doc.list_name == "File") return ["List", "File"]; + if (!frm.doc.reference_doctype) { + if (frm.doc.list_name == "Dashboard") + return ["dashboard-view", frm.doc.dashboard_name || "*"]; + route.push("*"); + } else { + route.push(frm.doc.reference_doctype); + } + route.push(frm.doc.list_name); + return route; + case "Form": + frm.doc.workspace_name = ""; + frm.doc.list_name = ""; + frm.doc.report_name = ""; + frm.doc.page_name = ""; + frm.doc.dashboard_name = ""; + if (!frm.doc.reference_doctype) { + route.push("*"); + frm.doc.new_document_form && route.push("new-*"); + return route; + } + route.push(frm.doc.reference_doctype); + if (await check_if_single(frm.doc.reference_doctype)) { + route.push(frm.doc.reference_doctype); + } else if (frm.doc.new_document_form) { + route.push("new-" + frappe.router.slug(frm.doc.reference_doctype)); + } + return route; + case "Tree": + frm.doc.workspace_name = ""; + frm.doc.list_name = ""; + frm.doc.new_document_form = 0; + frm.doc.report_name = ""; + frm.doc.page_name = ""; + frm.doc.dashboard_name = ""; + return route; + case "Page": + frm.doc.workspace_name = ""; + frm.doc.list_name = ""; + frm.doc.new_document_form = 0; + frm.doc.report_name = ""; + frm.doc.dashboard_name = ""; + frm.doc.reference_doctype = ""; + return [frm.doc.page_name]; + } +} diff --git a/frappe/desk/doctype/form_tour/form_tour.json b/frappe/desk/doctype/form_tour/form_tour.json index 6f3bd56a4e..95ec270f2c 100644 --- a/frappe/desk/doctype/form_tour/form_tour.json +++ b/frappe/desk/doctype/form_tour/form_tour.json @@ -7,27 +7,38 @@ "engine": "InnoDB", "field_order": [ "title", + "view_name", + "workspace_name", + "list_name", + "report_name", + "dashboard_name", + "new_document_form", + "page_name", "reference_doctype", "module", "column_break_6", + "ui_tour", + "track_steps", "is_standard", "save_on_complete", "first_document", "include_name_field", + "page_route", "section_break_3", "steps" ], "fields": [ { + "depends_on": "eval:(!doc.ui_tour || doc.ui_tour && [\"Workspaces\", \"Page\", \"Tree\"].indexOf(doc.view_name) == -1);", "fieldname": "reference_doctype", "fieldtype": "Link", "in_list_view": 1, "label": "Reference Document", - "options": "DocType", - "reqd": 1 + "mandatory_depends_on": "eval:(!doc.ui_tour)", + "options": "DocType" }, { - "depends_on": "reference_doctype", + "depends_on": "eval:(doc.ui_tour || doc.reference_doctype)", "fieldname": "steps", "fieldtype": "Table", "label": "Steps", @@ -47,6 +58,7 @@ }, { "default": "0", + "depends_on": "eval:(!doc.ui_tour)", "fieldname": "save_on_complete", "fieldtype": "Check", "label": "Save on Completion" @@ -58,10 +70,10 @@ "label": "Is Standard" }, { + "depends_on": "eval: doc.ui_tour && doc.is_standard", "fetch_from": "reference_doctype.module", "fieldname": "module", "fieldtype": "Link", - "hidden": 1, "label": "Module", "options": "Module Def", "read_only": 1 @@ -72,21 +84,101 @@ }, { "default": "0", + "depends_on": "eval:(!doc.ui_tour)", "fieldname": "first_document", "fieldtype": "Check", "label": "Show First Document Tour" }, { "default": "0", - "depends_on": "eval:!doc.first_document", + "depends_on": "eval:(!doc.ui_tour && !doc.first_document)", "fieldname": "include_name_field", "fieldtype": "Check", "label": "Include Name Field" + }, + { + "default": "0", + "fieldname": "ui_tour", + "fieldtype": "Check", + "label": "UI Tour", + "set_only_once": 1 + }, + { + "fieldname": "page_route", + "fieldtype": "Small Text", + "hidden": 1, + "label": "Page Route" + }, + { + "depends_on": "eval:(doc.ui_tour && doc.view_name == \"List\" && doc.list_name == \"Dashboard\")", + "fetch_from": ".", + "fieldname": "dashboard_name", + "fieldtype": "Link", + "label": "Select Dashboard", + "options": "Dashboard" + }, + { + "depends_on": "ui_tour", + "fieldname": "view_name", + "fieldtype": "Select", + "label": "View", + "mandatory_depends_on": "ui_tour", + "options": "Workspaces\nList\nForm\nTree\nPage" + }, + { + "depends_on": "eval:(doc.ui_tour && doc.view_name == \"Workspaces\")", + "fetch_from": ".", + "fieldname": "workspace_name", + "fieldtype": "Link", + "label": "Select Workspace", + "options": "Workspace" + }, + { + "depends_on": "eval:(doc.ui_tour && doc.view_name == \"Page\")", + "fetch_from": ".", + "fieldname": "page_name", + "fieldtype": "Link", + "label": "Select Page", + "mandatory_depends_on": "eval:(doc.ui_tour && doc.view_name == \"Page\")", + "options": "Page" + }, + { + "default": "List", + "depends_on": "eval:(doc.ui_tour && doc.view_name == \"List\")", + "fetch_from": ".", + "fieldname": "list_name", + "fieldtype": "Select", + "label": "Select List View", + "mandatory_depends_on": "eval:(doc.ui_tour && doc.view_name == \"List\")", + "options": "List\nReport\nDashboard\nKanban\nGantt\nCalendar\nFile\nImage\nInbox\nMap" + }, + { + "depends_on": "eval:(doc.ui_tour && doc.view_name == \"List\" && doc.list_name == \"Report\")", + "fetch_from": ".", + "fieldname": "report_name", + "fieldtype": "Link", + "label": "Select Report", + "options": "Report" + }, + { + "default": "0", + "depends_on": "ui_tour", + "description": "The next tour will start from where the user left off.", + "fieldname": "track_steps", + "fieldtype": "Check", + "label": "Track Steps" + }, + { + "default": "0", + "depends_on": "eval: (doc.ui_tour && doc.view_name == \"Form\")", + "fieldname": "new_document_form", + "fieldtype": "Check", + "label": "New Document Form" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-11-24 12:03:45.449311", + "modified": "2023-05-25 11:30:44.396248", "modified_by": "Administrator", "module": "Desk", "name": "Form Tour", @@ -104,9 +196,14 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "read": 1, + "role": "All" } ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/desk/doctype/form_tour/form_tour.py b/frappe/desk/doctype/form_tour/form_tour.py index 6248b43e62..bdfdcf7c79 100644 --- a/frappe/desk/doctype/form_tour/form_tour.py +++ b/frappe/desk/doctype/form_tour/form_tour.py @@ -1,27 +1,84 @@ # Copyright (c) 2021, Frappe Technologies and contributors # License: MIT. See LICENSE +import json + import frappe +from frappe import _ from frappe.model.document import Document from frappe.modules.export_file import export_to_files class FormTour(Document): def before_save(self): - meta = frappe.get_meta(self.reference_doctype) - for step in self.steps: - if step.is_table_field and step.parent_fieldname: - parent_field_df = meta.get_field(step.parent_fieldname) - step.child_doctype = parent_field_df.options - - field_df = frappe.get_meta(step.child_doctype).get_field(step.fieldname) - step.label = field_df.label - step.fieldtype = field_df.fieldtype + if self.is_standard and not self.module: + if self.workspace_name: + self.module = frappe.db.get_value("Workspace", self.workspace_name, "module") + elif self.dashboard_name: + dashboard_doctype = frappe.db.get_value("Dashboard", self.dashboard_name, "module") + self.module = frappe.db.get_value("DocType", dashboard_doctype, "module") else: - field_df = meta.get_field(step.fieldname) - step.label = field_df.label - step.fieldtype = field_df.fieldtype + self.module = "Desk" + if not self.ui_tour: + meta = frappe.get_meta(self.reference_doctype) + for step in self.steps: + if step.is_table_field and step.parent_fieldname: + parent_field_df = meta.get_field(step.parent_fieldname) + step.child_doctype = parent_field_df.options + field_df = frappe.get_meta(step.child_doctype).get_field(step.fieldname) + step.label = field_df.label + step.fieldtype = field_df.fieldtype + else: + field_df = meta.get_field(step.fieldname) + step.label = field_df.label + step.fieldtype = field_df.fieldtype def on_update(self): + frappe.cache.delete_key("bootinfo") + if frappe.conf.developer_mode and self.is_standard: export_to_files([["Form Tour", self.name]], self.module) + + def on_trash(self): + frappe.cache.delete_key("bootinfo") + + +@frappe.whitelist() +def reset_tour(tour_name): + for user in frappe.get_all("User", pluck="name"): + onboarding_status = frappe.parse_json(frappe.db.get_value("User", user, "onboarding_status")) + onboarding_status.pop(tour_name, None) + frappe.db.set_value( + "User", user, "onboarding_status", frappe.as_json(onboarding_status), update_modified=False + ) + frappe.cache.hdel("bootinfo", user) + + frappe.msgprint(_("Successfully reset onboarding status for all users."), alert=True) + + +@frappe.whitelist() +def update_user_status(value, step): + from frappe.utils.telemetry import capture + + step = frappe.parse_json(step) + tour = frappe.parse_json(value) + + capture( + frappe.scrub(f"{step.parent}_{step.title}"), + app="frappe_ui_tours", + properties={"is_completed": tour.is_completed}, + ) + frappe.db.set_value( + "User", frappe.session.user, "onboarding_status", value, update_modified=False + ) + + frappe.cache.hdel("bootinfo", frappe.session.user) + + +def get_onboarding_ui_tours(): + if not frappe.get_system_settings("enable_onboarding"): + return [] + + ui_tours = frappe.get_all("Form Tour", filters={"ui_tour": 1}, fields=["page_route", "name"]) + + return [[tour.name, json.loads(tour.page_route)] for tour in ui_tours] diff --git a/frappe/desk/doctype/form_tour/patches/__init__.py b/frappe/desk/doctype/form_tour/patches/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/form_tour/patches/introduce_ui_tours.py b/frappe/desk/doctype/form_tour/patches/introduce_ui_tours.py new file mode 100644 index 0000000000..2ca981dae7 --- /dev/null +++ b/frappe/desk/doctype/form_tour/patches/introduce_ui_tours.py @@ -0,0 +1,13 @@ +import json + +import frappe + + +def execute(): + """Handle introduction of UI tours""" + completed = {} + for tour in frappe.get_all("Form Tour", {"ui_tour": 1}, pluck="name"): + completed[tour] = {"is_complete": True} + + User = frappe.qb.DocType("User") + frappe.qb.update(User).set("onboarding_status", json.dumps(completed)).run() diff --git a/frappe/desk/doctype/form_tour_step/form_tour_step.json b/frappe/desk/doctype/form_tour_step/form_tour_step.json index 7eb6eab223..26209ccabe 100644 --- a/frappe/desk/doctype/form_tour_step/form_tour_step.json +++ b/frappe/desk/doctype/form_tour_step/form_tour_step.json @@ -2,20 +2,32 @@ "actions": [], "creation": "2021-05-21 23:05:45.342114", "doctype": "DocType", + "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "ui_tour", "is_table_field", "section_break_2", + "title", "parent_fieldname", "fieldname", - "title", + "element_selector", + "parent_element_selector", "description", + "ondemand_description", "column_break_2", "position", + "hide_buttons", + "popover_element", + "modal_trigger", + "offset_x", + "offset_y", + "next_on_click", "label", "fieldtype", "has_next_condition", "next_step_condition", + "next_form_tour", "section_break_13", "child_doctype" ], @@ -31,18 +43,20 @@ "columns": 4, "fieldname": "description", "fieldtype": "HTML Editor", + "ignore_xss_filter": 1, "in_list_view": 1, "label": "Description", "reqd": 1 }, { - "depends_on": "eval: (!doc.is_table_field || (doc.is_table_field && doc.parent_fieldname))", + "depends_on": "eval: (!doc.ui_tour && (!doc.is_table_field || (doc.is_table_field && doc.parent_fieldname)))", "fieldname": "fieldname", "fieldtype": "Select", "label": "Fieldname", - "reqd": 1 + "mandatory_depends_on": "eval: (!doc.ui_tour)" }, { + "depends_on": "eval:(!doc.ui_tour)", "fieldname": "label", "fieldtype": "Data", "in_list_view": 1, @@ -70,12 +84,14 @@ }, { "default": "0", + "depends_on": "eval:(!doc.ui_tour)", "fieldname": "has_next_condition", "fieldtype": "Check", "label": "Has Next Condition" }, { "default": "0", + "depends_on": "eval:(!doc.ui_tour)", "fieldname": "fieldtype", "fieldtype": "Data", "label": "Fieldtype", @@ -83,6 +99,7 @@ }, { "default": "0", + "depends_on": "eval:(!doc.ui_tour)", "fieldname": "is_table_field", "fieldtype": "Check", "label": "Is Table Field" @@ -105,17 +122,102 @@ "read_only": 1 }, { - "depends_on": "is_table_field", + "depends_on": "eval: (!doc.ui_tour || doc.is_table_field)", "fieldname": "parent_fieldname", "fieldtype": "Select", "label": "Parent Field", "mandatory_depends_on": "is_table_field" + }, + { + "default": "0", + "fetch_from": "next_form_tour.ui_tour", + "fieldname": "ui_tour", + "fieldtype": "Check", + "in_list_view": 1, + "label": "UI Tour" + }, + { + "depends_on": "eval:(doc.ui_tour)", + "description": "CSS selector for the element you want to highlight.", + "fieldname": "element_selector", + "fieldtype": "Data", + "label": "Element Selector", + "mandatory_depends_on": "eval:(doc.ui_tour)" + }, + { + "depends_on": "eval:(doc.ui_tour)", + "description": "Mozilla doesn't support :has() so you can pass parent selector here as workaround", + "fieldname": "parent_element_selector", + "fieldtype": "Data", + "label": "Parent Element Selector" + }, + { + "depends_on": "eval:(doc.ui_tour)", + "fieldname": "next_form_tour", + "fieldtype": "Link", + "label": "Next Form Tour", + "options": "Form Tour" + }, + { + "default": "0", + "depends_on": "eval:(doc.ui_tour)", + "description": "Hide Previous, Next and Close button on highlight dialog.", + "fieldname": "hide_buttons", + "fieldtype": "Check", + "label": "Hide Buttons" + }, + { + "default": "0", + "depends_on": "eval:(doc.ui_tour)", + "description": "Move to next step when clicked inside highlighted area.", + "fieldname": "next_on_click", + "fieldtype": "Check", + "label": "Next on Click" + }, + { + "default": "0", + "depends_on": "eval:(doc.ui_tour)", + "description": "when clicked on element it will focus popover if present.", + "fieldname": "popover_element", + "fieldtype": "Check", + "label": "Popover Element" + }, + { + "default": "0", + "depends_on": "eval:(doc.ui_tour)", + "fieldname": "offset_x", + "fieldtype": "Int", + "label": "Offset X" + }, + { + "default": "0", + "depends_on": "eval:(doc.ui_tour)", + "fieldname": "offset_y", + "fieldtype": "Int", + "label": "Offset Y" + }, + { + "default": "0", + "depends_on": "eval:(doc.ui_tour)", + "description": "Enable if on click\nopens modal.", + "fieldname": "modal_trigger", + "fieldtype": "Check", + "label": "Modal Trigger" + }, + { + "columns": 4, + "depends_on": "eval: (doc.popover_element || doc.modal_trigger)", + "fieldname": "ondemand_description", + "fieldtype": "HTML Editor", + "ignore_xss_filter": 1, + "in_list_view": 1, + "label": "Popover or Modal Description" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-01-27 15:18:36.481801", + "modified": "2023-05-23 13:09:15.923043", "modified_by": "Administrator", "module": "Desk", "name": "Form Tour Step", diff --git a/frappe/desk/doctype/global_search_settings/global_search_settings.py b/frappe/desk/doctype/global_search_settings/global_search_settings.py index 4e2b1e85f9..f0bf985550 100644 --- a/frappe/desk/doctype/global_search_settings/global_search_settings.py +++ b/frappe/desk/doctype/global_search_settings/global_search_settings.py @@ -28,7 +28,7 @@ class GlobalSearchSettings(Document): frappe.throw(_("Document Type {0} has been repeated.").format(repeated_dts)) # reset cache - frappe.cache().hdel("global_search", "search_priorities") + frappe.cache.hdel("global_search", "search_priorities") def get_doctypes_for_global_search(): @@ -36,7 +36,7 @@ def get_doctypes_for_global_search(): doctypes = frappe.get_all("Global Search DocType", fields=["document_type"], order_by="idx ASC") return [d.document_type for d in doctypes] or [] - return frappe.cache().hget("global_search", "search_priorities", get_from_db) + return frappe.cache.hget("global_search", "search_priorities", get_from_db) @frappe.whitelist() diff --git a/frappe/desk/doctype/kanban_board/kanban_board.py b/frappe/desk/doctype/kanban_board/kanban_board.py index e3257e25be..508407f76a 100644 --- a/frappe/desk/doctype/kanban_board/kanban_board.py +++ b/frappe/desk/doctype/kanban_board/kanban_board.py @@ -14,7 +14,7 @@ class KanbanBoard(Document): def on_change(self): frappe.clear_cache(doctype=self.reference_doctype) - frappe.cache().delete_keys("_user_settings") + frappe.cache.delete_keys("_user_settings") def before_insert(self): for column in self.columns: diff --git a/frappe/desk/doctype/list_view_settings/list_view_settings.py b/frappe/desk/doctype/list_view_settings/list_view_settings.py index 36ebce34d5..e3b6a60a42 100644 --- a/frappe/desk/doctype/list_view_settings/list_view_settings.py +++ b/frappe/desk/doctype/list_view_settings/list_view_settings.py @@ -6,8 +6,7 @@ from frappe.model.document import Document class ListViewSettings(Document): - def on_update(self): - frappe.clear_document_cache(self.doctype, self.name) + pass @frappe.whitelist() diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py index 451dc699fe..a8e4841953 100644 --- a/frappe/desk/doctype/number_card/number_card.py +++ b/frappe/desk/doctype/number_card/number_card.py @@ -202,7 +202,11 @@ def get_cards_for_user(doctype, txt, searchfield, start, page_len, filters): if txt: search_conditions = [numberCard[field].like(f"%{txt}%") for field in searchfields] - condition_query = frappe.qb.get_query(doctype, filters=filters) + condition_query = frappe.qb.get_query( + doctype, + filters=filters, + validate_filters=True, + ) return ( condition_query.select(numberCard.name, numberCard.label, numberCard.document_type) diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index 2759acd228..0769b2a81b 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -211,7 +211,7 @@ ], "in_create": 1, "links": [], - "modified": "2023-05-17 14:52:38.110224", + "modified": "2023-06-08 14:52:38.110224", "modified_by": "Administrator", "module": "Desk", "name": "Workspace", diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index 9bc7b138dd..1ec604c34d 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -531,13 +531,13 @@ def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): {"Address": {"fieldname": "customer"}..} """ if without_ignore_user_permissions_enabled: - return frappe.cache().hget( + return frappe.cache.hget( "linked_doctypes_without_ignore_user_permissions_enabled", doctype, lambda: _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled), ) else: - return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype)) + return frappe.cache.hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype)) def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index 62a9c89c81..580e8d5102 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -37,10 +37,10 @@ ASSET_KEYS = ( def get_meta(doctype, cached=True): # don't cache for developer mode as js files, templates may be edited if cached and not frappe.conf.developer_mode: - meta = frappe.cache().hget("doctype_form_meta", doctype) + meta = frappe.cache.hget("doctype_form_meta", doctype) if not meta: meta = FormMeta(doctype) - frappe.cache().hset("doctype_form_meta", doctype, meta) + frappe.cache.hset("doctype_form_meta", doctype, meta) else: meta = FormMeta(doctype) diff --git a/frappe/desk/form/save.py b/frappe/desk/form/save.py index 9ee2541a90..75335cb1ce 100644 --- a/frappe/desk/form/save.py +++ b/frappe/desk/form/save.py @@ -9,12 +9,14 @@ from frappe.desk.form.load import run_onload from frappe.model.docstatus import DocStatus from frappe.monitor import add_data_to_monitor from frappe.utils.scheduler import is_scheduler_inactive +from frappe.utils.telemetry import capture_doc @frappe.whitelist() def savedocs(doc, action): """save / submit / update doclist""" doc = frappe.get_doc(json.loads(doc)) + capture_doc(doc) set_local_name(doc) # action diff --git a/frappe/desk/form_tour/main_workspace_tour/main_workspace_tour.json b/frappe/desk/form_tour/main_workspace_tour/main_workspace_tour.json new file mode 100644 index 0000000000..afd0583cfb --- /dev/null +++ b/frappe/desk/form_tour/main_workspace_tour/main_workspace_tour.json @@ -0,0 +1,79 @@ +{ + "creation": "2023-05-18 12:08:23.196462", + "dashboard_name": "", + "docstatus": 0, + "doctype": "Form Tour", + "first_document": 0, + "idx": 0, + "include_name_field": 0, + "is_standard": 1, + "list_name": "", + "modified": "2023-05-24 12:43:43.741781", + "modified_by": "Administrator", + "module": "Desk", + "name": "Main Workspace Tour", + "new_document_form": 0, + "owner": "Administrator", + "page_name": "", + "page_route": "[\"Workspaces\",\"*\"]", + "reference_doctype": "", + "report_name": "", + "save_on_complete": 0, + "steps": [ + { + "description": "This is Awesomebar, it helps you to navigate anywhere in the system, find documents, reports, settings, create new records and many more things.", + "element_selector": "#navbar-search", + "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, + "parent_element_selector": ".input-group.search-bar", + "popover_element": 0, + "position": "Left", + "title": "Awesomebar", + "ui_tour": 1 + }, + { + "description": "These are workspaces. Each module workspace provides insightful information and shortcuts on one page. \n\n

\n\nTip: You can build custom workspaces for your needs.", + "element_selector": ".col-lg-2.layout-side-section", + "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": "Right", + "title": "Workspace List", + "ui_tour": 1 + }, + { + "description": "
Click to visit the Workspace
", + "element_selector": ".desk-sidebar-item.standard-sidebar-item > [title=\"Users\"]", + "fieldtype": "0", + "has_next_condition": 0, + "hide_buttons": 1, + "is_table_field": 0, + "modal_trigger": 0, + "next_form_tour": "New Tools Tour", + "next_on_click": 1, + "offset_x": 0, + "offset_y": 0, + "popover_element": 0, + "position": "Right", + "title": "Users Workspace", + "ui_tour": 1 + } + ], + "title": "Main Workspace Tour", + "track_steps": 1, + "ui_tour": 1, + "view_name": "Workspaces", + "workspace_name": "" +} \ No newline at end of file diff --git a/frappe/desk/form_tour/users_workspace_tour/users_workspace_tour.json b/frappe/desk/form_tour/users_workspace_tour/users_workspace_tour.json new file mode 100644 index 0000000000..97159ba6e3 --- /dev/null +++ b/frappe/desk/form_tour/users_workspace_tour/users_workspace_tour.json @@ -0,0 +1,62 @@ +{ + "creation": "2023-05-24 12:50:23.740052", + "dashboard_name": "", + "docstatus": 0, + "doctype": "Form Tour", + "first_document": 0, + "idx": 0, + "include_name_field": 0, + "is_standard": 1, + "list_name": "", + "modified": "2023-05-24 13:01:56.539128", + "modified_by": "Administrator", + "module": "Desk", + "name": "Users Workspace Tour", + "new_document_form": 0, + "owner": "Administrator", + "page_name": "", + "page_route": "[\"Workspaces\",\"Users\"]", + "reference_doctype": "", + "report_name": "", + "save_on_complete": 0, + "steps": [ + { + "description": "This is Users Workspace. You'll find all shortcuts for user, roles and permission management here.", + "element_selector": ".codex-editor", + "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": "Left", + "title": "Workspace", + "ui_tour": 1 + }, + { + "description": "This is a shortcut to User DocType. \n
\n\nLet's Click on the User shortcut to explore all users in System.", + "element_selector": "[shortcut_name=\"User\"]", + "fieldtype": "0", + "has_next_condition": 0, + "hide_buttons": 1, + "is_table_field": 0, + "modal_trigger": 0, + "next_form_tour": "User List Tour", + "next_on_click": 0, + "offset_x": 0, + "offset_y": 0, + "popover_element": 0, + "position": "Right", + "title": "Users Shortcut", + "ui_tour": 1 + } + ], + "title": "Users Workspace Tour", + "track_steps": 1, + "ui_tour": 1, + "view_name": "Workspaces", + "workspace_name": "Users" +} \ No newline at end of file diff --git a/frappe/desk/listview.py b/frappe/desk/listview.py index 05d45ad9ac..a1db82810e 100644 --- a/frappe/desk/listview.py +++ b/frappe/desk/listview.py @@ -36,7 +36,12 @@ def get_group_by_count(doctype: str, current_filters: str, field: str) -> list[d ToDo = DocType("ToDo") User = DocType("User") count = Count("*").as_("count") - filtered_records = frappe.qb.get_query(doctype, filters=current_filters, fields=["name"]) + filtered_records = frappe.qb.get_query( + doctype, + filters=current_filters, + fields=["name"], + validate_filters=True, + ) return ( frappe.qb.from_(ToDo) diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py index 271f2b4074..6334b18d1c 100644 --- a/frappe/desk/notifications.py +++ b/frappe/desk/notifications.py @@ -34,13 +34,12 @@ def get_notifications(): return out groups = list(config.get("for_doctype")) + list(config.get("for_module")) - cache = frappe.cache() notification_count = {} notification_percent = {} for name in groups: - count = cache.hget("notification_count:" + name, frappe.session.user) + count = frappe.cache.hget("notification_count:" + name, frappe.session.user) if count is not None: notification_count[name] = count @@ -83,7 +82,7 @@ def get_notifications_for_doctypes(config, notification_count): else: open_count_doctype[d] = result - frappe.cache().hset("notification_count:" + d, frappe.session.user, result) + frappe.cache.hset("notification_count:" + d, frappe.session.user, result) return open_count_doctype @@ -139,7 +138,6 @@ def get_notifications_for_targets(config, notification_percent): def clear_notifications(user=None): if frappe.flags.in_install: return - cache = frappe.cache() config = get_notification_config() if not config: @@ -151,17 +149,17 @@ def clear_notifications(user=None): for name in groups: if user: - cache.hdel("notification_count:" + name, user) + frappe.cache.hdel("notification_count:" + name, user) else: - cache.delete_key("notification_count:" + name) + frappe.cache.delete_key("notification_count:" + name) def clear_notification_config(user): - frappe.cache().hdel("notification_config", user) + frappe.cache.hdel("notification_config", user) def delete_notification_count_for(doctype): - frappe.cache().delete_key("notification_count:" + doctype) + frappe.cache.delete_key("notification_count:" + doctype) def clear_doctype_notifications(doc, method=None, *args, **kwargs): @@ -230,7 +228,7 @@ def get_notification_config(): config[key].update(nc.get(key, {})) return config - return frappe.cache().hget("notification_config", user, _get) + return frappe.cache.hget("notification_config", user, _get) def get_filters_for(doctype): diff --git a/frappe/desk/page/form_builder/form_builder.js b/frappe/desk/page/form_builder/form_builder.js index cc29084b69..a7a25b5c6c 100644 --- a/frappe/desk/page/form_builder/form_builder.js +++ b/frappe/desk/page/form_builder/form_builder.js @@ -19,7 +19,10 @@ frappe.pages["form-builder"].on_page_show = function (wrapper) { function load_form_builder(wrapper) { let route = frappe.get_route(); route = route.filter((a) => a); - if (route.length > 1) { + + if (route.length > 1 && route[1] === "new-doctype") { + frappe.pages["form-builder"].new_doctype(route[2]); + } else if (route.length > 1) { let doctype = route[1]; let is_customize_form = route[2] === "customize"; @@ -44,159 +47,165 @@ function load_form_builder(wrapper) { }); }); } else { - let d = new frappe.ui.Dialog({ - title: __("Select DocType"), - fields: [ - { - label: __("Select DocType"), - fieldname: "doctype", - fieldtype: "Link", - options: "DocType", - only_select: 1, - }, - { - label: __("Customize"), - fieldname: "customize", - fieldtype: "Check", - }, - ], - primary_action_label: __("Edit"), - primary_action({ doctype, customize }) { - if (customize) { - frappe.model.with_doctype(doctype).then(() => { - let meta = frappe.get_meta(doctype); - if (in_list(frappe.model.core_doctypes_list, this.doctype)) - frappe.throw(__("Core DocTypes cannot be customized.")); - - if (meta.issingle) - frappe.throw(__("Single DocTypes cannot be customized.")); - - if (meta.custom) - frappe.throw( - __( - "Only standard DocTypes are allowed to be customized from Customize Form." - ) - ); - frappe.set_route("form-builder", doctype, "customize"); - }); - } else { - frappe.set_route("form-builder", doctype); - } - }, - secondary_action_label: __("Create New DocType"), - secondary_action() { - let doctype = d.get_value("doctype") || ""; - let non_developer = - frappe.session.user !== "Administrator" || !frappe.boot.developer_mode; - d.hide(); - let new_d = new frappe.ui.Dialog({ - title: __("Create New DocType"), - fields: [ - { - label: __("DocType Name"), - fieldname: "doctype_name", - fieldtype: "Data", - default: doctype, - reqd: 1, - }, - { fieldtype: "Column Break" }, - { - label: __("Module"), - fieldname: "module", - fieldtype: "Link", - options: "Module Def", - reqd: 1, - }, - { fieldtype: "Section Break" }, - { - label: __("Is Submittable"), - fieldname: "is_submittable", - fieldtype: "Check", - description: __( - "Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended." - ), - depends_on: "eval:!doc.istable && !doc.issingle", - }, - { - label: __("Is Child Table"), - fieldname: "istable", - fieldtype: "Check", - description: __("Child Tables are shown as a Grid in other DocTypes"), - depends_on: "eval:!doc.is_submittable && !doc.issingle", - }, - { - label: __("Editable Grid"), - fieldname: "editable_grid", - fieldtype: "Check", - depends_on: "istable", - default: 1, - }, - { - label: __("Is Single"), - fieldname: "issingle", - fieldtype: "Check", - description: __( - "Single Types have only one record no tables associated. Values are stored in tabSingles" - ), - depends_on: "eval:!doc.istable && !doc.is_submittable", - }, - { - label: __("Custom?"), - fieldname: "custom", - fieldtype: "Check", - default: non_developer, - read_only: non_developer, - }, - ], - primary_action_label: __("Create & Continue"), - primary_action(values) { - if (!values.istable) values.editable_grid = 0; - frappe.db - .insert({ - doctype: "DocType", - name: values.doctype_name, - module: values.module, - istable: values.istable, - editable_grid: values.editable_grid, - issingle: values.issingle, - custom: values.custom, - is_submittable: values.is_submittable, - permissions: [ - { - create: 1, - delete: 1, - email: 1, - export: 1, - print: 1, - read: 1, - report: 1, - role: "System Manager", - share: 1, - write: 1, - }, - ], - fields: [ - { - label: "Title", - fieldname: "title", - fieldtype: "Data", - }, - ], - }) - .then((doc) => { - frappe.set_route("form-builder", doc.name); - }); - }, - secondary_action_label: __("Back"), - secondary_action() { - new_d.hide(); - d.show(); - }, - }); - new_d.show(); - }, - }); - - d.show(); + frappe.pages["form-builder"].select_doctype(); } } + +frappe.pages["form-builder"].select_doctype = function () { + let d = new frappe.ui.Dialog({ + title: __("Select DocType"), + fields: [ + { + label: __("Select DocType"), + fieldname: "doctype", + fieldtype: "Link", + options: "DocType", + only_select: 1, + }, + { + label: __("Customize"), + fieldname: "customize", + fieldtype: "Check", + }, + ], + primary_action_label: __("Edit"), + primary_action({ doctype, customize }) { + if (customize) { + frappe.model.with_doctype(doctype).then(() => { + let meta = frappe.get_meta(doctype); + if (in_list(frappe.model.core_doctypes_list, this.doctype)) + frappe.throw(__("Core DocTypes cannot be customized.")); + + if (meta.issingle) frappe.throw(__("Single DocTypes cannot be customized.")); + + if (meta.custom) + frappe.throw( + __( + "Only standard DocTypes are allowed to be customized from Customize Form." + ) + ); + frappe.set_route("form-builder", doctype, "customize"); + }); + } else { + frappe.set_route("form-builder", doctype); + } + }, + secondary_action_label: __("Create New DocType"), + secondary_action() { + let doctype = d.get_value("doctype") || ""; + d.hide(); + frappe.set_route("form-builder", "new-doctype", doctype); + }, + }); + + d.show(); +}; + +frappe.pages["form-builder"].new_doctype = function (doctype) { + let non_developer = frappe.session.user !== "Administrator" || !frappe.boot.developer_mode; + let new_d = new frappe.ui.Dialog({ + title: __("Create New DocType"), + fields: [ + { + label: __("DocType Name"), + fieldname: "doctype_name", + fieldtype: "Data", + default: doctype, + reqd: 1, + }, + { fieldtype: "Column Break" }, + { + label: __("Module"), + fieldname: "module", + fieldtype: "Link", + options: "Module Def", + reqd: 1, + }, + { fieldtype: "Section Break" }, + { + label: __("Is Submittable"), + fieldname: "is_submittable", + fieldtype: "Check", + description: __( + "Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended." + ), + depends_on: "eval:!doc.istable && !doc.issingle", + }, + { + label: __("Is Child Table"), + fieldname: "istable", + fieldtype: "Check", + description: __("Child Tables are shown as a Grid in other DocTypes"), + depends_on: "eval:!doc.is_submittable && !doc.issingle", + }, + { + label: __("Editable Grid"), + fieldname: "editable_grid", + fieldtype: "Check", + depends_on: "istable", + default: 1, + }, + { + label: __("Is Single"), + fieldname: "issingle", + fieldtype: "Check", + description: __( + "Single Types have only one record no tables associated. Values are stored in tabSingles" + ), + depends_on: "eval:!doc.istable && !doc.is_submittable", + }, + { + label: __("Custom?"), + fieldname: "custom", + fieldtype: "Check", + default: non_developer, + read_only: non_developer, + }, + ], + primary_action_label: __("Create & Continue"), + primary_action(values) { + if (!values.istable) values.editable_grid = 0; + frappe.db + .insert({ + doctype: "DocType", + name: values.doctype_name, + module: values.module, + istable: values.istable, + editable_grid: values.editable_grid, + issingle: values.issingle, + custom: values.custom, + is_submittable: values.is_submittable, + permissions: [ + { + create: 1, + delete: 1, + email: 1, + export: 1, + print: 1, + read: 1, + report: 1, + role: "System Manager", + share: 1, + write: 1, + }, + ], + fields: [ + { + label: "Title", + fieldname: "title", + fieldtype: "Data", + }, + ], + }) + .then((doc) => { + frappe.set_route("form-builder", doc.name); + }); + }, + secondary_action_label: __("Back"), + secondary_action() { + new_d.hide(); + window.history.back(); + }, + }); + new_d.show(); +}; diff --git a/frappe/desk/page/setup_wizard/setup_wizard.js b/frappe/desk/page/setup_wizard/setup_wizard.js index 56cb61696c..7d68fd683c 100644 --- a/frappe/desk/page/setup_wizard/setup_wizard.js +++ b/frappe/desk/page/setup_wizard/setup_wizard.js @@ -49,19 +49,14 @@ frappe.pages["setup-wizard"].on_page_load = function (wrapper) { }; frappe.wizard = new frappe.setup.SetupWizard(wizard_settings); frappe.setup.run_event("after_load"); - let route = frappe.get_route(); - if (route) { - frappe.wizard.show_slide(route[1]); - } + frappe.wizard.show_slide(cint(frappe.get_route()[1])); }, }); }); }; frappe.pages["setup-wizard"].on_page_show = function () { - if (frappe.get_route()[1]) { - frappe.wizard && frappe.wizard.show_slide(frappe.get_route()[1]); - } + frappe.wizard && frappe.wizard.show_slide(cint(frappe.get_route()[1])); }; frappe.setup.on("before_load", function () { @@ -122,12 +117,10 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { show_slide(id) { if (id === this.slides.length) { - // show_slide called on last slide - this.action_on_complete(); return; } super.show_slide(id); - frappe.set_route(this.page_name, id + ""); + frappe.set_route(this.page_name, cstr(id)); } show_hide_prev_next(id) { @@ -403,7 +396,7 @@ frappe.setup.slides_settings = [ }, { fieldname: "enable_telemetry", - label: __("Allow Sending Usage Data for Improving applications"), + label: __("Allow Sending Usage Data for Improving Applications"), fieldtype: "Check", default: 1, }, diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index cb869fb5fc..a50588bdca 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -325,8 +325,8 @@ def load_country(): @frappe.whitelist() def load_user_details(): return { - "full_name": frappe.cache().hget("full_name", "signup"), - "email": frappe.cache().hget("email", "signup"), + "full_name": frappe.cache.hget("full_name", "signup"), + "email": frappe.cache.hget("email", "signup"), } diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 69cdecb6dd..6f4bc716aa 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -119,7 +119,7 @@ def generate_report_result( "report_summary": report_summary, "skip_total_row": skip_total_row or 0, "status": None, - "execution_time": frappe.cache().hget("report_execution_time", report.name) or 0, + "execution_time": frappe.cache.hget("report_execution_time", report.name) or 0, } @@ -170,7 +170,7 @@ def get_script(report_name): return { "script": render_include(script), "html_format": html_format, - "execution_time": frappe.cache().hget("report_execution_time", report_name) or 0, + "execution_time": frappe.cache.hget("report_execution_time", report_name) or 0, } diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 67695e4e73..d347cc188c 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -311,8 +311,8 @@ def validate_and_sanitize_search_inputs(fn): @frappe.whitelist() def get_names_for_mentions(search_term): - users_for_mentions = frappe.cache().get_value("users_for_mentions", get_users_for_mentions) - user_groups = frappe.cache().get_value("user_groups", get_user_groups) + users_for_mentions = frappe.cache.get_value("users_for_mentions", get_users_for_mentions) + user_groups = frappe.cache.get_value("user_groups", get_user_groups) filtered_mentions = [] for mention_data in users_for_mentions + user_groups: diff --git a/frappe/email/__init__.py b/frappe/email/__init__.py index 486db2a784..5c4d6f4c72 100644 --- a/frappe/email/__init__.py +++ b/frappe/email/__init__.py @@ -96,7 +96,7 @@ def get_communication_doctype(doctype, txt, searchfield, start, page_len, filter def get_cached_contacts(txt): - contacts = frappe.cache().hget("contacts", frappe.session.user) or [] + contacts = frappe.cache.hget("contacts", frappe.session.user) or [] if not contacts: return @@ -113,9 +113,9 @@ def get_cached_contacts(txt): def update_contact_cache(contacts): - cached_contacts = frappe.cache().hget("contacts", frappe.session.user) or [] + cached_contacts = frappe.cache.hget("contacts", frappe.session.user) or [] uncached_contacts = [d for d in contacts if d not in cached_contacts] cached_contacts.extend(uncached_contacts) - frappe.cache().hset("contacts", frappe.session.user, cached_contacts) + frappe.cache.hset("contacts", frappe.session.user, cached_contacts) diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index 85241b8194..d61165b787 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -508,7 +508,7 @@ }, { "default": "0", - "depends_on": "eval:!doc.domain && doc.enable_outgoing", + "depends_on": "eval:!doc.domain && doc.enable_outgoing && doc.enable_incoming && doc.use_imap", "fieldname": "append_emails_to_sent_folder", "fieldtype": "Check", "hide_days": 1, @@ -616,7 +616,7 @@ "icon": "fa fa-inbox", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-12-28 14:56:18.754804", + "modified": "2023-06-05 15:03:08.538819", "modified_by": "Administrator", "module": "Email", "name": "Email Account", @@ -639,4 +639,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index faf28afdb3..3f6051ffc8 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -176,7 +176,7 @@ class EmailAccount(Document): def get_incoming_server(self, in_receive=False, email_sync_rule="UNSEEN"): """Returns logged in POP3/IMAP connection object.""" - if frappe.cache().get_value("workers:no-internet") == True: + if frappe.cache.get_value("workers:no-internet") == True: return None oauth_token = self.get_oauth_token() @@ -253,7 +253,7 @@ class EmailAccount(Document): if self.no_failed > 2: self.handle_incoming_connect_error(description=description) else: - frappe.cache().set_value("workers:no-internet", True) + frappe.cache.set_value("workers:no-internet", True) return None else: raise @@ -384,6 +384,10 @@ class EmailAccount(Document): "name": {"conf_names": ("email_sender_name",), "default": "Frappe"}, "auth_method": {"conf_names": ("auth_method"), "default": "Basic"}, "from_site_config": {"default": True}, + "no_smtp_authentication": { + "conf_names": ("disable_mail_smtp_authentication",), + "default": 0, + }, } account_details = {} @@ -436,13 +440,13 @@ class EmailAccount(Document): else: self.set_failed_attempts_count(self.get_failed_attempts_count() + 1) else: - frappe.cache().set_value("workers:no-internet", True) + frappe.cache.set_value("workers:no-internet", True) def set_failed_attempts_count(self, value): - frappe.cache().set(f"{self.name}:email-account-failed-attempts", value) + frappe.cache.set(f"{self.name}:email-account-failed-attempts", value) def get_failed_attempts_count(self): - return cint(frappe.cache().get(f"{self.name}:email-account-failed-attempts")) + return cint(frappe.cache.get(f"{self.name}:email-account-failed-attempts")) def receive(self): """Called by scheduler to receive emails from this EMail account using POP3/IMAP.""" @@ -648,21 +652,16 @@ class EmailAccount(Document): frappe.throw(_("Automatic Linking can be activated only for one Email Account.")) def append_email_to_sent_folder(self, message): - email_server = None - try: - email_server = self.get_incoming_server(in_receive=True) - except Exception: - self.log_error("Email Connection Error") - - if not email_server: + if not (self.enable_incoming and self.use_imap): + # don't try appending if enable incoming and imap is not set return - if email_server.imap: - try: - message = safe_encode(message) - email_server.imap.append("Sent", "\\Seen", imaplib.Time2Internaldate(time.time()), message) - except Exception: - self.log_error("Unable to add to Sent folder") + try: + email_server = self.get_incoming_server(in_receive=True) + message = safe_encode(message) + email_server.imap.append("Sent", "\\Seen", imaplib.Time2Internaldate(time.time()), message) + except Exception: + self.log_error("Unable to add to Sent folder") def get_oauth_token(self): if self.auth_method == "OAuth": @@ -766,9 +765,9 @@ def pull(now=False): """Will be called via scheduler, pull emails from all enabled Email accounts.""" from frappe.integrations.doctype.connected_app.connected_app import has_token - if frappe.cache().get_value("workers:no-internet") == True: + if frappe.cache.get_value("workers:no-internet") == True: if test_internet(): - frappe.cache().set_value("workers:no-internet", False) + frappe.cache.set_value("workers:no-internet", False) return doctype = frappe.qb.DocType("Email Account") diff --git a/frappe/email/doctype/email_domain/email_domain.json b/frappe/email/doctype/email_domain/email_domain.json index c162060436..5cb4c19940 100644 --- a/frappe/email/doctype/email_domain/email_domain.json +++ b/frappe/email/doctype/email_domain/email_domain.json @@ -107,6 +107,7 @@ }, { "default": "0", + "depends_on": "eval:doc.use_imap", "fieldname": "append_emails_to_sent_folder", "fieldtype": "Check", "label": "Append Emails to Sent Folder" @@ -133,7 +134,7 @@ "link_fieldname": "domain" } ], - "modified": "2022-08-19 12:55:06.434541", + "modified": "2023-06-05 12:55:06.434541", "modified_by": "Administrator", "module": "Email", "name": "Email Domain", diff --git a/frappe/email/doctype/email_queue/email_queue.js b/frappe/email/doctype/email_queue/email_queue.js index 2ac4b6f7fe..7d05053d4e 100644 --- a/frappe/email/doctype/email_queue/email_queue.js +++ b/frappe/email/doctype/email_queue/email_queue.js @@ -3,7 +3,7 @@ frappe.ui.form.on("Email Queue", { refresh: function (frm) { - if (["Not Sent", "Partially Sent"].indexOf(frm.doc.status) != -1) { + if (["Not Sent", "Partially Sent"].includes(frm.doc.status)) { let button = frm.add_custom_button("Send Now", function () { frappe.call({ method: "frappe.email.doctype.email_queue.email_queue.send_now", @@ -16,9 +16,7 @@ frappe.ui.form.on("Email Queue", { }, }); }); - } - - if (["Error", "Partially Errored"].indexOf(frm.doc.status) != -1) { + } else if (frm.doc.status == "Error") { let button = frm.add_custom_button("Retry Sending", function () { frm.call({ method: "retry_sending", @@ -26,10 +24,8 @@ frappe.ui.form.on("Email Queue", { name: frm.doc.name, }, btn: button, - callback: function (r) { - if (!r.exc) { - frm.set_value("status", "Not Sent"); - } + callback: function () { + frm.reload_doc(); }, }); }); diff --git a/frappe/email/doctype/email_queue/email_queue.json b/frappe/email/doctype/email_queue/email_queue.json index ac8d656678..15934ee8e7 100644 --- a/frappe/email/doctype/email_queue/email_queue.json +++ b/frappe/email/doctype/email_queue/email_queue.json @@ -55,12 +55,14 @@ "default": "Not Sent", "fieldname": "status", "fieldtype": "Select", + "hidden": 1, "in_list_view": 1, "in_standard_filter": 1, "label": "Status", - "options": "\nNot Sent\nSending\nSent\nError\nExpired" + "options": "Not Sent\nSending\nSent\nPartially Sent\nError\nExpired" }, { + "depends_on": "eval:doc.error", "fieldname": "error", "fieldtype": "Code", "label": "Error" @@ -152,7 +154,7 @@ "idx": 1, "in_create": 1, "links": [], - "modified": "2023-03-16 12:15:17.850292", + "modified": "2023-06-08 15:31:52.789186", "modified_by": "Administrator", "module": "Email", "name": "Email Queue", diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index d254c87a0a..06345f709e 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -123,31 +123,33 @@ class EmailQueue(Document): return True - def send(self, is_background_task: bool = False, smtp_server_instance: SMTPServer = None): + def send(self, smtp_server_instance: SMTPServer = None): """Send emails to recipients.""" if not self.can_send_now(): return - with SendMailContext(self, is_background_task, smtp_server_instance) as ctx: + with SendMailContext(self, smtp_server_instance) as ctx: message = None for recipient in self.recipients: - if not recipient.is_mail_to_be_sent(): + if recipient.is_mail_sent(): continue message = ctx.build_message(recipient.recipient) - method = get_hook_method("override_email_send") - if method: + if method := get_hook_method("override_email_send"): method(self, self.sender, recipient.recipient, message) else: if not frappe.flags.in_test: - ctx.smtp_session.sendmail(from_addr=self.sender, to_addrs=recipient.recipient, msg=message) - ctx.add_to_sent_list(recipient) + ctx.smtp_server.session.sendmail( + from_addr=self.sender, to_addrs=recipient.recipient, msg=message + ) + + ctx.update_recipient_status_to_sent(recipient) if frappe.flags.in_test: frappe.flags.sent_mail = message return - if ctx.email_account_doc.append_emails_to_sent_folder and ctx.sent_to: + if ctx.email_account_doc.append_emails_to_sent_folder: ctx.email_account_doc.append_email_to_sent_folder(message) @staticmethod @@ -177,24 +179,22 @@ class EmailQueue(Document): @task(queue="short") -def send_mail(email_queue_name, is_background_task=False, smtp_server_instance: SMTPServer = None): +def send_mail(email_queue_name, smtp_server_instance: SMTPServer = None): """This is equivalent to EmailQueue.send. This provides a way to make sending mail as a background job. """ record = EmailQueue.find(email_queue_name) - record.send(is_background_task=is_background_task, smtp_server_instance=smtp_server_instance) + record.send(smtp_server_instance=smtp_server_instance) class SendMailContext: def __init__( self, queue_doc: Document, - is_background_task: bool = False, smtp_server_instance: SMTPServer = None, ): self.queue_doc: EmailQueue = queue_doc - self.is_background_task = is_background_task self.email_account_doc = queue_doc.get_email_account() self.smtp_server = smtp_server_instance or self.email_account_doc.get_smtp_server() @@ -203,7 +203,9 @@ class SendMailContext: # Note: smtp session will have to be manually closed self.retain_smtp_session = bool(smtp_server_instance) - self.sent_to = [rec.recipient for rec in self.queue_doc.recipients if rec.is_mail_sent()] + self.sent_to_atleast_one_recipient = any( + rec.recipient for rec in self.queue_doc.recipients if rec.is_mail_sent() + ) def __enter__(self): self.queue_doc.update_status(status="Sending", commit=True) @@ -217,53 +219,35 @@ class SendMailContext: smtplib.SMTPHeloError, JobTimeoutException, ] + trace = "".join(traceback.format_tb(exc_tb)) if exc_tb else None if not self.retain_smtp_session: self.smtp_server.quit() - self.log_exception(exc_type, exc_val, exc_tb) - if exc_type in exceptions: - email_status = "Partially Sent" if self.sent_to else "Not Sent" - self.queue_doc.update_status(status=email_status, commit=True) - elif exc_type: - if self.queue_doc.retry < get_email_retry_limit(): - update_fields = {"status": "Not Sent", "retry": self.queue_doc.retry + 1} - else: - update_fields = {"status": (self.sent_to and "Partially Errored") or "Error"} - self.queue_doc.update_status(**update_fields, commit=True) - else: - email_status = self.is_mail_sent_to_all() and "Sent" - email_status = email_status or (self.sent_to and "Partially Sent") or "Not Sent" - update_fields = { - "status": email_status, - "email_account": self.email_account_doc.name - if self.email_account_doc.is_exists_in_db() - else None, + "status": "Partially Sent" if self.sent_to_atleast_one_recipient else "Not Sent", + "error": trace, } - self.queue_doc.update_status(**update_fields, commit=True) + elif exc_type: + update_fields = {"error": trace} + if self.queue_doc.retry < get_email_retry_limit(): + update_fields.update( + { + "status": "Partially Sent" if self.sent_to_atleast_one_recipient else "Not Sent", + "retry": self.queue_doc.retry + 1, + } + ) + else: + update_fields.update({"status": "Error"}) + else: + update_fields = {"status": "Sent"} - def log_exception(self, exc_type, exc_val, exc_tb): - if exc_type: - traceback_string = "".join(traceback.format_tb(exc_tb)) - traceback_string += f"\n Queue Name: {self.queue_doc.name}" + self.queue_doc.update_status(**update_fields, commit=True) - self.queue_doc.log_error("Email sending failed", traceback_string) - - @property - def smtp_session(self): - if frappe.flags.in_test: - return - return self.smtp_server.session - - def add_to_sent_list(self, recipient): - # Update recipient status + def update_recipient_status_to_sent(self, recipient): + self.sent_to_atleast_one_recipient = True recipient.update_db(status="Sent", commit=True) - self.sent_to.append(recipient.recipient) - - def is_mail_sent_to_all(self): - return sorted(self.sent_to) == sorted(rec.recipient for rec in self.queue_doc.recipients) def get_message_object(self, message): return Parser(policy=SMTPUTF8).parsestr(message) @@ -379,7 +363,7 @@ def retry_sending(name): doc = frappe.get_doc("Email Queue", name) doc.check_permission() - if doc and (doc.status == "Error" or doc.status == "Partially Errored"): + if doc and doc.status == "Error": doc.status = "Not Sent" for d in doc.recipients: if d.status != "Sent": diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 4a2f69a44c..63049fe83d 100644 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -143,7 +143,9 @@ class Newsletter(WebsiteGenerator): """Get list of pending recipients of the newsletter. These recipients may not have receive the newsletter in the previous iteration. """ - return [x for x in self.newsletter_recipients if x not in self.get_queued_recipients()] + + queued_recipients = set(self.get_queued_recipients()) + return [x for x in self.newsletter_recipients if x not in queued_recipients] def queue_all(self): """Queue Newsletter to all the recipients generated from the `Email Group` table""" diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index 2efbf597ec..eb0868a91e 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -42,10 +42,10 @@ class Notification(Document): self.validate_forbidden_types() self.validate_condition() self.validate_standard() - frappe.cache().hdel("notifications", self.document_type) + frappe.cache.hdel("notifications", self.document_type) def on_update(self): - frappe.cache().hdel("notifications", self.document_type) + frappe.cache.hdel("notifications", self.document_type) path = export_module_json(self, self.is_standard, self.module) if path: # js @@ -282,19 +282,8 @@ def get_context(context): email_ids = email_ids_value.replace(",", "\n") recipients = recipients + email_ids.split("\n") - if recipient.cc and "{" in recipient.cc: - recipient.cc = frappe.render_template(recipient.cc, context) - - if recipient.cc: - recipient.cc = recipient.cc.replace(",", "\n") - cc = cc + recipient.cc.split("\n") - - if recipient.bcc and "{" in recipient.bcc: - recipient.bcc = frappe.render_template(recipient.bcc, context) - - if recipient.bcc: - recipient.bcc = recipient.bcc.replace(",", "\n") - bcc = bcc + recipient.bcc.split("\n") + cc.extend(get_emails_from_template(recipient.cc, context)) + bcc.extend(get_emails_from_template(recipient.bcc, context)) # For sending emails to specified role if recipient.receiver_by_role: @@ -389,7 +378,7 @@ def get_context(context): self.message = frappe.utils.md_to_html(self.message) def on_trash(self): - frappe.cache().hdel("notifications", self.document_type) + frappe.cache.hdel("notifications", self.document_type) @frappe.whitelist() @@ -485,3 +474,11 @@ def get_assignees(doc): recipients = [d.allocated_to for d in assignees] return recipients + + +def get_emails_from_template(template, context): + if not template: + return () + + emails = frappe.render_template(template, context) if "{" in template else template + return filter(None, emails.replace(",", "\n").split("\n")) diff --git a/frappe/email/queue.py b/frappe/email/queue.py index 7d4b92baf1..75bb46f00c 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -154,7 +154,6 @@ def flush(from_test=False): frappe.enqueue( method=send_mail, email_queue_name=row.name, - is_background_task=not from_test, now=from_test, job_name=job_name, queue="short", diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 382fd2ac99..525703c8a2 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -459,6 +459,10 @@ class Email: if content_type == "text/plain": self.text_content += self.get_payload(part) + # attach txt file from received email as well aside from saving to text_content if it has filename + if part.get_filename(): + self.get_attachment(part) + elif content_type == "text/html": self.html_content += self.get_payload(part) diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py index 3b22bc4ce4..7b15440ccf 100644 --- a/frappe/email/smtp.py +++ b/frappe/email/smtp.py @@ -13,36 +13,6 @@ class InvalidEmailCredentials(frappe.ValidationError): pass -def send(email, append_to=None, retry=1): - """Deprecated: Send the message or add it to Outbox Email""" - - def _send(retry): - from frappe.email.doctype.email_account.email_account import EmailAccount - - try: - email_account = EmailAccount.find_outgoing(match_by_doctype=append_to) - smtpserver = email_account.get_smtp_server() - - # validate is called in as_string - email_body = email.as_string() - - smtpserver.sess.sendmail(email.sender, email.recipients + (email.cc or []), email_body) - except smtplib.SMTPSenderRefused: - frappe.throw(_("Invalid login or password"), title="Email Failed") - raise - except smtplib.SMTPRecipientsRefused: - frappe.msgprint(_("Invalid recipient address"), title="Email Failed") - raise - except (smtplib.SMTPServerDisconnected, smtplib.SMTPAuthenticationError): - if not retry: - raise - else: - retry = retry - 1 - _send(retry) - - _send(retry) - - class SMTPServer: def __init__( self, diff --git a/frappe/hooks.py b/frappe/hooks.py index f3d55d3d67..03cdf98132 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -196,6 +196,14 @@ scheduler_events = { "0/10 * * * *": [ "frappe.email.doctype.email_account.email_account.pull", ], + # Hourly but offset by 30 minutes + # "30 * * * *": [ + # + # ], + # Daily but offset by 45 minutes + "45 0 * * *": [ + "frappe.core.doctype.log_settings.log_settings.run_log_clean_up", + ], }, "all": [ "frappe.email.queue.flush", @@ -228,7 +236,6 @@ scheduler_events = { "frappe.automation.doctype.auto_repeat.auto_repeat.make_auto_repeat_entry", "frappe.automation.doctype.auto_repeat.auto_repeat.set_auto_repeat_as_completed", "frappe.email.doctype.unhandled_email.unhandled_email.remove_old_unhandled_emails", - "frappe.core.doctype.log_settings.log_settings.run_log_clean_up", ], "daily_long": [ "frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily", diff --git a/frappe/integrations/doctype/google_calendar/google_calendar.py b/frappe/integrations/doctype/google_calendar/google_calendar.py index d1cdd0d9e7..136e39e3a3 100644 --- a/frappe/integrations/doctype/google_calendar/google_calendar.py +++ b/frappe/integrations/doctype/google_calendar/google_calendar.py @@ -119,7 +119,7 @@ def authorize_access(g_calendar, reauthorize=None): ) if not google_calendar.authorization_code or reauthorize: - frappe.cache().hset("google_calendar", "google_calendar", google_calendar.name) + frappe.cache.hset("google_calendar", "google_calendar", google_calendar.name) return get_authentication_url(client_id=google_settings.client_id, redirect_uri=redirect_uri) else: try: @@ -163,7 +163,7 @@ def google_callback(code=None): """ Authorization code is sent to callback as per the API configuration """ - google_calendar = frappe.cache().hget("google_calendar", "google_calendar") + google_calendar = frappe.cache.hget("google_calendar", "google_calendar") frappe.db.set_value("Google Calendar", google_calendar, "authorization_code", code) frappe.db.commit() diff --git a/frappe/integrations/doctype/webhook/__init__.py b/frappe/integrations/doctype/webhook/__init__.py index b9c96190ca..dcad1c8b5c 100644 --- a/frappe/integrations/doctype/webhook/__init__.py +++ b/frappe/integrations/doctype/webhook/__init__.py @@ -20,7 +20,7 @@ def run_webhooks(doc, method): # TODO: remove this hazardous unnecessary cache in flags if frappe.flags.webhooks is None: # load webhooks from cache - webhooks = frappe.cache().get_value("webhooks") + webhooks = frappe.cache.get_value("webhooks") if webhooks is None: # query webhooks webhooks_list = frappe.get_all( @@ -33,7 +33,7 @@ def run_webhooks(doc, method): webhooks = {} for w in webhooks_list: webhooks.setdefault(w.webhook_doctype, []).append(w) - frappe.cache().set_value("webhooks", webhooks) + frappe.cache.set_value("webhooks", webhooks) frappe.flags.webhooks = webhooks diff --git a/frappe/integrations/doctype/webhook/test_webhook.py b/frappe/integrations/doctype/webhook/test_webhook.py index 8284db7fd3..1701c418f7 100644 --- a/frappe/integrations/doctype/webhook/test_webhook.py +++ b/frappe/integrations/doctype/webhook/test_webhook.py @@ -14,7 +14,10 @@ from frappe.tests.utils import FrappeTestCase @contextmanager def get_test_webhook(config): - wh = frappe.get_doc(config).insert() + wh = frappe.get_doc(config) + if not wh.name: + wh.name = frappe.generate_hash() + wh.insert() wh.reload() try: yield wh @@ -37,6 +40,7 @@ class TestWebhook(FrappeTestCase): def create_sample_webhooks(cls): samples_webhooks_data = [ { + "name": frappe.generate_hash(), "webhook_doctype": "User", "webhook_docevent": "after_insert", "request_url": "https://httpbin.org/post", @@ -44,6 +48,7 @@ class TestWebhook(FrappeTestCase): "enabled": True, }, { + "name": frappe.generate_hash(), "webhook_doctype": "User", "webhook_docevent": "after_insert", "request_url": "https://httpbin.org/post", @@ -97,7 +102,7 @@ class TestWebhook(FrappeTestCase): def test_webhook_trigger_with_enabled_webhooks(self): """Test webhook trigger for enabled webhooks""" - frappe.cache().delete_value("webhooks") + frappe.cache.delete_value("webhooks") frappe.flags.webhooks = None # Insert the user to db @@ -206,3 +211,59 @@ class TestWebhook(FrappeTestCase): enqueue_webhook(doc, wh) log = frappe.get_last_doc("Webhook Request Log") self.assertEqual(len(json.loads(log.response)["json"]), 3) + + def test_webhook_with_dynamic_url_enabled(self): + wh_config = { + "doctype": "Webhook", + "webhook_doctype": "Note", + "webhook_docevent": "after_insert", + "enabled": 1, + "request_url": "https://httpbin.org/anything/{{ doc.doctype }}", + "is_dynamic_url": 1, + "request_method": "POST", + "request_structure": "JSON", + "webhook_json": "{}", + "meets_condition": "Yes", + "webhook_headers": [ + { + "key": "Content-Type", + "value": "application/json", + } + ], + } + + with get_test_webhook(wh_config) as wh: + doc = frappe.new_doc("Note") + doc.title = "Test Webhook Note" + enqueue_webhook(doc, wh) + log = frappe.get_last_doc("Webhook Request Log") + self.assertEqual(json.loads(log.response)["url"], "https://httpbin.org/anything/Note") + + def test_webhook_with_dynamic_url_disabled(self): + wh_config = { + "doctype": "Webhook", + "webhook_doctype": "Note", + "webhook_docevent": "after_insert", + "enabled": 1, + "request_url": "https://httpbin.org/anything/{{doc.doctype}}", + "is_dynamic_url": 0, + "request_method": "POST", + "request_structure": "JSON", + "webhook_json": "{}", + "meets_condition": "Yes", + "webhook_headers": [ + { + "key": "Content-Type", + "value": "application/json", + } + ], + } + + with get_test_webhook(wh_config) as wh: + doc = frappe.new_doc("Note") + doc.title = "Test Webhook Note" + enqueue_webhook(doc, wh) + log = frappe.get_last_doc("Webhook Request Log") + self.assertEqual( + json.loads(log.response)["url"], "https://httpbin.org/anything/{{doc.doctype}}" + ) diff --git a/frappe/integrations/doctype/webhook/webhook.json b/frappe/integrations/doctype/webhook/webhook.json index c4fc4f675d..404e0be944 100644 --- a/frappe/integrations/doctype/webhook/webhook.json +++ b/frappe/integrations/doctype/webhook/webhook.json @@ -1,13 +1,12 @@ { "actions": [], - "autoname": "naming_series:", + "autoname": "prompt", "creation": "2017-09-08 16:16:13.060641", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "sb_doc_events", - "naming_series", "webhook_doctype", "cb_doc_events", "webhook_docevent", @@ -18,8 +17,9 @@ "html_condition", "sb_webhook", "request_url", - "request_method", + "is_dynamic_url", "cb_webhook", + "request_method", "request_structure", "sb_security", "enable_security", @@ -45,6 +45,7 @@ { "fieldname": "webhook_doctype", "fieldtype": "Link", + "in_list_view": 1, "label": "DocType", "options": "DocType", "reqd": 1, @@ -135,12 +136,6 @@ "label": "JSON Request Body", "options": "JSON" }, - { - "fieldname": "naming_series", - "fieldtype": "Select", - "label": "Naming Series", - "options": "\nHOOK-.####" - }, { "fieldname": "sb_security", "fieldtype": "Section Break", @@ -202,6 +197,13 @@ { "fieldname": "section_break_28", "fieldtype": "Section Break" + }, + { + "default": "0", + "description": "On checking this option, URL will be treated like a jinja template string", + "fieldname": "is_dynamic_url", + "fieldtype": "Check", + "label": "Is Dynamic URL?" } ], "links": [ @@ -210,11 +212,11 @@ "link_fieldname": "webhook" } ], - "modified": "2023-05-21 15:42:58.844826", + "modified": "2023-06-02 17:25:12.598232", "modified_by": "Administrator", "module": "Integrations", "name": "Webhook", - "naming_rule": "By \"Naming Series\" field", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { @@ -233,6 +235,5 @@ "sort_field": "modified", "sort_order": "DESC", "states": [], - "title_field": "webhook_doctype", "track_changes": 1 } \ No newline at end of file diff --git a/frappe/integrations/doctype/webhook/webhook.py b/frappe/integrations/doctype/webhook/webhook.py index 7d168c659f..a4d198a118 100644 --- a/frappe/integrations/doctype/webhook/webhook.py +++ b/frappe/integrations/doctype/webhook/webhook.py @@ -29,7 +29,7 @@ class Webhook(Document): self.preview_document = None def on_update(self): - frappe.cache().delete_value("webhooks") + frappe.cache.delete_value("webhooks") def validate_docevent(self): if self.webhook_doctype: @@ -115,29 +115,34 @@ def enqueue_webhook(doc, webhook) -> None: webhook: Webhook = frappe.get_doc("Webhook", webhook.get("name")) headers = get_webhook_headers(doc, webhook) data = get_webhook_data(doc, webhook) - r = None + if webhook.is_dynamic_url: + request_url = frappe.render_template(webhook.request_url, get_context(doc)) + else: + request_url = webhook.request_url + + r = None for i in range(3): try: r = requests.request( method=webhook.request_method, - url=webhook.request_url, + url=request_url, data=json.dumps(data, default=str), headers=headers, timeout=5, ) r.raise_for_status() frappe.logger().debug({"webhook_success": r.text}) - log_request(webhook.name, doc.name, webhook.request_url, headers, data, r) + log_request(webhook.name, doc.name, request_url, headers, data, r) break except requests.exceptions.ReadTimeout as e: frappe.logger().debug({"webhook_error": e, "try": i + 1}) - log_request(webhook.name, doc.name, webhook.request_url, headers, data) + log_request(webhook.name, doc.name, request_url, headers, data) except Exception as e: frappe.logger().debug({"webhook_error": e, "try": i + 1}) - log_request(webhook.name, doc.name, webhook.request_url, headers, data, r) + log_request(webhook.name, doc.name, request_url, headers, data, r) sleep(3 * i + 1) if i != 2: continue diff --git a/frappe/integrations/workspace/integrations/integrations.json b/frappe/integrations/workspace/integrations/integrations.json index 8d1dfd64af..73a1a393a5 100644 --- a/frappe/integrations/workspace/integrations/integrations.json +++ b/frappe/integrations/workspace/integrations/integrations.json @@ -2,12 +2,14 @@ "charts": [], "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Backup\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Google Services\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Authentication\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}}]", "creation": "2020-03-02 15:16:18.714190", + "custom_blocks": [], "docstatus": 0, "doctype": "Workspace", "for_user": "", "hide_custom": 0, "icon": "integration", "idx": 0, + "is_hidden": 0, "label": "Integrations", "links": [ { @@ -197,17 +199,18 @@ "type": "Link" } ], - "modified": "2022-07-23 18:00:28.805405", + "modified": "2023-05-24 14:58:55.910408", "modified_by": "Administrator", "module": "Integrations", "name": "Integrations", + "number_cards": [], "owner": "Administrator", "parent_page": "", "public": 1, "quick_lists": [], "restrict_to_domain": "", "roles": [], - "sequence_id": 15.0, + "sequence_id": 20.0, "shortcuts": [], "title": "Integrations" } \ No newline at end of file diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 811ba5894c..63188e749d 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -530,7 +530,7 @@ class BaseDocument: if not ignore_if_duplicate: frappe.msgprint( - _("{0} {1} already exists").format(self.doctype, frappe.bold(self.name)), + _("{0} {1} already exists").format(_(self.doctype), frappe.bold(self.name)), title=_("Duplicate Name"), indicator="red", ) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 340b3a97f4..ca1969abcf 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -730,18 +730,30 @@ class DatabaseQuery: lft, rgt = frappe.db.get_value(ref_doctype, f.value, ["lft", "rgt"]) # Get descendants elements of a DocType with a tree structure - if f.operator.lower() in ("descendants of", "not descendants of"): - result = frappe.get_all( - ref_doctype, filters={"lft": [">", lft], "rgt": ["<", rgt]}, order_by="`lft` ASC" + if f.operator.lower() in ( + "descendants of", + "not descendants of", + "descendants of (inclusive)", + ): + nodes = frappe.get_all( + ref_doctype, + filters={"lft": [">", lft], "rgt": ["<", rgt]}, + order_by="`lft` ASC", + pluck="name", ) + if f.operator.lower() == "descendants of (inclusive)": + nodes += [f.value] else: # Get ancestor elements of a DocType with a tree structure - result = frappe.get_all( - ref_doctype, filters={"lft": ["<", lft], "rgt": [">", rgt]}, order_by="`lft` DESC" + nodes = frappe.get_all( + ref_doctype, + filters={"lft": ["<", lft], "rgt": [">", rgt]}, + order_by="`lft` DESC", + pluck="name", ) fallback = "''" - value = [frappe.db.escape((cstr(v.name) or "").strip(), percent=False) for v in result] + value = [frappe.db.escape((cstr(v)).strip(), percent=False) for v in nodes] if len(value): value = f"({', '.join(value)})" else: diff --git a/frappe/model/document.py b/frappe/model/document.py index 8477d35418..6ef81314f1 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -959,17 +959,17 @@ class Document(BaseDocument): filters={"enabled": 1, "document_type": self.doctype}, ) - self.flags.notifications = frappe.cache().hget( - "notifications", self.doctype, _get_notifications - ) + self.flags.notifications = frappe.cache.hget("notifications", self.doctype, _get_notifications) if not self.flags.notifications: return def _evaluate_alert(alert): - if not alert.name in self.flags.notifications_executed: - evaluate_alert(self, alert.name, alert.event) - self.flags.notifications_executed.append(alert.name) + if alert.name in self.flags.notifications_executed: + return + + evaluate_alert(self, alert.name, alert.event) + self.flags.notifications_executed.append(alert.name) event_map = { "on_update": "Save", @@ -1198,7 +1198,6 @@ class Document(BaseDocument): if notify: self.notify_update() - self.clear_cache() if commit: frappe.db.commit() diff --git a/frappe/model/mapper.py b/frappe/model/mapper.py index 9df79ef276..4b9051f59c 100644 --- a/frappe/model/mapper.py +++ b/frappe/model/mapper.py @@ -62,6 +62,7 @@ def get_mapped_doc( postprocess=None, ignore_permissions=False, ignore_child_tables=False, + cached=False, ): apply_strict_user_permissions = frappe.get_system_settings("apply_strict_user_permissions") @@ -79,7 +80,10 @@ def get_mapped_doc( ): target_doc.raise_no_permission_to("create") - source_doc = frappe.get_doc(from_doctype, from_docname) + if cached: + source_doc = frappe.get_cached_doc(from_doctype, from_docname) + else: + source_doc = frappe.get_doc(from_doctype, from_docname) if not ignore_permissions: if not source_doc.has_permission("read"): @@ -255,7 +259,9 @@ def map_fetch_fields(target_doc, df, no_copy_fields): def map_child_doc(source_d, target_parent, table_map, source_parent=None): target_child_doctype = table_map["doctype"] target_parentfield = target_parent.get_parentfield_of_doctype(target_child_doctype) - target_d = frappe.new_doc(target_child_doctype, target_parent, target_parentfield) + target_d = frappe.new_doc( + target_child_doctype, parent_doc=target_parent, parentfield=target_parentfield + ) map_doc(source_d, target_d, table_map, source_parent) diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 32c1326170..d97146c480 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -59,11 +59,11 @@ def get_meta(doctype, cached=True) -> "Meta": if not cached: return Meta(doctype) - if meta := frappe.cache().hget("doctype_meta", doctype): + if meta := frappe.cache.hget("doctype_meta", doctype): return meta meta = Meta(doctype) - frappe.cache().hset("doctype_meta", doctype, meta) + frappe.cache.hset("doctype_meta", doctype, meta) return meta @@ -134,13 +134,10 @@ class Meta(Document): self.init_field_caches() return - has_custom_fields = self.add_custom_fields() + self.add_custom_fields() self.apply_property_setters() self.init_field_caches() - - if has_custom_fields: - self.sort_fields() - + self.sort_fields() self.get_valid_columns() self.set_custom_permissions() self.add_custom_links_and_actions() @@ -361,7 +358,6 @@ class Meta(Document): return self.extend("fields", custom_fields) - return True def apply_property_setters(self): """ @@ -372,11 +368,11 @@ class Meta(Document): if not frappe.db.table_exists("Property Setter"): return - property_setters = frappe.db.sql( - """select * from `tabProperty Setter` where - doc_type=%s""", - (self.name,), - as_dict=1, + property_setters = frappe.db.get_values( + "Property Setter", + filters={"doc_type": self.name}, + fieldname="*", + as_dict=True, ) if not property_setters: @@ -452,14 +448,56 @@ class Meta(Document): self._table_fields = self.get("fields", {"fieldtype": ["in", table_fields]}) def sort_fields(self): - """Sort custom fields on the basis of insert_after""" + """ + Sort fields on the basis of following rules (priority descending): + - `field_order` property setter + - `insert_after` computed based on default order for standard fields + - `insert_after` property for custom fields + """ - field_order = [] + if field_order := getattr(self, "field_order", []): + field_order = [fieldname for fieldname in json.loads(field_order) if fieldname in self._fields] + + # all fields match, best case scenario + if len(field_order) == len(self.fields): + self._update_fields_based_on_order(field_order) + return + + # if the first few standard fields are not in the field order, prepare to prepend them + if self.fields[0].fieldname not in field_order: + fields_to_prepend = [] + standard_field_found = False + + for fieldname, field in self._fields.items(): + if getattr(field, "is_custom_field", False): + # all custom fields from here on + break + + if fieldname in field_order: + standard_field_found = True + break + + fields_to_prepend.append(fieldname) + + if standard_field_found: + field_order = fields_to_prepend + field_order + else: + # worst case scenario, invalidate field_order + field_order = fields_to_prepend + + existing_fields = set(field_order) if field_order else False insert_after_map = {} - for field in self.fields: + for index, field in enumerate(self.fields): + if existing_fields and field.fieldname in existing_fields: + continue + if not getattr(field, "is_custom_field", False): - field_order.append(field.fieldname) + if existing_fields: + # compute insert_after from previous field + insert_after_map.setdefault(self.fields[index - 1].fieldname, []).append(field.fieldname) + else: + field_order.append(field.fieldname) elif insert_after := getattr(field, "insert_after", None): insert_after_map.setdefault(insert_after, []).append(field.fieldname) @@ -471,6 +509,9 @@ class Meta(Document): if insert_after_map: _update_field_order_based_on_insert_after(field_order, insert_after_map) + self._update_fields_based_on_order(field_order) + + def _update_fields_based_on_order(self, field_order): sorted_fields = [] for idx, fieldname in enumerate(field_order, 1): @@ -814,7 +855,7 @@ def trim_tables(doctype=None, dry_run=False, quiet=False): def trim_table(doctype, dry_run=True): - frappe.cache().hdel("table_columns", f"tab{doctype}") + frappe.cache.hdel("table_columns", f"tab{doctype}") ignore_fields = default_fields + optional_fields + child_table_fields columns = frappe.db.get_table_columns(doctype) fields = frappe.get_meta(doctype, cached=False).get_fieldnames_with_value() diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 6272c9cb7d..0b344b892a 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -11,6 +11,28 @@ from frappe.modules.import_file import import_file_by_path from frappe.modules.patch_handler import _patch_mode from frappe.utils import update_progress_bar +IMPORTABLE_DOCTYPES = [ + ("core", "doctype"), + ("core", "page"), + ("core", "report"), + ("desk", "dashboard_chart_source"), + ("printing", "print_format"), + ("website", "web_page"), + ("website", "website_theme"), + ("website", "web_form"), + ("website", "web_template"), + ("email", "notification"), + ("printing", "print_style"), + ("desk", "workspace"), + ("desk", "onboarding_step"), + ("desk", "module_onboarding"), + ("desk", "form_tour"), + ("custom", "client_script"), + ("core", "server_script"), + ("custom", "custom_field"), + ("custom", "property_setter"), +] + def sync_all(force=0, reset_permissions=False): _patch_mode(True) @@ -71,6 +93,11 @@ def sync_for(app_name, force=0, reset_permissions=False): ]: files.append(os.path.join(FRAPPE_PATH, "desk", "doctype", desk_module, f"{desk_module}.json")) + for module_name, document_type in IMPORTABLE_DOCTYPES: + file = os.path.join(FRAPPE_PATH, module_name, "doctype", document_type, f"{document_type}.json") + if file not in files: + files.append(file) + for module_name in frappe.local.app_modules.get(app_name) or []: folder = os.path.dirname(frappe.get_module(app_name + "." + module_name).__file__) files = get_doc_files(files=files, start_path=folder) @@ -97,29 +124,7 @@ def get_doc_files(files, start_path): files = files or [] - # load in sequence - warning for devs - document_types = [ - "doctype", - "page", - "report", - "dashboard_chart_source", - "print_format", - "web_page", - "website_theme", - "web_form", - "web_template", - "notification", - "print_style", - "workspace", - "onboarding_step", - "module_onboarding", - "form_tour", - "client_script", - "server_script", - "custom_field", - "property_setter", - ] - for doctype in document_types: + for _module, doctype in IMPORTABLE_DOCTYPES: doctype_path = os.path.join(start_path, doctype) if os.path.exists(doctype_path): for docname in os.listdir(doctype_path): diff --git a/frappe/model/utils/link_count.py b/frappe/model/utils/link_count.py index 9a7694b9f8..65b5092d46 100644 --- a/frappe/model/utils/link_count.py +++ b/frappe/model/utils/link_count.py @@ -1,6 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +from collections import defaultdict + import frappe ignore_doctypes = ("DocType", "Print Format", "Role", "Module Def", "Communication", "ToDo") @@ -8,46 +10,44 @@ ignore_doctypes = ("DocType", "Print Format", "Role", "Module Def", "Communicati def notify_link_count(doctype, name): """updates link count for given document""" - if hasattr(frappe.local, "link_count"): - if (doctype, name) in frappe.local.link_count: - frappe.local.link_count[(doctype, name)] += 1 - else: - frappe.local.link_count[(doctype, name)] = 1 + if not hasattr(frappe.local, "_link_count"): + frappe.local._link_count = defaultdict(int) + frappe.db.after_commit.add(flush_local_link_count) + + frappe.local._link_count[(doctype, name)] += 1 def flush_local_link_count(): """flush from local before ending request""" - if not getattr(frappe.local, "link_count", None): + new_links = getattr(frappe.local, "_link_count", None) + if not new_links: return - link_count = frappe.cache().get_value("_link_count") - if not link_count: - link_count = {} + link_count = frappe.cache.get_value("_link_count") or {} - for key, value in frappe.local.link_count.items(): - if key in link_count: - link_count[key] += frappe.local.link_count[key] - else: - link_count[key] = frappe.local.link_count[key] + for key, value in new_links.items(): + if key in link_count: + link_count[key] += value + else: + link_count[key] = value - frappe.cache().set_value("_link_count", link_count) + frappe.cache.set_value("_link_count", link_count) + new_links.clear() def update_link_count(): """increment link count in the `idx` column for the given document""" - link_count = frappe.cache().get_value("_link_count") + link_count = frappe.cache.get_value("_link_count") if link_count: - for key, count in link_count.items(): - if key[0] not in ignore_doctypes: + for (doctype, name), count in link_count.items(): + if doctype not in ignore_doctypes: try: - frappe.db.sql( - f"update `tab{key[0]}` set idx = idx + {count} where name=%s", - key[1], - auto_commit=1, - ) + table = frappe.qb.DocType(doctype) + frappe.qb.update(table).set(table.idx, table.idx + count).where(table.name == name).run() + frappe.db.commit() except Exception as e: if not frappe.db.is_table_missing(e): # table not found, single raise e # reset the count - frappe.cache().delete_value("_link_count") + frappe.cache.delete_value("_link_count") diff --git a/frappe/model/utils/user_settings.py b/frappe/model/utils/user_settings.py index c12c7e27ba..02bc67b929 100644 --- a/frappe/model/utils/user_settings.py +++ b/frappe/model/utils/user_settings.py @@ -11,7 +11,7 @@ filter_dict = {"doctype": 0, "docfield": 1, "operator": 2, "value": 3} def get_user_settings(doctype, for_update=False): - user_settings = frappe.cache().hget("_user_settings", f"{doctype}::{frappe.session.user}") + user_settings = frappe.cache.hget("_user_settings", f"{doctype}::{frappe.session.user}") if user_settings is None: user_settings = frappe.db.sql( @@ -41,12 +41,12 @@ def update_user_settings(doctype, user_settings, for_update=False): current.update(user_settings) - frappe.cache().hset("_user_settings", f"{doctype}::{frappe.session.user}", json.dumps(current)) + frappe.cache.hset("_user_settings", f"{doctype}::{frappe.session.user}", json.dumps(current)) def sync_user_settings(): """Sync from cache to database (called asynchronously via the browser)""" - for key, data in frappe.cache().hgetall("_user_settings").items(): + for key, data in frappe.cache.hgetall("_user_settings").items(): key = safe_decode(key) doctype, user = key.split("::") # WTF? frappe.db.multisql( @@ -99,4 +99,4 @@ def update_user_settings_data( ) # clear that user settings from the redis cache - frappe.cache().hset("_user_settings", f"{user_setting.doctype}::{user_setting.user}", None) + frappe.cache.hset("_user_settings", f"{user_setting.doctype}::{user_setting.user}", None) diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index 8338157996..1a52077331 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -26,12 +26,12 @@ class WorkflowPermissionError(frappe.ValidationError): def get_workflow_name(doctype): - workflow_name = frappe.cache().hget("workflow", doctype) + workflow_name = frappe.cache.hget("workflow", doctype) if workflow_name is None: workflow_name = frappe.db.get_value( "Workflow", {"document_type": doctype, "is_active": 1}, "name" ) - frappe.cache().hset("workflow", doctype, workflow_name or "") + frappe.cache.hset("workflow", doctype, workflow_name or "") return workflow_name @@ -102,6 +102,7 @@ def is_transition_condition_satisfied(transition, doc) -> bool: def apply_workflow(doc, action): """Allow workflow action on the current doc""" doc = frappe.get_doc(frappe.parse_json(doc)) + doc.load_from_db() workflow = get_workflow(doc.doctype) transitions = get_transitions(doc, workflow) user = frappe.session.user @@ -227,10 +228,10 @@ def send_email_alert(workflow_name): def get_workflow_field_value(workflow_name, field): - value = frappe.cache().hget("workflow_" + workflow_name, field) + value = frappe.cache.hget("workflow_" + workflow_name, field) if value is None: value = frappe.db.get_value("Workflow", workflow_name, field) - frappe.cache().hset("workflow_" + workflow_name, field, value) + frappe.cache.hset("workflow_" + workflow_name, field, value) return value diff --git a/frappe/modules/patch_handler.py b/frappe/modules/patch_handler.py index 8b25ffcb8e..bf0bd3d869 100644 --- a/frappe/modules/patch_handler.py +++ b/frappe/modules/patch_handler.py @@ -101,40 +101,43 @@ def get_patches_from_app(app: str, patch_type: PatchType | None = None) -> list[ 2. plain text file with each line representing a patch. """ - patches_txt = frappe.get_pymodule_path(app, "patches.txt") + patches_file = frappe.get_pymodule_path(app, "patches.txt") try: - # Attempt to parse as ini file with pre/post patches - # allow_no_value: patches are not key value pairs - # delimiters = '\n' to avoid treating default `:` and `=` in execute as k:v delimiter - parser = configparser.ConfigParser(allow_no_value=True, delimiters="\n") - # preserve case - parser.optionxform = str - parser.read(patches_txt) - - # empty file - if not parser.sections(): - return [] - - if not patch_type: - return [patch for patch in parser[PatchType.pre_model_sync.value]] + [ - patch for patch in parser[PatchType.post_model_sync.value] - ] - - if patch_type.value in parser.sections(): - return [patch for patch in parser[patch_type.value]] - else: - frappe.throw(frappe._("Patch type {} not found in patches.txt").format(patch_type)) - + return parse_as_configfile(patches_file, patch_type) except configparser.MissingSectionHeaderError: # treat as old format with each line representing a single patch # backward compatbility with old patches.txt format if not patch_type or patch_type == PatchType.pre_model_sync: - return frappe.get_file_items(patches_txt) + return frappe.get_file_items(patches_file) return [] +def parse_as_configfile(patches_file: str, patch_type: PatchType | None = None) -> list[str]: + # Attempt to parse as ini file with pre/post patches + # allow_no_value: patches are not key value pairs + # delimiters = '\n' to avoid treating default `:` and `=` in execute as k:v delimiter + parser = configparser.ConfigParser(allow_no_value=True, delimiters="\n") + # preserve case + parser.optionxform = str + parser.read(patches_file) + + # empty file + if not parser.sections(): + return [] + + if not patch_type: + return [patch for patch in parser[PatchType.pre_model_sync.value]] + [ + patch for patch in parser[PatchType.post_model_sync.value] + ] + + if patch_type.value in parser.sections(): + return [patch for patch in parser[patch_type.value]] + else: + frappe.throw(frappe._("Patch type {} not found in patches.txt").format(patch_type)) + + def reload_doc(args): import frappe.modules diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index 57d3e8f7ad..40e3b32690 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -214,7 +214,7 @@ def export_doc(doctype, name, module=None): def get_doctype_module(doctype: str) -> str: """Returns **Module Def** name of given doctype.""" - doctype_module_map = frappe.cache().get_value( + doctype_module_map = frappe.cache.get_value( "doctype_modules", generator=lambda: dict(frappe.qb.from_("DocType").select("name", "module").run()), ) diff --git a/frappe/monitor.py b/frappe/monitor.py index b93ba1d3bb..da2deb859e 100644 --- a/frappe/monitor.py +++ b/frappe/monitor.py @@ -106,22 +106,22 @@ class Monitor: traceback.print_exc() def store(self): - if frappe.cache().llen(MONITOR_REDIS_KEY) > MONITOR_MAX_ENTRIES: - frappe.cache().ltrim(MONITOR_REDIS_KEY, 1, -1) + if frappe.cache.llen(MONITOR_REDIS_KEY) > MONITOR_MAX_ENTRIES: + frappe.cache.ltrim(MONITOR_REDIS_KEY, 1, -1) serialized = json.dumps(self.data, sort_keys=True, default=str, separators=(",", ":")) - frappe.cache().rpush(MONITOR_REDIS_KEY, serialized) + frappe.cache.rpush(MONITOR_REDIS_KEY, serialized) def flush(): try: # Fetch all the logs without removing from cache - logs = frappe.cache().lrange(MONITOR_REDIS_KEY, 0, -1) + logs = frappe.cache.lrange(MONITOR_REDIS_KEY, 0, -1) if logs: logs = list(map(frappe.safe_decode, logs)) with open(log_file(), "a", os.O_NONBLOCK) as f: f.write("\n".join(logs)) f.write("\n") # Remove fetched entries from cache - frappe.cache().ltrim(MONITOR_REDIS_KEY, len(logs) - 1, -1) + frappe.cache.ltrim(MONITOR_REDIS_KEY, len(logs) - 1, -1) except Exception: traceback.print_exc() diff --git a/frappe/patches.txt b/frappe/patches.txt index fa9d884386..ba9c5180ba 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -183,7 +183,6 @@ frappe.patches.v13_0.reset_corrupt_defaults frappe.patches.v13_0.remove_share_for_std_users execute:frappe.reload_doc('custom', 'doctype', 'custom_field') frappe.email.doctype.email_queue.patches.drop_search_index_on_message_id -frappe.patches.v14_0.update_workspace2 # 20.09.2021 frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021 frappe.patches.v14_0.transform_todo_schema frappe.patches.v14_0.remove_post_and_post_comment @@ -196,10 +195,12 @@ frappe.patches.v14_0.update_webforms frappe.patches.v14_0.delete_payment_gateways frappe.patches.v15_0.remove_event_streaming frappe.patches.v15_0.copy_disable_prepared_report_to_prepared_report +execute:frappe.reload_doc("desk", "doctype", "Form Tour") [post_model_sync] execute:frappe.get_doc('Role', 'Guest').save() # remove desk access frappe.core.doctype.role.patches.v13_set_default_desk_properties +frappe.patches.v14_0.update_workspace2 # 06.06.2023 frappe.patches.v14_0.drop_data_import_legacy frappe.patches.v14_0.copy_mail_data #08.03.21 frappe.patches.v14_0.update_github_endpoints #08-11-2021 @@ -223,4 +224,6 @@ frappe.patches.v14_0.disable_email_accounts_with_oauth execute:frappe.delete_doc("Page", "translation-tool", force=1) frappe.patches.v15_0.remove_prepared_report_settings_from_system_settings frappe.patches.v14_0.remove_manage_subscriptions_from_navbar -frappe.patches.v15_0.remove_background_jobs_from_dropdown \ No newline at end of file +frappe.patches.v15_0.remove_background_jobs_from_dropdown +frappe.desk.doctype.form_tour.patches.introduce_ui_tours +execute:frappe.delete_doc_if_exists("Workspace", "Customization") \ No newline at end of file diff --git a/frappe/patches/v11_0/apply_customization_to_custom_doctype.py b/frappe/patches/v11_0/apply_customization_to_custom_doctype.py index d652efcef7..90986e065a 100644 --- a/frappe/patches/v11_0/apply_customization_to_custom_doctype.py +++ b/frappe/patches/v11_0/apply_customization_to_custom_doctype.py @@ -44,7 +44,7 @@ def execute(): if field: field.update(cf) else: - df = frappe.new_doc("DocField", meta, "fields") + df = frappe.new_doc("DocField", parent_doc=meta, parentfield="fields") df.update(cf) meta.fields.append(df) frappe.db.delete("Custom Field", {"name": cf.name}) diff --git a/frappe/patches/v14_0/update_workspace2.py b/frappe/patches/v14_0/update_workspace2.py index a6c9db503f..1a322c1f84 100644 --- a/frappe/patches/v14_0/update_workspace2.py +++ b/frappe/patches/v14_0/update_workspace2.py @@ -5,28 +5,15 @@ from frappe import _ def execute(): - frappe.reload_doc("desk", "doctype", "workspace", force=True) - - child_tables = frappe.get_all( - "DocField", - pluck="options", - filters={"fieldtype": ["in", frappe.model.table_fields], "parent": "Workspace"}, - ) - - for child_table in child_tables: - if child_table != "Has Role": - frappe.reload_doc("desk", "doctype", child_table, force=True) - - for seq, workspace in enumerate(frappe.get_all("Workspace", order_by="name asc")): + for seq, workspace in enumerate(frappe.get_all("Workspace")): doc = frappe.get_doc("Workspace", workspace.name) content = create_content(doc) update_workspace(doc, seq, content) - frappe.db.commit() def create_content(doc): content = [] - if doc.onboarding: + if doc.get("onboarding"): content.append({"type": "onboarding", "data": {"onboarding_name": doc.onboarding, "col": 12}}) if doc.charts: invalid_links = [] @@ -44,7 +31,7 @@ def create_content(doc): content.append( { "type": "header", - "data": {"text": doc.shortcuts_label or _("Your Shortcuts"), "level": 4, "col": 12}, + "data": {"text": doc.get("shortcuts_label") or _("Your Shortcuts"), "level": 4, "col": 12}, } ) for s in doc.shortcuts: @@ -60,7 +47,7 @@ def create_content(doc): content.append( { "type": "header", - "data": {"text": doc.cards_label or _("Reports & Masters"), "level": 4, "col": 12}, + "data": {"text": doc.get("cards_label") or _("Reports & Masters"), "level": 4, "col": 12}, } ) for l in doc.links: @@ -74,7 +61,12 @@ def create_content(doc): def update_workspace(doc, seq, content): - if not doc.title and not doc.content and not doc.is_standard and not doc.public: + if ( + not doc.title + and (not doc.content or doc.content == "[]") + and not doc.get("is_standard") + and not doc.public + ): doc.sequence_id = seq + 1 doc.content = json.dumps(content) doc.public = 0 if doc.for_user else 1 diff --git a/frappe/permissions.py b/frappe/permissions.py index 431132a0ae..67ed972c32 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -433,7 +433,7 @@ def get_roles(user=None, with_standard=True): ) return roles + ["All", "Guest"] - roles = frappe.cache().hget("roles", user, get) + roles = frappe.cache.hget("roles", user, get) # filter standard if required if not with_standard: diff --git a/frappe/printing/doctype/print_format/print_format.json b/frappe/printing/doctype/print_format/print_format.json index 7f4408c950..664692ec45 100644 --- a/frappe/printing/doctype/print_format/print_format.json +++ b/frappe/printing/doctype/print_format/print_format.json @@ -245,6 +245,7 @@ "default": "14", "fieldname": "font_size", "fieldtype": "Int", + "hidden": 1, "label": "Font Size" }, { @@ -258,7 +259,7 @@ "icon": "fa fa-print", "idx": 1, "links": [], - "modified": "2022-11-09 15:29:46.709305", + "modified": "2023-05-31 15:40:52.919029", "modified_by": "Administrator", "module": "Printing", "name": "Print Format", diff --git a/frappe/printing/doctype/print_settings/print_settings.json b/frappe/printing/doctype/print_settings/print_settings.json index f45de7637d..a67440b54e 100644 --- a/frappe/printing/doctype/print_settings/print_settings.json +++ b/frappe/printing/doctype/print_settings/print_settings.json @@ -47,7 +47,7 @@ "default": "1", "fieldname": "repeat_header_footer", "fieldtype": "Check", - "label": "Repeat Header and Footer in PDF" + "label": "Repeat Header and Footer" }, { "fieldname": "column_break_4", @@ -176,7 +176,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-09-17 12:59:14.783694", + "modified": "2023-05-30 14:55:25.740691", "modified_by": "Administrator", "module": "Printing", "name": "Print Settings", @@ -193,5 +193,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index 8e5e165c78..f930359b58 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -91,7 +91,7 @@ frappe.ui.form.PrintView = class { fieldtype: "Link", fieldname: "print_format", options: "Print Format", - placeholder: __("Print Format"), + label: __("Print Format"), get_query: () => { return { filters: { doc_type: this.frm.doctype } }; }, @@ -101,7 +101,7 @@ frappe.ui.form.PrintView = class { this.language_selector = this.add_sidebar_item({ fieldtype: "Link", fieldname: "language", - placeholder: __("Language"), + label: __("Language"), options: "Language", change: () => { this.set_user_lang(); @@ -109,12 +109,27 @@ frappe.ui.form.PrintView = class { }, }).$input; + let description = ""; + if (!cint(this.print_settings.repeat_header_footer)) { + description = + "
" + + __("Footer might not be visible as {0} option is disabled
", [ + `${__( + "Repeat Header and Footer" + )}`, + ]); + } + const print_view = this; this.letterhead_selector = this.add_sidebar_item({ fieldtype: "Link", fieldname: "letterhead", options: "Letter Head", - placeholder: __("Letter Head"), - change: () => this.preview(), + label: __("Letter Head"), + description: description, + change: function () { + this.set_description(this.get_value() ? description : ""); + print_view.preview(); + }, }).$input; this.sidebar_dynamic_section = $(`
`).appendTo( this.sidebar diff --git a/frappe/public/js/form_builder/FormBuilder.vue b/frappe/public/js/form_builder/FormBuilder.vue index fe7eaaa682..2a1441c51a 100644 --- a/frappe/public/js/form_builder/FormBuilder.vue +++ b/frappe/public/js/form_builder/FormBuilder.vue @@ -24,31 +24,7 @@ whenever(() => meta_s.value || ctrl_s.value, () => { function setup_change_doctype_dialog() { store.page.$title_area.on("click", () => { - let dialog = new frappe.ui.Dialog({ - title: __("Change DocType"), - fields: [ - { - label: __("Select DocType"), - fieldname: "doctype", - fieldtype: "Link", - options: "DocType", - default: store.doctype || null - }, - { - label: __("Customize"), - fieldname: "customize", - fieldtype: "Check", - default: store.is_customize_form - } - ], - primary_action_label: __("Change"), - primary_action({ doctype }) { - dialog.hide(); - let customize = dialog.get_value("customize") ? "customize" : ""; - frappe.set_route("form-builder", doctype, customize); - } - }); - dialog.show(); + frappe.pages["form-builder"].select_doctype(); }); } diff --git a/frappe/public/js/form_builder/form_builder.bundle.js b/frappe/public/js/form_builder/form_builder.bundle.js index 0e54bdd80b..87767d6961 100644 --- a/frappe/public/js/form_builder/form_builder.bundle.js +++ b/frappe/public/js/form_builder/form_builder.bundle.js @@ -57,10 +57,10 @@ class FormBuilder { } ); - this.customize_form_btn = this.page.add_menu_item(__("Switch to Customize Form"), () => { + this.customize_form_btn = this.page.add_menu_item(__("Switch to Customize"), () => { frappe.set_route("form-builder", this.doctype, "customize"); }); - this.doctype_form_btn = this.page.add_menu_item(__("Switch to DocType Form"), () => { + this.doctype_form_btn = this.page.add_menu_item(__("Switch to DocType"), () => { frappe.set_route("form-builder", this.doctype); }); @@ -106,21 +106,39 @@ class FormBuilder { this.reset_changes_btn.hide(); } - // toggle doctype / customize form btn based on url - this.customize_form_btn.toggle(!this.store.is_customize_form); - this.doctype_form_btn.toggle(this.store.is_customize_form); + // hide all buttons + this.go_to_doctype_list_btn.hide(); + this.customize_form_btn.hide(); + this.doctype_form_btn.hide(); + this.go_to_doctype_btn.hide(); + this.go_to_customize_form_btn.hide(); - // hide customize form & Go to customize form btn + this.page.menu_btn_group.show(); + let hide_menu = true; + + // show customize form & Go to customize form btn if ( this.store.doc && - (this.store.doc.custom || - this.store.doc.issingle || - in_list(frappe.model.core_doctypes_list, this.doctype)) + !this.store.doc.custom && + !this.store.doc.issingle && + !this.store.is_customize_form && + !in_list(frappe.model.core_doctypes_list, this.doctype) ) { - this.customize_form_btn.hide(); - if (this.doctype != "Customize Form") { - this.go_to_customize_form_btn.hide(); - } + this.customize_form_btn.show(); + this.go_to_customize_form_btn.show(); + hide_menu = false; + } + + // show doctype form & Go to doctype form btn + if ( + this.store.doc && + !this.store.doc.custom && + !this.store.doc.issingle && + this.store.is_customize_form + ) { + this.doctype_form_btn.show(); + this.go_to_doctype_btn.show(); + hide_menu = false; } // show Go to {0} List or Go to {0} button @@ -129,7 +147,11 @@ class FormBuilder { ? __("Go to {0}", [__(this.doctype)]) : __("Go to {0} List", [__(this.doctype)]); - this.go_to_doctype_list_btn.text(label); + this.go_to_doctype_list_btn.text(label).show(); + } + + if (hide_menu && window.matchMedia("(min-device-width: 992px)").matches) { + this.page.menu_btn_group.hide(); } // toggle preview btn text diff --git a/frappe/public/js/form_builder/store.js b/frappe/public/js/form_builder/store.js index d69f32eeb1..b60bdc7919 100644 --- a/frappe/public/js/form_builder/store.js +++ b/frappe/public/js/form_builder/store.js @@ -122,6 +122,7 @@ export const useStore = defineStore("form-builder-store", () => { } function setup_breadcrumbs() { + !is_customize_form.value && frappe.model.init_doctype("DocType"); let breadcrumbs = `
  • ${__("DocType")}
  • ${__(doctype.value)}
  • diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index a299e7c5ae..fba4678cbb 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -38,7 +38,6 @@ frappe.Application = class Application { this.load_user_permissions(); this.make_nav_bar(); this.set_favicon(); - this.setup_analytics(); this.set_fullwidth_if_enabled(); this.add_browser_class(); this.setup_energy_point_listeners(); @@ -74,6 +73,23 @@ frappe.Application = class Application { // page container this.make_page_container(); + if ( + !window.Cypress && + frappe.boot.onboarding_tours && + frappe.boot.user.onboarding_status != null + ) { + let pending_tours = + frappe.boot.onboarding_tours.findIndex((tour) => { + frappe.boot.user.onboarding_status[tour[0]]?.is_complete == true; + }) == -1; + if (pending_tours && frappe.boot.onboarding_tours.length > 0) { + frappe.require("onboarding_tours.bundle.js", () => { + frappe.utils.sleep(1000).then(() => { + frappe.ui.init_onboarding_tour(); + }); + }); + } + } this.set_route(); // trigger app startup @@ -495,18 +511,6 @@ frappe.Application = class Application { }); } - setup_analytics() { - if (window.mixpanel) { - window.mixpanel.identify(frappe.session.user); - window.mixpanel.people.set({ - $first_name: frappe.boot.user.first_name, - $last_name: frappe.boot.user.last_name, - $created: frappe.boot.user.creation, - $email: frappe.session.user, - }); - } - } - add_browser_class() { $("html").addClass(frappe.utils.get_browser().name.toLowerCase()); } diff --git a/frappe/public/js/frappe/doctype/index.js b/frappe/public/js/frappe/doctype/index.js index 0dc5fd0a34..a0023164d7 100644 --- a/frappe/public/js/frappe/doctype/index.js +++ b/frappe/public/js/frappe/doctype/index.js @@ -22,6 +22,29 @@ frappe.model.DocTypeController = class DocTypeController extends frappe.ui.form. }; } + refresh() { + this.show_db_utilization(); + } + + show_db_utilization() { + const doctype = this.frm.doc.doc_type || this.frm.doc.name; + frappe + .xcall("frappe.core.doctype.doctype.doctype.get_row_size_utilization", { + doctype, + }) + .then((r) => { + if (r < 50.0) return; + this.frm.dashboard.show_progress( + __("Database Row Size Utilization"), + r, + __( + "Database Table Row Size Utilization: {0}%, this limits number of fields you can add.", + [r] + ) + ); + }); + } + max_attachments() { if (!this.frm.doc.max_attachments) { return; diff --git a/frappe/public/js/frappe/dom.js b/frappe/public/js/frappe/dom.js index 2e4fec371c..a12e56d0d7 100644 --- a/frappe/public/js/frappe/dom.js +++ b/frappe/public/js/frappe/dom.js @@ -359,6 +359,50 @@ frappe.is_online = function () { return true; }; +frappe.create_shadow_element = function (wrapper, html, css, js) { + 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(html); + + // link global desk css + let link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = frappe.assets.bundled_asset("desk.bundle.css"); + + // css + let style = document.createElement("style"); + style.textContent = css; + + // javascript + let script = document.createElement("script"); + script.textContent = ` + (function() { + let cname = ${JSON.stringify(random_id)}; + let root_element = document.querySelector(cname).shadowRoot; + ${js} + })(); + `; + + this.attachShadow({ mode: "open" }); + this.shadowRoot?.appendChild(link); + 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}>`; +}; + // bind online/offline events $(window).on("online", function () { frappe.show_alert({ diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index 2188f29e94..fe665cee06 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -125,7 +125,7 @@ frappe.ui.form.Control = class BaseControl { status === "Read" && !this.only_input && is_null(value) && - !in_list(["HTML", "Image", "Button"], this.df.fieldtype) + !in_list(["HTML", "Image", "Button", "Geolocation"], this.df.fieldtype) ) { // eslint-disable-next-line if (explain) console.log("By Hide Read-only, null fields: None"); // eslint-disable-line no-console diff --git a/frappe/public/js/frappe/form/controls/base_input.js b/frappe/public/js/frappe/form/controls/base_input.js index 7831a9e9b6..5aacc193d1 100644 --- a/frappe/public/js/frappe/form/controls/base_input.js +++ b/frappe/public/js/frappe/form/controls/base_input.js @@ -178,7 +178,7 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control let $help = this.$wrapper.find("span.help"); $help.empty(); - $(` + $(` ${frappe.utils.icon("help", "sm")} `).appendTo($help); } diff --git a/frappe/public/js/frappe/form/controls/geolocation.js b/frappe/public/js/frappe/form/controls/geolocation.js index 42534a3c39..ada729fd66 100644 --- a/frappe/public/js/frappe/form/controls/geolocation.js +++ b/frappe/public/js/frappe/form/controls/geolocation.js @@ -4,28 +4,29 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f static horizontal = false; async make() { - await frappe.require(this.required_libs); super.make(); + $(this.input_area).addClass("hidden"); } - make_wrapper() { + set_disp_area(value) { // Create the elements for map area - super.make_wrapper(); + if (!this.disp_area) { + return; + } - let $input_wrapper = this.$wrapper.find(".control-input-wrapper"); this.map_id = frappe.dom.get_unique_id(); this.map_area = $( `
    -
    +
    ` ); - this.map_area.prependTo($input_wrapper); - this.$wrapper.find(".control-input").addClass("hidden"); + + $(this.disp_area).html(this.map_area); + $(this.disp_area).removeClass("like-disabled-input"); + $(this.disp_area).css("display", "block"); if (this.frm) { - this.make_map(); + this.make_map(value); } else { $(document).on("frappe.ui.Dialog:shown", () => { this.make_map(); @@ -33,7 +34,7 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f } } - make_map() { + make_map(value) { this.bind_leaflet_map(); if (this.disabled) { this.map.dragging.disable(); @@ -45,52 +46,50 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f this.map.zoomControl.remove(); } else { this.bind_leaflet_draw_control(); + this.bind_leaflet_event_listeners(); this.bind_leaflet_locate_control(); - this.bind_leaflet_refresh_button(); + this.bind_leaflet_data(value); } - this.map.setView(frappe.utils.map_defaults.center, frappe.utils.map_defaults.zoom); } - format_for_input(value) { - if (!this.map) return; - // render raw value from db into map - this.clear_editable_layers(); - if (value) { - var data_layers = new L.FeatureGroup().addLayer( - L.geoJson(JSON.parse(value), { - pointToLayer: function (geoJsonPoint, latlng) { - if (geoJsonPoint.properties.point_type == "circle") { - return L.circle(latlng, { radius: geoJsonPoint.properties.radius }); - } else if (geoJsonPoint.properties.point_type == "circlemarker") { - return L.circleMarker(latlng, { - radius: geoJsonPoint.properties.radius, - }); - } else { - return L.marker(latlng); - } - }, - }) - ); - this.add_non_group_layers(data_layers, this.editableLayers); - try { - this.map.fitBounds(this.editableLayers.getBounds(), { - padding: [50, 50], - }); - } catch (err) { - // suppress error if layer has a point. - } - this.editableLayers.addTo(this.map); - } else { - this.map.setView(frappe.utils.map_defaults.center, frappe.utils.map_defaults.zoom); + bind_leaflet_data(value) { + /* render raw value from db into map */ + if (!this.map || !value) { + return; + } + this.clear_editable_layers(); + + const data_layers = new L.FeatureGroup().addLayer( + L.geoJson(JSON.parse(value), { pointToLayer: this.point_to_layer }) + ); + this.add_non_group_layers(data_layers, this.editableLayers); + this.editableLayers.addTo(this.map); + this.fit_and_recenter_map(); + } + + /** + * Defines custom rules for how geoJSON data is rendered on the map. + * + * @param {Object} geoJsonPoint - The geoJSON object to be rendered on the map. + * @param {Object} latlng - The latitude and longitude where the geoJSON data should be rendered on the map. + * @returns {Object} - Returns the Leaflet layer object to be rendered on the map. + */ + point_to_layer(geoJsonPoint, latlng) { + // Custom rules for how geojson data is rendered on the map + if (geoJsonPoint.properties.point_type == "circle") { + return L.circle(latlng, { radius: geoJsonPoint.properties.radius }); + } else if (geoJsonPoint.properties.point_type == "circlemarker") { + return L.circleMarker(latlng, { radius: geoJsonPoint.properties.radius }); + } else { + return L.marker(latlng); } - this.map.invalidateSize(); } bind_leaflet_map() { - var circleToGeoJSON = L.Circle.prototype.toGeoJSON; + const circleToGeoJSON = L.Circle.prototype.toGeoJSON; L.Circle.include({ toGeoJSON: function () { - var feature = circleToGeoJSON.call(this); + const feature = circleToGeoJSON.call(this); feature.properties = { point_type: "circle", radius: this.getRadius(), @@ -101,7 +100,7 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f L.CircleMarker.include({ toGeoJSON: function () { - var feature = circleToGeoJSON.call(this); + const feature = circleToGeoJSON.call(this); feature.properties = { point_type: "circlemarker", radius: this.getRadius(), @@ -112,10 +111,13 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f L.Icon.Default.imagePath = "/assets/frappe/images/leaflet/"; this.map = L.map(this.map_id); + this.map.setView(frappe.utils.map_defaults.center, frappe.utils.map_defaults.zoom); L.tileLayer(frappe.utils.map_defaults.tiles, frappe.utils.map_defaults.options).addTo( this.map ); + + this.editableLayers = new L.FeatureGroup(); } bind_leaflet_locate_control() { @@ -125,9 +127,18 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f } bind_leaflet_draw_control() { - this.editableLayers = new L.FeatureGroup(); + if ( + !frappe.perm.has_perm(this.doctype, this.df.permlevel, "write", this.doc) || + this.df.read_only + ) { + return; + } - var options = { + this.map.addControl(this.get_leaflet_controls()); + } + + get_leaflet_controls() { + return new L.Control.Draw({ position: "topleft", draw: { polyline: { @@ -157,12 +168,10 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f featureGroup: this.editableLayers, //REQUIRED!! remove: true, }, - }; - - // create control and add to map - this.drawControl = new L.Control.Draw(options); - this.map.addControl(this.drawControl); + }); + } + bind_leaflet_event_listeners() { this.map.on("draw:created", (e) => { var type = e.layerType, layer = e.layer; @@ -174,31 +183,12 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f }); this.map.on("draw:deleted draw:edited", (e) => { - var layer = e.layer; + const { layer } = e; this.editableLayers.removeLayer(layer); this.set_value(JSON.stringify(this.editableLayers.toGeoJSON())); }); } - bind_leaflet_refresh_button() { - L.easyButton({ - id: "refresh-map-" + this.df.fieldname, - position: "topright", - type: "replace", - leafletClasses: true, - states: [ - { - stateName: "refresh-map", - onClick: function (button, map) { - map._onResize(); - }, - title: "Refresh map", - icon: "fa fa-refresh", - }, - ], - }).addTo(this.map); - } - add_non_group_layers(source_layer, target_group) { // https://gis.stackexchange.com/a/203773 // Would benefit from https://github.com/Leaflet/Leaflet/issues/4461 @@ -217,16 +207,19 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f }); } - get required_libs() { - return [ - "assets/frappe/js/lib/leaflet_easy_button/easy-button.css", - "assets/frappe/js/lib/leaflet_control_locate/L.Control.Locate.css", - "assets/frappe/js/lib/leaflet_draw/leaflet.draw.css", - "assets/frappe/js/lib/leaflet/leaflet.css", - "assets/frappe/js/lib/leaflet/leaflet.js", - "assets/frappe/js/lib/leaflet_easy_button/easy-button.js", - "assets/frappe/js/lib/leaflet_draw/leaflet.draw.js", - "assets/frappe/js/lib/leaflet_control_locate/L.Control.Locate.js", - ]; + fit_and_recenter_map() { + // Spread map across the wrapper, recenter and zoom w.r.t bounds + try { + this.map.invalidateSize(); + this.map.fitBounds(this.editableLayers.getBounds(), { + padding: [50, 50], + }); + } catch (err) { + // suppress error if layer has a point. + } + } + + on_section_collapse(hide) { + !hide && this.fit_and_recenter_map(); } }; diff --git a/frappe/public/js/frappe/form/controls/multicheck.js b/frappe/public/js/frappe/form/controls/multicheck.js index de4c330ff7..7b980299aa 100644 --- a/frappe/public/js/frappe/form/controls/multicheck.js +++ b/frappe/public/js/frappe/form/controls/multicheck.js @@ -18,13 +18,11 @@ frappe.ui.form.ControlMultiCheck = class ControlMultiCheck extends frappe.ui.for this.$checkbox_area = $(`
    `).appendTo( this.wrapper ); - this.refresh(); } refresh() { this.set_options(); this.bind_checkboxes(); - this.refresh_input(); super.refresh(); } diff --git a/frappe/public/js/frappe/form/controls/signature.js b/frappe/public/js/frappe/form/controls/signature.js index 0cbc1f3c26..6ab96012f1 100644 --- a/frappe/public/js/frappe/form/controls/signature.js +++ b/frappe/public/js/frappe/form/controls/signature.js @@ -133,4 +133,7 @@ frappe.ui.form.ControlSignature = class ControlSignature extends frappe.ui.form. this.set_my_value(base64_img); this.set_image(this.get_value()); } + on_section_collapse() { + this.refresh(); + } }; diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 47917422b5..4622c58155 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -1994,7 +1994,8 @@ frappe.ui.form.Form = class FrappeForm { return new Promise((resolve) => { frappe.model.with_doctype(reference_doctype, () => { frappe.get_meta(reference_doctype).fields.map((df) => { - filter_function(df) && options.push({ label: df.label, value: df.fieldname }); + filter_function(df) && + options.push({ label: df.label || df.fieldname, value: df.fieldname }); }); options && this.set_df_property( diff --git a/frappe/public/js/frappe/form/form_tour.js b/frappe/public/js/frappe/form/form_tour.js index dbdd673aea..1069ecdb13 100644 --- a/frappe/public/js/frappe/form/form_tour.js +++ b/frappe/public/js/frappe/form/form_tour.js @@ -73,9 +73,31 @@ frappe.ui.form.FormTour = class FormTour { if (!this.driver.hasNextStep()) { this.on_finish && this.on_finish(); } + let field = this.get_next_step()?.options.element.fieldobj; + if (field?.tab && !field.tab.is_active()) { + field.tab.set_active(); + this.driver.reset(true); + frappe.utils.sleep(200).then(() => { + this.start(step.idx); + this.driver.overlay.refresh(); + }); + } + }; + const on_prev = () => { + if (!this.driver.hasPreviousStep()) return; + let field = + this.driver.steps[this.driver.currentStep - 1]?.options.element.fieldobj; + if (field?.tab && !field.tab.is_active()) { + field.tab.set_active(); + this.driver.reset(true); + frappe.utils.sleep(200).then(() => { + this.start(step.idx - 2); + this.driver.overlay.refresh(); + }); + } }; - const driver_step = this.get_step(step, on_next); + const driver_step = this.get_step(step, on_next, on_prev); this.driver_steps.push(driver_step); if (step.fieldtype == "Table") this.handle_table_step(step); @@ -83,7 +105,7 @@ frappe.ui.form.FormTour = class FormTour { if (step.fieldtype == "Attach Image") this.handle_attach_image_steps(step); }); - if (this.tour.save_on_complete) { + if (this.tour.save_on_complete && this.frm.is_dirty()) { this.add_step_to_save(); } } @@ -93,7 +115,7 @@ frappe.ui.form.FormTour = class FormTour { return form.layout.evaluate_depends_on_value(step.next_step_condition || true); } - get_step(step_info, on_next) { + get_step(step_info, on_next, on_prev) { const { name, fieldname, title, description, position, is_table_field } = step_info; let element = `.frappe-control[data-fieldname='${fieldname}']`; @@ -113,6 +135,7 @@ frappe.ui.form.FormTour = class FormTour { name, popover: { title, description, position: frappe.router.slug(position || "Bottom") }, onNext: on_next, + onPrevious: on_prev, }; } @@ -261,10 +284,10 @@ frappe.ui.form.FormTour = class FormTour { allowClose: false, overlayClickNext: false, popover: { - title: __("Save"), + title: __("Save the document."), description: "", position: "left", - doneBtnText: __("Save"), + showButtons: false, }, onNext: () => { this.frm.save(); diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index f4371f901b..637fd7063d 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -103,8 +103,9 @@ frappe.form.formatters = { }, Currency: function (value, docfield, options, doc) { var currency = frappe.meta.get_field_currency(docfield, doc); - var precision = - docfield.precision || cint(frappe.boot.sysdefaults.currency_precision) || 2; + var precision = cint( + docfield.precision ?? frappe.boot.sysdefaults.currency_precision ?? 2 + ); // If you change anything below, it's going to hurt a company in UAE, a bit. if (precision > 2) { @@ -365,8 +366,14 @@ frappe.form.formatters = { ` : ""; }, + Attach: format_attachment_url, + AttachImage: format_attachment_url, }; +function format_attachment_url(url) { + return url ? `${url}` : ""; +} + frappe.form.get_formatter = function (fieldtype) { if (!fieldtype) fieldtype = "Data"; return frappe.form.formatters[fieldtype.replace(/ /g, "")] || frappe.form.formatters.Data; diff --git a/frappe/public/js/frappe/form/section.js b/frappe/public/js/frappe/form/section.js index a692cbac0d..b4908e749a 100644 --- a/frappe/public/js/frappe/form/section.js +++ b/frappe/public/js/frappe/form/section.js @@ -110,12 +110,7 @@ export default class Section { this.set_icon(hide); - // refresh signature fields - this.fields_list.forEach((f) => { - if (f.df.fieldtype == "Signature") { - f.refresh(); - } - }); + this.fields_list.forEach((f) => f.on_section_collapse && f.on_section_collapse(hide)); // save state for next reload ('' is falsy) if (this.df.css_class) diff --git a/frappe/public/js/frappe/form/templates/form_links.html b/frappe/public/js/frappe/form/templates/form_links.html index 57edb69a15..cd423bb238 100644 --- a/frappe/public/js/frappe/form/templates/form_links.html +++ b/frappe/public/js/frappe/form/templates/form_links.html @@ -5,7 +5,7 @@ {% } %}
    {% for (let j=0; j < transactions[i].items.length; j++) { %} {% let doctype = transactions[i].items[j]; %} diff --git a/frappe/public/js/frappe/list/base_list.js b/frappe/public/js/frappe/list/base_list.js index e6ed0df7f4..da47417942 100644 --- a/frappe/public/js/frappe/list/base_list.js +++ b/frappe/public/js/frappe/list/base_list.js @@ -678,7 +678,9 @@ class FilterArea { if ( fields_dict[fieldname] && (condition === "=" || - (condition === "like" && fields_dict[fieldname]?.df?.fieldtype != "Link")) + (condition === "like" && fields_dict[fieldname]?.df?.fieldtype != "Link") || + (condition === "descendants of (inclusive)" && + fields_dict[fieldname]?.df?.fieldtype == "Link")) ) { // standard filter out.promise = out.promise.then(() => fields_dict[fieldname].set_value(value)); @@ -788,6 +790,13 @@ class FilterArea { options = options.join("\n"); } } + if ( + df.fieldtype == "Link" && + df.options && + frappe.boot.treeviews.includes(df.options) + ) { + condition = "descendants of (inclusive)"; + } return { fieldtype: fieldtype, diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 7fd8d2c55c..f009593f6f 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -1029,7 +1029,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { return ` ${__(indicator[0])} - `; + `; } return ""; } diff --git a/frappe/public/js/frappe/model/perm.js b/frappe/public/js/frappe/model/perm.js index 948a31b1fc..1534521670 100644 --- a/frappe/public/js/frappe/model/perm.js +++ b/frappe/public/js/frappe/model/perm.js @@ -288,10 +288,9 @@ $.extend(frappe.perm, { const allowed_docs = filtered_perms.map((perm) => perm.doc); if (with_default_doc) { - const default_doc = - allowed_docs.length === 1 - ? allowed_docs - : filtered_perms.filter((perm) => perm.is_default).map((record) => record.doc); + const default_doc = filtered_perms + .filter((perm) => perm.is_default) + .map((record) => record.doc); return { allowed_records: allowed_docs, diff --git a/frappe/public/js/frappe/router.js b/frappe/public/js/frappe/router.js index c03bfc9b95..def04cf37e 100644 --- a/frappe/public/js/frappe/router.js +++ b/frappe/public/js/frappe/router.js @@ -139,6 +139,12 @@ frappe.router = { if (!frappe.app) return; let sub_path = this.get_sub_path(); + if (frappe.boot.setup_complete) { + !frappe.re_route["setup-wizard"] && (frappe.re_route["setup-wizard"] = "app"); + } else if (!sub_path.startsWith("setup-wizard")) { + frappe.re_route["setup-wizard"] && delete frappe.re_route["setup-wizard"]; + frappe.set_route(["setup-wizard"]); + } if (this.re_route(sub_path)) return; this.current_sub_path = sub_path; diff --git a/frappe/public/js/frappe/ui/filters/filter.js b/frappe/public/js/frappe/ui/filters/filter.js index 5902c136bd..dd8ce7f6dd 100644 --- a/frappe/public/js/frappe/ui/filters/filter.js +++ b/frappe/public/js/frappe/ui/filters/filter.js @@ -30,6 +30,7 @@ frappe.ui.Filter = class { this.nested_set_conditions = [ ["descendants of", __("Descendants Of")], + ["descendants of (inclusive)", __("Descendants Of (inclusive)")], ["not descendants of", __("Not Descendants Of")], ["ancestors of", __("Ancestors Of")], ["not ancestors of", __("Not Ancestors Of")], @@ -524,6 +525,7 @@ frappe.ui.filter_utils = { "=", "!=", "descendants of", + "descendants of (inclusive)", "ancestors of", "not descendants of", "not ancestors of", diff --git a/frappe/public/js/frappe/ui/like.js b/frappe/public/js/frappe/ui/like.js index aa007cf138..6828c77dfd 100644 --- a/frappe/public/js/frappe/ui/like.js +++ b/frappe/public/js/frappe/ui/like.js @@ -89,7 +89,7 @@ frappe.ui.click_toggle_like = function () { return false; }; -frappe.ui.setup_like_popover = ($parent, selector, check_not_liked = true) => { +frappe.ui.setup_like_popover = ($parent, selector) => { if (frappe.dom.is_touchscreen()) { return; } @@ -109,20 +109,6 @@ frappe.ui.setup_like_popover = ($parent, selector, check_not_liked = true) => { liked_by = liked_by ? decodeURI(liked_by) : "[]"; liked_by = JSON.parse(liked_by); - const user = frappe.session.user; - // hack - if (check_not_liked) { - if (target_element.parents(".liked-by").find(".not-liked").length) { - if (liked_by.indexOf(user) !== -1) { - liked_by.splice(liked_by.indexOf(user), 1); - } - } else { - if (liked_by.indexOf(user) === -1) { - liked_by.push(user); - } - } - } - if (!liked_by.length) { return ""; } diff --git a/frappe/public/js/frappe/utils/number_systems.js b/frappe/public/js/frappe/utils/number_systems.js index e27f5d98fd..8224f11d37 100644 --- a/frappe/public/js/frappe/utils/number_systems.js +++ b/frappe/public/js/frappe/utils/number_systems.js @@ -24,7 +24,7 @@ export default { }, { divisor: 1.0e5, - symbol: __("Lakh", null, "Number system"), + symbol: __("L", null, "Number system"), }, { divisor: 1.0e3, diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index ac9a18785b..095b04c931 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -1440,6 +1440,67 @@ Object.assign(frappe.utils, { prepend && wrapper.prepend(button); }, + add_select_group_button(wrapper, actions, btn_type, icon = "", prepend) { + // actions = [{ + // label: "Action 1", + // description: "Description 1", (optional) + // action: () => {}, + // }, + // { + // label: "Action 2", + // description: "Description 2", (optional) + // action: () => {}, + // }] + let selected_action = actions[0]; + + let $select_group_button = $(` +
    + + + + + +
    + `); + + actions.forEach((action) => { + $(`
  • + +
    ${frappe.utils.icon("check", "xs")}
    +
    +
    ${action.label}
    +
    ${action.description || ""}
    +
    +
    +
  • `) + .appendTo($select_group_button.find(".dropdown-menu")) + .click((e) => { + selected_action = action; + $select_group_button.find(".selected-button .label").text(action.label); + + $(e.currentTarget).find(".tick-icon").addClass("selected"); + $(e.currentTarget).siblings().find(".tick-icon").removeClass("selected"); + }); + }); + + $select_group_button.find(".dropdown-menu li:first-child .tick-icon").addClass("selected"); + + $select_group_button.find(".selected-button").click((event) => { + event.stopPropagation(); + selected_action.action && selected_action.action(event); + }); + + !prepend && $select_group_button.appendTo(wrapper); + prepend && wrapper.prepend($select_group_button); + + return $select_group_button; + }, + sleep(time) { return new Promise((resolve) => setTimeout(resolve, time)); }, diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 3505199d3f..6baf4893e9 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -56,7 +56,7 @@ frappe.views.CommunicationComposer = class { }, { fieldtype: "Button", - label: frappe.utils.icon("down"), + label: frappe.utils.icon("down", "xs"), fieldname: "option_toggle_button", click: () => { this.toggle_more_options(); @@ -77,12 +77,22 @@ frappe.views.CommunicationComposer = class { fieldtype: "MultiSelect", fieldname: "bcc", }, + { + fieldtype: "Section Break", + fieldname: "email_template_section_break", + hidden: 1, + }, { label: __("Email Template"), fieldtype: "Link", options: "Email Template", fieldname: "email_template", }, + { + fieldtype: "HTML", + label: __("Clear & Add template"), + fieldname: "clear_and_add_template", + }, { fieldtype: "Section Break" }, { label: __("Subject"), @@ -170,8 +180,9 @@ frappe.views.CommunicationComposer = class { toggle_more_options(show_options) { show_options = show_options || this.dialog.fields_dict.more_options.df.hidden; this.dialog.set_df_property("more_options", "hidden", !show_options); + this.dialog.set_df_property("email_template_section_break", "hidden", !show_options); - const label = frappe.utils.icon(show_options ? "up-line" : "down"); + const label = frappe.utils.icon(show_options ? "up-line" : "down", "xs"); this.dialog.get_field("option_toggle_button").set_label(label); } @@ -266,13 +277,14 @@ frappe.views.CommunicationComposer = class { setup_email_template() { const me = this; - this.dialog.fields_dict["email_template"].df.onchange = () => { + const fields = this.dialog.fields_dict; + const clear_and_add_template = $(fields.clear_and_add_template.wrapper); + + function add_template() { const email_template = me.dialog.fields_dict.email_template.get_value(); if (!email_template) return; function prepend_reply(reply) { - if (me.reply_added === email_template) return; - const content_field = me.dialog.fields_dict.content; const subject_field = me.dialog.fields_dict.subject; @@ -280,8 +292,6 @@ frappe.views.CommunicationComposer = class { content_field.set_value(`${reply.message}
    ${content}`); subject_field.set_value(reply.subject); - - me.reply_added = email_template; } frappe.call({ @@ -294,7 +304,25 @@ frappe.views.CommunicationComposer = class { prepend_reply(r.message); }, }); - }; + } + + let email_template_actions = [ + { + label: __("Add Template"), + description: __("Prepend the template to the email message"), + action: () => add_template(), + }, + { + label: __("Clear & Add Template"), + description: __("Clear the email message and add the template"), + action: () => { + me.dialog.fields_dict.content.set_value(""); + add_template(); + }, + }, + ]; + + frappe.utils.add_select_group_button(clear_and_add_template, email_template_actions); } setup_last_edited_communication() { @@ -719,7 +747,10 @@ frappe.views.CommunicationComposer = class { this.content_set = true; } - message += await this.get_signature(sender_email || null); + const signature = await this.get_signature(sender_email || ""); + if (!this.content_set || !strip_html(message).includes(strip_html(signature))) { + message += signature; + } if (this.is_a_reply && !this.reply_set) { message += this.get_earlier_reply(); diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js index 7bb53c65cd..894844497b 100644 --- a/frappe/public/js/frappe/views/workspace/workspace.js +++ b/frappe/public/js/frappe/views/workspace/workspace.js @@ -22,6 +22,7 @@ frappe.views.Workspace = class Workspace { this.page = wrapper.page; this.blocks = frappe.workspace_block.blocks; this.is_read_only = true; + this.is_page_loaded = false; this.pages = {}; this.sorted_public_items = []; this.sorted_private_items = []; @@ -248,10 +249,14 @@ frappe.views.Workspace = class Workspace { this.update_selected_sidebar(page, true); //add selected on new page if (!frappe.router.current_route[0]) { + this.is_page_loaded = true; frappe.set_route(frappe.router.slug(page.public ? page.name : "private/" + page.name)); } - this.show_page(page); + if (!this.is_page_loaded) { + this.show_page(page); + this.is_page_loaded = false; + } } update_selected_sidebar(page, add) { @@ -353,7 +358,7 @@ frappe.views.Workspace = class Workspace { let current_page = pages.filter((p) => p.title == page.name)[0]; this.content = current_page && JSON.parse(current_page.content); - this.add_custom_cards_in_content(); + this.content && this.add_custom_cards_in_content(); $(".item-anchor").addClass("disable-click"); diff --git a/frappe/public/js/frappe/widgets/custom_block_widget.js b/frappe/public/js/frappe/widgets/custom_block_widget.js index c64c2340a3..2ee9f06b06 100644 --- a/frappe/public/js/frappe/widgets/custom_block_widget.js +++ b/frappe/public/js/frappe/widgets/custom_block_widget.js @@ -27,44 +27,12 @@ export default class CustomBlockWidget extends Widget { await this.get_custom_block_data(); this.body.empty(); - this.random_id = "custom-block-" + frappe.utils.get_random(5).toLowerCase(); - - let me = this; - - class CustomBlock extends HTMLElement { - constructor() { - super(); - - // html - let div = document.createElement("div"); - div.innerHTML = frappe.dom.remove_script_and_style(me.custom_block_doc.html); - - // css - let style = document.createElement("style"); - style.textContent = me.custom_block_doc.style; - - // js - let script = document.createElement("script"); - script.textContent = ` - (function() { - let cname = ${JSON.stringify(me.random_id)}; - let root_element = document.querySelector(cname).shadowRoot; - ${me.custom_block_doc.script} - })(); - `; - - this.attachShadow({ mode: "open" }); - this.shadowRoot?.appendChild(div); - this.shadowRoot?.appendChild(style); - this.shadowRoot?.appendChild(script); - } - } - - if (!customElements.get(this.random_id)) { - customElements.define(this.random_id, CustomBlock); - } - - this.body.append(`<${this.random_id}>`); + frappe.create_shadow_element( + this.body[0], + this.custom_block_doc.html, + this.custom_block_doc.style, + this.custom_block_doc.script + ); } async get_custom_block_data() { diff --git a/frappe/public/js/frappe/widgets/onboarding_widget.js b/frappe/public/js/frappe/widgets/onboarding_widget.js index daacfb79c6..378c054c44 100644 --- a/frappe/public/js/frappe/widgets/onboarding_widget.js +++ b/frappe/public/js/frappe/widgets/onboarding_widget.js @@ -33,7 +33,13 @@ export default class OnboardingWidget extends Widget { this.add_step(step, index); }); - this.show_step(this.steps[0]); + let first_incomplete_step = this.steps.findIndex((s) => !s.is_skipped && !s.is_complete); + + if (first_incomplete_step == -1) { + first_incomplete_step = 0; + } + + this.show_step(this.steps[first_incomplete_step]); } add_step(step, index) { @@ -438,6 +444,7 @@ export default class OnboardingWidget extends Widget { }; this.update_step_status(step, "is_complete", 1, callback); + this.activate_next_step(step); } skip_step(step) { @@ -451,6 +458,16 @@ export default class OnboardingWidget extends Widget { }; this.update_step_status(step, "is_skipped", 1, callback); + this.activate_next_step(step); + } + + activate_next_step(step) { + let current_step_index = this.steps.findIndex((s) => s == step); + let next_step = this.steps[current_step_index + 1]; + + if (!next_step) return; + + this.show_step(next_step); } update_step_status(step, status, value, callback) { @@ -552,6 +569,7 @@ export default class OnboardingWidget extends Widget { localStorage.setItem("dismissed-onboarding", JSON.stringify(dismissed)); this.delete(true, true); this.widget.closest(".ce-block").hide(); + frappe.telemetry.capture("dismissed_" + frappe.scrub(this.title), "frappe_onboarding"); }); dismiss.appendTo(this.action_area); } diff --git a/frappe/public/js/frappe/widgets/widget_dialog.js b/frappe/public/js/frappe/widgets/widget_dialog.js index ba5270c27a..51138a7dc4 100644 --- a/frappe/public/js/frappe/widgets/widget_dialog.js +++ b/frappe/public/js/frappe/widgets/widget_dialog.js @@ -358,6 +358,7 @@ class ShortcutDialog extends WidgetDialog { query: "frappe.core.report.permitted_documents_for_user.permitted_documents_for_user.query_doctypes", filters: { user: frappe.session.user, + include_single_doctypes: true, }, }; }; @@ -714,6 +715,11 @@ class CustomBlockDialog extends WidgetDialog { label: "Custom Block Name", options: "Custom HTML Block", reqd: 1, + get_query: () => { + return { + query: "frappe.desk.doctype.custom_html_block.custom_html_block.get_custom_blocks_for_user", + }; + }, }, ]; } diff --git a/frappe/public/js/lib/leaflet_control_locate/L.Control.Locate.js b/frappe/public/js/lib/leaflet_control_locate/L.Control.Locate.js index 8544e17a04..8ea44ce00c 100644 --- a/frappe/public/js/lib/leaflet_control_locate/L.Control.Locate.js +++ b/frappe/public/js/lib/leaflet_control_locate/L.Control.Locate.js @@ -17,7 +17,7 @@ You can find the project at: https://github.com/domoritz/leaflet-locatecontrol if (typeof window !== 'undefined' && window.L) { module.exports = factory(L); } else { - module.exports = factory(require('leaflet')); + module.exports = factory(require('../leaflet/leaflet.js')); } } diff --git a/frappe/public/js/libs.bundle.js b/frappe/public/js/libs.bundle.js index e4e172c1b4..77704bb173 100644 --- a/frappe/public/js/libs.bundle.js +++ b/frappe/public/js/libs.bundle.js @@ -1,5 +1,9 @@ import "./jquery-bootstrap"; import "./lib/moment"; +import "../js/lib/leaflet/leaflet.js"; +import "../js/lib/leaflet_easy_button/easy-button.js"; +import "../js/lib/leaflet_draw/leaflet.draw.js"; +import "../js/lib/leaflet_control_locate/L.Control.Locate.js"; import Sortable from "sortablejs"; window.SetVueGlobals = (app) => { diff --git a/frappe/public/js/onboarding_tours.bundle.js b/frappe/public/js/onboarding_tours.bundle.js new file mode 100644 index 0000000000..6ed7934b78 --- /dev/null +++ b/frappe/public/js/onboarding_tours.bundle.js @@ -0,0 +1 @@ +import "./onboarding_tours/onboarding_tours.js"; diff --git a/frappe/public/js/onboarding_tours/onboarding_tours.js b/frappe/public/js/onboarding_tours/onboarding_tours.js new file mode 100644 index 0000000000..29790ce5de --- /dev/null +++ b/frappe/public/js/onboarding_tours/onboarding_tours.js @@ -0,0 +1,354 @@ +frappe.ui.OnboardingTour = class OnboardingTour { + constructor() { + this.driver_steps = []; + this.last_step_saved = null; + this.last_element_clicked = null; + } + + init_driver() { + this.driver = new frappe.Driver({ + className: "frappe-driver", + allowClose: false, + padding: 10, + overlayClickNext: false, + keyboardControl: true, + nextBtnText: "Next", + prevBtnText: "Previous", + opacity: 0.5, + onHighlighted: (step) => { + frappe.ui.next_form_tour = step.options.step_info?.next_form_tour; + const wait_for_node = setInterval(() => { + if (!step.popover.node) return; + if (step.options.step_info?.offset_x) { + step.popover.node.style.left = `${ + step.popover.node.offsetLeft + step.options.step_info.offset_x + }px`; + } + if (step.options.step_info?.offset_y) { + step.popover.node.style.top = `${ + step.popover.node.offsetTop + step.options.step_info.offset_y + }px`; + } + if (step.popover.node.offsetLeft < 0) { + step.popover.node.style.minWidth = "200px"; + step.popover.node.style.maxWidth = `${ + 350 + step.popover.node.offsetLeft + }px`; + step.popover.node.style.left = "0px"; + } + if (step.popover.closeBtnNode) { + step.popover.closeBtnNode.onclick = () => { + this.on_finish && this.on_finish(); + !frappe.boot.user.onboarding_status[this.tour.name] && + (frappe.boot.user.onboarding_status[this.tour.name] = {}); + frappe.boot.user.onboarding_status[this.tour.name].is_complete = true; + if (!this.driver.hasNextStep()) { + frappe.boot.user.onboarding_status[ + this.tour.name + ].all_steps_completed = true; + } + + frappe.call({ + method: "frappe.desk.doctype.form_tour.form_tour.update_user_status", + args: { + value: JSON.stringify(frappe.boot.user.onboarding_status), + step: JSON.stringify(step.options.step_info), + }, + }); + }; + } + clearInterval(wait_for_node); + }, 300); + + // focus on first input. + // TODO : later add option to select which input to focus as well. + const $input = $(step.node).find("input").get(0); + if ($input) frappe.utils.sleep(200).then(() => $input.focus()); + }, + }); + } + + async init({ tour_name, start_step }) { + this.tour = await frappe.db.get_doc("Form Tour", tour_name); + this.init_driver(); + this.build_steps(); + this.update_driver_steps(); + if (!this.tour.track_steps) { + start_step = 0; + } + this.start(start_step); + } + + build_steps() { + this.driver_steps = []; + this.tour.steps.forEach((step) => { + const on_next = async (el) => { + const step_index = this.driver.steps.indexOf(el); + if (step_index == -1 || this.last_step_saved?.name == step.name) return; + frappe.boot.user.onboarding_status[this.tour.name] = { + steps_complete: step_index, + }; + if (!this.driver.hasNextStep()) { + this.on_finish && this.on_finish(); + frappe.boot.user.onboarding_status[this.tour.name].is_complete = true; + } + this.last_step_saved = step; + frappe.call({ + method: "frappe.desk.doctype.form_tour.form_tour.update_user_status", + args: { + value: JSON.stringify(frappe.boot.user.onboarding_status), + step: JSON.stringify(step), + }, + }); + }; + const driver_step = this.get_step(step, on_next); + driver_step.element && this.driver_steps.push(driver_step); + }); + } + + get_step(step_info, on_next) { + const { + name, + element_selector, + title, + description, + ondemand_description, + position, + parent_element_selector, + hide_buttons, + next_on_click, + popover_element, + modal_trigger, + } = step_info; + let element = cur_page?.page.querySelector(element_selector); + !element && (element = document.querySelector(element_selector)); + if (parent_element_selector) { + element = element.closest(parent_element_selector); + } + if (element && (next_on_click || hide_buttons || modal_trigger)) { + $(element).on("click", () => { + if ( + !this.driver.getHighlightedElement() || + this.driver.getHighlightedElement().node.id?.startsWith("popover") + ) + return; + + if ( + modal_trigger && + (!this.last_element_clicked || + new Date().getTime() - new Date(this.last_element_clicked).getTime() > + 1000) + ) { + this.last_element_clicked = new Date().getTime(); + this.handle_modal_steps(this.driver.currentStep, title, ondemand_description); + return; + } + + if (!popover_element) { + on_next(this.driver.getHighlightedElement()); + this.driver.moveNext(); + this.driver.overlay.refresh(); + } + + if (!this.driver.getHighlightedElement()) return; + on_next(this.driver.getHighlightedElement()); + let popover = this.driver + .getHighlightedElement() + .node.getAttribute("aria-describedby") + ? this.driver.getHighlightedElement().node + : this.driver + .getHighlightedElement() + .node.querySelector('[aria-describedby^="popover"]'); + + if (!popover) return; + + let popover_id = popover.getAttribute("aria-describedby"); + let step_index = this.driver.steps.indexOf(this.driver.getHighlightedElement()); + + if (this.driver_steps[step_index + 1]?.element.id == popover_id) return; + + this.driver_steps = this.driver_steps.filter( + (step) => !step.element.id?.startsWith("popover") + ); + + let new_step = { ...this.driver_steps[step_index] }; + new_step.element = document.getElementById(popover_id); + new_step.showButtons = false; + ondemand_description && (new_step.popover.description = ondemand_description); + + this.driver_steps.splice(this.driver.currentStep + 1, 0, new_step); + this.update_driver_steps(); + this.driver.moveNext(); + this.driver.overlay.refresh(); + + $(popover).one("hide.bs.popover", (e) => { + this.driver_steps.splice(this.driver.currentStep, 1); + this.driver_steps[this.driver.currentStep - 1].showButtons = true; + new_step.popover.description = description; + this.update_driver_steps(); + this.driver.movePrevious(); + this.driver.overlay.refresh(); + }); + }); + } + + let showButtons = true; + if (popover_element || hide_buttons) { + showButtons = false; + } + return { + element, + name, + popover: { + title, + description, + position: frappe.router.slug(position || "Bottom"), + }, + onNext: on_next, + step_info: step_info, + showButtons, + }; + } + + update_driver_steps(steps = []) { + if (steps.length == 0) { + steps = this.driver_steps; + } + this.driver.defineSteps(steps); + } + + start(idx = 0) { + if (this.driver_steps.length == 0) { + return; + } + this.driver.start(idx); + } + + handle_modal_steps(step, title, description) { + setTimeout(() => { + const modal_element = $(".modal-content"); + const attach_dialog_step = { + element: modal_element[0], + allowClose: false, + overlayClickNext: false, + popover: { + title, + description, + position: "left-center", + doneBtnText: __("Next"), + }, + }; + this.driver_steps.splice(step + 1, 0, attach_dialog_step); + this.update_driver_steps(); // need to define again, since driver.js only considers steps which are inside DOM + this.driver.reset(); + this.driver.start(step + 1); + this.driver.overlay.refresh(); + modal_element.closest(".modal").one("hide.bs.modal", () => { + this.driver_steps.splice(this.driver.currentStep, 1); + this.update_driver_steps(); + this.driver.movePrevious(); + this.driver.moveNext(); + this.driver.overlay.refresh(); + }); + }, 500); + } +}; + +frappe.ui.init_onboarding_tour = () => { + // As of now Tours are only for desktop as it is annoying on mobile. + // Also lot of elements are hidden on mobile so until we find a better way to do it. + if (!window.matchMedia("(min-device-width: 992px)").matches) return; + + typeof frappe.boot.onboarding_tours == "undefined" && frappe.boot.onboarding_tours == []; + typeof frappe.boot.user.onboarding_status == "undefined" && + frappe.boot.user.onboarding_status == {}; + let route = frappe.router.current_route; + if (route[0] === "") return; + + let tour_name; + let matching_tours = []; + let start_step; + if (route[0] == "query-report") { + route = ["List", route[1], "Report"]; + } + if (route[0] != "dashboard-view") { + frappe.boot.onboarding_tours && + frappe.boot.onboarding_tours.forEach((tour) => { + let tour_route = tour[1]; + length = Math.min(route.length, tour_route.length); + if (length >= 1 && route[0] != tour_route[0]) return; + if (length >= 2 && tour_route[1] != "*" && route[1] != tour_route[1]) return; + if ( + length >= 3 && + ["*", "new-*"].indexOf(tour_route[2]) == -1 && + route[2] != tour_route[2] + ) + return; + matching_tours.push(tour); + }); + } + matching_tours = matching_tours.filter((tour) => { + if (frappe.boot.user.onboarding_status[tour[0]]?.is_complete == true) return false; + return true; + }); + matching_tours = matching_tours.map((tour) => { + if (frappe.boot.user.onboarding_status[tour[0]]?.steps_complete != undefined) { + tour.push(frappe.boot.user.onboarding_status[tour[0]].steps_complete); + } + return tour; + }); + if (matching_tours.length == 0) return; + let current_tour = matching_tours.find( + (tour) => tour[0] == frappe.ui.currentTourInstance?.tour?.name + ); + let next_tour = matching_tours.find((tour) => tour[0] == frappe.ui.next_form_tour); + if (current_tour) { + tour_name = current_tour[0]; + start_step = current_tour.at(-1); + if (typeof start_step != "number") { + start_step = 0; + } + } else if (next_tour) { + tour_name = next_tour[0]; + start_step = next_tour.at(-1); + if (typeof start_step != "number") { + start_step = 0; + } else { + start_step += 1; + } + frappe.ui.next_form_tour = undefined; + } else { + tour_name = matching_tours[0][0]; + start_step = matching_tours[0].at(-1); + if (typeof start_step != "number") { + start_step = 0; + } else { + start_step += 1; + } + } + if (!tour_name) return; + if (frappe.ui.currentTourInstance?.driver) { + frappe.ui.currentTourInstance.driver_steps = []; + frappe.ui.currentTourInstance.driver.reset(true); + frappe.ui.currentTourInstance.update_driver_steps(); + } + const tour = (frappe.ui.currentTourInstance = new frappe.ui.OnboardingTour()); + // wait for workspace and/or data to load. + const wait_for_data = setInterval(() => { + if (cur_page?.page.querySelector(".workspace-sidebar-skeleton")) return; + if (cur_page?.page.querySelector(".workspace-skeleton")) return; + if (document.body.getAttribute("data-ajax-state") === "complete") { + frappe.utils.sleep(500).then(() => { + tour.init({ + tour_name, + start_step, + }); + clearInterval(wait_for_data); + }); + } + }, 100); +}; + +frappe.router.on("change", () => { + frappe.ui.init_onboarding_tour(); +}); diff --git a/frappe/public/js/telemetry/index.js b/frappe/public/js/telemetry/index.js index f7e2c9646a..b9dee3be1c 100644 --- a/frappe/public/js/telemetry/index.js +++ b/frappe/public/js/telemetry/index.js @@ -6,6 +6,7 @@ class TelemetryManager { this.project_id = frappe.boot.posthog_project_id; this.telemetry_host = frappe.boot.posthog_host; + this.site_age = frappe.boot.telemetry_site_age; if (cint(frappe.boot.enable_telemetry) && this.project_id && this.telemetry_host) { this.enabled = true; @@ -24,15 +25,16 @@ class TelemetryManager { }); posthog.identify(frappe.boot.sitename); this.send_heartbeat(); + this.register_pageview_handler(); } catch (e) { console.trace("Failed to initialize telemetry", e); this.enabled = false; } } - capture(event, app) { + capture(event, app, props) { if (!this.enabled) return; - posthog.capture(`${app}_${event}`); + posthog.capture(`${app}_${event}`, props); } disable() { @@ -47,9 +49,19 @@ class TelemetryManager { if (!last || moment(now).diff(moment(last), "hours") > 12) { localStorage.setItem(KEY, now.toISOString()); - this.capture("heartbeat", "frappe"); + this.capture("heartbeat", "frappe", { frappe_version: frappe.boot?.versions?.frappe }); } } + + register_pageview_handler() { + if (this.site_age && this.site_age > 5) { + return; + } + + frappe.router.on("change", () => { + posthog.capture("$pageview"); + }); + } } frappe.telemetry = new TelemetryManager(); diff --git a/frappe/public/js/workflow_builder/store.js b/frappe/public/js/workflow_builder/store.js index 66e04adcb7..491e349ace 100644 --- a/frappe/public/js/workflow_builder/store.js +++ b/frappe/public/js/workflow_builder/store.js @@ -64,6 +64,7 @@ export const useStore = defineStore("workflow-builder-store", () => { } setup_undo_redo(); + setup_breadcrumbs(); } function reset_changes() { @@ -105,6 +106,16 @@ export const useStore = defineStore("workflow-builder-store", () => { workflow.value.elements.forEach((el) => (el.selected = false)); } + function setup_breadcrumbs() { + let breadcrumbs = ` +
  • ${__("Workflow")}
  • +
  • ${__(workflow_name.value)}
  • +
  • ${__("Workflow Builder")}
  • + `; + 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 []) %}