diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000000..e570d9403b --- /dev/null +++ b/.coveragerc @@ -0,0 +1,23 @@ +[run] +omit = + tests/* + .github/* + commands/* + **/test_*.py + +[report] +exclude_lines = + pragma: no cover + if TYPE_CHECKING: + +exclude_also = + def __repr__ + if self.debug: + if settings.DEBUG + raise AssertionError + raise NotImplementedError + if 0: + if __name__ == .__main__.: + if TYPE_CHECKING: + class .*\bProtocol\): + @(abc\.)?abstractmethod diff --git a/.github/workflows/initiate_release.yml b/.github/workflows/initiate_release.yml index ebeef6561c..0aa376b0dc 100644 --- a/.github/workflows/initiate_release.yml +++ b/.github/workflows/initiate_release.yml @@ -30,23 +30,3 @@ jobs: head: version-${{ matrix.version }}-hotfix env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} - - beta-release: - name: Release - runs-on: ubuntu-latest - strategy: - fail-fast: false - - steps: - - uses: octokit/request-action@v2.x - with: - route: POST /repos/{owner}/{repo}/pulls - owner: frappe - repo: frappe - title: |- - "chore: release v15 beta" - body: "Automated beta release." - base: version-15-beta - head: develop - env: - GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 8533f3cbfe..f485a9d6b6 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -130,12 +130,14 @@ jobs: DB: ${{ matrix.db }} - name: Run Tests - run: cd ~/frappe-bench/sites && ../env/bin/python3 ../apps/frappe/.github/helper/ci.py + run: ../env/bin/python3 ../apps/frappe/.github/helper/ci.py + working-directory: /home/runner/frappe-bench/sites env: SITE: test_site CI_BUILD_ID: ${{ github.run_id }} BUILD_NUMBER: ${{ matrix.container }} TOTAL_BUILDS: 2 + COVERAGE_RCFILE: /home/runner/frappe-bench/apps/frappe/.coveragerc - name: Show bench output if: ${{ always() }} diff --git a/cypress/integration/form.js b/cypress/integration/form.js index cab2e343c2..facc73f536 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -35,7 +35,7 @@ context("Form", () => { cy.visit("/app/todo/new"); cy.get_field("description", "Text Editor") .type("this is a test todo", { force: true }) - .wait(200); + .wait(1000); cy.get(".page-title").should("contain", "Not Saved"); cy.intercept({ method: "POST", diff --git a/cypress/integration/form_builder.js b/cypress/integration/form_builder.js index 43cec97a39..53c45cd379 100644 --- a/cypress/integration/form_builder.js +++ b/cypress/integration/form_builder.js @@ -77,7 +77,8 @@ context("Form Builder", () => { .as("input"); cy.get("@input").clear({ force: true }).type("Web Form Field", { delay: 200 }); cy.wait("@search_link"); - cy.get("@input").type("{enter}").blur(); + + cy.get(first_field).click({ force: true }); cy.get(first_field) .find(".table-controls .table-column") diff --git a/frappe/__init__.py b/frappe/__init__.py index e12db352f6..2f52c114f4 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -169,7 +169,7 @@ lang = local("lang") # This if block is never executed when running the code. It is only used for # telling static code analyzer where to find dynamically defined attributes. -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from werkzeug.wrappers import Request from frappe.database.mariadb.database import MariaDBDatabase @@ -488,9 +488,12 @@ def msgprint( def _raise_exception(): if raise_exception: if inspect.isclass(raise_exception) and issubclass(raise_exception, Exception): - raise raise_exception(msg) + exc = raise_exception(msg) else: - raise ValidationError(msg) + exc = ValidationError(msg) + if out.__frappe_exc_id: + exc.__frappe_exc_id = out.__frappe_exc_id + raise exc if flags.mute_messages: _raise_exception() @@ -527,6 +530,7 @@ def msgprint( if raise_exception: out.raise_exception = 1 + out.__frappe_exc_id = generate_hash() if primary_action: out.primary_action = primary_action @@ -534,11 +538,7 @@ def msgprint( if wide: out.wide = wide - message_log.append(json.dumps(out)) - - if raise_exception and hasattr(raise_exception, "__name__"): - local.response["exc_type"] = raise_exception.__name__ - + message_log.append(out) _raise_exception() @@ -1225,7 +1225,7 @@ def get_doc(doctype: str, /) -> _SingleDocument: @overload -def get_doc(doctype: str, name: str, /, for_update: bool | None = None) -> "Document": +def get_doc(doctype: str, name: str, /, *, for_update: bool | None = None) -> "Document": """Retrieve DocType from DB, doctype and name must be positional argument.""" pass @@ -1449,9 +1449,9 @@ def get_site_path(*joins): """Return path of current site. :param *joins: Join additional path elements using `os.path.join`.""" - from os.path import join, normpath + from os.path import join - return normpath(join(local.site_path, *joins)) + return join(local.site_path, *joins) def get_pymodule_path(modulename, *joins): diff --git a/frappe/api.py b/frappe/api.py deleted file mode 100644 index 084bee060b..0000000000 --- a/frappe/api.py +++ /dev/null @@ -1,306 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: MIT. See LICENSE -import base64 -import binascii -import json -from typing import Literal -from urllib.parse import urlencode, urlparse - -import frappe -import frappe.client -import frappe.handler -from frappe import _ -from frappe.utils.data import sbool -from frappe.utils.response import build_response - - -def handle(): - """ - Handler for `/api` methods - - ### Examples: - - `/api/method/{methodname}` will call a whitelisted method - - `/api/resource/{doctype}` will query a table - examples: - - `?fields=["name", "owner"]` - - `?filters=[["Task", "name", "like", "%005"]]` - - `?limit_start=0` - - `?limit_page_length=20` - - `/api/resource/{doctype}/{name}` will point to a resource - `GET` will return doclist - `POST` will insert - `PUT` will update - `DELETE` will delete - - `/api/resource/{doctype}/{name}?run_method={method}` will run a whitelisted controller method - """ - - parts = frappe.request.path[1:].split("/", 3) - call = doctype = name = None - - if len(parts) > 1: - call = parts[1] - - if len(parts) > 2: - doctype = parts[2] - - if len(parts) > 3: - name = parts[3] - - return _RESTAPIHandler(call, doctype, name).get_response() - - -class _RESTAPIHandler: - def __init__(self, call: Literal["method", "resource"], doctype: str | None, name: str | None): - self.call = call - self.doctype = doctype - self.name = name - - def get_response(self): - """Prepare and get response based on URL and form body. - - Note: most methods of this class directly operate on the response local. - """ - match self.call: - case "method": - return self.handle_method() - case "resource": - self.handle_resource() - case _: - raise frappe.DoesNotExistError - - return build_response("json") - - def handle_method(self): - frappe.local.form_dict.cmd = self.doctype - return frappe.handler.handle() - - def handle_resource(self): - if self.doctype and self.name: - self.handle_document_resource() - elif self.doctype: - self.handle_doctype_resource() - else: - raise frappe.DoesNotExistError - - def handle_document_resource(self): - if "run_method" in frappe.local.form_dict: - self.execute_doc_method() - return - - match frappe.local.request.method: - case "GET": - self.get_doc() - case "PUT": - self.update_doc() - case "DELETE": - self.delete_doc() - case _: - raise frappe.DoesNotExistError - - def handle_doctype_resource(self): - match frappe.local.request.method: - case "GET": - self.get_doc_list() - case "POST": - self.create_doc() - case _: - raise frappe.DoesNotExistError - - def execute_doc_method(self): - method = frappe.local.form_dict.pop("run_method") - doc = frappe.get_doc(self.doctype, self.name) - doc.is_whitelisted(method) - - if frappe.local.request.method == "GET": - if not doc.has_permission("read"): - frappe.throw(_("Not permitted"), frappe.PermissionError) - frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)}) - - elif frappe.local.request.method == "POST": - if not doc.has_permission("write"): - frappe.throw(_("Not permitted"), frappe.PermissionError) - - frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)}) - frappe.db.commit() - - def get_doc(self): - doc = frappe.get_doc(self.doctype, self.name) - if not doc.has_permission("read"): - raise frappe.PermissionError - doc.apply_fieldlevel_read_permissions() - frappe.local.response.update({"data": doc}) - - def update_doc(self): - data = get_request_form_data() - - doc = frappe.get_doc(self.doctype, self.name, for_update=True) - - if "flags" in data: - del data["flags"] - - # Not checking permissions here because it's checked in doc.save - doc.update(data) - - frappe.local.response.update({"data": doc.save().as_dict()}) - - # check for child table doctype - if doc.get("parenttype"): - frappe.get_doc(doc.parenttype, doc.parent).save() - frappe.db.commit() - - def delete_doc(self): - # Not checking permissions here because it's checked in delete_doc - frappe.delete_doc(self.doctype, self.name, ignore_missing=False) - frappe.local.response.http_status_code = 202 - frappe.local.response.message = "ok" - frappe.db.commit() - - def get_doc_list(self): - if frappe.local.form_dict.get("fields"): - frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"]) - - # set limit of records for frappe.get_list - frappe.local.form_dict.setdefault( - "limit_page_length", - frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20, - ) - - # convert strings to native types - only as_dict and debug accept bool - for param in ["as_dict", "debug"]: - param_val = frappe.local.form_dict.get(param) - if param_val is not None: - frappe.local.form_dict[param] = sbool(param_val) - - # evaluate frappe.get_list - data = frappe.call(frappe.client.get_list, self.doctype, **frappe.local.form_dict) - - # set frappe.get_list result to response - frappe.local.response.update({"data": data}) - - def create_doc(self): - data = get_request_form_data() - data.update({"doctype": self.doctype}) - - # insert document from request data - doc = frappe.get_doc(data).insert() - - # set response data - frappe.local.response.update({"data": doc.as_dict()}) - - # commit for POST requests - frappe.db.commit() - - -def get_request_form_data(): - if frappe.local.form_dict.data is None: - data = frappe.safe_decode(frappe.local.request.get_data()) - else: - data = frappe.local.form_dict.data - - try: - return frappe.parse_json(data) - except ValueError: - return frappe.local.form_dict - - -def validate_auth(): - """ - Authenticate and sets user for the request. - """ - authorization_header = frappe.get_request_header("Authorization", "").split(" ") - - if len(authorization_header) == 2: - validate_oauth(authorization_header) - validate_auth_via_api_keys(authorization_header) - - validate_auth_via_hooks() - - -def validate_oauth(authorization_header): - """ - Authenticate request using OAuth and set session user - - Args: - authorization_header (list of str): The 'Authorization' header containing the prefix and token - """ - - from frappe.integrations.oauth2 import get_oauth_server - from frappe.oauth import get_url_delimiter - - form_dict = frappe.local.form_dict - token = authorization_header[1] - req = frappe.request - parsed_url = urlparse(req.url) - access_token = {"access_token": token} - uri = ( - parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + "?" + urlencode(access_token) - ) - http_method = req.method - headers = req.headers - body = req.get_data() - if req.content_type and "multipart/form-data" in req.content_type: - body = None - - try: - required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split( - get_url_delimiter() - ) - valid, oauthlib_request = get_oauth_server().verify_request( - uri, http_method, body, headers, required_scopes - ) - if valid: - frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user")) - frappe.local.form_dict = form_dict - except AttributeError: - pass - - -def validate_auth_via_api_keys(authorization_header): - """ - Authenticate request using API keys and set session user - - Args: - authorization_header (list of str): The 'Authorization' header containing the prefix and token - """ - - try: - auth_type, auth_token = authorization_header - authorization_source = frappe.get_request_header("Frappe-Authorization-Source") - if auth_type.lower() == "basic": - api_key, api_secret = frappe.safe_decode(base64.b64decode(auth_token)).split(":") - validate_api_key_secret(api_key, api_secret, authorization_source) - elif auth_type.lower() == "token": - api_key, api_secret = auth_token.split(":") - validate_api_key_secret(api_key, api_secret, authorization_source) - except binascii.Error: - frappe.throw( - _("Failed to decode token, please provide a valid base64-encoded token."), - frappe.InvalidAuthorizationToken, - ) - except (AttributeError, TypeError, ValueError): - pass - - -def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None): - """frappe_authorization_source to provide api key and secret for a doctype apart from User""" - doctype = frappe_authorization_source or "User" - doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"]) - form_dict = frappe.local.form_dict - doc_secret = frappe.utils.password.get_decrypted_password(doctype, doc, fieldname="api_secret") - if api_secret == doc_secret: - if doctype == "User": - user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"]) - else: - user = frappe.db.get_value(doctype, doc, "user") - if frappe.local.login_manager.user in ("", "Guest"): - frappe.set_user(user) - frappe.local.form_dict = form_dict - - -def validate_auth_via_hooks(): - for auth_hook in frappe.get_hooks("auth_hooks", []): - frappe.get_attr(auth_hook)() diff --git a/frappe/api/__init__.py b/frappe/api/__init__.py new file mode 100644 index 0000000000..5c504b2512 --- /dev/null +++ b/frappe/api/__init__.py @@ -0,0 +1,80 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE +from enum import Enum + +from werkzeug.exceptions import NotFound +from werkzeug.routing import Map, Submount +from werkzeug.wrappers import Request, Response + +import frappe +import frappe.client +from frappe import _ +from frappe.utils.response import build_response + + +class ApiVersion(str, Enum): + V1 = "v1" + V2 = "v2" + + +def handle(request: Request): + """ + Entry point for `/api` methods. + + APIs are versioned using second part of path. + v1 -> `/api/v1/*` + v2 -> `/api/v2/*` + + Different versions have different specification but broadly following things are supported: + + - `/api/method/{methodname}` will call a whitelisted method + - `/api/resource/{doctype}` will query a table + examples: + - `?fields=["name", "owner"]` + - `?filters=[["Task", "name", "like", "%005"]]` + - `?limit_start=0` + - `?limit_page_length=20` + - `/api/resource/{doctype}/{name}` will point to a resource + `GET` will return document + `POST` will insert + `PUT` will update + `DELETE` will delete + """ + + try: + endpoint, arguments = API_URL_MAP.bind_to_environ(request.environ).match() + except NotFound: # Wrap 404 - backward compatiblity + raise frappe.DoesNotExistError + + data = endpoint(**arguments) + if isinstance(data, Response): + return data + + if data is not None: + frappe.response["data"] = data + return build_response("json") + + +# Merge all API version routing rules +from frappe.api.v1 import url_rules as v1_rules +from frappe.api.v2 import url_rules as v2_rules + +API_URL_MAP = Map( + [ + # V1 routes + Submount("/api", v1_rules), + Submount(f"/api/{ApiVersion.V1.value}", v1_rules), + Submount(f"/api/{ApiVersion.V2.value}", v2_rules), + ], + strict_slashes=False, # Allows skipping trailing slashes + merge_slashes=False, +) + + +def get_api_version() -> ApiVersion | None: + if not frappe.request: + return + + if frappe.request.path.startswith(f"/api/{ApiVersion.V2.value}"): + return ApiVersion.V2 + return ApiVersion.V1 diff --git a/frappe/api/utils.py b/frappe/api/utils.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/api/v1.py b/frappe/api/v1.py new file mode 100644 index 0000000000..d2758f45d5 --- /dev/null +++ b/frappe/api/v1.py @@ -0,0 +1,118 @@ +import json + +from werkzeug.routing import Rule + +import frappe +from frappe import _ +from frappe.utils.data import sbool + + +def document_list(doctype: str): + if frappe.form_dict.get("fields"): + frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"]) + + # set limit of records for frappe.get_list + frappe.form_dict.setdefault( + "limit_page_length", + frappe.form_dict.limit or frappe.form_dict.limit_page_length or 20, + ) + + # convert strings to native types - only as_dict and debug accept bool + for param in ["as_dict", "debug"]: + param_val = frappe.form_dict.get(param) + if param_val is not None: + frappe.form_dict[param] = sbool(param_val) + + # evaluate frappe.get_list + return frappe.call(frappe.client.get_list, doctype, **frappe.form_dict) + + +def handle_rpc_call(method: str): + import frappe.handler + + method = method.split("/")[0] # for backward compatiblity + + frappe.form_dict.cmd = method + return frappe.handler.handle() + + +def create_doc(doctype: str): + data = get_request_form_data() + data.pop("doctype", None) + return frappe.new_doc(doctype, **data).insert() + + +def update_doc(doctype: str, name: str): + data = get_request_form_data() + + doc = frappe.get_doc(doctype, name, for_update=True) + if "flags" in data: + del data["flags"] + + doc.update(data) + doc.save() + + # check for child table doctype + if doc.get("parenttype"): + frappe.get_doc(doc.parenttype, doc.parent).save() + + return doc + + +def delete_doc(doctype: str, name: str): + # TODO: child doc handling + frappe.delete_doc(doctype, name, ignore_missing=False) + frappe.response.http_status_code = 202 + return "ok" + + +def read_doc(doctype: str, name: str): + # Backward compatiblity + if "run_method" in frappe.form_dict: + return execute_doc_method(doctype, name) + + doc = frappe.get_doc(doctype, name) + if not doc.has_permission("read"): + raise frappe.PermissionError + doc.apply_fieldlevel_read_permissions() + return doc + + +def execute_doc_method(doctype: str, name: str, method: str | None = None): + method = method or frappe.form_dict.pop("run_method") + doc = frappe.get_doc(doctype, name) + doc.is_whitelisted(method) + + if frappe.request.method == "GET": + if not doc.has_permission("read"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + return doc.run_method(method, **frappe.form_dict) + + elif frappe.request.method == "POST": + if not doc.has_permission("write"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + return doc.run_method(method, **frappe.form_dict) + + +def get_request_form_data(): + if frappe.form_dict.data is None: + data = frappe.safe_decode(frappe.request.get_data()) + else: + data = frappe.form_dict.data + + try: + return frappe.parse_json(data) + except ValueError: + return frappe.form_dict + + +url_rules = [ + Rule("/method/", endpoint=handle_rpc_call), + Rule("/resource/", methods=["GET"], endpoint=document_list), + Rule("/resource/", methods=["POST"], endpoint=create_doc), + Rule("/resource///", methods=["GET"], endpoint=read_doc), + Rule("/resource///", methods=["PUT"], endpoint=update_doc), + Rule("/resource///", methods=["DELETE"], endpoint=delete_doc), + Rule("/resource///", methods=["POST"], endpoint=execute_doc_method), +] diff --git a/frappe/api/v2.py b/frappe/api/v2.py new file mode 100644 index 0000000000..06b6eab04e --- /dev/null +++ b/frappe/api/v2.py @@ -0,0 +1,193 @@ +"""REST API v2 + +This file defines routes and implementation for REST API. + +Note: + - All functions in this file should be treated as "whitelisted" as they are exposed via routes + - None of the functions present here should be called from python code, their location and + internal implementation can change without treating it as "breaking change". +""" +import json +from typing import Any + +from werkzeug.routing import Rule + +import frappe +import frappe.client +from frappe import _, get_newargs, is_whitelisted +from frappe.core.doctype.server_script.server_script_utils import get_server_script_map +from frappe.handler import is_valid_http_method, run_server_script, upload_file + +PERMISSION_MAP = { + "GET": "read", + "POST": "write", +} + + +def handle_rpc_call(method: str, doctype: str | None = None): + from frappe.modules.utils import load_doctype_module + + if doctype: + # Expand to run actual method from doctype controller + module = load_doctype_module(doctype) + method = module.__name__ + "." + method + + for hook in reversed(frappe.get_hooks("override_whitelisted_methods", {}).get(method, [])): + # override using the last hook + method = hook + break + + # via server script + server_script = get_server_script_map().get("_api", {}).get(method) + if server_script: + return run_server_script(server_script) + + try: + method = frappe.get_attr(method) + except Exception as e: + frappe.throw(_("Failed to get method {0} with {1}").format(method, e)) + + is_whitelisted(method) + is_valid_http_method(method) + + return frappe.call(method, **frappe.form_dict) + + +def login(): + """Login happens implicitly, this function doesn't do anything.""" + pass + + +def logout(): + frappe.local.login_manager.logout() + frappe.db.commit() + + +def read_doc(doctype: str, name: str): + doc = frappe.get_doc(doctype, name) + doc.check_permission("read") + doc.apply_fieldlevel_read_permissions() + return doc + + +def document_list(doctype: str): + if frappe.form_dict.get("fields"): + frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"]) + + # set limit of records for frappe.get_list + frappe.form_dict.limit_page_length = frappe.form_dict.limit or 20 + # evaluate frappe.get_list + return frappe.call(frappe.client.get_list, doctype, **frappe.form_dict) + + +def count(doctype: str) -> int: + from frappe.desk.reportview import get_count + + frappe.form_dict.doctype = doctype + + return get_count() + + +def create_doc(doctype: str): + data = frappe.form_dict + data.pop("doctype", None) + return frappe.new_doc(doctype, **data).insert() + + +def update_doc(doctype: str, name: str): + data = frappe.form_dict + + doc = frappe.get_doc(doctype, name, for_update=True) + data.pop("flags", None) + doc.update(data) + doc.save() + + # check for child table doctype + if doc.get("parenttype"): + frappe.get_doc(doc.parenttype, doc.parent).save() + + return doc + + +def delete_doc(doctype: str, name: str): + frappe.client.delete_doc(doctype, name) + frappe.response.http_status_code = 202 + return "ok" + + +def execute_doc_method(doctype: str, name: str, method: str | None = None): + """Get a document from DB and execute method on it. + + Use cases: + - Submitting/cancelling document + - Triggering some kind of update on a document + """ + method = method or frappe.form_dict.pop("run_method") + doc = frappe.get_doc(doctype, name) + doc.is_whitelisted(method) + + doc.check_permission(PERMISSION_MAP[frappe.request.method]) + return doc.run_method(method, **frappe.form_dict) + + +def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None): + """run a whitelisted controller method on in-memory document. + + + This is useful for building clients that don't necessarily encode all the business logic but + call server side function on object to validate and modify the doc. + + The doc CAN exists in DB too and can write to DB as well if method is POST. + """ + + if isinstance(document, str): + document = frappe.parse_json(document) + + if kwargs is None: + kwargs = {} + + doc = frappe.get_doc(document) + doc._original_modified = doc.modified + doc.check_if_latest() + + doc.check_permission(PERMISSION_MAP[frappe.request.method]) + + method_obj = getattr(doc, method) + fn = getattr(method_obj, "__func__", method_obj) + is_whitelisted(fn) + is_valid_http_method(fn) + + new_kwargs = get_newargs(fn, kwargs) + response = doc.run_method(method, **new_kwargs) + frappe.response.docs.append(doc) # send modified document and result both. + return response + + +url_rules = [ + # RPC calls + Rule("/method/login", endpoint=login), + Rule("/method/logout", endpoint=logout), + Rule("/method/ping", endpoint=frappe.ping), + Rule("/method/upload_file", endpoint=upload_file), + Rule("/method/", endpoint=handle_rpc_call), + Rule( + "/method/run_doc_method", + methods=["GET", "POST"], + endpoint=lambda: frappe.call(run_doc_method, **frappe.form_dict), + ), + Rule("/method//", endpoint=handle_rpc_call), + # Document level APIs + Rule("/document/", methods=["GET"], endpoint=document_list), + Rule("/document/", methods=["POST"], endpoint=create_doc), + Rule("/document///", methods=["GET"], endpoint=read_doc), + Rule("/document///", methods=["PATCH", "PUT"], endpoint=update_doc), + Rule("/document///", methods=["DELETE"], endpoint=delete_doc), + Rule( + "/document///method//", + methods=["GET", "POST"], + endpoint=execute_doc_method, + ), + # Collection level APIs + Rule("/doctype//meta", methods=["GET"], endpoint=frappe.get_meta), + Rule("/doctype//count", methods=["GET"], endpoint=count), +] diff --git a/frappe/app.py b/frappe/app.py index 28fa9c2426..add62c2bbd 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -22,10 +22,11 @@ import frappe.rate_limiter import frappe.recorder import frappe.utils.response from frappe import _ -from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest +from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest, validate_auth from frappe.middlewares import StaticDataMiddleware -from frappe.utils import CallbackManager, cint, get_site_name, sanitize_html +from frappe.utils import CallbackManager, cint, get_site_name from frappe.utils.data import escape_html +from frappe.utils.deprecations import deprecation_warning from frappe.utils.error import log_error_snapshot from frappe.website.serve import get_response @@ -93,16 +94,20 @@ def application(request: Request): init_request(request) - frappe.api.validate_auth() + validate_auth() if request.method == "OPTIONS": response = Response() elif frappe.form_dict.cmd: - response = frappe.handler.handle() + deprecation_warning( + f"{frappe.form_dict.cmd}: Sending `cmd` for RPC calls is deprecated, call REST API instead `/api/method/cmd`" + ) + frappe.handler.handle() + response = frappe.utils.response.build_response("json") elif request.path.startswith("/api/"): - response = frappe.api.handle() + response = frappe.api.handle(request) elif request.path.startswith("/backups"): response = frappe.utils.response.download_backup(request.path) diff --git a/frappe/auth.py b/frappe/auth.py index d1259e1aaf..35e07236bb 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -1,6 +1,8 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See LICENSE -from urllib.parse import quote +import base64 +import binascii +from urllib.parse import quote, urlencode, urlparse import frappe import frappe.database @@ -17,6 +19,7 @@ from frappe.twofactor import ( should_run_2fa, ) from frappe.utils import cint, date_diff, datetime, get_datetime, today +from frappe.utils.deprecations import deprecation_warning from frappe.utils.password import check_password from frappe.website.utils import get_home_page @@ -235,23 +238,28 @@ class LoginManager: _raw_user_name = user user = User.find_by_credentials(user, pwd) + ip_tracker = get_login_attempt_tracker(frappe.local.request_ip) if not user: + ip_tracker and ip_tracker.add_failure_attempt() self.fail("Invalid login credentials", user=_raw_user_name) # Current login flow uses cached credentials for authentication while checking OTP. # Incase of OTP check, tracker for auth needs to be disabled(If not, it can remove tracker history as it is going to succeed anyway) # Tracker is activated for 2FA incase of OTP. ignore_tracker = should_run_2fa(user.name) and ("otp" in frappe.form_dict) - tracker = None if ignore_tracker else get_login_attempt_tracker(user.name) + user_tracker = None if ignore_tracker else get_login_attempt_tracker(user.name) if not user.is_authenticated: - tracker and tracker.add_failure_attempt() + user_tracker and user_tracker.add_failure_attempt() + ip_tracker and ip_tracker.add_failure_attempt() self.fail("Invalid login credentials", user=user.name) elif not (user.name == "Administrator" or user.enabled): - tracker and tracker.add_failure_attempt() + user_tracker and user_tracker.add_failure_attempt() + ip_tracker and ip_tracker.add_failure_attempt() self.fail("User disabled or missing", user=user.name) else: - tracker and tracker.add_success_attempt() + user_tracker and user_tracker.add_success_attempt() + ip_tracker and ip_tracker.add_success_attempt() self.user = user.name def force_user_to_reset_password(self): @@ -433,7 +441,7 @@ def validate_ip_address(user): frappe.throw(_("Access not allowed from this IP Address"), frappe.AuthenticationError) -def get_login_attempt_tracker(user_name: str, raise_locked_exception: bool = True): +def get_login_attempt_tracker(key: str, raise_locked_exception: bool = True): """Get login attempt tracker instance. :param user_name: Name of the loggedin user @@ -447,7 +455,7 @@ def get_login_attempt_tracker(user_name: str, raise_locked_exception: bool = Tru tracker_kwargs["lock_interval"] = sys_settings.allow_login_after_fail tracker_kwargs["max_consecutive_login_attempts"] = sys_settings.allow_consecutive_login_attempts - tracker = LoginAttemptTracker(user_name, **tracker_kwargs) + tracker = LoginAttemptTracker(key, **tracker_kwargs) if raise_locked_exception and track_login_attempts and not tracker.is_user_allowed(): frappe.throw( @@ -466,7 +474,12 @@ class LoginAttemptTracker: """ def __init__( - self, user_name: str, max_consecutive_login_attempts: int = 3, lock_interval: int = 5 * 60 + self, + key: str, + max_consecutive_login_attempts: int = 3, + lock_interval: int = 5 * 60, + *, + user_name: str = None, ): """Initialize the tracker. @@ -474,21 +487,23 @@ class LoginAttemptTracker: :param max_consecutive_login_attempts: Maximum allowed consecutive failed login attempts :param lock_interval: Locking interval incase of maximum failed attempts """ - self.user_name = user_name + if user_name: + deprecation_warning("`username` parameter is deprecated, use `key` instead.") + self.key = key or user_name self.lock_interval = datetime.timedelta(seconds=lock_interval) self.max_failed_logins = max_consecutive_login_attempts @property def login_failed_count(self): - return frappe.cache.hget("login_failed_count", self.user_name) + return frappe.cache.hget("login_failed_count", self.key) @login_failed_count.setter def login_failed_count(self, count): - frappe.cache.hset("login_failed_count", self.user_name, count) + frappe.cache.hset("login_failed_count", self.key, count) @login_failed_count.deleter def login_failed_count(self): - frappe.cache.hdel("login_failed_count", self.user_name) + frappe.cache.hdel("login_failed_count", self.key) @property def login_failed_time(self): @@ -496,15 +511,15 @@ class LoginAttemptTracker: For every user we track only First failed login attempt time within lock interval of time. """ - return frappe.cache.hget("login_failed_time", self.user_name) + return frappe.cache.hget("login_failed_time", self.key) @login_failed_time.setter def login_failed_time(self, timestamp): - frappe.cache.hset("login_failed_time", self.user_name, timestamp) + frappe.cache.hset("login_failed_time", self.key, timestamp) @login_failed_time.deleter def login_failed_time(self): - frappe.cache.hdel("login_failed_time", self.user_name) + frappe.cache.hdel("login_failed_time", self.key) def add_failure_attempt(self): """Log user failure attempts into the system. @@ -547,3 +562,102 @@ class LoginAttemptTracker: ): return False return True + + +def validate_auth(): + """ + Authenticate and sets user for the request. + """ + authorization_header = frappe.get_request_header("Authorization", "").split(" ") + + if len(authorization_header) == 2: + validate_oauth(authorization_header) + validate_auth_via_api_keys(authorization_header) + + validate_auth_via_hooks() + + +def validate_oauth(authorization_header): + """ + Authenticate request using OAuth and set session user + + Args: + authorization_header (list of str): The 'Authorization' header containing the prefix and token + """ + + from frappe.integrations.oauth2 import get_oauth_server + from frappe.oauth import get_url_delimiter + + form_dict = frappe.local.form_dict + token = authorization_header[1] + req = frappe.request + parsed_url = urlparse(req.url) + access_token = {"access_token": token} + uri = ( + parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + "?" + urlencode(access_token) + ) + http_method = req.method + headers = req.headers + body = req.get_data() + if req.content_type and "multipart/form-data" in req.content_type: + body = None + + try: + required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split( + get_url_delimiter() + ) + valid, oauthlib_request = get_oauth_server().verify_request( + uri, http_method, body, headers, required_scopes + ) + if valid: + frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user")) + frappe.local.form_dict = form_dict + except AttributeError: + pass + + +def validate_auth_via_api_keys(authorization_header): + """ + Authenticate request using API keys and set session user + + Args: + authorization_header (list of str): The 'Authorization' header containing the prefix and token + """ + + try: + auth_type, auth_token = authorization_header + authorization_source = frappe.get_request_header("Frappe-Authorization-Source") + if auth_type.lower() == "basic": + api_key, api_secret = frappe.safe_decode(base64.b64decode(auth_token)).split(":") + validate_api_key_secret(api_key, api_secret, authorization_source) + elif auth_type.lower() == "token": + api_key, api_secret = auth_token.split(":") + validate_api_key_secret(api_key, api_secret, authorization_source) + except binascii.Error: + frappe.throw( + _("Failed to decode token, please provide a valid base64-encoded token."), + frappe.InvalidAuthorizationToken, + ) + except (AttributeError, TypeError, ValueError): + pass + + +def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None): + """frappe_authorization_source to provide api key and secret for a doctype apart from User""" + doctype = frappe_authorization_source or "User" + doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"]) + form_dict = frappe.local.form_dict + doc_secret = frappe.utils.password.get_decrypted_password(doctype, doc, fieldname="api_secret") + if api_secret == doc_secret: + if doctype == "User": + user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"]) + else: + user = frappe.db.get_value(doctype, doc, "user") + if frappe.local.login_manager.user in ("", "Guest"): + frappe.set_user(user) + frappe.local.form_dict = form_dict + + +def validate_auth_via_hooks(): + for auth_hook in frappe.get_hooks("auth_hooks", []): + frappe.get_attr(auth_hook)() diff --git a/frappe/boot.py b/frappe/boot.py index 3f6e3a6a39..c36927637a 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -295,8 +295,7 @@ def add_home_page(bootinfo, docs): docs.append(page) bootinfo["home_page"] = page.name except (frappe.DoesNotExistError, frappe.PermissionError): - if frappe.message_log: - frappe.message_log.pop() + frappe.clear_last_message() bootinfo["home_page"] = "Workspaces" diff --git a/frappe/client.py b/frappe/client.py index 85e99a6534..6439e9d71d 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -32,8 +32,8 @@ def get_list( limit_start=None, limit_page_length=20, parent=None, - debug=False, - as_dict=True, + debug: bool = False, + as_dict: bool = True, or_filters=None, ): """Returns a list of records by filters, fields, ordering and limit diff --git a/frappe/contacts/doctype/address/address.json b/frappe/contacts/doctype/address/address.json index 4c4694cf90..4b104c7ab0 100644 --- a/frappe/contacts/doctype/address/address.json +++ b/frappe/contacts/doctype/address/address.json @@ -148,7 +148,7 @@ "icon": "fa fa-map-marker", "idx": 5, "links": [], - "modified": "2023-10-09 11:42:04.982763", + "modified": "2023-10-11 11:48:26.954934", "modified_by": "Administrator", "module": "Contacts", "name": "Address", @@ -210,6 +210,7 @@ { "create": 1, "export": 1, + "if_owner": 1, "print": 1, "read": 1, "role": "All", diff --git a/frappe/contacts/doctype/address/address.py b/frappe/contacts/doctype/address/address.py index ea6e9772fd..169c9eecb4 100644 --- a/frappe/contacts/doctype/address/address.py +++ b/frappe/contacts/doctype/address/address.py @@ -1,8 +1,6 @@ # Copyright (c) 2015, Frappe Technologies and contributors # License: MIT. See LICENSE -from typing import Optional - from jinja2 import TemplateSyntaxError import frappe @@ -166,18 +164,23 @@ def get_default_address( @frappe.whitelist() def get_address_display(address_dict: dict | str | None) -> str | None: - if not address_dict: + return render_address(address_dict) + + +def render_address(address: dict | str | None, check_permissions=True) -> str | None: + if not address: return - if not isinstance(address_dict, dict): - address = frappe.get_cached_doc("Address", address_dict) - address.check_permission() - address_dict = address.as_dict() + if not isinstance(address, dict): + address = frappe.get_cached_doc("Address", address) + if check_permissions: + address.check_permission() + address = address.as_dict() - name, template = get_address_templates(address_dict) + name, template = get_address_templates(address) try: - return frappe.render_template(template, address_dict) + return frappe.render_template(template, address) except TemplateSyntaxError: frappe.throw(_("There is an error in your Address Template {0}").format(name)) @@ -258,7 +261,7 @@ def get_company_address(company): if company: ret.company_address = get_default_address("Company", company) - ret.company_address_display = get_address_display(ret.company_address) + ret.company_address_display = render_address(ret.company_address, check_permissions=False) return ret diff --git a/frappe/core/doctype/activity_log/test_activity_log.py b/frappe/core/doctype/activity_log/test_activity_log.py index 32644d9630..0017d8f870 100644 --- a/frappe/core/doctype/activity_log/test_activity_log.py +++ b/frappe/core/doctype/activity_log/test_activity_log.py @@ -20,6 +20,7 @@ class TestActivityLog(FrappeTestCase): } ) + frappe.local.request_ip = "127.0.0.1" frappe.local.cookie_manager = CookieManager() frappe.local.login_manager = LoginManager() @@ -60,6 +61,7 @@ class TestActivityLog(FrappeTestCase): {"cmd": "login", "sid": "Guest", "pwd": "admin", "usr": "Administrator"} ) + frappe.local.request_ip = "127.0.0.1" frappe.local.cookie_manager = CookieManager() frappe.local.login_manager = LoginManager() diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py index 978f5792dd..965e34c3e6 100644 --- a/frappe/core/doctype/data_import/test_importer.py +++ b/frappe/core/doctype/data_import/test_importer.py @@ -63,7 +63,7 @@ class TestImporter(FrappeTestCase): def test_data_import_without_mandatory_values(self): import_file = get_import_file("sample_import_file_without_mandatory") data_import = self.get_importer(doctype_name, import_file) - frappe.local.message_log = [] + frappe.clear_messages() data_import.start_import() data_import.reload() diff --git a/frappe/core/doctype/deleted_document/deleted_document.py b/frappe/core/doctype/deleted_document/deleted_document.py index b5b35206e9..aa6239c279 100644 --- a/frappe/core/doctype/deleted_document/deleted_document.py +++ b/frappe/core/doctype/deleted_document/deleted_document.py @@ -73,11 +73,11 @@ def bulk_restore(docnames): restored.append(d) except frappe.DocumentAlreadyRestored: - frappe.message_log.pop() + frappe.clear_last_message() invalid.append(d) except Exception: - frappe.message_log.pop() + frappe.clear_last_message() failed.append(d) frappe.db.rollback() diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index 650729aef6..929244c977 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -135,33 +135,6 @@ frappe.ui.form.on("DocField", { }, }); -function render_form_builder_message(frm) { - $(frm.fields_dict["try_form_builder_html"].wrapper).empty(); - if (!frm.is_new() && frm.fields_dict["try_form_builder_html"]) { - let title = __("Use Form Builder to visually edit your form layout"); - let msg = __( - "You can drag and drop fields to create your form layout, add tabs, sections and columns to organize your form and update field properties all from one screen." - ); - - let message = ` - - `; - - $(frm.fields_dict["try_form_builder_html"].wrapper).html(message); - } -} - function render_form_builder(frm) { if (frappe.form_builder && frappe.form_builder.doctype === frm.doc.name) { frappe.form_builder.setup_page_actions(); diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index a02f776188..cf5f608b4b 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -16,6 +16,7 @@ from frappe import _ from frappe.cache_manager import clear_controller_cache, clear_user_cache from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.custom.doctype.property_setter.property_setter import make_property_setter +from frappe.database import savepoint from frappe.database.schema import validate_column_length, validate_column_name from frappe.desk.notifications import delete_notification_count_for, get_filters_for from frappe.desk.utils import validate_route_conflict @@ -522,7 +523,9 @@ class DocType(Document): if self.flags.in_insert: self.run_module_method("after_doctype_insert") + self.sync_doctype_layouts() delete_notification_count_for(doctype=self.name) + frappe.clear_cache(doctype=self.name) # clear user cache so that on the next reload this doctype is included in boot @@ -533,6 +536,17 @@ class DocType(Document): clear_linked_doctype_cache() + @savepoint(catch=Exception) + def sync_doctype_layouts(self): + """Sync Doctype Layout""" + doctype_layouts = frappe.get_all( + "DocType Layout", filters={"document_type": self.name}, pluck="name", ignore_ddl=True + ) + for layout in doctype_layouts: + layout_doc = frappe.get_doc("DocType Layout", layout) + layout_doc.sync_fields() + layout_doc.save() + def setup_autoincrement_and_sequence(self): """Changes name type and makes sequence on change (if required)""" diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.py b/frappe/core/doctype/document_naming_settings/document_naming_settings.py index 3ec4147ec7..ddb25dd262 100644 --- a/frappe/core/doctype/document_naming_settings/document_naming_settings.py +++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.py @@ -248,8 +248,7 @@ class DocumentNamingSettings(Document): doc = self._fetch_last_doc_if_available() return "\n".join(NamingSeries(series).get_preview(doc=doc)) except Exception as e: - if frappe.message_log: - frappe.message_log.pop() + frappe.clear_last_message() return _("Failed to generate names from the series") + f"\n{str(e)}" def _fetch_last_doc_if_available(self): diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 42f349aef4..43dc51c8b1 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -501,7 +501,7 @@ class TestFile(FrappeTestCase): test_file.file_url = frappe.utils.get_url("unknown.jpg") test_file.make_thumbnail(suffix="xs") self.assertEqual( - json.loads(frappe.message_log[0]).get("message"), + frappe.message_log[0].get("message"), f"File '{frappe.utils.get_url('unknown.jpg')}' not found", ) self.assertEqual(test_file.thumbnail_url, None) diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json index cc2a0e870a..d6b1359337 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json @@ -53,6 +53,7 @@ { "allow_in_quick_entry": 1, "depends_on": "eval:doc.frequency==='Cron'", + "description": "
*  *  *  *  *\n\u252c  \u252c  \u252c  \u252c  \u252c\n\u2502  \u2502  \u2502  \u2502  \u2502\n\u2502  \u2502  \u2502  \u2502  \u2514 day of week (0 - 6) (0 is Sunday)\n\u2502  \u2502  \u2502  \u2514\u2500\u2500\u2500\u2500\u2500 month (1 - 12)\n\u2502  \u2502  \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 day of month (1 - 31)\n\u2502  \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 hour (0 - 23)\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 minute (0 - 59)\n\n---\n\n* - Any value\n/ - Step values\n
\n", "fieldname": "cron_format", "fieldtype": "Data", "label": "Cron Format", @@ -100,7 +101,7 @@ "link_fieldname": "scheduled_job_type" } ], - "modified": "2022-06-28 02:55:12.470915", + "modified": "2023-10-14 11:26:05.005930", "modified_by": "Administrator", "module": "Core", "name": "Scheduled Job Type", diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py index fc84204b37..6f56180c89 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -105,9 +105,12 @@ class ScheduledJobType(Document): if not self.cron_format: self.cron_format = CRON_MAP[self.frequency] - return croniter( - self.cron_format, get_datetime(self.last_execution or datetime(2000, 1, 1)) - ).get_next(datetime) + # If this is a cold start then last_execution will not be set. + # Creation is set as fallback because if very old fallback is set job might trigger + # immediately, even when it's meant to be daily. + # A dynamic fallback like current time might miss the scheduler interval and job will never start. + last_execution = get_datetime(self.last_execution or self.creation) + return croniter(self.cron_format, last_execution).get_next(datetime) def execute(self): self.scheduler_log = None diff --git a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py index 7edad24ac4..0db573c5b9 100644 --- a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py @@ -1,9 +1,12 @@ # Copyright (c) 2019, Frappe Technologies and Contributors # License: MIT. See LICENSE +from datetime import timedelta + import frappe from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs from frappe.tests.utils import FrappeTestCase from frappe.utils import get_datetime +from frappe.utils.data import add_to_date, now_datetime class TestScheduledJobType(FrappeTestCase): @@ -65,9 +68,34 @@ class TestScheduledJobType(FrappeTestCase): self.assertFalse(job.is_event_due(get_datetime("2019-01-31 23:59:59"))) def test_cron_job(self): + # Daily but offset by 45 minutes + job = frappe.get_doc( + "Scheduled Job Type", + dict(method="frappe.core.doctype.log_settings.log_settings.run_log_clean_up"), + ) + self.assertEqual( + job.next_execution, + add_to_date(None, days=1).replace(hour=0, minute=45, second=0, microsecond=0), + ) # runs every 15 mins job = frappe.get_doc("Scheduled Job Type", dict(method="frappe.oauth.delete_oauth2_data")) job.db_set("last_execution", "2019-01-01 00:00:00") + self.assertEqual(job.next_execution, get_datetime("2019-01-01 00:15:00")) self.assertTrue(job.is_event_due(get_datetime("2019-01-01 00:15:01"))) self.assertFalse(job.is_event_due(get_datetime("2019-01-01 00:05:06"))) self.assertFalse(job.is_event_due(get_datetime("2019-01-01 00:14:59"))) + + def test_cold_start(self): + now = now_datetime() + just_before_12_am = now.replace(hour=11, minute=59, second=30) + just_after_12_am = now.replace(hour=0, minute=0, second=30) + timedelta(days=1) + + job = frappe.new_doc("Scheduled Job Type") + job.frequency = "Daily" + job.set_user_and_timestamp() + + with self.freeze_time(just_before_12_am): + self.assertFalse(job.is_event_due()) + + with self.freeze_time(just_after_12_am): + self.assertTrue(job.is_event_due()) diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json index 67cb6e75ea..50f5bfcfe8 100644 --- a/frappe/core/doctype/server_script/server_script.json +++ b/frappe/core/doctype/server_script/server_script.json @@ -136,6 +136,7 @@ }, { "depends_on": "eval:doc.event_frequency==='Cron'", + "description": "
*  *  *  *  *\n\u252c  \u252c  \u252c  \u252c  \u252c\n\u2502  \u2502  \u2502  \u2502  \u2502\n\u2502  \u2502  \u2502  \u2502  \u2514 day of week (0 - 6) (0 is Sunday)\n\u2502  \u2502  \u2502  \u2514\u2500\u2500\u2500\u2500\u2500 month (1 - 12)\n\u2502  \u2502  \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 day of month (1 - 31)\n\u2502  \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 hour (0 - 23)\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 minute (0 - 59)\n\n---\n\n* - Any value\n/ - Step values\n
\n", "fieldname": "cron_format", "fieldtype": "Data", "label": "Cron Format" @@ -148,7 +149,7 @@ "link_fieldname": "server_script" } ], - "modified": "2023-05-27 16:33:16.595424", + "modified": "2023-10-14 11:24:46.478533", "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 1a0017e443..a58e50dddc 100644 --- a/frappe/core/doctype/server_script/server_script.py +++ b/frappe/core/doctype/server_script/server_script.py @@ -75,6 +75,7 @@ class ServerScript(Document): return super().clear_cache() def on_trash(self): + frappe.cache.delete_value("server_script_map") if self.script_type == "Scheduler Event": for job in self.scheduled_jobs: frappe.delete_doc("Scheduled Job Type", job.name) diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 76126fd4fa..7e8664595c 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -292,6 +292,7 @@ "label": "Brute Force Security" }, { + "default": "10", "fieldname": "allow_consecutive_login_attempts", "fieldtype": "Int", "label": "Allow Consecutive Login Attempts " @@ -602,7 +603,7 @@ "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2023-09-25 16:49:16.652874", + "modified": "2023-10-17 16:12:28.145496", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 1ca0a56ec0..dc973c9e8f 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -420,7 +420,7 @@ class TestUser(FrappeTestCase): self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/") update_password(old_password, old_password=new_password) self.assertEqual( - json.loads(frappe.message_log[0]).get("message"), + frappe.message_log[0].get("message"), "Password reset instructions have been sent to your email", ) diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index f168ad920f..a12dda661e 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -123,7 +123,8 @@ frappe.ui.form.on("User", { !doc.__unsaved && frappe.all_timezones && (hasChanged(doc.language, frappe.boot.user.language) || - hasChanged(doc.time_zone, frappe.boot.time_zone.user)) + hasChanged(doc.time_zone, frappe.boot.time_zone.user) || + hasChanged(doc.desk_theme, frappe.boot.user.desk_theme)) ) { frappe.msgprint(__("Refreshing...")); window.location.reload(); diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 1af7af72e5..f22c62050e 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -549,6 +549,10 @@ class User(Document): # delete user permissions frappe.db.delete("User Permission", {"user": self.name}) + # Delete OAuth data + frappe.db.delete("OAuth Authorization Code", {"user": self.name}) + frappe.db.delete("Token Cache", {"user": self.name}) + def before_rename(self, old_name, new_name, merge=False): frappe.clear_cache(user=old_name) self.validate_rename(old_name, new_name) @@ -775,7 +779,7 @@ def get_timezones(): @frappe.whitelist() -def get_all_roles(arg=None): +def get_all_roles(): """return all roles""" active_domains = frappe.get_active_domains() @@ -789,7 +793,7 @@ def get_all_roles(arg=None): order_by="name", ) - return [role.get("name") for role in roles] + return sorted([role.get("name") for role in roles]) @frappe.whitelist() 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 25657e17e8..b865c23b11 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 @@ -59,6 +59,6 @@ def query_doctypes(doctype, txt, searchfield, start, page_len, filters): return [ [dt] for dt in can_read - if txt.lower().replace("%", "") in dt.lower() + if txt.lower().replace("%", "") in frappe._(dt).lower() and (include_single_doctypes or dt not in single_doctypes) ] diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.py b/frappe/custom/doctype/doctype_layout/doctype_layout.py index 2589270944..c155f32ed1 100644 --- a/frappe/custom/doctype/doctype_layout/doctype_layout.py +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.py @@ -32,7 +32,7 @@ class DocTypeLayout(Document): @frappe.whitelist() def sync_fields(self): - doctype_fields = frappe.get_meta(self.document_type).fields + doctype_fields = frappe.get_meta(self.document_type, cached=False).fields if self.is_new(): added_fields = [field.fieldname for field in doctype_fields] diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index 4701879982..8ae20f7bb0 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -22,8 +22,7 @@ def handle_not_exist(fn): try: return fn(*args, **kwargs) except DoesNotExistError: - if frappe.message_log: - frappe.message_log.pop() + frappe.clear_last_message() return [] return wrapper diff --git a/frappe/desk/doctype/desktop_icon/desktop_icon.py b/frappe/desk/doctype/desktop_icon/desktop_icon.py index 524285f85d..7901ef9500 100644 --- a/frappe/desk/doctype/desktop_icon/desktop_icon.py +++ b/frappe/desk/doctype/desktop_icon/desktop_icon.py @@ -283,8 +283,7 @@ def set_desktop_icons(visible_list, ignore_duplicate=True): raise e else: visible_list.remove(module_name) - if frappe.message_log: - frappe.message_log.pop() + frappe.clear_last_message() # set the order set_order(visible_list) diff --git a/frappe/desk/doctype/note/note.py b/frappe/desk/doctype/note/note.py index ede8ecbc5d..ec89e7878c 100644 --- a/frappe/desk/doctype/note/note.py +++ b/frappe/desk/doctype/note/note.py @@ -54,3 +54,7 @@ def get_permission_query_conditions(user): user = frappe.session.user return f"(`tabNote`.owner = {frappe.db.escape(user)} or `tabNote`.public = 1)" + + +def has_permission(doc, user): + return doc.public or doc.owner == user diff --git a/frappe/desk/doctype/tag/tag.py b/frappe/desk/doctype/tag/tag.py index 05adaed926..8ee18fa74b 100644 --- a/frappe/desk/doctype/tag/tag.py +++ b/frappe/desk/doctype/tag/tag.py @@ -48,8 +48,6 @@ def add_tags(tags, dt, docs, color=None): for tag in tags: DocTags(dt).add(doc, tag) - # return tag - @frappe.whitelist() def remove_tag(tag, dt, dn): @@ -153,6 +151,7 @@ def update_tags(doc, tags): :param doc: Document to be added to global tags """ + doc.check_permission("write") new_tags = {tag.strip() for tag in tags.split(",") if tag} existing_tags = [ tag.tag diff --git a/frappe/desk/doctype/todo/todo.json b/frappe/desk/doctype/todo/todo.json index 518ca00374..c3b534d272 100644 --- a/frappe/desk/doctype/todo/todo.json +++ b/frappe/desk/doctype/todo/todo.json @@ -62,8 +62,11 @@ "label": "Color" }, { + "allow_in_quick_entry": 1, + "default": "Today", "fieldname": "date", "fieldtype": "Date", + "in_list_view": 1, "in_standard_filter": 1, "label": "Due Date", "oldfieldname": "date", @@ -158,7 +161,7 @@ "icon": "fa fa-check", "idx": 2, "links": [], - "modified": "2021-09-16 11:36:34.586898", + "modified": "2023-10-05 07:44:38.476400", "modified_by": "Administrator", "module": "Desk", "name": "ToDo", @@ -196,4 +199,4 @@ "title_field": "description", "track_changes": 1, "track_seen": 1 -} \ No newline at end of file +} diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 195f9a8ba5..758681b0dc 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -242,6 +242,12 @@ def new_page(new_page): if page.get("public") and not is_workspace_manager(): return + elif ( + not page.get("public") + and page.get("for_user") != frappe.session.user + and not is_workspace_manager() + ): + frappe.throw(_("Cannot create private workspace of other users"), frappe.PermissionError) doc = frappe.new_doc("Workspace") doc.title = page.get("title") @@ -283,6 +289,16 @@ def update_page(name, title, icon, indicator_color, parent, public): public = frappe.parse_json(public) doc = frappe.get_doc("Workspace", name) + if ( + not doc.get("public") + and doc.get("for_user") != frappe.session.user + and not is_workspace_manager() + ): + frappe.throw( + _("Need Workspace Manager role to edit private workspace of other users"), + frappe.PermissionError, + ) + if doc: doc.title = title doc.icon = icon @@ -328,7 +344,11 @@ def hide_unhide_page(page_name: str, is_hidden: bool): _("Need Workspace Manager role to hide/unhide public workspaces"), frappe.PermissionError ) - if not page.get("public") and page.get("for_user") != frappe.session.user: + if ( + not page.get("public") + and page.get("for_user") != frappe.session.user + and not is_workspace_manager() + ): frappe.throw(_("Cannot update private workspace of other users"), frappe.PermissionError) page.is_hidden = int(is_hidden) @@ -387,7 +407,17 @@ def delete_page(page): page = loads(page) if page.get("public") and not is_workspace_manager(): - return + frappe.throw( + _("Cannot delete public workspace without Workspace Manager role"), + frappe.PermissionError, + ) + elif not page.get("public") and not is_workspace_manager(): + workspace_owner = frappe.get_value("Workspace", page.get("name"), "for_user") + if workspace_owner != frappe.session.user: + frappe.throw( + _("Cannot delete private workspace of other users"), + frappe.PermissionError, + ) if frappe.db.exists("Workspace", page.get("name")): frappe.get_doc("Workspace", page.get("name")).delete(ignore_permissions=True) diff --git a/frappe/desk/form/assign_to.py b/frappe/desk/form/assign_to.py index b1b14ee28b..a02d35414f 100644 --- a/frappe/desk/form/assign_to.py +++ b/frappe/desk/form/assign_to.py @@ -63,6 +63,8 @@ def add(args=None): "status": "Open", "allocated_to": assign_to, } + parent_doc = frappe.get_doc(args["doctype"], args["name"]) + parent_doc.check_permission() if frappe.get_all("ToDo", filters=filters): users_with_duplicate_todo.append(assign_to) @@ -174,6 +176,9 @@ def close(doctype: str, name: str, assign_to: str): def set_status(doctype, name, todo=None, assign_to=None, status="Cancelled"): """remove from todo""" + + doc = frappe.get_doc(doctype, name) + doc.check_permission() try: if not todo: todo = frappe.db.get_value( diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index 8f569b5a9e..501953c030 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -38,6 +38,7 @@ def get_submitted_linked_docs(doctype: str, name: str) -> list[tuple]: 3. Searching for links is going to be a tree like structure where at every level, you will be finding documents using parent document and parent document links. """ + frappe.has_permission(doctype, doc=name) tree = SubmittableDocumentTree(doctype, name) visited_documents = tree.get_all_children() docs = [] @@ -427,8 +428,7 @@ def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> di link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt) except Exception as e: if isinstance(e, frappe.DoesNotExistError): - if frappe.local.message_log: - frappe.local.message_log.pop() + frappe.clear_last_message() continue linkmeta = link_meta_bundle[0] @@ -502,8 +502,7 @@ def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> di ret = None except frappe.PermissionError: - if frappe.local.message_log: - frappe.local.message_log.pop() + frappe.clear_last_message() continue @@ -515,6 +514,7 @@ def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> di @frappe.whitelist() def get(doctype, docname): + frappe.has_permission(doctype, doc=docname) linked_doctypes = get_linked_doctypes(doctype=doctype) return get_linked_docs(doctype=doctype, name=docname, linkinfo=linked_doctypes) diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index 102a708895..fd299af819 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -46,7 +46,7 @@ def get_list(): @frappe.whitelist() @frappe.read_only() -def get_count(): +def get_count() -> int: args = get_form_params() if is_virtual_doctype(args.doctype): @@ -65,7 +65,7 @@ def execute(doctype, *args, **kwargs): def get_form_params(): - """Stringify GET request parameters.""" + """parse GET request parameters.""" data = frappe._dict(frappe.local.form_dict) clean_params(data) validate_args(data) diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 6c2f68f43c..8b76cd35e1 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -1,20 +1,24 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import functools import json import re +from typing import TypedDict + +from typing_extensions import NotRequired # not required in 3.11+ import frappe # Backward compatbility from frappe import _, is_whitelisted, validate_and_sanitize_search_inputs from frappe.database.schema import SPECIAL_CHAR_PATTERN +from frappe.model.db_query import get_order_by from frappe.permissions import has_permission from frappe.utils import cint, cstr, unique +from frappe.utils.data import make_filter_tuple -def sanitize_searchfield(searchfield): +def sanitize_searchfield(searchfield: str): if not searchfield: return @@ -22,19 +26,25 @@ def sanitize_searchfield(searchfield): frappe.throw(_("Invalid Search Field {0}").format(searchfield), frappe.DataError) +class LinkSearchResults(TypedDict): + value: str + description: str + label: NotRequired[str] + + # this is called by the Link Field @frappe.whitelist() def search_link( - doctype, - txt, - query=None, - filters=None, - page_length=10, - searchfield=None, - reference_doctype=None, - ignore_user_permissions=False, -): - search_widget( + doctype: str, + txt: str, + query: str | None = None, + filters: str | dict | list | None = None, + page_length: int = 10, + searchfield: str | None = None, + reference_doctype: str | None = None, + ignore_user_permissions: bool = False, +) -> list[LinkSearchResults]: + results = search_widget( doctype, txt.strip(), query, @@ -44,25 +54,23 @@ def search_link( reference_doctype=reference_doctype, ignore_user_permissions=ignore_user_permissions, ) - - frappe.response["results"] = build_for_autosuggest(frappe.response["values"], doctype=doctype) - del frappe.response["values"] + return build_for_autosuggest(results, doctype=doctype) # this is called by the search box @frappe.whitelist() def search_widget( - doctype, - txt, - query=None, - searchfield=None, - start=0, - page_length=10, - filters=None, + doctype: str, + txt: str, + query: str | None = None, + searchfield: str = None, + start: int = 0, + page_length: int = 10, + filters: str | None | dict | list = None, filter_fields=None, - as_dict=False, - reference_doctype=None, - ignore_user_permissions=False, + as_dict: bool = False, + reference_doctype: str | None = None, + ignore_user_permissions: bool = False, ): start = cint(start) @@ -78,11 +86,13 @@ def search_widget( standard_queries = frappe.get_hooks().standard_queries or {} - if query and query.split(maxsplit=1)[0].lower() != "select": - # by method + if not query and doctype in standard_queries: + query = standard_queries[doctype][-1] + + if query: # Query = custom search query i.e. python function try: is_whitelisted(frappe.get_attr(query)) - frappe.response["values"] = frappe.call( + return frappe.call( query, doctype, txt, @@ -93,9 +103,9 @@ def search_widget( as_dict=as_dict, reference_doctype=reference_doctype, ) - except frappe.exceptions.PermissionError as e: + except (frappe.PermissionError, frappe.AppNotInstalledError, ImportError): if frappe.local.conf.developer_mode: - raise e + raise else: frappe.respond_as_web_page( title="Invalid Method", @@ -103,154 +113,123 @@ def search_widget( indicator_color="red", http_status_code=404, ) - return - except Exception as e: - raise e - elif not query and doctype in standard_queries: - # from standard queries - search_widget( - doctype=doctype, - txt=txt, - query=standard_queries[doctype][0], - searchfield=searchfield, - start=start, - page_length=page_length, - filters=filters, - filter_fields=filter_fields, - as_dict=as_dict, - reference_doctype=reference_doctype, - ignore_user_permissions=ignore_user_permissions, + return [] + + meta = frappe.get_meta(doctype) + + if isinstance(filters, dict): + filters_items = filters.items() + filters = [] + for key, value in filters_items: + filters.append(make_filter_tuple(doctype, key, value)) + + if filters is None: + filters = [] + or_filters = [] + + # build from doctype + if txt: + field_types = { + "Data", + "Text", + "Small Text", + "Long Text", + "Link", + "Select", + "Read Only", + "Text Editor", + } + search_fields = ["name"] + if meta.title_field: + search_fields.append(meta.title_field) + + if meta.search_fields: + search_fields.extend(meta.get_search_fields()) + + for f in search_fields: + fmeta = meta.get_field(f.strip()) + if not meta.translated_doctype and (f == "name" or (fmeta and fmeta.fieldtype in field_types)): + or_filters.append([doctype, f.strip(), "like", f"%{txt}%"]) + + if meta.get("fields", {"fieldname": "enabled", "fieldtype": "Check"}): + filters.append([doctype, "enabled", "=", 1]) + if meta.get("fields", {"fieldname": "disabled", "fieldtype": "Check"}): + filters.append([doctype, "disabled", "!=", 1]) + + # format a list of fields combining search fields and filter fields + fields = get_std_fields_list(meta, searchfield or "name") + if filter_fields: + fields = list(set(fields + json.loads(filter_fields))) + formatted_fields = [f"`tab{meta.name}`.`{f.strip()}`" for f in fields] + + # Insert title field query after name + if meta.show_title_field_in_link: + formatted_fields.insert(1, f"`tab{meta.name}`.{meta.title_field} as `label`") + + order_by_based_on_meta = get_order_by(doctype, meta) + # `idx` is number of times a document is referred, check link_count.py + order_by = f"`tab{doctype}`.idx desc, {order_by_based_on_meta}" + + if not meta.translated_doctype: + _txt = frappe.db.escape((txt or "").replace("%", "").replace("@", "")) + # locate returns 0 if string is not found, convert 0 to null and then sort null to end in order by + _relevance = f"(1 / nullif(locate({_txt}, `tab{doctype}`.`name`), 0))" + formatted_fields.append(f"""{_relevance} as `_relevance`""") + # Since we are sorting by alias postgres needs to know number of column we are sorting + if frappe.db.db_type == "mariadb": + order_by = f"ifnull(_relevance, -9999) desc, {order_by}" + elif frappe.db.db_type == "postgres": + # Since we are sorting by alias postgres needs to know number of column we are sorting + order_by = f"{len(formatted_fields)} desc nulls last, {order_by}" + + ignore_permissions = doctype == "DocType" or ( + cint(ignore_user_permissions) + and has_permission( + doctype, + ptype="select" if frappe.only_has_select_perm(doctype) else "read", + parent_doctype=reference_doctype, ) - else: - meta = frappe.get_meta(doctype) + ) - if query: - frappe.throw(_("This query style is discontinued")) - # custom query - # frappe.response["values"] = frappe.db.sql(scrub_custom_query(query, searchfield, txt)) + values = frappe.get_list( + doctype, + filters=filters, + fields=formatted_fields, + or_filters=or_filters, + limit_start=start, + limit_page_length=None if meta.translated_doctype else page_length, + order_by=order_by, + ignore_permissions=ignore_permissions, + reference_doctype=reference_doctype, + as_list=not as_dict, + strict=False, + ) + + if meta.translated_doctype: + # Filtering the values array so that query is included in very element + values = ( + result + for result in values + if any( + re.search(f"{re.escape(txt)}.*", _(cstr(value)) or "", re.IGNORECASE) + for value in (result.values() if as_dict else result) + ) + ) + + # Sorting the values array so that relevant results always come first + # This will first bring elements on top in which query is a prefix of element + # Then it will bring the rest of the elements and sort them in lexicographical order + values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict)) + + # remove _relevance from results + if not meta.translated_doctype: + if as_dict: + for r in values: + r.pop("_relevance", None) else: - if isinstance(filters, dict): - filters_items = filters.items() - filters = [] - for f in filters_items: - if isinstance(f[1], (list, tuple)): - filters.append([doctype, f[0], f[1][0], f[1][1]]) - else: - filters.append([doctype, f[0], "=", f[1]]) + values = [r[:-1] for r in values] - if filters is None: - filters = [] - or_filters = [] - - # build from doctype - if txt: - field_types = [ - "Data", - "Text", - "Small Text", - "Long Text", - "Link", - "Select", - "Read Only", - "Text Editor", - ] - search_fields = ["name"] - if meta.title_field: - search_fields.append(meta.title_field) - - if meta.search_fields: - search_fields.extend(meta.get_search_fields()) - - for f in search_fields: - fmeta = meta.get_field(f.strip()) - if not meta.translated_doctype and ( - f == "name" or (fmeta and fmeta.fieldtype in field_types) - ): - or_filters.append([doctype, f.strip(), "like", f"%{txt}%"]) - - if meta.get("fields", {"fieldname": "enabled", "fieldtype": "Check"}): - filters.append([doctype, "enabled", "=", 1]) - if meta.get("fields", {"fieldname": "disabled", "fieldtype": "Check"}): - filters.append([doctype, "disabled", "!=", 1]) - - # format a list of fields combining search fields and filter fields - fields = get_std_fields_list(meta, searchfield or "name") - if filter_fields: - fields = list(set(fields + json.loads(filter_fields))) - formatted_fields = [f"`tab{meta.name}`.`{f.strip()}`" for f in fields] - - # Insert title field query after name - if meta.show_title_field_in_link: - formatted_fields.insert(1, f"`tab{meta.name}`.{meta.title_field} as `label`") - - # In order_by, `idx` gets second priority, because it stores link count - from frappe.model.db_query import get_order_by - - order_by_based_on_meta = get_order_by(doctype, meta) - # 2 is the index of _relevance column - order_by = f"{order_by_based_on_meta}, `tab{doctype}`.idx desc" - - if not meta.translated_doctype: - formatted_fields.append( - """locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format( - _txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), - doctype=doctype, - ) - ) - order_by = f"_relevance, {order_by}" - - ignore_permissions = ( - True - if doctype == "DocType" - else ( - cint(ignore_user_permissions) - and has_permission( - doctype, - ptype="select" if frappe.only_has_select_perm(doctype) else "read", - parent_doctype=reference_doctype, - ) - ) - ) - - values = frappe.get_list( - doctype, - filters=filters, - fields=formatted_fields, - or_filters=or_filters, - limit_start=start, - limit_page_length=None if meta.translated_doctype else page_length, - order_by=order_by, - ignore_permissions=ignore_permissions, - reference_doctype=reference_doctype, - as_list=not as_dict, - strict=False, - ) - - if meta.translated_doctype: - # Filtering the values array so that query is included in very element - values = ( - result - for result in values - if any( - re.search(f"{re.escape(txt)}.*", _(cstr(value)) or "", re.IGNORECASE) - for value in (result.values() if as_dict else result) - ) - ) - - # Sorting the values array so that relevant results always come first - # This will first bring elements on top in which query is a prefix of element - # Then it will bring the rest of the elements and sort them in lexicographical order - values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict)) - - # remove _relevance from results - if not meta.translated_doctype: - if as_dict: - for r in values: - r.pop("_relevance") - else: - values = [r[:-1] for r in values] - - frappe.response["values"] = values + return values def get_std_fields_list(meta, key): @@ -271,7 +250,7 @@ def get_std_fields_list(meta, key): return sflist -def build_for_autosuggest(res: list[tuple], doctype: str) -> list[dict]: +def build_for_autosuggest(res: list[tuple], doctype: str) -> list[LinkSearchResults]: def to_string(parts): return ", ".join( unique(_(cstr(part)) if meta.translated_doctype else cstr(part) for part in parts if part) diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 149e42dd80..24d548cb37 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -309,7 +309,7 @@ class EmailAccount(Document): except OSError: if in_receive: # timeout while connecting, see receive.py connect method - description = frappe.message_log.pop() if frappe.message_log else "Socket Error" + description = frappe.clear_last_message() if frappe.message_log else "Socket Error" if test_internet(): self.db_set("no_failed", self.no_failed + 1) if self.no_failed > 2: @@ -496,7 +496,7 @@ class EmailAccount(Document): } ) except assign_to.DuplicateToDoError: - frappe.message_log.pop() + frappe.clear_last_message() pass else: self.set_failed_attempts_count(self.get_failed_attempts_count() + 1) diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 2dccd65b4e..5a4ac1ad70 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -6,11 +6,12 @@ import quopri import traceback from contextlib import suppress from email.parser import Parser -from email.policy import SMTPUTF8 +from email.policy import SMTPUTF8, default import frappe from frappe import _, safe_encode, task from frappe.core.utils import html2text +from frappe.database.database import savepoint from frappe.email.doctype.email_account.email_account import EmailAccount from frappe.email.email_body import add_attachment, get_email, get_formatted_html from frappe.email.queue import get_unsubcribed_url, get_unsubscribe_message @@ -259,11 +260,27 @@ class SendMailContext: ) else: update_fields.update({"status": "Error"}) + self.notify_failed_email() else: update_fields = {"status": "Sent"} self.queue_doc.update_status(**update_fields, commit=True) + @savepoint(catch=Exception) + def notify_failed_email(self): + # Parse the email body to extract the subject + subject = Parser(policy=default).parsestr(self.queue_doc.message)["Subject"] + + # Construct the notification + notification = frappe.new_doc("Notification Log") + notification.for_user = self.queue_doc.owner + notification.set("type", "Alert") + notification.from_user = self.queue_doc.owner + notification.document_type = self.queue_doc.doctype + notification.document_name = self.queue_doc.name + notification.subject = _("Failed to send email with subject:") + f" {subject}" + notification.insert() + def update_recipient_status_to_sent(self, recipient): self.sent_to_atleast_one_recipient = True recipient.update_db(status="Sent", commit=True) diff --git a/frappe/email/doctype/email_queue/test_email_queue.py b/frappe/email/doctype/email_queue/test_email_queue.py index 5a608b1b23..dbd01019b9 100644 --- a/frappe/email/doctype/email_queue/test_email_queue.py +++ b/frappe/email/doctype/email_queue/test_email_queue.py @@ -1,7 +1,9 @@ # Copyright (c) 2015, Frappe Technologies and Contributors # License: MIT. See LICENSE +import textwrap import frappe +from frappe.email.doctype.email_queue.email_queue import SendMailContext, get_email_retry_limit from frappe.tests.utils import FrappeTestCase @@ -39,3 +41,40 @@ class TestEmailQueue(FrappeTestCase): self.assertTrue(frappe.db.exists("Email Queue", new_record.name)) self.assertTrue(frappe.db.exists("Email Queue Recipient", {"parent": new_record.name})) + + def test_failed_email_notification(self): + subject = frappe.generate_hash() + email_record = frappe.new_doc("Email Queue") + email_record.sender = "Test " + email_record.message = textwrap.dedent( + f"""\ + MIME-Version: 1.0 + Message-Id: {frappe.generate_hash()} + X-Original-From: Test + Subject: {subject} + From: Test + To: + Date: {frappe.utils.now_datetime().strftime('%a, %d %b %Y %H:%M:%S %z')} + Reply-To: test@example.com + X-Frappe-Site: {frappe.local.site} + """ + ) + email_record.status = "Error" + email_record.retry = get_email_retry_limit() + email_record.priority = 1 + email_record.reference_doctype = "User" + email_record.reference_name = "Administrator" + email_record.insert() + + # Simulate an exception so that we get a notification + try: + with SendMailContext(queue_doc=email_record): + raise Exception("Test Exception") + except Exception: + pass + + notification_log = frappe.db.get_value( + "Notification Log", + {"subject": f"Failed to send email with subject: {subject}"}, + ) + self.assertTrue(notification_log) diff --git a/frappe/email/doctype/notification/notification.js b/frappe/email/doctype/notification/notification.js index eb3e2e8634..5be70b14b0 100644 --- a/frappe/email/doctype/notification/notification.js +++ b/frappe/email/doctype/notification/notification.js @@ -50,7 +50,7 @@ frappe.notification = { if (frm.doc.channel === "Email") { receiver_fields = $.map(fields, function (d) { // Add User and Email fields from child into select dropdown - if (d.fieldtype == "Table") { + if (frappe.model.table_fields.includes(d.fieldtype)) { let child_fields = frappe.get_doc("DocType", d.options).fields; return $.map(child_fields, function (df) { return df.options == "Email" || diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index c4194b2e0e..268de161b3 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -509,9 +509,10 @@ def replace_filename_with_cid(message): # found match img_path = groups[0] - filename = img_path.rsplit("/")[-1] + img_path_escaped = frappe.utils.html_utils.unescape_html(img_path) + filename = img_path_escaped.rsplit("/")[-1] - filecontent = get_filecontent_from_path(img_path) + filecontent = get_filecontent_from_path(img_path_escaped) if not filecontent: message = re.sub(f"""embed=['"]{re.escape(img_path)}['"]""", "", message) continue diff --git a/frappe/handler.py b/frappe/handler.py index 9e8629bf67..d24d548425 100644 --- a/frappe/handler.py +++ b/frappe/handler.py @@ -15,6 +15,7 @@ from frappe.core.doctype.server_script.server_script_utils import get_server_scr from frappe.monitor import add_data_to_monitor from frappe.utils import cint from frappe.utils.csvutils import build_csv_response +from frappe.utils.deprecations import deprecation_warning from frappe.utils.image import optimize_image from frappe.utils.response import build_response @@ -56,13 +57,11 @@ def handle(): # add the response to `message` label frappe.response["message"] = data - return build_response("json") - def execute_cmd(cmd, from_async=False): """execute a request as python module""" for hook in reversed(frappe.get_hooks("override_whitelisted_methods", {}).get(cmd, [])): - # override using the first hook + # override using the last hook cmd = hook break @@ -284,6 +283,9 @@ def get_attr(cmd): if "." in cmd: method = frappe.get_attr(cmd) else: + deprecation_warning( + f"Calling shorthand for {cmd} is deprecated, please specify full path in RPC call." + ) method = globals()[cmd] frappe.log("method:" + cmd) return method diff --git a/frappe/hooks.py b/frappe/hooks.py index 60941df10d..d11e4666fc 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -122,6 +122,7 @@ permission_query_conditions = { has_permission = { "Event": "frappe.desk.doctype.event.event.has_permission", "ToDo": "frappe.desk.doctype.todo.todo.has_permission", + "Note": "frappe.desk.doctype.note.note.has_permission", "User": "frappe.core.doctype.user.user.has_permission", "Dashboard Chart": "frappe.desk.doctype.dashboard_chart.dashboard_chart.has_permission", "Number Card": "frappe.desk.doctype.number_card.number_card.has_permission", diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index d571b2ba00..d6b173d040 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -48,7 +48,8 @@ class ConnectedApp(Document): def validate(self): base_url = frappe.utils.get_url() callback_path = ( - "/api/method/frappe.integrations.doctype.connected_app.connected_app.callback/" + self.name + "/api/method/frappe.integrations.doctype.connected_app.connected_app.callback" + + f"?app={self.name}" ) self.redirect_uri = urljoin(base_url, callback_path) @@ -148,7 +149,7 @@ class ConnectedApp(Document): @frappe.whitelist(methods=["GET"], allow_guest=True) -def callback(code=None, state=None): +def callback(code=None, state=None, app=None): """Handle client's code. Called during the oauthorization flow by the remote oAuth2 server to @@ -161,11 +162,7 @@ def callback(code=None, state=None): frappe.local.response["location"] = "/login?" + urlencode({"redirect-to": frappe.request.url}) return - path = frappe.request.path[1:].split("/") - if len(path) != 4 or not path[3]: - frappe.throw(_("Invalid Parameters.")) - - connected_app = frappe.get_doc("Connected App", path[3]) + connected_app = frappe.get_doc("Connected App", app) token_cache = frappe.get_doc("Token Cache", connected_app.name + "-" + frappe.session.user) if state != token_cache.state: diff --git a/frappe/integrations/doctype/connected_app/test_connected_app.py b/frappe/integrations/doctype/connected_app/test_connected_app.py index 88441db6b2..49ed6236ff 100644 --- a/frappe/integrations/doctype/connected_app/test_connected_app.py +++ b/frappe/integrations/doctype/connected_app/test_connected_app.py @@ -126,7 +126,7 @@ class TestConnectedApp(FrappeTestCase): def delete_if_exists(attribute): doc = getattr(self, attribute, None) if doc: - doc.delete() + doc.delete(force=True) delete_if_exists("token_cache") delete_if_exists("connected_app") diff --git a/frappe/integrations/doctype/google_calendar/google_calendar.py b/frappe/integrations/doctype/google_calendar/google_calendar.py index 7125e243a9..3f412efc90 100644 --- a/frappe/integrations/doctype/google_calendar/google_calendar.py +++ b/frappe/integrations/doctype/google_calendar/google_calendar.py @@ -214,7 +214,7 @@ def get_google_calendar_object(g_calendar): "token_uri": GoogleOAuth.OAUTH_URL, "client_id": google_settings.client_id, "client_secret": google_settings.get_password(fieldname="client_secret", raise_exception=False), - "scopes": ["https://www.googleapis.com/auth/calendar/v3"], + "scopes": [SCOPES], } credentials = google.oauth2.credentials.Credentials(**credentials_dict) @@ -406,9 +406,9 @@ def insert_event_in_google_calendar(doc, method=None): Insert Events in Google Calendar if sync_with_google_calendar is checked. """ if ( - not frappe.db.exists("Google Calendar", {"name": doc.google_calendar}) + not doc.sync_with_google_calendar or doc.pulled_from_google_calendar - or not doc.sync_with_google_calendar + or not frappe.db.exists("Google Calendar", {"name": doc.google_calendar}) ): return @@ -470,9 +470,9 @@ def update_event_in_google_calendar(doc, method=None): # Workaround to avoid triggering updation when Event is being inserted since # creation and modified are same when inserting doc if ( - not frappe.db.exists("Google Calendar", {"name": doc.google_calendar}) + not doc.sync_with_google_calendar or doc.modified == doc.creation - or not doc.sync_with_google_calendar + or not frappe.db.exists("Google Calendar", {"name": doc.google_calendar}) ): return diff --git a/frappe/integrations/doctype/google_contacts/google_contacts.py b/frappe/integrations/doctype/google_contacts/google_contacts.py index cee04a92a1..65bd2bf1d3 100644 --- a/frappe/integrations/doctype/google_contacts/google_contacts.py +++ b/frappe/integrations/doctype/google_contacts/google_contacts.py @@ -155,6 +155,10 @@ def sync_contacts_from_google_contacts(g_contact): frappe.publish_realtime( "import_google_contacts", dict(progress=idx + 1, total=len(results)), user=frappe.session.user ) + # Work-around to fix + # https://github.com/frappe/frappe/issues/22648 + if not connection.get("names"): + continue for name in connection.get("names"): if name.get("metadata").get("primary"): diff --git a/frappe/integrations/doctype/social_login_key/social_login_key.json b/frappe/integrations/doctype/social_login_key/social_login_key.json index bb97d8f625..12514c3fdd 100644 --- a/frappe/integrations/doctype/social_login_key/social_login_key.json +++ b/frappe/integrations/doctype/social_login_key/social_login_key.json @@ -18,6 +18,8 @@ "icon", "column_break_1", "base_url", + "configuration_section", + "sign_ups", "client_urls", "authorize_url", "access_token_url", @@ -157,11 +159,24 @@ "fieldname": "user_id_property", "fieldtype": "Data", "label": "User ID Property" + }, + { + "collapsible": 1, + "fieldname": "configuration_section", + "fieldtype": "Section Break", + "label": "Configuration" + }, + { + "description": "Controls whether new users can sign up using this Social Login Key. If unset, Website Settings is respected. ", + "fieldname": "sign_ups", + "fieldtype": "Select", + "label": "Sign ups", + "options": "\nAllow\nDeny" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-09-30 14:37:13.616002", + "modified": "2023-10-14 12:22:23.601130", "modified_by": "Administrator", "module": "Integrations", "name": "Social Login Key", @@ -182,6 +197,7 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "provider_name", "track_changes": 1 } \ No newline at end of file diff --git a/frappe/integrations/doctype/social_login_key/social_login_key.py b/frappe/integrations/doctype/social_login_key/social_login_key.py index 3994413d57..4c1fc8708f 100644 --- a/frappe/integrations/doctype/social_login_key/social_login_key.py +++ b/frappe/integrations/doctype/social_login_key/social_login_key.py @@ -54,6 +54,7 @@ class SocialLoginKey(Document): icon: DF.Data | None provider_name: DF.Data redirect_url: DF.Data | None + sign_ups: DF.Literal["", "Allow", "Deny"] social_login_provider: DF.Literal[ "Custom", "Facebook", "Frappe", "GitHub", "Google", "Office 365", "Salesforce", "fairlogin" ] @@ -214,3 +215,13 @@ class SocialLoginKey(Document): return return providers.get(provider) if provider else providers + + +def provider_allows_signup(provider: str) -> bool: + from frappe.website.utils import is_signup_disabled + + sign_up_config = frappe.db.get_value("Social Login Key", provider, "sign_ups") + + if not (sign_up_config and provider): # fallback to global settings + return is_signup_disabled() + return sign_up_config == "Allow" diff --git a/frappe/integrations/doctype/social_login_key/test_social_login_key.py b/frappe/integrations/doctype/social_login_key/test_social_login_key.py index 90003da3f3..961cd41b65 100644 --- a/frappe/integrations/doctype/social_login_key/test_social_login_key.py +++ b/frappe/integrations/doctype/social_login_key/test_social_login_key.py @@ -7,13 +7,22 @@ from rauth import OAuth2Service import frappe from frappe.auth import CookieManager, LoginManager from frappe.integrations.doctype.social_login_key.social_login_key import BaseUrlNotSetError -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import set_request from frappe.utils.oauth import login_via_oauth2 +TEST_GITHUB_USER = "githublogin@example.com" + class TestSocialLoginKey(FrappeTestCase): + def setUp(self) -> None: + frappe.set_user("Administrator") + frappe.delete_doc("User", TEST_GITHUB_USER, force=True) + super().setUp() + frappe.set_user("Guest") + def test_adding_frappe_social_login_provider(self): + frappe.set_user("Administrator") provider_name = "Frappe" social_login_key = make_social_login_key(social_login_provider=provider_name) social_login_key.get_social_login_provider(provider_name, initialize=True) @@ -40,17 +49,43 @@ class TestSocialLoginKey(FrappeTestCase): def test_normal_signup_and_github_login(self): github_social_login_setup() - if not frappe.db.exists("User", "githublogin@example.com"): - user = frappe.get_doc( - {"doctype": "User", "email": "githublogin@example.com", "first_name": "GitHub Login"} - ) - user.save(ignore_permissions=True) + if not frappe.db.exists("User", TEST_GITHUB_USER): + user = frappe.new_doc("User", email=TEST_GITHUB_USER, first_name="GitHub Login") + user.insert(ignore_permissions=True) mock_session = MagicMock() mock_session.get.side_effect = github_response_for_login with patch.object(OAuth2Service, "get_auth_session", return_value=mock_session): login_via_oauth2("github", "iwriu", {"token": "ewrwerwer"}) + self.assertEqual(frappe.session.user, TEST_GITHUB_USER) + + def test_force_disabled_signups(self): + key = github_social_login_setup() + key.sign_ups = "Deny" + key.save(ignore_permissions=True) + + mock_session = MagicMock() + mock_session.get.side_effect = github_response_for_login + + with patch.object(OAuth2Service, "get_auth_session", return_value=mock_session): + login_via_oauth2("github", "iwriu", {"token": "ewrwerwer"}) + self.assertEqual(frappe.session.user, "Guest") + + @change_settings("Website Settings", disable_signup=1) + def test_force_enabled_signups(self): + """Social login key can override website settings for disabled signups.""" + key = github_social_login_setup() + key.sign_ups = "Allow" + key.save(ignore_permissions=True) + + mock_session = MagicMock() + mock_session.get.side_effect = github_response_for_login + + with patch.object(OAuth2Service, "get_auth_session", return_value=mock_session): + login_via_oauth2("github", "iwriu", {"token": "ewrwerwer"}) + + self.assertEqual(frappe.session.user, TEST_GITHUB_USER) def make_social_login_key(**kwargs): @@ -83,7 +118,6 @@ def create_github_social_login_key(): social_login_key = make_social_login_key(social_login_provider=provider_name) social_login_key.get_social_login_provider(provider_name, initialize=True) - # Dummy client_id and client_secret social_login_key.client_id = "h6htd6q" social_login_key.client_secret = "keoererk988ekkhf8w9e8ewrjhhkjer9889" social_login_key.insert(ignore_permissions=True) @@ -125,7 +159,7 @@ def github_response_for_login(url, *args, **kwargs): "first_name": "Github Login", } else: - return_value = [{"email": "githublogin@example.com", "primary": True, "verified": True}] + return_value = [{"email": TEST_GITHUB_USER, "primary": True, "verified": True}] return MagicMock(status_code=200, json=MagicMock(return_value=return_value)) @@ -135,4 +169,4 @@ def github_social_login_setup(): frappe.local.cookie_manager = CookieManager() frappe.local.login_manager = LoginManager() - create_github_social_login_key() + return create_github_social_login_key() diff --git a/frappe/model/document.py b/frappe/model/document.py index 2721ebf1e7..1b7a97cc97 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -23,6 +23,7 @@ from frappe.model.workflow import set_workflow_state_on_action, validate_workflo from frappe.types import DF from frappe.utils import compare, cstr, date_diff, file_lock, flt, get_datetime_str, now from frappe.utils.data import get_absolute_url +from frappe.utils.deprecations import deprecated from frappe.utils.global_search import update_global_search if TYPE_CHECKING: @@ -140,12 +141,6 @@ class Document(BaseDocument): def is_locked(self): return file_lock.lock_exists(self.get_signature()) - @staticmethod - def whitelist(fn): - """Decorator: Whitelist method to be called remotely via REST API.""" - frappe.whitelist()(fn) - return fn - def load_from_db(self): """Load document and children from database and create properties from fields""" @@ -253,9 +248,15 @@ class Document(BaseDocument): This will check for user permissions and execute `before_insert`, `validate`, `on_update`, `after_insert` methods if they are written. - :param ignore_permissions: Do not check permissions if True.""" + :param ignore_permissions: Do not check permissions if True. + :param ignore_links: Do not check validity of links if True. + :param ignore_if_duplicate: Do not raise error if a duplicate entry exists. + :param ignore_mandatory: Do not check missing mandatory fields if True. + :param set_name: Name to set for the document, if valid. + :param set_child_names: Whether to set names for the child documents. + """ if self.flags.in_print: - return + return self self.flags.notifications_executed = [] @@ -1012,19 +1013,16 @@ class Document(BaseDocument): elif alert.event == "Method" and method == alert.method: _evaluate_alert(alert) - @whitelist.__func__ def _submit(self): """Submit the document. Sets `docstatus` = 1, then saves.""" self.docstatus = DocStatus.submitted() return self.save() - @whitelist.__func__ def _cancel(self): """Cancel the document. Sets `docstatus` = 2, then saves.""" self.docstatus = DocStatus.cancelled() return self.save() - @whitelist.__func__ def _rename( self, name: str, merge: bool = False, force: bool = False, validate_rename: bool = True ): @@ -1034,20 +1032,18 @@ class Document(BaseDocument): self.name = rename_doc(doc=self, new=name, merge=merge, force=force, validate=validate_rename) self.reload() - @whitelist.__func__ + @frappe.whitelist() def submit(self): """Submit the document. Sets `docstatus` = 1, then saves.""" return self._submit() - @whitelist.__func__ + @frappe.whitelist() def cancel(self): """Cancel the document. Sets `docstatus` = 2, then saves.""" return self._cancel() - @whitelist.__func__ - def rename( - self, name: str, merge: bool = False, force: bool = False, validate_rename: bool = True - ): + @frappe.whitelist() + def rename(self, name: str, merge=False, force=False, validate_rename=True): """Rename the document to `name`. This transforms the current object.""" return self._rename(name=name, merge=merge, force=force, validate_rename=validate_rename) @@ -1634,8 +1630,8 @@ def execute_action(__doctype, __name, __action, **kwargs): frappe.db.rollback() # add a comment (?) - if frappe.local.message_log: - msg = json.loads(frappe.local.message_log[-1]).get("message") + if frappe.message_log: + msg = frappe.message_log[-1].get("message") else: msg = "
" + frappe.get_traceback() + "
" diff --git a/frappe/public/js/form_builder/components/FieldProperties.vue b/frappe/public/js/form_builder/components/FieldProperties.vue index 9cb5c8707b..12a8ce79ef 100644 --- a/frappe/public/js/form_builder/components/FieldProperties.vue +++ b/frappe/public/js/form_builder/components/FieldProperties.vue @@ -10,11 +10,14 @@ let search_text = ref(""); let args = ref({}); let docfield_df = computed(() => { - let fields = store.get_docfields.filter(df => { + let fields = store.get_docfields.filter((df) => { if (in_list(frappe.model.layout_fields, df.fieldtype) || df.hidden) { return false; } - if (df.depends_on && !evaluate_depends_on_value(df.depends_on, store.form.selected_field)) { + if ( + df.depends_on && + !evaluate_depends_on_value(df.depends_on, store.form.selected_field) + ) { return false; } diff --git a/frappe/public/js/form_builder/components/controls/CodeControl.vue b/frappe/public/js/form_builder/components/controls/CodeControl.vue index b0a45a442f..77bbaa3b7e 100644 --- a/frappe/public/js/form_builder/components/controls/CodeControl.vue +++ b/frappe/public/js/form_builder/components/controls/CodeControl.vue @@ -7,43 +7,47 @@ let emit = defineEmits(["update:modelValue"]); let slots = useSlots(); let code = ref(null); -let code_control = ref(null); let update_control = ref(true); +let code_control = computed(() => { + if (!code.value) return; + code.value.innerHTML = ""; + + return frappe.ui.form.make_control({ + parent: code.value, + df: { + ...props.df, + fieldtype: "Code", + hidden: 0, + read_only: props.read_only, + change: () => { + if (update_control.value) { + content.value = code_control.value.get_value(); + } + update_control.value = true; + }, + }, + value: content.value, + disabled: Boolean(slots.label) || props.read_only, + render_input: true, + only_input: Boolean(slots.label), + }); +}); + let content = computed({ get: () => props.modelValue, - set: (value) => emit('update:modelValue', value) + set: (value) => emit("update:modelValue", value), }); onMounted(() => { - if (code.value) { - code_control.value = frappe.ui.form.make_control({ - parent: code.value, - df: { - ...props.df, - fieldtype: "Code", - hidden: 0, - read_only: props.read_only, - change: () => { - if (update_control.value) { - content.value = code_control.value.get_value(); - } - update_control.value = true; - } - }, - value: content.value, - disabled: Boolean(slots.label) || props.read_only, - render_input: true, - only_input: Boolean(slots.label), - }); - } + if (code.value) code_control.value; }); watch( () => content.value, (value) => { update_control.value = false; - code_control.value.set_value(value); + code_control.value?.set_value(value); } ); @@ -53,7 +57,7 @@ watch( if (code_control.value) { code_control.value.ace_editor_target.css("max-height", value); } - }, + } ); diff --git a/frappe/public/js/form_builder/components/controls/DataControl.vue b/frappe/public/js/form_builder/components/controls/DataControl.vue index e0d913b0f6..f0fb2d244a 100644 --- a/frappe/public/js/form_builder/components/controls/DataControl.vue +++ b/frappe/public/js/form_builder/components/controls/DataControl.vue @@ -48,7 +48,7 @@ if (props.df.fieldtype === "Icon") { class="form-control" type="text" :value="value" - :disabled="read_only" + :disabled="read_only || df.read_only" @input="event => $emit('update:modelValue', event.target.value)" /> diff --git a/frappe/public/js/form_builder/components/controls/GeolocationControl.vue b/frappe/public/js/form_builder/components/controls/GeolocationControl.vue index 8acc005fad..032234bdba 100644 --- a/frappe/public/js/form_builder/components/controls/GeolocationControl.vue +++ b/frappe/public/js/form_builder/components/controls/GeolocationControl.vue @@ -1,21 +1,24 @@ diff --git a/frappe/public/js/form_builder/components/controls/LinkControl.vue b/frappe/public/js/form_builder/components/controls/LinkControl.vue index 09ed47dc5a..7b791cf979 100644 --- a/frappe/public/js/form_builder/components/controls/LinkControl.vue +++ b/frappe/public/js/form_builder/components/controls/LinkControl.vue @@ -7,12 +7,34 @@ let emit = defineEmits(["update:modelValue"]); let slots = useSlots(); let link = ref(null); -let link_control = ref(null); let update_control = ref(true); +let link_control = computed(() => { + if (!link.value) return; + link.value.innerHTML = ""; + + return frappe.ui.form.make_control({ + parent: link.value, + df: { + ...props.df, + hidden: 0, + read_only: Boolean(slots.label) || props.read_only, + change: () => { + if (update_control.value) { + content.value = link_control.value.get_value(); + } + update_control.value = true; + }, + }, + value: content.value, + render_input: true, + only_input: Boolean(slots.label), + }); +}); + let content = computed({ get: () => props.modelValue, - set: value => emit("update:modelValue", value) + set: (value) => emit("update:modelValue", value), }); onMounted(() => { @@ -27,36 +49,20 @@ onMounted(() => { } } else { // reset filters - if (props.df.filters && 'istable' in props.df.filters) { + if (props.df.filters && "istable" in props.df.filters) { delete props.df.filters.istable; } } - link_control.value = frappe.ui.form.make_control({ - parent: link.value, - df: { - ...props.df, - hidden: 0, - read_only: Boolean(slots.label) || props.read_only, - change: () => { - if (update_control.value) { - content.value = link_control.value.get_value(); - } - update_control.value = true; - } - }, - value: content.value, - render_input: true, - only_input: Boolean(slots.label) - }); + link_control.value; } }); watch( () => content.value, - value => { + (value) => { update_control.value = false; - link_control.value.set_value(value); + link_control.value?.set_value(value); } ); @@ -81,4 +87,4 @@ watch(
- \ No newline at end of file + diff --git a/frappe/public/js/form_builder/components/controls/RatingControl.vue b/frappe/public/js/form_builder/components/controls/RatingControl.vue index 4356dc0b2d..fc9fd8be8f 100644 --- a/frappe/public/js/form_builder/components/controls/RatingControl.vue +++ b/frappe/public/js/form_builder/components/controls/RatingControl.vue @@ -1,21 +1,24 @@ @@ -44,5 +47,4 @@ watch( :deep(.rating) { --star-fill: var(--yellow-300) !important; } - diff --git a/frappe/public/js/form_builder/components/controls/TableControl.vue b/frappe/public/js/form_builder/components/controls/TableControl.vue index 78518f6fd6..295a6406c8 100644 --- a/frappe/public/js/form_builder/components/controls/TableControl.vue +++ b/frappe/public/js/form_builder/components/controls/TableControl.vue @@ -1,5 +1,5 @@ diff --git a/frappe/public/js/form_builder/store.js b/frappe/public/js/form_builder/store.js index 1f20a009b5..56c5437dc4 100644 --- a/frappe/public/js/form_builder/store.js +++ b/frappe/public/js/form_builder/store.js @@ -1,5 +1,5 @@ import { defineStore } from "pinia"; -import { create_layout, scrub_field_names } from "./utils"; +import { create_layout, scrub_field_names, load_doctype_model } from "./utils"; import { computed, nextTick, ref } from "vue"; import { useDebouncedRefHistory, onKeyDown } from "@vueuse/core"; @@ -77,7 +77,9 @@ export const useStore = defineStore("form-builder-store", () => { if (!get_docfields.value.length) { let docfield = is_customize_form.value ? "Customize Form Field" : "DocField"; - await frappe.model.with_doctype(docfield); + if (!frappe.get_meta(docfield)) { + await load_doctype_model(docfield); + } let df = frappe.get_meta(docfield).fields; if (is_customize_form.value) { custom_docfields.value = df; diff --git a/frappe/public/js/form_builder/utils.js b/frappe/public/js/form_builder/utils.js index b5c1699c40..91e0ad2cb8 100644 --- a/frappe/public/js/form_builder/utils.js +++ b/frappe/public/js/form_builder/utils.js @@ -96,11 +96,15 @@ export function create_layout(fields) { return layout; } +export async function load_doctype_model(doctype) { + await frappe.call("frappe.desk.form.load.getdoctype", { doctype }); +} + export async function get_table_columns(df, child_doctype) { let table_columns = []; if (!frappe.get_meta(df.options)) { - await frappe.model.with_doctype(df.options); + await load_doctype_model(df.options); } if (!child_doctype) { child_doctype = frappe.get_meta(df.options); diff --git a/frappe/public/js/frappe/db.js b/frappe/public/js/frappe/db.js index 661772fffb..58329f16d7 100644 --- a/frappe/public/js/frappe/db.js +++ b/frappe/public/js/frappe/db.js @@ -124,7 +124,7 @@ frappe.db = { filters, }, callback(r) { - resolve(r.results); + resolve(r.message); }, }); }); diff --git a/frappe/public/js/frappe/form/controls/autocomplete.js b/frappe/public/js/frappe/form/controls/autocomplete.js index a51a3d86db..825cfb5320 100644 --- a/frappe/public/js/frappe/form/controls/autocomplete.js +++ b/frappe/public/js/frappe/form/controls/autocomplete.js @@ -37,6 +37,7 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui get_awesomplete_settings() { var me = this; return { + tabSelect: true, minChars: 0, maxItems: this.df.max_items || 99, autoFirst: true, diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index b274c4532a..16d0b4091c 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -265,7 +265,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat if (!window.Cypress && !me.$input.is(":focus")) { return; } - r.results = me.merge_duplicates(r.results); + r.message = me.merge_duplicates(r.message); // show filter description in awesomplete let filter_string = me.df.filter_description @@ -274,7 +274,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat ? me.get_filter_description(args.filters) : null; if (filter_string) { - r.results.push({ + r.message.push({ html: `${filter_string}`, value: "", action: () => {}, @@ -284,7 +284,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat if (!me.df.only_select) { if (frappe.model.can_create(doctype)) { // new item - r.results.push({ + r.message.push({ html: "" + " " + @@ -302,13 +302,13 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat frappe.ui.form.ControlLink.link_options(me); if (custom__link_options) { - r.results = r.results.concat(custom__link_options); + r.message = r.message.concat(custom__link_options); } // advanced search if (locals && locals["DocType"]) { // not applicable in web forms - r.results.push({ + r.message.push({ html: "" + " " + @@ -320,7 +320,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat }); } } - me.$input.cache[doctype][term] = r.results; + me.$input.cache[doctype][term] = r.message; me.awesomplete.list = me.$input.cache[doctype][term]; me.toggle_href(doctype); }, diff --git a/frappe/public/js/frappe/form/controls/select.js b/frappe/public/js/frappe/form/controls/select.js index de96cd902c..53e7f35125 100644 --- a/frappe/public/js/frappe/form/controls/select.js +++ b/frappe/public/js/frappe/form/controls/select.js @@ -161,7 +161,7 @@ function parse_option(v) { is_disabled = Boolean(v.disabled); is_selected = Boolean(v.selected); - if (is_value_null && is_label_null) { + if (is_value_null && is_label_null && typeof v === "string") { value = v; label = __(v); } else { diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index a9440d234e..aee19cd2f5 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -394,7 +394,7 @@ frappe.form.get_formatter = function (fieldtype) { frappe.format = function (value, df, options, doc) { if (!df) df = { fieldtype: "Data" }; - if (df.fieldname == "_user_tags") df.fieldtype = "Tag"; + if (df.fieldname == "_user_tags") df = { ...df, fieldtype: "Tag" }; var fieldtype = df.fieldtype || "Data"; // format Dynamic Link as a Link diff --git a/frappe/public/js/frappe/form/link_selector.js b/frappe/public/js/frappe/form/link_selector.js index 1040233b61..c3470fb967 100644 --- a/frappe/public/js/frappe/form/link_selector.js +++ b/frappe/public/js/frappe/form/link_selector.js @@ -86,14 +86,14 @@ frappe.ui.form.LinkSelector = class LinkSelector { frappe.link_search( this.doctype, args, - function (r) { + function (results) { var parent = me.dialog.fields_dict.results.$wrapper; if (args.start === 0) { parent.empty(); } - if (r.values.length) { - for (const v of r.values) { + if (results.length) { + for (const v of results) { var row = $( repl( ' + {% endif %} {% if frm.meta.beta %} @@ -136,21 +138,21 @@