icedcoffee-sync 0.1.0.dev5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- icedcoffee_sync/__init__.py +3 -0
- icedcoffee_sync/apply/__init__.py +6 -0
- icedcoffee_sync/apply/diff.py +522 -0
- icedcoffee_sync/apply/execute.py +180 -0
- icedcoffee_sync/apply/model.py +45 -0
- icedcoffee_sync/apply/order.py +129 -0
- icedcoffee_sync/apply/park.py +107 -0
- icedcoffee_sync/apply/prune.py +215 -0
- icedcoffee_sync/erpnext/__init__.py +1 -0
- icedcoffee_sync/erpnext/accounts.py +183 -0
- icedcoffee_sync/erpnext/comment.py +62 -0
- icedcoffee_sync/erpnext/company.py +102 -0
- icedcoffee_sync/erpnext/documents.py +179 -0
- icedcoffee_sync/erpnext/ensure.py +34 -0
- icedcoffee_sync/erpnext/purchase.py +260 -0
- icedcoffee_sync/erpnext/webhook.py +137 -0
- icedcoffee_sync/frappe/__init__.py +0 -0
- icedcoffee_sync/frappe/access.py +379 -0
- icedcoffee_sync/frappe/client.py +539 -0
- icedcoffee_sync/frappe/errors.py +201 -0
- icedcoffee_sync/frappe/lifecycle.py +30 -0
- icedcoffee_sync/frappe/request.py +145 -0
- icedcoffee_sync/frappe/response.py +76 -0
- icedcoffee_sync/py.typed +0 -0
- icedcoffee_sync/site.py +65 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/METADATA +9 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/RECORD +29 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/WHEEL +4 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Provision Frappe roles, permissions, and users -- generically.
|
|
2
|
+
|
|
3
|
+
The client-side submit guard (`SiteConfig.may_submit`) is a courtesy any
|
|
4
|
+
caller can bypass; the boundary that holds is the API key's role permissions
|
|
5
|
+
on the server. This module builds that boundary -- a role whose flags never
|
|
6
|
+
include submit/cancel/amend, a user holding only that role -- and probes the
|
|
7
|
+
server to prove it. Callers supply doctypes, flags and names as policy.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import dataclasses
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
from icedcoffee_sync.frappe.client import Client, SiteConfig
|
|
16
|
+
from icedcoffee_sync.frappe.errors import DoesNotExistError, FrappeError, PermissionError_, ValidationError
|
|
17
|
+
|
|
18
|
+
# The Custom DocPerm flags a grant may request (Frappe version-15
|
|
19
|
+
# custom_docperm.json). `if_owner` and `select` are deliberately absent: see
|
|
20
|
+
# _ALWAYS_RESET_TO_ZERO.
|
|
21
|
+
PTYPES: frozenset[str] = frozenset({
|
|
22
|
+
"read",
|
|
23
|
+
"write",
|
|
24
|
+
"create",
|
|
25
|
+
"delete",
|
|
26
|
+
"submit",
|
|
27
|
+
"cancel",
|
|
28
|
+
"amend",
|
|
29
|
+
"report",
|
|
30
|
+
"export",
|
|
31
|
+
"import",
|
|
32
|
+
"print",
|
|
33
|
+
"email",
|
|
34
|
+
"share",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
# Flags `grant` always writes as "0": they change what a role's other flags
|
|
38
|
+
# mean (`if_owner=1` narrows every permission to documents the credential
|
|
39
|
+
# created), so a hand edit that set one must not survive a reconcile.
|
|
40
|
+
_ALWAYS_RESET_TO_ZERO: frozenset[str] = frozenset({"if_owner", "select"})
|
|
41
|
+
|
|
42
|
+
# Whitelisted server methods (Frappe version-15). The permission manager's
|
|
43
|
+
# methods and generate_keys require System Manager; generate_keys keeps an
|
|
44
|
+
# existing api_key and rotates api_secret on every call.
|
|
45
|
+
_PERMISSION_MANAGER = "frappe.core.page.permission_manager.permission_manager"
|
|
46
|
+
_GENERATE_KEYS = "frappe.core.doctype.user.user.generate_keys"
|
|
47
|
+
# frappe.client.has_permission(doctype, docname, perm_type) -> {"has_permission": bool}
|
|
48
|
+
_HAS_PERMISSION = "frappe.client.has_permission"
|
|
49
|
+
|
|
50
|
+
# The probe inserts this without its mandatory `accounts` rows, so even if
|
|
51
|
+
# permission passed, mandatory validation would stop anything posting.
|
|
52
|
+
PROBE_DOCTYPE = "Journal Entry"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclasses.dataclass(frozen=True)
|
|
56
|
+
class PermissionGrant:
|
|
57
|
+
"""The flags to turn on for one doctype at one permission level; `grant` writes every other flag as "0"."""
|
|
58
|
+
|
|
59
|
+
doctype: str
|
|
60
|
+
ptypes: frozenset[str]
|
|
61
|
+
permlevel: int = 0
|
|
62
|
+
|
|
63
|
+
def __post_init__(self) -> None:
|
|
64
|
+
unknown = sorted(self.ptypes - PTYPES)
|
|
65
|
+
if unknown:
|
|
66
|
+
raise ValueError(f"not a managed permission type: {', '.join(unknown)}; PTYPES is {sorted(PTYPES)}")
|
|
67
|
+
if self.permlevel < 0:
|
|
68
|
+
raise ValueError(f"permlevel must be 0 or more, not {self.permlevel}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _AccessClient(Protocol):
|
|
72
|
+
"""The slice of `Client` this module needs. Keeps fakes honest in tests."""
|
|
73
|
+
|
|
74
|
+
def get(self, doctype: str, name: str) -> Any: ...
|
|
75
|
+
def insert(self, doctype: str, doc: dict[str, Any]) -> Any: ...
|
|
76
|
+
def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
|
|
77
|
+
def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any: ...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclasses.dataclass(frozen=True)
|
|
81
|
+
class RoleChange:
|
|
82
|
+
"""What `ensure_role` did to reach the requested role, `desk_access`, and `disabled`."""
|
|
83
|
+
|
|
84
|
+
created: bool
|
|
85
|
+
desk_access_repaired: bool
|
|
86
|
+
disabled_repaired: bool = False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ensure_role(client: _AccessClient, role_name: str, *, desk_access: bool = False) -> RoleChange:
|
|
90
|
+
"""Create the role, or repair an existing one's `desk_access` and `disabled`.
|
|
91
|
+
|
|
92
|
+
A hand-disabled or desk-enabled role is drift, not policy, so a re-run
|
|
93
|
+
repairs it the way roles and grants are reconciled.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
existing = client.get("Role", role_name)
|
|
97
|
+
except DoesNotExistError:
|
|
98
|
+
client.insert("Role", {"role_name": role_name, "desk_access": 1 if desk_access else 0})
|
|
99
|
+
return RoleChange(created=True, desk_access_repaired=False)
|
|
100
|
+
|
|
101
|
+
updates: dict[str, Any] = {}
|
|
102
|
+
desk_access_repaired = bool(existing.get("desk_access")) != desk_access
|
|
103
|
+
if desk_access_repaired:
|
|
104
|
+
updates["desk_access"] = 1 if desk_access else 0
|
|
105
|
+
disabled_repaired = bool(existing.get("disabled"))
|
|
106
|
+
if disabled_repaired:
|
|
107
|
+
updates["disabled"] = 0
|
|
108
|
+
if updates:
|
|
109
|
+
client.update("Role", role_name, updates)
|
|
110
|
+
return RoleChange(created=False, desk_access_repaired=desk_access_repaired, disabled_repaired=disabled_repaired)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def grant(client: _AccessClient, role: str, permission_grant: PermissionGrant) -> list[tuple[str, str]]:
|
|
114
|
+
"""Write every managed flag for `role` on the doctype, "1" or "0", explicitly.
|
|
115
|
+
|
|
116
|
+
Explicit zeros are the point: a flag left unmentioned would depend on a
|
|
117
|
+
default this module does not control. Returns the (ptype, value) pairs sent.
|
|
118
|
+
"""
|
|
119
|
+
# `add` is a no-op when the (doctype, role, permlevel) row already exists.
|
|
120
|
+
client.call(
|
|
121
|
+
f"{_PERMISSION_MANAGER}.add",
|
|
122
|
+
{"parent": permission_grant.doctype, "role": role, "permlevel": permission_grant.permlevel},
|
|
123
|
+
)
|
|
124
|
+
sent: list[tuple[str, str]] = []
|
|
125
|
+
for ptype in sorted(PTYPES | _ALWAYS_RESET_TO_ZERO):
|
|
126
|
+
value = "1" if ptype in permission_grant.ptypes else "0"
|
|
127
|
+
client.call(
|
|
128
|
+
f"{_PERMISSION_MANAGER}.update",
|
|
129
|
+
{
|
|
130
|
+
"doctype": permission_grant.doctype,
|
|
131
|
+
"role": role,
|
|
132
|
+
"permlevel": permission_grant.permlevel,
|
|
133
|
+
"ptype": ptype,
|
|
134
|
+
"value": value,
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
sent.append((ptype, value))
|
|
138
|
+
return sent
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def revoke_grants_except(client: _AccessClient, role: str, keep: frozenset[tuple[str, int]]) -> list[str]:
|
|
142
|
+
"""Revoke every permission row `role` holds except rows whose (doctype, permlevel) is in `keep`.
|
|
143
|
+
|
|
144
|
+
Returns a label per revoked row.
|
|
145
|
+
"""
|
|
146
|
+
rows = client.call(f"{_PERMISSION_MANAGER}.get_permissions", {"role": role})
|
|
147
|
+
revoked: list[str] = []
|
|
148
|
+
for row in rows:
|
|
149
|
+
doctype = row["parent"]
|
|
150
|
+
permlevel = row["permlevel"]
|
|
151
|
+
if (doctype, permlevel) in keep:
|
|
152
|
+
continue
|
|
153
|
+
label = doctype if permlevel == 0 else f"{doctype} (permlevel {permlevel})"
|
|
154
|
+
# Frappe refuses to remove a doctype's last permission rule; that
|
|
155
|
+
# needs a person, so it is re-raised naming the row, not swallowed.
|
|
156
|
+
try:
|
|
157
|
+
client.call(
|
|
158
|
+
f"{_PERMISSION_MANAGER}.remove",
|
|
159
|
+
{"doctype": doctype, "role": role, "permlevel": permlevel},
|
|
160
|
+
)
|
|
161
|
+
except FrappeError as exc:
|
|
162
|
+
raise type(exc)(
|
|
163
|
+
f"could not revoke {label!r} from {role!r}: {exc.message}",
|
|
164
|
+
error_type=exc.error_type,
|
|
165
|
+
status=exc.status,
|
|
166
|
+
messages=exc.messages,
|
|
167
|
+
server_traceback=exc.server_traceback,
|
|
168
|
+
) from exc
|
|
169
|
+
revoked.append(label)
|
|
170
|
+
return revoked
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclasses.dataclass(frozen=True)
|
|
174
|
+
class UserChange:
|
|
175
|
+
"""What `ensure_user` did to reach the requested role set."""
|
|
176
|
+
|
|
177
|
+
created: bool
|
|
178
|
+
added_roles: frozenset[str]
|
|
179
|
+
removed_roles: frozenset[str]
|
|
180
|
+
enabled_repaired: bool = False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def ensure_user(client: _AccessClient, email: str, *, first_name: str, roles: frozenset[str]) -> UserChange:
|
|
184
|
+
"""Create the user, or reconcile an existing one's roles and `enabled` to policy.
|
|
185
|
+
|
|
186
|
+
The role set is made exactly `roles`, not merged: a role added by hand is
|
|
187
|
+
a privilege escalation riding on this key. A disabled user is drift for
|
|
188
|
+
the same reason and is re-enabled.
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
existing = client.get("User", email)
|
|
192
|
+
except DoesNotExistError:
|
|
193
|
+
client.insert(
|
|
194
|
+
"User",
|
|
195
|
+
{
|
|
196
|
+
"email": email,
|
|
197
|
+
"first_name": first_name,
|
|
198
|
+
"user_type": "System User",
|
|
199
|
+
"send_welcome_email": 0,
|
|
200
|
+
"enabled": 1,
|
|
201
|
+
"roles": [{"role": role} for role in sorted(roles)],
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
return UserChange(created=True, added_roles=frozenset(roles), removed_roles=frozenset())
|
|
205
|
+
|
|
206
|
+
current = frozenset(row["role"] for row in existing.get("roles", []))
|
|
207
|
+
added = roles - current
|
|
208
|
+
removed = current - roles
|
|
209
|
+
# An absent field means enabled (Frappe's default), not disabled.
|
|
210
|
+
enabled_repaired = not existing.get("enabled", 1)
|
|
211
|
+
updates: dict[str, Any] = {}
|
|
212
|
+
if added or removed:
|
|
213
|
+
updates["roles"] = [{"role": role} for role in sorted(roles)]
|
|
214
|
+
if enabled_repaired:
|
|
215
|
+
updates["enabled"] = 1
|
|
216
|
+
if updates:
|
|
217
|
+
client.update("User", email, updates)
|
|
218
|
+
return UserChange(created=False, added_roles=added, removed_roles=removed, enabled_repaired=enabled_repaired)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclasses.dataclass(frozen=True)
|
|
222
|
+
class ApiKeyPair:
|
|
223
|
+
"""A Frappe API key and secret. Kept out of reprs, like `SiteConfig`."""
|
|
224
|
+
|
|
225
|
+
api_key: str = dataclasses.field(repr=False)
|
|
226
|
+
api_secret: str = dataclasses.field(repr=False)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def generate_keys(client: _AccessClient, user: str) -> ApiKeyPair:
|
|
230
|
+
"""Mint a fresh API secret for `user` (the key is kept); a secret can never be read back."""
|
|
231
|
+
result = client.call(_GENERATE_KEYS, {"user": user})
|
|
232
|
+
return ApiKeyPair(api_key=result["api_key"], api_secret=result["api_secret"])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class OrphanProbeDocumentError(RuntimeError):
|
|
236
|
+
"""The probe's draft was created for real and could not be deleted afterwards.
|
|
237
|
+
|
|
238
|
+
Distinct from a probe that merely failed: a real document now exists
|
|
239
|
+
on the site and the probed credential (never granted delete) cannot
|
|
240
|
+
remove it. Somebody has to.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(self, doctype: str, name: str, cleanup_error: FrappeError) -> None:
|
|
244
|
+
super().__init__(
|
|
245
|
+
f"probe created a real {doctype} document ({name!r}) that could not be deleted: {cleanup_error}"
|
|
246
|
+
)
|
|
247
|
+
self.doctype = doctype
|
|
248
|
+
self.name = name
|
|
249
|
+
self.cleanup_error = cleanup_error
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclasses.dataclass(frozen=True)
|
|
253
|
+
class SubmitProbe:
|
|
254
|
+
"""The outcome of attempting a submit-on-insert with a real credential.
|
|
255
|
+
|
|
256
|
+
`refused` is True only when create access was proven by a real draft
|
|
257
|
+
attempt (`can_create`), the server's per-flag answer says submit is
|
|
258
|
+
denied (`submit_flag`), and the submit-flagged insert raised
|
|
259
|
+
`PermissionError_`. Frappe's refusal text is the same whichever flag
|
|
260
|
+
failed, so a 403 alone proves nothing about submit. `error` set to
|
|
261
|
+
anything else means permission passed and validation stopped it;
|
|
262
|
+
`document_name` set means something posted -- an incident, not a retry.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
refused: bool
|
|
266
|
+
can_create: bool | None = None
|
|
267
|
+
submit_flag: bool | None = None
|
|
268
|
+
error: FrappeError | None = None
|
|
269
|
+
document_name: str | None = None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _has_permission(client: _AccessClient, doctype: str, perm_type: str) -> bool:
|
|
273
|
+
"""The server's doctype-level answer to "does this credential hold `perm_type` on `doctype`"."""
|
|
274
|
+
result = client.call(_HAS_PERMISSION, {"doctype": doctype, "docname": "", "perm_type": perm_type})
|
|
275
|
+
return bool(result["has_permission"])
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def prohibited_flags_allowed(config: SiteConfig, *, doctype: str, ptypes: frozenset[str]) -> dict[str, bool]:
|
|
279
|
+
"""{ptype: allowed} for each of `ptypes` on `doctype`, per the server; nothing is written.
|
|
280
|
+
|
|
281
|
+
Frappe permissions are per doctype and per flag, so one call covers one
|
|
282
|
+
doctype. A doctype-level answer is trustworthy for submit/cancel/amend
|
|
283
|
+
(nothing document-scoped can widen a denied flag); it is not for
|
|
284
|
+
create, which `probe_submit` proves with a real draft instead.
|
|
285
|
+
"""
|
|
286
|
+
with Client(config) as client:
|
|
287
|
+
return {ptype: _has_permission(client, doctype, ptype) for ptype in sorted(ptypes)}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def granted_flags_effective(config: SiteConfig, *, doctype: str, ptypes: frozenset[str]) -> dict[str, bool]:
|
|
291
|
+
"""{ptype: effective} for each granted flag on `doctype`, per the server; nothing is written.
|
|
292
|
+
|
|
293
|
+
A Custom DocPerm row that exists and reads back is not proof it takes
|
|
294
|
+
effect -- a `read` row on `DocType` does not, measured live -- so a
|
|
295
|
+
positive grant is checked the same way a prohibited one is.
|
|
296
|
+
|
|
297
|
+
**Blind to permlevel.** `frappe.client.has_permission` answers a
|
|
298
|
+
doctype-level question and takes no permlevel of its own, so this cannot
|
|
299
|
+
tell a permlevel-1 DocPerm row's presence from a permlevel-0 grant
|
|
300
|
+
already on the same doctype -- a caller that asks this about a
|
|
301
|
+
permlevel-1 grant gets back "effective" whether or not that row exists.
|
|
302
|
+
Proving a grant above permlevel 0 needs a real round trip of a value
|
|
303
|
+
through the credential instead; do not call this for one.
|
|
304
|
+
"""
|
|
305
|
+
with Client(config) as client:
|
|
306
|
+
return {ptype: _has_permission(client, doctype, ptype) for ptype in sorted(ptypes)}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def probe_submit(config: SiteConfig, *, company: str) -> SubmitProbe:
|
|
310
|
+
"""Attempt a submit-on-insert with `config`'s credential and classify the server's answer.
|
|
311
|
+
|
|
312
|
+
The client-side guard is lifted on purpose (`may_submit=True`): only the
|
|
313
|
+
server's refusal counts. Frappe checks the submit permission in
|
|
314
|
+
`check_docstatus_transition` before any validation, and the probe
|
|
315
|
+
document lacks its mandatory `accounts` rows, so nothing can post even
|
|
316
|
+
if permission passes. Transport errors propagate: a probe that could
|
|
317
|
+
not run is not an answer.
|
|
318
|
+
"""
|
|
319
|
+
probing_config = dataclasses.replace(config, may_submit=True)
|
|
320
|
+
with Client(probing_config) as client:
|
|
321
|
+
submit_flag = _has_permission(client, PROBE_DOCTYPE, "submit")
|
|
322
|
+
|
|
323
|
+
# Create access is proven by a real draft, not a doctype-level
|
|
324
|
+
# has_permission: only the draft sees a Company-scoped restriction,
|
|
325
|
+
# and a 403 from such a restriction would read as a submit refusal.
|
|
326
|
+
create_error = _probe_create(client, company)
|
|
327
|
+
if create_error is not None:
|
|
328
|
+
return SubmitProbe(refused=False, can_create=False, submit_flag=submit_flag, error=create_error)
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
result = client.insert(
|
|
332
|
+
PROBE_DOCTYPE,
|
|
333
|
+
{
|
|
334
|
+
"company": company,
|
|
335
|
+
"posting_date": "2000-01-01",
|
|
336
|
+
"voucher_type": "Journal Entry",
|
|
337
|
+
"docstatus": 1,
|
|
338
|
+
},
|
|
339
|
+
)
|
|
340
|
+
except PermissionError_ as exc:
|
|
341
|
+
refused = submit_flag is False
|
|
342
|
+
return SubmitProbe(refused=refused, can_create=True, submit_flag=submit_flag, error=exc)
|
|
343
|
+
except FrappeError as exc:
|
|
344
|
+
return SubmitProbe(refused=False, can_create=True, submit_flag=submit_flag, error=exc)
|
|
345
|
+
return SubmitProbe(
|
|
346
|
+
refused=False,
|
|
347
|
+
can_create=True,
|
|
348
|
+
submit_flag=submit_flag,
|
|
349
|
+
document_name=result.get("name") if isinstance(result, dict) else None,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _probe_create(client: Client, company: str) -> PermissionError_ | None:
|
|
354
|
+
"""Prove create access against `company` with a real draft; None means proven.
|
|
355
|
+
|
|
356
|
+
Reaching validation (`ValidationError`) proves it -- for the missing
|
|
357
|
+
`accounts` rows, or for whatever else the company's setup objects to
|
|
358
|
+
first (on a perpetual-inventory company, ERPNext's
|
|
359
|
+
`StockAccountInvalidTransaction`, read as a validation failure from its
|
|
360
|
+
417); a `PermissionError_` is returned for the caller to report; anything
|
|
361
|
+
else -- a rate limit, a 5xx -- propagates, since it answers nothing. A
|
|
362
|
+
draft that unexpectedly lands is deleted at once.
|
|
363
|
+
"""
|
|
364
|
+
try:
|
|
365
|
+
draft = client.insert(
|
|
366
|
+
PROBE_DOCTYPE,
|
|
367
|
+
{"company": company, "posting_date": "2000-01-01", "voucher_type": "Journal Entry"},
|
|
368
|
+
)
|
|
369
|
+
except PermissionError_ as exc:
|
|
370
|
+
return exc
|
|
371
|
+
except ValidationError:
|
|
372
|
+
return None
|
|
373
|
+
name = draft.get("name") if isinstance(draft, dict) else None
|
|
374
|
+
if name:
|
|
375
|
+
try:
|
|
376
|
+
client.delete(PROBE_DOCTYPE, name)
|
|
377
|
+
except FrappeError as exc:
|
|
378
|
+
raise OrphanProbeDocumentError(PROBE_DOCTYPE, name, exc) from exc
|
|
379
|
+
return None
|