easycontract 0.1.1__tar.gz → 0.1.7__tar.gz
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.
- {easycontract-0.1.1 → easycontract-0.1.7}/CHANGELOG.md +126 -0
- {easycontract-0.1.1 → easycontract-0.1.7}/PKG-INFO +1 -1
- {easycontract-0.1.1 → easycontract-0.1.7}/pyproject.toml +1 -1
- {easycontract-0.1.1 → easycontract-0.1.7}/src/easycontract/__init__.py +1 -1
- {easycontract-0.1.1 → easycontract-0.1.7}/src/easycontract/client.py +4 -0
- {easycontract-0.1.1 → easycontract-0.1.7}/src/easycontract/resources.py +708 -14
- {easycontract-0.1.1 → easycontract-0.1.7}/src/easycontract/webhooks.py +26 -8
- {easycontract-0.1.1 → easycontract-0.1.7}/.gitignore +0 -0
- {easycontract-0.1.1 → easycontract-0.1.7}/LICENSE.txt +0 -0
- {easycontract-0.1.1 → easycontract-0.1.7}/README.md +0 -0
- {easycontract-0.1.1 → easycontract-0.1.7}/src/easycontract/errors.py +0 -0
- {easycontract-0.1.1 → easycontract-0.1.7}/tests/smoke_test.py +0 -0
|
@@ -2,6 +2,132 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `easycontract` Python SDK.
|
|
4
4
|
|
|
5
|
+
## 0.1.7 (2026-11-10)
|
|
6
|
+
|
|
7
|
+
**Features**
|
|
8
|
+
|
|
9
|
+
- Template versioning: envelopes are created from the latest APPROVED
|
|
10
|
+
version of a template, never from its draft.
|
|
11
|
+
`client.templates.list_versions/approve_version/get_version`;
|
|
12
|
+
`create_envelope` on a template with no approved version raises the
|
|
13
|
+
`template_not_approved` error (409). Templates and envelopes carry the
|
|
14
|
+
version (`current_version`, `envelope["template"]["version"]`).
|
|
15
|
+
- `client.templates.update_role` (a new key rewrites the role's tokens in
|
|
16
|
+
the documents; `adopt_tokens_of` gives a role the tokens of a key no role
|
|
17
|
+
has, listed in the template's `unknown_roles` with `expand=roles`) and
|
|
18
|
+
`client.templates.delete_role`.
|
|
19
|
+
- `client.templates.set_margin_image` / `delete_margin_image`: a logo or
|
|
20
|
+
seal in the header or footer of an authored document (PNG, JPEG or WebP,
|
|
21
|
+
2 MB at most; `align` start, center or end). A new image is checked at
|
|
22
|
+
once (400 if refused), then waits for the malware scan before it is
|
|
23
|
+
published: the template document carries `header_image` /
|
|
24
|
+
`footer_image` (the published one: type, size, SHA-256, place) and
|
|
25
|
+
`header_image_upload` / `footer_image_upload` (one still in the scan, or
|
|
26
|
+
refused).
|
|
27
|
+
- `client.templates.preview(technical=True)`: the technical view of the
|
|
28
|
+
template draft (every fillable field explained, highlighted in the
|
|
29
|
+
documents with example values and how the API passes each one).
|
|
30
|
+
- `client.templates.compare_versions` (what changed between two versions,
|
|
31
|
+
or a version and the draft) and `client.templates.restore_version` (copy
|
|
32
|
+
an earlier version into the draft; approving it makes a new version).
|
|
33
|
+
- `client.organization.group()`: the organization group (Enterprise,
|
|
34
|
+
read only), its members and the invitations received; `features` gains
|
|
35
|
+
`organization_groups`. `client.organization.group_templates()` and
|
|
36
|
+
`copy_group_template()`: templates the other organizations of the group
|
|
37
|
+
offer, copied as a draft of your own; `templates.update(group_shared=...)`
|
|
38
|
+
offers one, and templates carry `group_shared` and `copied_from`.
|
|
39
|
+
A copy follows its source: `client.templates.upstream()` (newer approved
|
|
40
|
+
version?), `compare_upstream()` and `pull_upstream()` (into the draft);
|
|
41
|
+
`templates.update(follow_updates=...)` switches the e-mail notices.
|
|
42
|
+
- Attention points in other languages: `client.templates.list_attention_translations`,
|
|
43
|
+
`get_attention_translation`, `set_attention_translation` (drafts),
|
|
44
|
+
`delete_attention_translation` and `autotranslate_attention_points` (a
|
|
45
|
+
machine draft; a document's autotranslation drafts them too). Template
|
|
46
|
+
points carry `translations` (status per language), translation jobs
|
|
47
|
+
carry `scope`, and the version comparison `attention_point_translations`.
|
|
48
|
+
A template is usable in a language only when its attention points are
|
|
49
|
+
approved in it too (approval stays in the panel).
|
|
50
|
+
- `templates.request_countersign_authorization(scope=...)`: `"version"`
|
|
51
|
+
(default) or `"future_versions"` (the authorization also covers the
|
|
52
|
+
versions approved later). Authorizations carry `template_version` and
|
|
53
|
+
`scope`.
|
|
54
|
+
|
|
55
|
+
## 0.1.6 (2026-11-08)
|
|
56
|
+
|
|
57
|
+
**Features**
|
|
58
|
+
|
|
59
|
+
- Attention points: parts of the contract the signer must notice, shown on
|
|
60
|
+
the signing page before the documents (never stamped in the PDF).
|
|
61
|
+
`client.templates.list_attention_points/add_attention_point/
|
|
62
|
+
update_attention_point/delete_attention_point` (the defaults of every
|
|
63
|
+
envelope created from the template) and the same four on
|
|
64
|
+
`client.envelopes` (draft envelopes; on one created from a template, Pro
|
|
65
|
+
and Enterprise). `requires_ack=True` makes the signer confirm it before
|
|
66
|
+
signing. `templates.create_envelope(attention_points=[…])` replaces the
|
|
67
|
+
template's for that envelope only (Pro and Enterprise).
|
|
68
|
+
|
|
69
|
+
## 0.1.5 (2026-10-23)
|
|
70
|
+
|
|
71
|
+
**Features**
|
|
72
|
+
|
|
73
|
+
- Automatic countersignature of the organization (Pro+):
|
|
74
|
+
`client.templates.request_countersign_authorization(template_id,
|
|
75
|
+
authorizer_email=…, authorizer_title=…, valid_until="YYYY-MM-DD",
|
|
76
|
+
max_uses=None, variables={…})` (the authorizer signs it by e-mail),
|
|
77
|
+
`client.templates.list_countersign_authorizations`,
|
|
78
|
+
`client.countersign_authorizations.list/get/revoke`,
|
|
79
|
+
`client.envelopes.retry_countersign` and
|
|
80
|
+
`client.envelopes.skip_countersign(reason=…)`. `templates.create_envelope`
|
|
81
|
+
accepts `countersign="none"`; envelopes carry `countersign`.
|
|
82
|
+
|
|
83
|
+
## 0.1.4 (2026-10-20)
|
|
84
|
+
|
|
85
|
+
**Features**
|
|
86
|
+
|
|
87
|
+
- Projects (paid plans): `client.projects.list/create/get/update`. An
|
|
88
|
+
organization key sees everything; a project key only its project.
|
|
89
|
+
`project=` on `envelopes.create`, `envelopes.list`, `templates.create_envelope`
|
|
90
|
+
and `webhooks.create` (organization keys only). `client.whoami()` now returns
|
|
91
|
+
`project` (null = organization key) and `key`.
|
|
92
|
+
- Webhooks: `client.webhooks.get`, `update` (url, event_types, is_active,
|
|
93
|
+
description) and `rotate_secret(grace_period_hours=24)`, which returns the
|
|
94
|
+
new `secret` once.
|
|
95
|
+
|
|
96
|
+
**Fixes**
|
|
97
|
+
|
|
98
|
+
- `easycontract.webhooks.verify_signature` accepts ANY `v1=` in the header
|
|
99
|
+
and `secret` may be a list: after a rotation, during the grace period,
|
|
100
|
+
deliveries carry `v1=<new>,v1=<old>`. 0.1.3 and earlier only checked the
|
|
101
|
+
last `v1` (the old secret), so they failed once you switched to the new one
|
|
102
|
+
before the grace period ended.
|
|
103
|
+
|
|
104
|
+
## 0.1.3 (2026-10-20)
|
|
105
|
+
|
|
106
|
+
**Features**
|
|
107
|
+
|
|
108
|
+
- Cryptographic restrictions: `client.organization.update(seal_profile=
|
|
109
|
+
"hybrid" | "classic", acknowledge=True)` chooses how the platform seals
|
|
110
|
+
the certificate, the final PDFs and the evidence package (hybrid ECDSA +
|
|
111
|
+
ML-DSA-87, or ECDSA only). `client.organization.get()["cryptography"]`
|
|
112
|
+
shows the choice and what each option implies; every envelope reports the
|
|
113
|
+
profile frozen at send in `seal_profile`.
|
|
114
|
+
- The evidence package (`client.envelopes.download_evidence(...)`) is now an ASiC-E
|
|
115
|
+
container (still a `.zip`) signed by the platform.
|
|
116
|
+
|
|
117
|
+
## 0.1.2 (2026-10-19)
|
|
118
|
+
|
|
119
|
+
**Features**
|
|
120
|
+
|
|
121
|
+
- Machine-drafted template translations: `client.templates.autotranslate(
|
|
122
|
+
template_id, document_id, language, acknowledge=True)` queues a draft made
|
|
123
|
+
by the platform's translators and reviewed by a panel with quorum;
|
|
124
|
+
`client.templates.get_translation_job(job_id)` returns its progress,
|
|
125
|
+
`verdict` (`consensus`, `quorum`, `no_quorum`, `unreviewed`, `simulated`)
|
|
126
|
+
and the reviewers' notes with English back-translations. The result is
|
|
127
|
+
always a draft that a person approves in the panel.
|
|
128
|
+
- The platform now has 16 languages (es, en, ca, gl, eu, ast, an, oc, pt, fr,
|
|
129
|
+
it, de, ru, zh-hans, ar, he) for `language` everywhere.
|
|
130
|
+
|
|
5
131
|
## 0.1.1 (2026-10-07)
|
|
6
132
|
|
|
7
133
|
**Security (audit 2026-10-07)**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: easycontract
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: SDK oficial de Python para el API de easycontract (firma electrónica eIDAS)
|
|
5
5
|
Project-URL: Documentation, https://easycontract.online/docs/api
|
|
6
6
|
Author-email: Román Ramírez <roman@easycontract.online>
|
|
@@ -23,12 +23,14 @@ import requests
|
|
|
23
23
|
from . import __version__
|
|
24
24
|
from .errors import APIError, error_from_response
|
|
25
25
|
from .resources import (
|
|
26
|
+
CountersignAuthorizations,
|
|
26
27
|
Envelopes,
|
|
27
28
|
Events,
|
|
28
29
|
Identity,
|
|
29
30
|
Invitations,
|
|
30
31
|
Members,
|
|
31
32
|
Organization,
|
|
33
|
+
Projects,
|
|
32
34
|
Retention,
|
|
33
35
|
Templates,
|
|
34
36
|
Webhooks,
|
|
@@ -108,7 +110,9 @@ class EasyContract:
|
|
|
108
110
|
self.identity = Identity(self)
|
|
109
111
|
self.retention = Retention(self)
|
|
110
112
|
self.templates = Templates(self)
|
|
113
|
+
self.countersign_authorizations = CountersignAuthorizations(self)
|
|
111
114
|
self.webhooks = Webhooks(self)
|
|
115
|
+
self.projects = Projects(self)
|
|
112
116
|
self.events = Events(self)
|
|
113
117
|
self.organization = Organization(self)
|
|
114
118
|
self.members = Members(self)
|
|
@@ -14,6 +14,15 @@ from urllib.parse import quote
|
|
|
14
14
|
from .errors import EasyContractError
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _attention_body(title, body, document_id, page, requires_ack) -> dict:
|
|
18
|
+
payload: dict = {"title": title, "body": body, "requires_ack": requires_ack}
|
|
19
|
+
if document_id is not None:
|
|
20
|
+
payload["document_id"] = document_id
|
|
21
|
+
if page is not None:
|
|
22
|
+
payload["page"] = page
|
|
23
|
+
return payload
|
|
24
|
+
|
|
25
|
+
|
|
17
26
|
def _id(value) -> str:
|
|
18
27
|
"""A resource identifier as a path segment: it must be a UUID (every
|
|
19
28
|
resource id of the API is). Anything else — "../webhooks/x", "a?b",
|
|
@@ -38,6 +47,12 @@ def _seg(value) -> str:
|
|
|
38
47
|
OPERATIONS: dict[tuple[str, str], str] = {
|
|
39
48
|
("GET", "/whoami/"): "client.whoami",
|
|
40
49
|
("GET", "/organization/"): "client.organization.get",
|
|
50
|
+
("GET", "/organization/group/"): "client.organization.group",
|
|
51
|
+
("GET", "/organization/group/templates/"): "client.organization.group_templates",
|
|
52
|
+
(
|
|
53
|
+
"POST",
|
|
54
|
+
"/organization/group/templates/{template_id}/copy/",
|
|
55
|
+
): "client.organization.copy_group_template",
|
|
41
56
|
("GET", "/members/"): "client.members.list",
|
|
42
57
|
("PATCH", "/members/{membership_id}/"): "client.members.change_role",
|
|
43
58
|
("DELETE", "/members/{membership_id}/"): "client.members.remove",
|
|
@@ -59,6 +74,31 @@ OPERATIONS: dict[tuple[str, str], str] = {
|
|
|
59
74
|
"/envelopes/{envelope_id}/signers/{signer_id}/embedded-session/",
|
|
60
75
|
): "client.envelopes.create_embedded_session",
|
|
61
76
|
("POST", "/envelopes/{envelope_id}/void/"): "client.envelopes.void",
|
|
77
|
+
(
|
|
78
|
+
"POST",
|
|
79
|
+
"/envelopes/{envelope_id}/countersign/retry/",
|
|
80
|
+
): "client.envelopes.retry_countersign",
|
|
81
|
+
(
|
|
82
|
+
"POST",
|
|
83
|
+
"/envelopes/{envelope_id}/countersign/skip/",
|
|
84
|
+
): "client.envelopes.skip_countersign",
|
|
85
|
+
("GET", "/countersign-authorizations/"): "client.countersign_authorizations.list",
|
|
86
|
+
(
|
|
87
|
+
"GET",
|
|
88
|
+
"/countersign-authorizations/{authorization_id}/",
|
|
89
|
+
): "client.countersign_authorizations.get",
|
|
90
|
+
(
|
|
91
|
+
"POST",
|
|
92
|
+
"/countersign-authorizations/{authorization_id}/revoke/",
|
|
93
|
+
): "client.countersign_authorizations.revoke",
|
|
94
|
+
(
|
|
95
|
+
"GET",
|
|
96
|
+
"/templates/{template_id}/countersign-authorizations/",
|
|
97
|
+
): "client.templates.list_countersign_authorizations",
|
|
98
|
+
(
|
|
99
|
+
"POST",
|
|
100
|
+
"/templates/{template_id}/countersign-authorizations/",
|
|
101
|
+
): "client.templates.request_countersign_authorization",
|
|
62
102
|
("GET", "/envelopes/{envelope_id}/certificate/"): "client.envelopes.download_certificate",
|
|
63
103
|
(
|
|
64
104
|
"POST",
|
|
@@ -135,15 +175,103 @@ OPERATIONS: dict[tuple[str, str], str] = {
|
|
|
135
175
|
"DELETE",
|
|
136
176
|
"/templates/{template_id}/documents/{document_id}/translations/{language}/",
|
|
137
177
|
): "client.templates.delete_translation",
|
|
178
|
+
(
|
|
179
|
+
"POST",
|
|
180
|
+
"/templates/{template_id}/documents/{document_id}/translations/{language}/autotranslate/",
|
|
181
|
+
): "client.templates.autotranslate",
|
|
182
|
+
("GET", "/translation-jobs/{job_id}/"): "client.templates.get_translation_job",
|
|
138
183
|
("POST", "/templates/{template_id}/preview/"): "client.templates.preview",
|
|
184
|
+
(
|
|
185
|
+
"POST",
|
|
186
|
+
"/templates/{template_id}/documents/{document_id}/margin-images/{slot}/",
|
|
187
|
+
): "client.templates.set_margin_image",
|
|
188
|
+
(
|
|
189
|
+
"DELETE",
|
|
190
|
+
"/templates/{template_id}/documents/{document_id}/margin-images/{slot}/",
|
|
191
|
+
): "client.templates.delete_margin_image",
|
|
139
192
|
("POST", "/templates/{template_id}/duplicate/"): "client.templates.duplicate",
|
|
140
193
|
("POST", "/templates/{template_id}/roles/"): "client.templates.add_role",
|
|
194
|
+
("PATCH", "/templates/{template_id}/roles/{role_id}/"): "client.templates.update_role",
|
|
195
|
+
("DELETE", "/templates/{template_id}/roles/{role_id}/"): "client.templates.delete_role",
|
|
196
|
+
("GET", "/templates/{template_id}/versions/"): "client.templates.list_versions",
|
|
197
|
+
("POST", "/templates/{template_id}/versions/"): "client.templates.approve_version",
|
|
198
|
+
("GET", "/templates/{template_id}/versions/{number}/"): "client.templates.get_version",
|
|
199
|
+
("GET", "/templates/{template_id}/versions/compare/"): "client.templates.compare_versions",
|
|
200
|
+
(
|
|
201
|
+
"POST",
|
|
202
|
+
"/templates/{template_id}/versions/{number}/restore/",
|
|
203
|
+
): "client.templates.restore_version",
|
|
141
204
|
("POST", "/templates/{template_id}/fields/"): "client.templates.add_field",
|
|
205
|
+
("GET", "/templates/{template_id}/upstream/"): "client.templates.upstream",
|
|
206
|
+
(
|
|
207
|
+
"GET",
|
|
208
|
+
"/templates/{template_id}/upstream/compare/",
|
|
209
|
+
): "client.templates.compare_upstream",
|
|
210
|
+
("POST", "/templates/{template_id}/upstream/pull/"): "client.templates.pull_upstream",
|
|
211
|
+
(
|
|
212
|
+
"GET",
|
|
213
|
+
"/templates/{template_id}/attention-points/",
|
|
214
|
+
): "client.templates.list_attention_points",
|
|
215
|
+
(
|
|
216
|
+
"POST",
|
|
217
|
+
"/templates/{template_id}/attention-points/",
|
|
218
|
+
): "client.templates.add_attention_point",
|
|
219
|
+
(
|
|
220
|
+
"PATCH",
|
|
221
|
+
"/templates/{template_id}/attention-points/{point_id}/",
|
|
222
|
+
): "client.templates.update_attention_point",
|
|
223
|
+
(
|
|
224
|
+
"DELETE",
|
|
225
|
+
"/templates/{template_id}/attention-points/{point_id}/",
|
|
226
|
+
): "client.templates.delete_attention_point",
|
|
227
|
+
(
|
|
228
|
+
"GET",
|
|
229
|
+
"/templates/{template_id}/attention-points/translations/",
|
|
230
|
+
): "client.templates.list_attention_translations",
|
|
231
|
+
(
|
|
232
|
+
"GET",
|
|
233
|
+
"/templates/{template_id}/attention-points/translations/{language}/",
|
|
234
|
+
): "client.templates.get_attention_translation",
|
|
235
|
+
(
|
|
236
|
+
"PUT",
|
|
237
|
+
"/templates/{template_id}/attention-points/translations/{language}/",
|
|
238
|
+
): "client.templates.set_attention_translation",
|
|
239
|
+
(
|
|
240
|
+
"DELETE",
|
|
241
|
+
"/templates/{template_id}/attention-points/translations/{language}/",
|
|
242
|
+
): "client.templates.delete_attention_translation",
|
|
243
|
+
(
|
|
244
|
+
"POST",
|
|
245
|
+
"/templates/{template_id}/attention-points/translations/{language}/autotranslate/",
|
|
246
|
+
): "client.templates.autotranslate_attention_points",
|
|
247
|
+
(
|
|
248
|
+
"GET",
|
|
249
|
+
"/envelopes/{envelope_id}/attention-points/",
|
|
250
|
+
): "client.envelopes.list_attention_points",
|
|
251
|
+
(
|
|
252
|
+
"POST",
|
|
253
|
+
"/envelopes/{envelope_id}/attention-points/",
|
|
254
|
+
): "client.envelopes.add_attention_point",
|
|
255
|
+
(
|
|
256
|
+
"PATCH",
|
|
257
|
+
"/envelopes/{envelope_id}/attention-points/{point_id}/",
|
|
258
|
+
): "client.envelopes.update_attention_point",
|
|
259
|
+
(
|
|
260
|
+
"DELETE",
|
|
261
|
+
"/envelopes/{envelope_id}/attention-points/{point_id}/",
|
|
262
|
+
): "client.envelopes.delete_attention_point",
|
|
142
263
|
("POST", "/templates/{template_id}/envelopes/"): "client.templates.create_envelope",
|
|
143
264
|
("GET", "/events/"): "client.events.list",
|
|
144
265
|
("GET", "/webhooks/"): "client.webhooks.list",
|
|
145
266
|
("POST", "/webhooks/"): "client.webhooks.create",
|
|
267
|
+
("GET", "/webhooks/{endpoint_id}/"): "client.webhooks.get",
|
|
268
|
+
("PATCH", "/webhooks/{endpoint_id}/"): "client.webhooks.update",
|
|
146
269
|
("DELETE", "/webhooks/{endpoint_id}/"): "client.webhooks.delete",
|
|
270
|
+
("POST", "/webhooks/{endpoint_id}/rotate-secret/"): "client.webhooks.rotate_secret",
|
|
271
|
+
("GET", "/projects/"): "client.projects.list",
|
|
272
|
+
("POST", "/projects/"): "client.projects.create",
|
|
273
|
+
("GET", "/projects/{project_id}/"): "client.projects.get",
|
|
274
|
+
("PATCH", "/projects/{project_id}/"): "client.projects.update",
|
|
147
275
|
("POST", "/webhooks/deliveries/{delivery_id}/redeliver/"): "client.webhooks.redeliver",
|
|
148
276
|
}
|
|
149
277
|
|
|
@@ -161,6 +289,36 @@ def _file_tuple(file, name: str | None, content_type: str = "application/pdf"):
|
|
|
161
289
|
return (name or getattr(file, "name", "document.pdf"), file, content_type)
|
|
162
290
|
|
|
163
291
|
|
|
292
|
+
def _image_mime(file, name: str | None) -> str:
|
|
293
|
+
"""Content type of an image upload, from its first bytes when it can be
|
|
294
|
+
read without consuming it, else from the file name (the server decides
|
|
295
|
+
anyway: it decodes and re-encodes every image)."""
|
|
296
|
+
head = b""
|
|
297
|
+
if isinstance(file, bytes):
|
|
298
|
+
head = file[:12]
|
|
299
|
+
elif isinstance(file, (str, Path)):
|
|
300
|
+
with Path(file).open("rb") as fh:
|
|
301
|
+
head = fh.read(12)
|
|
302
|
+
if head.startswith(b"\x89PNG"):
|
|
303
|
+
return "image/png"
|
|
304
|
+
if head.startswith(b"\xff\xd8"):
|
|
305
|
+
return "image/jpeg"
|
|
306
|
+
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
|
307
|
+
return "image/webp"
|
|
308
|
+
if name:
|
|
309
|
+
label = name
|
|
310
|
+
elif isinstance(file, (str, Path)):
|
|
311
|
+
label = str(file)
|
|
312
|
+
else:
|
|
313
|
+
label = str(getattr(file, "name", "") or "")
|
|
314
|
+
lower = label.lower()
|
|
315
|
+
if lower.endswith((".jpg", ".jpeg")):
|
|
316
|
+
return "image/jpeg"
|
|
317
|
+
if lower.endswith(".webp"):
|
|
318
|
+
return "image/webp"
|
|
319
|
+
return "image/png"
|
|
320
|
+
|
|
321
|
+
|
|
164
322
|
class _Resource:
|
|
165
323
|
def __init__(self, client):
|
|
166
324
|
self._client = client
|
|
@@ -198,9 +356,12 @@ class Envelopes(_Resource):
|
|
|
198
356
|
biometrics: bool = False,
|
|
199
357
|
document_type: str | None = None,
|
|
200
358
|
kyc_images_retention: str | None = None,
|
|
359
|
+
project: str | None = None,
|
|
201
360
|
idempotency_key: str | None = None,
|
|
202
361
|
) -> dict:
|
|
203
|
-
"""`
|
|
362
|
+
"""`project` (organization keys only): create inside that project; a
|
|
363
|
+
project key always creates in its own.
|
|
364
|
+
`biometrics=True` offers signers the OPTIONAL stroke capture (only
|
|
204
365
|
effective when the organization enabled it: Enterprise + DPIA).
|
|
205
366
|
`document_type` is a key of `client.retention.document_types()`.
|
|
206
367
|
`kyc_images_retention`: "after_review" (default) or
|
|
@@ -220,17 +381,25 @@ class Envelopes(_Resource):
|
|
|
220
381
|
body["document_type"] = document_type
|
|
221
382
|
if kyc_images_retention:
|
|
222
383
|
body["kyc_images_retention"] = kyc_images_retention
|
|
384
|
+
if project:
|
|
385
|
+
body["project"] = project
|
|
223
386
|
return self._client.request(
|
|
224
387
|
"POST", "/envelopes/", json=body, idempotency_key=idempotency_key
|
|
225
388
|
)
|
|
226
389
|
|
|
227
390
|
def list(
|
|
228
|
-
self,
|
|
391
|
+
self,
|
|
392
|
+
*,
|
|
393
|
+
status: str | None = None,
|
|
394
|
+
project: str | None = None,
|
|
395
|
+
limit: int | None = None,
|
|
396
|
+
cursor: str | None = None,
|
|
229
397
|
) -> dict:
|
|
230
398
|
params = {
|
|
231
399
|
k: v
|
|
232
400
|
for k, v in {
|
|
233
401
|
"status": status,
|
|
402
|
+
"project": project,
|
|
234
403
|
"limit": limit,
|
|
235
404
|
"cursor": cursor,
|
|
236
405
|
}.items()
|
|
@@ -349,11 +518,64 @@ class Envelopes(_Resource):
|
|
|
349
518
|
def release_legal_hold(self, envelope_id: str) -> dict:
|
|
350
519
|
return self._client.request("DELETE", f"/envelopes/{_id(envelope_id)}/legal-hold/")
|
|
351
520
|
|
|
521
|
+
def list_attention_points(self, envelope_id: str) -> dict:
|
|
522
|
+
"""Attention points shown to the signers before the documents, with
|
|
523
|
+
who confirmed each one."""
|
|
524
|
+
return self._client.request("GET", f"/envelopes/{_id(envelope_id)}/attention-points/")
|
|
525
|
+
|
|
526
|
+
def add_attention_point(
|
|
527
|
+
self,
|
|
528
|
+
envelope_id: str,
|
|
529
|
+
*,
|
|
530
|
+
title: str,
|
|
531
|
+
body: str = "",
|
|
532
|
+
document_id: str | None = None,
|
|
533
|
+
page: int | None = None,
|
|
534
|
+
requires_ack: bool = False,
|
|
535
|
+
idempotency_key: str | None = None,
|
|
536
|
+
) -> dict:
|
|
537
|
+
"""Draft envelopes only. On an envelope created from a template it is
|
|
538
|
+
Pro and Enterprise (403 feature_not_available otherwise)."""
|
|
539
|
+
return self._client.request(
|
|
540
|
+
"POST",
|
|
541
|
+
f"/envelopes/{_id(envelope_id)}/attention-points/",
|
|
542
|
+
json=_attention_body(title, body, document_id, page, requires_ack),
|
|
543
|
+
idempotency_key=idempotency_key,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
def update_attention_point(self, envelope_id: str, point_id: str, **changes) -> dict:
|
|
547
|
+
"""title, body, document_id, page, requires_ack (drafts only)."""
|
|
548
|
+
return self._client.request(
|
|
549
|
+
"PATCH",
|
|
550
|
+
f"/envelopes/{_id(envelope_id)}/attention-points/{_id(point_id)}/",
|
|
551
|
+
json=changes,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
def delete_attention_point(self, envelope_id: str, point_id: str):
|
|
555
|
+
return self._client.request(
|
|
556
|
+
"DELETE", f"/envelopes/{_id(envelope_id)}/attention-points/{_id(point_id)}/"
|
|
557
|
+
)
|
|
558
|
+
|
|
352
559
|
def void(self, envelope_id: str, *, reason: str = "") -> dict:
|
|
353
560
|
return self._client.request(
|
|
354
561
|
"POST", f"/envelopes/{_id(envelope_id)}/void/", json={"reason": reason}
|
|
355
562
|
)
|
|
356
563
|
|
|
564
|
+
def retry_countersign(self, envelope_id: str) -> dict:
|
|
565
|
+
"""Signed by everybody and waiting (countersign.status == "failed"):
|
|
566
|
+
try the organization's countersignature again, e.g. after a new
|
|
567
|
+
authorization was signed."""
|
|
568
|
+
return self._client.request("POST", f"/envelopes/{_id(envelope_id)}/countersign/retry/")
|
|
569
|
+
|
|
570
|
+
def skip_countersign(self, envelope_id: str, *, reason: str) -> dict:
|
|
571
|
+
"""Complete it WITHOUT the organization's countersignature (the
|
|
572
|
+
reason is required and stated in the certificate)."""
|
|
573
|
+
return self._client.request(
|
|
574
|
+
"POST",
|
|
575
|
+
f"/envelopes/{_id(envelope_id)}/countersign/skip/",
|
|
576
|
+
json={"reason": reason},
|
|
577
|
+
)
|
|
578
|
+
|
|
357
579
|
def download_certificate(self, envelope_id: str, *, to: str | Path | None = None) -> bytes:
|
|
358
580
|
content = self._client.request(
|
|
359
581
|
"GET", f"/envelopes/{_id(envelope_id)}/certificate/", raw=True
|
|
@@ -402,7 +624,9 @@ class Envelopes(_Resource):
|
|
|
402
624
|
include_biometrics: bool = False,
|
|
403
625
|
include_identity_proofs: bool = False,
|
|
404
626
|
) -> bytes:
|
|
405
|
-
"""The full evidence ZIP — what a tenant would take to court.
|
|
627
|
+
"""The full evidence ZIP — what a tenant would take to court. With the
|
|
628
|
+
platform seal on, it is an ASiC-E container signed by the platform
|
|
629
|
+
(manifest.json → `container` describes it)."""
|
|
406
630
|
content = self._client.request(
|
|
407
631
|
"GET",
|
|
408
632
|
f"/envelopes/{_id(envelope_id)}/evidence/",
|
|
@@ -450,7 +674,9 @@ class Templates(_Resource):
|
|
|
450
674
|
|
|
451
675
|
def update(self, template_id: str, **changes) -> dict:
|
|
452
676
|
"""PATCH settings: name, description, signing_order,
|
|
453
|
-
retention_period_months, variables (schema list), metadata
|
|
677
|
+
retention_period_months, variables (schema list), metadata,
|
|
678
|
+
group_shared (offer it to the organization group), follow_updates
|
|
679
|
+
(a group copy: notices of the source's new versions)."""
|
|
454
680
|
return self._client.request("PATCH", f"/templates/{_id(template_id)}/", json=changes)
|
|
455
681
|
|
|
456
682
|
def catalog(self, *, language: str | None = None) -> dict:
|
|
@@ -578,6 +804,34 @@ class Templates(_Resource):
|
|
|
578
804
|
f"/templates/{_id(template_id)}/documents/{_id(document_id)}/translations/{_seg(language)}/",
|
|
579
805
|
)
|
|
580
806
|
|
|
807
|
+
def autotranslate(
|
|
808
|
+
self,
|
|
809
|
+
template_id: str,
|
|
810
|
+
document_id: str,
|
|
811
|
+
language: str,
|
|
812
|
+
*,
|
|
813
|
+
acknowledge: bool,
|
|
814
|
+
overwrite: bool = False,
|
|
815
|
+
idempotency_key: str | None = None,
|
|
816
|
+
) -> dict:
|
|
817
|
+
"""Queue a MACHINE DRAFT of a language version: translators plus a
|
|
818
|
+
reviewer panel with quorum. Returns the job (202); poll
|
|
819
|
+
`get_translation_job`. `acknowledge=True` is required: the result is
|
|
820
|
+
a draft that a person reviews and approves in the panel. `overwrite`
|
|
821
|
+
replaces a translation that already has its own text."""
|
|
822
|
+
return self._client.request(
|
|
823
|
+
"POST",
|
|
824
|
+
f"/templates/{_id(template_id)}/documents/{_id(document_id)}/translations/{_seg(language)}/autotranslate/",
|
|
825
|
+
json={"acknowledge": acknowledge, "overwrite": overwrite},
|
|
826
|
+
idempotency_key=idempotency_key,
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
def get_translation_job(self, job_id: str) -> dict:
|
|
830
|
+
"""Status of a machine translation: `verdict` (consensus, quorum,
|
|
831
|
+
no_quorum, unreviewed, simulated) and, when done, the reviewers'
|
|
832
|
+
`notes` with an English back-translation."""
|
|
833
|
+
return self._client.request("GET", f"/translation-jobs/{_id(job_id)}/")
|
|
834
|
+
|
|
581
835
|
def download_docx(
|
|
582
836
|
self, template_id: str, document_id: str, *, to: str | Path | None = None
|
|
583
837
|
) -> bytes:
|
|
@@ -600,6 +854,47 @@ class Templates(_Resource):
|
|
|
600
854
|
data={"name": name} if name else None,
|
|
601
855
|
)
|
|
602
856
|
|
|
857
|
+
def set_margin_image(
|
|
858
|
+
self,
|
|
859
|
+
template_id: str,
|
|
860
|
+
document_id: str,
|
|
861
|
+
slot: str,
|
|
862
|
+
*,
|
|
863
|
+
file=None,
|
|
864
|
+
align: str | None = None,
|
|
865
|
+
name: str | None = None,
|
|
866
|
+
) -> dict:
|
|
867
|
+
"""Put, replace or move the image of the ``slot`` margin (``"header"``
|
|
868
|
+
or ``"footer"``) of an authored document of the template draft: a
|
|
869
|
+
PNG, JPEG or WebP of 2 MB at most (``file``: path, bytes or file
|
|
870
|
+
object) and/or ``align`` (``"start"``, ``"center"``, ``"end"``).
|
|
871
|
+
A new image is published after its malware scan (see
|
|
872
|
+
``header_image_upload`` / ``footer_image_upload`` in the returned
|
|
873
|
+
document); approving a new version then puts it in new envelopes."""
|
|
874
|
+
if slot not in ("header", "footer"):
|
|
875
|
+
raise ValueError("slot must be 'header' or 'footer'")
|
|
876
|
+
path = f"/templates/{_id(template_id)}/documents/{_id(document_id)}/margin-images/{slot}/"
|
|
877
|
+
if file is None:
|
|
878
|
+
if align is None:
|
|
879
|
+
raise ValueError("give file, align or both")
|
|
880
|
+
return self._client.request("POST", path, json={"align": align})
|
|
881
|
+
mime = _image_mime(file, name)
|
|
882
|
+
return self._client.request(
|
|
883
|
+
"POST",
|
|
884
|
+
path,
|
|
885
|
+
files={"file": _file_tuple(file, name or f"{slot}.{mime.split('/')[1]}", mime)},
|
|
886
|
+
data={"align": align} if align else None,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
def delete_margin_image(self, template_id: str, document_id: str, slot: str) -> dict:
|
|
890
|
+
"""Remove the image of the ``slot`` margin (``"header"`` or ``"footer"``)."""
|
|
891
|
+
if slot not in ("header", "footer"):
|
|
892
|
+
raise ValueError("slot must be 'header' or 'footer'")
|
|
893
|
+
return self._client.request(
|
|
894
|
+
"DELETE",
|
|
895
|
+
f"/templates/{_id(template_id)}/documents/{_id(document_id)}/margin-images/{slot}/",
|
|
896
|
+
)
|
|
897
|
+
|
|
603
898
|
def preview(
|
|
604
899
|
self,
|
|
605
900
|
template_id: str,
|
|
@@ -609,11 +904,16 @@ class Templates(_Resource):
|
|
|
609
904
|
signers: dict[str, dict] | None = None,
|
|
610
905
|
title: str = "",
|
|
611
906
|
language: str | None = None,
|
|
907
|
+
technical: bool = False,
|
|
612
908
|
to: str | Path | None = None,
|
|
613
909
|
) -> bytes:
|
|
614
910
|
"""PDF preview of the authored documents (sample values where
|
|
615
|
-
none are given).
|
|
911
|
+
none are given). ``technical=True``: the technical view of the
|
|
912
|
+
draft instead (every fillable field explained, with examples and
|
|
913
|
+
how the API passes each one). Nothing is persisted."""
|
|
616
914
|
body: dict = {"variables": variables or {}, "signers": signers or {}, "title": title}
|
|
915
|
+
if technical:
|
|
916
|
+
body["technical"] = True
|
|
617
917
|
if document_id:
|
|
618
918
|
body["document_id"] = document_id
|
|
619
919
|
if language:
|
|
@@ -666,6 +966,186 @@ class Templates(_Resource):
|
|
|
666
966
|
body["auth_methods"] = auth_methods
|
|
667
967
|
return self._client.request("POST", f"/templates/{_id(template_id)}/roles/", json=body)
|
|
668
968
|
|
|
969
|
+
def update_role(
|
|
970
|
+
self,
|
|
971
|
+
template_id: str,
|
|
972
|
+
role_id: str,
|
|
973
|
+
*,
|
|
974
|
+
key: str | None = None,
|
|
975
|
+
label: str | None = None,
|
|
976
|
+
order: int | None = None,
|
|
977
|
+
auth_methods: list[str] | None = None,
|
|
978
|
+
adopt_tokens_of: str | None = None,
|
|
979
|
+
) -> dict:
|
|
980
|
+
"""Edit a role of the template draft. A new ``key`` rewrites the
|
|
981
|
+
role's tokens in every authored document and translation (changed
|
|
982
|
+
translations go back to draft); ``adopt_tokens_of`` gives the role
|
|
983
|
+
the tokens of a key no role has (``unknown_roles`` of the template
|
|
984
|
+
with ``expand=roles``); ``renamed_in`` says where."""
|
|
985
|
+
body: dict = {}
|
|
986
|
+
for name, value in (
|
|
987
|
+
("key", key),
|
|
988
|
+
("label", label),
|
|
989
|
+
("order", order),
|
|
990
|
+
("auth_methods", auth_methods),
|
|
991
|
+
("adopt_tokens_of", adopt_tokens_of),
|
|
992
|
+
):
|
|
993
|
+
if value is not None:
|
|
994
|
+
body[name] = value
|
|
995
|
+
return self._client.request(
|
|
996
|
+
"PATCH", f"/templates/{_id(template_id)}/roles/{_id(role_id)}/", json=body
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
def delete_role(self, template_id: str, role_id: str) -> None:
|
|
1000
|
+
"""Remove a role from the draft (refused while a document uses its tokens)."""
|
|
1001
|
+
self._client.request("DELETE", f"/templates/{_id(template_id)}/roles/{_id(role_id)}/")
|
|
1002
|
+
|
|
1003
|
+
def list_versions(self, template_id: str) -> dict:
|
|
1004
|
+
"""Approved versions kept, newest first, plus ``current_version`` and
|
|
1005
|
+
``has_unapproved_changes`` (the draft differs from the version in use)."""
|
|
1006
|
+
return self._client.request("GET", f"/templates/{_id(template_id)}/versions/")
|
|
1007
|
+
|
|
1008
|
+
def approve_version(
|
|
1009
|
+
self, template_id: str, *, note: str = "", idempotency_key: str | None = None
|
|
1010
|
+
) -> dict:
|
|
1011
|
+
"""Approve the draft as the next version: envelopes created from now
|
|
1012
|
+
on use it. Raises on 409 ``template_unchanged`` when nothing changed."""
|
|
1013
|
+
return self._client.request(
|
|
1014
|
+
"POST",
|
|
1015
|
+
f"/templates/{_id(template_id)}/versions/",
|
|
1016
|
+
json={"note": note} if note else {},
|
|
1017
|
+
idempotency_key=idempotency_key,
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
def compare_versions(
|
|
1021
|
+
self, template_id: str, *, from_: int | str | None = None, to: int | str = "draft"
|
|
1022
|
+
) -> dict:
|
|
1023
|
+
"""What changed from ``from_`` to ``to`` (a version number or
|
|
1024
|
+
``"draft"``). Default: from the version in use to the draft."""
|
|
1025
|
+
params: dict = {"to": str(to)}
|
|
1026
|
+
if from_ is not None:
|
|
1027
|
+
params["from"] = str(from_)
|
|
1028
|
+
return self._client.request(
|
|
1029
|
+
"GET", f"/templates/{_id(template_id)}/versions/compare/", params=params
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
def restore_version(
|
|
1033
|
+
self, template_id: str, number: int, *, idempotency_key: str | None = None
|
|
1034
|
+
) -> dict:
|
|
1035
|
+
"""Copy version ``number`` into the draft (unapproved draft work is
|
|
1036
|
+
lost). Approve the draft afterwards: it becomes a new version."""
|
|
1037
|
+
return self._client.request(
|
|
1038
|
+
"POST",
|
|
1039
|
+
f"/templates/{_id(template_id)}/versions/{int(number)}/restore/",
|
|
1040
|
+
idempotency_key=idempotency_key,
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
def get_version(self, template_id: str, number: int) -> dict:
|
|
1044
|
+
return self._client.request("GET", f"/templates/{_id(template_id)}/versions/{int(number)}/")
|
|
1045
|
+
|
|
1046
|
+
def list_attention_points(self, template_id: str) -> dict:
|
|
1047
|
+
return self._client.request("GET", f"/templates/{_id(template_id)}/attention-points/")
|
|
1048
|
+
|
|
1049
|
+
def add_attention_point(
|
|
1050
|
+
self,
|
|
1051
|
+
template_id: str,
|
|
1052
|
+
*,
|
|
1053
|
+
title: str,
|
|
1054
|
+
body: str = "",
|
|
1055
|
+
document_id: str | None = None,
|
|
1056
|
+
page: int | None = None,
|
|
1057
|
+
requires_ack: bool = False,
|
|
1058
|
+
idempotency_key: str | None = None,
|
|
1059
|
+
) -> dict:
|
|
1060
|
+
"""Default attention point of every envelope created from the template."""
|
|
1061
|
+
return self._client.request(
|
|
1062
|
+
"POST",
|
|
1063
|
+
f"/templates/{_id(template_id)}/attention-points/",
|
|
1064
|
+
json=_attention_body(title, body, document_id, page, requires_ack),
|
|
1065
|
+
idempotency_key=idempotency_key,
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
def update_attention_point(self, template_id: str, point_id: str, **changes) -> dict:
|
|
1069
|
+
"""title, body, document_id, page, requires_ack."""
|
|
1070
|
+
return self._client.request(
|
|
1071
|
+
"PATCH",
|
|
1072
|
+
f"/templates/{_id(template_id)}/attention-points/{_id(point_id)}/",
|
|
1073
|
+
json=changes,
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
def delete_attention_point(self, template_id: str, point_id: str):
|
|
1077
|
+
return self._client.request(
|
|
1078
|
+
"DELETE", f"/templates/{_id(template_id)}/attention-points/{_id(point_id)}/"
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
def upstream(self, template_id: str) -> dict:
|
|
1082
|
+
"""A copy of a group template: its source and whether it has a newer
|
|
1083
|
+
approved version (`update_available`)."""
|
|
1084
|
+
return self._client.request("GET", f"/templates/{_id(template_id)}/upstream/")
|
|
1085
|
+
|
|
1086
|
+
def compare_upstream(self, template_id: str) -> dict:
|
|
1087
|
+
"""What pulling the source's latest approved version would change."""
|
|
1088
|
+
return self._client.request("GET", f"/templates/{_id(template_id)}/upstream/compare/")
|
|
1089
|
+
|
|
1090
|
+
def pull_upstream(self, template_id: str, *, idempotency_key: str | None = None) -> dict:
|
|
1091
|
+
"""The source's latest approved version replaces the copy's draft
|
|
1092
|
+
(translations as drafts); approve the draft to use it."""
|
|
1093
|
+
return self._client.request(
|
|
1094
|
+
"POST",
|
|
1095
|
+
f"/templates/{_id(template_id)}/upstream/pull/",
|
|
1096
|
+
json={},
|
|
1097
|
+
idempotency_key=idempotency_key,
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
def list_attention_translations(self, template_id: str) -> dict:
|
|
1101
|
+
"""Per language: how many attention points are approved, stale, draft
|
|
1102
|
+
or missing. `complete` = usable for envelopes in that language."""
|
|
1103
|
+
return self._client.request(
|
|
1104
|
+
"GET", f"/templates/{_id(template_id)}/attention-points/translations/"
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
def get_attention_translation(self, template_id: str, language: str) -> dict:
|
|
1108
|
+
"""The attention points in one language, with their source text."""
|
|
1109
|
+
return self._client.request(
|
|
1110
|
+
"GET", f"/templates/{_id(template_id)}/attention-points/translations/{_seg(language)}/"
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
def set_attention_translation(self, template_id: str, language: str, points: list) -> dict:
|
|
1114
|
+
"""Write the text of one or more points in a language, as drafts:
|
|
1115
|
+
`points=[{"point_id": ..., "title": ..., "body": ...}]`. Approval is
|
|
1116
|
+
a human act in the panel."""
|
|
1117
|
+
return self._client.request(
|
|
1118
|
+
"PUT",
|
|
1119
|
+
f"/templates/{_id(template_id)}/attention-points/translations/{_seg(language)}/",
|
|
1120
|
+
json={"points": points},
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
def delete_attention_translation(self, template_id: str, language: str):
|
|
1124
|
+
return self._client.request(
|
|
1125
|
+
"DELETE",
|
|
1126
|
+
f"/templates/{_id(template_id)}/attention-points/translations/{_seg(language)}/",
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
def autotranslate_attention_points(
|
|
1130
|
+
self,
|
|
1131
|
+
template_id: str,
|
|
1132
|
+
language: str,
|
|
1133
|
+
*,
|
|
1134
|
+
acknowledge: bool,
|
|
1135
|
+
overwrite: bool = False,
|
|
1136
|
+
idempotency_key: str | None = None,
|
|
1137
|
+
) -> dict:
|
|
1138
|
+
"""Queue a MACHINE DRAFT of the attention points in `language` (202,
|
|
1139
|
+
poll `get_translation_job`). A document's autotranslation drafts
|
|
1140
|
+
them too. `overwrite` also replaces points with their own or an
|
|
1141
|
+
approved text."""
|
|
1142
|
+
return self._client.request(
|
|
1143
|
+
"POST",
|
|
1144
|
+
f"/templates/{_id(template_id)}/attention-points/translations/{_seg(language)}/autotranslate/",
|
|
1145
|
+
json={"acknowledge": acknowledge, "overwrite": overwrite},
|
|
1146
|
+
idempotency_key=idempotency_key,
|
|
1147
|
+
)
|
|
1148
|
+
|
|
669
1149
|
def add_field(
|
|
670
1150
|
self,
|
|
671
1151
|
template_id: str,
|
|
@@ -714,12 +1194,20 @@ class Templates(_Resource):
|
|
|
714
1194
|
language: str | None = None,
|
|
715
1195
|
kyc_images_retention: str | None = None,
|
|
716
1196
|
biometrics: bool = False,
|
|
1197
|
+
project: str | None = None,
|
|
1198
|
+
countersign: str | None = None,
|
|
1199
|
+
attention_points: list[dict] | None = None,
|
|
717
1200
|
idempotency_key: str | None = None,
|
|
718
1201
|
) -> dict:
|
|
719
1202
|
"""The one-call automation flow (§10.4): instantiate and optionally
|
|
720
1203
|
send, getting each signer's sign_url back for embedded flows.
|
|
721
1204
|
`variables` fills the tokens of authored documents; `language` picks
|
|
722
|
-
the source or an approved translation.""
|
|
1205
|
+
the source or an approved translation. `countersign="none"` creates it
|
|
1206
|
+
without the organization's automatic countersignature (default
|
|
1207
|
+
"auto": countersigned when the template has an active authorization;
|
|
1208
|
+
other values than the authorized ones give 409). `attention_points`
|
|
1209
|
+
replaces the template's for this envelope only (Pro and Enterprise;
|
|
1210
|
+
None = the template's, [] = none)."""
|
|
723
1211
|
body: dict = {
|
|
724
1212
|
"signers": signers,
|
|
725
1213
|
"title": title,
|
|
@@ -736,6 +1224,12 @@ class Templates(_Resource):
|
|
|
736
1224
|
body["biometrics"] = True
|
|
737
1225
|
if expires_in_days is not None:
|
|
738
1226
|
body["expires_in_days"] = expires_in_days
|
|
1227
|
+
if project:
|
|
1228
|
+
body["project"] = project
|
|
1229
|
+
if countersign:
|
|
1230
|
+
body["countersign"] = countersign
|
|
1231
|
+
if attention_points is not None:
|
|
1232
|
+
body["attention_points"] = attention_points
|
|
739
1233
|
return self._client.request(
|
|
740
1234
|
"POST",
|
|
741
1235
|
f"/templates/{_id(template_id)}/envelopes/",
|
|
@@ -743,22 +1237,125 @@ class Templates(_Resource):
|
|
|
743
1237
|
idempotency_key=idempotency_key,
|
|
744
1238
|
)
|
|
745
1239
|
|
|
1240
|
+
def request_countersign_authorization(
|
|
1241
|
+
self,
|
|
1242
|
+
template_id: str,
|
|
1243
|
+
*,
|
|
1244
|
+
authorizer_email: str,
|
|
1245
|
+
authorizer_title: str,
|
|
1246
|
+
valid_until: str,
|
|
1247
|
+
max_uses: int | None = None,
|
|
1248
|
+
variables: dict | None = None,
|
|
1249
|
+
project: str | None = None,
|
|
1250
|
+
scope: str | None = None,
|
|
1251
|
+
) -> dict:
|
|
1252
|
+
"""Ask an owner/admin to sign the standing authorization for the
|
|
1253
|
+
organization's automatic countersignature (Pro+). The signing link
|
|
1254
|
+
goes to the authorizer by e-mail only; it becomes active when signed.
|
|
1255
|
+
``scope``: ``"version"`` (default, the approved version in use only) or
|
|
1256
|
+
``"future_versions"`` (also the versions approved later, except one
|
|
1257
|
+
adding variables whose values it does not fix)."""
|
|
1258
|
+
body: dict = {
|
|
1259
|
+
"authorizer_email": authorizer_email,
|
|
1260
|
+
"authorizer_title": authorizer_title,
|
|
1261
|
+
"valid_until": valid_until,
|
|
1262
|
+
"variables": variables or {},
|
|
1263
|
+
}
|
|
1264
|
+
if max_uses is not None:
|
|
1265
|
+
body["max_uses"] = max_uses
|
|
1266
|
+
if project:
|
|
1267
|
+
body["project"] = project
|
|
1268
|
+
if scope:
|
|
1269
|
+
body["scope"] = scope
|
|
1270
|
+
return self._client.request(
|
|
1271
|
+
"POST", f"/templates/{_id(template_id)}/countersign-authorizations/", json=body
|
|
1272
|
+
)
|
|
746
1273
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
self, *, url: str, event_types: list[str] | None = None, description: str = ""
|
|
1274
|
+
def list_countersign_authorizations(
|
|
1275
|
+
self, template_id: str, *, limit: int | None = None, cursor: str | None = None
|
|
750
1276
|
) -> dict:
|
|
751
|
-
""
|
|
1277
|
+
params = {k: v for k, v in {"limit": limit, "cursor": cursor}.items() if v is not None}
|
|
1278
|
+
return self._client.request(
|
|
1279
|
+
"GET", f"/templates/{_id(template_id)}/countersign-authorizations/", params=params
|
|
1280
|
+
)
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
class CountersignAuthorizations(_Resource):
|
|
1284
|
+
"""Standing authorizations for the organization's automatic
|
|
1285
|
+
countersignature (docs: «Contrafirma automática»)."""
|
|
1286
|
+
|
|
1287
|
+
def list(self, *, limit: int | None = None, cursor: str | None = None) -> dict:
|
|
1288
|
+
params = {k: v for k, v in {"limit": limit, "cursor": cursor}.items() if v is not None}
|
|
1289
|
+
return self._client.request("GET", "/countersign-authorizations/", params=params)
|
|
1290
|
+
|
|
1291
|
+
def get(self, authorization_id: str) -> dict:
|
|
1292
|
+
return self._client.request("GET", f"/countersign-authorizations/{_id(authorization_id)}/")
|
|
1293
|
+
|
|
1294
|
+
def revoke(self, authorization_id: str, *, reason: str = "") -> dict:
|
|
1295
|
+
"""Revoke an active one (what was countersigned stays) or cancel a
|
|
1296
|
+
pending one (its signing envelope is voided)."""
|
|
752
1297
|
return self._client.request(
|
|
753
1298
|
"POST",
|
|
754
|
-
"/
|
|
755
|
-
json={"
|
|
1299
|
+
f"/countersign-authorizations/{_id(authorization_id)}/revoke/",
|
|
1300
|
+
json={"reason": reason},
|
|
756
1301
|
)
|
|
757
1302
|
|
|
1303
|
+
|
|
1304
|
+
class Webhooks(_Resource):
|
|
1305
|
+
def create(
|
|
1306
|
+
self,
|
|
1307
|
+
*,
|
|
1308
|
+
url: str,
|
|
1309
|
+
event_types: list[str] | None = None,
|
|
1310
|
+
description: str = "",
|
|
1311
|
+
project: str | None = None,
|
|
1312
|
+
) -> dict:
|
|
1313
|
+
"""The response includes `secret` (whsec_…) ONCE — store it now.
|
|
1314
|
+
Mode = the key's. `project` (organization keys only): receive only
|
|
1315
|
+
that project's events; a project key always registers for its own."""
|
|
1316
|
+
body: dict = {"url": url, "event_types": event_types or [], "description": description}
|
|
1317
|
+
if project:
|
|
1318
|
+
body["project"] = project
|
|
1319
|
+
return self._client.request("POST", "/webhooks/", json=body)
|
|
1320
|
+
|
|
758
1321
|
def list(self, *, limit: int | None = None, cursor: str | None = None) -> dict:
|
|
759
1322
|
params = {k: v for k, v in {"limit": limit, "cursor": cursor}.items() if v}
|
|
760
1323
|
return self._client.request("GET", "/webhooks/", params=params)
|
|
761
1324
|
|
|
1325
|
+
def get(self, endpoint_id: str) -> dict:
|
|
1326
|
+
return self._client.request("GET", f"/webhooks/{_id(endpoint_id)}/")
|
|
1327
|
+
|
|
1328
|
+
def update(
|
|
1329
|
+
self,
|
|
1330
|
+
endpoint_id: str,
|
|
1331
|
+
*,
|
|
1332
|
+
url: str | None = None,
|
|
1333
|
+
event_types: list[str] | None = None,
|
|
1334
|
+
is_active: bool | None = None,
|
|
1335
|
+
description: str | None = None,
|
|
1336
|
+
) -> dict:
|
|
1337
|
+
"""Change only what you pass. Reactivating counts against the plan."""
|
|
1338
|
+
body = {
|
|
1339
|
+
k: v
|
|
1340
|
+
for k, v in {
|
|
1341
|
+
"url": url,
|
|
1342
|
+
"event_types": event_types,
|
|
1343
|
+
"is_active": is_active,
|
|
1344
|
+
"description": description,
|
|
1345
|
+
}.items()
|
|
1346
|
+
if v is not None
|
|
1347
|
+
}
|
|
1348
|
+
return self._client.request("PATCH", f"/webhooks/{_id(endpoint_id)}/", json=body)
|
|
1349
|
+
|
|
1350
|
+
def rotate_secret(self, endpoint_id: str, *, grace_period_hours: int = 24) -> dict:
|
|
1351
|
+
"""New `secret` returned ONCE. For `grace_period_hours` (0-72) deliveries
|
|
1352
|
+
carry both signatures: verify with [new, old] until you switch."""
|
|
1353
|
+
return self._client.request(
|
|
1354
|
+
"POST",
|
|
1355
|
+
f"/webhooks/{_id(endpoint_id)}/rotate-secret/",
|
|
1356
|
+
json={"grace_period_hours": grace_period_hours},
|
|
1357
|
+
)
|
|
1358
|
+
|
|
762
1359
|
def delete(self, endpoint_id: str) -> None:
|
|
763
1360
|
self._client.request("DELETE", f"/webhooks/{_id(endpoint_id)}/")
|
|
764
1361
|
|
|
@@ -766,6 +1363,69 @@ class Webhooks(_Resource):
|
|
|
766
1363
|
return self._client.request("POST", f"/webhooks/deliveries/{_id(delivery_id)}/redeliver/")
|
|
767
1364
|
|
|
768
1365
|
|
|
1366
|
+
class Projects(_Resource):
|
|
1367
|
+
"""Projects inside the organization (paid plans). An organization key
|
|
1368
|
+
manages them all; a project key only reads its own."""
|
|
1369
|
+
|
|
1370
|
+
def list(
|
|
1371
|
+
self,
|
|
1372
|
+
*,
|
|
1373
|
+
is_active: bool | None = None,
|
|
1374
|
+
limit: int | None = None,
|
|
1375
|
+
cursor: str | None = None,
|
|
1376
|
+
) -> dict:
|
|
1377
|
+
params = {
|
|
1378
|
+
k: v
|
|
1379
|
+
for k, v in {
|
|
1380
|
+
"is_active": None if is_active is None else str(is_active).lower(),
|
|
1381
|
+
"limit": limit,
|
|
1382
|
+
"cursor": cursor,
|
|
1383
|
+
}.items()
|
|
1384
|
+
if v is not None
|
|
1385
|
+
}
|
|
1386
|
+
return self._client.request("GET", "/projects/", params=params)
|
|
1387
|
+
|
|
1388
|
+
def create(
|
|
1389
|
+
self,
|
|
1390
|
+
*,
|
|
1391
|
+
name: str,
|
|
1392
|
+
description: str = "",
|
|
1393
|
+
embed_allowed_origins: list[str] | None = None,
|
|
1394
|
+
idempotency_key: str | None = None,
|
|
1395
|
+
) -> dict:
|
|
1396
|
+
body: dict = {"name": name, "description": description}
|
|
1397
|
+
if embed_allowed_origins is not None:
|
|
1398
|
+
body["embed_allowed_origins"] = list(embed_allowed_origins)
|
|
1399
|
+
return self._client.request(
|
|
1400
|
+
"POST", "/projects/", json=body, idempotency_key=idempotency_key
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
def get(self, project_id: str) -> dict:
|
|
1404
|
+
return self._client.request("GET", f"/projects/{_id(project_id)}/")
|
|
1405
|
+
|
|
1406
|
+
def update(
|
|
1407
|
+
self,
|
|
1408
|
+
project_id: str,
|
|
1409
|
+
*,
|
|
1410
|
+
name: str | None = None,
|
|
1411
|
+
description: str | None = None,
|
|
1412
|
+
is_active: bool | None = None,
|
|
1413
|
+
embed_allowed_origins: list[str] | None = None,
|
|
1414
|
+
) -> dict:
|
|
1415
|
+
"""`is_active=False` archives: its keys and webhooks stop working."""
|
|
1416
|
+
body = {
|
|
1417
|
+
k: v
|
|
1418
|
+
for k, v in {
|
|
1419
|
+
"name": name,
|
|
1420
|
+
"description": description,
|
|
1421
|
+
"is_active": is_active,
|
|
1422
|
+
"embed_allowed_origins": embed_allowed_origins,
|
|
1423
|
+
}.items()
|
|
1424
|
+
if v is not None
|
|
1425
|
+
}
|
|
1426
|
+
return self._client.request("PATCH", f"/projects/{_id(project_id)}/", json=body)
|
|
1427
|
+
|
|
1428
|
+
|
|
769
1429
|
class Events(_Resource):
|
|
770
1430
|
"""The consultable event log — reconciliation source when a webhook is
|
|
771
1431
|
lost (poll `list` and compare against what you received)."""
|
|
@@ -870,20 +1530,54 @@ class Organization(_Resource):
|
|
|
870
1530
|
"""Org info including plan limits and features."""
|
|
871
1531
|
return self._client.request("GET", "/organization/")
|
|
872
1532
|
|
|
1533
|
+
def group(self) -> dict:
|
|
1534
|
+
"""The organization group (Enterprise): the group, its members and the
|
|
1535
|
+
invitations received. Read only: the acts happen in the panel."""
|
|
1536
|
+
return self._client.request("GET", "/organization/group/")
|
|
1537
|
+
|
|
1538
|
+
def group_templates(self) -> dict:
|
|
1539
|
+
"""Templates the other organizations of the group offer (approved
|
|
1540
|
+
version, names only)."""
|
|
1541
|
+
return self._client.request("GET", "/organization/group/templates/")
|
|
1542
|
+
|
|
1543
|
+
def copy_group_template(
|
|
1544
|
+
self, template_id: str, *, name: str = "", idempotency_key: str | None = None
|
|
1545
|
+
) -> dict:
|
|
1546
|
+
"""Copy an offered template into this organization as a new DRAFT
|
|
1547
|
+
(translations too): approve it and request its own countersign."""
|
|
1548
|
+
return self._client.request(
|
|
1549
|
+
"POST",
|
|
1550
|
+
f"/organization/group/templates/{_id(template_id)}/copy/",
|
|
1551
|
+
json={"name": name} if name else {},
|
|
1552
|
+
idempotency_key=idempotency_key,
|
|
1553
|
+
)
|
|
1554
|
+
|
|
873
1555
|
def update(
|
|
874
1556
|
self,
|
|
875
1557
|
*,
|
|
876
1558
|
retention_policies: dict | None = None,
|
|
877
1559
|
embed_allowed_origins: list[str] | None = None,
|
|
1560
|
+
seal_profile: str | None = None,
|
|
1561
|
+
acknowledge: bool = False,
|
|
878
1562
|
) -> dict:
|
|
879
1563
|
"""Organization settings an API key may change: retention policy per
|
|
880
|
-
document type (months within the plan's range)
|
|
881
|
-
allowed to embed the signer flow (the FULL list; [] disables it)
|
|
1564
|
+
document type (months within the plan's range), the web origins
|
|
1565
|
+
allowed to embed the signer flow (the FULL list; [] disables it) and
|
|
1566
|
+
the cryptographic restrictions of the platform seal.
|
|
1567
|
+
|
|
1568
|
+
`seal_profile`: "hybrid" (ECDSA P-384 + ML-DSA-87, quantum resistant;
|
|
1569
|
+
readers without ML-DSA show that signature as unknown) or "classic"
|
|
1570
|
+
(ECDSA only, no warnings, not quantum resistant). Changing it needs
|
|
1571
|
+
`acknowledge=True`: you confirm the trade-off described in
|
|
1572
|
+
`get()["cryptography"]["options"]`. It applies to envelopes sent
|
|
1573
|
+
afterwards."""
|
|
882
1574
|
body: dict = {}
|
|
883
1575
|
if retention_policies is not None:
|
|
884
1576
|
body["retention_policies"] = retention_policies
|
|
885
1577
|
if embed_allowed_origins is not None:
|
|
886
1578
|
body["embed_allowed_origins"] = list(embed_allowed_origins)
|
|
1579
|
+
if seal_profile is not None:
|
|
1580
|
+
body["cryptography"] = {"seal_profile": seal_profile, "acknowledge": acknowledge}
|
|
887
1581
|
return self._client.request("PATCH", "/organization/", json=body)
|
|
888
1582
|
|
|
889
1583
|
|
|
@@ -5,12 +5,16 @@ easycontract signs every delivery with
|
|
|
5
5
|
X-EasyContract-Signature: t=<unix>,v1=<hex>
|
|
6
6
|
|
|
7
7
|
where v1 = HMAC-SHA256(secret, f"{t}.{body}"). Verify with your endpoint's
|
|
8
|
-
whsec_… secret (shown once at creation):
|
|
8
|
+
whsec_… secret (shown once at creation or rotation):
|
|
9
9
|
|
|
10
10
|
event = easycontract.webhooks.verify_signature(
|
|
11
11
|
payload=request.body, header=request.headers["X-EasyContract-Signature"],
|
|
12
12
|
secret=WEBHOOK_SECRET,
|
|
13
13
|
)
|
|
14
|
+
|
|
15
|
+
After a secret rotation, during the grace period, the header carries one v1
|
|
16
|
+
per valid secret (`t=…,v1=<new>,v1=<old>`): the delivery is valid if ANY v1
|
|
17
|
+
matches. `secret` may also be a list (e.g. [new, old]) while you switch.
|
|
14
18
|
"""
|
|
15
19
|
|
|
16
20
|
from __future__ import annotations
|
|
@@ -29,7 +33,7 @@ def verify_signature(
|
|
|
29
33
|
*,
|
|
30
34
|
payload: bytes | str,
|
|
31
35
|
header: str,
|
|
32
|
-
secret: bytes | str,
|
|
36
|
+
secret: bytes | str | list | tuple,
|
|
33
37
|
tolerance: int | None = DEFAULT_TOLERANCE_SECONDS,
|
|
34
38
|
) -> dict:
|
|
35
39
|
"""Verify a delivery and return the parsed event. Raises
|
|
@@ -51,20 +55,34 @@ def verify_signature(
|
|
|
51
55
|
body = payload.encode("utf-8")
|
|
52
56
|
else:
|
|
53
57
|
raise SignatureVerificationError("payload must be bytes or str")
|
|
54
|
-
|
|
58
|
+
secrets = list(secret) if isinstance(secret, (list, tuple)) else [secret]
|
|
59
|
+
if not secrets:
|
|
60
|
+
raise SignatureVerificationError("no secret given")
|
|
61
|
+
secret_keys = [
|
|
62
|
+
s if isinstance(s, (bytes, bytearray)) else str(s).encode("utf-8") for s in secrets
|
|
63
|
+
]
|
|
55
64
|
|
|
56
65
|
try:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
pairs = [part.split("=", 1) for part in header.split(",")]
|
|
67
|
+
timestamps = [value for key, value in pairs if key.strip() == "t"]
|
|
68
|
+
signatures = [value.strip() for key, value in pairs if key.strip() == "v1"]
|
|
69
|
+
if len(timestamps) != 1 or not signatures:
|
|
70
|
+
raise KeyError("t/v1")
|
|
71
|
+
timestamp = int(timestamps[0])
|
|
60
72
|
except (ValueError, KeyError) as exc:
|
|
61
73
|
raise SignatureVerificationError("malformed signature header") from exc
|
|
62
74
|
|
|
63
75
|
if tolerance is not None and abs(time.time() - timestamp) > tolerance:
|
|
64
76
|
raise SignatureVerificationError("timestamp outside tolerance (possible replay)")
|
|
65
77
|
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
signed = b"%d." % timestamp + body
|
|
79
|
+
matched = False
|
|
80
|
+
for key in secret_keys:
|
|
81
|
+
expected = hmac.new(key, signed, hashlib.sha256).hexdigest().encode()
|
|
82
|
+
for signature in signatures:
|
|
83
|
+
# no early exit: constant work whatever matches
|
|
84
|
+
matched |= hmac.compare_digest(expected, signature.encode())
|
|
85
|
+
if not matched:
|
|
68
86
|
raise SignatureVerificationError("signature mismatch")
|
|
69
87
|
try:
|
|
70
88
|
return json.loads(body.decode("utf-8"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|