Merge branch 'develop' into fix-attach-before-doc-save

This commit is contained in:
Maharshi Patel 2023-10-19 11:40:20 +05:30
commit 409a3a8105
120 changed files with 2008 additions and 999 deletions

23
.coveragerc Normal file
View file

@ -0,0 +1,23 @@
[run]
omit =
tests/*
.github/*
commands/*
**/test_*.py
[report]
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
exclude_also =
def __repr__
if self.debug:
if settings.DEBUG
raise AssertionError
raise NotImplementedError
if 0:
if __name__ == .__main__.:
if TYPE_CHECKING:
class .*\bProtocol\):
@(abc\.)?abstractmethod

View file

@ -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 }}

View file

@ -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() }}

View file

@ -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",

View file

@ -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")

View file

@ -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):

View file

@ -1,306 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import base64
import binascii
import json
from typing import Literal
from urllib.parse import urlencode, urlparse
import frappe
import frappe.client
import frappe.handler
from frappe import _
from frappe.utils.data import sbool
from frappe.utils.response import build_response
def handle():
"""
Handler for `/api` methods
### Examples:
`/api/method/{methodname}` will call a whitelisted method
`/api/resource/{doctype}` will query a table
examples:
- `?fields=["name", "owner"]`
- `?filters=[["Task", "name", "like", "%005"]]`
- `?limit_start=0`
- `?limit_page_length=20`
`/api/resource/{doctype}/{name}` will point to a resource
`GET` will return doclist
`POST` will insert
`PUT` will update
`DELETE` will delete
`/api/resource/{doctype}/{name}?run_method={method}` will run a whitelisted controller method
"""
parts = frappe.request.path[1:].split("/", 3)
call = doctype = name = None
if len(parts) > 1:
call = parts[1]
if len(parts) > 2:
doctype = parts[2]
if len(parts) > 3:
name = parts[3]
return _RESTAPIHandler(call, doctype, name).get_response()
class _RESTAPIHandler:
def __init__(self, call: Literal["method", "resource"], doctype: str | None, name: str | None):
self.call = call
self.doctype = doctype
self.name = name
def get_response(self):
"""Prepare and get response based on URL and form body.
Note: most methods of this class directly operate on the response local.
"""
match self.call:
case "method":
return self.handle_method()
case "resource":
self.handle_resource()
case _:
raise frappe.DoesNotExistError
return build_response("json")
def handle_method(self):
frappe.local.form_dict.cmd = self.doctype
return frappe.handler.handle()
def handle_resource(self):
if self.doctype and self.name:
self.handle_document_resource()
elif self.doctype:
self.handle_doctype_resource()
else:
raise frappe.DoesNotExistError
def handle_document_resource(self):
if "run_method" in frappe.local.form_dict:
self.execute_doc_method()
return
match frappe.local.request.method:
case "GET":
self.get_doc()
case "PUT":
self.update_doc()
case "DELETE":
self.delete_doc()
case _:
raise frappe.DoesNotExistError
def handle_doctype_resource(self):
match frappe.local.request.method:
case "GET":
self.get_doc_list()
case "POST":
self.create_doc()
case _:
raise frappe.DoesNotExistError
def execute_doc_method(self):
method = frappe.local.form_dict.pop("run_method")
doc = frappe.get_doc(self.doctype, self.name)
doc.is_whitelisted(method)
if frappe.local.request.method == "GET":
if not doc.has_permission("read"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
elif frappe.local.request.method == "POST":
if not doc.has_permission("write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
frappe.db.commit()
def get_doc(self):
doc = frappe.get_doc(self.doctype, self.name)
if not doc.has_permission("read"):
raise frappe.PermissionError
doc.apply_fieldlevel_read_permissions()
frappe.local.response.update({"data": doc})
def update_doc(self):
data = get_request_form_data()
doc = frappe.get_doc(self.doctype, self.name, for_update=True)
if "flags" in data:
del data["flags"]
# Not checking permissions here because it's checked in doc.save
doc.update(data)
frappe.local.response.update({"data": doc.save().as_dict()})
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
frappe.db.commit()
def delete_doc(self):
# Not checking permissions here because it's checked in delete_doc
frappe.delete_doc(self.doctype, self.name, ignore_missing=False)
frappe.local.response.http_status_code = 202
frappe.local.response.message = "ok"
frappe.db.commit()
def get_doc_list(self):
if frappe.local.form_dict.get("fields"):
frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"])
# set limit of records for frappe.get_list
frappe.local.form_dict.setdefault(
"limit_page_length",
frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20,
)
# convert strings to native types - only as_dict and debug accept bool
for param in ["as_dict", "debug"]:
param_val = frappe.local.form_dict.get(param)
if param_val is not None:
frappe.local.form_dict[param] = sbool(param_val)
# evaluate frappe.get_list
data = frappe.call(frappe.client.get_list, self.doctype, **frappe.local.form_dict)
# set frappe.get_list result to response
frappe.local.response.update({"data": data})
def create_doc(self):
data = get_request_form_data()
data.update({"doctype": self.doctype})
# insert document from request data
doc = frappe.get_doc(data).insert()
# set response data
frappe.local.response.update({"data": doc.as_dict()})
# commit for POST requests
frappe.db.commit()
def get_request_form_data():
if frappe.local.form_dict.data is None:
data = frappe.safe_decode(frappe.local.request.get_data())
else:
data = frappe.local.form_dict.data
try:
return frappe.parse_json(data)
except ValueError:
return frappe.local.form_dict
def validate_auth():
"""
Authenticate and sets user for the request.
"""
authorization_header = frappe.get_request_header("Authorization", "").split(" ")
if len(authorization_header) == 2:
validate_oauth(authorization_header)
validate_auth_via_api_keys(authorization_header)
validate_auth_via_hooks()
def validate_oauth(authorization_header):
"""
Authenticate request using OAuth and set session user
Args:
authorization_header (list of str): The 'Authorization' header containing the prefix and token
"""
from frappe.integrations.oauth2 import get_oauth_server
from frappe.oauth import get_url_delimiter
form_dict = frappe.local.form_dict
token = authorization_header[1]
req = frappe.request
parsed_url = urlparse(req.url)
access_token = {"access_token": token}
uri = (
parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + "?" + urlencode(access_token)
)
http_method = req.method
headers = req.headers
body = req.get_data()
if req.content_type and "multipart/form-data" in req.content_type:
body = None
try:
required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split(
get_url_delimiter()
)
valid, oauthlib_request = get_oauth_server().verify_request(
uri, http_method, body, headers, required_scopes
)
if valid:
frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user"))
frappe.local.form_dict = form_dict
except AttributeError:
pass
def validate_auth_via_api_keys(authorization_header):
"""
Authenticate request using API keys and set session user
Args:
authorization_header (list of str): The 'Authorization' header containing the prefix and token
"""
try:
auth_type, auth_token = authorization_header
authorization_source = frappe.get_request_header("Frappe-Authorization-Source")
if auth_type.lower() == "basic":
api_key, api_secret = frappe.safe_decode(base64.b64decode(auth_token)).split(":")
validate_api_key_secret(api_key, api_secret, authorization_source)
elif auth_type.lower() == "token":
api_key, api_secret = auth_token.split(":")
validate_api_key_secret(api_key, api_secret, authorization_source)
except binascii.Error:
frappe.throw(
_("Failed to decode token, please provide a valid base64-encoded token."),
frappe.InvalidAuthorizationToken,
)
except (AttributeError, TypeError, ValueError):
pass
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
"""frappe_authorization_source to provide api key and secret for a doctype apart from User"""
doctype = frappe_authorization_source or "User"
doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"])
form_dict = frappe.local.form_dict
doc_secret = frappe.utils.password.get_decrypted_password(doctype, doc, fieldname="api_secret")
if api_secret == doc_secret:
if doctype == "User":
user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"])
else:
user = frappe.db.get_value(doctype, doc, "user")
if frappe.local.login_manager.user in ("", "Guest"):
frappe.set_user(user)
frappe.local.form_dict = form_dict
def validate_auth_via_hooks():
for auth_hook in frappe.get_hooks("auth_hooks", []):
frappe.get_attr(auth_hook)()

80
frappe/api/__init__.py Normal file
View file

@ -0,0 +1,80 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from enum import Enum
from werkzeug.exceptions import NotFound
from werkzeug.routing import Map, Submount
from werkzeug.wrappers import Request, Response
import frappe
import frappe.client
from frappe import _
from frappe.utils.response import build_response
class ApiVersion(str, Enum):
V1 = "v1"
V2 = "v2"
def handle(request: Request):
"""
Entry point for `/api` methods.
APIs are versioned using second part of path.
v1 -> `/api/v1/*`
v2 -> `/api/v2/*`
Different versions have different specification but broadly following things are supported:
- `/api/method/{methodname}` will call a whitelisted method
- `/api/resource/{doctype}` will query a table
examples:
- `?fields=["name", "owner"]`
- `?filters=[["Task", "name", "like", "%005"]]`
- `?limit_start=0`
- `?limit_page_length=20`
- `/api/resource/{doctype}/{name}` will point to a resource
`GET` will return document
`POST` will insert
`PUT` will update
`DELETE` will delete
"""
try:
endpoint, arguments = API_URL_MAP.bind_to_environ(request.environ).match()
except NotFound: # Wrap 404 - backward compatiblity
raise frappe.DoesNotExistError
data = endpoint(**arguments)
if isinstance(data, Response):
return data
if data is not None:
frappe.response["data"] = data
return build_response("json")
# Merge all API version routing rules
from frappe.api.v1 import url_rules as v1_rules
from frappe.api.v2 import url_rules as v2_rules
API_URL_MAP = Map(
[
# V1 routes
Submount("/api", v1_rules),
Submount(f"/api/{ApiVersion.V1.value}", v1_rules),
Submount(f"/api/{ApiVersion.V2.value}", v2_rules),
],
strict_slashes=False, # Allows skipping trailing slashes
merge_slashes=False,
)
def get_api_version() -> ApiVersion | None:
if not frappe.request:
return
if frappe.request.path.startswith(f"/api/{ApiVersion.V2.value}"):
return ApiVersion.V2
return ApiVersion.V1

0
frappe/api/utils.py Normal file
View file

118
frappe/api/v1.py Normal file
View file

@ -0,0 +1,118 @@
import json
from werkzeug.routing import Rule
import frappe
from frappe import _
from frappe.utils.data import sbool
def document_list(doctype: str):
if frappe.form_dict.get("fields"):
frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"])
# set limit of records for frappe.get_list
frappe.form_dict.setdefault(
"limit_page_length",
frappe.form_dict.limit or frappe.form_dict.limit_page_length or 20,
)
# convert strings to native types - only as_dict and debug accept bool
for param in ["as_dict", "debug"]:
param_val = frappe.form_dict.get(param)
if param_val is not None:
frappe.form_dict[param] = sbool(param_val)
# evaluate frappe.get_list
return frappe.call(frappe.client.get_list, doctype, **frappe.form_dict)
def handle_rpc_call(method: str):
import frappe.handler
method = method.split("/")[0] # for backward compatiblity
frappe.form_dict.cmd = method
return frappe.handler.handle()
def create_doc(doctype: str):
data = get_request_form_data()
data.pop("doctype", None)
return frappe.new_doc(doctype, **data).insert()
def update_doc(doctype: str, name: str):
data = get_request_form_data()
doc = frappe.get_doc(doctype, name, for_update=True)
if "flags" in data:
del data["flags"]
doc.update(data)
doc.save()
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
return doc
def delete_doc(doctype: str, name: str):
# TODO: child doc handling
frappe.delete_doc(doctype, name, ignore_missing=False)
frappe.response.http_status_code = 202
return "ok"
def read_doc(doctype: str, name: str):
# Backward compatiblity
if "run_method" in frappe.form_dict:
return execute_doc_method(doctype, name)
doc = frappe.get_doc(doctype, name)
if not doc.has_permission("read"):
raise frappe.PermissionError
doc.apply_fieldlevel_read_permissions()
return doc
def execute_doc_method(doctype: str, name: str, method: str | None = None):
method = method or frappe.form_dict.pop("run_method")
doc = frappe.get_doc(doctype, name)
doc.is_whitelisted(method)
if frappe.request.method == "GET":
if not doc.has_permission("read"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
return doc.run_method(method, **frappe.form_dict)
elif frappe.request.method == "POST":
if not doc.has_permission("write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
return doc.run_method(method, **frappe.form_dict)
def get_request_form_data():
if frappe.form_dict.data is None:
data = frappe.safe_decode(frappe.request.get_data())
else:
data = frappe.form_dict.data
try:
return frappe.parse_json(data)
except ValueError:
return frappe.form_dict
url_rules = [
Rule("/method/<path:method>", endpoint=handle_rpc_call),
Rule("/resource/<doctype>", methods=["GET"], endpoint=document_list),
Rule("/resource/<doctype>", methods=["POST"], endpoint=create_doc),
Rule("/resource/<doctype>/<path:name>/", methods=["GET"], endpoint=read_doc),
Rule("/resource/<doctype>/<path:name>/", methods=["PUT"], endpoint=update_doc),
Rule("/resource/<doctype>/<path:name>/", methods=["DELETE"], endpoint=delete_doc),
Rule("/resource/<doctype>/<path:name>/", methods=["POST"], endpoint=execute_doc_method),
]

193
frappe/api/v2.py Normal file
View file

@ -0,0 +1,193 @@
"""REST API v2
This file defines routes and implementation for REST API.
Note:
- All functions in this file should be treated as "whitelisted" as they are exposed via routes
- None of the functions present here should be called from python code, their location and
internal implementation can change without treating it as "breaking change".
"""
import json
from typing import Any
from werkzeug.routing import Rule
import frappe
import frappe.client
from frappe import _, get_newargs, is_whitelisted
from frappe.core.doctype.server_script.server_script_utils import get_server_script_map
from frappe.handler import is_valid_http_method, run_server_script, upload_file
PERMISSION_MAP = {
"GET": "read",
"POST": "write",
}
def handle_rpc_call(method: str, doctype: str | None = None):
from frappe.modules.utils import load_doctype_module
if doctype:
# Expand to run actual method from doctype controller
module = load_doctype_module(doctype)
method = module.__name__ + "." + method
for hook in reversed(frappe.get_hooks("override_whitelisted_methods", {}).get(method, [])):
# override using the last hook
method = hook
break
# via server script
server_script = get_server_script_map().get("_api", {}).get(method)
if server_script:
return run_server_script(server_script)
try:
method = frappe.get_attr(method)
except Exception as e:
frappe.throw(_("Failed to get method {0} with {1}").format(method, e))
is_whitelisted(method)
is_valid_http_method(method)
return frappe.call(method, **frappe.form_dict)
def login():
"""Login happens implicitly, this function doesn't do anything."""
pass
def logout():
frappe.local.login_manager.logout()
frappe.db.commit()
def read_doc(doctype: str, name: str):
doc = frappe.get_doc(doctype, name)
doc.check_permission("read")
doc.apply_fieldlevel_read_permissions()
return doc
def document_list(doctype: str):
if frappe.form_dict.get("fields"):
frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"])
# set limit of records for frappe.get_list
frappe.form_dict.limit_page_length = frappe.form_dict.limit or 20
# evaluate frappe.get_list
return frappe.call(frappe.client.get_list, doctype, **frappe.form_dict)
def count(doctype: str) -> int:
from frappe.desk.reportview import get_count
frappe.form_dict.doctype = doctype
return get_count()
def create_doc(doctype: str):
data = frappe.form_dict
data.pop("doctype", None)
return frappe.new_doc(doctype, **data).insert()
def update_doc(doctype: str, name: str):
data = frappe.form_dict
doc = frappe.get_doc(doctype, name, for_update=True)
data.pop("flags", None)
doc.update(data)
doc.save()
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
return doc
def delete_doc(doctype: str, name: str):
frappe.client.delete_doc(doctype, name)
frappe.response.http_status_code = 202
return "ok"
def execute_doc_method(doctype: str, name: str, method: str | None = None):
"""Get a document from DB and execute method on it.
Use cases:
- Submitting/cancelling document
- Triggering some kind of update on a document
"""
method = method or frappe.form_dict.pop("run_method")
doc = frappe.get_doc(doctype, name)
doc.is_whitelisted(method)
doc.check_permission(PERMISSION_MAP[frappe.request.method])
return doc.run_method(method, **frappe.form_dict)
def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None):
"""run a whitelisted controller method on in-memory document.
This is useful for building clients that don't necessarily encode all the business logic but
call server side function on object to validate and modify the doc.
The doc CAN exists in DB too and can write to DB as well if method is POST.
"""
if isinstance(document, str):
document = frappe.parse_json(document)
if kwargs is None:
kwargs = {}
doc = frappe.get_doc(document)
doc._original_modified = doc.modified
doc.check_if_latest()
doc.check_permission(PERMISSION_MAP[frappe.request.method])
method_obj = getattr(doc, method)
fn = getattr(method_obj, "__func__", method_obj)
is_whitelisted(fn)
is_valid_http_method(fn)
new_kwargs = get_newargs(fn, kwargs)
response = doc.run_method(method, **new_kwargs)
frappe.response.docs.append(doc) # send modified document and result both.
return response
url_rules = [
# RPC calls
Rule("/method/login", endpoint=login),
Rule("/method/logout", endpoint=logout),
Rule("/method/ping", endpoint=frappe.ping),
Rule("/method/upload_file", endpoint=upload_file),
Rule("/method/<method>", endpoint=handle_rpc_call),
Rule(
"/method/run_doc_method",
methods=["GET", "POST"],
endpoint=lambda: frappe.call(run_doc_method, **frappe.form_dict),
),
Rule("/method/<doctype>/<method>", endpoint=handle_rpc_call),
# Document level APIs
Rule("/document/<doctype>", methods=["GET"], endpoint=document_list),
Rule("/document/<doctype>", methods=["POST"], endpoint=create_doc),
Rule("/document/<doctype>/<path:name>/", methods=["GET"], endpoint=read_doc),
Rule("/document/<doctype>/<path:name>/", methods=["PATCH", "PUT"], endpoint=update_doc),
Rule("/document/<doctype>/<path:name>/", methods=["DELETE"], endpoint=delete_doc),
Rule(
"/document/<doctype>/<path:name>/method/<method>/",
methods=["GET", "POST"],
endpoint=execute_doc_method,
),
# Collection level APIs
Rule("/doctype/<doctype>/meta", methods=["GET"], endpoint=frappe.get_meta),
Rule("/doctype/<doctype>/count", methods=["GET"], endpoint=count),
]

View file

@ -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)

View file

@ -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)()

View file

@ -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"

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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 = `
<div class="flex form-message blue p-3">
<div class="mr-3"><img style="border-radius: var(--border-radius-md)" width="360" src="/assets/frappe/images/form-builder.gif"></div>
<div>
<p style="font-size: var(--text-lg)">${title}</p>
<p>${msg}</p>
<div>
<a class="btn btn-primary btn-sm" href="/app/form-builder/${frm.doc.name}">
${__("Form Builder")} ${frappe.utils.icon("right", "xs")}
</a>
</div>
</div>
</div>
`;
$(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();

View file

@ -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)"""

View file

@ -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):

View file

@ -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)

View file

@ -53,6 +53,7 @@
{
"allow_in_quick_entry": 1,
"depends_on": "eval:doc.frequency==='Cron'",
"description": "<pre>* * * * *\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</pre>\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",

View file

@ -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

View file

@ -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())

View file

@ -136,6 +136,7 @@
},
{
"depends_on": "eval:doc.event_frequency==='Cron'",
"description": "<pre>* * * * *\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</pre>\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",

View file

@ -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)

View file

@ -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",

View file

@ -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",
)

View file

@ -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();

View file

@ -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()

View file

@ -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)
]

View file

@ -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]

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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
}
}

View file

@ -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)

View file

@ -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(

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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 <test@example.com>"
email_record.message = textwrap.dedent(
f"""\
MIME-Version: 1.0
Message-Id: {frappe.generate_hash()}
X-Original-From: Test <test@example.com>
Subject: {subject}
From: Test <test@example.com>
To: <!--recipient-->
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)

View file

@ -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" ||

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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:

View file

@ -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")

View file

@ -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

View file

@ -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"):

View file

@ -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
}

View file

@ -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"

View file

@ -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()

View file

@ -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 = "<pre><code>" + frappe.get_traceback() + "</pre></code>"

View file

@ -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;
}

View file

@ -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);
}
},
}
);
</script>

View file

@ -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)"
/>
<input

View file

@ -1,6 +1,7 @@
<!-- Used as Fetch From Control -->
<script setup>
import { useStore } from "../../store";
import { load_doctype_model } from "../../utils";
import { ref, computed, watch } from "vue";
import { computedAsync } from "@vueuse/core";
@ -39,7 +40,7 @@ let field_df = computedAsync(async () => {
fieldname.value = "";
}
await frappe.model.with_doctype(doctype_name);
await load_doctype_model(doctype_name);
let fields = frappe.meta
.get_docfields(doctype_name, null, {
@ -76,13 +77,20 @@ watch([() => doctype.value, () => fieldname.value], ([doctype_value, fieldname_v
</script>
<template>
<SelectControl :df="doctype_df" :value="doctype" :read_only="read_only" v-model="doctype" />
<SelectControl
v-if="doctype"
:df="field_df"
:read_only="read_only"
:value="fieldname"
v-model="fieldname"
:no_label="true"
/>
<div>
<SelectControl
:df="doctype_df"
:value="doctype"
:read_only="read_only"
v-model="doctype"
/>
<SelectControl
v-if="doctype"
:df="field_df"
:read_only="read_only"
:value="fieldname"
v-model="fieldname"
:no_label="true"
/>
</div>
</template>

View file

@ -1,21 +1,24 @@
<script setup>
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
const props = defineProps(["df"]);
let map = ref(null);
let map_control = ref(null);
let map_control = computed(() => {
if (!map.value) return;
map.value.innerHTML = "";
return frappe.ui.form.make_control({
parent: map.value,
df: { ...props.df, hidden: 0 },
frm: true,
disabled: true,
render_input: true,
});
});
onMounted(() => {
if (map.value) {
map_control.value = frappe.ui.form.make_control({
parent: map.value,
df: { ...props.df, hidden: 0 },
frm: true,
disabled: true,
render_input: true,
});
}
if (map.value) map_control.value;
});
</script>

View file

@ -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);
}
);
</script>
@ -81,4 +87,4 @@ watch(
<div v-if="df.description" class="mt-2 description" v-html="df.description" />
</div>
<div v-else ref="link"></div>
</template>
</template>

View file

@ -1,21 +1,24 @@
<script setup>
import { onMounted, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
const props = defineProps(["df"]);
let rating = ref(null);
let rating_control = ref(null);
let rating_control = computed(() => {
if (!rating.value) return;
rating.value.innerHTML = "";
return frappe.ui.form.make_control({
parent: rating.value,
df: { ...props.df, hidden: 0 },
disabled: true,
render_input: true,
only_input: true,
});
});
onMounted(() => {
if (rating.value) {
rating_control.value = frappe.ui.form.make_control({
parent: rating.value,
df: { ...props.df, hidden: 0 },
disabled: true,
render_input: true,
only_input: true,
});
}
if (rating.value) rating_control.value;
});
watch(
@ -23,9 +26,9 @@ watch(
(value) => {
if (rating_control.value) {
rating_control.value.df.options = value;
rating_control.value.make_input();
rating_control.value?.make_input();
}
},
}
);
</script>
@ -44,5 +47,4 @@ watch(
:deep(.rating) {
--star-fill: var(--yellow-300) !important;
}
</style>

View file

@ -1,5 +1,5 @@
<script setup>
import { get_table_columns } from "../../utils";
import { get_table_columns, load_doctype_model } from "../../utils";
import { computedAsync } from "@vueuse/core";
const props = defineProps(["df"]);
@ -7,7 +7,9 @@ const props = defineProps(["df"]);
let table_columns = computedAsync(async () => {
let doctype = props.df.options;
if (!doctype) return [];
await frappe.model.with_doctype(doctype);
if (!frappe.get_meta(doctype)) {
await load_doctype_model(doctype);
}
let child_doctype = frappe.get_meta(doctype);
return get_table_columns(props.df, child_doctype);
}, []);

View file

@ -1,21 +1,24 @@
<script setup>
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
const props = defineProps(["df"]);
let quill = ref(null);
let quill_control = ref(null);
let quill_control = computed(() => {
if (!quill.value) return;
quill.value.innerHTML = "";
return frappe.ui.form.make_control({
parent: quill.value,
df: { ...props.df, hidden: 0 },
disabled: true,
render_input: true,
only_input: true,
});
});
onMounted(() => {
if (quill.value) {
quill_control.value = frappe.ui.form.make_control({
parent: quill.value,
df: { ...props.df, hidden: 0 },
disabled: true,
render_input: true,
only_input: true,
});
}
if (quill.value) quill_control.value;
});
</script>

View file

@ -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;

View file

@ -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);

View file

@ -124,7 +124,7 @@ frappe.db = {
filters,
},
callback(r) {
resolve(r.results);
resolve(r.message);
},
});
});

View file

@ -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,

View file

@ -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: `<span class="text-muted" style="line-height: 1.5">${filter_string}</span>`,
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:
"<span class='link-option'>" +
"<i class='fa fa-plus' style='margin-right: 5px;'></i> " +
@ -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:
"<span class='link-option'>" +
"<i class='fa fa-search' style='margin-right: 5px;'></i> " +
@ -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);
},

View file

@ -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 {

View file

@ -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

View file

@ -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(
'<div class="row link-select-row">\
@ -149,7 +149,7 @@ frappe.ui.form.LinkSelector = class LinkSelector {
}
var more_btn = me.dialog.fields_dict.more.$wrapper;
if (r.values.length < me.page_length) {
if (results.length < me.page_length) {
more_btn.hide();
} else {
more_btn.show();
@ -246,7 +246,7 @@ frappe.link_search = function (doctype, args, callback, btn) {
type: "GET",
args: args,
callback: function (r) {
callback && callback(r);
callback && callback(r.message);
},
btn: btn,
});

View file

@ -576,22 +576,22 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog {
no_spinner: true,
args: args,
});
const more = res.values.length && res.values.length > this.page_length ? 1 : 0;
const more = res.message.length && res.message.length > this.page_length ? 1 : 0;
return [res, more];
return [res.message, more];
}
async get_results() {
const args = this.get_args_for_search();
const [res, more] = await this.perform_search(args);
let [results, more] = await this.perform_search(args);
if (more) {
res.values = res.values.splice(0, this.page_length);
results = results.splice(0, this.page_length);
}
this.results = [];
if (res.values.length) {
res.values.forEach((result) => {
if (results.length) {
results.forEach((result) => {
result.checked = 0;
this.results.push(result);
});
@ -602,11 +602,11 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog {
async get_filtered_parents_for_child_search() {
const parent_search_args = this.get_args_for_search();
parent_search_args.filter_fields = ["name"];
const [response, _] = await this.perform_search(parent_search_args);
const [results, _] = await this.perform_search(parent_search_args);
let parent_names = [];
if (response.values.length) {
parent_names = response.values.map((v) => v.name);
if (results.length) {
parent_names = results.map((v) => v.name);
}
return parent_names;
}

View file

@ -16,6 +16,7 @@ frappe.ui.form.Sidebar = class {
var sidebar_content = frappe.render_template("form_sidebar", {
doctype: this.frm.doctype,
frm: this.frm,
can_write: frappe.model.can_write(this.frm.doctype, this.frm.docname),
});
this.sidebar = $('<div class="form-sidebar overlay-sidebar hidden-xs hidden-sm"></div>')

View file

@ -5,6 +5,7 @@
<div class="sidebar-standard-image">
<div class="standard-image"></div>
</div>
{% if can_write %}
<div class="sidebar-image-actions">
<div class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">{{ __("Change") }}</a>
@ -14,6 +15,7 @@
</div>
</div>
</div>
{% endif %}
</li>
</ul>
{% if frm.meta.beta %}
@ -136,21 +138,21 @@
</ul>
<ul class="list-unstyled sidebar-menu form-sidebar-stats">
<li class="flex">
<div class="form-stats">
<div class="form-stats d-flex">
<span class="form-stats-likes">
<span class="liked-by like-action">
<span class="liked-by like-action d-flex align-items-center">
<svg class="es-icon icon-sm">
<use href="#es-solid-heart" class="like-icon"></use>
</svg>
<span class="like-count"></span>
<span class="like-count ml-2"></span>
</span>
</span>
<span class="mx-1">·</span>
<a class="comments">
<span class="mx-2">·</span>
<a class="comments d-flex align-items-center">
<svg class="es-icon icon-sm">
<use href="#es-line-chat-alt" class="comment-icon"></use>
</svg>
<span class="comments-count"></span>
<span class="comments-count ml-2"></span>
</a>
</div>
<a class="form-follow text-sm">

View file

@ -313,7 +313,9 @@ frappe.views.BaseList = class BaseList {
this.filter_area = new FilterArea(this);
if (this.filters && this.filters.length > 0) {
return this.filter_area.set(this.filters);
return this.filter_area.set(this.filters).catch(() => {
this.filter_area.clear(false);
});
}
}

View file

@ -631,9 +631,6 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
let subject_html = `
<input class="level-item list-check-all" type="checkbox"
title="${__("Select All")}">
<span class="level-item list-liked-by-me hidden-xs">
<span title="${__("Likes")}">${frappe.utils.icon("es-solid-heart", "sm", "like-icon")}</span>
</span>
<span class="level-item" data-sort-by="${subject_field.fieldname}"
title="${__("Click to sort by {0}", [subject_field.label])}">
${__(subject_field.label)}
@ -667,7 +664,16 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
})
.join("");
return this.get_header_html_skeleton($columns, '<span class="list-count"></span>');
const right_html = `
<span class="list-count"></span>
<span class="level-item list-liked-by-me hidden-xs">
<span title="${__("Liked by me")}">
${frappe.utils.icon("es-solid-heart", "sm", "like-icon")}
</span>
</span>
`;
return this.get_header_html_skeleton($columns, right_html);
}
get_header_html_skeleton(left = "", right = "") {
@ -895,21 +901,18 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
const modified = comment_when(doc.modified, true);
let assigned_to = `<div class="list-assignments">
<span class="avatar avatar-small">
<span class="avatar-empty"></span>
</div>`;
let assigned_to = ``;
let assigned_users = JSON.parse(doc._assign || "[]");
if (assigned_users.length) {
assigned_to = `<div class="list-assignments">
assigned_to = `<div class="list-assignments d-flex align-items-center">
${frappe.avatar_group(assigned_users, 3, { filterable: true })[0].outerHTML}
</div>`;
}
let comment_count = null;
if (this.list_view_settings && !this.list_view_settings.disable_comment_count) {
comment_count = $(`<span class="comment-count"></span>`);
comment_count = $(`<span class="comment-count d-flex align-items-center"></span>`);
$(comment_count).append(`
${frappe.utils.icon("es-line-chat-alt")}
${doc._comment_count > 99 ? "99+" : doc._comment_count || 0}`);
@ -920,8 +923,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
<div class="hidden-md hidden-xs">
${settings_button || assigned_to}
</div>
${modified}
<span class="modified">${modified}</span>
${comment_count ? $(comment_count).prop("outerHTML") : ""}
${comment_count ? '<span class="mx-2">·</span>' : ""}
<span class="list-row-like hidden-xs style="margin-bottom: 1px;">
${this.get_like_html(doc)}
</span>
</div>
<div class="level-item visible-xs text-right">
${this.get_indicator_dot(doc)}
@ -1012,9 +1019,6 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
div.innerHTML = `
<span class="level-item select-like">
<input class="list-row-checkbox" type="checkbox">
<span class="list-row-like hidden-xs style="margin-bottom: 1px;">
${this.get_like_html(doc)}
</span>
</span>
<span class="level-item ${seen} ellipsis">
<a class="ellipsis"></a>

View file

@ -40,6 +40,11 @@ frappe.views.ListViewSelect = class ListViewSelect {
set_route(view, calendar_name) {
const route = [this.slug(), "view", view];
if (calendar_name) route.push(calendar_name);
let search_params = cur_list?.get_search_params();
if (search_params) {
frappe.route_options = Object.fromEntries(search_params);
}
frappe.set_route(route);
}

View file

@ -19,7 +19,7 @@ frappe.xcall = function (method, params) {
resolve(r.message);
},
error: (r) => {
reject(r.message);
reject(r?.message);
},
});
});
@ -88,7 +88,11 @@ frappe.call = function (opts) {
let url = opts.url;
if (!url) {
url = "/api/method/" + args.cmd;
let prefix = "/api/method/";
if (opts.api_version) {
prefix = `/api/${opts.api_version}/method/`;
}
url = prefix + args.cmd;
if (window.cordova) {
let host = frappe.request.url;
host = host.slice(0, host.length - 1);
@ -116,6 +120,7 @@ frappe.call = function (opts) {
// show_spinner: !opts.no_spinner,
async: opts.async,
silent: opts.silent,
api_version: opts.api_version,
url,
});
};
@ -133,6 +138,7 @@ frappe.request.call = function (opts) {
} else {
frappe.app.handle_session_expired();
}
opts.error_callback && opts.error_callback();
},
404: function (xhr) {
frappe.msgprint({
@ -140,6 +146,7 @@ frappe.request.call = function (opts) {
indicator: "red",
message: __("The resource you are looking for is not available"),
});
opts.error_callback && opts.error_callback();
},
403: function (xhr) {
if (frappe.session.user === "Guest" && frappe.session.logged_in_user !== "Guest") {
@ -169,6 +176,7 @@ frappe.request.call = function (opts) {
),
});
}
opts.error_callback && opts.error_callback();
},
508: function (xhr) {
frappe.utils.play_sound("error");
@ -179,6 +187,7 @@ frappe.request.call = function (opts) {
"Another transaction is blocking this one. Please try again in a few seconds."
),
});
opts.error_callback && opts.error_callback();
},
413: function (data, xhr) {
frappe.msgprint({
@ -188,6 +197,7 @@ frappe.request.call = function (opts) {
(frappe.boot.max_file_size || 5242880) / 1048576,
]),
});
opts.error_callback && opts.error_callback();
},
417: function (xhr) {
var r = xhr.responseJSON;
@ -220,6 +230,7 @@ frappe.request.call = function (opts) {
},
502: function (xhr) {
frappe.msgprint(__("Internal Server Error"));
opts.error_callback && opts.error_callback();
},
};
@ -438,12 +449,18 @@ frappe.request.cleanup = function (opts, r) {
}
// show messages
if (r._server_messages && !opts.silent) {
//
let messages;
if (opts.api_version == "v2") {
messages = r.messages;
} else if (r._server_messages) {
messages = JSON.parse(r._server_messages);
}
if (messages && !opts.silent) {
// show server messages if no handlers exist
if (handlers.length === 0) {
r._server_messages = JSON.parse(r._server_messages);
frappe.hide_msgprint();
frappe.msgprint(r._server_messages);
frappe.msgprint(messages);
}
}

View file

@ -427,7 +427,7 @@ frappe.ui.GroupBy = class {
}
get_group_by_field_label() {
let field = this.group_by_fields[this.group_by_doctype].find(
let field = this.group_by_fields[this.group_by_doctype]?.find(
(field) => field.fieldname == this.group_by_field
);
return field?.label || field?.fieldname;

View file

@ -144,7 +144,15 @@ frappe.msgprint = function (msg, title, is_minimizable) {
if (data.message instanceof Array) {
let messages = data.message;
const exceptions = messages.map((m) => JSON.parse(m)).filter((m) => m.raise_exception);
const exceptions = messages
.map((m) => {
if (typeof m == "string") {
return JSON.parse(m);
} else {
return m;
}
})
.filter((m) => m.raise_exception);
// only show exceptions if any exceptions exist
if (exceptions.length) {

View file

@ -45,6 +45,31 @@ function strip_number_groups(v, number_format) {
return v;
}
function convert_old_to_new_number_format(v, old_number_format, new_number_format) {
if (!new_number_format) new_number_format = get_number_format();
let new_info = get_number_format_info(new_number_format);
if (!old_number_format) old_number_format = "#,###.##";
let old_info = get_number_format_info(old_number_format);
if (old_number_format === new_number_format) return v;
if (new_info.decimal_str == "") {
return strip_number_groups(v);
}
let v_parts = v.split(old_info.decimal_str);
let v_before_decimal = v_parts[0];
let v_after_decimal = v_parts[1] || "";
// replace old group separator with new group separator in v_before_decimal
let old_group_regex = new RegExp(old_info.group_sep === "." ? "\\." : old_info.group_sep, "g");
v_before_decimal = v_before_decimal.replace(old_group_regex, new_info.group_sep);
v = v_before_decimal + new_info.decimal_str + v_after_decimal;
return v;
}
frappe.number_format_info = {
"#,###.##": { decimal_str: ".", group_sep: "," },
"#.###,##": { decimal_str: ",", group_sep: "." },
@ -284,6 +309,7 @@ Object.assign(window, {
flt,
cint,
strip_number_groups,
convert_old_to_new_number_format,
format_currency,
fmt_money,
get_currency_symbol,

View file

@ -1609,7 +1609,6 @@ Object.assign(frappe.utils, {
get_filter_as_json(filters) {
// convert filter array to json
let filter = null;
if (filters.length) {
filter = {};
filters.forEach((arr) => {
@ -1617,10 +1616,13 @@ Object.assign(frappe.utils, {
});
filter = JSON.stringify(filter);
}
return filter;
},
process_filter_expression(filter) {
return new Function(`return ${filter}`)();
},
get_filter_from_json(filter_json, doctype) {
// convert json to filter array
if (filter_json) {
@ -1628,12 +1630,22 @@ Object.assign(frappe.utils, {
return [];
}
const filters_json = new Function(`return ${filter_json}`)();
const filters_json = this.process_filter_expression(filter_json);
if (!doctype) {
// e.g. return {
// priority: (2) ['=', 'Medium'],
// status: (2) ['=', 'Open']
// }
// don't remove unless patch is created to convert all existing filters from object to array
// backward compatibility
if (Array.isArray(filters_json)) {
let filter = {};
filters_json.forEach((arr) => {
filter[arr[1]] = [arr[2], arr[3]];
});
return filter || [];
}
return filters_json || [];
}
@ -1641,6 +1653,11 @@ Object.assign(frappe.utils, {
// ['ToDo', 'status', '=', 'Open', false],
// ['ToDo', 'priority', '=', 'Medium', false]
// ]
if (Array.isArray(filters_json)) {
return filters_json;
}
// don't remove unless patch is created to convert all existing filters from object to array
// backward compatibility
return Object.keys(filters_json).map((filter) => {
let val = filters_json[filter];
return [doctype, filter, val[0], val[1], false];

View file

@ -362,8 +362,8 @@ frappe.views.FileView = class FileView extends frappe.views.ListView {
<input class="list-row-checkbox"
type="checkbox" data-name="${file.name}">
</span>
<span class="level-item ellipsis" title="${file.file_name}">
<a class="ellipsis" href="${route_url}" title="${file.file_name}">
<span class="level-item ellipsis" title="${frappe.utils.escape_html(file.file_name)}">
<a class="ellipsis" href="${route_url}" title="${frappe.utils.escape_html(file.file_name)}">
${file.subject_html}
</a>
</span>

View file

@ -222,6 +222,7 @@ export default class NumberCardWidget extends Widget {
let number_parts = shortened_number.split(" ");
const symbol = number_parts[1] || "";
number_parts[0] = window.convert_old_to_new_number_format(number_parts[0]);
const formatted_number = $(frappe.format(number_parts[0], df)).text();
this.formatted_number = formatted_number + " " + __(symbol);

View file

@ -76,7 +76,7 @@ export default class QuickListWidget extends Widget {
delete this.filter_group;
}
this.filters = frappe.utils.get_filter_from_json(this.quick_list_filter, doctype);
this.filters = frappe.utils.process_filter_expression(this.quick_list_filter);
this.filter_group = new frappe.ui.FilterGroup({
parent: this.dialog.get_field("filter_area").$wrapper,
@ -104,7 +104,7 @@ export default class QuickListWidget extends Widget {
primary_action: function () {
let old_filter = me.quick_list_filter;
let filters = me.filter_group.get_filters();
me.quick_list_filter = frappe.utils.get_filter_as_json(filters);
me.quick_list_filter = JSON.parse(filters);
this.hide();
@ -193,7 +193,7 @@ export default class QuickListWidget extends Widget {
fields.push("modified");
let quick_list_filter = frappe.utils.get_filter_from_json(this.quick_list_filter);
let quick_list_filter = frappe.utils.process_filter_expression(this.quick_list_filter);
let args = {
method: "frappe.desk.reportview.get",

View file

@ -71,7 +71,7 @@ export default class ShortcutWidget extends Widget {
this.widget.addClass("shortcut-widget-box");
let filters = frappe.utils.get_filter_from_json(this.stats_filter);
let filters = frappe.utils.process_filter_expression(this.stats_filter);
if (this.type == "DocType" && filters) {
frappe.db
.count(this.link_to, {

View file

@ -182,7 +182,7 @@ class QuickListDialog extends WidgetDialog {
process_data(data) {
if (this.filter_group) {
let filters = this.filter_group.get_filters();
data.quick_list_filter = frappe.utils.get_filter_as_json(filters);
data.quick_list_filter = JSON.stringify(filters);
}
data.label = data.label ? data.label : data.document_type;
@ -540,7 +540,7 @@ class ShortcutDialog extends WidgetDialog {
process_data(data) {
if (this.dialog.get_value("type") == "DocType" && this.filter_group) {
let filters = this.filter_group.get_filters();
data.stats_filter = frappe.utils.get_filter_as_json(filters);
data.stats_filter = JSON.stringify(filters);
}
data.label = data.label ? data.label : frappe.model.unscrub(data.link_to);

View file

@ -22,7 +22,7 @@
use.like-icon {
--icon-stroke: transparent;
cursor: pointer;
stroke: var(--gray-600);
stroke: var(--gray-800);
}
#icon-file-large {
@ -44,7 +44,6 @@ use.like-icon {
.liked {
use.like-icon {
--icon-stroke: var(--red-500);
stroke: var(--icon-stroke);
fill: var(--icon-stroke);
}
}

View file

@ -95,11 +95,6 @@
.standard-image {
font-size: var(--text-xs);
}
.avatar-empty::after {
content: "\002D";
line-height: 28px;
}
}
.avatar-medium {

View file

@ -94,6 +94,7 @@
.level-right {
flex: 1;
overflow: visible;
align-items: center;
}
.tag-col {
@ -118,10 +119,10 @@
&> span {
display: inline-block;
}
&:not(:last-child) {
margin-right: 15px;
}
.modified {
margin-right: var(--margin-sm);
}
.comment-count {
@ -131,7 +132,6 @@
.frappe-timestamp {
font-size: var(--text-xs);
white-space: nowrap;
min-width: 30px;
}
.list-assignments, .list-actions {
@ -195,10 +195,6 @@ $level-margin-right: 8px;
color: var(--text-color);
}
.level-item:not(.file-select) {
margin-right: $level-margin-right;
}
&.seen {
font-weight: normal;
}

View file

@ -74,7 +74,7 @@ def main(
# workaround! since there is no separate test db
frappe.clear_cache()
scheduler_disabled_by_user = frappe.utils.scheduler.is_scheduler_disabled()
scheduler_disabled_by_user = frappe.utils.scheduler.is_scheduler_disabled(verbose=False)
if not scheduler_disabled_by_user:
frappe.utils.scheduler.disable_scheduler()

View file

@ -1,20 +1,21 @@
import json
import sys
from contextlib import contextmanager
from functools import cached_property
from random import choice
from threading import Thread
from time import time
from unittest.mock import patch
from urllib.parse import urljoin
import requests
from filetype import guess_mime
from semantic_version import Version
from werkzeug.test import TestResponse
import frappe
from frappe.installer import update_site_config
from frappe.tests.utils import FrappeTestCase, patch_hooks
from frappe.utils import cint, get_site_url, get_test_client
from frappe.utils import cint, get_test_client, get_url
try:
_site = frappe.local.site
@ -73,34 +74,55 @@ class ThreadWithReturnValue(Thread):
return self._return
resource_key = {
"": "resource",
"v1": "resource",
"v2": "document",
}
class FrappeAPITestCase(FrappeTestCase):
SITE = frappe.local.site
SITE_URL = get_site_url(SITE)
RESOURCE_URL = f"{SITE_URL}/api/resource"
version = "" # Empty implies v1
TEST_CLIENT = get_test_client()
@property
def site_url(self):
return get_url()
def resource_path(self, *parts):
return self.get_path(resource_key[self.version], *parts)
def method_path(self, *method):
return self.get_path("method", *method)
def doctype_path(self, *method):
return self.get_path("doctype", *method)
def get_path(self, *parts):
return urljoin(self.site_url, "/".join(("api", self.version, *parts)))
@cached_property
def sid(self) -> str:
if not getattr(self, "_sid", None):
from frappe.auth import CookieManager, LoginManager
from frappe.utils import set_request
from frappe.auth import CookieManager, LoginManager
from frappe.utils import set_request
set_request(path="/")
frappe.local.cookie_manager = CookieManager()
frappe.local.login_manager = LoginManager()
frappe.local.login_manager.login_as("Administrator")
self._sid = frappe.session.sid
return self._sid
set_request(path="/")
frappe.local.cookie_manager = CookieManager()
frappe.local.login_manager = LoginManager()
frappe.local.login_manager.login_as("Administrator")
return frappe.session.sid
def get(self, path: str, params: dict | None = None, **kwargs) -> TestResponse:
return make_request(target=self.TEST_CLIENT.get, args=(path,), kwargs={"data": params, **kwargs})
return make_request(target=self.TEST_CLIENT.get, args=(path,), kwargs={"json": params, **kwargs})
def post(self, path, data, **kwargs) -> TestResponse:
return make_request(target=self.TEST_CLIENT.post, args=(path,), kwargs={"data": data, **kwargs})
return make_request(target=self.TEST_CLIENT.post, args=(path,), kwargs={"json": data, **kwargs})
def put(self, path, data, **kwargs) -> TestResponse:
return make_request(target=self.TEST_CLIENT.put, args=(path,), kwargs={"data": data, **kwargs})
return make_request(target=self.TEST_CLIENT.put, args=(path,), kwargs={"json": data, **kwargs})
def patch(self, path, data, **kwargs) -> TestResponse:
return make_request(target=self.TEST_CLIENT.patch, args=(path,), kwargs={"json": data, **kwargs})
def delete(self, path, **kwargs) -> TestResponse:
return make_request(target=self.TEST_CLIENT.delete, args=(path,), kwargs=kwargs)
@ -113,8 +135,9 @@ class TestResourceAPI(FrappeAPITestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
for _ in range(10):
for _ in range(20):
doc = frappe.get_doc({"doctype": "ToDo", "description": frappe.mock("paragraph")}).insert()
cls.GENERATED_DOCUMENTS = []
cls.GENERATED_DOCUMENTS.append(doc.name)
frappe.db.commit()
@ -126,31 +149,31 @@ class TestResourceAPI(FrappeAPITestCase):
def test_unauthorized_call(self):
# test 1: fetch documents without auth
response = requests.get(f"{self.RESOURCE_URL}/{self.DOCTYPE}")
response = requests.get(self.resource_path(self.DOCTYPE))
self.assertEqual(response.status_code, 403)
def test_get_list(self):
# test 2: fetch documents without params
response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid})
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid})
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.json, dict)
self.assertIn("data", response.json)
def test_get_list_limit(self):
# test 3: fetch data with limit
response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "limit": 2})
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "limit": 2})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json["data"]), 2)
def test_get_list_dict(self):
# test 4: fetch response as (not) dict
response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "as_dict": True})
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "as_dict": True})
json = frappe._dict(response.json)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(json.data, list)
self.assertIsInstance(json.data[0], dict)
response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "as_dict": False})
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "as_dict": False})
json = frappe._dict(response.json)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(json.data, list)
@ -158,7 +181,8 @@ class TestResourceAPI(FrappeAPITestCase):
def test_get_list_debug(self):
# test 5: fetch response with debug
response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "debug": True})
with suppress_stdout():
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "debug": True})
self.assertEqual(response.status_code, 200)
self.assertIn("exc", response.json)
self.assertIsInstance(response.json["exc"], str)
@ -167,52 +191,48 @@ class TestResourceAPI(FrappeAPITestCase):
def test_get_list_fields(self):
# test 6: fetch response with fields
response = self.get(
f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "fields": '["description"]'}
self.resource_path(self.DOCTYPE), {"sid": self.sid, "fields": '["description"]'}
)
self.assertEqual(response.status_code, 200)
json = frappe._dict(response.json)
self.assertIn("description", json.data[0])
def test_create_document(self):
# test 7: POST method on /api/resource to create doc
data = {"description": frappe.mock("paragraph"), "sid": self.sid}
response = self.post(f"/api/resource/{self.DOCTYPE}", data)
response = self.post(self.resource_path(self.DOCTYPE), data)
self.assertEqual(response.status_code, 200)
docname = response.json["data"]["name"]
self.assertIsInstance(docname, str)
self.GENERATED_DOCUMENTS.append(docname)
def test_update_document(self):
# test 8: PUT method on /api/resource to update doc
generated_desc = frappe.mock("paragraph")
data = {"description": generated_desc, "sid": self.sid}
random_doc = choice(self.GENERATED_DOCUMENTS)
desc_before_update = frappe.db.get_value(self.DOCTYPE, random_doc, "description")
response = self.put(f"/api/resource/{self.DOCTYPE}/{random_doc}", data=data)
response = self.put(self.resource_path(self.DOCTYPE, random_doc), data=data)
self.assertEqual(response.status_code, 200)
self.assertNotEqual(response.json["data"]["description"], desc_before_update)
self.assertEqual(response.json["data"]["description"], generated_desc)
response = self.get(self.resource_path(self.DOCTYPE, random_doc))
self.assertEqual(response.json["data"]["description"], generated_desc)
def test_delete_document(self):
# test 9: DELETE method on /api/resource
doc_to_delete = choice(self.GENERATED_DOCUMENTS)
response = self.delete(f"/api/resource/{self.DOCTYPE}/{doc_to_delete}")
response = self.delete(self.resource_path(self.DOCTYPE, doc_to_delete))
self.assertEqual(response.status_code, 202)
self.assertDictEqual(response.json, {"message": "ok"})
self.GENERATED_DOCUMENTS.remove(doc_to_delete)
self.assertDictEqual(response.json, {"data": "ok"})
non_existent_doc = frappe.generate_hash(length=12)
with suppress_stdout():
response = self.delete(f"/api/resource/{self.DOCTYPE}/{non_existent_doc}")
response = self.get(self.resource_path(self.DOCTYPE, doc_to_delete))
self.assertEqual(response.status_code, 404)
self.assertDictEqual(response.json, {})
self.GENERATED_DOCUMENTS.remove(doc_to_delete)
def test_run_doc_method(self):
# test 10: Run whitelisted method on doc via /api/resource
# status_code is 403 if no other tests are run before this - it's not logged in
self.post("/api/resource/Website Theme/Standard", {"run_method": "get_apps"})
response = self.get("/api/resource/Website Theme/Standard", {"run_method": "get_apps"})
self.post(self.resource_path("Website Theme", "Standard"), {"run_method": "get_apps"})
response = self.get(self.resource_path("Website Theme", "Standard"), {"run_method": "get_apps"})
self.assertIn(response.status_code, (403, 200))
if response.status_code == 403:
@ -232,25 +252,16 @@ class TestResourceAPI(FrappeAPITestCase):
class TestMethodAPI(FrappeAPITestCase):
METHOD_PATH = "/api/method"
def setUp(self):
if self._testMethodName == "test_auth_cycle":
from frappe.core.doctype.user.user import generate_keys
generate_keys("Administrator")
frappe.db.commit()
def test_ping(self):
# test 2: test for /api/method/ping
response = self.get(f"{self.METHOD_PATH}/ping")
response = self.get(self.method_path("ping"))
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.json, dict)
self.assertEqual(response.json["message"], "pong")
def test_get_user_info(self):
# test 3: test for /api/method/frappe.realtime.get_user_info
response = self.get(f"{self.METHOD_PATH}/frappe.realtime.get_user_info")
response = self.get(self.method_path("frappe.realtime.get_user_info"))
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.json, dict)
self.assertIn(response.json.get("message").get("user"), ("Administrator", "Guest"))
@ -258,10 +269,11 @@ class TestMethodAPI(FrappeAPITestCase):
def test_auth_cycle(self):
# test 4: Pass authorization token in request
global authorization_token
generate_admin_keys()
user = frappe.get_doc("User", "Administrator")
api_key, api_secret = user.api_key, user.get_password("api_secret")
authorization_token = f"{api_key}:{api_secret}"
response = self.get("/api/method/frappe.auth.get_logged_user")
response = self.get(self.method_path("frappe.auth.get_logged_user"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json["message"], "Administrator")
@ -269,18 +281,45 @@ class TestMethodAPI(FrappeAPITestCase):
authorization_token = None
def test_404s(self):
response = self.get("/api/rest", {"sid": self.sid})
response = self.get(self.get_path("rest"), {"sid": self.sid})
self.assertEqual(response.status_code, 404)
response = self.get("/api/resource/User/NonExistent@s.com", {"sid": self.sid})
response = self.get(self.resource_path("User", "NonExistent@s.com"), {"sid": self.sid})
self.assertEqual(response.status_code, 404)
def test_logs(self):
method = "frappe.tests.test_api.test"
def get_message(resp, msg_type):
return frappe.parse_json(frappe.parse_json(frappe.parse_json(resp.json)[msg_type])[0])
expected_message = "Failed"
response = self.get(self.method_path(method), {"sid": self.sid, "message": expected_message})
self.assertEqual(get_message(response, "_server_messages").message, expected_message)
# Cause handled failured
with suppress_stdout():
response = self.get(
self.method_path(method), {"sid": self.sid, "message": expected_message, "fail": True}
)
self.assertEqual(get_message(response, "_server_messages").message, expected_message)
self.assertEqual(response.json["exc_type"], "ValidationError")
self.assertIn("Traceback", response.json["exc"])
# Cause handled failured
with suppress_stdout():
response = self.get(
self.method_path(method),
{"sid": self.sid, "message": expected_message, "fail": True, "handled": False},
)
self.assertNotIn("_server_messages", response.json)
self.assertIn("ZeroDivisionError", response.json["exception"]) # WHY?
self.assertIn("Traceback", response.json["exc"])
class TestReadOnlyMode(FrappeAPITestCase):
"""During migration if read only mode can be enabled.
Test if reads work well and writes are blocked"""
REQ_PATH = "/api/resource/ToDo"
@classmethod
def setUpClass(cls):
super().setUpClass()
@ -290,13 +329,16 @@ class TestReadOnlyMode(FrappeAPITestCase):
update_site_config("maintenance_mode", 1)
def test_reads(self):
response = self.get(self.REQ_PATH, {"sid": self.sid})
response = self.get(self.resource_path("ToDo"), {"sid": self.sid})
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.json, dict)
self.assertIsInstance(response.json["data"], list)
def test_blocked_writes(self):
response = self.post(self.REQ_PATH, {"description": frappe.mock("paragraph"), "sid": self.sid})
with suppress_stdout():
response = self.post(
self.resource_path("ToDo"), {"description": frappe.mock("paragraph"), "sid": self.sid}
)
self.assertEqual(response.status_code, 503)
self.assertEqual(response.json["exc_type"], "InReadOnlyMode")
@ -368,3 +410,21 @@ class TestResponse(FrappeAPITestCase):
self.assertEqual(response.status_code, 200)
self.assertIn("text/csv", response.headers["content-type"])
self.assertGreater(cint(response.headers["content-length"]), 0)
def generate_admin_keys():
from frappe.core.doctype.user.user import generate_keys
generate_keys("Administrator")
frappe.db.commit()
@frappe.whitelist()
def test(*, fail=False, handled=True, message="Failed"):
if fail:
if handled:
frappe.throw(message)
else:
1 / 0
else:
frappe.msgprint(message)

297
frappe/tests/test_api_v2.py Normal file
View file

@ -0,0 +1,297 @@
from random import choice
import requests
import frappe
from frappe.installer import update_site_config
from frappe.tests.test_api import FrappeAPITestCase, suppress_stdout
authorization_token = None
resource_key = {
"": "resource",
"v1": "resource",
"v2": "document",
}
class TestResourceAPIV2(FrappeAPITestCase):
version = "v2"
DOCTYPE = "ToDo"
GENERATED_DOCUMENTS = []
@classmethod
def setUpClass(cls):
super().setUpClass()
for _ in range(20):
doc = frappe.get_doc({"doctype": "ToDo", "description": frappe.mock("paragraph")}).insert()
cls.GENERATED_DOCUMENTS = []
cls.GENERATED_DOCUMENTS.append(doc.name)
frappe.db.commit()
@classmethod
def tearDownClass(cls):
for name in cls.GENERATED_DOCUMENTS:
frappe.delete_doc_if_exists(cls.DOCTYPE, name)
frappe.db.commit()
def test_unauthorized_call(self):
# test 1: fetch documents without auth
response = requests.get(self.resource_path(self.DOCTYPE))
self.assertEqual(response.status_code, 403)
def test_get_list(self):
# test 2: fetch documents without params
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid})
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.json, dict)
self.assertIn("data", response.json)
def test_get_list_limit(self):
# test 3: fetch data with limit
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "limit": 2})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json["data"]), 2)
def test_get_list_dict(self):
# test 4: fetch response as (not) dict
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "as_dict": True})
json = frappe._dict(response.json)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(json.data, list)
self.assertIsInstance(json.data[0], dict)
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "as_dict": False})
json = frappe._dict(response.json)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(json.data, list)
self.assertIsInstance(json.data[0], list)
def test_get_list_fields(self):
# test 6: fetch response with fields
response = self.get(
self.resource_path(self.DOCTYPE), {"sid": self.sid, "fields": '["description"]'}
)
self.assertEqual(response.status_code, 200)
json = frappe._dict(response.json)
self.assertIn("description", json.data[0])
def test_create_document(self):
data = {"description": frappe.mock("paragraph"), "sid": self.sid}
response = self.post(self.resource_path(self.DOCTYPE), data)
self.assertEqual(response.status_code, 200)
docname = response.json["data"]["name"]
self.assertIsInstance(docname, str)
self.GENERATED_DOCUMENTS.append(docname)
def test_delete_document(self):
doc_to_delete = choice(self.GENERATED_DOCUMENTS)
response = self.delete(self.resource_path(self.DOCTYPE, doc_to_delete))
self.assertEqual(response.status_code, 202)
self.assertDictEqual(response.json, {"data": "ok"})
response = self.get(self.resource_path(self.DOCTYPE, doc_to_delete))
self.assertEqual(response.status_code, 404)
self.GENERATED_DOCUMENTS.remove(doc_to_delete)
def test_execute_doc_method(self):
response = self.get(self.resource_path("Website Theme", "Standard", "method", "get_apps"))
self.assertEqual(response.json["data"][0]["name"], "frappe")
def test_update_document(self):
generated_desc = frappe.mock("paragraph")
data = {"description": generated_desc, "sid": self.sid}
random_doc = choice(self.GENERATED_DOCUMENTS)
response = self.patch(self.resource_path(self.DOCTYPE, random_doc), data=data)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json["data"]["description"], generated_desc)
response = self.get(self.resource_path(self.DOCTYPE, random_doc))
self.assertEqual(response.json["data"]["description"], generated_desc)
def test_delete_document_non_existing(self):
non_existent_doc = frappe.generate_hash(length=12)
with suppress_stdout():
response = self.delete(self.resource_path(self.DOCTYPE, non_existent_doc))
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json["errors"][0]["type"], "DoesNotExistError")
# 404s dont return exceptions
self.assertFalse(response.json["errors"][0].get("exception"))
class TestMethodAPIV2(FrappeAPITestCase):
version = "v2"
def setUp(self) -> None:
self.post(self.method_path("login"), {"sid": self.sid})
return super().setUp()
def test_ping(self):
response = self.get(self.method_path("ping"))
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.json, dict)
self.assertEqual(response.json["data"], "pong")
def test_get_user_info(self):
response = self.get(self.method_path("frappe.realtime.get_user_info"))
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.json, dict)
self.assertIn(response.json.get("data").get("user"), ("Administrator", "Guest"))
def test_auth_cycle(self):
global authorization_token
generate_admin_keys()
user = frappe.get_doc("User", "Administrator")
api_key, api_secret = user.api_key, user.get_password("api_secret")
authorization_token = f"{api_key}:{api_secret}"
response = self.get(self.method_path("frappe.auth.get_logged_user"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json["data"], "Administrator")
authorization_token = None
def test_404s(self):
response = self.get(self.get_path("rest"), {"sid": self.sid})
self.assertEqual(response.status_code, 404)
response = self.get(self.resource_path("User", "NonExistent@s.com"), {"sid": self.sid})
self.assertEqual(response.status_code, 404)
def test_shorthand_controller_methods(self):
shorthand_response = self.get(self.method_path("User", "get_all_roles"), {"sid": self.sid})
self.assertIn("Blogger", shorthand_response.json["data"])
expanded_response = self.get(
self.method_path("frappe.core.doctype.user.user.get_all_roles"), {"sid": self.sid}
)
self.assertEqual(expanded_response.data, shorthand_response.data)
def test_logout(self):
self.post(self.method_path("logout"), {"sid": self.sid})
response = self.get(self.method_path("ping"))
self.assertFalse(response.request.cookies["sid"])
def test_run_doc_method_in_memory(self):
dns = frappe.get_doc("Document Naming Settings")
# Check that simple API can be called.
response = self.get(
self.method_path("run_doc_method"),
{
"sid": self.sid,
"document": dns.as_dict(),
"method": "get_transactions_and_prefixes",
},
)
self.assertTrue(response.json["data"])
self.assertGreaterEqual(len(response.json["docs"]), 1)
# Call with known and unknown arguments, only known should get passed
response = self.get(
self.method_path("run_doc_method"),
{
"sid": self.sid,
"document": dns.as_dict(),
"method": "get_options",
"kwargs": {"doctype": "Webhook", "unknown": "what"},
},
)
self.assertEqual(response.status_code, 200)
def test_logs(self):
method = "frappe.tests.test_api.test"
expected_message = "Failed v2"
response = self.get(
self.method_path(method), {"sid": self.sid, "message": expected_message}
).json
self.assertIsInstance(response["messages"], list)
self.assertEqual(response["messages"][0]["message"], expected_message)
# Cause handled failured
with suppress_stdout():
response = self.get(
self.method_path(method), {"sid": self.sid, "message": expected_message, "fail": True}
).json
self.assertIsInstance(response["errors"], list)
self.assertEqual(response["errors"][0]["message"], expected_message)
self.assertEqual(response["errors"][0]["type"], "ValidationError")
self.assertIn("Traceback", response["errors"][0]["exception"])
# Cause handled failured
with suppress_stdout():
response = self.get(
self.method_path(method),
{"sid": self.sid, "message": expected_message, "fail": True, "handled": False},
).json
self.assertIsInstance(response["errors"], list)
self.assertEqual(response["errors"][0]["type"], "ZeroDivisionError")
self.assertIn("Traceback", response["errors"][0]["exception"])
class TestDocTypeAPIV2(FrappeAPITestCase):
version = "v2"
def setUp(self) -> None:
self.post(self.method_path("login"), {"sid": self.sid})
return super().setUp()
def test_meta(self):
response = self.get(self.doctype_path("ToDo", "meta"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json["data"]["name"], "ToDo")
def test_count(self):
response = self.get(self.doctype_path("ToDo", "count"))
self.assertIsInstance(response.json["data"], int)
class TestReadOnlyMode(FrappeAPITestCase):
"""During migration if read only mode can be enabled.
Test if reads work well and writes are blocked"""
version = "v2"
@classmethod
def setUpClass(cls):
super().setUpClass()
update_site_config("allow_reads_during_maintenance", 1)
cls.addClassCleanup(update_site_config, "maintenance_mode", 0)
update_site_config("maintenance_mode", 1)
def test_reads(self):
response = self.get(self.resource_path("ToDo"), {"sid": self.sid})
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.json, dict)
self.assertIsInstance(response.json["data"], list)
def test_blocked_writes_v2(self):
with suppress_stdout():
response = self.post(
self.resource_path("ToDo"), {"description": frappe.mock("paragraph"), "sid": self.sid}
)
self.assertEqual(response.status_code, 503)
self.assertEqual(response.json["errors"][0]["type"], "InReadOnlyMode")
def generate_admin_keys():
from frappe.core.doctype.user.user import generate_keys
generate_keys("Administrator")
frappe.db.commit()
@frappe.whitelist()
def test(*, fail=False, handled=True, message="Failed"):
if fail:
if handled:
frappe.throw(message)
else:
1 / 0
else:
frappe.msgprint(message)

View file

@ -161,9 +161,7 @@ class TestAuth(FrappeTestCase):
class TestLoginAttemptTracker(FrappeTestCase):
def test_account_lock(self):
"""Make sure that account locks after `n consecutive failures"""
tracker = LoginAttemptTracker(
user_name="tester", max_consecutive_login_attempts=3, lock_interval=60
)
tracker = LoginAttemptTracker("tester", max_consecutive_login_attempts=3, lock_interval=60)
# Clear the cache by setting attempt as success
tracker.add_success_attempt()
@ -183,7 +181,7 @@ class TestLoginAttemptTracker(FrappeTestCase):
"""Make sure that locked account gets unlocked after lock_interval of time."""
lock_interval = 2 # In sec
tracker = LoginAttemptTracker(
user_name="tester", max_consecutive_login_attempts=1, lock_interval=lock_interval
"tester", max_consecutive_login_attempts=1, lock_interval=lock_interval
)
# Clear the cache by setting attempt as success
tracker.add_success_attempt()

Some files were not shown because too many files have changed in this diff Show more