easycontract 0.1.4__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.
@@ -2,6 +2,84 @@
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
+
5
83
  ## 0.1.4 (2026-10-20)
6
84
 
7
85
  **Features**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: easycontract
3
- Version: 0.1.4
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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "easycontract"
3
- version = "0.1.4"
3
+ version = "0.1.7"
4
4
  description = "SDK oficial de Python para el API de easycontract (firma electrónica eIDAS)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -6,7 +6,7 @@ client = EasyContract(api_key="ec_test_...")
6
6
  envelope = client.envelopes.create(title="Contrato", retention_period_months=72)
7
7
  """
8
8
 
9
- __version__ = "0.1.4"
9
+ __version__ = "0.1.7"
10
10
 
11
11
  from . import webhooks
12
12
  from .client import EasyContract
@@ -23,6 +23,7 @@ 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,
@@ -109,6 +110,7 @@ class EasyContract:
109
110
  self.identity = Identity(self)
110
111
  self.retention = Retention(self)
111
112
  self.templates = Templates(self)
113
+ self.countersign_authorizations = CountersignAuthorizations(self)
112
114
  self.webhooks = Webhooks(self)
113
115
  self.projects = Projects(self)
114
116
  self.events = Events(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",
@@ -141,9 +181,85 @@ OPERATIONS: dict[tuple[str, str], str] = {
141
181
  ): "client.templates.autotranslate",
142
182
  ("GET", "/translation-jobs/{job_id}/"): "client.templates.get_translation_job",
143
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",
144
192
  ("POST", "/templates/{template_id}/duplicate/"): "client.templates.duplicate",
145
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",
146
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",
147
263
  ("POST", "/templates/{template_id}/envelopes/"): "client.templates.create_envelope",
148
264
  ("GET", "/events/"): "client.events.list",
149
265
  ("GET", "/webhooks/"): "client.webhooks.list",
@@ -173,6 +289,36 @@ def _file_tuple(file, name: str | None, content_type: str = "application/pdf"):
173
289
  return (name or getattr(file, "name", "document.pdf"), file, content_type)
174
290
 
175
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
+
176
322
  class _Resource:
177
323
  def __init__(self, client):
178
324
  self._client = client
@@ -372,11 +518,64 @@ class Envelopes(_Resource):
372
518
  def release_legal_hold(self, envelope_id: str) -> dict:
373
519
  return self._client.request("DELETE", f"/envelopes/{_id(envelope_id)}/legal-hold/")
374
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
+
375
559
  def void(self, envelope_id: str, *, reason: str = "") -> dict:
376
560
  return self._client.request(
377
561
  "POST", f"/envelopes/{_id(envelope_id)}/void/", json={"reason": reason}
378
562
  )
379
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
+
380
579
  def download_certificate(self, envelope_id: str, *, to: str | Path | None = None) -> bytes:
381
580
  content = self._client.request(
382
581
  "GET", f"/envelopes/{_id(envelope_id)}/certificate/", raw=True
@@ -475,7 +674,9 @@ class Templates(_Resource):
475
674
 
476
675
  def update(self, template_id: str, **changes) -> dict:
477
676
  """PATCH settings: name, description, signing_order,
478
- 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)."""
479
680
  return self._client.request("PATCH", f"/templates/{_id(template_id)}/", json=changes)
480
681
 
481
682
  def catalog(self, *, language: str | None = None) -> dict:
@@ -653,6 +854,47 @@ class Templates(_Resource):
653
854
  data={"name": name} if name else None,
654
855
  )
655
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
+
656
898
  def preview(
657
899
  self,
658
900
  template_id: str,
@@ -662,11 +904,16 @@ class Templates(_Resource):
662
904
  signers: dict[str, dict] | None = None,
663
905
  title: str = "",
664
906
  language: str | None = None,
907
+ technical: bool = False,
665
908
  to: str | Path | None = None,
666
909
  ) -> bytes:
667
910
  """PDF preview of the authored documents (sample values where
668
- none are given). Nothing is persisted."""
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."""
669
914
  body: dict = {"variables": variables or {}, "signers": signers or {}, "title": title}
915
+ if technical:
916
+ body["technical"] = True
670
917
  if document_id:
671
918
  body["document_id"] = document_id
672
919
  if language:
@@ -719,6 +966,186 @@ class Templates(_Resource):
719
966
  body["auth_methods"] = auth_methods
720
967
  return self._client.request("POST", f"/templates/{_id(template_id)}/roles/", json=body)
721
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
+
722
1149
  def add_field(
723
1150
  self,
724
1151
  template_id: str,
@@ -768,12 +1195,19 @@ class Templates(_Resource):
768
1195
  kyc_images_retention: str | None = None,
769
1196
  biometrics: bool = False,
770
1197
  project: str | None = None,
1198
+ countersign: str | None = None,
1199
+ attention_points: list[dict] | None = None,
771
1200
  idempotency_key: str | None = None,
772
1201
  ) -> dict:
773
1202
  """The one-call automation flow (§10.4): instantiate and optionally
774
1203
  send, getting each signer's sign_url back for embedded flows.
775
1204
  `variables` fills the tokens of authored documents; `language` picks
776
- 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)."""
777
1211
  body: dict = {
778
1212
  "signers": signers,
779
1213
  "title": title,
@@ -792,6 +1226,10 @@ class Templates(_Resource):
792
1226
  body["expires_in_days"] = expires_in_days
793
1227
  if project:
794
1228
  body["project"] = project
1229
+ if countersign:
1230
+ body["countersign"] = countersign
1231
+ if attention_points is not None:
1232
+ body["attention_points"] = attention_points
795
1233
  return self._client.request(
796
1234
  "POST",
797
1235
  f"/templates/{_id(template_id)}/envelopes/",
@@ -799,6 +1237,69 @@ class Templates(_Resource):
799
1237
  idempotency_key=idempotency_key,
800
1238
  )
801
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
+ )
1273
+
1274
+ def list_countersign_authorizations(
1275
+ self, template_id: str, *, limit: int | None = None, cursor: str | None = None
1276
+ ) -> dict:
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)."""
1297
+ return self._client.request(
1298
+ "POST",
1299
+ f"/countersign-authorizations/{_id(authorization_id)}/revoke/",
1300
+ json={"reason": reason},
1301
+ )
1302
+
802
1303
 
803
1304
  class Webhooks(_Resource):
804
1305
  def create(
@@ -1029,6 +1530,28 @@ class Organization(_Resource):
1029
1530
  """Org info including plan limits and features."""
1030
1531
  return self._client.request("GET", "/organization/")
1031
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
+
1032
1555
  def update(
1033
1556
  self,
1034
1557
  *,
File without changes
File without changes
File without changes