543 lines
13 KiB
Python
543 lines
13 KiB
Python
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
|
# MIT License. See license.txt
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import typing
|
|
|
|
import jingrow
|
|
from frappe.client import set_value as _set_value
|
|
from frappe.handler import run_pg_method as _run_pg_method
|
|
from frappe.model import child_table_fields, default_fields
|
|
from frappe.model.base_document import get_controller
|
|
from frappe.utils import cstr
|
|
from pypika.queries import QueryBuilder
|
|
|
|
from press.access import dashboard_access_rules
|
|
from press.access.support_access import has_support_access
|
|
from press.exceptions import TeamHeaderNotInRequestError
|
|
from press.guards import role_guard
|
|
from press.utils import has_role
|
|
|
|
if typing.TYPE_CHECKING:
|
|
from frappe.model.meta import Meta
|
|
|
|
ALLOWED_DOCTYPES = [
|
|
"Site",
|
|
"Site App",
|
|
"Site Domain",
|
|
"Site Backup",
|
|
"Site Activity",
|
|
"Server Activity",
|
|
"Site Config",
|
|
"Site Plan",
|
|
"Site Update",
|
|
"Site Group Deploy",
|
|
"Invoice",
|
|
"Balance Transaction",
|
|
"Stripe Payment Method",
|
|
"Bench",
|
|
"Bench App",
|
|
"Bench Dependency Version",
|
|
"Release Group",
|
|
"Release Group App",
|
|
"Release Group Dependency",
|
|
"Cluster",
|
|
"Press Permission Group",
|
|
"Press Role",
|
|
"Team",
|
|
"Product Trial Request",
|
|
"Deploy Candidate",
|
|
"Deploy Candidate Difference",
|
|
"Deploy Candidate Difference App",
|
|
"Agent Job",
|
|
"Agent Job Type",
|
|
"Common Site Config",
|
|
"Server",
|
|
"Database Server",
|
|
"Ansible Play",
|
|
"Server Plan",
|
|
"Release Group Variable",
|
|
"Resource Tag",
|
|
"Press Tag",
|
|
"Partner Approval Request",
|
|
"Marketplace App",
|
|
"Subscription",
|
|
"Marketplace App Version",
|
|
"Marketplace App Plan",
|
|
"App Release",
|
|
"Payout Order",
|
|
"App Patch",
|
|
"Product Trial",
|
|
"Press Notification",
|
|
"User SSH Key",
|
|
"Frappe Version",
|
|
"Dashboard Banner",
|
|
"App Release Approval Request",
|
|
"Press Webhook",
|
|
"SQL Playground Log",
|
|
"Site Database User",
|
|
"Press Settings",
|
|
"Mpesa Payment Record",
|
|
"Partner Certificate",
|
|
"Partner Payment Payout",
|
|
"Deploy Candidate Build",
|
|
"Partner Lead",
|
|
"Partner Lead Type",
|
|
"Lead Followup",
|
|
"Partner Consent",
|
|
"Account Request",
|
|
"Server Snapshot",
|
|
"Server Snapshot Recovery",
|
|
"Support Access",
|
|
"Partner Lead Origin",
|
|
"Auto Scale Record",
|
|
]
|
|
|
|
whitelisted_methods = set()
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_list(
|
|
doctype: str,
|
|
fields: list | None = None,
|
|
filters: dict | None = None,
|
|
order_by: str | None = None,
|
|
start: int = 0,
|
|
limit: int = 20,
|
|
parent: str | None = None,
|
|
debug: bool = False,
|
|
):
|
|
if filters is None:
|
|
filters = {}
|
|
|
|
# these doctypes doesn't have a team field to filter by but are used in get or run_pg_method
|
|
if doctype in ["Team", "User SSH Key"]:
|
|
return []
|
|
|
|
check_permissions(doctype)
|
|
valid_fields = validate_fields(doctype, fields)
|
|
valid_filters = validate_filters(doctype, filters)
|
|
|
|
meta = frappe.get_meta(doctype)
|
|
if meta.istable and not (filters.get("parenttype") and filters.get("parent")):
|
|
frappe.throw("parenttype and parent are required to get child records")
|
|
|
|
apply_team_filter = not (
|
|
filters.get("skip_team_filter_for_system_user_and_support_agent")
|
|
and (frappe.local.system_user() or has_role("Press Support Agent"))
|
|
)
|
|
if apply_team_filter and meta.has_field("team"):
|
|
valid_filters.team = frappe.local.team().name
|
|
|
|
query = get_list_query(
|
|
doctype,
|
|
meta,
|
|
filters,
|
|
valid_filters,
|
|
valid_fields,
|
|
start,
|
|
limit,
|
|
order_by,
|
|
)
|
|
filters = frappe._dict(filters or {})
|
|
list_args = dict(
|
|
fields=fields,
|
|
filters=filters,
|
|
order_by=order_by,
|
|
start=start,
|
|
limit=limit,
|
|
parent=parent,
|
|
debug=debug,
|
|
)
|
|
query = apply_custom_filters(doctype, query, **list_args)
|
|
if isinstance(query, QueryBuilder):
|
|
return query.run(as_dict=1, debug=debug)
|
|
|
|
if isinstance(query, list):
|
|
return query
|
|
|
|
return []
|
|
|
|
|
|
@role_guard.document(
|
|
document_type=lambda args: str(args.get("doctype")),
|
|
should_throw=False,
|
|
inject_values=True,
|
|
injection_key="document_options",
|
|
)
|
|
def get_list_query(
|
|
doctype: str,
|
|
meta: "Meta",
|
|
filters: dict,
|
|
valid_filters: frappe._dict,
|
|
valid_fields: list | None,
|
|
start: int,
|
|
limit: int,
|
|
order_by: str | None,
|
|
document_options=None,
|
|
):
|
|
query = frappe.qb.get_query(
|
|
doctype, filters=valid_filters, fields=valid_fields, offset=start, limit=limit, order_by=order_by
|
|
)
|
|
|
|
if meta.istable and frappe.get_meta(filters.get("parenttype")).has_field("team"):
|
|
ParentDocType = frappe.qb.DocType(filters.get("parenttype"))
|
|
ChildDocType = frappe.qb.DocType(doctype)
|
|
|
|
query = (
|
|
query.join(ParentDocType)
|
|
.on(ParentDocType.name == ChildDocType.parent)
|
|
.where(ParentDocType.team == frappe.local.team().name)
|
|
)
|
|
|
|
if document_options and isinstance(document_options, list):
|
|
QueryDoctype = frappe.qb.DocType(doctype)
|
|
query = query.where(QueryDoctype.name.isin(document_options))
|
|
|
|
return query
|
|
|
|
|
|
@frappe.whitelist()
|
|
@role_guard.document(
|
|
document_type=lambda args: str(args.get("doctype")),
|
|
document_name=lambda args: str(args.get("name")),
|
|
)
|
|
def get(doctype, name):
|
|
check_permissions(doctype)
|
|
try:
|
|
pg = frappe.get_pg(doctype, name)
|
|
except frappe.DoesNotExistError:
|
|
controller = get_controller(doctype)
|
|
if hasattr(controller, "on_not_found"):
|
|
return controller.on_not_found(name)
|
|
raise
|
|
|
|
if (
|
|
not (frappe.local.system_user() or has_support_access(doctype, name))
|
|
and frappe.get_meta(doctype).has_field("team")
|
|
and pg.team != frappe.local.team().name
|
|
):
|
|
raise_not_permitted()
|
|
|
|
fields = tuple(default_fields)
|
|
if hasattr(pg, "dashboard_fields"):
|
|
fields += tuple(pg.dashboard_fields)
|
|
|
|
_pg = frappe._dict()
|
|
for fieldname in fields:
|
|
_pg[fieldname] = pg.get(fieldname)
|
|
|
|
if hasattr(pg, "get_pg"):
|
|
result = pg.get_pg(_pg)
|
|
if isinstance(result, dict):
|
|
_pg.update(result)
|
|
|
|
return dashboard_access_rules(_pg)
|
|
|
|
|
|
@frappe.whitelist(methods=["POST", "PUT"])
|
|
def insert(pg=None):
|
|
if not pg or not pg.get("doctype"):
|
|
frappe.throw(frappe._("pg.doctype is required"))
|
|
|
|
check_permissions(pg.get("doctype"))
|
|
|
|
pg = frappe._dict(pg)
|
|
if frappe.is_table(pg.doctype):
|
|
if not (pg.parenttype and pg.parent and pg.parentfield):
|
|
frappe.throw(frappe._("Parenttype, Parent and Parentfield are required to insert a child record"))
|
|
|
|
# inserting a child record
|
|
parent = frappe.get_pg(pg.parenttype, pg.parent)
|
|
|
|
if frappe.get_meta(parent.doctype).has_field("team") and parent.team != frappe.local.team().name:
|
|
raise_not_permitted()
|
|
|
|
parent.append(pg.parentfield, pg)
|
|
parent.save()
|
|
return get(parent.doctype, parent.name)
|
|
|
|
_pg = frappe.get_pg(pg)
|
|
|
|
if frappe.get_meta(pg.doctype).has_field("team"):
|
|
if not _pg.team:
|
|
# set team if not set
|
|
_pg.team = frappe.local.team().name
|
|
if not frappe.local.system_user():
|
|
# don't allow dashboard user to set any other team
|
|
_pg.team = frappe.local.team().name
|
|
_pg.insert()
|
|
return get(_pg.doctype, _pg.name)
|
|
|
|
|
|
@frappe.whitelist(methods=["POST", "PUT"])
|
|
def set_value(doctype: str, name: str, fieldname: dict | str, value: str | None = None):
|
|
check_permissions(doctype)
|
|
check_document_access(doctype, name)
|
|
|
|
for field in fieldname:
|
|
# fields mentioned in dashboard_fields are allowed to be set via set_value
|
|
is_allowed_field(doctype, field)
|
|
|
|
_set_value(doctype, name, fieldname, value)
|
|
|
|
# frappe set_value returns just the pg and not press's overriden `get_pg`
|
|
return get(doctype, name)
|
|
|
|
|
|
@frappe.whitelist(methods=["DELETE", "POST"])
|
|
def delete(doctype: str, name: str):
|
|
method = "delete"
|
|
|
|
check_permissions(doctype)
|
|
check_document_access(doctype, name)
|
|
check_dashboard_actions(doctype, name, method)
|
|
|
|
_run_pg_method(dt=doctype, dn=name, method=method, args=None)
|
|
|
|
|
|
@frappe.whitelist()
|
|
def run_pg_method(dt: str, dn: str, method: str, args: dict | None = None):
|
|
check_permissions(dt)
|
|
check_document_access(dt, dn)
|
|
check_dashboard_actions(dt, dn, method)
|
|
|
|
_run_pg_method(
|
|
dt=dt,
|
|
dn=dn,
|
|
method=method,
|
|
args=fix_args(method, args),
|
|
)
|
|
|
|
frappe.response.docs = [get(dt, dn)]
|
|
|
|
|
|
@frappe.whitelist()
|
|
def search_link(
|
|
doctype: str,
|
|
query: str | None = None,
|
|
filters: dict | None = None,
|
|
order_by: str | None = None,
|
|
page_length: int | None = None,
|
|
):
|
|
check_permissions(doctype)
|
|
if doctype == "Team" and not frappe.local.system_user():
|
|
raise_not_permitted()
|
|
|
|
meta = frappe.get_meta(doctype)
|
|
DocType = frappe.qb.DocType(doctype)
|
|
valid_filters = validate_filters(doctype, filters)
|
|
valid_fields = validate_fields(doctype, ["name", meta.title_field or "name"])
|
|
q = get_list_query(
|
|
doctype,
|
|
meta,
|
|
filters,
|
|
valid_filters,
|
|
valid_fields,
|
|
0,
|
|
page_length or 10,
|
|
order_by or "modified desc",
|
|
)
|
|
q = q.select(DocType.name.as_("value"))
|
|
if meta.title_field:
|
|
q = q.select(DocType[meta.title_field].as_("label"))
|
|
if meta.has_field("enabled"):
|
|
q = q.where(DocType.enabled == 1)
|
|
if meta.has_field("disabled"):
|
|
q = q.where(DocType.disabled != 1)
|
|
if meta.has_field("team") and (not frappe.local.system_user() or 1):
|
|
q = q.where(DocType.team == frappe.local.team().name)
|
|
if query:
|
|
condition = DocType.name.like(f"%{query}%")
|
|
if meta.title_field:
|
|
condition = condition | DocType[meta.title_field].like(f"%{query}%")
|
|
q = q.where(condition)
|
|
return q.run(as_dict=1)
|
|
|
|
|
|
def check_document_access(doctype: str, name: str):
|
|
if frappe.local.system_user():
|
|
return
|
|
|
|
team = ""
|
|
meta = frappe.get_meta(doctype)
|
|
if meta.has_field("team"):
|
|
team = frappe.db.get_value(doctype, name, "team")
|
|
elif meta.has_field("bench"):
|
|
bench = frappe.db.get_value(doctype, name, "bench")
|
|
team = frappe.db.get_value("Bench", bench, "team")
|
|
elif meta.has_field("group"):
|
|
group = frappe.db.get_value(doctype, name, "group")
|
|
team = frappe.db.get_value("Release Group", group, "team")
|
|
else:
|
|
return
|
|
|
|
if team == frappe.local.team().name:
|
|
return
|
|
|
|
if has_support_access(doctype, name):
|
|
return
|
|
|
|
raise_not_permitted()
|
|
|
|
|
|
def check_dashboard_actions(doctype, name, method):
|
|
pg = frappe.get_pg(doctype, name)
|
|
method_obj = getattr(pg, method)
|
|
fn = getattr(method_obj, "__func__", method_obj)
|
|
|
|
if fn not in whitelisted_methods:
|
|
raise_not_permitted()
|
|
|
|
|
|
def apply_custom_filters(doctype, query, **list_args):
|
|
"""Apply custom filters to query"""
|
|
controller = get_controller(doctype)
|
|
if hasattr(controller, "get_list_query"):
|
|
if inspect.getfullargspec(controller.get_list_query).varkw:
|
|
return controller.get_list_query(query, **list_args)
|
|
return controller.get_list_query(query)
|
|
|
|
return query
|
|
|
|
|
|
def validate_filters(doctype, filters):
|
|
"""Filter filters based on permissions"""
|
|
if not filters:
|
|
filters = {}
|
|
|
|
out = frappe._dict()
|
|
for fieldname, value in filters.items():
|
|
if is_allowed_field(doctype, fieldname):
|
|
out[fieldname] = value
|
|
|
|
return out
|
|
|
|
|
|
def validate_fields(doctype, fields):
|
|
"""Filter fields based on permissions"""
|
|
if not fields:
|
|
return fields
|
|
|
|
filtered_fields = []
|
|
for field in fields:
|
|
if is_allowed_field(doctype, field):
|
|
filtered_fields.append(field)
|
|
|
|
return filtered_fields
|
|
|
|
|
|
def is_allowed_field(doctype, field):
|
|
"""Check if field is valid"""
|
|
if not field:
|
|
return False
|
|
|
|
controller = get_controller(doctype)
|
|
dashboard_fields = getattr(controller, "dashboard_fields", ())
|
|
|
|
if field in dashboard_fields:
|
|
return True
|
|
|
|
if "." in field and is_allowed_linked_field(doctype, field):
|
|
return True
|
|
|
|
if isinstance(field, dict) and is_allowed_table_field(doctype, field):
|
|
return True
|
|
|
|
if field in [*default_fields, *child_table_fields]:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def is_allowed_linked_field(doctype, field):
|
|
linked_field = linked_field_fieldname = None
|
|
if " as " in field:
|
|
linked_field, _ = field.split(" as ")
|
|
else:
|
|
linked_field = field
|
|
|
|
linked_field, linked_field_fieldname = linked_field.split(".")
|
|
if not is_allowed_field(doctype, linked_field):
|
|
return False
|
|
|
|
linked_field_doctype = frappe.get_meta(doctype).get_field(linked_field).options
|
|
if not is_allowed_field(linked_field_doctype, linked_field_fieldname):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def is_allowed_table_field(doctype, field):
|
|
for table_fieldname, table_fields in field.items():
|
|
if not is_allowed_field(doctype, table_fieldname):
|
|
return False
|
|
|
|
table_doctype = frappe.get_meta(doctype).get_field(table_fieldname).options
|
|
for table_field in table_fields:
|
|
if not is_allowed_field(table_doctype, table_field):
|
|
return False
|
|
return True
|
|
|
|
|
|
def check_permissions(doctype):
|
|
if doctype not in ALLOWED_DOCTYPES:
|
|
raise_not_permitted()
|
|
|
|
if not hasattr(frappe.local, "team") or not frappe.local.team():
|
|
frappe.throw(
|
|
"current_team is not set. Use X-PRESS-TEAM header in the request to set it.",
|
|
TeamHeaderNotInRequestError,
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
def is_owned_by_team(doctype, docname, raise_exception=True):
|
|
if not frappe.local.team():
|
|
return False
|
|
|
|
docname = cstr(docname)
|
|
owned = frappe.db.get_value(doctype, docname, "team") == frappe.local.team().name
|
|
if not owned and raise_exception:
|
|
raise_not_permitted()
|
|
return owned
|
|
|
|
|
|
def raise_not_permitted():
|
|
frappe.throw("Not permitted", frappe.PermissionError)
|
|
|
|
|
|
def dashboard_whitelist(allow_guest=False, xss_safe=False, methods=None):
|
|
def wrapper(func):
|
|
global whitelisted_methods
|
|
|
|
decorated_func = frappe.whitelist(allow_guest=allow_guest, xss_safe=xss_safe, methods=methods)(func)
|
|
|
|
def inner(*args, **kwargs):
|
|
return decorated_func(*args, **kwargs)
|
|
|
|
whitelisted_methods.add(decorated_func)
|
|
return decorated_func
|
|
|
|
return wrapper
|
|
|
|
|
|
def fix_args(method, args):
|
|
# This is a fixer function. Certain callers of `run_pg_method`
|
|
# pass duplicates of the passed kwargs in the `args` arg.
|
|
#
|
|
# This causes "got multiple values for argument 'method'"
|
|
if not isinstance(args, dict):
|
|
return args
|
|
|
|
# Even if it doesn't match it'll probably throw
|
|
# down the call stack, but in that case it's unexpected
|
|
# behavior and so it's better to error-out.
|
|
if args.get("method") == method:
|
|
del args["method"]
|
|
|
|
return args
|