Merge branch 'develop' into fix-attach-before-doc-save
This commit is contained in:
commit
409a3a8105
120 changed files with 2008 additions and 999 deletions
23
.coveragerc
Normal file
23
.coveragerc
Normal 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
|
||||
20
.github/workflows/initiate_release.yml
vendored
20
.github/workflows/initiate_release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
4
.github/workflows/server-tests.yml
vendored
4
.github/workflows/server-tests.yml
vendored
|
|
@ -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() }}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
306
frappe/api.py
306
frappe/api.py
|
|
@ -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
80
frappe/api/__init__.py
Normal 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
0
frappe/api/utils.py
Normal file
118
frappe/api/v1.py
Normal file
118
frappe/api/v1.py
Normal 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
193
frappe/api/v2.py
Normal 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),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
144
frappe/auth.py
144
frappe/auth.py
|
|
@ -1,6 +1,8 @@
|
|||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See LICENSE
|
||||
from urllib.parse import quote
|
||||
import base64
|
||||
import binascii
|
||||
from urllib.parse import quote, urlencode, urlparse
|
||||
|
||||
import frappe
|
||||
import frappe.database
|
||||
|
|
@ -17,6 +19,7 @@ from frappe.twofactor import (
|
|||
should_run_2fa,
|
||||
)
|
||||
from frappe.utils import cint, date_diff, datetime, get_datetime, today
|
||||
from frappe.utils.deprecations import deprecation_warning
|
||||
from frappe.utils.password import check_password
|
||||
from frappe.website.utils import get_home_page
|
||||
|
||||
|
|
@ -235,23 +238,28 @@ class LoginManager:
|
|||
_raw_user_name = user
|
||||
user = User.find_by_credentials(user, pwd)
|
||||
|
||||
ip_tracker = get_login_attempt_tracker(frappe.local.request_ip)
|
||||
if not user:
|
||||
ip_tracker and ip_tracker.add_failure_attempt()
|
||||
self.fail("Invalid login credentials", user=_raw_user_name)
|
||||
|
||||
# Current login flow uses cached credentials for authentication while checking OTP.
|
||||
# Incase of OTP check, tracker for auth needs to be disabled(If not, it can remove tracker history as it is going to succeed anyway)
|
||||
# Tracker is activated for 2FA incase of OTP.
|
||||
ignore_tracker = should_run_2fa(user.name) and ("otp" in frappe.form_dict)
|
||||
tracker = None if ignore_tracker else get_login_attempt_tracker(user.name)
|
||||
user_tracker = None if ignore_tracker else get_login_attempt_tracker(user.name)
|
||||
|
||||
if not user.is_authenticated:
|
||||
tracker and tracker.add_failure_attempt()
|
||||
user_tracker and user_tracker.add_failure_attempt()
|
||||
ip_tracker and ip_tracker.add_failure_attempt()
|
||||
self.fail("Invalid login credentials", user=user.name)
|
||||
elif not (user.name == "Administrator" or user.enabled):
|
||||
tracker and tracker.add_failure_attempt()
|
||||
user_tracker and user_tracker.add_failure_attempt()
|
||||
ip_tracker and ip_tracker.add_failure_attempt()
|
||||
self.fail("User disabled or missing", user=user.name)
|
||||
else:
|
||||
tracker and tracker.add_success_attempt()
|
||||
user_tracker and user_tracker.add_success_attempt()
|
||||
ip_tracker and ip_tracker.add_success_attempt()
|
||||
self.user = user.name
|
||||
|
||||
def force_user_to_reset_password(self):
|
||||
|
|
@ -433,7 +441,7 @@ def validate_ip_address(user):
|
|||
frappe.throw(_("Access not allowed from this IP Address"), frappe.AuthenticationError)
|
||||
|
||||
|
||||
def get_login_attempt_tracker(user_name: str, raise_locked_exception: bool = True):
|
||||
def get_login_attempt_tracker(key: str, raise_locked_exception: bool = True):
|
||||
"""Get login attempt tracker instance.
|
||||
|
||||
:param user_name: Name of the loggedin user
|
||||
|
|
@ -447,7 +455,7 @@ def get_login_attempt_tracker(user_name: str, raise_locked_exception: bool = Tru
|
|||
tracker_kwargs["lock_interval"] = sys_settings.allow_login_after_fail
|
||||
tracker_kwargs["max_consecutive_login_attempts"] = sys_settings.allow_consecutive_login_attempts
|
||||
|
||||
tracker = LoginAttemptTracker(user_name, **tracker_kwargs)
|
||||
tracker = LoginAttemptTracker(key, **tracker_kwargs)
|
||||
|
||||
if raise_locked_exception and track_login_attempts and not tracker.is_user_allowed():
|
||||
frappe.throw(
|
||||
|
|
@ -466,7 +474,12 @@ class LoginAttemptTracker:
|
|||
"""
|
||||
|
||||
def __init__(
|
||||
self, user_name: str, max_consecutive_login_attempts: int = 3, lock_interval: int = 5 * 60
|
||||
self,
|
||||
key: str,
|
||||
max_consecutive_login_attempts: int = 3,
|
||||
lock_interval: int = 5 * 60,
|
||||
*,
|
||||
user_name: str = None,
|
||||
):
|
||||
"""Initialize the tracker.
|
||||
|
||||
|
|
@ -474,21 +487,23 @@ class LoginAttemptTracker:
|
|||
:param max_consecutive_login_attempts: Maximum allowed consecutive failed login attempts
|
||||
:param lock_interval: Locking interval incase of maximum failed attempts
|
||||
"""
|
||||
self.user_name = user_name
|
||||
if user_name:
|
||||
deprecation_warning("`username` parameter is deprecated, use `key` instead.")
|
||||
self.key = key or user_name
|
||||
self.lock_interval = datetime.timedelta(seconds=lock_interval)
|
||||
self.max_failed_logins = max_consecutive_login_attempts
|
||||
|
||||
@property
|
||||
def login_failed_count(self):
|
||||
return frappe.cache.hget("login_failed_count", self.user_name)
|
||||
return frappe.cache.hget("login_failed_count", self.key)
|
||||
|
||||
@login_failed_count.setter
|
||||
def login_failed_count(self, count):
|
||||
frappe.cache.hset("login_failed_count", self.user_name, count)
|
||||
frappe.cache.hset("login_failed_count", self.key, count)
|
||||
|
||||
@login_failed_count.deleter
|
||||
def login_failed_count(self):
|
||||
frappe.cache.hdel("login_failed_count", self.user_name)
|
||||
frappe.cache.hdel("login_failed_count", self.key)
|
||||
|
||||
@property
|
||||
def login_failed_time(self):
|
||||
|
|
@ -496,15 +511,15 @@ class LoginAttemptTracker:
|
|||
|
||||
For every user we track only First failed login attempt time within lock interval of time.
|
||||
"""
|
||||
return frappe.cache.hget("login_failed_time", self.user_name)
|
||||
return frappe.cache.hget("login_failed_time", self.key)
|
||||
|
||||
@login_failed_time.setter
|
||||
def login_failed_time(self, timestamp):
|
||||
frappe.cache.hset("login_failed_time", self.user_name, timestamp)
|
||||
frappe.cache.hset("login_failed_time", self.key, timestamp)
|
||||
|
||||
@login_failed_time.deleter
|
||||
def login_failed_time(self):
|
||||
frappe.cache.hdel("login_failed_time", self.user_name)
|
||||
frappe.cache.hdel("login_failed_time", self.key)
|
||||
|
||||
def add_failure_attempt(self):
|
||||
"""Log user failure attempts into the system.
|
||||
|
|
@ -547,3 +562,102 @@ class LoginAttemptTracker:
|
|||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_auth():
|
||||
"""
|
||||
Authenticate and sets user for the request.
|
||||
"""
|
||||
authorization_header = frappe.get_request_header("Authorization", "").split(" ")
|
||||
|
||||
if len(authorization_header) == 2:
|
||||
validate_oauth(authorization_header)
|
||||
validate_auth_via_api_keys(authorization_header)
|
||||
|
||||
validate_auth_via_hooks()
|
||||
|
||||
|
||||
def validate_oauth(authorization_header):
|
||||
"""
|
||||
Authenticate request using OAuth and set session user
|
||||
|
||||
Args:
|
||||
authorization_header (list of str): The 'Authorization' header containing the prefix and token
|
||||
"""
|
||||
|
||||
from frappe.integrations.oauth2 import get_oauth_server
|
||||
from frappe.oauth import get_url_delimiter
|
||||
|
||||
form_dict = frappe.local.form_dict
|
||||
token = authorization_header[1]
|
||||
req = frappe.request
|
||||
parsed_url = urlparse(req.url)
|
||||
access_token = {"access_token": token}
|
||||
uri = (
|
||||
parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + "?" + urlencode(access_token)
|
||||
)
|
||||
http_method = req.method
|
||||
headers = req.headers
|
||||
body = req.get_data()
|
||||
if req.content_type and "multipart/form-data" in req.content_type:
|
||||
body = None
|
||||
|
||||
try:
|
||||
required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split(
|
||||
get_url_delimiter()
|
||||
)
|
||||
valid, oauthlib_request = get_oauth_server().verify_request(
|
||||
uri, http_method, body, headers, required_scopes
|
||||
)
|
||||
if valid:
|
||||
frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user"))
|
||||
frappe.local.form_dict = form_dict
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
def validate_auth_via_api_keys(authorization_header):
|
||||
"""
|
||||
Authenticate request using API keys and set session user
|
||||
|
||||
Args:
|
||||
authorization_header (list of str): The 'Authorization' header containing the prefix and token
|
||||
"""
|
||||
|
||||
try:
|
||||
auth_type, auth_token = authorization_header
|
||||
authorization_source = frappe.get_request_header("Frappe-Authorization-Source")
|
||||
if auth_type.lower() == "basic":
|
||||
api_key, api_secret = frappe.safe_decode(base64.b64decode(auth_token)).split(":")
|
||||
validate_api_key_secret(api_key, api_secret, authorization_source)
|
||||
elif auth_type.lower() == "token":
|
||||
api_key, api_secret = auth_token.split(":")
|
||||
validate_api_key_secret(api_key, api_secret, authorization_source)
|
||||
except binascii.Error:
|
||||
frappe.throw(
|
||||
_("Failed to decode token, please provide a valid base64-encoded token."),
|
||||
frappe.InvalidAuthorizationToken,
|
||||
)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
|
||||
"""frappe_authorization_source to provide api key and secret for a doctype apart from User"""
|
||||
doctype = frappe_authorization_source or "User"
|
||||
doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"])
|
||||
form_dict = frappe.local.form_dict
|
||||
doc_secret = frappe.utils.password.get_decrypted_password(doctype, doc, fieldname="api_secret")
|
||||
if api_secret == doc_secret:
|
||||
if doctype == "User":
|
||||
user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"])
|
||||
else:
|
||||
user = frappe.db.get_value(doctype, doc, "user")
|
||||
if frappe.local.login_manager.user in ("", "Guest"):
|
||||
frappe.set_user(user)
|
||||
frappe.local.form_dict = form_dict
|
||||
|
||||
|
||||
def validate_auth_via_hooks():
|
||||
for auth_hook in frappe.get_hooks("auth_hooks", []):
|
||||
frappe.get_attr(auth_hook)()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>"
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ frappe.db = {
|
|||
filters,
|
||||
},
|
||||
callback(r) {
|
||||
resolve(r.results);
|
||||
resolve(r.message);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>')
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,11 +95,6 @@
|
|||
.standard-image {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.avatar-empty::after {
|
||||
content: "\002D";
|
||||
line-height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-medium {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
297
frappe/tests/test_api_v2.py
Normal 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)
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue