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,260 @@
|
|
|
1
|
+
"""Purchase Orders as ERPNext holds them, and the documents we write against one.
|
|
2
|
+
|
|
3
|
+
Reads are typed: the connector needs a stable shape to turn into observations,
|
|
4
|
+
and a dict from Frappe carries "" where we mean None. Writes are narrow: the
|
|
5
|
+
two `allow_on_submit` confirmation fields (verified on the v16 Purchase Order
|
|
6
|
+
doctype), and a draft Purchase Receipt. Nothing here submits.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
DOCTYPE_PURCHASE_ORDER = "Purchase Order"
|
|
16
|
+
DOCTYPE_PURCHASE_RECEIPT = "Purchase Receipt"
|
|
17
|
+
DEFAULT_UOM = "Nos"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Getter(Protocol):
|
|
21
|
+
def get(self, doctype: str, name: str) -> Any: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Lister(Protocol):
|
|
25
|
+
def list_page(
|
|
26
|
+
self, doctype: str, limit: int, start: int, **kwargs: Any
|
|
27
|
+
) -> tuple[list[dict[str, Any]], bool | None]: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _Updater(Protocol):
|
|
31
|
+
def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _text(value: object) -> str | None:
|
|
35
|
+
return value if isinstance(value, str) and value != "" else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _num(value: object) -> float:
|
|
39
|
+
return float(value) if isinstance(value, int | float) and not isinstance(value, bool) else 0.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclasses.dataclass(frozen=True)
|
|
43
|
+
class PurchaseOrderItem:
|
|
44
|
+
"""One row of a Purchase Order's `items` table."""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
idx: int
|
|
48
|
+
item_code: str
|
|
49
|
+
item_name: str | None
|
|
50
|
+
description: str | None
|
|
51
|
+
qty: float
|
|
52
|
+
rate: float
|
|
53
|
+
amount: float
|
|
54
|
+
uom: str
|
|
55
|
+
schedule_date: str | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclasses.dataclass(frozen=True)
|
|
59
|
+
class PurchaseOrder:
|
|
60
|
+
"""A Purchase Order as read from the site."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
supplier: str
|
|
64
|
+
supplier_name: str | None
|
|
65
|
+
company: str | None
|
|
66
|
+
currency: str | None
|
|
67
|
+
transaction_date: str | None
|
|
68
|
+
schedule_date: str | None
|
|
69
|
+
docstatus: int
|
|
70
|
+
status: str | None
|
|
71
|
+
grand_total: float
|
|
72
|
+
order_confirmation_no: str | None
|
|
73
|
+
order_confirmation_date: str | None
|
|
74
|
+
amended_from: str | None
|
|
75
|
+
modified: str
|
|
76
|
+
transpire_source: str | None
|
|
77
|
+
transpire_ref: str | None
|
|
78
|
+
transpire_order_no: str | None
|
|
79
|
+
items: tuple[PurchaseOrderItem, ...]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def is_submitted(self) -> bool:
|
|
83
|
+
return self.docstatus == 1
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def is_cancelled(self) -> bool:
|
|
87
|
+
return self.docstatus == 2
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def purchase_order_from_document(doc: dict[str, Any]) -> PurchaseOrder:
|
|
91
|
+
"""Type a raw `Purchase Order` document. Pure."""
|
|
92
|
+
items = tuple(
|
|
93
|
+
PurchaseOrderItem(
|
|
94
|
+
name=str(row.get("name", "")),
|
|
95
|
+
idx=int(row.get("idx", 0)),
|
|
96
|
+
item_code=str(row["item_code"]),
|
|
97
|
+
item_name=_text(row.get("item_name")),
|
|
98
|
+
description=_text(row.get("description")),
|
|
99
|
+
qty=_num(row.get("qty")),
|
|
100
|
+
rate=_num(row.get("rate")),
|
|
101
|
+
amount=_num(row.get("amount")),
|
|
102
|
+
uom=_text(row.get("uom")) or DEFAULT_UOM,
|
|
103
|
+
schedule_date=_text(row.get("schedule_date")),
|
|
104
|
+
)
|
|
105
|
+
for row in doc.get("items") or []
|
|
106
|
+
)
|
|
107
|
+
return PurchaseOrder(
|
|
108
|
+
name=str(doc["name"]),
|
|
109
|
+
supplier=str(doc["supplier"]),
|
|
110
|
+
supplier_name=_text(doc.get("supplier_name")),
|
|
111
|
+
company=_text(doc.get("company")),
|
|
112
|
+
currency=_text(doc.get("currency")),
|
|
113
|
+
transaction_date=_text(doc.get("transaction_date")),
|
|
114
|
+
schedule_date=_text(doc.get("schedule_date")),
|
|
115
|
+
docstatus=int(doc.get("docstatus", 0)),
|
|
116
|
+
status=_text(doc.get("status")),
|
|
117
|
+
grand_total=_num(doc.get("grand_total")),
|
|
118
|
+
order_confirmation_no=_text(doc.get("order_confirmation_no")),
|
|
119
|
+
order_confirmation_date=_text(doc.get("order_confirmation_date")),
|
|
120
|
+
amended_from=_text(doc.get("amended_from")),
|
|
121
|
+
modified=str(doc.get("modified", "")),
|
|
122
|
+
transpire_source=_text(doc.get("transpire_source")),
|
|
123
|
+
transpire_ref=_text(doc.get("transpire_ref")),
|
|
124
|
+
transpire_order_no=_text(doc.get("transpire_order_no")),
|
|
125
|
+
items=items,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_purchase_order(client: _Getter, name: str) -> PurchaseOrder:
|
|
130
|
+
"""Fetch and type one Purchase Order."""
|
|
131
|
+
return purchase_order_from_document(client.get(DOCTYPE_PURCHASE_ORDER, name))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def list_submitted_purchase_orders(
|
|
135
|
+
client: _Lister, *, modified_after: str | None, page_size: int = 100, company: str | None = None
|
|
136
|
+
) -> Iterator[dict[str, Any]]:
|
|
137
|
+
"""`{"name", "modified"}` for every submitted or cancelled PO past the cursor, oldest first.
|
|
138
|
+
|
|
139
|
+
`modified_after` is Frappe's own timestamp text (`YYYY-MM-DD HH:MM:SS[.ffffff]`),
|
|
140
|
+
compared server-side, so the cursor a caller stores is exactly what the
|
|
141
|
+
site last returned. The document at the cursor itself is listed again;
|
|
142
|
+
see the comment on the filter.
|
|
143
|
+
|
|
144
|
+
`company` narrows to one Frappe Company (a tenant's site may run several);
|
|
145
|
+
omitted, every company on the site is in scope.
|
|
146
|
+
|
|
147
|
+
Pages by key, never by offset. `modified` is what the listing is ordered
|
|
148
|
+
by, and it moves: a document edited between two pages sorts later than it
|
|
149
|
+
did, and with offsets the unread document that slid into its old slot
|
|
150
|
+
would be skipped, permanently, once the caller stored the cursor. Each
|
|
151
|
+
page instead continues from the last `(modified, name)` yielded, in two
|
|
152
|
+
steps because Frappe's list filters only AND together: first the rest of
|
|
153
|
+
the documents tied at that `modified`, by `name`, then everything after
|
|
154
|
+
it. An edit only ever moves a document later, so it can be listed twice
|
|
155
|
+
(every caller keys its source id on `modified`, so a repeat is
|
|
156
|
+
idempotent) but not lost.
|
|
157
|
+
"""
|
|
158
|
+
cursor = modified_after
|
|
159
|
+
last_name: str | None = None
|
|
160
|
+
while True:
|
|
161
|
+
if last_name is not None:
|
|
162
|
+
# The rest of the tie at `cursor`. `name` is immutable, so this
|
|
163
|
+
# walk is stable however the documents are edited meanwhile.
|
|
164
|
+
while True:
|
|
165
|
+
tie = [["modified", "=", cursor], ["name", ">", last_name]]
|
|
166
|
+
rows, more = _page(client, tie, order_by="name asc", page_size=page_size, company=company)
|
|
167
|
+
if not rows:
|
|
168
|
+
break
|
|
169
|
+
yield from rows
|
|
170
|
+
last_name = rows[-1]["name"]
|
|
171
|
+
if _last_page(rows, more, page_size):
|
|
172
|
+
break
|
|
173
|
+
after: list[list[Any]] = []
|
|
174
|
+
if cursor is not None:
|
|
175
|
+
# Inclusive on the caller's own cursor, on purpose: several
|
|
176
|
+
# documents can share one `modified`, and a run that stopped
|
|
177
|
+
# between two of them would otherwise never see the rest.
|
|
178
|
+
# Re-reading the document at the cursor is idempotent for every
|
|
179
|
+
# caller. Strict once the tie has been walked above.
|
|
180
|
+
after = [["modified", ">" if last_name is not None else ">=", cursor]]
|
|
181
|
+
rows, more = _page(client, after, order_by="modified asc, name asc", page_size=page_size, company=company)
|
|
182
|
+
if not rows:
|
|
183
|
+
return
|
|
184
|
+
yield from rows
|
|
185
|
+
cursor, last_name = rows[-1]["modified"], rows[-1]["name"]
|
|
186
|
+
if _last_page(rows, more, page_size):
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _page(
|
|
191
|
+
client: _Lister, filters: list[list[Any]], *, order_by: str, page_size: int, company: str | None = None
|
|
192
|
+
) -> tuple[list[dict[str, Any]], bool | None]:
|
|
193
|
+
# Submitted and cancelled, never drafts: a cancellation whose Webhook was
|
|
194
|
+
# lost is exactly what the reconcile poll exists to recover.
|
|
195
|
+
base: list[list[Any]] = [["docstatus", "in", [1, 2]]]
|
|
196
|
+
if company:
|
|
197
|
+
base.append(["company", "=", company])
|
|
198
|
+
return client.list_page(
|
|
199
|
+
DOCTYPE_PURCHASE_ORDER,
|
|
200
|
+
page_size,
|
|
201
|
+
0,
|
|
202
|
+
fields=["name", "modified"],
|
|
203
|
+
filters=[*base, *filters],
|
|
204
|
+
order_by=order_by,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _last_page(rows: list[dict[str, Any]], more: bool | None, page_size: int) -> bool:
|
|
209
|
+
"""Trust the server's flag; without one (v1), a short page is the end."""
|
|
210
|
+
return more is False or (more is None and len(rows) < page_size)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def set_order_confirmation(client: _Updater, name: str, *, number: str, date: str) -> dict[str, Any]:
|
|
214
|
+
"""Set the vendor's order number and date on a submitted PO. Both fields allow it."""
|
|
215
|
+
return client.update(
|
|
216
|
+
DOCTYPE_PURCHASE_ORDER, name, {"order_confirmation_no": number, "order_confirmation_date": date}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclasses.dataclass(frozen=True)
|
|
221
|
+
class DesiredPurchaseReceiptItem:
|
|
222
|
+
"""One received line, linked back to the PO row it fulfils."""
|
|
223
|
+
|
|
224
|
+
item_code: str
|
|
225
|
+
qty: float
|
|
226
|
+
purchase_order: str
|
|
227
|
+
purchase_order_item: str
|
|
228
|
+
rate: float
|
|
229
|
+
uom: str = DEFAULT_UOM
|
|
230
|
+
|
|
231
|
+
def as_document(self) -> dict[str, Any]:
|
|
232
|
+
return {
|
|
233
|
+
"item_code": self.item_code,
|
|
234
|
+
"qty": self.qty,
|
|
235
|
+
"uom": self.uom,
|
|
236
|
+
"rate": self.rate,
|
|
237
|
+
"purchase_order": self.purchase_order,
|
|
238
|
+
"purchase_order_item": self.purchase_order_item,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclasses.dataclass(frozen=True)
|
|
243
|
+
class DesiredPurchaseReceipt:
|
|
244
|
+
"""A draft Purchase Receipt against one PO. Never carries a docstatus."""
|
|
245
|
+
|
|
246
|
+
purchase_order: str
|
|
247
|
+
supplier: str
|
|
248
|
+
supplier_delivery_note: str
|
|
249
|
+
posting_date: str
|
|
250
|
+
remarks: str
|
|
251
|
+
items: tuple[DesiredPurchaseReceiptItem, ...]
|
|
252
|
+
|
|
253
|
+
def as_document(self) -> dict[str, Any]:
|
|
254
|
+
return {
|
|
255
|
+
"supplier": self.supplier,
|
|
256
|
+
"posting_date": self.posting_date,
|
|
257
|
+
"supplier_delivery_note": self.supplier_delivery_note,
|
|
258
|
+
"remarks": self.remarks,
|
|
259
|
+
"items": [item.as_document() for item in self.items],
|
|
260
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Frappe `Webhook` documents, provisioned so nobody transcribes a form.
|
|
2
|
+
|
|
3
|
+
A Webhook fires from `Document.run_method`, so a Purchase Order submitted from
|
|
4
|
+
the desk, the REST API, or a script all ring the same bell. The body is a
|
|
5
|
+
two-field template naming the document; phloem fetches the rest. The site id
|
|
6
|
+
rides in a header so the gateway can tell which site's secret signs the
|
|
7
|
+
request without a per-site route.
|
|
8
|
+
|
|
9
|
+
Field names are ERPNext v16's (`frappe/integrations/doctype/webhook/webhook.json`
|
|
10
|
+
on version-16-hotfix). Frappe names a Webhook by prompt, so the caller sets
|
|
11
|
+
`name`, which is what makes provisioning idempotent.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from typing import Any, Protocol
|
|
19
|
+
|
|
20
|
+
from icedcoffee_sync.erpnext.ensure import ensure_by_name
|
|
21
|
+
|
|
22
|
+
DOCTYPE_WEBHOOK = "Webhook"
|
|
23
|
+
PURCHASE_ORDER = "Purchase Order"
|
|
24
|
+
PURCHASE_ORDER_EVENTS: tuple[str, ...] = ("on_submit", "on_update_after_submit", "on_cancel")
|
|
25
|
+
|
|
26
|
+
SITE_HEADER = "X-Transpire-Site"
|
|
27
|
+
TIMEOUT_SECONDS = 10
|
|
28
|
+
|
|
29
|
+
# A doorbell, not the data. Frappe renders this Jinja against the document.
|
|
30
|
+
DOORBELL_TEMPLATE = '{"doctype": "{{ doc.doctype }}", "name": "{{ doc.name }}"}'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Writer(Protocol):
|
|
34
|
+
def get(self, doctype: str, name: str) -> Any: ...
|
|
35
|
+
def insert(self, doctype: str, doc: dict[str, Any]) -> Any: ...
|
|
36
|
+
def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def webhook_name(site_id: str, doctype: str, event: str) -> str:
|
|
40
|
+
"""`transpire-<site>-<doctype slug>-<event>`; deterministic, so ensure is get-then-insert."""
|
|
41
|
+
slug = doctype.lower().replace(" ", "-")
|
|
42
|
+
return f"transpire-{site_id}-{slug}-{event}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclasses.dataclass(frozen=True)
|
|
46
|
+
class DesiredWebhook:
|
|
47
|
+
"""One Webhook document as Transpire wants it on the site."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
doctype: str
|
|
51
|
+
event: str
|
|
52
|
+
request_url: str
|
|
53
|
+
site_id: str
|
|
54
|
+
secret: str = dataclasses.field(repr=False)
|
|
55
|
+
# None means "do not touch the switch": a Webhook this creates starts off
|
|
56
|
+
# (an enabled Webhook pointed at a route that 404s burns Frappe's three
|
|
57
|
+
# attempts for nothing), and one that already exists keeps whatever it
|
|
58
|
+
# was, so a re-run for a key rotation cannot silently stop deliveries.
|
|
59
|
+
# True and False set it either way.
|
|
60
|
+
enabled: bool | None = None
|
|
61
|
+
|
|
62
|
+
def as_document(self) -> dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"name": self.name,
|
|
65
|
+
"webhook_doctype": self.doctype,
|
|
66
|
+
"webhook_docevent": self.event,
|
|
67
|
+
"request_url": self.request_url,
|
|
68
|
+
"request_method": "POST",
|
|
69
|
+
"request_structure": "JSON",
|
|
70
|
+
"webhook_json": DOORBELL_TEMPLATE,
|
|
71
|
+
"webhook_headers": [
|
|
72
|
+
{"key": "Content-Type", "value": "application/json"},
|
|
73
|
+
{"key": SITE_HEADER, "value": self.site_id},
|
|
74
|
+
],
|
|
75
|
+
"enable_security": 1,
|
|
76
|
+
"webhook_secret": self.secret,
|
|
77
|
+
"enabled": 1 if self.enabled else 0,
|
|
78
|
+
"timeout": TIMEOUT_SECONDS,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def purchase_order_webhooks(
|
|
83
|
+
*, site_id: str, request_url: str, secret: str, enabled: bool | None = None
|
|
84
|
+
) -> tuple[DesiredWebhook, ...]:
|
|
85
|
+
"""The three Purchase Order Webhooks the connector listens to.
|
|
86
|
+
|
|
87
|
+
`enabled` left None creates them off and leaves existing ones as they are;
|
|
88
|
+
re-running provisioning with it True, once the inbound route and its topic
|
|
89
|
+
are deployed, is what switches deliveries on. False switches them off.
|
|
90
|
+
"""
|
|
91
|
+
return tuple(
|
|
92
|
+
DesiredWebhook(
|
|
93
|
+
name=webhook_name(site_id, PURCHASE_ORDER, event),
|
|
94
|
+
doctype=PURCHASE_ORDER,
|
|
95
|
+
event=event,
|
|
96
|
+
request_url=request_url,
|
|
97
|
+
site_id=site_id,
|
|
98
|
+
secret=secret,
|
|
99
|
+
enabled=enabled,
|
|
100
|
+
)
|
|
101
|
+
for event in PURCHASE_ORDER_EVENTS
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclasses.dataclass(frozen=True)
|
|
106
|
+
class WebhookReport:
|
|
107
|
+
"""What `ensure_webhooks` did: the names it created and the names it re-asserted."""
|
|
108
|
+
|
|
109
|
+
created: list[str]
|
|
110
|
+
updated: list[str]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def ensure_webhooks(client: _Writer, webhooks: Iterable[DesiredWebhook]) -> WebhookReport:
|
|
114
|
+
"""Create every Webhook the site lacks; re-assert the machine-owned fields on the rest.
|
|
115
|
+
|
|
116
|
+
A Transpire Webhook is ours by name, so an existing one is brought back to
|
|
117
|
+
the desired document -- URL, headers, template, secret, timeout -- rather
|
|
118
|
+
than left alone. That is what lets a re-run apply a changed gateway URL or
|
|
119
|
+
a re-derived secret after the master secret rotates; the secret is a
|
|
120
|
+
Password field Frappe never reads back, so it is always written. The
|
|
121
|
+
`enabled` switch is re-asserted only when the desired document says which
|
|
122
|
+
way (see `DesiredWebhook.enabled`).
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The names created and the names updated, each in input order.
|
|
126
|
+
"""
|
|
127
|
+
created: list[str] = []
|
|
128
|
+
updated: list[str] = []
|
|
129
|
+
for hook in webhooks:
|
|
130
|
+
document = hook.as_document()
|
|
131
|
+
if ensure_by_name(client, DOCTYPE_WEBHOOK, hook.name, document):
|
|
132
|
+
created.append(hook.name)
|
|
133
|
+
continue
|
|
134
|
+
untouched = {"name"} | ({"enabled"} if hook.enabled is None else set())
|
|
135
|
+
client.update(DOCTYPE_WEBHOOK, hook.name, {k: v for k, v in document.items() if k not in untouched})
|
|
136
|
+
updated.append(hook.name)
|
|
137
|
+
return WebhookReport(created=created, updated=updated)
|
|
File without changes
|