refactor: Use werkzeug router for API routing
This commit is contained in:
parent
c7338f5a83
commit
5af6624cce
4 changed files with 291 additions and 307 deletions
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)()
|
||||
157
frappe/api/__init__.py
Normal file
157
frappe/api/__init__.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
import base64
|
||||
import binascii
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from werkzeug.exceptions import NotFound
|
||||
from werkzeug.routing import Map
|
||||
from werkzeug.wrappers import Request
|
||||
|
||||
import frappe
|
||||
import frappe.client
|
||||
import frappe.handler
|
||||
from frappe import _
|
||||
from frappe.utils.response import build_response
|
||||
|
||||
|
||||
def handle(request: Request):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
try:
|
||||
endpoint, arguments = API_URL_MAP.bind_to_environ(request.environ).match()
|
||||
except NotFound: # Wrap 404 - backward compatiblity
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
endpoint(**arguments)
|
||||
return build_response("json")
|
||||
|
||||
|
||||
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)()
|
||||
|
||||
|
||||
# Merge all API version routing rules
|
||||
from frappe.api.v1 import url_rules as v1_rules
|
||||
|
||||
url_rules = [
|
||||
*v1_rules,
|
||||
]
|
||||
|
||||
API_URL_MAP = Map(url_rules)
|
||||
133
frappe/api/v1.py
Normal file
133
frappe/api/v1.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import json
|
||||
|
||||
from werkzeug.routing import Rule
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils.data import sbool
|
||||
|
||||
|
||||
def handle_rpc_call(method: str):
|
||||
# TODO: inline this weird circular calls
|
||||
frappe.local.form_dict.cmd = method
|
||||
return frappe.handler.handle()
|
||||
|
||||
|
||||
def get_doc_list(doctype: str):
|
||||
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, doctype, **frappe.local.form_dict)
|
||||
|
||||
# set frappe.get_list result to response
|
||||
frappe.local.response.update({"data": data})
|
||||
|
||||
|
||||
def create_doc(doctype: str):
|
||||
data = get_request_form_data()
|
||||
data.update({"doctype": 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 read_doc(doctype: str, name: str):
|
||||
# Backward compatiblity
|
||||
if "run_method" in frappe.local.form_dict:
|
||||
execute_doc_method(doctype, name)
|
||||
return
|
||||
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
if not doc.has_permission("read"):
|
||||
raise frappe.PermissionError
|
||||
doc.apply_fieldlevel_read_permissions()
|
||||
frappe.local.response.update({"data": doc})
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
# 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(doctype: str, name: str):
|
||||
# Not checking permissions here because it's checked in delete_doc
|
||||
frappe.delete_doc(doctype, name, ignore_missing=False)
|
||||
frappe.local.response.http_status_code = 202
|
||||
frappe.local.response.message = "ok"
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
def execute_doc_method(doctype: str, name: str, method: str | None = None):
|
||||
method = method or frappe.local.form_dict.pop("run_method")
|
||||
doc = frappe.get_doc(doctype, 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_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
|
||||
|
||||
|
||||
url_rules = [
|
||||
Rule("/api/method/<string:method>", endpoint=handle_rpc_call),
|
||||
Rule("/api/resource/<string:doctype>", methods=["GET"], endpoint=get_doc_list),
|
||||
Rule("/api/resource/<string:doctype>", methods=["POST"], endpoint=create_doc),
|
||||
Rule("/api/resource/<string:doctype>/<string:name>", methods=["GET"], endpoint=read_doc),
|
||||
Rule("/api/resource/<string:doctype>/<string:name>", methods=["PUT"], endpoint=update_doc),
|
||||
Rule("/api/resource/<string:doctype>/<string:name>", methods=["DELETE"], endpoint=delete_doc),
|
||||
Rule(
|
||||
"/api/resource/<string:doctype>/<string:name>", methods=["POST"], endpoint=execute_doc_method
|
||||
),
|
||||
]
|
||||
|
|
@ -106,7 +106,7 @@ def application(request: Request):
|
|||
response = frappe.handler.handle()
|
||||
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue